Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartForestPlotData,
  ChartForestPlotStudy,
} from "./chart-forest-plot.contract"

export interface ChartForestPlotProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartForestPlotData {
  /** Height of one study row in px (clamped 22–48, default 30). */

Installation

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

Prompt

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.

On This Page