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

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")