/**
* 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');