Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  analyseFunnelPlot,
  FUNNEL_Z95,
  FUNNEL_Z99,
  funnelTickDecimals,
  funnelTicks,
  type ChartFunnelPlotData,
  type FunnelStudyState,
} from "./chart-funnel-plot.contract"

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartFunnelPlot" card — a
publication-bias funnel plot for meta-analyses — in plain SVG (no chart
library) with zod.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    caption?: string; effectLabel?: string;
    studies: { effect: number; se: number > 0; label?: string }[];
    pooledEffect: number }.
  se must be strictly positive: an SE of zero would claim infinite precision
  and every band test divides by it. A ready payload needs at least one
  study; unlabeled studies are named "Study N" in input order.
- Component props = z.infer of the schema plus height (default 280, clamped
  160-520), showBands (default true), formatEffect, onRetry and className.
  No hand-written parallel interface.
- Ship the statistics as a pure module beside the schema:
  analyseFunnelPlot(studies, pooledEffect) returning per-study
  { z, side, band } plus ignored, seMax, a padded x domain, band counts,
  the flagged left/right split and Egger's test — everything below is
  computed there and merely painted by the component.

Behavior
- GEOMETRY: x is the effect size; y is the standard error, INVERTED — SE 0
  at the top, the least precise study at the bottom. seMax = max(se) × 1.06
  so nobody sits on the edge. The pseudo confidence funnel is two triangles
  with a shared apex at (pooledEffect, SE 0): at depth se the 95% edge sits
  at pooled ± 1.96·se and the 99% edge at pooled ± 2.576·se. The x domain
  must contain every study AND the funnel's full mouth
  (pooled ± 2.576·seMax), padded 6% — a wedge with clipped corners stops
  looking like a confidence region and starts looking like a bug.
- BANDS per study, from z = (effect − pooled) / se with a 1e-9 relative
  epsilon so 1.9600000001 is not "outside": 0 inside 95% (|z| ≤ 1.96),
  1 between 95% and 99%, 2 beyond 99% (|z| > 2.576). The contours are drawn
  around the SUPPLIED pooled effect, never re-estimated from the plotted
  points — the pooled effect is the meta-analysis's own summary.
- EGGER'S TEST, the plot's companion statistic: OLS of (effect/SE) on
  (1/SE); report the intercept and t = intercept / SE(intercept). Needs
  n ≥ 3 and differing precisions — say which is missing instead of hiding
  the line. Read |t| ≥ 2 as the conventional asymmetry flag, and say in
  prose that asymmetry has causes besides publication bias.
- ASYMMETRY SENTENCE: count flagged studies by side of the pooled effect.
  All on one side → "the one-sided pattern selective publication produces";
  both sides → "more like heterogeneity than selective publication". The
  verdict is stated in the header strip, the figure's aria-label and the
  explanatory paragraph — never left to the reader's eye alone.
- Studies with a non-finite effect or a non-positive/non-finite SE are
  dropped BEFORE anything is indexed, and the count of dropped studies is
  reported in the empty branch and the footer line.
- CLEANUP: this component owns no timers, no requestAnimationFrame, no
  observers and no listeners outside React's synthetic events, so there is
  nothing to tear down. If you add a hover delay or polling, cancel it in
  the effect teardown AND on every dependency change.
- The four states are first-class branches of one bg-card panel: a skeleton
  that keeps the funnel silhouette (aria-hidden, with an sr-only
  role="status"), an empty state whose second line changes when studies
  arrived but were all unusable, an error state with "Try again" only when
  onRetry exists, and ready.

Rendering & styling
- Geometry in one SVG with a constant viewBox and preserveAspectRatio
  "none", so the plot stretches to any width with no ResizeObserver, no
  first-frame reflow and no SSR mismatch. ONLY x is stretched: vertical
  geometry stays in px, which keeps the HTML markers and tick labels on the
  lines the SVG painted. Every glyph is HTML — a <circle> in a
  non-uniformly scaled viewBox is an ellipse whose eccentricity depends on
  the container width. One absolutely positioned <button> per study at
  (x%, y px), size-5 for a tappable target.
- COLOUR IS NEVER THE ONLY ENCODING. One population, one token:
  var(--chart-1) for the in-funnel dots and both wedge fills
  (color-mix into transparent at 5% for the 99% wedge, 8% for the 95% wedge
  painted over it, so the fills stack and deepen toward the centre).
  Flagged studies wear --destructive, but what actually distinguishes them
  is SHAPE: dot inside 95%, ring between 95% and 99%, diamond beyond 99% —
  repeated in the legend with counts. The two contours differ by dash
  pattern (6 4 vs 2 3), also repeated in the legend, so the chart survives
  greyscale and colour blindness.
- Recessive chrome: horizontal grid lines at SE ticks in stroke-border, a
  solid border-coloured vertical at effect 0 only when zero is on screen,
  the pooled effect as a solid muted-foreground vertical. Ticks on
  1/2/5 × 10^k steps, re-rounded to the step's precision. SE axis labels in
  a left lane of min(52px, 22%); effect labels in an HTML strip below,
  positioned in percent so they track the stretch.
- ACCESSIBILITY: the plot is a <figure> (not role="img" — it contains
  focusable children) whose aria-label states the finding: study count,
  pooled effect, the asymmetry sentence and Egger's result. Each study
  button's aria-label is the same sentence the visual readout shows: label,
  effect, SE, signed distance in SE units, band. Below, an sr-only WRAPPER
  DIV holds a real table with one row per study; sr-only on the wrapper,
  never on the table (CSS width is only a lower bound for a table box).
- KEYBOARD: one roving tab stop — one Tab in, one Tab out. Left/Right walk
  the studies, Up/Down jump to the previous/next flagged study, Home/End go
  to the ends, Enter or Space pins the readout (aria-pressed) because touch
  has no hover, Escape clears the pin. Move focus with .focus() rather than
  by re-rendering, so the browser keeps ownership of the focus ring.
- Hovering or focusing a study drops a dashed crosshair at its (x, y). The
  readout paragraph is aria-hidden on purpose: the focused button already
  announces the sentence, and a live region would say all of it twice.
- Motion: the loading skeleton pulses and stops at motion-reduce; nothing
  else animates, so the chart is fully functional with motion off.

Customization levers
- height sets the funnel's steepness: taller separates the precision tiers,
  shorter fits a dashboard row. showBands={false} drops the shaded wedges
  and keeps only the dashed contours — the journal-figure look.
- formatEffect reaches the axis, the readout, the summary and the table at
  once: pass Math.exp-based formatting when the scale is a log odds ratio,
  so the plot stays linear in log space but reads in ratios.
- The contour levels are the FUNNEL_Z95/FUNNEL_Z99 constants; a
  contour-enhanced variant at 90/95/99% is one more wedge and one more dash
  pattern with the same apex.
- Palette: re-point var(--chart-1) for the population and the wedges; keep
  the flag on --destructive or move it to another semantic token if your
  product reserves red. Never encode the band in colour alone — change the
  shape map instead.
- Interaction: the pin is the only state the chart owns. Wire onClick on a
  study button to open the paper, or lift `pinned` to sync with a forest
  plot of the same meta-analysis sitting beside it.

Concepts

  • Inverted SE axis — the y axis runs from perfect precision at the top to the noisiest study at the bottom, so the picture reads as gravity: big trials cluster at the apex, small ones spread along the mouth. Plotting SE upward would draw the same data as a meaningless V.
  • Pseudo confidence contours — the wedge is not a confidence interval for any study; it is the region where studies WOULD land if sampling error around the pooled effect were the only force. That is why it must be centred on the supplied pooled effect, not re-fitted to the points: the funnel is a hypothesis to test the studies against.
  • Asymmetry as a sided count, not a vibe — the tell for publication bias is not "some points are outside" but which corner is bare: flagged studies all on one side, with the mirror-image corner empty, is the signature of small negative studies that were never published. The component counts sides and says which pattern it sees.
  • Egger's intercept — the eyeball test made citable: regress the standardized effect on precision and a symmetric funnel puts the intercept at zero. |t| ≥ 2 is the conventional flag — and the prose still cautions that asymmetry has other causes (heterogeneity, chance, true small-study differences), because a funnel plot suggests, it never convicts.
  • Shape-coded bands — dot inside 95%, ring between the contours, diamond beyond 99%, with the two contour lines told apart by dash pattern; colour marks severity, never identity, so the plot survives greyscale, projectors and colour blindness.

On This Page