Forest Plot
A four-state meta-analysis forest plot — weight-sized squares with CI whiskers per study against a vertical null line, and a pooled summary diamond.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-forest-plot.jsonPrompt
Build a React + TypeScript + Tailwind "ChartForestPlot" meta-analysis widget in
hand-rolled SVG (no chart library) with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
effectLabel: string; ciLevel?: number (0–1, naming only — never rescale);
nullValue: number; scale?: "linear" | "log";
directionLabels?: { low: string; high: string };
studies: { id, label, effect, ciLow, ciHigh, weight >= 0 }[];
pooled?: { label?, effect, ciLow, ciHigh } }.
- effect, ciLow and ciHigh must admit non-finite numbers: zod's z.number()
rejects NaN/Infinity at parse time, so type those fields with a
typeof-number custom check — a study with no usable numbers has to survive
parsing to keep its label-only "not estimable" row.
- Component props = z.infer of the schema, plus rowHeight? (22–48, default 30),
onRetry?: () => void, emptyState?: ReactNode and className; spread remaining
div props on the root. No hand-written parallel interface.
Behavior
- Four first-class branches inside one bg-card panel: loading (pulsing rows
that mirror the plot silhouette — whiskers, squares, null line, diamond),
empty, error with a "Try again" button only when onRetry exists, ready.
- One row per study, top to bottom in payload order: label left (truncated at
~20 chars, full name in the data table), CI whisker with end caps in the
middle, square point estimate whose AREA is proportional to weight/Σweight,
"effect (low to high)" text and weight % right. All-zero weights mean
weights are unavailable: one neutral square size, disclosed in a footnote.
- The pooled summary is a diamond in its own row under a separator: widest
points at its CI bounds, vertices at the effect. Omit pooled for a CI plot
with no combined estimate.
- Dirty extraction data is repaired, kept or marked — never silently dropped:
swapped CI bounds are re-ordered (the one repair with a single correct
reading); a non-finite effect or bound keeps a label-only "not estimable"
row; an estimate outside its own interval is drawn exactly where reported
and flagged with the destructive token. Every count lands in the footnote.
- scale "log" places ratio measures symmetrically around 1; if any drawn value
(the null line included) is <= 0 the axis falls back to linear and says so.
Ticks snap to a 1/2/5×10^n progression on both axis kinds.
- The key reading is said out loud under the chart: how many studies cross the
no-effect line, and whether the pooled diamond clears it.
Rendering & styling
- Semantic tokens only: study squares var(--chart-1), pooled diamond
var(--chart-2), flagged squares var(--destructive), whiskers and null line
stroke-muted-foreground, axis stroke-border, panel rounded-xl border bg-card;
no hex / rgb / oklch anywhere.
- Width from a ResizeObserver on a callback ref (disconnected on unmount),
inside an overflow-x-auto wrapper that scrolls below the ~506px natural
minimum; height = header + rows + pooled row + axis, computed, never fixed.
- The SVG is role="img" with a one-paragraph summary as its label; per-row
<title> elements give native hover tooltips; the same numbers repeat in an
sr-only table (study, effect, CI, weight, crosses-null reading). Skeleton
pulses carry motion-reduce:animate-none; cn() merges className; the retry
button has a focus-visible ring.
Customization levers
- Row density: rowHeight — 22–24 for a dense evidence table, 36+ for slides;
square and diamond sizes derive from it, so nothing else needs touching.
- Columns: set WEIGHT_W to 0 and drop the weight texts when weights are
meaningless for your data; widen LABEL_W (and LABEL_CHARS) for long study
names instead of accepting truncation.
- Measure semantics travel together: ratio measures = effectLabel "Odds ratio"
+ nullValue 1 + scale "log"; differences = "Mean difference" + nullValue 0 +
linear. ciLevel only renames the interval — feed intervals pre-computed.
- Marker palette: squares, diamond and flags each map to exactly one token
(--chart-1 / --chart-2 / --destructive); re-point the square token per
subgroup to turn it into a subgroup forest plot.
- Reading aids: directionLabels prints "favours X / favours Y" either side of
the null line for a lay audience; omit them for a technical one.Concepts
- Weight-sized squares — a study's square scales in area, not side length, with its share of the total weight, so a study carrying four times the evidence reads as four times the ink; when every weight is zero the encoding is dropped for one neutral size and the footnote says so, rather than inventing precision.
- The null line is the reading — a whisker that touches the vertical no-effect line marks a study whose data cannot be told apart from "no effect"; the component counts those crossings and states them in prose instead of leaving the inference to squinting.
- Pooled diamond — the summary is a shape, not another whisker: its width is the combined interval and its vertices sit at the combined effect, so "does the diamond clear the line" is the one-glance verdict of the whole analysis.
- Repair, keep or mark — never drop — swapped bounds have exactly one correct reading and are repaired; a study with no usable numbers keeps its labelled row; an estimate outside its own interval is drawn where it was reported and flagged. Each case is counted in a visible footnote, because a forest plot that silently tidies its inputs misreports the evidence.
- Log axis for ratio measures — on "log", 0.5 and 2.0 sit the same distance from 1, which is what makes ratio symmetry readable; if any drawn value (the null line included) is zero or negative the axis falls back to linear and discloses it instead of quietly dropping rows.
Bland-Altman Plot
A four-state method-agreement plot — pair differences against pair means, with a computed bias line, dashed ±1.96 SD limits of agreement, and out-of-limits pairs flagged in the destructive tone.
Funnel Plot
A four-state publication-bias funnel plot in plain SVG — effect size against inverted standard error, shaded 95%/99% pseudo confidence contours around the pooled effect, flagged outliers and Egger's test.