Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type { ChartRocCurve, ChartRocData, ChartRocPoint } from "./chart-roc.contract"

/** what a handle is standing on, handed to the consumer on every move */
export interface RocReading {
  curveId: string
  curveLabel: string
  /** position in the cleaned, fpr-ascending vertex list */
  index: number
  threshold: number

Installation

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

Prompt

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.

On This Page