Visual

Scatter chart

Sand settles into a small cloud around each point, then a solid marker glyph — circle, triangle, square or asterisk — resolves on top, the scatter analogue of the line chart's solid line and the bar chart's solid border. Both axes are continuous (numeric or time), and a swappable approximation strategy can fit a trend/connector line over each series' cloud.

Two structural differences from bar/line. (1) data is a MeshDataSet — an explicit array of series, not a flat Point[] grouped by z — because z here is a per-point value payload (MeshValue), not a series key. (2) X is positioned with a real continuous scale (LinearScale/TimeScale), not slotted into evenly-spaced categories like the line chart's X axis.

Constructor

new ScatterChart(host: HTMLElement, config: ScatterChartConfig)

Mounts a canvas into host and boots the best backend (WebGPU → Canvas2D). Only data is required.

Config — top level

Mirrors the line chart's config field-for-field except for data (a MeshDataSet, not a DataSet), pointRadius (new, replaces lineThickness), linemarker, and the new approximation block.

PropertyTypeDefaultDescription
data requiredMeshDataSetAn explicit array of series. See Mesh data model.
pointRadiusnumber0.02New for scatter charts. Sand-cloud radius grains scatter within per point, in layout units.
grainDensitynumber0.6Grains per unit area of every point's cloud.
maxGrainsnumber100000Hard grain ceiling. Grains scale with total cloud area, not point count, so cost stays bounded as the data grows.
colorsstring[]DEFAULT_PALETTESeries colors (CSS), cycled per series.
backgroundstringtransparentCanvas clear color.
grainobjectGrain appearance. See below.
animationobjectTiming. See below.
interactionobjectSame as bar chart, but hover targets a single point rather than a whole series; dim (legend click-to-isolate) targets a whole series, same as everywhere else.
axes{ x?, y? }offSame shape as bar chart — both continuous here (see the note above).
legendLegendConfigoffSame as bar chart, including click-to-isolate (interactive).
titleTitleConfigoffChart title; shares the legend's placement vocabulary.
currentValueobjectoffSame as bar chart.
markerMarkerStyleConfigoffSolid marker glyph per point. See below.
approximationScatterApproximationConfigoffTrend/connector fit over each series' cloud. See below.
fpsFpsConfigoffSame as bar chart.
panZoomPanZoomConfigoffDrag to pan, wheel/UI to zoom. Shared pan & zoom.
backend'auto' | 'webgpu' | 'webgl2' | 'canvas2d''auto'Force a rendering backend.

grain

Same shape as the bar chart: sizePx (3), shape ('disc'), settleJitter (0.004). jitter (default 0.6) means something slightly different here — it scales how far grains spread across the point's cloud radius (0 collapses every grain onto the point center, 1 fills the whole pointRadius), the same way it scales the line chart's ribbon width. See bar chart → grain for the other properties.

animation

Units: duration, stagger and morphDuration are in milliseconds.

Same as the bar chart, with the line chart's two three-way twists renamed for markers:

PropertyTypeDefaultDescription
durationnumber (ms)900Per-grain travel time.
staggernumber (ms)500Spread of pour start times.
ease'linear' | 'easeOutCubic' | 'easeOutQuint''easeOutCubic'Grain travel easing.
morphDurationnumber (ms)duration + staggerTransition window for live changes (marker tween + grain fade).
reflow'translate' | 'reshuffle' | 'withMarker''translate' How grains of an unchanged point move. withMarker (vs. the line's withLine) locks them rigidly to the marker — no delay, no fade flash.
morphGrainsbooleantrue Animate the sand during an update/add/remove morph. false snaps every grain straight to its new position so only the solid marker tweens on a data change. Ignored for the initial pour-in.
enter'pour' | 'rise' | 'continue''pour' How an added point's grains appear. continue skips the emergence entirely — the marker appears already settled at its position. Best for continuous streaming.
exit'fall' | 'vanish''fall'How a removed point's grains leave.

marker — solid glyph MarkerStyleConfig

The scatter analogue of the line chart's solid line / bar chart's solid border. Off by default; a config without marker renders as pure sand. Shape/size cycle per series exactly like colors; the marker always uses the point's series color.

PropertyTypeDefaultDescription
shape'circle' | 'triangle' | 'square' | 'asterisk''circle'Default shape for every series.
shapesMarkerShape[]Per-series shape cycling, like colors. Overrides shape per series.
sizenumber6Marker size in CSS px.
sizesnumber[]Per-series size cycling.
opacitynumber (0..1)1Marker fill opacity (stroke opacity for asterisk).
revealRevealConfigTiming of the particle→marker crossfade — same shape as the bar chart's bars.reveal (start, duration, ease, grainsTo).
Glyphs are cached as a Path2D per (shape, size) pair and translated per point — the shape is never rebuilt per point per frame, so marker count doesn't change per-frame drawing cost beyond the translate + fill/stroke itself.

approximation — trend/connector fit ScatterApproximationConfig

Off by default. When set, each series' point cloud is fit independently and drawn as a stroked path once the markers resolve, colored by that series unless color overrides it.

PropertyTypeDefaultDescription
kind'none' | 'straight' | 'spline' | 'leastSquares' | Approximation'none'Built-in fit, or any object implementing Approximation for a fully custom strategy. See below.
widthnumber1Stroke width in CSS px.
opacitynumber (0..1)1Stroke opacity.
colorstringseries colorStroke color override (CSS).

Built-in kinds

kindBehavior
'none'No-op; nothing is drawn.
'straight'Connect consecutively, in the order the series' points are given.
'spline'Smooth with a Catmull-Rom curve through the points in the order given (the same curve math the line chart's spline line style uses, extracted to core/geometry/curve.ts).
'leastSquares'Ordinary linear least-squares (y = mx + b) over the whole series (order-independent), drawn as the two endpoints spanning its x-domain. A near-vertical cloud (all points at ~the same x) falls back to the mean vertical instead of dividing by ~0.
Ordering is the caller's responsibility. straight and spline connect the points of each series in the order they appear in data.series[i].points — they do not sort by x. A function-shaped cloud (one y per x) typically wants its points pre-sorted by x; a non-monotonic shape like a spiral wants its own natural order (e.g. the order the points were traced in). Sort your series before handing it to the chart if you need x-order.

Passing an object in place of a kind string plugs in a fully custom fitter without touching the chart:

interface FitPoint { x: number; y: number; }

interface Approximation {
  readonly kind: string;
  fit(points: FitPoint[]): FitPoint[];   // unordered cloud -> ordered path to connect with straight segments
}

Shared blocks

axes, legend, currentValue, fps, interaction.hover and interaction.dim take the same shape the bar chart uses. Full property tables: axes · legend · currentValue · fps · interaction.hover / interaction.dim.

Unlike the bar/line X axis (evenly-spaced category slots), scatter's axes.x and axes.y are continuous scales built straight from the data's numeric or time extent, padded ~8% on each side so extreme points don't sit flush against the plot edge.
Isolating a series with legend.interactive / focusSeries() dims that series' markers and its approximation trend/connector line together.

Methods

MethodDescription
whenReady(): Promise<void>Resolves once the backend is initialized and the first frame is scheduled.
get backend'webgpu' | 'webgl2' | 'canvas2d' | null.
on('hover', fn)Subscribe to hover; returns an unsubscribe function.
on('seriesFocus', fn)Subscribe to legend isolation changes; returns an unsubscribe function. See Events.
focusSeries(index)Isolate one series by index — it stays full opacity, every other series (markers + approximation line) dims to interaction.dim.opacity. Pass null to clear. Equivalent to clicking that series' legend.interactive entry; fires seriesFocus.
getFocusedSeries()number | null — the currently isolated series index, or null.
update(data)Replace the whole MeshDataSet; grains morph and the marker/approximation layer tweens (no re-pour). There is no point-patch overload — scatter has no z series key to match a patch against.
add(points, seriesIndex = 0)Append one or more MeshPoints to a series (created if seriesIndex is past the end); new markers grow in per animation.enter.
remove(indices, seriesIndex = 0)Remove points from a series by index within it (negative = from end); the cloud reflows.
repour()Re-run the pour-in animation with the current data (no morph).
getData(): MeshDataSetDeep-cloned snapshot of the current dataset.
dispose()Cancel the loop, remove listeners, free the backend and DOM.

It also implements the shared PanZoomable interface: getView(), setView(v), panBy(), panTo(), zoomBy(), zoomTo(), resetView(), isPanZoomEnabled().

Point identity. There is no z key to match points across a rebuild, so morph identity falls back to a point's (seriesIndex, index within that series) — same limitation the line chart has for points sharing an x-slot. Inserting or removing a point inside a series shifts every later point's identity for that morph.

Events

The hover event carries { point: ScatterMeta | null }, and — unlike the line chart, which highlights the whole hovered series — targets a single point.

// ScatterMeta — metadata for one drawn point
{
  pointId: number;
  seriesIndex: number;
  indexInSeries: number;   // position within its series — the morph identity key
  seriesKey: Scalar | undefined;
  xValue: Scalar;
  yValue: Scalar;
  meshValue: MeshValue | undefined;  // the point's z payload, if any
  cx: number; cy: number;      // layout-space position [0,1]
  color: RGBA;
}

seriesFocus carries { index: number | null } — same shape as the bar chart's. See bar chart → Events.

Examples

Solid markers over a point cloud

new ScatterChart(el, {
  data,
  pointRadius: 0.02,
  marker: { shape: 'circle', size: 7, reveal: { duration: 500, grainsTo: 0.1 } },
  axes: { x: { show: true }, y: { show: true, gridLines: true } },
});

Trend line via least-squares

new ScatterChart(el, {
  data,
  marker: { shape: 'circle', size: 5, opacity: 0.85 },
  approximation: { kind: 'leastSquares', width: 2 },
});

Custom approximation strategy

const movingAverage: Approximation = {
  kind: 'movingAverage',
  fit(points) {
    const sorted = [...points].sort((a, b) => a.x - b.x);
    return sorted.map((p, i) => {
      const window = sorted.slice(Math.max(0, i - 4), i + 5);
      return { x: p.x, y: window.reduce((s, w) => s + w.y, 0) / window.length };
    });
  },
};

new ScatterChart(el, { data, approximation: { kind: movingAverage } });

Per-series marker shapes

new ScatterChart(el, {
  data,   // MeshDataSet: series: [{ key: 'A', points: [...] }, { key: 'B', points: [...] }]
  marker: { shapes: ['circle', 'triangle', 'square'], sizes: [6, 8, 6] },
  legend: { show: true },
});

Streaming: appended points settle without a pour

const chart = new ScatterChart(el, {
  data,
  marker: { shape: 'circle' },
  animation: { enter: 'continue' },   // no pour — marker appears settled
});

setInterval(() => {
  chart.add({ x: Date.now(), y: nextSample() }, 0);   // series 0
}, 1000);

Hover

chart.on('hover', ({ point }) => {
  if (point) label.textContent = `(${point.xValue}, ${point.yValue}) · ${point.seriesKey}`;
});

Series dimming — click a legend entry to isolate it

new ScatterChart(el, {
  data,
  legend: { show: true, interactive: true },
  approximation: { kind: 'leastSquares' },   // trend lines dim along with their series' markers
  interaction: { dim: { opacity: 0.15 } },
});

© 2026 · License · Data model →