/** * 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"], [ { 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() 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")