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:
co-authored by
Claude Opus 5
parent
0154bf3537
commit
e3b3d234ce
@@ -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 0–100 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user