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.
315 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|