import type { ElkNode, ElkPort } from "elkjs/lib/elk-api" /** * 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 * can rearrange is one worth keeping small, which is what "atomic flow" means * here — and it also means a flow document carries no positions to go stale. * * 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 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 /** * Centre to centre between two handles. They are 12px across (`flow.css`), so * this leaves 6px of rim between them. */ const PORT_PITCH = 18 /** * How tall a node with this many ports on one side has to be. * * A pure function of the document — the ports are declared, so the height is * known before anything is drawn. That is what keeps it out of the measuring * problem below: nothing is fed back, so there is nothing to oscillate. */ 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 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 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. * * A node box is landscape, so siblings cost a rank four times as much across * as they do down it: eight of them side by side is two thousand pixels, which * a phone can only show by shrinking the labels out of existence. Two abreast * is 480px — the fit still reads at 390 — and anything beyond that wraps onto * the ranks below, so a wide fan-out grows the way the screen does. */ const ABREAST = 2 /** * How many times to let the wrapping settle before taking what it has. * * A bound rather than a fixed point: each pass moves nodes strictly downwards, * so it does converge, but a pathological graph should not be allowed to * relayout itself twenty times on a phone. */ const WRAP_PASSES = 6 /** * How wide a wrapped graph may still be before its ranks are centred. * * Wrapping keeps a rank to `ABREAST` nodes, but the layout straightens edges, * so each wrapped row lands where its edges pull it: the rows staircase * sideways, and a big flow ended up thousands of pixels across on a phone. * Past one node more than the rows allow, every rank is centred instead, which * stacks the rows into one column. Below it the straight edges are worth more, * which is the case for a flow that wraps a rank or two. */ const TOO_WIDE = (ABREAST + 1) * (NODE_W + NODE_GAP) /** * Room for a value chip when the ranks are centred, running downwards. * * Centring packs a rank's chips together where the edges meet in the middle, * so with no width at all they piled onto each other. This is a number's * worth: a longer value overlaps its neighbour rather than widening the graph. */ const CENTRED_LABEL_W = 60 /** * 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 = { 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: [] } /** * 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, } } /** 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] /** * 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: LayoutEdge[], direction: Direction, shapes: Map, wrap: Wrap[], centred: boolean, ): 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, } }) 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. const endOf = (kind: "in" | "out", node: string, port?: string | null) => (port && portIds.get(portKey(kind, node, port))) || node 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", // See `TOO_WIDE`. Every rank is centred on one axis, so a wrapped row // sits under the row above rather than where its edges pull it. What // bends the edges then takes room down the channels between the ranks, // so the runs there sit closer together. ...(centred ? { "org.eclipse.elk.layered.nodePlacement.strategy": "SIMPLE", "org.eclipse.elk.layered.spacing.edgeEdgeBetweenLayers": "6", } : {}), }, 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: [ { // 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 : centred ? CENTRED_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], })), ], } } /** * Which nodes have to move down a rank for the graph to stay narrow. * * Two nodes of the same rank never have an edge between them, so chaining the * 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(placed: Map, ids: string[]): Wrap[] { const ranks = new Map() for (const id of ids) { const at = placed.get(id) if (!at) continue const rank = Math.round(at.y) ranks.set(rank, [...(ranks.get(rank) ?? []), id]) } const wrap: Wrap[] = [] for (const rank of ranks.values()) { if (rank.length <= ABREAST) continue for (let i = ABREAST; i < rank.length; i += 1) { wrap.push([rank[i - ABREAST], rank[i]]) } } 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: 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 * a plain box with no ports. */ export async function layoutGraph( ids: string[], edges: LayoutEdge[], direction: Direction, shapes: Map = new Map(), ): Promise { const engine = await elk() const place = async (wrap: Wrap[], centred = false) => { const laid = await engine.layout( graphOf(ids, edges, direction, shapes, wrap, centred), ) const placed = new Map() for (const child of laid.children ?? []) { placed.set(child.id, { x: child.x ?? 0, y: child.y ?? 0 }) } return { placed, laid } } 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 // below it up into the room that freed, which can overfill a rank that was // fine — so it settles rather than being done once. A handful of passes over // a graph of a few dozen nodes, and none at all when nothing is too wide. if (direction === "TB") { const wrap: Wrap[] = [] for (let pass = 0; pass < WRAP_PASSES; pass += 1) { const more = wrapWideRanks(placed, ids) if (!more.length) break wrap.push(...more) ;({ placed, laid } = await place(wrap)) } const [left, right] = extentOf(placed, shapes, direction) if (wrap.length && right - left > TOO_WIDE) { ;({ placed, laid } = await place(wrap, true)) } } // 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`. const [left, right] = extentOf(positions, shapes, direction) 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 leftmost and rightmost x any node reaches. */ function extentOf( placed: Map, shapes: Map, direction: Direction, ): [number, number] { let [left, right] = [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY] for (const [id, at] of placed) { left = Math.min(left, at.x) right = Math.max( right, at.x + sizeOf(shapes.get(id) ?? EMPTY, direction).width, ) } return [left, right] } /** * 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 }