import { useEffect, useLayoutEffect, useRef, useState } 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 /** * The ramp's slots, as a palette names them. * * The whole set one may draw from — five, for the same reason `MAX_SERIES` is * five, because they are the same five. */ export const CHART_SLOTS = Array.from({ length: MAX_SERIES }, (_, index) => String(index + 1), ) /** No palette named; a chart left with this spreads itself. */ export const NO_PALETTE: string[] = [] /** * What a chart of *n* lines draws with when no palette was named: the ramp * spread, rather than its first *n* steps. * * Three lines used to take slots 1, 2 and 3, which are adjacent steps of a * ramp that carries identity by lightness alone — at a 2px stroke they are * close to one picture. The ends and the middle are as far apart as five slots * allow, and the same reasoning gives every other count. * * This moved after the palette shipped, and moving it was safe *because* of * how the palette is stored: a named palette is honoured exactly as written, * so no stored document means anything different than it did. Only the * unwritten default moved, and any dashboard that dislikes where it moved to * can now name the old one. That inertness is the property worth keeping — * not this table, which is a design decision and may move again. */ const SPREADS: Record = { 1: ["1"], 2: ["1", "5"], 3: ["1", "3", "5"], 4: ["1", "2", "4", "5"], 5: ["1", "2", "3", "4", "5"], } /** The slots a chart of `count` lines draws with, named palette or not. */ export function slotsFor(count: number, palette?: string[]): string[] { if (palette?.length) return palette return SPREADS[Math.min(Math.max(Math.trunc(count) || 1, 1), MAX_SERIES)] } /** * What the pointer correction reads off a chart. * * Structural rather than `uPlot` itself, so the check beside this file can * hand it a plain object. */ type Painted = { rect: { left: number; top: number; width: number } over: { clientWidth: number } } /** * A pointer event, put back into the panel's own pixels. * * A dashboard canvas is drawn at its panel's own pixel size and CSS-scaled to * fit the screen, while uPlot works in layout pixels throughout: it takes * `clientX - rect.left` — visual pixels — and measures it against its own * unscaled plot width. The cursor drifts further the deeper into a scaled * chart it goes, and on a panel scaled *up* it does worse than drift. Past * `1 / drawn` of the way across, the visual offset has passed the layout plot * width, and uPlot's own edge snap (`cacheMouse`, uPlot.esm.js:5776) rounds it * to that width outright: the readout stops advancing partway across and * sticks to the last point. * * Correcting the event, before uPlot has done any arithmetic with it, is what * makes that whole chain come out right — the snap included. It is also * stateless, so it cannot be applied twice. `cursor.move`, the other seam, is * handed its own output back and re-run on every redraw, so anything refined * there can only stay right by recognising its own last answer. * * `drawn` is the ratio the element is painted at; unscaled it is 1 and this is * a no-op. One ratio for both axes: the panel is scaled uniformly. */ export function inLayoutPixels( self: Painted, event: E, ): E { const { rect } = self const drawn = rect.width / self.over.clientWidth if (!(drawn > 0) || drawn === 1) return event return new Proxy(event, { get(target, key) { if (key === "clientX") return rect.left + (event.clientX - rect.left) / drawn if (key === "clientY") return rect.top + (event.clientY - rect.top) / drawn const value = Reflect.get(target, key) // The event's own methods still need the event as their receiver. return typeof value === "function" ? value.bind(target) : value }, }) } /** * uPlot's own listener filters — `filtBtn0` and `filtTarg` — with the pointer * corrected on the way through. Only the three events that carry a position * are wrapped; the others read no coordinates. */ const binder = (button: boolean) => ( self: Painted, target: object, handle: (event: MouseEvent) => void, onlyTarget = true, ) => (event: MouseEvent) => { if (button && event.button !== 0) return if (onlyTarget && event.target !== target) return handle(inLayoutPixels(self, event)) } /** The cursor every chart is built with; exported so the check can drive it. */ export const CURSOR: uPlot.Cursor = { y: false, // uPlot's shipped types drop the binder's fourth `onlyTarg` argument, which // it does pass — the document-wide mouseup binding depends on it. bind: { mousedown: binder(true), mouseup: binder(true), mousemove: binder(false), } as unknown as uPlot.Cursor.Bind, drag: { // Drag across the plot to read a stretch of it closer. `setScale` stays // off because the chart owns its x range itself: `setData` runs on every // render and would re-range the scales from the data, so a held window is // what tells it to leave them alone. Without that this only ever flashed a // selection box over a live chart, which is why it used to be off. x: true, y: false, setScale: false, }, } /** Below this a drag is a click that moved, not a window. In pixels. */ const DRAG_FLOOR = 4 /** How close two taps have to be to count as one gesture. */ const DOUBLE_TAP_MS = 300 /** 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. * * Read off the chart's own element rather than the document: a dashboard * states its colours on the canvas it is drawn on, and custom properties * inherit, so a tile inside one has to be asked where it stands. Asking the * root would draw the shell's axes on the dashboard's chart. */ function token(name: string, from: Element): string { return getComputedStyle(from).getPropertyValue(name).trim() } /** * What one line is drawn in. * * A palette entry is one of three things: a slot of the app's own ramp — the * shape a dashboard stored before it could name colours — a token, for a line * that means something the ramp has no step for, or a colour, which is what a * dashboard palette writes today. A colour is used as it stands; the other two * are resolved here, since the canvas cannot read a custom property. */ const seriesColor = (index: number, slots: string[], from: Element) => { const slot = slots[index % slots.length] if (CHART_SLOTS.includes(slot)) return token(`--chart-${slot}`, from) return slot.startsWith("--") ? token(slot, from) : slot } /** * 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, palette, smooth = false, xTime = true, 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 /** The ramp slots this chart's lines take, in order — the dashboard's own * palette — colours, or slots of the app's own ramp. Unset, the chart * spreads itself across the ramp by how many lines it draws, which is what one outside a dashboard (Health, Home) * always does. */ palette?: 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 axis reads as time. False when x is a count rather than a moment — * a run's metric is indexed by step, and drawn as time it would date every * point to 1970. */ xTime?: 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) // A dragged x window, held so the next reading does not wash it away. The // ref is what the data effect reads; the state is only what draws the way // back out, and the two are set together. const zoomed = useRef(false) const [showReset, setShowReset] = useState(false) const clearZoom = () => { zoomed.current = false setShowReset(false) } // 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 — // and so are the line shape and the palette, which the series close over. const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}|${yLabel ?? ""}|${smooth}|${palette?.join("") ?? ""}|${xTime}` // 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 // A different set of series is a different picture; the window that was // held over the old one means nothing on it. clearZoom() const axis = { stroke: () => token("--muted-foreground", element), grid: { stroke: () => token("--border", element), width: 1 }, ticks: { stroke: () => token("--border", element), 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 /** Whether the click about to arrive is the end of a drag. */ let dragging = false // Resolved once for the whole chart: how many lines there are is part of // which slots they take, when nothing named them. const slots = slotsFor(labels.length, palette) // 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: CURSOR, 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) }, ], setSelect: [ (self) => { // uPlot fires this for a plain click too. A few pixels is a // slip of the hand, not a window anybody meant to ask for. if (self.select.width <= DRAG_FLOOR) return const from = self.posToVal(self.select.left, "x") const to = self.posToVal( self.select.left + self.select.width, "x", ) // The box has done its job; the scale is what holds the window // from here. `false` so this hook does not fire on itself. self.setSelect({ left: 0, width: 0, top: 0, height: 0 }, false) self.setScale("x", { min: from, max: to }) dragging = true zoomed.current = true setShowReset(true) }, ], ready: [ (self) => { self.over.addEventListener("click", () => { // The mouseup that ended a drag arrives here as a click as // well; pinning a moment is not what it was asking for. if (dragging) { dragging = false return } report.current.onSelect?.(under(self)) }) self.over.addEventListener("dblclick", clearZoom) // ponytail: a touch screen gets no dblclick from every browser, // and uPlot has no dbltap of its own. Two taps in a moment is // the whole of the gesture. let lastTap = 0 self.over.addEventListener("pointerup", (event) => { if (event.pointerType !== "touch") return const now = event.timeStamp if (now - lastTap < DOUBLE_TAP_MS) clearZoom() lastTap = now }) }, ], }, scales: { x: { time: xTime }, ...(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, slots, element), // 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. That // re-ranging is exactly what a dragged window has to be spared, so while one // is held the data goes in and the scales stay where they were put. Clearing // the window renders, which brings the next pass through here with the reset // back on: that is what puts the whole range back. useEffect(() => { if (!chart.current || plots.length === 0) return chart.current.setData(table(plots), !zoomed.current) }) // 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 (
{/* Double-clicking does the same thing, but nothing says so. */} {showReset ? ( ) : null} {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. */}
) }