Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildRidgelineLayout,
  niceTicks,
  tickDecimals,
  type ChartRidgelineData,
  type RidgelineCurvePoint,
  type RidgelineHeightScale,
  type RidgelineRowStat,
} from "./chart-ridgeline.contract"

Installation

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

Prompt

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 count scale, 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.

On This Page