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