OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 46266f6

The symbol level: operations inside their zones
Open a symbol from the file level and you get its interior — operations as nodes, zones as CONTAINERS, dataflow as wires, each node expandable to its own source line. This is what lib/layout.js's container support was built for; until now nothing used it. Both rungs now produce the same shape — cards, links, containers — so layout and rendering are shared and the two levels cannot drift apart visually. ## What the symbol level draws - **zones as labelled regions**, nested, carrying their derived facts: a container reads `foreach Object.keys(maps)` or `guard !mod.entry`, and shows `carries total` when the zone has carried state - **operations** with their resolved inputs (`BUILTINS.has [local], bare [local]`), the line they came from, and call/operation colouring - **named wires**: a declaration names its producing operation, so the node is badged `-> bare` rather than the name floating free (R3) - **nested functions** as nodes, because 12 of lib/sections.js's 16 functions are nested inside another and belong to an interior, not the file level (L1) - **exits with no operation of their own** — break, continue — which would otherwise be invisible even though they are what gives a zone its shape Verified by screenshot: isRuntimeSupplied (19 nodes, 4 zones, one guard nested inside a foreach) and entryKeyFor with every node expanded, where the guard container grows to enclose three expanded nodes. That is the L4 reflow happening inside a container, which is the case the layout exists for. ## Navigation Click a symbol's title to descend, the breadcrumb or Esc to come back. Deep links carry the level: ?path=X&symbol=NAME. At the file level ?expand= takes symbol names; at the symbol level operation ids are byte offsets, so only `expand=all` round-trips — stated rather than pretended otherwise. ## A gap I am not papering over The card builders (buildFileLevel, buildSymbolLevel) are verified by screenshot, not by unit test, because level.js imports the served '/floor/layout.js' path and so cannot be imported from node. Extracting them into a pure module that both the browser and the test runner can import is the obvious next step; the parse gate in test/floor-view.test.js is all that guards them today. Suite unchanged at 168 tests / 1021 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
46266f63a444206c5549faebb154c803aa2343d3
by plankatron <fleeky@gmail.com> · 2026-09-20T22:57:23.000Z

Changed files (2) +370 -147

lib/floor/level.html +9 -2
……@@ -31,6 +31,12 @@
3131 .n-chev { fill: #8b949e; font-size: 10px; cursor: pointer; }
3232 .n-hit { fill: transparent; cursor: pointer; }
3333 .link { fill: none; stroke: #4d5866; stroke-width: 1.4; }
34 .zone { fill-opacity: 0.16; stroke-opacity: 0.5; stroke: #58a6ff; stroke-width: 1.2; }
35 .zone-label { font-size: 9px; fill: #8b949e; font-family: ui-monospace, monospace; }
36 .n-open { cursor: pointer; text-decoration: underline; text-decoration-style: dotted; }
37 #crumb a { color: #8b949e; text-decoration: none; cursor: pointer; }
38 #crumb a.current { color: #ffa657; cursor: default; }
39 #crumb .sep { color: #4a4d55; }
3440 .depth1 { fill: #2b2f3a } .depth2 { fill: #33405a } .depth3 { fill: #3b5378 } .depth4 { fill: #436796 }
3541</style>
3642</head>
……@@ -38,8 +44,9 @@
3844<svg id="svg"><g id="world"></g></svg>
3945
4046<div class="hud" id="head">
41 <div><b>file level</b> — one node per symbol &middot; click a node to expand its code</div>
47 <div id="levelname"><b>file level</b></div>
4248 <div style="margin-top:6px"><input id="path" spellcheck="false"> <span id="stats"></span></div>
49 <div style="margin-top:4px" id="crumb"></div>
4350</div>
4451<div class="hud" id="banner"></div>
4552<div class="hud" id="legend">
……@@ -49,7 +56,7 @@
4956 <span class="sw" style="background:#1f4a6b"></span>module body &nbsp;&nbsp;
5057 <span class="sw" style="background:#33405a"></span>zone depth rail
5158</div>
52<div class="hud" id="hint">drag to pan &middot; scroll to zoom &middot; E expand all &middot; C collapse all</div>
59<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>
5360
5461<script type="module" src="/floor/level.js"></script>
5562</body>
lib/floor/level.js +361 -145
……@@ -1,15 +1,15 @@
11/**
2 * The file level: one node per symbol, each expandable to its own code.
2 * Two rungs of the ladder: the file level (one node per symbol) and the symbol
3 * level (the expression floor — operations inside their zones).
34 *
4 * Specified in docs/EXPRESSION-FLOOR.md section 3. A node here is a SYMBOL — a
5 * class, a top-level function, a const, or the module body — never a derived
6 * function, because 12 of lib/sections.js's 16 functions are nested inside
7 * another and belong to an interior instead (L1).
5 * Specified in docs/EXPRESSION-FLOOR.md section 3. Both levels produce the same
6 * shape — cards, links, containers — so layout and rendering are shared and the
7 * two rungs cannot drift apart visually.
88 *
99 * It imports the SAME lib/layout.js the node tests run against, served
10 * statically, rather than a copy that can drift from it. Expanding a node is
11 * therefore honest end to end: the height changes, layout runs again, containers
12 * and columns reflow (L4).
10 * statically, rather than a copy. Expanding a card is therefore honest end to
11 * end: the height changes, layout runs again, zone containers reflow around it
12 * (L4).
1313 */
1414
1515import { layout } from '/floor/layout.js'
……@@ -20,15 +20,21 @@ const KIND_COLOR = {
2020 function: '#b8642a',
2121 class: '#6b4a86',
2222 const: '#8a6a2a',
23 'module-body': '#1f4a6b'
23 'module-body': '#1f4a6b',
24 op: '#2f5b86',
25 call: '#2f6b6b',
26 exit: '#8a3b36',
27 nested: '#b8642a'
2428}
2529
2630const NODE_W = 260
27const NODE_W_OPEN = 520 // an expanded node earns room for its code
31const NODE_W_OPEN = 520
32const OP_W = 230
33const OP_W_OPEN = 520
2834const HEAD_H = 20
2935const LINE_H = 11
3036const CODE_LINE_H = 10
31const MAX_CODE_LINES = 120 // an expanded 400-line function is not readable anyway
37const MAX_CODE_LINES = 120
3238
3339const view = {
3440 path: null,
……@@ -36,10 +42,12 @@ const view = {
3642 source: null,
3743 lines: [],
3844 lineDepth: [],
45 symbolId: null, // null = the file level
3946 expanded: new Set(),
47 cards: [],
4048 laid: null,
4149 panX: 60,
42 panY: 90,
50 panY: 100,
4351 zoom: 0.9,
4452 panning: false,
4553 sx: 0,
……@@ -61,24 +69,23 @@ async function load (path) {
6169 view.lines = data.source.split('\n')
6270 view.lineDepth = lineDepths(data.model, view.lines.length)
6371 view.expanded = new Set()
64 applyExpandParam()
65 document.getElementById('path').value = path
6672
67 const m = data.model
68 document.getElementById('stats').textContent =
69 m.symbols.length + ' symbols · ' + m.nodes.length + ' ops · ' +
70 m.zones.length + ' zones · ' + m.exits.length + ' exits' +
71 (m.language ? ' · ' + m.language : '')
72 banner(m.partial ? 'PARTIAL — ' + m.partialReason : null)
73 const params = new URLSearchParams(location.search)
74 const wantSymbol = params.get('symbol')
75 const sym = wantSymbol
76 ? data.model.symbols.find(s => s.name === wantSymbol)
77 : null
78 view.symbolId = sym ? sym.id : null
7379
74 relayout()
80 applyExpandParam()
81 document.getElementById('path').value = path
82 rebuild()
7583}
7684
77// The deepest zone covering each line, so an expanded node can show a depth rail
78// beside its code. This is the same semantic channel cells.js packs into a byte;
79// at this scale a per-line value is enough.
85// The deepest zone covering each line, so expanded code can show a depth rail.
86// Same semantic channel cells.js packs into a byte, at line granularity.
8087function lineDepths (model, lineCount) {
81 const out = new Array(lineCount + 1).fill(0)
88 const out = new Array(lineCount + 2).fill(0)
8289 for (const z of model.zones || []) {
8390 for (let l = z.range.startLine; l <= z.range.endLine; l++) {
8491 if (l <= lineCount && z.depth > out[l]) out[l] = z.depth
……@@ -87,73 +94,167 @@ function lineDepths (model, lineCount) {
8794 return out
8895}
8996
90function codeOf (sym) {
91 // The module body's RANGE is the whole file - it IS the program - so slicing it
92 // would dump every line including every other symbol's body. Its own code is
93 // the lines its direct operations and wires actually sit on.
94 if (sym.kind === 'module-body') {
95 const own = new Set()
96 for (const n of view.model.nodes) {
97 if (n.container !== sym.fn) continue
98 for (let l = n.range.startLine; l <= n.range.endLine; l++) own.add(l)
99 }
100 for (const n of view.model.names) {
101 if (n.container !== sym.fn) continue
102 for (let l = n.range.startLine; l <= n.range.endLine; l++) own.add(l)
103 }
104 const sorted = Array.from(own).sort((a, b) => a - b).slice(0, MAX_CODE_LINES)
97function currentSymbol () {
98 if (!view.symbolId) return null
99 return view.model.symbols.find(s => s.id === view.symbolId) || null
100}
101
102function ownLines (containerId) {
103 const own = new Set()
104 for (const n of view.model.nodes) {
105 if (n.container !== containerId) continue
106 for (let l = n.range.startLine; l <= n.range.endLine; l++) own.add(l)
107 }
108 for (const n of view.model.names) {
109 if (n.container !== containerId) continue
110 for (let l = n.range.startLine; l <= n.range.endLine; l++) own.add(l)
111 }
112 return Array.from(own).sort((a, b) => a - b)
113}
114
115function codeFor (card) {
116 if (!card.code) return { numbers: [], lines: [], clipped: false }
117 if (card.code.sparse) {
118 const nums = ownLines(card.code.container).slice(0, MAX_CODE_LINES)
105119 return {
106 numbers: sorted,
107 lines: sorted.map(l => view.lines[l - 1] || ''),
108 clipped: own.size > sorted.length
120 numbers: nums,
121 lines: nums.map(l => view.lines[l - 1] || ''),
122 clipped: ownLines(card.code.container).length > nums.length
109123 }
110124 }
111 const from = sym.range.startLine
112 const to = Math.min(sym.range.endLine, from + MAX_CODE_LINES - 1)
125 const from = card.code.from
126 const to = Math.min(card.code.to, from + MAX_CODE_LINES - 1)
113127 const lines = view.lines.slice(from - 1, to)
114 return { numbers: lines.map((_, i) => from + i), lines, clipped: to < sym.range.endLine }
128 return { numbers: lines.map((_, i) => from + i), lines, clipped: to < card.code.to }
115129}
116130
117function interiorCounts (sym) {
131// --- the two levels, both producing cards ----------------------------------
132
133function buildFileLevel () {
118134 const m = view.model
119 if (!sym.fn) {
120 return sym.members.length > 0 ? sym.members.length + ' members' : ''
121 }
122 const ops = m.nodes.filter(n => n.container === sym.fn).length
123 const zones = m.zones.filter(z => z.container === sym.fn).length
124 const wires = m.names.filter(n => n.container === sym.fn).length
125 return ops + ' ops · ' + zones + ' zones · ' + wires + ' wires'
135 const cards = m.symbols.map(sym => ({
136 id: sym.id,
137 color: KIND_COLOR[sym.kind] || '#3a3d44',
138 title: sym.name,
139 kindLabel: sym.kind,
140 sig: signatureOf(sym),
141 sub: interiorCounts(sym) + ' L' + sym.range.startLine + '-' + sym.range.endLine,
142 container: null,
143 // The module body IS the program, so its range is the whole file; its own
144 // code is only the lines its operations and wires sit on.
145 code: sym.kind === 'module-body'
146 ? { sparse: true, container: sym.fn }
147 : { from: sym.range.startLine, to: sym.range.endLine },
148 openTo: sym.fn ? sym.name : null,
149 wide: NODE_W_OPEN,
150 narrow: NODE_W
151 }))
152 return { cards, links: fileLinks(), containers: [] }
126153}
127154
128function signatureOf (sym) {
129 const fn = (view.model.functions || []).find(f => f.id === sym.fn)
130 if (fn) return fn.name + ' (' + fn.params.join(', ') + ')'
131 if (sym.kind === 'class') return 'class ' + sym.name
132 return sym.name
133}
155function buildSymbolLevel (sym) {
156 const m = view.model
157 const fnId = sym.fn
158
159 // Zones directly in this symbol become CONTAINERS. A zone inside a nested
160 // function belongs to that function's own interior, not to this one.
161 const zones = m.zones.filter(z => z.container === fnId)
162 const zoneIds = new Set(zones.map(z => z.id))
163 const containers = zones.map(z => ({
164 id: z.id,
165 parent: zoneIds.has(z.parent) ? z.parent : null,
166 label: z.shape + (z.condition ? ' ' + clip(z.condition.text, 30) : '') +
167 (z.carried && z.carried.length > 0 ? ' carries ' + z.carried.join(',') : '')
168 }))
169
170 const nameOfNode = new Map()
171 for (const n of m.names) if (n.node) nameOfNode.set(n.node, n.name)
172
173 const cards = []
174 for (const op of m.nodes) {
175 if (op.container !== fnId) continue
176 const named = nameOfNode.get(op.id)
177 const ins = op.inputs.map(i => (i.literal ? i.name : i.name + (i.source === 'node' ? '' : ' [' + i.source + ']')))
178 cards.push({
179 id: op.id,
180 color: op.kind === 'CallExpression' ? KIND_COLOR.call : KIND_COLOR.op,
181 title: op.label,
182 kindLabel: named ? '-> ' + named : shortKind(op.kind),
183 sig: ins.length > 0 ? clip(ins.join(', '), 44) : '',
184 sub: 'L' + op.range.startLine + (op.writes ? ' writes ' + op.writes : ''),
185 container: zoneIds.has(op.zone) ? op.zone : null,
186 code: { from: op.range.startLine, to: op.range.endLine },
187 openTo: null,
188 wide: OP_W_OPEN,
189 narrow: OP_W
190 })
191 }
134192
135function widthOf (sym) {
136 return view.expanded.has(sym.id) ? NODE_W_OPEN : NODE_W
193 // A nested function is a node you can open in turn — Blender's nested groups,
194 // falling out of the data rather than being invented (L1).
195 for (const fn of m.functions) {
196 if (fn.parent !== fnId) continue
197 cards.push({
198 id: fn.id,
199 color: KIND_COLOR.nested,
200 title: fn.name,
201 kindLabel: 'nested fn',
202 sig: fn.name + ' (' + fn.params.join(', ') + ')',
203 sub: m.nodes.filter(n => n.container === fn.id).length + ' ops · L' +
204 fn.range.startLine + '-' + fn.range.endLine,
205 container: zoneOfRange(zones, fn.range),
206 code: { from: fn.range.startLine, to: fn.range.endLine },
207 openTo: null,
208 wide: OP_W_OPEN,
209 narrow: OP_W
210 })
211 }
212
213 // An exit with no operation of its own — break, continue — would otherwise be
214 // invisible, even though it is the thing that makes the zone's shape what it is.
215 for (const ex of m.exits) {
216 if (ex.container !== fnId || ex.node) continue
217 cards.push({
218 id: ex.id,
219 color: KIND_COLOR.exit,
220 title: ex.kind,
221 kindLabel: 'exit',
222 sig: ex.lands ? String(ex.lands.on) : '',
223 sub: 'L' + ex.range.startLine,
224 container: zoneIds.has(ex.zone) ? ex.zone : null,
225 code: { from: ex.range.startLine, to: ex.range.endLine },
226 openTo: null,
227 wide: OP_W_OPEN,
228 narrow: OP_W
229 })
230 }
231
232 const ids = new Set(cards.map(c => c.id))
233 const links = []
234 for (const op of m.nodes) {
235 if (!ids.has(op.id)) continue
236 for (const i of op.inputs) {
237 if (i.source === 'node' && ids.has(i.node)) links.push({ from: i.node, to: op.id })
238 }
239 }
240 return { cards, links, containers }
137241}
138242
139function heightOf (sym) {
140 let h = HEAD_H + LINE_H + LINE_H + 6 // signature + counts
141 if (view.expanded.has(sym.id)) {
142 const code = codeOf(sym)
143 let gaps = 0
144 for (let i = 1; i < code.numbers.length; i++) {
145 if (code.numbers[i] > code.numbers[i - 1] + 1) gaps++
243function zoneOfRange (zones, range) {
244 let best = null
245 for (const z of zones) {
246 if (z.range.startByte <= range.startByte && z.range.endByte >= range.endByte) {
247 if (!best || z.depth > best.depth) best = z
146248 }
147 h += (code.lines.length + gaps) * CODE_LINE_H + 8
148 if (code.clipped) h += CODE_LINE_H
149249 }
150 return h
250 return best ? best.id : null
151251}
152252
153// A call between two file-level symbols. An operation calling `foo` where `foo`
154// is a symbol in this file becomes an edge from foo to whatever symbol contains
155// the call — the file's own call graph, derived, not authored.
156function buildLinks () {
253function shortKind (kind) {
254 return kind.replace('Expression', '').replace('Statement', '').toLowerCase()
255}
256
257function fileLinks () {
157258 const m = view.model
158259 const byName = new Map()
159260 for (const s of m.symbols) if (s.name) byName.set(s.name, s)
……@@ -166,8 +267,7 @@ function buildLinks () {
166267 if (op.kind !== 'CallExpression') continue
167268 const callee = op.inputs.find(i => i.slot === 'callee')
168269 if (!callee || !callee.name) continue
169 const root = callee.name.split('.')[0].split('[')[0]
170 const target = byName.get(root)
270 const target = byName.get(callee.name.split('.')[0].split('[')[0])
171271 const holder = byFn.get(op.container)
172272 if (!target || !holder || target.id === holder.id) continue
173273 const key = target.id + '->' + holder.id
……@@ -178,29 +278,65 @@ function buildLinks () {
178278 return links
179279}
180280
181// Every view in codetree serve is deep-linkable (invariant I5), so which nodes
182// are expanded lives in the URL: ?expand=all, or a comma-separated name list.
183function applyExpandParam () {
184 const want = new URLSearchParams(location.search).get('expand')
185 if (!want) return
186 if (want === 'all') {
187 for (const s of view.model.symbols) view.expanded.add(s.id)
188 return
189 }
190 const names = new Set(want.split(',').map(x => x.trim()).filter(Boolean))
191 for (const s of view.model.symbols) if (names.has(s.name)) view.expanded.add(s.id)
281function signatureOf (sym) {
282 const fn = (view.model.functions || []).find(f => f.id === sym.fn)
283 if (fn) return fn.name + ' (' + fn.params.join(', ') + ')'
284 if (sym.kind === 'class') return 'class ' + sym.name
285 return sym.name
192286}
193287
194function syncExpandParam () {
195 const names = view.model.symbols.filter(s => view.expanded.has(s.id)).map(s => s.name)
196 history.replaceState(null, '', '/floor?path=' + encodeURIComponent(view.path) +
197 (names.length === 0 ? '' : '&expand=' + encodeURIComponent(names.join(','))))
288function interiorCounts (sym) {
289 const m = view.model
290 if (!sym.fn) return sym.members.length > 0 ? sym.members.length + ' members' : ''
291 return m.nodes.filter(n => n.container === sym.fn).length + ' ops · ' +
292 m.zones.filter(z => z.container === sym.fn).length + ' zones · ' +
293 m.names.filter(n => n.container === sym.fn).length + ' wires'
294}
295
296// --- layout ----------------------------------------------------------------
297
298function heightOf (card) {
299 let h = HEAD_H + (card.sig ? LINE_H : 0) + LINE_H + 6
300 if (view.expanded.has(card.id)) {
301 const code = codeFor(card)
302 let gaps = 0
303 for (let i = 1; i < code.numbers.length; i++) {
304 if (code.numbers[i] > code.numbers[i - 1] + 1) gaps++
305 }
306 h += (code.lines.length + gaps) * CODE_LINE_H + 8
307 if (code.clipped) h += CODE_LINE_H
308 }
309 return h
198310}
199311
200function relayout () {
312function rebuild () {
313 const sym = currentSymbol()
314 const built = sym ? buildSymbolLevel(sym) : buildFileLevel()
315 view.cards = built.cards
316 view.links = built.links
317 view.containers = built.containers
318
319 view.laid = layout({
320 nodes: built.cards.map(c => ({
321 id: c.id,
322 container: c.container,
323 w: view.expanded.has(c.id) ? c.wide : c.narrow,
324 h: heightOf(c)
325 })),
326 links: built.links,
327 containers: built.containers
328 })
329
201330 const m = view.model
202 const nodes = m.symbols.map(s => ({ id: s.id, container: null, w: widthOf(s), h: heightOf(s) }))
203 view.laid = layout({ nodes, links: buildLinks(), containers: [] })
331 document.getElementById('levelname').innerHTML = sym
332 ? '<b>symbol level</b> - operations inside their zones'
333 : '<b>file level</b> - one node per symbol'
334 document.getElementById('stats').textContent = sym
335 ? built.cards.length + ' nodes · ' + built.containers.length + ' zones in ' + sym.name
336 : m.symbols.length + ' symbols · ' + m.nodes.length + ' ops · ' + m.zones.length +
337 ' zones · ' + m.exits.length + ' exits' + (m.language ? ' · ' + m.language : '')
338 banner(m.partial ? 'PARTIAL - ' + m.partialReason : null)
339 breadcrumb()
204340 render()
205341}
206342
……@@ -218,14 +354,53 @@ function banner (text) {
218354 b.textContent = text || ''
219355}
220356
357function breadcrumb () {
358 const bc = document.getElementById('crumb')
359 bc.textContent = ''
360 const sym = currentSymbol()
361
362 const file = document.createElement('a')
363 file.textContent = view.path
364 file.href = '#'
365 file.className = sym ? '' : 'current'
366 file.addEventListener('click', (e) => {
367 e.preventDefault()
368 enter(null)
369 })
370 bc.appendChild(file)
371
372 if (sym) {
373 const sep = document.createElement('span')
374 sep.className = 'sep'
375 sep.textContent = ' > '
376 bc.appendChild(sep)
377 const here = document.createElement('a')
378 here.textContent = sym.name
379 here.href = '#'
380 here.className = 'current'
381 bc.appendChild(here)
382 }
383}
384
221385function render () {
222386 const world = document.getElementById('world')
223387 world.textContent = ''
224 const m = view.model
225388 const placed = new Map(view.laid.nodes.map(n => [n.id, n]))
226389
227 // links first, so nodes sit on top of them
228 for (const l of view.laid ? buildLinks() : []) {
390 // Zone containers sit behind everything: they are regions, not boxes drawn
391 // around whatever positions fell out (that was the old frames bug).
392 for (const box of view.laid.containers) {
393 const depth = Math.min(box.depth, 4)
394 world.appendChild(el('rect', {
395 class: 'zone depth' + depth,
396 x: box.x, y: box.y, width: box.w, height: box.h, rx: 8
397 }))
398 const label = el('text', { class: 'zone-label', x: box.x + 8, y: box.y + 13 })
399 label.textContent = box.label || ''
400 world.appendChild(label)
401 }
402
403 for (const l of view.links) {
229404 const a = placed.get(l.from)
230405 const b = placed.get(l.to)
231406 if (!a || !b) continue
……@@ -233,7 +408,7 @@ function render () {
233408 const y1 = a.y + HEAD_H / 2
234409 const x2 = b.x
235410 const y2 = b.y + HEAD_H / 2
236 const dx = Math.max(40, Math.abs(x2 - x1) * 0.5)
411 const dx = Math.max(30, Math.abs(x2 - x1) * 0.5)
237412 world.appendChild(el('path', {
238413 class: 'link',
239414 d: 'M ' + x1 + ' ' + y1 + ' C ' + (x1 + dx) + ' ' + y1 + ', ' + (x2 - dx) + ' ' + y2 +
……@@ -241,63 +416,64 @@ function render () {
241416 }))
242417 }
243418
244 for (const sym of m.symbols) {
245 const pos = placed.get(sym.id)
246 if (pos) drawNode(world, sym, pos)
419 for (const card of view.cards) {
420 const pos = placed.get(card.id)
421 if (pos) drawCard(world, card, pos)
247422 }
248423 applyTransform()
249424}
250425
251function drawNode (world, sym, pos) {
426function drawCard (world, card, pos) {
252427 const g = el('g', { transform: 'translate(' + pos.x + ',' + pos.y + ')' })
253 const open = view.expanded.has(sym.id)
428 const open = view.expanded.has(card.id)
254429
255430 g.appendChild(el('rect', { class: 'n-body', width: pos.w, height: pos.h, rx: 5 }))
256431 g.appendChild(el('path', {
257432 class: 'n-head',
258 fill: KIND_COLOR[sym.kind] || '#3a3d44',
433 fill: card.color,
259434 d: 'M 0 5 a 5 5 0 0 1 5 -5 h ' + (pos.w - 10) + ' a 5 5 0 0 1 5 5 v ' + (HEAD_H - 5) +
260435 ' h ' + -pos.w + ' z'
261436 }))
262437
438 const chev = el('text', { class: 'n-chev', x: 7, y: 14 })
439 chev.textContent = open ? 'v' : '>'
440 g.appendChild(chev)
441
263442 const title = el('text', { class: 'n-title', x: 20, y: 14 })
264 title.textContent = sym.name
443 title.textContent = clip(card.title, Math.floor((pos.w - 90) / 6))
265444 g.appendChild(title)
266445
267446 const kind = el('text', { class: 'n-sub', x: pos.w - 7, y: 14, 'text-anchor': 'end' })
268 kind.textContent = sym.kind
447 kind.textContent = card.kindLabel
269448 g.appendChild(kind)
270449
271 const chev = el('text', { class: 'n-chev', x: 7, y: 14 })
272 chev.textContent = open ? '▾' : '▸'
273 g.appendChild(chev)
274
275 const sig = el('text', { class: 'n-sig', x: 8, y: HEAD_H + 11 })
276 sig.textContent = clip(signatureOf(sym), Math.floor((pos.w - 16) / 5.4))
277 g.appendChild(sig)
278
279 const counts = el('text', { class: 'n-sub', x: 8, y: HEAD_H + 11 + LINE_H })
280 counts.textContent = interiorCounts(sym) + ' L' + sym.range.startLine + '-' + sym.range.endLine
281 g.appendChild(counts)
450 let y = HEAD_H + 11
451 if (card.sig) {
452 const sig = el('text', { class: 'n-sig', x: 8, y })
453 sig.textContent = clip(card.sig, Math.floor((pos.w - 16) / 5.4))
454 g.appendChild(sig)
455 y += LINE_H
456 }
457 const sub = el('text', { class: 'n-sub', x: 8, y })
458 sub.textContent = card.sub
459 g.appendChild(sub)
282460
283461 if (open) {
284 const code = codeOf(sym)
285 let y = HEAD_H + LINE_H + LINE_H + 12
286 let prevLine = null
462 const code = codeFor(card)
463 y += 12
464 let prev = null
287465 for (let i = 0; i < code.lines.length; i++) {
288466 const lineNo = code.numbers[i]
289 // A sparse slice (the module body) skips other symbols' bodies; mark the
290 // break so the line numbers are not read as contiguous.
291 if (prevLine !== null && lineNo > prevLine + 1) {
467 if (prev !== null && lineNo > prev + 1) {
292468 const gap = el('text', { class: 'n-lineno', x: 24, y })
293469 gap.textContent = '...'
294470 g.appendChild(gap)
295471 y += CODE_LINE_H
296472 }
297 prevLine = lineNo
473 prev = lineNo
474
298475 const depth = Math.min(view.lineDepth[lineNo] || 0, 4)
299476 if (depth > 0) {
300 // The zone-depth rail: the semantic channel, at line granularity.
301477 g.appendChild(el('rect', {
302478 class: 'depth' + depth, x: 22, y: y - 7, width: pos.w - 26, height: CODE_LINE_H,
303479 opacity: 0.35
……@@ -306,7 +482,6 @@ function drawNode (world, sym, pos) {
306482 const num = el('text', { class: 'n-lineno', x: 18, y, 'text-anchor': 'end' })
307483 num.textContent = lineNo
308484 g.appendChild(num)
309
310485 const t = el('text', { class: 'n-code', x: 24, y })
311486 t.textContent = clip(code.lines[i].replace(/\t/g, ' '), Math.floor((pos.w - 30) / 4.9))
312487 g.appendChild(t)
……@@ -319,26 +494,69 @@ function drawNode (world, sym, pos) {
319494 }
320495 }
321496
322 // One hit target over the header, so clicking anywhere on it toggles.
497 // The header toggles code. The title descends, when there is somewhere to go.
323498 const hit = el('rect', { class: 'n-hit', width: pos.w, height: HEAD_H })
324499 hit.addEventListener('click', (e) => {
325500 e.stopPropagation()
326 toggle(sym.id)
501 toggle(card.id)
327502 })
328503 g.appendChild(hit)
329504
505 if (card.openTo) {
506 title.classList.add('n-open')
507 title.addEventListener('click', (e) => {
508 e.stopPropagation()
509 enter(card.openTo)
510 })
511 }
512
330513 world.appendChild(g)
331514}
332515
333516function clip (text, n) {
334 return text.length > n ? text.slice(0, n - 1) + '…' : text
517 const max = Math.max(4, n)
518 return text.length > max ? text.slice(0, max - 1) + '…' : text
519}
520
521// --- navigation ------------------------------------------------------------
522
523function enter (symbolName) {
524 const sym = symbolName ? view.model.symbols.find(s => s.name === symbolName) : null
525 view.symbolId = sym ? sym.id : null
526 view.expanded = new Set()
527 view.panX = 60
528 view.panY = 100
529 syncUrl()
530 rebuild()
335531}
336532
337533function toggle (id) {
338534 if (view.expanded.has(id)) view.expanded.delete(id)
339535 else view.expanded.add(id)
340 syncExpandParam()
341 relayout()
536 rebuild()
537}
538
539// Deep-linkable, because invariant I5 says every view in codetree serve is. At
540// the file level the expanded set is symbol NAMES; at the symbol level operation
541// ids are byte offsets, so only `all` round-trips.
542function applyExpandParam () {
543 const want = new URLSearchParams(location.search).get('expand')
544 if (!want) return
545 if (want === 'all') {
546 const sym = currentSymbol()
547 const built = sym ? buildSymbolLevel(sym) : buildFileLevel()
548 for (const c of built.cards) view.expanded.add(c.id)
549 return
550 }
551 const names = new Set(want.split(',').map(x => x.trim()).filter(Boolean))
552 for (const s of view.model.symbols) if (names.has(s.name)) view.expanded.add(s.id)
553}
554
555function syncUrl () {
556 const sym = currentSymbol()
557 let url = '/floor?path=' + encodeURIComponent(view.path)
558 if (sym) url += '&symbol=' + encodeURIComponent(sym.name)
559 history.replaceState(null, '', url)
342560}
343561
344562function applyTransform () {
……@@ -346,15 +564,13 @@ function applyTransform () {
346564 'translate(' + view.panX.toFixed(1) + ',' + view.panY.toFixed(1) + ') scale(' + view.zoom.toFixed(3) + ')')
347565}
348566
349// --- input -----------------------------------------------------------------
350
351567function wire () {
352568 const svg = document.getElementById('svg')
353569
354570 svg.addEventListener('wheel', (e) => {
355571 e.preventDefault()
356572 const f = e.deltaY > 0 ? 0.9 : 1.1
357 const next = Math.max(0.1, Math.min(4, view.zoom * f))
573 const next = Math.max(0.05, Math.min(4, view.zoom * f))
358574 const r = svg.getBoundingClientRect()
359575 const mx = e.clientX - r.left
360576 const my = e.clientY - r.top
……@@ -384,23 +600,23 @@ function wire () {
384600 document.addEventListener('keydown', (e) => {
385601 if (!view.model) return
386602 if (e.key === 'e' || e.key === 'E') {
387 for (const s of view.model.symbols) view.expanded.add(s.id)
388 syncExpandParam()
389 relayout()
603 for (const c of view.cards) view.expanded.add(c.id)
604 rebuild()
390605 } else if (e.key === 'c' || e.key === 'C') {
391606 view.expanded.clear()
392 syncExpandParam()
393 relayout()
607 rebuild()
608 } else if (e.key === 'Escape') {
609 enter(null)
394610 }
395611 })
396612
397613 const input = document.getElementById('path')
398614 input.addEventListener('change', () => {
399615 const p = input.value.trim()
400 if (p) {
401 history.replaceState(null, '', '/floor?path=' + encodeURIComponent(p))
402 load(p)
403 }
616 if (!p) return
617 view.symbolId = null
618 history.replaceState(null, '', '/floor?path=' + encodeURIComponent(p))
619 load(p)
404620 })
405621}
406622