Sparkline every message a node names

A number in the panel says where a value is, not where it has been. Each
named port in Consumes and Provides now carries a sparkline under it,
fetched once on open and extended from the socket as values arrive, held
to the same 120-point window the server keeps.

Both lists render through the shared port row, so a sink node shows what
flows through it rather than nothing. A flat series draws its mid line
and drops the redundant range label, a lone reading is just its dot, and
a series spanning decades switches to a log scale with the labels left
in real units.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkmeRiyeYmVZqJVwuyHq9o
This commit is contained in:
Melvin Strobl
2026-08-15 21:59:30 +02:00
co-authored by Claude Opus 5
parent 0154bf3537
commit e3b3d234ce
3 changed files with 260 additions and 33 deletions
@@ -0,0 +1,210 @@
import { useQuery } from "@tanstack/react-query"
import { useEffect, useId, useState } from "react"
import type { HistoryPoint } from "@/client"
import { qualify } from "./deriveEdges"
import { useLiveValue } from "./liveStore"
import { messageHistoryQueryOptions } from "./queries"
/** The same bound the server keeps, so the live tail cannot outgrow the window. */
const WINDOW = 120
/** Room above and below the curve for the stroke, in viewBox units. */
const PAD = 8
/** Above this ratio a linear series is all baseline and one spike. */
const LOG_RATIO = 100
/** Short enough for a label, precise enough to tell two of them apart. */
function compact(value: number): string {
const size = Math.abs(value)
if (size === 0) return "0"
if (size >= 1e6 || size < 1e-2) return value.toExponential(1)
return String(Number(value.toPrecision(4)))
}
/** How a value that cannot be plotted still reads. */
function describe(value: unknown): string {
if (typeof value === "string") return value
return JSON.stringify(value) ?? String(value)
}
/**
* 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
}
/**
* Curve and area for a series, in a 0100 box.
*
* The awkward series are the point: one reading has no line to draw, a series
* that never moved has no span to divide by, and one spanning decades is only
* legible once the exponent is what varies.
*/
function shape(points: HistoryPoint[]) {
const values = points.map((point) => point.value)
const low = Math.min(...values)
const high = Math.max(...values)
// Logs need every reading on the same side of zero.
const logged = low > 0 && high / low >= LOG_RATIO
const project = (value: number) => (logged ? Math.log10(value) : value)
const floor = project(low)
const span = project(high) - floor
const y = (value: number) =>
span === 0
? 50
: 100 - PAD - ((project(value) - floor) / span) * (100 - 2 * PAD)
const x = (index: number) =>
points.length === 1 ? 100 : (index / (points.length - 1)) * 100
const line = points
.map(
(point, index) =>
`${index ? "L" : "M"}${x(index).toFixed(2)},${y(point.value).toFixed(2)}`,
)
.join(" ")
return {
low,
high,
line,
area: `${line} L100,100 L0,100 Z`,
end: y(values[values.length - 1]),
}
}
/**
* 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,
}: {
flow: string
name: string
}) {
const message = useSettled(name)
const live = useLiveValue(qualify(flow, message))
const { data } = useQuery(messageHistoryQueryOptions(flow, message))
const [tail, setTail] = useState<HistoryPoint[]>([])
const gradient = useId()
// 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(() => {
if (typeof value !== "number" || !ts) return
setTail((previous) =>
previous[previous.length - 1]?.ts === ts
? previous
: [...previous, { ts, value }].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) {
if (live === undefined) {
return (
<p className="text-xs text-muted-foreground">
Nothing has come through yet.
</p>
)
}
return (
<div className="flex items-baseline gap-2 text-xs">
<span className="min-w-0 flex-1 truncate font-mono">
{describe(live.value)}
</span>
<span className="shrink-0 text-muted-foreground">no history</span>
</div>
)
}
const { low, high, line, area, end } = shape(points)
return (
// The gap leaves the live dot room to sit on the last reading without
// touching the labels.
<div className="flex items-center gap-3">
<div className="relative h-8 min-w-0 flex-1">
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="h-full w-full overflow-visible"
aria-hidden="true"
>
<defs>
<linearGradient id={gradient} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.28" />
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0" />
</linearGradient>
</defs>
{points.length > 1 ? (
<>
<path d={area} fill={`url(#${gradient})`} />
<path
d={line}
fill="none"
stroke="var(--primary)"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
// Keeps the stroke even, though the box is far wider than tall.
vectorEffect="non-scaling-stroke"
/>
</>
) : null}
</svg>
{/* The newest reading is always the right edge, so the dot only needs to
know how high it sits. */}
<span
className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/20"
style={{ left: "100%", top: `${end}%` }}
/>
<span
className="pointer-events-none absolute size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary"
style={{ left: "100%", top: `${end}%` }}
/>
</div>
{/* A shared minimum width, so the curves all end on the same line. */}
<div className="min-w-24 shrink-0 whitespace-nowrap text-right font-mono text-xs leading-tight">
<div className="font-medium">
{compact(points[points.length - 1].value)}
</div>
{/* A series that never moved has no range worth repeating. */}
{low === high ? null : (
<div className="text-muted-foreground">
{compact(low)}{compact(high)}
</div>
)}
</div>
</div>
)
}
+42 -33
View File
@@ -23,6 +23,7 @@ import {
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { MessageSparkline } from "./MessageSparkline"
import { nodeSourceQueryOptions } from "./queries"
import { PANEL_SECTION, SidePanel } from "./SidePanel"
@@ -177,39 +178,47 @@ function PortList({
) : null}
{specs.map((spec, index) => (
<div key={`port-${index}`} className="flex items-center gap-1.5">
<MessageNameInput
value={spec.name ?? ""}
suggestions={suggestions}
placeholder={`name in ${flow}`}
autoFocus={index === freshIndex}
onChange={(name) => update(index, { name, port: "" })}
onRenamed={onRenamed}
/>
<Select
value={spec.dtype ?? "float"}
onValueChange={(value) => update(index, { dtype: value as DType })}
>
<SelectTrigger className="!h-8 w-[92px] text-sm" aria-label="Type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DTYPES.map((dtype) => (
<SelectItem key={dtype} value={dtype}>
{dtype}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove port"
onClick={() => onChange(specs.filter((_, i) => i !== index))}
>
<X />
</Button>
<div key={`port-${index}`} className="grid gap-1">
<div className="flex items-center gap-1.5">
<MessageNameInput
value={spec.name ?? ""}
suggestions={suggestions}
placeholder={`name in ${flow}`}
autoFocus={index === freshIndex}
onChange={(name) => update(index, { name, port: "" })}
onRenamed={onRenamed}
/>
<Select
value={spec.dtype ?? "float"}
onValueChange={(value) =>
update(index, { dtype: value as DType })
}
>
<SelectTrigger
className="!h-8 w-[92px] text-sm"
aria-label="Type"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DTYPES.map((dtype) => (
<SelectItem key={dtype} value={dtype}>
{dtype}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove port"
onClick={() => onChange(specs.filter((_, i) => i !== index))}
>
<X />
</Button>
</div>
{spec.name ? <MessageSparkline flow={flow} name={spec.name} /> : null}
</div>
))}
</div>
+8
View File
@@ -12,6 +12,8 @@ export const flowKeys = {
detail: (name: string) => ["flows", name] as const,
source: (name: string, nodeId: string) =>
["flows", name, "source", nodeId] as const,
history: (name: string, message: string) =>
["flows", name, "history", message] as const,
nodeTypes: ["flows", "node-types"] as const,
}
@@ -36,6 +38,12 @@ export const nodeSourceQueryOptions = (name: string, nodeId: string) => ({
queryFn: () => FlowsService.readNodeSource({ name, nodeId }),
})
/** The recent values of one message, for the panel's sparkline. */
export const messageHistoryQueryOptions = (name: string, message: string) => ({
queryKey: flowKeys.history(name, message),
queryFn: () => FlowsService.readMessageHistory({ name, message }),
})
const AUTOSAVE_DELAY = 800
/**