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 (
{/* The legend carries real numbers. Without them the ramp is a mood. */}
0
{SWATCH.map((cls, k) => (
{k === 4 ? `${lower(k)}+` : lower(k) || 1}
))}
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 (