OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 872d975

The Nodes tab's group boxes: use the shared layout, not member bounds
The overlap reported three times was in the OLD SPA's Nodes tab at `/`, not in /floor. I never checked which page was open, measured /floor's DOM instead, found zero overlaps and said so — twice. The screenshot settled it. That view draws its group boxes in nvDrawFrames by taking each group's member BOUNDING BOX after layering. Every column ordered its rows independently, so two groups whose members interleaved across columns produced crossing rectangles. It is the exact bug lib/layout.js was written to kill, in the one view that never got it — because I built a new view beside it instead of fixing it. nvLayout now calls the SAME layout module as /floor and as the node tests, with each semantic frame as a CONTAINER, and nvDrawFrames just draws the boxes the layout returned. Measured from the rendered DOM of that tab: group boxes drawn 15 overlapping pairs 0 (was: most of them) smallest gap between stacked 90px ## Two things this dragged in, both worth having The page now imports the shared module — one layout for the SPA, the floor and the tests. That is a first concrete step toward the shared representation the audit called for, rather than a third copy. The tab is deep-linkable as ?tab=nodes. The tab is part of where you are, so it belongs in the URL like every other view here. It is also the only way to review that tab without a human clicking it, which is how the next bug surfaced: A <script type="module"> is DEFERRED, so on an immediate deep-link the shared layout had not loaded yet and nvLayout silently took its fallback path — columns with no groups at all. A human clicking the tab a second later got the good path. Intermittent by construction. The layout is now pulled in on demand and the view redraws when it lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
872d975ead2bb288fbe762dce29f85af8fba77b1
by plankatron <fleeky@gmail.com> · 2026-09-21T00:24:55.000Z

Changed files (2) +85 -53

lib/render-nodes.js +67 -53
……@@ -99,6 +99,8 @@ var NV = {
9999 panning: false, panSX: 0, panSY: 0,
100100 initialized: false,
101101 sourceCache: {},
102 frames: [],
103 layoutPending: false,
102104 selected: null
103105}
104106
……@@ -439,12 +441,64 @@ window.nvToggleFrames = function (on) {
439441// ---- layout: columns by dependency depth, left = supplies, right = consumers
440442
441443function nvLayout (nodes, links) {
444 NV.frames = []
445 // The <script type="module"> that supplies the shared layout is DEFERRED, so
446 // it has not necessarily run when this does — a human clicking the tab gets
447 // it, an immediate deep-link does not, which is the worst kind of bug. Pull it
448 // in on demand and redraw once it lands.
449 if (!window.__ctLayout) {
450 if (!NV.layoutPending) {
451 NV.layoutPending = true
452 import(API + '/floor/layout.js').then(function (m) {
453 window.__ctLayout = m.layout
454 NV.layoutPending = false
455 nvBuild()
456 }).catch(function () { NV.layoutPending = false })
457 }
458 return nvLayoutFallback(nodes, links)
459 }
460
461 // A group is a CONTAINER in the layout, not a rectangle drawn around whatever
462 // positions fell out afterwards. That is the whole difference: containers are
463 // placed as whole boxes in one pass, so sibling groups cannot interleave.
464 var containers = []
465 var seen = {}
466 var items = []
467 for (var i = 0; i < nodes.length; i++) {
468 var n = nodes[i]
469 var frame = n.isInterface ? null : nvFrameFor(n)
470 if (frame && !seen[frame]) {
471 seen[frame] = true
472 containers.push({ id: 'frame:' + frame, parent: null, label: frame })
473 }
474 items.push({
475 id: n.id,
476 container: frame ? 'frame:' + frame : null,
477 w: NV_W,
478 h: nvNodeHeight(n)
479 })
480 }
481
482 var out = window.__ctLayout({ nodes: items, links: links, containers: containers })
483
484 var pos = {}
485 for (var j = 0; j < out.nodes.length; j++) pos[out.nodes[j].id] = out.nodes[j]
486 for (var k = 0; k < nodes.length; k++) {
487 var p = pos[nodes[k].id]
488 if (!p) continue
489 nodes[k].x = p.x
490 nodes[k].y = p.y
491 }
492 NV.frames = out.containers
493}
494
495// Only if the shared module failed to load: the original single-pass layering,
496// kept so the tab still draws something rather than nothing.
497function nvLayoutFallback (nodes, links) {
442498 var byId = {}
443499 for (var i = 0; i < nodes.length; i++) byId[nodes[i].id] = nodes[i]
444
445500 var depth = {}
446501 for (var i = 0; i < nodes.length; i++) depth[nodes[i].id] = 0
447 // Longest-path layering. Bounded iterations so a dependency cycle cannot spin.
448502 for (var pass = 0; pass < nodes.length + 2; pass++) {
449503 var moved = false
450504 for (var l = 0; l < links.length; l++) {
……@@ -454,20 +508,6 @@ function nvLayout (nodes, links) {
454508 }
455509 if (!moved) break
456510 }
457
458 // Interface nodes are pinned to the far edges.
459 for (var i = 0; i < nodes.length; i++) {
460 if (nodes[i].id === '__group_in__') depth[nodes[i].id] = -1
461 }
462 var maxD = 0
463 for (var i = 0; i < nodes.length; i++) {
464 if (nodes[i].id === '__group_out__') continue
465 if (depth[nodes[i].id] > maxD) maxD = depth[nodes[i].id]
466 }
467 for (var i = 0; i < nodes.length; i++) {
468 if (nodes[i].id === '__group_out__') depth[nodes[i].id] = maxD + 1
469 }
470
471511 var cols = {}
472512 for (var i = 0; i < nodes.length; i++) {
473513 var d = depth[nodes[i].id]
……@@ -478,13 +518,6 @@ function nvLayout (nodes, links) {
478518 var x = 0
479519 for (var c = 0; c < keys.length; c++) {
480520 var col = cols[keys[c]]
481 // Order rows within a column by frame, so a frame's members end up adjacent
482 // and its box encloses them instead of straddling unrelated nodes.
483 col.sort(function (a, b) {
484 var fa = nvFrameFor(a) || '~', fb = nvFrameFor(b) || '~'
485 if (fa !== fb) return fa < fb ? -1 : 1
486 return a.title < b.title ? -1 : a.title > b.title ? 1 : 0
487 })
488521 var y = 0
489522 for (var r = 0; r < col.length; r++) {
490523 col[r].x = x
……@@ -540,36 +573,17 @@ function nvFrameFor (n) {
540573
541574function nvDrawFrames (parent) {
542575 if (!NV.showFrames) return
543 var groups = {}
544 for (var i = 0; i < NV.nodes.length; i++) {
545 var n = NV.nodes[i]
546 if (n.isInterface) continue
547 var f = nvFrameFor(n)
548 if (!f) continue
549 if (!groups[f]) groups[f] = []
550 groups[f].push(n)
551 }
552 var names = Object.keys(groups).sort()
553 for (var g = 0; g < names.length; g++) {
554 var members = groups[names[g]]
555 if (members.length < 2) continue
556 var x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity
557 for (var i = 0; i < members.length; i++) {
558 var m = members[i]
559 if (m.x < x0) x0 = m.x
560 if (m.y < y0) y0 = m.y
561 if (m.x + NV_W > x1) x1 = m.x + NV_W
562 var h = nvNodeHeight(m)
563 if (m.y + h > y1) y1 = m.y + h
564 }
565 var pad = 16
576 var frames = NV.frames || []
577 for (var g = 0; g < frames.length; g++) {
578 var box = frames[g]
566579 var color = NV_FRAME_COLORS[g % NV_FRAME_COLORS.length]
580
567581 var r = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
568582 r.setAttribute('class', 'nv-frame')
569 r.setAttribute('x', x0 - pad)
570 r.setAttribute('y', y0 - pad - 16)
571 r.setAttribute('width', (x1 - x0) + pad * 2)
572 r.setAttribute('height', (y1 - y0) + pad * 2 + 16)
583 r.setAttribute('x', box.x)
584 r.setAttribute('y', box.y)
585 r.setAttribute('width', box.w)
586 r.setAttribute('height', box.h)
573587 r.setAttribute('rx', 8)
574588 r.setAttribute('fill', color)
575589 r.setAttribute('stroke', color)
……@@ -577,10 +591,10 @@ function nvDrawFrames (parent) {
577591
578592 var t = document.createElementNS('http://www.w3.org/2000/svg', 'text')
579593 t.setAttribute('class', 'nv-frame-label')
580 t.setAttribute('x', x0 - pad + 8)
581 t.setAttribute('y', y0 - pad - 3)
594 t.setAttribute('x', box.x + 8)
595 t.setAttribute('y', box.y + 13)
582596 t.setAttribute('fill', color)
583 t.textContent = names[g]
597 t.textContent = box.label
584598 parent.appendChild(t)
585599 }
586600}
lib/render.js +18 -0
……@@ -584,6 +584,14 @@ ${NODE_VIEW_HTML}
584584 </div>
585585</div>
586586
587<script type="module">
588 // The Nodes tab uses the SAME compound layout as /floor and the node tests.
589 // Its group boxes used to be drawn AFTER layering, from each group's member
590 // bounding box, so two groups whose members interleaved across columns made
591 // crossing boxes — the overlap reported all along.
592 import { layout } from '${apiPrefix}/floor/layout.js'
593 window.__ctLayout = layout
594</script>
587595<script>
588596const tree = ${treeJSON};
589597const API = '${apiPrefix}';
……@@ -3966,6 +3974,16 @@ ${NODE_VIEW_JS}
39663974buildTree()
39673975initResizable()
39683976
3977// The tab is part of WHERE YOU ARE, so it belongs in the URL like every other
3978// view here (invariant I5). ?tab=nodes opens the Nodes tab directly, which also
3979// makes the tab reviewable without a human clicking it.
3980;(function selectTabFromUrl () {
3981 const wanted = new URLSearchParams(location.search).get('tab')
3982 if (!wanted) return
3983 const tab = document.querySelector('.main-tab[data-tab="' + wanted + '"]')
3984 if (tab) tab.click()
3985})()
3986
39693987// ====== URL ROUTING — popstate + initial URL ======
39703988// On cold page load, parse location.pathname and route to the right view.
39713989// popstate (back/forward button) re-derives the route from the URL and