/**
* 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 · scroll to zoom · 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 · ' +
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">›</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 · <b>' + NV.links.length + '</b> noodles — ' + escapeHtml(what) +
'<br><span style="color:#7ee787">●</span> mated ' +
'<span style="color:#6e7076">○</span> unmated ' +
'<span style="color:#f85149">○</span> unresolved' +
'<br>left sockets demand · right sockets supply' +
'<br>hover traces a node · click for its full dependency depth' +
'<br><b>G</b> shows only what it connects to · <b>[ ]</b> how far · ' +
'<b>F</b> frames · <b>R</b> routing · <b>O</b> only connected · <b>Esc</b>' +
(NV.relations && NV.hidden
? '<br><span style="color:#ffa657">RELATIONS within ' + NV.relations.depth +
(NV.relations.depth === 1 ? ' hop' : ' hops') + ' — 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)))
}
}
`