ROC Curve
A four-state ROC card — one curve per model with its AUC, the chance diagonal, and a draggable, keyboard-reachable threshold handle that reads TPR, FPR and the counts behind them at every cutoff.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-roc.jsonPrompt
Build a React + TypeScript + Tailwind "ChartRoc" model-evaluation card in plain
SVG with zod. Not recharts, for two reasons that are both load-bearing: the plot
has to be an exact SQUARE (the chance diagonal only reads as 45 degrees in one,
and "how far does this curve bulge from chance" is the whole visual task), and
the plot contains FOCUSABLE marks — a children-presentational role="img"
container would silence every one of them. The layout, the axis and the
threshold handles are done by hand in small pure functions beside the schema.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
positiveLabel?: string; negativeLabel?: string;
curves: { id: string; label: string;
points: { threshold: number; fpr: number; tpr: number }[];
auc?: number; positives?: number; negatives?: number }[] }.
fpr and tpr are constrained to 0..1. This is exactly what
sklearn.metrics.roc_curve hands back, and taking it as the input is
deliberate: shipping every scored sample instead moves tens of thousands of
rows over the wire to recompute a number the evaluation job already has, and
it lets the chart disagree with the model card whenever the two round
differently.
- `points` may be empty, and empty is not absent: a model that was queued and
returned nothing keeps its legend row and says so, because a silently missing
curve reads as "we never ran it".
- `auc` is what the job computed over the FULL threshold set. The area under the
vertices you ship equals it only when you ship every threshold — so when both
exist and disagree by more than 0.005, print the reported figure and name the
gap rather than showing a number nobody else will reproduce.
- superRefine: a ready chart needs at least one curve with at least one point;
curve ids unique; thresholds unique inside a curve (one cutoff produces one
confusion matrix, so a repeat means two readings claim the same cutoff).
Guard every access — sibling refinements all run, so a ragged payload has to
produce an issue rather than a TypeError thrown out of safeParse.
- Props = z.infer of the schema plus showBest (default true), maxDots (default
40, clamped 0-400), onRetry, onThresholdChange, 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: cleanRocPoints(), closeRoc(), rocArea(),
bestThresholdIndex(), backwardSteps(), rankSample(), buildRocModel(),
rocLayout(), rocX(), rocY(), rocPathD(), nearestVertexIndex(), glyphPath(),
elide().
Behavior
- ORDER BY RATE, NOT BY THRESHOLD. Sort a copy by ascending fpr, ties upward in
tpr. The picture is a function of the rates: two cutoffs that produced the same
confusion matrix have to land on the same pixel whatever their scores were, and
ties on fpr are the vertical run a ROC grows when more thresholds only ever
catch more positives.
- CLOSE THE CURVE OUT LOUD. A ROC runs from (0,0) — call nothing positive — to
(1,1) — call everything positive. A feed that stops at fpr 0.35 has measured
PART of a curve, and its area is undefined until the rest is assumed. Draw the
closure segments dashed, at half weight, and say in words that those two
corners are geometry rather than thresholds: no handle stops there.
- AUC is a trapezoid over the polyline as drawn, because linear interpolation
between operating points is the line being painted, so the number is always the
area of the shape on screen. A coarse grid therefore reads LOWER than the true
AUC — disclose it, never correct it.
- Degenerate inputs are named, never silently swallowed:
· a rate outside 0..1, or a non-finite threshold or rate -> dropped and counted
in a visible note;
· tpr falling as fpr rises -> impossible for one ROC (both rates are monotone
in the cutoff), so count the backward steps, say so, and draw the points
EXACTLY as given rather than sorting them into a shape the data never had;
· AUC under 0.5 -> the curve is below chance; name the likely cause (flipped
label polarity or score sign) and quote what flipping would give;
· one operating point -> still a point plus its two dashed corners;
· every curve empty -> the empty branch, which repeats the drop count.
- THE THRESHOLD HANDLE IS THE PRODUCT, and a gesture is never the only path to
it. Each curve owns one role="slider" handle sitting on an operating point,
defaulting to that curve's maximum-Youden-J point.
· Pointer: the curve itself is the rail — a fat transparent stroke, because a
2px line is not a pointer target. Press anywhere on it and the handle jumps
there (a click is a zero-length drag), then follows the pointer, snapping to
the nearest vertex by pixel distance. Convert through the plot square's own
client box so the mapping survives the SVG being scaled down. Drags are
tracked on window with a pointerId guard and a buttons === 0 bail-out,
because a pointerup outside the window never arrives and pointer ids get
reused.
· Keyboard: ONE roving tab stop for the whole plot. Left/Right step the
threshold, Home/End jump to the ends, PageUp/PageDown move 5% of the
vertices (which is how you cross 2,400 cutoffs without 2,400 keystrokes),
and Up/Down switch MODEL — landing on the nearest equivalent fpr, so the
comparison stays like for like. With a single curve there is nothing to
switch to, so Up/Down step the threshold under the plain ARIA slider map.
preventDefault fires only for keys that were handled, so Tab still leaves.
· Focus is moved with .focus() at the moment of the move — the target is
already focusable at tabIndex -1 — so focus can never land on <body>.
· A legend entry is a button that activates its curve and focuses its handle:
a second, discoverable route to the same state. Curves with no points render
as plain text, never as a disabled-looking button.
- Live input precedence has one rule because there is one piece of state: each
curve remembers its own operating point, held as an override map keyed by
curve id and clamped against the current vertex count, so a changed payload
can never strand a handle out of range or leave the tab stop on nothing.
- Four first-class branches of one card: loading (a square skeleton with a
concave curve silhouette over its diagonal, aria-hidden, plus one sr-only
role=status line), empty (outlined axes and a dashed diagonal, worded so it
cannot be mistaken for a failed fetch), error (a Try again button only when
onRetry was passed), ready.
- CLEANUP: one ResizeObserver, disconnected on unmount and whenever the node
changes; the three window pointer listeners are attached per drag and removed
in the effect's cleanup. No timers, no rAF, nothing time-derived at render.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel, border for
gridlines, muted for the skeleton, muted-foreground for axis and legend text,
ring for the focus halo, card for glyph outlines and the axis readout plates,
and var(--chart-1..5) cycling for the curves. Never a hex, and never a chart
token as a 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) and its own marker shape (circle, square,
triangle, diamond, plus) — adjacent chart tokens are only about 1.3:1 apart,
so those two are what survive greyscale and a colour-blind reader. The legend
repeats both, the chance diagonal is labelled in place rather than only in the
legend, and the curve being read gets a direct label beside its handle, elided
with the full name in a title and in the sr-only table.
- The active reading is a crosshair: dashed rules from the handle to both axes
plus the two rates printed on the axes they belong to, over card-coloured
plates so they never blend into a tick label.
- Ticks at 0, .25, .5, .75, 1 on both axes, muted-foreground text-xs, gridlines
in border. Numbers use explicit "en-US" Intl.NumberFormat: three decimals on
rates and AUC, up to four on thresholds, grouped integers on counts.
- ACCESSIBILITY: do NOT put role="img" on the plot — that is
children-presentational and would silence every handle. Use role="group"
labelled by the card heading and described by the summary line, whose sr-only
tail says which axis is which and what the diagonal means. Each handle carries
aria-valuemin/max/now over the operating-point index plus an aria-valuetext
sentence: model, threshold, TPR, FPR, position in the curve, and the counts
when the class balance was supplied. A polite live region carries ONLY
pointer-driven changes, because a focused slider already announces its own
valuetext and a live region would otherwise say every reading twice. Below the
plot an sr-only WRAPPER DIV (never sr-only on the table itself: CSS width is
only a lower bound for a table box, so width:1px does not hold one back and a
narrow viewport picks up real horizontal scroll) holds a per-curve table of up
to 12 rank-spaced operating points plus the maximum-J point.
- Motion: the loading pulse carries motion-reduce:animate-none, and the handle's
transform transition is motion-reduce:transition-none and is switched off
entirely mid-drag, so the handle sits under the pointer instead of chasing it.
Customization levers
- showBest: the max-Youden-J ring is a DEFAULT, not a recommendation — J weights
a missed positive and a false alarm equally, which almost no deployment does.
Turn it off when the cost matrix is known, or re-point the ring at your own
objective (max TPR subject to FPR under a budget, or max expected value) by
swapping bestThresholdIndex — everything downstream, including the jump
button and the table's "(maximum)" mark, follows it.
- maxDots: raise it when a reader must see every measured cutoff, drop it to 0
for a bare line in a dashboard tile. Whatever it is, the disclosure follows —
there is no setting in which a vertex disappears without the card saying so.
- positives / negatives: attach the class balance and every reading gains its
counts and its precision, which is what turns "FPR 0.011" into "260 false
alarms". Leave them off for a pure rate chart.
- Density: MAX_PLOT caps the square, AXIS_LEFT / AXIS_BOTTOM the gutters; drop
the legend or the help line for a compact card. The whole plot is derived from
one measured width, so a card that changes size stays square.
- Palette: re-point CURVE_INK at the host palette, but keep the dash and glyph
ladders — they are the two channels that survive a greyscale print.
- Zoom: for the low-FPR regime, scale rocX by a partial domain (0 to 0.1) rather
than filtering points — filtering would change the drawn area, and the AUC
printed in the legend would stop matching the shape on screen.
- Interaction: onThresholdChange already carries the whole reading — wire it to
a confusion matrix, a cost calculator or a "promote this cutoff" action. The
rails are where a double-click or a context menu goes without touching the
geometry.Concepts
- Threshold sweep, not a snapshot — a confusion matrix describes one cutoff; a ROC is the whole family of them, drawn as the trade a classifier offers between catching positives and raising false alarms. Every vertex on the line is a real cutoff you could ship, which is why the handle snaps to vertices rather than sliding continuously: a position between two measured points is a confusion matrix nobody ever computed.
- The chance diagonal — a coin flip lands on the diagonal, so a curve's distance from it is the only thing on this chart that means skill. It is drawn as reference geometry with its own inline label rather than a legend entry, because a reader should never have to look away from the square to learn what the dotted line is.
- AUC is the area of the shape on screen — computed as trapezoids over the exact polyline being painted, so the number and the picture can never drift apart. That makes a coarse threshold grid read lower than the model's true AUC, which is a property of the payload and is disclosed beside the reported figure instead of being quietly patched.
- Closure is an assumption, so it is dashed — a partial evaluation that stops at fpr 0.35 has not measured a different curve, it has measured part of one. The segments that carry it to (0,0) and (1,1) are drawn at half weight, named in words, and excluded from the handle's reachable set: they are geometry, not thresholds.
- Maximum Youden J is a default, not advice — J is TPR minus FPR, the point furthest above the diagonal, and it weights a missed positive exactly like a false alarm. Almost no deployment does. It is where each handle starts because a chart has to start somewhere, and the ring is a hint rather than a recommendation.
- Rank-blind by construction — a ROC only sees the ordering of the scores, so any monotone re-scaling of a model leaves it identical. That is its strength when comparing rankers and its blind spot when the probabilities themselves have to be trustworthy, which is the question a reliability diagram answers instead.
Q-Q Plot
A four-state quantile-quantile plot that ranks the sample itself — ppoints plotting positions, a resistant quartile reference line, a pointwise order-statistic band, and tail departures named on the plot instead of left to the eye.
Confusion Matrix
A predicted × actual grid that reads as raw counts or row/column normalized shares, with per-class recall and precision margins, a hatched off-diagonal, and undefined rates kept apart from zero.