Charts

Histogram

A four-state distribution chart that bins raw values itself — auto / fixed-count / fixed-width bins with optional percentile reference lines.

Preview in your theme

Loading preview…

"use client"

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

import {
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
} from "@/components/ui/chart"
import { cn } from "@/lib/utils"
import type { ChartHistogramBinning, ChartHistogramData } from "./chart-histogram.contract"

Installation

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

Prompt

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 binning is a rendering prop rather than part of the data.
  • Counts-sum invariant — one shared edges array 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 tablerole="img" and a one-sentence summary make the chart announceable, and because that role hides its own subtree, the per-bin numbers live in an sr-only table beside it rather than inside it.

On This Page