| … | … | @@ -249,6 +249,72 @@ export function buildSymbolLevel (model, sym) { |
| 249 | 249 | return { cards, links, containers } |
| 250 | 250 | } |
| 251 | 251 | |
| 252 | /** | |
| 253 | * Everything a node depends on, transitively, with the distance to each. | |
| 254 | * | |
| 255 | * Clicking a module should answer "what does this actually pull in", not just | |
| 256 | * "what does it name in its package.json" — a one-hop view hides that a module | |
| 257 | * two steps away is the thing really being depended on. | |
| 258 | * | |
| 259 | * Links read supplier -> consumer at every level, so DEPENDENCIES are found by | |
| 260 | * walking BACKWARD (edges whose `to` is the node) and DEPENDENTS by walking | |
| 261 | * forward. Breadth-first, so the depth recorded is the SHORTEST path, and a | |
| 262 | * cycle simply stops rather than spinning. | |
| 263 | * | |
| 264 | * Returns { nodes: Map(id -> depth), edges: Map('from -> to' -> depth) } with | |
| 265 | * the start node at depth 0. | |
| 266 | */ | |
| 267 | export function dependencyClosure (links, startId, direction = 'dependencies') { | |
| 268 | const forward = direction === 'dependents' | |
| 269 | const adjacency = new Map() | |
| 270 | for (const l of links) { | |
| 271 | const key = forward ? l.from : l.to | |
| 272 | const next = forward ? l.to : l.from | |
| 273 | if (!adjacency.has(key)) adjacency.set(key, []) | |
| 274 | adjacency.get(key).push({ next, edge: l.from + ' -> ' + l.to }) | |
| 275 | } | |
| 276 | ||
| 277 | const nodes = new Map([[startId, 0]]) | |
| 278 | const edges = new Map() | |
| 279 | let frontier = [startId] | |
| 280 | let depth = 0 | |
| 281 | ||
| 282 | while (frontier.length > 0) { | |
| 283 | depth++ | |
| 284 | const nextFrontier = [] | |
| 285 | for (const id of frontier) { | |
| 286 | for (const step of adjacency.get(id) || []) { | |
| 287 | if (!edges.has(step.edge)) edges.set(step.edge, depth) | |
| 288 | if (nodes.has(step.next)) continue | |
| 289 | nodes.set(step.next, depth) | |
| 290 | nextFrontier.push(step.next) | |
| 291 | } | |
| 292 | } | |
| 293 | frontier = nextFrontier | |
| 294 | } | |
| 295 | return { nodes, edges, maxDepth: Math.max(0, ...nodes.values()) } | |
| 296 | } | |
| 297 | ||
| 298 | /** | |
| 299 | * A colour for an edge at distance `depth` out of `maxDepth`. | |
| 300 | * | |
| 301 | * Near edges are hot and far ones cool, so how far out a dependency sits is | |
| 302 | * readable without counting hops. | |
| 303 | */ | |
| 304 | export function depthColor (depth, maxDepth) { | |
| 305 | if (depth <= 0) return '#ffa657' | |
| 306 | const span = Math.max(1, maxDepth) | |
| 307 | const t = Math.min(1, (depth - 1) / span) | |
| 308 | // Amber -> red -> pink -> violet, going DOWN through 0 rather than up through | |
| 309 | // green: a ramp that passes through green reads as "near and healthy" in the | |
| 310 | // middle, which is the opposite of what distance should feel like. | |
| 311 | const hue = (35 - t * 95 + 360) % 360 | |
| 312 | const sat = 85 - t * 20 | |
| 313 | const light = 62 - t * 10 | |
| 314 | return 'hsl(' + hue.toFixed(0) + ', ' + sat.toFixed(0) + '%, ' + light.toFixed(0) + '%)' | |
| 315 | } | |
| 316 | ||
| 317 | ||
| 252 | 318 | /** |
| 253 | 319 | * Collapse member-to-member edges into one edge per container pair. |
| 254 | 320 | * |
| … | … | @@ -4,22 +4,28 @@ |
| 4 | 4 | <meta charset="utf-8"> |
| 5 | 5 | <title>codetree — file level</title> |
| 6 | 6 | <style> |
| 7 | /* The panels are BARS, not floating overlays. As overlays the header sat on top | |
| 8 | of whatever was at world origin, which reads — correctly — as groups | |
| 9 | overlapping. A grid row cannot cover the canvas. */ | |
| 7 | 10 | html, body { margin: 0; height: 100%; background: #131417; color: #c9d1d9; overflow: hidden; |
| 8 | 11 | font: 12px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } |
| 9 | svg { display: block; width: 100vw; height: 100vh; cursor: grab; } | |
| 12 | body { display: grid; grid-template-rows: auto 1fr auto; } | |
| 13 | svg { display: block; width: 100%; height: 100%; cursor: grab; min-height: 0; } | |
| 10 | 14 | svg.dragging { cursor: grabbing; } |
| 11 | .hud { position: absolute; background: rgba(28,29,33,0.94); border: 1px solid #2f3136; | |
| 12 | border-radius: 6px; padding: 8px 12px; line-height: 1.8; } | |
| 13 | #head { top: 12px; left: 12px; } | |
| 15 | .hud { background: #1c1d21; border-bottom: 1px solid #2f3136; padding: 8px 14px; | |
| 16 | line-height: 1.7; } | |
| 17 | #head { display: flex; gap: 20px; align-items: baseline; flex-wrap: wrap; } | |
| 18 | #foot { border-bottom: none; border-top: 1px solid #2f3136; display: flex; | |
| 19 | justify-content: space-between; align-items: center; gap: 16px; } | |
| 14 | 20 | #head input { background: #131417; border: 1px solid #2f3136; color: #c9d1d9; font: inherit; |
| 15 | 21 | padding: 3px 6px; border-radius: 3px; width: 340px; } |
| 16 | 22 | #head b { color: #e8e8ea; } |
| 17 | 23 | #stats { color: #8b949e; } |
| 18 | #hint { bottom: 12px; right: 12px; color: #8b949e; } | |
| 19 | #legend { bottom: 12px; left: 12px; } | |
| 24 | #hint { color: #8b949e; font-size: 11px; } | |
| 25 | #legend { font-size: 11px; } | |
| 20 | 26 | .sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; |
| 21 | 27 | vertical-align: -1px; } |
| 22 | #banner { top: 12px; right: 12px; color: #f0883e; max-width: 360px; display: none; } | |
| 28 | #banner { color: #f0883e; display: none; margin-left: 12px; } | |
| 23 | 29 | .n-body { fill: #26272b; stroke: #17181a; } |
| 24 | 30 | .n-head { stroke: none; } |
| 25 | 31 | .n-title { fill: #e8e8ea; font-size: 11px; font-weight: 600; } |
| … | … | @@ -39,6 +45,11 @@ |
| 39 | 45 | .link.bundled { stroke: #58a6ff; opacity: 0.45; } |
| 40 | 46 | .link.hot { stroke: #ffa657; opacity: 0.95; } |
| 41 | 47 | .link.cold { opacity: 0.04; } |
| 48 | /* A selected node's dependency closure: near edges hot, far ones cooling off, | |
| 49 | so depth is readable without counting hops. */ | |
| 50 | .link.dep { opacity: 0.92; } | |
| 51 | .n-body.selected { stroke: #ffa657; stroke-width: 2; } | |
| 52 | .dep-badge { font-size: 8px; font-family: ui-monospace, monospace; fill: #c9d1d9; } | |
| 42 | 53 | .link-count { font-size: 9px; fill: #8b949e; font-family: ui-monospace, monospace; |
| 43 | 54 | text-anchor: middle; pointer-events: none; } |
| 44 | 55 | .n-body, .n-head, .n-title { transition: opacity .12s ease; } |
| … | … | @@ -53,22 +64,30 @@ |
| 53 | 64 | </style> |
| 54 | 65 | </head> |
| 55 | 66 | <body> |
| 56 | <svg id="svg"><g id="world"></g></svg> | |
| 57 | ||
| 58 | 67 | <div class="hud" id="head"> |
| 59 | <div id="levelname"><b>file level</b></div> | |
| 60 | <div style="margin-top:6px"><input id="path" spellcheck="false"> <span id="stats"></span></div> | |
| 61 | <div style="margin-top:4px" id="crumb"></div> | |
| 68 | <div> | |
| 69 | <div id="levelname"><b>file level</b></div> | |
| 70 | <div style="margin-top:4px" id="crumb"></div> | |
| 71 | </div> | |
| 72 | <div style="flex:1"> | |
| 73 | <input id="path" spellcheck="false" placeholder="open a file by path"> | |
| 74 | <span id="stats"></span> | |
| 75 | <span id="banner"></span> | |
| 76 | </div> | |
| 62 | 77 | </div> |
| 63 | <div class="hud" id="banner"></div> | |
| 64 | <div class="hud" id="legend"> | |
| 78 | ||
| 79 | <svg id="svg"><g id="world"></g></svg> | |
| 80 | ||
| 81 | <div class="hud" id="foot"> | |
| 82 | <div id="legend"> | |
| 65 | 83 | <span class="sw" style="background:#b8642a"></span>function |
| 66 | 84 | <span class="sw" style="background:#6b4a86"></span>class |
| 67 | 85 | <span class="sw" style="background:#8a6a2a"></span>const |
| 68 | 86 | <span class="sw" style="background:#1f4a6b"></span>module body |
| 69 | 87 | <span class="sw" style="background:#33405a"></span>zone depth rail |
| 70 | 88 | </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> | |
| 89 | <div id="hint">hover traces edges · click a node for its dependency depth · B bundles · title opens · Esc back</div> | |
| 90 | </div> | |
| 72 | 91 | |
| 73 | 92 | <script type="module" src="/floor/level.js"></script> |
| 74 | 93 | </body> |
| … | … | @@ -15,7 +15,8 @@ |
| 15 | 15 | import { layout } from '/floor/layout.js' |
| 16 | 16 | import { |
| 17 | 17 | buildSystemLevel, buildModuleLevel, buildFileLevel, buildSymbolLevel, |
| 18 | resolveCode, lineDepths, spanOnLine, marksOwnSpan, bundleLinks | |
| 18 | resolveCode, lineDepths, spanOnLine, marksOwnSpan, bundleLinks, | |
| 19 | dependencyClosure, depthColor | |
| 19 | 20 | } from '/floor/cards.js' |
| 20 | 21 | |
| 21 | 22 | const SVG_NS = 'http://www.w3.org/2000/svg' |
| … | … | @@ -58,6 +59,7 @@ const view = { |
| 58 | 59 | links: [], |
| 59 | 60 | containers: [], |
| 60 | 61 | bundled: false, // one edge per container pair instead of per member |
| 62 | selected: null, // a card whose whole dependency closure is showing | |
| 61 | 63 | linkEls: [], |
| 62 | 64 | cardEls: null, |
| 63 | 65 | laid: null, |
| … | … | @@ -264,6 +266,18 @@ function render () { |
| 264 | 266 | |
| 265 | 267 | view.linkEls = [] |
| 266 | 268 | const edges = view.bundled ? bundleLinks(view.cards, view.links) : view.links |
| 269 | ||
| 270 | // Clicking a node shows its FULL dependency depth, not just one hop, with the | |
| 271 | // distance carried by colour. Measured on a 56-module scan: 10 modules have a | |
| 272 | // transitive dependency and the deepest reaches 14 modules three hops out — | |
| 273 | // which a one-hop view simply does not show. | |
| 274 | const selectedCard = view.selected | |
| 275 | ? view.cards.find(c => c.id === view.selected) | |
| 276 | : null | |
| 277 | const closure = selectedCard | |
| 278 | ? dependencyClosure(edges, edgeKeyOf(selectedCard)) | |
| 279 | : null | |
| 280 | ||
| 267 | 281 | for (const l of edges) { |
| 268 | 282 | const a = boxes.get(l.from) |
| 269 | 283 | const b = boxes.get(l.to) |
| … | … | @@ -278,6 +292,16 @@ function render () { |
| 278 | 292 | d: 'M ' + x1 + ' ' + y1 + ' C ' + (x1 + dx) + ' ' + y1 + ', ' + (x2 - dx) + ' ' + y2 + |
| 279 | 293 | ', ' + x2 + ' ' + y2 |
| 280 | 294 | }) |
| 295 | if (closure) { | |
| 296 | const depth = closure.edges.get(l.from + ' -> ' + l.to) | |
| 297 | if (depth === undefined) { | |
| 298 | path.classList.add('cold') | |
| 299 | } else { | |
| 300 | path.classList.add('dep') | |
| 301 | path.setAttribute('stroke', depthColor(depth, closure.maxDepth)) | |
| 302 | path.setAttribute('stroke-width', Math.max(1.2, 2.6 - depth * 0.35).toFixed(2)) | |
| 303 | } | |
| 304 | } | |
| 281 | 305 | if (view.bundled && l.count > 1) { |
| 282 | 306 | path.setAttribute('stroke-width', Math.min(6, 1 + Math.log2(l.count)).toFixed(2)) |
| 283 | 307 | } |
| … | … | @@ -294,25 +318,45 @@ function render () { |
| 294 | 318 | view.cardEls = new Map() |
| 295 | 319 | for (const card of view.cards) { |
| 296 | 320 | const pos = placed.get(card.id) |
| 297 | if (pos) drawCard(world, card, pos) | |
| 321 | if (pos) drawCard(world, card, pos, closure) | |
| 298 | 322 | } |
| 299 | 323 | applyTransform() |
| 300 | 324 | |
| 301 | 325 | // Focus is deep-linkable too (invariant I5): ?focus=<title> opens the view |
| 302 | 326 | // with that node's edges already traced, which is also the only way to show |
| 303 | 327 | // the behaviour in a headless screenshot. |
| 304 | const wanted = new URLSearchParams(location.search).get('focus') | |
| 328 | const params = new URLSearchParams(location.search) | |
| 329 | const wanted = params.get('focus') | |
| 305 | 330 | if (wanted) { |
| 306 | 331 | const card = view.cards.find(c => c.title === wanted) |
| 307 | 332 | if (card) focusCard(card) |
| 308 | 333 | } |
| 334 | // ?select=<title> opens with that node's whole dependency closure showing. | |
| 335 | const wantSelect = params.get('select') | |
| 336 | if (wantSelect && !view.selected) { | |
| 337 | const card = view.cards.find(c => c.title === wantSelect) | |
| 338 | if (card) { view.selected = card.id; render() } | |
| 339 | } | |
| 309 | 340 | } |
| 310 | 341 | |
| 311 | function drawCard (world, card, pos) { | |
| 342 | function drawCard (world, card, pos, closure) { | |
| 312 | 343 | const g = el('g', { transform: 'translate(' + pos.x + ',' + pos.y + ')' }) |
| 313 | 344 | const open = view.expanded.has(card.id) |
| 314 | 345 | |
| 315 | g.appendChild(el('rect', { class: 'n-body', width: pos.w, height: pos.h, rx: 5 })) | |
| 346 | const body = el('rect', { class: 'n-body', width: pos.w, height: pos.h, rx: 5 }) | |
| 347 | if (view.selected === card.id) body.classList.add('selected') | |
| 348 | g.appendChild(body) | |
| 349 | ||
| 350 | if (closure) { | |
| 351 | const depth = closure.nodes.get(edgeKeyOf(card)) | |
| 352 | if (depth === undefined) g.classList.add('dim') | |
| 353 | else if (depth > 0) { | |
| 354 | const badge = el('text', { class: 'dep-badge', x: pos.w - 7, y: pos.h - 5, 'text-anchor': 'end' }) | |
| 355 | badge.textContent = depth + (depth === 1 ? ' hop' : ' hops') | |
| 356 | badge.setAttribute('fill', depthColor(depth, closure.maxDepth)) | |
| 357 | g.appendChild(badge) | |
| 358 | } | |
| 359 | } | |
| 316 | 360 | g.appendChild(el('path', { |
| 317 | 361 | class: 'n-head', |
| 318 | 362 | fill: KIND_COLOR[card.kind] || '#3a3d44', |
| … | … | @@ -398,8 +442,12 @@ function drawCard (world, card, pos) { |
| 398 | 442 | // Edges are dim until you point at something: at real scale they are the |
| 399 | 443 | // noise, not the signal - 105 crossing curves at the system level, 316 inside |
| 400 | 444 | // one 58-file module. |
| 401 | g.addEventListener('mouseenter', () => focusCard(card)) | |
| 402 | g.addEventListener('mouseleave', () => unfocus()) | |
| 445 | g.addEventListener('mouseenter', () => { if (!view.selected) focusCard(card) }) | |
| 446 | g.addEventListener('mouseleave', () => { if (!view.selected) unfocus() }) | |
| 447 | g.addEventListener('click', (e) => { | |
| 448 | e.stopPropagation() | |
| 449 | select(view.selected === card.id ? null : card.id) | |
| 450 | }) | |
| 403 | 451 | |
| 404 | 452 | world.appendChild(g) |
| 405 | 453 | view.cardEls.set(card.id, g) |
| … | … | @@ -427,6 +475,12 @@ function focusCard (card) { |
| 427 | 475 | for (const [id, g] of view.cardEls) g.classList.toggle('dim', !connected.has(id)) |
| 428 | 476 | } |
| 429 | 477 | |
| 478 | function select (id) { | |
| 479 | view.selected = id | |
| 480 | unfocus() | |
| 481 | render() | |
| 482 | } | |
| 483 | ||
| 430 | 484 | function unfocus () { |
| 431 | 485 | for (const link of view.linkEls) link.el.classList.remove('hot', 'cold') |
| 432 | 486 | if (view.cardEls) for (const [, g] of view.cardEls) g.classList.remove('dim') |
| … | … | @@ -575,6 +629,10 @@ function wire () { |
| 575 | 629 | applyTransform() |
| 576 | 630 | }, { passive: false }) |
| 577 | 631 | |
| 632 | svg.addEventListener('click', (e) => { | |
| 633 | if (e.target === svg || e.target.id === 'world') select(null) | |
| 634 | }) | |
| 635 | ||
| 578 | 636 | svg.addEventListener('mousedown', (e) => { |
| 579 | 637 | view.panning = true |
| 580 | 638 | view.sx = e.clientX - view.panX |
| … | … | @@ -605,6 +663,7 @@ function wire () { |
| 605 | 663 | syncUrl() |
| 606 | 664 | rebuild() |
| 607 | 665 | } else if (e.key === 'Escape') { |
| 666 | if (view.selected) { select(null); return } | |
| 608 | 667 | const kind = levelKind() |
| 609 | 668 | if (kind === 'symbol') enter(null) |
| 610 | 669 | else if (kind === 'file') { |
| … | … | @@ -2,7 +2,9 @@ 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, bundleLinks } from '../lib/floor/cards.js' | |
| 5 | import { | |
| 6 | buildSystemLevel, buildModuleLevel, bundleLinks, dependencyClosure, depthColor | |
| 7 | } from '../lib/floor/cards.js' | |
| 6 | 8 | |
| 7 | 9 | const HERE = dirname(fileURLToPath(import.meta.url)) |
| 8 | 10 | const WORKSPACE = resolve(HERE, 'fixtures/workspace') |
| … | … | @@ -200,3 +202,69 @@ test('bundling: on a real level every crossing edge is accounted for once', (t) |
| 200 | 202 | const crossing = sys.links.filter(l => groupOf(l.from) !== groupOf(l.to)).length |
| 201 | 203 | t.is(bundled.reduce((a, b) => a + b.count, 0), crossing, 'exactly once') |
| 202 | 204 | }) |
| 205 | ||
| 206 | test('closure: dependencies are found by walking backward, with shortest depth', (t) => { | |
| 207 | // a -> b -> c -> d (supplier -> consumer), so d depends on c, b and a. | |
| 208 | const links = [ | |
| 209 | { from: 'a', to: 'b' }, { from: 'b', to: 'c' }, { from: 'c', to: 'd' } | |
| 210 | ] | |
| 211 | const out = dependencyClosure(links, 'd') | |
| 212 | ||
| 213 | t.is(out.nodes.get('d'), 0, 'the start is depth 0') | |
| 214 | t.is(out.nodes.get('c'), 1, 'its direct dependency is 1') | |
| 215 | t.is(out.nodes.get('b'), 2, 'then 2') | |
| 216 | t.is(out.nodes.get('a'), 3, 'then 3') | |
| 217 | t.is(out.maxDepth, 3, 'and it reports how deep it went') | |
| 218 | ||
| 219 | // The other direction finds nothing: d supplies nobody. | |
| 220 | t.is(dependencyClosure(links, 'd', 'dependents').nodes.size, 1, 'no dependents') | |
| 221 | t.is(dependencyClosure(links, 'a', 'dependents').nodes.get('d'), 3, 'but a has d at 3') | |
| 222 | }) | |
| 223 | ||
| 224 | test('closure: depth is the SHORTEST path, not the first one walked', (t) => { | |
| 225 | // a -> b -> c -> e | |
| 226 | // a -------------> e (a is both 3 hops and 1 hop away) | |
| 227 | const links = [ | |
| 228 | { from: 'a', to: 'b' }, { from: 'b', to: 'c' }, { from: 'c', to: 'e' }, | |
| 229 | { from: 'a', to: 'e' } | |
| 230 | ] | |
| 231 | const out = dependencyClosure(links, 'e') | |
| 232 | t.is(out.nodes.get('a'), 1, 'a is one hop away by the short edge') | |
| 233 | }) | |
| 234 | ||
| 235 | test('closure: a cycle terminates', (t) => { | |
| 236 | const links = [{ from: 'a', to: 'b' }, { from: 'b', to: 'c' }, { from: 'c', to: 'a' }] | |
| 237 | const out = dependencyClosure(links, 'a') | |
| 238 | t.is(out.nodes.size, 3, 'all three reached') | |
| 239 | t.ok(out.maxDepth <= 3, 'and it stopped') | |
| 240 | }) | |
| 241 | ||
| 242 | test('closure: unrelated nodes are not in it', (t) => { | |
| 243 | const links = [{ from: 'a', to: 'b' }, { from: 'x', to: 'y' }] | |
| 244 | const out = dependencyClosure(links, 'b') | |
| 245 | t.absent(out.nodes.has('x'), 'a different component is excluded') | |
| 246 | t.absent(out.nodes.has('y'), 'entirely') | |
| 247 | }) | |
| 248 | ||
| 249 | test('closure: on a real level it finds the real dependency', (t) => { | |
| 250 | // This fixture is deliberately one hop deep — widget-rooms feeds widget-store | |
| 251 | // and nothing else chains — so the claim here is that the closure finds the | |
| 252 | // right module at the right distance, not that it goes deep. Depth is proven | |
| 253 | // on the synthetic chains above, where the shape is controlled. | |
| 254 | const sys = buildSystemLevel(tree) | |
| 255 | const store = sys.cards.find(c => c.title === 'widget-store') | |
| 256 | const out = dependencyClosure(sys.links, store.id) | |
| 257 | ||
| 258 | t.is(out.nodes.get('module:widget-rooms'), 1, 'widget-rooms is one hop away') | |
| 259 | t.is(out.maxDepth, 1, 'and this fixture is exactly one hop deep') | |
| 260 | t.absent(out.nodes.has('module:legacy-sync'), 'an unrelated module is not pulled in') | |
| 261 | }) | |
| 262 | ||
| 263 | test('gradient: colour moves with depth and is stable at the ends', (t) => { | |
| 264 | t.is(depthColor(0, 5), '#ffa657', 'the selected node itself is the accent colour') | |
| 265 | const near = depthColor(1, 5) | |
| 266 | const far = depthColor(5, 5) | |
| 267 | t.not(near, far, 'near and far differ') | |
| 268 | t.ok(near.startsWith('hsl('), 'and they are hsl ramps: ' + near + ' -> ' + far) | |
| 269 | t.is(depthColor(9, 1), depthColor(9, 1), 'deterministic') | |
| 270 | }) |