Files
app/frontend/src/components/Common/UplotChart.tsx
T
stroblme b424153465 Chart cursor, range picker in the header, line smoothing
A dashboard canvas is CSS-scaled to fit its panel while uPlot maps the
pointer against its own unscaled plot width, so the cursor drifted further
right the further into a chart it went. A `cursor.move` refiner divides the
visual offset back into layout pixels; unscaled hosts get a no-op.

A querying chart's range picker moves onto the frame's title line through a
new `useHeaderSlot`, giving the plot back the row it spent. The editor's drag
handle is the header, so the picker is exempted from it.

Charts can be drawn as a monotone cubic spline — uPlot's own path builder,
monotone so a smoothed line never invents a reading between two real ones.
2026-08-23 06:27:48 +02:00

315 lines
12 KiB
TypeScript

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<HTMLDivElement>(null)
const legend = useRef<HTMLDivElement>(null)
const chart = useRef<uPlot | null>(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 (
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative min-h-0 flex-1">
<div ref={host} className="absolute inset-0" />
{points === 0 ? (
pending ? (
<Skeleton className="absolute inset-0" />
) : (
<p className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
{empty}
</p>
)
) : null}
</div>
{/* 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. */}
<div ref={legend} className="min-h-6 min-w-0 shrink-0 overflow-x-auto" />
</div>
)
}