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}

`${hhmm(Number(t))} UTC`} formatter={(v) => [`${v} ${unit}`, title]} /> {gapRuns.map(([a, b]) => ( ))}
{baseline === "fit" &&

Axis does not start at zero.

}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { TimeSeries, type Sample } from "./TimeSeries"; /** Checkout p95 in ms, one sample a minute from 14:00, and ten minutes with no samples at all. */ const T0 = Date.UTC(2026, 8, 15, 14, 0); const VALUES: (number | null)[] = [ 90, 104, 112, 98, 120, 116, 124, 110, 128, 134, 126, 140, 146, 138, 152, 150, 144, 158, 164, 156, null, null, null, null, null, null, null, null, null, null, 196, 188, 204, 174, 212, 220, 208, 241, 230, 236, 219, 248, 256, 262, 272, ]; const DATA: Sample[] = VALUES.map((value, i) => ({ t: T0 + i * 60_000, value })); export default function Demo() { return (
); }