Box Plot
A four-state box plot comparing distributions side by side — R-7 quartiles, Tukey 1.5×IQR whiskers that stop on real observations, de-overlapped outliers, and raw points for groups too small for quartiles.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-boxplot.jsonPrompt
Build a React + TypeScript + Tailwind "ChartBoxplot" card on the shadcn chart
primitives (ChartContainer/ChartTooltip over recharts) with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
unit?: string;
groups: Array<
| { id: string; label: string; values: number[] } // raw path
| { id: string; label: string; summary: Summary } > // precomputed
}
Summary = { n: int >= 1; q1; median; q3; whiskerLow; whiskerHigh;
outliers?: number[] }
- Both group shapes must be accepted, because half the teams that need this
chart reduce their rows in the warehouse and never ship raw values to the
browser. Export the reduction (summarizeBoxplot(values) -> Summary) so the
server can run the *same* function; feeding values and feeding
summarizeBoxplot(values) must then draw pixel-identical geometry.
- Make both union members strict objects, so a group carrying BOTH keys fails
to parse instead of silently matching the first branch.
- Name the ends whiskerLow/whiskerHigh, never min/max. On a Tukey plot the
whisker stops at the most extreme observation inside the fence, which is not
the sample min/max whenever the group has outliers; two teams that both say
"min" ship two charts that look identical and disagree about the data.
- Refine the summary: monotone (whiskerLow <= q1 <= median <= q3 <=
whiskerHigh) and every outlier strictly outside the whiskers.
Behavior — the statistics (export them as pure functions; this is the part
that has to be right)
- quantile(sorted, p) is linear interpolation between order statistics:
pos = (n-1)*p, then a[floor] + (a[ceil]-a[floor])*(pos-floor). That is R
type 7 = numpy.percentile default = pandas .quantile = Excel QUARTILE.INC.
SAY SO on the card and in the docs: R alone ships nine quantile definitions
and they disagree — for [1,2,3,4] this rule gives Q1 = 1.75 while R type 6 /
Excel QUARTILE.EXC give 1.25 and R type 2 gives 1.5. An unnamed rule is an
unreproducible chart.
- Whiskers are Tukey's: fences at Q1 - 1.5*IQR and Q3 + 1.5*IQR, but each
whisker is drawn to the furthest ACTUAL observation still inside its fence —
never to the fence itself. Drawing to the fence is the classic bug: it
invents a data point that may sit far past anything measured, and it makes
whisker length a function of the IQR instead of of the data. A value sitting
exactly on a fence is inside it, so it is not an outlier.
- Implementation that gets this right in two walks: sort a finite-only copy,
advance `lo` while a[lo] < lowFence, retreat `hi` while a[hi] > highFence.
The inliers are one contiguous run, so the whiskers are a[lo] / a[hi] and
the outliers are everything outside — no per-point re-scan, no drift.
- Keep the whisker constant (1.5) a module constant, not a prop: a plot whose
fence rule changes per instance cannot be compared across two screenshots,
and the number has to be printed on the card either way.
- Below five observations, DO NOT draw a box. Q1 and Q3 would be interpolated
between at most two points, so the box would just be the data range wearing
a costume. Render those groups as their individual points joined by a dotted
connector, and say why in a line under the plot ("canary (n = 4), beta
(n = 2) are drawn as individual points"). For a precomputed group the points
you may legitimately place are the whisker ends and the outliers — plus the
median only when n is odd, because for even n it is the average of two
middle values and was never observed.
- A "ready" payload with nothing placeable degrades to the empty branch rather
than drawing an axis with no domain. Non-finite numbers are filtered; a
summary that arrives non-monotone (props are typed, not parsed) is sorted
into order rather than trusted, so a box can never have negative height.
Behavior — states
- Four first-class branches in one bg-card panel: loading (pulsing box
silhouettes with fixed literal heights, aria-hidden), empty (outline box
glyphs + "No groups to compare"), error (message + a "Try again" button
rendered only when onRetry exists), ready.
- Header: title, then a summary line that names the method — "4 groups · 400
values · quartiles by linear interpolation (R-7) · whiskers 1.5 × IQR · 5
outliers" — then a glyph legend (box / median / whisker / outlier).
Rendering & styling
- One floating recharts Bar with dataKey="range" per group, where range =
[groupLow, groupHigh] covers every mark including outliers, plus a custom
shape. recharts hands the shape the pixel box of that range: y is the pixel
of high, y+height the pixel of low, so interpolating inside it keeps every
mark exactly on the axis scale at any card width (measured against a
least-squares fit of the rendered axis ticks: max 0.02px once the ±0.05-unit
rounding of the printed values is accounted for).
- Value axis: domain = data ± 6%, NOT snapped outward to round numbers —
snapping [45, 580] up to [0, 800] spends 40% of the plot height on nothing.
Recover round numbers by passing an explicit `ticks` array (nice steps of
1/2/2.5/5 × 10^n, nearest rung rather than next-one-up, aiming at ~6 ticks)
and set CartesianGrid syncWithTicks — otherwise recharts adds two extra grid
lines at the raw domain edges, unlabelled rules a reader takes for round
values.
- Identical outliers must not stack: cluster them by pixel distance and spread
each cluster symmetrically around the band centre, tightening the step so it
still fits the band. Deterministic, no random jitter, and every point is
drawn — a dense cluster gets wider, never shorter. Draw them hollow
(fill var(--card), stroke the group ink) so overlaps keep their outlines.
- The median is the headline: thickest mark, painted var(--foreground), the
only mark that does not depend on hue. The box is the group ink at
fillOpacity 0.18 with a 1px stroke; a collapsed box (Q1 === Q3) keeps a 2px
hairline instead of vanishing.
- Colour is decorative here — every box is directly labelled on the axis — so
cycle var(--chart-1..5) only while the cycle stays unambiguous, and fall back
to a single token past five groups, where repeating hues would imply a
grouping that does not exist.
- Group labels: render the axis tick as a foreignObject exactly one band wide
containing a line-clamp-2 div. The browser wraps and truncates; measuring
text in JS is wrong (29% under for all-caps, 30% over for digits) and
recharts' own fallback is to drop whole labels, which here would leave a box
with no identity. At 375px with 8 groups a band is ~34px, where "us-east-1"
survives as "us-/east-1" but would truncate to "us-…".
- No animation: a custom shape animated by y/height squashes every interior
mark mid-flight, and on this chart the geometry IS the message. The skeleton
pulse still honours motion-reduce:animate-none.
- Accessibility: role="img" + a sentence-long aria-label (group count, value
range, what the marks mean, outlier count, widest group) on the chart
container, accessibilityLayer={false} on the BarChart (its focusable svg
would be an unnamed Tab stop inside a children-presentational role="img"),
and the per-group numbers in a sr-only table OUTSIDE the chart. Put sr-only
on a wrapping div, never on the table element: a table treats width:1px as a
floor and pushes real horizontal page scroll on narrow screens.
- Formatting: one Intl.NumberFormat("en-US") whose maximumFractionDigits comes
from the axis step, so integer data prints "150" and a 0.05-wide step prints
"0.35"; never Intl with an implicit locale.
Customization levers
- Whisker rule: WHISKER_K = 1.5 is one constant. Set 3.0 for Tukey's "far out"
points, or bypass the rule entirely by shipping summaries whose whiskerLow /
whiskerHigh are the plain min/max with outliers: [] — a min-max box plot,
which is legitimate as long as the caption says so.
- Small-sample threshold: MIN_BOX_N = 5 decides box vs point strip. Raise it
to 10 for a stricter house rule; lowering it below 5 is not advised.
- Density: h-[280px] and MAX_BOX_PX = 72 suit a dashboard grid — drop to
h-[180px] and 32px boxes for a compact tile, or remove the legend row and
the summary line for a bare "shape only" panel.
- Palette: boxes read var(--chart-1..5); swap the GROUP_INK list for brand
tokens, or key the ink off a group flag (var(--destructive) for groups
breaching an SLO, say) since the shape already carries the meaning.
- Extra marks: the shape gets the same toY() for everything, so a mean
diamond, a notch at median ± 1.58·IQR/√n, or a target ReferenceLine are each
a few lines on the same scale.
- Orientation: going horizontal is BarChart layout="vertical" plus exchanging
x/y inside the shape; worth it past ~8 groups, where vertical bands stop
fitting readable labels.Concepts
- Five numbers, one named rule — quartiles are linearly interpolated between order statistics (R type 7 =
numpy.percentile's default). The rule is printed on the card because it changes the answer: for[1, 2, 3, 4]it gives Q1 = 1.75 where R type 6 / ExcelQUARTILE.EXCgive 1.25. - The whisker is a data point, not a fence — the fence
Q1 − 1.5·IQRonly selects; the whisker is then drawn to the furthest observation still inside it. Drawing to the fence itself invents a reading that may sit far beyond anything measured, and makes whisker length a function of the IQR rather than of the data. - Two doors, one geometry — raw
valuesare reduced by the exportedsummarizeBoxplot, which is also what a backend ships back assummary. Both paths draw byte-identical marks, so moving the reduction server-side is a deployment decision rather than a visual one. - Small samples get told on — with
n < 5the quartiles would be interpolated between at most two points, so no box is drawn: those groups become their individual points plus a line naming them. A precomputed summary can only place the points it actually knows — whisker ends, outliers, and the median only whennis odd. - Tied outliers spread, never stack — six requests hitting the same timeout are six circles fanned symmetrically across the band, tightened to fit, deterministic and never dropped; a single dot would let the reader count one.
- Colour is the third channel — position on the axis and the direct label identify each box, so the palette only speeds up scanning; past five groups it stops cycling rather than repeating hues that would imply a grouping nobody asked for.
- Image plus table —
role="img"and one summary sentence make the plot announceable, and because that role hides its own subtree, every group's five numbers live in ansr-onlytable beside it.
Pareto Chart
A four-state Pareto chart — bars ranked high to low with a cumulative-percentage curve on a second axis, a configurable 80% line, and the crossing bar named in words.
Bump Chart
A four-state bump chart that plots position instead of value — reversed integer rows, competition ranking for ties, edge labels at both ends, and a broken line wherever an entity is off the board.