Precision-Recall Curve
A four-state precision-recall card — one curve per model with its AUC-PR in the legend, a dashed no-skill baseline at the prevalence, and optional muted iso-F1 guide arcs.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-pr-curve.jsonPrompt
Build a React + TypeScript + Tailwind "ChartPrCurve" model-evaluation card in
plain SVG with zod. Not recharts: the plot must be an exact SQUARE (both axes
run 0..1 and "how close to the top-right corner, how far above the floor" is
the whole visual task), and the reference layers — a baseline pinned to a data
value and a family of iso-F1 arcs — are three small pure functions, not a
charting library's annotation API.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
prevalence: number (0..1);
curves: { label: string;
points: { recall: number; precision: number }[];
auc?: number }[] }.
recall and precision are constrained to 0..1. This is what
sklearn.metrics.precision_recall_curve hands back; shipping raw scores
instead would move thousands of rows over the wire to recompute numbers the
evaluation job already has.
- prevalence is REQUIRED, not optional: it is the height of the no-skill
baseline — flag at random and precision equals the prevalence at every
recall — and a PR curve without its floor is a shape with no zero.
- label is the curve's identity (no separate id): two curves with the same
label would be indistinguishable to a reader anyway. superRefine: labels
unique; a ready chart needs at least one curve with at least one point;
guard every access so a ragged payload produces an issue, not a TypeError.
- `points` may be empty, and empty is not absent: a model that was queued and
returned nothing keeps its legend row and says so.
- `auc` is AUC-PR / average precision over the FULL sweep as the job computed
it. When it differs from the area under the shipped chords by more than
0.01, print the reported figure and name the gap — AP steps between cutoffs
and covers the whole sweep, so the two legitimately disagree.
- Props = z.infer of the schema plus showIsoF1 (default true), onRetry,
className and the div's native props; forwardRef to the card.
- Export the maths beside the schema so a test can print the same numbers the
picture is made of: cleanPrPoints(), prArea(), buildPrModel(), prLayout(),
prX(), prY(), prPathD(), isoF1Precision(), isoF1PathD().
Behavior
- ORDER BY RECALL, ties downward in precision. Sort a copy — never mutate the
caller's array. A tie on recall is the vertical drop a PR curve makes when
extra thresholds only ever add false positives.
- NO CLOSURE. Unlike a ROC, a PR curve owns no free corners: precision at
recall 0 is undefined and the right end decays to the prevalence. Draw
exactly the span the payload measured and let prArea() cover only that span.
- The legend AUC-PR is the reported figure when one exists, otherwise the
trapezoid area under the polyline as drawn, suffixed "(drawn)" so nobody
quotes it as AP.
- Degenerate inputs are named, never silently swallowed: rates outside 0..1 or
non-finite -> dropped and counted in a visible note; a curve with no points
keeps its legend row reading "no operating points"; a single point renders
as a round-cap dot (a bare moveto paints nothing).
- Four first-class branches of one card: loading (a square skeleton with a
decaying-precision silhouette over its dashed baseline, aria-hidden, plus an
sr-only role=status line), empty (outlined axes + dashed floor, worded so it
cannot be mistaken for a failed fetch), 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 pointer listeners — this card is a reading, not a widget.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel, border
for gridlines, muted for the skeleton, muted-foreground for axis text and
the iso-F1 guides, foreground/45 for the baseline, and var(--chart-1..5)
cycling for the curves. Never a hex, never a chart token as text colour.
- COLOUR IS NEVER THE ONLY CHANNEL: each curve also gets its own dash pattern
(solid, 7 4, 2 3, 11 3 2 3, 1 3), repeated in the legend swatch — that is
what survives greyscale and a colour-blind reader.
- Reference geometry is quieter than data: the baseline is a dashed
foreground/45 line labelled in place ("no skill · precision 5.8%"), the
iso-F1 arcs are muted-foreground/35 dashed with tiny labels at their
precision-1 intercepts along the top edge — and both in-place labels drop
on plots narrower than ~240px instead of overlapping.
- Ticks at 0, .25, .5, .75, 1 on both axes; explicit "en-US" Intl formats:
three decimals on rates and AUC-PR, percent with one decimal on prevalence.
- ACCESSIBILITY: nothing in the plot is focusable, so role="img" on the svg is
safe — one aria-label sentence naming the models, their AUC-PR and what the
dashed floor means. Below it, 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
12 rank-spaced operating points per curve with recall, precision and F1.
- Motion: the only animation is the skeleton pulse, and it carries
motion-reduce:animate-none.
Customization levers
- showIsoF1: off for thumbnails and dashboard tiles; the help line under the
legend adjusts its wording with it. Re-tune ISO_F1_LEVELS to the bands your
team argues about (e.g. [0.3, 0.5, 0.7]).
- Baseline emphasis: the floor is the product for stakeholder decks — thicken
it or wire a short caption ("random flagging = 5.8% precision") where the
audience does not know what prevalence buys.
- Density: MAX_PLOT caps the square, AXIS_LEFT / AXIS_BOTTOM the gutters;
drop the legend and the help line for a compact card. The whole plot is
derived from one measured width, so a resizing card stays square.
- Palette: re-point CURVE_INK at the host palette, but keep the dash ladder —
it is the channel that survives a greyscale print.
- Zoom: for a high-precision regime, change prY's domain (e.g. 0.5..1) rather
than filtering points — filtering would change the drawn area, and the
"(drawn)" figure would stop matching the shape on screen. Keep the baseline
visible or state that it sits below the crop.
- Interaction: this card is deliberately static. If a threshold picker is the
product, put a role="slider" handle on the curve the way the ROC sibling
does — and drop role="img" first, because it silences focusable children.Concepts
- The floor is data, not decoration — on a ROC the no-skill reference is always the same diagonal; on a PR chart it is a horizontal line at the prevalence, so it moves with the dataset. A 40% precision that looks mediocre is seven times better than random at 5.8% prevalence, which is why the baseline is part of the contract rather than an optional annotation.
- Rare positives are the home turf — FPR divides false alarms by the sea of negatives, so at 1% prevalence a ROC can look superb while nine of every ten alarms are wrong. Precision divides by the alarms themselves, which is the number an on-call reviewer actually experiences. Same models, same holdout — the PR view is simply the one that refuses to hide the base rate.
- Iso-F1 guides make crossings decidable — PR curves of real models cross, and "which is better" then depends on where you sit. Every point on one muted arc scores the same F1, so the arcs turn the square into a contour map: whichever curve reaches the higher arc is winning at that operating regime, no arithmetic required.
- No free corners — a ROC always owns (0,0) and (1,1), so a partial curve can be closed by assumption. A PR curve owns neither: precision at recall 0 is 0/0, and the right end decays toward the prevalence. The card therefore draws exactly the span that was measured and computes the drawn area over that span alone — nothing is extrapolated on the reader's behalf.
- Reported versus drawn, said out loud — average precision steps between cutoffs and covers the full sweep; the chords under a shipped subset of points enclose something else. When both figures exist and disagree, the card prints the reported one and names the gap, so the legend never shows a number the model card would contradict.
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.
Rolling Average
A four-state rolling-average smoother: the raw noisy series stays visible as faint dots over a hairline, the trailing-window mean is drawn on top, a ±1 SD band from the same window is shaded under it, and the window size is named in the legend.