Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildStreamLayout,
  inspectStreamData,
  type ChartStreamData,
  type StreamBand,
  type StreamOffset,
  type StreamOrder,
} from "./chart-stream.contract"

Installation

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

Prompt

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.

On This Page