| … | … | @@ -0,0 +1,424 @@ |
| 1 | /** |
| 2 | * The file level: one node per symbol, each expandable to its own code. |
| 3 | * |
| 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). |
| 8 | * |
| 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). |
| 13 | */ |
| 14 | |
| 15 | import { layout } from '/floor/layout.js' |
| 16 | |
| 17 | const SVG_NS = 'http://www.w3.org/2000/svg' |
| 18 | |
| 19 | const KIND_COLOR = { |
| 20 | function: '#b8642a', |
| 21 | class: '#6b4a86', |
| 22 | const: '#8a6a2a', |
| 23 | 'module-body': '#1f4a6b' |
| 24 | } |
| 25 | |
| 26 | const NODE_W = 260 |
| 27 | const NODE_W_OPEN = 520 // an expanded node earns room for its code |
| 28 | const HEAD_H = 20 |
| 29 | const LINE_H = 11 |
| 30 | const CODE_LINE_H = 10 |
| 31 | const MAX_CODE_LINES = 120 // an expanded 400-line function is not readable anyway |
| 32 | |
| 33 | const view = { |
| 34 | path: null, |
| 35 | model: null, |
| 36 | source: null, |
| 37 | lines: [], |
| 38 | lineDepth: [], |
| 39 | expanded: new Set(), |
| 40 | laid: null, |
| 41 | panX: 60, |
| 42 | panY: 90, |
| 43 | zoom: 0.9, |
| 44 | panning: false, |
| 45 | sx: 0, |
| 46 | sy: 0 |
| 47 | } |
| 48 | |
| 49 | // --- data ------------------------------------------------------------------ |
| 50 | |
| 51 | async function load (path) { |
| 52 | const res = await fetch('/api/derive?path=' + encodeURIComponent(path)) |
| 53 | const data = await res.json() |
| 54 | if (data.error) { |
| 55 | banner('cannot derive ' + path + ': ' + data.error) |
| 56 | return |
| 57 | } |
| 58 | view.path = path |
| 59 | view.model = data.model |
| 60 | view.source = data.source |
| 61 | view.lines = data.source.split('\n') |
| 62 | view.lineDepth = lineDepths(data.model, view.lines.length) |
| 63 | view.expanded = new Set() |
| 64 | applyExpandParam() |
| 65 | document.getElementById('path').value = path |
| 66 | |
| 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 | |
| 74 | relayout() |
| 75 | } |
| 76 | |
| 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. |
| 80 | function lineDepths (model, lineCount) { |
| 81 | const out = new Array(lineCount + 1).fill(0) |
| 82 | for (const z of model.zones || []) { |
| 83 | for (let l = z.range.startLine; l <= z.range.endLine; l++) { |
| 84 | if (l <= lineCount && z.depth > out[l]) out[l] = z.depth |
| 85 | } |
| 86 | } |
| 87 | return out |
| 88 | } |
| 89 | |
| 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) |
| 105 | return { |
| 106 | numbers: sorted, |
| 107 | lines: sorted.map(l => view.lines[l - 1] || ''), |
| 108 | clipped: own.size > sorted.length |
| 109 | } |
| 110 | } |
| 111 | const from = sym.range.startLine |
| 112 | const to = Math.min(sym.range.endLine, from + MAX_CODE_LINES - 1) |
| 113 | const lines = view.lines.slice(from - 1, to) |
| 114 | return { numbers: lines.map((_, i) => from + i), lines, clipped: to < sym.range.endLine } |
| 115 | } |
| 116 | |
| 117 | function interiorCounts (sym) { |
| 118 | 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' |
| 126 | } |
| 127 | |
| 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 | } |
| 134 | |
| 135 | function widthOf (sym) { |
| 136 | return view.expanded.has(sym.id) ? NODE_W_OPEN : NODE_W |
| 137 | } |
| 138 | |
| 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++ |
| 146 | } |
| 147 | h += (code.lines.length + gaps) * CODE_LINE_H + 8 |
| 148 | if (code.clipped) h += CODE_LINE_H |
| 149 | } |
| 150 | return h |
| 151 | } |
| 152 | |
| 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 () { |
| 157 | const m = view.model |
| 158 | const byName = new Map() |
| 159 | for (const s of m.symbols) if (s.name) byName.set(s.name, s) |
| 160 | const byFn = new Map() |
| 161 | for (const s of m.symbols) if (s.fn) byFn.set(s.fn, s) |
| 162 | |
| 163 | const seen = new Set() |
| 164 | const links = [] |
| 165 | for (const op of m.nodes) { |
| 166 | if (op.kind !== 'CallExpression') continue |
| 167 | const callee = op.inputs.find(i => i.slot === 'callee') |
| 168 | if (!callee || !callee.name) continue |
| 169 | const root = callee.name.split('.')[0].split('[')[0] |
| 170 | const target = byName.get(root) |
| 171 | const holder = byFn.get(op.container) |
| 172 | if (!target || !holder || target.id === holder.id) continue |
| 173 | const key = target.id + '->' + holder.id |
| 174 | if (seen.has(key)) continue |
| 175 | seen.add(key) |
| 176 | links.push({ from: target.id, to: holder.id }) |
| 177 | } |
| 178 | return links |
| 179 | } |
| 180 | |
| 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) |
| 192 | } |
| 193 | |
| 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(',')))) |
| 198 | } |
| 199 | |
| 200 | function relayout () { |
| 201 | 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: [] }) |
| 204 | render() |
| 205 | } |
| 206 | |
| 207 | // --- rendering ------------------------------------------------------------- |
| 208 | |
| 209 | function el (name, attrs) { |
| 210 | const node = document.createElementNS(SVG_NS, name) |
| 211 | for (const k in attrs) node.setAttribute(k, attrs[k]) |
| 212 | return node |
| 213 | } |
| 214 | |
| 215 | function banner (text) { |
| 216 | const b = document.getElementById('banner') |
| 217 | b.style.display = text ? 'block' : 'none' |
| 218 | b.textContent = text || '' |
| 219 | } |
| 220 | |
| 221 | function render () { |
| 222 | const world = document.getElementById('world') |
| 223 | world.textContent = '' |
| 224 | const m = view.model |
| 225 | const placed = new Map(view.laid.nodes.map(n => [n.id, n])) |
| 226 | |
| 227 | // links first, so nodes sit on top of them |
| 228 | for (const l of view.laid ? buildLinks() : []) { |
| 229 | const a = placed.get(l.from) |
| 230 | const b = placed.get(l.to) |
| 231 | if (!a || !b) continue |
| 232 | const x1 = a.x + a.w |
| 233 | const y1 = a.y + HEAD_H / 2 |
| 234 | const x2 = b.x |
| 235 | const y2 = b.y + HEAD_H / 2 |
| 236 | const dx = Math.max(40, Math.abs(x2 - x1) * 0.5) |
| 237 | world.appendChild(el('path', { |
| 238 | class: 'link', |
| 239 | d: 'M ' + x1 + ' ' + y1 + ' C ' + (x1 + dx) + ' ' + y1 + ', ' + (x2 - dx) + ' ' + y2 + |
| 240 | ', ' + x2 + ' ' + y2 |
| 241 | })) |
| 242 | } |
| 243 | |
| 244 | for (const sym of m.symbols) { |
| 245 | const pos = placed.get(sym.id) |
| 246 | if (pos) drawNode(world, sym, pos) |
| 247 | } |
| 248 | applyTransform() |
| 249 | } |
| 250 | |
| 251 | function drawNode (world, sym, pos) { |
| 252 | const g = el('g', { transform: 'translate(' + pos.x + ',' + pos.y + ')' }) |
| 253 | const open = view.expanded.has(sym.id) |
| 254 | |
| 255 | g.appendChild(el('rect', { class: 'n-body', width: pos.w, height: pos.h, rx: 5 })) |
| 256 | g.appendChild(el('path', { |
| 257 | class: 'n-head', |
| 258 | fill: KIND_COLOR[sym.kind] || '#3a3d44', |
| 259 | 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 | ' h ' + -pos.w + ' z' |
| 261 | })) |
| 262 | |
| 263 | const title = el('text', { class: 'n-title', x: 20, y: 14 }) |
| 264 | title.textContent = sym.name |
| 265 | g.appendChild(title) |
| 266 | |
| 267 | const kind = el('text', { class: 'n-sub', x: pos.w - 7, y: 14, 'text-anchor': 'end' }) |
| 268 | kind.textContent = sym.kind |
| 269 | g.appendChild(kind) |
| 270 | |
| 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) |
| 282 | |
| 283 | if (open) { |
| 284 | const code = codeOf(sym) |
| 285 | let y = HEAD_H + LINE_H + LINE_H + 12 |
| 286 | let prevLine = null |
| 287 | for (let i = 0; i < code.lines.length; i++) { |
| 288 | 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) { |
| 292 | const gap = el('text', { class: 'n-lineno', x: 24, y }) |
| 293 | gap.textContent = '...' |
| 294 | g.appendChild(gap) |
| 295 | y += CODE_LINE_H |
| 296 | } |
| 297 | prevLine = lineNo |
| 298 | const depth = Math.min(view.lineDepth[lineNo] || 0, 4) |
| 299 | if (depth > 0) { |
| 300 | // The zone-depth rail: the semantic channel, at line granularity. |
| 301 | g.appendChild(el('rect', { |
| 302 | class: 'depth' + depth, x: 22, y: y - 7, width: pos.w - 26, height: CODE_LINE_H, |
| 303 | opacity: 0.35 |
| 304 | })) |
| 305 | } |
| 306 | const num = el('text', { class: 'n-lineno', x: 18, y, 'text-anchor': 'end' }) |
| 307 | num.textContent = lineNo |
| 308 | g.appendChild(num) |
| 309 | |
| 310 | const t = el('text', { class: 'n-code', x: 24, y }) |
| 311 | t.textContent = clip(code.lines[i].replace(/\t/g, ' '), Math.floor((pos.w - 30) / 4.9)) |
| 312 | g.appendChild(t) |
| 313 | y += CODE_LINE_H |
| 314 | } |
| 315 | if (code.clipped) { |
| 316 | const more = el('text', { class: 'n-sub', x: 24, y }) |
| 317 | more.textContent = 'more lines (clipped at ' + MAX_CODE_LINES + ')' |
| 318 | g.appendChild(more) |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | // One hit target over the header, so clicking anywhere on it toggles. |
| 323 | const hit = el('rect', { class: 'n-hit', width: pos.w, height: HEAD_H }) |
| 324 | hit.addEventListener('click', (e) => { |
| 325 | e.stopPropagation() |
| 326 | toggle(sym.id) |
| 327 | }) |
| 328 | g.appendChild(hit) |
| 329 | |
| 330 | world.appendChild(g) |
| 331 | } |
| 332 | |
| 333 | function clip (text, n) { |
| 334 | return text.length > n ? text.slice(0, n - 1) + '…' : text |
| 335 | } |
| 336 | |
| 337 | function toggle (id) { |
| 338 | if (view.expanded.has(id)) view.expanded.delete(id) |
| 339 | else view.expanded.add(id) |
| 340 | syncExpandParam() |
| 341 | relayout() |
| 342 | } |
| 343 | |
| 344 | function applyTransform () { |
| 345 | document.getElementById('world').setAttribute('transform', |
| 346 | 'translate(' + view.panX.toFixed(1) + ',' + view.panY.toFixed(1) + ') scale(' + view.zoom.toFixed(3) + ')') |
| 347 | } |
| 348 | |
| 349 | // --- input ----------------------------------------------------------------- |
| 350 | |
| 351 | function wire () { |
| 352 | const svg = document.getElementById('svg') |
| 353 | |
| 354 | svg.addEventListener('wheel', (e) => { |
| 355 | e.preventDefault() |
| 356 | const f = e.deltaY > 0 ? 0.9 : 1.1 |
| 357 | const next = Math.max(0.1, Math.min(4, view.zoom * f)) |
| 358 | const r = svg.getBoundingClientRect() |
| 359 | const mx = e.clientX - r.left |
| 360 | const my = e.clientY - r.top |
| 361 | view.panX = mx - (mx - view.panX) * (next / view.zoom) |
| 362 | view.panY = my - (my - view.panY) * (next / view.zoom) |
| 363 | view.zoom = next |
| 364 | applyTransform() |
| 365 | }, { passive: false }) |
| 366 | |
| 367 | svg.addEventListener('mousedown', (e) => { |
| 368 | view.panning = true |
| 369 | view.sx = e.clientX - view.panX |
| 370 | view.sy = e.clientY - view.panY |
| 371 | svg.classList.add('dragging') |
| 372 | }) |
| 373 | document.addEventListener('mousemove', (e) => { |
| 374 | if (!view.panning) return |
| 375 | view.panX = e.clientX - view.sx |
| 376 | view.panY = e.clientY - view.sy |
| 377 | applyTransform() |
| 378 | }) |
| 379 | document.addEventListener('mouseup', () => { |
| 380 | view.panning = false |
| 381 | svg.classList.remove('dragging') |
| 382 | }) |
| 383 | |
| 384 | document.addEventListener('keydown', (e) => { |
| 385 | if (!view.model) return |
| 386 | if (e.key === 'e' || e.key === 'E') { |
| 387 | for (const s of view.model.symbols) view.expanded.add(s.id) |
| 388 | syncExpandParam() |
| 389 | relayout() |
| 390 | } else if (e.key === 'c' || e.key === 'C') { |
| 391 | view.expanded.clear() |
| 392 | syncExpandParam() |
| 393 | relayout() |
| 394 | } |
| 395 | }) |
| 396 | |
| 397 | const input = document.getElementById('path') |
| 398 | input.addEventListener('change', () => { |
| 399 | const p = input.value.trim() |
| 400 | if (p) { |
| 401 | history.replaceState(null, '', '/floor?path=' + encodeURIComponent(p)) |
| 402 | load(p) |
| 403 | } |
| 404 | }) |
| 405 | } |
| 406 | |
| 407 | async function start () { |
| 408 | wire() |
| 409 | const params = new URLSearchParams(location.search) |
| 410 | let path = params.get('path') |
| 411 | if (!path) { |
| 412 | const res = await fetch('/api/files') |
| 413 | const data = await res.json() |
| 414 | const first = (data.files || []).find(f => f.path.endsWith('.js')) || (data.files || [])[0] |
| 415 | path = first ? first.path : null |
| 416 | } |
| 417 | if (!path) { |
| 418 | banner('no files in the scan to open') |
| 419 | return |
| 420 | } |
| 421 | await load(path) |
| 422 | } |
| 423 | |
| 424 | start() |