OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 1740e19

Panels become bars; click a node for its full dependency depth
## The overlap was mine, and it was the panels Reported: "system level, the groups still overlap". I had measured zero container overlaps from layout() and treated the report as something else. Wrong instrument. Measured again from the RENDERED DOM: container-vs-container overlaps 0 cards straddling a group boundary 0 card-vs-card overlaps 0 groups covered by the header HUD 1 <- "@noble/hashes users" The boxes never intersected; the floating header panel sat on top of whatever was at world origin, which reads exactly like groups overlapping. I had even noted the occlusion myself and filed it as cosmetic. The panels are now grid ROWS — header, canvas, footer — so a panel cannot cover the canvas at any pan or zoom. That is a fix, not a workaround. ## Recursive dependencies, with distance in the colour Clicking a node shows its whole transitive dependency closure rather than one hop. dependencyClosure() walks BACKWARD along supplier -> consumer edges, breadth-first, so the depth recorded is the SHORTEST path and a cycle stops instead of spinning. Nodes in the closure carry a "N hops" badge; everything outside it dims. Measured on the 56-module scan: 10 modules have a transitive dependency, and the deepest reaches 14 modules three hops out — which a one-hop view does not show at all. depthColor() ramps amber -> red -> pink -> violet, going DOWN through 0 rather than up through green: a ramp through green reads as "near and healthy" in the middle, the opposite of what distance should feel like. Verified from the DOM that the strokes really carry it — 35 closure edges at three distinct hues and widths, 70 edges cold, 14 badges. Both are deep-linkable: ?select=<title> and ?focus=<title>. One test expectation corrected: I asserted the fixture workspace had a transitive dependency, and it does not — it is deliberately one hop deep. The test now asserts what that fixture actually proves, and depth is proven on synthetic chains where the shape is controlled. Suite: 206 tests, 4707 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1740e199e5b3583970e994e9c34e0ca16403c468
by plankatron <fleeky@gmail.com> · 2026-09-21T00:13:27.000Z

Changed files (4) +235 -23

lib/floor/cards.js +66 -0
……@@ -249,6 +249,72 @@ export function buildSymbolLevel (model, sym) {
249249 return { cards, links, containers }
250250}
251251
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 */
267export 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 */
304export 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
252318/**
253319 * Collapse member-to-member edges into one edge per container pair.
254320 *
lib/floor/level.html +34 -15
……@@ -4,22 +4,28 @@
44<meta charset="utf-8">
55<title>codetree — file level</title>
66<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. */
710 html, body { margin: 0; height: 100%; background: #131417; color: #c9d1d9; overflow: hidden;
811 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; }
1014 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; }
1420 #head input { background: #131417; border: 1px solid #2f3136; color: #c9d1d9; font: inherit;
1521 padding: 3px 6px; border-radius: 3px; width: 340px; }
1622 #head b { color: #e8e8ea; }
1723 #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; }
2026 .sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px;
2127 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; }
2329 .n-body { fill: #26272b; stroke: #17181a; }
2430 .n-head { stroke: none; }
2531 .n-title { fill: #e8e8ea; font-size: 11px; font-weight: 600; }
……@@ -39,6 +45,11 @@
3945 .link.bundled { stroke: #58a6ff; opacity: 0.45; }
4046 .link.hot { stroke: #ffa657; opacity: 0.95; }
4147 .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; }
4253 .link-count { font-size: 9px; fill: #8b949e; font-family: ui-monospace, monospace;
4354 text-anchor: middle; pointer-events: none; }
4455 .n-body, .n-head, .n-title { transition: opacity .12s ease; }
……@@ -53,22 +64,30 @@
5364</style>
5465</head>
5566<body>
56<svg id="svg"><g id="world"></g></svg>
57
5867<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>
6277</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">
6583 <span class="sw" style="background:#b8642a"></span>function &nbsp;
6684 <span class="sw" style="background:#6b4a86"></span>class &nbsp;
6785 <span class="sw" style="background:#8a6a2a"></span>const &nbsp;
6886 <span class="sw" style="background:#1f4a6b"></span>module body &nbsp;&nbsp;
6987 <span class="sw" style="background:#33405a"></span>zone depth rail
7088</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>
89<div id="hint">hover traces edges &middot; click a node for its dependency depth &middot; B bundles &middot; title opens &middot; Esc back</div>
90</div>
7291
7392<script type="module" src="/floor/level.js"></script>
7493</body>
lib/floor/level.js +66 -7
……@@ -15,7 +15,8 @@
1515import { layout } from '/floor/layout.js'
1616import {
1717 buildSystemLevel, buildModuleLevel, buildFileLevel, buildSymbolLevel,
18 resolveCode, lineDepths, spanOnLine, marksOwnSpan, bundleLinks
18 resolveCode, lineDepths, spanOnLine, marksOwnSpan, bundleLinks,
19 dependencyClosure, depthColor
1920} from '/floor/cards.js'
2021
2122const SVG_NS = 'http://www.w3.org/2000/svg'
……@@ -58,6 +59,7 @@ const view = {
5859 links: [],
5960 containers: [],
6061 bundled: false, // one edge per container pair instead of per member
62 selected: null, // a card whose whole dependency closure is showing
6163 linkEls: [],
6264 cardEls: null,
6365 laid: null,
……@@ -264,6 +266,18 @@ function render () {
264266
265267 view.linkEls = []
266268 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
267281 for (const l of edges) {
268282 const a = boxes.get(l.from)
269283 const b = boxes.get(l.to)
……@@ -278,6 +292,16 @@ function render () {
278292 d: 'M ' + x1 + ' ' + y1 + ' C ' + (x1 + dx) + ' ' + y1 + ', ' + (x2 - dx) + ' ' + y2 +
279293 ', ' + x2 + ' ' + y2
280294 })
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 }
281305 if (view.bundled && l.count > 1) {
282306 path.setAttribute('stroke-width', Math.min(6, 1 + Math.log2(l.count)).toFixed(2))
283307 }
……@@ -294,25 +318,45 @@ function render () {
294318 view.cardEls = new Map()
295319 for (const card of view.cards) {
296320 const pos = placed.get(card.id)
297 if (pos) drawCard(world, card, pos)
321 if (pos) drawCard(world, card, pos, closure)
298322 }
299323 applyTransform()
300324
301325 // Focus is deep-linkable too (invariant I5): ?focus=<title> opens the view
302326 // with that node's edges already traced, which is also the only way to show
303327 // 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')
305330 if (wanted) {
306331 const card = view.cards.find(c => c.title === wanted)
307332 if (card) focusCard(card)
308333 }
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 }
309340}
310341
311function drawCard (world, card, pos) {
342function drawCard (world, card, pos, closure) {
312343 const g = el('g', { transform: 'translate(' + pos.x + ',' + pos.y + ')' })
313344 const open = view.expanded.has(card.id)
314345
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 }
316360 g.appendChild(el('path', {
317361 class: 'n-head',
318362 fill: KIND_COLOR[card.kind] || '#3a3d44',
……@@ -398,8 +442,12 @@ function drawCard (world, card, pos) {
398442 // Edges are dim until you point at something: at real scale they are the
399443 // noise, not the signal - 105 crossing curves at the system level, 316 inside
400444 // 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 })
403451
404452 world.appendChild(g)
405453 view.cardEls.set(card.id, g)
……@@ -427,6 +475,12 @@ function focusCard (card) {
427475 for (const [id, g] of view.cardEls) g.classList.toggle('dim', !connected.has(id))
428476}
429477
478function select (id) {
479 view.selected = id
480 unfocus()
481 render()
482}
483
430484function unfocus () {
431485 for (const link of view.linkEls) link.el.classList.remove('hot', 'cold')
432486 if (view.cardEls) for (const [, g] of view.cardEls) g.classList.remove('dim')
……@@ -575,6 +629,10 @@ function wire () {
575629 applyTransform()
576630 }, { passive: false })
577631
632 svg.addEventListener('click', (e) => {
633 if (e.target === svg || e.target.id === 'world') select(null)
634 })
635
578636 svg.addEventListener('mousedown', (e) => {
579637 view.panning = true
580638 view.sx = e.clientX - view.panX
……@@ -605,6 +663,7 @@ function wire () {
605663 syncUrl()
606664 rebuild()
607665 } else if (e.key === 'Escape') {
666 if (view.selected) { select(null); return }
608667 const kind = levelKind()
609668 if (kind === 'symbol') enter(null)
610669 else if (kind === 'file') {
test/floor-upper-levels.test.js +69 -1
……@@ -2,7 +2,9 @@ import { test } from 'brittle'
22import { resolve, dirname } from 'path'
33import { fileURLToPath } from 'url'
44import { scan } from '../lib/scanner.js'
5import { buildSystemLevel, buildModuleLevel, bundleLinks } from '../lib/floor/cards.js'
5import {
6 buildSystemLevel, buildModuleLevel, bundleLinks, dependencyClosure, depthColor
7} from '../lib/floor/cards.js'
68
79const HERE = dirname(fileURLToPath(import.meta.url))
810const WORKSPACE = resolve(HERE, 'fixtures/workspace')
……@@ -200,3 +202,69 @@ test('bundling: on a real level every crossing edge is accounted for once', (t)
200202 const crossing = sys.links.filter(l => groupOf(l.from) !== groupOf(l.to)).length
201203 t.is(bundled.reduce((a, b) => a + b.count, 0), crossing, 'exactly once')
202204})
205
206test('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
224test('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
235test('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
242test('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
249test('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
263test('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})