Dense small-multiple layout
Everything on one screen, legibility traded away deliberately. It works because every cell shares one y-axis, and the moment they do not the grid becomes a wall of shapes that cannot be compared.
Tokens this needs: --muted-foreground, --border, --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.
────────────────────────────────────────────────────────────────────────
// SmallMultiples.tsx
────────────────────────────────────────────────────────────────────────
import { cn } from "@/lib/utils";
/**
* Forty panels, one instrument.
*
* Every cell is the same size, the same encoding, and the same y-scale, which
* is the one thing the component will not let a caller vary: the domain is
* computed once across every series, so a rise in one cell is a rise and not
* an autoscale. The page is grey until it isn't. A cell whose latest value
* has left the band the rest of the page sits in is drawn in the warn colour,
* and nothing else on the page is coloured at all.
*/
export type Panel = { key: string; values: number[] };
type Props = {
panels: Panel[];
unit: string;
columns?: number;
/** Which cells want a person. Defaults to: the latest value is outside
* the 5th–95th percentile of everything on the page. */
abnormal?: (panel: Panel, all: Panel[]) => boolean;
onOpen?: (panel: Panel) => void;
};
const percentile = (sorted: number[], p: number) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
const outsideBand = (panel: Panel, all: Panel[]) => {
const pool = all.flatMap((p) => p.values).sort((a, b) => a - b);
const last = panel.values[panel.values.length - 1];
return last < percentile(pool, 0.05) || last > percentile(pool, 0.95);
};
const W = 74, H = 30;
export function SmallMultiples({ panels, unit, columns = 8, abnormal = outsideBand, onOpen }: Props) {
// One domain for the page. Per-cell autoscaling would make every flat line
// look like a mountain range and the real climb look like everything else.
const all = panels.flatMap((p) => p.values);
const lo = Math.min(...all), hi = Math.max(...all);
const span = hi - lo || 1;
const n = panels[0]?.values.length ?? 1;
const x = (i: number) => 4 + (i / (n - 1)) * (W - 8);
const y = (v: number) => H - 3 - ((v - lo) / span) * (H - 6);
return (
{panels.length} panels, one scale: {lo}–{hi} {unit}