| … | … | @@ -0,0 +1,231 @@ |
| 1 | /** | |
| 2 | * Compound layout — a graph whose zones are CONTAINERS, not boxes drawn after | |
| 3 | * the fact. | |
| 4 | * | |
| 5 | * The old node view layered nodes into columns and then computed each frame's | |
| 6 | * rectangle from the bounding box of its members. Because every column ordered | |
| 7 | * its own rows independently, one frame's members in column 2 interleaved with | |
| 8 | * another frame's in column 3, and the two rectangles crossed. No amount of | |
| 9 | * post-hoc box fitting can fix that: the members were never placed together. | |
| 10 | * | |
| 11 | * So a container is a layout UNIT. Its contents are laid out in their own frame, | |
| 12 | * that frame becomes a box with a size, and the parent lays the box out as if it | |
| 13 | * were a single node. Siblings therefore cannot interleave — they are placed as | |
| 14 | * whole rectangles in one pass — and a parent encloses its children by | |
| 15 | * construction rather than by measurement. | |
| 16 | * | |
| 17 | * Everything here is pure: same input, same output, no clock, no DOM. Node sizes | |
| 18 | * come IN, so expanding a node's code is just a new `h` and another call | |
| 19 | * (spec L4). | |
| 20 | */ | |
| 21 | ||
| 22 | const DEFAULTS = { | |
| 23 | colGap: 130, // horizontal space between dependency columns | |
| 24 | rowGap: 26, // vertical space between siblings in a column | |
| 25 | pad: 16, // container padding around its contents | |
| 26 | labelH: 18 // extra headroom inside a container for its label | |
| 27 | } | |
| 28 | ||
| 29 | // The implicit group that holds everything with no container of its own. Not a | |
| 30 | // real container: it is never drawn and gets no padding or label. | |
| 31 | const ROOT = '__layout_root__' | |
| 32 | ||
| 33 | /** | |
| 34 | * @param {object} input | |
| 35 | * nodes: [{ id, container, w, h }] container = a container id or null | |
| 36 | * links: [{ from, to }] node ids | |
| 37 | * containers: [{ id, parent, label }] parent = a container id or null | |
| 38 | * @returns {object} { nodes: [{id,x,y,w,h}], containers: [{id,x,y,w,h,depth,label}], | |
| 39 | * width, height } | |
| 40 | */ | |
| 41 | export function layout (input, opts = {}) { | |
| 42 | const O = { ...DEFAULTS, ...opts } | |
| 43 | const nodes = input.nodes || [] | |
| 44 | const links = input.links || [] | |
| 45 | const containers = input.containers || [] | |
| 46 | ||
| 47 | const nodeById = new Map() | |
| 48 | for (const n of nodes) nodeById.set(n.id, n) | |
| 49 | ||
| 50 | const containerById = new Map() | |
| 51 | for (const c of containers) containerById.set(c.id, c) | |
| 52 | ||
| 53 | // Naming a parent that does not exist puts you at the root rather than | |
| 54 | // vanishing from the layout. | |
| 55 | function parentOf (c) { | |
| 56 | if (!c.parent) return ROOT | |
| 57 | return containerById.has(c.parent) ? c.parent : ROOT | |
| 58 | } | |
| 59 | function groupOf (n) { | |
| 60 | if (!n.container) return ROOT | |
| 61 | return containerById.has(n.container) ? n.container : ROOT | |
| 62 | } | |
| 63 | ||
| 64 | // --- the group tree: which nodes and which child containers each group holds | |
| 65 | const childNodes = new Map() | |
| 66 | const childGroups = new Map() | |
| 67 | const ensure = (id) => { | |
| 68 | if (!childNodes.has(id)) childNodes.set(id, []) | |
| 69 | if (!childGroups.has(id)) childGroups.set(id, []) | |
| 70 | } | |
| 71 | ensure(ROOT) | |
| 72 | for (const c of containers) ensure(c.id) | |
| 73 | for (const c of containers) childGroups.get(parentOf(c)).push(c.id) | |
| 74 | for (const n of nodes) childNodes.get(groupOf(n)).push(n.id) | |
| 75 | ||
| 76 | // Which item inside `gid` does this node belong to — itself, or the child | |
| 77 | // container that transitively holds it? This is what turns a link between two | |
| 78 | // deeply nested nodes into an edge between the two containers that the parent | |
| 79 | // can actually order. | |
| 80 | const itemOfNodeIn = new Map() | |
| 81 | function resolveItem (gid, nodeId) { | |
| 82 | const cached = itemOfNodeIn.get(gid) | |
| 83 | if (cached && cached.has(nodeId)) return cached.get(nodeId) | |
| 84 | ||
| 85 | const n = nodeById.get(nodeId) | |
| 86 | if (!n) return null | |
| 87 | let cursor = groupOf(n) | |
| 88 | let item = nodeId | |
| 89 | const guard = containers.length + 2 | |
| 90 | for (let i = 0; i <= guard; i++) { | |
| 91 | if (cursor === gid) break | |
| 92 | const c = containerById.get(cursor) | |
| 93 | if (!c) return null // not inside gid at all | |
| 94 | item = cursor | |
| 95 | cursor = parentOf(c) | |
| 96 | if (i === guard) return null // malformed parent chain; ignore the link | |
| 97 | } | |
| 98 | if (cursor !== gid) return null | |
| 99 | ||
| 100 | if (!itemOfNodeIn.has(gid)) itemOfNodeIn.set(gid, new Map()) | |
| 101 | itemOfNodeIn.get(gid).set(nodeId, item) | |
| 102 | return item | |
| 103 | } | |
| 104 | ||
| 105 | // --- lay out one group in its own frame, bottom-up. dx/dy are relative to the | |
| 106 | // group's own origin. | |
| 107 | const laidOut = new Map() | |
| 108 | ||
| 109 | function layoutGroup (gid) { | |
| 110 | if (laidOut.has(gid)) return laidOut.get(gid) | |
| 111 | ||
| 112 | const items = [] | |
| 113 | for (const cid of childGroups.get(gid)) { | |
| 114 | const box = layoutGroup(cid) | |
| 115 | items.push({ id: cid, kind: 'container', w: box.w, h: box.h }) | |
| 116 | } | |
| 117 | for (const nid of childNodes.get(gid)) { | |
| 118 | const n = nodeById.get(nid) | |
| 119 | items.push({ id: nid, kind: 'node', w: n.w || 0, h: n.h || 0 }) | |
| 120 | } | |
| 121 | ||
| 122 | // Edges between items of THIS group. A link whose endpoints resolve to the | |
| 123 | // same item is internal to that item and says nothing about ordering here. | |
| 124 | const itemIds = new Set(items.map(i => i.id)) | |
| 125 | const edges = [] | |
| 126 | for (const l of links) { | |
| 127 | const a = resolveItem(gid, l.from) | |
| 128 | const b = resolveItem(gid, l.to) | |
| 129 | if (a === null || b === null || a === b) continue | |
| 130 | if (!itemIds.has(a) || !itemIds.has(b)) continue | |
| 131 | edges.push([a, b]) | |
| 132 | } | |
| 133 | ||
| 134 | // Longest-path layering, bounded, so a dependency cycle settles instead of | |
| 135 | // spinning: once a pass stops moving anything, the cycle has stopped | |
| 136 | // deepening. | |
| 137 | const depth = new Map() | |
| 138 | for (const i of items) depth.set(i.id, 0) | |
| 139 | for (let pass = 0; pass < items.length + 1; pass++) { | |
| 140 | let moved = false | |
| 141 | for (const [a, b] of edges) { | |
| 142 | if (depth.get(b) < depth.get(a) + 1) { depth.set(b, depth.get(a) + 1); moved = true } | |
| 143 | } | |
| 144 | if (!moved) break | |
| 145 | } | |
| 146 | ||
| 147 | // Columns, each ordered by id so the result is stable across calls. | |
| 148 | const columns = new Map() | |
| 149 | for (const i of items) { | |
| 150 | const d = depth.get(i.id) | |
| 151 | if (!columns.has(d)) columns.set(d, []) | |
| 152 | columns.get(d).push(i) | |
| 153 | } | |
| 154 | const keys = Array.from(columns.keys()).sort((a, b) => a - b) | |
| 155 | ||
| 156 | const placed = [] | |
| 157 | let x = 0 | |
| 158 | let maxH = 0 | |
| 159 | for (const k of keys) { | |
| 160 | const col = columns.get(k) | |
| 161 | col.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) | |
| 162 | let y = 0 | |
| 163 | let colW = 0 | |
| 164 | for (const item of col) { | |
| 165 | placed.push({ id: item.id, kind: item.kind, dx: x, dy: y, w: item.w, h: item.h }) | |
| 166 | y += item.h + O.rowGap | |
| 167 | if (item.w > colW) colW = item.w | |
| 168 | } | |
| 169 | const used = Math.max(0, y - O.rowGap) | |
| 170 | if (used > maxH) maxH = used | |
| 171 | x += colW + O.colGap | |
| 172 | } | |
| 173 | const contentW = Math.max(0, x - O.colGap) | |
| 174 | ||
| 175 | const isRoot = gid === ROOT | |
| 176 | const padL = isRoot ? 0 : O.pad | |
| 177 | const padT = isRoot ? 0 : O.pad + O.labelH | |
| 178 | const box = { | |
| 179 | w: contentW + padL * 2, | |
| 180 | h: maxH + padT + (isRoot ? 0 : O.pad), | |
| 181 | padL, | |
| 182 | padT, | |
| 183 | placed | |
| 184 | } | |
| 185 | laidOut.set(gid, box) | |
| 186 | return box | |
| 187 | } | |
| 188 | ||
| 189 | const rootBox = layoutGroup(ROOT) | |
| 190 | ||
| 191 | // --- absolute positions: walk down, adding each group's origin and padding. | |
| 192 | const outNodes = [] | |
| 193 | const outContainers = [] | |
| 194 | ||
| 195 | function emit (gid, originX, originY, depth) { | |
| 196 | const box = laidOut.get(gid) | |
| 197 | const baseX = originX + box.padL | |
| 198 | const baseY = originY + box.padT | |
| 199 | ||
| 200 | for (const p of box.placed) { | |
| 201 | const x = baseX + p.dx | |
| 202 | const y = baseY + p.dy | |
| 203 | if (p.kind === 'node') { | |
| 204 | outNodes.push({ id: p.id, x, y, w: p.w, h: p.h }) | |
| 205 | } else { | |
| 206 | const c = containerById.get(p.id) | |
| 207 | outContainers.push({ | |
| 208 | id: p.id, | |
| 209 | label: c && c.label !== undefined ? c.label : null, | |
| 210 | x, | |
| 211 | y, | |
| 212 | w: p.w, | |
| 213 | h: p.h, | |
| 214 | depth: depth + 1 | |
| 215 | }) | |
| 216 | emit(p.id, x, y, depth + 1) | |
| 217 | } | |
| 218 | } | |
| 219 | } | |
| 220 | emit(ROOT, 0, 0, 0) | |
| 221 | ||
| 222 | outNodes.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) | |
| 223 | outContainers.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) | |
| 224 | ||
| 225 | return { | |
| 226 | nodes: outNodes, | |
| 227 | containers: outContainers, | |
| 228 | width: rootBox.w, | |
| 229 | height: rootBox.h | |
| 230 | } | |
| 231 | } |
| … | … | @@ -0,0 +1,182 @@ |
| 1 | import { test } from 'brittle' | |
| 2 | import { layout } from '../lib/layout.js' | |
| 3 | ||
| 4 | // Zones are CONTAINERS in the layout, not boxes drawn afterwards around whatever | |
| 5 | // positions fell out. The old node view computed a frame's rectangle from the | |
| 6 | // bounding box of its members AFTER layering, and since each column ordered its | |
| 7 | // rows independently, two frames' members interleaved and their boxes crossed. | |
| 8 | // This is the fix, and these tests are the guarantee. | |
| 9 | ||
| 10 | function node (id, container = null, w = 200, h = 60) { | |
| 11 | return { id, container, w, h } | |
| 12 | } | |
| 13 | function boxesOverlap (a, b) { | |
| 14 | return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h | |
| 15 | } | |
| 16 | function contains (outer, inner) { | |
| 17 | return outer.x <= inner.x && outer.y <= inner.y && | |
| 18 | outer.x + outer.w >= inner.x + inner.w && | |
| 19 | outer.y + outer.h >= inner.y + inner.h | |
| 20 | } | |
| 21 | function byId (list) { | |
| 22 | const m = {} | |
| 23 | for (const item of list) m[item.id] = item | |
| 24 | return m | |
| 25 | } | |
| 26 | ||
| 27 | test('layout: a flat graph lays out in dependency columns', (t) => { | |
| 28 | const out = layout({ | |
| 29 | nodes: [node('a'), node('b'), node('c')], | |
| 30 | links: [{ from: 'a', to: 'b' }, { from: 'b', to: 'c' }], | |
| 31 | containers: [] | |
| 32 | }) | |
| 33 | ||
| 34 | const n = byId(out.nodes) | |
| 35 | t.ok(n.a.x < n.b.x, 'a is left of b') | |
| 36 | t.ok(n.b.x < n.c.x, 'b is left of c') | |
| 37 | t.is(n.a.y, n.b.y, 'a single chain keeps one row') | |
| 38 | t.ok(out.width > 0 && out.height > 0, 'reports its extent') | |
| 39 | }) | |
| 40 | ||
| 41 | test('layout: two independent nodes stack, they do not collide', (t) => { | |
| 42 | const out = layout({ nodes: [node('a'), node('b')], links: [], containers: [] }) | |
| 43 | const n = byId(out.nodes) | |
| 44 | t.is(n.a.x, n.b.x, 'same column') | |
| 45 | t.ok(!boxesOverlap(n.a, n.b), 'and they do not overlap') | |
| 46 | }) | |
| 47 | ||
| 48 | test('layout: a container encloses its members with padding', (t) => { | |
| 49 | const out = layout({ | |
| 50 | nodes: [node('x', 'z1'), node('y', 'z1')], | |
| 51 | links: [{ from: 'x', to: 'y' }], | |
| 52 | containers: [{ id: 'z1', parent: null, label: 'loop' }] | |
| 53 | }) | |
| 54 | ||
| 55 | const n = byId(out.nodes) | |
| 56 | const z = byId(out.containers).z1 | |
| 57 | t.ok(z, 'the container has a box') | |
| 58 | t.ok(contains(z, n.x), 'it contains x') | |
| 59 | t.ok(contains(z, n.y), 'it contains y') | |
| 60 | t.ok(z.x < n.x.x, 'with padding on the left') | |
| 61 | t.ok(z.y < n.x.y, 'and room for its label above') | |
| 62 | }) | |
| 63 | ||
| 64 | test('layout: SIBLING CONTAINERS CANNOT OVERLAP — the bug this exists to kill', (t) => { | |
| 65 | const out = layout({ | |
| 66 | nodes: [ | |
| 67 | node('a1', 'zA'), node('a2', 'zA'), node('a3', 'zA'), | |
| 68 | node('b1', 'zB'), node('b2', 'zB'), | |
| 69 | node('loose') | |
| 70 | ], | |
| 71 | // Links that would, under the old scheme, spread each zone's members across | |
| 72 | // several columns and interleave them with the other zone's. | |
| 73 | links: [ | |
| 74 | { from: 'a1', to: 'a2' }, { from: 'a2', to: 'a3' }, | |
| 75 | { from: 'b1', to: 'b2' }, | |
| 76 | { from: 'loose', to: 'a1' } | |
| 77 | ], | |
| 78 | containers: [ | |
| 79 | { id: 'zA', parent: null, label: 'zone A' }, | |
| 80 | { id: 'zB', parent: null, label: 'zone B' } | |
| 81 | ] | |
| 82 | }) | |
| 83 | ||
| 84 | const c = byId(out.containers) | |
| 85 | t.absent(boxesOverlap(c.zA, c.zB), 'zone A and zone B are disjoint') | |
| 86 | ||
| 87 | // And every member really is inside its own container, not the other one. | |
| 88 | const n = byId(out.nodes) | |
| 89 | for (const id of ['a1', 'a2', 'a3']) t.ok(contains(c.zA, n[id]), id + ' inside zone A') | |
| 90 | for (const id of ['b1', 'b2']) t.ok(contains(c.zB, n[id]), id + ' inside zone B') | |
| 91 | t.absent(contains(c.zA, n.loose), 'a node outside every zone is not captured by one') | |
| 92 | }) | |
| 93 | ||
| 94 | test('layout: nesting — a parent container strictly contains its child', (t) => { | |
| 95 | const out = layout({ | |
| 96 | nodes: [node('outer1', 'zOuter'), node('inner1', 'zInner'), node('inner2', 'zInner')], | |
| 97 | links: [{ from: 'inner1', to: 'inner2' }, { from: 'outer1', to: 'inner1' }], | |
| 98 | containers: [ | |
| 99 | { id: 'zOuter', parent: null, label: 'outer' }, | |
| 100 | { id: 'zInner', parent: 'zOuter', label: 'inner' } | |
| 101 | ] | |
| 102 | }) | |
| 103 | ||
| 104 | const c = byId(out.containers) | |
| 105 | const n = byId(out.nodes) | |
| 106 | t.ok(contains(c.zOuter, c.zInner), 'outer contains inner') | |
| 107 | t.ok(contains(c.zInner, n.inner1), 'inner contains its own members') | |
| 108 | t.ok(contains(c.zOuter, n.outer1), 'outer contains its direct member') | |
| 109 | t.absent(contains(c.zInner, n.outer1), 'but inner does not claim the outer member') | |
| 110 | t.is(c.zInner.depth, 2, 'depth is reported') | |
| 111 | t.is(c.zOuter.depth, 1, 'from the root down') | |
| 112 | }) | |
| 113 | ||
| 114 | test('layout: a link crossing a boundary orders the containers themselves', (t) => { | |
| 115 | const out = layout({ | |
| 116 | nodes: [node('a', 'zA'), node('b', 'zB')], | |
| 117 | links: [{ from: 'a', to: 'b' }], | |
| 118 | containers: [ | |
| 119 | { id: 'zA', parent: null, label: 'A' }, | |
| 120 | { id: 'zB', parent: null, label: 'B' } | |
| 121 | ] | |
| 122 | }) | |
| 123 | ||
| 124 | const c = byId(out.containers) | |
| 125 | t.ok(c.zA.x < c.zB.x, 'A is placed left of B because a feeds b') | |
| 126 | t.absent(boxesOverlap(c.zA, c.zB), 'and they still do not overlap') | |
| 127 | }) | |
| 128 | ||
| 129 | test('layout: expanding a node reflows without breaking enclosure (L4)', (t) => { | |
| 130 | const input = () => ({ | |
| 131 | nodes: [node('x', 'z1'), node('y', 'z1'), node('outside')], | |
| 132 | links: [{ from: 'x', to: 'y' }], | |
| 133 | containers: [{ id: 'z1', parent: null, label: 'loop' }] | |
| 134 | }) | |
| 135 | ||
| 136 | const before = layout(input()) | |
| 137 | const grown = input() | |
| 138 | grown.nodes.find(n => n.id === 'x').h = 400 // the user expanded x's code | |
| 139 | const after = layout(grown) | |
| 140 | ||
| 141 | const zBefore = byId(before.containers).z1 | |
| 142 | const zAfter = byId(after.containers).z1 | |
| 143 | t.ok(zAfter.h > zBefore.h, 'the container grew with its member') | |
| 144 | ||
| 145 | const n = byId(after.nodes) | |
| 146 | t.ok(contains(zAfter, n.x), 'still contains the expanded node') | |
| 147 | t.ok(contains(zAfter, n.y), 'and its sibling') | |
| 148 | t.absent(boxesOverlap(zAfter, n.outside), 'and does not swallow the node outside it') | |
| 149 | }) | |
| 150 | ||
| 151 | test('layout: a dependency cycle terminates', (t) => { | |
| 152 | const out = layout({ | |
| 153 | nodes: [node('a'), node('b'), node('c')], | |
| 154 | links: [{ from: 'a', to: 'b' }, { from: 'b', to: 'c' }, { from: 'c', to: 'a' }], | |
| 155 | containers: [] | |
| 156 | }) | |
| 157 | t.is(out.nodes.length, 3, 'all three placed') | |
| 158 | for (const a of out.nodes) { | |
| 159 | for (const b of out.nodes) { | |
| 160 | if (a.id !== b.id) t.absent(boxesOverlap(a, b), a.id + ' and ' + b.id + ' disjoint') | |
| 161 | } | |
| 162 | } | |
| 163 | }) | |
| 164 | ||
| 165 | test('layout: purity and stability', (t) => { | |
| 166 | const input = { | |
| 167 | nodes: [node('b', 'z'), node('a', 'z'), node('c')], | |
| 168 | links: [{ from: 'a', to: 'b' }], | |
| 169 | containers: [{ id: 'z', parent: null, label: 'z' }] | |
| 170 | } | |
| 171 | const a = layout(input) | |
| 172 | const b = layout(input) | |
| 173 | t.alike(a, b, 'same input, same output') | |
| 174 | }) | |
| 175 | ||
| 176 | test('layout: degenerate input does not throw', (t) => { | |
| 177 | t.ok(layout({ nodes: [], links: [], containers: [] }), 'empty') | |
| 178 | t.ok(layout({ nodes: [node('a', 'missing')], links: [], containers: [] }), | |
| 179 | 'a node naming a container that does not exist falls back to the root') | |
| 180 | t.ok(layout({ nodes: [node('a')], links: [{ from: 'a', to: 'nope' }], containers: [] }), | |
| 181 | 'a link to a node that does not exist is ignored') | |
| 182 | }) |