OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / floor / level.js

911 lines · javascript ref:
/**
 * 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()