Threshold line The rule behind an alert, drawn where the data is. A number that is about to page someone should show the line it is approaching rather than leaving the reader to remember what it was. npm i recharts Tokens this needs: --card, --muted-foreground, --border, --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. ──────────────────────────────────────────────────────────────────────── // ThresholdChart.tsx ──────────────────────────────────────────────────────────────────────── import { Line, LineChart, ReferenceArea, ReferenceLine, XAxis, YAxis } from "recharts"; /** * A threshold is a claim that somebody decided what too much means, so the * decision travels with the number. A limit with no owner is the default 80 * that shipped with the panel, red for a reason nobody chose. */ export type Threshold = { value: number; /** Who set it, when, and against what. All three, or the number is decoration. */ setBy: string; on: string; reason: string; /** Three renderings, three claims. A line says "here is the limit", dashed says "this is annotation, not data", a region says "this whole zone is bad". */ style: "line" | "dashed" | "region"; /** The alert rule that uses this same number, so the two cannot drift apart. */ href?: string; }; export type Sample = { t: number; value: number }; const hhmm = (t: number) => { const d = new Date(t); return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`; }; /** Where the series crosses the limit, interpolated, so the shaded span starts at the crossing rather than at the nearest sample. */ export function breaches(data: Sample[], limit: number): [number, number][] { const out: [number, number][] = []; let start: number | null = null; for (let i = 1; i < data.length; i++) { const a = data[i - 1], b = data[i]; const cross = a.t + ((limit - a.value) / (b.value - a.value)) * (b.t - a.t); if (a.value <= limit && b.value > limit) start = cross; if (a.value > limit && b.value <= limit && start !== null) { out.push([start, cross]); start = null; } } if (start !== null) out.push([start, data[data.length - 1].t]); return out; } const SERIES = "hsl(var(--chart-1))"; const LIMIT = "hsl(var(--status-critical))"; export function ThresholdChart({ title, data, threshold, format, width = 340, height = 150 }: { title: string; data: Sample[]; threshold: Threshold; /** Renders a value the way the axis does, so the limit's label and the ticks agree. */ format: (v: number) => string; width?: number; height?: number; }) { const over = breaches(data, threshold.value); const top = Math.max(threshold.value, ...data.map((d) => d.value)) * 1.15; const label = { value: format(threshold.value), position: "insideTopRight" as const, fill: LIMIT, fontSize: 10 }; return (
{title}
{over.length > 0 && over {format(threshold.value)} for {Math.round(over.reduce((n, [a, b]) => n + b - a, 0) / 60_000)} min · } limit set by {threshold.setBy}, {threshold.on}, {threshold.reason} {threshold.href && <> · alert rule>}