Files
app/frontend/src/components/Dashboard/ChartWidget.tsx
T
stroblmeandClaude Opus 5 680c6053b9 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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZeGnqVsf5VHQqvz4HdUhN
2026-08-23 06:27:48 +02:00

292 lines
10 KiB
TypeScript

import { useQueries } from "@tanstack/react-query"
import { useEffect, useRef, useState } from "react"
import type { HistoryPoint } from "@/client"
import {
DEFAULT_RANGE,
RANGES,
type Range,
RangePicker,
} from "@/components/Common/RangePicker"
import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
import { useLiveValue } from "@/components/Flow/liveStore"
import { messageHistoryQueryOptions, usePublishMessage } from "./queries"
import { type Series, useHeaderSlot, type WidgetProps } from "./widgets"
// The five-series ceiling is the chart host's, and the panel reads it here.
export { MAX_SERIES }
/** The window a chart draws, unless it asks for more. */
const DEFAULT_POINTS = 300
/**
* How often a chart asks again, for the window it is showing.
*
* A window is drawn in buckets, and nothing the chart can show changes until
* the bucket it is drawing closes — so the resolution sets the pace. A week at
* quarter-hour buckets asks four times an hour instead of twice a minute, for
* the same picture. A slower refresh than that is honoured; a faster one only
* buys the same answer again, so it is clamped.
*/
export const refreshFor = (range: Range, configured: unknown) =>
Math.max(range.bucketS, Number(configured) || 0)
/** How long an unanswered request blocks an identical one. */
const INFLIGHT_MS = 15_000
/**
* Requests waiting for an answer, across every chart in this tab.
*
* Two charts on the same node, or two panels of one dashboard, would otherwise
* ask the same question at the same moment and make the flow run twice.
*
* ponytail: per-tab. Two browsers still duplicate a query — an `interval` on
* the request port is what stops that, and it belongs to the flow that serves
* it rather than to the widget asking.
*/
const inflight = new Map<string, number>()
/** What a data flow answers with: the lines, and what they were computed for. */
type SeriesPayload = {
range_s?: number
interval_s?: number
lines: { label: string; points: [number, number][] }[]
}
function asPayload(value: unknown): SeriesPayload | null {
const payload = value as SeriesPayload | null
return payload && Array.isArray(payload.lines) ? payload : null
}
const config = (widget: WidgetProps["widget"]) =>
(widget.config ?? {}) as Record<string, unknown>
/** The unit, the fixed axis and the line shape, in either mode. */
function presentation(cfg: Record<string, unknown>) {
const low = Number(cfg.y_min)
const high = Number(cfg.y_max)
return {
unit: cfg.unit ? String(cfg.unit) : undefined,
yLabel: cfg.y_label ? String(cfg.y_label) : undefined,
smooth: Boolean(cfg.smooth),
yRange:
Number.isFinite(low) && Number.isFinite(high) && low < high
? ([low, high] as [number, number])
: undefined,
}
}
/** The points a chart actually plots: the stored past, then the live tail. */
function merge(fetched: HistoryPoint[], tail: HistoryPoint[], cap: number) {
const since = fetched[fetched.length - 1]?.ts ?? 0
return [...fetched, ...tail.filter((point) => point.ts > since)].slice(-cap)
}
/**
* A chart, drawn from whichever source it was pointed at.
*
* The two modes are separate components rather than branches: each calls a
* different set of hooks, and React counts them.
*/
export function ChartWidget(props: WidgetProps) {
return config(props.widget).source === "query" ? (
<QueryChart {...props} />
) : (
<LiveChart {...props} />
)
}
/**
* Several messages over time, drawn on one axis.
*
* uPlot rather than SVG: a chart may hold five series of up to `HISTORY_CAP`
* 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.
*/
function LiveChart({ widget }: WidgetProps) {
// Read inline rather than through `widgets.tsx`: that module renders this
// one, and a runtime import back would close the circle.
const series = (((widget.config ?? {}) as { series?: Series[] }).series ?? [])
.filter((entry): entry is Series & { message: string } =>
Boolean(entry.message),
)
.slice(0, MAX_SERIES)
const names = series.map((entry) => entry.message)
const cap = Math.max(
2,
Number(
(widget.config as { history?: { points?: number } })?.history?.points,
) || DEFAULT_POINTS,
)
const histories = useQueries({
queries: names.map((name) => messageHistoryQueryOptions(name)),
})
// A fixed set: hooks cannot be called in a loop, and five is the ceiling.
const live = [
useLiveValue(names[0]),
useLiveValue(names[1]),
useLiveValue(names[2]),
useLiveValue(names[3]),
useLiveValue(names[4]),
]
// What arrived over the socket since the history was fetched. Appending beats
// refetching the whole series every time a value lands.
const [tails, setTails] = useState<Record<string, HistoryPoint[]>>({})
const key = names.join(" ")
const stamps = live.map((value) => value?.ts ?? 0).join(",")
// biome-ignore lint/correctness/useExhaustiveDependencies: the timestamps are what makes a tail longer; `live` is a fresh array every render.
useEffect(() => {
setTails((previous) => {
const next: Record<string, HistoryPoint[]> = {}
let changed = false
names.forEach((name, index) => {
const point = live[index]
const kept = previous[name] ?? []
const ts = point?.ts
if (
typeof point?.value !== "number" ||
!ts ||
kept[kept.length - 1]?.ts === ts
) {
next[name] = kept
return
}
next[name] = [...kept, { ts, value: point.value }].slice(-cap)
changed = true
})
// Dropping a series is a change too, even without a new reading.
return changed || Object.keys(previous).length !== names.length
? next
: previous
})
}, [key, stamps, cap])
const plots = names.map((name, index) =>
merge(
(histories[index]?.data?.points ?? []) as HistoryPoint[],
tails[name] ?? [],
cap,
),
)
const labels = series.map((entry, index) => entry.label || names[index])
if (names.length === 0) {
return <p className="text-sm text-muted-foreground">Pick a message.</p>
}
return (
<UplotChart
labels={labels}
plots={plots}
{...presentation(widget.config as Record<string, unknown>)}
/>
)
}
/**
* A chart that asks for what it draws.
*
* It publishes a request — the window and the resolution it wants — exactly as
* a slider publishes a value, and draws the `series` a flow answers with. What
* serves that request is the flow's business: a database node with a function
* either side of it, and the widget never learns which database it was.
*
* The answer says what it was computed for, and one computed for a different
* window is ignored rather than drawn. Two charts on one node therefore cost a
* duplicate query instead of overwriting each other's picture.
*/
function QueryChart({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const request = String(cfg.request ?? "")
const message = String(cfg.message ?? "")
const [range, setRange] = useState<Range>(
() =>
RANGES.find((r) => r.hours * 3600 === Number(cfg.range_s)) ??
DEFAULT_RANGE,
)
const rangeS = range.hours * 3600
const intervalS = range.bucketS
// Follows the window: a wider one is drawn coarser, so it is worth asking
// about less often.
const refreshS = refreshFor(range, cfg.refresh_s)
const publish = usePublishMessage()
const live = useLiveValue(message || undefined)
const [answer, setAnswer] = useState<SeriesPayload | null>(null)
// The request is a value like any other, so it goes out the way a control's
// does — and the engine type-checks it against the port that declared it.
const ask = useRef<() => void>(() => {})
ask.current = () => {
if (!request) return
const key = `${request}|${rangeS}|${intervalS}`
const since = inflight.get(key)
if (since !== undefined && Date.now() - since < INFLIGHT_MS) return
inflight.set(key, Date.now())
publish.mutate({
name: request,
value: { range_s: rangeS, interval_s: intervalS },
dashboard,
widget: widget.id,
label: widget.title || widget.id,
kind: widget.type,
})
}
// Ask now, then keep asking. A new window tears the timer down and asks
// again at once rather than waiting out the old one.
// biome-ignore lint/correctness/useExhaustiveDependencies: the window is read through the ref, but changing it is exactly what must restart the timer.
useEffect(() => {
if (!request) return
ask.current()
const timer = setInterval(() => ask.current(), refreshS * 1000)
return () => clearInterval(timer)
}, [request, rangeS, intervalS, refreshS])
// The window changed, so what is on screen is no longer an answer to it.
// biome-ignore lint/correctness/useExhaustiveDependencies: the window is the signal; clearing is not derived from the answer.
useEffect(() => setAnswer(null), [rangeS, intervalS, message])
// Keep only an answer computed for what we asked. Anything else belongs to
// another chart's window and would draw the wrong picture under our label.
useEffect(() => {
const payload = asPayload(live?.value)
if (!payload) return
if (payload.range_s !== rangeS || payload.interval_s !== intervalS) return
setAnswer(payload)
inflight.delete(`${request}|${rangeS}|${intervalS}`)
// The store hands out a fresh object per value, so its identity is the
// signal that something arrived — no timestamp needed beside it.
}, [live?.value, request, rangeS, intervalS])
// Drawn on the title's line rather than above the plot: a tile is short, and
// a row of its own costs the chart about a tenth of its height. Nothing to
// pick a window for until the chart is wired, so a half-configured tile says
// that and nothing else.
const bound = Boolean(request && message)
useHeaderSlot(
bound ? <RangePicker value={range} onChange={setRange} /> : null,
[range, bound],
)
if (!request || !message) {
return <p className="text-sm text-muted-foreground">Pick a message.</p>
}
const lines = (answer?.lines ?? []).slice(0, MAX_SERIES)
return (
<UplotChart
labels={lines.map((line, index) => line.label || `Series ${index + 1}`)}
plots={lines.map((line) =>
(line.points ?? []).map(([ts, value]) => ({ ts, value })),
)}
empty="Waiting for an answer."
{...presentation(cfg)}
/>
)
}