Time series Recharts, with the two decisions a line hides made explicit: the interval in the header, a closed set of gap treatments, and an axis that starts at zero unless the caller asks otherwise and the panel says so. npm i recharts Tokens this needs: --card, --muted, --muted-foreground, --border, --chart-1 ──────────────────────────────────────────────────────────────────────── // TimeSeries.tsx ──────────────────────────────────────────────────────────────────────── import { CartesianGrid, Line, LineChart, ReferenceArea, Tooltip, XAxis, YAxis } from "recharts"; export type Sample = { t: number; value: number | null }; /** * Grafana's three positions for a gap, made a closed set. `never` draws the * gap; `always` draws a straight line through nothing; a threshold joins * only gaps shorter than it, which is the judgment call written down. */ export type Gaps = "never" | "always" | { joinUnderMs: number }; const hhmm = (t: number) => new Date(t).toISOString().slice(11, 16); const MIN = 60_000; /** The null runs, as [first, last] timestamps. */ const runs = (data: Sample[]) => { const out: [number, number][] = []; for (const s of data) { if (s.value !== null) continue; const last = out[out.length - 1]; if (last && s.t - last[1] <= 1.5 * (data[1].t - data[0].t)) last[1] = s.t; else out.push([s.t, s.t]); } return out; }; /** Fill the gaps shorter than the threshold by straight line, leave the rest. */ const join = (data: Sample[], underMs: number): Sample[] => { const out = data.map((s) => ({ ...s })); for (const [a, b] of runs(data)) { if (b - a + MIN > underMs) continue; const i = out.findIndex((s) => s.t === a), j = out.findIndex((s) => s.t === b); const y0 = out[i - 1]?.value, y1 = out[j + 1]?.value; if (y0 == null || y1 == null) continue; for (let k = i; k <= j; k++) out[k].value = y0 + ((y1 - y0) * (k - i + 1)) / (j - i + 2); } return out; }; /** * Time on the horizontal, value on the vertical, and the two decisions the * line would otherwise hide, stated: the interval is in the header, the gap * treatment is a prop with three positions, and the axis starts at zero * unless the caller says otherwise, in which case the panel says so too. */ export function TimeSeries({ title, interval, unit, data, gaps = "never", baseline = "zero", max, width = 400, height = 160 }: { title: string; /** The aggregation interval, as the viewer should read it: "1m", "5m", "1h". */ interval: string; unit: string; data: Sample[]; gaps?: Gaps; baseline?: "zero" | "fit"; /** Pin the top of the axis, so panels on one page can share a scale. */ max?: number; width?: number; height?: number; }) { const series = typeof gaps === "object" ? join(data, gaps.joinUnderMs) : data; const gapRuns = runs(series); // Tick every 20 minutes on the clock, not from the first sample. const step = 20 * MIN; const ticks: number[] = []; for (let t = Math.ceil(data[0].t / step) * step; t <= data[data.length - 1].t; t += step) ticks.push(t); const stroke = "hsl(var(--chart-1))"; const tick = { fontSize: 10, fill: "hsl(var(--muted-foreground))" }; return (
{title} · {interval} interval {unit}
Axis does not start at zero.
}