/**
* Cards — what a level of the ladder shows, derived from the model.
*
* Pure: model in, cards out. No DOM, no view state, no pixel sizes, so node can
* import it directly and the browser can import the same file over /floor/cards.js.
* It was inside level.js and therefore untestable, guarded only by a parse gate.
*
* Both rungs produce the SAME shape — { cards, links, containers } — which is
* what lets one layout and one renderer serve both (spec section 3).
*
* A card is:
* id the model id it came from
* kind what it IS, semantically: function | class | const | module-body |
* op | call | exit | nested. The view maps kind to colour; this
* module has no opinion about pixels.
* title the big label
* badge the small right-hand label
* sig one line under the title
* sub counts and position
* container the zone that holds it, or null
* code how to find its source: { from, to } or { sparse: true, container }
* openTo the symbol NAME this card descends into, or null
*/
/**
* The system level: one card per module, grouped into CONTAINERS by category.
*
* The categories were drawn as frames after layout in the old view, which is how
* they came to overlap. Here they are containers, so the layout places them as
* whole rectangles and they cannot interleave.
*/
export function buildSystemLevel (tree) {
const categories = tree.moduleCategories || {}
const used = new Set()
const cards = (tree.system.modules || []).map(name => {
const mod = tree.modules[name] || {}
const category = categories[name] || null
if (category) used.add(category)
const deps = Object.keys(mod.dependencies || {})
const api = mod.publicAPI || []
return {
id: 'module:' + name,
kind: 'module',
title: name,
badge: mod.version || '',
sig: api.length > 0 ? api.slice(0, 4).join(', ') + (api.length > 4 ? ', ...' : '') : '',
sub: Object.keys(mod.files || {}).length + ' files · ' + deps.length + ' deps · ' +
api.length + ' exports',
container: category ? 'cat:' + category : null,
code: null,
span: null,
openTo: name
}
})
const containers = Array.from(used).sort().map(c => ({
id: 'cat:' + c, parent: null, label: c, shape: 'category', depth: 1
}))
// Only edges between modules the scan actually has. An external dependency is
// not a card here, so an edge to it would point at nothing.
const present = new Set(cards.map(c => c.id))
const links = []
for (const [name, deps] of Object.entries(tree.system.dependencies || {})) {
for (const dep of deps) {
const from = 'module:' + dep
const to = 'module:' + name
if (present.has(from) && present.has(to) && from !== to) links.push({ from, to })
}
}
return { cards, links, containers }
}
/**
* The module level: one card per file, grouped into CONTAINERS by directory.
*
* Directories nest, so the containers nest — lib/handlers sits inside lib — which
* is the same machinery zones use one rung further down.
*/
export function buildModuleLevel (tree, moduleName) {
const mod = (tree.modules || {})[moduleName]
if (!mod) return { cards: [], links: [], containers: [] }
const dirs = new Set()
const cards = Object.entries(mod.files || {}).map(([path, file]) => {
const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : null
if (dir) {
// Register every ancestor, so lib/handlers implies lib.
const parts = dir.split('/')
for (let i = 1; i <= parts.length; i++) dirs.add(parts.slice(0, i).join('/'))
}
const exports = file.exports || []
return {
id: 'file:' + moduleName + ':' + path,
kind: 'file',
title: path.includes('/') ? path.slice(path.lastIndexOf('/') + 1) : path,
badge: file.role || 'lib',
sig: exports.length > 0
? exports.map(e => e.name).slice(0, 4).join(', ') + (exports.length > 4 ? ', ...' : '')
: '',
sub: (file.imports || []).length + ' imports · ' + exports.length + ' exports · ' +
(file.functions || []).length + ' functions',
container: dir ? 'dir:' + dir : null,
code: null,
span: null,
openTo: mod.path && mod.path !== '.' ? mod.path + '/' + path : path
}
})
const containers = Array.from(dirs).sort().map(dir => {
const parent = dir.includes('/') ? dir.slice(0, dir.lastIndexOf('/')) : null
return {
id: 'dir:' + dir,
parent: parent ? 'dir:' + parent : null,
label: dir + '/',
shape: 'directory',
depth: dir.split('/').length
}
})
// `from` imports `to`, so the supplier is on the left, matching every other
// level's reading direction.
const present = new Set(cards.map(c => c.id))
const links = []
const seen = new Set()
for (const edge of (mod.importGraph && mod.importGraph.edges) || []) {
const from = 'file:' + moduleName + ':' + edge.to
const to = 'file:' + moduleName + ':' + edge.from
if (!present.has(from) || !present.has(to) || from === to) continue
const key = from + '->' + to
if (seen.has(key)) continue
seen.add(key)
links.push({ from, to })
}
return { cards, links, containers }
}
/** The file level: one card per symbol (L1), including the module body (L2). */
export function buildFileLevel (model) {
const cards = (model.symbols || []).map(sym => ({
id: sym.id,
kind: sym.kind,
title: sym.name,
badge: sym.kind,
sig: signatureOf(model, sym),
sub: interiorCounts(model, sym) + ' L' + sym.range.startLine + '-' + sym.range.endLine,
container: null,
// The module body's RANGE is the whole file — it IS the program — so a
// contiguous slice would dump every other symbol's body with it. Its own
// code is the lines its operations and wires actually sit on.
code: sym.kind === 'module-body'
? { sparse: true, container: sym.fn }
: { from: sym.range.startLine, to: sym.range.endLine },
span: sym.range,
openTo: sym.fn ? sym.name : null
}))
return { cards, links: fileLinks(model), containers: [] }
}
/**
* The symbol level: the expression floor for one symbol.
*
* Membership is by CONTAINER, never by range (L3/E4b) — an operation inside a
* nested closure is also inside this symbol's range, and claiming it here would
* put it in the wrong node.
*/
export function buildSymbolLevel (model, sym) {
const fnId = sym.fn
const cards = []
const zones = (model.zones || []).filter(z => z.container === fnId)
const zoneIds = new Set(zones.map(z => z.id))
const containers = zones.map(z => ({
id: z.id,
parent: zoneIds.has(z.parent) ? z.parent : null,
label: zoneLabel(z),
shape: z.shape,
depth: z.depth
}))
// A declaration names its producing operation, so the value is recognisable
// downstream by the name the code gave it (R3).
const nameOfNode = new Map()
for (const n of model.names || []) if (n.node) nameOfNode.set(n.node, n.name)
for (const op of model.nodes || []) {
if (op.container !== fnId) continue
const named = nameOfNode.get(op.id)
cards.push({
id: op.id,
kind: op.kind === 'CallExpression' ? 'call' : 'op',
title: op.label,
badge: named ? '-> ' + named : shortKind(op.kind),
sig: op.inputs.map(describeInput).join(', '),
sub: 'L' + op.range.startLine + (op.writes ? ' writes ' + op.writes : ''),
container: zoneIds.has(op.zone) ? op.zone : null,
code: { from: op.range.startLine, to: op.range.endLine },
span: op.range,
openTo: null
})
}
// A nested function is a node you can open in turn — Blender's nested groups,
// falling out of the data rather than being invented.
for (const fn of model.functions || []) {
if (fn.parent !== fnId) continue
cards.push({
id: fn.id,
kind: 'nested',
title: fn.name,
badge: 'nested fn',
sig: fn.name + ' (' + fn.params.join(', ') + ')',
sub: (model.nodes || []).filter(n => n.container === fn.id).length + ' ops · L' +
fn.range.startLine + '-' + fn.range.endLine,
container: innermostZoneOf(zones, fn.range),
code: { from: fn.range.startLine, to: fn.range.endLine },
span: fn.range,
openTo: null
})
}
// An exit with no operation of its own — break, continue — would otherwise be
// invisible, even though it is what gives its zone the shape it has.
for (const ex of model.exits || []) {
if (ex.container !== fnId || ex.node) continue
cards.push({
id: ex.id,
kind: 'exit',
title: ex.kind,
badge: 'exit',
sig: ex.lands ? String(ex.lands.on) : '',
sub: 'L' + ex.range.startLine,
container: zoneIds.has(ex.zone) ? ex.zone : null,
code: { from: ex.range.startLine, to: ex.range.endLine },
span: ex.range,
openTo: null
})
}
const present = new Set(cards.map(c => c.id))
const links = []
for (const op of model.nodes || []) {
if (!present.has(op.id)) continue
for (const i of op.inputs) {
if (i.source === 'node' && present.has(i.node)) links.push({ from: i.node, to: op.id })
}
}
return { cards, links, containers }
}
/**
* Everything a node depends on, transitively, with the distance to each.
*
* Clicking a module should answer "what does this actually pull in", not just
* "what does it name in its package.json" — a one-hop view hides that a module
* two steps away is the thing really being depended on.
*
* Links read supplier -> consumer at every level, so DEPENDENCIES are found by
* walking BACKWARD (edges whose `to` is the node) and DEPENDENTS by walking
* forward. Breadth-first, so the depth recorded is the SHORTEST path, and a
* cycle simply stops rather than spinning.
*
* Returns { nodes: Map(id -> depth), edges: Map('from -> to' -> depth) } with
* the start node at depth 0.
*/
export function dependencyClosure (links, startId, direction = 'dependencies') {
const forward = direction === 'dependents'
const adjacency = new Map()
for (const l of links) {
const key = forward ? l.from : l.to
const next = forward ? l.to : l.from
if (!adjacency.has(key)) adjacency.set(key, [])
adjacency.get(key).push({ next, edge: l.from + ' -> ' + l.to })
}
const nodes = new Map([[startId, 0]])
const edges = new Map()
let frontier = [startId]
let depth = 0
while (frontier.length > 0) {
depth++
const nextFrontier = []
for (const id of frontier) {
for (const step of adjacency.get(id) || []) {
if (!edges.has(step.edge)) edges.set(step.edge, depth)
if (nodes.has(step.next)) continue
nodes.set(step.next, depth)
nextFrontier.push(step.next)
}
}
frontier = nextFrontier
}
return { nodes, edges, maxDepth: Math.max(0, ...nodes.values()) }
}
/**
* Everything within `depth` hops of a node, with the SIDE it sits on.
*
* Breadth-first and undirected, so the hop is the shortest path and a cycle stops
* rather than spinning — but each node also records which way the FIRST step out
* of the focus went. Links read supplier -> consumer everywhere here, so `from`
* is a dependency and `to` is a dependent:
*
* side 'up' reached by walking towards what the focus depends on
* side 'down' reached by walking towards what depends on the focus
* side 'focus' the node itself
*
* A purely directional walk would miss siblings: with up1 supplying both the
* focus and `sib`, sib is not upstream OF the focus nor downstream of it, yet it
* is plainly related — it shares a supplier. Undirected search finds it at two
* hops, and inheriting the first step's side says HOW: two hops out on the
* upstream side. That is true, and it keeps every node in exactly one column.
*
* Upstream wins a tie at equal hop, because "what does this depend on" is the
* question people ask first, and something has to break it deterministically.
*
* Returns Map(id -> { hop, side }).
*/
export function relationRings (links, focusId, depth) {
const adjacency = new Map()
const join = (from, to, side) => {
if (!adjacency.has(from)) adjacency.set(from, [])
adjacency.get(from).push({ next: to, side })
}
for (const l of links) {
join(l.to, l.from, 'up') // l.from supplies l.to, so it is l.to's dependency
join(l.from, l.to, 'down')
}
const rings = new Map([[focusId, { hop: 0, side: 'focus' }]])
let frontier = [focusId]
for (let hop = 1; hop <= depth && frontier.length > 0; hop++) {
const next = []
// Two sweeps per ring, upstream first, so a node reachable both ways at this
// hop lands upstream regardless of the order the links happened to be in.
for (const want of ['up', 'down']) {
for (const id of frontier) {
const from = rings.get(id)
for (const step of adjacency.get(id) || []) {
const side = from.side === 'focus' ? step.side : from.side
if (step.side !== want && from.side === 'focus') continue
if (rings.has(step.next)) continue
rings.set(step.next, { hop, side })
next.push(step.next)
}
}
}
frontier = next
}
return rings
}
/**
* The relation view: the focus and what it is connected to, and nothing else.
*
* Everything outside the rings is REMOVED, not dimmed. That is the point of the
* mode — the question is "how is this connected", and 55 unrelated modules are
* the noise that buries the answer. The cost is real, so `hidden` carries the
* counts and the view states them rather than pretending to be the whole graph.
*
* Each ring is a container with an explicit `rank`, so the columns read left to
* right as furthest-upstream -> focus -> furthest-downstream. Ranking them beats
* letting the layout infer columns from edges: a sibling two hops out on the
* upstream side is not a transitive supplier of the focus, so edge layering would
* put it wherever its own edges fell, and the column would stop meaning distance.
*/
export function buildRelationView (built, focusId, depth) {
const rings = relationRings(built.links, focusId, depth)
const present = new Set(built.cards.map(c => c.id))
if (!present.has(focusId)) {
return { cards: [], links: [], containers: [], rings: new Map(), hidden: { nodes: 0, links: 0 } }
}
for (const id of [...rings.keys()]) if (!present.has(id)) rings.delete(id)
// How many of a card's own links point out of the view. Without this the slice
// looks like the whole truth about that node.
const offView = new Map()
let hiddenLinks = 0
for (const l of built.links) {
const a = rings.has(l.from)
const b = rings.has(l.to)
if (a && b) continue
hiddenLinks++
if (a) offView.set(l.from, (offView.get(l.from) || 0) + 1)
if (b) offView.set(l.to, (offView.get(l.to) || 0) + 1)
}
const ringIdOf = (r) => (r.side === 'focus' ? 'ring:focus' : 'ring:' + r.side + ':' + r.hop)
const cards = []
for (const c of built.cards) {
const r = rings.get(c.id)
if (!r) continue
const n = offView.get(c.id) || 0
cards.push({
...c,
container: ringIdOf(r),
sub: n > 0 ? (c.sub ? c.sub + ' · ' : '') + n + ' off-view' : c.sub
})
}
const links = built.links.filter(l => rings.has(l.from) && rings.has(l.to))
// Upstream rings count down to the focus, downstream rings count up from it, so
// the leftmost column is the furthest dependency.
let maxUp = 0
for (const r of rings.values()) if (r.side === 'up' && r.hop > maxUp) maxUp = r.hop
const used = new Map()
for (const r of rings.values()) used.set(ringIdOf(r), r)
const containers = [...used.entries()].map(([id, r]) => ({
id,
parent: null,
label: r.side === 'focus'
? 'focus'
: r.hop + (r.hop === 1 ? ' hop ' : ' hops ') + (r.side === 'up' ? 'upstream' : 'downstream'),
shape: 'ring',
depth: 1,
rank: r.side === 'focus' ? maxUp : r.side === 'up' ? maxUp - r.hop : maxUp + r.hop
})).sort((a, b) => a.rank - b.rank || (a.id < b.id ? -1 : 1))
return {
cards,
links,
containers,
rings,
hidden: { nodes: built.cards.length - cards.length, links: hiddenLinks }
}
}
/**
* A colour for an edge at distance `depth` out of `maxDepth`.
*
* Near edges are hot and far ones cool, so how far out a dependency sits is
* readable without counting hops.
*/
export function depthColor (depth, maxDepth) {
if (depth <= 0) return '#ffa657'
const span = Math.max(1, maxDepth)
const t = Math.min(1, (depth - 1) / span)
// Amber -> red -> pink -> violet, going DOWN through 0 rather than up through
// green: a ramp that passes through green reads as "near and healthy" in the
// middle, which is the opposite of what distance should feel like.
const hue = (35 - t * 95 + 360) % 360
const sat = 85 - t * 20
const light = 62 - t * 10
return 'hsl(' + hue.toFixed(0) + ', ' + sat.toFixed(0) + '%, ' + light.toFixed(0) + '%)'
}
/**
* Collapse member-to-member edges into one edge per container pair.
*
* At real scale the member edges are the problem: 56 modules in 15 categories
* draw 105 crossing curves, and a 58-file module draws 316. Bundled, the same
* information is a handful of edges saying WHICH groups depend on which, with a
* count for how heavily.
*
* An endpoint with no container stands for itself, so a loose card still takes
* part. An edge whose endpoints land in the SAME group would be a self-edge
* saying nothing, so it is dropped: inside a group you turn the toggle off and
* read the real edges.
*
* Returns [{ from, to, count, members }] in a stable order.
*/
export function bundleLinks (cards, links) {
const groupOf = new Map()
for (const c of cards) groupOf.set(c.id, c.container || c.id)
const byPair = new Map()
for (const l of links) {
const from = groupOf.get(l.from)
const to = groupOf.get(l.to)
if (from === undefined || to === undefined) continue
if (from === to) continue
const key = from + ' -> ' + to
if (!byPair.has(key)) byPair.set(key, { from, to, count: 0, members: [] })
const entry = byPair.get(key)
entry.count++
entry.members.push(l)
}
return Array.from(byPair.values()).sort((a, b) => {
if (a.from !== b.from) return a.from < b.from ? -1 : 1
return a.to < b.to ? -1 : 1
})
}
/**
* Resolve a card's `code` descriptor against the file's lines.
*
* Returns explicit line NUMBERS rather than a start offset, because the module
* body's slice is sparse — it skips over every other symbol's body — and the
* numbers are the only way the view can mark where it jumped.
*/
export function resolveCode (model, lines, card, maxLines = 120) {
if (!card.code) return { numbers: [], lines: [], clipped: false }
if (card.code.sparse) {
const all = ownLines(model, card.code.container)
const numbers = all.slice(0, maxLines)
return {
numbers,
lines: numbers.map(l => lines[l - 1] || ''),
clipped: all.length > numbers.length
}
}
const from = card.code.from
const to = Math.min(card.code.to, from + maxLines - 1)
const slice = lines.slice(from - 1, to)
return {
numbers: slice.map((_, i) => from + i),
lines: slice,
clipped: to < card.code.to
}
}
/** The lines a container's own operations and wires sit on, ascending. */
export function ownLines (model, containerId) {
const own = new Set()
for (const list of [model.nodes || [], model.names || []]) {
for (const entry of list) {
if (entry.container !== containerId) continue
for (let l = entry.range.startLine; l <= entry.range.endLine; l++) own.add(l)
}
}
return Array.from(own).sort((a, b) => a - b)
}
/** The deepest zone covering each line — the semantic channel, per line. */
export function lineDepths (model, lineCount) {
const out = new Array(lineCount + 2).fill(0)
for (const z of model.zones || []) {
for (let l = z.range.startLine; l <= z.range.endLine; l++) {
if (l <= lineCount && z.depth > out[l]) out[l] = z.depth
}
}
return out
}
/**
* The file's own call graph: an operation calling `foo`, where `foo` is a symbol
* in this file, is an edge from foo to whatever symbol holds the call. Derived,
* never authored.
*/
export function fileLinks (model) {
const byName = new Map()
const byFn = new Map()
for (const s of model.symbols || []) {
if (s.name) byName.set(s.name, s)
if (s.fn) byFn.set(s.fn, s)
}
const seen = new Set()
const links = []
for (const op of model.nodes || []) {
if (op.kind !== 'CallExpression') continue
const callee = op.inputs.find(i => i.slot === 'callee')
if (!callee || !callee.name) continue
const target = byName.get(callee.name.split('.')[0].split('[')[0])
const holder = byFn.get(op.container)
if (!target || !holder || target.id === holder.id) continue
const key = target.id + '->' + holder.id
if (seen.has(key)) continue
seen.add(key)
links.push({ from: target.id, to: holder.id })
}
return links
}
export function signatureOf (model, sym) {
const fn = (model.functions || []).find(f => f.id === sym.fn)
if (fn) return fn.name + ' (' + fn.params.join(', ') + ')'
if (sym.kind === 'class') return 'class ' + sym.name
return sym.name
}
export function interiorCounts (model, sym) {
if (!sym.fn) return sym.members.length > 0 ? sym.members.length + ' members' : ''
return (model.nodes || []).filter(n => n.container === sym.fn).length + ' ops · ' +
(model.zones || []).filter(z => z.container === sym.fn).length + ' zones · ' +
(model.names || []).filter(n => n.container === sym.fn).length + ' wires'
}
export function zoneLabel (zone) {
let out = zone.shape
if (zone.condition) out += ' ' + zone.condition.text
if (zone.carried && zone.carried.length > 0) out += ' carries ' + zone.carried.join(',')
return out
}
/**
* Which columns of one displayed line the node ITSELF occupies.
*
* Several operations routinely share a line — `!`, `?:` and `return` all live on
* line 21 of entryKeyFor — so expanding each of them showed the same line three
* times with nothing to say which part was which. This is that part.
*
* The columns must be DISPLAYED columns, not source columns: the view expands
* tabs before drawing, so a tab-indented line shifts by (tabWidth - 1) per tab
* ahead of the span. Getting this wrong puts the highlight in the wrong place on
* exactly the files that need it most — GDScript is tab-indented throughout.
*
* Returns null when there is nothing useful to mark: a line outside the span, or
* a span that covers the whole line anyway (a function card's span is its whole
* body, so every line is "its own" and highlighting says nothing).
*/
export function spanOnLine (span, lineNumber, rawLine, tabWidth = 2) {
if (!span || typeof rawLine !== 'string') return null
if (lineNumber < span.startLine || lineNumber > span.endLine) return null
const srcFrom = lineNumber === span.startLine ? span.startCol : 0
const srcTo = lineNumber === span.endLine ? span.endCol : rawLine.length
if (srcTo <= srcFrom) return null
const from = displayColumn(rawLine, srcFrom, tabWidth)
const to = displayColumn(rawLine, srcTo, tabWidth)
// Nothing to say if it is the entire line's content.
const firstInk = displayColumn(rawLine, rawLine.length - rawLine.replace(/^\s+/, '').length, tabWidth)
const lastInk = displayColumn(rawLine, rawLine.replace(/\s+$/, '').length, tabWidth)
if (from <= firstInk && to >= lastInk) return null
return { from, to }
}
/**
* Whether marking this card's own span inside its code is worth doing.
*
* spanOnLine is mechanical and will happily mark a symbol card's span — which
* on `export function tally (rows) {` is everything after `export `, accurate
* and useless. The feature exists because several operations SHARE a line, so
* the policy is: mark only a span that lives on one line.
*/
export function marksOwnSpan (card) {
return !!(card && card.span && card.span.startLine === card.span.endLine)
}
/** A source column mapped through tab expansion to the column actually drawn. */
export function displayColumn (rawLine, sourceCol, tabWidth = 2) {
let out = 0
const limit = Math.min(sourceCol, rawLine.length)
for (let i = 0; i < limit; i++) {
if (rawLine[i] === '\t') out = Math.floor(out / tabWidth) * tabWidth + tabWidth
else out++
}
return out + Math.max(0, sourceCol - rawLine.length)
}
function describeInput (i) {
if (i.literal) return i.name
return i.name + (i.source === 'node' ? '' : ' [' + i.source + ']')
}
function shortKind (kind) {
return kind.replace('Expression', '').replace('Statement', '').toLowerCase()
}
// The innermost zone whose range covers this one — used for a nested function,
// which has no `zone` field of its own.
function innermostZoneOf (zones, range) {
let best = null
for (const z of zones) {
if (z.range.startByte <= range.startByte && z.range.endByte >= range.endByte) {
if (!best || z.depth > best.depth) best = z
}
}
return best ? best.id : null
}
// --- the tree, walked once -------------------------------------------------
//
// These were implemented twice: here for the floor, and again inside render.js
// for the SPA, from a DIFFERENT field of the same scan. getNodesAtPath walked
// `tree.dirTree`, buildModuleLevel walked `tree.modules`, and nothing made them
// agree about what exists at a path. One implementation, both views.
export function findDirTreeNode (tree, path) {
if (!path) return tree.dirTree
const parts = path.split('/')
let node = tree.dirTree
for (const part of parts) {
if (!node || !node.children) return null
const next = node.children.find(c => c.name === part)
if (!next) return null
node = next
}
return node
}
export function countPackagesUnder (dirEntry) {
let count = 0
const visit = (n) => {
if (!n) return
if (n.module) count++
if (n.children) for (const c of n.children) visit(c)
}
visit(dirEntry)
return count
}
/** One level of the directory tree: [{ name, type: 'dir'|'module', mod, dirEntry, packageCount }] */
export function nodesAtPath (tree, path) {
const node = findDirTreeNode(tree, path)
if (!node || !node.children) return []
const out = []
for (const child of node.children) {
if (child.type === 'file') continue
if (child.module && tree.modules[child.module]) {
out.push({
name: child.module,
type: 'module',
dirEntry: child,
mod: tree.modules[child.module],
packageCount: 1
})
} else if (child.children && child.children.length > 0) {
out.push({
name: child.name,
type: 'dir',
dirEntry: child,
mod: null,
packageCount: countPackagesUnder(child)
})
}
}
return out
}
/**
* The category a module belongs to.
*
* The scanner resolves this ONCE into tree.moduleCategories — curated groupings
* from spec/SYSTEM.md first, then the most-shared-dependency fallback — exactly
* so the CLI and the views cannot drift. render.js reimplemented that algorithm
* against tree.categories instead, which is a second answer to the same
* question that nothing keeps in step.
*/
export function categoryOf (tree, moduleName) {
const resolved = tree.moduleCategories || {}
if (resolved[moduleName]) return resolved[moduleName]
// A tree scanned before the scanner resolved categories: fall back to the
// curated map alone rather than reimplementing the derivation.
for (const [cat, mods] of Object.entries(tree.categories || {})) {
if (mods.includes(moduleName)) return cat
}
return 'Other'
}
/** Every category in use, in stable order. */
export function categoriesOf (tree) {
const seen = new Set()
for (const name of (tree.system && tree.system.modules) || []) seen.add(categoryOf(tree, name))
return Array.from(seen).sort()
}
const CATEGORY_COLORS = [
'#58a6ff', '#3fb950', '#d2a8ff', '#f0883e', '#ff7b72',
'#ffa657', '#79c0ff', '#f778ba', '#56d364'
]
/**
* A stable colour for a category.
*
* render.js coloured only the CURATED categories, so every derived one ("b4a
* users") fell through to a per-module colour and the grouping lost its cue.
*/
export function categoryColor (tree, category) {
const all = categoriesOf(tree)
const idx = all.indexOf(category)
return CATEGORY_COLORS[(idx < 0 ? 0 : idx) % CATEGORY_COLORS.length]
}
/**
* The counts every view shows for a module, computed once.
*
* render.js carried TWO definitions of memberCount: at module level it counted
* only class methods and getters, at file level it also counted free functions.
* So a module of plain functions reported zero members in the graph and a real
* number in the file view. This is the fuller definition, used by both.
*/
export function moduleStats (mod) {
if (!mod) return { fileCount: 0, memberCount: 0, exportCount: 0 }
const files = mod.files || {}
let memberCount = 0
for (const file of Object.values(files)) memberCount += fileMembers(file)
return {
fileCount: Object.keys(files).length,
memberCount,
exportCount: (mod.publicAPI || []).length
}
}
/** Members of one file: its functions, plus every class method and getter. */
export function fileMembers (file) {
if (!file) return 0
let count = (file.functions || []).length
for (const cls of file.classes || []) {
count += (cls.methods || []).length
count += (cls.getters || []).length
}
return count
}