Flow canvas: mark streaming edges with a marching dash

A port with `stream` set publishes repeatedly during one execution, and
until now nothing on the canvas said so. `deriveEdges` carries the flag
from the producing `MessageSpec` onto the edge, `LiveEdge` turns it into
an `edge-stream` class, and the class draws a dashed stroke whose offset
marches toward the target. The pulse still lands on top: both animations
share one declaration when a value arrives on a streaming edge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
This commit is contained in:
2026-08-20 09:14:44 +02:00
co-authored by Claude Opus 5
parent 86e02b49a3
commit a00b4eaec2
3 changed files with 37 additions and 5 deletions
+2 -2
View File
@@ -34,7 +34,7 @@ function LiveEdgeComponent({
data, data,
selected, selected,
}: EdgeProps) { }: EdgeProps) {
const { message, producerId } = (data ?? {}) as FlowEdgeData const { message, producerId, stream } = (data ?? {}) as FlowEdgeData
const live = useLiveValue(message) const live = useLiveValue(message)
const zoom = useStore((state) => state.transform[2]) const zoom = useStore((state) => state.transform[2])
const [pulsing, setPulsing] = useState(false) const [pulsing, setPulsing] = useState(false)
@@ -69,7 +69,7 @@ function LiveEdgeComponent({
<BaseEdge <BaseEdge
id={id} id={id}
path={path} path={path}
className={cn(pulsing && "edge-live")} className={cn(pulsing && "edge-live", stream && "edge-stream")}
style={selected ? { stroke: "var(--primary)" } : undefined} style={selected ? { stroke: "var(--primary)" } : undefined}
/> />
{live !== undefined && zoom >= CHIP_MIN_ZOOM ? ( {live !== undefined && zoom >= CHIP_MIN_ZOOM ? (
+9 -3
View File
@@ -29,6 +29,8 @@ export type FlowEdgeData = {
flow: string flow: string
/** Whose publication this edge represents, so only it pulses. */ /** Whose publication this edge represents, so only it pulses. */
producerId: string producerId: string
/** The producing port publishes repeatedly during one run, not once at its end. */
stream?: boolean
[key: string]: unknown [key: string]: unknown
} }
@@ -40,14 +42,17 @@ export type FlowEdgeData = {
* message therefore draw two edges converging on the same input. * message therefore draw two edges converging on the same input.
*/ */
export function deriveEdges(nodes: NodeDef_Input[], flow: string): Edge[] { export function deriveEdges(nodes: NodeDef_Input[], flow: string): Edge[] {
const producers = new Map<string, { node: string; port: string }[]>() const producers = new Map<
string,
{ node: string; port: string; stream?: boolean }[]
>()
for (const node of nodes) { for (const node of nodes) {
for (const spec of node.provides ?? []) { for (const spec of node.provides ?? []) {
const message = qualify(flow, spec.name ?? "") const message = qualify(flow, spec.name ?? "")
if (!message) continue if (!message) continue
const list = producers.get(message) ?? [] const list = producers.get(message) ?? []
list.push({ node: node.id, port: portOf(spec) }) list.push({ node: node.id, port: portOf(spec), stream: spec.stream })
producers.set(message, list) producers.set(message, list)
} }
} }
@@ -71,6 +76,7 @@ export function deriveEdges(nodes: NodeDef_Input[], flow: string): Edge[] {
message, message,
flow, flow,
producerId: `${flow}.${producer.node}`, producerId: `${flow}.${producer.node}`,
stream: producer.stream,
} satisfies FlowEdgeData, } satisfies FlowEdgeData,
}) })
} }
@@ -90,7 +96,7 @@ export function bindingsKey(nodes: NodeDef_Input[]): string {
`${node.id}|${(node.requires ?? []) `${node.id}|${(node.requires ?? [])
.map((s) => `${portOf(s)}=${s.name ?? ""}`) .map((s) => `${portOf(s)}=${s.name ?? ""}`)
.join(",")}|${(node.provides ?? []) .join(",")}|${(node.provides ?? [])
.map((s) => `${portOf(s)}=${s.name ?? ""}`) .map((s) => `${portOf(s)}=${s.name ?? ""}${s.stream ? "*" : ""}`)
.join(",")}`, .join(",")}`,
) )
.join(";") .join(";")
+26
View File
@@ -71,6 +71,32 @@
stroke-width: var(--xy-edge-stroke-width); stroke-width: var(--xy-edge-stroke-width);
} }
} }
/*
* An output that publishes repeatedly during one run reads as a march rather
* than a line. Only the dash offset moves, so the pulse above still shows
* through on the same path: it recolours and widens the dashes as they go.
* A loop rather than a one-shot, so none of the `--duration-*` tokens fit —
* the literal is one dash period per step, slow enough to read as direction.
*/
.react-flow__edge-path.edge-stream {
stroke-dasharray: 6 4;
animation: edge-march 700ms linear infinite;
}
/* Both at once: one `animation` declaration has to carry both, or the
cascade keeps whichever rule came last and drops the other. */
.react-flow__edge-path.edge-stream.edge-live {
animation:
edge-pulse var(--duration-pulse) var(--ease-emphasized),
edge-march 700ms linear infinite;
}
@keyframes edge-march {
to {
stroke-dashoffset: -10;
}
}
} }
/* A node that just published something says so, once, and settles. */ /* A node that just published something says so, once, and settles. */