Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartDecisionCurveData,
  ChartDecisionCurvePoint,
  ChartDecisionCurveStrategy,
} from "./chart-decision-curve.contract"

export interface ChartDecisionCurveProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartDecisionCurveData {

Installation

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

Prompt

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.

On This Page