Record flags in history and draw them as steps

A bool was excluded from the history as "not a measurement", so a true/false
port had no curve in the node panel and none on an edge — only the word. It is
recorded as 0/1 now and drawn as steps, since a bezier through two states
slopes through readings that never happened. The axis is pinned to 0..1, so a
flag that was never on sits at the floor rather than mid-box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 15:54:06 +02:00
co-authored by Claude Opus 5
parent 168aadb217
commit 6bc71ddaf3
7 changed files with 59 additions and 31 deletions
+27 -12
View File
@@ -28,15 +28,17 @@ const READOUT_DIGITS = 4
* 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[]) {
function shape(points: HistoryPoint[], step = false) {
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 logged = !step && low > 0 && high / low >= LOG_RATIO
const project = (value: number) => (logged ? Math.log10(value) : value)
const floor = project(low)
const span = project(high) - floor
// A flag's axis is the two values it has, so one that was never on sits at
// the floor rather than in the middle of the box the way a flat curve does.
const floor = step ? 0 : project(low)
const span = step ? 1 : project(high) - floor
const y = (value: number) =>
span === 0
@@ -46,10 +48,15 @@ function shape(points: HistoryPoint[]) {
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)}`,
)
.map((point, index) => {
const at = `${x(index).toFixed(2)},${y(point.value).toFixed(2)}`
if (!index) return `M${at}`
// A flag holds its last value until it changes, so it turns a corner
// rather than sloping through readings it never took.
return step
? `L${x(index).toFixed(2)},${y(points[index - 1].value).toFixed(2)} L${at}`
: `L${at}`
})
.join(" ")
return {
@@ -79,6 +86,7 @@ export function Sparkline({
height = "h-8",
dot = true,
readout = true,
bool = false,
}: {
points: HistoryPoint[]
/** The token the curve, its area and the dot are drawn in. */
@@ -89,9 +97,11 @@ export function Sparkline({
dot?: boolean
/** The current value and the range, beside the curve. */
readout?: boolean
/** A flag: drawn as steps between 0 and 1, and read as true/false. */
bool?: boolean
}) {
const id = useId()
const { low, high, line, area, end } = shape(points)
const { low, high, line, area, end } = shape(points, bool)
return (
// The gap leaves the live dot room to sit on the last reading without
@@ -181,10 +191,15 @@ export function Sparkline({
{readout ? (
<div className="min-w-24 shrink-0 whitespace-nowrap text-right font-mono text-xs leading-tight">
<div className="font-medium">
{si(points[points.length - 1].value, READOUT_DIGITS)}
{bool
? points[points.length - 1].value
? "true"
: "false"
: si(points[points.length - 1].value, READOUT_DIGITS)}
</div>
{/* A series that never moved has no range worth repeating. */}
{low === high ? null : (
{/* A series that never moved has no range worth repeating, and
"falsetrue" is not a range anyone reads. */}
{low === high || bool ? null : (
<div className="text-muted-foreground">
{si(low, READOUT_DIGITS)}{si(high, READOUT_DIGITS)}
</div>
@@ -132,11 +132,11 @@ export function EdgeInspector({
</div>
{/* The same curve the node panel draws for this message: one value
says little, how it has been moving says the rest. Only for a
number, because only numbers are recorded — for anything else the
sparkline falls through to its own copy of the value, which the
popover already shows below. */}
{typeof live?.value === "number" ? (
says little, how it has been moving says the rest. Only for what is
recorded — a number, or a flag as the steps between 0 and 1. For
anything else the sparkline falls through to its own copy of the
value, which the popover already shows below. */}
{typeof live?.value === "number" || typeof live?.value === "boolean" ? (
<div className="mt-2">
<MessageSparkline
flow={flow}
@@ -83,11 +83,13 @@ export function MessageSparkline({
const ts = live?.ts
const value = live?.value
useEffect(() => {
if (typeof value !== "number" || !ts) return
// 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 }].slice(-WINDOW),
: [...previous, { ts, value: reading }].slice(-WINDOW),
)
}, [ts, value])
@@ -127,7 +129,10 @@ export function MessageSparkline({
<Tooltip>
<TooltipTrigger asChild>
<div>
<Sparkline points={points} />
<Sparkline
points={points}
bool={dtype === "bool" || typeof live?.value === "boolean"}
/>
</div>
</TooltipTrigger>
<TooltipContent>{caption(points)}</TooltipContent>