Files
app/frontend/src/components/Flow/deriveEdges.ts
T

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(";")
}