Files
app/frontend/src/components/Flow/endpoints.ts
T
stroblme b643eb8de8 Computed flow layout, and mobile written into the design
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.
2026-08-17 17:35:14 +02:00

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 }
}