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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-parallel-coordinates.jsonPrompt
Build a React + TypeScript + Tailwind "ChartParallelCoordinates" card in plain
SVG (no charting library), with zod for the contract and lucide-react for two
icons.
Contract
- One zod schema is the source of truth and the props are z.infer of it plus
render options:
{ status: "loading" | "empty" | "error" | "ready";
title: string; caption?: string;
axes: { key: string; label: string; unit?: string;
domain?: [number, number]; invert?: boolean;
decimals?: number (int, 0-6) }[];
items: { id: string; label: string; group?: string;
values: Record<string, number | null> }[] }
plus bandHeight (default 240, clamped 140-520), minLaneWidth (default 76,
clamped 44-240), reorderable (default true), brushable (default true),
onSelectionChange(ids), onRetry, className, forwardRef and the native div
props spread on the root.
- Records address axes BY KEY, never by array position. Position is not
identity here: the reader is allowed to reorder the axes at runtime, and an
index-keyed record would silently re-point the moment they do.
- null - and an absent key - means measured and missing. It is never 0: the
polyline breaks at that axis instead of diving to the floor, and a brush on
that axis excludes the record, because "unknown" cannot be shown to sit
inside a range.
- The schema refuses at parse time everything that cannot be drawn honestly:
fewer than two axes (with one axis there are no segments at all), two axes
sharing a key (the second would silently plot the first one's numbers), two
records sharing an id, and an axis with neither a fixed domain nor a single
finite value to fit one from. Ship the same pass as a pure
inspectParallelData() and call it from the component too - props are z.infer
types, so nothing stops a caller that never parsed its feed.
- Ship the maths as pure functions beside the schema so they can be tested and
described without React: buildParallelLayout() (axes to resolved domains,
records to per-axis positions), selectParallelRows() (brushes to surviving
ids), rankCorrelation() (Spearman, on tie-averaged ranks) and
describeAxisPairs() (one entry per NEIGHBOURING pair).
Behavior
- PER-AXIS NORMALISATION is the whole idea. Each axis maps its own [min, max]
onto the same vertical band: t = (v - min) / (max - min), then t = 1 - t when
the axis is inverted, then y = bandBottom - t * bandHeight. That is what lets
milliseconds, dollars and counts share one picture, and it is also the lie to
own up to - heights are comparable ALONG an axis and never ACROSS two. Say so
in the summary and in the axis table.
- A fitted domain (no domain in the contract) always touches both ends of the
band, so the worst record of a good batch looks exactly as bad as the worst
record of a terrible one. A fixed domain is what makes two renders
comparable; values outside it are drawn ON the edge, counted as clamped, and
still compared at their true magnitude by every brush - a record that blew
past the SLO is the one you most need to see.
- BRUSHING filters by DIMMING, never by deleting. Drag vertically anywhere on
an axis to define a range; drag a handle to move one bound; a press with no
travel (under 4px) on the lane clears that axis. Brushes live in DATA units
rather than pixels, are ANDed across axes, and are compared with a slack of
1e-9 times the span so the record that defines the maximum does not drop out
of a full-extent brush through float noise. Records that fail stay on screen
at low opacity: what a filter excludes is exactly as informative as what it
keeps, and a filter that empties the canvas cannot be read at all.
- REORDERING is a first-class question, not decoration. The plot only draws
segments between NEIGHBOURS, so two axes three columns apart have no visible
relationship; dragging them together is how the reader asks about them. Drag
an axis header (nearest-slot drop, dashed drop indicator) or press
Shift+Left / Shift+Right on it. Keep the order as an array of KEYS in state
and rebuild the drawn order by looking each key up in the incoming axes: a
refreshed feed with a new column then appends it instead of losing the
reader's arrangement.
- FLIPPING: clicking a header (or Enter/Space) toggles that axis' direction,
aria-pressed and all. This is the knob that turns a hairball into a reading -
flip every axis whose good end is low (latency, cost, error rate) and a
healthy record becomes a line that stays high all the way across, so a
crossing means a real trade-off rather than a change of polarity. Guard the
click with a ref written AND read synchronously inside the pointer handlers,
or the click that ends a drag also flips the axis.
- HOVER picks the NEAREST SEGMENT in the gap under the pointer, not a fat
transparent hit stroke per line: hit strokes of crossing lines overlap, so
the topmost one wins wherever they cross, and in a chart made of crossings
that means pointing straight at a line regularly highlights a different one.
Only the selected set is hoverable - the dimmed bundle is context - and the
highlighted record is repainted last with a card-coloured halo, a thicker
stroke and a dot on every axis.
- KEYBOARD reaches everything the pointer does. The records share ONE roving
tab stop (Up/Down/Home/End walk them, filtered-out records included, so a
keyboard user can ask why a record was excluded); each axis header is a
button (Left/Right move between headers, Shift+arrow reorders); each brush
bound is a role="slider" with aria-orientation, aria-valuemin/max/now and an
aria-valuetext that also carries the live match count (arrows step 1% of the
span, PageUp/PageDown 10%, Home/End jump to the domain edge, Escape or Delete
clears). Slider arrows follow the DATA, not the screen: up is always a larger
value, so aria-valuenow moves the way the key says it does, and on an
inverted axis the handle travels downward - which is exactly what "this axis
is drawn upside down" means.
- FOUR STATES are first-class branches of one bg-card panel: an aria-hidden
pulsing skeleton of axes and polylines, an empty state (also used when a
ready payload carries zero records), an error state showing either the
transport message or the specific contract issue plus a Try again button only
when onRetry exists, and ready.
- DEGENERATE DATA, each case handled deliberately: an axis whose values are all
equal gets t = 0.5 for every record (mid-band, labelled "all equal", and no
brush lane, because a range on it could only ever mean all or nothing); a
single record makes every fitted axis flat at once, while a fixed-domain axis
still places it properly; negative values need no special case because
domains are fitted; a value past a fixed domain is clamped and flagged; a
record with one lone value renders as a round dot via a zero-length segment
plus stroke-linecap round; a label wider than its lane is truncated by the
browser and reprinted in full in the readout and the table.
- CLEANUP: there is nothing to cancel, and that is a design choice. Pointer
capture keeps every drag listener on the element React owns, so no document
listeners are ever added; the only observer is a ResizeObserver hook that
disconnects itself; there are no timers and no rAF. The one defensive touch
is clearing a stale drag ref when a pointermove arrives with no button held.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel,
stroke-border for the axes, stroke-muted-foreground for the dimmed bundle,
fill-foreground for the brush window and its handles, stroke-ring for the
handle focus ring, stroke-card for the highlight halo, text-muted-foreground
for ticks and captions, text-destructive for the error icon, and
var(--chart-1..5) cycling for the groups. No hex, rgb or oklch anywhere.
- Colour is never the only channel: group N takes both var(--chart-N mod 5 + 1)
and dash pattern N mod 5, so the first five groups differ in two channels at
once and the plot survives greyscale printing and colour vision deficiency.
Past five groups the pairings repeat and the legend plus the table stay
authoritative.
- LAYOUT: every axis owns one lane and sits in its middle, so a label can only
grow into space no other label can claim - no collision pass is needed and
the outermost labels stay inside the drawing. Width comes from a
ResizeObserver and the viewBox always equals it, so the one frame before the
observer reports is drawn at natural size instead of clipped. Below
axisCount * minLaneWidth the card scrolls sideways rather than squeezing the
labels into two-letter stubs. Headers and tick values live in foreignObjects
with CSS truncation: the browser measures the real font in any script, which
no JS width estimate can match. Tick boxes are pointer-events:none so they
never swallow a hover meant for the plot.
- INK BUDGET: stroke opacity falls as the crowd grows - about 40/selected for
the selected set (clamped 0.35-0.95) and 18/dimmed for the rest (clamped
0.05-0.3) - so 40 lines read as lines and 400 read as a bundle whose shape is
the finding.
- ACCESSIBILITY: the svg is role="group" with an aria-label carrying the actual
finding - record and axis counts, the normalisation caveat, the active filter
with its match count, and the strongest neighbouring pair with its rank
correlation. role="img" would be wrong here: it makes its subtree
presentational, and this plot's marks are focusable. Under it, an sr-only
WRAPPER DIV (never sr-only on a table - 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 three tables: every record's value
on every axis plus whether it is in the filter, every axis with its range,
domain, direction and missing / clamped counts, and every neighbouring pair
with its rank correlation. A polite live region announces brush commits,
clears, reorders and flips; the visible readout line is aria-hidden because
the focused mark already announces itself.
- PERFORMANCE: memoise the record layer and the record table on the data and
the selection only, never on the hover, or every pointer move re-renders
hundreds of paths and thousands of table cells. Delegate focus and keydown to
one wrapping g element instead of adding two listeners per line.
Customization levers
- bandHeight and minLaneWidth are the two density knobs: 180 and 60 for a card
embed, 400 and 120 for a full-page analysis view with long axis labels.
- brushable / reorderable turn the plot into a read-only figure for a report or
an email; hover, keyboard traversal, the flip toggle and the tables all keep
working.
- invert per axis is the most valuable thing to set before shipping: choose the
polarity so "good" is up on every axis and the picture starts readable
instead of having to be earned.
- domain per axis: fix it for anything with a meaning outside this dataset (an
SLO, a 0-100 percentage, a score out of 10) or when two renders must be
comparable; leave it fitted when the batch is read on its own.
- group is what colour and dash encode. Point it at a decile bucket, a status
or an experiment arm to recolour the whole plot without touching the axes.
- onSelectionChange makes the brush the filter for the rest of the page: feed
it to a table, a map or a detail panel and the plot becomes a control
surface. It fires on commit, never during a drag.
- The finding line under the plot is one sentence built from
describeAxisPairs(); print the whole list instead, or a rho above each gap,
when teaching the reading matters more than the ink.
- Ordering: pass the axes in the order that tells the story (cause to effect,
cheap to expensive). The reader can rearrange them, but the first paint is
the one most people will read.Concepts
- Per-axis normalisation — every axis is scaled to its own range before anything is drawn, which is the only reason milliseconds, dollars and replica counts can share one picture. The price is that a height means something only within its own axis; the summary and the axis table say so out loud instead of letting the picture imply otherwise.
- Brush as dimming, not deleting — a range on an axis pushes the records that fail it into a low-opacity bundle rather than removing them. What a filter excludes is exactly as informative as what it keeps, and a filter that empties the canvas leaves the reader nothing to judge the remainder against.
- Adjacency is the question — only neighbouring axes are joined by a segment, so only neighbours have a visible relationship. That makes axis order a query rather than a style choice: dragging two axes together is how you ask whether they agree, and the reported rank correlation follows the order on screen.
- Parallel versus crossing — when two neighbouring axes agree their segments run parallel, and when they disagree the segments form an X. Flipping one axis inverts that reading without changing a single number, which is why the component tracks the data correlation and the on-screen parallelism as two separate values.
- Ink budget — with 40 lines each can afford to be solid; with 400 they would paint an opaque slab. Stroke opacity therefore falls as the crowd grows, so what you read at scale is the shape of the bundle — where it tightens, where it fans out — rather than any individual line.
- Roving tab stop over marks — hundreds of polylines cannot each be a tab stop, so the records share one: arrows walk the population, filtered-out records included, and every mark carries its full readout as its accessible name. Each brush bound is a real slider, so the filter itself is operable without a pointer.
Ridgeline Plot
A four-state ridgeline (joyplot) that estimates every row's density itself — one shared axis, overlapping curves, keyboard-walkable rows with a peak and median readout, and a pinnable median comparison line.
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.