OverviewFilesCommitsIssuesDocsReleasesPeers

hypercore-kb / opencog-kb / language-learning / mvp / viz / archive / viz-hierarchy.js

725 lines · javascript ref:
/**
 * Hierarchical Language Learning Visualization
 *
 * Zoomable circle-packing for N-level recursive structures
 * - Level 0: Words
 * - Level 1: Word clusters (pairs initially)
 * - Level 2: Super-clusters (clusters of clusters)
 * - Level N: Meta-structures
 *
 * Same visualization pattern at every level (fractal/recursive)
 */

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const tooltip = document.getElementById('tooltip');
const loading = document.getElementById('loading');

// State
let data = null;
let hierarchy = null;  // Tree structure
let currentView = null;  // Currently zoomed node
let hoveredNode = null;
let animating = false;

// View state
let viewX = 0, viewY = 0, viewScale = 1;
let targetX = 0, targetY = 0, targetScale = 1;

// Colors by level (recursive pattern)
const LEVEL_COLORS = [
  { fill: 'rgba(70, 130, 180, 0.3)', stroke: 'rgba(70, 130, 180, 0.8)' },   // L0: Steel blue
  { fill: 'rgba(60, 179, 113, 0.3)', stroke: 'rgba(60, 179, 113, 0.8)' },   // L1: Medium sea green
  { fill: 'rgba(255, 165, 0, 0.3)', stroke: 'rgba(255, 165, 0, 0.8)' },     // L2: Orange
  { fill: 'rgba(186, 85, 211, 0.3)', stroke: 'rgba(186, 85, 211, 0.8)' },   // L3: Medium orchid
  { fill: 'rgba(220, 20, 60, 0.3)', stroke: 'rgba(220, 20, 60, 0.8)' },     // L4: Crimson
  { fill: 'rgba(0, 206, 209, 0.3)', stroke: 'rgba(0, 206, 209, 0.8)' },     // L5: Turquoise
];

// Get color for level (cycles through palette)
function getLevelColor(level) {
  return LEVEL_COLORS[level % LEVEL_COLORS.length];
}

// ═══════════════════════════════════════════════════════════════
// DATA PROCESSING
// ═══════════════════════════════════════════════════════════════

function buildHierarchy(data) {
  /**
   * Build a tree structure from flat cluster data and merge history
   *
   * Structure:
   * {
   *   id: 'root',
   *   level: -1,
   *   children: [
   *     { id: 'cluster_0', level: 1, children: [{id: 'word1', level: 0}, ...] },
   *     { id: 'word_orphan', level: 0, children: [] },  // unclustered
   *     ...
   *   ]
   * }
   */

  const root = {
    id: 'root',
    name: 'All Structures',
    level: -1,
    children: [],
    data: { type: 'root' }
  };

  const words = data.words || {};
  const clusterDetails = data.clusterDetails || {};
  const hierarchy = data.hierarchy || {};
  const merges = hierarchy.merges || [];

  // Create word nodes
  const wordNodes = new Map();
  for (const [word, info] of Object.entries(words)) {
    wordNodes.set(word, {
      id: `word_${word}`,
      name: word,
      level: 0,
      children: [],
      data: {
        type: 'word',
        word: word,
        surprise: info.surprise || 0,
        count: info.count || 1,
        degree: info.degree || 0,
        totalMI: info.totalMI || 0,
        neighbors: info.neighbors || []
      }
    });
  }

  // Group words by cluster
  const clusterNodes = new Map();
  const wordToCluster = new Map();

  for (const [word, info] of Object.entries(words)) {
    const clusterId = info.cluster;
    if (clusterId >= 0) {
      wordToCluster.set(word, clusterId);

      if (!clusterNodes.has(clusterId)) {
        // Find merge info for this cluster
        const mergeInfo = merges.find(m => m.clusterId === clusterId);

        clusterNodes.set(clusterId, {
          id: `cluster_${clusterId}`,
          name: mergeInfo
            ? `${mergeInfo.entity1} + ${mergeInfo.entity2}`
            : `Cluster ${clusterId}`,
          level: 1,
          children: [],
          data: {
            type: 'cluster',
            clusterId: clusterId,
            vagueness: 0,
            memberCount: 0,
            iteration: mergeInfo?.iteration || 0,
            similarity: mergeInfo?.similarity || 0
          }
        });
      }

      // Add word to cluster
      const wordNode = wordNodes.get(word);
      if (wordNode) {
        clusterNodes.get(clusterId).children.push(wordNode);
        clusterNodes.get(clusterId).data.memberCount++;
      }
    }
  }

  // Calculate cluster-level statistics
  for (const [id, cluster] of clusterNodes) {
    let totalSurprise = 0;
    let totalMI = 0;
    for (const child of cluster.children) {
      totalSurprise += child.data.surprise || 0;
      totalMI += child.data.totalMI || 0;
    }
    cluster.data.vagueness = cluster.children.length > 0
      ? totalSurprise / cluster.children.length
      : 0;
    cluster.data.totalMI = totalMI;
  }

  // Sort clusters by iteration (earliest merges first) then by size
  const clusters = Array.from(clusterNodes.values());
  clusters.sort((a, b) => {
    // First by iteration
    const iterDiff = (a.data.iteration || 0) - (b.data.iteration || 0);
    if (iterDiff !== 0) return iterDiff;
    // Then by size
    return b.children.length - a.children.length;
  });

  // Group clusters by similarity for potential Level 2+ visualization
  // For now, just add all clusters to root
  root.children.push(...clusters);

  // Add orphan words (not in any cluster)
  const orphanWords = [];
  for (const [word, node] of wordNodes) {
    if (!wordToCluster.has(word)) {
      orphanWords.push(node);
    }
  }

  if (orphanWords.length > 0) {
    root.children.push({
      id: 'unclustered',
      name: 'Unclustered',
      level: 1,
      children: orphanWords,
      data: {
        type: 'unclustered',
        memberCount: orphanWords.length
      }
    });
  }

  // Store merge history for timeline visualization
  root.data.mergeHistory = merges;
  root.data.totalIterations = hierarchy.clusterIterations || merges.length;

  return root;
}

function buildFromHierarchyData(hierarchyData, fullData) {
  /**
   * Build tree from explicit hierarchy export
   * hierarchy: {
   *   levels: [
   *     { id: 0, name: 'Words', nodes: [...] },
   *     { id: 1, name: 'Clusters', nodes: [...] },
   *     ...
   *   ],
   *   edges: [{ parent: 'cluster_0', child: 'word_the' }, ...]
   * }
   */
  const root = {
    id: 'root',
    name: 'All Structures',
    level: -1,
    children: [],
    data: { type: 'root' }
  };

  const nodeMap = new Map();
  nodeMap.set('root', root);

  // Create nodes for each level
  for (const level of hierarchyData.levels || []) {
    for (const node of level.nodes || []) {
      const n = {
        id: node.id,
        name: node.name || node.id,
        level: level.id,
        children: [],
        data: node.data || {}
      };
      nodeMap.set(node.id, n);
    }
  }

  // Connect edges
  for (const edge of hierarchyData.edges || []) {
    const parent = nodeMap.get(edge.parent);
    const child = nodeMap.get(edge.child);
    if (parent && child) {
      parent.children.push(child);
    }
  }

  // Find top-level nodes (those not children of anything except root)
  const childIds = new Set((hierarchyData.edges || []).map(e => e.child));
  for (const [id, node] of nodeMap) {
    if (id !== 'root' && !childIds.has(id)) {
      root.children.push(node);
    }
  }

  return root;
}

function buildHigherLevels(root, hierarchyData) {
  // Placeholder for building Level 2+ from hierarchy data
  // Will be populated when unified-loop exports merge history
}

// ═══════════════════════════════════════════════════════════════
// CIRCLE PACKING LAYOUT
// ═══════════════════════════════════════════════════════════════

function packCircles(node, x, y, radius) {
  /**
   * Recursive circle packing layout
   * Each node gets: x, y, r (position and radius)
   */
  node.x = x;
  node.y = y;
  node.r = radius;

  if (!node.children || node.children.length === 0) {
    return;
  }

  // Simple circle packing: arrange children in concentric rings
  const children = node.children;
  const n = children.length;

  if (n === 0) return;

  // Calculate child radius based on count
  // Leave some padding between circles
  const padding = radius * 0.05;
  const innerRadius = radius - padding;

  if (n === 1) {
    // Single child gets most of the space
    packCircles(children[0], x, y, innerRadius * 0.7);
    return;
  }

  // For multiple children, use a simple circular arrangement
  // with size based on child's own children count

  // Calculate total "weight" of children
  let totalWeight = 0;
  for (const child of children) {
    const weight = Math.max(1, (child.children?.length || 0) + 1);
    child._weight = Math.sqrt(weight);  // Square root for area
    totalWeight += child._weight;
  }

  // Determine arrangement
  if (n <= 7) {
    // Small number: single ring around center
    const angleStep = (2 * Math.PI) / n;
    const ringRadius = innerRadius * 0.55;
    const childRadius = innerRadius * 0.35;

    for (let i = 0; i < n; i++) {
      const angle = i * angleStep - Math.PI / 2;
      const cx = x + Math.cos(angle) * ringRadius;
      const cy = y + Math.sin(angle) * ringRadius;
      // Scale radius by weight
      const cr = childRadius * (children[i]._weight / (totalWeight / n));
      packCircles(children[i], cx, cy, Math.min(cr, childRadius * 1.5));
    }
  } else {
    // Many children: use multiple rings or grid-like packing
    // Simplified: use golden angle spiral
    const goldenAngle = Math.PI * (3 - Math.sqrt(5));
    const maxChildRadius = innerRadius / (Math.sqrt(n) + 1);

    for (let i = 0; i < n; i++) {
      const angle = i * goldenAngle;
      const r = innerRadius * 0.3 + (innerRadius * 0.6) * Math.sqrt(i / n);
      const cx = x + Math.cos(angle) * r * 0.85;
      const cy = y + Math.sin(angle) * r * 0.85;
      const cr = maxChildRadius * 0.8 * (children[i]._weight / (totalWeight / n));
      packCircles(children[i], cx, cy, Math.min(cr, maxChildRadius * 1.2));
    }
  }
}

// ═══════════════════════════════════════════════════════════════
// RENDERING
// ═══════════════════════════════════════════════════════════════

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // Background
  ctx.fillStyle = '#0a0a0f';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  if (!hierarchy) {
    ctx.fillStyle = '#4ecdc4';
    ctx.font = '14px Monaco, monospace';
    ctx.textAlign = 'center';
    ctx.fillText('Loading hierarchy...', canvas.width / 2, canvas.height / 2);
    requestAnimationFrame(render);
    return;
  }

  // Animate view transition
  if (animating) {
    const ease = 0.15;
    viewX += (targetX - viewX) * ease;
    viewY += (targetY - viewY) * ease;
    viewScale += (targetScale - viewScale) * ease;

    if (Math.abs(targetX - viewX) < 0.5 &&
        Math.abs(targetY - viewY) < 0.5 &&
        Math.abs(targetScale - viewScale) < 0.01) {
      viewX = targetX;
      viewY = targetY;
      viewScale = targetScale;
      animating = false;
    }
  }

  // Apply view transform
  ctx.save();
  ctx.translate(canvas.width / 2, canvas.height / 2);
  ctx.scale(viewScale, viewScale);
  ctx.translate(-viewX, -viewY);

  // Draw hierarchy
  drawNode(hierarchy, 0);

  ctx.restore();

  // Draw breadcrumb / level indicator
  drawBreadcrumb();

  // Draw legend
  drawLegend();

  requestAnimationFrame(render);
}

function drawNode(node, depth) {
  if (!node.r || node.r < 1) return;

  // Skip nodes too small to see
  const screenRadius = node.r * viewScale;
  if (screenRadius < 2) return;

  const colors = getLevelColor(node.level + 1);  // +1 so root is different
  const isHovered = hoveredNode === node;
  const isCurrentView = currentView === node;

  // Draw circle
  ctx.beginPath();
  ctx.arc(node.x, node.y, node.r, 0, Math.PI * 2);

  // Fill
  if (isHovered) {
    ctx.fillStyle = colors.fill.replace('0.3', '0.5');
  } else if (isCurrentView) {
    ctx.fillStyle = colors.fill.replace('0.3', '0.4');
  } else {
    ctx.fillStyle = colors.fill;
  }
  ctx.fill();

  // Stroke
  ctx.strokeStyle = isHovered ? '#fff' : colors.stroke;
  ctx.lineWidth = isHovered ? 2 / viewScale : 1 / viewScale;
  ctx.stroke();

  // Draw label if large enough
  if (screenRadius > 20) {
    ctx.fillStyle = isHovered ? '#fff' : colors.stroke;
    ctx.font = `${Math.max(10, Math.min(14, node.r * 0.3))}px Monaco, monospace`;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';

    let label = node.name;
    if (label.length > 15) label = label.slice(0, 12) + '...';

    // Only show label if no children visible or at leaf level
    if (!node.children || node.children.length === 0 || screenRadius < 50) {
      ctx.fillText(label, node.x, node.y);
    } else if (screenRadius > 100) {
      // Show label at top of circle for containers
      ctx.fillText(label, node.x, node.y - node.r * 0.7);
    }
  }

  // Draw children
  if (node.children && screenRadius > 10) {
    for (const child of node.children) {
      drawNode(child, depth + 1);
    }
  }
}

function drawBreadcrumb() {
  // Show path from root to current view
  const path = [];
  let node = currentView || hierarchy;
  while (node) {
    path.unshift(node);
    node = node.parent;
  }

  ctx.fillStyle = '#666';
  ctx.font = '12px Monaco, monospace';
  ctx.textAlign = 'left';

  let x = 10;
  const y = canvas.height - 20;

  for (let i = 0; i < path.length; i++) {
    const p = path[i];
    const text = p.name || p.id;

    if (i > 0) {
      ctx.fillStyle = '#444';
      ctx.fillText(' > ', x, y);
      x += ctx.measureText(' > ').width;
    }

    ctx.fillStyle = i === path.length - 1 ? '#4ecdc4' : '#888';
    ctx.fillText(text, x, y);
    x += ctx.measureText(text).width;
  }
}

function drawLegend() {
  const x = 10;
  let y = 60;

  ctx.font = '11px Monaco, monospace';
  ctx.textAlign = 'left';

  ctx.fillStyle = '#666';
  ctx.fillText('LEVELS:', x, y);
  y += 18;

  const maxLevel = getMaxLevel(hierarchy);
  for (let level = 0; level <= Math.min(maxLevel, 5); level++) {
    const colors = getLevelColor(level + 1);

    // Color circle
    ctx.beginPath();
    ctx.arc(x + 8, y - 4, 6, 0, Math.PI * 2);
    ctx.fillStyle = colors.fill;
    ctx.fill();
    ctx.strokeStyle = colors.stroke;
    ctx.lineWidth = 1;
    ctx.stroke();

    // Label
    ctx.fillStyle = '#888';
    const labels = ['Words', 'Clusters', 'Super-clusters', 'Meta-clusters', 'Hyper-clusters', 'Ultra-clusters'];
    ctx.fillText(`L${level}: ${labels[level] || `Level ${level}`}`, x + 20, y);
    y += 18;
  }

  // Instructions
  y += 10;
  ctx.fillStyle = '#555';
  ctx.fillText('Click: zoom in', x, y); y += 14;
  ctx.fillText('Right-click: zoom out', x, y); y += 14;
  ctx.fillText('Scroll: zoom', x, y);
}

function getMaxLevel(node) {
  let max = node.level;
  if (node.children) {
    for (const child of node.children) {
      max = Math.max(max, getMaxLevel(child));
    }
  }
  return max;
}

// ═══════════════════════════════════════════════════════════════
// INTERACTION
// ═══════════════════════════════════════════════════════════════

function resize() {
  const container = document.getElementById('canvas-container');
  canvas.width = container.clientWidth;
  canvas.height = container.clientHeight;

  if (hierarchy) {
    // Repack with new dimensions
    const size = Math.min(canvas.width, canvas.height) * 0.45;
    packCircles(hierarchy, canvas.width / 2, canvas.height / 2, size);

    // Reset view
    viewX = canvas.width / 2;
    viewY = canvas.height / 2;
    viewScale = 1;
    targetX = viewX;
    targetY = viewY;
    targetScale = 1;
  }
}

function findNodeAt(x, y, node) {
  if (!node || !node.r) return null;

  // Transform coordinates
  const tx = (x - canvas.width / 2) / viewScale + viewX;
  const ty = (y - canvas.height / 2) / viewScale + viewY;

  const dx = tx - node.x;
  const dy = ty - node.y;
  const dist = Math.sqrt(dx * dx + dy * dy);

  if (dist > node.r) return null;

  // Check children first (they're on top)
  if (node.children) {
    for (const child of node.children) {
      const found = findNodeAt(x, y, child);
      if (found) return found;
    }
  }

  return node;
}

function zoomTo(node) {
  if (!node) return;

  currentView = node;

  // Calculate zoom to fit node
  const padding = 1.2;
  const targetRadius = Math.min(canvas.width, canvas.height) / 2 / padding;

  targetX = node.x;
  targetY = node.y;
  targetScale = targetRadius / node.r;
  animating = true;
}

function zoomOut() {
  if (currentView && currentView.parent) {
    zoomTo(currentView.parent);
  } else if (currentView !== hierarchy) {
    zoomTo(hierarchy);
  }
}

canvas.addEventListener('mousemove', (e) => {
  const rect = canvas.getBoundingClientRect();
  const x = e.clientX - rect.left;
  const y = e.clientY - rect.top;

  hoveredNode = findNodeAt(x, y, hierarchy);

  if (hoveredNode && hoveredNode.data) {
    tooltip.style.display = 'block';
    tooltip.style.left = (e.clientX + 15) + 'px';
    tooltip.style.top = (e.clientY + 15) + 'px';

    const d = hoveredNode.data;
    let html = `<strong>${hoveredNode.name}</strong><br>`;
    html += `Level: ${hoveredNode.level}<br>`;

    if (d.type === 'word') {
      html += `Count: ${d.count}<br>`;
      html += `Surprise: ${d.surprise?.toFixed(2) || 0}<br>`;
      html += `Connections: ${d.degree}<br>`;
      if (d.neighbors && d.neighbors.length > 0) {
        html += `Neighbors: ${d.neighbors.slice(0, 5).join(', ')}`;
      }
    } else if (d.type === 'cluster' || d.type === 'unclustered') {
      html += `Members: ${hoveredNode.children?.length || 0}<br>`;
      html += `Vagueness: ${d.vagueness?.toFixed(2) || 0}`;
    } else if (d.type === 'root') {
      html += `Total structures: ${hoveredNode.children?.length || 0}`;
    }

    tooltip.innerHTML = html;
  } else {
    tooltip.style.display = 'none';
  }
});

canvas.addEventListener('click', (e) => {
  if (hoveredNode && hoveredNode !== hierarchy) {
    // Set parent references for navigation
    setParentRefs(hierarchy, null);
    zoomTo(hoveredNode);
  }
});

canvas.addEventListener('contextmenu', (e) => {
  e.preventDefault();
  zoomOut();
});

canvas.addEventListener('wheel', (e) => {
  e.preventDefault();
  const delta = e.deltaY > 0 ? 0.9 : 1.1;
  targetScale *= delta;
  targetScale = Math.max(0.5, Math.min(50, targetScale));
  animating = true;
});

function setParentRefs(node, parent) {
  node.parent = parent;
  if (node.children) {
    for (const child of node.children) {
      setParentRefs(child, node);
    }
  }
}

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

async function loadData() {
  try {
    const response = await fetch('data.json');
    data = await response.json();
    loading.style.display = 'none';

    // Build hierarchy
    hierarchy = buildHierarchy(data);
    setParentRefs(hierarchy, null);

    // Initial layout
    const size = Math.min(canvas.width, canvas.height) * 0.45;
    packCircles(hierarchy, canvas.width / 2, canvas.height / 2, size);

    // Set initial view
    viewX = canvas.width / 2;
    viewY = canvas.height / 2;
    viewScale = 1;
    targetX = viewX;
    targetY = viewY;
    targetScale = 1;
    currentView = hierarchy;

    console.log('Hierarchy loaded:', countNodes(hierarchy), 'nodes');
    updateStats();
  } catch (e) {
    console.error('Failed to load data.json:', e);
    loading.textContent = 'Failed to load data.json';
  }
}

function countNodes(node) {
  let count = 1;
  if (node.children) {
    for (const child of node.children) {
      count += countNodes(child);
    }
  }
  return count;
}

function updateStats() {
  if (!data) return;
  const stats = data.stats || {};
  document.getElementById('stat-words').textContent = Object.keys(data.words || {}).length;
  document.getElementById('stat-pairs').textContent = stats.totalPairs || 0;
  document.getElementById('stat-clusters').textContent = stats.totalClusters || 0;
  document.getElementById('stat-level').textContent = stats.level || 0;
  document.getElementById('stat-surprise').textContent = (stats.avgSurprise || 0).toFixed(2);
}

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

console.log('Hierarchical visualization initialized');