Chart Scatter Matrix
A SPLOM whose panels share one domain per variable, brushes a record across every panel at once, puts distributions on the diagonal and Pearson r on the upper triangle, and states its panel cap instead of melting on 20 variables.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-scatter-matrix.jsonPrompt
Build a React + TypeScript + Tailwind "ChartScatterMatrix" (SPLOM) in plain SVG
with zod. No chart library: a scatter matrix is a grid of tiny linked panels
with one shared scale per variable, which no recharts primitive expresses.
Contract
- One zod schema is the source of truth, and the props are z.infer of it plus
the render knobs — never a parallel hand-written interface:
{ status: "loading" | "empty" | "error" | "ready";
title: string; description?: string;
variables: { key: string; label: string; unit?: string; decimals?: number }[];
points: { id: string; label: string; group?: string;
values: Record<string, number | null> }[];
groups?: { id: string; label: string }[] }
- Wide format, one object per record — not the long { row, column, value }
shape a heatmap wants. `values[key]` is looked up by the variable's key, so
adding a variable never renumbers anything.
- null (and an absent key) means NOT MEASURED. Such a record is left out of
every panel that needs that variable and out of the correlation for those
pairs. It is never coerced to 0: zero is a real position that would move the
domain, drag r and invent a cluster in the corner.
- Extra props: maxVariables (default 5, clamped 2-8), maxPoints (default 250,
clamped 10-2000), diagonal ("histogram" | "label"), upperPanels
("correlation" | "mirror" | "blank"), bins (number | "auto"), locale
(default "en-US"), onRetry, className, plus the native div props; forwardRef
to the card root and spread the rest.
- Ship the maths beside the schema as pure functions the panels only consume:
readValue(), domainOf(), pearson(), binValues(), suggestBinCount().
Behavior
- CAP THE PANEL COUNT, OUT LOUD. Panels grow with the square of the variable
count: 5 variables are 25 panels, 8 are 64, 20 would be 400 panels of ~26px
each — unreadable long before it is slow. Draw the first maxVariables
variables and the first maxPoints records, then say so in the footer and in
the accessible summary, naming the variables that were left out. Silently
drawing 400 panels and silently drawing 5 are equally dishonest.
- ONE DOMAIN PER VARIABLE, SHARED BY ITS WHOLE ROW AND COLUMN. That shared
scale is what makes the grid readable: a point's height in row 2 means the
same thing in every column. Fit each domain over EVERY drawn record — not
over the currently visible ones — so hiding a series in the legend never
moves an axis under the reader. Correlations and histograms do follow the
visible selection, because filtering is exactly what they are for, and each
correlation panel prints its own n so the change is visible.
- DEGENERATE DOMAINS. All values identical (or none at all) gives a zero span
and (v - lo) / 0 = NaN, which blanks a whole row and column. Detect it, widen
to v +/- max(|v| * 0.5, 0.5) so the record sits dead centre, and label the
diagonal panel "no spread" so the invented span is never mistaken for a
measured one. Pad every real domain by ~6% as well, so the extreme records
do not sit half under the frame.
- PEARSON r ON THE UPPER TRIANGLE (default), which is a better use of the
mirror half than the same clouds drawn twice. Compute it in two passes
(mean first, then the deviations): the textbook one-pass Sxy - n*xbar*ybar
subtracts two nearly equal large numbers and loses every digit on data like
1e9 +/- 3. Return null, not 0, when fewer than three complete pairs exist
(any two points are perfectly collinear, so r would be +/-1 by construction)
or when a variable is flat — "not answerable" and "no relationship" are
different findings. Say in the footer and in the table caption that r
measures straight-line association only: all of Anscombe's very different
clouds score 0.82.
- DIAGONAL. A histogram binned over the SAME domain as the panels above and
below it, so the bars line up column-for-column with the clouds; bin count is
sqrt(n) clamped 4-16 by default. Clamp the bin index: (v - lo) / width is
exactly binCount at the top of the domain, which floors one bin past the end
and would drop the maximum. The variable's name and its measured count sit
in a foreignObject with CSS truncate — let the browser measure the real
glyphs; a JS advance-width estimate is off by about a third on all-caps runs.
- BRUSHING AND LINKING is the whole point. Structure the DOM by RECORD, not by
panel: one <g> per record holding that record's mark in every panel. Then
highlighting a record is one element, and the same record lights up in all 25
panels at once. On brush: draw a ring around its mark in every panel, a
vertical rule at its value on each diagonal, and a readout line naming the
record, its series and every value.
- POINTER HIT TESTING is maths, not per-mark listeners: 250 records over 10
panels would be 2,500 event closures. Listen once on the <svg>, convert
client coordinates to user units (subtract the bounding rect, divide by the
uniform preserveAspectRatio scale, and remove the letterbox offset), find the
panel that contains the point, then the nearest mark within ~10 user units.
A 2.6px dot is not a mouse target; the search radius is.
- KEYBOARD. Per-mark tab stops are absurd here (2,500 of them), so the grid
gets ONE roving tab stop that walks RECORDS: each record's <g> carries
tabIndex 0 when it is the cursor and -1 otherwise, plus role="img" and an
aria-label reading "Record 12 of 54: name, series. Variable value; ...".
ArrowRight/Down and ArrowLeft/Up step one record, PageDown/PageUp ten,
Home/End jump to the ends, Escape drops the pointer highlight. Move by
calling .focus() on the next group and let the focus event update state, so
the move never waits for a render. preventDefault on the keys you handle.
- LEGEND = FILTER. One aria-pressed button per series toggling its visibility,
with the label struck through and the swatch dimmed when off, so the state is
not carried by colour alone. Hovering or focusing a legend entry dims the
other series. Never put native `disabled` on any of these — a control the
user is focused on that goes disabled blurs to <body>.
- FOUR STATES are first-class branches of one bg-card panel: a 4x4 pulsing
panel skeleton (aria-hidden, with an sr-only role="status" line), an empty
state, an error state whose "Try again" button only exists when onRetry was
passed, and ready. "ready" with no variables or no records falls back to the
empty branch rather than drawing an empty grid that pretends to have data.
- SERIES OVERFLOW folds rather than drops: past six series the tail becomes one
"Other - N groups" entry. A record that vanished because its category came
seventh would be a silent lie about the sample.
Rendering & styling
- One <svg> with viewBox and preserveAspectRatio="xMidYMid meet", class
"block h-auto w-full" plus width/height attributes for the intrinsic ratio,
inside an overflow-x-auto wrapper with a min-width of ~480px: the figure
scales with its container and scrolls sideways below that instead of
shrinking its tick labels into mush. Geometry in user units: 106px panels,
6px gaps, 9px inset inside each panel, a 44px lane on the left for the row
variable's ticks and 24px underneath for the column's.
- TICKS only on the outer lanes, three per variable (low, middle, high), with
the outer two anchored inward (textAnchor start / end) so two neighbouring
panels' labels grow away from each other and can never collide. Inside one
panel they still can: on a compact axis "-30.7M" starts at the left edge and
reaches a centred "1.6B". Estimate each label's advance width — tabular
figures share one advance, separators are about 0.3em, compact suffixes are
the widest glyphs on the lane — and drop the MIDDLE tick of the bottom lane
when it cannot clear both extremes. Never drop the extremes: they are what
state the scale, and an axis that quietly hides its own endpoints is lying.
Tick precision follows the SPAN, not the magnitude — on [0.80, 0.86] a fixed
one-digit format prints "0.8, 0.8, 0.9" — and compact notation takes over
past 10,000.
- COLOUR IS NEVER ALONE. Series colour is var(--chart-1..5) cycling, paired
with a shape (circle, square, triangle, diamond, cross) that only advances
once the five colours are used up, so no two series share both channels and
the grouping survives greyscale and colour blindness. Draw every mark as a
<path> whatever the shape, sized for roughly equal ink rather than equal
bounding box, at fill-opacity ~0.72 so overplotting reads as density. Put the
colour on the record's <g> via style={{ fill }} and let the marks inherit it:
a CSS var does not work in a presentation attribute, only in a declaration.
- Everything else is semantic tokens: stroke-border frames, fill-muted-foreground
for histogram bars, stroke-muted-foreground for the dashed zero line drawn in
any panel whose domain straddles zero, stroke-foreground for the brush ring,
text-destructive in the error branch. No hex, no oklch, no invented hue.
- PERFORMANCE. Memoise the panel layer and the record layer; do the dimming
with ONE class on the record layer's wrapper and draw the brushed record
again on top. That way a moving pointer repaints a ring and a readout, not
2,500 paths. Round path coordinates to one decimal.
- ACCESSIBILITY. The <svg> is role="group" with an aria-label summarising the
variables, the record count, the panel count and the strongest relationship
with its r and n, plus aria-describedby pointing at an sr-only paragraph that
states the keyboard map. Below it, an sr-only WRAPPER div (never sr-only on
the tables themselves: CSS width is only a lower bound for a table box, so
width:1px does not hold one back and a narrow viewport picks up hundreds of
px of horizontal scroll) holds two real tables — every record with every
value, and every pair with its r and n. The visible readout line is
aria-hidden, because the focused record already announces itself and a live
region would say all of it twice.
- MOTION is decorative only: opacity transitions with motion-reduce:transition-none
and a skeleton with motion-reduce:animate-none. Nothing here animates layout,
so the chart works identically with motion off. There are no timers, no rAF
and no observers to clean up — resizing is handled by the viewBox.
Customization levers
- maxVariables / maxPoints: the readability budget. 4 variables give big,
presentable panels; 8 is the dense analyst view. Raise maxPoints only with
the mark count in mind (records x scatter panels).
- upperPanels: "correlation" for the coefficient plus a signed magnitude bar,
"mirror" for the classic symmetric SPLOM (worth it when brushing across
twice as many panels is the point), "blank" for the sparsest triangle.
- diagonal: "histogram" to see each distribution's shape, "label" when the
variable names need the room and the distributions are already known.
- bins: fix the count when two matrices must be compared bin-for-bin.
- Domain policy: the built-in is fit-to-data-plus-6%. Swap in fixed domains per
variable when several matrices have to be read side by side, or a robust
1st-99th percentile domain when a single outlier is flattening everything.
- Statistic: swap pearson() for Spearman (rank the values first, then reuse the
same function) when the relationships are monotonic but not linear, or print
a regression slope in the caller's units instead.
- Marks: dot radius, fill-opacity and the shape ordering are the three knobs
for overplotting; at thousands of records swap the mark for a 2D density
panel and keep the brushing layer as it is.
- Interaction: brushing is state driven by an id, so a caller can lift it —
select on click, drive it from a table row, or link two matrices together by
sharing the same active id.Concepts
- Brushing and linking — one record, highlighted everywhere at once. It is what turns 25 unrelated small charts into one instrument: the dot you are looking at in "latency vs CPU" is visibly the same service in "errors vs cost". Grouping the DOM by record instead of by panel is what makes it a one-element change rather than a cross-panel lookup.
- Shared domain per variable — every panel in row 2 uses the same vertical scale, and it is fitted over all drawn records rather than the visible ones. Hiding a series must never move an axis: the reader was mid-comparison, and a scale that shifts underneath them silently rewrites the answer.
- Quadratic panel budget — the panel count is the square of the variable count, so the honest design is a stated cap plus a sentence naming what was left out. "It slowed down" and "it drew everything at 26px" are the same failure with different symptoms.
- Colour plus shape — series identity rides two channels at once, colour cycling every five and shape only advancing after that. A SPLOM printed in greyscale, or read by someone who cannot separate two of the chart tokens, still separates the groups.
- Pearson r hides the shape — the coefficient summarises a cloud into one number, and very different clouds share a number: Anscombe's sets all score 0.82. That is precisely why the coefficient goes in the mirror half while the clouds keep the main half, and why the caveat is printed rather than assumed.
- Missing is not zero — a null leaves a record out of the panels that need that variable and out of those correlations, instead of parking it at the origin where it would bend the domain, drag r and invent a cluster nobody measured.
Parallel Coordinates
A four-state parallel coordinates plot for mixed-unit records — per-axis normalisation, drag-to-brush filtering that dims rather than deletes, draggable axis order, and a rank-correlation reading for every neighbouring pair.
Marimekko Chart
A four-state mosaic chart where column width is each column's share of the total and stacked height is its internal mix, so a cell's area is its share of everything — with a minimum-width floor, texture tiers past the fifth band, and labels that drop out cell by cell.