| … | … | @@ -249,6 +249,45 @@ export function buildSymbolLevel (model, sym) { |
| 249 | 249 | return { cards, links, containers } |
| 250 | 250 | } |
| 251 | 251 | |
| 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 | */ | |
| 267 | export 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 | ||
| 252 | 291 | /** |
| 253 | 292 | * Resolve a card's `code` descriptor against the file's lines. |
| 254 | 293 | * |
| … | … | @@ -32,7 +32,17 @@ |
| 32 | 32 | .n-lineno { font-family: ui-monospace, monospace; font-size: 7px; fill: #484f58; } |
| 33 | 33 | .n-chev { fill: #8b949e; font-size: 10px; cursor: pointer; } |
| 34 | 34 | .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; } | |
| 36 | 46 | .zone { fill-opacity: 0.16; stroke-opacity: 0.5; stroke: #58a6ff; stroke-width: 1.2; } |
| 37 | 47 | .zone-label { font-size: 9px; fill: #8b949e; font-family: ui-monospace, monospace; } |
| 38 | 48 | .n-open { cursor: pointer; text-decoration: underline; text-decoration-style: dotted; } |
| … | … | @@ -58,7 +68,7 @@ |
| 58 | 68 | <span class="sw" style="background:#1f4a6b"></span>module body |
| 59 | 69 | <span class="sw" style="background:#33405a"></span>zone depth rail |
| 60 | 70 | </div> |
| 61 | <div class="hud" id="hint">click a title to open it · header toggles code · Esc back · E/C expand/collapse all</div> | |
| 71 | <div class="hud" id="hint">hover a node to trace its edges · B bundles them by group · click a title to open it · Esc back</div> | |
| 62 | 72 | |
| 63 | 73 | <script type="module" src="/floor/level.js"></script> |
| 64 | 74 | </body> |
| … | … | @@ -15,7 +15,7 @@ |
| 15 | 15 | import { layout } from '/floor/layout.js' |
| 16 | 16 | import { |
| 17 | 17 | buildSystemLevel, buildModuleLevel, buildFileLevel, buildSymbolLevel, |
| 18 | resolveCode, lineDepths, spanOnLine, marksOwnSpan | |
| 18 | resolveCode, lineDepths, spanOnLine, marksOwnSpan, bundleLinks | |
| 19 | 19 | } from '/floor/cards.js' |
| 20 | 20 | |
| 21 | 21 | const SVG_NS = 'http://www.w3.org/2000/svg' |
| … | … | @@ -57,6 +57,9 @@ const view = { |
| 57 | 57 | cards: [], |
| 58 | 58 | links: [], |
| 59 | 59 | containers: [], |
| 60 | bundled: false, // one edge per container pair instead of per member | |
| 61 | linkEls: [], | |
| 62 | cardEls: null, | |
| 60 | 63 | laid: null, |
| 61 | 64 | panX: 60, |
| 62 | 65 | panY: 100, |
| … | … | @@ -182,7 +185,10 @@ function rebuild () { |
| 182 | 185 | stats = m.symbols.length + ' symbols · ' + m.nodes.length + ' ops · ' + m.zones.length + |
| 183 | 186 | ' zones · ' + m.exits.length + ' exits' + (m.language ? ' · ' + m.language : '') |
| 184 | 187 | } |
| 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 | |
| 186 | 192 | banner(m && m.partial ? 'PARTIAL - ' + m.partialReason : null) |
| 187 | 193 | breadcrumb() |
| 188 | 194 | render() |
| … | … | @@ -252,27 +258,54 @@ function render () { |
| 252 | 258 | world.appendChild(label) |
| 253 | 259 | } |
| 254 | 260 | |
| 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) | |
| 258 | 270 | 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) | |
| 259 | 273 | const x1 = a.x + a.w |
| 260 | const y1 = a.y + HEAD_H / 2 | |
| 261 | 274 | const x2 = b.x |
| 262 | const y2 = b.y + HEAD_H / 2 | |
| 263 | 275 | 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' : ''), | |
| 266 | 278 | d: 'M ' + x1 + ' ' + y1 + ' C ' + (x1 + dx) + ' ' + y1 + ', ' + (x2 - dx) + ' ' + y2 + |
| 267 | 279 | ', ' + 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 | } | |
| 269 | 292 | } |
| 270 | 293 | |
| 294 | view.cardEls = new Map() | |
| 271 | 295 | for (const card of view.cards) { |
| 272 | 296 | const pos = placed.get(card.id) |
| 273 | 297 | if (pos) drawCard(world, card, pos) |
| 274 | 298 | } |
| 275 | 299 | 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 | } | |
| 276 | 309 | } |
| 277 | 310 | |
| 278 | 311 | function drawCard (world, card, pos) { |
| … | … | @@ -362,7 +395,41 @@ function drawCard (world, card, pos) { |
| 362 | 395 | }) |
| 363 | 396 | } |
| 364 | 397 | |
| 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 | ||
| 365 | 404 | 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. | |
| 409 | function edgeKeyOf (card) { | |
| 410 | return view.bundled ? (card.container || card.id) : card.id | |
| 411 | } | |
| 412 | ||
| 413 | function 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 | ||
| 430 | function 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') | |
| 366 | 433 | } |
| 367 | 434 | |
| 368 | 435 | /** |
| … | … | @@ -480,6 +547,7 @@ function applyExpandParam () { |
| 480 | 547 | function syncUrl () { |
| 481 | 548 | const sym = currentSymbol() |
| 482 | 549 | const q = [] |
| 550 | if (view.bundled) q.push('edges=bundled') | |
| 483 | 551 | if (view.module) q.push('module=' + encodeURIComponent(view.module)) |
| 484 | 552 | if (view.path) q.push('path=' + encodeURIComponent(view.path)) |
| 485 | 553 | if (sym) q.push('symbol=' + encodeURIComponent(sym.name)) |
| … | … | @@ -532,6 +600,10 @@ function wire () { |
| 532 | 600 | } else if (e.key === 'c' || e.key === 'C') { |
| 533 | 601 | view.expanded.clear() |
| 534 | 602 | rebuild() |
| 603 | } else if (e.key === 'b' || e.key === 'B') { | |
| 604 | view.bundled = !view.bundled | |
| 605 | syncUrl() | |
| 606 | rebuild() | |
| 535 | 607 | } else if (e.key === 'Escape') { |
| 536 | 608 | const kind = levelKind() |
| 537 | 609 | if (kind === 'symbol') enter(null) |
| … | … | @@ -557,6 +629,7 @@ async function start () { |
| 557 | 629 | await loadTree() |
| 558 | 630 | const params = new URLSearchParams(location.search) |
| 559 | 631 | view.module = params.get('module') |
| 632 | view.bundled = params.get('edges') === 'bundled' | |
| 560 | 633 | const path = params.get('path') |
| 561 | 634 | |
| 562 | 635 | if (path) { |
| … | … | @@ -2,7 +2,7 @@ import { test } from 'brittle' |
| 2 | 2 | import { resolve, dirname } from 'path' |
| 3 | 3 | import { fileURLToPath } from 'url' |
| 4 | 4 | import { scan } from '../lib/scanner.js' |
| 5 | import { buildSystemLevel, buildModuleLevel } from '../lib/floor/cards.js' | |
| 5 | import { buildSystemLevel, buildModuleLevel, bundleLinks } from '../lib/floor/cards.js' | |
| 6 | 6 | |
| 7 | 7 | const HERE = dirname(fileURLToPath(import.meta.url)) |
| 8 | 8 | const 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 |
| 103 | 103 | const out = buildModuleLevel(tree, 'nope') |
| 104 | 104 | t.alike(out, { cards: [], links: [], containers: [] }, 'empty') |
| 105 | 105 | }) |
| 106 | ||
| 107 | test('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 | ||
| 134 | test('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 | ||
| 143 | test('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 | ||
| 156 | test('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 | ||
| 182 | test('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 | ||
| 191 | test('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 | }) |