Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  analyseAlluvial,
  DEFAULT_ORDER_PASSES,
  type AlluvialAnalysis,
  type AlluvialFlowStat,
  type AlluvialNodeStat,
  type AlluvialOrder,
  type ChartAlluvialData,
} from "./chart-alluvial.contract"

Installation

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

Prompt

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 from equals its to. 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.

On This Page