diff --git a/bun.lock b/bun.lock index 7def5be..471ba9e 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,6 @@ "name": "frontend", "version": "0.0.0", "dependencies": { - "@dagrejs/dagre": "^3.1.1", "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-avatar": "^1.1.11", @@ -38,6 +37,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "d3-force": "^3.0.0", + "elkjs": "^0.12.0", "form-data": "4.0.5", "lucide-react": "^0.562.0", "monaco-editor": "^0.56.0", @@ -131,10 +131,6 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.12", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="], - "@dagrejs/dagre": ["@dagrejs/dagre@3.1.1", "", { "dependencies": { "@dagrejs/graphlib": "4.0.5" } }, "sha512-zroZB1dFOFiGgv4Xcrn1DckB1o4aOikPqD2NDQPV0WM//CXGcS6xiD0rNkqHmw6FEg4tabt4nxPLwgCWT+Vb2A=="], - - "@dagrejs/graphlib": ["@dagrejs/graphlib@4.0.5", "", {}, "sha512-7xrBTqIts3o+PMUZX97wSc+7TUbW+/rULzGNCTP6yooNVDXbzw4Wutg/H/xOutTB/c/k0YqOAavgPh4/Zk9PFA=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], @@ -633,6 +629,8 @@ "electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="], + "elkjs": ["elkjs@0.12.0", "", {}, "sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ=="], + "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], diff --git a/frontend/package.json b/frontend/package.json index 749702f..2df65ad 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,6 @@ "test:ui": "bunx playwright test --ui" }, "dependencies": { - "@dagrejs/dagre": "^3.1.1", "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-avatar": "^1.1.11", @@ -44,6 +43,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "d3-force": "^3.0.0", + "elkjs": "^0.12.0", "form-data": "4.0.5", "lucide-react": "^0.562.0", "monaco-editor": "^0.56.0", diff --git a/frontend/src/components/Flow/EndpointNode.tsx b/frontend/src/components/Flow/EndpointNode.tsx index fa4461c..849d866 100644 --- a/frontend/src/components/Flow/EndpointNode.tsx +++ b/frontend/src/components/Flow/EndpointNode.tsx @@ -5,6 +5,7 @@ import { memo } from "react" import { useIsMobile } from "@/hooks/useMobile" import { cn } from "@/lib/utils" import type { EndpointNodeData } from "./endpoints" +import { handleOffset, nodeHeight } from "./layout" const KIND_ICONS = { dashboard: LayoutDashboard, @@ -28,8 +29,14 @@ function EndpointNodeComponent({ data, selected }: NodeProps) { // Follows the graph's own direction; see DESIGN-GUIDELINES.md → Responsive. const vertical = useIsMobile() + // A dashboard reading six messages has six edges, and stacking their dots on + // one point is what made them leave in a knot. Spread and sized the way a + // node's ports are, so the layout can reserve exactly this much. + const ports = Math.max(provides.length, requires.length) + return (
{/* Both sides always exist so an edge can attach; only one is used. */} - {requires.map((message) => ( - - ))} - {provides.map((message) => ( - - ))} + {requires.map((message, index) => { + const offset = handleOffset(index, requires.length) + return ( + + ) + })} + {provides.map((message, index) => { + const offset = handleOffset(index, provides.length) + return ( + + ) + })} diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index c0b68d0..0ba0e06 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -2,6 +2,7 @@ import { Background, BackgroundVariant, type Connection, + type Edge, type EdgeChange, type Node as FlowCanvasNode, type NodeChange, @@ -57,12 +58,17 @@ import { } from "./deriveEdges" import { EdgeInspector, type InspectedEdge } from "./EdgeInspector" import { EndpointNode } from "./EndpointNode" -import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints" +import { + deriveEndpoints, + ENDPOINT_TYPE, + type EndpointNodeData, + isEndpointNode, +} from "./endpoints" import { FIT_VIEW, FIT_VIEW_PANEL, FlowDock } from "./FlowDock" import { FlowNode, type FlowNodeData } from "./FlowNode" import { FlowPanel } from "./FlowPanel" import { LiveEdge } from "./LiveEdge" -import { type Direction, layoutGraph, nodeHeight } from "./layout" +import { type Direction, layoutGraph, type NodeShape } from "./layout" import { NodePanel } from "./NodePanel" import { RunDialog } from "./RunDialog" import "./flow.css" @@ -80,6 +86,41 @@ import { useFlowSocket } from "./useFlowSocket" const nodeTypes = { flow: FlowNode, [ENDPOINT_TYPE]: EndpointNode } +/** + * Where every node sits, once the engine has said so. + * + * The layout is a chunk of its own and answers asynchronously, so a canvas has + * a moment before it knows where anything goes. It draws nothing at all until + * then — a route already shows a skeleton for that — and an *edit* keeps the + * arrangement it had, so only the node that changed lands a frame late rather + * than the whole graph flickering through the corner. + */ +function useLayout( + ids: string[], + edges: Edge[], + direction: Direction, + shapes: Map, + shapeKey: string, +): Map | null { + const [placed, setPlaced] = useState | null>(null) + // biome-ignore lint/correctness/useExhaustiveDependencies: the shape key is the dependency; the arrays are rebuilt every render. + useEffect(() => { + let stale = false + layoutGraph(ids, edges, direction, shapes).then((positions) => { + // A graph edited while the last one was still being laid out has already + // asked for another; that one is the answer, not this. + if (!stale) setPlaced(positions) + }) + return () => { + stale = true + } + }, [shapeKey, edges]) + return placed +} + /** Is this canvas node actually part of the flow document? */ const isDocumentNode = (node: { id: string }) => !isEndpointNode(node) const edgeTypes = { live: LiveEdge } @@ -517,29 +558,30 @@ function FlowEditorInner({ ...external.nodes.map((node) => node.id), ] const shapeKey = `${direction}|${key}|${ids.join(",")}` - // How tall each node's ports make it, which `FlowNode` draws to the same - // number. A function of the document — the bindings key above already covers - // every port, so this changes exactly when the layout has to run again, and - // nothing measured is ever fed back into it. - const heights = - direction === "LR" - ? new Map( - definitions.map((node) => [ - node.id, - nodeHeight( - Math.max( - (node.requires ?? []).length, - (node.provides ?? []).length, - ), - ), - ]), - ) - : new Map() - // biome-ignore lint/correctness/useExhaustiveDependencies: the shape key is the dependency; the arrays are rebuilt every render. - const positions = useMemo( - () => layoutGraph(ids, edges, direction, heights), - [shapeKey, edges], - ) + // What each node is, as far as the layout is concerned: the ports it draws, + // in the order it draws them. A function of the document — the bindings key + // above already covers every port, so this changes exactly when the layout + // has to run again, and nothing measured is ever fed back into it. + const shapes = new Map([ + ...definitions.map( + (node) => + [ + node.id, + { + requires: (node.requires ?? []).map(portOf), + provides: (node.provides ?? []).map(portOf), + }, + ] as const, + ), + ...external.nodes.map((node) => { + const data = node.data as EndpointNodeData + return [ + node.id, + { requires: data.requires, provides: data.provides, endpoint: true }, + ] as const + }), + ]) + const positions = useLayout(ids, edges, direction, shapes, shapeKey) /** * The endpoints, placed. @@ -551,10 +593,15 @@ function FlowEditorInner({ */ const externalNodes = useMemo( () => - external.nodes.map((node) => ({ - ...node, - position: positions.get(node.id) ?? node.position, - })), + external.nodes + // A node the layout has not placed yet — one just added, or the whole + // graph on the first render — is left out rather than drawn at the + // origin, which is where every unplaced node would pile up. + .filter((node) => positions?.has(node.id)) + .map((node) => ({ + ...node, + position: positions?.get(node.id) ?? node.position, + })), [external, positions], ) @@ -568,10 +615,12 @@ function FlowEditorInner({ const shownNodes = useMemo( () => [ - ...renderedNodes.map((node) => ({ - ...node, - position: positions.get(node.id) ?? node.position, - })), + ...renderedNodes + .filter((node) => positions?.has(node.id)) + .map((node) => ({ + ...node, + position: positions?.get(node.id) ?? node.position, + })), ...externalNodes, ], [renderedNodes, externalNodes, positions], @@ -621,6 +670,9 @@ function FlowEditorInner({ // A phone's sheet covers the canvas outright, and so does the expanded // editor: there is no viewport to aim. if (editorExpanded) return + // Nothing is on the canvas until the layout has answered, and fitting an + // empty one would spend the single instant fit on nothing. + if (!positions) return const frame = requestAnimationFrame(() => { const view = panelOpen && !isMobile ? FIT_VIEW_PANEL : FIT_VIEW const duration = fitted.current ? 300 : 0 @@ -637,6 +689,7 @@ function FlowEditorInner({ definitions.length, external.nodes.length, edges.length, + positions, selectedId, panelOpen, editorExpanded, diff --git a/frontend/src/components/Flow/FlowNode.tsx b/frontend/src/components/Flow/FlowNode.tsx index a85f7e4..dde25b3 100644 --- a/frontend/src/components/Flow/FlowNode.tsx +++ b/frontend/src/components/Flow/FlowNode.tsx @@ -32,7 +32,7 @@ import { useIsMobile } from "@/hooks/useMobile" import { duration } from "@/lib/motion" import { cn } from "@/lib/utils" import { portOf } from "./deriveEdges" -import { nodeHeight, PORT_SPAN } from "./layout" +import { handleOffset, nodeHeight } from "./layout" import { liveStore, useNodeEmits, @@ -77,15 +77,6 @@ export type FlowNodeData = { [key: string]: unknown } -/** Spread handles along the node's edge so several ports stay reachable. */ -function handleOffset(index: number, total: number): string { - if (total <= 1) return "50%" - // The same fraction `nodeHeight` sizes the node for, so a node is always - // tall enough for the ports this spreads down it. - const span = PORT_SPAN * 100 - return `${50 - span / 2 + (span / (total - 1)) * index}%` -} - function PortHandles({ specs, type, diff --git a/frontend/src/components/Flow/LiveEdge.tsx b/frontend/src/components/Flow/LiveEdge.tsx index 686413f..c694ba4 100644 --- a/frontend/src/components/Flow/LiveEdge.tsx +++ b/frontend/src/components/Flow/LiveEdge.tsx @@ -3,6 +3,7 @@ import { EdgeLabelRenderer, type EdgeProps, getBezierPath, + Position, useStore, } from "@xyflow/react" import { memo, useEffect, useRef, useState } from "react" @@ -15,6 +16,51 @@ import { useLiveValue } from "./liveStore" /** Below this zoom the value chips would be unreadable, so they step aside. */ const CHIP_MIN_ZOOM = 0.5 +/** How far a feedback edge stands off the ranks it has to get back past. */ +const BOW = 60 + +/** + * The path a feedback edge takes. + * + * The layout ranks a flow in the direction it runs, so an edge closing a cycle + * is the one that goes the other way — a widget setting the window a node + * upstream of it reads. A curve drawn straight between those two handles is + * drawn straight through everything between them. This one leaves its handle + * the way every other edge does, swings out into a lane beside the graph, and + * comes back into its target from the far side, so what it passes it passes + * around. + * + * ponytail: how far out the lane sits is read off how far back the edge has to + * travel, not off what is actually in the way — this stands a cycle apart from + * the flow rather than guaranteeing it clears every node. The layout engine + * routes these properly and React Flow draws its own curves; taking its bend + * points is the real fix, and a bigger one. + */ +function cyclePath( + sourceX: number, + sourceY: number, + targetX: number, + targetY: number, + vertical: boolean, +): [string, number, number] { + const span = vertical + ? Math.abs(targetY - sourceY) + : Math.abs(targetX - sourceX) + const lane = Math.max(BOW, span * 0.3) + const [c1x, c1y] = vertical + ? [sourceX + lane, sourceY + BOW] + : [sourceX + BOW, sourceY + lane] + const [c2x, c2y] = vertical + ? [targetX + lane, targetY - BOW] + : [targetX - BOW, targetY + lane] + return [ + `M${sourceX},${sourceY} C${c1x},${c1y} ${c2x},${c2y} ${targetX},${targetY}`, + // Halfway along a cubic, which is where the chip rides. + (sourceX + 3 * c1x + 3 * c2x + targetX) / 8, + (sourceY + 3 * c1y + 3 * c2y + targetY) / 8, + ] +} + function formatValue(value: unknown): string { if (typeof value === "number") { return Number.isInteger(value) ? String(value) : value.toFixed(2) @@ -40,14 +86,21 @@ function LiveEdgeComponent({ const [pulsing, setPulsing] = useState(false) const lastTs = useRef(null) - const [path, labelX, labelY] = getBezierPath({ - sourceX, - sourceY, - sourcePosition, - targetX, - targetY, - targetPosition, - }) + // Every rank sits further along the flow than the one before it, so an edge + // whose target has ended up behind its source is one closing a cycle. + const vertical = sourcePosition === Position.Bottom + const backwards = vertical ? targetY < sourceY : targetX < sourceX + + const [path, labelX, labelY] = backwards + ? cyclePath(sourceX, sourceY, targetX, targetY, vertical) + : getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }) // Restart the stroke animation whenever a newer message lands — but only // for the producer that actually published it. A message can have several diff --git a/frontend/src/components/Flow/flow.css b/frontend/src/components/Flow/flow.css index 04e1a1a..e151c4c 100644 --- a/frontend/src/components/Flow/flow.css +++ b/frontend/src/components/Flow/flow.css @@ -57,6 +57,29 @@ --edge-rest: var(--primary); } +/* + * Following one edge through a busy rank. + * + * The layout keeps the crossings a graph does not need, but a fan-out of eight + * still has edges running past each other, and the one under the pointer is + * the one being read. It resolves to full strength rather than to blue: blue + * is what selection means here, and hovering is not selecting. React Flow lays + * a wide transparent path over each edge, so the pointer finds it without + * having to be on the line itself — and clicking it opens the same inspector, + * so nothing is only reachable by hovering. + */ +.react-flow__edge:hover .react-flow__edge-path { + stroke: var(--muted-foreground); + stroke-width: var(--edge-pulse-width); + transition: + stroke var(--duration-fast) var(--ease-standard), + stroke-width var(--duration-fast) var(--ease-standard); +} + +.react-flow__edge.selected:hover .react-flow__edge-path { + stroke: var(--primary); +} + /* * The dashes are the signal, the march below is only its motion: the pattern * is declared outside the guard so a reduced-motion reader still sees which diff --git a/frontend/src/components/Flow/layout.check.ts b/frontend/src/components/Flow/layout.check.ts index 5e0df0c..d0a9953 100644 --- a/frontend/src/components/Flow/layout.check.ts +++ b/frontend/src/components/Flow/layout.check.ts @@ -1,5 +1,6 @@ /** - * A node's height against the ports it has to fit. + * What the layout promises: a node tall enough for its ports, an edge that + * lands where its handle is, and no crossing it could have avoided. * * Run: `bun src/components/Flow/layout.check.ts` (there is no unit runner; the * suite in `tests/` drives a running stack). Typechecked with the rest of @@ -7,21 +8,30 @@ */ import assert from "node:assert/strict" -import { layoutGraph, nodeHeight, PORT_SPAN } from "./layout" +// ELK's fake worker decides where it is running by looking for `self` without a +// `document` beside it, which is a browser worker — and is also bun. Taking the +// global away lets it load here; nothing in a browser goes near this. +// @ts-expect-error the runtime has it, the DOM types say it is not removable. +globalThis.self = undefined + +const { layoutGraph, nodeHeight, portFraction, PORT_SPAN } = await import( + "./layout" +) /** Handles are 12px across — see `.react-flow__handle` in `flow.css`. */ const HANDLE = 12 +const NODE_H = 56 // Two ports fit the plain box, so most nodes are unchanged. -assert.equal(nodeHeight(0), 56) -assert.equal(nodeHeight(1), 56) -assert.equal(nodeHeight(2), 56) -assert.ok(nodeHeight(3) > 56) +assert.equal(nodeHeight(0), NODE_H) +assert.equal(nodeHeight(1), NODE_H) +assert.equal(nodeHeight(2), NODE_H) +assert.ok(nodeHeight(3) > NODE_H) for (let ports = 2; ports <= 12; ports += 1) { const height = nodeHeight(ports) - // `handleOffset` spreads the ports over `PORT_SPAN` of the edge, so that is - // the room they actually get. Two handles must not touch. + // The ports are spread over `PORT_SPAN` of the edge, so that is the room + // they actually get. Two handles must not touch. const between = (PORT_SPAN * height) / (ports - 1) assert.ok( between > HANDLE, @@ -31,20 +41,168 @@ for (let ports = 2; ports <= 12; ports += 1) { assert.ok(height >= nodeHeight(ports - 1)) } -// The layout places top-left corners, so a taller node has to be lifted by its -// own half-height rather than by the default box's. -const tall = nodeHeight(8) -const placed = layoutGraph( +// A single port sits in the middle; several spread symmetrically about it. +assert.equal(portFraction(0, 1), 0.5) +assert.equal(portFraction(0, 2) + portFraction(1, 2), 1) +assert.ok(portFraction(0, 2) < portFraction(1, 2)) + +const one = (port: string) => ({ requires: [port], provides: [port] }) + +// A chain lays out along the flow, an edge at a time. +const chain = await layoutGraph( ["a", "b"], - [{ source: "a", target: "b" }], + [{ source: "a", target: "b", sourceHandle: "v", targetHandle: "v" }], "LR", - new Map([["b", tall]]), + new Map([ + ["a", one("v")], + ["b", one("v")], + ]), ) -const a = placed.get("a") -const b = placed.get("b") -assert.ok(a && b) -// Dagre centres the two on one rank line, so the taller one starts higher up -// by exactly the difference in half-heights. -assert.equal(Math.round(a.y - b.y), Math.round((tall - 56) / 2)) +const [ca, cb] = [chain.get("a"), chain.get("b")] +assert.ok(ca && cb) +// Two single-port nodes wired to each other have nothing to step around, so +// the edge between them is level — which is what a straight edge means here. +assert.equal(ca.y, cb.y) +assert.ok(cb.x > ca.x) + +// The same graph twice is the same picture: a flow reopened cannot rearrange +// itself. +const again = await layoutGraph( + ["a", "b"], + [{ source: "a", target: "b", sourceHandle: "v", targetHandle: "v" }], + "LR", + new Map([ + ["a", one("v")], + ["b", one("v")], + ]), +) +assert.deepEqual([...again], [...chain]) + +/** + * The crossing this whole thing exists for. + * + * `c` declares its inputs as [high, low] and is fed by `a` on the *lower* one + * and `b` on the upper. Ordering the producers by the document — which is all + * a layout blind to ports can do — puts `a` on top and crosses the two edges + * just before they land. Reading the ports puts `b` there instead. + */ +const fan = await layoutGraph( + ["a", "b", "c"], + [ + { source: "a", target: "c", sourceHandle: "out", targetHandle: "low" }, + { source: "b", target: "c", sourceHandle: "out", targetHandle: "high" }, + ], + "LR", + new Map([ + ["a", { requires: [], provides: ["out"] }], + ["b", { requires: [], provides: ["out"] }], + ["c", { requires: ["high", "low"], provides: [] }], + ]), +) +const [fa, fb] = [fan.get("a"), fan.get("b")] +assert.ok(fa && fb) +assert.ok( + fb.y < fa.y, + `the producer feeding the upper port should sit above the other (a at ${fa.y}, b at ${fb.y})`, +) + +// Two messages between one pair of nodes are two edges, not one: both have to +// reach a port of their own. +const parallel = await layoutGraph( + ["a", "b"], + [ + { source: "a", target: "b", sourceHandle: "p1", targetHandle: "q1" }, + { source: "a", target: "b", sourceHandle: "p2", targetHandle: "q2" }, + ], + "LR", + new Map([ + ["a", { requires: [], provides: ["p1", "p2"] }], + ["b", { requires: ["q1", "q2"], provides: [] }], + ]), +) +const [pa, pb] = [parallel.get("a"), parallel.get("b")] +assert.ok(pa && pb) +assert.ok(pb.x > pa.x + 220, "the pair still ranks one after the other") + +// An endpoint is a label rather than a card, so it takes less room across than +// a node does — 192 against 220, plus the rank gap either side of the chip. +const labelled = await layoutGraph( + ["input:x", "n"], + [{ source: "input:x", target: "n", sourceHandle: "x", targetHandle: "x" }], + "LR", + new Map([ + ["input:x", { requires: [], provides: ["x"], endpoint: true }], + ["n", { requires: ["x"], provides: [] }], + ]), +) +const [ex, en] = [labelled.get("input:x"), labelled.get("n")] +assert.ok(ex && en) +assert.equal(ex.x, 40, "the graph starts at its margin") +assert.equal( + en.x - ex.x, + 192 + 55 + 150 + 55, + "an endpoint is 192 wide, then the chip's rank", +) + +/** + * The nodes are the spine; a label reading a value takes the room beside it. + * + * `mid` is read by a dashboard as well as by `end`, so the label for it lands + * in the same rank as `end`. Left to itself the layout gives the label the + * column the chain was running down and steps every node after it sideways, + * which on a phone is the whole graph zig-zagging past the screen. + */ +const spine = await layoutGraph( + ["start", "mid", "end", "dashboard:panel:v"], + [ + { source: "start", target: "mid", sourceHandle: "v", targetHandle: "v" }, + { source: "mid", target: "end", sourceHandle: "w", targetHandle: "w" }, + { + source: "start", + target: "dashboard:panel:v", + sourceHandle: "v", + targetHandle: "v", + }, + ], + "TB", + new Map([ + ["start", { requires: [], provides: ["v"] }], + ["mid", { requires: ["v"], provides: ["w"] }], + ["end", { requires: ["w"], provides: [] }], + ["dashboard:panel:v", { requires: ["v"], provides: [], endpoint: true }], + ]), +) +const [s1, s2] = [spine.get("start"), spine.get("mid")] +assert.ok(s1 && s2) +assert.equal( + s1.x, + s2.x, + `the chain steps sideways to let a label have its column (${s1.x} then ${s2.x})`, +) + +// Running downwards, a rank wider than a phone wraps onto the ranks below. +const wide = await layoutGraph( + ["src", "s0", "s1", "s2", "s3", "s4", "s5"], + ["s0", "s1", "s2", "s3", "s4", "s5"].map((sink) => ({ + source: "src", + target: sink, + sourceHandle: "v", + targetHandle: "v", + })), + "TB", + new Map( + ["src", "s0", "s1", "s2", "s3", "s4", "s5"].map((id) => [id, one("v")]), + ), +) +const rows = new Map() +for (const [, at] of wide) { + rows.set(Math.round(at.y), (rows.get(Math.round(at.y)) ?? 0) + 1) +} +for (const [y, count] of rows) { + assert.ok( + count <= 2, + `${count} nodes abreast at y=${y}, more than a phone fits`, + ) +} console.log("layout: ok") diff --git a/frontend/src/components/Flow/layout.ts b/frontend/src/components/Flow/layout.ts index b3681ac..9f645cb 100644 --- a/frontend/src/components/Flow/layout.ts +++ b/frontend/src/components/Flow/layout.ts @@ -1,4 +1,4 @@ -import dagre from "@dagrejs/dagre" +import type { ElkNode, ElkPort } from "elkjs/lib/elk-api" /** * Where the nodes of a flow go. @@ -10,19 +10,27 @@ import dagre from "@dagrejs/dagre" * * Left to right on a desktop, top to bottom on a phone, which is the direction * each screen has room to grow in. + * + * The layout is ELK's layered algorithm rather than a plain ranking, because + * the thing that actually crosses on this canvas is *ports*, not nodes. An edge + * leaves a particular handle and lands on a particular handle, so two producers + * feeding one consumer in the other order cross just before they land however + * well the nodes themselves are arranged. ELK is told where every handle sits + * and counts crossings there, which lets it order the nodes to undo the twist. */ export type Direction = "LR" | "TB" /** `FlowNode` is `min-w-[168px] max-w-[220px]`; an endpoint is narrower. */ const NODE_W = 220 +/** An endpoint is drawn as a label, `max-w-48` in `EndpointNode`. */ +const ENDPOINT_W = 192 /** Icon row plus two text lines, as measured. The floor, not the height. */ const NODE_H = 56 /** * How much of a node's edge the ports are spread over, as a fraction. * - * Shared with `FlowNode`'s `handleOffset`, which does the spreading: the - * height below is chosen so that span has room for the ports, so the two - * cannot be allowed to drift apart. + * Shared with the offsets below, which do the spreading: the height is chosen + * so that span has room for the ports, so the two cannot drift apart. */ export const PORT_SPAN = 0.6 /** @@ -42,20 +50,50 @@ export function nodeHeight(ports: number): number { return Math.max(NODE_H, Math.ceil(((ports - 1) * PORT_PITCH) / PORT_SPAN)) } +/** + * Where a port's handle sits along its side, as a fraction of that side. + * + * The one place this is decided. The node components render it as a percentage + * and the layout hands ELK the same number as a fixed port position, so what + * the crossing count is minimised over is exactly what ends up on the screen. + */ +export function portFraction(index: number, total: number): number { + if (total <= 1) return 0.5 + return 0.5 - PORT_SPAN / 2 + (PORT_SPAN / (total - 1)) * index +} + +/** {@link portFraction} as the CSS offset a handle is placed with. */ +export function handleOffset(index: number, total: number): string { + return `${portFraction(index, total) * 100}%` +} + /** * Room for the live value an edge carries (`LiveEdge`'s chip is - * `max-w-[140px]`). Reserved on the edge itself, so dagre routes nodes around - * the chip rather than through it. + * `max-w-[140px]`). Reserved on the edge itself, so the layout routes nodes + * around the chip rather than through it. * * Only its height is reserved when the graph runs downwards. The chip's width * is then spent across the screen rather than along the flow, and a phone has - * none to spare — while the rank gap it would otherwise widen is already 80px, + * none to spare — while the rank gap it would otherwise widen is already there, * more than the chip is tall. Sibling edges are a node's width apart there, so * the chips clear each other without being asked to. */ const LABEL_W = 150 const LABEL_H = 24 +/** Along a rank. */ +const NODE_GAP = 40 +/** + * Between two ranks — half of what a labelled edge should span. + * + * A chip gets a rank of its own and the gap is paid on both sides of it, so + * the pitch between two wired nodes is this twice over plus the chip: 480px + * across, 160px down, which is what the canvas has always used. + */ +const RANK_GAP = { LR: 55, TB: 40 } as const +/** Around the whole graph. */ +const MARGIN = 40 + /** * How many nodes may sit abreast when the graph runs downwards. * @@ -76,45 +114,179 @@ const ABREAST = 2 */ const WRAP_PASSES = 6 +/** + * What a node is, as far as the layout is concerned. + * + * The declared ports in the order they are drawn in, and whether the node is + * one of the endpoint labels rather than a card. Read from the document rather + * than from the canvas on purpose — see {@link nodeHeight}. + */ +export type NodeShape = { + requires: string[] + provides: string[] + endpoint?: boolean +} + +export type LayoutEdge = { + source: string + target: string + sourceHandle?: string | null + targetHandle?: string | null +} + +const EMPTY: NodeShape = { requires: [], provides: [] } + +/** + * ponytail: every node is still treated as its widest rather than measured. + * Measuring would feed the result back into the layout and oscillate; if nodes + * ever grow past that, take the width from `node.measured` once it has settled. + */ +function sizeOf(shape: NodeShape, direction: Direction) { + // Ports run down the sides only while the graph runs across, and that is the + // only direction in which their number decides the height: running downwards + // they spread along the node's width, which is fixed. + const ports = Math.max(shape.requires.length, shape.provides.length) + return { + width: shape.endpoint ? ENDPOINT_W : NODE_W, + height: direction === "LR" ? nodeHeight(ports) : NODE_H, + } +} + +/** A wire the layout adds and nobody draws: "put this one a rank further on". */ type Wrap = [string, string] -function build( +/** + * The graph as ELK takes it. + * + * Every declared port becomes a fixed point on the node's rim, at the fraction + * {@link portFraction} puts the handle at — including the ports nothing is + * wired to, since they still take up their slot in the spread and so decide + * where the wired ones land. + */ +function graphOf( ids: string[], - edges: { source: string; target: string }[], + edges: LayoutEdge[], direction: Direction, + shapes: Map, wrap: Wrap[], - heights: Map, -) { - const graph = new dagre.graphlib.Graph() - graph.setDefaultEdgeLabel(() => ({})) - graph.setGraph({ - rankdir: direction, - // Along the rank, and between ranks. A left-to-right graph needs the wider - // gap between ranks because the nodes themselves are wide. - nodesep: 40, - ranksep: direction === "LR" ? 110 : 80, - marginx: 40, - marginy: 40, +): ElkNode { + const across = direction === "LR" + // A port is identified by the side it is on as well as by its name: a node + // may well read and write the same message. The id ELK gets is generated + // rather than composed, so nothing has to be escaped out of a name somebody + // else chose, and the key that finds it again cannot run two of them + // together either. + const portIds = new Map() + const portKey = (side: string, node: string, port: string) => + JSON.stringify([side, node, port]) + + const children: ElkNode[] = ids.map((id) => { + const shape = shapes.get(id) ?? EMPTY + const { width, height } = sizeOf(shape, direction) + const ports: ElkPort[] = [] + + const side = ( + names: string[], + kind: "in" | "out", + elkSide: string, + at: (fraction: number) => { x: number; y: number }, + ) => { + names.forEach((name, index) => { + const portId = `p${portIds.size}` + portIds.set(portKey(kind, id, name), portId) + ports.push({ + id: portId, + width: 0, + height: 0, + ...at(portFraction(index, names.length)), + layoutOptions: { "org.eclipse.elk.port.side": elkSide }, + }) + }) + } + + side(shape.requires, "in", across ? "WEST" : "NORTH", (fraction) => + across ? { x: 0, y: height * fraction } : { x: width * fraction, y: 0 }, + ) + side(shape.provides, "out", across ? "EAST" : "SOUTH", (fraction) => + across + ? { x: width, y: height * fraction } + : { x: width * fraction, y: height }, + ) + + return { + id, + width, + height, + // Where the handles are is the whole point of using this algorithm, so + // ELK is told them exactly rather than left to invent an order. + layoutOptions: { "org.eclipse.elk.portConstraints": "FIXED_POS" }, + ports, + } }) - // Insertion order is what makes the result deterministic, so it follows the - // document rather than whatever order the edges happen to mention nodes in. - for (const id of ids) { - graph.setNode(id, { width: NODE_W, height: heights.get(id) ?? NODE_H }) - } - for (const edge of edges) { - if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue - graph.setEdge(edge.source, edge.target, { - width: direction === "LR" ? LABEL_W : 0, - height: LABEL_H, - labelpos: "c", - }) - } - // Nothing draws these: they only say "put this one a rank further down". - for (const [from, to] of wrap) graph.setEdge(from, to, { weight: 2 }) + const known = new Set(ids) + const wired = edges.filter( + (edge) => known.has(edge.source) && known.has(edge.target), + ) + const isEndpoint = (id: string) => shapes.get(id)?.endpoint === true + // An edge whose handle the node never declared — a half-bound spec — is hung + // off the node itself rather than dropped. + const endOf = (kind: "in" | "out", node: string, port?: string | null) => + (port && portIds.get(portKey(kind, node, port))) || node - dagre.layout(graph) - return graph + return { + id: "root", + layoutOptions: { + "org.eclipse.elk.algorithm": "layered", + "org.eclipse.elk.direction": across ? "RIGHT" : "DOWN", + "org.eclipse.elk.spacing.nodeNode": String(NODE_GAP), + "org.eclipse.elk.layered.spacing.nodeNodeBetweenLayers": String( + RANK_GAP[direction], + ), + "org.eclipse.elk.padding": `[top=${MARGIN},left=${MARGIN},bottom=${MARGIN},right=${MARGIN}]`, + // What decides a tie is the order the document lists things in, so the + // graph reads the way the flow is written rather than however the + // algorithm happened to land. + "org.eclipse.elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES", + // The default, set out loud: the same flow has to lay out the same way + // every time it is opened. + "org.eclipse.elk.randomSeed": "1", + }, + children, + edges: [ + ...wired.map((edge, index) => ({ + id: `e${index}`, + sources: [endOf("out", edge.source, edge.sourceHandle)], + targets: [endOf("in", edge.target, edge.targetHandle)], + // The nodes are the flow's spine and the labels only account for where + // a value also goes, so what is held straight is the run of node to + // node: a label takes the room beside that line rather than a place on + // it. Without this a dashboard reading a value halfway down steps the + // whole chain sideways to let its label have the column. + layoutOptions: + isEndpoint(edge.source) || isEndpoint(edge.target) + ? undefined + : { "org.eclipse.elk.layered.priority.straightness": "10" }, + labels: [ + { + // Nothing reads this — `LiveEdge` draws the chip itself — but a + // label with no text at all is one ELK reserves no room for. + text: " ", + width: across ? LABEL_W : 0, + height: LABEL_H, + layoutOptions: { "org.eclipse.elk.edgeLabels.inline": "true" }, + }, + ], + })), + // Nothing draws these, and they carry no chip: they only say "put this + // one a rank further down". + ...wrap.map(([from, to], index) => ({ + id: `w${index}`, + sources: [from], + targets: [to], + })), + ], + } } /** @@ -124,13 +296,21 @@ function build( * third to the first — and the fourth to the second, and so on — cannot make a * cycle. It lays the rank out in rows of `ABREAST`, in the order the document * lists them, so the wrap reads the way the flow is written. + * + * A rank running downwards is a band across the screen, so what identifies one + * is the y its nodes share. They do share it: every node is `NODE_H` tall in + * this direction, so nothing in a band is offset against its neighbours. */ -function wrapWideRanks(graph: ReturnType, ids: string[]): Wrap[] { +function wrapWideRanks( + placed: Map, + ids: string[], +): Wrap[] { const ranks = new Map() for (const id of ids) { - const node = graph.node(id) as { rank?: number } | undefined - if (node?.rank === undefined) continue - ranks.set(node.rank, [...(ranks.get(node.rank) ?? []), id]) + const at = placed.get(id) + if (!at) continue + const rank = Math.round(at.y) + ranks.set(rank, [...(ranks.get(rank) ?? []), id]) } const wrap: Wrap[] = [] @@ -143,24 +323,48 @@ function wrapWideRanks(graph: ReturnType, ids: string[]): Wrap[] { return wrap } +/** + * The layout engine, fetched the first time a flow needs laying out. + * + * Its own chunk: ELK is a compiled Java library and weighs more than the rest + * of the canvas put together, so nothing but this route ever loads it. + */ +let engine: Promise<{ layout: (graph: ElkNode) => Promise }> | null = + null + +function elk() { + engine ??= import("elkjs/lib/elk.bundled.js").then( + (module) => new module.default(), + ) + return engine +} + /** * Lay the graph out and return each node's top-left corner. * - * `heights` is what a node's ports make it, from {@link nodeHeight}; anything - * left out is the plain box. It is read from the document rather than from the - * canvas on purpose — see that function. - * - * ponytail: every node is still treated as 220 wide rather than measured. - * Measuring would feed the result back into the layout and oscillate; if nodes - * ever grow past that, take the width from `node.measured` once it has settled. + * `shapes` is what the document says each node is — its ports in the order + * they are drawn in, and whether it is an endpoint label. Anything left out is + * a plain box with no ports. */ -export function layoutGraph( +export async function layoutGraph( ids: string[], - edges: { source: string; target: string }[], + edges: LayoutEdge[], direction: Direction, - heights: Map = new Map(), -): Map { - let graph = build(ids, edges, direction, [], heights) + shapes: Map = new Map(), +): Promise> { + const engine = await elk() + const place = async (wrap: Wrap[]) => { + const laid = await engine.layout( + graphOf(ids, edges, direction, shapes, wrap), + ) + const placed = new Map() + for (const child of laid.children ?? []) { + placed.set(child.id, { x: child.x ?? 0, y: child.y ?? 0 }) + } + return placed + } + + let placed = await place([]) // Running downwards, a rank wider than the screen is the one thing the // layout can still do something about. Wrapping one rank pushes whatever was @@ -170,23 +374,13 @@ export function layoutGraph( if (direction === "TB") { const wrap: Wrap[] = [] for (let pass = 0; pass < WRAP_PASSES; pass += 1) { - const more = wrapWideRanks(graph, ids) + const more = wrapWideRanks(placed, ids) if (!more.length) break wrap.push(...more) - graph = build(ids, edges, direction, wrap, heights) + placed = await place(wrap) } } - // dagre places centres; React Flow wants top-left corners. - return new Map( - ids.map((id) => { - const node = graph.node(id) - return [ - id, - node - ? { x: node.x - NODE_W / 2, y: node.y - node.height / 2 } - : { x: 0, y: 0 }, - ] - }), - ) + // ELK places top-left corners already, which is what React Flow wants. + return new Map(ids.map((id) => [id, placed.get(id) ?? { x: 0, y: 0 }])) }