Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, ArrowDownUp, GripVertical } from "lucide-react"

import { cn } from "@/lib/utils"
import { useResizeObserver } from "@/registry/hooks/use-resize-observer"
import {
  buildParallelLayout,
  describeAxisPairs,
  inspectParallelData,
  selectParallelRows,
  type ChartParallelAxis,
  type ChartParallelCoordinatesData,

Installation

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

Prompt

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.

On This Page