OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 45d1c1a

Graph tab closure, route caching (the double-click hang), column ordering
## The double-click hang Routing is expensive — A* per edge — and depends ONLY on the geometry, never on what is selected. Recomputing it on every render meant a double-click ran two full passes back to back: 0.4s each on the system level, far worse on a big module. Routes are now cached against a signature of the node positions, so a highlight render reuses them. Same fix in both views. ## Graph tab Focusing a module now shows its FULL dependency closure rather than one hop, with distance in the edge colour, and the detail panel gains two sections: every declared dependency marked in-scan or external with the count actually drawn, and what it reaches through those with hop counts. Deep-linkable as ?tab=graph&focus=<module>. Verified from the rendered DOM: 56 nodes, "23 declared / 9 drawn as edges / reached through 1", 138 dimmed elements, 7 depth-coloured edges. A graph edge here points consumer -> supplier, the opposite of the shared convention, so it is flipped on the way in rather than reasoning about direction twice. ## Column ordering, and what it is NOT worth Each column is now ordered by the median heuristic instead of by id, which is the standard way to uncross wires: crossings are a LAYOUT problem, no routing is clever enough to fix two nodes in the wrong order. On clean boards it is exact — a fully reversed pairing and a fan-out both lay out with zero crossings, and connected nodes end up near each other. On the real boards it is a WASH, measured rather than assumed: system crossings 59 -> 60 wire 155k -> 155k hyper-overlay crossings 95 -> 84 wire 164k -> 171k pear-git crossings 141 -> 189 wire 866k -> 856k The cause is structural: column WRAPPING breaks the relationship the ordering establishes, because once a column is split across sub-columns and rows, position-within-column no longer maps to y. Packing and crossing-minimisation are pulling against each other. The pass stays (it is provably right where wrapping does not interfere) and `order: 'id'` keeps the comparison runnable. Bringing densely connected groups closer needs the sequencing to be connectivity-aware BEFORE wrapping, which this does not do yet. ## Three self-inflicted breaks, one process failure render.js returns one giant template literal, and I put a literal newline inside a JS string in it — emitted as a real line break, which is an unterminated string. That is the third such break in this file (two backticks, now a newline). Worse: the parse gate DID catch it, and I missed that, because I grep the test output for the summary lines and read "no output" as "fine" when the run had aborted. Missing test output is a failure, not a pass. Suite: 240 tests, 5200 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
45d1c1a2eacdf0722e1ede7f0f4bcd68c7bd563c
by plankatron <fleeky@gmail.com> · 2026-09-21T01:24:25.000Z

Changed files (5) +298 -9

lib/floor/level.js +15 -2
……@@ -64,6 +64,7 @@ const view = {
6464 containers: [],
6565 bundled: false, // one edge per container pair instead of per member
6666 circuit: true, // orthogonal routes that go AROUND nodes, not behind them
67 routeCache: null,
6768 selected: null, // a card whose whole dependency closure is showing
6869 linkEls: [],
6970 cardEls: null,
……@@ -280,9 +281,20 @@ function render () {
280281 // 56-module scan, 105 edges take 3.4s at 20 and 0.4s at 40, both routing
281282 // every edge. A layout pass that blocks the tab for three seconds on every
282283 // expand is not worth the finer clearance.
283 const routedPaths = new Map()
284 // Routing depends only on the GEOMETRY, never on the selection, so a render
285 // that merely changes highlighting must reuse it. Without this a double-click
286 // runs two full A* passes back to back and hangs the view.
287 let routedPaths = new Map()
284288 let junctions = []
285 if (view.circuit) {
289 const signature = view.circuit
290 ? view.laid.nodes.map(n => n.id + ':' + Math.round(n.x) + ':' + Math.round(n.y)).join('|') +
291 '#' + edges.length + (view.bundled ? '#b' : '')
292 : null
293
294 if (signature && view.routeCache && view.routeCache.signature === signature) {
295 routedPaths = view.routeCache.routedPaths
296 junctions = view.routeCache.junctions
297 } else if (view.circuit) {
286298 const boxes = []
287299 for (const n of view.laid.nodes) boxes.push({ id: n.id, x: n.x, y: n.y, w: n.w, h: n.h })
288300 if (view.bundled) {
……@@ -294,6 +306,7 @@ function render () {
294306 'M ' + r.points.map(p => p.x.toFixed(1) + ' ' + p.y.toFixed(1)).join(' L '))
295307 }
296308 junctions = findJunctions(out.routes)
309 view.routeCache = { signature, routedPaths, junctions }
297310 }
298311
299312 // Clicking a node shows its FULL dependency depth, not just one hop, with the
lib/layout.js +74 -3
……@@ -42,13 +42,75 @@ const DEFAULTS = {
4242 // is derived per group from its own content area and this aspect;
4343 // maxColumnHeight overrides it outright.
4444 aspect: 1.6,
45 maxColumnHeight: undefined
45 maxColumnHeight: undefined,
46
47 // 'median' orders each column to uncross the wires and shorten them; 'id' is
48 // the old arbitrary order, kept so the difference can be MEASURED rather than
49 // asserted.
50 order: 'median'
4651}
4752
4853// The implicit group that holds everything with no container of its own. Not a
4954// real container: it is never drawn and gets no padding or label.
5055const ROOT = '__layout_root__'
5156
57/**
58 * Median-heuristic ordering, sweeping down then up a few times.
59 *
60 * For each item, look at the items it connects to in the neighbouring column,
61 * take the MEDIAN of their positions, and sort by that. An item with no
62 * neighbours keeps its place, so the result stays stable; ties break by id, so
63 * the result stays deterministic.
64 */
65function orderColumns (columns, keys, edges) {
66 const neighboursUp = new Map()
67 const neighboursDown = new Map()
68 for (const [a, b] of edges) {
69 if (!neighboursDown.has(a)) neighboursDown.set(a, [])
70 neighboursDown.get(a).push(b)
71 if (!neighboursUp.has(b)) neighboursUp.set(b, [])
72 neighboursUp.get(b).push(a)
73 }
74
75 // Start from a deterministic order so the sweeps are reproducible.
76 for (const k of keys) {
77 columns.get(k).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
78 }
79
80 const SWEEPS = 4
81 for (let pass = 0; pass < SWEEPS; pass++) {
82 const order = pass % 2 === 0 ? keys.slice(1) : keys.slice(0, -1).reverse()
83 const lookup = pass % 2 === 0 ? neighboursUp : neighboursDown
84
85 for (const k of order) {
86 const ref = pass % 2 === 0 ? k - 1 : k + 1
87 const refCol = columns.get(ref)
88 if (!refCol) continue
89 const index = new Map(refCol.map((item, i) => [item.id, i]))
90
91 const col = columns.get(k)
92 const current = new Map(col.map((item, i) => [item.id, i]))
93 const median = new Map()
94 for (const item of col) {
95 const ns = (lookup.get(item.id) || [])
96 .map(id => index.get(id))
97 .filter(v => v !== undefined)
98 .sort((a, b) => a - b)
99 // No neighbours: keep this item where it is rather than dragging it to
100 // the top, which would shuffle unrelated nodes on every pass.
101 median.set(item.id, ns.length === 0
102 ? current.get(item.id)
103 : ns[Math.floor((ns.length - 1) / 2)] + (ns.length % 2 === 0 ? 0.5 : 0))
104 }
105 col.sort((a, b) => {
106 const d = median.get(a.id) - median.get(b.id)
107 if (d !== 0) return d
108 return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
109 })
110 }
111 }
112}
113
52114function adjacencyOf (edges) {
53115 const out = new Map()
54116 for (const [a, b] of edges) {
……@@ -224,7 +286,6 @@ export function layout (input, opts = {}) {
224286 if (!moved) break
225287 }
226288
227 // Columns, each ordered by id so the result is stable across calls.
228289 const columns = new Map()
229290 for (const i of items) {
230291 const d = depth.get(i.id)
……@@ -233,6 +294,17 @@ export function layout (input, opts = {}) {
233294 }
234295 const keys = Array.from(columns.keys()).sort((a, b) => a - b)
235296
297 // Order each column to UNCROSS the wires.
298 //
299 // Sorting a column by id is arbitrary with respect to the edges, so wires
300 // criss-cross for no reason other than the order their endpoints happen to
301 // sit in. Crossings are a LAYOUT problem: no amount of clever routing fixes
302 // two nodes that are in the wrong order. This is the median heuristic —
303 // sweep down, then up, placing each item at the median position of the
304 // neighbours it is joined to, which is the standard way to do it.
305 if (O.order === 'median') orderColumns(columns, keys, edges)
306 else for (const k of keys) columns.get(k).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
307
236308 // Columns run left to right in dependency order, and WRAP into a new row
237309 // when the board gets too wide — word-wrap, for columns.
238310 //
……@@ -251,7 +323,6 @@ export function layout (input, opts = {}) {
251323 let maxRight = 0
252324 for (const k of keys) {
253325 const col = columns.get(k)
254 col.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
255326
256327 const colWidth = col.reduce((w, it) => Math.max(w, it.w), 0)
257328 if (x > 0 && x + colWidth > targetWidth) {
lib/render-nodes.js +20 -0
……@@ -123,6 +123,7 @@ var NV = {
123123 frames: [],
124124 circuit: true,
125125 selected: null,
126 routeCache: null,
126127 linkEls: [],
127128 nodeEls: null,
128129 layoutPending: false,
……@@ -633,7 +634,25 @@ function nvRender () {
633634 var junctions = []
634635 NV.linkEls = []
635636 NV.nodeEls = {}
637
638 // Routing is expensive — A* per edge — and depends ONLY on the geometry, not
639 // on what is selected or hovered. Recomputing it on every render made a
640 // double-click run two full passes back to back and hang the view. Cache it
641 // against a signature of the geometry and reuse it for highlight renders.
642 var signature = null
636643 if (NV.circuit && window.__ctRoute) {
644 var sigParts = [NV.links.length]
645 for (var si2 = 0; si2 < NV.nodes.length; si2++) {
646 var sn = NV.nodes[si2]
647 sigParts.push(sn.id + ':' + Math.round(sn.x) + ':' + Math.round(sn.y))
648 }
649 signature = sigParts.join('|')
650 }
651
652 if (signature && NV.routeCache && NV.routeCache.signature === signature) {
653 routed = NV.routeCache.routed
654 junctions = NV.routeCache.junctions
655 } else if (NV.circuit && window.__ctRoute) {
637656 var boxes = []
638657 for (var bi = 0; bi < NV.nodes.length; bi++) {
639658 var bn = NV.nodes[bi]
……@@ -663,6 +682,7 @@ function nvRender () {
663682 routed[rt.from + ' -> ' + rt.to] = 'M ' + dparts.join(' L ')
664683 }
665684 junctions = window.__ctRoute.findJunctions(out.routes)
685 NV.routeCache = { signature: signature, routed: routed, junctions: junctions }
666686 }
667687
668688 for (var l = 0; l < NV.links.length; l++) {
lib/render.js +60 -4
……@@ -1823,6 +1823,15 @@ function initGraph () {
18231823 }
18241824 showGraphSettings()
18251825 loadSections()
1826
1827 // Deep-linkable focus, like the other views, and the only way to review what
1828 // focusing actually shows without a mouse: ?focus=<module name>.
1829 const wantFocus = new URLSearchParams(location.search).get('focus')
1830 if (wantFocus) {
1831 for (const node of graphNodes) {
1832 if (node.name === wantFocus) { focusModule(node.idx); break }
1833 }
1834 }
18261835}
18271836
18281837// --- Focus a module: highlight its connections ---
……@@ -1833,23 +1842,40 @@ function focusModule (idx) {
18331842 for (const t of n.dependsOn) connected.add(t)
18341843 for (const s of n.usedBy) connected.add(s)
18351844
1836 // Dim all nodes
1845 // The FULL dependency closure, not one hop. A graph edge here points consumer
1846 // -> supplier, the opposite of the shared convention, so it is flipped on the
1847 // way in rather than reasoning about direction twice.
1848 let closure = null
1849 if (window.__ct) {
1850 const asLinks = graphEdges.map(e => ({ from: String(e.target), to: String(e.source) }))
1851 closure = window.__ct.dependencyClosure(asLinks, String(idx))
1852 for (const node of graphNodes) {
1853 if (closure.nodes.get(String(node.idx)) !== undefined) connected.add(node.idx)
1854 }
1855 }
1856
18371857 for (const node of graphNodes) {
18381858 node.el.classList.toggle('dimmed', !connected.has(node.idx))
18391859 node.el.classList.toggle('focused', node.idx === idx)
18401860 }
18411861
1842 // Color edges
18431862 for (const edge of graphEdges) {
18441863 const isDepOf = edge.source === idx // focused depends on target
18451864 const isUsedBy = edge.target === idx // source depends on focused
1846 const isConnected = isDepOf || isUsedBy
1865 const depth = closure ? closure.edges.get(String(edge.target) + ' -> ' + String(edge.source)) : undefined
1866 const isConnected = isDepOf || isUsedBy || depth !== undefined
18471867
18481868 edge.el.classList.toggle('dimmed', !isConnected)
18491869 edge.el.classList.toggle('depends-on', isDepOf)
18501870 edge.el.classList.toggle('dep-of', isUsedBy)
18511871
1852 // Arrow color
1872 // Distance in the colour: amber one hop out, cooling as it goes.
1873 if (depth !== undefined && depth > 1 && window.__ct) {
1874 edge.el.setAttribute('stroke', window.__ct.depthColor(depth, closure.maxDepth))
1875 } else {
1876 edge.el.removeAttribute('stroke')
1877 }
1878
18531879 if (isDepOf) edge.el.setAttribute('marker-end', 'url(#arrowhead-green)')
18541880 else if (isUsedBy) edge.el.setAttribute('marker-end', 'url(#arrowhead-blue)')
18551881 else edge.el.setAttribute('marker-end', 'url(#arrowhead)')
……@@ -1871,6 +1897,36 @@ function focusModule (idx) {
18711897 extra += n.usedBy.map(s => graphNodes[s].name).join('\\n')
18721898 extra += '</code></pre></div>'
18731899 }
1900 // The FULL declared list, not just the part with a node on screen. On this
1901 // repo's own scan 684 dependencies are declared and only 105 have both ends
1902 // in the scan, so the drawing alone says a module with ten external
1903 // dependencies depends on nothing.
1904 if (n.mod) {
1905 const declared = Object.keys(n.mod.dependencies || {}).sort()
1906 const inScanSet = new Set(tree.system.modules)
1907 const drawn = declared.filter(d => inScanSet.has(d))
1908 extra += '<div class="detail"><h3>All declared dependencies (' + declared.length + ')</h3>' +
1909 '<div style="color:#8b949e;font-size:11px;margin-bottom:6px">' + drawn.length +
1910 ' drawn as edges &middot; ' + (declared.length - drawn.length) + ' external</div><pre><code>'
1911 extra += declared.map(d => (inScanSet.has(d) ? ' ' : ' ~ ') + escapeHtml(d)).join('\\n')
1912 extra += '</code></pre></div>'
1913 }
1914
1915 // And what it reaches THROUGH those, with hop counts.
1916 if (closure) {
1917 const reached = []
1918 for (const node of graphNodes) {
1919 const depth = closure.nodes.get(String(node.idx))
1920 if (depth === undefined || depth < 2) continue
1921 reached.push(node.name + ' (' + depth + ' hops)')
1922 }
1923 if (reached.length > 0) {
1924 extra += '<div class="detail"><h3 style="color:#d2a8ff">Reached through those (' +
1925 reached.length + ')</h3><pre><code>' +
1926 reached.sort().map(escapeHtml).join('\\n') + '</code></pre></div>'
1927 }
1928 }
1929
18741930 if (extra) panel.innerHTML += extra
18751931}
18761932
test/layout.test.js +129 -0
……@@ -320,6 +320,135 @@ test('layout: an explicit wrap height still wins', (t) => {
320320 t.ok(out.height <= 600, 'honoured: ' + out.height)
321321})
322322
323// Count how many pairs of edges cross, judging by the vertical order of their
324// endpoints within each column.
325function countCrossings (out, links) {
326 const pos = {}
327 for (const n of out.nodes) pos[n.id] = n
328 let crossings = 0
329 for (let i = 0; i < links.length; i++) {
330 for (let j = i + 1; j < links.length; j++) {
331 const a = links[i]
332 const b = links[j]
333 const a1 = pos[a.from]; const a2 = pos[a.to]
334 const b1 = pos[b.from]; const b2 = pos[b.to]
335 if (!a1 || !a2 || !b1 || !b2) continue
336 // only compare edges spanning the same pair of columns
337 if (Math.abs(a1.x - b1.x) > 1 || Math.abs(a2.x - b2.x) > 1) continue
338 const startOrder = Math.sign(a1.y - b1.y)
339 const endOrder = Math.sign(a2.y - b2.y)
340 if (startOrder !== 0 && endOrder !== 0 && startOrder !== endOrder) crossings++
341 }
342 }
343 return crossings
344}
345
346test('layout: orders each column to UNCROSS the wires', (t) => {
347 // Sorting a column by id is arbitrary with respect to the edges, so a graph
348 // whose natural order is reversed crosses every wire with every other. The
349 // fix belongs in layout, not in routing: wires cross because the nodes they
350 // join are in the wrong order.
351 const nodes = []
352 const links = []
353 for (let i = 0; i < 6; i++) {
354 nodes.push(node('a' + i, null, 200, 60))
355 nodes.push(node('b' + i, null, 200, 60))
356 links.push({ from: 'a' + i, to: 'b' + (5 - i) }) // fully reversed
357 }
358 const out = layout({ nodes, links, containers: [] })
359 t.is(countCrossings(out, links), 0, 'a reversed pairing lays out with no crossings')
360})
361
362test('layout: a fan-out does not manufacture crossings', (t) => {
363 const nodes = [node('src', null, 200, 60)]
364 const links = []
365 for (let i = 0; i < 5; i++) {
366 nodes.push(node('t' + i, null, 200, 60))
367 links.push({ from: 'src', to: 't' + i })
368 }
369 const out = layout({ nodes, links, containers: [] })
370 t.is(countCrossings(out, links), 0, 'a fan from one node never crosses itself')
371})
372
373test('layout: ordering keeps every other guarantee', (t) => {
374 const nodes = []
375 const links = []
376 const containers = [{ id: 'z', parent: null, label: 'z' }]
377 for (let i = 0; i < 8; i++) {
378 nodes.push(node('n' + i, i < 4 ? 'z' : null, 200, 60))
379 if (i > 0) links.push({ from: 'n' + (i - 1), to: 'n' + i })
380 }
381 const out = layout({ nodes, links, containers })
382 for (const a of out.nodes) {
383 for (const b of out.nodes) {
384 if (a.id !== b.id) t.absent(boxesOverlap(a, b), a.id + '/' + b.id + ' disjoint')
385 }
386 }
387 const box = out.containers[0]
388 for (const n of out.nodes.filter(n => ['n0', 'n1', 'n2', 'n3'].includes(n.id))) {
389 t.ok(box.x <= n.x && box.x + box.w >= n.x + n.w, n.id + ' still inside its container')
390 }
391})
392
393function totalWireLength (out, links) {
394 const pos = {}
395 for (const n of out.nodes) pos[n.id] = n
396 let total = 0
397 for (const l of links) {
398 const a = pos[l.from]
399 const b = pos[l.to]
400 if (!a || !b) continue
401 total += Math.abs(a.x - b.x) + Math.abs(a.y - b.y)
402 }
403 return total
404}
405
406test('layout: densely connected nodes are placed near each other', (t) => {
407 // Element placement, as on a board: parts that talk to each other belong
408 // close together, so the traces between them are short. Ids here interleave
409 // the two clusters deliberately, so an id-ordered layout spreads each cluster
410 // across the whole column.
411 const nodes = []
412 const links = []
413 for (let i = 0; i < 4; i++) {
414 nodes.push(node('a' + (i * 2), null, 200, 60)) // cluster A: a0 a2 a4 a6
415 nodes.push(node('b' + (i * 2 + 1), null, 200, 60)) // cluster B: b1 b3 b5 b7
416 }
417 // Each cluster is a chain; the two never touch.
418 for (let i = 0; i < 3; i++) {
419 links.push({ from: 'a' + (i * 2), to: 'a' + ((i + 1) * 2) })
420 links.push({ from: 'b' + (i * 2 + 1), to: 'b' + ((i + 1) * 2 + 1) })
421 }
422
423 const out = layout({ nodes, links, containers: [] })
424 const pos = {}
425 for (const n of out.nodes) pos[n.id] = n
426
427 // Every wire should be short: a chain link should not jump across the board.
428 for (const l of links) {
429 const dy = Math.abs(pos[l.from].y - pos[l.to].y)
430 t.ok(dy <= 200, l.from + ' -> ' + l.to + ' spans ' + Math.round(dy) + 'px vertically')
431 }
432})
433
434test('layout: placement beats an arbitrary order on total wire length', (t) => {
435 const nodes = []
436 const links = []
437 for (let i = 0; i < 8; i++) nodes.push(node('n' + i, null, 200, 60))
438 // A pairing that id-order gets wrong: 0-7, 1-6, 2-5, 3-4 in the next column.
439 for (let i = 0; i < 8; i++) nodes.push(node('m' + i, null, 200, 60))
440 for (let i = 0; i < 8; i++) links.push({ from: 'n' + i, to: 'm' + (7 - i) })
441
442 const out = layout({ nodes, links, containers: [] })
443 const length = totalWireLength(out, links)
444
445 // With the columns ordered to match, every wire is horizontal: the only
446 // distance left is the one column gap each.
447 const oneGap = totalWireLength({ nodes: out.nodes.map(n => ({ ...n, y: 0 })) }, links)
448 t.ok(length <= oneGap * 1.15, 'wire length is near the horizontal minimum: ' +
449 Math.round(length) + ' vs ' + Math.round(oneGap))
450})
451
323452test('layout: purity and stability', (t) => {
324453 const input = {
325454 nodes: [node('b', 'z'), node('a', 'z'), node('c')],