Sankey and path Flow where the band width is the volume. Worth it when the branching is the question and expensive when it is not, because a Sankey with more than a few nodes becomes a plate of spaghetti nobody reads twice. Tokens this needs: --foreground, --card, --muted-foreground, --border, --status-warn, --chart-1 The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // Sankey.tsx ──────────────────────────────────────────────────────────────────────── /** * Width is volume, and it has to conserve. What enters a node leaves it, or * the widths stop meaning anything, so any node whose outflow is short of its * inflow gets the remainder sent to an exit node in the next column. The * exit is computed rather than optional: product flows leak people, and the * diagram has to say where. */ export type Node = { id: string; label: string; column: number; /** What entered a node that has no inbound links, so its leak can be computed too. */ value?: number; }; export type Link = { from: string; to: string; value: number }; const NODE_W = 14, GAP = 14, PAD = 30; export function Sankey({ nodes, links, exit = "left", highlight, width = 440, height = 250, onSelect }: { /** In display order within each column. Keep the order stable between renders so the diagram can be learned. */ nodes: Node[]; links: Link[]; /** Label for the computed exit node. */ exit?: string; /** A node whose bands are drawn as the route worth looking at. */ highlight?: string; width?: number; height?: number; onSelect?: (link: Link) => void; }) { const inflow = (n: Node) => links.filter((l) => l.to === n.id).reduce((s, l) => s + l.value, 0) || n.value || 0; const outflow = (id: string) => links.filter((l) => l.from === id).reduce((n, l) => n + l.value, 0); // Conservation: send whatever leaks out of a node to an exit in the next column. const cols = Math.max(...nodes.map((n) => n.column)) + 1; const all: Node[] = [...nodes], flows: Link[] = [...links]; for (const n of nodes) { const lost = inflow(n) - outflow(n.id); if (n.column === cols - 1 || lost <= 0) continue; const id = `${exit}:${n.column + 1}`; if (!all.some((x) => x.id === id)) all.push({ id, label: exit, column: n.column + 1 }); flows.push({ from: n.id, to: id, value: lost }); } const size = (id: string) => Math.max( flows.filter((l) => l.to === id).reduce((n, l) => n + l.value, 0), flows.filter((l) => l.from === id).reduce((n, l) => n + l.value, 0), ); const r = (v: number) => +v.toFixed(1); // Stack each column, scaled so the tallest column fills the height. const byCol = Array.from({ length: cols }, (_, c) => all.filter((n) => n.column === c)); const scale = Math.min(...byCol.map((col) => (height - 2 * PAD - GAP * (col.length - 1)) / col.reduce((n, x) => n + size(x.id), 0))); const x = (c: number) => PAD + 40 + (c * (width - 2 * PAD - 60 - NODE_W)) / (cols - 1); const pos: Record = {}; byCol.forEach((col, c) => { let y = PAD; for (const n of col) { const h = size(n.id) * scale; pos[n.id] = { x: x(c), y, h, out: y, in: y }; y += h + GAP; } }); const isExit = (id: string) => id.startsWith(`${exit}:`); const band = (l: Link) => { const a = pos[l.from], b = pos[l.to], h = l.value * scale; const y0 = a.out, y1 = b.in; a.out += h; b.in += h; const x0 = a.x + NODE_W, x1 = b.x, cx = (x0 + x1) / 2; return `M${x0} ${r(y0)} C${cx} ${r(y0)} ${cx} ${r(y1)} ${x1} ${r(y1)} L${x1} ${r(y1 + h)} C${cx} ${r(y1 + h)} ${cx} ${r(y0 + h)} ${x0} ${r(y0 + h)} Z`; }; return ( {flows.map((l) => ( ${l.to}`} d={band(l)} onClick={onSelect && (() => onSelect(l))} className={isExit(l.to) ? "fill-muted-foreground/30" : l.from === highlight || l.to === highlight ? "fill-status-warn/50" : "fill-chart-1/40"}> {`${all.find((n) => n.id === l.from)!.label} → ${all.find((n) => n.id === l.to)!.label}: ${l.value.toLocaleString("en-GB")}`} ))} {all.map((n) => { const p = pos[n.id], right = isExit(n.id), first = n.column === 0; const [bar, label] = right ? ["fill-muted-foreground", "fill-muted-foreground"] : n.id === highlight ? ["fill-status-warn", "fill-status-warn"] : ["fill-chart-1", "fill-foreground"]; return ( {`${n.label}: ${size(n.id).toLocaleString("en-GB")}`} {n.label} ); })} ); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { Sankey } from "./Sankey"; /** * Four thousand people landed on the pricing page. The links only say where * people went; the exits are computed from what each node failed to pass on. * Help is highlighted because everyone who detoured through it reached the * cart, and the funnel had no column for them. */ export default function Demo() { return (
console.log(l)} />
); }