Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartQqPlotData,
  ChartQqPlotObservation,
  ChartQqPlotReference,
} from "./chart-qq-plot.contract"

/** how the reference line is placed through the quantile pairs */
export type ChartQqPlotLine = "robust" | "least-squares" | "identity"
/** two-sided coverage of the pointwise band */

Installation

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

Prompt

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.

On This Page