OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / render-nodes.js

1344 lines · javascript ref:
/**
 * The node view — code as a Geometry-Nodes graph of nested groups.
 *
 * Everything here is DERIVED from the scan. Nothing is authored.
 *
 *   a germ            -> a node
 *   an import         -> an input socket   (a demand)
 *   an export         -> an output socket  (a supply)
 *   a mating          -> a noodle from an output socket to an input socket
 *   an unmated socket -> an empty socket, visibly unconnected
 *
 * Scales nest exactly the way a Blender node group does: a node at one level
 * opens into a whole graph at the next, and the group's interface — its Group
 * Input and Group Output nodes — is the germ's own boundary, which is to say
 * the connectors left over once the interior's matings cancel.
 *
 *   root    module nodes     no interface
 *   module  file nodes       in: the module's package deps   out: its publicAPI
 *   file    symbol nodes     in: the file's imports          out: its exports
 *   symbol  THE SOURCE       in: its params                  out: its name
 *
 * The lowest nodes carry the actual code, read from /api/file over the line
 * range the parser recorded.
 *
 * The emitted script avoids backticks and dollar-brace: it is interpolated into
 * render.js's template literal, and this file is a template literal too.
 */

export const NODE_VIEW_CSS = `
#node-container {
  position: absolute; top: 0; left: 0; right: 0; bottom: 0;
  display: none; overflow: hidden; background: #131417;
}
#node-container.active { display: block; }
#node-svg { width: 100%; height: 100%; cursor: grab; }
#node-svg.dragging { cursor: grabbing; }

.nv-node-body { fill: #26272b; stroke: #17181a; stroke-width: 1; }
.nv-node-head { stroke: none; }
.nv-node-title { fill: #e8e8ea; font-size: 11px; font-weight: bold; pointer-events: none; }
.nv-node-sub { fill: #a0a0a8; font-size: 9px; pointer-events: none; }
.nv-sock-label { fill: #c4c4cc; font-size: 10px; pointer-events: none; }
.nv-sock-label.unmated { fill: #6e7076; font-style: italic; }
.nv-noodle { fill: none; stroke-width: 1.6; pointer-events: none; }
.nv-node.selected .nv-node-body { stroke: #ffa657; stroke-width: 2; }
.nv-frame-ring { fill-opacity: 0.1; stroke-width: 1.6; stroke-dasharray: 7 4; stroke-opacity: 0.9; }
.nv-group-node .nv-node-body { stroke: #58a6ff; stroke-dasharray: 4,2; }
.nv-code { font-family: 'SF Mono','Fira Code',monospace; font-size: 8px; fill: #9aa0a6; pointer-events: none; white-space: pre; }
.nv-code-kw { fill: #ff7b72; }
.nv-lineno { fill: #4a4d55; font-size: 7px; pointer-events: none; }

.nv-frame { fill-opacity: 0.10; stroke-opacity: 0.55; stroke-width: 1.5; rx: 8; }
.nv-frame-label { font-size: 12px; font-weight: bold; fill-opacity: 0.95; pointer-events: none; }
.nv-junction { fill: #5a6570; stroke: none; }
.nv-noodle.hot { stroke: #ffa657 !important; stroke-width: 2.2; }
.nv-noodle.cold { opacity: 0.06; }
.nv-noodle.dep { stroke-width: 2; }
.nv-node.dim { opacity: 0.25; }
.nv-hops { font-size: 8px; font-family: ui-monospace, monospace; }
.nv-noodle { stroke-linejoin: round; stroke-linecap: round; }

#node-breadcrumb {
  position: absolute; top: 12px; left: 12px; display: flex; gap: 4px;
  align-items: center; font-size: 12px; z-index: 5;
}
#node-breadcrumb a {
  color: #8b949e; cursor: pointer; padding: 4px 9px; text-decoration: none;
  background: #1c1d21; border: 1px solid #2f3136; border-radius: 4px;
}
#node-breadcrumb a:hover { color: #e8e8ea; border-color: #58a6ff; }
#node-breadcrumb a.current { color: #ffa657; border-color: #ffa657; cursor: default; }
#node-breadcrumb .sep { color: #4a4d55; }

#node-legend {
  position: absolute; bottom: 12px; left: 12px; background: #1c1d21;
  border: 1px solid #2f3136; border-radius: 6px; padding: 8px 12px;
  font-size: 10px; color: #8b949e; pointer-events: none; line-height: 1.7;
}
#node-legend label { pointer-events: auto; }
#node-legend b { color: #c9d1d9; }
#nv-deps {
  position: absolute; top: 12px; right: 12px; width: 300px; max-height: 70%;
  overflow-y: auto; background: #1c1d21; border: 1px solid #2f3136;
  border-radius: 6px; padding: 10px 12px; font-size: 11px; color: #8b949e;
  display: none; line-height: 1.6;
}
#nv-deps.open { display: block; }
#nv-deps h4 { margin: 0 0 6px; color: #e8e8ea; font-size: 11px; }
#nv-deps .grp { margin-top: 8px; color: #c9d1d9; font-weight: 600; }
#nv-deps .row { display: flex; justify-content: space-between; gap: 8px; }
#nv-deps .ext { color: #6e7076; }
#nv-deps .hops { font-family: ui-monospace, monospace; font-size: 9px; }

#node-hint {
  position: absolute; bottom: 12px; right: 12px; background: #1c1d21;
  border: 1px solid #2f3136; border-radius: 6px; padding: 8px 12px;
  font-size: 10px; color: #8b949e; pointer-events: none;
}
`

export const NODE_VIEW_HTML = `
    <div id="node-container">
      <svg id="node-svg"><defs></defs></svg>
      <div id="node-breadcrumb"></div>
      <div id="node-legend"></div>
      <div id="nv-deps"></div>
      <div id="node-hint">drag to pan &middot; scroll to zoom &middot; click a node title to open it</div>
    </div>
`

export const NODE_VIEW_JS = `
// ====== NODE VIEW (nested groups) ======

var NV = {
  stack: [{ kind: 'path', path: '', label: 'System' }],
  onlyConnected: false,
  showFrames: true,
  nodes: [],
  links: [],
  zoom: 0.9, panX: 60, panY: 40,
  panning: false, panSX: 0, panSY: 0,
  initialized: false,
  sourceCache: {},
  frames: [],
  circuit: true,
  selected: null,
  // The relation view: { id, depth } in hops, or null for the ordinary board.
  // While it is on, only the focus and what it connects to are DRAWN.
  relations: null,
  rings: null,
  ringContainers: null,
  hidden: null,
  hover: null,             // the node under the pointer, so a key can act on it
  note: null,              // a one-line message in the legend
  // A LIST of cached routings, not one slot: entering and leaving the relation
  // view swaps between two geometries over and over, and a single slot makes
  // every swap a fresh A* pass over the whole board.
  routeCache: [],
  linkEls: [],
  nodeEls: null,
  layoutPending: false,
  selected: null
}

var NV_ROW = 15
var NV_HEAD = 20
var NV_W = 210
var NV_COL_GAP = 130
var NV_ROW_GAP = 26

function nvSocketColor (scope, mated) {
  if (!mated) return scope === 'unresolved' ? '#f85149' : '#6e7076'
  if (scope === 'package' || scope === 'external') return '#8b949e'
  return '#7ee787'
}

function nvRoleColor (role) {
  var c = { entry: '#b8642a', test: '#3f7a4a', lib: '#2f5b86', schema: '#6b4a86',
            bin: '#8a3b36', script: '#8a6a2a', header: '#2f5b86', source: '#2f5b86',
            shader: '#6b4a86', module: '#7a4a1f', group: '#1f4a6b' }
  return c[role] || '#3a3d44'
}

// ---- data assembly, all derived from the scan ----

// Every germ contained under a dirTree entry, so a directory can report what
// actually crosses its boundary.
function nvContainedModules (entry) {
  var out = []
  function visit (n) {
    if (!n) return
    if (n.module && tree.modules[n.module]) out.push(n.module)
    if (n.children) for (var i = 0; i < n.children.length; i++) visit(n.children[i])
  }
  visit(entry)
  return out
}

// One level of the directory tree, exactly the set the graph view shows at the
// same path — reusing its helper so the two views cannot disagree.
//
// A node's sockets are the module names that CROSS its boundary: inputs are
// modules outside it that its contents depend on, outputs are modules inside it
// that something outside depends on. Named that way, a noodle connects an
// output socket to the input socket with the SAME NAME, so the wire and the
// socket always agree.
function nvPathNodes (path) {
  var items = getNodesAtPath(path)
  var nodes = []
  var owner = {}

  for (var i = 0; i < items.length; i++) {
    var it = items[i]
    var contained = it.type === 'module' ? [it.name] : nvContainedModules(it.dirEntry)
    var id = (it.type === 'module' ? 'mod:' : 'dir:') + it.name
    for (var c = 0; c < contained.length; c++) owner[contained[c]] = id
    nodes.push({
      id: id, title: it.name + (it.type === 'dir' ? '/' : ''),
      sub: it.type === 'module' ? (it.mod ? it.mod.version : '') : (it.packageCount + ' pkg'),
      role: it.type === 'module' ? 'module' : 'group',
      contained: contained, kind: it.type,
      inputs: [], outputs: [],
      openable: true,
      open: it.type === 'module'
        ? { kind: 'module', name: it.name, label: it.name }
        : { kind: 'path', path: path ? path + '/' + it.name : it.name, label: it.name }
    })
  }

  // Derive the crossing sets from the real dependency graph.
  var inSet = {}, outSet = {}
  for (var i = 0; i < nodes.length; i++) { inSet[nodes[i].id] = {}; outSet[nodes[i].id] = {} }

  for (var m = 0; m < tree.system.modules.length; m++) {
    var name = tree.system.modules[m]
    var mod = tree.modules[name]
    if (!mod) continue
    var from = owner[name]
    if (!from) continue
    var deps = Object.keys(mod.dependencies || {})
    for (var d = 0; d < deps.length; d++) {
      var to = owner[deps[d]]
      if (!to || to === from) continue          // internal to this node: it cancels
      inSet[from][deps[d]] = true
      outSet[to][deps[d]] = true
    }
  }

  for (var i = 0; i < nodes.length; i++) {
    var n = nodes[i]
    var ins = Object.keys(inSet[n.id]).sort()
    var outs = Object.keys(outSet[n.id]).sort()
    for (var a = 0; a < ins.length; a++) n.inputs.push({ name: ins[a], scope: 'local', mated: true })
    for (var b = 0; b < outs.length; b++) n.outputs.push({ name: outs[b], scope: 'local', mated: true })
  }

  return nodes
}

// A noodle per crossing module name, anchored on the sockets that carry it.
function nvPathLinks (nodes) {
  var links = []
  for (var i = 0; i < nodes.length; i++) {
    var src = nodes[i]
    for (var o = 0; o < src.outputs.length; o++) {
      var provided = src.outputs[o].name
      for (var j = 0; j < nodes.length; j++) {
        if (i === j) continue
        var dst = nodes[j]
        for (var k = 0; k < dst.inputs.length; k++) {
          if (dst.inputs[k].name !== provided) continue
          links.push({ from: src.id, fromSock: o, fromSide: 'out',
                       to: dst.id, toSock: k, toSide: 'in' })
        }
      }
    }
  }
  return links
}

function nvFileNodes (data) {
  // data = { module, sections: [file-scale sections with conns] }
  var nodes = []
  var secById = {}
  for (var i = 0; i < data.sections.length; i++) secById[data.sections[i].id] = data.sections[i]

  for (var i = 0; i < data.sections.length; i++) {
    var sec = data.sections[i]
    var ins = []
    var outs = []
    var connIndex = {}
    for (var c = 0; c < sec.conns.length; c++) {
      var conn = sec.conns[c]
      var mated = conn.mates && conn.mates.length > 0
      if (conn.pol === '-') {
        connIndex[conn.id] = { side: 'in', idx: ins.length }
        ins.push({ name: conn.type, scope: conn.scope, mated: mated, connId: conn.id, target: conn.target })
      } else {
        connIndex[conn.id] = { side: 'out', idx: outs.length }
        outs.push({ name: conn.type, scope: conn.scope, mated: mated, connId: conn.id })
      }
    }
    nodes.push({
      id: sec.id, title: sec.germ.name, sub: sec.germ.role,
      role: sec.germ.role, inputs: ins, outputs: outs,
      connIndex: connIndex, germ: sec.germ,
      openable: true,
      open: { kind: 'file', module: data.module, path: sec.germ.name, label: sec.germ.name }
    })
  }
  return { nodes: nodes, secById: secById }
}

function nvFileLinks (nodes) {
  var byId = {}
  for (var i = 0; i < nodes.length; i++) byId[nodes[i].id] = nodes[i]
  var links = []
  for (var i = 0; i < nodes.length; i++) {
    var n = nodes[i]
    for (var s = 0; s < n.inputs.length; s++) {
      var sock = n.inputs[s]
      if (!sock.mated || !sock.target) continue
      var src = byId[sock.target]
      if (!src) continue
      // Find the matching supply socket by name on the target node.
      var fi = -1
      for (var o = 0; o < src.outputs.length; o++) {
        if (src.outputs[o].name === sock.name) { fi = o; break }
      }
      if (fi < 0) continue
      links.push({ from: src.id, fromSock: fi, fromSide: 'out', to: n.id, toSock: s, toSide: 'in' })
    }
  }
  return links
}

// The symbol level: one node per class / function / const, each carrying its
// own source text. This is the bottom of the ladder.
function nvSymbolNodes (moduleName, filePath) {
  var mod = tree.modules[moduleName]
  if (!mod) return []
  var file = mod.files[filePath]
  if (!file) return []
  var nodes = []

  function add (kind, name, line, endLine, params, extra) {
    nodes.push({
      id: 'sym:' + filePath + ':' + name,
      title: name, sub: kind + (line ? '  :' + line : ''),
      role: kind === 'class' ? 'schema' : kind === 'const' ? 'script' : 'lib',
      inputs: (params || []).map(function (p) { return { name: p, scope: 'local', mated: false } }),
      outputs: [{ name: name, scope: 'local', mated: true }],
      code: null, line: line, endLine: endLine,
      filePath: filePath, moduleName: moduleName,
      openable: false, extra: extra || null
    })
  }

  var cls = file.classes || []
  for (var i = 0; i < cls.length; i++) {
    var c = cls[i]
    add('class', c.name, c.line, c.endLine, c.constructor ? c.constructor.params : [],
        (c.methods || []).length + ' methods')
  }
  var fns = file.functions || []
  for (var i = 0; i < fns.length; i++) {
    add('function', fns[i].name, fns[i].line, fns[i].endLine, fns[i].params, null)
  }
  var cs = file.constants || []
  for (var i = 0; i < cs.length; i++) {
    if (cs[i].type === 'class' || cs[i].type === 'function') continue
    add('const', cs[i].name, cs[i].line, cs[i].endLine, [], null)
  }
  return nodes
}

// ---- the group interface: Group Input / Group Output ----
//
// These are the germ's OWN connectors, seen from inside. Group Input supplies
// what the interior demands from outside; Group Output collects what the
// interior offers outward. Exactly Blender's group interface.
// The group interface is DERIVED, never assumed. A germ only gets a Group
// Input if something inside it actually demands from outside, and only gets a
// Group Output if it actually supplies outward. A module with no external deps
// has no Group Input; a CLI or a test file supplies nothing and has no Group
// Output. Emitting empty ones would draw structure that is not there.
//
// Returns { gin, gout, links } — already wired to the interior.
function nvGroupInterface (level, interior) {
  var gin = null, gout = null
  var links = []

  // --- Group Input: every unmated demand the interior makes on the outside,
  //     named by the binding the code actually uses.
  var demandRows = []          // { name, scope, specifier, nodeId, sockIdx }
  for (var i = 0; i < interior.length; i++) {
    var n = interior[i]
    for (var k = 0; k < n.inputs.length; k++) {
      var sock = n.inputs[k]
      if (sock.mated) continue
      demandRows.push({ name: sock.name, scope: sock.scope,
                        specifier: sock.specifier || '', nodeId: n.id, sockIdx: k })
    }
  }

  if (demandRows.length > 0) {
    // One socket per distinct name; several files demanding the same binding
    // share it, which is what a group input does.
    var seen = {}
    var outs = []
    for (var i = 0; i < demandRows.length; i++) {
      var r = demandRows[i]
      if (seen[r.name] === undefined) {
        seen[r.name] = outs.length
        outs.push({ name: r.name, scope: r.scope, mated: true, specifier: r.specifier })
      }
    }
    gin = { id: '__group_in__', title: 'Group Input',
            sub: demandRows.length + ' from outside',
            role: 'group', inputs: [], outputs: outs,
            openable: false, isInterface: true }
    for (var i = 0; i < demandRows.length; i++) {
      var r = demandRows[i]
      links.push({ from: '__group_in__', fromSock: seen[r.name], fromSide: 'out',
                   to: r.nodeId, toSock: r.sockIdx, toSide: 'in' })
    }
  }

  // --- Group Output: what this germ offers outward.
  var wanted = []
  if (level.kind === 'module') {
    var mod = tree.modules[level.name]
    wanted = (mod && mod.publicAPI) ? mod.publicAPI.slice() : []
  } else if (level.kind === 'file') {
    var m2 = tree.modules[level.module]
    var f = m2 && m2.files[level.path]
    var ex = (f && f.exports) || []
    for (var i = 0; i < ex.length; i++) if (ex[i].name && ex[i].name !== '*') wanted.push(ex[i].name)
  }

  if (wanted.length > 0) {
    var ins = []
    var wire = []
    for (var i = 0; i < wanted.length; i++) {
      var name = wanted[i]
      // Which interior node supplies it?
      var srcId = null, srcSock = -1
      for (var j = 0; j < interior.length; j++) {
        for (var o = 0; o < interior[j].outputs.length; o++) {
          if (interior[j].outputs[o].name === name) { srcId = interior[j].id; srcSock = o; break }
        }
        if (srcId) break
      }
      ins.push({ name: name, scope: 'local', mated: !!srcId })
      if (srcId) wire.push({ from: srcId, fromSock: srcSock, fromSide: 'out',
                             to: '__group_out__', toSock: i, toSide: 'in' })
    }
    gout = { id: '__group_out__', title: 'Group Output',
             sub: level.kind === 'module' ? 'publicAPI' : 'exports',
             role: 'group', inputs: ins, outputs: [],
             openable: false, isInterface: true }
    for (var i = 0; i < wire.length; i++) links.push(wire[i])
  }

  return { gin: gin, gout: gout, links: links }
}

// Collapse to only connected: drop every node with no noodle on it. At a
// directory level most packages depend on nobody visible, and hiding them is
// the difference between a wall and a readable graph. Group Input/Output are
// the interface and always stay.
function nvApplyCollapse (nodes, links) {
  if (!NV.onlyConnected) return { nodes: nodes, links: links }
  var used = {}
  for (var i = 0; i < links.length; i++) { used[links[i].from] = true; used[links[i].to] = true }
  var kept = []
  for (var i = 0; i < nodes.length; i++) {
    if (used[nodes[i].id] || nodes[i].isInterface) kept.push(nodes[i])
  }
  var keptIds = {}
  for (var i = 0; i < kept.length; i++) keptIds[kept[i].id] = true
  var keptLinks = []
  for (var i = 0; i < links.length; i++) {
    if (keptIds[links[i].from] && keptIds[links[i].to]) keptLinks.push(links[i])
  }
  return { nodes: kept, links: keptLinks }
}

/**
 * The relation view, as a filter on what gets drawn.
 *
 * Unlike the floor view, this one does not rebuild from a card list — it holds
 * NV.nodes directly — so the slice happens here, in the same seam the
 * only-connected collapse uses, and leaving the view is just another nvBuild().
 */
/**
 * The rings in the shape dependencyClosure returns, so the drawing code below is
 * untouched. An edge takes the hop of whichever end is further from the focus.
 */
function nvRingHighlight (rings, links, depth) {
  var nodes = new Map()
  rings.forEach(function (r, id) { nodes.set(id, r.hop) })
  var edges = new Map()
  for (var i = 0; i < links.length; i++) {
    var a = nodes.get(links[i].from)
    var b = nodes.get(links[i].to)
    if (a === undefined || b === undefined) continue
    edges.set(links[i].from + ' -> ' + links[i].to, Math.max(a, b))
  }
  return { nodes: nodes, edges: edges, maxDepth: Math.max(1, depth) }
}

function nvApplyRelations (nodes, links) {
  NV.rings = null
  NV.hidden = null
  if (!NV.relations || !window.__ct || !window.__ct.buildRelationView) {
    return { nodes: nodes, links: links }
  }
  var present = false
  for (var i = 0; i < nodes.length; i++) if (nodes[i].id === NV.relations.id) present = true
  if (!present) { NV.relations = null; return { nodes: nodes, links: links } }

  var v = window.__ct.buildRelationView(
    { cards: nodes, links: links, containers: [] }, NV.relations.id, NV.relations.depth)
  NV.rings = v.rings
  NV.hidden = v.hidden
  return { nodes: v.cards, links: v.links, containers: v.containers }
}

// Collapse first, then the relation slice: collapsing drops nodes with no wires
// at all, which can never be part of a relation view anyway.
function nvSlice (nodes, links) {
  var c = nvApplyCollapse(nodes, links)
  var r = nvApplyRelations(c.nodes, c.links)
  NV.ringContainers = r.containers || null
  return r
}

window.nvToggleConnected = function (on) {
  NV.onlyConnected = !!on
  nvBuild()
}

window.nvToggleFrames = function (on) {
  NV.showFrames = !!on
  nvRender()
}

// ---- layout: columns by dependency depth, left = supplies, right = consumers

function nvLayout (nodes, links) {
  NV.frames = []
  // boot() awaits the shared modules before anything renders, so this is a
  // guard against a genuinely broken load, not a race.
  if (!window.__ctLayout) return nvLayoutFallback(nodes, links)

  // A group is a CONTAINER in the layout, not a rectangle drawn around whatever
  // positions fell out afterwards. That is the whole difference: containers are
  // placed as whole boxes in one pass, so sibling groups cannot interleave.
  var containers = []
  var seen = {}
  var items = []
  for (var i = 0; i < nodes.length; i++) {
    var n = nodes[i]
    var frame = n.isInterface ? null : nvFrameFor(n)
    if (frame && !seen[frame]) {
      seen[frame] = true
      containers.push({ id: 'frame:' + frame, parent: null, label: frame })
    }
    items.push({
      id: n.id,
      container: frame ? 'frame:' + frame : null,
      w: NV_W,
      h: nvNodeHeight(n)
    })
  }

  // In the relation view the rings ARE the grouping: a ranked container per hop
  // distance, so the columns read furthest-upstream to furthest-downstream. The
  // semantic frames would say something true but irrelevant here, and two
  // overlapping groupings of the same nodes cannot both be drawn.
  if (NV.relations && NV.ringContainers && NV.rings) {
    containers = NV.ringContainers
    for (var gi = 0; gi < items.length; gi++) {
      var ring = NV.rings.get(items[gi].id)
      items[gi].container = ring
        ? (ring.side === 'focus' ? 'ring:focus' : 'ring:' + ring.side + ':' + ring.hop)
        : null
    }
  }

  var out = window.__ctLayout({ nodes: items, links: links, containers: containers })

  var pos = {}
  for (var j = 0; j < out.nodes.length; j++) pos[out.nodes[j].id] = out.nodes[j]
  for (var k = 0; k < nodes.length; k++) {
    var p = pos[nodes[k].id]
    if (!p) continue
    nodes[k].x = p.x
    nodes[k].y = p.y
  }
  NV.frames = out.containers
}

// Only if the shared module failed to load: the original single-pass layering,
// kept so the tab still draws something rather than nothing.
function nvLayoutFallback (nodes, links) {
  var byId = {}
  for (var i = 0; i < nodes.length; i++) byId[nodes[i].id] = nodes[i]
  var depth = {}
  for (var i = 0; i < nodes.length; i++) depth[nodes[i].id] = 0
  for (var pass = 0; pass < nodes.length + 2; pass++) {
    var moved = false
    for (var l = 0; l < links.length; l++) {
      var a = links[l].from, b = links[l].to
      if (byId[a] === undefined || byId[b] === undefined) continue
      if (depth[b] < depth[a] + 1) { depth[b] = depth[a] + 1; moved = true }
    }
    if (!moved) break
  }
  var cols = {}
  for (var i = 0; i < nodes.length; i++) {
    var d = depth[nodes[i].id]
    if (!cols[d]) cols[d] = []
    cols[d].push(nodes[i])
  }
  var keys = Object.keys(cols).map(Number).sort(function (a, b) { return a - b })
  var x = 0
  for (var c = 0; c < keys.length; c++) {
    var col = cols[keys[c]]
    var y = 0
    for (var r = 0; r < col.length; r++) {
      col[r].x = x
      col[r].y = y
      y += nvNodeHeight(col[r]) + NV_ROW_GAP
    }
    x += NV_W + NV_COL_GAP
  }
}

function nvNodeHeight (n) {
  var rows = n.inputs.length + n.outputs.length
  var h = NV_HEAD + rows * NV_ROW + 8
  if (n.code) h += n.code.length * 9 + 8
  return Math.max(h, NV_HEAD + 16)
}

function nvSocketPos (n, side, idx) {
  // Outputs are listed first inside the node body, then inputs — Blender's order.
  var row = side === 'out' ? idx : n.outputs.length + idx
  return {
    x: n.x + (side === 'out' ? NV_W : 0),
    y: n.y + NV_HEAD + row * NV_ROW + NV_ROW / 2
  }
}

// ---- frames (Blender's label nodes) ----
//
// A frame is a labelled box drawn behind a set of nodes. Ours come from the
// semantic groupings the scan already carries: the categories declared in
// spec/SYSTEM.md, falling back to the most-shared dependency. That mapping is
// resolved once in scanner.js so the CLI and both views cannot disagree.
var NV_FRAME_COLORS = ['#58a6ff', '#3fb950', '#d2a8ff', '#f0883e', '#ff7b72',
                       '#ffa657', '#79c0ff', '#f778ba', '#56d364']

function nvFrameFor (n) {
  if (!NV.showFrames) return null
  var cats = tree.moduleCategories || {}
  if (n.kind === 'module') return cats[n.title] || null
  if (n.contained && n.contained.length > 0) {
    // A directory frames under the category its contents mostly belong to.
    var tally = {}
    for (var i = 0; i < n.contained.length; i++) {
      var c = cats[n.contained[i]]
      if (c) tally[c] = (tally[c] || 0) + 1
    }
    var best = null, bestN = 0
    for (var k in tally) if (tally[k] > bestN) { best = k; bestN = tally[k] }
    return best
  }
  return null
}

function nvDrawFrames (parent) {
  if (!NV.showFrames) return
  var frames = NV.frames || []
  for (var g = 0; g < frames.length; g++) {
    var box = frames[g]
    var isRing = String(box.id).slice(0, 5) === 'ring:'
    var color = isRing ? '#ffa657' : NV_FRAME_COLORS[g % NV_FRAME_COLORS.length]

    var r = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
    // Dashed, because a ring is a column of hop distance rather than part of the
    // structure — a solid box would read as one more semantic frame.
    r.setAttribute('class', isRing ? 'nv-frame nv-frame-ring' : 'nv-frame')
    r.setAttribute('x', box.x)
    r.setAttribute('y', box.y)
    r.setAttribute('width', box.w)
    r.setAttribute('height', box.h)
    r.setAttribute('rx', 8)
    r.setAttribute('fill', color)
    r.setAttribute('stroke', color)
    parent.appendChild(r)

    var t = document.createElementNS('http://www.w3.org/2000/svg', 'text')
    t.setAttribute('class', 'nv-frame-label')
    t.setAttribute('x', box.x + 8)
    t.setAttribute('y', box.y + 13)
    t.setAttribute('fill', color)
    t.textContent = box.label
    parent.appendChild(t)
  }
}

// ---- rendering ----

function nvRender () {
  var svg = document.getElementById('node-svg')
  if (!svg) return
  svg.innerHTML = '<defs></defs><g id="nv-world"><g id="nv-frames"></g><g id="nv-noodles"></g><g id="nv-nodes"></g></g>'
  var world = document.getElementById('nv-world')
  var frameG = document.getElementById('nv-frames')
  var noodleG = document.getElementById('nv-noodles')
  var nodeG = document.getElementById('nv-nodes')

  var byId = {}
  for (var i = 0; i < NV.nodes.length; i++) byId[NV.nodes[i].id] = NV.nodes[i]

  // Orthogonal routing, anchored to the actual SOCKETS: a wire leaves the socket
  // it belongs to, runs in the free channels and goes AROUND whatever is in the
  // way, rather than a bezier drawn straight through everything between.
  var routed = {}
  var junctions = []
  NV.linkEls = []
  NV.nodeEls = {}

  // Routing is expensive — A* per edge — and depends ONLY on the geometry, not
  // on what is selected or hovered. Recomputing it on every render made a
  // double-click run two full passes back to back and hang the view. Cache it
  // against a signature of the geometry and reuse it for highlight renders.
  var signature = null
  if (NV.circuit && window.__ctRoute) {
    var sigParts = [NV.links.length]
    for (var si2 = 0; si2 < NV.nodes.length; si2++) {
      var sn = NV.nodes[si2]
      sigParts.push(sn.id + ':' + Math.round(sn.x) + ':' + Math.round(sn.y))
    }
    signature = sigParts.join('|')
  }

  var hit = null
  for (var ci = 0; signature && ci < NV.routeCache.length; ci++) {
    if (NV.routeCache[ci].signature === signature) hit = NV.routeCache[ci]
  }
  if (hit) {
    routed = hit.routed
    junctions = hit.junctions
  } else if (NV.circuit && window.__ctRoute) {
    var boxes = []
    for (var bi = 0; bi < NV.nodes.length; bi++) {
      var bn = NV.nodes[bi]
      boxes.push({ id: bn.id, x: bn.x, y: bn.y, w: NV_W, h: nvNodeHeight(bn) })
    }
    var routeLinks = []
    for (var ri = 0; ri < NV.links.length; ri++) {
      var rl = NV.links[ri]
      var ra = byId[rl.from], rb = byId[rl.to]
      if (!ra || !rb) continue
      routeLinks.push({
        from: rl.from,
        to: rl.to,
        fromPoint: nvSocketPos(ra, rl.fromSide, rl.fromSock),
        toPoint: nvSocketPos(rb, rl.toSide, rl.toSock)
      })
    }
    // No grid: the router picks one from the work this board needs. Fixed at 40,
    // a 316-edge board took 55 SECONDS — which is what the hang was.
    var out = window.__ctRoute.routeEdges(boxes, routeLinks)
    for (var oi = 0; oi < out.routes.length; oi++) {
      var rt = out.routes[oi]
      var dparts = []
      for (var pi = 0; pi < rt.points.length; pi++) {
        dparts.push(rt.points[pi].x.toFixed(1) + ' ' + rt.points[pi].y.toFixed(1))
      }
      routed[rt.from + ' -> ' + rt.to] = 'M ' + dparts.join(' L ')
    }
    junctions = window.__ctRoute.findJunctions(out.routes)
    NV.routeCache.push({ signature: signature, routed: routed, junctions: junctions })
    while (NV.routeCache.length > 6) NV.routeCache.shift()
  }

  for (var l = 0; l < NV.links.length; l++) {
    var lk = NV.links[l]
    var a = byId[lk.from], b = byId[lk.to]
    if (!a || !b) continue
    var p1 = nvSocketPos(a, lk.fromSide, lk.fromSock)
    var p2 = nvSocketPos(b, lk.toSide, lk.toSock)
    var dx = Math.max(40, Math.abs(p2.x - p1.x) * 0.5)
    var path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
    path.setAttribute('d', routed[lk.from + ' -> ' + lk.to] || ('M ' + p1.x + ' ' + p1.y +
      ' C ' + (p1.x + dx) + ' ' + p1.y + ', ' + (p2.x - dx) + ' ' + p2.y +
      ', ' + p2.x + ' ' + p2.y))
    path.setAttribute('class', 'nv-noodle')
    path.setAttribute('stroke', '#5a6570')
    NV.linkEls.push({ el: path, from: lk.from, to: lk.to })
    noodleG.appendChild(path)
  }

  for (var ji = 0; ji < junctions.length; ji++) {
    var jc = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
    jc.setAttribute('class', 'nv-junction')
    jc.setAttribute('cx', junctions[ji].x)
    jc.setAttribute('cy', junctions[ji].y)
    jc.setAttribute('r', 3)
    noodleG.appendChild(jc)
  }

  nvDrawFrames(frameG)
  for (var i = 0; i < NV.nodes.length; i++) nvDrawNode(nodeG, NV.nodes[i])

  nvDepsPanel()

  // Dependencies of dependencies: clicking a node traces its WHOLE closure, not
  // one hop, with distance carried by colour. Same shared implementation the
  // floor uses, so both views agree about what "depends on" means.
  if (NV.selected && window.__ct) {
    // In the relation view the highlight comes from the RINGS. dependencyClosure
    // only walks upstream, so for a focus whose relations are all downstream it
    // matched nothing and the view rendered almost entirely dimmed. Everything
    // drawn here is related by construction, so nothing may be dimmed — and the
    // hop number, which is the information, is already computed.
    var closure = (NV.relations && NV.rings)
      ? nvRingHighlight(NV.rings, NV.links, NV.relations.depth)
      : window.__ct.dependencyClosure(NV.links, NV.selected)
    for (var li = 0; li < NV.linkEls.length; li++) {
      var le = NV.linkEls[li]
      var d = closure.edges.get(le.from + ' -> ' + le.to)
      if (d === undefined) {
        le.el.classList.add('cold')
      } else {
        le.el.classList.add('dep')
        le.el.setAttribute('stroke', window.__ct.depthColor(d, closure.maxDepth))
      }
    }
    for (var ni = 0; ni < NV.nodes.length; ni++) {
      var nn = NV.nodes[ni]
      var nd = closure.nodes.get(nn.id)
      if (nd === undefined) { if (nn.el) nn.el.classList.add('dim'); continue }
      if (nd === 0 && nn.el) nn.el.classList.add('selected')
      if (nd > 0 && nn.el) {
        var hops = document.createElementNS('http://www.w3.org/2000/svg', 'text')
        hops.setAttribute('class', 'nv-hops')
        hops.setAttribute('x', NV_W - 7)
        hops.setAttribute('y', nvNodeHeight(nn) - 5)
        hops.setAttribute('text-anchor', 'end')
        hops.setAttribute('fill', window.__ct.depthColor(nd, closure.maxDepth))
        hops.textContent = nd + (nd === 1 ? ' hop' : ' hops')
        nn.el.appendChild(hops)
      }
    }
  }

  nvApplyTransform()
  nvBreadcrumb()
  nvLegend()
}

function nvDrawNode (parent, n) {
  var h = nvNodeHeight(n)
  var g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
  g.setAttribute('class', 'nv-node' + (n.isInterface ? ' nv-group-node' : ''))
  g.setAttribute('transform', 'translate(' + n.x + ',' + n.y + ')')

  var body = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
  body.setAttribute('class', 'nv-node-body')
  body.setAttribute('width', NV_W)
  body.setAttribute('height', h)
  body.setAttribute('rx', 5)
  g.appendChild(body)

  var head = document.createElementNS('http://www.w3.org/2000/svg', 'path')
  head.setAttribute('class', 'nv-node-head')
  head.setAttribute('d', 'M 0 5 a 5 5 0 0 1 5 -5 h ' + (NV_W - 10) +
    ' a 5 5 0 0 1 5 5 v ' + (NV_HEAD - 5) + ' h ' + (-NV_W) + ' z')
  head.setAttribute('fill', nvRoleColor(n.role))
  g.appendChild(head)

  var title = document.createElementNS('http://www.w3.org/2000/svg', 'text')
  title.setAttribute('class', 'nv-node-title')
  title.setAttribute('x', 7)
  title.setAttribute('y', 14)
  var shortT = n.title.length > 26 ? n.title.slice(0, 25) + '\\u2026' : n.title
  title.textContent = shortT
  g.appendChild(title)

  if (n.sub) {
    var sub = document.createElementNS('http://www.w3.org/2000/svg', 'text')
    sub.setAttribute('class', 'nv-node-sub')
    sub.setAttribute('x', NV_W - 7)
    sub.setAttribute('y', 14)
    sub.setAttribute('text-anchor', 'end')
    sub.textContent = n.sub
    g.appendChild(sub)
  }

  var row = 0
  for (var o = 0; o < n.outputs.length; o++) { nvDrawSocket(g, n, n.outputs[o], 'out', row); row++ }
  for (var i2 = 0; i2 < n.inputs.length; i2++) { nvDrawSocket(g, n, n.inputs[i2], 'in', row); row++ }

  if (n.code) {
    var top = NV_HEAD + row * NV_ROW + 6
    for (var c = 0; c < n.code.length; c++) {
      var ln = document.createElementNS('http://www.w3.org/2000/svg', 'text')
      ln.setAttribute('class', 'nv-lineno')
      ln.setAttribute('x', 5)
      ln.setAttribute('y', top + c * 9 + 7)
      ln.textContent = (n.line + c)
      g.appendChild(ln)
      var t = document.createElementNS('http://www.w3.org/2000/svg', 'text')
      t.setAttribute('class', 'nv-code')
      t.setAttribute('x', 26)
      t.setAttribute('y', top + c * 9 + 7)
      var src = n.code[c].replace(/\\t/g, '  ')
      if (src.length > 44) src = src.slice(0, 43) + '\\u2026'
      t.textContent = src
      g.appendChild(t)
    }
  }

  if (n.openable) {
    head.style.cursor = 'pointer'
    title.style.cursor = 'pointer'
    var openIt = function (e) { e.stopPropagation(); nvEnter(n.open) }
    head.addEventListener('click', openIt)
    title.addEventListener('click', openIt)
  }

  // Hover traces this node's own wires; click keeps its whole dependency depth
  // on screen until you click it again or hit Escape.
  g.addEventListener('mouseenter', function () {
    NV.hover = n.id
    if (!NV.selected) nvFocus(n)
  })
  g.addEventListener('mouseleave', function () {
    if (NV.hover === n.id) NV.hover = null
    if (!NV.selected) nvUnfocus()
  })
  g.addEventListener('click', function (e) {
    e.stopPropagation()
    NV.selected = NV.selected === n.id ? null : n.id
    nvRender()
  })

  parent.appendChild(g)
  n.el = g
  NV.nodeEls[n.id] = g
}

/**
 * The selected node's FULL dependency list.
 *
 * The graph can only draw an edge when both ends are nodes on screen, and most
 * dependencies are not: measured on this scan, 684 dependencies are declared and
 * only 105 have both ends in the scan. A module like atproto-identity declares
 * ten and has NONE in-scan, so the drawing alone says it depends on nothing.
 * This panel says what the edges cannot.
 */
function nvDepsPanel () {
  var panel = document.getElementById('nv-deps')
  if (!panel) return
  var node = null
  for (var i = 0; i < NV.nodes.length; i++) if (NV.nodes[i].id === NV.selected) node = NV.nodes[i]
  if (!node || node.kind !== 'module' || !tree.modules[node.title]) {
    panel.className = ''
    panel.innerHTML = ''
    return
  }

  var mod = tree.modules[node.title]
  var direct = Object.keys(mod.dependencies || {}).sort()
  var inScan = {}
  for (var m = 0; m < tree.system.modules.length; m++) inScan[tree.system.modules[m]] = true

  var html = '<h4>' + escapeHtml(node.title) + ' dependencies</h4>'
  html += '<div>' + direct.length + ' declared &middot; ' +
    direct.filter(function (d) { return inScan[d] }).length + ' drawn as edges</div>'

  html += '<div class="grp">direct</div>'
  for (var d = 0; d < direct.length; d++) {
    var isIn = !!inScan[direct[d]]
    html += '<div class="row"><span' + (isIn ? '' : ' class="ext"') + '>' +
      escapeHtml(direct[d]) + '</span><span class="hops">' +
      (isIn ? 'in scan' : 'external') + '</span></div>'
  }

  // Transitive, through the modules the scan actually has.
  if (window.__ct) {
    var closure = window.__ct.dependencyClosure(NV.links, NV.selected)
    var rows = []
    for (var j = 0; j < NV.nodes.length; j++) {
      var other = NV.nodes[j]
      var depth = closure.nodes.get(other.id)
      if (depth === undefined || depth < 2) continue
      rows.push({ name: other.title, depth: depth })
    }
    rows.sort(function (a, b) { return a.depth - b.depth || (a.name < b.name ? -1 : 1) })
    html += '<div class="grp">through those (' + rows.length + ')</div>'
    if (rows.length === 0) html += '<div class="ext">none - every dependency is direct</div>'
    for (var r = 0; r < rows.length; r++) {
      html += '<div class="row"><span>' + escapeHtml(rows[r].name) +
        '</span><span class="hops">' + rows[r].depth + ' hops</span></div>'
    }
  }

  panel.innerHTML = html
  panel.className = 'open'
}

function nvFocus (node) {
  var connected = {}
  connected[node.id] = true
  for (var i = 0; i < NV.linkEls.length; i++) {
    var le = NV.linkEls[i]
    var hot = le.from === node.id || le.to === node.id
    le.el.classList.toggle('hot', hot)
    le.el.classList.toggle('cold', !hot)
    if (hot) connected[le.from === node.id ? le.to : le.from] = true
  }
  for (var id in NV.nodeEls) {
    NV.nodeEls[id].classList.toggle('dim', !connected[id])
  }
}

function nvUnfocus () {
  for (var i = 0; i < NV.linkEls.length; i++) {
    NV.linkEls[i].el.classList.remove('hot', 'cold')
  }
  for (var id in NV.nodeEls) NV.nodeEls[id].classList.remove('dim')
}

function nvDrawSocket (g, n, sock, side, row) {
  var y = NV_HEAD + row * NV_ROW + NV_ROW / 2
  var x = side === 'out' ? NV_W : 0
  var c = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
  c.setAttribute('cx', x)
  c.setAttribute('cy', y)
  c.setAttribute('r', 4)
  c.setAttribute('fill', sock.mated ? nvSocketColor(sock.scope, true) : '#1a1b1e')
  c.setAttribute('stroke', nvSocketColor(sock.scope, sock.mated))
  c.setAttribute('stroke-width', '1.5')
  g.appendChild(c)

  var t = document.createElementNS('http://www.w3.org/2000/svg', 'text')
  t.setAttribute('class', 'nv-sock-label' + (sock.mated ? '' : ' unmated'))
  t.setAttribute('y', y + 3)
  if (side === 'out') {
    t.setAttribute('x', NV_W - 9)
    t.setAttribute('text-anchor', 'end')
  } else {
    t.setAttribute('x', 9)
  }
  var lbl = sock.name
  if (lbl.length > 24) lbl = lbl.slice(0, 23) + '\\u2026'
  t.textContent = lbl
  g.appendChild(t)

  var title = document.createElementNS('http://www.w3.org/2000/svg', 'title')
  title.textContent = (side === 'out' ? 'supplies ' : 'demands ') + sock.name +
    ' [' + sock.scope + ']' + (sock.mated ? '' : ' — UNMATED')
  c.appendChild(title)
}

// ---- navigation ----

function nvEnter (level) {
  if (!level) return
  NV.stack.push(level)
  nvBuild()
}

function nvGoto (depth) {
  NV.stack = NV.stack.slice(0, depth + 1)
  nvBuild()
}

function nvBreadcrumb () {
  var bc = document.getElementById('node-breadcrumb')
  if (!bc) return
  var html = ''
  for (var i = 0; i < NV.stack.length; i++) {
    if (i > 0) html += '<span class="sep">&rsaquo;</span>'
    var last = i === NV.stack.length - 1
    html += '<a href="#" data-d="' + i + '"' + (last ? ' class="current"' : '') + '>' +
      escapeHtml(NV.stack[i].label) + '</a>'
  }
  bc.innerHTML = html
  var as = bc.querySelectorAll('a')
  for (var i = 0; i < as.length; i++) {
    as[i].addEventListener('click', function (e) {
      e.preventDefault()
      nvGoto(parseInt(this.getAttribute('data-d'), 10))
    })
  }
}

/**
 * Enter the relation view, leave it, or move its depth.
 *
 * Clamped at 1 hop: 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, so [ walks back in and then out.
 *
 * This goes through nvBuild, not nvLayout: the view changes WHICH nodes exist,
 * and leaving it has to bring back the ones it dropped.
 */
function nvRelate (id, depth) {
  if (!id || depth < 1) {
    if (!NV.relations) return
    NV.relations = null
  } else {
    NV.relations = { id: id, depth: Math.min(depth, 8) }
    NV.selected = id
  }
  nvBuild()
}

function nvLegend () {
  var el = document.getElementById('node-legend')
  if (!el) return
  var lvl = NV.stack[NV.stack.length - 1]
  var what = lvl.kind === 'root' ? 'modules'
    : lvl.kind === 'module' ? 'files in ' + lvl.name
    : lvl.kind === 'file' ? 'symbols in ' + lvl.path
    : ''
  el.innerHTML =
    '<b>' + NV.nodes.length + '</b> nodes &middot; <b>' + NV.links.length + '</b> noodles &mdash; ' + escapeHtml(what) +
    '<br><span style="color:#7ee787">&#9679;</span> mated &nbsp;' +
    '<span style="color:#6e7076">&#9675;</span> unmated &nbsp;' +
    '<span style="color:#f85149">&#9675;</span> unresolved' +
    '<br>left sockets demand &middot; right sockets supply' +
    '<br>hover traces a node &middot; click for its full dependency depth' +
    '<br><b>G</b> shows only what it connects to &middot; <b>[ ]</b> how far &middot; ' +
    '<b>F</b> frames &middot; <b>R</b> routing &middot; <b>O</b> only connected &middot; <b>Esc</b>' +
    (NV.relations && NV.hidden
      ? '<br><span style="color:#ffa657">RELATIONS within ' + NV.relations.depth +
        (NV.relations.depth === 1 ? ' hop' : ' hops') + ' &mdash; hiding ' +
        NV.hidden.nodes + ' nodes, ' + NV.hidden.links + ' links</span>'
      : '') +
    (NV.note ? '<br><span style="color:#f0883e">' + escapeHtml(NV.note) + '</span>' : '') +
    '<br><label style="pointer-events:auto;cursor:pointer;color:#c9d1d9">' +
    '<input type="checkbox" id="nv-only-connected"' + (NV.onlyConnected ? ' checked' : '') +
    ' style="vertical-align:-1px;accent-color:#58a6ff"> collapse to only connected</label>' +
    '<br><label style="pointer-events:auto;cursor:pointer;color:#c9d1d9">' +
    '<input type="checkbox" id="nv-frames"' + (NV.showFrames ? ' checked' : '') +
    ' style="vertical-align:-1px;accent-color:#58a6ff"> semantic frames</label>' +
    '<br><label style="pointer-events:auto;cursor:pointer;color:#c9d1d9">' +
    '<input type="checkbox" id="nv-circuit"' + (NV.circuit ? ' checked' : '') +
    ' style="vertical-align:-1px;accent-color:#58a6ff"> circuit routing</label>'

  var cb = document.getElementById('nv-only-connected')
  if (cb) cb.addEventListener('change', function () { nvToggleConnected(this.checked) })
  var fb = document.getElementById('nv-frames')
  if (fb) fb.addEventListener('change', function () { nvToggleFrames(this.checked) })
  var cb2 = document.getElementById('nv-circuit')
  if (cb2) cb2.addEventListener('change', function () { NV.circuit = this.checked; nvRender() })
}

// ---- build per level ----

function nvBuild () {
  var lvl = NV.stack[NV.stack.length - 1]
  NV.nodes = []
  NV.links = []

  if (lvl.kind === 'path') {
    var pn = nvPathNodes(lvl.path)
    var pl = nvPathLinks(pn)
    var filtered = nvSlice(pn, pl)
    NV.nodes = filtered.nodes
    NV.links = filtered.links
    nvLayout(NV.nodes, NV.links)
    nvRender()
    return
  }

  if (lvl.kind === 'module') {
    fetch(API + '/api/sections?module=' + encodeURIComponent(lvl.name))
      .then(function (r) { return r.json() })
      .then(function (data) {
        if (!data || !data.sections) { NV.nodes = []; nvRender(); return }
        var built = nvFileNodes(data)
        var iface = nvGroupInterface(lvl, built.nodes)
        var all = built.nodes.slice()
        if (iface.gin) all.unshift(iface.gin)
        if (iface.gout) all.push(iface.gout)
        var fl = nvFileLinks(built.nodes).concat(iface.links)
        var col = nvSlice(all, fl)
        NV.nodes = col.nodes
        NV.links = col.links
        nvLayout(NV.nodes, NV.links)
        nvRender()
      })
      .catch(function () { NV.nodes = []; nvRender() })
    return
  }

  if (lvl.kind === 'file') {
    var syms = nvSymbolNodes(lvl.module, lvl.path)
    var iface = nvGroupInterface(lvl, syms)
    var all = syms.slice()
    if (iface.gin) all.unshift(iface.gin)
    if (iface.gout) all.push(iface.gout)
    var sl = nvSlice(all, iface.links)
    NV.nodes = sl.nodes
    NV.links = sl.links
    nvLayout(NV.nodes, NV.links)
    nvRender()
    // The bottom of the ladder: fetch the real source and put it in the nodes.
    nvLoadSource(lvl, syms)
    return
  }
}

// Read the file once and slice each symbol's own lines out of it, using the
// range the parser recorded. This is what makes the lowest nodes show code.
function nvLoadSource (lvl, syms) {
  var mod = tree.modules[lvl.module]
  if (!mod) return
  var full = (mod.path && mod.path !== '.') ? mod.path + '/' + lvl.path : lvl.path
  var cached = NV.sourceCache[full]

  function apply (lines) {
    for (var i = 0; i < syms.length; i++) {
      var s = syms[i]
      if (!s.line) continue
      var end = s.endLine && s.endLine >= s.line ? s.endLine : s.line
      if (end - s.line > 13) end = s.line + 13
      s.code = lines.slice(s.line - 1, end)
    }
    nvLayout(NV.nodes, NV.links)
    nvRender()
  }

  if (cached) { apply(cached); return }
  fetch(API + '/api/file?path=' + encodeURIComponent(full))
    .then(function (r) { return r.json() })
    .then(function (d) {
      if (!d || d.error || !d.content) return
      var lines = d.content.split('\\n')
      NV.sourceCache[full] = lines
      apply(lines)
    })
    .catch(function () {})
}

// ---- pan / zoom ----

function nvApplyTransform () {
  var w = document.getElementById('nv-world')
  if (!w) return
  w.setAttribute('transform', 'translate(' + NV.panX.toFixed(1) + ',' + NV.panY.toFixed(1) +
    ') scale(' + NV.zoom.toFixed(3) + ')')
}

function nvInit () {
  if (NV.initialized) return
  NV.initialized = true
  var svg = document.getElementById('node-svg')

  svg.addEventListener('wheel', function (e) {
    e.preventDefault()
    var f = e.deltaY > 0 ? 0.9 : 1.1
    var nz = Math.max(0.08, Math.min(4, NV.zoom * f))
    var r = svg.getBoundingClientRect()
    var mx = e.clientX - r.left, my = e.clientY - r.top
    NV.panX = mx - (mx - NV.panX) * (nz / NV.zoom)
    NV.panY = my - (my - NV.panY) * (nz / NV.zoom)
    NV.zoom = nz
    nvApplyTransform()
  }, { passive: false })

  svg.addEventListener('click', function (e) {
    if (e.target === svg || e.target.id === 'nv-world') {
      if (NV.selected) { NV.selected = null; nvRender() }
    }
  })

  document.addEventListener('keydown', function (e) {
    // The page has a search box and a path field. Without this guard every one of
    // these letters fires while you are typing.
    var t = e.target
    if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
    if (e.metaKey || e.ctrlKey || e.altKey) return
    // These are the node view's keys; they must not fire while another tab is up.
    if (typeof currentTab !== 'undefined' && currentTab !== 'nodes') return

    NV.note = null
    if (e.key === 'g' || e.key === 'G') {
      // Hover counts, not just selection. A node's HEAD and TITLE open it — that
      // is the obvious thing to click — so only the body selects, and requiring a
      // selection made G look broken.
      var target = NV.relations ? null : (NV.selected || NV.hover)
      if (!NV.relations && !target) {
        NV.note = 'point at a node, or click its body, then press G'
        nvLegend()
        return
      }
      nvRelate(target, NV.relations ? 0 : 1)
    } else if (e.key === ']' || e.key === '}') {
      if (NV.relations) nvRelate(NV.relations.id, NV.relations.depth + 1)
    } else if (e.key === '[' || e.key === '{') {
      if (NV.relations) nvRelate(NV.relations.id, NV.relations.depth - 1)
    } else if (e.key === 'f' || e.key === 'F') {
      NV.showFrames = !NV.showFrames
      nvRender()
    } else if (e.key === 'r' || e.key === 'R') {
      NV.circuit = !NV.circuit
      nvRender()
    } else if (e.key === 'o' || e.key === 'O') {
      window.nvToggleConnected(!NV.onlyConnected)
    } else if (e.key === 'Escape') {
      if (NV.relations) { nvRelate(null, 0); return }
      if (NV.selected) { NV.selected = null; nvRender() }
    }
  })

  svg.addEventListener('mousedown', function (e) {
    NV.panning = true
    NV.panSX = e.clientX - NV.panX
    NV.panSY = e.clientY - NV.panY
    svg.classList.add('dragging')
  })
  document.addEventListener('mousemove', function (e) {
    if (!NV.panning) return
    NV.panX = e.clientX - NV.panSX
    NV.panY = e.clientY - NV.panSY
    nvApplyTransform()
  })
  document.addEventListener('mouseup', function () {
    NV.panning = false
    svg.classList.remove('dragging')
  })

  nvBuild()

  // Deep-linkable, like every other view here, and the only way to review a
  // selection without a mouse: ?select=<node title>.
  var params = new URLSearchParams(location.search)
  var want = params.get('select')
  if (want) {
    for (var si = 0; si < NV.nodes.length; si++) {
      if (NV.nodes[si].title === want) { NV.selected = NV.nodes[si].id; break }
    }
    if (NV.selected) nvRender()
  }
  // ?relations=<depth> opens straight into the relation view, so it is a link
  // rather than something you reproduce by hand.
  var wantRel = params.get('relations')
  if (wantRel && NV.selected) {
    nvRelate(NV.selected, Math.max(1, Math.min(8, Number(wantRel) || 1)))
  }
}
`