Heatmap A matrix where colour is the value. It only works if the ramp is monotonic in lightness and the legend carries real numbers, otherwise it is a mood board with axes. Tokens this needs: --card, --muted-foreground, --border, --scale-seq-1, --scale-seq-2, --scale-seq-3, --scale-seq-4, --scale-seq-5 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. ──────────────────────────────────────────────────────────────────────── // Heatmap.tsx ──────────────────────────────────────────────────────────────────────── /** * Bucket on both axes and encode the count as colour. Time across the bottom, * the measured value up the side, and the y buckets logarithmic, because a * latency distribution is skewed and a linear axis crushes it into one row. * * Drawn by hand: recharts has no heatmap, and the colour step per cell is the * whole point. */ const STEP = ["fill-scale-seq-1", "fill-scale-seq-2", "fill-scale-seq-3", "fill-scale-seq-4", "fill-scale-seq-5"]; const SWATCH = ["bg-scale-seq-1", "bg-scale-seq-2", "bg-scale-seq-3", "bg-scale-seq-4", "bg-scale-seq-5"]; type Props = { /** counts[column][row], row 0 the lowest bucket. Pre-bucketed upstream. */ counts: number[][]; /** The y extent in ms. Rows are spread logarithmically between the two. */ y: { min: number; max: number }; x: { from: Date; stepMs: number }; /** The ramp ends at this percentile of the non-empty cells, so one * catastrophic cell cannot take the whole ramp for itself. */ clampAt?: number; cell?: { w: number; h: number }; }; const ms = (v: number) => (v < 1000 ? `${Math.round(v)}ms` : `${Number((v / 1000).toPrecision(1))}s`); const hhmm = (d: Date) => d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: "UTC" }); export function Heatmap({ counts, y, x, clampAt = 0.99, cell = { w: 10, h: 9 } }: Props) { const cols = counts.length; const rows = counts[0].length; const ratio = Math.pow(y.max / y.min, 1 / rows); const edge = (row: number) => y.min * Math.pow(ratio, row); // Clamp first, then five equal steps in log(count). Cells above the clamp // share the top colour rather than pushing everything else into the bottom. const nonEmpty = counts.flat().filter((c) => c > 0).sort((a, b) => a - b); const clamp = nonEmpty[Math.floor(clampAt * (nonEmpty.length - 1))] ?? 1; const top = Math.log1p(clamp); const step = (c: number) => Math.min(4, Math.floor((Math.log1p(c) / top) * 5)); const lower = (k: number) => Math.ceil(Math.expm1((k / 5) * top)); const GUTTER = 44, FOOT = 18; const W = GUTTER + cols * cell.w, H = rows * cell.h + FOOT; const time = (col: number) => hhmm(new Date(x.from.getTime() + col * x.stepMs)); return (
{counts.map((column, c) => column.map((n, r) => n > 0 && ( {`${time(c)} · ${ms(edge(r))}–${ms(edge(r + 1))} · ${n} events`} ), ), )} {/* A tick every four rows, labelled with the bucket's lower edge, so the base of the log is on the page rather than in the config. */} {Array.from({ length: Math.floor(rows / 4) + 1 }, (_, i) => i * 4).map((r) => ( {ms(edge(r))} ))} {[0, Math.floor(cols / 2), cols - 1].map((c) => ( {time(c)} ))}
{/* The legend carries real numbers. Without them the ramp is a mood. */}
{SWATCH.map((cls, k) => ( ))} one cell is {Math.round(x.stepMs / 60_000)} min × one bucket (×{ratio.toFixed(2)} per row) · ramp clamped at p{Math.round(clampAt * 100)} = {clamp}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { Heatmap } from "./Heatmap"; /** * Twenty-six minutes of request latency in twenty log buckets, from the * fastest request in the window (10 ms) to the slowest (7.76 s). Two bands: * a fast path around 40 ms and a slow one that starts near 300 ms and gets * wider from left to right. Each column is one minute, lowest bucket first, * with the count of events in each cell. */ const COUNT: Record = { "0": 0, "1": 1, "2": 5, "3": 18, "4": 65, "5": 300 }; const MINUTES = [ "00024210000011100000", "00024210000011100000", "00024210000011100000", "00024210000012100000", "00024210000012100000", "00024210000012100000", "00024210000012100000", "00024210000012100000", "00024210000012110000", "00024210000013110000", "00024210000113210000", "00024210000123210000", "00024210000123210000", "00024210000123210000", "00024210000123210000", "00024210000123210000", "00024210000124210000", "00024210000124210000", "00024210000124210000", "00024210000124210000", "00024210000124210000", "00024210000124310000", "00024210000135310000", "00024210000135310000", "00024210000135310000", "00024210000135310000", ].map((column) => [...column].map((d) => COUNT[d])); export default function Demo() { return (
); }