diff --git a/frontend/src/components/Flow/LiveEdge.tsx b/frontend/src/components/Flow/LiveEdge.tsx
index 8161233..686413f 100644
--- a/frontend/src/components/Flow/LiveEdge.tsx
+++ b/frontend/src/components/Flow/LiveEdge.tsx
@@ -34,7 +34,7 @@ function LiveEdgeComponent({
data,
selected,
}: EdgeProps) {
- const { message, producerId } = (data ?? {}) as FlowEdgeData
+ const { message, producerId, stream } = (data ?? {}) as FlowEdgeData
const live = useLiveValue(message)
const zoom = useStore((state) => state.transform[2])
const [pulsing, setPulsing] = useState(false)
@@ -69,7 +69,7 @@ function LiveEdgeComponent({
{live !== undefined && zoom >= CHIP_MIN_ZOOM ? (
diff --git a/frontend/src/components/Flow/deriveEdges.ts b/frontend/src/components/Flow/deriveEdges.ts
index 5439d7b..9ead9be 100644
--- a/frontend/src/components/Flow/deriveEdges.ts
+++ b/frontend/src/components/Flow/deriveEdges.ts
@@ -29,6 +29,8 @@ export type FlowEdgeData = {
flow: string
/** Whose publication this edge represents, so only it pulses. */
producerId: string
+ /** The producing port publishes repeatedly during one run, not once at its end. */
+ stream?: boolean
[key: string]: unknown
}
@@ -40,14 +42,17 @@ export type FlowEdgeData = {
* message therefore draw two edges converging on the same input.
*/
export function deriveEdges(nodes: NodeDef_Input[], flow: string): Edge[] {
- const producers = new Map()
+ const producers = new Map<
+ string,
+ { node: string; port: string; stream?: boolean }[]
+ >()
for (const node of nodes) {
for (const spec of node.provides ?? []) {
const message = qualify(flow, spec.name ?? "")
if (!message) continue
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)
}
}
@@ -71,6 +76,7 @@ export function deriveEdges(nodes: NodeDef_Input[], flow: string): Edge[] {
message,
flow,
producerId: `${flow}.${producer.node}`,
+ stream: producer.stream,
} satisfies FlowEdgeData,
})
}
@@ -90,7 +96,7 @@ export function bindingsKey(nodes: NodeDef_Input[]): string {
`${node.id}|${(node.requires ?? [])
.map((s) => `${portOf(s)}=${s.name ?? ""}`)
.join(",")}|${(node.provides ?? [])
- .map((s) => `${portOf(s)}=${s.name ?? ""}`)
+ .map((s) => `${portOf(s)}=${s.name ?? ""}${s.stream ? "*" : ""}`)
.join(",")}`,
)
.join(";")
diff --git a/frontend/src/components/Flow/flow.css b/frontend/src/components/Flow/flow.css
index 00c5e53..4c1313b 100644
--- a/frontend/src/components/Flow/flow.css
+++ b/frontend/src/components/Flow/flow.css
@@ -71,6 +71,32 @@
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. */