Percentile Bands
A four-state percentile fan: nested p50/p90/p99 bands over time with the median line on top, an SLO rule whose breach stretches are interpolated, counted and keyboard-reachable, and an optional log axis for a tail that dwarfs the median.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-percentile-bands.jsonPrompt
Build a React + TypeScript + Tailwind "ChartPercentileBands" card in plain SVG
with zod and a useResizeObserver hook. It draws N percentile levels of one
distribution over time as nested bands, with the lowest level (the median) as
the line on top, an optional threshold rule, and the stretches where the tracked
percentile is over that rule highlighted, counted and keyboard-reachable.
Recharts is not the right tool here even though the shape looks cartesian: the
band edges are quantiles that must be re-nested before they can be drawn, the
breach highlights start at INTERPOLATED crossings rather than at bucket edges,
and the value axis has a log mode. All of that is layout maths, so it lives in
small exported pure functions beside the component where a test can print the
same numbers the picture is made of.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; unit?: string;
levels: { p: number; label?: string }[]; // >= 2, strictly ascending
buckets: { x: number; label: string;
values: (number | null)[]; // aligned with levels BY INDEX
count?: number }[];
threshold?: { value: number; label?: string; level?: number } }
- levels are declared ONCE for the whole chart, and that is what makes this a
percentile fan rather than two unrelated bounds: p50 / p90 / p99 are three
cuts of the SAME sample, so they nest by definition.
- values are index-aligned with levels because a percentile feed arrives as a
matrix; the refinement checks the length rather than leaving it to guesswork.
null is a STATED absence — the bands touching it break open, they are not
drawn straight through the hole and it is never read as zero.
- threshold.level names which percentile is judged (default: the highest, since
an SLO is nearly always written against the tail). Breaching is strict:
equal to the threshold is not over it.
- superRefine: levels ascend strictly; every bucket's values length matches the
level count; no two buckets share an x (they would stack on one pixel column
and fight over one React key); a ready chart needs at least one reading;
threshold.level must be one of the declared levels. 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 scale ("linear" | "log", default linear),
height (default 240, clamped 140-480), maxTicks (default 5, clamped 3-8), now
(an INJECTED instant, no clock is ever read at render), formatValue, onRetry,
onBucketSelect, className and the div's native props, forwardRef to the card.
- Export the maths: prepareBuckets(), buildValueScale(), projectValue(),
usableRuns(), bandPath(), levelPath(), findBreachRuns(), stackLabels(),
columnEdges(), pickTickIndices(), bandFillOpacity(), niceStep(),
formatLevel().
Behavior
- PREPARE, and count every repair out loud under the chart. A bucket with no
usable x is dropped (it has nowhere to stand). NaN / Infinity readings become
holes. On a log axis, readings <= 0 become holes too, because a log axis has
no position for zero and inventing one lies about the data. Percentiles are
re-nested with a RUNNING MAXIMUM: p90 below p50 is arithmetically impossible
for one sample, so it is a feed defect, and drawing it produces a band of
negative height that renders as a crossed ribbon reading like an encoding.
Buckets are sorted by x on a copy, and duplicates / re-sorting / drops all
appear in a "Feed notes" line. The caller's array is never mutated.
- BANDS are drawn between consecutive levels: out along the upper percentile,
back along the lower one, one polygon per unbroken run. A run needs BOTH of
its edges, so a hole in either level splits the band rather than bridging it.
A run of a single bucket becomes a 3px column centred on its x — a zero-width
polygon paints nothing, and a bucket that exists has to be visible. Level
lines lift the pen over every hole; a reading whose neighbours are both holes
is drawn as a dot for the same reason.
- THE VALUE AXIS has two modes. Linear rounds the observed extent outwards to
whole 1 / 2 / 2.5 / 5 x 10^n steps. Log snaps to the 1 / 2 / 5 rung below and
above (snapping 1,240 up to 10,000 would spend three quarters of the height on
nothing) and thins its ticks through [1,2,5] -> [1,5] -> [1] until they fit the
target count. The threshold is included in the domain: an SLO line outside the
frame is an SLO line nobody can see. projectValue returns NaN for anything the
axis cannot place, which is how every caller learns to skip a mark instead of
drawing it on the floor.
- BREACH RUNS are the point of the card. findBreachRuns walks the tracked
percentile and returns the stretches strictly over the threshold, with ends
INTERPOLATED between buckets — the picture shows a straight segment, so the
moment it crosses the rule is a point on that segment and the highlight has to
start exactly there. The interpolation runs in PROJECTED space, so on a log
axis the edge still lands on the drawn line. A hole breaks a run (a percentile
that stopped reporting is not "still over"), and a run the window ends on is
flagged open, so the card can say "still over at the last bucket" instead of
letting it scroll off the right edge. The ledger states the count of
stretches, the count of buckets, and the x-weighted share of the window.
- INTERACTION. The pointer maps to a bucket through hit columns that each reach
halfway to their neighbours, converted with getScreenCTM().inverse() so it
stays correct while the viewBox is scaling the plot. Keyboard: ONE tab stop
for the whole plot, a roving tabindex over role="option" rects inside a
role="listbox" group. Left / Right step a bucket, Home / End jump to the ends,
Page Up / Page Down jump between BREACH STRETCHES (the finding, not every
bucket), Enter / Space fire onBucketSelect, Escape clears it — and Escape is
only intercepted when there is a handler to clear, otherwise it belongs to the
dialog this card might live in. Movement clamps and never wraps: a time axis
has two ends, and arriving back at the first bucket by pressing right on the
last one is a lie. preventDefault fires only for keys that were handled, so
Tab still leaves the chart. A GESTURE IS NEVER THE ONLY PATH: everything the
pointer can produce is one key press away.
- FOCUS IS NEVER DROPPED ON <body>: when a feed update removes the bucket the
keyboard was on, the browser silently blurs it, so the tab stop takes focus
back — and only in exactly that case (we owned focus, the index we owned is
gone, and nothing else has claimed focus since), read and written through a
ref synchronously.
- Four first-class branches of one card: loading (a deterministic skeleton band,
aria-hidden, plus one sr-only role=status line), empty (a valid contract with
no readings, worded so it cannot be mistaken for a failed fetch, naming the
levels it still expects), error (a Try again button only when onRetry was
passed), ready. A ready chart with nothing drawable renders the empty branch.
- CLEANUP: one ResizeObserver behind the hook, disconnected on unmount and on
every node swap. No timers, no rAF, no animation loop to stop.
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 tick text,
ring for the keyboard rule, var(--chart-1) for the whole fan and
var(--destructive) for the threshold, the breach hatch, the ribbon and the
peak marker. No hex, rgb, hsl or oklch anywhere; the band ramp is one hue at
several alphas (0.30 next to the median down to 0.10 at the tail) and the
legend swatches are built with color-mix(in oklab, ...) from the same token.
- ONE HUE, NEVER FIVE. The bands are nested cuts of ONE distribution; a second
hue would claim they are separate series. Identity is therefore carried by
three other channels: a direct label per level in the right gutter (stacked
apart so none overlaps, truncating with a title attribute), a distinct dash
per percentile (the median solid, the rest cycling), and text in the legend.
A legend never substitutes for a reachable label.
- The breach highlight is three marks, not one colour: a hatched column BEHIND
the bands (a highlight painted over the data would hide the readings it points
at), a solid ribbon under the axis where no band can cover it, and a triangle
at the peak of each stretch — shape survives a greyscale printout and any
colour vision deficiency.
- Axis and tick text is text-muted-foreground text-xs with tabular-nums;
gridlines are the border token. x tick labels sit in foreignObjects that
truncate and carry the full text as a title, so a long bucket name elides
instead of overflowing its slot.
- ACCESSIBILITY: do NOT put role="img" on the plot — that is
children-presentational and would silence every focusable bucket. Use
role="group" labelled by the card heading and described by an sr-only summary
that states the finding in words: how many buckets, over what span, where the
tracked percentile peaks, how many stretches breached and what share of the
window they cover. Each bucket rect carries a one-sentence aria-label with
every level's reading, the sample size and the verdict. The visible readout is
aria-hidden (a focused bucket already announces itself, and a live region
would say it twice); a separate polite role=status carries the POINTER-driven
readout, which no focus event announces. 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 bucket, every level, the sample size and
how far over the threshold it went.
- Motion: the only animation is the loading skeleton's pulse, carrying
motion-reduce:animate-none. Nothing else moves, so the chart is complete and
readable with animation off.
Customization levers
- scale: "log" is the right default for anything where the tail is an order of
magnitude above the median (latency, queue wait, payload size); "linear" is
right when the reader is going to subtract two readings by eye. Switching it
changes which readings are placeable, and the feed notes follow automatically.
- levels: two levels is a single band, five is a fan; the ramp, the legend, the
gutter labels, the dashes and the table all follow the array's length, so
adding p99.9 is one entry. Levels do not have to be percentiles of latency —
any strictly ascending quantiles of one sample work.
- threshold: drop it for a pure distribution card, point .level at p90 instead
of p99 to judge a different cut, or pass two cards side by side for a warning
and a hard limit. Everything downstream (highlight, ribbon, ledger, Page
Up/Down, table column) keys off the one object.
- bandFillOpacity: re-point the 0.30 / 0.10 ends for a denser or airier fan, or
make it a step function if you want the outermost band to read as "rare"
rather than "wide". Keep it ONE hue.
- height / maxTicks / the 92px tick pitch: the three density knobs. 140-160 with
three ticks makes a dashboard row; 320+ makes the fan the hero of a review.
- now: pass the instant the page was rendered (or the moment an alert fired) to
mark it; it is a prop precisely so the render stays deterministic and SSR-safe.
- onBucketSelect carries the PREPARED sample — re-nested, index-aligned, sorted —
which is what the picture was drawn from. Wire it to a drill-down, a trace
search or a linked table.
- A fixed domain makes two cards comparable: add it to buildValueScale, and then
decide out loud what happens to readings outside it (count and name them,
never clamp, or a band would claim a value nothing was measured at).Concepts
- Nested quantile fan — the bands are not two independent bounds, they are consecutive cuts of one sample, so they nest by definition and the picture reads as one distribution changing shape over time. That is also why the fill is one hue at several alphas: a second colour would claim p90 is a different series from p50.
- Monotonic re-nesting — a feed that reports p90 below p50 has a defect, because no sample can produce it. The readings are repaired with a running maximum and the bucket is counted in the feed notes; drawing them as sent would produce a band with negative height, which renders as a crossed ribbon that reads like an encoding of something.
- Interpolated breach edge — the highlight starts where the drawn line crosses the rule, not at the first bucket that happens to be over it. The crossing is solved in projected space, so the same code puts the edge on the line whether the axis is linear or logarithmic, and the x-weighted share of the window is a duration rather than a bucket count.
- Open run — a breach the window ends on is flagged rather than silently closed at the last bucket, because an incident that is still running is a different sentence from one that recovered, and it is the sentence an on-call reader needs first.
- Holes break bands — a null reading splits every band that needs it into separate runs instead of being bridged. A straight line across a gap is an invented measurement, and on a percentile card the gap is usually the exporter falling over during exactly the minute that mattered.
- Ratio space — the log axis exists because a tail fifty times the median flattens everything else onto the baseline. On it, equal ratios take equal distance, so a doubling reads the same at 40 ms and at 4 s; the price is that zero has no position at all, which the card discloses instead of clamping.
- One tab stop, roving index — the plot is a listbox of buckets rather than dozens of tab stops, and Page Up / Page Down jump between breach stretches so nobody has to arrow through forty-eight buckets to reach the finding.
PCA Biplot
A four-state PCA biplot that draws observation scores and variable loading arrows on one equal-aspect plane, puts explained variance in the axis titles, and states the arrow scale factor instead of hiding it.
Trace Waterfall
A four-state request waterfall — nested spans on one shared relative clock, phase segments inside each bar, self-placing duration labels, an interval-attributed critical path ruled over exactly the stretch each span was blocking, and a collapsible tree the arrow keys can walk.