Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildTornadoLayout,
  type ChartTornadoData,
  type TornadoEndpoint,
  type TornadoRow,
  type TornadoSort,
} from "./chart-tornado.contract"

export interface ChartTornadoProps

Installation

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

Prompt

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 baseline falls (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.

On This Page