Files
app/frontend/src/components/Flow/layout.ts
T

540 lines
20 KiB
TypeScript

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<string, Point>
/** By edge id. An edge whose ends the layout did not know has none. */
routes: Map<string, Route>
}
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<string, NodeShape>,
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<string, string>()
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}]`,
// Running downwards, a chip moved onto a run across the flow (see
// `longestRun`) is centred on it, so a run keeps half a chip and a
// node's border clear of the nodes it passes. ELK's default of 10px
// put such a chip over the border of the card above it.
...(across
? {}
: {
"org.eclipse.elk.layered.spacing.edgeNodeBetweenLayers": String(
LABEL_H / 2 + 2,
),
}),
// 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<string, Point>, ids: string[]): Wrap[] {
const ranks = new Map<number, string[]>()
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. And
* its own thread: a phone lays the Demo house out eight times over, about
* half a second each, which on the page would freeze it for seconds.
*/
let engine: Promise<{ layout: (graph: ElkNode) => Promise<ElkNode> }> | null =
null
function elk() {
engine ??= Promise.all([
import("elkjs/lib/elk-api.js"),
import("elkjs/lib/elk-worker.min.js?worker"),
]).then(
([api, worker]) =>
new api.default({ workerFactory: () => new worker.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<string, NodeShape> = new Map(),
): Promise<Layout> {
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<string, Point>()
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<string, Route>()
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<string, Point>,
shapes: Map<string, NodeShape>,
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
}