Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildCumulativeFlowLayout,
  inspectCumulativeFlowData,
  type ChartCumulativeFlowData,
  type LeadTimeReading,
} from "./chart-cumulative-flow.contract"

export interface ChartCumulativeFlowProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,

Installation

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

Prompt

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.

On This Page