Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"

import { type ChartConfig, ChartContainer, ChartTooltip } from "@/components/ui/chart"
import { cn } from "@/lib/utils"
import type {
  ChartBoxplotData,
  ChartBoxplotGroup,
  ChartBoxplotSummary,
} from "./chart-boxplot.contract"

export interface ChartBoxplotProps extends ChartBoxplotData {

Installation

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

Prompt

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 / Excel QUARTILE.EXC give 1.25.
  • The whisker is a data point, not a fence — the fence Q1 − 1.5·IQR only 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 values are reduced by the exported summarizeBoxplot, which is also what a backend ships back as summary. 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 < 5 the 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 when n is 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 tablerole="img" and one summary sentence make the plot announceable, and because that role hides its own subtree, every group's five numbers live in an sr-only table beside it.

On This Page