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}

{/* Quiet and grey. No outline: an edge would read as a limit. */} {/* Hover gives the range as numbers, so "outside" can be checked. */} { const p = item.payload as Point; return name === "expected" ? [`${p.expected[0]}–${p.expected[1]}`, "expected"] : [String(v), "actual"]; }} />

Band: {method.name}, from {method.history}. {alarms > 0 && ` ${alarms} of ${points.length} points outside it.`}

); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { AnomalyBand, type Point } from "./AnomalyBand"; /** * Twenty-four hours of requests per second. The band is ±220 at 03:00 and * ±80 at midday because the metric's own rhythm is, and three evening points * leave it. Everything else is wobble the band already allows for. */ const HOURS: [center: number, half: number, actual: number][] = [ [520, 190, 500], [470, 200, 450], [430, 210, 410], [410, 220, 430], [430, 210, 400], [500, 190, 520], [620, 170, 600], [800, 140, 830], [1000, 120, 980], [1180, 100, 1200], [1300, 90, 1290], [1380, 80, 1400], [1420, 80, 1410], [1400, 80, 1380], [1340, 90, 1360], [1240, 100, 1220], [1120, 110, 1140], [1000, 120, 990], [900, 130, 920], [820, 140, 870], [750, 150, 1260], [690, 160, 1320], [630, 170, 900], [570, 180, 640], ]; const POINTS: Point[] = HOURS.map(([center, half, actual], h) => ({ t: `${String(h).padStart(2, "0")}:00`, value: actual, expected: [center - half, center + half], })); export default function Demo() { return ( ); }