Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, LayoutGrid } from "lucide-react"
import { cn } from "@/lib/utils"
import {
  binValues,
  domainOf,
  pearson,
  readValue,
  suggestBinCount,
  type ChartScatterMatrixData,
  type ScatterMatrixBin,
  type ScatterMatrixCorrelation,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-scatter-matrix.json

Prompt

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.

On This Page