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 }