The canvas lays itself out: a layered graph, left to right on a desktop and top to bottom on a phone, with room reserved for the value each edge carries. Nodes cannot be dragged and `NodeDef.position` is gone from the document — a graph nobody can arrange is one worth keeping small, which is what keeps flows atomic. Endpoints join the same layout, so their lanes and the localStorage that remembered where they were dragged go too. Mobile, per the new Responsive section of DESIGN-GUIDELINES.md: the dock caps its width and wraps instead of running off the screen, the dashboard stacks into one column rather than shrinking a wall panel to a fifth of its size, and Home stops widening its grid track past the viewport. A Playwright project at a phone's width fails the build when a screen no longer fits. Along the way: publish is the checkmark that was already there rather than a button that appears and disappears, with discard beside it on both the flow and the dashboard; the brain reveals a neuron's name on the first tap; and the port sparklines get room to breathe.
138 lines
4.3 KiB
TypeScript
138 lines
4.3 KiB
TypeScript
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
|
|
}
|
|
|
|
export function isEndpointNode(node: { id: string }): boolean {
|
|
return node.id.startsWith("dashboard:") || node.id.startsWith("flow:")
|
|
}
|
|
|
|
/**
|
|
* Build the endpoints and wire them to the nodes they touch.
|
|
*
|
|
* They carry no position: the caller lays them out together with the flow's
|
|
* own nodes (see `layout.ts`), so an endpoint that publishes lands upstream of
|
|
* what it feeds and one that reads lands downstream of what feeds it, by the
|
|
* same rule that orders everything else.
|
|
*/
|
|
export function deriveEndpoints(
|
|
endpoints: Endpoint[],
|
|
definitions: NodeDef_Input[],
|
|
flow: string,
|
|
): { 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<string, { node: string; port: string }[]>()
|
|
const producers = new Map<string, { node: string; port: string }[]>()
|
|
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) },
|
|
])
|
|
}
|
|
}
|
|
|
|
const nodes: FlowCanvasNode[] = []
|
|
const edges: Edge[] = []
|
|
|
|
for (const endpoint of endpoints) {
|
|
const produces = endpoint.provides ?? []
|
|
const reads = endpoint.requires ?? []
|
|
|
|
nodes.push({
|
|
id: endpoint.id,
|
|
type: ENDPOINT_TYPE,
|
|
// Filled in by the layout, along with the flow's own nodes.
|
|
position: { x: 0, y: 0 },
|
|
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 }
|
|
}
|