feat: dashboard v2.1 glassmorphism + graph decomposition + fix flaky macOS vector test

Dashboard v2.1 "Nuclear" upgrade:
- Dark glassmorphism UI system (4-tier glass utilities, ambient orbs, nav glow)
- Graph3D decomposed from 806-line monolith into 10 focused modules
- Custom GLSL shaders (nebula FBM background, chromatic aberration, film grain, vignette)
- Enhanced dream mode with smooth 2s lerped transitions and aurora cycling
- Cognitive pipeline visualizer (7-stage search cascade animation)
- Temporal playback slider (scrub through memory evolution over time)
- Bioluminescent color palette for node types and events

Fix flaky CI test on macOS:
- vector::tests::test_add_and_search used near-identical test vectors (additive phase shift)
- Changed to multiplicative frequency so each seed produces a distinct vector

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-03-01 21:24:10 -06:00
parent 2c1f499a8b
commit d98cf6136a
241 changed files with 6262 additions and 4884 deletions

View file

@ -1,12 +1,17 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import type { GraphNode, GraphEdge, VestigeEvent } from '$types';
import { NODE_TYPE_COLORS } from '$types';
import { createScene, resizeScene, disposeScene, type SceneContext } from '$lib/graph/scene';
import { ForceSimulation } from '$lib/graph/force-sim';
import { NodeManager } from '$lib/graph/nodes';
import { EdgeManager } from '$lib/graph/edges';
import { ParticleSystem } from '$lib/graph/particles';
import { EffectManager } from '$lib/graph/effects';
import { DreamMode } from '$lib/graph/dream-mode';
import { mapEventToEffects } from '$lib/graph/events';
import { createNebulaBackground, updateNebula } from '$lib/graph/shaders/nebula.frag';
import { createPostProcessing, updatePostProcessing, type PostProcessingStack } from '$lib/graph/shaders/post-processing';
import type * as THREE from 'three';
interface Props {
nodes: GraphNode[];
@ -20,45 +25,47 @@
let { nodes, edges, centerId, events = [], isDreaming = false, onSelect }: Props = $props();
let container: HTMLDivElement;
let renderer: THREE.WebGLRenderer;
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let controls: OrbitControls;
let composer: EffectComposer;
let bloomPass: UnrealBloomPass;
let raycaster: THREE.Raycaster;
let mouse: THREE.Vector2;
let ctx: SceneContext;
let animationId: number;
let nodeGroup: THREE.Group;
let edgeGroup: THREE.Group;
let particleSystem: THREE.Points;
let starField: THREE.Points;
// Maps for lookup
let nodeMeshMap = new Map<string, THREE.Mesh>();
let nodePositions = new Map<string, THREE.Vector3>();
let labelSprites = new Map<string, THREE.Sprite>();
let hoveredNode: string | null = null;
let selectedNode: string | null = null;
// Modules
let nodeManager: NodeManager;
let edgeManager: EdgeManager;
let particles: ParticleSystem;
let effects: EffectManager;
let forceSim: ForceSimulation;
let dreamMode: DreamMode;
let nebulaMaterial: THREE.ShaderMaterial;
let postStack: PostProcessingStack;
// Force simulation state
let velocities = new Map<string, THREE.Vector3>();
let simulationRunning = true;
let simulationStep = 0;
// Event-driven animation state
// Event tracking
let processedEventCount = 0;
let pulseEffects: { nodeId: string; intensity: number; color: THREE.Color; decay: number }[] = [];
let connectionFlashes: { line: THREE.Line; intensity: number }[] = [];
let spawnBursts: { position: THREE.Vector3; age: number; particles: THREE.Points }[] = [];
let dreamTrails: { points: THREE.Vector3[]; line: THREE.Line; age: number }[] = [];
let shockwaves: { mesh: THREE.Mesh; age: number; maxAge: number }[] = [];
onMount(() => {
initScene();
createStarField();
createGraph();
createParticleSystem();
ctx = createScene(container);
// Nebula background
const nebula = createNebulaBackground(ctx.scene);
nebulaMaterial = nebula.material;
// Post-processing (added after bloom)
postStack = createPostProcessing(ctx.composer);
// Modules
particles = new ParticleSystem(ctx.scene);
nodeManager = new NodeManager();
edgeManager = new EdgeManager();
effects = new EffectManager(ctx.scene);
dreamMode = new DreamMode();
// Build graph
const positions = nodeManager.createNodes(nodes);
edgeManager.createEdges(edges, positions);
forceSim = new ForceSimulation(positions);
ctx.scene.add(edgeManager.group);
ctx.scene.add(nodeManager.group);
animate();
window.addEventListener('resize', onResize);
@ -71,432 +78,48 @@
window.removeEventListener('resize', onResize);
container?.removeEventListener('pointermove', onPointerMove);
container?.removeEventListener('click', onClick);
// Dispose Three.js resources to prevent GPU memory leaks
scene?.traverse((obj: THREE.Object3D) => {
if (obj instanceof THREE.Mesh || obj instanceof THREE.InstancedMesh) {
obj.geometry?.dispose();
if (Array.isArray(obj.material)) {
obj.material.forEach((m: THREE.Material) => m.dispose());
} else if (obj.material) {
(obj.material as THREE.Material).dispose();
}
}
});
renderer?.dispose();
composer?.dispose();
effects?.dispose();
particles?.dispose();
nodeManager?.dispose();
edgeManager?.dispose();
if (ctx) disposeScene(ctx);
});
function initScene() {
// Scene
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x050510, 0.008);
// Camera
camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 0.1, 2000);
camera.position.set(0, 30, 80);
// Renderer (WebGL2 — WebGPU requires async init, use WebGL for now with bloom)
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: 'high-performance'
});
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
container.appendChild(renderer.domElement);
// Controls
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.rotateSpeed = 0.5;
controls.zoomSpeed = 0.8;
controls.minDistance = 10;
controls.maxDistance = 500;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.3;
// Post-processing: Bloom
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
bloomPass = new UnrealBloomPass(
new THREE.Vector2(container.clientWidth, container.clientHeight),
0.8, // strength
0.4, // radius
0.85 // threshold
);
composer.addPass(bloomPass);
// Lighting
const ambient = new THREE.AmbientLight(0x1a1a3a, 0.5);
scene.add(ambient);
const point1 = new THREE.PointLight(0x6366f1, 1.5, 200);
point1.position.set(50, 50, 50);
scene.add(point1);
const point2 = new THREE.PointLight(0xa855f7, 1, 200);
point2.position.set(-50, -30, -50);
scene.add(point2);
// Raycaster
raycaster = new THREE.Raycaster();
raycaster.params.Points = { threshold: 2 };
mouse = new THREE.Vector2();
}
function createStarField() {
const starGeo = new THREE.BufferGeometry();
const starCount = 3000;
const positions = new Float32Array(starCount * 3);
const sizes = new Float32Array(starCount);
for (let i = 0; i < starCount; i++) {
positions[i * 3] = (Math.random() - 0.5) * 1000;
positions[i * 3 + 1] = (Math.random() - 0.5) * 1000;
positions[i * 3 + 2] = (Math.random() - 0.5) * 1000;
sizes[i] = Math.random() * 1.5;
}
starGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
starGeo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
const starMat = new THREE.PointsMaterial({
color: 0x6366f1,
size: 0.5,
transparent: true,
opacity: 0.4,
sizeAttenuation: true,
blending: THREE.AdditiveBlending
});
starField = new THREE.Points(starGeo, starMat);
scene.add(starField);
}
function createGraph() {
nodeGroup = new THREE.Group();
edgeGroup = new THREE.Group();
// Position nodes using force-directed initial layout
const nodeCount = nodes.length;
const phi = (1 + Math.sqrt(5)) / 2; // Golden ratio for sphere distribution
nodes.forEach((node, i) => {
// Fibonacci sphere distribution for initial positions
const y = 1 - (2 * i) / (nodeCount - 1 || 1);
const radius = Math.sqrt(1 - y * y);
const theta = 2 * Math.PI * i / phi;
const spread = 30 + nodeCount * 0.5;
const pos = new THREE.Vector3(
radius * Math.cos(theta) * spread,
y * spread,
radius * Math.sin(theta) * spread
);
// Center node at origin
if (node.isCenter) pos.set(0, 0, 0);
nodePositions.set(node.id, pos);
velocities.set(node.id, new THREE.Vector3());
// Create node mesh
const size = 0.5 + node.retention * 2;
const color = NODE_TYPE_COLORS[node.type] || '#6b7280';
const geometry = new THREE.SphereGeometry(size, 16, 16);
const material = new THREE.MeshStandardMaterial({
color: new THREE.Color(color),
emissive: new THREE.Color(color),
emissiveIntensity: 0.3 + node.retention * 0.5,
roughness: 0.3,
metalness: 0.1,
transparent: true,
opacity: 0.3 + node.retention * 0.7,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.copy(pos);
mesh.userData = { nodeId: node.id, type: node.type, retention: node.retention };
nodeMeshMap.set(node.id, mesh);
nodeGroup.add(mesh);
// Glow sprite
const spriteMat = new THREE.SpriteMaterial({
color: new THREE.Color(color),
transparent: true,
opacity: 0.15 + node.retention * 0.2,
blending: THREE.AdditiveBlending,
});
const sprite = new THREE.Sprite(spriteMat);
sprite.scale.set(size * 4, size * 4, 1);
sprite.position.copy(pos);
sprite.userData = { isGlow: true, nodeId: node.id };
nodeGroup.add(sprite);
// Text label sprite (distance-faded)
const labelText = node.label || node.type;
const labelSprite = createTextSprite(labelText, '#e2e8f0');
labelSprite.position.copy(pos);
labelSprite.position.y += size * 2 + 1.5;
labelSprite.userData = { isLabel: true, nodeId: node.id, offset: size * 2 + 1.5 };
nodeGroup.add(labelSprite);
labelSprites.set(node.id, labelSprite);
});
// Create edges
edges.forEach(edge => {
const sourcePos = nodePositions.get(edge.source);
const targetPos = nodePositions.get(edge.target);
if (!sourcePos || !targetPos) return;
const points = [sourcePos, targetPos];
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color: 0x4a4a7a,
transparent: true,
opacity: Math.min(0.1 + edge.weight * 0.5, 0.6),
blending: THREE.AdditiveBlending,
});
const line = new THREE.Line(geometry, material);
line.userData = { source: edge.source, target: edge.target };
edgeGroup.add(line);
});
scene.add(edgeGroup);
scene.add(nodeGroup);
}
function createParticleSystem() {
const particleCount = 500;
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(particleCount * 3);
const colors = new Float32Array(particleCount * 3);
for (let i = 0; i < particleCount; i++) {
positions[i * 3] = (Math.random() - 0.5) * 100;
positions[i * 3 + 1] = (Math.random() - 0.5) * 100;
positions[i * 3 + 2] = (Math.random() - 0.5) * 100;
// Purple-blue neural particles
colors[i * 3] = 0.4 + Math.random() * 0.3;
colors[i * 3 + 1] = 0.3 + Math.random() * 0.2;
colors[i * 3 + 2] = 0.8 + Math.random() * 0.2;
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
size: 0.3,
vertexColors: true,
transparent: true,
opacity: 0.4,
blending: THREE.AdditiveBlending,
sizeAttenuation: true,
});
particleSystem = new THREE.Points(geometry, material);
scene.add(particleSystem);
}
function createTextSprite(text: string, color: string): THREE.Sprite {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
canvas.width = 512;
canvas.height = 64;
// Truncate text
const label = text.length > 40 ? text.slice(0, 37) + '...' : text;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = 'bold 28px -apple-system, BlinkMacSystemFont, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Shadow for readability
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)';
ctx.shadowBlur = 6;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 2;
ctx.fillStyle = color;
ctx.fillText(label, canvas.width / 2, canvas.height / 2);
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
const mat = new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: 0,
depthTest: false,
sizeAttenuation: true,
});
const sprite = new THREE.Sprite(mat);
sprite.scale.set(12, 1.5, 1);
return sprite;
}
function runForceSimulation() {
if (!simulationRunning || simulationStep > 300) return;
simulationStep++;
const alpha = Math.max(0.001, 1 - simulationStep / 300);
const repulsionStrength = 500;
const attractionStrength = 0.01;
const dampening = 0.9;
// Repulsion between all nodes
const nodeIds = Array.from(nodePositions.keys());
for (let i = 0; i < nodeIds.length; i++) {
for (let j = i + 1; j < nodeIds.length; j++) {
const posA = nodePositions.get(nodeIds[i])!;
const posB = nodePositions.get(nodeIds[j])!;
const diff = new THREE.Vector3().subVectors(posA, posB);
const dist = diff.length() || 1;
const force = repulsionStrength / (dist * dist) * alpha;
const dir = diff.normalize().multiplyScalar(force);
velocities.get(nodeIds[i])!.add(dir);
velocities.get(nodeIds[j])!.sub(dir);
}
}
// Attraction along edges
edges.forEach(edge => {
const posA = nodePositions.get(edge.source);
const posB = nodePositions.get(edge.target);
if (!posA || !posB) return;
const diff = new THREE.Vector3().subVectors(posB, posA);
const dist = diff.length();
const force = dist * attractionStrength * edge.weight * alpha;
const dir = diff.normalize().multiplyScalar(force);
velocities.get(edge.source)!.add(dir);
velocities.get(edge.target)!.sub(dir);
});
// Centering force
nodeIds.forEach(id => {
const pos = nodePositions.get(id)!;
const vel = velocities.get(id)!;
vel.sub(pos.clone().multiplyScalar(0.001 * alpha));
vel.multiplyScalar(dampening);
pos.add(vel);
// Update mesh positions
const mesh = nodeMeshMap.get(id);
if (mesh) mesh.position.copy(pos);
});
// Update glow sprite and label positions
nodeGroup.children.forEach(child => {
if (child.userData.nodeId) {
const pos = nodePositions.get(child.userData.nodeId);
if (!pos) return;
if (child.userData.isGlow) {
child.position.copy(pos);
} else if (child.userData.isLabel) {
child.position.copy(pos);
child.position.y += child.userData.offset;
}
}
});
// Update edge positions
edgeGroup.children.forEach(child => {
const line = child as THREE.Line;
const sourcePos = nodePositions.get(line.userData.source);
const targetPos = nodePositions.get(line.userData.target);
if (sourcePos && targetPos) {
const positions = line.geometry.attributes.position as THREE.BufferAttribute;
positions.setXYZ(0, sourcePos.x, sourcePos.y, sourcePos.z);
positions.setXYZ(1, targetPos.x, targetPos.y, targetPos.z);
positions.needsUpdate = true;
}
});
}
function animate() {
animationId = requestAnimationFrame(animate);
const time = performance.now() * 0.001;
// Force simulation
runForceSimulation();
forceSim.tick(edges);
// Animate particles
if (particleSystem) {
const positions = particleSystem.geometry.attributes.position as THREE.BufferAttribute;
for (let i = 0; i < positions.count; i++) {
positions.setY(i, positions.getY(i) + Math.sin(time + i * 0.1) * 0.02);
positions.setX(i, positions.getX(i) + Math.cos(time + i * 0.05) * 0.01);
}
positions.needsUpdate = true;
}
// Update positions
nodeManager.updatePositions();
edgeManager.updatePositions(nodeManager.positions);
// Slow star rotation
if (starField) {
starField.rotation.y += 0.0001;
starField.rotation.x += 0.00005;
}
// Animate
particles.animate(time);
nodeManager.animate(time, nodes, ctx.camera);
// Node breathing (retention-based pulse)
nodeMeshMap.forEach((mesh, id) => {
const node = nodes.find(n => n.id === id);
if (!node) return;
const breathe = 1 + Math.sin(time * 1.5 + nodes.indexOf(node) * 0.5) * 0.05 * node.retention;
mesh.scale.setScalar(breathe);
// Dream mode
dreamMode.setActive(isDreaming);
dreamMode.update(ctx.scene, ctx.bloomPass, ctx.controls, ctx.lights, time);
// Highlight hovered
const mat = mesh.material as THREE.MeshStandardMaterial;
if (id === hoveredNode) {
mat.emissiveIntensity = 1.0;
} else if (id === selectedNode) {
mat.emissiveIntensity = 0.8;
} else {
mat.emissiveIntensity = 0.3 + node.retention * 0.5;
}
});
// Nebula + post-processing
updateNebula(
nebulaMaterial,
time,
dreamMode.current.nebulaIntensity,
container.clientWidth,
container.clientHeight
);
updatePostProcessing(postStack, time, dreamMode.current.nebulaIntensity);
// Distance-based label visibility
labelSprites.forEach((sprite, id) => {
const pos = nodePositions.get(id);
if (!pos) return;
const dist = camera.position.distanceTo(pos);
const mat = sprite.material as THREE.SpriteMaterial;
// Fade in when close (< 40 units), fade out when far (> 80 units)
const targetOpacity = id === hoveredNode || id === selectedNode
? 1.0
: dist < 40 ? 0.9 : dist < 80 ? 0.9 * (1 - (dist - 40) / 40) : 0;
mat.opacity += (targetOpacity - mat.opacity) * 0.1;
});
// Dream mode: slower rotation, purple tint, stronger bloom
if (isDreaming) {
controls.autoRotateSpeed = 0.1;
bloomPass.strength = 1.5;
scene.fog = new THREE.FogExp2(0x0a0520, 0.006);
} else {
controls.autoRotateSpeed = 0.3;
bloomPass.strength = 0.8;
}
// Process incoming events
// Events + effects
processEvents();
effects.update(nodeManager.meshMap, ctx.camera);
// Update visual effects
updateEffects(time);
controls.update();
composer.render();
ctx.controls.update();
ctx.composer.render();
}
function processEvents() {
@ -506,300 +129,40 @@
processedEventCount = events.length;
for (const event of newEvents) {
switch (event.type) {
case 'MemoryCreated': {
// Spawn burst: ring of particles expanding outward
const nodeId = (event.data as { id?: string })?.id;
const pos = nodeId ? nodePositions.get(nodeId) : null;
const burstPos = pos?.clone() ?? new THREE.Vector3(
(Math.random() - 0.5) * 40,
(Math.random() - 0.5) * 40,
(Math.random() - 0.5) * 40
);
createSpawnBurst(burstPos, new THREE.Color(0x10b981));
// Also create a shockwave ring
createShockwave(burstPos, new THREE.Color(0x10b981));
break;
}
case 'SearchPerformed': {
// Pulse all visible nodes with blue ripple
const query = (event.data as { query?: string })?.query;
nodeMeshMap.forEach((_, id) => {
pulseEffects.push({
nodeId: id,
intensity: 0.6 + Math.random() * 0.4,
color: new THREE.Color(0x3b82f6),
decay: 0.02
});
});
break;
}
case 'DreamStarted': {
// Dramatic: pulse everything purple, slow time
nodeMeshMap.forEach((_, id) => {
pulseEffects.push({
nodeId: id,
intensity: 1.0,
color: new THREE.Color(0xa855f7),
decay: 0.005
});
});
break;
}
case 'DreamProgress': {
// Light up specific memories as they're "replayed"
const memoryId = (event.data as { memory_id?: string })?.memory_id;
if (memoryId && nodeMeshMap.has(memoryId)) {
pulseEffects.push({
nodeId: memoryId,
intensity: 1.5,
color: new THREE.Color(0xc084fc),
decay: 0.01
});
}
break;
}
case 'DreamCompleted': {
// Celebration burst from center
createSpawnBurst(new THREE.Vector3(0, 0, 0), new THREE.Color(0xa855f7));
createShockwave(new THREE.Vector3(0, 0, 0), new THREE.Color(0xa855f7));
break;
}
case 'ConnectionDiscovered': {
const data = event.data as { source_id?: string; target_id?: string };
const srcPos = data.source_id ? nodePositions.get(data.source_id) : null;
const tgtPos = data.target_id ? nodePositions.get(data.target_id) : null;
if (srcPos && tgtPos) {
createConnectionFlash(srcPos, tgtPos, new THREE.Color(0xf59e0b));
}
break;
}
case 'RetentionDecayed': {
const decayId = (event.data as { id?: string })?.id;
if (decayId && nodeMeshMap.has(decayId)) {
pulseEffects.push({
nodeId: decayId,
intensity: 0.8,
color: new THREE.Color(0xef4444),
decay: 0.03
});
}
break;
}
case 'MemoryPromoted': {
const promoId = (event.data as { id?: string })?.id;
if (promoId && nodeMeshMap.has(promoId)) {
pulseEffects.push({
nodeId: promoId,
intensity: 1.2,
color: new THREE.Color(0x10b981),
decay: 0.01
});
const promoPos = nodePositions.get(promoId);
if (promoPos) createShockwave(promoPos, new THREE.Color(0x10b981));
}
break;
}
case 'ConsolidationCompleted': {
// Global shimmer effect
nodeMeshMap.forEach((_, id) => {
pulseEffects.push({
nodeId: id,
intensity: 0.4 + Math.random() * 0.3,
color: new THREE.Color(0xf59e0b),
decay: 0.015
});
});
break;
}
}
}
}
function createSpawnBurst(position: THREE.Vector3, color: THREE.Color) {
const count = 60;
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const velocitiesArr = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
positions[i * 3] = position.x;
positions[i * 3 + 1] = position.y;
positions[i * 3 + 2] = position.z;
// Random outward velocity
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const speed = 0.3 + Math.random() * 0.5;
velocitiesArr[i * 3] = Math.sin(phi) * Math.cos(theta) * speed;
velocitiesArr[i * 3 + 1] = Math.sin(phi) * Math.sin(theta) * speed;
velocitiesArr[i * 3 + 2] = Math.cos(phi) * speed;
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('velocity', new THREE.BufferAttribute(velocitiesArr, 3));
const mat = new THREE.PointsMaterial({
color,
size: 0.6,
transparent: true,
opacity: 1.0,
blending: THREE.AdditiveBlending,
sizeAttenuation: true,
});
const pts = new THREE.Points(geo, mat);
scene.add(pts);
spawnBursts.push({ position: position.clone(), age: 0, particles: pts });
}
function createShockwave(position: THREE.Vector3, color: THREE.Color) {
const geo = new THREE.RingGeometry(0.1, 0.5, 64);
const mat = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide,
blending: THREE.AdditiveBlending,
});
const ring = new THREE.Mesh(geo, mat);
ring.position.copy(position);
// Face camera
ring.lookAt(camera.position);
scene.add(ring);
shockwaves.push({ mesh: ring, age: 0, maxAge: 60 });
}
function createConnectionFlash(from: THREE.Vector3, to: THREE.Vector3, color: THREE.Color) {
const points = [from.clone(), to.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
const mat = new THREE.LineBasicMaterial({
color,
transparent: true,
opacity: 1.0,
blending: THREE.AdditiveBlending,
});
const line = new THREE.Line(geo, mat);
scene.add(line);
connectionFlashes.push({ line, intensity: 1.0 });
}
function updateEffects(time: number) {
// Update pulse effects on nodes
for (let i = pulseEffects.length - 1; i >= 0; i--) {
const pulse = pulseEffects[i];
pulse.intensity -= pulse.decay;
if (pulse.intensity <= 0) {
pulseEffects.splice(i, 1);
continue;
}
const mesh = nodeMeshMap.get(pulse.nodeId);
if (mesh) {
const mat = mesh.material as THREE.MeshStandardMaterial;
mat.emissive.lerp(pulse.color, pulse.intensity * 0.3);
mat.emissiveIntensity = Math.max(mat.emissiveIntensity, pulse.intensity);
}
}
// Update spawn burst particles
for (let i = spawnBursts.length - 1; i >= 0; i--) {
const burst = spawnBursts[i];
burst.age++;
if (burst.age > 120) {
scene.remove(burst.particles);
burst.particles.geometry.dispose();
(burst.particles.material as THREE.Material).dispose();
spawnBursts.splice(i, 1);
continue;
}
const positions = burst.particles.geometry.attributes.position as THREE.BufferAttribute;
const vels = burst.particles.geometry.attributes.velocity as THREE.BufferAttribute;
for (let j = 0; j < positions.count; j++) {
positions.setX(j, positions.getX(j) + vels.getX(j));
positions.setY(j, positions.getY(j) + vels.getY(j));
positions.setZ(j, positions.getZ(j) + vels.getZ(j));
// Dampen velocity
vels.setX(j, vels.getX(j) * 0.97);
vels.setY(j, vels.getY(j) * 0.97);
vels.setZ(j, vels.getZ(j) * 0.97);
}
positions.needsUpdate = true;
const mat = burst.particles.material as THREE.PointsMaterial;
mat.opacity = Math.max(0, 1 - burst.age / 120);
mat.size = 0.6 * (1 - burst.age / 200);
}
// Update shockwave rings
for (let i = shockwaves.length - 1; i >= 0; i--) {
const sw = shockwaves[i];
sw.age++;
if (sw.age > sw.maxAge) {
scene.remove(sw.mesh);
sw.mesh.geometry.dispose();
(sw.mesh.material as THREE.Material).dispose();
shockwaves.splice(i, 1);
continue;
}
const progress = sw.age / sw.maxAge;
const scale = 1 + progress * 20;
sw.mesh.scale.setScalar(scale);
(sw.mesh.material as THREE.MeshBasicMaterial).opacity = 0.8 * (1 - progress);
sw.mesh.lookAt(camera.position);
}
// Update connection flash lines
for (let i = connectionFlashes.length - 1; i >= 0; i--) {
const flash = connectionFlashes[i];
flash.intensity -= 0.015;
if (flash.intensity <= 0) {
scene.remove(flash.line);
flash.line.geometry.dispose();
(flash.line.material as THREE.Material).dispose();
connectionFlashes.splice(i, 1);
continue;
}
(flash.line.material as THREE.LineBasicMaterial).opacity = flash.intensity;
mapEventToEffects(event, effects, nodeManager.positions, nodeManager.meshMap, ctx.camera);
}
}
function onResize() {
if (!container) return;
const w = container.clientWidth;
const h = container.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
composer.setSize(w, h);
if (!container || !ctx) return;
resizeScene(ctx, container);
}
function onPointerMove(event: PointerEvent) {
const rect = container.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
ctx.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
ctx.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const meshes = Array.from(nodeMeshMap.values());
const intersects = raycaster.intersectObjects(meshes);
ctx.raycaster.setFromCamera(ctx.mouse, ctx.camera);
const intersects = ctx.raycaster.intersectObjects(nodeManager.getMeshes());
if (intersects.length > 0) {
hoveredNode = intersects[0].object.userData.nodeId;
nodeManager.hoveredNode = intersects[0].object.userData.nodeId;
container.style.cursor = 'pointer';
} else {
hoveredNode = null;
nodeManager.hoveredNode = null;
container.style.cursor = 'grab';
}
}
function onClick() {
if (hoveredNode) {
selectedNode = hoveredNode;
onSelect?.(hoveredNode);
if (nodeManager.hoveredNode) {
nodeManager.selectedNode = nodeManager.hoveredNode;
onSelect?.(nodeManager.hoveredNode);
// Fly camera to selected node
const pos = nodePositions.get(hoveredNode);
const pos = nodeManager.positions.get(nodeManager.hoveredNode);
if (pos) {
const target = pos.clone();
controls.target.lerp(target, 0.5);
ctx.controls.target.lerp(pos.clone(), 0.5);
}
}
}

View file

@ -0,0 +1,127 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
resultCount?: number;
durationMs?: number;
active?: boolean;
}
let { resultCount = 0, durationMs = 0, active = false }: Props = $props();
const stages = [
{ name: 'Overfetch', icon: '◎', color: '#818CF8', desc: 'Pull 3x results from hybrid search' },
{ name: 'Rerank', icon: '⟿', color: '#00A8FF', desc: 'Re-score by relevance quality' },
{ name: 'Temporal', icon: '◷', color: '#00D4FF', desc: 'Recent memories get recency bonus' },
{ name: 'Access', icon: '◇', color: '#00FFD1', desc: 'FSRS-6 retention threshold filter' },
{ name: 'Context', icon: '◬', color: '#FFB800', desc: 'Encoding specificity matching' },
{ name: 'Compete', icon: '⬡', color: '#FF3CAC', desc: 'Retrieval-induced forgetting' },
{ name: 'Activate', icon: '◈', color: '#9D00FF', desc: 'Spreading activation cascade' },
];
let activeStage = $state(-1);
let animating = $state(false);
let showResult = $state(false);
$effect(() => {
if (active && !animating) {
startAnimation();
}
});
function startAnimation() {
animating = true;
activeStage = -1;
showResult = false;
// Stretch animation to 2x actual duration for visibility (min 1.5s)
const totalDuration = Math.max(1500, (durationMs || 50) * 2);
const stageDelay = totalDuration / (stages.length + 1);
stages.forEach((_, i) => {
setTimeout(() => {
activeStage = i;
}, stageDelay * (i + 1));
});
setTimeout(() => {
showResult = true;
animating = false;
}, totalDuration);
}
</script>
<div class="glass-subtle rounded-xl p-4 space-y-3">
<div class="flex items-center justify-between">
<span class="text-[10px] text-synapse-glow uppercase tracking-wider font-medium">Cognitive Search Pipeline</span>
{#if showResult}
<span class="text-[10px] text-recall">{resultCount} results in {durationMs}ms</span>
{/if}
</div>
<!-- 7-stage pipeline visualization -->
<div class="flex items-center gap-0.5">
{#each stages as stage, i}
{@const isActive = i <= activeStage}
{@const isCurrent = i === activeStage && animating}
<!-- Stage node -->
<div class="flex flex-col items-center gap-1 flex-1 min-w-0">
<div
class="w-8 h-8 rounded-full flex items-center justify-center text-xs transition-all duration-300
{isCurrent ? 'scale-125' : ''}"
style="background: {isActive ? stage.color + '25' : 'rgba(255,255,255,0.03)'};
border: 1.5px solid {isActive ? stage.color : 'rgba(255,255,255,0.06)'};
color: {isActive ? stage.color : '#4a4a7a'};
box-shadow: {isCurrent ? '0 0 12px ' + stage.color + '40' : 'none'}"
title={stage.desc}
>
{stage.icon}
</div>
<span class="text-[8px] truncate w-full text-center transition-colors duration-300"
style="color: {isActive ? stage.color : '#4a4a7a'}">
{stage.name}
</span>
</div>
<!-- Connecting line -->
{#if i < stages.length - 1}
<div class="h-px flex-shrink-0 w-2 mt-[-12px] transition-all duration-300"
style="background: {i < activeStage ? stages[i + 1].color + '60' : 'rgba(255,255,255,0.06)'}">
</div>
{/if}
{/each}
</div>
<!-- Energy pulse bar -->
<div class="h-1 bg-white/[0.03] rounded-full overflow-hidden">
{#if animating || showResult}
<div
class="h-full rounded-full transition-all ease-out"
style="width: {showResult ? '100' : ((activeStage + 1) / stages.length * 100).toFixed(0)}%;
background: linear-gradient(90deg, #818CF8, #00FFD1, #9D00FF);
transition-duration: {animating ? '300ms' : '500ms'}"
></div>
{/if}
</div>
<!-- Result burst -->
{#if showResult}
<div class="flex items-center gap-2 pt-1 animate-fade-in">
<div class="w-1.5 h-1.5 rounded-full bg-recall animate-pulse-glow"></div>
<span class="text-[10px] text-dim">
Pipeline complete: {resultCount} memories surfaced from {stages.length}-stage cognitive cascade
</span>
</div>
{/if}
</div>
<style>
@keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
</style>

View file

@ -0,0 +1,144 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { GraphNode } from '$types';
import { getDateRange } from '$lib/graph/temporal';
interface Props {
nodes: GraphNode[];
onDateChange: (date: Date) => void;
onToggle: (enabled: boolean) => void;
}
let { nodes, onDateChange, onToggle }: Props = $props();
let enabled = $state(false);
let playing = $state(false);
let speed = $state(1); // days per second
let sliderValue = $state(100); // 0-100 percentage
let animFrameId: number;
let lastTime = 0;
let dateRange = $derived(getDateRange(nodes));
let currentDate = $derived.by(() => {
const oldest = dateRange.oldest.getTime();
const newest = dateRange.newest.getTime();
const range = newest - oldest || 1;
return new Date(oldest + (sliderValue / 100) * range);
});
function formatDate(d: Date): string {
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function toggle() {
enabled = !enabled;
onToggle(enabled);
if (enabled) {
sliderValue = 100;
onDateChange(currentDate);
}
}
function togglePlay() {
playing = !playing;
if (playing) {
sliderValue = 0;
lastTime = performance.now();
playLoop();
} else {
cancelAnimationFrame(animFrameId);
}
}
function playLoop() {
animFrameId = requestAnimationFrame((now) => {
const deltaSeconds = (now - lastTime) / 1000;
lastTime = now;
const oldest = dateRange.oldest.getTime();
const newest = dateRange.newest.getTime();
const totalDays = (newest - oldest) / (24 * 60 * 60 * 1000) || 1;
// Advance by speed days per second
const percentPerSecond = (speed / totalDays) * 100;
sliderValue = Math.min(100, sliderValue + percentPerSecond * deltaSeconds);
onDateChange(currentDate);
if (sliderValue >= 100) {
playing = false;
return;
}
playLoop();
});
}
function onSliderInput() {
onDateChange(currentDate);
}
onDestroy(() => {
cancelAnimationFrame(animFrameId);
});
</script>
{#if enabled}
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 w-[90%] max-w-xl">
<div class="glass-panel rounded-xl p-3 space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<button
onclick={togglePlay}
class="w-7 h-7 rounded-lg bg-synapse/20 border border-synapse/30 text-synapse-glow text-xs flex items-center justify-center hover:bg-synapse/30 transition"
>
{playing ? '⏸' : '▶'}
</button>
<select
bind:value={speed}
class="px-2 py-1 bg-white/[0.03] border border-synapse/10 rounded-lg text-[10px] text-dim focus:outline-none"
>
<option value={1}>1x</option>
<option value={7}>7x</option>
<option value={30}>30x</option>
</select>
</div>
<span class="text-xs text-bright font-medium">{formatDate(currentDate)}</span>
<button
onclick={toggle}
class="text-[10px] text-muted hover:text-text transition"
>
Close
</button>
</div>
<input
type="range"
min="0"
max="100"
step="0.1"
bind:value={sliderValue}
oninput={onSliderInput}
class="w-full h-1.5 appearance-none bg-white/[0.06] rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-synapse-glow
[&::-webkit-slider-thumb]:shadow-[0_0_8px_rgba(129,140,248,0.4)]"
/>
<div class="flex justify-between text-[9px] text-muted">
<span>{formatDate(dateRange.oldest)}</span>
<span>{formatDate(dateRange.newest)}</span>
</div>
</div>
</div>
{:else}
<button
onclick={toggle}
class="absolute bottom-4 right-4 z-10 px-3 py-2 glass rounded-xl text-dim text-xs hover:text-text transition flex items-center gap-1.5"
>
<span></span>
<span>Timeline</span>
</button>
{/if}