// Graph layout & derivations — pure functions split out of graph_api.jsx.
// Contract: api_contract_graph.md (Part 1 & Part 3).
// Depends on graph_api.jsx globals (edgeSelectorFromDTO); must load AFTER it.

// ─────────────────────────────────────────────────────────────────────────
// Derivations (see api_contract_graph.md Part 1 and Part 3 / frontend per-view rendering)
// ─────────────────────────────────────────────────────────────────────────
function allGraphNodes(snapshot) {
  const factual = (snapshot.factual_nodes || []).map(n => ({ ...n, category: 'factual' }));
  const concept = (snapshot.concept_nodes || []).map(n => ({ ...n, category: 'concept' }));
  return factual.concat(concept);
}

function allGraphEdges(snapshot) {
  const tag = (arr, type) => (arr || []).map(e => ({ ...e, type }));
  return [
    ...tag(snapshot.structure_edges,  'structure'),
    ...tag(snapshot.mapping_edges,    'mapping'),
    ...tag(snapshot.dependency_edges, 'dependency'),
    ...tag(snapshot.relation_edges,   'relation'),
    ...tag(snapshot.category_edges,   'category'),
  ];
}

function buildGraphNodeIndex(nodes) {
  const idx = new Map();
  nodes.forEach(n => idx.set(n.id, n));
  return idx;
}

function buildGraphAdjacency(edges) {
  const outByNode = new Map();
  const inByNode = new Map();
  edges.forEach(e => {
    if (!outByNode.has(e.source)) outByNode.set(e.source, []);
    outByNode.get(e.source).push(e);
    if (!inByNode.has(e.target)) inByNode.set(e.target, []);
    inByNode.get(e.target).push(e);
  });
  return { outByNode, inByNode };
}

function deriveNodeDomain(node, outEdges, nodeIndex) {
  if (node.category === 'factual') {
    if (node.type === 'table'    || node.type === 'column') return 'db';
    if (node.type === 'document' || node.type === 'chunk')  return 'doc';
    return 'orphan';
  }
  // Concept node — inspect mapping-edge targets (topology first)
  let hasCol = false, hasChunk = false;
  for (const e of (outEdges || [])) {
    if (e.type !== 'mapping') continue;
    const tgt = nodeIndex.get(e.target);
    if (!tgt) continue;
    if (tgt.type === 'column') hasCol = true;
    else if (tgt.type === 'chunk') hasChunk = true;
    if (hasCol && hasChunk) break;
  }
  if (hasCol && hasChunk) return 'bridge';
  if (hasCol)   return 'db';
  if (hasChunk) return 'doc';
  const hint = node.properties && node.properties.domain_hint;
  if (hint === 'db' || hint === 'doc') return hint;
  return 'orphan';
}

function isValueConceptNode(outEdges) {
  if (!outEdges || outEdges.length === 0) return false;
  return outEdges.every(e => e.type === 'category');
}

// ─────────────────────────────────────────────────────────────────────────
// Deterministic string hash (djb2-ish). Used to seed initial positions so
// re-mounting the graph (e.g. after tab switch) produces the same layout.
// ─────────────────────────────────────────────────────────────────────────
function hashStringDeterministic(s) {
  let h = 2166136261 >>> 0;
  for (let i = 0; i < (s || '').length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 16777619) >>> 0;
  }
  return h >>> 0;
}

// ─────────────────────────────────────────────────────────────────────────
// Clustered Fruchterman–Reingold layout with domain gravity.
// Mutates nodes in place (sets n.x, n.y). Returns {width, height}.
// Deterministic: same inputs → same positions.
// ─────────────────────────────────────────────────────────────────────────
function layoutGraphClustered(nodes, edges, opts = {}) {
  const W = opts.width  || 1200;
  const H = opts.height || 780;
  const N = nodes.length;
  if (N === 0) return { width: W, height: H };

  const centers = {
    db:     { x: W * 0.24, y: H * 0.50 },
    doc:    { x: W * 0.76, y: H * 0.50 },
    bridge: { x: W * 0.50, y: H * 0.46 },
    orphan: { x: W * 0.50, y: H * 0.14 },
  };

  // Seed positions deterministically from node id hash, near cluster center
  nodes.forEach((n, i) => {
    const c = centers[n.domain] || centers.orphan;
    const jitter = 80;
    const h = hashStringDeterministic((n.id || '') + ':' + (opts.seedSalt || '') + ':' + i);
    const jx = ((h & 0xffff) / 0xffff - 0.5) * jitter;
    const jy = (((h >>> 16) & 0xffff) / 0xffff - 0.5) * jitter;
    n.x = c.x + jx;
    n.y = c.y + jy;
  });

  const idx = new Map();
  nodes.forEach(n => idx.set(n.id, n));

  const area = W * H;
  const k = Math.sqrt(area / Math.max(N, 1)) * (opts.k || 0.85);
  const iters = opts.iters || (N < 50 ? 220 : N < 200 ? 120 : N < 500 ? 60 : 30);
  let t = Math.min(W, H) / 10;
  const minT = 0.5;
  const cooling = 0.95;
  const gravity = 0.035;

  for (let it = 0; it < iters; it++) {
    for (let i = 0; i < N; i++) { nodes[i]._vx = 0; nodes[i]._vy = 0; }

    // Repulsion: O(N^2)
    for (let i = 0; i < N; i++) {
      for (let j = i + 1; j < N; j++) {
        const dx = nodes[i].x - nodes[j].x;
        const dy = nodes[i].y - nodes[j].y;
        const d = Math.sqrt(dx * dx + dy * dy) + 0.01;
        const f = (k * k) / d;
        const fx = (dx / d) * f, fy = (dy / d) * f;
        nodes[i]._vx += fx; nodes[i]._vy += fy;
        nodes[j]._vx -= fx; nodes[j]._vy -= fy;
      }
    }

    // Attraction along edges (structure edges get extra weight)
    edges.forEach(e => {
      const a = idx.get(e.source);
      const b = idx.get(e.target);
      if (!a || !b) return;
      const weight = e.type === 'structure' ? 2.0 : 1.0;
      const dx = a.x - b.x, dy = a.y - b.y;
      const d = Math.sqrt(dx * dx + dy * dy) + 0.01;
      const f = ((d * d) / k) * weight;
      const fx = (dx / d) * f, fy = (dy / d) * f;
      a._vx -= fx; a._vy -= fy;
      b._vx += fx; b._vy += fy;
    });

    // Cluster gravity
    nodes.forEach(n => {
      const c = centers[n.domain] || centers.orphan;
      n._vx += (c.x - n.x) * gravity;
      n._vy += (c.y - n.y) * gravity;
    });

    // Apply with temperature clamp
    for (let i = 0; i < N; i++) {
      const n = nodes[i];
      const mag = Math.sqrt(n._vx * n._vx + n._vy * n._vy) + 0.01;
      n.x += (n._vx / mag) * Math.min(mag, t);
      n.y += (n._vy / mag) * Math.min(mag, t);
      n.x = Math.max(30, Math.min(W - 30, n.x));
      n.y = Math.max(30, Math.min(H - 30, n.y));
    }

    t = Math.max(minT, t * cooling);
  }

  for (let i = 0; i < N; i++) { delete nodes[i]._vx; delete nodes[i]._vy; }
  return { width: W, height: H };
}

// ─────────────────────────────────────────────────────────────────────────
// Connected-component helpers for tile packing. If the macro graph has
// multiple disconnected clusters (e.g. concepts tied to separate databases),
// FR alone won't reliably keep them apart — shelf-pack them after layout.
// ─────────────────────────────────────────────────────────────────────────
function findConnectedMacroComponents(nodes, edges) {
  const byId = new Map(nodes.map(n => [n.id, n]));
  const adj = new Map();
  nodes.forEach(n => adj.set(n.id, []));
  edges.forEach(e => {
    if (adj.has(e.source) && byId.has(e.target)) adj.get(e.source).push(e.target);
    if (adj.has(e.target) && byId.has(e.source)) adj.get(e.target).push(e.source);
  });
  const seen = new Set();
  const comps = [];
  for (const n of nodes) {
    if (seen.has(n.id)) continue;
    const comp = [];
    const stack = [n.id];
    while (stack.length) {
      const id = stack.pop();
      if (seen.has(id)) continue;
      seen.add(id);
      const node = byId.get(id);
      if (node) comp.push(node);
      (adj.get(id) || []).forEach(nid => { if (!seen.has(nid)) stack.push(nid); });
    }
    if (comp.length) comps.push(comp);
  }
  return comps;
}

function packMacroComponents(comps, { W, H, gap }) {
  comps.forEach(comp => {
    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
    comp.forEach(n => {
      if (n.x < minX) minX = n.x;
      if (n.y < minY) minY = n.y;
      if (n.x > maxX) maxX = n.x;
      if (n.y > maxY) maxY = n.y;
    });
    comp._bbox = { minX, minY, maxX, maxY,
      w: Math.max(1, maxX - minX), h: Math.max(1, maxY - minY) };
  });
  // Shelf pack — sort tall-first, flow left-to-right, wrap on row overflow.
  const sorted = [...comps].sort((a, b) => b._bbox.h - a._bbox.h);
  let curX = gap, curY = gap, rowH = 0;
  sorted.forEach(comp => {
    const bw = comp._bbox.w, bh = comp._bbox.h;
    if (curX > gap && curX + bw > W - gap) {
      curX = gap;
      curY += rowH + gap;
      rowH = 0;
    }
    const dx = curX - comp._bbox.minX;
    const dy = curY - comp._bbox.minY;
    comp.forEach(n => { n.x += dx; n.y += dy; });
    curX += bw + gap;
    rowH = Math.max(rowH, bh);
  });
}

// Shared chip-layout helper — used both by buildViewLayout (to compute
// absolute chip positions so category edges can target them) and by
// ConceptNode at render time. Pure function, deterministic.
function layoutCategoryChips(chips, maxRowWidth = 220) {
  const gap = 5;
  const padding = 10;
  const chipH = 16;
  const rowGap = 4;

  const widths = chips.map(c => {
    const label = String(c.value || c.name || '');
    return Math.max(26, Math.min(68, label.length * 6 + padding));
  });

  const rows = [[]];
  let curRowW = 0;
  widths.forEach((w, idx) => {
    const cur = rows[rows.length - 1];
    const addW = cur.length === 0 ? w : w + gap;
    if (cur.length > 0 && curRowW + addW > maxRowWidth) {
      rows.push([]);
      curRowW = 0;
    }
    rows[rows.length - 1].push({ idx, w });
    curRowW = rows[rows.length - 1].length === 1 ? w : curRowW + gap + w;
  });

  const out = chips.map(c => ({ ...c }));
  rows.forEach((row, rowI) => {
    const totalW = row.reduce((s, r) => s + r.w, 0) + gap * Math.max(0, row.length - 1);
    let x = -totalW / 2;
    row.forEach(r => {
      out[r.idx].x = x;
      out[r.idx].y = rowI * (chipH + rowGap);
      out[r.idx].w = r.w;
      out[r.idx].h = chipH;
      x += r.w + gap;
    });
  });
  return out;
}

// Offset of the chip strip below the concept circle (must match ConceptNode).
const CHIP_STRIP_OFFSET = 18 /*r*/ + 10 /*gap*/;

// ─────────────────────────────────────────────────────────────────────────
// buildViewLayout — view-specific hierarchical layout.
//
// Splits the snapshot into a Database view (tables + columns + concepts that
// map to columns) or a Docs view (documents + chunks + concepts that map to
// chunks) and runs a two-pass layout:
//
//   - Macro pass: Fruchterman–Reingold on tables/documents + concepts,
//     with concept→column mappings promoted to concept→table for attraction
//     (concept→chunk → concept→document for docs view).
//   - Micro pass: columns/chunks laid out radially around their parent
//     table/document.
//
// Category value concepts don't get positions — they're returned as
// `categoryChips` attached to their dimension concept.
//
// Concepts classified as `bridge` (mapping to both column and chunk) appear
// in BOTH views so the caller can render a jump affordance.
//
// Returns: { view, width, height, macroNodes, microNodesByParent,
//            categoryChips, bridges, edges, nodeIndex, fullIndex,
//            outByNode, inByNode }
// ─────────────────────────────────────────────────────────────────────────
function buildViewLayout(snapshot, view, opts = {}) {
  const W = opts.width  || 1400;
  const H = opts.height || 880;
  const seedSalt = (opts.seedSalt || '') + ':' + view;

  // Drop soft-undone nodes and edges from the graph view entirely (per
  // product decision — Learning page keeps them visible with a Recover
  // button, Graph view just hides them). Items lacking the `state` field
  // (legacy snapshots / mock data) are treated as active.
  // Deep-copy nodes so position mutation doesn't leak to the snapshot.
  const isUndone = (item) => item && item.state === 'undo';
  const nodesCopy = allGraphNodes(snapshot)
    .filter(n => !isUndone(n))
    .map(n => ({ ...n }));
  const edges     = allGraphEdges(snapshot).filter(e => !isUndone(e));
  const fullIndex = buildGraphNodeIndex(nodesCopy);
  const { outByNode, inByNode } = buildGraphAdjacency(edges);

  nodesCopy.forEach(n => {
    n.domain  = deriveNodeDomain(n, outByNode.get(n.id) || [], fullIndex);
    n.isValue = n.category === 'concept' && isValueConceptNode(outByNode.get(n.id) || []);
  });

  const tables    = nodesCopy.filter(n => n.category === 'factual' && n.type === 'table');
  const columns   = nodesCopy.filter(n => n.category === 'factual' && n.type === 'column');
  const documents = nodesCopy.filter(n => n.category === 'factual' && n.type === 'document');
  const chunks    = nodesCopy.filter(n => n.category === 'factual' && n.type === 'chunk');

  // A concept belongs to this view if:
  //   - it's not a category-value (those become chips under their dimension)
  //   - domain is bridge or orphan (both appear in every view) OR matches view
  const targetDomain = view === 'db' ? 'db' : 'doc';
  const conceptInView = (n) => {
    if (n.category !== 'concept' || n.isValue) return false;
    if (n.domain === 'bridge' || n.domain === 'orphan') return true;
    return n.domain === targetDomain;
  };
  const concepts = nodesCopy.filter(conceptInView);

  // Category chips — for each concept in view that has incoming category edges,
  // gather the value concepts pointing at it as attached visual chips.
  const categoryChips = new Map();
  concepts.forEach(dim => {
    const incoming = (inByNode.get(dim.id) || []).filter(e => e.type === 'category');
    if (incoming.length === 0) return;
    // Dedupe by source id — the backend may emit multiple category edges
    // for the same (value, dimension) pair (e.g. different data_types or
    // repeated deltas). Chips are keyed on source id in React, so duplicates
    // would trigger "same key" warnings.
    const seenChip = new Set();
    const chips = [];
    incoming.forEach(e => {
      if (seenChip.has(e.source)) return;
      seenChip.add(e.source);
      const src = fullIndex.get(e.source);
      chips.push({
        id:         e.source,
        value:      e.value || (src ? src.name : e.source),
        dataType:   e.data_type,
        confidence: e.confidence,
        name:       src ? src.name : (e.value || e.source),
      });
    });
    categoryChips.set(dim.id, chips);
  });

  const macroNodes = view === 'db'
    ? [...tables, ...concepts]
    : [...documents, ...concepts];
  const macroIds = new Set(macroNodes.map(n => n.id));
  const macroIndex = new Map();
  macroNodes.forEach(n => macroIndex.set(n.id, n));

  // Parent (table or document) for each column/chunk, derived via incoming
  // structure edges. This is more robust than relying on
  // `properties.table` / `properties.document`, which the contract doesn't
  // mandate on child nodes.
  const parentIdByChildId = new Map();
  const allChildren = [...columns, ...chunks];
  allChildren.forEach(child => {
    const wantType = child.type === 'column' ? 'table' : 'document';
    const incoming = (inByNode.get(child.id) || []).filter(e => e.type === 'structure');
    for (const e of incoming) {
      const src = fullIndex.get(e.source);
      if (src && src.type === wantType) {
        parentIdByChildId.set(child.id, src.id);
        break;
      }
    }
  });

  // Macro-level attraction edges (fed to FR only):
  //   - FK table↔table (DB view only)
  //   - Concept→column promoted to concept→table (DB view)
  //   - Concept→chunk promoted to concept→document (docs view)
  //   - Concept↔concept relations
  //   - Concept↔concept dependencies
  const macroEdges = [];
  const seenMacro = new Set();
  const addMacroEdge = (src, tgt, weight) => {
    if (src === tgt || !macroIds.has(src) || !macroIds.has(tgt)) return;
    const key = src + '|' + tgt;
    if (seenMacro.has(key)) return;
    seenMacro.add(key);
    macroEdges.push({ source: src, target: tgt, weight });
  };

  edges.forEach(e => {
    if (e.type === 'structure') {
      if (view === 'db') {
        const s = fullIndex.get(e.source), t = fullIndex.get(e.target);
        if (s && t && s.type === 'table' && t.type === 'table') {
          addMacroEdge(e.source, e.target, 1.6);
        }
      }
      return;
    }
    if (e.type === 'mapping') {
      const tgt = fullIndex.get(e.target);
      if (!tgt) return;
      if (view === 'db' && tgt.type === 'column') {
        const parentId = parentIdByChildId.get(e.target);
        if (parentId) addMacroEdge(e.source, parentId, 1.0);
      } else if (view !== 'db' && tgt.type === 'chunk') {
        const parentId = parentIdByChildId.get(e.target);
        if (parentId) addMacroEdge(e.source, parentId, 1.0);
      }
      return;
    }
    if (e.type === 'relation')   addMacroEdge(e.source, e.target, 0.8);
    if (e.type === 'dependency') addMacroEdge(e.source, e.target, 0.6);
  });

  // Effective collision radius per macro node — covers the full visible
  // footprint including label and attached chips. Used as the minimum
  // center-to-center distance during repulsion.
  const macroRadius = (n) => {
    if (n.category === 'factual') return 68;  // table/document card + orbit headroom
    const chips = categoryChips.get(n.id);
    return (chips && chips.length > 0) ? 38 : 26;
  };

  // ─── Seeding ─────────────────────────────────────────────────────────
  // Factual: uniform by hash (no left/right bias).
  // Concept: semantic anchor — seed near the centroid of mapped parent
  //          tables/documents so concepts cluster around the data they
  //          describe. Orphans seed near the canvas center.
  const factualNodesForSeed = macroNodes.filter(n => n.category === 'factual');
  const conceptNodesForSeed = macroNodes.filter(n => n.category === 'concept');

  factualNodesForSeed.forEach(n => {
    const h  = hashStringDeterministic(n.id + ':' + seedSalt);
    const jx = (h & 0xffff) / 0xffff;
    const jy = ((h >>> 16) & 0xffff) / 0xffff;
    n.x = W * 0.15 + jx * W * 0.70;
    n.y = H * 0.15 + jy * H * 0.70;
  });

  conceptNodesForSeed.forEach(n => {
    const h   = hashStringDeterministic(n.id + ':seed:' + seedSalt);
    const ang = ((h & 0xffff) / 0xffff) * Math.PI * 2;
    const rad = 60 + ((h >>> 16) & 0xff) / 255 * 70;
    const anchors = [];
    (outByNode.get(n.id) || []).forEach(e => {
      if (e.type !== 'mapping') return;
      const tgt = fullIndex.get(e.target);
      if (!tgt) return;
      const expect = view === 'db' ? 'column' : 'chunk';
      if (tgt.type !== expect) return;
      const pid = parentIdByChildId.get(e.target);
      const p = pid && macroIndex.get(pid);
      if (p) anchors.push(p);
    });
    if (anchors.length > 0) {
      const cx = anchors.reduce((s, a) => s + a.x, 0) / anchors.length;
      const cy = anchors.reduce((s, a) => s + a.y, 0) / anchors.length;
      n.x = cx + Math.cos(ang) * rad;
      n.y = cy + Math.sin(ang) * rad;
    } else {
      n.x = W * 0.5 + Math.cos(ang) * rad;
      n.y = H * 0.5 + Math.sin(ang) * rad;
    }
  });

  // ─── Simulation ──────────────────────────────────────────────────────
  // Spring-based edges with target length L0 + coulomb-like repulsion +
  // hard push when label boxes overlap + weak center gravity so components
  // don't drift off indefinitely.
  const N = macroNodes.length;
  if (N > 0) {
    const L0                  = opts.idealEdgeLength   || 130;
    const edgeStiffness       = opts.edgeStiffness     || 0.14;
    const repulsionK          = opts.repulsionK        || 25;
    const collisionPad        = opts.collisionPad      || 12;
    const collisionStiffness  = opts.collisionStiffness|| 0.85;
    const gravity             = opts.gravity           || 0.028;
    const iters = opts.iters || (N < 30 ? 280 : N < 100 ? 180 : 110);
    const gCx = W / 2, gCy = H / 2;
    let t = 1.0;
    const cooling = 0.97;
    const maxStepBase = Math.min(W, H) * 0.08;

    for (let it = 0; it < iters; it++) {
      for (let i = 0; i < N; i++) { macroNodes[i]._vx = 0; macroNodes[i]._vy = 0; }

      // Pairwise repulsion + label-box collision guard
      for (let i = 0; i < N; i++) {
        const ri = macroRadius(macroNodes[i]);
        for (let j = i + 1; j < N; j++) {
          const rj = macroRadius(macroNodes[j]);
          let dx = macroNodes[i].x - macroNodes[j].x;
          let dy = macroNodes[i].y - macroNodes[j].y;
          let d  = Math.sqrt(dx * dx + dy * dy);
          if (d < 0.01) { dx = 0.3; dy = 0.1; d = Math.sqrt(dx*dx + dy*dy); }
          // Cutoff distant pairs entirely — avoids peripheral nodes getting
          // slowly shoved into the boundary clamp on large graphs.
          const minD = ri + rj + collisionPad;
          if (d > L0 * 3 && d > minD) continue;
          // Softened coulomb (falls off gently so distant nodes don't push forever)
          let fMag = (repulsionK * repulsionK) / (d + 30);
          // Hard spring when effective-radius boxes overlap
          if (d < minD) fMag += (minD - d) * collisionStiffness * repulsionK * 0.5;
          const fx = (dx / d) * fMag;
          const fy = (dy / d) * fMag;
          macroNodes[i]._vx += fx; macroNodes[i]._vy += fy;
          macroNodes[j]._vx -= fx; macroNodes[j]._vy -= fy;
        }
      }

      // Edge springs with target length L0
      macroEdges.forEach(e => {
        const a = macroIndex.get(e.source);
        const b = macroIndex.get(e.target);
        if (!a || !b) return;
        let dx = a.x - b.x, dy = a.y - b.y;
        let d  = Math.sqrt(dx * dx + dy * dy);
        if (d < 0.01) d = 0.01;
        const f = edgeStiffness * (d - L0) * (e.weight || 1);
        const fx = (dx / d) * f, fy = (dy / d) * f;
        a._vx -= fx; a._vy -= fy;
        b._vx += fx; b._vy += fy;
      });

      // Weak center gravity
      for (let i = 0; i < N; i++) {
        const n = macroNodes[i];
        n._vx += (gCx - n.x) * gravity;
        n._vy += (gCy - n.y) * gravity;
      }

      // Integrate with temperature clamp
      const maxStep = maxStepBase * t;
      for (let i = 0; i < N; i++) {
        const n = macroNodes[i];
        const mag = Math.sqrt(n._vx * n._vx + n._vy * n._vy);
        if (mag > maxStep && mag > 0.0001) {
          n._vx = (n._vx / mag) * maxStep;
          n._vy = (n._vy / mag) * maxStep;
        }
        n.x += n._vx;
        n.y += n._vy;
        n.x = Math.max(110, Math.min(W - 110, n.x));
        n.y = Math.max(110, Math.min(H - 110, n.y));
      }
      t *= cooling;
    }
    for (let i = 0; i < N; i++) { delete macroNodes[i]._vx; delete macroNodes[i]._vy; }
  }

  // Tile: if the macro graph has multiple disconnected components, shelf-pack
  // them so they don't pile on top of each other (common when a snapshot
  // contains several unrelated databases or concept clusters).
  const comps = findConnectedMacroComponents(macroNodes, macroEdges);
  if (comps.length > 1) {
    // gap 90 > worst-case column orbit radius (~82) so adjacent components'
    // orbits don't overlap in the packed layout.
    packMacroComponents(comps, { W, H, gap: 90 });
  }

  // Micro pass: ALL column/chunk nodes defined in the snapshot are rendered.
  // Parented ones sit radially around their table/document; orphans (no
  // incoming structure edge from a parent) are placed in a scatter grid at
  // the bottom so they're still visible and can receive mapping edges.
  const microNodesByParent = new Map();
  const orphanChildren = [];
  const childrenSource = view === 'db' ? columns : chunks;
  childrenSource.forEach(c => {
    const pid = parentIdByChildId.get(c.id);
    if (pid && macroIndex.has(pid)) {
      if (!microNodesByParent.has(pid)) microNodesByParent.set(pid, []);
      microNodesByParent.get(pid).push(c);
    } else {
      orphanChildren.push(c);
    }
  });

  // Radial around parent
  microNodesByParent.forEach((children, parentId) => {
    const parent = macroIndex.get(parentId);
    if (!parent) return;
    const n = children.length;
    const baseRadius = 64;
    const radius = baseRadius + Math.max(0, n - 6) * 4;
    const startAngle = ((hashStringDeterministic(parentId) & 0xff) / 255) * Math.PI * 2;
    children.forEach((c, i) => {
      const angle = startAngle + (i / n) * Math.PI * 2;
      c.x = parent.x + Math.cos(angle) * radius;
      c.y = parent.y + Math.sin(angle) * radius;
    });
  });

  // Orphan scatter grid at the bottom strip
  if (orphanChildren.length > 0) {
    const cols = Math.min(orphanChildren.length, Math.max(4, Math.ceil(Math.sqrt(orphanChildren.length * 3))));
    const gridW = Math.min(W - 160, Math.max(200, cols * 80));
    const cellX = gridW / Math.max(1, cols);
    const cellY = 28;
    const startX = (W - gridW) / 2 + cellX / 2;
    const startY = H - 80;
    orphanChildren.forEach((c, i) => {
      const r = Math.floor(i / cols);
      const col = i % cols;
      c.x = startX + col * cellX;
      c.y = startY - r * cellY;
    });
  }

  const bridges = new Set();
  concepts.forEach(c => { if (c.domain === 'bridge') bridges.add(c.id); });

  // Compute absolute chip positions (value-concept id → {x, y}) so that
  // category edges can terminate at the chip they represent. Keeps chips as
  // their own visual primitive while still giving category edges a real
  // endpoint to land on.
  const chipPositions = new Map();
  categoryChips.forEach((chips, dimId) => {
    const dim = macroIndex.get(dimId);
    if (!dim) return;
    const laidOut = layoutCategoryChips(chips);
    laidOut.forEach(c => {
      const absX = dim.x + c.x + c.w / 2;
      const absY = dim.y + CHIP_STRIP_OFFSET + c.y + c.h / 2;
      chipPositions.set(c.id, { id: c.id, x: absX, y: absY, _chip: true, dimId });
    });
  });

  // Renderable edges (for SVG lines).
  const childIds = new Set();
  microNodesByParent.forEach(ch => ch.forEach(c => childIds.add(c.id)));
  orphanChildren.forEach(c => childIds.add(c.id));

  const renderEdges = [];
  edges.forEach(e => {
    if (e.type === 'structure') {
      const s = fullIndex.get(e.source), t = fullIndex.get(e.target);
      if (!s || !t) return;
      if (view === 'db') {
        if (s.type === 'table' && t.type === 'column' && macroIds.has(s.id) && childIds.has(t.id)) {
          renderEdges.push({ ...e });
        } else if (s.type === 'table' && t.type === 'table' && macroIds.has(s.id) && macroIds.has(t.id)) {
          renderEdges.push({ ...e, _fk: true });
        }
      } else {
        if (s.type === 'document' && t.type === 'chunk' && macroIds.has(s.id) && childIds.has(t.id)) {
          renderEdges.push({ ...e });
        }
      }
      return;
    }
    if (e.type === 'mapping') {
      const tgt = fullIndex.get(e.target);
      if (!tgt || !macroIds.has(e.source)) return;
      if (view === 'db' && tgt.type === 'column' && childIds.has(e.target)) {
        renderEdges.push({ ...e });
      } else if (view !== 'db' && tgt.type === 'chunk' && childIds.has(e.target)) {
        renderEdges.push({ ...e });
      }
      return;
    }
    if (e.type === 'relation' && macroIds.has(e.source) && macroIds.has(e.target)) {
      renderEdges.push({ ...e });
      return;
    }
    if (e.type === 'dependency' && macroIds.has(e.source) && macroIds.has(e.target)) {
      renderEdges.push({ ...e });
      return;
    }
    // Category edge: value-concept → dimension-concept. Rendered from the
    // chip's absolute position to the dimension concept body.
    if (e.type === 'category') {
      if (chipPositions.has(e.source) && macroIds.has(e.target)) {
        renderEdges.push({ ...e });
      }
    }
  });

  const renderIndex = new Map();
  macroNodes.forEach(n => renderIndex.set(n.id, n));
  microNodesByParent.forEach(ch => ch.forEach(c => renderIndex.set(c.id, c)));
  orphanChildren.forEach(c => renderIndex.set(c.id, c));
  chipPositions.forEach((pos, id) => renderIndex.set(id, pos));

  return {
    view, width: W, height: H,
    macroNodes, microNodesByParent, orphanChildren,
    categoryChips, bridges,
    edges: renderEdges,
    nodeIndex: renderIndex,
    fullIndex, outByNode, inByNode,
  };
}

Object.assign(window, {
  allGraphNodes, allGraphEdges, buildGraphNodeIndex, buildGraphAdjacency,
  deriveNodeDomain, isValueConceptNode, layoutGraphClustered, buildViewLayout,
  layoutCategoryChips,
});
