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"
|
} from "@/components/ui/select"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { MessageSparkline } from "./MessageSparkline"
|
||||||
import { nodeSourceQueryOptions } from "./queries"
|
import { nodeSourceQueryOptions } from "./queries"
|
||||||
import { PANEL_SECTION, SidePanel } from "./SidePanel"
|
import { PANEL_SECTION, SidePanel } from "./SidePanel"
|
||||||
|
|
||||||
@@ -177,39 +178,47 @@ function PortList({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{specs.map((spec, index) => (
|
{specs.map((spec, index) => (
|
||||||
<div key={`port-${index}`} className="flex items-center gap-1.5">
|
<div key={`port-${index}`} className="grid gap-1">
|
||||||
<MessageNameInput
|
<div className="flex items-center gap-1.5">
|
||||||
value={spec.name ?? ""}
|
<MessageNameInput
|
||||||
suggestions={suggestions}
|
value={spec.name ?? ""}
|
||||||
placeholder={`name in ${flow}`}
|
suggestions={suggestions}
|
||||||
autoFocus={index === freshIndex}
|
placeholder={`name in ${flow}`}
|
||||||
onChange={(name) => update(index, { name, port: "" })}
|
autoFocus={index === freshIndex}
|
||||||
onRenamed={onRenamed}
|
onChange={(name) => update(index, { name, port: "" })}
|
||||||
/>
|
onRenamed={onRenamed}
|
||||||
<Select
|
/>
|
||||||
value={spec.dtype ?? "float"}
|
<Select
|
||||||
onValueChange={(value) => update(index, { dtype: value as DType })}
|
value={spec.dtype ?? "float"}
|
||||||
>
|
onValueChange={(value) =>
|
||||||
<SelectTrigger className="!h-8 w-[92px] text-sm" aria-label="Type">
|
update(index, { dtype: value as DType })
|
||||||
<SelectValue />
|
}
|
||||||
</SelectTrigger>
|
>
|
||||||
<SelectContent>
|
<SelectTrigger
|
||||||
{DTYPES.map((dtype) => (
|
className="!h-8 w-[92px] text-sm"
|
||||||
<SelectItem key={dtype} value={dtype}>
|
aria-label="Type"
|
||||||
{dtype}
|
>
|
||||||
</SelectItem>
|
<SelectValue />
|
||||||
))}
|
</SelectTrigger>
|
||||||
</SelectContent>
|
<SelectContent>
|
||||||
</Select>
|
{DTYPES.map((dtype) => (
|
||||||
<Button
|
<SelectItem key={dtype} value={dtype}>
|
||||||
variant="ghost"
|
{dtype}
|
||||||
size="icon-sm"
|
</SelectItem>
|
||||||
className="text-muted-foreground"
|
))}
|
||||||
aria-label="Remove port"
|
</SelectContent>
|
||||||
onClick={() => onChange(specs.filter((_, i) => i !== index))}
|
</Select>
|
||||||
>
|
<Button
|
||||||
<X />
|
variant="ghost"
|
||||||
</Button>
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const flowKeys = {
|
|||||||
detail: (name: string) => ["flows", name] as const,
|
detail: (name: string) => ["flows", name] as const,
|
||||||
source: (name: string, nodeId: string) =>
|
source: (name: string, nodeId: string) =>
|
||||||
["flows", name, "source", nodeId] as const,
|
["flows", name, "source", nodeId] as const,
|
||||||
|
history: (name: string, message: string) =>
|
||||||
|
["flows", name, "history", message] as const,
|
||||||
nodeTypes: ["flows", "node-types"] as const,
|
nodeTypes: ["flows", "node-types"] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +38,12 @@ export const nodeSourceQueryOptions = (name: string, nodeId: string) => ({
|
|||||||
queryFn: () => FlowsService.readNodeSource({ name, nodeId }),
|
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
|
const AUTOSAVE_DELAY = 800
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user