/**
* Two rungs of the ladder: the file level (one node per symbol) and the symbol
* level (the expression floor — operations inside their zones).
*
* Specified in docs/EXPRESSION-FLOOR.md section 3. Both levels produce the same
* shape — cards, links, containers — so layout and rendering are shared and the
* two rungs cannot drift apart visually.
*
* It imports the SAME lib/layout.js the node tests run against, served
* statically, rather than a copy. Expanding a card is therefore honest end to
* end: the height changes, layout runs again, zone containers reflow around it
* (L4).
*/
import { layout } from '/floor/layout.js'
import {
parseLocation, locationToUrl, otherSurface, splitFilePath, joinFilePath
} from '/floor/location.js'
import { routeEdges, findJunctions } from '/floor/routing.js'
import {
buildSystemLevel, buildModuleLevel, buildFileLevel, buildSymbolLevel,
resolveCode, lineDepths, spanOnLine, marksOwnSpan, bundleLinks,
dependencyClosure, depthColor, buildRelationView
} from '/floor/cards.js'
const SVG_NS = 'http://www.w3.org/2000/svg'
// A card's KIND is semantic and comes from cards.js; the mapping to a colour is
// the view's business and lives only here.
const KIND_COLOR = {
function: '#b8642a',
class: '#6b4a86',
const: '#8a6a2a',
'module-body': '#1f4a6b',
module: '#1f4a6b',
file: '#2f5b86',
op: '#2f5b86',
call: '#2f6b6b',
exit: '#8a3b36',
nested: '#b8642a'
}
const NODE_W = 260
const NODE_W_OPEN = 520
const OP_W = 230
const OP_W_OPEN = 520
const HEAD_H = 20
const LINE_H = 11
const CODE_LINE_H = 10
const MAX_CODE_LINES = 120
const view = {
tree: null, // the scan, for the system and module levels
module: null, // the module level, when there is no path
path: null,
model: null,
source: null,
lines: [],
lineDepth: [],
symbolId: null, // null = the file level
expanded: new Set(),
cards: [],
links: [],
containers: [],
bundled: false, // one edge per container pair instead of per member
circuit: true, // orthogonal routes that go AROUND nodes, not behind them
routeCache: new Map(), // signature -> routes, so toggling a mode is instant
selected: null, // a card whose whole dependency closure is showing
// The relation view: { id, depth }, depth in hops, or null for the ordinary
// board. While it is on, the board shows ONLY the focus and what it is
// connected to, in columns of hop distance.
relations: null,
hidden: null, // what the relation view leaves out, for the HUD
ringsFor: null, // id -> { hop, side } while the relation view is on
hovered: null, // the card under the pointer, so a key can act on it
linkEls: [],
cardEls: null,
laid: null,
panX: 60,
panY: 100,
zoom: 0.9,
panning: false,
sx: 0,
sy: 0
}
// system -> module -> file -> symbol. Which rung we are on is decided by what
// the URL names, so every level is deep-linkable (invariant I5).
function levelKind () {
if (view.path && view.symbolId) return 'symbol'
if (view.path) return 'file'
if (view.module) return 'module'
return 'system'
}
async function loadTree () {
if (view.tree) return view.tree
const res = await fetch('/api/tree')
view.tree = await res.json()
return view.tree
}
async function load (path, symbolName) {
const res = await fetch('/api/derive?path=' + encodeURIComponent(path))
const data = await res.json()
if (data.error) {
banner('cannot derive ' + path + ': ' + data.error)
return
}
view.path = path
view.model = data.model
view.source = data.source
view.lines = data.source.split('\n')
view.lineDepth = lineDepths(data.model, view.lines.length)
view.expanded = new Set()
const wantSymbol = symbolName || parseLocation(location.pathname, '').symbol
const sym = wantSymbol ? data.model.symbols.find(s => s.name === wantSymbol) : null
view.symbolId = sym ? sym.id : null
applyExpandParam()
document.getElementById('path').value = path
rebuild()
}
function currentSymbol () {
if (!view.symbolId) return null
return view.model.symbols.find(s => s.id === view.symbolId) || null
}
// --- layout ----------------------------------------------------------------
// Pixel sizes are the VIEW's business; cards.js has no opinion about them.
function widthOf (card) {
const open = view.expanded.has(card.id)
const symbolLevel = card.kind === 'op' || card.kind === 'call' ||
card.kind === 'exit' || card.kind === 'nested'
if (symbolLevel) return open ? OP_W_OPEN : OP_W
return open ? NODE_W_OPEN : NODE_W
}
function heightOf (card) {
let h = HEAD_H + (card.sig ? LINE_H : 0) + LINE_H + 6
if (view.expanded.has(card.id)) {
const code = resolveCode(view.model, view.lines, card, MAX_CODE_LINES)
let gaps = 0
for (let i = 1; i < code.numbers.length; i++) {
if (code.numbers[i] > code.numbers[i - 1] + 1) gaps++
}
h += (code.lines.length + gaps) * CODE_LINE_H + 8
if (code.clipped) h += CODE_LINE_H
}
return h
}
function builtLevel () {
const kind = levelKind()
if (kind === 'system') return buildSystemLevel(view.tree)
if (kind === 'module') return buildModuleLevel(view.tree, view.module)
const sym = currentSymbol()
return sym ? buildSymbolLevel(view.model, sym) : buildFileLevel(view.model)
}
function rebuild () {
const kind = levelKind()
const sym = currentSymbol()
let built = builtLevel()
// The relation view REPLACES the board, after it is built and before it is laid
// out, so every other mode — bundling, expansion, routing — keeps working
// inside it.
view.hidden = null
view.ringsFor = null
if (view.relations && built.cards.some(c => c.id === view.relations.id)) {
built = buildRelationView(built, view.relations.id, view.relations.depth)
view.hidden = built.hidden
view.ringsFor = built.rings
} else if (view.relations) {
view.relations = null // the card is not on this rung any more
}
view.cards = built.cards
view.links = built.links
view.containers = built.containers
view.laid = layout({
nodes: built.cards.map(c => ({
id: c.id,
container: c.container,
w: widthOf(c),
h: heightOf(c)
})),
links: built.links,
containers: built.containers
})
const NAMES = {
system: '<b>system level</b> - one node per module, grouped by category',
module: '<b>module level</b> - one node per file, grouped by directory',
file: '<b>file level</b> - one node per symbol',
symbol: '<b>symbol level</b> - operations inside their zones'
}
document.getElementById('levelname').innerHTML = NAMES[kind]
const m = view.model
let stats = ''
if (kind === 'system') {
stats = built.cards.length + ' modules · ' + built.containers.length + ' categories'
} else if (kind === 'module') {
stats = built.cards.length + ' files · ' + built.containers.length + ' directories'
} else if (kind === 'symbol') {
stats = built.cards.length + ' nodes · ' + built.containers.length + ' zones in ' + sym.name
} else {
stats = m.symbols.length + ' symbols · ' + m.nodes.length + ' ops · ' + m.zones.length +
' zones · ' + m.exits.length + ' exits' + (m.language ? ' · ' + m.language : '')
}
const edgeCount = view.bundled
? bundleLinks(built.cards, built.links).length + ' bundled edges'
: built.links.length + ' edges'
if (view.relations && built.rings) {
// The ordinary counts would describe the slice as if it were the board. Say
// what this is instead, and state what it is leaving out.
const card = built.cards.find(c => c.id === view.relations.id)
document.getElementById('stats').textContent =
'RELATIONS of ' + (card ? card.title : view.relations.id) +
' · within ' + view.relations.depth + (view.relations.depth === 1 ? ' hop' : ' hops') +
' · ' + built.cards.length + ' nodes, ' + built.links.length + ' links' +
' · hiding ' + built.hidden.nodes + ' nodes and ' + built.hidden.links + ' links' +
' · [ ] depth, Esc out'
} else {
document.getElementById('stats').textContent = stats + ' · ' + edgeCount
}
banner(m && m.partial ? 'PARTIAL - ' + m.partialReason : null)
breadcrumb()
render()
}
// --- rendering -------------------------------------------------------------
function el (name, attrs) {
const node = document.createElementNS(SVG_NS, name)
for (const k in attrs) node.setAttribute(k, attrs[k])
return node
}
function banner (text) {
const b = document.getElementById('banner')
b.style.display = text ? 'block' : 'none'
b.textContent = text || ''
}
/**
* The rings, in the shape dependencyClosure returns, so every drawing path that
* already understands a closure works unchanged.
*
* An edge takes the hop of whichever end is further from the focus, which is how
* far out you have to go to see it. Every node and every edge in the view gets an
* entry, so nothing is dimmed or cold.
*/
function ringHighlight (rings, edges, depth) {
const nodes = new Map()
for (const [id, r] of rings) nodes.set(id, r.hop)
const edgeMap = new Map()
for (const l of edges) {
const a = nodes.get(l.from)
const b = nodes.get(l.to)
if (a === undefined || b === undefined) continue
edgeMap.set(l.from + ' -> ' + l.to, Math.max(a, b))
}
return { nodes, edges: edgeMap, maxDepth: Math.max(1, depth) }
}
function breadcrumb () {
const bc = document.getElementById('crumb')
bc.textContent = ''
const kind = levelKind()
const sym = currentSymbol()
const rungs = [{ label: 'system', go: () => goSystem() }]
if (view.module) rungs.push({ label: view.module, go: () => goModule(view.module) })
if (view.path) rungs.push({ label: view.path, go: () => goFile(view.path) })
if (sym) rungs.push({ label: sym.name, go: null })
rungs.forEach((rung, i) => {
if (i > 0) {
const sep = document.createElement('span')
sep.className = 'sep'
sep.textContent = ' > '
bc.appendChild(sep)
}
const a = document.createElement('a')
a.textContent = rung.label
a.href = '#'
const last = i === rungs.length - 1
if (last) a.className = 'current'
if (!last && rung.go) {
a.addEventListener('click', (e) => { e.preventDefault(); rung.go() })
}
bc.appendChild(a)
})
void kind
}
function render () {
const world = document.getElementById('world')
world.textContent = ''
const placed = new Map(view.laid.nodes.map(n => [n.id, n]))
// Zone containers sit behind everything: they are regions, not boxes drawn
// around whatever positions fell out (that was the old frames bug).
for (const box of view.laid.containers) {
const depth = Math.min(box.depth, 4)
const isRing = box.id.slice(0, 5) === 'ring:'
world.appendChild(el('rect', {
class: isRing ? 'zone zone-ring' : 'zone depth' + depth,
x: box.x, y: box.y, width: box.w, height: box.h, rx: 8
}))
const label = el('text', { class: 'zone-label', x: box.x + 8, y: box.y + 13 })
// cards.js builds the full label; how much of it fits is the view's problem.
label.textContent = clip(box.label || '', Math.floor((box.w - 16) / 5.4))
world.appendChild(label)
}
// A bundled edge can end on a container, so boxes come from both lists.
const boxes = new Map(view.laid.nodes.map(n => [n.id, n]))
for (const c of view.laid.containers) boxes.set(c.id, c)
view.linkEls = []
const edges = view.bundled ? bundleLinks(view.cards, view.links) : view.links
// Orthogonal routing: wires leave a node's edge, run in the free channels and
// go AROUND whatever is in the way, instead of a bezier drawn straight from A
// to B and passing behind everything between them.
//
// Routing depends only on the GEOMETRY, never on the selection, so a render
// that merely changes highlighting must reuse it. Without this a double-click
// runs two full A* passes back to back and hangs the view.
let routedPaths = new Map()
let junctions = []
const signature = view.circuit
? view.laid.nodes.map(n => n.id + ':' + Math.round(n.x) + ':' + Math.round(n.y)).join('|') +
'#' + edges.length + (view.bundled ? '#b' : '')
: null
const cached = signature ? view.routeCache.get(signature) : null
if (cached) {
routedPaths = cached.routedPaths
junctions = cached.junctions
} else if (view.circuit) {
const boxes = []
for (const n of view.laid.nodes) boxes.push({ id: n.id, x: n.x, y: n.y, w: n.w, h: n.h })
if (view.bundled) {
for (const c of view.laid.containers) boxes.push({ id: c.id, x: c.x, y: c.y, w: c.w, h: c.h })
}
// No grid: routing picks one from the work the board needs.
const out = routeEdges(boxes, edges.map(e => ({ from: e.from, to: e.to })))
for (const r of out.routes) {
routedPaths.set(r.from + ' -> ' + r.to,
'M ' + r.points.map(p => p.x.toFixed(1) + ' ' + p.y.toFixed(1)).join(' L '))
}
junctions = findJunctions(out.routes)
// A handful of entries, not one: entering and leaving the relation view swaps
// geometries repeatedly, and a single slot makes every swap a fresh A* pass.
view.routeCache.set(signature, { routedPaths, junctions })
while (view.routeCache.size > 6) view.routeCache.delete(view.routeCache.keys().next().value)
}
// Clicking a node shows its FULL dependency depth, not just one hop, with the
// distance carried by colour. Measured on a 56-module scan: 10 modules have a
// transitive dependency and the deepest reaches 14 modules three hops out —
// which a one-hop view simply does not show.
const selectedCard = view.selected
? view.cards.find(c => c.id === view.selected)
: null
// In the relation view the highlight comes from the RINGS, not from a directed
// closure. dependencyClosure only walks upstream, so for a focus whose
// relations are all downstream it matched nothing and the view rendered almost
// entirely grey: 18 of 19 cards dimmed and all 33 links cold. Everything drawn
// here is related by construction, so nothing may be dimmed — the hop number
// is the information, and it is already computed.
const closure = view.relations && view.ringsFor
? ringHighlight(view.ringsFor, edges, view.relations.depth)
: selectedCard
? dependencyClosure(edges, edgeKeyOf(selectedCard))
: null
for (const l of edges) {
const a = boxes.get(l.from)
const b = boxes.get(l.to)
if (!a || !b) continue
const y1 = a.y + (placed.has(l.from) ? HEAD_H / 2 : a.h / 2)
const y2 = b.y + (placed.has(l.to) ? HEAD_H / 2 : b.h / 2)
const x1 = a.x + a.w
const x2 = b.x
const dx = Math.max(30, Math.abs(x2 - x1) * 0.5)
const routed = routedPaths.get(l.from + ' -> ' + l.to)
const path = el('path', {
class: 'link' + (view.bundled ? ' bundled' : ''),
d: routed || ('M ' + x1 + ' ' + y1 + ' C ' + (x1 + dx) + ' ' + y1 + ', ' + (x2 - dx) + ' ' + y2 +
', ' + x2 + ' ' + y2)
})
if (closure) {
const depth = closure.edges.get(l.from + ' -> ' + l.to)
if (depth === undefined) {
path.classList.add('cold')
} else {
path.classList.add('dep')
path.setAttribute('stroke', depthColor(depth, closure.maxDepth))
path.setAttribute('stroke-width', Math.max(1.2, 2.6 - depth * 0.35).toFixed(2))
}
}
if (view.bundled && l.count > 1) {
path.setAttribute('stroke-width', Math.min(6, 1 + Math.log2(l.count)).toFixed(2))
}
world.appendChild(path)
view.linkEls.push({ el: path, from: l.from, to: l.to })
if (view.bundled) {
const label = el('text', { class: 'link-count', x: (x1 + x2) / 2, y: (y1 + y2) / 2 - 4 })
label.textContent = l.count
world.appendChild(label)
}
}
// Junction dots where branches leave a shared trunk — a bus with taps, the
// way a circuit diagram marks a connection rather than a crossing.
for (const j of junctions) {
world.appendChild(el('circle', { class: 'junction', cx: j.x, cy: j.y, r: 3 }))
}
view.cardEls = new Map()
for (const card of view.cards) {
const pos = placed.get(card.id)
if (pos) drawCard(world, card, pos, closure)
}
applyTransform()
// Focus is deep-linkable too (invariant I5): ?focus=<title> opens the view
// with that node's edges already traced, which is also the only way to show
// the behaviour in a headless screenshot.
const params = new URLSearchParams(location.search)
const wanted = params.get('focus')
if (wanted) {
const card = view.cards.find(c => c.title === wanted)
if (card) focusCard(card)
}
// ?select=<title> opens with that node's whole dependency closure showing.
const wantSelect = params.get('select')
if (wantSelect && !view.selected) {
const card = view.cards.find(c => c.title === wantSelect)
if (card) { view.selected = card.id; render() }
}
// ?relations=<depth> opens straight into the relation view. `select` names
// WHAT and `relations` says how far out, so a relation view is a link like
// every other state here.
const wantRel = params.get('relations')
if (wantRel && view.selected && !view.relations) {
relate(view.selected, Math.max(1, Math.min(8, Number(wantRel) || 1)))
}
}
function drawCard (world, card, pos, closure) {
const g = el('g', { transform: 'translate(' + pos.x + ',' + pos.y + ')' })
const open = view.expanded.has(card.id)
const body = el('rect', { class: 'n-body', width: pos.w, height: pos.h, rx: 5 })
if (view.selected === card.id) body.classList.add('selected')
g.appendChild(body)
if (closure) {
const depth = closure.nodes.get(edgeKeyOf(card))
if (depth === undefined) g.classList.add('dim')
else if (depth > 0) {
const badge = el('text', { class: 'dep-badge', x: pos.w - 7, y: pos.h - 5, 'text-anchor': 'end' })
badge.textContent = depth + (depth === 1 ? ' hop' : ' hops')
badge.setAttribute('fill', depthColor(depth, closure.maxDepth))
g.appendChild(badge)
}
}
g.appendChild(el('path', {
class: 'n-head',
fill: KIND_COLOR[card.kind] || '#3a3d44',
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) +
' h ' + -pos.w + ' z'
}))
const chev = el('text', { class: 'n-chev', x: 7, y: 14 })
chev.textContent = open ? 'v' : '>'
g.appendChild(chev)
const title = el('text', { class: 'n-title', x: 20, y: 14 })
title.textContent = clip(card.title, Math.floor((pos.w - 90) / 6))
g.appendChild(title)
const kind = el('text', { class: 'n-sub', x: pos.w - 7, y: 14, 'text-anchor': 'end' })
kind.textContent = card.badge
g.appendChild(kind)
let y = HEAD_H + 11
if (card.sig) {
const sig = el('text', { class: 'n-sig', x: 8, y })
sig.textContent = clip(card.sig, Math.floor((pos.w - 16) / 5.4))
g.appendChild(sig)
y += LINE_H
}
const sub = el('text', { class: 'n-sub', x: 8, y })
sub.textContent = card.sub
g.appendChild(sub)
if (open) {
const code = resolveCode(view.model, view.lines, card, MAX_CODE_LINES)
y += 12
let prev = null
for (let i = 0; i < code.lines.length; i++) {
const lineNo = code.numbers[i]
if (prev !== null && lineNo > prev + 1) {
const gap = el('text', { class: 'n-lineno', x: 24, y })
gap.textContent = '...'
g.appendChild(gap)
y += CODE_LINE_H
}
prev = lineNo
const depth = Math.min(view.lineDepth[lineNo] || 0, 4)
if (depth > 0) {
g.appendChild(el('rect', {
class: 'depth' + depth, x: 22, y: y - 7, width: pos.w - 26, height: CODE_LINE_H,
opacity: 0.35
}))
}
const num = el('text', { class: 'n-lineno', x: 18, y, 'text-anchor': 'end' })
num.textContent = lineNo
g.appendChild(num)
g.appendChild(codeLine(card, code.lines[i], lineNo, Math.floor((pos.w - 30) / 4.9), y))
y += CODE_LINE_H
}
if (code.clipped) {
const more = el('text', { class: 'n-sub', x: 24, y })
more.textContent = 'more lines (clipped at ' + MAX_CODE_LINES + ')'
g.appendChild(more)
}
}
// The header toggles code. The title descends, when there is somewhere to go.
const hit = el('rect', { class: 'n-hit', width: pos.w, height: HEAD_H })
hit.addEventListener('click', (e) => {
e.stopPropagation()
toggle(card.id)
})
g.appendChild(hit)
if (card.openTo) {
title.classList.add('n-open')
title.addEventListener('click', (e) => {
e.stopPropagation()
if (card.kind === 'module') goModule(card.openTo)
else if (card.kind === 'file') goFile(card.openTo)
else enter(card.openTo)
})
}
// Edges are dim until you point at something: at real scale they are the
// noise, not the signal - 105 crossing curves at the system level, 316 inside
// one 58-file module.
g.addEventListener('mouseenter', () => {
view.hovered = card.id
if (!view.selected) focusCard(card)
})
g.addEventListener('mouseleave', () => {
if (view.hovered === card.id) view.hovered = null
if (!view.selected) unfocus()
})
g.addEventListener('click', (e) => {
e.stopPropagation()
select(view.selected === card.id ? null : card.id)
})
world.appendChild(g)
view.cardEls.set(card.id, g)
}
// What a card counts as for edges: itself, or its container once bundled.
function edgeKeyOf (card) {
return view.bundled ? (card.container || card.id) : card.id
}
function focusCard (card) {
const key = edgeKeyOf(card)
const connected = new Set([card.id])
for (const link of view.linkEls) {
const hot = link.from === key || link.to === key
link.el.classList.toggle('hot', hot)
link.el.classList.toggle('cold', !hot)
if (!hot) continue
const other = link.from === key ? link.to : link.from
for (const c of view.cards) {
if (c.id === other || (view.bundled && (c.container || c.id) === other)) connected.add(c.id)
}
}
for (const [id, g] of view.cardEls) g.classList.toggle('dim', !connected.has(id))
}
function select (id) {
view.selected = id
unfocus()
render()
}
/**
* Enter the relation view, leave it, or move its depth.
*
* Depth clamps at 1, not 0: a relation view of one node showing no relations is
* not a view at its limit, it is a bug that happens to be reachable. Going below
* 1 leaves instead, so [ walks back in and then out.
*/
function relate (id, depth) {
if (!id || depth < 1) {
if (!view.relations) return
view.relations = null
} else {
view.relations = { id, depth: Math.min(depth, 8) }
view.selected = id
}
unfocus()
syncUrl()
rebuild()
}
function unfocus () {
for (const link of view.linkEls) link.el.classList.remove('hot', 'cold')
if (view.cardEls) for (const [, g] of view.cardEls) g.classList.remove('dim')
}
/**
* One line of code, with the card's OWN span drawn bright and the rest dim.
*
* Several operations share a line all the time - `!`, `?:` and `return` all sit
* on line 21 of entryKeyFor - so without this, expanding each of them showed the
* same line three times with nothing to distinguish them.
*
* Three tspans rather than a positioned highlight rect: a tspan flows from the
* one before it, so the split lands exactly on the character boundary whatever
* the font's metrics turn out to be.
*/
function codeLine (card, rawLine, lineNo, maxChars, y) {
const text = el('text', { class: 'n-code', x: 24, y })
const displayed = clip(rawLine.replace(/\t/g, ' '), maxChars)
const mark = marksOwnSpan(card) ? spanOnLine(card.span, lineNo, rawLine, 2) : null
if (!mark) {
text.textContent = displayed
return text
}
const from = Math.min(mark.from, displayed.length)
const to = Math.min(mark.to, displayed.length)
const parts = [
{ t: displayed.slice(0, from), cls: 'code-dim' },
{ t: displayed.slice(from, to), cls: 'code-own' },
{ t: displayed.slice(to), cls: 'code-dim' }
]
for (const part of parts) {
if (!part.t) continue
const span = document.createElementNS(SVG_NS, 'tspan')
span.setAttribute('class', part.cls)
span.textContent = part.t
text.appendChild(span)
}
return text
}
function clip (text, n) {
const max = Math.max(4, n)
return text.length > max ? text.slice(0, max - 1) + '…' : text
}
// --- navigation ------------------------------------------------------------
async function goSystem () {
await loadTree()
view.module = null
view.path = null
view.model = null
view.symbolId = null
resetView()
syncUrl()
rebuild()
}
async function goModule (name) {
await loadTree()
view.module = name
view.path = null
view.model = null
view.symbolId = null
resetView()
syncUrl()
rebuild()
}
async function goFile (path) {
view.symbolId = null
resetView()
await load(path)
syncUrl()
}
function resetView () {
view.expanded = new Set()
view.panX = 60
view.panY = 110
}
function enter (symbolName) {
const sym = symbolName ? view.model.symbols.find(s => s.name === symbolName) : null
view.symbolId = sym ? sym.id : null
view.expanded = new Set()
view.panX = 60
view.panY = 100
syncUrl()
rebuild()
}
function toggle (id) {
if (view.expanded.has(id)) view.expanded.delete(id)
else view.expanded.add(id)
rebuild()
}
// Deep-linkable, because invariant I5 says every view in codetree serve is. At
// the file level the expanded set is symbol NAMES; at the symbol level operation
// ids are byte offsets, so only `all` round-trips.
function applyExpandParam () {
const want = new URLSearchParams(location.search).get('expand')
if (!want) return
if (want === 'all') {
const sym = currentSymbol()
const built = sym ? buildSymbolLevel(view.model, sym) : buildFileLevel(view.model)
for (const c of built.cards) view.expanded.add(c.id)
return
}
const names = new Set(want.split(',').map(x => x.trim()).filter(Boolean))
for (const s of view.model.symbols) if (names.has(s.name)) view.expanded.add(s.id)
}
// The URL is the canonical scheme the SPA defined, under /floor. Anything that
// is a VIEW PREFERENCE rather than a place — bundled edges, a selection — stays
// a query param, so the path itself means the same thing on either surface.
function syncUrl () {
const loc = currentLocation()
const q = []
if (view.bundled) q.push('edges=bundled')
if (view.selected) {
const card = view.cards.find(c => c.id === view.selected)
if (card) q.push('select=' + encodeURIComponent(card.title))
}
if (view.relations) q.push('relations=' + view.relations.depth)
history.replaceState(null, '', locationToUrl(loc, '') + (q.length > 0 ? '?' + q.join('&') : ''))
const link = document.getElementById('other-surface')
if (link) link.setAttribute('href', otherSurface(loc, ''))
}
// Where we are, in the shared model rather than in this view's private fields.
function currentLocation () {
const sym = currentSymbol()
const split = view.path && view.tree
? splitFilePath(view.tree, view.path)
: { module: null, file: null }
const base = { surface: 'floor', module: null, file: null, symbol: null, path: [], extra: {} }
if (split.module && sym) {
return { ...base, kind: 'symbol', module: split.module, file: split.file, symbol: sym.name }
}
if (split.module) return { ...base, kind: 'file', module: split.module, file: split.file }
if (view.module) return { ...base, kind: 'module', module: view.module }
return { ...base, kind: 'system' }
}
function applyTransform () {
document.getElementById('world').setAttribute('transform',
'translate(' + view.panX.toFixed(1) + ',' + view.panY.toFixed(1) + ') scale(' + view.zoom.toFixed(3) + ')')
}
function wire () {
const svg = document.getElementById('svg')
svg.addEventListener('wheel', (e) => {
e.preventDefault()
const f = e.deltaY > 0 ? 0.9 : 1.1
const next = Math.max(0.05, Math.min(4, view.zoom * f))
const r = svg.getBoundingClientRect()
const mx = e.clientX - r.left
const my = e.clientY - r.top
view.panX = mx - (mx - view.panX) * (next / view.zoom)
view.panY = my - (my - view.panY) * (next / view.zoom)
view.zoom = next
applyTransform()
}, { passive: false })
svg.addEventListener('click', (e) => {
if (e.target === svg || e.target.id === 'world') select(null)
})
svg.addEventListener('mousedown', (e) => {
view.panning = true
view.sx = e.clientX - view.panX
view.sy = e.clientY - view.panY
svg.classList.add('dragging')
})
document.addEventListener('mousemove', (e) => {
if (!view.panning) return
view.panX = e.clientX - view.sx
view.panY = e.clientY - view.sy
applyTransform()
})
document.addEventListener('mouseup', () => {
view.panning = false
svg.classList.remove('dragging')
})
document.addEventListener('keydown', (e) => {
// NOT `if (!view.model) return`. view.model is null on the system and module
// rungs — those levels are built from the tree, not from a parsed file — so
// that guard silently killed B, R, E, C and Escape on the two upper rungs,
// while the hint bar went on promising them.
if (!view.tree && !view.model) return
// This view has a path input. Without this guard, typing a path containing g,
// b, r, e or c fired every one of these shortcuts mid-word.
const t = e.target
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
if (e.metaKey || e.ctrlKey || e.altKey) return
if (e.key === 'g' || e.key === 'G') {
// Hover counts, not just selection. On this board the obvious click target
// is a card's TITLE, and that opens the card rather than selecting it — so
// requiring a selection made G look broken.
const target = view.relations ? null : (view.selected || view.hovered)
if (!view.relations && !target) {
banner('point at a node, or click one, then press G for its relations')
return
}
relate(target, view.relations ? 0 : 1)
} else if (e.key === ']' || e.key === '}') {
if (view.relations) relate(view.relations.id, view.relations.depth + 1)
} else if (e.key === '[' || e.key === '{') {
if (view.relations) relate(view.relations.id, view.relations.depth - 1)
} else if (e.key === 'e' || e.key === 'E') {
if (!view.model) return // nothing to expand without parsed source
for (const c of view.cards) view.expanded.add(c.id)
rebuild()
} else if (e.key === 'c' || e.key === 'C') {
view.expanded.clear()
rebuild()
} else if (e.key === 'r' || e.key === 'R') {
view.circuit = !view.circuit
rebuild()
} else if (e.key === 'b' || e.key === 'B') {
view.bundled = !view.bundled
syncUrl()
rebuild()
} else if (e.key === 'Escape') {
if (view.relations) { relate(null, 0); return }
if (view.selected) { select(null); return }
const kind = levelKind()
if (kind === 'symbol') enter(null)
else if (kind === 'file') {
if (view.module) goModule(view.module)
else goSystem()
} else if (kind === 'module') goSystem()
}
})
const input = document.getElementById('path')
input.addEventListener('change', () => {
const p = input.value.trim()
if (!p) return
view.symbolId = null
history.replaceState(null, '', '/floor?path=' + encodeURIComponent(p))
load(p)
})
}
async function start () {
wire()
await loadTree()
const loc = parseLocation(location.pathname, '')
view.bundled = new URLSearchParams(location.search).get('edges') === 'bundled'
if (loc.kind === 'file' || loc.kind === 'symbol') {
const rootRel = joinFilePath(view.tree, loc.module, loc.file)
view.module = loc.module
if (rootRel) {
await load(rootRel, loc.symbol)
syncUrl()
return
}
}
if (loc.kind === 'module') view.module = loc.module
// Anything else is the system level: the top of the ladder, and the honest
// default rather than guessing a file out of the scan.
rebuild()
syncUrl()
}
start()