381 lines
11 KiB
TypeScript
381 lines
11 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
|
|
|
|
// A page runs ELK in a worker Vite builds (`?worker`). Here the same build
|
|
// runs in-thread, as the fake worker it exports outside a worker.
|
|
// @ts-expect-error the worker build ships without types.
|
|
const { Worker: InThread } = await import("elkjs/lib/elk-worker.min.js")
|
|
// @ts-expect-error bun's global, which the DOM types do not know.
|
|
Bun.plugin({
|
|
name: "elk in-thread",
|
|
setup: (build: { module: (id: string, load: () => object) => void }) =>
|
|
build.module("elkjs/lib/elk-worker.min.js?worker", () => ({
|
|
exports: { default: InThread },
|
|
loader: "object",
|
|
})),
|
|
})
|
|
|
|
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"],
|
|
[
|
|
{
|
|
id: "a>b",
|
|
source: "a",
|
|
target: "b",
|
|
sourceHandle: "v",
|
|
targetHandle: "v",
|
|
},
|
|
],
|
|
"LR",
|
|
new Map([
|
|
["a", one("v")],
|
|
["b", one("v")],
|
|
]),
|
|
)
|
|
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.
|
|
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"],
|
|
[
|
|
{
|
|
id: "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"],
|
|
[
|
|
{
|
|
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([
|
|
["a", { requires: [], provides: ["out"] }],
|
|
["b", { requires: [], provides: ["out"] }],
|
|
["c", { requires: ["high", "low"], provides: [] }],
|
|
]),
|
|
)
|
|
const [fa, fb] = [fan.positions.get("a"), fan.positions.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"],
|
|
[
|
|
{
|
|
id: "1",
|
|
source: "a",
|
|
target: "b",
|
|
sourceHandle: "p1",
|
|
targetHandle: "q1",
|
|
},
|
|
{
|
|
id: "2",
|
|
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.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")
|
|
|
|
// 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"],
|
|
[
|
|
{
|
|
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.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(
|
|
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"],
|
|
[
|
|
{
|
|
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",
|
|
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.positions.get("start"), spine.positions.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) => ({
|
|
id: 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.positions) {
|
|
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`,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* A hub read by eight readers, each feeding a sink of its own: the shape of
|
|
* the Demo's house and its dashboards. Wrapped two abreast with straight
|
|
* edges, each row landed where its edges pulled it and the graph staircased
|
|
* to 1400px across. Centred, the rows stack into one column.
|
|
*/
|
|
const readers = Array.from({ length: 8 }, (_, i) => `r${i}`)
|
|
const hub = await layoutGraph(
|
|
["hub", ...readers, ...readers.map((r) => `${r}.sink`)],
|
|
readers.flatMap((r) => [
|
|
{ id: r, source: "hub", target: r, sourceHandle: "v", targetHandle: "v" },
|
|
{
|
|
id: `${r}.sink`,
|
|
source: r,
|
|
target: `${r}.sink`,
|
|
sourceHandle: "v",
|
|
targetHandle: "v",
|
|
},
|
|
]),
|
|
"TB",
|
|
new Map(
|
|
["hub", ...readers, ...readers.map((r) => `${r}.sink`)].map((id) => [
|
|
id,
|
|
one("v"),
|
|
]),
|
|
),
|
|
)
|
|
const hubX = [...hub.positions.values()].map((at) => at.x)
|
|
const across = Math.max(...hubX) + 220 - Math.min(...hubX)
|
|
// Three nodes and their gaps: one more than the two a row holds.
|
|
assert.ok(across <= 3 * 260, `a wrapped graph runs ${across}px across`)
|
|
|
|
/**
|
|
* 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")
|