Alluvial Diagram
A four-state alluvial that re-draws the same categories at every reading — band width is how many members moved, one global category order chosen by measured crossing cost, and a retained / arrived / left readout for whatever you point at.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-alluvial.jsonPrompt
Build a React + TypeScript + Tailwind "ChartAlluvial" component — the same set of
categories re-drawn at every time slice, with bands between neighbouring slices
sized by how many members were re-assigned — using zod and hand-composed SVG. No
charting library: recharts has no alluvial, and the layout here is a stack of
rectangles plus cubic bands, which is less code than bending a Sankey renderer
into repeating its nodes.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; unit?: string; message?: string;
slices: { id: string; label: string }[]; // max 12, left to right
groups: { id: string; label: string }[]; // max 10, the category set
flows: { at: string; from: string; to: string; value: number > 0 }[] }
- A flow's `at` is the id of the slice it LEAVES; it lands in the next slice.
from === to is the retained band — the members who were in the same category
at both readings, which is the number this chart exists to show next to the
ones that moved.
- Validate in ONE superRefine, not a chain of refines: zod runs every refine in
a chain even after an earlier one failed, so a second refine that dereferences
flows[i].from throws a TypeError out of safeParse on exactly the malformed
payload it was added to catch. Reject duplicate slice / group ids, unknown ids,
a flow leaving the last slice, and the same (at, from, to) declared twice.
- Cap groups at 10 and say why in the message: past that the five chart tokens
have to repeat and the bands are thinner than their own labels. Ask the caller
to bucket the tail into "Other" instead of rendering something unreadable.
- Component props = z.infer of the schema plus order, orderPasses, onRetry and
className. No parallel hand-written interface.
- Ship a pure analyseAlluvial() beside the schema, with no pixels in it: it
returns the category order, per-node inflow / outflow / stayed / arrived /
left / entering / leaving, per-band share of its origin, slice totals, overall
retention, the largest move, and the crossing cost of the order it chose.
Behavior
- ONE CATEGORY ORDER FOR EVERY SLICE. This is the whole difference from a
Sankey: a Sankey orders each column independently, which here would make the
retained band slope for no reason at all. Expose the strategy as `order`:
- "untangle" (default) runs barycentre sweeps (a global jump a swap-at-a-
time search cannot make) and then adjacent transposition (the descent the
sweep cannot do), keeping whichever permutation measured cheapest — the
contract's own included, so it can never come out worse than "given".
The sweep alone is measurably not enough: on a severity scale stored
alphabetically it settles at 522 crossing units against an optimum of 207
that one adjacent swap reaches. Over 200 random 3–6 category feeds the
pair was never worse than the contract order, hit the brute-force optimum
153 times, and averaged 26% above it in the rest.
- "given" keeps the contract order, for when that order carries meaning
(tiers, severity, price bands) and re-ranking it would mislead.
- "size" puts the largest category on top: stable, obvious, optimises
nothing, and measured 1,775 crossing units where the contract's own order
scored 580.
Score an order by charging, for every pair of bands in one transition whose
endpoints are in opposite relative order, the SMALLER of the two values. That
keeps the cost in entity units and stops one fat band from owning the
objective.
- Inside a node, bands leave ordered by where they land and arrive ordered by
where they came from. That removes the crossings within a node; the category
order removes the ones between nodes.
- A node is painted max(inflow, outflow), and when those disagree the shorter
face gets a dashed cut-out in var(--card) spanning the difference — members
joined the population there, or left it. Do NOT quietly pad the short face:
that is how a chart of a growing population looks exactly like a chart of a
stable one. The first and last slice are exempt, because neither can tell a
population change from the edge of the data.
- Clamp every numeric prop (orderPasses to 0–32, NaN and Infinity back to the
default) and treat a degenerate scale as zero rather than dividing by it.
- Four first-class branches of one bg-card panel: a pulsing column skeleton
(aria-hidden), an empty state that says one reading is not a movement, an
error state showing `message` plus a "Try again" button only when onRetry
exists, and ready.
Rendering & styling
- ONE viewBox, NO MEASURING PASS. Draw into a 1000-unit-wide viewBox with
preserveAspectRatio="none" and the viewBox height equal to the pixel height:
horizontal positions become proportions that stretch with the card, while the
vertical axis — the one every value is encoded on — stays 1:1 with px. No
ResizeObserver, no first-frame fallback width overflowing the card, no
hydration mismatch. Give any stroke vector-effect="non-scaling-stroke" so the
non-uniform scale cannot thin it.
- All text is HTML, never SVG text, so it never scales with the stretch: a
two-column grid, grid-cols-[minmax(3rem,min(22%,6.5rem))_minmax(0,1fr)],
puts a category lane beside the plot (measured 104px at 900px, 66px at 375px)
and lets the browser do the fitting with `truncate` — estimating advance
widths in JS is off by -29% on all-caps and +30% on digits.
- Slice labels are absolutely positioned, each owning the band between its own
column and the next, clamped at the two ends. Because the boxes tile without
overlapping, two labels can touch but never collide (measured 0 overlaps at
900px and 375px). The cost is that the first and last label get half a column
— abbreviate long period names or widen that budget.
- A category label is drawn in the lane only when its first node is at least
16px tall. That threshold is also what guarantees labels cannot collide: a
~14px label centred in a >=16px node, with 8px between nodes, cannot reach its
neighbour.
- Bands are cubic beziers with the control points at the midpoint x. Floor the
painted height at 1px but advance the stacking cursor by the TRUE height, so
the floor only costs (1px - true height) of overshoot on a band that was
invisible anyway (measured: a 2-of-480 band paints 0.70px unfloored). The
exact count stays in the readout and in the data table.
- Colour: nodes are var(--chart-1..5), and a sixth category onward is the same
token mixed toward var(--foreground), which deepens the ramp in light mode and
brightens it in dark — a fixed second ramp goes invisible at one end of one
theme (measured 3.63–14.35:1 in light and 4.64–14.47:1 in dark for seven
categories). Bands are a DEEPER shade of their origin node, not the node's own
token: bands must be translucent so the ones underneath still read, and a raw
token at that alpha lands at 1.63:1 against the card. color-mix toward
--foreground first, then paint at 0.65 — measured 3.18–5.46:1 light and
3.95–6.97:1 dark. Never use a chart token as a text colour.
- Highlighting: hovering a band lights it and drops everything else to 0.08;
hovering a node lights the bands that touch it; a legend chip pins a category
and lights its whole alluvium, both directions, at every reading. One readout
line underneath says what is lit — a band's count and share of its origin, or
a node's stayed / arrived / left, or a pinned category's split per transition.
Escape clears the pin. Gate the fade on motion-reduce:transition-none.
- Accessibility: the plot is role="img" with a sentence summarising categories,
readings, peak, retention and the largest move. Children-presentational is
safe here because the SVG deliberately carries NO tab stops — the legend chips
are the keyboard and touch route in, and they sit outside that subtree.
Below it, an sr-only WRAPPER DIV (never sr-only on a table: width:1px is only
a lower bound for a table box, measured 2,643px of real horizontal scroll in a
375px viewport when it is put there) holds two tables — every band with its
share, and every node with members / stayed / arrived / left / population
change.
Customization levers
- order: "given" whenever the category order is itself information; "untangle"
when it is an accident of the source system; "size" for a fixed, explainable
rule. orderPasses trades milliseconds for a slightly better order on dense
feeds and does nothing for the other two strategies.
- Node width (14 viewBox units), the 8px gap between nodes and the 34px per
category that sets the height are the three numbers controlling density; drop
the height clamp for a tall poster-sized version.
- Band opacity 0.65 / lit 0.85 / dimmed 0.08 is the readability dial. Lower the
base for very dense feeds, but re-measure the contrast against the card if you
do — that number is what the color-mix toward --foreground is paying for.
- Colour by DESTINATION instead of by origin (use the target index for the band
fill) when the question is "where did this cohort come from" rather than
"where did it go".
- The dashed cut-out is the honesty valve. Keep it, but re-word "joined / left
the population" for the domain — hires and leavers, admissions and discharges,
opened and closed.
- The readout is one line; swap it for a floating tooltip if the card is wide,
or wire the legend's toggle to a drill-down alongside the pin.Concepts
- Retained band — the flow whose
fromequals itsto. It is not a special case in the data model and it is not drawn differently; it simply connects the same row to itself, so "stayed put" is the flattest thing on the chart and everything sloped is a move. That single property is what makes an alluvial answer "who stayed" and "who went where" in one picture. - One global category order — the permutation is chosen once and used at every slice. Ordering each column separately, which is what a Sankey layout engine does, buys fewer crossings and destroys the flat retained band in exchange. Everything about this chart's layout follows from refusing that trade.
- Crossing cost — for every pair of bands in one transition that cross, the smaller of the two values. Charging the smaller keeps the number in entity units ("how many members are caught in a crossing") and stops one fat band from deciding the order on its own.
- Barycentre then transpose — two heuristics because neither is enough alone: the sweep can jump to a completely different permutation in one step but stalls next to the answer; adjacent transposition can only take one swap at a time but always downhill. Both phases only ever adopt an order they measured as cheaper, which is why the pair cannot come out worse than the order you supplied.
- Population cut-out — a node is painted
max(inflow, outflow), and when the two disagree the shorter face is cut with a dashed line spanning the difference. Padding the short face instead would make a growing or shrinking population look exactly like a stable one, which is the specific lie this chart is easiest to tell. - Stretch-invariant encoding — the plot is one viewBox stretched with
preserveAspectRatio="none", so nothing is measured at runtime. It works because every value here is encoded vertically and the viewBox height equals the pixel height: the stretch only ever touches the axis that carries no data. Labels stay out of the SVG so the stretch cannot reach the type. - No tab stops in the plot — the SVG is
role="img"and deliberately carries nothing focusable, so children-presentational is honest rather than destructive. Every number the hover reveals is also reachable from a legend chip, from the readout, and from the sr-only tables.
Punchcard
A weekday × hour dot grid — every Monday 09:00 folded into one bucket, dot area on a square-root scale, switchable size / colour / both encodings and the aggregation timezone stated on the card.
Error Bars
A four-state categorical error-bar chart: one point estimate per category with an interval whose meaning — SD, SE, CI or custom bounds — is declared in the contract and printed in the legend, tooltip and data table.