OverviewFilesCommitsIssuesDocsReleasesPeers

hypercore-kb / opencog-kb / language-learning / mvp / viz / viz-3d.js

3116 lines · javascript ref:
/**
 * Language Learning 3D Visualization — Faithful Graph MVP
 * WebGL-based 3D point cloud with orbit controls
 *
 * Single layout: MST force-directed (MI determines spatial distance)
 * Three color modes: density, cluster, vagueness
 * Fly mode (Shift+F) for first-person navigation
 */

const canvas = document.getElementById('canvas');
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
const tooltip = document.getElementById('tooltip');
const loading = document.getElementById('loading');

if (!gl) {
  alert('WebGL not supported');
}

// Deterministic seeded PRNG (mulberry32) — NO Math.random() anywhere
let _rngState = 42;
function rng() {
  _rngState |= 0;
  _rngState = _rngState + 0x6D2B79F5 | 0;
  let t = Math.imul(_rngState ^ _rngState >>> 15, 1 | _rngState);
  t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
  return ((t ^ t >>> 14) >>> 0) / 4294967296;
}
function rngSeed(seed) { _rngState = seed | 0; }
function hashString(s) {
  let h = 0;
  for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
  return Math.abs(h);
}

// State
let data = null;
let nodes = [];
let edges = [];
let rotation = { x: 0.3, y: 0 };
let zoom = 2.0;
let panTarget = { x: 0, y: 0, z: 0 };
let hoveredNode = null;
let selectedCluster = null;
let autoRotate = true;

// Settings
let miThreshold = 0.5;
let vagueThreshold = 10;
let pointSizeScale = 1.0;
let lineWidthScale = 0.4;
let colorMode = 'cluster'; // 'density', 'cluster', 'wordClass', 'vagueness', 'atomspace', or 'mindmap'
let layoutMode = 'unified'; // 'unified', 'atomspaces', or 'chapters'
let detailLevel = 3; // 1=books, 2=core, 3=communities, 4=full
let gridOpacity = 0.4;
let gridCellSize = 12;
let gridCellGap = 2;

// Atomspace visualization state
let atomspaceColors = new Map(); // name → [r,g,b]
let atomspaceCentroids = new Map(); // name → {x,y,z}
let atomspaceNodes = new Map(); // name → [nodeIndex, ...]

// Cluster colors
const CLUSTER_COLORS = [];
for (let i = 0; i < 120; i++) {
  const hue = (i * 137.5) % 360;
  const s = 0.7, l = 0.5;
  const c = (1 - Math.abs(2*l - 1)) * s;
  const x = c * (1 - Math.abs((hue/60) % 2 - 1));
  const m = l - c/2;
  let r, g, b;
  if (hue < 60) { r = c; g = x; b = 0; }
  else if (hue < 120) { r = x; g = c; b = 0; }
  else if (hue < 180) { r = 0; g = c; b = x; }
  else if (hue < 240) { r = 0; g = x; b = c; }
  else if (hue < 300) { r = x; g = 0; b = c; }
  else { r = c; g = 0; b = x; }
  CLUSTER_COLORS.push([r+m, g+m, b+m]);
}

// Vagueness colors (blue → red)
function getVaguenessColor(surprise, max) {
  const t = Math.min(1, surprise / max);
  if (t < 0.25) return [0.2, 0.4 + t*2, 1.0];
  if (t < 0.5) return [0.2, 1.0, 1.0 - (t-0.25)*4];
  if (t < 0.75) return [0.2 + (t-0.5)*3, 1.0 - (t-0.5)*2, 0.2];
  return [1.0, 0.5 - (t-0.75)*2, 0.2];
}

// Density colors (low connections = blue, high = orange/white)
function getDensityColor(t) {
  if (t < 0.2) return [0.2, 0.3, 0.8];
  if (t < 0.4) return [0.2, 0.6, 0.9];
  if (t < 0.6) return [0.4, 0.8, 0.6];
  if (t < 0.8) return [0.9, 0.7, 0.2];
  return [1.0, 0.9, 0.7];
}

// Mindmap colors: vivid center, faded periphery
// Hue = community, Saturation = certainty, Lightness = centrality
function getMindmapColor(node, maxCentrality) {
  const cid = node.community ?? node.cluster ?? 0;
  const hue = (cid * 137.5) % 360;
  // Surprise norm: 0 = certain, 1 = vague
  const surNorm = node._radius !== undefined
    ? Math.min(1, node._radius / 10)
    : Math.min(1, (node.surprise || 5) / 10);
  const sat = 1.0 - surNorm * 0.6; // certain = vivid, vague = washed
  const centralityNorm = (node._centrality || 0) / Math.max(maxCentrality, 0.01);
  const light = 0.35 + centralityNorm * 0.35; // central = bright, peripheral = dimmer
  // HSL to RGB
  const c = (1 - Math.abs(2 * light - 1)) * sat;
  const x = c * (1 - Math.abs((hue / 60) % 2 - 1));
  const m = light - c / 2;
  let r, g, b;
  if (hue < 60) { r = c; g = x; b = 0; }
  else if (hue < 120) { r = x; g = c; b = 0; }
  else if (hue < 180) { r = 0; g = c; b = x; }
  else if (hue < 240) { r = 0; g = x; b = c; }
  else if (hue < 300) { r = x; g = 0; b = c; }
  else { r = c; g = 0; b = x; }
  return [r + m, g + m, b + m];
}

// Atomspace colors — fixed palette for named atomspaces
const ATOMSPACE_PALETTE = {
  'common':       [1.0, 0.92, 0.6],  // gold
  'alice':        [0.4, 0.8, 1.0],    // sky blue
  'pride':        [1.0, 0.4, 0.6],    // rose
  'sherlock':     [0.4, 1.0, 0.6],    // emerald
  'expectations': [0.8, 0.5, 1.0],    // lavender
  'moby':         [1.0, 0.7, 0.3],    // amber
};

function getAtomspaceColor(asName) {
  if (ATOMSPACE_PALETTE[asName]) return ATOMSPACE_PALETTE[asName];
  // Fallback: hash name to hue
  let hash = 0;
  for (let i = 0; i < asName.length; i++) hash = asName.charCodeAt(i) + ((hash << 5) - hash);
  const hue = Math.abs(hash % 360);
  const s = 0.7, l = 0.6;
  const c = (1 - Math.abs(2*l - 1)) * s;
  const x = c * (1 - Math.abs((hue/60) % 2 - 1));
  const m = l - c/2;
  let r, g, b;
  if (hue < 60) { r = c; g = x; b = 0; }
  else if (hue < 120) { r = x; g = c; b = 0; }
  else if (hue < 180) { r = 0; g = c; b = x; }
  else if (hue < 240) { r = 0; g = x; b = c; }
  else if (hue < 300) { r = x; g = 0; b = c; }
  else { r = c; g = 0; b = x; }
  return [r+m, g+m, b+m];
}

// ═══════════════════════════════════════════════════════════════
// SHADERS
// ═══════════════════════════════════════════════════════════════

const vertexShaderSource = `
  attribute vec3 a_position;
  attribute vec3 a_color;
  attribute float a_size;

  uniform mat4 u_projection;
  uniform mat4 u_view;
  uniform float u_pointScale;

  varying vec3 v_color;

  void main() {
    vec4 viewPos = u_view * vec4(a_position, 1.0);
    gl_Position = u_projection * viewPos;
    float dist = max(-viewPos.z, 0.1);
    gl_PointSize = max(2.0, a_size * u_pointScale * (400.0 / dist));
    v_color = a_color;
  }
`;

const fragmentShaderSource = `
  precision mediump float;
  varying vec3 v_color;

  void main() {
    vec2 coord = gl_PointCoord - vec2(0.5);
    if (dot(coord, coord) > 0.25) discard;
    gl_FragColor = vec4(v_color, 1.0);
  }
`;

const lineVertexShaderSource = `
  attribute vec3 a_position;
  attribute vec4 a_color;

  uniform mat4 u_projection;
  uniform mat4 u_view;
  uniform float u_lineAlpha;

  varying vec4 v_color;

  void main() {
    gl_Position = u_projection * u_view * vec4(a_position, 1.0);
    v_color = vec4(a_color.rgb, a_color.a * u_lineAlpha);
  }
`;

const lineFragmentShaderSource = `
  precision mediump float;
  varying vec4 v_color;

  void main() {
    gl_FragColor = v_color;
  }
`;

const skyboxVertexShaderSource = `
  attribute vec3 a_position;
  attribute vec4 a_color;

  uniform mat4 u_projection;
  uniform mat4 u_viewRotation;

  varying vec4 v_color;

  void main() {
    vec4 pos = u_projection * u_viewRotation * vec4(a_position, 1.0);
    gl_Position = vec4(pos.xy, pos.w, pos.w);
    v_color = a_color;
  }
`;

const skyboxFragmentShaderSource = `
  precision mediump float;
  varying vec4 v_color;

  void main() {
    gl_FragColor = v_color;
  }
`;


// ═══════════════════════════════════════════════════════════════
// WEBGL SETUP
// ═══════════════════════════════════════════════════════════════

function compileShader(source, type) {
  const shader = gl.createShader(type);
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    console.error('Shader error:', gl.getShaderInfoLog(shader));
    return null;
  }
  return shader;
}

function createProgram(vsSource, fsSource) {
  const vs = compileShader(vsSource, gl.VERTEX_SHADER);
  const fs = compileShader(fsSource, gl.FRAGMENT_SHADER);
  const program = gl.createProgram();
  gl.attachShader(program, vs);
  gl.attachShader(program, fs);
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error('Program error:', gl.getProgramInfoLog(program));
    return null;
  }
  return program;
}

let pointProgram, lineProgram, skyboxProgram;
let pointBuffer, lineBuffer, gridBuffer;
let gridVertices = [];
let gridBufferData = null;

function initGL() {
  pointProgram = createProgram(vertexShaderSource, fragmentShaderSource);
  lineProgram = createProgram(lineVertexShaderSource, lineFragmentShaderSource);
  skyboxProgram = createProgram(skyboxVertexShaderSource, skyboxFragmentShaderSource);

  pointBuffer = gl.createBuffer();
  lineBuffer = gl.createBuffer();
  gridBuffer = gl.createBuffer();

  generateSphericalGrid(50, 18, 36);

  gridBufferData = new Float32Array(gridVertices);
  gl.bindBuffer(gl.ARRAY_BUFFER, gridBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, gridBufferData, gl.STATIC_DRAW);

  gl.enable(gl.BLEND);
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
  gl.enable(gl.DEPTH_TEST);
  gl.depthFunc(gl.LEQUAL);

  resize();
}

function generateSphericalGrid(radius, latSegments, lonSegments) {
  gridVertices = [];
  const color = [0.12, 0.18, 0.28, 0.4];

  // Latitude lines
  for (let lat = 1; lat < latSegments; lat++) {
    const theta = (lat / latSegments) * Math.PI;
    const y = Math.cos(theta) * radius;
    const r = Math.sin(theta) * radius;
    const latBrightness = lat === Math.floor(latSegments / 2) ? 1.5 : 1.0;
    const lc = [color[0] * latBrightness, color[1] * latBrightness, color[2] * latBrightness, color[3]];
    for (let lon = 0; lon < lonSegments; lon++) {
      const phi1 = (lon / lonSegments) * Math.PI * 2;
      const phi2 = ((lon + 1) / lonSegments) * Math.PI * 2;
      gridVertices.push(
        Math.cos(phi1) * r, y, Math.sin(phi1) * r, ...lc,
        Math.cos(phi2) * r, y, Math.sin(phi2) * r, ...lc
      );
    }
  }

  // Longitude lines
  for (let lon = 0; lon < lonSegments; lon++) {
    const phi = (lon / lonSegments) * Math.PI * 2;
    const isCardinal = (lon === 0 || lon === lonSegments/4 || lon === lonSegments/2 || lon === 3*lonSegments/4);
    const lonBrightness = isCardinal ? 1.3 : 1.0;
    const lc = [color[0] * lonBrightness, color[1] * lonBrightness, color[2] * lonBrightness, color[3]];
    for (let lat = 0; lat < latSegments; lat++) {
      const theta1 = (lat / latSegments) * Math.PI;
      const theta2 = ((lat + 1) / latSegments) * Math.PI;
      const y1 = Math.cos(theta1) * radius;
      const r1 = Math.sin(theta1) * radius;
      const y2 = Math.cos(theta2) * radius;
      const r2 = Math.sin(theta2) * radius;
      gridVertices.push(
        Math.cos(phi) * r1, y1, Math.sin(phi) * r1, ...lc,
        Math.cos(phi) * r2, y2, Math.sin(phi) * r2, ...lc
      );
    }
  }

  // Equator
  const eqColor = [0.25, 0.35, 0.5, 0.6];
  for (let lon = 0; lon < lonSegments * 2; lon++) {
    const phi1 = (lon / (lonSegments * 2)) * Math.PI * 2;
    const phi2 = ((lon + 1) / (lonSegments * 2)) * Math.PI * 2;
    gridVertices.push(
      Math.cos(phi1) * radius, 0, Math.sin(phi1) * radius, ...eqColor,
      Math.cos(phi2) * radius, 0, Math.sin(phi2) * radius, ...eqColor
    );
  }

  // Prime meridian
  for (let lat = 0; lat < latSegments * 2; lat++) {
    const theta1 = (lat / (latSegments * 2)) * Math.PI;
    const theta2 = ((lat + 1) / (latSegments * 2)) * Math.PI;
    gridVertices.push(
      0, Math.cos(theta1) * radius, Math.sin(theta1) * radius, ...eqColor,
      0, Math.cos(theta2) * radius, Math.sin(theta2) * radius, ...eqColor
    );
  }

  // Anti-meridian
  for (let lat = 0; lat < latSegments * 2; lat++) {
    const theta1 = (lat / (latSegments * 2)) * Math.PI;
    const theta2 = ((lat + 1) / (latSegments * 2)) * Math.PI;
    gridVertices.push(
      0, Math.cos(theta1) * radius, -Math.sin(theta1) * radius, ...eqColor,
      0, Math.cos(theta2) * radius, -Math.sin(theta2) * radius, ...eqColor
    );
  }

  console.log(`Generated spherical grid skybox: ${gridVertices.length / 14} line segments`);
}

function resize() {
  const container = document.getElementById('canvas-container');
  canvas.width = container.clientWidth;
  canvas.height = container.clientHeight;
  gl.viewport(0, 0, canvas.width, canvas.height);
}

// ═══════════════════════════════════════════════════════════════
// MATRIX MATH
// ═══════════════════════════════════════════════════════════════

function perspective(fov, aspect, near, far) {
  const f = 1.0 / Math.tan(fov / 2);
  const nf = 1 / (near - far);
  return new Float32Array([
    f / aspect, 0, 0, 0,
    0, f, 0, 0,
    0, 0, (far + near) * nf, -1,
    0, 0, 2 * far * near * nf, 0
  ]);
}

function lookAt(eye, center, up) {
  const z = normalize([eye[0]-center[0], eye[1]-center[1], eye[2]-center[2]]);
  const x = normalize(cross(up, z));
  const y = cross(z, x);
  return new Float32Array([
    x[0], y[0], z[0], 0,
    x[1], y[1], z[1], 0,
    x[2], y[2], z[2], 0,
    -dot(x, eye), -dot(y, eye), -dot(z, eye), 1
  ]);
}

function normalize(v) {
  const len = Math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]);
  return [v[0]/len, v[1]/len, v[2]/len];
}

function cross(a, b) {
  return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]];
}

function dot(a, b) {
  return a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
}

// ═══════════════════════════════════════════════════════════════
// MST (Maximum Spanning Tree)
// ═══════════════════════════════════════════════════════════════

let _mstEdges = null;
let _mstEdgeCount = 0;

function computeMST() {
  const N = nodes.length;
  const E = edges.length;
  if (E === 0 || N < 2) { _mstEdges = []; return; }

  // Sort edges by MI descending (MAXIMUM spanning tree)
  const sorted = [];
  for (let i = 0; i < E; i++) sorted.push(i);
  sorted.sort((a, b) => edges[b].mi - edges[a].mi);

  // Union-Find
  const parent = new Int32Array(N);
  const rank = new Uint8Array(N);
  for (let i = 0; i < N; i++) parent[i] = i;

  function find(x) {
    while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; }
    return x;
  }
  function union(a, b) {
    a = find(a); b = find(b);
    if (a === b) return false;
    if (rank[a] < rank[b]) { const t = a; a = b; b = t; }
    parent[b] = a;
    if (rank[a] === rank[b]) rank[a]++;
    return true;
  }

  _mstEdges = [];
  for (const ei of sorted) {
    if (_mstEdges.length >= N - 1) break;
    const e = edges[ei];
    if (union(e.from, e.to)) {
      _mstEdges.push(ei);
    }
  }
  _mstEdgeCount = E;
}

// ═══════════════════════════════════════════════════════════════
// LAYOUT — MST force-directed (the ONE faithful layout)
// ═══════════════════════════════════════════════════════════════

function layoutNodes() {
  // Dispatch to atomspace layout if in that mode
  if (layoutMode === 'atomspaces' || layoutMode === 'chapters') return layoutAtomspaces();
  if (!data || !data.words) return;
  rngSeed(42);

  nodes = [];
  const wordMap = new Map();

  let i = 0;
  for (const [word, info] of Object.entries(data.words)) {
    const cluster = info.cluster !== undefined ? info.cluster : -1;
    const node = {
      word: word,
      x: (rng() - 0.5) * 6,
      y: (rng() - 0.5) * 6,
      z: (rng() - 0.5) * 6,
      surprise: info.surprise || 5,
      cluster: cluster,
      community: cluster,
      classId: info.classId ?? -1,
      count: info.count || info.degree || 1,
      degree: info.degree || 0,
      globalDegree: info.globalDegree || 0,
      totalMI: info.totalMI || 0,
      senses: info.senses || 0,
      neighbors: info.neighbors || []
    };
    nodes.push(node);
    wordMap.set(word, i);
    i++;
  }

  // Create edges
  edges = [];
  if (data.pairs) {
    for (const pair of data.pairs) {
      const w1 = pair[0];
      const w2 = pair[1];
      const mi = pair[2];
      if (mi < 0.3) continue;
      const i1 = wordMap.get(w1);
      const i2 = wordMap.get(w2);
      if (i1 !== undefined && i2 !== undefined && i1 !== i2) {
        edges.push({ from: i1, to: i2, mi: mi, count: pair[3] || 1 });
      }
    }
  }

  // Run community detection
  detectCommunities();

  // MST force-directed layout
  const N = nodes.length;
  if (N > 1 && edges.length > 0) {
    computeMST();
    let maxMI = 1;
    for (const edge of edges) if (edge.mi > maxMI) maxMI = edge.mi;

    // For large graphs: community-seeded positions, then MST refinement
    if (N >= 500) {
      // Group by community, place centroids well-separated
      const communities = new Map();
      for (let i = 0; i < N; i++) {
        const cid = nodes[i].community ?? nodes[i].cluster ?? -1;
        if (!communities.has(cid)) communities.set(cid, []);
        communities.get(cid).push(i);
      }
      const realComms = [...communities.entries()].filter(([id]) => id >= 0);
      realComms.sort((a, b) => b[1].length - a[1].length);
      const commCount = realComms.length;
      const volumeR = Math.max(4, Math.cbrt(commCount) * 2.0);
      const centroids = new Map();
      const placed = [];

      for (let k = 0; k < commCount; k++) {
        const [cid] = realComms[k];
        let best = null, bestMinDist = -1;
        for (let attempt = 0; attempt < 150; attempt++) {
          const x = (rng() - 0.5) * volumeR * 2;
          const y = (rng() - 0.5) * volumeR * 2;
          const z = (rng() - 0.5) * volumeR * 2;
          let minDist = Infinity;
          for (const p of placed) {
            const d = Math.sqrt((x-p.x)**2 + (y-p.y)**2 + (z-p.z)**2);
            if (d < minDist) minDist = d;
          }
          if (placed.length === 0 || minDist > bestMinDist) {
            best = { x, y, z };
            bestMinDist = minDist;
          }
        }
        centroids.set(cid, best);
        placed.push(best);
      }

      // Place nodes near their community centroid
      for (let i = 0; i < N; i++) {
        const cid = nodes[i].community ?? nodes[i].cluster ?? -1;
        const center = centroids.get(cid);
        if (center) {
          const members = communities.get(cid).length;
          const spread = 0.3 + Math.sqrt(members) * 0.2;
          nodes[i].x = center.x + (rng() - 0.5) * spread;
          nodes[i].y = center.y + (rng() - 0.5) * spread;
          nodes[i].z = center.z + (rng() - 0.5) * spread;
        } else {
          nodes[i].x = (rng() - 0.5) * 2;
          nodes[i].y = (rng() - 0.5) * 2;
          nodes[i].z = (rng() - 0.5) * 2;
        }
      }
    }

    // MST spring refinement
    const ITERS = N < 500 ? 300 : 200;
    const SAMPLES = Math.min(N, 20);
    const REPEL = 0.08;
    let temp = N < 500 ? 1.0 : 0.6;

    for (let iter = 0; iter < ITERS; iter++) {
      const dt = temp;

      // MST springs with MI-proportional ideal distance
      if (_mstEdges) {
        for (const ei of _mstEdges) {
          const edge = edges[ei];
          const n1 = nodes[edge.from]; const n2 = nodes[edge.to];
          const dx = n2.x - n1.x; const dy = n2.y - n1.y; const dz = n2.z - n1.z;
          const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.01;
          const idealDist = (N < 500 ? 1.2 : 0.6) * Math.sqrt(maxMI / Math.max(edge.mi, 0.01));
          const force = (dist - idealDist) * 0.12 * dt;
          const fx = dx/dist*force; const fy = dy/dist*force; const fz = dz/dist*force;
          n1.x += fx; n1.y += fy; n1.z += fz;
          n2.x -= fx; n2.y -= fy; n2.z -= fz;
        }
      }

      // Non-MST springs
      const mstSet = new Set(_mstEdges);
      const nonMstSamples = Math.min(edges.length, Math.floor(N * 0.3));
      for (let k = 0; k < nonMstSamples; k++) {
        const ei = Math.floor(rng() * edges.length);
        if (mstSet.has(ei)) continue;
        const edge = edges[ei];
        const n1 = nodes[edge.from]; const n2 = nodes[edge.to];
        const dx = n2.x - n1.x; const dy = n2.y - n1.y; const dz = n2.z - n1.z;
        const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.01;
        const idealDist = (N < 500 ? 1.2 : 0.6) * Math.sqrt(maxMI / Math.max(edge.mi, 0.01));
        const force = (dist - idealDist) * 0.03 * dt;
        const fx = dx/dist*force; const fy = dy/dist*force; const fz = dz/dist*force;
        n1.x += fx; n1.y += fy; n1.z += fz;
        n2.x -= fx; n2.y -= fy; n2.z -= fz;
      }

      // Short-range stochastic repulsion
      for (let i = 0; i < N; i++) {
        if (!nodes[i].degree) continue;
        for (let s = 0; s < SAMPLES; s++) {
          const j = Math.floor(rng() * N);
          if (j === i || !nodes[j].degree) continue;
          const dx = nodes[j].x - nodes[i].x; const dy = nodes[j].y - nodes[i].y; const dz = nodes[j].z - nodes[i].z;
          const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.01;
          if (dist > 4) continue;
          const force = REPEL * dt / (dist * dist);
          nodes[i].x -= dx/dist*force; nodes[i].y -= dy/dist*force; nodes[i].z -= dz/dist*force;
          nodes[j].x += dx/dist*force; nodes[j].y += dy/dist*force; nodes[j].z += dz/dist*force;
        }
      }

      // Gentle center gravity + clamp
      const MAX_R = 8;
      for (let i = 0; i < N; i++) {
        if (!nodes[i].degree) continue;
        nodes[i].x *= 0.999; nodes[i].y *= 0.999; nodes[i].z *= 0.999;
        const r = Math.sqrt(nodes[i].x**2 + nodes[i].y**2 + nodes[i].z**2);
        if (r > MAX_R) { const s = MAX_R/r; nodes[i].x *= s; nodes[i].y *= s; nodes[i].z *= s; }
      }

      temp *= N < 500 ? 0.985 : 0.99;
    }

    // Auto-zoom camera to fit the layout
    let maxDist = 0;
    for (const node of nodes) {
      const d = Math.sqrt(node.x*node.x + node.y*node.y + node.z*node.z);
      if (d > maxDist) maxDist = d;
    }
    zoom = Math.max(1.5, (maxDist + 1) / 3.0);
  }

  calculateNodeDensity();
  computeCentroids();
  updateClusterList();
  updateWordClassList();
  console.log(`Laid out ${nodes.length} nodes, ${edges.length} edges`);
  validateLayout();
}

// ═══════════════════════════════════════════════════════════════
// ATOMSPACE LAYOUT — Multi-cloud pentagon with center root
// ═══════════════════════════════════════════════════════════════

// Grid cell wireframe data for atomspace mode
let atomspaceGridCells = []; // [{cx,cy,cz,hw,hh,hd,color}] — center + half-extents

function layoutAtomspaces() {
  if (!data || !data.atomspaces || !data.atomspaces.nodes) {
    console.log('No atomspace data, falling back to unified layout');
    layoutMode = 'unified';
    return layoutNodes();
  }

  if (!data || !data.words) return;
  rngSeed(42);

  nodes = [];
  const wordMap = new Map();
  atomspaceColors = new Map();
  atomspaceCentroids = new Map();
  atomspaceNodes = new Map();
  atomspaceGridCells = [];

  // Initialize atomspace colors
  const asNodes = data.atomspaces.nodes;
  for (const [name, info] of Object.entries(asNodes)) {
    atomspaceColors.set(name, getAtomspaceColor(name));
    atomspaceNodes.set(name, []);
  }

  // Determine primary atomspace for each word
  function primaryAtomspace(word) {
    const info = data.words[word];
    if (!info || !info.atomspaces) return 'common';
    const as = info.atomspaces;
    for (const a of as) {
      if (a !== 'common') return a;
    }
    return 'common';
  }

  // Build ALL nodes — show everything, let structure emerge naturally
  let i = 0;
  for (const [word, info] of Object.entries(data.words)) {
    const cluster = info.cluster !== undefined ? info.cluster : -1;
    const senses = info.senses || [];
    const hasSenses = senses.length > 1;

    if (hasSenses) {
      const senseIndices = [];
      for (const sense of senses) {
        const senseAs = (sense.in && sense.in.length > 0) ? sense.in[0] : primaryAtomspace(word);
        const node = {
          word: word,
          x: (rng() - 0.5) * 6, y: (rng() - 0.5) * 6, z: (rng() - 0.5) * 6,
          surprise: info.surprise || 5, cluster, community: cluster,
          classId: sense.classId ?? info.classId ?? -1,
          count: info.count || info.degree || 1,
          degree: info.degree || 0, globalDegree: info.globalDegree || 0,
          totalMI: info.totalMI || 0, senseId: sense.id,
          senseComps: sense.in || [], sensePartners: sense.partners || [],
          neighbors: info.neighbors || [],
          atomspace: senseAs, atomspaces: info.atomspaces || [senseAs],
          polysemy: true, isSenseNode: true
        };
        nodes.push(node);
        senseIndices.push(i);
        if (atomspaceNodes.has(senseAs)) atomspaceNodes.get(senseAs).push(i);
        i++;
      }
      if (senseIndices.length > 0) {
        wordMap.set(word, senseIndices[0]);
        wordMap.set(word + '__senses', senseIndices);
      }
    } else {
      const primaryAs = primaryAtomspace(word);
      const node = {
        word: word,
        x: (rng() - 0.5) * 6, y: (rng() - 0.5) * 6, z: (rng() - 0.5) * 6,
        surprise: info.surprise || 5, cluster, community: cluster,
        classId: info.classId ?? -1,
        count: info.count || info.degree || 1,
        degree: info.degree || 0, globalDegree: info.globalDegree || 0,
        totalMI: info.totalMI || 0,
        senseId: senses.length === 1 ? senses[0].id : -1,
        neighbors: info.neighbors || [],
        atomspace: primaryAs, atomspaces: info.atomspaces || [primaryAs],
        polysemy: info.polysemy || false, isSenseNode: false
      };
      nodes.push(node);
      wordMap.set(word, i);
      if (atomspaceNodes.has(primaryAs)) atomspaceNodes.get(primaryAs).push(i);
      i++;
    }
  }

  // Build edges (with atomspace source from pairs)
  // For multi-sense words, connect to the sense node in the pair's atomspace
  edges = [];

  // Helper: find the right node index for a word in a given atomspace
  function findNodeInAs(word, asName) {
    const senseKey = word + '__senses';
    const senseIndices = wordMap.get(senseKey);
    if (senseIndices) {
      // Multi-sense word — find the sense node in this atomspace
      for (const si of senseIndices) {
        if (nodes[si].atomspace === asName) return si;
      }
      // Fallback to first sense if no match
      return senseIndices[0];
    }
    // Single node
    return wordMap.get(word);
  }

  if (data.pairs) {
    for (const pair of data.pairs) {
      const w1 = pair[0];
      const w2 = pair[1];
      const mi = pair[2];
      const asSource = pair[3] || null;
      if (mi < 0.3) continue;
      const i1 = asSource ? findNodeInAs(w1, asSource) : wordMap.get(w1);
      const i2 = asSource ? findNodeInAs(w2, asSource) : wordMap.get(w2);
      if (i1 !== undefined && i2 !== undefined && i1 !== i2) {
        edges.push({ from: i1, to: i2, mi: mi, count: 1, atomspace: asSource });
      }
    }
  }

  // Add cross-sense filaments: connect same word's sense nodes across atomspaces
  for (const [key, val] of wordMap.entries()) {
    if (key.endsWith('__senses') && Array.isArray(val) && val.length > 1) {
      for (let s = 0; s < val.length - 1; s++) {
        edges.push({
          from: val[s], to: val[s + 1],
          mi: 0.5, count: 1,
          atomspace: null,
          isFilament: true  // thin cross-atomspace link
        });
      }
    }
  }

  detectCommunities();

  // ── 3D Grid Layout: cells for each atomspace ──
  // Layout: 2×3 grid in XZ plane, common elevated above center
  const leafNames = [];
  for (const [name, info] of Object.entries(asNodes)) {
    if (info.depth > 0) leafNames.push(name);
  }
  const nLeaves = leafNames.length;

  // Grid dimensions — 2 rows × 3 cols (or adapt to leaf count)
  const cols = Math.min(nLeaves, 3);
  const rows = Math.ceil(nLeaves / cols);
  const cellSize = gridCellSize;
  const cellGap = gridCellGap;
  const cellStep = cellSize + cellGap;
  const gridW = cols * cellStep - cellGap;
  const gridD = rows * cellStep - cellGap;

  // Place leaves in grid cells
  for (let k = 0; k < nLeaves; k++) {
    const col = k % cols;
    const row = Math.floor(k / cols);
    const cx = (col - (cols - 1) / 2) * cellStep;
    const cy = 0;
    const cz = (row - (rows - 1) / 2) * cellStep;
    atomspaceCentroids.set(leafNames[k], { x: cx, y: cy, z: cz });

    const color = getAtomspaceColor(leafNames[k]);
    atomspaceGridCells.push({
      name: leafNames[k],
      cx, cy, cz,
      hw: cellSize / 2, hh: cellSize / 2, hd: cellSize / 2,
      color
    });
  }

  // Common (root) — only show if it has words
  const commonY = cellSize * 0.8 + cellGap;
  atomspaceCentroids.set('common', { x: 0, y: commonY, z: 0 });
  const commonColor = getAtomspaceColor('common');
  const commonHalf = cellSize / 3;
  const commonWordCount = data.atomspaces.nodes.common ? (data.atomspaces.nodes.common.wordCount || 0) : 0;
  if (commonWordCount > 0) {
    atomspaceGridCells.push({
      name: 'common',
      cx: 0, cy: commonY, cz: 0,
      hw: commonHalf, hh: commonHalf, hd: commonHalf,
      color: commonColor
    });
  }

  // ── Per-Atomspace Mindmap Layout ──
  //
  // Each book gets its own radial mindmap WITHIN its grid cell bounding box.
  // Center of cell = most certain concepts (low surprise, high connectivity)
  // Branches = communities within that atomspace
  // Distance from cell center = surprise (higher = farther out)
  // Y = small jitter (NOT a data dimension)

  const N = nodes.length;

  // Step 1: Compute centrality score per node (global — used for color + core marking)
  let maxCentrality = 0;
  for (let i = 0; i < N; i++) {
    const node = nodes[i];
    const deg = node.degree || node.globalDegree || 1;
    const sur = node.surprise || 5;
    node._centrality = deg * (1 / (1 + sur));
    if (node._centrality > maxCentrality) maxCentrality = node._centrality;
  }
  maxCentrality = Math.max(maxCentrality, 0.01);

  // Build adjacency for MI-weighted angular attraction
  const adj = new Map();
  for (const edge of edges) {
    if (edge.isFilament) continue;
    if (!adj.has(edge.from)) adj.set(edge.from, []);
    if (!adj.has(edge.to)) adj.set(edge.to, []);
    adj.get(edge.from).push({ n: edge.to, mi: edge.mi });
    adj.get(edge.to).push({ n: edge.from, mi: edge.mi });
  }

  // Step 2: Layout each atomspace's nodes within its grid cell
  const fillFraction = 0.45; // 45% fill, 55% gap between community sectors

  // Build a lookup from atomspace name → grid cell
  const cellLookup = new Map();
  for (const cell of atomspaceGridCells) {
    cellLookup.set(cell.name, cell);
  }

  for (const [asName, asIndices] of atomspaceNodes) {
    if (asIndices.length === 0) continue;
    const cell = cellLookup.get(asName);
    if (!cell) continue; // No grid cell for this atomspace

    const { cx, cy, cz, hw, hd } = cell;
    // Max radius = 90% of the smaller half-dimension so nodes stay inside the box
    const maxRadius = Math.min(hw, hd) * 0.90;

    // Compute surprise ranks WITHIN this atomspace (local ranking)
    const localNodes = asIndices.map(idx => ({ idx, sur: nodes[idx].surprise || 5 }));
    localNodes.sort((a, b) => a.sur - b.sur);
    const localRank = new Map();
    for (let k = 0; k < localNodes.length; k++) {
      localRank.set(localNodes[k].idx, k / Math.max(localNodes.length - 1, 1));
    }

    // Detect communities within this atomspace
    const rawComs = new Map();
    for (const idx of asIndices) {
      const cid = nodes[idx].community ?? nodes[idx].cluster ?? -1;
      if (!rawComs.has(cid)) rawComs.set(cid, []);
      rawComs.get(cid).push(idx);
    }

    // Merge small communities: keep top 6 largest, merge rest into "other"
    const MAX_SECTORS = 6;
    const sortedRaw = [...rawComs.entries()].sort((a, b) => b[1].length - a[1].length);
    const localComs = new Map();
    const otherMembers = [];
    for (let ci = 0; ci < sortedRaw.length; ci++) {
      if (ci < MAX_SECTORS) {
        localComs.set(sortedRaw[ci][0], sortedRaw[ci][1]);
      } else {
        otherMembers.push(...sortedRaw[ci][1]);
      }
    }
    if (otherMembers.length > 0) {
      localComs.set(-999, otherMembers); // "other" bucket
    }

    // Sort communities by total centrality within this atomspace
    const localComList = [...localComs.entries()].sort((a, b) => {
      const sumA = a[1].reduce((s, i) => s + (nodes[i]._centrality || 0), 0);
      const sumB = b[1].reduce((s, i) => s + (nodes[i]._centrality || 0), 0);
      return sumB - sumA;
    });

    const nLocalComs = localComList.length;
    const localNodeCount = asIndices.length;

    // Assign angular sectors per community within this atomspace
    let angleOff = 0;
    const localComAngles = new Map();
    for (let ci = 0; ci < nLocalComs; ci++) {
      const [comId, members] = localComList[ci];
      const sectorFull = (2 * Math.PI) * (members.length / localNodeCount);
      const sectorFill = sectorFull * fillFraction;
      const gapHalf = (sectorFull - sectorFill) / 2;
      localComAngles.set(comId, {
        start: angleOff + gapHalf,
        end: angleOff + gapHalf + sectorFill,
        center: angleOff + sectorFull / 2
      });
      angleOff += sectorFull;
    }

    // Position each node radially within this cell
    for (let ci = 0; ci < nLocalComs; ci++) {
      const [comId, members] = localComList[ci];
      const sector = localComAngles.get(comId);
      if (!sector) continue;
      const sectorWidth = sector.end - sector.start;

      // Sort members by centrality descending (hubs → center)
      members.sort((a, b) => (nodes[b]._centrality || 0) - (nodes[a]._centrality || 0));

      for (let mi = 0; mi < members.length; mi++) {
        const idx = members[mi];
        const node = nodes[idx];

        // Radial distance: rank-based within this atomspace
        const rNorm = localRank.get(idx) || 0;
        const innerR = maxRadius * 0.08;
        const finalR = innerR + rNorm * (maxRadius - innerR);

        // Angle: within community sector
        let angle;
        if (members.length === 1) {
          angle = sector.center;
        } else {
          let bandPos;
          if (mi === 0) {
            bandPos = 0;
          } else {
            const side = (mi % 2 === 1) ? -1 : 1;
            const tier = Math.ceil(mi / 2);
            bandPos = side * (tier / Math.ceil(members.length / 2)) * 0.5;
          }
          angle = sector.center + bandPos * sectorWidth;
        }

        // Small angular scatter
        angle += (rng() - 0.5) * 0.04;

        // Position relative to cell center
        // Y (up): uniform spread within box, based on index position (not surprise)
        const { hh } = cell;
        const yNorm = mi / Math.max(members.length - 1, 1); // even spread by centrality rank
        node.x = cx + Math.cos(angle) * finalR;
        node.z = cz + Math.sin(angle) * finalR;
        node.y = cy + (yNorm - 0.5) * 2 * hh * 0.85 + (rng() - 0.5) * 0.3;
        node._angle = angle;
        node._radius = finalR;
        node._cellName = asName;
      }
    }

    // MI-weighted angular attraction within this atomspace (3 iterations)
    for (let iter = 0; iter < 3; iter++) {
      for (const idx of asIndices) {
        const node = nodes[idx];
        const neighbors = adj.get(idx);
        if (!neighbors || neighbors.length === 0) continue;

        let pullAngle = 0, totalWeight = 0;
        for (const { n, mi } of neighbors) {
          if (n >= N) continue;
          const other = nodes[n];
          // Use angle relative to cell center
          pullAngle += Math.atan2(other.z - cz, other.x - cx) * mi;
          totalWeight += mi;
        }

        if (totalWeight > 0 && node._radius > 0) {
          const targetAngle = pullAngle / totalWeight;
          const newAngle = node._angle * 0.9 + targetAngle * 0.1;
          node.x = cx + Math.cos(newAngle) * node._radius;
          node.z = cz + Math.sin(newAngle) * node._radius;
          node._angle = newAngle;
        }
      }
    }

    console.log(`  Mindmap "${asName}": ${localNodeCount} nodes, ${nLocalComs} communities, maxR=${maxRadius.toFixed(1)}`);
  }

  // Mark inner core: words in 4+ atomspaces
  for (let i = 0; i < N; i++) {
    const node = nodes[i];
    const asCount = (node.atomspaces || []).length;
    node._isCore = asCount >= Math.min(4, atomspaceNodes.size);
  }

  // ── Chapter layout override: per-book chapter rings within cell ──
  if (layoutMode === 'chapters' && data && data.atomspaces && data.atomspaces.nodes) {
    const asNodeData = data.atomspaces.nodes;

    for (const [asName, asIndices] of atomspaceNodes) {
      if (asIndices.length === 0) continue;
      const cell = cellLookup.get(asName);
      if (!cell) continue;
      const asInfo = asNodeData[asName];
      if (!asInfo || !asInfo.chapters || asInfo.chapters.length === 0) continue;

      const { cx, cy, cz, hw, hd } = cell;
      const maxR = Math.min(hw, hd) * 0.90;
      const chapters = asInfo.chapters;
      const nChapters = chapters.length;
      const ringGap = maxR / (nChapters + 1);

      // Build chapter → word indices for this atomspace
      const chapterWordSets = new Map();
      for (const ch of chapters) chapterWordSets.set(ch, []);

      for (const idx of asIndices) {
        const word = nodes[idx].word;
        const wordData = data.words[word];
        if (!wordData || !wordData.chapters || !wordData.chapters[asName]) continue;
        const wordChs = wordData.chapters[asName];
        for (const ch of wordChs) {
          if (chapterWordSets.has(ch)) {
            chapterWordSets.get(ch).push(idx);
          }
        }
      }

      // Concentric rings per chapter within this cell
      const placed = new Set();
      for (let ci = 0; ci < nChapters; ci++) {
        const chName = chapters[ci];
        const chNodes = chapterWordSets.get(chName) || [];
        const ringR = ringGap * (ci + 1);

        chNodes.sort((a, b) => (nodes[a].community || 0) - (nodes[b].community || 0));

        for (let ni = 0; ni < chNodes.length; ni++) {
          const idx = chNodes[ni];
          if (placed.has(idx)) continue; // First chapter wins
          placed.add(idx);
          const angle = (ni / Math.max(chNodes.length, 1)) * Math.PI * 2;
          const chYNorm = 1.0 - (ci / Math.max(nChapters - 1, 1)); // early chapters top
          nodes[idx].x = cx + Math.cos(angle) * ringR;
          nodes[idx].z = cz + Math.sin(angle) * ringR;
          nodes[idx].y = cy + (chYNorm - 0.5) * 2 * hd * 0.85 + (rng() - 0.5) * 0.2;
          nodes[idx]._chapterRing = ci;
        }
      }

      // Words not in any chapter: place near cell center
      for (const idx of asIndices) {
        if (!placed.has(idx)) {
          nodes[idx].x = cx + (rng() - 0.5) * 2;
          nodes[idx].z = cz + (rng() - 0.5) * 2;
          nodes[idx].y = cy + (rng() - 0.5) * hd * 0.5;
        }
      }

      console.log(`  Chapter layout "${asName}": ${nChapters} chapters, maxR=${maxR.toFixed(1)}`);
    }
  }

  console.log(`Per-atomspace mindmap: ${N} nodes in ${atomspaceNodes.size} atomspaces`);

  // Auto-zoom for per-atomspace layout: zoom to fit grid
  const gridExtent = Math.max(gridW, gridD, cellSize) / 2 + cellSize;
  zoom = Math.max(3.0, gridExtent / 2.5);
  rotation.x = Math.PI / 2 - 0.15; // Nearly top-down (~81 degrees)
  rotation.y = 0;

  // Atomspace centroids = grid cell centers (already computed)
  // (atomspaceCentroids was set during grid cell creation above)

  calculateNodeDensity();
  computeCentroids();
  updateClusterList();
  updateWordClassList();
  updateAtomspacePanel();
  // Debug: log grid cell positions
  for (const cell of atomspaceGridCells) {
    console.log(`  Grid cell "${cell.name}": center=(${cell.cx.toFixed(1)}, ${cell.cy.toFixed(1)}, ${cell.cz.toFixed(1)}), half=${cell.hw.toFixed(1)}`);
  }
  // Debug: log node distribution per atomspace
  for (const [name, indices] of atomspaceNodes) {
    if (indices.length > 0) {
      const sample = nodes[indices[0]];
      console.log(`  Atomspace "${name}": ${indices.length} nodes, sample pos=(${sample.x.toFixed(1)}, ${sample.y.toFixed(1)}, ${sample.z.toFixed(1)})`);
    }
  }
  console.log(`Atomspace layout: ${nodes.length} nodes in ${atomspaceNodes.size} atomspaces, ${atomspaceGridCells.length} grid cells`);
  validateLayout();
}

function updateAtomspacePanel() {
  const panel = document.getElementById('atomspace-list');
  if (!panel || !data || !data.atomspaces) return;
  panel.innerHTML = '';

  const asNodes = data.atomspaces.nodes;
  // Sort: root first, then leaves by sentence count
  const sorted = Object.entries(asNodes).sort((a, b) => {
    if (a[1].depth === 0) return -1;
    if (b[1].depth === 0) return 1;
    return (b[1].sentences || 0) - (a[1].sentences || 0);
  });

  for (const [name, info] of sorted) {
    const div = document.createElement('div');
    div.className = 'cluster-item';
    const color = getAtomspaceColor(name);
    div.style.borderLeft = `3px solid rgb(${Math.floor(color[0]*255)},${Math.floor(color[1]*255)},${Math.floor(color[2]*255)})`;

    const nameSpan = document.createElement('strong');
    nameSpan.textContent = name;
    nameSpan.style.color = `rgb(${Math.floor(color[0]*255)},${Math.floor(color[1]*255)},${Math.floor(color[2]*255)})`;
    div.appendChild(nameSpan);

    if (info.isIntersection) {
      const badge = document.createElement('span');
      badge.textContent = ' ROOT';
      badge.style.color = '#888';
      badge.style.fontSize = '9px';
      div.appendChild(badge);
    }

    const statsDiv = document.createElement('div');
    statsDiv.style.color = '#666';
    statsDiv.style.fontSize = '9px';
    statsDiv.style.marginTop = '2px';
    const parts = [];
    if (info.wordCount !== undefined) parts.push(`${info.wordCount} words`);
    if (info.pairCount !== undefined) parts.push(`${info.pairCount} pairs`);
    if (info.sentences !== undefined) parts.push(`${info.sentences} sent`);
    if (info.classes !== undefined) parts.push(`${info.classes} classes`);
    if (info.state) parts.push(info.state);
    statsDiv.textContent = parts.join(' | ');
    div.appendChild(statsDiv);

    panel.appendChild(div);
  }
}

function calculateNodeDensity() {
  for (const node of nodes) {
    node.degree = 0;
    node.totalMI = 0;
  }
  for (const edge of edges) {
    nodes[edge.from].degree++;
    nodes[edge.to].degree++;
    nodes[edge.from].totalMI += edge.mi;
    nodes[edge.to].totalMI += edge.mi;
  }
  let maxDegree = 1;
  let maxTotalMI = 1;
  for (const node of nodes) {
    if (node.degree > maxDegree) maxDegree = node.degree;
    if (node.totalMI > maxTotalMI) maxTotalMI = node.totalMI;
  }
  for (const node of nodes) {
    node.densityNorm = node.degree / maxDegree;
    node.miNorm = node.totalMI / maxTotalMI;
  }
  console.log(`Node density calculated. Max degree: ${maxDegree}, Max total MI: ${maxTotalMI.toFixed(2)}`);
}

// ═══════════════════════════════════════════════════════════════
// COMMUNITY DETECTION (Label Propagation)
// ═══════════════════════════════════════════════════════════════

function detectCommunities() {
  if (nodes.length < 3 || edges.length < 2) return;
  const N = nodes.length;

  // Build adjacency
  const adj = new Array(N).fill(null).map(() => []);
  for (const edge of edges) {
    if (edge.mi > 0.5) {
      adj[edge.from].push({ n: edge.to, mi: edge.mi });
      adj[edge.to].push({ n: edge.from, mi: edge.mi });
    }
  }

  // Phase 1: Label propagation with server-classified nodes as pinned anchors
  const labels = new Array(N);
  const pinned = new Uint8Array(N);
  let hasExisting = false;
  for (let i = 0; i < N; i++) {
    const existing = nodes[i].community;
    const hasServerClass = nodes[i].cluster !== undefined && nodes[i].cluster >= 0;
    if (existing !== undefined && existing >= 0) {
      labels[i] = existing;
      hasExisting = true;
      if (hasServerClass) pinned[i] = 1;
    } else {
      labels[i] = i;
    }
  }

  const maxIters = hasExisting ? 5 : 15;
  for (let iter = 0; iter < maxIters; iter++) {
    let changed = 0;
    const order = [...Array(N).keys()];
    for (let i = order.length - 1; i > 0; i--) {
      const j = Math.floor(rng() * (i + 1));
      [order[i], order[j]] = [order[j], order[i]];
    }
    for (const i of order) {
      if (pinned[i]) continue;
      if (adj[i].length === 0) continue;
      const votes = new Map();
      for (const { n, mi } of adj[i]) {
        const lbl = labels[n];
        const weight = pinned[n] ? mi * 3 : mi;
        votes.set(lbl, (votes.get(lbl) || 0) + weight);
      }
      let bestLabel = labels[i], bestScore = -1;
      for (const [lbl, score] of votes) {
        if (score > bestScore) { bestScore = score; bestLabel = lbl; }
      }
      if (bestLabel !== labels[i]) { labels[i] = bestLabel; changed++; }
    }
    if (changed === 0) break;
  }

  // Phase 2: Hierarchical merging
  const TARGET_MAX = Math.min(40, Math.max(15, Math.floor(N / 30)));
  const MIN_SIZE = Math.max(5, Math.floor(N / TARGET_MAX));

  const commMembers = new Map();
  for (let i = 0; i < N; i++) {
    const lbl = labels[i];
    if (!commMembers.has(lbl)) commMembers.set(lbl, new Set());
    commMembers.get(lbl).add(i);
  }

  for (let round = 0; round < 500; round++) {
    let smallest = null, smallestSize = Infinity;
    for (const [lbl, members] of commMembers) {
      if (members.size < smallestSize) {
        smallestSize = members.size;
        smallest = lbl;
      }
    }
    if (smallestSize >= MIN_SIZE && commMembers.size <= TARGET_MAX) break;
    if (commMembers.size <= TARGET_MAX && smallestSize >= 3) break;
    if (commMembers.size <= 2) break;

    const neighborWeights = new Map();
    for (const nodeIdx of commMembers.get(smallest)) {
      for (const { n, mi } of adj[nodeIdx]) {
        const neighborComm = labels[n];
        if (neighborComm !== smallest) {
          neighborWeights.set(neighborComm, (neighborWeights.get(neighborComm) || 0) + mi);
        }
      }
    }

    if (neighborWeights.size === 0) {
      let largestComm = null, largestSize = 0;
      for (const [lbl, members] of commMembers) {
        if (lbl !== smallest && members.size > largestSize) {
          largestSize = members.size;
          largestComm = lbl;
        }
      }
      if (largestComm === null) break;
      neighborWeights.set(largestComm, 0);
    }

    let bestNeighbor = null, bestWeight = -1;
    for (const [lbl, w] of neighborWeights) {
      if (w > bestWeight) { bestWeight = w; bestNeighbor = lbl; }
    }

    for (const nodeIdx of commMembers.get(smallest)) {
      labels[nodeIdx] = bestNeighbor;
      commMembers.get(bestNeighbor).add(nodeIdx);
    }
    commMembers.delete(smallest);
  }

  // Phase 3: Renumber to sequential IDs sorted by size
  const commCounts = new Map();
  for (const lbl of labels) {
    commCounts.set(lbl, (commCounts.get(lbl) || 0) + 1);
  }
  const sorted = [...commCounts.entries()].sort((a, b) => b[1] - a[1]);
  const communityMap = new Map();
  sorted.forEach(([lbl], idx) => {
    communityMap.set(lbl, idx);
  });

  for (let i = 0; i < N; i++) {
    nodes[i].community = communityMap.get(labels[i]) ?? -1;
  }

  const numCommunities = sorted.length;
  const sizes = sorted.map(([, c]) => c);
  console.log(`Communities: ${numCommunities} (sizes: ${sizes.slice(0, 10).join(', ')}${numCommunities > 10 ? '...' : ''})`);
  return numCommunities;
}

// Community centroids for centroid labels
let communityCentroids = new Map();

// Max centrality for mindmap color scaling (computed during render)
let maxCentralityForColor = 0.01;
let centralityThreshold50 = 0;

function computeCentroids() {
  communityCentroids = new Map();
  for (const node of nodes) {
    const c = node.community ?? -1;
    if (c < 0) continue;
    if (!communityCentroids.has(c)) {
      communityCentroids.set(c, { x: 0, y: 0, z: 0, count: 0 });
    }
    const cen = communityCentroids.get(c);
    cen.x += node.x;
    cen.y += node.y;
    cen.z += node.z;
    cen.count++;
  }
  for (const cen of communityCentroids.values()) {
    cen.x /= cen.count;
    cen.y /= cen.count;
    cen.z /= cen.count;
  }
}

// ═══════════════════════════════════════════════════════════════
// RENDER
// ═══════════════════════════════════════════════════════════════

function render() {
  gl.clearColor(0.02, 0.02, 0.04, 1);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

  if (autoRotate && !flyMode) {
    rotation.y += 0.003;
  }

  const aspect = canvas.width / canvas.height;
  const projection = perspective(Math.PI / 4, aspect, 0.1, 200);

  let view;
  if (flyMode) {
    flyStep();
    const lookX = flyPos.x - Math.sin(flyYaw) * Math.cos(flyPitch);
    const lookY = flyPos.y + Math.sin(flyPitch);
    const lookZ = flyPos.z - Math.cos(flyYaw) * Math.cos(flyPitch);
    view = lookAt([flyPos.x, flyPos.y, flyPos.z], [lookX, lookY, lookZ], [0, 1, 0]);
  } else {
    const camDist = zoom * 5;
    const camX = panTarget.x + Math.sin(rotation.y) * Math.cos(rotation.x) * camDist;
    const camY = panTarget.y + Math.sin(rotation.x) * camDist;
    const camZ = panTarget.z + Math.cos(rotation.y) * Math.cos(rotation.x) * camDist;
    view = lookAt([camX, camY, camZ], [panTarget.x, panTarget.y, panTarget.z], [0, 1, 0]);
  }

  if (nodes.length === 0) {
    drawSkybox(projection, view);
    requestAnimationFrame(render);
    return;
  }

  let maxSurprise = 1;
  for (const n of nodes) { if (n.surprise > maxSurprise) maxSurprise = n.surprise; }

  // Draw edges first (behind points)
  gl.depthMask(false);

  let maxMI = 1;
  for (const edge of edges) {
    if (edge.mi > maxMI) maxMI = edge.mi;
  }

  const maxLineVerts = edges.length * 2;
  if (!render._lineArr || render._lineArr.length < maxLineVerts * 7) {
    render._lineArr = new Float32Array(Math.max(maxLineVerts * 7, 16384));
  }
  const lineArr = render._lineArr;
  let lineIdx = 0;

  // Draw intra-atomspace edges first (bright), then filaments (dim)
  for (const edge of edges) {
    const n1 = nodes[edge.from];
    const n2 = nodes[edge.to];
    if (!n1 || !n2) continue;

    // Detail level filtering for edges
    if ((layoutMode === 'atomspaces' || layoutMode === 'chapters') && detailLevel <= 1) continue;
    if (detailLevel === 2) {
      // Only show edges between core nodes
      if (!(n1._isCore || (n1._centrality || 0) >= centralityThreshold50) ||
          !(n2._isCore || (n2._centrality || 0) >= centralityThreshold50)) continue;
    }

    let r, g, b, alpha;

    if (edge.isFilament) {
      // Cross-sense filaments: thin red dashed-style, very dim
      r = 0.8; g = 0.2; b = 0.2;
      alpha = 0.12 * lineWidthScale;
    } else if (n1.atomspace !== n2.atomspace) {
      // Cross-atomspace MI edge: very dim
      if (edge.mi < miThreshold) continue;
      r = 0.4; g = 0.4; b = 0.5;
      alpha = 0.08 * lineWidthScale;
    } else {
      // INTRA-atomspace edge: bright, MI-colored
      if (edge.mi < miThreshold) continue;
      if (n1.surprise > vagueThreshold || n2.surprise > vagueThreshold) continue;
      const t = edge.mi / maxMI;
      if (t < 0.33) {
        const s = t / 0.33;
        r = 0.1; g = 0.3 + s * 0.5; b = 0.6 + s * 0.2;
      } else if (t < 0.66) {
        const s = (t - 0.33) / 0.33;
        r = s * 0.8; g = 0.8 + s * 0.2; b = 0.8 - s * 0.5;
      } else {
        const s = (t - 0.66) / 0.34;
        r = 0.8 + s * 0.2; g = 1.0; b = 0.3 + s * 0.7;
      }
      // Boost alpha for intra-atomspace edges
      alpha = (0.5 + t * 0.5) * lineWidthScale;
    }

    lineArr[lineIdx++] = n1.x; lineArr[lineIdx++] = n1.y; lineArr[lineIdx++] = n1.z;
    lineArr[lineIdx++] = r; lineArr[lineIdx++] = g; lineArr[lineIdx++] = b; lineArr[lineIdx++] = alpha;
    lineArr[lineIdx++] = n2.x; lineArr[lineIdx++] = n2.y; lineArr[lineIdx++] = n2.z;
    lineArr[lineIdx++] = r; lineArr[lineIdx++] = g; lineArr[lineIdx++] = b; lineArr[lineIdx++] = alpha;
  }
  const lineVertCount = lineIdx / 7;

  if (lineVertCount > 0) {
    gl.useProgram(lineProgram);
    gl.bindBuffer(gl.ARRAY_BUFFER, lineBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, lineArr.subarray(0, lineIdx), gl.DYNAMIC_DRAW);

    const posLoc = gl.getAttribLocation(lineProgram, 'a_position');
    const colorLoc = gl.getAttribLocation(lineProgram, 'a_color');
    gl.enableVertexAttribArray(posLoc);
    gl.enableVertexAttribArray(colorLoc);
    const stride = 7 * 4;
    gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, stride, 0);
    gl.vertexAttribPointer(colorLoc, 4, gl.FLOAT, false, stride, 12);
    gl.uniformMatrix4fv(gl.getUniformLocation(lineProgram, 'u_projection'), false, projection);
    gl.uniformMatrix4fv(gl.getUniformLocation(lineProgram, 'u_view'), false, view);
    gl.uniform1f(gl.getUniformLocation(lineProgram, 'u_lineAlpha'), lineWidthScale);
    gl.drawArrays(gl.LINES, 0, lineVertCount);
  }

  // Draw per-atomspace guide rings + bounding boxes
  if (layoutMode === 'atomspaces' && atomspaceGridCells.length > 0) {
    const ringSegments = 48;
    const ringCount = 3; // 33%, 66%, 100% within each cell
    // Budget: per cell: ringCount*ringSegments + 12 box edges = ~156 lines per cell
    const maxLines = atomspaceGridCells.length * (ringCount * ringSegments + 24 + ringSegments) + 100;
    const gridArr = new Float32Array(maxLines * 14);
    let gIdx = 0;

    function pushLine(x1,y1,z1, x2,y2,z2, r,g,b,a) {
      gridArr[gIdx++]=x1; gridArr[gIdx++]=y1; gridArr[gIdx++]=z1;
      gridArr[gIdx++]=r; gridArr[gIdx++]=g; gridArr[gIdx++]=b; gridArr[gIdx++]=a;
      gridArr[gIdx++]=x2; gridArr[gIdx++]=y2; gridArr[gIdx++]=z2;
      gridArr[gIdx++]=r; gridArr[gIdx++]=g; gridArr[gIdx++]=b; gridArr[gIdx++]=a;
    }

    const ringAlpha = gridOpacity * 0.35;

    for (const cell of atomspaceGridCells) {
      const {cx, cy, cz, hw, hh, hd, color} = cell;
      const cellMaxR = Math.min(hw, hd) * 0.90;

      if (layoutMode === 'chapters') {
        // Chapter mode: per-cell chapter rings
        const asInfo = data && data.atomspaces && data.atomspaces.nodes && data.atomspaces.nodes[cell.name];
        if (asInfo && asInfo.chapters && asInfo.chapters.length > 0) {
          const nChapters = asInfo.chapters.length;
          const ringGap = cellMaxR / (nChapters + 1);
          for (let ci = 0; ci < nChapters; ci++) {
            const ringR = ringGap * (ci + 1);
            const brightness = 0.2 + (ci / nChapters) * 0.2;
            for (let seg = 0; seg < ringSegments; seg++) {
              const a1 = (seg / ringSegments) * Math.PI * 2;
              const a2 = ((seg + 1) / ringSegments) * Math.PI * 2;
              pushLine(
                cx + Math.cos(a1) * ringR, cy, cz + Math.sin(a1) * ringR,
                cx + Math.cos(a2) * ringR, cy, cz + Math.sin(a2) * ringR,
                brightness, brightness + 0.1, brightness + 0.15, ringAlpha
              );
            }
          }
        }
      } else {
        // Mindmap mode: concentric guide rings within each cell
        for (let ri = 1; ri <= ringCount; ri++) {
          const ringR = cellMaxR * (ri / ringCount);
          const brightness = ri === ringCount ? 0.4 : 0.25;
          for (let seg = 0; seg < ringSegments; seg++) {
            const a1 = (seg / ringSegments) * Math.PI * 2;
            const a2 = ((seg + 1) / ringSegments) * Math.PI * 2;
            pushLine(
              cx + Math.cos(a1) * ringR, cy, cz + Math.sin(a1) * ringR,
              cx + Math.cos(a2) * ringR, cy, cz + Math.sin(a2) * ringR,
              brightness, brightness + 0.1, brightness + 0.15, ringAlpha
            );
          }
        }

        // Inner core ring per cell
        const coreR = cellMaxR * 0.15;
        const coreAlpha = gridOpacity * 0.5;
        for (let seg = 0; seg < ringSegments; seg++) {
          const a1 = (seg / ringSegments) * Math.PI * 2;
          const a2 = ((seg + 1) / ringSegments) * Math.PI * 2;
          pushLine(
            cx + Math.cos(a1) * coreR, cy, cz + Math.sin(a1) * coreR,
            cx + Math.cos(a2) * coreR, cy, cz + Math.sin(a2) * coreR,
            0.4, 0.55, 0.5, coreAlpha
          );
        }
      }

      // Bounding box outline for each cell
      const r = color[0], g = color[1], b = color[2];
      const alpha = gridOpacity * 0.4;
      const c = [
        [cx-hw, cy-hh, cz-hd], [cx+hw, cy-hh, cz-hd],
        [cx+hw, cy-hh, cz+hd], [cx-hw, cy-hh, cz+hd],
        [cx-hw, cy+hh, cz-hd], [cx+hw, cy+hh, cz-hd],
        [cx+hw, cy+hh, cz+hd], [cx-hw, cy+hh, cz+hd]
      ];
      const boxEdges = [
        [0,1],[1,2],[2,3],[3,0],
        [4,5],[5,6],[6,7],[7,4],
        [0,4],[1,5],[2,6],[3,7]
      ];
      for (const [a,b2] of boxEdges) {
        pushLine(c[a][0],c[a][1],c[a][2], c[b2][0],c[b2][1],c[b2][2], r,g,b,alpha);
      }
    }

    if (gIdx > 0) {
      gl.useProgram(lineProgram);
      gl.bindBuffer(gl.ARRAY_BUFFER, lineBuffer);
      gl.bufferData(gl.ARRAY_BUFFER, gridArr.subarray(0, gIdx), gl.DYNAMIC_DRAW);
      const posLoc = gl.getAttribLocation(lineProgram, 'a_position');
      const colorLoc = gl.getAttribLocation(lineProgram, 'a_color');
      gl.enableVertexAttribArray(posLoc);
      gl.enableVertexAttribArray(colorLoc);
      const stride = 7 * 4;
      gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, stride, 0);
      gl.vertexAttribPointer(colorLoc, 4, gl.FLOAT, false, stride, 12);
      gl.uniformMatrix4fv(gl.getUniformLocation(lineProgram, 'u_projection'), false, projection);
      gl.uniformMatrix4fv(gl.getUniformLocation(lineProgram, 'u_view'), false, view);
      gl.uniform1f(gl.getUniformLocation(lineProgram, 'u_lineAlpha'), 1.0);
      gl.drawArrays(gl.LINES, 0, gIdx / 7);
    }
  }

  // Draw points
  gl.depthMask(true);
  if (!render._pointArr || render._pointArr.length < nodes.length * 7) {
    render._pointArr = new Float32Array(Math.max(nodes.length * 7, 4096));
  }
  const pointArr = render._pointArr;
  let pointIdx = 0;
  // Compute maxCentrality for mindmap color mode
  maxCentralityForColor = 0.01;
  for (const node of nodes) {
    if ((node._centrality || 0) > maxCentralityForColor) maxCentralityForColor = node._centrality;
  }

  // Compute top centrality nodes for detail level filtering
  centralityThreshold50 = 0; // top 50 by centrality
  if (detailLevel === 2 && nodes.length > 50) {
    const centralitySorted = nodes.slice().sort((a, b) => (b._centrality || 0) - (a._centrality || 0));
    centralityThreshold50 = centralitySorted[Math.min(49, centralitySorted.length - 1)]._centrality || 0;
  }

  for (const node of nodes) {
    if (node.surprise > vagueThreshold) continue;

    // Detail level filtering (only in atomspaces/mindmap layout)
    if (layoutMode === 'atomspaces' || layoutMode === 'chapters') {
      if (detailLevel === 1) continue; // Level 1: no words at all
      if (detailLevel === 2 && (node._centrality || 0) < centralityThreshold50) continue; // Level 2: only core
      // Level 3 and 4: show all words
    }

    let color;
    if (selectedCluster !== null && (node.community ?? node.cluster) === selectedCluster) {
      color = [1, 1, 1];
    } else if (colorMode === 'mindmap') {
      color = getMindmapColor(node, maxCentralityForColor);
      // Inner core words: larger and brighter
      if (node._isCore) {
        color = [Math.min(1, color[0] * 1.4), Math.min(1, color[1] * 1.4), Math.min(1, color[2] * 1.4)];
      }
    } else if (colorMode === 'density') {
      color = getDensityColor(node.densityNorm || 0);
    } else if (colorMode === 'cluster') {
      const cid = node.community ?? node.cluster ?? -1;
      color = cid >= 0 ? CLUSTER_COLORS[cid % CLUSTER_COLORS.length] : [0.3, 0.3, 0.4];
    } else if (colorMode === 'wordClass') {
      const wcid = node.classId ?? -1;
      color = wcid >= 0 ? CLUSTER_COLORS[wcid % CLUSTER_COLORS.length] : [0.3, 0.3, 0.4];
    } else if (colorMode === 'atomspace') {
      const as = node.atomspace || 'common';
      color = getAtomspaceColor(as);
      // Polysemy words get brighter
      if (node.polysemy) {
        color = [Math.min(1, color[0] + 0.3), Math.min(1, color[1] + 0.3), Math.min(1, color[2] + 0.3)];
      }
    } else {
      color = getVaguenessColor(node.surprise, maxSurprise);
    }

    // Size from degree (hub words bigger) + totalMI (important words bigger)
    const deg = node.degree || node.globalDegree || 1;
    const mi = node.totalMI || 0;
    const senseBoost = node.senses > 1 ? Math.log(node.senses) * 0.08 : 0;
    let size = 0.06 + Math.log(1 + deg) * 0.06 + Math.log(1 + mi) * 0.02 + senseBoost;
    // Inner core words rendered larger in mindmap mode
    if (node._isCore && (colorMode === 'mindmap' || layoutMode === 'atomspaces')) {
      size *= 1.5;
    }

    pointArr[pointIdx++] = node.x; pointArr[pointIdx++] = node.y; pointArr[pointIdx++] = node.z;
    pointArr[pointIdx++] = color[0]; pointArr[pointIdx++] = color[1]; pointArr[pointIdx++] = color[2];
    pointArr[pointIdx++] = size;
  }
  const pointVertCount = pointIdx / 7;

  if (pointVertCount > 0) {
    gl.useProgram(pointProgram);
    gl.bindBuffer(gl.ARRAY_BUFFER, pointBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, pointArr.subarray(0, pointIdx), gl.DYNAMIC_DRAW);

    const posLoc = gl.getAttribLocation(pointProgram, 'a_position');
    const colorLoc = gl.getAttribLocation(pointProgram, 'a_color');
    const sizeLoc = gl.getAttribLocation(pointProgram, 'a_size');
    gl.enableVertexAttribArray(posLoc);
    gl.enableVertexAttribArray(colorLoc);
    gl.enableVertexAttribArray(sizeLoc);
    const stride = 7 * 4;
    gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, stride, 0);
    gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, stride, 12);
    gl.vertexAttribPointer(sizeLoc, 1, gl.FLOAT, false, stride, 24);
    gl.uniformMatrix4fv(gl.getUniformLocation(pointProgram, 'u_projection'), false, projection);
    gl.uniformMatrix4fv(gl.getUniformLocation(pointProgram, 'u_view'), false, view);
    gl.uniform1f(gl.getUniformLocation(pointProgram, 'u_pointScale'), pointSizeScale);
    gl.drawArrays(gl.POINTS, 0, pointVertCount);
  }

  drawSkybox(projection, view);
  updateLabels(view, projection);
  requestAnimationFrame(render);
}

function drawSkybox(projection, view) {
  if (gridVertices.length === 0) return;
  const viewRotation = new Float32Array(view);
  viewRotation[12] = 0;
  viewRotation[13] = 0;
  viewRotation[14] = 0;
  gl.useProgram(skyboxProgram);
  gl.bindBuffer(gl.ARRAY_BUFFER, gridBuffer);
  const posLoc = gl.getAttribLocation(skyboxProgram, 'a_position');
  const colorLoc = gl.getAttribLocation(skyboxProgram, 'a_color');
  gl.enableVertexAttribArray(posLoc);
  gl.enableVertexAttribArray(colorLoc);
  const stride = 7 * 4;
  gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, stride, 0);
  gl.vertexAttribPointer(colorLoc, 4, gl.FLOAT, false, stride, 12);
  gl.uniformMatrix4fv(gl.getUniformLocation(skyboxProgram, 'u_projection'), false, projection);
  gl.uniformMatrix4fv(gl.getUniformLocation(skyboxProgram, 'u_viewRotation'), false, viewRotation);
  gl.drawArrays(gl.LINES, 0, gridVertices.length / 7);
}

// ═══════════════════════════════════════════════════════════════
// UI
// ═══════════════════════════════════════════════════════════════

function getClusterName(clusterNodes) {
  if (clusterNodes.length === 0) return '?';
  const sorted = clusterNodes.slice().sort((a, b) => (b.count || 0) - (a.count || 0));
  const nameWords = sorted.slice(0, 2).map(n => n.word);
  return nameWords.join('/');
}

function getClusterContext(clusterNodes, adj) {
  if (clusterNodes.length < 2) return '';
  const neighborCounts = new Map();
  const clusterWords = new Set(clusterNodes.map(n => n.word));
  for (const node of clusterNodes) {
    const seen = new Set();
    const neighbors = adj.get(node._idx) || [];
    for (const ni of neighbors) {
      const neighbor = nodes[ni];
      if (neighbor && !clusterWords.has(neighbor.word) && !seen.has(neighbor.word)) {
        seen.add(neighbor.word);
        neighborCounts.set(neighbor.word, (neighborCounts.get(neighbor.word) || 0) + 1);
      }
    }
  }
  const shared = Array.from(neighborCounts.entries())
    .filter(([, cnt]) => cnt >= Math.ceil(clusterNodes.length * 0.5))
    .sort((a, b) => b[1] - a[1])
    .slice(0, 3)
    .map(([w]) => w);
  return shared.length > 0 ? shared.join(', ') : '';
}

let clusterMeta = new Map();

function updateClusterList() {
  const list = document.getElementById('cluster-list');
  const clusters = new Map();

  const adj = new Map();
  for (const edge of edges) {
    if (!adj.has(edge.from)) adj.set(edge.from, []);
    if (!adj.has(edge.to)) adj.set(edge.to, []);
    adj.get(edge.from).push(edge.to);
    adj.get(edge.to).push(edge.from);
  }

  for (let i = 0; i < nodes.length; i++) {
    nodes[i]._idx = i;
  }

  for (const node of nodes) {
    const cid = node.community ?? node.cluster ?? -1;
    if (cid >= 0) {
      if (!clusters.has(cid)) clusters.set(cid, []);
      clusters.get(cid).push(node);
    }
  }

  clusterMeta = new Map();
  for (const [id, clusterNodes] of clusters) {
    const name = getClusterName(clusterNodes);
    const context = getClusterContext(clusterNodes, adj);
    clusterMeta.set(id, { name, context, members: clusterNodes.map(n => n.word) });
  }

  const sorted = Array.from(clusters.entries()).sort((a, b) => b[1].length - a[1].length);

  list.innerHTML = '';
  for (const [id, clusterNodes] of sorted) {
    const meta = clusterMeta.get(id);
    const div = document.createElement('div');
    div.className = 'cluster-item';
    const color = CLUSTER_COLORS[id % CLUSTER_COLORS.length];
    div.style.borderLeft = `3px solid rgb(${Math.floor(color[0]*255)},${Math.floor(color[1]*255)},${Math.floor(color[2]*255)})`;

    const nameSpan = document.createElement('strong');
    nameSpan.textContent = meta.name;
    nameSpan.style.color = `rgb(${Math.floor(color[0]*255)},${Math.floor(color[1]*255)},${Math.floor(color[2]*255)})`;
    div.appendChild(nameSpan);

    const countSpan = document.createElement('span');
    countSpan.textContent = ` (${clusterNodes.length})`;
    countSpan.style.color = '#666';
    countSpan.style.fontSize = '10px';
    div.appendChild(countSpan);

    if (meta.context) {
      const ctxDiv = document.createElement('div');
      ctxDiv.textContent = meta.context;
      ctxDiv.style.color = '#555';
      ctxDiv.style.fontSize = '9px';
      ctxDiv.style.marginTop = '2px';
      div.appendChild(ctxDiv);
    }

    const memberDiv = document.createElement('div');
    memberDiv.textContent = meta.members.slice(0, 6).join(', ') + (meta.members.length > 6 ? '...' : '');
    memberDiv.style.color = '#777';
    memberDiv.style.fontSize = '9px';
    memberDiv.style.marginTop = '1px';
    div.appendChild(memberDiv);

    div.onclick = () => {
      selectedCluster = selectedCluster === id ? null : id;
    };
    list.appendChild(div);
  }
}

function updateWordClassList() {
  const list = document.getElementById('class-list');
  if (!list) return;
  list.innerHTML = '';

  // Use data.classes if available (exported from corpus-learning.scm)
  const classes = data && data.classes ? data.classes : {};

  // If no exported classes, fall back to scanning node classId
  let classMap;
  if (Object.keys(classes).length > 0) {
    classMap = new Map();
    for (const [cid, members] of Object.entries(classes)) {
      classMap.set(parseInt(cid), members);
    }
  } else {
    classMap = new Map();
    for (const node of nodes) {
      const cid = node.classId ?? -1;
      if (cid >= 0) {
        if (!classMap.has(cid)) classMap.set(cid, []);
        classMap.get(cid).push(node.word);
      }
    }
  }

  if (classMap.size === 0) {
    list.innerHTML = '<div style="color:#555;font-size:10px;padding:4px;">No word classes yet</div>';
    return;
  }

  const sorted = Array.from(classMap.entries()).sort((a, b) => b[1].length - a[1].length);

  for (const [cid, members] of sorted) {
    const div = document.createElement('div');
    div.className = 'cluster-item';
    const color = CLUSTER_COLORS[cid % CLUSTER_COLORS.length];
    div.style.borderLeft = `3px solid rgb(${Math.floor(color[0]*255)},${Math.floor(color[1]*255)},${Math.floor(color[2]*255)})`;

    const nameSpan = document.createElement('strong');
    nameSpan.textContent = `C${cid}`;
    nameSpan.style.color = `rgb(${Math.floor(color[0]*255)},${Math.floor(color[1]*255)},${Math.floor(color[2]*255)})`;
    div.appendChild(nameSpan);

    const countSpan = document.createElement('span');
    countSpan.textContent = ` (${members.length})`;
    countSpan.style.color = '#666';
    countSpan.style.fontSize = '10px';
    div.appendChild(countSpan);

    const memberDiv = document.createElement('div');
    memberDiv.textContent = members.slice(0, 6).join(', ') + (members.length > 6 ? '...' : '');
    memberDiv.style.color = '#777';
    memberDiv.style.fontSize = '9px';
    memberDiv.style.marginTop = '1px';
    div.appendChild(memberDiv);

    div.onclick = () => {
      // Highlight class members by switching to wordClass color mode and selecting
      const colorSelect = document.getElementById('color-mode');
      if (colorSelect && colorSelect.value !== 'wordClass') {
        colorSelect.value = 'wordClass';
        colorSelect.dispatchEvent(new Event('change'));
      }
      selectedCluster = selectedCluster === cid ? null : cid;
      // Override selectedCluster check in render to use classId
      selectedCluster = null; // reset community-based selection
      // Toggle highlight by marking nodes
      for (const node of nodes) {
        node._classHighlight = (node.classId === cid);
      }
    };
    list.appendChild(div);
  }
}

function updateStats() {
  if (!data) return;
  const stats = data.stats || {};

  // Learning progress
  const setEl = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
  setEl('stat-sentences', (stats.totalSentences || 0).toLocaleString());
  setEl('stat-learn-rate', (stats.learnRate || 0).toFixed(1) + '%');
  setEl('stat-level', stats.level || data.level || 0);
  setEl('stat-epoch', stats.epoch || 0);
  setEl('stat-surprise', (stats.avgSurprise || data.avgSurprise || 0).toFixed(2));

  // Graph
  setEl('stat-words', Object.keys(data.words || {}).length);
  setEl('stat-pairs', stats.totalPairs || (data.pairs || []).length);
  setEl('stat-clusters', stats.totalClusters || data.clusters || 0);
  setEl('stat-classes', stats.wordClasses || 0);

  // MI distribution
  setEl('stat-mi-total', (stats.miTotalPairs || stats.miCacheSize || 0).toLocaleString());
  setEl('stat-mi-mean', stats.miMean !== undefined ? stats.miMean.toFixed(2) : '-');
  setEl('stat-mi-std', stats.miStd !== undefined ? stats.miStd.toFixed(2) : '-');
  if (stats.miMin !== undefined && stats.miMax !== undefined) {
    setEl('stat-mi-range', stats.miMin.toFixed(1) + ' .. ' + stats.miMax.toFixed(1));
  }

  // L1 senses
  setEl('stat-senses', stats.l1Senses || 0);
  setEl('stat-sections', stats.l1SectionPairs || 0);

  // Draw histogram if data available
  if (data.miHistogram || data.surpriseHistogram) {
    drawHistogram();
  }

  // Update benchmark and promotion panels
  updateBenchmark(stats);
  updatePromotion(stats);
}

function formatMs(ms) {
  if (ms >= 60000) return (ms / 60000).toFixed(1) + 'min';
  if (ms >= 1000) return (ms / 1000).toFixed(1) + 's';
  return Math.round(ms) + 'ms';
}

function formatSec(sec) {
  if (sec >= 3600) return (sec / 3600).toFixed(1) + 'h';
  if (sec >= 60) return (sec / 60).toFixed(1) + 'min';
  return Math.round(sec) + 's';
}

function updateBenchmark(stats) {
  const setEl = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };

  if (stats.benchRuntimeSec !== undefined) {
    setEl('bench-runtime', formatSec(stats.benchRuntimeSec));
  }
  if (stats.benchSentPerSec !== undefined) {
    setEl('bench-throughput', stats.benchSentPerSec.toFixed(1) + ' sent/s');
  }

  // GPU MI
  if (stats.benchMiCalls !== undefined) {
    setEl('bench-mi-summary', formatMs(stats.benchMiTotalMs) + ' / ' + stats.benchMiCalls + ' calls');
    setEl('bench-mi-avg', formatMs(stats.benchMiAvgMs) + '/call');
    setEl('bench-mi-pairs', (stats.benchMiPairs || 0).toLocaleString());
  }

  // Clustering
  if (stats.benchClusterCalls !== undefined) {
    setEl('bench-cluster-summary', formatMs(stats.benchClusterTotalMs) + ' / ' + stats.benchClusterCalls + ' rounds');
    setEl('bench-cluster-avg', formatMs(stats.benchClusterAvgMs) + '/round');
  }

  // Eviction
  if (stats.benchEvictCalls !== undefined) {
    setEl('bench-evict-summary', formatMs(stats.benchEvictTotalMs) + ' / ' + stats.benchEvictCalls + ' calls');
  }

  // Export
  if (stats.benchExportCalls !== undefined) {
    setEl('bench-export-summary', formatMs(stats.benchExportTotalMs) + ' / ' + stats.benchExportCalls + ' exports');
    setEl('bench-export-avg', formatMs(stats.benchExportAvgMs) + '/export');
  }
}

function updatePromotion(stats) {
  const setEl = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };

  if (stats.promChecks === undefined) return;

  // Progress bar
  const pct = Math.round((stats.promChecks / stats.promTotal) * 100);
  const bar = document.getElementById('prom-bar-fill');
  if (bar) bar.style.width = pct + '%';

  // Individual checks (emergent metrics)
  const checks = [
    ['prom-sent', stats.promSentences, 'PASS', 'FAIL'],
    ['prom-iter', stats.promIterations, 'PASS', 'FAIL'],
    ['prom-classes', stats.promClasses, 'PASS', 'FAIL'],
    ['prom-exhausted', stats.promExhausted,
      'EXHAUSTED', 'ACTIVE'],
  ];

  for (const [id, passed, passText, failText] of checks) {
    const el = document.getElementById(id);
    if (el) {
      el.textContent = passed ? passText : failText;
      el.className = passed ? 'pass' : 'fail';
    }
  }

  // Top-pair score bar (fills from right as score drops toward noise floor)
  const topScoreEl = document.getElementById('prom-top-score');
  if (topScoreEl && stats.promTopScore !== undefined) {
    const score = stats.promTopScore;
    const floor = stats.promNoiseFloor || 0.15;
    topScoreEl.textContent = score.toFixed(3) + ' / ' + floor.toFixed(2);
    topScoreEl.className = score < floor ? 'pass' : 'fail';
  }

  // Entropy
  const entropyEl = document.getElementById('prom-entropy');
  if (entropyEl && stats.promEntropy !== undefined) {
    const plateau = stats.promEntropyPlateau;
    entropyEl.textContent = stats.promEntropy.toFixed(3) + (plateau ? ' (plateau)' : '');
    entropyEl.className = plateau ? 'pass' : 'fail';
  }
}

// ═══════════════════════════════════════════════════════════════
// HISTOGRAM RENDERING (2D canvas overlay)
// ═══════════════════════════════════════════════════════════════

let histogramMode = 'mi'; // 'mi' or 'surprise'

function drawHistogram() {
  const hCanvas = document.getElementById('histogram-canvas');
  if (!hCanvas || !data) return;
  const ctx = hCanvas.getContext('2d');
  const W = hCanvas.width;
  const H = hCanvas.height;
  // Scale padding and fonts for larger canvases
  const scale = Math.max(1, Math.min(W / 496, H / 180));
  const PAD_L = Math.round(45 * scale);
  const PAD_R = Math.round(10 * scale);
  const PAD_T = Math.round(10 * scale);
  const PAD_B = Math.round(30 * scale);
  const fontSize = Math.round(9 * scale);
  const labelFont = `${fontSize}px monospace`;
  const titleFont = `${Math.round(11 * scale)}px monospace`;
  const plotW = W - PAD_L - PAD_R;
  const plotH = H - PAD_T - PAD_B;

  ctx.clearRect(0, 0, W, H);

  const histData = histogramMode === 'mi' ? data.miHistogram : data.surpriseHistogram;
  if (!histData || histData.length === 0) {
    ctx.fillStyle = '#555';
    ctx.font = titleFont;
    ctx.fillText('No histogram data — run (corpus-export)', 80, H / 2);
    return;
  }

  // histData is [[mi_value, count], ...]
  const values = histData.map(d => d[0]);
  const counts = histData.map(d => d[1]);
  const maxCount = Math.max(...counts);
  const minVal = Math.min(...values);
  const maxVal = Math.max(...values);
  const range = maxVal - minVal || 1;

  // Semi-log: y-axis is log10(count)
  const logMax = Math.log10(maxCount + 1);

  // Grid lines
  ctx.strokeStyle = '#1a1a25';
  ctx.lineWidth = 1;
  for (let p = 0; p <= logMax; p++) {
    const y = PAD_T + plotH - (p / logMax) * plotH;
    ctx.beginPath();
    ctx.moveTo(PAD_L, y);
    ctx.lineTo(PAD_L + plotW, y);
    ctx.stroke();
  }

  // Draw bars
  const barW = Math.max(1, (plotW / histData.length) - 0.5);
  for (let i = 0; i < histData.length; i++) {
    const val = histData[i][0];
    const count = histData[i][1];
    if (count === 0) continue;

    const x = PAD_L + ((val - minVal) / range) * plotW;
    const logH = (Math.log10(count + 1) / logMax) * plotH;
    const y = PAD_T + plotH - logH;

    // Color: gradient from blue (low MI) to cyan (mid) to yellow (high)
    let r, g, b;
    if (histogramMode === 'mi') {
      const t = (val - minVal) / range;
      if (t < 0.33) { r = 40; g = 80 + t * 500; b = 200; }
      else if (t < 0.66) { r = (t - 0.33) * 750; g = 200; b = 200 - (t - 0.33) * 400; }
      else { r = 255; g = 255; b = (t - 0.66) * 600; }
    } else {
      // Surprise: red (high surprise) to blue (low)
      const t = (val - minVal) / range;
      r = 80 + t * 175; g = 80; b = 200 - t * 150;
    }

    ctx.fillStyle = `rgb(${Math.floor(r)},${Math.floor(g)},${Math.floor(b)})`;
    ctx.fillRect(x, y, barW, logH);
  }

  // MI threshold line (from sidebar filter)
  if (histogramMode === 'mi') {
    const threshX = PAD_L + ((miThreshold - minVal) / range) * plotW;
    if (threshX >= PAD_L && threshX <= PAD_L + plotW) {
      ctx.strokeStyle = '#ff4444';
      ctx.lineWidth = 1.5 * scale;
      ctx.setLineDash([4 * scale, 3 * scale]);
      ctx.beginPath();
      ctx.moveTo(threshX, PAD_T);
      ctx.lineTo(threshX, PAD_T + plotH);
      ctx.stroke();
      ctx.setLineDash([]);
      ctx.fillStyle = '#ff4444';
      ctx.font = labelFont;
      ctx.fillText('filter', threshX + 3, PAD_T + fontSize + 2);
    }

    // Mean line
    const stats = data.stats || {};
    if (stats.miMean !== undefined) {
      const meanX = PAD_L + ((stats.miMean - minVal) / range) * plotW;
      if (meanX >= PAD_L && meanX <= PAD_L + plotW) {
        ctx.strokeStyle = '#4ecdc4';
        ctx.lineWidth = 1;
        ctx.setLineDash([2 * scale, 2 * scale]);
        ctx.beginPath();
        ctx.moveTo(meanX, PAD_T);
        ctx.lineTo(meanX, PAD_T + plotH);
        ctx.stroke();
        ctx.setLineDash([]);
        ctx.fillStyle = '#4ecdc4';
        ctx.font = labelFont;
        ctx.fillText('mean', meanX + 3, PAD_T + fontSize * 2 + 4);
      }
    }
  }

  // Axes
  ctx.strokeStyle = '#444';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(PAD_L, PAD_T);
  ctx.lineTo(PAD_L, PAD_T + plotH);
  ctx.lineTo(PAD_L + plotW, PAD_T + plotH);
  ctx.stroke();

  // Y-axis labels (log scale)
  ctx.fillStyle = '#666';
  ctx.font = labelFont;
  ctx.textAlign = 'right';
  for (let p = 0; p <= logMax; p++) {
    const y = PAD_T + plotH - (p / logMax) * plotH;
    const label = Math.pow(10, p).toFixed(0);
    ctx.fillText(label, PAD_L - 4, y + Math.round(fontSize / 3));
  }

  // X-axis labels
  ctx.textAlign = 'center';
  const labelStep = range > 20 ? 5 : range > 10 ? 2 : 1;
  const startLabel = Math.ceil(minVal / labelStep) * labelStep;
  for (let v = startLabel; v <= maxVal; v += labelStep) {
    const x = PAD_L + ((v - minVal) / range) * plotW;
    ctx.fillText(v.toFixed(0), x, PAD_T + plotH + fontSize + 4);
  }

  // Axis titles
  ctx.fillStyle = '#555';
  ctx.font = labelFont;
  ctx.textAlign = 'center';
  ctx.fillText(histogramMode === 'mi' ? 'MI value' : 'Per-word avg MI', PAD_L + plotW / 2, H - 2);
  ctx.save();
  ctx.translate(Math.round(10 * scale), PAD_T + plotH / 2);
  ctx.rotate(-Math.PI / 2);
  ctx.fillText('count (log)', 0, 0);
  ctx.restore();

  // Stats line below histogram
  const statsEl = document.getElementById('histogram-stats');
  if (statsEl && data.stats) {
    const s = data.stats;
    if (histogramMode === 'mi') {
      statsEl.innerHTML =
        `N=<span>${(s.miTotalPairs || 0).toLocaleString()}</span> ` +
        `mean=<span>${(s.miMean || 0).toFixed(2)}</span> ` +
        `std=<span>${(s.miStd || 0).toFixed(2)}</span> ` +
        `range=<span>[${(s.miMin || 0).toFixed(1)}, ${(s.miMax || 0).toFixed(1)}]</span>`;
    } else {
      const surpriseData = data.surpriseHistogram || [];
      const total = surpriseData.reduce((s, d) => s + d[1], 0);
      statsEl.innerHTML = `N=<span>${total.toLocaleString()}</span> words with surprise values`;
    }
  }
}

// Histogram toggle + tabs
document.getElementById('histogram-toggle').addEventListener('click', () => {
  const panel = document.getElementById('histogram-panel');
  panel.classList.toggle('collapsed');
  const btn = document.getElementById('histogram-toggle');
  if (panel.classList.contains('collapsed')) {
    btn.style.color = '#4ecdc4';
  } else {
    btn.style.color = '#fff';
    drawHistogram();
  }
});

document.querySelectorAll('.histogram-tab[data-hist]').forEach(tab => {
  tab.addEventListener('click', () => {
    document.querySelectorAll('.histogram-tab[data-hist]').forEach(t => t.classList.remove('active'));
    tab.classList.add('active');
    histogramMode = tab.dataset.hist;
    drawHistogram();
  });
});

// Fullscreen histogram toggle
document.getElementById('hist-fullscreen').addEventListener('click', () => {
  const panel = document.getElementById('histogram-panel');
  const hCanvas = document.getElementById('histogram-canvas');
  const wrap = hCanvas.parentElement;
  const btn = document.getElementById('hist-fullscreen');
  panel.classList.toggle('fullscreen');
  if (panel.classList.contains('fullscreen')) {
    btn.textContent = 'SMALL';
    btn.classList.add('active');
    // Wait for flex layout to settle, then size canvas to fill wrap
    requestAnimationFrame(() => {
      hCanvas.width = wrap.clientWidth;
      hCanvas.height = wrap.clientHeight;
      drawHistogram();
    });
  } else {
    btn.textContent = 'FULL';
    btn.classList.remove('active');
    hCanvas.width = 496;
    hCanvas.height = 180;
    drawHistogram();
  }
});

// ═══════════════════════════════════════════════════════════════
// FLY MODE (Shift+F) — first-person navigation
// ═══════════════════════════════════════════════════════════════
let flyMode = false;
let flyPos = { x: 0, y: 0, z: 10 };
let flyYaw = Math.PI;
let flyPitch = 0;
const flySpeed = 0.15;
const flyKeys = new Set();
let flyLastMouse = { x: -1, y: -1 };

function enterFlyMode() {
  flyMode = true;
  autoRotate = false;
  const camDist = zoom * 5;
  flyPos.x = panTarget.x + Math.sin(rotation.y) * Math.cos(rotation.x) * camDist;
  flyPos.y = panTarget.y + Math.sin(rotation.x) * camDist;
  flyPos.z = panTarget.z + Math.cos(rotation.y) * Math.cos(rotation.x) * camDist;
  flyYaw = rotation.y;
  flyPitch = rotation.x;
  tooltip.style.display = 'none';
  hoveredNode = null;
  const lockPromise = canvas.requestPointerLock();
  if (lockPromise && lockPromise.catch) {
    lockPromise.catch(() => {
      console.log('Pointer lock denied, using manual mouselook');
    });
  }
  flyLastMouse = { x: -1, y: -1 };
  showFlyHUD(true);
}

function exitFlyMode() {
  flyMode = false;
  flyKeys.clear();
  document.exitPointerLock();
  showFlyHUD(false);
  const dx = flyPos.x - panTarget.x;
  const dy = flyPos.y - panTarget.y;
  const dz = flyPos.z - panTarget.z;
  zoom = Math.sqrt(dx*dx + dy*dy + dz*dz) / 5;
  rotation.y = Math.atan2(dx, dz);
  rotation.x = Math.asin(Math.max(-1, Math.min(1, dy / (zoom * 5))));
}

function showFlyHUD(show) {
  let hud = document.getElementById('fly-hud');
  if (show && !hud) {
    hud = document.createElement('div');
    hud.id = 'fly-hud';
    hud.style.cssText = 'position:absolute;top:10px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.8);color:#4ecdc4;padding:6px 16px;border-radius:4px;font-size:11px;font-family:monospace;z-index:200;pointer-events:none;';
    hud.textContent = 'FLY MODE — WASD move, mouse look, Shift+F or ESC to exit';
    document.getElementById('canvas-container').appendChild(hud);
  } else if (!show && hud) {
    hud.remove();
  }
}

function flyStep() {
  if (!flyMode) return;
  const fwdX = -Math.sin(flyYaw) * Math.cos(flyPitch);
  const fwdY = Math.sin(flyPitch);
  const fwdZ = -Math.cos(flyYaw) * Math.cos(flyPitch);
  const rightX = Math.cos(flyYaw);
  const rightZ = -Math.sin(flyYaw);

  const speed = flySpeed * (flyKeys.has('shift') ? 3 : 1);
  if (flyKeys.has('w')) { flyPos.x += fwdX * speed; flyPos.y += fwdY * speed; flyPos.z += fwdZ * speed; }
  if (flyKeys.has('s')) { flyPos.x -= fwdX * speed; flyPos.y -= fwdY * speed; flyPos.z -= fwdZ * speed; }
  if (flyKeys.has('a')) { flyPos.x -= rightX * speed; flyPos.z -= rightZ * speed; }
  if (flyKeys.has('d')) { flyPos.x += rightX * speed; flyPos.z += rightZ * speed; }
  if (flyKeys.has('q') || flyKeys.has('e')) {
    const upDir = flyKeys.has('e') ? 1 : -1;
    flyPos.y += upDir * speed;
  }
}

// ═══════════════════════════════════════════════════════════════
// MOUSE & KEYBOARD CONTROLS
// ═══════════════════════════════════════════════════════════════

let isDragging = false;
let isPanning = false;
let lastMouse = { x: 0, y: 0 };

canvas.addEventListener('mousedown', (e) => {
  if (flyMode) return;
  isDragging = true;
  isPanning = e.shiftKey;
  autoRotate = false;
  lastMouse = { x: e.clientX, y: e.clientY };
});

canvas.addEventListener('mousemove', (e) => {
  if (flyMode) {
    if (document.pointerLockElement === canvas) {
      flyYaw -= e.movementX * 0.002;
      flyPitch -= e.movementY * 0.002;
    } else {
      if (flyLastMouse.x >= 0) {
        flyYaw -= (e.clientX - flyLastMouse.x) * 0.002;
        flyPitch -= (e.clientY - flyLastMouse.y) * 0.002;
      }
      flyLastMouse.x = e.clientX;
      flyLastMouse.y = e.clientY;
      const rect = canvas.getBoundingClientRect();
      const margin = 5;
      if (e.clientX <= rect.left + margin || e.clientX >= rect.right - margin ||
          e.clientY <= rect.top + margin || e.clientY >= rect.bottom - margin) {
        const lockPromise = canvas.requestPointerLock();
        if (lockPromise && lockPromise.catch) lockPromise.catch(() => {});
      }
    }
    flyPitch = Math.max(-Math.PI/2 + 0.05, Math.min(Math.PI/2 - 0.05, flyPitch));
    return;
  }

  if (isDragging) {
    const dx = e.clientX - lastMouse.x;
    const dy = e.clientY - lastMouse.y;
    if (isPanning || e.shiftKey) {
      const cosX = Math.cos(rotation.x), sinX = Math.sin(rotation.x);
      const cosY = Math.cos(rotation.y), sinY = Math.sin(rotation.y);
      const rightX = cosY, rightY = 0, rightZ = -sinY;
      const upX = -sinY * sinX, upY = cosX, upZ = -cosY * sinX;
      const panSpeed = zoom * 0.005;
      panTarget.x -= (dx * rightX + dy * upX) * panSpeed;
      panTarget.y -= (dx * rightY + dy * upY) * panSpeed;
      panTarget.z -= (dx * rightZ + dy * upZ) * panSpeed;
    } else {
      rotation.y += dx * 0.01;
      rotation.x += dy * 0.01;
      rotation.x = Math.max(-Math.PI/2 + 0.1, Math.min(Math.PI/2 - 0.1, rotation.x));
    }
    lastMouse = { x: e.clientX, y: e.clientY };
  }

  // Tooltip hover
  const rect = canvas.getBoundingClientRect();
  const mouseX = e.clientX - rect.left;
  const mouseY = e.clientY - rect.top;

  const aspect = canvas.width / canvas.height;
  const camDist = zoom * 5;
  const camX = panTarget.x + Math.sin(rotation.y) * Math.cos(rotation.x) * camDist;
  const camY = panTarget.y + Math.sin(rotation.x) * camDist;
  const camZ = panTarget.z + Math.cos(rotation.y) * Math.cos(rotation.x) * camDist;
  const projMat = perspective(Math.PI / 4, aspect, 0.1, 100);
  const viewMat = lookAt([camX, camY, camZ], [panTarget.x, panTarget.y, panTarget.z], [0, 1, 0]);

  let closestNode = null;
  let closestDist = 30;

  for (const node of nodes) {
    if (node.surprise > vagueThreshold) continue;
    const screenPos = projectToScreen(node.x, node.y, node.z, viewMat, projMat);
    if (!screenPos) continue;
    const dx = screenPos.x - mouseX;
    const dy = screenPos.y - mouseY;
    const dist = Math.sqrt(dx*dx + dy*dy);
    if (dist < closestDist) {
      closestDist = dist;
      closestNode = node;
    }
  }

  if (closestNode) {
    tooltip.style.display = 'block';
    tooltip.style.left = (e.clientX + 15) + 'px';
    tooltip.style.top = (e.clientY + 15) + 'px';
    const gDegLine = closestNode.globalDegree > 0
      ? `Global MI pairs: ${closestNode.globalDegree}<br>` : '';
    const classLine = (closestNode.classId ?? -1) >= 0
      ? `Word Class: C${closestNode.classId}<br>` : '';
    const asLine = `<span style="color:#ffcc44">In: ${closestNode.atomspace}</span><br>`;
    const polyLine = closestNode.polysemy
      ? `<span style="color:#ff6666">POLYSEMY</span><br>` : '';
    // Sense-specific partners
    let senseLine = '';
    if (closestNode.isSenseNode && closestNode.sensePartners) {
      const topP = closestNode.sensePartners.slice(0, 5).map(p => `${p[0]}(${p[1].toFixed(1)})`).join(', ');
      senseLine = `<span style="color:#88ddff">Partners: ${topP}</span><br>`;
      if (closestNode.senseComps && closestNode.senseComps.length > 0) {
        senseLine = `<span style="color:#88ddff">Sense from: ${closestNode.senseComps.join(', ')}</span><br>` + senseLine;
      }
    }
    // Chapter info from data
    let chapterLine = '';
    const wordData = data && data.words && data.words[closestNode.word];
    if (wordData && wordData.chapters) {
      const chEntries = Object.entries(wordData.chapters);
      if (chEntries.length > 0) {
        const chParts = chEntries.map(([book, chs]) => `${book}: ${chs.slice(0, 3).join(', ')}${chs.length > 3 ? '...' : ''}`);
        chapterLine = `<span style="color:#aa88ff">Ch: ${chParts.join('; ')}</span><br>`;
      }
    }
    tooltip.innerHTML = `
      <strong>${closestNode.word}</strong><br>
      ${asLine}${polyLine}${senseLine}${chapterLine}Surprise: ${closestNode.surprise.toFixed(2)} bits<br>
      ${gDegLine}Degree: ${closestNode.degree}<br>
      ${classLine}Cluster: ${closestNode.cluster >= 0 ? 'C' + closestNode.cluster : 'none'}<br>
      Neighbors: ${closestNode.neighbors ? closestNode.neighbors.slice(0,3).join(', ') : '-'}
    `;
  } else {
    tooltip.style.display = 'none';
  }
});

function projectToScreen(x, y, z, viewMat, projMat) {
  const vx = viewMat[0]*x + viewMat[4]*y + viewMat[8]*z + viewMat[12];
  const vy = viewMat[1]*x + viewMat[5]*y + viewMat[9]*z + viewMat[13];
  const vz = viewMat[2]*x + viewMat[6]*y + viewMat[10]*z + viewMat[14];
  const vw = viewMat[3]*x + viewMat[7]*y + viewMat[11]*z + viewMat[15];
  const px = projMat[0]*vx + projMat[4]*vy + projMat[8]*vz + projMat[12]*vw;
  const py = projMat[1]*vx + projMat[5]*vy + projMat[9]*vz + projMat[13]*vw;
  const pz = projMat[2]*vx + projMat[6]*vy + projMat[10]*vz + projMat[14]*vw;
  const pw = projMat[3]*vx + projMat[7]*vy + projMat[11]*vz + projMat[15]*vw;
  if (pw <= 0) return null;
  const ndcX = px / pw;
  const ndcY = py / pw;
  const screenX = (ndcX + 1) * 0.5 * canvas.width;
  const screenY = (1 - ndcY) * 0.5 * canvas.height;
  return { x: screenX, y: screenY, depth: pz / pw };
}

canvas.addEventListener('mouseup', () => {
  isDragging = false;
});

canvas.addEventListener('wheel', (e) => {
  e.preventDefault();
  if (!flyMode) {
    zoom *= e.deltaY > 0 ? 1.1 : 0.9;
    zoom = Math.max(0.5, Math.min(10, zoom));
  }
});

document.addEventListener('keydown', (e) => {
  if (e.shiftKey && e.key === 'F') {
    e.preventDefault();
    if (flyMode) exitFlyMode();
    else enterFlyMode();
    return;
  }
  if (e.key === 'Escape' && flyMode) {
    exitFlyMode();
    return;
  }
  if (flyMode) {
    const k = e.key.toLowerCase();
    if ('wasdqe'.includes(k)) {
      flyKeys.add(k);
      e.preventDefault();
    }
    if (e.shiftKey) flyKeys.add('shift');
  }
});

document.addEventListener('keyup', (e) => {
  if (flyMode) {
    const k = e.key.toLowerCase();
    flyKeys.delete(k);
    if (!e.shiftKey) flyKeys.delete('shift');
  }
});

document.addEventListener('pointerlockchange', () => {
  if (flyMode && document.pointerLockElement !== canvas) {
    exitFlyMode();
  }
});

// Slider/checkbox bindings
document.getElementById('mi-slider').addEventListener('input', (e) => {
  miThreshold = parseFloat(e.target.value);
  document.getElementById('mi-value').textContent = miThreshold.toFixed(1);
  if (!document.getElementById('histogram-panel').classList.contains('collapsed')) {
    drawHistogram();
  }
});

document.getElementById('vague-slider').addEventListener('input', (e) => {
  vagueThreshold = parseFloat(e.target.value);
  document.getElementById('vague-value').textContent = vagueThreshold.toFixed(1);
});

document.getElementById('point-size-slider').addEventListener('input', (e) => {
  pointSizeScale = parseFloat(e.target.value);
  document.getElementById('point-size-value').textContent = pointSizeScale.toFixed(1);
});

document.getElementById('line-width-slider').addEventListener('input', (e) => {
  lineWidthScale = parseFloat(e.target.value);
  document.getElementById('line-width-value').textContent = lineWidthScale.toFixed(1);
});

document.getElementById('auto-rotate').addEventListener('change', (e) => {
  autoRotate = e.target.checked;
});

document.getElementById('color-mode').addEventListener('change', (e) => {
  colorMode = e.target.value;
  document.getElementById('density-legend').style.display = colorMode === 'density' ? 'flex' : 'none';
  document.getElementById('vagueness-legend').style.display = colorMode === 'vagueness' ? 'flex' : 'none';
  document.getElementById('cluster-legend').style.display = colorMode === 'cluster' ? 'flex' : 'none';
  document.getElementById('wordclass-legend').style.display = colorMode === 'wordClass' ? 'flex' : 'none';
  const asLegend = document.getElementById('atomspace-legend');
  if (asLegend) asLegend.style.display = colorMode === 'atomspace' ? 'flex' : 'none';
  const mmLegend = document.getElementById('mindmap-legend');
  if (mmLegend) mmLegend.style.display = colorMode === 'mindmap' ? 'flex' : 'none';
});

// Layout mode handler
const layoutSelect = document.getElementById('layout-mode');
if (layoutSelect) {
  layoutSelect.addEventListener('change', (e) => {
    layoutMode = e.target.value;
    layoutNodes();
  });
}

// Detail level slider
const detailLevelSlider = document.getElementById('detail-level-slider');
if (detailLevelSlider) {
  detailLevelSlider.addEventListener('input', (e) => {
    detailLevel = parseInt(e.target.value);
    document.getElementById('detail-level-value').textContent = detailLevel;
  });
}

// Grid controls (atomspace layout)
const gridOpacitySlider = document.getElementById('grid-opacity-slider');
if (gridOpacitySlider) {
  gridOpacitySlider.addEventListener('input', (e) => {
    gridOpacity = parseFloat(e.target.value);
    document.getElementById('grid-opacity-value').textContent = gridOpacity.toFixed(2);
  });
}
const cellSizeSlider = document.getElementById('cell-size-slider');
if (cellSizeSlider) {
  cellSizeSlider.addEventListener('input', (e) => {
    gridCellSize = parseInt(e.target.value);
    document.getElementById('cell-size-value').textContent = gridCellSize;
    if (layoutMode === 'atomspaces') layoutNodes();
  });
}
const cellGapSlider = document.getElementById('cell-gap-slider');
if (cellGapSlider) {
  cellGapSlider.addEventListener('input', (e) => {
    gridCellGap = parseFloat(e.target.value);
    document.getElementById('cell-gap-value').textContent = gridCellGap.toFixed(1);
    if (layoutMode === 'atomspaces') layoutNodes();
  });
}

// ═══════════════════════════════════════════════════════════════
// FLOATING WORD LABELS
// ═══════════════════════════════════════════════════════════════

let showLabels = true;
let labelCount = 60;
const labelsOverlay = document.getElementById('labels-overlay');
let labelPool = [];

document.getElementById('show-labels').addEventListener('change', (e) => {
  showLabels = e.target.checked;
  if (!showLabels) labelsOverlay.innerHTML = '';
});

document.getElementById('label-count-slider').addEventListener('input', (e) => {
  labelCount = parseInt(e.target.value);
  document.getElementById('label-count-value').textContent = labelCount;
});

let _labelRanked = [];
let _labelRankedTime = 0;
const LABEL_RANK_INTERVAL = 2000;

function updateLabels(viewMat, projMat) {
  if (!showLabels || nodes.length === 0) {
    labelsOverlay.innerHTML = '';
    return;
  }

  const now = performance.now();
  if (now - _labelRankedTime > LABEL_RANK_INTERVAL || _labelRanked.length === 0) {
    _labelRanked = nodes
      .map((n, i) => ({ node: n, idx: i, score: (n.degree || 0) + Math.log(n.count + 1) * 2 }))
      .filter(r => r.node.surprise <= vagueThreshold)
      .sort((a, b) => b.score - a.score)
      .slice(0, labelCount);
    _labelRankedTime = now;
  }
  const ranked = _labelRanked;

  while (labelPool.length < ranked.length) {
    const el = document.createElement('div');
    el.className = 'node-label';
    labelsOverlay.appendChild(el);
    labelPool.push(el);
  }

  for (let i = ranked.length; i < labelPool.length; i++) {
    labelPool[i].style.display = 'none';
  }

  for (let i = 0; i < ranked.length; i++) {
    const { node, score } = ranked[i];
    const el = labelPool[i];
    const screenPos = projectToScreen(node.x, node.y, node.z, viewMat, projMat);

    if (!screenPos || screenPos.x < -50 || screenPos.x > canvas.width + 50 ||
        screenPos.y < -20 || screenPos.y > canvas.height + 20) {
      el.style.display = 'none';
      continue;
    }

    const maxScore = ranked[0].score || 1;
    const t = score / maxScore;
    const fontSize = 8 + t * 10;

    let labelColor = '#e0e0e0';
    const cid = node.community ?? node.cluster ?? -1;
    if (colorMode === 'mindmap') {
      const c = getMindmapColor(node, maxCentralityForColor || 0.01);
      labelColor = `rgb(${Math.floor(c[0]*255)},${Math.floor(c[1]*255)},${Math.floor(c[2]*255)})`;
    } else if (colorMode === 'cluster' && cid >= 0) {
      const c = CLUSTER_COLORS[cid % CLUSTER_COLORS.length];
      labelColor = `rgb(${Math.floor(c[0]*255)},${Math.floor(c[1]*255)},${Math.floor(c[2]*255)})`;
    } else if (colorMode === 'wordClass' && (node.classId ?? -1) >= 0) {
      const c = CLUSTER_COLORS[node.classId % CLUSTER_COLORS.length];
      labelColor = `rgb(${Math.floor(c[0]*255)},${Math.floor(c[1]*255)},${Math.floor(c[2]*255)})`;
    } else if (colorMode === 'vagueness') {
      let maxSur = 1;
      for (const n of nodes) { if (n.surprise > maxSur) maxSur = n.surprise; }
      const c = getVaguenessColor(node.surprise, maxSur);
      labelColor = `rgb(${Math.floor(c[0]*255)},${Math.floor(c[1]*255)},${Math.floor(c[2]*255)})`;
    } else if (colorMode === 'atomspace') {
      const c = getAtomspaceColor(node.atomspace || 'common');
      labelColor = `rgb(${Math.floor(c[0]*255)},${Math.floor(c[1]*255)},${Math.floor(c[2]*255)})`;
    } else if (colorMode === 'density') {
      const c = getDensityColor(node.densityNorm || 0);
      labelColor = `rgb(${Math.floor(c[0]*255)},${Math.floor(c[1]*255)},${Math.floor(c[2]*255)})`;
    }

    const opacity = 0.5 + t * 0.5;

    el.style.display = 'block';
    el.style.left = screenPos.x + 'px';
    el.style.top = screenPos.y + 'px';
    el.style.fontSize = fontSize + 'px';
    el.style.color = labelColor;
    el.style.opacity = opacity;
    el.style.textShadow = '';
    el.textContent = node.word;
  }

  // Cluster centroid labels
  if (communityCentroids.size > 0 && clusterMeta.size > 0) {
    const centroidStart = ranked.length;
    const centroidCount = Math.min(communityCentroids.size, 50);
    while (labelPool.length < centroidStart + centroidCount) {
      const el = document.createElement('div');
      el.className = 'node-label';
      labelsOverlay.appendChild(el);
      labelPool.push(el);
    }
    let ci = 0;
    for (const [cid, cen] of communityCentroids) {
      if (ci >= centroidCount) break;
      const meta = clusterMeta.get(cid);
      if (!meta || !meta.name) { ci++; continue; }
      const el = labelPool[centroidStart + ci];
      const screenPos = projectToScreen(cen.x, cen.y, cen.z, viewMat, projMat);
      if (!screenPos || screenPos.x < -50 || screenPos.x > canvas.width + 50 ||
          screenPos.y < -20 || screenPos.y > canvas.height + 20) {
        el.style.display = 'none';
        ci++; continue;
      }
      const color = CLUSTER_COLORS[cid % CLUSTER_COLORS.length];
      const rgb = `rgb(${Math.floor(color[0]*255)},${Math.floor(color[1]*255)},${Math.floor(color[2]*255)})`;
      el.style.display = 'block';
      el.style.left = screenPos.x + 'px';
      el.style.top = (screenPos.y - 14) + 'px';
      el.style.fontSize = '13px';
      el.style.fontWeight = 'bold';
      el.style.color = rgb;
      el.style.opacity = 0.9;
      el.style.textShadow = `0 0 6px ${rgb}, 0 0 12px rgba(0,0,0,0.8)`;
      el.textContent = '[' + meta.name + ']';
      ci++;
    }
    for (let i = centroidStart + ci; i < labelPool.length; i++) {
      labelPool[i].style.display = 'none';
    }
  }

  // Atomspace centroid labels (in atomspace layout mode)
  if (layoutMode === 'atomspaces' && atomspaceCentroids.size > 0) {
    const asStart = labelPool.length;
    const asCount = atomspaceCentroids.size;
    while (labelPool.length < asStart + asCount) {
      const el = document.createElement('div');
      el.className = 'node-label';
      labelsOverlay.appendChild(el);
      labelPool.push(el);
    }
    let ai = 0;
    for (const [name, cen] of atomspaceCentroids) {
      if (ai >= asCount) break;
      const el = labelPool[asStart + ai];
      const screenPos = projectToScreen(cen.x, cen.y + 1.5, cen.z, viewMat, projMat);
      if (!screenPos || screenPos.x < -50 || screenPos.x > canvas.width + 50 ||
          screenPos.y < -20 || screenPos.y > canvas.height + 20) {
        el.style.display = 'none';
        ai++; continue;
      }
      const color = getAtomspaceColor(name);
      const rgb = `rgb(${Math.floor(color[0]*255)},${Math.floor(color[1]*255)},${Math.floor(color[2]*255)})`;
      el.style.display = 'block';
      el.style.left = screenPos.x + 'px';
      el.style.top = (screenPos.y - 20) + 'px';
      el.style.fontSize = '16px';
      el.style.fontWeight = 'bold';
      el.style.color = rgb;
      el.style.opacity = 1.0;
      el.style.textShadow = `0 0 8px ${rgb}, 0 0 16px rgba(0,0,0,0.9)`;
      el.textContent = name.toUpperCase();
      ai++;
    }
    for (let i = asStart + ai; i < labelPool.length; i++) {
      labelPool[i].style.display = 'none';
    }
  }
}

// ═══════════════════════════════════════════════════════════════
// LAYOUT VALIDATION
// ═══════════════════════════════════════════════════════════════

function validateLayout() {
  if (edges.length < 2 || nodes.length < 3) return;

  let maxMI = 1;
  for (const edge of edges) if (edge.mi > maxMI) maxMI = edge.mi;
  const spreadFactor = Math.max(1.5, Math.cbrt(nodes.length / 20));

  const measurements = [];
  for (const edge of edges) {
    if (edge.from === edge.to) continue;
    const n1 = nodes[edge.from], n2 = nodes[edge.to];
    if (!n1 || !n2) continue;
    const dx = n2.x - n1.x, dy = n2.y - n1.y, dz = n2.z - n1.z;
    const actualDist = Math.sqrt(dx*dx + dy*dy + dz*dz);
    const idealDist = spreadFactor * Math.sqrt(maxMI / Math.max(edge.mi, 0.01));
    measurements.push({ actual: actualDist, ideal: idealDist, mi: edge.mi });
  }

  if (measurements.length < 2) return;

  let totalError = 0, totalIdeal = 0;
  for (const m of measurements) {
    totalError += Math.abs(m.actual - m.ideal);
    totalIdeal += m.ideal;
  }
  const stress = totalIdeal > 0 ? totalError / totalIdeal : 0;

  const n = measurements.length;
  const miRanks = rankArray(measurements.map(m => m.mi));
  const invDistRanks = rankArray(measurements.map(m => 1 / (m.actual + 0.001)));

  let sumD2 = 0;
  for (let i = 0; i < n; i++) {
    const d = miRanks[i] - invDistRanks[i];
    sumD2 += d * d;
  }
  const spearman = 1 - (6 * sumD2) / (n * (n * n - 1));

  const stressEl = document.getElementById('stat-stress');
  const corrEl = document.getElementById('stat-corr');
  if (stressEl) {
    stressEl.textContent = stress.toFixed(3);
    stressEl.style.color = stress < 0.3 ? '#44cc88' : stress < 0.6 ? '#ffcc44' : '#ff4444';
  }
  if (corrEl) {
    corrEl.textContent = spearman.toFixed(3);
    corrEl.style.color = spearman > 0.7 ? '#44cc88' : spearman > 0.4 ? '#ffcc44' : '#ff4444';
  }

  console.log(`Layout validation: stress=${stress.toFixed(4)}, spearman=${spearman.toFixed(4)} (${n} edges)`);
  return { stress, spearman, n };
}

function rankArray(arr) {
  const indexed = arr.map((v, i) => ({ v, i }));
  indexed.sort((a, b) => a.v - b.v);
  const ranks = new Float64Array(arr.length);
  for (let i = 0; i < indexed.length; i++) {
    ranks[indexed[i].i] = i + 1;
  }
  return ranks;
}

// Export positions + MI comparison as downloadable JSON (call from console)
function exportPositions() {
  if (edges.length === 0 || nodes.length === 0) {
    console.log('No data to export');
    return;
  }

  let maxMI = 1;
  for (const edge of edges) if (edge.mi > maxMI) maxMI = edge.mi;
  const spreadFactor = Math.max(1.5, Math.cbrt(nodes.length / 20));

  const positions = {};
  for (const node of nodes) {
    positions[node.word] = { x: +node.x.toFixed(4), y: +node.y.toFixed(4), z: +node.z.toFixed(4) };
  }

  const edgeComparison = [];
  for (const edge of edges) {
    if (edge.from === edge.to) continue;
    const n1 = nodes[edge.from], n2 = nodes[edge.to];
    const dx = n2.x - n1.x, dy = n2.y - n1.y, dz = n2.z - n1.z;
    const actualDist = Math.sqrt(dx*dx + dy*dy + dz*dz);
    const idealDist = spreadFactor * Math.sqrt(maxMI / Math.max(edge.mi, 0.01));
    const error = Math.abs(actualDist - idealDist) / idealDist;
    edgeComparison.push({
      w1: n1.word, w2: n2.word, mi: +edge.mi.toFixed(3),
      actualDist: +actualDist.toFixed(4), idealDist: +idealDist.toFixed(4), error: +error.toFixed(4)
    });
  }
  edgeComparison.sort((a, b) => b.error - a.error);

  const errors = edgeComparison.map(e => e.error);
  const avgError = errors.reduce((s, e) => s + e, 0) / errors.length;

  const result = {
    summary: {
      nodes: nodes.length, edges: edges.length,
      maxMI: +maxMI.toFixed(3), spreadFactor: +spreadFactor.toFixed(3),
      avgError: +avgError.toFixed(4), mstEdges: _mstEdges ? _mstEdges.length : 0
    },
    positions,
    edges: edgeComparison
  };

  const blob = new Blob([JSON.stringify(result, null, 2)], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'layout-validation.json';
  a.click();
  URL.revokeObjectURL(url);

  console.log(`Exported ${nodes.length} positions, ${edgeComparison.length} edges`);
  return result;
}

// ═══════════════════════════════════════════════════════════════
// DATA LOADING + AUTO-REFRESH
// ═══════════════════════════════════════════════════════════════

let lastDataETag = null;       // ETag or Last-Modified from server
let lastDataLength = 0;        // Content-Length for change detection
let autoRefreshTimer = null;
const AUTO_REFRESH_INTERVAL = 10000; // Poll every 10 seconds

async function loadData() {
  try {
    const response = await fetch('data.json', { cache: 'no-store' });
    lastDataETag = response.headers.get('ETag') || response.headers.get('Last-Modified') || '';
    lastDataLength = parseInt(response.headers.get('Content-Length') || '0');

    data = await response.json();
    loading.style.display = 'none';

    // Auto-detect atomspace mode
    const hasAtomspaces = data.atomspaces && data.atomspaces.nodes;
    const layoutSelect = document.getElementById('layout-mode');

    if (hasAtomspaces) {
      layoutMode = 'atomspaces';
      colorMode = 'mindmap';
      if (layoutSelect) layoutSelect.value = 'atomspaces';
      const colorSelect = document.getElementById('color-mode');
      if (colorSelect) {
        colorSelect.value = 'mindmap';
        // Hide all legends except mindmap
        document.getElementById('density-legend').style.display = 'none';
        document.getElementById('vagueness-legend').style.display = 'none';
        document.getElementById('cluster-legend').style.display = 'none';
        document.getElementById('wordclass-legend').style.display = 'none';
        const asLegend = document.getElementById('atomspace-legend');
        if (asLegend) asLegend.style.display = 'none';
        const mmLegend = document.getElementById('mindmap-legend');
        if (mmLegend) mmLegend.style.display = 'flex';
      }
    } else {
      layoutMode = 'unified';
      if (layoutSelect) layoutSelect.value = 'unified';
      const colorSelect = document.getElementById('color-mode');
      if (colorSelect) {
        colorSelect.value = 'cluster';
        colorMode = 'cluster';
        document.getElementById('density-legend').style.display = 'none';
        document.getElementById('vagueness-legend').style.display = 'none';
        document.getElementById('cluster-legend').style.display = 'flex';
      }
    }

    layoutNodes();
    updateStats();

    const wordCount = Object.keys(data.words).length;
    console.log('Data loaded:', wordCount, 'words,', (data.pairs || []).length, 'pairs');

    // Start auto-refresh polling
    startAutoRefresh();
  } catch (e) {
    console.error('Failed to load data.json:', e);
    loading.textContent = 'Failed to load data.json — waiting for export...';
    // Retry in 5 seconds even on failure (data.json may not exist yet)
    setTimeout(loadData, 5000);
  }
}

function startAutoRefresh() {
  if (autoRefreshTimer) return;
  autoRefreshTimer = setInterval(checkForUpdates, AUTO_REFRESH_INTERVAL);
  updateRefreshIndicator(true);
  console.log(`Auto-refresh enabled (every ${AUTO_REFRESH_INTERVAL/1000}s)`);
}

function stopAutoRefresh() {
  if (autoRefreshTimer) {
    clearInterval(autoRefreshTimer);
    autoRefreshTimer = null;
  }
  updateRefreshIndicator(false);
  console.log('Auto-refresh disabled');
}

async function checkForUpdates() {
  try {
    // HEAD request to check if file changed without downloading it
    const response = await fetch('data.json', { method: 'HEAD', cache: 'no-store' });
    const etag = response.headers.get('ETag') || response.headers.get('Last-Modified') || '';
    const length = parseInt(response.headers.get('Content-Length') || '0');

    // Compare — if either ETag/Last-Modified or Content-Length changed, reload
    const changed = (etag && etag !== lastDataETag) || (length > 0 && length !== lastDataLength);
    if (!changed) return;

    console.log('data.json changed, reloading...');
    lastDataETag = etag;
    lastDataLength = length;

    // Fetch the new data
    const dataResponse = await fetch('data.json', { cache: 'no-store' });
    const newData = await dataResponse.json();

    const oldWords = Object.keys(data?.words || {}).length;
    const oldPairs = (data?.pairs || []).length;
    data = newData;

    const newWords = Object.keys(data.words).length;
    const newPairs = (data.pairs || []).length;

    // Re-layout and update
    layoutNodes();
    updateStats();

    console.log(`Refreshed: ${oldWords}→${newWords} words, ${oldPairs}→${newPairs} pairs`);
    showRefreshFlash();
  } catch (e) {
    // Silent — server might be mid-write
  }
}

function updateRefreshIndicator(active) {
  let indicator = document.getElementById('refresh-indicator');
  if (active && !indicator) {
    indicator = document.createElement('div');
    indicator.id = 'refresh-indicator';
    indicator.style.cssText = 'position:absolute;bottom:10px;right:10px;background:rgba(0,0,0,0.7);color:#4ecdc4;padding:4px 10px;border-radius:4px;font-size:10px;font-family:monospace;z-index:200;cursor:pointer;';
    indicator.textContent = 'AUTO-REFRESH ON';
    indicator.title = 'Click to toggle auto-refresh';
    indicator.onclick = () => {
      if (autoRefreshTimer) stopAutoRefresh();
      else startAutoRefresh();
    };
    document.getElementById('canvas-container').appendChild(indicator);
  } else if (indicator) {
    indicator.textContent = active ? 'AUTO-REFRESH ON' : 'AUTO-REFRESH OFF';
    indicator.style.color = active ? '#4ecdc4' : '#666';
  }
}

function showRefreshFlash() {
  let flash = document.getElementById('refresh-flash');
  if (!flash) {
    flash = document.createElement('div');
    flash.id = 'refresh-flash';
    flash.style.cssText = 'position:absolute;bottom:10px;left:50%;transform:translateX(-50%);background:rgba(78,205,196,0.9);color:#000;padding:6px 16px;border-radius:4px;font-size:11px;font-family:monospace;z-index:200;pointer-events:none;transition:opacity 0.5s;';
    document.getElementById('canvas-container').appendChild(flash);
  }
  flash.textContent = `Updated: ${Object.keys(data.words).length} words, ${(data.pairs || []).length} pairs`;
  flash.style.opacity = '1';
  setTimeout(() => { flash.style.opacity = '0'; }, 2000);
}

// ═══════════════════════════════════════════════════════════════
// INITIALIZATION
// ═══════════════════════════════════════════════════════════════

window.addEventListener('resize', resize);
initGL();
loadData();
render();

console.log('3D Visualization initialized (faithful graph MVP)');