| … | … | @@ -1,15 +1,15 @@ |
| 1 | 1 | /** |
| 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). |
| 3 | 4 | * |
| 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. |
| 8 | 8 | * |
| 9 | 9 | * 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). |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | 15 | import { layout } from '/floor/layout.js' |
| … | … | @@ -20,15 +20,21 @@ const KIND_COLOR = { |
| 20 | 20 | function: '#b8642a', |
| 21 | 21 | class: '#6b4a86', |
| 22 | 22 | const: '#8a6a2a', |
| 23 | | 'module-body': '#1f4a6b' |
| 23 | 'module-body': '#1f4a6b', |
| 24 | op: '#2f5b86', |
| 25 | call: '#2f6b6b', |
| 26 | exit: '#8a3b36', |
| 27 | nested: '#b8642a' |
| 24 | 28 | } |
| 25 | 29 | |
| 26 | 30 | const NODE_W = 260 |
| 27 | | const NODE_W_OPEN = 520 // an expanded node earns room for its code |
| 31 | const NODE_W_OPEN = 520 |
| 32 | const OP_W = 230 |
| 33 | const OP_W_OPEN = 520 |
| 28 | 34 | const HEAD_H = 20 |
| 29 | 35 | const LINE_H = 11 |
| 30 | 36 | const CODE_LINE_H = 10 |
| 31 | | const MAX_CODE_LINES = 120 // an expanded 400-line function is not readable anyway |
| 37 | const MAX_CODE_LINES = 120 |
| 32 | 38 | |
| 33 | 39 | const view = { |
| 34 | 40 | path: null, |
| … | … | @@ -36,10 +42,12 @@ const view = { |
| 36 | 42 | source: null, |
| 37 | 43 | lines: [], |
| 38 | 44 | lineDepth: [], |
| 45 | symbolId: null, // null = the file level |
| 39 | 46 | expanded: new Set(), |
| 47 | cards: [], |
| 40 | 48 | laid: null, |
| 41 | 49 | panX: 60, |
| 42 | | panY: 90, |
| 50 | panY: 100, |
| 43 | 51 | zoom: 0.9, |
| 44 | 52 | panning: false, |
| 45 | 53 | sx: 0, |
| … | … | @@ -61,24 +69,23 @@ async function load (path) { |
| 61 | 69 | view.lines = data.source.split('\n') |
| 62 | 70 | view.lineDepth = lineDepths(data.model, view.lines.length) |
| 63 | 71 | view.expanded = new Set() |
| 64 | | applyExpandParam() |
| 65 | | document.getElementById('path').value = path |
| 66 | 72 | |
| 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 |
| 73 | 79 | |
| 74 | | relayout() |
| 80 | applyExpandParam() |
| 81 | document.getElementById('path').value = path |
| 82 | rebuild() |
| 75 | 83 | } |
| 76 | 84 | |
| 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. |
| 80 | 87 | function lineDepths (model, lineCount) { |
| 81 | | const out = new Array(lineCount + 1).fill(0) |
| 88 | const out = new Array(lineCount + 2).fill(0) |
| 82 | 89 | for (const z of model.zones || []) { |
| 83 | 90 | for (let l = z.range.startLine; l <= z.range.endLine; l++) { |
| 84 | 91 | if (l <= lineCount && z.depth > out[l]) out[l] = z.depth |
| … | … | @@ -87,73 +94,167 @@ function lineDepths (model, lineCount) { |
| 87 | 94 | return out |
| 88 | 95 | } |
| 89 | 96 | |
| 90 | | function 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) |
| 97 | function currentSymbol () { |
| 98 | if (!view.symbolId) return null |
| 99 | return view.model.symbols.find(s => s.id === view.symbolId) || null |
| 100 | } |
| 101 | |
| 102 | function 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 | |
| 115 | function 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) |
| 105 | 119 | 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 |
| 109 | 123 | } |
| 110 | 124 | } |
| 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) |
| 113 | 127 | 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 } |
| 115 | 129 | } |
| 116 | 130 | |
| 117 | | function interiorCounts (sym) { |
| 131 | // --- the two levels, both producing cards ---------------------------------- |
| 132 | |
| 133 | function buildFileLevel () { |
| 118 | 134 | 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: [] } |
| 126 | 153 | } |
| 127 | 154 | |
| 128 | | function 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 | | } |
| 155 | function 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 | } |
| 134 | 192 | |
| 135 | | function 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 } |
| 137 | 241 | } |
| 138 | 242 | |
| 139 | | function 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++ |
| 243 | function 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 |
| 146 | 248 | } |
| 147 | | h += (code.lines.length + gaps) * CODE_LINE_H + 8 |
| 148 | | if (code.clipped) h += CODE_LINE_H |
| 149 | 249 | } |
| 150 | | return h |
| 250 | return best ? best.id : null |
| 151 | 251 | } |
| 152 | 252 | |
| 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. |
| 156 | | function buildLinks () { |
| 253 | function shortKind (kind) { |
| 254 | return kind.replace('Expression', '').replace('Statement', '').toLowerCase() |
| 255 | } |
| 256 | |
| 257 | function fileLinks () { |
| 157 | 258 | const m = view.model |
| 158 | 259 | const byName = new Map() |
| 159 | 260 | for (const s of m.symbols) if (s.name) byName.set(s.name, s) |
| … | … | @@ -166,8 +267,7 @@ function buildLinks () { |
| 166 | 267 | if (op.kind !== 'CallExpression') continue |
| 167 | 268 | const callee = op.inputs.find(i => i.slot === 'callee') |
| 168 | 269 | 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]) |
| 171 | 271 | const holder = byFn.get(op.container) |
| 172 | 272 | if (!target || !holder || target.id === holder.id) continue |
| 173 | 273 | const key = target.id + '->' + holder.id |
| … | … | @@ -178,29 +278,65 @@ function buildLinks () { |
| 178 | 278 | return links |
| 179 | 279 | } |
| 180 | 280 | |
| 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. |
| 183 | | function 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) |
| 281 | function 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 |
| 192 | 286 | } |
| 193 | 287 | |
| 194 | | function 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(',')))) |
| 288 | function 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 | |
| 298 | function 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 |
| 198 | 310 | } |
| 199 | 311 | |
| 200 | | function relayout () { |
| 312 | function 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 | |
| 201 | 330 | 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() |
| 204 | 340 | render() |
| 205 | 341 | } |
| 206 | 342 | |
| … | … | @@ -218,14 +354,53 @@ function banner (text) { |
| 218 | 354 | b.textContent = text || '' |
| 219 | 355 | } |
| 220 | 356 | |
| 357 | function 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 | |
| 221 | 385 | function render () { |
| 222 | 386 | const world = document.getElementById('world') |
| 223 | 387 | world.textContent = '' |
| 224 | | const m = view.model |
| 225 | 388 | const placed = new Map(view.laid.nodes.map(n => [n.id, n])) |
| 226 | 389 | |
| 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) { |
| 229 | 404 | const a = placed.get(l.from) |
| 230 | 405 | const b = placed.get(l.to) |
| 231 | 406 | if (!a || !b) continue |
| … | … | @@ -233,7 +408,7 @@ function render () { |
| 233 | 408 | const y1 = a.y + HEAD_H / 2 |
| 234 | 409 | const x2 = b.x |
| 235 | 410 | 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) |
| 237 | 412 | world.appendChild(el('path', { |
| 238 | 413 | class: 'link', |
| 239 | 414 | d: 'M ' + x1 + ' ' + y1 + ' C ' + (x1 + dx) + ' ' + y1 + ', ' + (x2 - dx) + ' ' + y2 + |
| … | … | @@ -241,63 +416,64 @@ function render () { |
| 241 | 416 | })) |
| 242 | 417 | } |
| 243 | 418 | |
| 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) |
| 247 | 422 | } |
| 248 | 423 | applyTransform() |
| 249 | 424 | } |
| 250 | 425 | |
| 251 | | function drawNode (world, sym, pos) { |
| 426 | function drawCard (world, card, pos) { |
| 252 | 427 | 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) |
| 254 | 429 | |
| 255 | 430 | g.appendChild(el('rect', { class: 'n-body', width: pos.w, height: pos.h, rx: 5 })) |
| 256 | 431 | g.appendChild(el('path', { |
| 257 | 432 | class: 'n-head', |
| 258 | | fill: KIND_COLOR[sym.kind] || '#3a3d44', |
| 433 | fill: card.color, |
| 259 | 434 | 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) + |
| 260 | 435 | ' h ' + -pos.w + ' z' |
| 261 | 436 | })) |
| 262 | 437 | |
| 438 | const chev = el('text', { class: 'n-chev', x: 7, y: 14 }) |
| 439 | chev.textContent = open ? 'v' : '>' |
| 440 | g.appendChild(chev) |
| 441 | |
| 263 | 442 | 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)) |
| 265 | 444 | g.appendChild(title) |
| 266 | 445 | |
| 267 | 446 | 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 |
| 269 | 448 | g.appendChild(kind) |
| 270 | 449 | |
| 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) |
| 282 | 460 | |
| 283 | 461 | 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 |
| 287 | 465 | for (let i = 0; i < code.lines.length; i++) { |
| 288 | 466 | 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) { |
| 292 | 468 | const gap = el('text', { class: 'n-lineno', x: 24, y }) |
| 293 | 469 | gap.textContent = '...' |
| 294 | 470 | g.appendChild(gap) |
| 295 | 471 | y += CODE_LINE_H |
| 296 | 472 | } |
| 297 | | prevLine = lineNo |
| 473 | prev = lineNo |
| 474 | |
| 298 | 475 | const depth = Math.min(view.lineDepth[lineNo] || 0, 4) |
| 299 | 476 | if (depth > 0) { |
| 300 | | // The zone-depth rail: the semantic channel, at line granularity. |
| 301 | 477 | g.appendChild(el('rect', { |
| 302 | 478 | class: 'depth' + depth, x: 22, y: y - 7, width: pos.w - 26, height: CODE_LINE_H, |
| 303 | 479 | opacity: 0.35 |
| … | … | @@ -306,7 +482,6 @@ function drawNode (world, sym, pos) { |
| 306 | 482 | const num = el('text', { class: 'n-lineno', x: 18, y, 'text-anchor': 'end' }) |
| 307 | 483 | num.textContent = lineNo |
| 308 | 484 | g.appendChild(num) |
| 309 | | |
| 310 | 485 | const t = el('text', { class: 'n-code', x: 24, y }) |
| 311 | 486 | t.textContent = clip(code.lines[i].replace(/\t/g, ' '), Math.floor((pos.w - 30) / 4.9)) |
| 312 | 487 | g.appendChild(t) |
| … | … | @@ -319,26 +494,69 @@ function drawNode (world, sym, pos) { |
| 319 | 494 | } |
| 320 | 495 | } |
| 321 | 496 | |
| 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. |
| 323 | 498 | const hit = el('rect', { class: 'n-hit', width: pos.w, height: HEAD_H }) |
| 324 | 499 | hit.addEventListener('click', (e) => { |
| 325 | 500 | e.stopPropagation() |
| 326 | | toggle(sym.id) |
| 501 | toggle(card.id) |
| 327 | 502 | }) |
| 328 | 503 | g.appendChild(hit) |
| 329 | 504 | |
| 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 | |
| 330 | 513 | world.appendChild(g) |
| 331 | 514 | } |
| 332 | 515 | |
| 333 | 516 | function 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 | |
| 523 | function 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() |
| 335 | 531 | } |
| 336 | 532 | |
| 337 | 533 | function toggle (id) { |
| 338 | 534 | if (view.expanded.has(id)) view.expanded.delete(id) |
| 339 | 535 | 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. |
| 542 | function 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 | |
| 555 | function 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) |
| 342 | 560 | } |
| 343 | 561 | |
| 344 | 562 | function applyTransform () { |
| … | … | @@ -346,15 +564,13 @@ function applyTransform () { |
| 346 | 564 | 'translate(' + view.panX.toFixed(1) + ',' + view.panY.toFixed(1) + ') scale(' + view.zoom.toFixed(3) + ')') |
| 347 | 565 | } |
| 348 | 566 | |
| 349 | | // --- input ----------------------------------------------------------------- |
| 350 | | |
| 351 | 567 | function wire () { |
| 352 | 568 | const svg = document.getElementById('svg') |
| 353 | 569 | |
| 354 | 570 | svg.addEventListener('wheel', (e) => { |
| 355 | 571 | e.preventDefault() |
| 356 | 572 | 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)) |
| 358 | 574 | const r = svg.getBoundingClientRect() |
| 359 | 575 | const mx = e.clientX - r.left |
| 360 | 576 | const my = e.clientY - r.top |
| … | … | @@ -384,23 +600,23 @@ function wire () { |
| 384 | 600 | document.addEventListener('keydown', (e) => { |
| 385 | 601 | if (!view.model) return |
| 386 | 602 | 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() |
| 390 | 605 | } else if (e.key === 'c' || e.key === 'C') { |
| 391 | 606 | view.expanded.clear() |
| 392 | | syncExpandParam() |
| 393 | | relayout() |
| 607 | rebuild() |
| 608 | } else if (e.key === 'Escape') { |
| 609 | enter(null) |
| 394 | 610 | } |
| 395 | 611 | }) |
| 396 | 612 | |
| 397 | 613 | const input = document.getElementById('path') |
| 398 | 614 | input.addEventListener('change', () => { |
| 399 | 615 | 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) |
| 404 | 620 | }) |
| 405 | 621 | } |
| 406 | 622 | |