feat: add memory graph & sovereignty loop visualization (#18)

- Memory graph: 9 typed nodes (user/feedback/project/reference) as
  glowing spheres with halo rings and labels, positioned at (-15,0,-10)
  with edge connections between related nodes and floating animation
- Sovereignty loop: EXPORT→COMPRESS→TRAIN→EVAL ring at (0,8,-22) with
  arc segments, central soul icosahedron, traveling pulse sphere that
  changes color per stage, slow Y-axis rotation
- HUD legend (top-right) with color key for both memory types and
  sovereignty stages
- Animation: memory nodes float/pulse independently; sovereignty group
  rotates; pulse sphere travels the ring with per-stage color shifts

Fixes #18

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-23 21:22:36 -04:00
parent 3725c933cf
commit c4dec58938
3 changed files with 306 additions and 0 deletions

253
app.js
View File

@@ -34,6 +34,8 @@ let debugOverlay;
let frameCount = 0, lastFPSTime = 0, fps = 0;
let chatOpen = true;
let loadProgress = 0;
let memoryNodes = [];
let sovereigntyGroup, sovereigntyPulse, sovereigntyPulseT = 0;
// ═══ INIT ═══
function init() {
@@ -77,6 +79,8 @@ function init() {
createDustParticles();
updateLoad(85);
createAmbientStructures();
createMemoryGraph();
createSovereigntyLoop();
updateLoad(90);
// Post-processing
@@ -787,6 +791,214 @@ function createAmbientStructures() {
scene.add(pedestal);
}
// ═══ MEMORY GRAPH ═══
function createMemoryGraph() {
const group = new THREE.Group();
group.position.set(-15, 0, -10);
group.name = 'memory-graph';
scene.add(group);
// Memory type color map
const typeColors = {
user: 0x4af0c0,
feedback: 0xff8844,
project: 0x7b5cff,
reference: 0xffd700,
};
// Node definitions: label, type, local position
const nodeDefs = [
{ label: 'role', type: 'user', pos: [-2.5, 4, 0.5] },
{ label: 'prefs', type: 'user', pos: [-1, 5.5, -1 ] },
{ label: 'expertise', type: 'user', pos: [-3, 3, -1.5] },
{ label: 'corrections', type: 'feedback', pos: [ 1.5, 4, -0.5] },
{ label: 'confirmed', type: 'feedback', pos: [ 2.5, 5.5, 1 ] },
{ label: 'milestone', type: 'project', pos: [ 0.5, 2.5, -2 ] },
{ label: 'decisions', type: 'project', pos: [-1, 2, 1 ] },
{ label: 'gitea', type: 'reference', pos: [ 2, 2.8, 2 ] },
{ label: 'grafana', type: 'reference', pos: [-2, 5, 2 ] },
];
// Edges (index pairs)
const edges = [
[0, 1], [0, 2], [1, 2],
[3, 4],
[5, 6],
[7, 8],
[0, 3], [2, 6], [4, 5], [6, 7],
];
// Create node spheres
nodeDefs.forEach((def, i) => {
const color = typeColors[def.type];
const geo = new THREE.SphereGeometry(0.22, 16, 16);
const mat = new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 1.2,
roughness: 0.2,
metalness: 0.5,
});
const sphere = new THREE.Mesh(geo, mat);
sphere.position.set(...def.pos);
sphere.userData = { baseY: def.pos[1], phase: i * 0.7 };
sphere.name = 'memnode_' + i;
group.add(sphere);
// Halo ring
const haloGeo = new THREE.TorusGeometry(0.32, 0.02, 8, 32);
const haloMat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.5 });
const halo = new THREE.Mesh(haloGeo, haloMat);
halo.rotation.x = Math.PI / 2;
sphere.add(halo);
// Canvas label
const lc = document.createElement('canvas');
lc.width = 256; lc.height = 48;
const lctx = lc.getContext('2d');
lctx.font = 'bold 22px "JetBrains Mono", monospace';
lctx.fillStyle = '#' + new THREE.Color(color).getHexString();
lctx.textAlign = 'center';
lctx.fillText(def.label, 128, 32);
const ltex = new THREE.CanvasTexture(lc);
const lmat = new THREE.MeshBasicMaterial({ map: ltex, transparent: true, side: THREE.DoubleSide, depthWrite: false });
const lmesh = new THREE.Mesh(new THREE.PlaneGeometry(1.0, 0.2), lmat);
lmesh.position.y = 0.42;
sphere.add(lmesh);
memoryNodes.push(sphere);
});
// Create edge lines
edges.forEach(([a, b]) => {
const posA = new THREE.Vector3(...nodeDefs[a].pos);
const posB = new THREE.Vector3(...nodeDefs[b].pos);
const points = [posA, posB];
const lineGeo = new THREE.BufferGeometry().setFromPoints(points);
const lineMat = new THREE.LineBasicMaterial({
color: 0x334466,
transparent: true,
opacity: 0.4,
});
group.add(new THREE.Line(lineGeo, lineMat));
});
// Section label
const lc = document.createElement('canvas');
lc.width = 512; lc.height = 56;
const lctx = lc.getContext('2d');
lctx.font = 'bold 28px "Orbitron", sans-serif';
lctx.fillStyle = '#4af0c0';
lctx.textAlign = 'center';
lctx.fillText('◈ MEMORY BANKS', 256, 38);
const ltex = new THREE.CanvasTexture(lc);
const lmat = new THREE.MeshBasicMaterial({ map: ltex, transparent: true, side: THREE.DoubleSide });
const lmesh = new THREE.Mesh(new THREE.PlaneGeometry(4, 0.45), lmat);
lmesh.position.set(0, 7, 0);
group.add(lmesh);
}
// ═══ SOVEREIGNTY LOOP ═══
function createSovereigntyLoop() {
sovereigntyGroup = new THREE.Group();
sovereigntyGroup.position.set(0, 8, -22);
sovereigntyGroup.name = 'sovereignty-loop';
scene.add(sovereigntyGroup);
const stages = [
{ label: 'EXPORT', color: 0x4af0c0, angle: 0 },
{ label: 'COMPRESS', color: 0x7b5cff, angle: Math.PI / 2 },
{ label: 'TRAIN', color: 0xff8844, angle: Math.PI },
{ label: 'EVAL', color: 0xffd700, angle: Math.PI * 3 / 2 },
];
const ringRadius = 3.5;
stages.forEach((s, i) => {
const x = Math.cos(s.angle) * ringRadius;
const z = Math.sin(s.angle) * ringRadius;
// Stage sphere
const geo = new THREE.SphereGeometry(0.38, 20, 20);
const mat = new THREE.MeshStandardMaterial({
color: s.color,
emissive: s.color,
emissiveIntensity: 1.5,
roughness: 0.1,
metalness: 0.7,
});
const sphere = new THREE.Mesh(geo, mat);
sphere.position.set(x, 0, z);
sphere.name = 'stage_' + i;
sovereigntyGroup.add(sphere);
// Stage label
const lc = document.createElement('canvas');
lc.width = 256; lc.height = 48;
const lctx = lc.getContext('2d');
lctx.font = 'bold 24px "Orbitron", sans-serif';
lctx.fillStyle = '#' + new THREE.Color(s.color).getHexString();
lctx.textAlign = 'center';
lctx.fillText(s.label, 128, 32);
const ltex = new THREE.CanvasTexture(lc);
const lmat = new THREE.MeshBasicMaterial({ map: ltex, transparent: true, side: THREE.DoubleSide, depthWrite: false });
const lmesh = new THREE.Mesh(new THREE.PlaneGeometry(1.4, 0.26), lmat);
lmesh.position.set(x, 0.7, z);
sovereigntyGroup.add(lmesh);
// Arc segment to next stage
const nextAngle = stages[(i + 1) % stages.length].angle;
const pts = [];
const steps = 24;
for (let k = 0; k <= steps; k++) {
const a = s.angle + (nextAngle - s.angle + (i === 3 ? Math.PI * 2 : 0)) * (k / steps);
pts.push(new THREE.Vector3(Math.cos(a) * ringRadius, 0, Math.sin(a) * ringRadius));
}
const arcGeo = new THREE.BufferGeometry().setFromPoints(pts);
const arcMat = new THREE.LineBasicMaterial({ color: s.color, transparent: true, opacity: 0.35 });
sovereigntyGroup.add(new THREE.Line(arcGeo, arcMat));
});
// Central soul sphere
const soulGeo = new THREE.IcosahedronGeometry(0.55, 2);
const soulMat = new THREE.MeshPhysicalMaterial({
color: 0xffffff,
emissive: 0x4af0c0,
emissiveIntensity: 2,
roughness: 0,
metalness: 1,
transmission: 0.4,
});
const soul = new THREE.Mesh(soulGeo, soulMat);
soul.name = 'soul-core';
sovereigntyGroup.add(soul);
// Traveling pulse sphere
const pulseGeo = new THREE.SphereGeometry(0.15, 12, 12);
const pulseMat = new THREE.MeshStandardMaterial({
color: 0x4af0c0,
emissive: 0x4af0c0,
emissiveIntensity: 3,
roughness: 0,
});
sovereigntyPulse = new THREE.Mesh(pulseGeo, pulseMat);
sovereigntyGroup.add(sovereigntyPulse);
// Section label
const lc = document.createElement('canvas');
lc.width = 640; lc.height = 56;
const lctx = lc.getContext('2d');
lctx.font = 'bold 28px "Orbitron", sans-serif';
lctx.fillStyle = '#ffd700';
lctx.textAlign = 'center';
lctx.fillText('◈ SOVEREIGNTY LOOP', 320, 38);
const ltex = new THREE.CanvasTexture(lc);
const lmat = new THREE.MeshBasicMaterial({ map: ltex, transparent: true, side: THREE.DoubleSide });
const lmesh = new THREE.Mesh(new THREE.PlaneGeometry(5, 0.45), lmat);
lmesh.position.set(0, 2.2, 0);
sovereigntyGroup.add(lmesh);
}
// ═══ CONTROLS ═══
function setupControls() {
document.addEventListener('keydown', (e) => {
@@ -941,6 +1153,47 @@ function gameLoop() {
}
}
// Animate memory nodes (float & pulse)
memoryNodes.forEach((node, i) => {
node.position.y = node.userData.baseY + Math.sin(elapsed * 0.9 + node.userData.phase) * 0.25;
node.material.emissiveIntensity = 1.0 + Math.sin(elapsed * 1.5 + node.userData.phase) * 0.4;
});
// Animate sovereignty loop
if (sovereigntyGroup) {
sovereigntyGroup.rotation.y = elapsed * 0.12;
// Soul core pulse
const soul = scene.getObjectByName('soul-core');
if (soul) {
soul.rotation.y = elapsed * 0.8;
soul.rotation.x = elapsed * 0.4;
soul.material.emissiveIntensity = 1.5 + Math.sin(elapsed * 2.5) * 0.5;
}
// Traveling pulse around the ring
if (sovereigntyPulse) {
sovereigntyPulseT = (elapsed * 0.4) % 1; // full lap period ~2.5s
const stageAngles = [0, Math.PI / 2, Math.PI, Math.PI * 3 / 2];
const stageColors = [0x4af0c0, 0x7b5cff, 0xff8844, 0xffd700];
const seg = sovereigntyPulseT * 4;
const segIdx = Math.floor(seg) % 4;
const segT = seg - Math.floor(seg);
const aFrom = stageAngles[segIdx];
const aTo = stageAngles[(segIdx + 1) % 4];
// handle wrap-around from EVAL (270°) back to EXPORT (0°/360°)
let diff = aTo - aFrom;
if (diff < 0) diff += Math.PI * 2;
const angle = aFrom + diff * segT;
const ringRadius = 3.5;
sovereigntyPulse.position.set(Math.cos(angle) * ringRadius, 0, Math.sin(angle) * ringRadius);
const col = new THREE.Color(stageColors[segIdx]);
sovereigntyPulse.material.color.copy(col);
sovereigntyPulse.material.emissive.copy(col);
sovereigntyPulse.material.emissiveIntensity = 2.5 + Math.sin(elapsed * 8) * 0.5;
}
}
// Animate nexus core
const core = scene.getObjectByName('nexus-core');
if (core) {

View File

@@ -95,6 +95,20 @@
</div>
</div>
<!-- Memory legend -->
<div class="hud-memory-legend">
<div class="legend-title">MEMORY BANKS</div>
<div class="legend-row"><span class="legend-dot" style="background:#4af0c0"></span>USER</div>
<div class="legend-row"><span class="legend-dot" style="background:#ff8844"></span>FEEDBACK</div>
<div class="legend-row"><span class="legend-dot" style="background:#7b5cff"></span>PROJECT</div>
<div class="legend-row"><span class="legend-dot" style="background:#ffd700"></span>REFERENCE</div>
<div class="legend-title" style="margin-top:8px">SOVEREIGNTY LOOP</div>
<div class="legend-row"><span class="legend-dot" style="background:#4af0c0"></span>EXPORT</div>
<div class="legend-row"><span class="legend-dot" style="background:#7b5cff"></span>COMPRESS</div>
<div class="legend-row"><span class="legend-dot" style="background:#ff8844"></span>TRAIN</div>
<div class="legend-row"><span class="legend-dot" style="background:#ffd700"></span>EVAL</div>
</div>
<!-- Minimap / Controls hint -->
<div class="hud-controls">
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat

View File

@@ -359,3 +359,42 @@ canvas#nexus-canvas {
display: none;
}
}
/* === MEMORY LEGEND === */
.hud-memory-legend {
position: fixed;
top: 80px;
right: var(--space-4);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--panel-radius);
padding: var(--space-3);
backdrop-filter: blur(var(--panel-blur));
min-width: 140px;
font-size: var(--text-xs);
font-family: var(--font-body);
color: var(--color-text-muted);
}
.legend-title {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.1em;
color: var(--color-primary);
margin-bottom: var(--space-1);
text-transform: uppercase;
}
.legend-row {
display: flex;
align-items: center;
gap: var(--space-2);
margin: 3px 0;
font-size: var(--text-xs);
letter-spacing: 0.05em;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 6px currentColor;
}