Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type { ChartEcdfData, ChartEcdfGroup } from "./chart-ecdf.contract"

export interface ChartEcdfProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartEcdfData {
  /**
   * Percent levels drawn as horizontal guides, e.g. `[0.5, 0.9]` (the default).
   *
   * Levels are clamped to the open interval (0, 1) — 0 and 1 are the axis ends

Installation

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

Prompt

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.

On This Page