ECDF
A four-state empirical CDF that bins nothing — one step curve per group on a shared percent axis, percentiles read off the steps themselves, named crossings between curves, a keyboard-driven scan line and a vertex cap that states its own error.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-ecdf.jsonPrompt
Build a React + TypeScript + Tailwind "ChartEcdf" card in plain SVG with zod.
Recharts has a step line, but not a step whose riser height is a tie count, not
an inverse-CDF marker that lands ON the curve, and not a scan line that reads
every series at one x — so the maths, the axis and the label fitting are small
pure functions beside the schema. No new dependency, no d3.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
unit?: string; measure?: string; sample?: { one: string; many: string };
groups: { id: string; label: string; values: number[] }[] }.
- `values` is bare numbers in any order, and that is deliberate: the share at or
below a value is a property of the whole group, not of a row you can point at.
The moment the question becomes "which run was that", the chart is a beeswarm,
whose contract carries an id per observation because it has to.
- `values` may be empty, and empty is not absent: a group that was measured and
produced nothing keeps its legend row and says so, because a silently missing
row reads as "nobody asked" rather than "we asked and got nothing".
- superRefine: a ready chart needs at least one finite reading somewhere; group
ids unique (a duplicate collides as a React key and as the key the readout and
the table look a group up by). Guard every access — sibling refinements all
run, so a ragged payload has to produce an issue rather than a TypeError
thrown out of safeParse, which the caller cannot catch as a validation error.
- Props = z.infer of the schema plus guides (default [0.5, 0.9]; levels outside
(0, 1) dropped, deduped, sorted, capped at four), height (240, clamped
140-560), maxVertices (600, clamped 40-4000), showObservations (default
"auto", also "always" | "never"), onRetry, onScanChange, className and the
div's native props, forwardRef to the card.
- Export the maths beside the schema so a test can print the same numbers the
picture is made of: buildEcdfSteps(), ecdfAt(), inverseEcdf(), thinSteps(),
maxDrawnGap(), normalizeGuides(), crossingsBetween(), buildEcdfModel(),
nearestStop(), niceStep(), buildScale(), shareTicks(), anchorLevels(),
stepPath(), readAt(), elide().
Behavior
- THE STAIRCASE IS THE ARGUMENT. Sort a copy (never mutate the caller's array),
collapse equal readings into one step carrying its count, and set that step's
share to j/n where j is the number at or below it. F is right-continuous: a
reading sitting exactly on x is counted in. There is no bin width, no
bandwidth and no smoothing parameter anywhere — a histogram of the same sample
can be made to look bimodal or flat by choosing bins, and this cannot, so two
cards built from the same readings cannot disagree.
- TIES ARE A RISER, NEVER A JITTER. Twelve identical readings are a visible
twelve-unit riser; spreading them out would invent precision the instrument
never had.
- PERCENTILES ARE READ OFF THE STEP, not interpolated. inverseEcdf finds the
smallest reading whose share reaches the level — the inverse of the drawn
curve. R-7 (what numpy, pandas and this library's box plot return) sits
between two order statistics, so on an ECDF its p90 is a value nobody measured
and a marker that floats beside the line it claims to read. Carry the attained
share back with the value and print it (0.914 for a p90 on a sample of 35),
because "p90 = 812 ms" without "91.4% at or below" rounds a fact into a claim.
- CROSSINGS ARE THE FINDING. Walk the union of drawn values, take the sign of
F_a(x) − F_b(x) for every pair, and record where it flips (ties count as
neither side, so a shared flat run is not two crossings; cap at four per
pair). "Faster at the median, slower at the ninetieth" is a crossing, and a
pair of box plots reports it as two unrelated numbers. No crossing anywhere is
itself the stronger statement: one curve dominates the other at every value.
- NOTHING IS EVER CLIPPED OR QUIETLY CAPPED. Past maxVertices distinct readings
a curve draws every k-th step with both ends kept — indices are
round(k·(g−1)/(m−1)), rank-systematic over the STEPS so the long thin tail
stays long and thin (keeping the first N would delete exactly the part a
cumulative curve is read for), and strictly increasing while m is under g, so
a vertex is never repeated. The card then names which groups were thinned and
prints how far
the drawn curve can sit below the exact one, MEASURED from the vertices kept
(max over i of share_i − count_i/n − share_(i−1)), not asserted from the
ratio. Percentiles, extremes and every scan reading always use the whole
sample, never a sample of a sample.
- NON-FINITE READINGS ARE DROPPED AND COUNTED, per group and for the card. Props
are not always parsed, and a NaN absorbed into a denominator would move every
share on the chart by an amount nobody can see.
- THE VALUE AXIS is the observed extent plus a 5% margin, deliberately not
rounded outwards: snapping [45, 580] up to [0, 800] spends 40% of the plot on
empty space and squashes every curve into the left third. Round numbers come
back as an explicit tick list instead — the 1 / 2 / 2.5 / 5 × 10^n ladder,
NEAREST rung rather than the next one up, one tick per ~96px so a 375px card
never grows a crowded axis, explicit "en-US" locale, compact notation past
100,000. That margin also buys the flat run-in at 0% and run-out at 100%
enough width to read as flat, which is how you see where a group starts and
where it is done. A dashed rule marks zero whenever the domain straddles it. A
sample with no spread at all still gets an axis to sit on.
- THE PERCENT AXIS is fixed at 0-100% — that is what makes two cards
comparable — with quarter gridlines when the plot is at least 200px tall and
halves when it is not.
- EVERY CURVE CARRIES ITS OWN NAME. Group i of k is labelled at share
(i+1)/(k+1), so the labels are vertically separated by construction and no
collision pass is needed; the point (F⁻¹(level), level) always lies on that
curve's riser, so the name sits ON the line rather than near it. A level
landing within 0.04 of a guide is nudged clear, because a name printed on a
dashed rule reads as a caption for the rule. Labels elide with the full string
in a <title>, never overflow.
- THE SCAN LINE is one tab stop, not one per curve: role="slider" over the plot,
snapping to the distinct drawn values ("stops"). It rests at the middle stop
so the card ships with a live reading instead of an empty readout nobody
triggers. Keyboard: Left/Down step back, Right/Up step forward, PageDown and
PageUp jump a tenth of the stops, Home/End go to the extremes; preventDefault
fires only for keys that were handled, so Tab still leaves the chart. Pointer
moves pick the nearest stop, converting through the hit rect's OWN client box
so it stays correct when the SVG scales down below its minimum width. A
GESTURE IS NEVER THE ONLY PATH: everything the pointer can do, the keys do.
Keyboard moves announce into a polite live region; pointer moves do not,
because a live region updated on every pointer sample is a queue nobody can
listen through. Both call onScanChange with the whole cross-group reading.
- FOUR FIRST-CLASS BRANCHES of one card: loading (a deterministic monotone
skeleton staircase, 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, and saying what an ECDF needs: raw readings, not bucket counts or a
percentile table), error (a Try again button only when onRetry was passed),
ready. A ready payload with nothing drawable renders the empty branch rather
than an axis with no curves.
- ONE-SHOT FOCUS RESCUE: the retry button unmounts the instant the branch
changes, and focus left on <body> loses the reader's place. A ref written when
the button is pressed and read synchronously in the branch effect parks focus
on the card instead — one press, one rescue, no timer.
- SSR-STABLE AND DETERMINISTIC: no Math.random, no Date.now, no jitter. Path
coordinates round to 0.01px so the string is byte-identical between server and
client. Before the ResizeObserver reports, geometry is computed at a fallback
width and the viewBox matches it, so an unmeasured chart is drawn at natural
size rather than stretched.
- CLEANUP: one ResizeObserver, disconnected on unmount and whenever the observed
node changes. No timers, no rAF, nothing else to cancel.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel, border for
the frame and gridlines, muted for the skeleton, muted-foreground for axis
text, guides and notes, foreground for the scan line, ring for the focus
outline, var(--chart-1..5) cycling for the curves. Never a chart token as text
colour; axis and tick text is text-muted-foreground at text-xs.
- COLOUR IS NEVER THE ONLY DIFFERENCE. Every curve also gets its own dash
pattern, a marker where it meets each guide, a dot at its label anchor and its
name printed on the line. Past five groups the ramp repeats, which is
survivable precisely because the printed name was always the identity. The
legend rows repeat each curve's dash swatch, so a legend is a summary and
never the only way to tell two curves apart.
- Guide rules are dashed and labelled "p50" / "p90" in place; the marker where a
guide meets a curve is a diamond in that curve's ink with a card-coloured
stroke, so two curves crossing at the same level still read as two.
- showObservations dots each step corner: "auto" does it for groups of at most
40 distinct readings, where the steps ARE the data and a smooth line would be
a lie; past that it turns the staircase into a smear.
- ACCESSIBILITY: do NOT put role="img" on the plot — that is
children-presentational and would silence the slider inside it. Use
role="group" labelled by the card heading and described by the summary line,
which states the finding in words: how many groups, how many readings, where
the medians span, whether the curves cross, how many readings were dropped.
The slider carries aria-valuemin/max/now plus aria-valuetext in real units.
The visible readout is aria-hidden — the slider announces where the scan is
and the live region announces what it means, so a third copy would say the
same move three times. 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 n, the lowest reading, each percentile with its
attained share, the highest reading and the share at the current scan value,
per group — plus a caption stating the quantile convention, because a reader
comparing this card to a box plot deserves to know why the medians differ.
- 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
- guides: the percent levels that get a dashed rule and a per-curve marker.
[0.5] for a plain median reference, [0.5, 0.9, 0.99] for an SLO review, [] for
a bare distribution. Levels outside (0, 1) are dropped (0% and 100% are the
axis ends), duplicates collapse, and four is the cap because a fifth dashed
rule stops being a reference and becomes a grid.
- height: 140-180 for a dashboard tile (the percent axis drops to halves by
itself), 300-400 when the crossing region is the point and you need the
vertical resolution to see it.
- maxVertices: raise it when every distinct reading must have its own vertex and
you accept the path length; drop it for a sparkline-sized card. Whatever it
is, the disclosure follows automatically — there is no configuration in which
a vertex disappears without the card printing the resulting error.
- showObservations: "always" for small samples where each dot is a run somebody
can name; "never" for a print figure where the steps alone read cleaner.
- Palette: re-point CURVE_INK to one token for a monochrome figure and let
CURVE_DASH carry the whole distinction, or key the ink off a status
("control" / "treatment") rather than the group index when colour should mean
something. Keep the scan line on --foreground either way.
- Axis: a fixed domain makes two cards comparable — add it as a prop, and then
decide out loud what happens to readings outside it (count and name them,
never clamp, or a step would claim a value nothing was ever measured at).
TICK_PITCH trades tick density against crowding at 375px.
- Wording: `measure` and `sample` drive every sentence on the card ("share of
requests at or below each response time"), so a card about salaries never says
"readings". `unit` is suffixed to every printed figure.
- Interaction: onScanChange carries the whole cross-group reading — wire it to a
linked table, a threshold picker or a "what would this SLO cost" panel. The
hit rect is where a click-to-pin or a context menu goes without touching the
geometry.Concepts
- No bin width — the curve is the sample, not a summary of it. A histogram of the same readings can be made to look bimodal or flat by choosing bins, and a density curve by choosing a bandwidth; an ECDF has no such parameter, so two people plotting the same numbers get the same picture and the reader has nothing to second-guess.
- Ties are risers — equal readings collapse into one step whose height is their count, so forty runs that all landed on 250 ms are one full-height riser you can count rather than forty invisible ones or a smear of jitter. Height means "how many", and it is the only thing that does.
- Percentile off the step, not between two — the marker is the smallest reading whose share reaches the level, which is a value somebody actually measured and a point that is genuinely on the line. Interpolated quantiles such as R-7 sit between two order statistics, so their marker floats beside the curve it claims to read; the card carries the share that level actually attained ("812 ms, 91.4% at or below" rather than a bare "p90 = 812 ms"), so the rounding stays visible instead of hardening into a claim.
- Crossings, and dominance when there are none — the sign of the gap between two curves flips exactly where one group stops being ahead, which is how "faster at the median, slower at the ninetieth" becomes one labelled value instead of two unrelated numbers. When it never flips, one curve is to the right of the other everywhere: the whole distribution moved, not just its middle.
- Disclosed thinning — past the vertex cap a curve draws every k-th distinct reading with both ends kept, and the card prints how far that can put it below the exact curve, measured from the vertices actually kept. Percentiles, extremes and every scan reading still come from the whole sample, so the number of vertices on screen never changes a number on the card.
- One scan, two ways in — pointer and keyboard both drive the same slider to the same snapped stops, so the gesture is a convenience rather than the only path. Arrow keys announce into a polite live region and pointer moves stay silent, which keeps a screen reader from queueing one message per pointer sample.
UpSet Plot
A four-state UpSet plot for set overlaps a Venn cannot draw — a dots-and-lines membership matrix under sorted size bars, per-set totals on the left, and disjoint columns that add up instead of double-counting.
Wind Rose
A compass rose of directional frequencies, where each sampled direction owns its wedge, stacks its magnitude classes from the hub outward, and is read against labelled percent-of-total rings with calm counted in the hub.