Histogram
A four-state distribution chart that bins raw values itself — auto / fixed-count / fixed-width bins with optional percentile reference lines.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-histogram.jsonPrompt
Build a React + TypeScript + Tailwind "ChartHistogram" 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;
values: number[]; // RAW observations, not aggregated rows
unit?: string; // e.g. "ms"
binning?: { mode: "auto" }
| { mode: "count"; binCount: 2..200 }
| { mode: "width"; binWidth: > 0 };
markers?: number[] } // percentiles 0..100, e.g. [50, 95, 99]
- Component props = z.infer of the schema plus onRetry?: () => void and
className. No hand-written parallel interface.
- This is what separates a histogram from a bar chart: it is fed one number
per event and derives the bars itself. Never make the caller pre-bin.
Behavior — binning (export it as a pure buildHistogram(values, binning,
percentiles) so it can be unit-tested and reused)
- Sort a finite-only copy of values once; non-finite input is dropped, and
the sample size reported to the reader is that finite count.
- "auto" = Freedman-Diaconis width h = 2 * IQR / n^(1/3), bin count
ceil(range / h). When the IQR collapses (heavily tied data) h is 0 or
infinite, so fall back to Sturges, ceil(log2 n) + 1.
- "count" = k equal-width bins over [min, max]; "width" = fixed w with the
first edge snapped down to floor(min / w) * w, so edges are round numbers.
- Zero variance (min === max) expands the range to [min - 0.5, max + 0.5],
numpy's rule, and uses 3 bins so the whole spike sits in the middle one
instead of half a chart.
- Clamp the bin count to [2, 200]. One bin is not a distribution, and a
lone bar has no neighbour to size itself against on a numeric axis. A
binWidth that would exceed 200 bins is widened rather than honoured.
- Correctness invariant, worth a test: the counts MUST sum to the number of
finite inputs. Build one shared edges array (edges[i] = lo + i * w, with
edges[k] pinned to the exact hi), then walk the SORTED values with a
forward-only cursor: `while (cursor < k - 1 && v >= edges[cursor + 1])
cursor++`. Every value is counted exactly once, bins are left-closed /
right-open, the last bin is closed on the right, and — because the
comparison uses the very edges the labels are printed from — a value
like 0.3 with w = 0.1 can never drift into the neighbouring bin.
- Percentiles are linear-interpolated (R type 7) over the sorted values;
sanitize the marker list by clamping to 0..100, deduping, sorting and
capping at ~5 lines.
Behavior — states and interaction
- Four first-class branches inside one bg-card panel: loading (pulsing
bell-shaped skeleton bars with fixed literal heights, aria-hidden),
empty (outline bars + "No observations yet"), error (message + a "Try
again" button rendered only when onRetry exists), ready (chart).
A "ready" payload whose values are all unusable degrades to empty rather
than drawing an axis with no domain.
- Header: title, then a summary line "1,200 values · 24 bins × 25 ms ·
freedman-diaconis" — naming the rule tells the reader why the bars are
that wide.
- One chip per percentile showing "p95 · 506 ms"; chips are real
<button type="button" aria-pressed> toggles that hide/show their own
reference line.
Rendering & styling
- ONE numeric XAxis (type="number", dataKey = bin centre, explicit
domain [lo, hi]). Bars keyed on the centre tile the domain, and
ReferenceLine x={percentile} lands on its exact value on the same scale.
Do not reach for a second hidden axis: recharts silently drops a
ReferenceLine bound to an xAxisId that no graphical item uses.
- barCategoryGap "4%" keeps bars nearly touching (histogram convention,
as opposed to the airy gaps of a categorical bar chart); radius
[2,2,0,0]; compact YAxis with allowDecimals={false}.
- Give the chart ~20px right margin: the last tick sits on the domain
maximum and a centred label would be clipped by the svg edge.
- Colors: bars use one chart token via the ChartConfig (var(--color-count)).
On a monochrome chart palette pick the mid token, not chart-1 — measured
on this theme chart-1 is 1.48:1 against a white card (invisible) while
chart-2 is 4.74:1 light / 4.18:1 dark.
- Reference lines are var(--foreground) with a different strokeDasharray
per marker; their labels are stacked one row apart (12 + i * 15 px from
the plot top) so coincident percentiles never collide, and they carry a
var(--card) halo via stroke + paintOrder="stroke" because a marker can
land on top of a tall bar.
- Motion: recharts grows bars on mount and never consults the OS. Read
prefers-reduced-motion with useSyncExternalStore (server fallback false)
and pass isAnimationActive={!reduced}; the skeleton gets
motion-reduce:animate-none.
- Accessibility: put role="img" plus a sentence-long aria-label (n, range,
bin size, median, tallest bin) on the chart container, and render the
per-bin numbers as a sr-only <table> OUTSIDE it — role="img" makes its
own children presentational, so data nested inside would be unreadable.
- Formatting: one Intl.NumberFormat("en-US") whose maximumFractionDigits is
derived from the bin width, so integer data prints "150" and 0.1-wide
bins print "0.3"; never Intl with an implicit locale.
Customization levers
- Binning UI: binning is a prop, so a parent can expose a "bins" slider or
an auto/10/25/50 segmented control and pass it straight through; the
component recomputes on prop change, no internal mode state to fight.
- Markers: drop `markers` for a plain distribution, or pass [25, 50, 75]
for a quartile view; the chip row disappears when the array is empty.
- Density: h-[250px] and px-6 suit a dashboard grid — tighten to h-[160px],
drop the YAxis and the chips for a compact "shape only" tile.
- Palette: bars read var(--color-count) from the ChartConfig, so re-theming
is one token; to flag a region instead, swap in <Cell> per bin (e.g. bins
above the SLO in var(--destructive)).
- Cumulative view: the same bins support an ogive — feed a running total
into a <Line> on a second yAxisId and keep the percentile lines.Concepts
- Bins are derived, not given — every other bar chart takes pre-aggregated rows; this one takes the raw observations and owns the bin layout, which is why
binningis a rendering prop rather than part of the data. - Counts-sum invariant — one shared
edgesarray plus a forward-only cursor over sorted values guarantees each observation is counted exactly once: bins are left-closed / right-open, the last bin closed, so a value sitting exactly on an edge is never double-counted or dropped. - Freedman–Diaconis with a Sturges escape hatch — FD picks the width from the IQR, which is robust to outliers but degenerates on heavily tied data (IQR 0 ⇒ infinite bins); Sturges takes over there, and the header names whichever rule ran.
- Degenerate-range widening — a zero-variance sample would collapse the scale, so the range expands to ±0.5 (numpy's rule) and three bins park the spike in the middle one.
- Percentiles as reference lines — p50/p95/p99 are interpolated from the sorted values, not from the bins, so they stay exact no matter how coarse the binning is; each chip toggles its own line.
- Numeric axis, not a band of labels — bars are keyed on their bin centre over an explicit
[lo, hi]domain, so the x axis is a real number line and a percentile line lands on its value instead of snapping to the nearest bin. - Image plus table —
role="img"and a one-sentence summary make the chart announceable, and because that role hides its own subtree, the per-bin numbers live in ansr-onlytable beside it rather than inside it.
Candlestick Chart
A four-state OHLC candlestick chart: hollow-up / solid-down bodies drawn as a custom recharts shape, with doji and limit-locked sessions kept visible as a 2px line.
100% Stacked Bar Chart
A four-state 100% stacked bar chart whose segments are apportioned with largest-remainder rounding, so every bar's percentages add up to exactly 100.