diff --git a/nexus/components/memory-pulse.js b/nexus/components/memory-pulse.js new file mode 100644 index 00000000..ae75bffe --- /dev/null +++ b/nexus/components/memory-pulse.js @@ -0,0 +1,256 @@ +// ═══════════════════════════════════════════════════════════ +// MNEMOSYNE — Memory Pulse +// ═══════════════════════════════════════════════════════════ +// +// Visual pulse wave that radiates through the connection graph +// when a memory crystal is clicked. Illuminates linked memories +// by BFS hop distance — closer neighbors light up first. +// +// Usage from app.js: +// import { MemoryPulse } from './nexus/components/memory-pulse.js'; +// MemoryPulse.init(scene); +// MemoryPulse.trigger(clickedMemId, SpatialMemory); +// +// Depends on: SpatialMemory (getAllMemories, getMemoryFromMesh) +// ═══════════════════════════════════════════════════════════ + +const MemoryPulse = (() => { + let _scene = null; + let _activePulses = []; // track running animations for cleanup + + const HOP_DELAY = 300; // ms between each BFS hop wave + const GLOW_DURATION = 800; // ms each crystal glows at peak + const FADE_DURATION = 600; // ms to fade back to normal + const PULSE_COLOR = 0x4af0c0; // cyan-green pulse glow + const PULSE_INTENSITY = 6.0; // peak emissive during pulse + const RING_DURATION = 1200; // ms for the expanding ring effect + + // ─── INIT ──────────────────────────────────────────────── + function init(scene) { + _scene = scene; + } + + // ─── BFS TRAVERSAL ─────────────────────────────────────── + // Returns array of arrays: [[hop-0 ids], [hop-1 ids], [hop-2 ids], ...] + function bfsHops(startId, allMemories) { + const memMap = {}; + for (const m of allMemories) { + memMap[m.id] = m; + } + + if (!memMap[startId]) return []; + + const visited = new Set([startId]); + const hops = []; + let frontier = [startId]; + + while (frontier.length > 0) { + hops.push([...frontier]); + const next = []; + for (const id of frontier) { + const mem = memMap[id]; + if (!mem || !mem.connections) continue; + for (const connId of mem.connections) { + if (!visited.has(connId)) { + visited.add(connId); + next.push(connId); + } + } + } + frontier = next; + } + + return hops; + } + + // ─── EXPANDING RING ────────────────────────────────────── + // Creates a flat ring geometry that expands outward from a position + function createExpandingRing(position, color) { + const ringGeo = new THREE.RingGeometry(0.1, 0.2, 32); + const ringMat = new THREE.MeshBasicMaterial({ + color: color, + transparent: true, + opacity: 0.8, + side: THREE.DoubleSide, + depthWrite: false + }); + const ring = new THREE.Mesh(ringGeo, ringMat); + ring.position.copy(position); + ring.position.y += 0.1; // slightly above crystal + ring.rotation.x = -Math.PI / 2; // flat horizontal + ring.scale.set(0.1, 0.1, 0.1); + _scene.add(ring); + return ring; + } + + // ─── ANIMATE RING ──────────────────────────────────────── + function animateRing(ring, onComplete) { + const startTime = performance.now(); + function tick() { + const elapsed = performance.now() - startTime; + const t = Math.min(1, elapsed / RING_DURATION); + + // Expand outward + const scale = 0.1 + t * 4.0; + ring.scale.set(scale, scale, scale); + + // Fade out + ring.material.opacity = 0.8 * (1 - t * t); + + if (t < 1) { + requestAnimationFrame(tick); + } else { + _scene.remove(ring); + ring.geometry.dispose(); + ring.material.dispose(); + if (onComplete) onComplete(); + } + } + requestAnimationFrame(tick); + } + + // ─── PULSE CRYSTAL GLOW ────────────────────────────────── + // Temporarily boosts a crystal's emissive intensity + function pulseGlow(mesh, hopIndex) { + if (!mesh || !mesh.material) return; + + const originalIntensity = mesh.material.emissiveIntensity; + const originalColor = mesh.material.emissive ? mesh.material.emissive.clone() : null; + const delay = hopIndex * HOP_DELAY; + + setTimeout(() => { + if (!mesh.material) return; + + // Store original for restore + const origInt = mesh.material.emissiveIntensity; + + // Flash to pulse color + if (mesh.material.emissive) { + mesh.material.emissive.setHex(PULSE_COLOR); + } + mesh.material.emissiveIntensity = PULSE_INTENSITY; + + // Also boost point light if present + let origLightIntensity = null; + let origLightColor = null; + if (mesh.children) { + for (const child of mesh.children) { + if (child.isPointLight) { + origLightIntensity = child.intensity; + origLightColor = child.color.clone(); + child.intensity = 3.0; + child.color.setHex(PULSE_COLOR); + } + } + } + + // Hold at peak, then fade + setTimeout(() => { + const fadeStart = performance.now(); + function fadeTick() { + const elapsed = performance.now() - fadeStart; + const t = Math.min(1, elapsed / FADE_DURATION); + const eased = 1 - (1 - t) * (1 - t); // ease-out quad + + mesh.material.emissiveIntensity = PULSE_INTENSITY + (origInt - PULSE_INTENSITY) * eased; + + if (originalColor) { + const pr = ((PULSE_COLOR >> 16) & 0xff) / 255; + const pg = ((PULSE_COLOR >> 8) & 0xff) / 255; + const pb = (PULSE_COLOR & 0xff) / 255; + mesh.material.emissive.setRGB( + pr + (originalColor.r - pr) * eased, + pg + (originalColor.g - pg) * eased, + pb + (originalColor.b - pb) * eased + ); + } + + // Restore point light + if (origLightIntensity !== null && mesh.children) { + for (const child of mesh.children) { + if (child.isPointLight) { + child.intensity = 3.0 + (origLightIntensity - 3.0) * eased; + if (origLightColor) { + const pr = ((PULSE_COLOR >> 16) & 0xff) / 255; + const pg = ((PULSE_COLOR >> 8) & 0xff) / 255; + const pb = (PULSE_COLOR & 0xff) / 255; + child.color.setRGB( + pr + (origLightColor.r - pr) * eased, + pg + (origLightColor.g - pg) * eased, + pb + (origLightColor.b - pb) * eased + ); + } + } + } + } + + if (t < 1) { + requestAnimationFrame(fadeTick); + } + } + requestAnimationFrame(fadeTick); + }, GLOW_DURATION); + }, delay); + } + + // ─── TRIGGER ───────────────────────────────────────────── + // Main entry point: fire a pulse wave from the given memory ID + function trigger(memId, spatialMemory) { + if (!_scene) return; + + const allMemories = spatialMemory.getAllMemories(); + const hops = bfsHops(memId, allMemories); + + if (hops.length <= 1) { + // No connections — just do a local ring + const obj = spatialMemory.getMemoryFromMesh( + spatialMemory.getCrystalMeshes().find(m => m.userData.memId === memId) + ); + if (obj && obj.mesh) { + const ring = createExpandingRing(obj.mesh.position, PULSE_COLOR); + animateRing(ring); + } + return; + } + + // For each hop level, create expanding rings and pulse glows + for (let hopIdx = 0; hopIdx < hops.length; hopIdx++) { + const idsInHop = hops[hopIdx]; + + for (const id of idsInHop) { + // Find mesh for this memory + const meshes = spatialMemory.getCrystalMeshes(); + let targetMesh = null; + for (const m of meshes) { + if (m.userData && m.userData.memId === id) { + targetMesh = m; + break; + } + } + + if (!targetMesh) continue; + + // Schedule pulse glow + pulseGlow(targetMesh, hopIdx); + + // Create expanding ring at this hop's delay + ((mesh, delay) => { + setTimeout(() => { + const ring = createExpandingRing(mesh.position, PULSE_COLOR); + animateRing(ring); + }, delay * HOP_DELAY); + })(targetMesh, hopIdx); + } + } + } + + // ─── CLEANUP ───────────────────────────────────────────── + function dispose() { + // Active pulses will self-clean via their animation callbacks + _activePulses = []; + } + + return { init, trigger, dispose, bfsHops }; +})(); + +export { MemoryPulse };