Anomaly band A band saying what normal looked like, so a spike is read against an expectation rather than against a reader's memory. The band has to say how it was computed, because a model nobody can inspect is an opinion drawn as a fact. npm i recharts Tokens this needs: --card, --muted, --muted-foreground, --border, --popover, --status-critical, --chart-1 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. ──────────────────────────────────────────────────────────────────────── // AnomalyBand.tsx ──────────────────────────────────────────────────────────────────────── import { Area, ComposedChart, Line, Tooltip, XAxis, YAxis } from "recharts"; export type Point = { /** The x label, "13:00". */ t: string; value: number; /** What normal looks like at this moment: the band's floor and ceiling. */ expected: [lo: number, hi: number]; }; /** * The band has to say how it was computed. A model nobody can inspect is an * opinion drawn as a fact, so the method and its history are required, and * they print under the chart rather than living in a tooltip. */ export type Method = { name: string; history: string }; const BAND = "hsl(var(--muted))"; const SERIES = "hsl(var(--chart-1))"; const ALARM = "hsl(var(--status-critical))"; const outside = (p: Point) => p.value < p.expected[0] || p.value > p.expected[1]; export function AnomalyBand({ title, points, method, width = 400, height = 180 }: { title: string; points: Point[]; method: Method; width?: number; height?: number; }) { /** * Two lines over one series. The normal line is null wherever the value * leaves the band; the alarm line is null wherever it stays in. Each keeps * the boundary point on either side so the two meet instead of gapping. * Colour appears only where the line leaves the band, and nowhere else. */ const data = points.map((p, i) => { const out = outside(p); const nearOut = out || outside(points[i - 1] ?? p) || outside(points[i + 1] ?? p); return { ...p, normal: out ? null : p.value, alarm: nearOut ? p.value : null }; }); const alarms = points.filter(outside).length; return (
{title}
Band: {method.name}, from {method.history}. {alarms > 0 && ` ${alarms} of ${points.length} points outside it.`}