import { useEffect, useLayoutEffect, useRef } from "react" import uPlot from "uplot" import "uplot/dist/uPlot.min.css" // After uPlot's own sheet, and beside this component rather than in the // dashboard chunk: a chart on Health or Home is styled without a dashboard // having been visited first. import "./uplot.css" import type { HistoryPoint } from "@/client" import { useTheme } from "@/components/theme-provider" import { Skeleton } from "@/components/ui/skeleton" import { si } from "@/lib/utils" /** * How many lines one chart carries. * * The bound is the palette's: `--chart-1…5` is one designed ramp, and a sixth * line would either repeat a step or invent a colour outside the system. */ export const MAX_SERIES = 5 /** Room for the axis ticks; uPlot measures the rest of the box itself. */ const PADDING: uPlot.Padding = [10, 12, 0, 0] const canvasHeight = (element: HTMLElement) => Math.max(60, element.clientHeight || 180) /** A token, resolved for the canvas — which cannot read CSS variables. */ function token(name: string): string { return getComputedStyle(document.documentElement) .getPropertyValue(name) .trim() } const seriesColor = (index: number) => token(`--chart-${(index % 5) + 1}`) /** * Tick labels that stay distinct. * * `si` shortens to three significant figures, which is what a curve spanning * decades wants and the opposite of what one wobbling inside a degree does — * 18.9 and 19.1 both read "19", and the axis says nothing at all. When the * short labels would repeat, the tick spacing decides the decimals instead. */ function tickLabels(ticks: number[]): string[] { const short = ticks.map((value) => si(value)) if (new Set(short).size === short.length) return short const step = Math.abs(ticks[1] - ticks[0]) || 1 const decimals = Math.min(6, Math.max(0, Math.ceil(-Math.log10(step)))) return ticks.map((value) => value.toFixed(decimals)) } /** The series joined onto one x axis, which is what uPlot draws. */ function table(plots: HistoryPoint[][]): uPlot.AlignedData { return uPlot.join( plots.map( (plot) => [ plot.map((point) => point.ts), plot.map((point) => point.value), ] as uPlot.AlignedData, ), ) } /** * Several series over time, drawn on one axis. * * uPlot rather than SVG: a chart may hold five series of hundreds of points * each, which is more path data than React should be rebuilding on every value * that arrives. Its own legend doubles as the hover readout, so the cursor * tells you what each line was worth at that moment — and with more than one * line a legend is required anyway. * * `onCursor` / `onSelect` report where the pointer is in the data, so a page * can tie a list to the chart without reaching into the uPlot instance. */ export function UplotChart({ labels, plots, empty = "Nothing has come through yet.", pending = false, unit, yRange, yLabel, smooth = false, onCursor, onSelect, }: { /** One label per series; the set of them is the chart's identity. */ labels: string[] /** The points of each series, in the same order as `labels`. */ plots: HistoryPoint[][] empty?: string /** No readings yet because none have arrived: a chart with nothing in it * says "nothing has run", which is a different thing to say. */ pending?: boolean /** Written after every reading, on the axis and in the legend. */ unit?: string /** A y axis fixed to these bounds; unset lets it follow the data. */ yRange?: [number, number] /** A named y axis; the unit stays on the ticks. */ yLabel?: string /** Draw the lines as a monotone cubic spline rather than straight segments. * Monotone rather than plain cubic on purpose: a spline that overshoots * invents readings between two the sensor actually took. */ smooth?: boolean /** The x value under the pointer, and null once it leaves the plot. */ onCursor?: (ts: number | null) => void /** The x value clicked, or null for a click that landed on no point. */ onSelect?: (ts: number | null) => void }) { const host = useRef(null) const legend = useRef(null) const chart = useRef(null) // The chart outlives a render, so its handlers are read through a ref // rather than baked into the config it was built with. const report = useRef({ onCursor, onSelect }) const { resolvedTheme } = useTheme() useEffect(() => { report.current = { onCursor, onSelect } }) const points = plots.reduce((total, plot) => total + plot.length, 0) // The identity of the series set: the chart is rebuilt when it changes, // while a new reading only sets its data. The unit, the fixed range and the // axis title are part of it — all are baked into the axes at build time. const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}|${yLabel ?? ""}|${smooth}` // uPlot leaves its axes half-initialised while the scales have no range, and // a resize in that window (a card still settling, say) draws them anyway and // throws. Waiting for the first reading avoids the state altogether. const ready = points > 0 // biome-ignore lint/correctness/useExhaustiveDependencies: the label string is the identity of the series set. useLayoutEffect(() => { const element = host.current if (!element || labels.length === 0 || !ready) return const axis = { stroke: () => token("--muted-foreground"), grid: { stroke: () => token("--border"), width: 1 }, ticks: { stroke: () => token("--border"), width: 1 }, font: `11px ${getComputedStyle(element).fontFamily}`, } /** The x value the page was last told about, so a move within one bucket * does not re-render it. */ let told: number | null = null // One builder for the whole chart: it is a factory, and the series only // need the function it returns. const spline = smooth ? { paths: uPlot.paths.spline?.() } : {} const under = (self: uPlot) => self.cursor.idx == null ? null : Number(self.data[0][self.cursor.idx]) const plot = new uPlot( { width: element.clientWidth || 320, height: canvasHeight(element), padding: PADDING, cursor: { y: false, // A dashboard canvas is CSS-scaled to fit its panel, and uPlot maps // the pointer with `clientX - rect.left` — visual pixels — against // its own unscaled plot width. On a scaled panel the cursor then // drifts further right the further in it goes. Dividing by the ratio // the element is actually drawn at puts it back in layout pixels; // unscaled, the ratio is 1 and this is a no-op. move: (self, left, top) => { const drawn = self.rect.width / self.over.clientWidth return drawn > 0 && drawn !== 1 ? [left / drawn, top / drawn] : [left, top] }, }, legend: { live: true, // Mounted in its own row under the plot rather than inside it: a // legend that wraps then takes height from the chart instead of // spilling past the bottom of the card. mount: (_self, element) => legend.current?.appendChild(element), }, hooks: { setCursor: [ (self) => { const ts = under(self) if (ts === told) return told = ts report.current.onCursor?.(ts) }, ], ready: [ (self) => { self.over.addEventListener("click", () => report.current.onSelect?.(under(self)), ) }, ], }, scales: { x: { time: true }, ...(yRange ? { y: { range: yRange } } : {}), }, axes: [ { ...axis, size: 28 }, { ...axis, label: yLabel, // uPlot's default label font is a hardcoded bold sans-serif, which // would ignore the app's face. labelFont: axis.font, // The unit is written after every tick, so the gutter widens to // hold it rather than clipping the number in front of it. size: unit ? 62 : 46, // The gutter has a fixed width, so a grouped "15,000" would be // clipped to something that reads as a different number entirely. values: (_self: uPlot, ticks: number[]) => tickLabels(ticks).map((label) => unit ? `${label} ${unit}` : label, ), }, ], series: [ {}, ...labels.map((label, index) => ({ label, width: 2, // Read at draw time, so a theme toggle is a redraw rather than a // rebuilt chart. stroke: () => seriesColor(index), // The cursor readout is what decides how wide the legend gets, so // it is shortened here; a named unit is short enough to keep. Four // figures rather than three: this is the number someone is pointing // at to read, and 18.97 rounded to "19" is not an answer. value: (_self: uPlot, raw: number) => unit ? `${si(raw, 4)} ${unit}` : si(raw, 4), // Series arrive on their own clocks; a joined table is mostly // holes, and a line with a hole per point is not a line. spanGaps: true, points: { show: false }, ...spline, })), ], }, // Built with the readings it already has: uPlot's axes are only half // initialised while its scales have no range. table(plots), element, ) chart.current = plot const observer = new ResizeObserver(() => { plot.setSize({ width: element.clientWidth, height: canvasHeight(element), }) }) observer.observe(element) return () => { observer.disconnect() plot.destroy() // The legend was moved out of the plot's root, so destroying it leaves // the table behind. legend.current?.replaceChildren() chart.current = null } }, [key, ready]) // Every render, deliberately. `plots` is rebuilt on every render in both // consumers, so no memo can hit, and any signature short of hashing shares // the blind spot a point count has: once a rolling window is full, a refetch // carrying different readings leaves the count where it was and never fires. // Safe to run this often because `setData` is idempotent and re-ranges the // scales *from the data* — the opposite of the `redraw(false)` below. useEffect(() => { if (!chart.current || plots.length === 0) return chart.current.setData(table(plots)) }) // The canvas cannot follow a CSS variable, so a theme swap is a redraw. The // paths are geometry and stay as they are — and leaving them alone is what // keeps this safe on the mount it also fires on: a full redraw re-sets the x // scale from the chart's own bounds, which are still empty when the chart was // built in this same commit (uPlot ranges its scales in a microtask). That // overwrites the range it was about to take from the data, and the chart is // left with no x axis and no lines until something sets its data again. // biome-ignore lint/correctness/useExhaustiveDependencies: the theme is the signal, not something the effect reads. useEffect(() => { chart.current?.redraw(false) }, [resolvedTheme]) return (
{points === 0 ? ( pending ? ( ) : (

{empty}

) ) : null}
{/* Kept at the legend's resting height, so the plot does not resize under the pointer the first time a reading arrives. uPlot mounts a table here and a table cannot lay out below its min-content width, so the labels get their own scroller rather than widening the card. */}
) }