Streamgraph
A four-state streamgraph whose bands stack on a wiggle-minimising baseline — inside-out ordering, overshoot-free monotone curves, a per-column readout and an sr-only series-by-tick table.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-stream.jsonPrompt
Build a React + TypeScript + Tailwind "ChartStream" streamgraph card in plain
SVG (no chart library) with zod for the contract.
Contract
- One zod schema is the source of truth and the props are its z.infer plus
presentation options:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; unit?: string; ticks: string[];
series: { id: string; label: string; values: number[] }[] }
values are aligned BY INDEX with ticks and are >= 0. Non-negative is not
fussiness: a stacked stream encodes a part as thickness, and thickness has
no sign. A signed measure must be split into a gains series and a losses
series, or drawn on a chart with a real zero axis.
- Extra props: offset ("wiggle" default | "silhouette" | "zero"), order
("inside-out" default | "descending" | "contract"), curve ("smooth"
default | "linear"), height (default 264, clamped 160-520),
showBandLabels (default true), formatValue, onRetry, className, plus
forwardRef and the native div props spread on the root.
- Ship two pure modules beside the schema so the maths is testable and the
component only paints: inspectStreamData(series, ticks) returning a typed
issue or null, and buildStreamLayout(series, ticks, { offset, order })
returning bands (values, y0, y1, total, peakIndex, share, stackIndex),
boundaries, per-tick totals, grandTotal, min/max, peakTickIndex,
quietTickIndex and levelTotals.
- inspectStreamData refuses, by name, everything that would silently corrupt
the picture: duplicate series ids, a series whose values length differs
from ticks length (a stream stacks by index, so a short series shifts every
column after the gap), a negative value, a non-finite value. It runs on
unparsed props too — a component handed raw props cannot know whether the
schema was ever applied.
Behavior
- ORDERING. "inside-out" is Byron and Wattenberg's: sort series by onset (the
tick where each one peaks), then deal them alternately onto the bottom and
the top of the stack, always feeding the lighter side, and finally reverse
the bottom pile. Late arrivals end up on the outer edges where an arrival
reads as a shape; the calm long-running layers stay in the middle, where
every layer above them rides on their motion. Ordering by total instead
drops each newcomer into the middle and makes the whole stack shudder.
"descending" (biggest at the bottom) and "contract" (the order carries
meaning: severity, tier, plan) are the two other useful choices.
- BASELINE. offset decides where the floor of the stack goes.
zero: baseline[j] = 0
silhouette: baseline[j] = -total[j] / 2
wiggle: baseline[j] = baseline[j-1] + dy(j), with
dy(j) = -( sum_i [ (D_i/2 + sum_{k<i} D_k) * v_i(j) ] )
/ ( sum_i v_i(j) ), D_i = v_i(j) - v_i(j-1)
layers indexed bottom-up. That dy is the shift minimising the
thickness-weighted sum of every layer's squared slope: it spends the one
degree of freedom a stack has (where its floor sits) on keeping the layers
flat. Accumulate the inner sum_{k<i} while climbing the stack so the pass
is O(layers x ticks), not O(layers^2 x ticks). A column whose total is 0
contributes no shift instead of dividing by zero.
- STACKING. Boundaries are cumulative sums from the baseline, and band i's
upper boundary is literally the same array object as band i+1's lower one.
That shared reference is what makes the picture seamless: whatever the
renderer does to a boundary it does once, so no pair of neighbours can
drift apart by a rounding step.
- CURVES. Interpolate each boundary with a monotone cubic (Fritsch-Carlson:
one-sided end tangents, a zero tangent at every sign change, then the
a^2 + b^2 > 9 limiter). Monotone is not a style choice — an unconstrained
Catmull-Rom overshoots after a spike, and an overshooting boundary dips
below the boundary underneath it, so the band visibly crosses its
neighbour and, at the bottom of the stack, pinches through the baseline.
Emit each band as the top boundary walked forward and the bottom boundary
walked backward; a cubic (P0,C1,C2,P1) reversed is (P1,C2,C1,P0), so the
return leg is the same curve, not a similar one. curve="linear" puts the
control points on the thirds of the straight line, which reproduces a
polyline exactly and keeps one code path.
- INTERACTION. Pointer move over the plot converts the event through
getScreenCTM().inverse() (never clientX - rect.left: the viewBox may be
scaling on any frame where the measured width is stale), then picks the
band from the boundaries interpolated at the pointer's real x — what the
eye sees — while reporting the value measured at the NEAREST column — what
the data says. Snapping both would highlight a neighbour wherever a band is
thin at the tick and thick between ticks. Clicking a band pins it; pinned
dims every other band to 25% and outlines the pinned one.
- KEYBOARD. The legend chips are the keyboard handle on the bands: one real
button each with aria-pressed. Tab reaches a chip, focusing it parks the
column cursor on that series' peak, ArrowLeft / ArrowRight walk the
columns, Home / End jump to the ends, Enter / Space pins. Every move
updates one readout line and, only when the source was the keyboard, a
polite live region — a hover a screen-reader user never made must not talk.
- FOUR STATES are first-class branches of one bg-card panel: a three-band
pulsing skeleton (aria-hidden, plus an sr-only "Loading" status), an empty
branch, an error branch showing the transport message or the specific data
issue with a "Try again" button only when onRetry exists, and ready.
- DEGENERATE DATA, each handled on purpose: ready with zero series or zero
ticks renders the empty branch, not an axis with nothing on it; a single
tick is painted as a constant band spanning the plot (there is no x extent
to travel across) while the table still reports one column; an all-zero
window keeps its ticks, gets a one-unit domain so the flat line lands in
the middle instead of dividing by zero, and says "every value is zero" in
the footnote; identical column totals set levelTotals, which the summary
reports as "only the mix moves"; a single series simply owns 100%; labels
wider than their band truncate instead of spilling.
- CLEANUP. The only subscription is a ResizeObserver on the plot wrapper,
attached through a CALLBACK ref because the measured node is replaced (not
merely resized) every time the status branch changes, and an effect would
never re-run to notice. Commits are coalesced into one animation frame,
which also breaks the resize -> render -> resize loop browsers report as
"ResizeObserver loop completed with undelivered notifications". Observer
and pending frame are torn down when the node is replaced and again on
unmount.
Rendering & styling
- Geometry is computed in px inside a viewBox that always equals the width it
was computed for, so the one frame before the observer reports (and the
server's render) is scaled to fit rather than clipped; once the real width
arrives the scale is exactly 1 and the labels sit at their stated size. The
svg carries an explicit height so it cannot collapse to zero in a flex
parent.
- Colour is var(--chart-1..5) cycled by stack position, and it is never the
only channel: every band is named in the legend, in the readout and in the
table; bands at least 17px thick at their peak carry an inline label; and
each full turn of the palette adds a texture (diagonal hatch, then a dot
screen) cut in var(--card), so a repeated hue still separates in greyscale
and under colour vision deficiency. A 1px hairline in var(--card) keeps
neighbours apart. Inline labels live in a foreignObject with CSS truncate
and a text-shadow halo in var(--card) — the HTML twin of
paint-order:stroke — because estimating advance widths in JS is off by tens
of percent between all-caps runs and digits.
- Be honest about the y axis: with wiggle or silhouette there is no zero
line, so do not draw one. Put a bracket worth one nice step (1 / 2 / 5 x
10^k) in the left gutter and say in the footnote that only thickness is a
value. offset="zero" swaps the bracket for real gridlines and compact
labels. x labels are thinned by a stride computed from the available width,
with the first and last column always drawn.
- Accessibility: the svg is aria-hidden (its content is text-free geometry)
and the text alternative is a figure whose sr-only figcaption states the
finding — series count, window, total, largest share and where it peaks,
the busiest and quietest column, the biggest riser and faller, and which
baseline is in use — followed by an sr-only table of every series at every
tick with totals and shares. 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. Legend chips carry the per-series numbers in their
aria-label; the visible readout line is aria-hidden because the live region
already says that sentence.
- Motion is decorative only: opacity transitions carry
motion-reduce:transition-none and the skeleton motion-reduce:animate-none.
Nothing about reading, pinning or walking the chart depends on animation.
Customization levers
- offset: "wiggle" for "what is the mix doing", "silhouette" for the classic
symmetric ThemeRiver, "zero" when the total is the story and you want a
real y axis back. It moves the baseline only — every number is untouched.
- order: "inside-out" while onset matters, "descending" to put the giant at
the bottom and read the tail above it, "contract" when the order encodes
severity or tier and must not be reshuffled.
- curve: "linear" whenever the columns are far apart in time and a smooth
edge would invent a shape between two measurements.
- height plus showBandLabels are the density dial: drop the inline labels and
the same data reads at 180px in a dashboard tile.
- Palette: the cycle is var(--chart-1..5) by stack position, so re-pointing
those tokens moves bands, swatches and textures together. Change the
texture tiers if your palette has more than five distinguishable steps.
- Interaction: pinning is local state — lift it to make the pinned series
drive a table elsewhere, or drop the click handler for a static card. The
readout is one function; replace it to show the delta against the previous
column instead of a share of that column.
- formatValue takes over every printed number (currency, compact notation,
locale) except the axis, which stays compact on purpose.Concepts
- Wiggle baseline — a stack has exactly one degree of freedom: where its floor goes. The wiggle offset spends it on the shift that minimises the thickness-weighted squared slope of every layer, so the layers stay as flat as the data allows. The price is the y axis: after the shift, height on the page means nothing and only a band's thickness is a value, which is why the component says so in the footnote and offers a bracket instead of an axis.
- Inside-out ordering — series are sorted by onset (where each one peaks) and then dealt alternately to the bottom and the top of the stack. Newcomers land on the outer edges, where an arrival is a shape rather than a shove, and the long-running layers hold the calm middle that everything above them rides on.
- Shared boundary — band i's upper edge is the same array as band i+1's lower edge, and both are drawn from the same curve walked in opposite directions. Neighbours therefore cannot drift apart by a rounding step, and the stream never shows the hairline gaps that give away a chart drawing each band independently.
- Overshoot-free interpolation — monotone cubic (Fritsch–Carlson) instead of Catmull-Rom. An unconstrained spline sails past a spike, and an overshooting boundary dips below the one underneath it: bands cross, and at the bottom of the stack the picture pinches through its own baseline. Monotone curves look slightly flatter at a peak and cannot do that.
- Column cursor — one cursor, two sources. The pointer picks the band from the boundaries interpolated at its real x but reports the value at the nearest column; the keyboard moves the same cursor from a focused legend chip. Only keyboard moves reach the live region, so a screen reader is never narrated a hover its user did not make.
- Thickness is the only channel carrying a number — everything else is redundancy on purpose: hue by stack position, a texture per turn of the palette, an inline label on bands thick enough to hold one, the legend, the readout and a full series-by-tick table underneath. Any one of them can be lost (greyscale printing, colour vision deficiency, a screen reader) and the chart still answers the question.
Dumbbell Chart
A four-state dumbbell chart — one row per category, two dots joined by a bar on one shared scale, ranked by valence-aware change with direction carried by shape, arrow and row order as well as colour.
Chart Bubble
A four-state bubble chart in plain SVG: area-proportional radii from a zero baseline, hue paired with fill texture for the fourth dimension, and an optional time slider that interpolates positions between snapshots.