import { useQuery } from "@tanstack/react-query" import { useEffect, useState } from "react" import type { DType, HistoryPoint } from "@/client" import { Sparkline } from "@/components/Common/Sparkline" import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" import { qualify } from "./deriveEdges" import { useLiveValue } from "./liveStore" import { messageHistoryQueryOptions } from "./queries" import { ValuePreview } from "./ValuePreview" /** The same bound the server keeps, so the live tail cannot outgrow the window. */ const WINDOW = 120 /** A stretch of time in the coarsest unit that still says it. */ function span(seconds: number): string { if (seconds < 90) return `${Math.round(seconds)}s` if (seconds < 5400) return `${Math.round(seconds / 60)}m` return `${Math.round(seconds / 3600)}h` } /** * What the curve is actually showing. * * These are a ring of the last `WINDOW` values per message, not a window of * time — a message that fires twice an hour and one that fires at 10 Hz draw * the same width for wildly different spans. The readings themselves are what * says which, so the caption reads it off them rather than claiming a range. */ function caption(points: HistoryPoint[]): string { const readings = `The last ${points.length} reading${points.length === 1 ? "" : "s"}` const covered = points[points.length - 1].ts - points[0].ts return covered > 0 ? `${readings}, over ${span(covered)}.` : `${readings}.` } /** * The name only settles once typing stops. Without this, every keystroke in the * message field would ask the server for a history. */ function useSettled(value: string): string { const [settled, setSettled] = useState(value) useEffect(() => { const timer = setTimeout(() => setSettled(value), 400) return () => clearTimeout(timer) }, [value]) return settled } /** * How one message has been moving, as a sparkline with a live end. * * The panel already knows the current value, so the three silences stay * distinct: nothing has run yet, the message carries something a chart cannot * say anything about, or there is a series to draw. */ export function MessageSparkline({ flow, name, dtype, }: { flow: string name: string /** What the port declares, so a value that never arrived still says what it would be. */ dtype?: DType }) { const message = useSettled(name) const live = useLiveValue(qualify(flow, message)) const { data } = useQuery(messageHistoryQueryOptions(flow, message)) const [tail, setTail] = useState([]) // Pointing the field at another message makes the collected tail meaningless. // biome-ignore lint/correctness/useExhaustiveDependencies: the name is what invalidates the tail, not anything the effect reads. useEffect(() => { setTail((previous) => (previous.length ? [] : previous)) }, [message]) // Values keep arriving over the socket while the panel is open. Appending // them beats refetching the whole series on every emission. const ts = live?.ts const value = live?.value useEffect(() => { // A flag is recorded as 0/1, the way the server keeps it. if ((typeof value !== "number" && typeof value !== "boolean") || !ts) return const reading = Number(value) setTail((previous) => previous[previous.length - 1]?.ts === ts ? previous : [...previous, { ts, value: reading }].slice(-WINDOW), ) }, [ts, value]) const fetched = data?.points ?? [] // The fetch and the socket overlap; only what the server had not yet seen. const since = fetched[fetched.length - 1]?.ts ?? 0 const points = [ ...fetched, ...tail.filter((point) => point.ts > since), ].slice(-WINDOW) if (points.length === 0) { // Three silences, kept apart: nothing has run, or something arrived that // no curve can say anything about. The second is worth showing; the first // is worth admitting to in as few words as the row can spare. if (live === undefined) { // A dash rather than a sentence: one of these sits under every port, and // nine ports of "nothing has come through yet" is a panel of prose about // the absence of values. The row keeps its height either way, so nothing // shifts when the first one arrives. return (

—

) } return ( ) } // The value is live here, so the dot on the newest reading is earned. The // caption is on hover rather than beside the curve: these sit one per port // in a dense panel, and a line of prose each would crowd it out. return (
{caption(points)}
) }