Cumulative Flow Diagram
A four-state cumulative flow diagram that reads band thickness as work in progress and the horizontal gap between the arrival and delivery curves as approximate lead time, annotated on the plot and reachable by keyboard.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-cumulative-flow.jsonPrompt
Build a React + TypeScript + Tailwind "ChartCumulativeFlow" card in plain SVG
(no chart library) with zod. A cumulative flow diagram is not a stacked area
chart with nicer labels: its whole value is two DERIVED readings — vertical
thickness is work in progress, horizontal distance is lead time — and a build
that does not compute and annotate both has missed the point.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; unit?: string; dates: string[];
stages: { id: string; label: string; counts: number[] }[];
daysPerPoint?: number }.
- stages are in WORKFLOW ORDER, upstream first. counts[j] aligns by index
with dates[j]. Every stage except the last carries work in progress (how
many items sat in that stage that day); the LAST stage carries the
cumulative number finished by that day. Say this loudly in the schema doc
comment — a feed that sends "finished today" for the last stage collapses
every curve in the chart.
- counts are non-negative: a band encodes a queue as thickness and thickness
cannot go below zero.
- daysPerPoint is the calendar gap between two columns (default 1). Every
duration the chart reports is multiplied by it, so a weekly board reports
real days instead of columns.
- Component props = z.infer of the schema plus height (default 288, clamped
180-560), showStageLabels (default true), annotate (default true),
throughputWindow (default 7, clamped 1-60), yDomain: "zero" | "fit"
(default "zero"), formatValue, onRetry, className and the native div props
through forwardRef.
- Ship a pure module beside the schema: inspectCumulativeFlowData() for the
structural pass, leadTimeAt(), throughputAt() and
buildCumulativeFlowLayout() returning bands, arrivals, deliveries, the
arrival envelope, per-date wip, per-date readings, the Little's Law
cross-check and the degenerate flags (frozen, hasBackflow, firstDipIndex).
Behavior — the maths, which is the product
- STACK ORDER IS REVERSED. Paint the LAST stage at the bottom and walk
upstream. That is what makes each upper boundary a cumulative arrival
curve: boundary_s = (items in s) + (everything downstream of s), and
because work moves one way that equals "items that have ever reached stage
s". Neighbouring bands must SHARE the boundary array (band i's y1 is band
i+1's y0, the same object) so the two edges are identical rather than
merely similar.
- THICKNESS = WIP. Band s at date j is exactly counts_s[j] tall. State the
one exception in the UI: the bottom band is the finished pile, so its
thickness is a running total, not a queue.
- HORIZONTAL DISTANCE = LEAD TIME. At column j the workflow has delivered
L = deliveries[j] items. Solve arrivals(x) = L by linear interpolation and
report (j − x) × daysPerPoint. Under FIFO that gap is exactly how long the
L-th item waited; away from FIFO it is an average, so every printed label
says "approximate". Take the inverse against the RUNNING MAXIMUM of the
arrival curve, not the raw curve: cancelled scope makes arrivals step back,
a non-monotone curve has no unique inverse, and a naive scan would return
whichever crossing it met first.
- Censoring is a first-class answer. If the crossing falls left of the first
column the window can only prove a lower bound — return "at least N days",
never a confident number. If nothing has been delivered yet, say that. With
a single stage the arrival and delivery curves are the same line: there is
no queue and no lead time, and saying so beats drawing a confident zero.
- Throughput is a TRAILING difference over throughputWindow columns, not a
one-day difference: a step-shaped delivery curve reads 0 on four days out
of five and then spikes on the fifth. It may come out negative when items
are re-opened; report it rather than clamping, because that is real.
- Cross-check with Little's Law in the summary: average WIP divided by
average throughput, against the mean lead time measured horizontally. When
the two disagree the queue is not FIFO, and a reader deserves to know.
- CURVES ARE STRAIGHT. Offer no smoothing option. The lead-time reading is
the horizontal inverse of the arrival curve AS DRAWN, and a spline's
inverse is not the one the maths computes, so a smoothed CFD would annotate
itself with a number that contradicts its own picture.
- The four states are first-class branches of one bg-card panel: a pulsing
three-layer skeleton (aria-hidden, plus an sr-only role="status"), an empty
state, an error state carrying either the transport message or the specific
contract issue plus a "Try again" button only when onRetry exists, and
ready. status="ready" with no stages or no dates falls through to the empty
copy instead of dividing by zero.
Rendering & styling
- Layout: the viewBox always equals the width the geometry was computed for,
so a not-yet-measured chart is scaled to fit rather than clipped. Measure
with a LOCAL ResizeObserver behind a callback ref, not useRef + useEffect:
the plot lives behind four branches, so the node is replaced rather than
merely resized when the status changes and an effect would never re-run to
notice. Coalesce commits into one animation frame — which is also what
avoids "ResizeObserver loop completed with undelivered notifications" — and
tear down observer and frame both when the node is replaced and on unmount.
- Axis: a real zero baseline by default, ticks at 1/2/5 × 10^k steps.
yDomain="fit" starts the axis just under the delivery curve, the only way
to read a 40-item queue riding on 1,400 delivered — at the price of a
truncated axis, which the footer then says out loud. x tick labels are
thinned by a stride computed from the available width, but the first and
the last are always drawn.
- Colour: var(--chart-1..5), cycled from the bottom of the stack. Colour is
never the only encoding — the stack order IS the workflow order (repeated
in the legend, the summary and the table), every band is named, thick bands
carry an inline label, and each full turn of the palette adds a texture
(flat, then a 45-degree hatch, then a dot screen) cut in var(--card), so it
lightens on a light card and darkens on a dark one and survives greyscale
printing, which a hue change does not. A 1px hairline in var(--card)
separates neighbours whatever the hue.
- The two curves the readings come from are drawn in var(--foreground), not
in a palette token: solid for arrivals, dashed for deliveries. They have to
stay identifiable while every band around them is dimmed.
- ANNOTATIONS are the feature. At the active date draw a vertical bracket
between the two curves labelled "WIP n", and a horizontal double-headed
arrow at the delivery level running back to the crossing on the arrival
curve, labelled with the lead time. Flip the WIP label to the left of the
guide when the right edge is closer; drop either label — never overflow it
— when its mark is shorter than about 46px or thinner than 16px. Both
labels live in a foreignObject with CSS truncate and a text-shadow halo in
var(--card), the HTML twin of paint-order:stroke, because measuring advance
widths in JS is off by tens of percent between all-caps and digits.
- With no cursor the active date is the LAST column, so the card explains
itself before anyone touches it.
- Accessibility contract: a <figure> whose sr-only <figcaption> is the actual
finding — arrived / delivered / in flight, where WIP peaks, lead time now
versus at the start, average throughput, the Little's Law cross-check and
any degenerate note. The <svg> is aria-hidden and focusable="false"; it is
text-free geometry and everything it says exists as text elsewhere. Below
it an sr-only WRAPPER DIV holds a real table: one row per date with the
queue in each stage, in flight, arrived, delivered, rate and approximate
lead time. Put sr-only on the wrapper, 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.
- Keyboard: the legend chips are the keyboard surface. Tab reaches each chip;
with one focused, ArrowLeft / ArrowRight walk the dates and Home / End jump
to the ends; Enter or Space pins that band (aria-pressed) and dims the
others. The visible readout line is aria-hidden and mirrored into an
sr-only role="status" that speaks ONLY when the cursor was moved by
keyboard — a hover a screen-reader user never made must not talk. A pointer
leaving the plot must not clear a keyboard cursor.
- Motion: the only animation is an opacity transition on dimming plus the
skeleton pulse, both with motion-reduce variants. Nothing about reading the
chart depends on motion.
- Degenerate data, each handled deliberately: zero dates or zero stages →
empty branch; one date → the band is drawn as a constant strip spanning the
plot instead of a zero-width sliver, and lead time degrades to a bound; all
values equal → nothing is delivered, throughput 0, Little's Law undefined,
lead time reported as a lower bound that grows one day per day; one stage →
arrival equals delivery, no queue, no lead time; a boundary that steps back
→ keep drawing it (it is real: items re-opened or scope cut), read lead
times against the running maximum and name the date in the footer; ragged
or negative counts → refuse in the contract and render the reason in the
error branch, because stacking by index would otherwise shift every column
after the gap.
Customization levers
- annotate={false} keeps both numbers in the readout, the summary and the
table but stops drawing the bracket and the arrow — use it for a dense
dashboard tile where the card is a picture with a caption.
- yDomain="fit" for a long-running board whose delivered pile dwarfs its
queue; keep "zero" whenever area has to be comparable across cards.
- throughputWindow: 7 smooths a weekly rhythm, 1 shows raw day-to-day
movement, 14-30 suits a quarterly view.
- height and showStageLabels are the density dial: drop the labels first, the
height second.
- daysPerPoint turns a weekly or hourly board into real durations without
touching the maths.
- formatValue and unit re-point every printed number (points instead of
issues, "GB" instead of a noun).
- Palette: the stack is coloured from the bottom up, so re-pointing the token
list changes which end of the workflow is loudest; keep the texture tiers if
you ever exceed five stages.
- Interaction: the readout is deliberately one line and there is no tooltip.
Wire onClick on a band to drill into that stage's items, or lift the active
date into a parent to sync a table beside the chart.Concepts
- Thickness is work in progress — a band's height on a date is the number of items sitting in that stage that day, which is why the bands stack raw queue counts and are never normalised. The one exception is the bottom band: it is the finished pile, so its thickness is a running total rather than a queue, and the component says so instead of letting the reader assume.
- Horizontal distance is lead time — walk left from the delivery curve at today's delivered count until you meet the arrival curve at the same count. Under FIFO that gap is exactly how long that item waited; away from FIFO it is an average, which is why the label always says "approximate" and never pretends to name an item.
- Arrival envelope — the inverse lookup runs against the running maximum of the arrival curve, not the raw one. Cancelled scope and deleted tickets make arrivals step back, a non-monotone curve has no unique inverse, and reading one anyway would silently return whichever crossing the scan happened to meet first.
- Censored reading — when the crossing falls before the first column, the window can only prove that the item waited at least as long as the window is wide. Reporting that bound is the honest answer; reporting the visible part as if it were the whole wait is the classic way a cumulative flow diagram flatters a slow queue.
- Little's Law cross-check — average WIP divided by average throughput should land near the lead time measured horizontally. When the two diverge, the queue is not FIFO — something is being expedited while something else ages — and that divergence is itself the finding, so the summary states both numbers side by side.
- Reversed stack order — the finished stage is painted at the bottom on purpose. That is the only arrangement in which every upper boundary means "items that have ever reached this stage", which is what makes the horizontal reading possible at all; pre-reversing the contract array to "fix" the picture destroys it.
Chart Burndown
A four-state sprint burndown: remaining work against an ideal that only burns on working days, scope changes drawn as steps, and a projected finish from a burn rate a descope cannot fake.
Control Chart
A four-state SPC control chart that estimates its own limits from the moving range and flags every rule violation with the rule it broke.