OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / floor / cards.js

809 lines · javascript ref:
/**
 * 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
}