OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 9f9a677

layout: order a group against the board, in a second pass
Groups are laid out bottom-up — a directory is sized before the parent decides where the directory goes — so while a group's members are being ordered, nothing outside it has a position yet. For most groups that is all the information there is: a directory whose every import crosses a directory boundary has no sibling edges at all, and the median heuristic falls back to id order, i.e. alphabetical. That is why densely connected things ended up far apart. So lay the whole thing out twice. Pass two uses pass one's absolute positions as the hint for where each member's outside neighbours will be, and orders each column by the median of those. Only members that have a hint move, and only into the slots they already occupy between them, so an unconnected item is never dragged across a column by a number that does not apply to it. Two passes is enough: a third moves nothing, on four real modules and on a board built to be hostile. Against the actual router, not a proxy: hyper-overlay's wires got 10% shorter with 3% fewer turns, favela-web's 16% shorter with 10% fewer turns. favela-peer went the other way, 7% longer, and its board is identical, so that is the ordering and not the packing — reported rather than tuned away. Also adds sequence: 'affinity', which was written first: it clusters a wrapping column by SHARED neighbours rather than by where they are. It is off by default because the second pass beats it and the two fight — with affinity on, favela-web came out 5% shorter instead of 16%. Kept, because it is the only tool for a board where positions cannot help, and so the comparison stays runnable. Two variants that sound better were measured and both lose: hinting from every neighbour including internal ones widened favela-web's board from 4328 to 5140 and the router refused it outright, and hinting only members with no sibling edges gave -4%/-2%/+4% against -10%/-16%/+7%. Sibling containers still never overlap: 53 real symbol interiors, 0 overlaps, 0 enclosure failures, and 15 group frames disjoint in the served DOM. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9f9a677db0f7f10a118da778f0d2f0bc1edac949
by plankatron <fleeky@gmail.com> · 2026-09-21T01:48:11.000Z

Changed files (2) +422 -5

lib/layout.js +249 -5
……@@ -47,7 +47,31 @@ const DEFAULTS = {
4747 // 'median' orders each column to uncross the wires and shorten them; 'id' is
4848 // the old arbitrary order, kept so the difference can be MEASURED rather than
4949 // asserted.
50 order: 'median'
50 order: 'median',
51
52 // 'affinity' re-sequences a column that is about to WRAP so that items sharing
53 // an outside neighbour stay adjacent. OFF by default: it was written first and
54 // then measured against the second layout pass below, which knows where those
55 // neighbours actually ARE rather than merely that they are shared. The two
56 // fight, and the pass wins — with affinity on, favela-web's routed wires came
57 // out 5% shorter instead of 16%. Kept because it is the only tool for a board
58 // where positions cannot help, and so the comparison stays reproducible.
59 sequence: 'none',
60
61 // How many times to lay the whole thing out.
62 //
63 // Groups are laid out BOTTOM-UP — a directory is sized before the parent
64 // decides where the directory goes — so while a group is being laid out, the
65 // position of everything OUTSIDE it is unknown. That is fatal for ordering,
66 // because nearly every wire in a directory leaves the directory: the first
67 // pass can only order members against each other, which for most groups means
68 // it cannot order them at all.
69 //
70 // A second pass fixes that by using the FIRST pass's absolute positions as the
71 // hint for where each member's outside neighbours will be. Two passes is
72 // enough — a third moved nothing measurable — and it stays pure: same input,
73 // same output.
74 passes: 2
5175}
5276
5377// The implicit group that holds everything with no container of its own. Not a
……@@ -111,6 +135,117 @@ function orderColumns (columns, keys, edges) {
111135 }
112136}
113137
138/**
139 * Sequence ONE column's items so that items sharing connections end up next to
140 * each other — done BEFORE the column is wrapped into sub-columns.
141 *
142 * Median ordering puts an item at the median y of its neighbours in the adjacent
143 * column. That is the right order while the column stays ONE column, because
144 * then position-in-the-list is y and y is comparable across columns. A column
145 * that overflows `wrapHeight` is cut into several sub-columns, and at that point
146 * the list position stops meaning y: items 1 and 40 can end up side by side
147 * while 39 and 40 sit a sub-column apart. So the median order is not merely
148 * useless after a wrap, it scatters clusters — sorting keet-cli's 207 files by a
149 * median almost none of them have falls back to id order, i.e. alphabetical, and
150 * files that all import the same module land in five different sub-columns.
151 *
152 * For a column that will wrap, sequence by affinity instead: greedily grow runs
153 * of items that are connected. Affinity is a direct edge (strong — they really
154 * are joined) plus one per neighbour they have in COMMON, which is what makes a
155 * cluster a cluster when the members are not joined to each other. Ties break by
156 * degree then id, so the result is deterministic.
157 *
158 * The common neighbours are mostly OUTSIDE the group. Layering only ever needs
159 * edges between siblings, so that is all `edges` holds — and with only those, a
160 * directory of twelve files that all import the same module looks completely
161 * unconnected and this pass does nothing (measured: 0% on every level). Hence
162 * `external`, the [member, outside-endpoint] pairs for links that leave the
163 * group: two files pointing at the same outside node are what makes them
164 * neighbours here.
165 */
166function sequenceByAffinity (col, edges, external) {
167 if (col.length < 3) return col
168 const inCol = new Map(col.map((it, i) => [it.id, i]))
169
170 const affinity = col.map(() => new Map())
171 const bump = (i, j, w) => {
172 if (i === j) return
173 affinity[i].set(j, (affinity[i].get(j) || 0) + w)
174 affinity[j].set(i, (affinity[j].get(i) || 0) + w)
175 }
176
177 const fan = new Map() // a neighbour outside the column -> members attached to it
178 const attach = (member, outside) => {
179 const i = inCol.get(member)
180 if (i === undefined) return
181 if (!fan.has(outside)) fan.set(outside, [])
182 fan.get(outside).push(i)
183 }
184 for (const [a, b] of edges) {
185 const ia = inCol.get(a)
186 const ib = inCol.get(b)
187 if (ia !== undefined && ib !== undefined) { bump(ia, ib, 3); continue }
188 if (ia !== undefined) attach(a, b)
189 else if (ib !== undefined) attach(b, a)
190 }
191 for (const [member, outside] of external) attach(member, outside)
192
193 // A neighbour that most of the column touches cannot tell you which members
194 // belong together, and pairing it up is what makes this pass quadratic.
195 const HUB = 64
196 for (const raw of fan.values()) {
197 // The same member can reach one outside node through several links; counting
198 // it twice would let one heavy importer dominate a pair's affinity.
199 const members = Array.from(new Set(raw))
200 if (members.length > HUB) continue
201 for (let i = 0; i < members.length; i++) {
202 for (let j = i + 1; j < members.length; j++) bump(members[i], members[j], 1)
203 }
204 }
205
206 // This is a REFINEMENT of the incoming order, not a replacement for it.
207 //
208 // Seeding runs by degree and breaking ties by id throws the median order away,
209 // and the median order is not worthless after a wrap — within one sub-column,
210 // list position is still y. Measured, discarding it cost favela-web 4% more
211 // wire and 6% more crossings while helping elsewhere: a wash bought with a
212 // regression. So walk the incoming order and, each time an item is placed,
213 // DRAG ITS CLUSTER ALONG. Where affinity says nothing the median order
214 // survives untouched.
215 const order = []
216 const taken = new Array(col.length).fill(false)
217 let cursor = 0
218 while (order.length < col.length) {
219 while (taken[cursor]) cursor++
220 const seed = cursor
221 taken[seed] = true
222 order.push(seed)
223
224 for (;;) {
225 const tail = order[order.length - 1]
226 const prev = order.length > 1 ? order[order.length - 2] : -1
227 let pick = -1
228 let best = 0
229 for (let i = 0; i < col.length; i++) {
230 if (taken[i]) continue
231 // Weight the tail heavily but let the one before it break ties, so a run
232 // follows the cluster rather than wandering off down a single strong edge.
233 const score = (affinity[tail].get(i) || 0) * 4 +
234 (prev === -1 ? 0 : (affinity[prev].get(i) || 0))
235 if (score === 0) continue
236 // Ties go to the earlier item in the incoming order, so the median order
237 // still decides everything affinity is silent about.
238 if (score > best) { pick = i; best = score }
239 }
240 if (pick === -1) break // this run is exhausted; the next seed starts another
241 taken[pick] = true
242 order.push(pick)
243 }
244 }
245
246 return order.map(i => col[i])
247}
248
114249function adjacencyOf (edges) {
115250 const out = new Map()
116251 for (const [a, b] of edges) {
……@@ -160,6 +295,17 @@ function findBackEdges (items, forward) {
160295 */
161296export function layout (input, opts = {}) {
162297 const O = { ...DEFAULTS, ...opts }
298 const first = layoutOnce(input, O, null)
299 if (O.passes < 2) return first
300
301 // What pass one learned: where each node actually ended up. Feed that back as
302 // the hint for outside neighbours and lay the whole thing out again.
303 const hint = new Map()
304 for (const n of first.nodes) hint.set(n.id, n.y + n.h / 2)
305 return layoutOnce(input, O, hint)
306}
307
308function layoutOnce (input, O, hint) {
163309 const nodes = input.nodes || []
164310 const links = input.links || []
165311 const containers = input.containers || []
……@@ -254,12 +400,51 @@ export function layout (input, opts = {}) {
254400 // same item is internal to that item and says nothing about ordering here.
255401 const itemIds = new Set(items.map(i => i.id))
256402 const edges = []
403 // Links that LEAVE this group, as [member, outside endpoint]. Useless for
404 // layering — an outside node has no column here — but they are what tells
405 // the sequencing pass which members belong together.
406 const external = []
257407 for (const l of links) {
258408 const a = resolveItem(gid, l.from)
259409 const b = resolveItem(gid, l.to)
260 if (a === null || b === null || a === b) continue
261 if (!itemIds.has(a) || !itemIds.has(b)) continue
262 edges.push([a, b])
410 const inA = a !== null && itemIds.has(a)
411 const inB = b !== null && itemIds.has(b)
412 if (inA && inB) {
413 if (a !== b) edges.push([a, b])
414 continue
415 }
416 if (inA) external.push([a, l.to])
417 else if (inB) external.push([b, l.from])
418 }
419
420 // Where each member's OUTSIDE neighbours sat last pass, as one median y per
421 // member. This is the only thing a group knows about the rest of the board,
422 // and without it a directory whose every import crosses a directory boundary
423 // has nothing whatever to sort by.
424 //
425 // Three variants were measured on four real modules; this one wins and the
426 // two that sound better both lose:
427 // - hinting from EVERY neighbour, internal ones included, reordered
428 // favela-web enough to widen its board from 4328 to 5140 and the router
429 // REFUSED the result outright;
430 // - hinting only the members with no sibling edges — "never override the
431 // median with less information" — gave -4%/-2%/+4% against this one's
432 // -10%/-16%/+7%.
433 // So the hint is applied to every member that has an outside neighbour, even
434 // where the median heuristic also has an opinion.
435 const hintY = new Map()
436 if (hint) {
437 const acc = new Map()
438 for (const [member, outside] of external) {
439 const y = hint.get(outside)
440 if (y === undefined) continue
441 if (!acc.has(member)) acc.set(member, [])
442 acc.get(member).push(y)
443 }
444 for (const [member, ys] of acc) {
445 ys.sort((a, b) => a - b)
446 hintY.set(member, ys[Math.floor((ys.length - 1) / 2)])
447 }
263448 }
264449
265450 // Layering, with BACK EDGES REMOVED first.
……@@ -305,6 +490,43 @@ export function layout (input, opts = {}) {
305490 if (O.order === 'median') orderColumns(columns, keys, edges)
306491 else for (const k of keys) columns.get(k).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
307492
493 // Pull each column into the order its OUTSIDE neighbours sit in.
494 //
495 // Only the items that have a hint move, and they move only into the slots
496 // those items already occupy between them, so an unconnected item never gets
497 // dragged across the column by a number that does not apply to it.
498 if (hint) {
499 for (const k of keys) {
500 const col = columns.get(k)
501 const slots = []
502 const movable = []
503 for (let i = 0; i < col.length; i++) {
504 if (hintY.has(col[i].id)) { slots.push(i); movable.push(col[i]) }
505 }
506 if (movable.length < 2) continue
507 movable.sort((a, b) => {
508 const d = hintY.get(a.id) - hintY.get(b.id)
509 if (d !== 0) return d
510 return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
511 })
512 for (let i = 0; i < slots.length; i++) col[slots[i]] = movable[i]
513 }
514 }
515
516 // Connectivity-aware sequencing, BEFORE the wrap — and only for the columns
517 // that are actually going to wrap. A column that fits keeps its median
518 // order, because there y is still comparable to the next column's.
519 if (O.sequence === 'affinity') {
520 for (const k of keys) {
521 const col = columns.get(k)
522 let stack = 0
523 for (const it of col) {
524 stack += it.h + (it.kind === 'container' ? O.groupGap : O.rowGap)
525 }
526 if (stack > wrapHeight) columns.set(k, sequenceByAffinity(col, edges, external))
527 }
528 }
529
308530 // Columns run left to right in dependency order, and WRAP into a new row
309531 // when the board gets too wide — word-wrap, for columns.
310532 //
……@@ -314,6 +536,23 @@ export function layout (input, opts = {}) {
314536 // order and packs the board toward something a screen can hold.
315537 const targetWidth = Math.max(900, Math.sqrt(contentArea * Math.max(0.2, O.aspect)) * 1.7)
316538
539 // How strongly each column is tied to the one before it, so the wrap can
540 // break where the board is WEAKLY connected instead of wherever the width
541 // happens to run out. Breaking through the middle of a tightly connected
542 // run is what scatters a cluster across two rows.
543 const seamWeight = new Map()
544 for (let ki = 1; ki < keys.length; ki++) {
545 const prev = new Set(columns.get(keys[ki - 1]).map(it => it.id))
546 const here = new Set(columns.get(keys[ki]).map(it => it.id))
547 let weight = 0
548 for (const [a, b] of edges) {
549 if ((prev.has(a) && here.has(b)) || (prev.has(b) && here.has(a))) weight++
550 }
551 seamWeight.set(keys[ki], weight)
552 }
553 const weights = Array.from(seamWeight.values()).sort((a, b) => a - b)
554 const medianSeam = weights.length > 0 ? weights[Math.floor(weights.length / 2)] : 0
555
317556 const placed = []
318557 let x = 0
319558 let rowY = 0
……@@ -325,7 +564,12 @@ export function layout (input, opts = {}) {
325564 const col = columns.get(k)
326565
327566 const colWidth = col.reduce((w, it) => Math.max(w, it.w), 0)
328 if (x > 0 && x + colWidth > targetWidth) {
567
568 // A heavy seam is worth some overflow to keep intact; a light one is
569 // where the row should break.
570 const seam = seamWeight.get(k) || 0
571 const allowance = seam > medianSeam ? 1.45 : 1.0
572 if (x > 0 && x + colWidth > targetWidth * allowance) {
329573 x = 0
330574 rowY += rowH + (col.some(it => it.kind === 'container') ? O.groupGap : O.rowGap)
331575 rowH = 0
test/layout.test.js +173 -0
……@@ -449,6 +449,55 @@ test('layout: placement beats an arbitrary order on total wire length', (t) => {
449449 Math.round(length) + ' vs ' + Math.round(oneGap))
450450})
451451
452test('layout: wraps at a WEAK seam rather than through a dense one', (t) => {
453 // Two blocks that are densely wired internally, joined by a single link. When
454 // the board must wrap, the break belongs at the thin join — breaking through
455 // a dense block is what scatters a cluster across two rows.
456 //
457 // Three nodes per column, three parallel edges between consecutive columns
458 // inside a block, and exactly one edge between the blocks.
459 const nodes = []
460 const links = []
461 const block = (prefix, cols) => {
462 for (let c = 0; c < cols; c++) {
463 for (let r = 0; r < 3; r++) nodes.push(node(prefix + c + '_' + r, null, 260, 60))
464 }
465 for (let c = 1; c < cols; c++) {
466 for (let r = 0; r < 3; r++) {
467 links.push({ from: prefix + (c - 1) + '_' + r, to: prefix + c + '_' + r })
468 }
469 }
470 }
471 block('a', 3)
472 block('b', 3)
473 links.push({ from: 'a2_0', to: 'b0_0' }) // the one thin join
474
475 const out = layout({ nodes, links, containers: [] })
476 const pos = {}
477 for (const n of out.nodes) pos[n.id] = n
478
479 // Find where rows actually change, in column order.
480 const columnOf = new Map()
481 for (const n of out.nodes) {
482 const col = Math.round(n.x)
483 if (!columnOf.has(col)) columnOf.set(col, n.row)
484 }
485 const rows = [...new Set(out.nodes.map(n => n.row))]
486 if (rows.length === 1) {
487 t.pass('it all fits on one row, so there is no seam to choose')
488 return
489 }
490
491 // Every dense pair must stay on the same row: the split can only be the join.
492 for (const prefix of ['a', 'b']) {
493 for (let c = 1; c < 3; c++) {
494 const before = pos[prefix + (c - 1) + '_0'].row
495 const after = pos[prefix + c + '_0'].row
496 t.is(before, after, prefix + ' stays intact across its dense seam ' + c)
497 }
498 }
499})
500
452501test('layout: purity and stability', (t) => {
453502 const input = {
454503 nodes: [node('b', 'z'), node('a', 'z'), node('c')],
……@@ -467,3 +516,127 @@ test('layout: degenerate input does not throw', (t) => {
467516 t.ok(layout({ nodes: [node('a')], links: [{ from: 'a', to: 'nope' }], containers: [] }),
468517 'a link to a node that does not exist is ignored')
469518})
519
520// --- the second pass: ordering a group against the rest of the board ---------
521//
522// A group is sized before its parent places it, so while its members are being
523// ordered, nothing outside it has a position yet. For most groups that is all
524// the information there is — a directory whose every import crosses a directory
525// boundary has no sibling edges at all — so pass one cannot order it. Pass two
526// re-lays everything out using pass one's absolute positions as the hint.
527
528function crossingBoard () {
529 // Three grouped nodes wired to three ungrouped ones in the OPPOSITE order, so
530 // pass one's id ordering puts every wire across every other.
531 return {
532 nodes: [
533 { id: 'a1', container: 'G1', w: 200, h: 40 },
534 { id: 'a2', container: 'G1', w: 200, h: 40 },
535 { id: 'a3', container: 'G1', w: 200, h: 40 },
536 { id: 'x1', container: null, w: 200, h: 40 },
537 { id: 'x2', container: null, w: 200, h: 40 },
538 { id: 'x3', container: null, w: 200, h: 40 }
539 ],
540 containers: [{ id: 'G1', parent: null, label: 'G1' }],
541 links: [{ from: 'a1', to: 'x3' }, { from: 'a2', to: 'x2' }, { from: 'a3', to: 'x1' }]
542 }
543}
544
545function totalRise (input, out) {
546 const y = new Map()
547 for (const n of out.nodes) y.set(n.id, n.y + n.h / 2)
548 let sum = 0
549 for (const l of input.links) sum += Math.abs(y.get(l.from) - y.get(l.to))
550 return sum
551}
552
553test('layout: a second pass orders a group by where its neighbours landed', (t) => {
554 const input = crossingBoard()
555
556 const one = layout(input, { passes: 1 })
557 const two = layout(input, { passes: 2 })
558
559 const y1 = new Map(one.nodes.map(n => [n.id, n.y]))
560 const y2 = new Map(two.nodes.map(n => [n.id, n.y]))
561
562 // The targets do not move: their own order is settled by edges inside the root
563 // group, which pass one already had.
564 for (const id of ['x1', 'x2', 'x3']) {
565 t.is(y2.get(id), y1.get(id), id + ' keeps its place')
566 }
567
568 // a1 wires to the LOWEST target, so after the hint it must sit lowest itself.
569 t.ok(y1.get('a1') < y1.get('a3'), 'pass one has a1 above a3, by id')
570 t.ok(y2.get('a1') > y2.get('a3'), 'pass two has flipped them to follow the targets')
571
572 // And the thing that actually matters: the wires got shorter.
573 const before = totalRise(input, one)
574 const after = totalRise(input, two)
575 t.ok(after < before, 'total vertical run fell from ' + before + ' to ' + after)
576})
577
578test('layout: two passes reach a fixed point', (t) => {
579 // If a third pass moved anything, the hint would be oscillating rather than
580 // converging and the default of 2 would be arbitrary. Measured on four real
581 // modules it changes nothing; this pins that for a board built to be hostile.
582 const input = crossingBoard()
583 t.alike(layout(input, { passes: 3 }), layout(input, { passes: 2 }),
584 'a third pass moves nothing')
585})
586
587test('layout: the hint needs a neighbour OUTSIDE the group to work with', (t) => {
588 // The pass can only help where a group has external edges. With everything
589 // wired inside one group there is no hint to build, and the result must be
590 // identical rather than merely similar — otherwise the pass is moving things
591 // for reasons it cannot justify.
592 const input = {
593 nodes: [
594 { id: 'a1', container: 'G1', w: 200, h: 40 },
595 { id: 'a2', container: 'G1', w: 200, h: 40 },
596 { id: 'a3', container: 'G1', w: 200, h: 40 }
597 ],
598 containers: [{ id: 'G1', parent: null, label: 'G1' }],
599 links: [{ from: 'a1', to: 'a2' }, { from: 'a2', to: 'a3' }]
600 }
601 t.alike(layout(input, { passes: 2 }), layout(input, { passes: 1 }),
602 'no external edges, no change')
603})
604
605test('layout: affinity sequencing puts sharers of a neighbour side by side', (t) => {
606 // sequence: 'affinity' is off by default because the second pass beats it, but
607 // it is the only tool for a board where positions cannot help, so it has to
608 // keep working. Two of the five members share a target; they must end up
609 // adjacent in the column once it wraps.
610 const nodes = []
611 for (let i = 1; i <= 8; i++) nodes.push({ id: 'm' + i, container: 'G1', w: 200, h: 90 })
612 nodes.push({ id: 'hub', container: null, w: 200, h: 40 })
613 nodes.push({ id: 'other', container: null, w: 200, h: 40 })
614
615 const input = {
616 nodes,
617 containers: [{ id: 'G1', parent: null, label: 'G1' }],
618 // m1 and m8 are the pair: nothing else touches `hub`.
619 links: [
620 { from: 'm1', to: 'hub' }, { from: 'm8', to: 'hub' },
621 { from: 'm3', to: 'other' }, { from: 'm4', to: 'other' }, { from: 'm5', to: 'other' }
622 ]
623 }
624
625 // passes: 1 so the only thing at work is the sequencing.
626 const off = layout(input, { passes: 1, sequence: 'none' })
627 const on = layout(input, { passes: 1, sequence: 'affinity' })
628
629 const slot = (out) => {
630 const placed = out.nodes.filter(n => n.id.startsWith('m'))
631 .sort((a, b) => (a.x - b.x) || (a.y - b.y))
632 const index = new Map()
633 placed.forEach((n, i) => index.set(n.id, i))
634 return index
635 }
636 const a = slot(off)
637 const b = slot(on)
638
639 t.ok(Math.abs(a.get('m1') - a.get('m8')) > 1,
640 'by id the pair is ' + Math.abs(a.get('m1') - a.get('m8')) + ' slots apart')
641 t.is(Math.abs(b.get('m1') - b.get('m8')), 1, 'affinity puts them next to each other')
642})