OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 116ceca

Layout: wrap a layer instead of growing one endless column
Found by running the floor on a real scan rather than a fixture: 56 modules, 2,490 files, 5.93MB of tree. Layering decides x, but a layer whose members have no edges to spread them had nothing to bound its height. One module in that scan has 207 files, 117 directories and exactly ONE import edge, so every item landed at depth 0 and stacked into a column 26,012px tall — structurally correct and unusable. A layer may now occupy several columns, wrapping at maxColumnHeight (1600px). Wrapping keeps x monotonic in dependency depth — every member of a layer is placed before the next layer starts — so a supplier still sits left of its consumer, asserted by its own test. Measured on the same scan, before and after: 298 files, 14 dirs 8802x14824 -> 15074x1692 226 files, 33 dirs 8118x4888 -> 14192x1692 207 files, 117 dirs 842x26012 -> 12518x1794 Height collapses, width grows: the right trade for panning. Across all of them, zero sibling-container overlaps and zero nodes escaping their container. The system level needed no change: 56 modules in 15 categories lays out 5132x1396 with 0 overlapping category boxes — the bug that started this work, absent at real scale rather than only in a fixture. Suite: 194 tests, 3590 asserts (the exhaustive pairwise overlap checks in the new wrapping test account for most of that count). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
116ceca1be52e86f04102b8a35e8bc2b11ec9eb3
by plankatron <fleeky@gmail.com> · 2026-09-20T23:49:51.000Z

Changed files (2) +65 -3

lib/layout.js +22 -3
……@@ -23,7 +23,15 @@ const DEFAULTS = {
2323 colGap: 130, // horizontal space between dependency columns
2424 rowGap: 26, // vertical space between siblings in a column
2525 pad: 16, // container padding around its contents
26 labelH: 18 // extra headroom inside a container for its label
26 labelH: 18, // extra headroom inside a container for its label
27
28 // How tall one layer may grow before it wraps into another column.
29 //
30 // Layering decides x, but a layer with many members and no edges to spread
31 // them has to wrap or it becomes an endless column: measured on a real scan,
32 // keet-cli has 207 files, 117 directories and exactly ONE import edge, which
33 // put every item at depth 0 and produced a column 26,012px tall.
34 maxColumnHeight: 1600
2735}
2836
2937// The implicit group that holds everything with no container of its own. Not a
……@@ -159,15 +167,26 @@ export function layout (input, opts = {}) {
159167 for (const k of keys) {
160168 const col = columns.get(k)
161169 col.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
170
171 // One LAYER may occupy several columns. Wrapping keeps x monotonic in
172 // dependency depth — every member of this layer is placed before the next
173 // layer starts — so a supplier still sits left of its consumer.
162174 let y = 0
163175 let colW = 0
176 let layerW = 0
164177 for (const item of col) {
178 if (y > 0 && y + item.h > O.maxColumnHeight) {
179 x += colW + O.colGap
180 layerW += colW + O.colGap
181 y = 0
182 colW = 0
183 }
165184 placed.push({ id: item.id, kind: item.kind, dx: x, dy: y, w: item.w, h: item.h })
166185 y += item.h + O.rowGap
167186 if (item.w > colW) colW = item.w
187 if (y - O.rowGap > maxH) maxH = y - O.rowGap
168188 }
169 const used = Math.max(0, y - O.rowGap)
170 if (used > maxH) maxH = used
189 void layerW
171190 x += colW + O.colGap
172191 }
173192 const contentW = Math.max(0, x - O.colGap)
test/layout.test.js +43 -0
……@@ -162,6 +162,49 @@ test('layout: a dependency cycle terminates', (t) => {
162162 }
163163})
164164
165test('layout: a link-less group WRAPS instead of forming one endless column', (t) => {
166 // Found by running this on a real scan: keet-cli has 207 files, 117
167 // directories and exactly ONE import edge, so every item layered to depth 0
168 // and stacked into a single column 26,012px tall. Layering gives x; within one
169 // layer, height has to wrap.
170 const nodes = []
171 for (let i = 0; i < 60; i++) nodes.push(node('n' + String(i).padStart(2, '0'), null, 200, 60))
172 const out = layout({ nodes, links: [], containers: [] }, { maxColumnHeight: 600 })
173
174 const xs = new Set(out.nodes.map(n => n.x))
175 t.ok(xs.size > 1, 'it used more than one column: ' + xs.size)
176 t.ok(out.height <= 700, 'and stayed inside the height budget: ' + out.height)
177
178 for (const a of out.nodes) {
179 for (const b of out.nodes) {
180 if (a.id !== b.id) t.absent(boxesOverlap(a, b), a.id + '/' + b.id + ' disjoint')
181 }
182 }
183})
184
185test('layout: wrapping does not break dependency order', (t) => {
186 // Two layers, each too tall for the budget. Everything in layer 0 must still
187 // sit left of everything in layer 1.
188 const nodes = []
189 const links = []
190 for (let i = 0; i < 12; i++) {
191 nodes.push(node('a' + i, null, 200, 100))
192 nodes.push(node('b' + i, null, 200, 100))
193 links.push({ from: 'a' + i, to: 'b' + i })
194 }
195 const out = layout({ nodes, links, containers: [] }, { maxColumnHeight: 400 })
196 const byId = {}
197 for (const n of out.nodes) byId[n.id] = n
198
199 let maxA = -Infinity
200 let minB = Infinity
201 for (let i = 0; i < 12; i++) {
202 maxA = Math.max(maxA, byId['a' + i].x + byId['a' + i].w)
203 minB = Math.min(minB, byId['b' + i].x)
204 }
205 t.ok(maxA <= minB, 'every supplier is left of every consumer')
206})
207
165208test('layout: purity and stability', (t) => {
166209 const input = {
167210 nodes: [node('b', 'z'), node('a', 'z'), node('c')],