Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
138 lines
4.3 KiB
TypeScript
138 lines
4.3 KiB
TypeScript
import type { Edge } from "@xyflow/react"
|
|
|
|
import type { MessageSpec, NodeDef_Input } from "@/client"
|
|
import type { Route } from "./layout"
|
|
|
|
/** Resolve a message name against its flow: bare names belong to this flow. */
|
|
export function qualify(flow: string, name: string): string {
|
|
if (!name) return ""
|
|
return name.includes(".") ? name : `${flow}.${name}`
|
|
}
|
|
|
|
/**
|
|
* A message named with its flow, as the server checks it wherever a name has
|
|
* to arrive qualified (`QUALIFIED_PATTERN` in `flow/messages.py`).
|
|
*/
|
|
const QUALIFIED_MESSAGE = /^[a-z][a-z0-9_]*(\.[A-Za-z0-9_]+)+$/
|
|
|
|
/** What is wrong with a name that has to be qualified, or null. */
|
|
export function qualifiedProblem(name: string): string | null {
|
|
return QUALIFIED_MESSAGE.test(name)
|
|
? null
|
|
: "Name the message as flow.name: a lowercase flow name, a dot, then letters, digits and underscores"
|
|
}
|
|
|
|
/** The flow a qualified message belongs to. */
|
|
export function flowOf(message: string): string {
|
|
return message.split(".", 1)[0]
|
|
}
|
|
|
|
/** How a message reads inside its own flow. */
|
|
export function displayName(flow: string, message: string): string {
|
|
return message.startsWith(`${flow}.`)
|
|
? message.slice(flow.length + 1)
|
|
: message
|
|
}
|
|
|
|
export function portOf(spec: MessageSpec): string {
|
|
return spec.port || spec.name?.split(".").pop() || ""
|
|
}
|
|
|
|
export type FlowEdgeData = {
|
|
message: string
|
|
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
|
|
/** How the layout routed it, once it has. */
|
|
route?: Route
|
|
[key: string]: unknown
|
|
}
|
|
|
|
/**
|
|
* Turn name bindings into canvas edges.
|
|
*
|
|
* Nodes never point at each other: a node declares the messages it consumes,
|
|
* and every node providing that message is upstream of it. Two producers of one
|
|
* message therefore draw two edges converging on the same input.
|
|
*/
|
|
export function deriveEdges(nodes: NodeDef_Input[], flow: string): Edge[] {
|
|
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), stream: spec.stream })
|
|
producers.set(message, list)
|
|
}
|
|
}
|
|
|
|
const edges: Edge[] = []
|
|
for (const node of nodes) {
|
|
for (const spec of node.requires ?? []) {
|
|
const message = qualify(flow, spec.name ?? "")
|
|
if (!message) continue
|
|
const targetPort = portOf(spec)
|
|
for (const producer of producers.get(message) ?? []) {
|
|
if (producer.node === node.id) continue
|
|
edges.push({
|
|
id: `${producer.node}:${producer.port}->${node.id}:${targetPort}`,
|
|
source: producer.node,
|
|
sourceHandle: producer.port,
|
|
target: node.id,
|
|
targetHandle: targetPort,
|
|
type: "live",
|
|
data: {
|
|
message,
|
|
flow,
|
|
producerId: `${flow}.${producer.node}`,
|
|
stream: producer.stream,
|
|
} satisfies FlowEdgeData,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return edges
|
|
}
|
|
|
|
/**
|
|
* A cheap fingerprint of the flow's boundary — what it takes from outside and,
|
|
* for a batch flow, what it reports — since those are drawn like wiring too.
|
|
*/
|
|
export function boundaryKey(doc: {
|
|
mode?: string | null
|
|
inputs?: { spec?: MessageSpec | null; origin?: string }[] | null
|
|
outputs?: string[] | null
|
|
}): string {
|
|
const inputs = (doc.inputs ?? [])
|
|
.map(
|
|
(one) =>
|
|
`${one.spec?.name ?? ""}:${one.spec?.dtype ?? ""}:${one.origin ?? ""}`,
|
|
)
|
|
.join(",")
|
|
return `${doc.mode ?? "live"}|${inputs}|${(doc.outputs ?? []).join(",")}`
|
|
}
|
|
|
|
/**
|
|
* A cheap fingerprint of everything edges depend on, so dragging a node (which
|
|
* replaces the array every frame) does not re-derive them.
|
|
*/
|
|
export function bindingsKey(nodes: NodeDef_Input[]): string {
|
|
return nodes
|
|
.map(
|
|
(node) =>
|
|
`${node.id}|${(node.requires ?? [])
|
|
.map((s) => `${portOf(s)}=${s.name ?? ""}`)
|
|
.join(",")}|${(node.provides ?? [])
|
|
.map((s) => `${portOf(s)}=${s.name ?? ""}${s.stream ? "*" : ""}`)
|
|
.join(",")}`,
|
|
)
|
|
.join(";")
|
|
}
|