import type { Edge, Node as FlowCanvasNode } from "@xyflow/react" import type { Endpoint, NodeDef_Input } from "@/client" import type { FlowEdgeData } from "./deriveEdges" import { portOf, qualify } from "./deriveEdges" /** * Canvas elements for things wired into a flow that are not nodes in it. * * A dashboard control setting one of its messages, a tile showing one, or a * node in another flow across a dotted name. Without these the canvas shows a * value changing with nothing to account for it — and pulses whichever node * happens to be drawn as a producer, which did nothing. * * They are never part of the document: this returns display-only nodes that * are appended after everything which reads or writes `canvasNodes`, so an * autosave, an undo or a delete cannot reach them. */ /** Marks a canvas node as one of these, for anything that has to skip them. */ export const ENDPOINT_TYPE = "endpoint" export type EndpointNodeData = { label: string kind: string detail: string /** Messages it publishes into this flow, and ones it reads out of it. */ provides: string[] requires: string[] [key: string]: unknown } /** Lanes either side of the graph, so a label never lands on a node. */ const GAP_X = 120 const STACK_Y = 64 /** Roughly a node's width; only used to find the right-hand lane. */ const NODE_W = 220 export function isEndpointNode(node: { id: string }): boolean { return node.id.startsWith("dashboard:") || node.id.startsWith("flow:") } /** * Where the author dragged an endpoint to. * * Not in the flow document — an endpoint is not part of the flow, and writing * a position for one into `flow.json` would be a lie about what it contains. * A view preference belongs to the view, so it lives in the browser. */ const POSITION_KEY = "fluksio-endpoint-positions" type Placements = Record> function readPlacements(): Placements { try { return JSON.parse(localStorage.getItem(POSITION_KEY) ?? "{}") as Placements } catch { return {} } } export function placementsFor( flow: string, ): Record { return readPlacements()[flow] ?? {} } export function rememberPlacement( flow: string, id: string, position: { x: number; y: number }, ): void { const all = readPlacements() all[flow] = { ...(all[flow] ?? {}), [id]: position } try { localStorage.setItem(POSITION_KEY, JSON.stringify(all)) } catch { // A full or disabled store just means positions reset; not worth failing. } } /** * Place the endpoints and wire them to the nodes they touch. * * Positions are computed rather than stored: an endpoint is not part of the * flow, so there is nowhere to keep a position that would not be a lie about * what the document contains. A producer sits left of what it feeds, a * consumer right of what feeds it. */ export function deriveEndpoints( endpoints: Endpoint[], definitions: NodeDef_Input[], flow: string, positions: Map, moved: Record = {}, ): { nodes: FlowCanvasNode[]; edges: Edge[] } { if (endpoints.length === 0) return { nodes: [], edges: [] } // Which node consumes or produces each message, so a label can sit beside it. const consumers = new Map() const producers = new Map() for (const node of definitions) { for (const spec of node.requires ?? []) { const message = qualify(flow, spec.name ?? "") if (!message) continue consumers.set(message, [ ...(consumers.get(message) ?? []), { node: node.id, port: portOf(spec) }, ]) } for (const spec of node.provides ?? []) { const message = qualify(flow, spec.name ?? "") if (!message) continue producers.set(message, [ ...(producers.get(message) ?? []), { node: node.id, port: portOf(spec) }, ]) } } // A lane either side of the graph. Anchoring each label to the node it // feeds put them on top of the nodes, so they live outside the whole thing // instead: producers to the left of everything, consumers to the right. const placed = [...positions.values()] const bounds = { left: placed.length ? Math.min(...placed.map((p) => p.x)) : 0, right: placed.length ? Math.max(...placed.map((p) => p.x)) + NODE_W : 0, top: placed.length ? Math.min(...placed.map((p) => p.y)) : 0, } const nodes: FlowCanvasNode[] = [] const edges: Edge[] = [] // How many labels already sit on each side, so they stack instead of overlap. const stacked = { left: 0, right: 0 } for (const endpoint of endpoints) { const produces = endpoint.provides ?? [] const reads = endpoint.requires ?? [] // A producer belongs upstream of what it feeds; everything else downstream. const side = produces.length > 0 ? "left" : "right" const index = stacked[side] stacked[side] += 1 nodes.push({ id: endpoint.id, type: ENDPOINT_TYPE, position: moved[endpoint.id] ?? { x: side === "left" ? bounds.left - GAP_X : bounds.right + GAP_X, y: bounds.top + index * STACK_Y, }, // Movable, so a canvas can be arranged; still not the flow's to delete. draggable: true, selectable: true, deletable: false, data: { label: endpoint.label, kind: endpoint.kind, detail: endpoint.detail ?? "", provides: produces, requires: reads, } satisfies EndpointNodeData, }) // An endpoint that publishes feeds every node consuming that message. for (const message of produces) { for (const consumer of consumers.get(message) ?? []) { edges.push({ id: `${endpoint.id}->${consumer.node}:${consumer.port}`, source: endpoint.id, sourceHandle: message, target: consumer.node, targetHandle: consumer.port, type: "live", data: { message, flow, producerId: endpoint.id, } satisfies FlowEdgeData, }) } } // One that reads is fed by every node producing it. for (const message of reads) { for (const producer of producers.get(message) ?? []) { edges.push({ id: `${producer.node}:${producer.port}->${endpoint.id}`, source: producer.node, sourceHandle: producer.port, target: endpoint.id, targetHandle: message, type: "live", data: { message, flow, producerId: `${flow}.${producer.node}`, } satisfies FlowEdgeData, }) } } } return { nodes, edges } }