Decision Curve Analysis
A four-state decision curve: net benefit against threshold probability, one line per strategy against treat-all and treat-none references derived from the prevalence, with the useful threshold range banded and the leading strategy called out as you scan.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-decision-curve.jsonPrompt
Build a React + TypeScript + Tailwind "ChartDecisionCurve" clinical-utility card
in plain SVG with zod. Not recharts: two of the three lines on this plot are not
data at all — they are derived from one number (the prevalence) — and the band
marking where a strategy beats both of them is a root-finding pass, not an
annotation API.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
prevalence: number (0..1);
strategies: { label: string;
points: { threshold: number (0..1, 1 excluded);
netBenefit: number }[] }[];
interventionLabel?: string }.
- threshold is the threshold probability p_t: the risk at which this reader
would accept the intervention, so its odds p_t / (1 - p_t) ARE the
harm-to-benefit ratio. 1 is excluded, not clamped — the odds diverge there.
- netBenefit is TP/n - (FP/n) * p_t / (1 - p_t), in true positives per patient.
Negative values must survive the wire: below zero means the strategy is worse
than intervening on nobody, which is the whole point of the plot.
- prevalence is REQUIRED because it IS the reference pair. The component derives
both, and the payload must not send them as strategies:
treat none: 0 at every threshold;
treat all: prevalence - (1 - prevalence) * p_t / (1 - p_t),
which opens at the prevalence and crosses zero at p_t =
prevalence.
superRefine: labels unique; a "treat all"/"treat none" label (also under the
caller's own interventionLabel verb) is rejected as a duplicated reference; a
ready card needs one strategy with one threshold; guard every access so a
ragged payload yields an issue, not a TypeError.
- interventionLabel renames the references in every surface at once — "biopsy
all" / "biopsy none" instead of "treat".
- Props = z.infer plus height (default 260, clamped 160..480), onRetry,
className and the div's native props; forwardRef to the card.
- Export the maths next to the schema so a test can print the numbers the
picture is made of: treatAllNetBenefit(), treatAllExit(),
buildDecisionCurveModel(), netBenefitAt(), usefulThresholdRuns(),
leadingReading(), axisStep(), nearestStop(), netBenefitFormatters().
Behavior
- CLEAN, THEN DRAW. Sort a copy by ascending threshold; drop non-finite values
and thresholds outside [0,1); drop a repeated threshold rather than averaging
it (two net benefits at one threshold are a contradiction, and the mean of a
contradiction is a number nobody computed). Count every drop and say so in
view.
- EMPTY IS NOT ABSENT. A strategy that shipped no usable threshold keeps its
legend row ("no thresholds recorded"), its table column and its colour slot:
ink and dash come from the payload index, never from the drawable index, so a
queued-but-empty series can never re-colour the ones that did arrive.
- CLIP THE REFERENCE, DO NOT CHASE IT. treat-all dives toward minus infinity as
p_t approaches 1. Solve the threshold where it crosses the y floor
(odds = (prevalence - floor) / (1 - prevalence); p_t = odds / (1 + odds)),
end the path exactly there and mark the exit with a caret plus a sentence.
Scaling the axis to it would flatten every model into a mat. The caret and the
sentence are gated on the SAME condition as the path: on a low-prevalence
cohort the reference can already be under the floor before the first plotted
threshold, and a caret then points at a line nobody drew while the sentence
names a threshold outside the axis — say "below the frame across this range"
instead.
- USEFUL RANGE. Sample the axis for stretches where some strategy clears BOTH
references by a stated margin (0.002 net benefit = one extra case per 500
patients), then bisect each edge so a band that says "4.6%" means 4.6%. Draw
every run as a tinted band with dashed edges; name the widest in the header
and the caption; say plainly when there is none.
- SCAN + CALL-OUT. One tab stop, role="slider", snapping to measured
thresholds: pointer move, arrow keys, PageUp/PageDown, Home/End. It reads
every strategy AND both references at that threshold; the legend badges the
highest. The margin only ever decides a strategy against a reference: a tie
inside it goes to the reference, because a model that only matches "treat all"
has changed nothing. Between strategies the badge goes to the strictly highest
curve — payload order breaks exact ties and nothing else, or the curve listed
first would steal the call-out from a higher one. Keyboard moves speak through an
sr-only role=status line; pointer moves do not (a live region updated on every
pointer sample is a queue nobody can listen through).
- Four first-class branches of one card: loading (skeleton with a rising model
silhouette against the sloping reference, aria-hidden, plus an sr-only
role=status line), empty (worded so it cannot be mistaken for a failed fetch,
and it still explains that the references are ready), error (Try again only
when onRetry was passed), ready. status "ready" with nothing drawable renders
the empty branch.
- CLEANUP: one ResizeObserver measuring the plot column so SVG user units are
CSS pixels, disconnected on unmount and whenever the node changes. No timers,
no rAF, no window listeners.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground panel, border gridlines,
muted skeleton, muted-foreground axis text and the treat-all line,
foreground/45 for the zero line, primary/10 fill (dark:primary/20 — primary is
a near-white ink on a dark card, and a tenth of it lands within a few sRGB
steps of the card itself, leaving the band to survive on its edges alone) and
primary/40 dashed edges for the useful band, bg-primary +
text-primary-foreground for the "leads here" badge, stroke-ring for the focus
frame, and var(--chart-1..5) cycling for the strategies. Never a hex, never a
chart token as text colour.
- COLOUR IS NEVER THE ONLY CHANNEL: each strategy carries its own dash pattern,
repeated in the legend swatch; the references have their own patterns (6 4 for
treat-all, 2 3 for zero) and are labelled in place on plots wide enough,
dropping the labels rather than overlapping when they are not.
- Y axis in net benefit with an axis step chosen as the SMALLEST nice rung that
fits the padded span (nearest-rung rounding can double the step and pad the
frame with air); x axis in percent, one tick per ~72px, plus the domain ends
labelled unless a round tick already sits there.
- PRECISION FOLLOWS THE SCALE, never a constant. Derive the readout and tick
digits from the chosen y step (one digit finer, clamped 2..6): a screening
cohort lands on a 1e-4 step, and three fixed digits would print six gridlines
all reading "0" while every readout said "0.000". Strip the sign off a
rounded-away negative so no label reads "-0".
- ACCESSIBILITY: role="group" on the svg (never role="img" — it is
children-presentational and would silence the slider), aria-labelledby the
heading and aria-describedby the summary line. An sr-only WRAPPER DIV (never
sr-only on the table itself: CSS width is only a lower bound for a table box)
holds up to 14 rank-spaced thresholds with every strategy, both references and
who leads.
- Motion: the only animation is the skeleton pulse, and it carries
motion-reduce:animate-none.
Customization levers
- The bar for "useful": MEANINGFUL_LEAD drives both the band and the call-out.
Raise it for a costly intervention (0.01 = one case per 100), lower it toward
zero for a research-grade plot that shows every crossing.
- Vocabulary: interventionLabel swaps the reference names everywhere; the
caption's "unnecessary X decisions" phrasing follows it.
- Density: height is the plot box; drop the caption paragraph and the in-place
reference labels for a dashboard tile, or raise LABELS_AT to keep them longer.
- Axis window: pass a narrower threshold sweep from the evaluation side rather
than filtering here — the band, the exit point and the y floor are all derived
from what you send, so cropping upstream keeps them consistent.
- Palette: re-point CURVE_INK at the host palette, but keep the dash ladder — it
is the channel that survives a greyscale print.
- Interaction: for a static report card, drop the slider group and keep the
band; for a shared cursor across a page of charts, lift scanIndex into a
controlled prop and emit onThresholdChange.
- Standardised net benefit: divide every value by the prevalence in the readout
formatter to read the axis as "share of the achievable benefit" — a relabel of
the same geometry, not a second component.Concepts
- The threshold probability is the reader's own trade — the x axis is not a tuning knob, it is a preference. Its odds
p_t / (1 - p_t)say how many unnecessary interventions the reader will accept to catch one more event: at 20% that is four, at 5% it is nineteen. Because the chart never picks a single threshold, two clinicians who disagree can still read the same picture and each find their own answer on it. - The references come from one number, so they cannot drift — treat-none is zero everywhere and treat-all is a line the prevalence alone determines, opening at the prevalence and crossing zero exactly where the threshold equals it. Deriving them beats accepting them: a payload that shipped its own "treat all" could quietly disagree with the prevalence printed on the same card.
- Useful is a band, not a point — a decision curve is read as an interval, so the component measures the stretches where some strategy clears both references by a stated margin, bisects the edges to earn its decimals, and prints them. A model with a higher AUC that only wins outside the range clinicians actually use has won nothing.
- A tie goes to the simple thing — inside the margin the call-out names the reference rather than the model. Net benefit differences of a thousandth are the evaluation set's rounding, and a badge that crowns a model for one is dressing rounding up as evidence. The margin only arbitrates model against reference, though: between two strategies the badge follows the higher curve, never the one the payload happened to list first.
- Clipping is honesty, not cropping — treat-all falls toward minus infinity as the threshold approaches 1, so an axis that chased it would compress every real curve into a flat mat. The card solves where the line leaves the frame, cuts it there, marks the exit with a caret and says in words that it keeps falling.
- A fixed cut-off gives itself away by its shape — a binary rule has one confusion matrix, so its net benefit moves only with the odds term; models re-select patients at every threshold and bend differently. Putting rule and model on the same axes makes "what does the score buy over the current policy" a shape you can see.
Quantile Dot Plot
A four-state quantile dot plot that turns a predictive distribution into countable outcomes — N equal-probability dots stacked into non-overlapping columns, an optional threshold that splits them, and the share on each side reported as a number you can verify by counting.
Poincaré Plot (HRV)
A four-state Poincaré return map for heart-rate variability that derives its own statistics — SD1, SD2 and their ratio measured in the 45°-rotated frame, an ellipse drawn from exactly those two numbers about the cloud's centroid, and pairs formed only from beats the recording's own clock agrees are successive.