OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 9df6283

Edges: dim by default with focus on hover, and a bundling toggle
At real scale the edges were the noise rather than the signal — 105 crossing curves at the system level, 316 inside one 58-file module — and they buried a layout that was otherwise correct. Two answers, composed. ## 1. Dim by default, trace on focus Edges sit at 16% opacity. Hovering a card lights its own edges, brightens the cards at the other end, and fades everything else to 25%. Deep-linkable as ?focus=<title>, which is both consistent with invariant I5 and the only way to show hover behaviour in a headless screenshot. Verified on the 56-module scan: focusing one module traces its eight dependents and the other 97 edges vanish. ## 2. Bundling, as a toggle (B, or ?edges=bundled) bundleLinks() collapses member-to-member edges into one per container pair, carrying a count and the members it came from. Thickness is log2(count), and the count is drawn on the edge. On the same scan: 105 edges become 29 bundled ones. Each now says WHICH category depends on which and how heavily, instead of which two modules out of 56. It is pure and lives in cards.js with the other builders, so it is tested rather than eyeballed: crossing edges collapse with the right count, an edge inside one container is dropped (a self-edge says nothing — turn the toggle off and read the real edges), a card with no container stands for itself, direction is kept, order is stable, and on a real level every crossing edge is accounted for exactly once. Suite: 197 tests, 4688 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9df628396620abd0d012fb3ff23e0403b1ae9c8b
by plankatron <fleeky@gmail.com> · 2026-09-20T23:59:08.000Z

Changed files (4) +232 -13

lib/floor/cards.js +39 -0
……@@ -249,6 +249,45 @@ export function buildSymbolLevel (model, sym) {
249249 return { cards, links, containers }
250250}
251251
252/**
253 * Collapse member-to-member edges into one edge per container pair.
254 *
255 * At real scale the member edges are the problem: 56 modules in 15 categories
256 * draw 105 crossing curves, and a 58-file module draws 316. Bundled, the same
257 * information is a handful of edges saying WHICH groups depend on which, with a
258 * count for how heavily.
259 *
260 * An endpoint with no container stands for itself, so a loose card still takes
261 * part. An edge whose endpoints land in the SAME group would be a self-edge
262 * saying nothing, so it is dropped: inside a group you turn the toggle off and
263 * read the real edges.
264 *
265 * Returns [{ from, to, count, members }] in a stable order.
266 */
267export function bundleLinks (cards, links) {
268 const groupOf = new Map()
269 for (const c of cards) groupOf.set(c.id, c.container || c.id)
270
271 const byPair = new Map()
272 for (const l of links) {
273 const from = groupOf.get(l.from)
274 const to = groupOf.get(l.to)
275 if (from === undefined || to === undefined) continue
276 if (from === to) continue
277 const key = from + ' -> ' + to
278 if (!byPair.has(key)) byPair.set(key, { from, to, count: 0, members: [] })
279 const entry = byPair.get(key)
280 entry.count++
281 entry.members.push(l)
282 }
283
284 return Array.from(byPair.values()).sort((a, b) => {
285 if (a.from !== b.from) return a.from < b.from ? -1 : 1
286 return a.to < b.to ? -1 : 1
287 })
288}
289
290
252291/**
253292 * Resolve a card's `code` descriptor against the file's lines.
254293 *
lib/floor/level.html +12 -2
……@@ -32,7 +32,17 @@
3232 .n-lineno { font-family: ui-monospace, monospace; font-size: 7px; fill: #484f58; }
3333 .n-chev { fill: #8b949e; font-size: 10px; cursor: pointer; }
3434 .n-hit { fill: transparent; cursor: pointer; }
35 .link { fill: none; stroke: #4d5866; stroke-width: 1.4; }
35 /* Edges are dim until you point at a node: at real scale they are the noise,
36 not the signal. Hovering a card lights its own and fades the rest. */
37 .link { fill: none; stroke: #4d5866; stroke-width: 1.4; opacity: 0.16;
38 transition: opacity .12s ease, stroke .12s ease; }
39 .link.bundled { stroke: #58a6ff; opacity: 0.45; }
40 .link.hot { stroke: #ffa657; opacity: 0.95; }
41 .link.cold { opacity: 0.04; }
42 .link-count { font-size: 9px; fill: #8b949e; font-family: ui-monospace, monospace;
43 text-anchor: middle; pointer-events: none; }
44 .n-body, .n-head, .n-title { transition: opacity .12s ease; }
45 g.dim { opacity: 0.25; }
3646 .zone { fill-opacity: 0.16; stroke-opacity: 0.5; stroke: #58a6ff; stroke-width: 1.2; }
3747 .zone-label { font-size: 9px; fill: #8b949e; font-family: ui-monospace, monospace; }
3848 .n-open { cursor: pointer; text-decoration: underline; text-decoration-style: dotted; }
……@@ -58,7 +68,7 @@
5868 <span class="sw" style="background:#1f4a6b"></span>module body &nbsp;&nbsp;
5969 <span class="sw" style="background:#33405a"></span>zone depth rail
6070</div>
61<div class="hud" id="hint">click a title to open it &middot; header toggles code &middot; Esc back &middot; E/C expand/collapse all</div>
71<div class="hud" id="hint">hover a node to trace its edges &middot; B bundles them by group &middot; click a title to open it &middot; Esc back</div>
6272
6373<script type="module" src="/floor/level.js"></script>
6474</body>
lib/floor/level.js +83 -10
……@@ -15,7 +15,7 @@
1515import { layout } from '/floor/layout.js'
1616import {
1717 buildSystemLevel, buildModuleLevel, buildFileLevel, buildSymbolLevel,
18 resolveCode, lineDepths, spanOnLine, marksOwnSpan
18 resolveCode, lineDepths, spanOnLine, marksOwnSpan, bundleLinks
1919} from '/floor/cards.js'
2020
2121const SVG_NS = 'http://www.w3.org/2000/svg'
……@@ -57,6 +57,9 @@ const view = {
5757 cards: [],
5858 links: [],
5959 containers: [],
60 bundled: false, // one edge per container pair instead of per member
61 linkEls: [],
62 cardEls: null,
6063 laid: null,
6164 panX: 60,
6265 panY: 100,
……@@ -182,7 +185,10 @@ function rebuild () {
182185 stats = m.symbols.length + ' symbols · ' + m.nodes.length + ' ops · ' + m.zones.length +
183186 ' zones · ' + m.exits.length + ' exits' + (m.language ? ' · ' + m.language : '')
184187 }
185 document.getElementById('stats').textContent = stats
188 const edgeCount = view.bundled
189 ? bundleLinks(built.cards, built.links).length + ' bundled edges'
190 : built.links.length + ' edges'
191 document.getElementById('stats').textContent = stats + ' · ' + edgeCount
186192 banner(m && m.partial ? 'PARTIAL - ' + m.partialReason : null)
187193 breadcrumb()
188194 render()
……@@ -252,27 +258,54 @@ function render () {
252258 world.appendChild(label)
253259 }
254260
255 for (const l of view.links) {
256 const a = placed.get(l.from)
257 const b = placed.get(l.to)
261 // A bundled edge can end on a container, so boxes come from both lists.
262 const boxes = new Map(view.laid.nodes.map(n => [n.id, n]))
263 for (const c of view.laid.containers) boxes.set(c.id, c)
264
265 view.linkEls = []
266 const edges = view.bundled ? bundleLinks(view.cards, view.links) : view.links
267 for (const l of edges) {
268 const a = boxes.get(l.from)
269 const b = boxes.get(l.to)
258270 if (!a || !b) continue
271 const y1 = a.y + (placed.has(l.from) ? HEAD_H / 2 : a.h / 2)
272 const y2 = b.y + (placed.has(l.to) ? HEAD_H / 2 : b.h / 2)
259273 const x1 = a.x + a.w
260 const y1 = a.y + HEAD_H / 2
261274 const x2 = b.x
262 const y2 = b.y + HEAD_H / 2
263275 const dx = Math.max(30, Math.abs(x2 - x1) * 0.5)
264 world.appendChild(el('path', {
265 class: 'link',
276 const path = el('path', {
277 class: 'link' + (view.bundled ? ' bundled' : ''),
266278 d: 'M ' + x1 + ' ' + y1 + ' C ' + (x1 + dx) + ' ' + y1 + ', ' + (x2 - dx) + ' ' + y2 +
267279 ', ' + x2 + ' ' + y2
268 }))
280 })
281 if (view.bundled && l.count > 1) {
282 path.setAttribute('stroke-width', Math.min(6, 1 + Math.log2(l.count)).toFixed(2))
283 }
284 world.appendChild(path)
285 view.linkEls.push({ el: path, from: l.from, to: l.to })
286
287 if (view.bundled) {
288 const label = el('text', { class: 'link-count', x: (x1 + x2) / 2, y: (y1 + y2) / 2 - 4 })
289 label.textContent = l.count
290 world.appendChild(label)
291 }
269292 }
270293
294 view.cardEls = new Map()
271295 for (const card of view.cards) {
272296 const pos = placed.get(card.id)
273297 if (pos) drawCard(world, card, pos)
274298 }
275299 applyTransform()
300
301 // Focus is deep-linkable too (invariant I5): ?focus=<title> opens the view
302 // with that node's edges already traced, which is also the only way to show
303 // the behaviour in a headless screenshot.
304 const wanted = new URLSearchParams(location.search).get('focus')
305 if (wanted) {
306 const card = view.cards.find(c => c.title === wanted)
307 if (card) focusCard(card)
308 }
276309}
277310
278311function drawCard (world, card, pos) {
……@@ -362,7 +395,41 @@ function drawCard (world, card, pos) {
362395 })
363396 }
364397
398 // Edges are dim until you point at something: at real scale they are the
399 // noise, not the signal - 105 crossing curves at the system level, 316 inside
400 // one 58-file module.
401 g.addEventListener('mouseenter', () => focusCard(card))
402 g.addEventListener('mouseleave', () => unfocus())
403
365404 world.appendChild(g)
405 view.cardEls.set(card.id, g)
406}
407
408// What a card counts as for edges: itself, or its container once bundled.
409function edgeKeyOf (card) {
410 return view.bundled ? (card.container || card.id) : card.id
411}
412
413function focusCard (card) {
414 const key = edgeKeyOf(card)
415 const connected = new Set([card.id])
416
417 for (const link of view.linkEls) {
418 const hot = link.from === key || link.to === key
419 link.el.classList.toggle('hot', hot)
420 link.el.classList.toggle('cold', !hot)
421 if (!hot) continue
422 const other = link.from === key ? link.to : link.from
423 for (const c of view.cards) {
424 if (c.id === other || (view.bundled && (c.container || c.id) === other)) connected.add(c.id)
425 }
426 }
427 for (const [id, g] of view.cardEls) g.classList.toggle('dim', !connected.has(id))
428}
429
430function unfocus () {
431 for (const link of view.linkEls) link.el.classList.remove('hot', 'cold')
432 if (view.cardEls) for (const [, g] of view.cardEls) g.classList.remove('dim')
366433}
367434
368435/**
……@@ -480,6 +547,7 @@ function applyExpandParam () {
480547function syncUrl () {
481548 const sym = currentSymbol()
482549 const q = []
550 if (view.bundled) q.push('edges=bundled')
483551 if (view.module) q.push('module=' + encodeURIComponent(view.module))
484552 if (view.path) q.push('path=' + encodeURIComponent(view.path))
485553 if (sym) q.push('symbol=' + encodeURIComponent(sym.name))
……@@ -532,6 +600,10 @@ function wire () {
532600 } else if (e.key === 'c' || e.key === 'C') {
533601 view.expanded.clear()
534602 rebuild()
603 } else if (e.key === 'b' || e.key === 'B') {
604 view.bundled = !view.bundled
605 syncUrl()
606 rebuild()
535607 } else if (e.key === 'Escape') {
536608 const kind = levelKind()
537609 if (kind === 'symbol') enter(null)
……@@ -557,6 +629,7 @@ async function start () {
557629 await loadTree()
558630 const params = new URLSearchParams(location.search)
559631 view.module = params.get('module')
632 view.bundled = params.get('edges') === 'bundled'
560633 const path = params.get('path')
561634
562635 if (path) {
test/floor-upper-levels.test.js +98 -1
……@@ -2,7 +2,7 @@ import { test } from 'brittle'
22import { resolve, dirname } from 'path'
33import { fileURLToPath } from 'url'
44import { scan } from '../lib/scanner.js'
5import { buildSystemLevel, buildModuleLevel } from '../lib/floor/cards.js'
5import { buildSystemLevel, buildModuleLevel, bundleLinks } from '../lib/floor/cards.js'
66
77const HERE = dirname(fileURLToPath(import.meta.url))
88const WORKSPACE = resolve(HERE, 'fixtures/workspace')
……@@ -103,3 +103,100 @@ test('upper levels: a module that does not exist yields an empty level, not a th
103103 const out = buildModuleLevel(tree, 'nope')
104104 t.alike(out, { cards: [], links: [], containers: [] }, 'empty')
105105})
106
107test('bundling: crossing edges collapse to one per container pair, with a count', (t) => {
108 const cards = [
109 { id: 'a1', container: 'A' }, { id: 'a2', container: 'A' },
110 { id: 'b1', container: 'B' }, { id: 'b2', container: 'B' },
111 { id: 'loose', container: null }
112 ]
113 const links = [
114 { from: 'a1', to: 'b1' },
115 { from: 'a1', to: 'b2' },
116 { from: 'a2', to: 'b1' },
117 { from: 'a1', to: 'a2' }, // inside A — says nothing bundled
118 { from: 'loose', to: 'b1' }
119 ]
120 const bundled = bundleLinks(cards, links)
121
122 const ab = bundled.find(b => b.from === 'A' && b.to === 'B')
123 t.ok(ab, 'A -> B exists')
124 t.is(ab.count, 3, 'carrying all three crossing edges')
125 t.is(ab.members.length, 3, 'and remembering which they were')
126
127 t.absent(bundled.find(b => b.from === 'A' && b.to === 'A'), 'no self-edge for the internal one')
128
129 const lb = bundled.find(b => b.from === 'loose')
130 t.ok(lb, 'a card with no container stands for itself')
131 t.is(lb.to, 'B', 'and still reaches the group it points at')
132})
133
134test('bundling: direction is kept, and the order is stable', (t) => {
135 const cards = [{ id: 'x', container: 'A' }, { id: 'y', container: 'B' }]
136 const links = [{ from: 'x', to: 'y' }, { from: 'y', to: 'x' }]
137 const bundled = bundleLinks(cards, links)
138
139 t.is(bundled.length, 2, 'A->B and B->A are different edges')
140 t.alike(bundleLinks(cards, links), bundled, 'and the order does not wander')
141})
142
143test('bundling: on a real level it is a large reduction', (t) => {
144 const sys = buildSystemLevel(tree)
145 const bundled = bundleLinks(sys.cards, sys.links)
146 t.ok(bundled.length <= sys.links.length, 'never more edges than it started with')
147 t.ok(bundled.every(b => b.count >= 1), 'every bundle carries at least one')
148 const total = bundled.reduce((a, b) => a + b.count, 0)
149 const crossing = sys.links.filter(l => {
150 const g = (id) => (sys.cards.find(c => c.id === id) || {}).container
151 return g(l.from) !== g(l.to)
152 }).length
153 t.is(total, crossing, 'and every crossing edge is accounted for exactly once')
154})
155
156test('bundling: crossing edges collapse to one per container pair, with a count', (t) => {
157 const cards = [
158 { id: 'a1', container: 'A' }, { id: 'a2', container: 'A' },
159 { id: 'b1', container: 'B' }, { id: 'b2', container: 'B' },
160 { id: 'loose', container: null }
161 ]
162 const links = [
163 { from: 'a1', to: 'b1' },
164 { from: 'a1', to: 'b2' },
165 { from: 'a2', to: 'b1' },
166 { from: 'a1', to: 'a2' }, // inside A - says nothing once bundled
167 { from: 'loose', to: 'b1' }
168 ]
169 const bundled = bundleLinks(cards, links)
170
171 const ab = bundled.find(b => b.from === 'A' && b.to === 'B')
172 t.ok(ab, 'A -> B exists')
173 t.is(ab.count, 3, 'carrying all three crossing edges')
174 t.is(ab.members.length, 3, 'and remembering which they were')
175 t.absent(bundled.find(b => b.from === 'A' && b.to === 'A'), 'no self-edge for the internal one')
176
177 const lb = bundled.find(b => b.from === 'loose')
178 t.ok(lb, 'a card with no container stands for itself')
179 t.is(lb.to, 'B', 'and still reaches the group it points at')
180})
181
182test('bundling: direction is kept, and the order is stable', (t) => {
183 const cards = [{ id: 'x', container: 'A' }, { id: 'y', container: 'B' }]
184 const links = [{ from: 'x', to: 'y' }, { from: 'y', to: 'x' }]
185 const bundled = bundleLinks(cards, links)
186
187 t.is(bundled.length, 2, 'A->B and B->A are different edges')
188 t.alike(bundleLinks(cards, links), bundled, 'and the order does not wander')
189})
190
191test('bundling: on a real level every crossing edge is accounted for once', (t) => {
192 const sys = buildSystemLevel(tree)
193 const bundled = bundleLinks(sys.cards, sys.links)
194 t.ok(bundled.length <= sys.links.length, 'never more edges than it started with')
195
196 const groupOf = (id) => {
197 const card = sys.cards.find(c => c.id === id)
198 return card ? (card.container || card.id) : undefined
199 }
200 const crossing = sys.links.filter(l => groupOf(l.from) !== groupOf(l.to)).length
201 t.is(bundled.reduce((a, b) => a + b.count, 0), crossing, 'exactly once')
202})