Histogram A distribution, not a time series with the axis relabelled. The bucket width is the whole argument: too wide and two humps merge, too narrow and noise looks like structure. npx shadcn@latest add toggle-group npm i recharts Tokens this needs: --card, --muted, --muted-foreground, --border, --chart-1, --status-warn The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // Histogram.tsx ──────────────────────────────────────────────────────────────────────── import { Bar, BarChart, ReferenceLine, XAxis, YAxis } from "recharts"; /** * The bucket count is the whole argument, so it is a required prop rather * than a default someone forgot. Too few and two humps merge; too many and * the shape dissolves into noise. Neither failure announces itself. */ export type Bin = { lo: number; hi: number; count: number }; export function bin(samples: number[], buckets: number, min: number, max: number): Bin[] { const width = (max - min) / buckets; const bins: Bin[] = Array.from({ length: buckets }, (_, i) => ({ lo: min + i * width, hi: min + (i + 1) * width, count: 0 })); for (const v of samples) { // The top edge is inclusive, so a sample at exactly `max` lands in the // last bin instead of falling off the chart. const i = Math.min(buckets - 1, Math.floor((v - min) / width)); if (i >= 0) bins[i].count++; } return bins; } /** * A spike against the right edge is a timeout truncating the tail: * everything past it was recorded as exactly `max`. Flagged when the last * bin holds several times what the bins before it do. */ export function wall(bins: Bin[]): Bin | null { const last = bins[bins.length - 1]; const before = bins.slice(-6, -1); const mean = before.reduce((n, b) => n + b.count, 0) / before.length; return last.count > 0 && last.count > 4 * mean ? last : null; } const fmt = (v: number) => (v === 0 ? "0" : v % 1000 === 0 ? `${v / 1000}s` : `${v}ms`); export function Histogram({ samples, buckets, min = 0, max, width = 300, height = 150 }: { /** Raw values, in ms. The component bins them, so changing `buckets` rebins the same population. */ samples: number[]; buckets: number; min?: number; /** The top of the range. Required, because a histogram cut at the data's max hides the wall. */ max: number; width?: number; height?: number; }) { const bins = bin(samples, buckets, min, max); const cut = wall(bins); const step = (max - min) / buckets; const data = bins.map((b) => ({ mid: b.lo + step / 2, count: b.count })); return (
{cut && }

{buckets} buckets of {fmt(Math.round(step))} · {samples.length.toLocaleString("en-GB")} requests {cut && · {cut.count} recorded as exactly {fmt(max)}, which is a timeout, not a mode}

); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Histogram } from "./Histogram"; /** * Six thousand checkout requests. The collector stored them in forty fixed * 50ms buckets, so the samples are rebuilt at each bucket's midpoint; the * 401 that hit the two-second timeout were recorded as exactly 2s. Eight * buckets merge the fast and slow paths into one hump and bury the wall. */ const PER_BUCKET = [ 375, 1865, 920, 240, 60, 21, 39, 60, 88, 103, 146, 190, 188, 164, 164, 162, 159, 108, 95, 74, 66, 68, 53, 51, 26, 32, 21, 11, 10, 13, 6, 5, 5, 3, 3, 2, 3, 0, 0, 0, ]; const TIMED_OUT = 401; const SAMPLES = [ ...PER_BUCKET.flatMap((n, i) => Array.from({ length: n }, () => i * 50 + 25)), ...Array.from({ length: TIMED_OUT }, () => 2000), ]; export default function Demo() { const [buckets, setBuckets] = useState(40); return (
v && setBuckets(Number(v))} className="mt-3 justify-start gap-1" aria-label="bucket count"> {[8, 20, 40].map((n) => ( {n} buckets ))}
); }