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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-qq-plot.jsonPrompt
Build a React + TypeScript + Tailwind "ChartQqPlot" card in plain SVG with zod.
Recharts has no quantile-quantile primitive: the x axis is manufactured from the
ranks of the data rather than read off it, and the band is a function of rank,
not of x. Everything is done by hand in small pure functions that live beside
the schema, and there is no statistics dependency.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
unit?: string; sample?: { one: string; many: string };
reference?: "normal" | "exponential" | "uniform";
observations: { id: string; label: string; value: number;
meta?: string }[] }.
- The sample arrives RAW, never pre-quantiled. Plotting positions depend on n,
so a caller sending quantiles would have to have guessed the same convention,
and the disagreement would show up as a departure that is really just two
definitions of the same rank.
- Observations carry a label as well as a value, and that is the whole point:
this chart is read at its ends, and "which three readings drag the tail out"
is not a question a bare number can answer.
- superRefine: a ready chart needs at least one observation; ids unique (a
duplicate would collide as a React key and as the id aria-activedescendant
points at). Guard every access — sibling refinements all run, so a ragged
payload has to produce an issue rather than a TypeError out of safeParse.
- Props = z.infer of the schema plus line ("robust" | "least-squares" |
"identity", default "robust"), band ("pointwise" | "none"), level (0.9 | 0.95
| 0.99, default 0.95), height (300, clamped 200-560), dotRadius (3.2, clamped
1.5-7), maxPoints (400, clamped 24-2000), annotate (4, clamped 0-12), onRetry,
onObservationSelect, className and the div's native props; forwardRef to the
card.
- Export the maths beside the schema so it is testable and so this prompt can
describe it: normalQuantile(), referenceQuantile(), referenceDensity(),
plottingPositions(), quantile(), fitReferenceLine(), tailSizeFor(),
classifyTails(), describeShape(), buildQqModel(), niceStep(), buildAxis(),
clipLine(), rankSample().
Behavior
- THE MATHS IS THE COMPONENT.
* Sort a copy, then plotting positions (i − a)/(n + 1 − 2a) with a = 3/8 for
n <= 10 and a = 1/2 beyond — R's ppoints, the convention qqnorm uses. Not
i/n: that asks the reference for its 100th percentile, which for a normal is
+infinity, and the largest observation — the one the chart is about — would
have nowhere to be drawn.
* The reference quantile at each position. Normal needs the probit and
JavaScript has no Math.erfinv, so ship Acklam's rational approximation
(relative error < 1.15e-9, one branch for the middle and one for the tails);
exponential is −ln(1 − p); uniform is p. All three are the STANDARD member
of their family, because the line carries the location and the scale — which
is what lets the chart judge shape without being told the mean first.
* The line: "robust" runs through the first and third quartile pair (R's
qqline). That is the default on purpose — the tails are what is being
judged, so they do not get to place the line they are judged against.
"least-squares" is two-pass OLS over every pair (mean first, then
deviations: the one-pass Σz² − (Σz)²/n subtracts two large nearly equal
numbers and can come back negative, which is a NaN slope and a blank chart).
"identity" is y = x, no fitting.
* The band is pointwise, from the spread of the k-th order statistic:
half = crit · σ̂ / f(z) · sqrt(p(1 − p)/n), with σ̂ the line's own slope, f
the reference density at that quantile and crit a normal critical value
(1.645 / 1.96 / 2.576 — the band is asymptotic, so a t table would be false
precision). This is why a normal band flares at both ends and a uniform one
does not: where the reference is flat, a rank says very little about a
value.
* A point's departure is (value − expected)/half, so ±1 IS the band edge and
the number is comparable everywhere on the plot.
- THE VERDICT IS A SENTENCE, not a colour. Take the outer decile at each end
(at least 3 observations, at most half the sample), average the departures
there, and turn the pair of means into one line: heavier tails, lighter tails,
skewed left, skewed right, one tail only, or nothing to report. Every sentence
is about a tail's AVERAGE, never about every dot in it — a sample can hold one
reading outside the band with both tails sitting where they should, and saying
otherwise would contradict the count printed beside it. Below 20 observations
the sentence is withheld entirely: three readings per tail is noise wearing
the clothes of a finding.
- SAY THE EXPECTED COUNT OUT LOUD. Print "11 of 180 fall outside the 95%
pointwise band — about 9 would by chance alone" next to the verdict. Pointwise
means per point: about one in twenty is outside a 95% band even when the
sample really is from the reference, and a card that hides that turns a
correct chart into a false alarm generator.
- NOTHING IS DROPPED QUIETLY. Non-finite values cannot be ranked, so they are
filtered and COUNTED on the card ("2 of 12 carried no usable number"). Past
maxPoints the plot draws a rank-systematic sample — every k-th value of the
sorted sample, both extremes kept, plus every named departure forced in so a
name is never printed beside a circle that was not drawn — and says so. The
line, the band, the counts and the verdict always use every observation.
- REFUSALS ARE SPECIFIC. n = 1: no line (it needs two quantile pairs), no band,
no verdict, and the card names which. Zero spread: the line is flat, σ̂ is 0
and there is no band — stated, not divided by. Middle half of the sample on
one value: a quartile line would be flat while the sample plainly spreads, so
fall back to least squares and print which line was actually drawn. Departures
in the middle of the sample rather than in a tail get their own note, because
that means the LINE is wrong, not the tails.
- IDENTITY MODE SHARES ONE DOMAIN across both axes, so y = x is the diagonal of
the plot box and "above the line" is something you can see rather than
something you have to check. It is the right mode when the sample is already
on the reference's scale (p-values against U(0,1), standardised residuals
against N(0,1)) and the wrong one otherwise, because it cannot absorb a
location or scale error — which is exactly why it can catch one.
- INTERACTION. One transparent hit rect owns the pointer and finds the nearest
dot itself (a 3px circle is not a pointer target), converting through its own
client box so it stays correct when the SVG scales below its minimum width.
Click pins; the pin survives the pointer leaving. Keyboard: the plot is one
role="listbox" with tabIndex 0 and aria-activedescendant, so there is one tab
stop rather than four hundred; Tab lands on the LARGEST DEPARTURE, because on
this chart that is the finding. Left/Down and Right/Up step a rank, Home/End
jump to the two extremes, Enter/Space pins, Escape releases. preventDefault
fires only for keys that were handled, so Tab still leaves the chart.
- Live input wins in the order hover, keyboard cursor, pin.
- Four first-class branches of one card: loading (stat placeholders plus a
diagonal ribbon of dots — the silhouette the plot itself has — aria-hidden,
plus one sr-only role=status line), empty (a valid contract with nothing to
rank, worded so it cannot be mistaken for a failed fetch), error (a Try again
button only when onRetry was passed), ready. A ready chart with no rankable
observation renders the empty branch.
- CLEANUP: one ResizeObserver, disconnected on unmount and whenever the node
changes. No timers, no rAF, nothing time-derived at render — the same props
produce the same SVG on the server and on the client.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel, border for
the frame and the gridlines, muted for the skeleton, muted-foreground for axis
text and notes, ring for the focus outline, var(--chart-1) for the sample,
var(--chart-5) for departures, var(--primary) for the line and the band. The
data is a chart hue and the MODEL is --primary on purpose: --chart-N is a
five-hue ramp whose neighbours are only ~1.3:1 apart, so a reference line
painted in it can land a step away from the dots it has to be read against.
- COLOUR NEVER CARRIES THE DISTINCTION ALONE. A departure is a hollow diamond,
an ordinary reading is a filled circle, the largest departures have their name
printed beside them, and the verdict says in words what the shape is doing.
Turn the palette to one hue and the chart still reads.
- Axis ticks on the 1 / 2 / 2.5 / 5 × 10ⁿ ladder, one per ~92px so a 375px card
never grows a crowded axis, explicit "en-US" locale, compact notation past
100,000. The domain is the data plus a small margin and is never rounded
outwards — snapping [312, 704] up to [0, 800] spends half the plot on nothing.
The band may leave the plot but may not squash the data flat, so the y domain
grows at most one full data span past the sample and the band is clamped into
it; the line is clipped to the box analytically, so its endpoints stay real
numbers a test can assert on.
- Names printed beside dots are elided at 18 characters and anchored inward past
60% of the plot width, so a label never leaves the frame; the full text stays
in the readout, the option's accessible name and the table.
- ACCESSIBILITY: do NOT put role="img" on the plot — that is
children-presentational and would silence the focusable list inside it. Use
role="group" labelled by the heading and described by the summary line, which
states the finding in words: how many observations, what the line says the
mean and the spread are, how many are outside versus expected, the tail
verdict and the named departures. Each dot is a role="option" with a
one-sentence accessible name (label, value, rank, reference quantile, expected
value, departure in band widths). 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 every departure as a row. The visible readout line is
aria-hidden because aria-activedescendant already announces the active
observation; a polite live region carries only the pin, which nothing else
says.
- Motion: the only animation is the loading skeleton's pulse, carrying
motion-reduce:animate-none. Nothing else moves, so nothing else has to stop.
Customization levers
- line: the single most consequential knob. "robust" is the default because a
fitted line lets the outliers hide themselves — measured on the demo's 200
requests, least squares moves the slope from 36.5 to 53.8 ms per z, pushes 50
ordinary requests outside a 99% band and returns a clean tail verdict.
"identity" is mandatory for p-values and standardised residuals.
- reference: normal / exponential / uniform cover residuals, waiting times and
p-values. Adding a family is two pure functions — its quantile and its density
— plus a row of axis wording; log-normal is a normal reference on logged
values, so do that transform upstream rather than adding a fourth family.
- band + level: drop to "none" for a plain diagnostic, or widen to 0.99 when the
card is used for triage rather than for evidence. Whatever it is, the expected
count is printed beside the actual one.
- maxPoints + dotRadius: 2px dots and the default cap keep a 2,400-point plot
legible; raise the cap when every reading must be drawn and accept the ink.
The disclosure follows automatically — there is no configuration in which a
dot disappears without the card saying so.
- annotate: 0 for a clean figure, 8-12 when the chart is the argument and every
offender needs its name. The layout puts names left or right of their dot by
which half of the plot they are in; a leader line or a collision solver drops
in at that one call site.
- Palette: re-point SAMPLE_INK / DEPARTURE_INK / MODEL_INK. Keep the model on
--primary and keep the marks distinguishable by SHAPE, so a monochrome card
loses nothing but decoration.
- Interaction: onObservationSelect already carries the whole observation — wire
it to a trace link, a log query or a linked table. The hit rect is where a
double-click or a context menu goes without touching the geometry.Concepts
- Plotting positions — the x coordinate is manufactured, not measured. Each rank gets the reference's quantile at
(i − a)/(n + 1 − 2a), so the two axes are guaranteed to be correlated and correlation is therefore not a finding here. The only thing worth reading is the distance from the line. - Straightening the reference — a histogram or an ECDF asks the eye to judge a curve against a remembered shape; a Q-Q plot bends the reference into a straight line first, and departure from a straight line is one of the few judgements human vision is actually good at. The price is an axis nobody can read directly, which is exactly the trade to refuse when the question is "what fraction is under 500 ms".
- The resistant line — the default line runs through the quartile pair, because a least-squares line is placed partly by the observations being judged. Let three slow requests rotate it and they end up sitting neatly on their own line while the innocent middle of the sample is pushed outside the band: the outliers hide by helping.
- Pointwise, and said so — the band is the spread of one order statistic,
σ̂/f(z)·√(p(1−p)/n), evaluated rank by rank. About one point in twenty leaves a 95% band even from a perfect sample, so the card prints the expected count beside the actual one; without it, "some points are outside" reads as a verdict when it is only arithmetic. - Departure in band widths — dividing by the local half-width makes one number comparable across the plot, where a residual in milliseconds is not: the band is a hairline in the body and metres wide in the tail. It is what ranks the names printed on the chart, what Tab jumps to, and what the tail averages are taken over.
- Refusal beats a drawn guess — one observation gets no line, a sample with no spread gets no band, a sample of ten gets no tail verdict, and every one of those says which thing is missing and why. A chart that answers "is this normal" from three readings is worse than a chart that declines.
Pictogram
A four-state ISOTYPE unit chart: one row per category, icons repeated at a stated one-icon-equals-N scale, the last icon clipped to the leftover instead of shrunk, and unusable rows dropped and counted in view.
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.