import { useQueries } from "@tanstack/react-query" import { useEffect, useState } from "react" import type { HistoryPoint } from "@/client" import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart" import { useLiveValue } from "@/components/Flow/liveStore" import { messageHistoryQueryOptions } from "./queries" import type { Series, 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 /** 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) } /** * 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. */ export function ChartWidget({ 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>({}) 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 = {} 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

Pick a message.

} return }