/** * 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() 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")