From bcd2173a65d53ecbf2529084cba31fbb449869e2 Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 15 Oct 2026 18:24:38 +0200 Subject: [PATCH] Edges follow ELK's routes: layoutGraph returns each edge's start, bends, end and chip point, LiveEdge draws them square with rounded corners, moved onto the drawn handles, and keeps the curve for an edge whose node moved since; a feedback edge clears the nodes it passes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx --- docs/interface/flow-editor.md | 4 +- frontend/src/components/Flow/FlowEditor.tsx | 33 ++-- frontend/src/components/Flow/LiveEdge.tsx | 174 ++++++++++++++++--- frontend/src/components/Flow/deriveEdges.ts | 3 + frontend/src/components/Flow/layout.check.ts | 158 +++++++++++++++-- frontend/src/components/Flow/layout.ts | 128 ++++++++++++-- 6 files changed, 432 insertions(+), 68 deletions(-) diff --git a/docs/interface/flow-editor.md b/docs/interface/flow-editor.md index d5c222f..e188169 100644 --- a/docs/interface/flow-editor.md +++ b/docs/interface/flow-editor.md @@ -8,7 +8,9 @@ the connections and arranges the canvas for you. The graph runs left to right on desktop and top to bottom on phones. - **Nodes** show their execution status. -- **Edges** pulse when values pass through. Click one to inspect its payload. +- **Edges** are routed around the nodes, a loop back to an earlier node + included, and pulse when values pass through. Click one to inspect its + payload. - **Endpoints** show flow inputs, dashboard widgets, panel I/O ports and nodes in other flows that exchange messages with this flow. A value passing lights one up in a sweep: left to right when it sends into the flow, right to left diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index 34c286f..0ee46dc 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -57,6 +57,7 @@ import { bindingsKey, boundaryKey, deriveEdges, + type FlowEdgeData, flowOf, portOf, qualify, @@ -74,7 +75,12 @@ import { FlowNode, type FlowNodeData } from "./FlowNode" import { FlowPanel } from "./FlowPanel" import { InputInspector, type InspectedInput } from "./InputInspector" import { LiveEdge } from "./LiveEdge" -import { type Direction, layoutGraph, type NodeShape } from "./layout" +import { + type Direction, + type Layout, + layoutGraph, + type NodeShape, +} from "./layout" import { NodePanel } from "./NodePanel" import { RunDialog } from "./RunDialog" import "./flow.css" @@ -93,7 +99,7 @@ import { useFlowSocket } from "./useFlowSocket" const nodeTypes = { flow: FlowNode, [ENDPOINT_TYPE]: EndpointNode } /** - * Where every node sits, once the engine has said so. + * Where every node sits and how every edge runs, 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 @@ -107,18 +113,15 @@ function useLayout( direction: Direction, shapes: Map, shapeKey: string, -): Map | null { - const [placed, setPlaced] = useState | null>(null) +): Layout | null { + const [placed, setPlaced] = useState(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) => { + layoutGraph(ids, edges, direction, shapes).then((layout) => { // 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) + if (!stale) setPlaced(layout) }) return () => { stale = true @@ -644,7 +647,8 @@ function FlowEditorInner({ ] as const }), ]) - const positions = useLayout(ids, edges, direction, shapes, shapeKey) + const layout = useLayout(ids, edges, direction, shapes, shapeKey) + const positions = layout?.positions ?? null /** * The endpoints, placed. @@ -670,17 +674,22 @@ function FlowEditorInner({ ) // Marked after the layout has had the derived array, so selecting an edge - // never sends the graph through the layout again. + // never sends the graph through the layout again. The route rides along + // the same way. const shownEdges = useMemo( () => edges.map((edge) => ({ ...edge, + data: { + ...(edge.data as FlowEdgeData), + route: layout?.routes.get(edge.id), + }, selected: selectedEdges.has(edge.id) || edge.source === landing || edge.target === landing, })), - [edges, selectedEdges, landing], + [edges, selectedEdges, landing, layout], ) const shownNodes = useMemo( diff --git a/frontend/src/components/Flow/LiveEdge.tsx b/frontend/src/components/Flow/LiveEdge.tsx index 3a4940f..33b46b6 100644 --- a/frontend/src/components/Flow/LiveEdge.tsx +++ b/frontend/src/components/Flow/LiveEdge.tsx @@ -12,6 +12,7 @@ import { describeArtifact, isRef } from "@/lib/media" import { duration } from "@/lib/motion" import { cn } from "@/lib/utils" import type { FlowEdgeData } from "./deriveEdges" +import type { Point } from "./layout" import { useLiveValue } from "./liveStore" /** Below this zoom the value chips would be unreadable, so they step aside. */ @@ -20,22 +21,129 @@ 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 radius a routed edge turns its corners with. */ +const CORNER = 10 + /** - * The path a feedback edge takes. + * The route the layout found, met up with the handles React Flow drew. + * + * The layout reserves a card's widest and a label's widest, and either is + * narrower when its text is, so a handle on screen can sit beside the port the + * route was found for. Each end moves onto its handle and takes the bend next + * to it along, which keeps every segment square. A straight run between two + * handles that no longer line up gets a step halfway. + * + * Null for a route that does not alternate along and across the flow, which + * is all this knows how to move. + */ +function stitch( + route: Point[], + from: Point, + to: Point, + vertical: boolean, +): Point[] | null { + // [along the flow, across it] and back. + const split = (p: Point) => (vertical ? [p.y, p.x] : [p.x, p.y]) + const join = (along: number, across: number): Point => + vertical ? { x: across, y: along } : { x: along, y: across } + + // ELK can hand back a corner that is not one: two runs in the same line. + const points = route.filter((p, i) => { + const [a, b] = [route[i - 1], route[i + 1]] + return ( + !a || !b || ((a.x !== p.x || b.x !== p.x) && (a.y !== p.y || b.y !== p.y)) + ) + }) + for (let i = 1; i < points.length; i += 1) { + const [a0, c0] = split(points[i - 1]) + const [a1, c1] = split(points[i]) + // Odd segments run along the flow, even ones across it. + if (i % 2 ? c0 !== c1 : a0 !== a1) return null + } + if (points.length % 2) return null + + const [fromAlong, fromAcross] = split(from) + const [toAlong, toAcross] = split(to) + if (points.length === 2) { + if (fromAcross === toAcross) return [from, to] + const halfway = (fromAlong + toAlong) / 2 + return [from, join(halfway, fromAcross), join(halfway, toAcross), to] + } + const bends = points.slice(1, -1) + const first = bends[0] + const last = bends[bends.length - 1] + bends[0] = join(split(first)[0], fromAcross) + bends[bends.length - 1] = join(split(last)[0], toAcross) + return [from, ...bends, to] +} + +/** A square route with its corners rounded off. */ +function roundedPath(points: Point[]): string { + const towards = (from: Point, to: Point, by: number): Point => { + const length = Math.hypot(to.x - from.x, to.y - from.y) || 1 + return { + x: from.x + ((to.x - from.x) * by) / length, + y: from.y + ((to.y - from.y) * by) / length, + } + } + let path = `M${points[0].x},${points[0].y}` + for (let i = 1; i < points.length - 1; i += 1) { + const [before, corner, after] = [points[i - 1], points[i], points[i + 1]] + // Never more than half of either run, or two corners would overlap. + const radius = Math.min( + CORNER, + Math.hypot(corner.x - before.x, corner.y - before.y) / 2, + Math.hypot(after.x - corner.x, after.y - corner.y) / 2, + ) + const enter = towards(corner, before, radius) + const leave = towards(corner, after, radius) + path += ` L${enter.x},${enter.y} Q${corner.x},${corner.y} ${leave.x},${leave.y}` + } + const end = points[points.length - 1] + return `${path} L${end.x},${end.y}` +} + +/** The point of a polyline closest to `to`. */ +function nearest(points: Point[], to: Point): Point { + let best = points[0] + let gap = Number.POSITIVE_INFINITY + for (let i = 1; i < points.length; i += 1) { + const [a, b] = [points[i - 1], points[i]] + const [dx, dy] = [b.x - a.x, b.y - a.y] + const t = Math.max( + 0, + Math.min( + 1, + ((to.x - a.x) * dx + (to.y - a.y) * dy) / (dx * dx + dy * dy || 1), + ), + ) + const at = { x: a.x + t * dx, y: a.y + t * dy } + const distance = Math.hypot(at.x - to.x, at.y - to.y) + if (distance < gap) [best, gap] = [at, distance] + } + return best +} + +/** + * React Flow reads a handle's place out of the DOM through the zoom, which + * leaves noise in the last digits — enough for one flow to draw two different + * paths on two loads. Half a pixel is as fine as a handle is ever placed. + */ +const half = (value: number) => Math.round(value * 2) / 2 + +/** + * The path a feedback edge takes when the layout has no route for it. * * 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. + * comes back into its target from the far side. * - * 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. + * How far out the lane sits is read off how far back the edge has to travel, + * not off what is in the way, which is good enough for the moment between an + * edit and the layout answering it. */ function cyclePath( sourceX: number, @@ -75,6 +183,8 @@ function formatValue(value: unknown): string { function LiveEdgeComponent({ id, + source, + target, sourceX, sourceY, targetX, @@ -84,9 +194,20 @@ function LiveEdgeComponent({ data, selected, }: EdgeProps) { - const { message, producerId, stream } = (data ?? {}) as FlowEdgeData + const { message, producerId, stream, route } = (data ?? {}) as FlowEdgeData const live = useLiveValue(message) const zoom = useStore((state) => state.transform[2]) + // The route holds while both ends are where the layout put them. One that + // has moved since — dragged, or placed by a layout this edge is not from yet + // — takes the plain curve until the next layout routes it again. + const placed = useStore((state) => { + if (!route) return false + const at = (node: string, where: Point) => { + const now = state.nodeLookup.get(node)?.internals.positionAbsolute + return now?.x === where.x && now?.y === where.y + } + return at(source, route.from) && at(target, route.to) + }) const [pulsing, setPulsing] = useState(false) const lastTs = useRef(null) // Owned by the pulse rather than by the effect: a value from another @@ -95,21 +216,34 @@ function LiveEdgeComponent({ const timer = useRef>(undefined) useEffect(() => () => clearTimeout(timer.current), []) + const vertical = sourcePosition === Position.Bottom + const square = + placed && route + ? stitch( + route.points, + { x: half(sourceX), y: half(sourceY) }, + { x: half(targetX), y: half(targetY) }, + vertical, + ) + : null // 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 chip = square && route ? nearest(square, route.label) : null - const [path, labelX, labelY] = backwards - ? cyclePath(sourceX, sourceY, targetX, targetY, vertical) - : getBezierPath({ - sourceX, - sourceY, - sourcePosition, - targetX, - targetY, - targetPosition, - }) + const [path, labelX, labelY] = + square && chip + ? [roundedPath(square), chip.x, chip.y] + : 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/deriveEdges.ts b/frontend/src/components/Flow/deriveEdges.ts index 07f5906..d72a253 100644 --- a/frontend/src/components/Flow/deriveEdges.ts +++ b/frontend/src/components/Flow/deriveEdges.ts @@ -1,6 +1,7 @@ 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 { @@ -44,6 +45,8 @@ export type FlowEdgeData = { 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 } diff --git a/frontend/src/components/Flow/layout.check.ts b/frontend/src/components/Flow/layout.check.ts index d0a9953..d96ff15 100644 --- a/frontend/src/components/Flow/layout.check.ts +++ b/frontend/src/components/Flow/layout.check.ts @@ -51,14 +51,22 @@ 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", sourceHandle: "v", targetHandle: "v" }], + [ + { + id: "a>b", + source: "a", + target: "b", + sourceHandle: "v", + targetHandle: "v", + }, + ], "LR", new Map([ ["a", one("v")], ["b", one("v")], ]), ) -const [ca, cb] = [chain.get("a"), chain.get("b")] +const [ca, cb] = [chain.positions.get("a"), chain.positions.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. @@ -69,14 +77,22 @@ assert.ok(cb.x > ca.x) // itself. const again = await layoutGraph( ["a", "b"], - [{ source: "a", target: "b", sourceHandle: "v", targetHandle: "v" }], + [ + { + id: "a>b", + source: "a", + target: "b", + sourceHandle: "v", + targetHandle: "v", + }, + ], "LR", new Map([ ["a", one("v")], ["b", one("v")], ]), ) -assert.deepEqual([...again], [...chain]) +assert.deepEqual(again, chain) /** * The crossing this whole thing exists for. @@ -89,8 +105,20 @@ assert.deepEqual([...again], [...chain]) const fan = await layoutGraph( ["a", "b", "c"], [ - { source: "a", target: "c", sourceHandle: "out", targetHandle: "low" }, - { source: "b", target: "c", sourceHandle: "out", targetHandle: "high" }, + { + id: "a>c", + source: "a", + target: "c", + sourceHandle: "out", + targetHandle: "low", + }, + { + id: "b>c", + source: "b", + target: "c", + sourceHandle: "out", + targetHandle: "high", + }, ], "LR", new Map([ @@ -99,7 +127,7 @@ const fan = await layoutGraph( ["c", { requires: ["high", "low"], provides: [] }], ]), ) -const [fa, fb] = [fan.get("a"), fan.get("b")] +const [fa, fb] = [fan.positions.get("a"), fan.positions.get("b")] assert.ok(fa && fb) assert.ok( fb.y < fa.y, @@ -111,8 +139,20 @@ assert.ok( const parallel = await layoutGraph( ["a", "b"], [ - { source: "a", target: "b", sourceHandle: "p1", targetHandle: "q1" }, - { source: "a", target: "b", sourceHandle: "p2", targetHandle: "q2" }, + { + id: "1", + source: "a", + target: "b", + sourceHandle: "p1", + targetHandle: "q1", + }, + { + id: "2", + source: "a", + target: "b", + sourceHandle: "p2", + targetHandle: "q2", + }, ], "LR", new Map([ @@ -120,7 +160,7 @@ const parallel = await layoutGraph( ["b", { requires: ["q1", "q2"], provides: [] }], ]), ) -const [pa, pb] = [parallel.get("a"), parallel.get("b")] +const [pa, pb] = [parallel.positions.get("a"), parallel.positions.get("b")] assert.ok(pa && pb) assert.ok(pb.x > pa.x + 220, "the pair still ranks one after the other") @@ -128,14 +168,25 @@ assert.ok(pb.x > pa.x + 220, "the pair still ranks one after the other") // 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" }], + [ + { + id: "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")] +const [ex, en] = [ + labelled.positions.get("input:x"), + labelled.positions.get("n"), +] assert.ok(ex && en) assert.equal(ex.x, 40, "the graph starts at its margin") assert.equal( @@ -155,9 +206,22 @@ assert.equal( 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" }, { + id: "start>mid", + source: "start", + target: "mid", + sourceHandle: "v", + targetHandle: "v", + }, + { + id: "mid>end", + source: "mid", + target: "end", + sourceHandle: "w", + targetHandle: "w", + }, + { + id: "start>dashboard:panel:v", source: "start", target: "dashboard:panel:v", sourceHandle: "v", @@ -172,7 +236,7 @@ const spine = await layoutGraph( ["dashboard:panel:v", { requires: ["v"], provides: [], endpoint: true }], ]), ) -const [s1, s2] = [spine.get("start"), spine.get("mid")] +const [s1, s2] = [spine.positions.get("start"), spine.positions.get("mid")] assert.ok(s1 && s2) assert.equal( s1.x, @@ -184,6 +248,7 @@ assert.equal( const wide = await layoutGraph( ["src", "s0", "s1", "s2", "s3", "s4", "s5"], ["s0", "s1", "s2", "s3", "s4", "s5"].map((sink) => ({ + id: sink, source: "src", target: sink, sourceHandle: "v", @@ -195,7 +260,7 @@ const wide = await layoutGraph( ), ) const rows = new Map() -for (const [, at] of wide) { +for (const [, at] of wide.positions) { rows.set(Math.round(at.y), (rows.get(Math.round(at.y)) ?? 0) + 1) } for (const [y, count] of rows) { @@ -205,4 +270,65 @@ for (const [y, count] of rows) { ) } +/** + * Every edge comes back routed, port to port, square, and around what is in + * the way. `back` closes a cycle over `mid`: drawn straight between its two + * handles it would run through `mid`, which is what the route exists to avoid. + */ +for (const direction of ["LR", "TB"] as const) { + const ids = ["start", "mid", "end"] + const loop = await layoutGraph( + ids, + [ + { + id: "on", + source: "start", + target: "mid", + sourceHandle: "v", + targetHandle: "v", + }, + { + id: "off", + source: "mid", + target: "end", + sourceHandle: "v", + targetHandle: "v", + }, + { + id: "back", + source: "end", + target: "start", + sourceHandle: "v", + targetHandle: "v", + }, + ], + direction, + new Map(ids.map((id) => [id, one("v")])), + ) + const back = loop.routes.get("back") + const end = loop.positions.get("end") + const mid = loop.positions.get("mid") + assert.ok(back && end && mid && loop.routes.size === 3) + // One port on each side, so it sits halfway along: the bottom or the right. + assert.deepEqual( + back.points[0], + direction === "TB" + ? { x: end.x + 110, y: end.y + NODE_H } + : { x: end.x + 220, y: end.y + NODE_H / 2 }, + ) + for (let i = 1; i < back.points.length; i += 1) { + const [a, b] = [back.points[i - 1], back.points[i]] + assert.ok(a.x === b.x || a.y === b.y, `a slanted run in ${direction}`) + const [left, right] = [Math.min(a.x, b.x), Math.max(a.x, b.x)] + const [top, bottom] = [Math.min(a.y, b.y), Math.max(a.y, b.y)] + assert.ok( + right <= mid.x || + left >= mid.x + 220 || + bottom <= mid.y || + top >= mid.y + NODE_H, + `the feedback edge runs through the node it passes in ${direction}`, + ) + } +} + console.log("layout: ok") diff --git a/frontend/src/components/Flow/layout.ts b/frontend/src/components/Flow/layout.ts index 9f645cb..d540c72 100644 --- a/frontend/src/components/Flow/layout.ts +++ b/frontend/src/components/Flow/layout.ts @@ -1,7 +1,7 @@ import type { ElkNode, ElkPort } from "elkjs/lib/elk-api" /** - * Where the nodes of a flow go. + * Where the nodes of a flow go, and how the edges between them run. * * Nothing on this canvas is placed by hand: a flow is a graph the editor draws, * not a picture someone arranges. That is the design decision — a canvas nobody @@ -128,12 +128,35 @@ export type NodeShape = { } export type LayoutEdge = { + id: string source: string target: string sourceHandle?: string | null targetHandle?: string | null } +export type Point = { x: number; y: number } + +/** + * The way the layout routed an edge: from the source port through its bends to + * the target port, square to the ranks. `from` and `to` are where its two + * nodes were placed, so a drawing can tell when either has moved since. + */ +export type Route = { + points: Point[] + /** Where the room reserved for the edge's value chip is centred. */ + label: Point + from: Point + to: Point +} + +export type Layout = { + /** Each node's top-left corner. */ + positions: Map + /** By edge id. An edge whose ends the layout did not know has none. */ + routes: Map +} + const EMPTY: NodeShape = { requires: [], provides: [] } /** @@ -152,6 +175,14 @@ function sizeOf(shape: NodeShape, direction: Direction) { } } +/** The edges the layout can place: both of their nodes are in it. */ +function wiredOf(ids: string[], edges: LayoutEdge[]): LayoutEdge[] { + const known = new Set(ids) + return edges.filter( + (edge) => known.has(edge.source) && known.has(edge.target), + ) +} + /** A wire the layout adds and nobody draws: "put this one a rank further on". */ type Wrap = [string, string] @@ -224,10 +255,7 @@ function graphOf( } }) - const known = new Set(ids) - const wired = edges.filter( - (edge) => known.has(edge.source) && known.has(edge.target), - ) + const wired = wiredOf(ids, edges) 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. @@ -269,8 +297,8 @@ function graphOf( : { "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. + // Only where it lands is read — `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, @@ -301,10 +329,7 @@ function graphOf( * 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( - placed: Map, - ids: string[], -): Wrap[] { +function wrapWideRanks(placed: Map, ids: string[]): Wrap[] { const ranks = new Map() for (const id of ids) { const at = placed.get(id) @@ -340,7 +365,7 @@ function elk() { } /** - * Lay the graph out and return each node's top-left corner. + * Lay the graph out: each node's top-left corner, and the route of each edge. * * `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 @@ -351,20 +376,20 @@ export async function layoutGraph( edges: LayoutEdge[], direction: Direction, shapes: Map = new Map(), -): Promise> { +): 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() + const placed = new Map() for (const child of laid.children ?? []) { placed.set(child.id, { x: child.x ?? 0, y: child.y ?? 0 }) } - return placed + return { placed, laid } } - let placed = await place([]) + let { placed, laid } = 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 @@ -377,10 +402,75 @@ export async function layoutGraph( const more = wrapWideRanks(placed, ids) if (!more.length) break wrap.push(...more) - placed = await place(wrap) + ;({ placed, laid } = await place(wrap)) } } - // 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 }])) + // ELK places top-left corners already, which is what React Flow wants, and + // routes every edge in the same frame: they all hang off the root. + const positions = new Map( + ids.map((id) => [id, placed.get(id) ?? { x: 0, y: 0 }]), + ) + const wired = wiredOf(ids, edges) + // How far across the nodes reach, for `longestRun`. + let [left, right] = [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY] + for (const [id, at] of positions) { + left = Math.min(left, at.x) + right = Math.max( + right, + at.x + sizeOf(shapes.get(id) ?? EMPTY, direction).width, + ) + } + const routes = new Map() + for (const edge of laid.edges ?? []) { + const index = Number(edge.id.slice(1)) + const section = edge.sections?.[0] + // The wrap wires are `w`; only the ones somebody draws are `e`. + if (!edge.id.startsWith("e") || !section) continue + const label = edge.labels?.[0] + const { source, target, id } = wired[index] + const points = [ + section.startPoint, + ...(section.bendPoints ?? []), + section.endPoint, + ] + const room = label + ? { + x: (label.x ?? 0) + (label.width ?? 0) / 2, + y: (label.y ?? 0) + (label.height ?? 0) / 2, + } + : section.startPoint + routes.set(id, { + points, + label: + direction === "TB" && (room.x < left || room.x > right) + ? (longestRun(points) ?? room) + : room, + from: positions.get(source) ?? { x: 0, y: 0 }, + to: positions.get(target) ?? { x: 0, y: 0 }, + }) + } + return { positions, routes } +} + +/** + * The middle of a route's longest run across the flow, running downwards. + * + * Where the chip of an edge goes when ELK put its label outside every node's + * column, on a lane round the side of the graph. A label is given no width in + * this direction (see {@link LABEL_W}), so a chip centred on that lane would + * hang half of itself past the edge of the fit. On a run across, its width + * lies along the line. + */ +function longestRun(points: Point[]): Point | null { + let best: Point | null = null + let longest = 0 + for (let i = 1; i < points.length; i += 1) { + const [a, b] = [points[i - 1], points[i]] + const length = Math.abs(b.x - a.x) + if (a.y !== b.y || length <= longest) continue + longest = length + best = { x: (a.x + b.x) / 2, y: a.y } + } + return best }