OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 76db7b4

Compound layout: zones are containers, so sibling boxes cannot overlap
lib/layout.js is a pure function — (nodes, links, container tree) -> positions and boxes. No DOM, no clock, sizes come in, so expanding a node's code is a new `h` and another call (spec L4). ## Why the old approach could not be patched The node view layered nodes into columns and then computed each frame's rectangle from the bounding box of its members. Every column ordered its own rows independently, so one frame's members in column 2 interleaved with another's in column 3 and the rectangles crossed. That is the overlap reported at the start of this work, and no amount of better box-fitting fixes it: the members were never placed together in the first place. So a container is a layout UNIT. Its contents are laid out in their own frame, that frame becomes a box with a size, and the parent places the box as if it were a single node. Siblings cannot interleave because they are placed as whole rectangles in one pass, and a parent encloses its children by construction rather than by measurement. A link between two deeply nested nodes is promoted to an edge between the two containers the parent can actually order, which is what makes cross-zone dataflow lay out left-to-right instead of arbitrarily. ## Verified on real data, not just fixtures Every symbol interior in five of this repo's own files — operations as nodes, zones as containers, wires as links — laid out and checked exhaustively: 53 symbol interiors · 0 sibling overlaps · 0 enclosure failures Extent distribution over 74 interiors: p25 4 nodes -> 530x111 (~2 columns) median 15 nodes -> 924x1092 (~3 columns) p75 46 nodes -> 1584x2598 (~5 columns) p90 84 nodes -> 2606x4777 (~8 columns) max 177 nodes -> 9174x6952 (~28 columns) The median symbol is a 15-node graph on one screen, which is the case that matters. The outlier is `cmdDerive` — a 177-operation procedural function I wrote earlier in this work — and 28 columns is a property of that code, not of the layout. It is also the case the spec already anticipates collapsing: a straight-line run of operations should become one node. Tests: 10 layout tests, 41 asserts; suite 1013 asserts green. Nothing renders yet — this is the engine the renderer needs, and it was the blocker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
76db7b4622b51cf81691812b8ca50e8e5888a74e
by plankatron <fleeky@gmail.com> · 2026-09-20T22:07:16.000Z

Changed files (2) +413 -0

lib/layout.js +231 -0
……@@ -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
22const 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.
31const 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 */
41export 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}
test/layout.test.js +182 -0
……@@ -0,0 +1,182 @@
1import { test } from 'brittle'
2import { 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
10function node (id, container = null, w = 200, h = 60) {
11 return { id, container, w, h }
12}
13function 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}
16function 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}
21function byId (list) {
22 const m = {}
23 for (const item of list) m[item.id] = item
24 return m
25}
26
27test('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
41test('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
48test('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
64test('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
94test('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
114test('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
129test('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
151test('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
165test('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
176test('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})