Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartPrCurveCurve,
  ChartPrCurveData,
  ChartPrCurvePoint,
} from "./chart-pr-curve.contract"

export interface ChartPrCurveProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartPrCurveData {

Installation

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

Prompt

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.

On This Page