The canvas placed nodes with dagre, which orders nodes but knows nothing about ports: an edge leaves a particular handle and lands on a particular one, so two producers feeding a consumer in the other order crossed just before they landed however well the nodes were arranged. The graph was not a multigraph either, so two messages between one pair of nodes collapsed into one before crossings were counted, and the endpoint labels were laid out as 220-wide cards with every handle stacked on a single pixel. ELK's layered algorithm replaces it. Every declared port is handed to it as a fixed point on the node's rim, at the fraction `portFraction` puts the handle at — which the node components now render from the same function — so what the crossing count is minimised over is what ends up on the screen. Measured on this installation's flows, with the labels included: `home` goes from 51 crossings to 6 across and 70 to 29 down, `demo_training` 27 to 5 and 59 to 32, and every graph is the same size or smaller. The engine answers asynchronously and is a chunk of its own, so positions became state: the canvas draws nothing until the first layout lands, and an edit keeps the arrangement it had rather than flashing through the corner. The entry chunk is untouched and the flow route's own chunk came down 167 to 121 kB, since dagre used to be in it. Two things the crossings made worse come with it. A hovered edge resolves to full strength so one line can be followed through a busy rank, and a feedback edge — one whose target the layout put behind its source — swings out into a lane beside the graph instead of being drawn through everything between its ends, which is what "House history" was reported for.
209 lines
6.8 KiB
TypeScript
209 lines
6.8 KiB
TypeScript
/**
|
|
* What the layout promises: a node tall enough for its ports, an edge that
|
|
* lands where its handle is, and no crossing it could have avoided.
|
|
*
|
|
* Run: `bun src/components/Flow/layout.check.ts` (there is no unit runner; the
|
|
* suite in `tests/` drives a running stack). Typechecked with the rest of
|
|
* `src` and imported by nothing, so it is not in the bundle.
|
|
*/
|
|
import assert from "node:assert/strict"
|
|
|
|
// ELK's fake worker decides where it is running by looking for `self` without a
|
|
// `document` beside it, which is a browser worker — and is also bun. Taking the
|
|
// global away lets it load here; nothing in a browser goes near this.
|
|
// @ts-expect-error the runtime has it, the DOM types say it is not removable.
|
|
globalThis.self = undefined
|
|
|
|
const { layoutGraph, nodeHeight, portFraction, PORT_SPAN } = await import(
|
|
"./layout"
|
|
)
|
|
|
|
/** Handles are 12px across — see `.react-flow__handle` in `flow.css`. */
|
|
const HANDLE = 12
|
|
const NODE_H = 56
|
|
|
|
// Two ports fit the plain box, so most nodes are unchanged.
|
|
assert.equal(nodeHeight(0), NODE_H)
|
|
assert.equal(nodeHeight(1), NODE_H)
|
|
assert.equal(nodeHeight(2), NODE_H)
|
|
assert.ok(nodeHeight(3) > NODE_H)
|
|
|
|
for (let ports = 2; ports <= 12; ports += 1) {
|
|
const height = nodeHeight(ports)
|
|
// The ports are spread over `PORT_SPAN` of the edge, so that is the room
|
|
// they actually get. Two handles must not touch.
|
|
const between = (PORT_SPAN * height) / (ports - 1)
|
|
assert.ok(
|
|
between > HANDLE,
|
|
`${ports} ports on a ${height}px node leave ${between}px between handles`,
|
|
)
|
|
// Growing, never shrinking: one more port can only need more room.
|
|
assert.ok(height >= nodeHeight(ports - 1))
|
|
}
|
|
|
|
// A single port sits in the middle; several spread symmetrically about it.
|
|
assert.equal(portFraction(0, 1), 0.5)
|
|
assert.equal(portFraction(0, 2) + portFraction(1, 2), 1)
|
|
assert.ok(portFraction(0, 2) < portFraction(1, 2))
|
|
|
|
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" }],
|
|
"LR",
|
|
new Map([
|
|
["a", one("v")],
|
|
["b", one("v")],
|
|
]),
|
|
)
|
|
const [ca, cb] = [chain.get("a"), chain.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.
|
|
assert.equal(ca.y, cb.y)
|
|
assert.ok(cb.x > ca.x)
|
|
|
|
// The same graph twice is the same picture: a flow reopened cannot rearrange
|
|
// itself.
|
|
const again = await layoutGraph(
|
|
["a", "b"],
|
|
[{ source: "a", target: "b", sourceHandle: "v", targetHandle: "v" }],
|
|
"LR",
|
|
new Map([
|
|
["a", one("v")],
|
|
["b", one("v")],
|
|
]),
|
|
)
|
|
assert.deepEqual([...again], [...chain])
|
|
|
|
/**
|
|
* The crossing this whole thing exists for.
|
|
*
|
|
* `c` declares its inputs as [high, low] and is fed by `a` on the *lower* one
|
|
* and `b` on the upper. Ordering the producers by the document — which is all
|
|
* a layout blind to ports can do — puts `a` on top and crosses the two edges
|
|
* just before they land. Reading the ports puts `b` there instead.
|
|
*/
|
|
const fan = await layoutGraph(
|
|
["a", "b", "c"],
|
|
[
|
|
{ source: "a", target: "c", sourceHandle: "out", targetHandle: "low" },
|
|
{ source: "b", target: "c", sourceHandle: "out", targetHandle: "high" },
|
|
],
|
|
"LR",
|
|
new Map([
|
|
["a", { requires: [], provides: ["out"] }],
|
|
["b", { requires: [], provides: ["out"] }],
|
|
["c", { requires: ["high", "low"], provides: [] }],
|
|
]),
|
|
)
|
|
const [fa, fb] = [fan.get("a"), fan.get("b")]
|
|
assert.ok(fa && fb)
|
|
assert.ok(
|
|
fb.y < fa.y,
|
|
`the producer feeding the upper port should sit above the other (a at ${fa.y}, b at ${fb.y})`,
|
|
)
|
|
|
|
// Two messages between one pair of nodes are two edges, not one: both have to
|
|
// reach a port of their own.
|
|
const parallel = await layoutGraph(
|
|
["a", "b"],
|
|
[
|
|
{ source: "a", target: "b", sourceHandle: "p1", targetHandle: "q1" },
|
|
{ source: "a", target: "b", sourceHandle: "p2", targetHandle: "q2" },
|
|
],
|
|
"LR",
|
|
new Map([
|
|
["a", { requires: [], provides: ["p1", "p2"] }],
|
|
["b", { requires: ["q1", "q2"], provides: [] }],
|
|
]),
|
|
)
|
|
const [pa, pb] = [parallel.get("a"), parallel.get("b")]
|
|
assert.ok(pa && pb)
|
|
assert.ok(pb.x > pa.x + 220, "the pair still ranks one after the other")
|
|
|
|
// An endpoint is a label rather than a card, so it takes less room across than
|
|
// 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" }],
|
|
"LR",
|
|
new Map([
|
|
["input:x", { requires: [], provides: ["x"], endpoint: true }],
|
|
["n", { requires: ["x"], provides: [] }],
|
|
]),
|
|
)
|
|
const [ex, en] = [labelled.get("input:x"), labelled.get("n")]
|
|
assert.ok(ex && en)
|
|
assert.equal(ex.x, 40, "the graph starts at its margin")
|
|
assert.equal(
|
|
en.x - ex.x,
|
|
192 + 55 + 150 + 55,
|
|
"an endpoint is 192 wide, then the chip's rank",
|
|
)
|
|
|
|
/**
|
|
* The nodes are the spine; a label reading a value takes the room beside it.
|
|
*
|
|
* `mid` is read by a dashboard as well as by `end`, so the label for it lands
|
|
* in the same rank as `end`. Left to itself the layout gives the label the
|
|
* column the chain was running down and steps every node after it sideways,
|
|
* which on a phone is the whole graph zig-zagging past the screen.
|
|
*/
|
|
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" },
|
|
{
|
|
source: "start",
|
|
target: "dashboard:panel:v",
|
|
sourceHandle: "v",
|
|
targetHandle: "v",
|
|
},
|
|
],
|
|
"TB",
|
|
new Map([
|
|
["start", { requires: [], provides: ["v"] }],
|
|
["mid", { requires: ["v"], provides: ["w"] }],
|
|
["end", { requires: ["w"], provides: [] }],
|
|
["dashboard:panel:v", { requires: ["v"], provides: [], endpoint: true }],
|
|
]),
|
|
)
|
|
const [s1, s2] = [spine.get("start"), spine.get("mid")]
|
|
assert.ok(s1 && s2)
|
|
assert.equal(
|
|
s1.x,
|
|
s2.x,
|
|
`the chain steps sideways to let a label have its column (${s1.x} then ${s2.x})`,
|
|
)
|
|
|
|
// Running downwards, a rank wider than a phone wraps onto the ranks below.
|
|
const wide = await layoutGraph(
|
|
["src", "s0", "s1", "s2", "s3", "s4", "s5"],
|
|
["s0", "s1", "s2", "s3", "s4", "s5"].map((sink) => ({
|
|
source: "src",
|
|
target: sink,
|
|
sourceHandle: "v",
|
|
targetHandle: "v",
|
|
})),
|
|
"TB",
|
|
new Map(
|
|
["src", "s0", "s1", "s2", "s3", "s4", "s5"].map((id) => [id, one("v")]),
|
|
),
|
|
)
|
|
const rows = new Map<number, number>()
|
|
for (const [, at] of wide) {
|
|
rows.set(Math.round(at.y), (rows.get(Math.round(at.y)) ?? 0) + 1)
|
|
}
|
|
for (const [y, count] of rows) {
|
|
assert.ok(
|
|
count <= 2,
|
|
`${count} nodes abreast at y=${y}, more than a phone fits`,
|
|
)
|
|
}
|
|
|
|
console.log("layout: ok")
|