540 lines
20 KiB
TypeScript
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
|
|
}
|