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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-ridgeline.jsonPrompt
Build a React + TypeScript + Tailwind "ChartRidgeline" card (a joyplot) in plain
SVG with zod. No chart library: recharts has no density primitive, and the whole
component is 200 lines of arithmetic plus one polyline per row.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; unit?: string; valueLabel?: string;
rows: { id: string; label: string; values: number[] }[];
domain?: [number, number] }.
`values` are RAW observations, unsorted and unbinned. The component estimates
the density itself, so nothing upstream has to agree with it about bin edges
or bandwidth and two charts built from the same numbers stay comparable.
An empty `values` array is legal and means "measured, saw nothing": the row
keeps its slot, because closing a gap in a sequence changes what the sequence
says.
- Component props = z.infer of the schema plus overlap (default 0.55, clamped
0-2), rowHeight (default 44px, clamped 24-160), heightScale ("row" | "shared"
| "count", default "row"), resolution (default 96 grid points, clamped
24-256), labelWidth (default 116px, clamped 64-260), formatValue and onRetry,
on top of the div's own attributes (Omit<HTMLAttributes, "title">, since the
contract owns title). Forward the ref and spread the leftover attributes onto
the card in ALL FOUR states, so a consumer can measure or annotate the panel
without knowing which branch is currently rendering.
- Clamp every numeric prop through one helper that treats NaN and Infinity as
"the caller handed us garbage" and falls back to the documented default
rather than to the range floor.
- Ship the maths as pure functions beside the schema: quantileSorted (R-7),
bandwidthFor (Silverman), gaussianDensityAt, niceTicks, and
buildRidgelineLayout() returning per-row count / ignored / outside / min /
median / max / peak / peakDensity / bandwidth / flat / curve plus the shared
domain, totals and the biggest neighbouring shift. Keeping them pure is what
makes the component testable without a DOM and rewritable by an agent.
Behavior
- DENSITY. Gaussian KDE on one shared grid. Bandwidth is Silverman's rule,
0.9 * min(sd, IQR/1.349) * n^(-1/5). The IQR term is not decoration: a single
10x outlier inflates sd, and a bandwidth taken from sd alone smears a clean
bimodal shape into one wide blob — which is exactly the feature this chart
exists to reveal. Truncate the kernel at +/-4 bandwidths and find the window
by binary search on the sorted sample, so cost scales with "observations near
x" rather than "observations"; the omitted mass is 6.3e-5 of the kernel, far
under a pixel.
- SHARED AXIS. Every row is evaluated on the same grid over the same domain. A
ridgeline only means anything because a horizontal position means the same
thing in every row; per-row axes turn "the distribution moved right" into an
artefact of per-row scaling. Fitted domain = data extent padded by three
bandwidths, which is where a Gaussian tail has 0.3% of its mass left, so
curves land on the baseline inside the plot instead of being sliced off flush
with the edge (a slice reads as a cliff in the data). A caller-supplied
domain skips the padding; observations outside it are still counted in every
statistic and the count is printed, because a chart that quietly drops the
tail is the chart someone will use to argue the tail is gone.
- HEIGHT IS A CHOICE, SO SAY WHICH ONE. "row" scales each curve to its own peak
(shape and position compare, volume does not), "shared" divides every row by
the tallest peak, "count" multiplies by n first. Default to "row" and print
each row's observation count next to its label — equal heights hide unequal
evidence, and the count is the only thing that admits it.
- DEGENERATE INPUTS, each handled on purpose: zero rows and rows that measured
nothing route to the empty branch; a row with no observations inside a live
chart keeps its slot and is drawn as a dashed rule (a polyline lying on the
baseline would claim "zero density everywhere", a different statement); n = 1
and all-identical samples have no spread, so Silverman returns 0 — substitute
span/60, mark the row flat, and say so in the footnote and the table, never
divide by it; non-finite values are dropped and counted; a zero-width domain
(one value, or a caller passing [5, 5]) is widened to +/-max(10% of the
value, 0.5) before anything is divided by the span; duplicate row ids keep
their first entry; labels wider than the lane truncate in CSS with the full
text in the row's aria-label, its title and the table.
- INTERACTION. One transparent HTML band per row, sitting on the slab directly
above that row's baseline, overlaid on the SVG. The band — not the painted
curve — is the hit target, so which row answers a click never depends on how
far a tail happens to reach into its neighbour. Hover or focus dims every
other row to 0.45, thickens the active ridge, repaints it last so it is never
occluded, and fills a readout line with count, median, peak and range.
Clicking a row (Enter/Space on the keyboard) toggles it as the comparison
baseline: a dashed vertical rule is drawn at its median across every row, and
every other row's lane picks up its signed median shift against it. That is
the whole point of the pin — "how far did this move" answered without
arithmetic.
- KEYBOARD MAP. Roving tabindex over the row bands: one Tab enters the ridge
and one Tab leaves it, however many rows there are. ArrowUp / ArrowDown move
between rows, Home / End jump to the first / last, Enter or Space pins or
unpins the baseline, Escape clears the pin. Move focus with .focus() on the
target band rather than by re-rendering, so the browser keeps ownership of
the focus ring and the readout rides along on the focus event.
- FOUR STATES are first-class branches of one bg-card panel: five pulsing
skeleton ridges (aria-hidden, plus an sr-only role="status"), an error panel
whose "Try again" button appears only when onRetry is passed, an empty panel
that distinguishes "no rows" from "rows exist but nothing has been observed
yet", and ready.
- CLEANUP: there is deliberately nothing to clean up — no ResizeObserver, no
rAF, no timers, no window listeners. If you replace the layout strategy with
a measured one, disconnect the observer on unmount AND on dependency change.
Rendering & styling
- RESPONSIVE WITHOUT MEASURING. The SVG carries a constant viewBox
(0 0 1000 plotHeight) with preserveAspectRatio="none", so x stretches to any
container width and y stays in px. That keeps the HTML row bands aligned with
the painted baselines at every width with no observer and no first-frame
reflow. Two consequences to respect: every stroke needs
vector-effect="non-scaling-stroke" (vector-effect is NOT inherited, so put it
on each element, not on the group), and NO text may live in the SVG — glyphs
would stretch with x. Labels, axis ticks and the readout are all HTML; axis
ticks are positioned with left: N% , which is exactly the mapping the
stretched viewBox applies, so they stay on their grid lines.
- GEOMETRY. Row pitch p, amplitude a = p * (1 + overlap). Baseline of row i is
a + i*p, so the plot is a + (rows-1)*p tall and a single row still renders.
The band for row i spans [a + (i-1)*p, a + i*p], with row 0 taking everything
above its baseline. The fill path closes down to the baseline; the ridge path
stays open so the baseline is not stroked twice. Interpolate between grid
points with straight lines, never a spline: a cardinal spline overshoots and
invents local maxima the estimate does not have.
- MARKERS. A solid tick at the median and a dashed tick at the peak, each
stopping exactly at the curve (linear interpolation of the row's own grid),
so "where the mass is" and "where the middle is" are two channels that
survive greyscale. Vertical ticks are also immune to the horizontal stretch.
- COLOUR is var(--chart-1..5) cycling by row index, raw for the ridge stroke and
color-mix(in oklab, token 15%, var(--card)) for the fill. Mix into --card
rather than fading with alpha: an OPAQUE fill is what makes a lower ridge
occlude the one above it, and that occlusion is the depth cue the joyplot
rests on — two translucent curves stacked instead produce a third colour that
means nothing. Past five rows the palette repeats, so colour is explicitly
NOT the identifier: the label lane, the reading order and the sr-only table
are. Everything else is semantic tokens (bg-card, bg-muted, text-foreground,
text-muted-foreground, stroke-border, ring, accent).
- ACCESSIBILITY. The plot is a <figure> with an aria-label that states the
finding, not the structure: row count, total observations, the axis range,
which rows hold the lowest and highest medians, the biggest neighbouring
shift, which height scale is in force and how many observations fell outside
the axis. role="img" would be wrong here — it makes children presentational,
and the row bands are focusable. The SVG itself is aria-hidden; every number
also lives in an sr-only table (row, n, min, median, peak, max, shift vs
baseline, note) whose caption names the estimator. Put sr-only on the WRAPPER
DIV, never on the table: CSS width is only a lower bound for a table box, so
width:1px does not hold one back and a 375px viewport picks up hundreds of px
of horizontal scroll. The visible readout is aria-hidden — the focused band
already announces those numbers, and a live region would say all of it twice.
- MOTION is one 150ms opacity transition with motion-reduce:transition-none;
the skeleton pulse carries motion-reduce:animate-none. Nothing about reading,
walking or pinning depends on animation.
Customization levers
- overlap: 0 gives every curve its own lane (measurable, taller card), 0.4-0.7
is the classic joyplot, 1-2 compresses a long sequence into one shape at the
cost of hiding the upper rows' tails. rowHeight trades total height for
legibility the same way; under 34px the second label line is dropped
automatically.
- heightScale: "row" to compare shape and position, "count" when the story is
how much evidence each row rests on, "shared" when density itself is the
quantity. Whichever you pick, keep the sentence that says which — the picture
cannot say it alone.
- domain: fix it to make two cards comparable, or to cut a tail that squashes
everything into the left tenth. The out-of-range count is disclosed either
way; do not silently clip.
- resolution: 96 grid points is smooth for typical data; raise it toward 256
for very spiky samples, drop it toward 32 for a sparkline-sized card.
- Palette: re-point the token cycle, or key colour off a valence (regression =
chart-5, improvement = chart-2) instead of the index when rows carry a
direction. Fill strength is the one number that controls how strongly the
rows occlude each other.
- Markers and lanes: drop the peak tick for a cleaner ridge, widen labelWidth
for long group names, or replace the second label line with your own metric.
The pin's downstream effect (median shift per row) is one function — swap it
for a KS distance or a percentile delta if that is the comparison you owe
your readers.Concepts
- Shared axis — the one invariant that makes a ridgeline readable: all rows are estimated on the same grid over the same domain, so a horizontal position means the same thing everywhere and "the distribution moved" is a fact about the data rather than about per-row scaling.
- Overlap as a depth cue — a row's amplitude is
pitch × (1 + overlap), so curves climb into the rows above them. Fills are opaque mixes into the card colour, which turns overlap into occlusion: the lower row is nearer the reader. Fade the fills with alpha instead and the stack becomes mud. - Bandwidth substitution — Silverman's rule returns 0 for a sample with no spread (one observation, or twelve identical ones). That is a signal, not a bandwidth: the component substitutes a domain-derived width, draws a narrow spike, and admits the substitution in the footnote and the table instead of dividing by zero and painting NaN.
- Height scale honesty — scaling each row to its own peak is what makes shapes comparable and what hides that one row rests on 6 observations and another on 6,000. The counts printed beside every label, and the
countscale, are the two ways out; the picture cannot make that disclosure by itself. - Pinned median baseline — clicking or pressing Enter on a row draws its median across the whole plot and turns every other row's lane into a signed shift against it. It converts "these look like they moved" into a number without leaving the chart.
- Band hit target — rows are picked by an invisible slab above each baseline, not by the painted curve. Hit-testing the curve would mean a long tail reaching into a neighbour steals that neighbour's clicks, and which row you get would depend on the data rather than on where you pointed.
Violin Plot
A four-state violin plot that estimates each group's density itself — Silverman bandwidth, a quartile box inside the silhouette, prominence-tested peak detection and an sr-only stats table.
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.