Tornado Chart
A four-state sensitivity chart — one row per driver, low and high segments measured out from a base-case line that is never assumed to be zero, ranked by swing into a funnel, on an axis that is never forced symmetric.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-tornado.jsonPrompt
Build a React + TypeScript + Tailwind "ChartTornado" card — a sensitivity
analysis drawn as a funnel of two-sided bars — with zod. No chart library: the
plot is one fixed-layout table plus percentage widths, so text stays at its real
size at every container width and nothing ever has to be measured.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready";
title: string; caption?: string;
baseline: number; // the result with every driver at base
resultLabel?: string; unit?: string; baselineLabel?: string;
cases?: { low: string; high: string }; // defaults "Low" / "High"
variables: { id: string; label: string;
low: { input?: string; result: number };
high: { input?: string; result: number } }[] }.
`input` is a STRING the caller has already formatted ("8.5%", "$1.20 / unit",
"Vendor B"): every driver carries its own unit, so no chart-level formatter
could print them all. `result` is in the same unit as `baseline` for every
driver — that is what makes one shared axis legitimate.
Reject a duplicate id in a superRefine (it collides as a React key and the two
rows shadow each other). `ready` with an empty array is a legitimate answer
from a real feed and renders the empty branch, not an axis with nothing on it.
- Component props = z.infer of the schema plus the display levers: sort
("impact" | "given", default "impact"), barHeight (clamped 8-36, default 20 or
14 above 12 drivers), axisIntervals (clamped 2-8, default 4), minBarWidth
(clamped 0-8, default 2), showValues (default true), formatValue, locale
(default "en-US"), skeletonRows, onRetry, emptyState, className, plus the
native div props through forwardRef.
- Ship a pure module beside the schema: inspectTornadoVariables() for the
structural pass, niceSignedAxis(min, max, intervals) for a round axis that may
sit anywhere on the number line, and buildTornadoLayout() returning
{ ok: true, layout } | { ok: false, issue }. The layout carries per row the
swing, the two endpoints (delta from the base case, which side they fall on,
the fraction of the axis they span), the oneSided / inverted / inert flags and
the swing relative to the widest row, plus axisMin, axisMax, ticks,
baselinePct, the top and bottom drivers and the counts of the odd shapes.
Keep it DOM-free so the same geometry can feed an export, a PDF or a test.
Behavior
- ONE ROW PER DRIVER, VARIED ALONE. Every row is an independent what-if — this
driver at its low setting, everything else at base — so the bars must never be
stacked, summed or read as a decomposition. Say so on the card, because a row
of horizontal bars looks exactly like a decomposition.
- RANK BY SWING. swing = |high.result - low.result|, sorted descending, ties
broken by the feed's own order so the render is a pure function of the data.
That ordering IS the chart: it is what makes the silhouette a funnel and what
turns the picture into "go re-estimate this one first". Print the swing in its
own column so the sort key is legible rather than implied. sort="given" keeps
the feed order, and the summary still names the genuinely largest driver.
- THE BASE CASE IS A NUMBER, NOT ZERO. Bars grow out from `baseline`
wherever it falls (9.6 for an NPV model, 31.4 for a margin model, -2.4 for a
business currently under water). Include the baseline in the axis domain, and
the reference line is guaranteed to land inside the track even when every
driver pushes the same way.
- NEVER FORCE A SYMMETRIC AXIS. Snap both ends outward independently to a
1 / 2 / 2.5 / 5 x 10^n step. A driver worth -3 on one side and +8 on the other
really is lopsided, and mirroring the scale would draw the -3 at the length of
a -8 that does not exist while spending half the width on an empty half. Use
an epsilon on both bounds so a value that is already an exact multiple of the
step does not gain a whole empty interval from float noise, and round every
tick to the step's own precision or the axis prints 0.30000000000000004.
- SIDES ARE DATA, NOT LAYOUT. An endpoint sits left or right of the base case
according to the sign of `result - baseline`, compared with a tolerance scaled
to the magnitudes so 1e6 ± 1e-7 still counts as unchanged. Three shapes fall
out and each is handled on purpose:
- INVERSELY RELATED (high.result < low.result): the high end lands on the
LEFT. This is the norm for anything cost-shaped, which is exactly why
position cannot be the channel that identifies which end is which.
- SAME DIRECTION (both ends on one side): the two segments overlap. Draw
them longest-first and inset the second vertically by about a quarter of
the bar height, so both stay visible without moving either off the line it
is measured from, and tag the row "same direction" — it means the base
case is not between the two settings, which is a finding.
- INERT (neither end moves the result): no bars at all and a "no effect"
tag, so a real zero reads as a real zero rather than a failed render.
- DEGENERATE DATA: every reading equal to the base case collapses the span to
zero, which would divide by zero for every bar — open a ±10% window around it,
draw NO ticks and no gridlines, and say in words that nothing moves. A
duplicate id or a non-finite number refuses to draw and names the driver: one
NaN takes the axis with it and turns every width into NaN%, which renders, and
lies.
- MINIMUM BAR WIDTH: a driver worth 0.2% of the axis is a fifth of a pixel and
simply disappears. Floor the painted width with CSS max(<pct>%, <floor>px) —
the browser is the only party that knows the track width, so there is nothing
to measure and nothing to re-measure on resize. Be honest about the trade:
below the floor the width no longer encodes the value, the exact number is
always printed in the row, and minBarWidth={0} restores strict
proportionality.
- The four states are first-class branches of one bg-card panel: a
funnel-shaped pulsing skeleton (aria-hidden, with an sr-only role="status"
beside it), an empty state, an error state that prints either the transport
failure or the specific data issue and offers "Try again" only when onRetry
exists, and ready.
- CLEANUP: there is nothing to clean up, and that is a decision, not an
omission — no timers, no rAF, no ResizeObserver, no window listeners.
Responsiveness is percentage widths inside a fixed-layout table plus two
container queries, so the component is correct on its first paint, during SSR,
and at every width, without measuring anything.
Rendering & styling
- Layout is one table with table-fixed and three columns in a colgroup:
[driver][plot][swing]. The plot column declares NO width, so the fixed-layout
algorithm gives it whatever is left and every row's track is identical — that
is what lets one base-case line run straight down the plot without measuring
anything. The driver column is sized from the longest label in ch (capped at
14ch) and the swing column from the widest formatted number (capped at 10ch),
so a forty-character driver name moves the columns and never the scale. A
min-width of (labels + swing + 96px) scrolls the card rather than letting the
track collapse. Long names WRAP (`[overflow-wrap:anywhere]`) instead of
truncating: the driver's name is the primary content of its row.
- The driver name and its swing are top-aligned and nudged down by
(trackHeight - lineHeight) / 2 onto the bar's centre line. Vertical centring
looks right until the printed ends make the cell twice the bar's height, at
which point the name floats between two rows and reads as belonging to
neither.
- Bars are absolutely positioned inside a relative track: a left-growing segment
is anchored `right: (100 - baselinePct)%` and a right-growing one
`left: baselinePct%`, both with width as a percentage of the track, so the
anchor stays on the base case and the pixel floor grows AWAY from it.
- Gridlines cost zero DOM: one repeating-linear-gradient with period
100/(ticks-1) percent plus a single gradient pinned at the right edge (the
repeating layer's last line falls exactly on the boundary and is clipped
away). The base-case line is a separate element drawn AFTER the bars, in
color-mix(in oklab, var(--foreground) 30%, transparent) — not --border, which
is oklch(1 0 0 / 10%) on the dark card and vanishes exactly where every bar is
measured from. Clamp its left with CSS clamp() so a base case hard against an
edge keeps its whole 1px inside the track instead of poking half a pixel into
the scroll container.
- Colour is never the only channel, and here it cannot even be the second one:
position does NOT identify the series, because an inversely related driver
puts its low end on the right. Low is var(--chart-1), high var(--chart-2) —
the two most separated hues — and high additionally carries a 45-degree hatch
built from one step toward --foreground and one toward --card at equal
strength, so it adds about as much ink as it removes. On top of that, every
end prints its case name and its setting beside a chip repeating both fill and
hatch.
- Each end's setting and result are printed under the bar, pinned to the two
ends of the TRACK rather than to the ends of the bar: stable positions need no
measurement and can never collide, and the chip ties each label to its own
segment. Under about 220px of track the two stack full-width instead — two
47px columns turn "Low $0.90" into one word per line. Inside one label the
number wraps below the setting rather than squeezing it. Use container queries
on the track, never the viewport: the driver and swing columns take an
arbitrary bite out of the card first.
- The base-case value rides in a strip ABOVE the plot, anchored at its own
percentage and pulled fully inside when it is within 25% of either edge, so it
can never collide with a tick label. Tick labels live in a tfoot row, centred
on their gridline except the two ends, which are pulled inside; they thin
themselves by container query (~12px per character plus air) — the high end
always survives, the low end survives whenever two labels fit, interior labels
drop outward-in. Gridlines never thin.
- ACCESSIBILITY in two layers. (1) An sr-only paragraph BEFORE the table states
the finding: driver count, the base case, the axis range, the largest driver
with both its settings and its swing, the smallest as a percentage of the
largest, and how many drivers are inversely related or push the same way —
the last two matter most to someone who cannot see which side a bar is on.
(2) The plot IS the data table: an sr-only caption, a real column header per
column and a row header per driver, with each end's setting and result as
ordinary cell text, so the text alternative cannot drift out of sync with the
picture the way a duplicated hidden table does. showValues={false} keeps those
labels for screen readers instead of deleting them. The tfoot is aria-hidden —
"0 · 5 · 10 · 15 · 20" read out of a table row teaches nobody anything.
- Motion is decorative only: bar widths transition and the skeleton pulses, both
off under motion-reduce. Nothing about reading the chart depends on either.
- Semantic tokens only: bg-card, bg-muted, border, text-muted-foreground,
text-destructive, ring, var(--chart-1), var(--chart-2), and color-mix over
--foreground / --card for the hatch and the base line. No hex, no raw oklch,
and no --chart-* on any text.
Customization levers
- sort: "impact" is the chart. Reach for "given" only when the rows already mean
something in order (a model's own parameter list, a page the reader is
following elsewhere) — you lose the funnel, and the summary will still name
the largest driver, so the picture and the sentence stop agreeing at a glance.
- axisIntervals: 4 by default. This is a choice about the SCALE, not about
fitting labels — a track too narrow to print them all thins them itself. Take
it to 2 for a coarser, rounder step (accepting more empty headroom), 6-8 on a
wide dashboard tile for a finer one.
- barHeight + showValues: 20px with printed settings is the reading layout;
12-14px with showValues={false} halves the row height for a dense board and
leaves a pure silhouette — the settings stay in the DOM for screen readers
either way, and the swing column still carries the magnitudes.
- minBarWidth: 0 for strictly proportional bars, 4-8 when the tail of the funnel
matters more than the arithmetic.
- cases + baselineLabel: name the two ends for the audience — "Downside" /
"Upside" for a risk review, "Not shipped" / "Shipped" for an experiment
backlog, "P10" / "P90" for a probabilistic model — and rename the reference
line ("Current run", "Budget", "Do nothing").
- formatValue + locale: one function covers the printed results, the swing
column, the ticks, the base-case marker and the summary, so a currency or
compact formatter propagates everywhere at once.
- Palette: re-point the two fills at any two --chart-* tokens; keep the hatch on
exactly one side (that is the greyscale channel) and keep the legend chips
painting fill and hatch identically to the bars.
- Top-N: slice the array before handing it over rather than adding a cap. The
component never silently truncates, because "the drivers you did not see" is
the one thing a ranking must not hide.Concepts
- One-at-a-time what-if — every row is a separate model run with one driver moved and everything else held at base, so the bars are independent and never add up to anything. A row of horizontal bars looks exactly like a decomposition, which is why the card says out loud that it is not one.
- Swing is the sort key, and the sort is the finding — ranking by
|high − low|is what turns a pile of bars into a funnel and turns the picture into "go re-estimate this driver first". The magnitude gets its own printed column so the ordering is legible rather than implied, and ties fall back to the feed's order so the render stays a pure function of the data. - The base case is a number — bars grow out from wherever
baselinefalls (9.6, 31.4%, −2.4), not from zero. Because the baseline is included in the axis domain, the reference line is guaranteed to land inside the track, right up to the case where every driver pushes the same way and it sits hard against an edge. - An unmirrored axis — both ends of the scale are snapped outward independently. A driver worth −3 down and +8 up is drawn exactly that lopsided; forcing symmetry would draw the −3 at the length of an −8 that does not exist and spend half the width on an empty half.
- Side is data — which side an end lands on comes from the sign of
result − baseline, so an inversely related driver puts its low end on the right. Position therefore cannot identify the series, which is why each end carries a hue, a hatch and its own printed name. - Same direction and no effect — when both settings push the result the same way the segments overlap (shorter drawn on top and inset), and when neither moves it there are no bars at all. Both are tagged in words: the first means the base case is not between the two settings, the second is a real zero rather than a failed render.
- The plot is the table — the bars live inside a real table with column and row headers, and each end's setting and result are ordinary cell text, so the text alternative is the chart itself instead of a hidden copy that can drift away from it. Only the finding-level summary is duplicated, once, as an sr-only sentence.
Survival Curve
A four-state Kaplan–Meier card — stepped survival curves computed from raw follow-up times, censoring ticks on the curve, a log-log confidence band and a numbers-at-risk table pinned to the axis.
Seasonality
A four-state seasonal decomposition card — one series split into trend, season and residual across three panels on one shared x axis, with anomalous residuals marked against a robust cut-off and undecomposable feeds refused instead of fitted.