feat: live memory materialization — nodes spawn in 3D graph in real-time

When memories are created, promoted, deleted, or dreamed via MCP tools,
the 3D graph now shows spectacular live animations:

- Rainbow particle burst + elastic scale-up on MemoryCreated
- Ripple wave cascading to nearby nodes
- Green pulse + node growth on MemoryPromoted
- Implosion + dissolution on MemoryDeleted
- Edge growth animation on ConnectionDiscovered
- Purple cascade on DreamStarted/DreamProgress/DreamCompleted
- FIFO eviction at 50 live nodes to guard performance

Also: graph center defaults to most-connected node, legacy HTML
redirects to SvelteKit dashboard, CSS height chain fix in layout.

Testing: 150 unit tests (vitest), 11 e2e tests (Playwright with
MCP Streamable HTTP client), 22 proof screenshots.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-03-03 14:04:31 -06:00
parent 816b577f69
commit 9bdcc69ce3
76 changed files with 5915 additions and 332 deletions

View file

@ -0,0 +1,310 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('three', async () => {
const mock = await import('./three-mock');
return { ...mock };
});
import { EdgeManager } from '../edges';
import { Vector3 } from './three-mock';
import { makeEdge, resetNodeCounter } from './helpers';
describe('EdgeManager', () => {
let manager: EdgeManager;
let positions: Map<string, InstanceType<typeof Vector3>>;
beforeEach(() => {
resetNodeCounter();
manager = new EdgeManager();
positions = new Map([
['a', new Vector3(0, 0, 0)],
['b', new Vector3(10, 0, 0)],
['c', new Vector3(0, 10, 0)],
['d', new Vector3(10, 10, 0)],
]);
});
describe('createEdges', () => {
it('creates line objects for all edges', () => {
const edges = [makeEdge('a', 'b'), makeEdge('b', 'c')];
manager.createEdges(edges, positions);
expect(manager.group.children.length).toBe(2);
});
it('skips edges with missing node positions', () => {
const edges = [makeEdge('a', 'missing')];
manager.createEdges(edges, positions);
expect(manager.group.children.length).toBe(0);
});
it('stores source/target in userData', () => {
const edges = [makeEdge('a', 'b')];
manager.createEdges(edges, positions);
const line = manager.group.children[0];
expect(line.userData.source).toBe('a');
expect(line.userData.target).toBe('b');
});
it('caps opacity at 0.6', () => {
const edges = [makeEdge('a', 'b', { weight: 10.0 })];
manager.createEdges(edges, positions);
const line = manager.group.children[0] as any;
expect(line.material.opacity).toBeLessThanOrEqual(0.6);
});
});
describe('addEdge — growth animation', () => {
it('adds a new line to the group', () => {
const edge = makeEdge('a', 'b');
manager.addEdge(edge, positions);
expect(manager.group.children.length).toBe(1);
});
it('starts with zero-length line at source position', () => {
const edge = makeEdge('a', 'b');
manager.addEdge(edge, positions);
const line = manager.group.children[0] as any;
const attrs = line.geometry.attributes.position;
// Both endpoints should be at source (a) position
expect(attrs.getX(0)).toBe(0);
expect(attrs.getX(1)).toBe(0); // not yet at target
});
it('starts with zero opacity', () => {
const edge = makeEdge('a', 'b');
manager.addEdge(edge, positions);
const line = manager.group.children[0] as any;
expect(line.material.opacity).toBe(0);
});
it('grows to full length over 45 frames', () => {
const edge = makeEdge('a', 'b');
manager.addEdge(edge, positions);
// Animate through growth
for (let f = 0; f < 45; f++) {
manager.animateEdges(positions);
}
const line = manager.group.children[0] as any;
const attrs = line.geometry.attributes.position;
// End point should be at target position
expect(attrs.getX(1)).toBeCloseTo(10, 0);
});
it('opacity increases during growth', () => {
const edge = makeEdge('a', 'b');
manager.addEdge(edge, positions);
for (let f = 0; f < 25; f++) {
manager.animateEdges(positions);
}
const line = manager.group.children[0] as any;
expect(line.material.opacity).toBeGreaterThan(0);
});
it('reaches final opacity after growth completes', () => {
const edge = makeEdge('a', 'b');
manager.addEdge(edge, positions);
for (let f = 0; f < 46; f++) {
manager.animateEdges(positions);
}
const line = manager.group.children[0] as any;
expect(line.material.opacity).toBe(0.5);
});
it('uses easeOutCubic for smooth deceleration', () => {
const edge = makeEdge('a', 'b');
manager.addEdge(edge, positions);
// Record end point X at each frame
const xValues: number[] = [];
for (let f = 0; f < 45; f++) {
manager.animateEdges(positions);
const line = manager.group.children[0] as any;
const attrs = line.geometry.attributes.position;
xValues.push(attrs.getX(1));
}
// easeOutCubic: fast start, slow end
// First half should cover more than 50% of distance
const midIdx = Math.floor(xValues.length / 2);
const midProgress = xValues[midIdx] / 10;
expect(midProgress).toBeGreaterThan(0.5);
});
it('skips edges with missing positions', () => {
const edge = makeEdge('a', 'missing');
manager.addEdge(edge, positions);
// Line should be created but with no geometry update
expect(manager.group.children.length).toBe(0);
});
});
describe('removeEdgesForNode', () => {
it('marks connected edges for dissolution', () => {
const edges = [makeEdge('a', 'b'), makeEdge('b', 'c'), makeEdge('c', 'd')];
manager.createEdges(edges, positions);
expect(manager.group.children.length).toBe(3);
manager.removeEdgesForNode('b');
// After dissolution animation completes
for (let f = 0; f < 45; f++) {
manager.animateEdges(positions);
}
// Only edge c->d should remain
expect(manager.group.children.length).toBe(1);
expect(manager.group.children[0].userData.source).toBe('c');
});
it('dissolving edges fade out over 40 frames', () => {
const edges = [makeEdge('a', 'b')];
manager.createEdges(edges, positions);
const line = manager.group.children[0] as any;
const initialOpacity = line.material.opacity;
manager.removeEdgesForNode('a');
// Midway through dissolution
for (let f = 0; f < 20; f++) {
manager.animateEdges(positions);
}
expect(line.material.opacity).toBeLessThan(initialOpacity);
expect(line.material.opacity).toBeGreaterThan(0);
});
it('fully removes edge after dissolution completes', () => {
const edges = [makeEdge('a', 'b')];
manager.createEdges(edges, positions);
manager.removeEdgesForNode('a');
for (let f = 0; f < 45; f++) {
manager.animateEdges(positions);
}
expect(manager.group.children.length).toBe(0);
});
it('cancels active growth animation if edge is dissolving', () => {
const edge = makeEdge('a', 'b');
manager.addEdge(edge, positions);
// Partially grow
for (let f = 0; f < 10; f++) {
manager.animateEdges(positions);
}
// Then dissolve
manager.removeEdgesForNode('a');
for (let f = 0; f < 45; f++) {
manager.animateEdges(positions);
}
expect(manager.group.children.length).toBe(0);
});
});
describe('updatePositions', () => {
it('updates static edge endpoints', () => {
const edges = [makeEdge('a', 'b')];
manager.createEdges(edges, positions);
// Move node a
positions.set('a', new Vector3(5, 5, 5));
manager.updatePositions(positions);
const line = manager.group.children[0] as any;
const attrs = line.geometry.attributes.position;
expect(attrs.getX(0)).toBe(5);
expect(attrs.getY(0)).toBe(5);
});
it('skips edges currently being animated', () => {
// Add a growing edge
const edge = makeEdge('a', 'b');
manager.addEdge(edge, positions);
// updatePositions should not override the animation
manager.updatePositions(positions);
// Growing edge should still be at its animated state
const line = manager.group.children[0] as any;
const attrs = line.geometry.attributes.position;
// Point 1 should still be at source (zero-length start), not target
expect(attrs.getX(1)).toBe(0);
});
});
describe('multiple simultaneous edge animations', () => {
it('handles multiple edges growing at once', () => {
manager.addEdge(makeEdge('a', 'b'), positions);
manager.addEdge(makeEdge('c', 'd'), positions);
for (let f = 0; f < 50; f++) {
manager.animateEdges(positions);
}
// Both should be fully grown
expect(manager.group.children.length).toBe(2);
manager.group.children.forEach((child) => {
expect((child as any).material.opacity).toBe(0.5);
});
});
it('handles mixed growing and dissolving edges', () => {
// Create a static edge
manager.createEdges([makeEdge('a', 'b')], positions);
// Add a growing edge
manager.addEdge(makeEdge('c', 'd'), positions);
// Dissolve the static edge
manager.removeEdgesForNode('a');
for (let f = 0; f < 50; f++) {
manager.animateEdges(positions);
}
// Only the new edge should remain
expect(manager.group.children.length).toBe(1);
expect(manager.group.children[0].userData.source).toBe('c');
});
});
describe('dispose', () => {
it('clears animation queues and disposes materials without error', () => {
manager.createEdges([makeEdge('a', 'b')], positions);
manager.addEdge(makeEdge('c', 'd'), positions);
// Dispose should not throw and should clean up materials
expect(() => manager.dispose()).not.toThrow();
// After dispose, adding new animations should not interact with old state
manager.addEdge(makeEdge('a', 'c'), positions);
expect(() => {
for (let f = 0; f < 50; f++) {
manager.animateEdges(positions);
}
}).not.toThrow();
});
});
});

View file

@ -0,0 +1,499 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('three', async () => {
const mock = await import('./three-mock');
return { ...mock };
});
import { EffectManager } from '../effects';
import { Vector3, Color, Scene } from './three-mock';
describe('EffectManager', () => {
let scene: InstanceType<typeof Scene>;
let effects: EffectManager;
let nodeMeshMap: Map<string, any>;
let camera: any;
beforeEach(() => {
scene = new Scene();
effects = new EffectManager(scene as any);
camera = { position: new Vector3(0, 30, 80) };
nodeMeshMap = new Map();
});
function createMockMesh(id: string, pos: InstanceType<typeof Vector3>) {
const mesh = {
scale: new Vector3(1, 1, 1),
position: pos.clone(),
material: {
emissive: new Color(0x000000),
emissiveIntensity: 0.5,
},
userData: { nodeId: id },
};
nodeMeshMap.set(id, mesh);
return mesh;
}
describe('pulse effects', () => {
it('adds pulse and decays it over time', () => {
createMockMesh('a', new Vector3(0, 0, 0));
effects.addPulse('a', 1.0, new Color(0xff0000) as any, 0.1);
expect(effects.pulseEffects.length).toBe(1);
// Update a few times
for (let i = 0; i < 5; i++) {
effects.update(nodeMeshMap, camera);
}
expect(effects.pulseEffects[0].intensity).toBeLessThan(1.0);
});
it('removes pulse when intensity reaches zero', () => {
createMockMesh('a', new Vector3(0, 0, 0));
effects.addPulse('a', 0.5, new Color(0xff0000) as any, 0.1);
for (let i = 0; i < 20; i++) {
effects.update(nodeMeshMap, camera);
}
expect(effects.pulseEffects.length).toBe(0);
});
it('modulates mesh emissive color and intensity', () => {
const mesh = createMockMesh('a', new Vector3(0, 0, 0));
const pulseColor = new Color(0xff0000);
effects.addPulse('a', 1.0, pulseColor as any, 0.05);
effects.update(nodeMeshMap, camera);
// Emissive intensity should be elevated
expect(mesh.material.emissiveIntensity).toBeGreaterThan(0.5);
});
});
describe('createSpawnBurst', () => {
it('adds particles to the scene', () => {
const childCount = scene.children.length;
effects.createSpawnBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
expect(scene.children.length).toBe(childCount + 1);
});
it('creates 60 particles', () => {
effects.createSpawnBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
const pts = scene.children[scene.children.length - 1] as any;
expect(pts.geometry.attributes.position.count).toBe(60);
});
it('particles move outward and fade over 120 frames', () => {
effects.createSpawnBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
for (let i = 0; i < 121; i++) {
effects.update(nodeMeshMap, camera);
}
// Burst should be cleaned up
expect(scene.children.length).toBe(0);
});
it('particle opacity decreases over time', () => {
effects.createSpawnBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
const pts = scene.children[0] as any;
effects.update(nodeMeshMap, camera);
const earlyOpacity = pts.material.opacity;
for (let i = 0; i < 60; i++) {
effects.update(nodeMeshMap, camera);
}
expect(pts.material.opacity).toBeLessThan(earlyOpacity);
});
});
describe('createRainbowBurst', () => {
it('creates 120 particles (2x normal burst)', () => {
effects.createRainbowBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
const pts = scene.children[scene.children.length - 1] as any;
expect(pts.geometry.attributes.position.count).toBe(120);
});
it('has a 180-frame (3-second) lifespan', () => {
effects.createRainbowBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
// Run for 179 frames — should still exist
for (let i = 0; i < 179; i++) {
effects.update(nodeMeshMap, camera);
}
expect(scene.children.length).toBe(1);
// Frame 180 — should be cleaned up
effects.update(nodeMeshMap, camera);
expect(scene.children.length).toBe(1); // age increments to 180
effects.update(nodeMeshMap, camera);
expect(scene.children.length).toBe(0);
});
it('color cycles through rainbow HSL', () => {
effects.createRainbowBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
const pts = scene.children[0] as any;
const initialColor = { r: pts.material.color.r, g: pts.material.color.g, b: pts.material.color.b };
// Advance several frames
for (let i = 0; i < 30; i++) {
effects.update(nodeMeshMap, camera);
}
// Color should have changed due to HSL cycling
const currentColor = pts.material.color;
const colorChanged =
Math.abs(currentColor.r - initialColor.r) > 0.01 ||
Math.abs(currentColor.g - initialColor.g) > 0.01 ||
Math.abs(currentColor.b - initialColor.b) > 0.01;
expect(colorChanged).toBe(true);
});
it('particle size pulses (not monotonically decreasing)', () => {
effects.createRainbowBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
const pts = scene.children[0] as any;
const sizes: number[] = [];
for (let i = 0; i < 30; i++) {
effects.update(nodeMeshMap, camera);
sizes.push(pts.material.size);
}
// Check that size varies (pulses) — not monotonically decreasing
let monotonic = true;
for (let i = 1; i < sizes.length; i++) {
if (sizes[i] > sizes[i - 1]) {
monotonic = false;
break;
}
}
expect(monotonic).toBe(false);
});
it('has hueOffset attribute for per-particle variation', () => {
effects.createRainbowBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
const pts = scene.children[0] as any;
expect(pts.geometry.attributes.hueOffset).toBeDefined();
expect(pts.geometry.attributes.hueOffset.count).toBe(120);
});
});
describe('createRippleWave', () => {
it('creates a ripple wave state', () => {
const nodePositions = new Map<string, any>([
['n1', new Vector3(5, 0, 0)],
['n2', new Vector3(15, 0, 0)],
]);
effects.createRippleWave(new Vector3(0, 0, 0) as any);
// Ripple wave is internal state — verify it runs without error
for (let i = 0; i < 100; i++) {
effects.update(nodeMeshMap, camera, nodePositions);
}
});
it('pulses nearby nodes as wavefront reaches them', () => {
const nodePositions = new Map<string, any>([
['close', new Vector3(5, 0, 0)],
['far', new Vector3(50, 0, 0)],
]);
const closeMesh = createMockMesh('close', new Vector3(5, 0, 0));
const farMesh = createMockMesh('far', new Vector3(50, 0, 0));
effects.createRippleWave(new Vector3(0, 0, 0) as any);
// Run until wavefront reaches "close" (dist=5, speed=1.2, ~4 frames)
for (let i = 0; i < 10; i++) {
effects.update(nodeMeshMap, camera, nodePositions);
}
// Should have pulsed the close node — check for pulse effect
const closeHasPulse = effects.pulseEffects.some((p) => p.nodeId === 'close');
expect(closeHasPulse).toBe(true);
});
it('pulses each node only once', () => {
const nodePositions = new Map<string, any>([
['n1', new Vector3(3, 0, 0)],
]);
createMockMesh('n1', new Vector3(3, 0, 0));
effects.createRippleWave(new Vector3(0, 0, 0) as any);
// Run many frames
for (let i = 0; i < 30; i++) {
effects.update(nodeMeshMap, camera, nodePositions);
}
// Count pulses for n1 — should be exactly 1
const n1Pulses = effects.pulseEffects.filter((p) => p.nodeId === 'n1');
// Could be 0 if already decayed, but should have been created once
expect(n1Pulses.length).toBeLessThanOrEqual(1);
});
it('applies scale bump to contacted nodes', () => {
const nodePositions = new Map<string, any>([
['bump', new Vector3(3, 0, 0)],
]);
const mesh = createMockMesh('bump', new Vector3(3, 0, 0));
effects.createRippleWave(new Vector3(0, 0, 0) as any);
// Run until wavefront reaches the node
for (let i = 0; i < 10; i++) {
effects.update(nodeMeshMap, camera, nodePositions);
}
// Scale should have been bumped (1.3x)
expect(mesh.scale.x).toBeGreaterThan(1.0);
});
it('completes and cleans up after 90 frames', () => {
effects.createRippleWave(new Vector3(0, 0, 0) as any);
for (let i = 0; i < 95; i++) {
effects.update(nodeMeshMap, camera, new Map());
}
// Internal rippleWaves array should be empty (no way to check directly,
// but running more frames should not cause any errors)
effects.update(nodeMeshMap, camera, new Map());
});
});
describe('createImplosion', () => {
it('creates 40 particles', () => {
effects.createImplosion(new Vector3(5, 5, 5) as any, new Color(0xff4757) as any);
const pts = scene.children[scene.children.length - 1] as any;
expect(pts.geometry.attributes.position.count).toBe(40);
});
it('particles start spread out around the target', () => {
const center = new Vector3(5, 5, 5);
effects.createImplosion(center as any, new Color(0xff4757) as any);
const pts = scene.children[0] as any;
const positions = pts.geometry.attributes.position;
// At least some particles should be far from center
let maxDist = 0;
for (let i = 0; i < positions.count; i++) {
const px = positions.getX(i);
const py = positions.getY(i);
const pz = positions.getZ(i);
const dist = Math.sqrt(
(px - center.x) ** 2 + (py - center.y) ** 2 + (pz - center.z) ** 2
);
if (dist > maxDist) maxDist = dist;
}
expect(maxDist).toBeGreaterThan(2);
});
it('particles move INWARD toward center', () => {
const center = new Vector3(0, 0, 0);
effects.createImplosion(center as any, new Color(0xff4757) as any);
const pts = scene.children[0] as any;
const positions = pts.geometry.attributes.position;
// Record initial average distance
let initialAvgDist = 0;
for (let i = 0; i < positions.count; i++) {
const px = positions.getX(i);
const py = positions.getY(i);
const pz = positions.getZ(i);
initialAvgDist += Math.sqrt(px * px + py * py + pz * pz);
}
initialAvgDist /= positions.count;
// Advance 30 frames
for (let f = 0; f < 30; f++) {
effects.update(nodeMeshMap, camera);
}
// Record new average distance
let newAvgDist = 0;
for (let i = 0; i < positions.count; i++) {
const px = positions.getX(i);
const py = positions.getY(i);
const pz = positions.getZ(i);
newAvgDist += Math.sqrt(px * px + py * py + pz * pz);
}
newAvgDist /= positions.count;
expect(newAvgDist).toBeLessThan(initialAvgDist);
});
it('creates a flash at convergence (frame 60)', () => {
effects.createImplosion(new Vector3(0, 0, 0) as any, new Color(0xff4757) as any);
// Run to convergence
for (let f = 0; f < 60; f++) {
effects.update(nodeMeshMap, camera);
}
// Should have particles + flash mesh
expect(scene.children.length).toBe(2);
});
it('flash fades out and everything cleans up by frame 80', () => {
effects.createImplosion(new Vector3(0, 0, 0) as any, new Color(0xff4757) as any);
for (let f = 0; f < 85; f++) {
effects.update(nodeMeshMap, camera);
}
// Everything should be cleaned up
expect(scene.children.length).toBe(0);
});
it('flash sphere expands during fade-out', () => {
effects.createImplosion(new Vector3(0, 0, 0) as any, new Color(0xff4757) as any);
for (let f = 0; f < 65; f++) {
effects.update(nodeMeshMap, camera);
}
// Find the flash mesh (should be the second child)
const flash = scene.children.find(
(c) => c instanceof Object && 'geometry' in c && !(('attributes' in (c as any).geometry))
);
// Flash should have expanded beyond scale 1
if (flash) {
expect(flash.scale.x).toBeGreaterThan(1);
}
});
});
describe('createShockwave', () => {
it('adds a ring mesh to the scene', () => {
effects.createShockwave(
new Vector3(0, 0, 0) as any,
new Color(0x00ffd1) as any,
camera
);
expect(scene.children.length).toBe(1);
});
it('ring expands over time', () => {
effects.createShockwave(
new Vector3(0, 0, 0) as any,
new Color(0x00ffd1) as any,
camera
);
const ring = scene.children[0] as any;
const initialScale = ring.scale.x;
for (let f = 0; f < 30; f++) {
effects.update(nodeMeshMap, camera);
}
expect(ring.scale.x).toBeGreaterThan(initialScale);
});
it('ring fades out and cleans up after 60 frames', () => {
effects.createShockwave(
new Vector3(0, 0, 0) as any,
new Color(0x00ffd1) as any,
camera
);
for (let f = 0; f < 65; f++) {
effects.update(nodeMeshMap, camera);
}
expect(scene.children.length).toBe(0);
});
});
describe('createConnectionFlash', () => {
it('creates a line between two points', () => {
effects.createConnectionFlash(
new Vector3(0, 0, 0) as any,
new Vector3(10, 10, 10) as any,
new Color(0x00d4ff) as any
);
expect(scene.children.length).toBe(1);
});
it('fades out and cleans up', () => {
effects.createConnectionFlash(
new Vector3(0, 0, 0) as any,
new Vector3(10, 10, 10) as any,
new Color(0x00d4ff) as any
);
for (let f = 0; f < 100; f++) {
effects.update(nodeMeshMap, camera);
}
expect(scene.children.length).toBe(0);
});
});
describe('multiple simultaneous effects', () => {
it('handles all effect types simultaneously', () => {
const nodePositions = new Map<string, any>([
['n1', new Vector3(5, 0, 0)],
]);
createMockMesh('n1', new Vector3(5, 0, 0));
effects.addPulse('n1', 1.0, new Color(0xff0000) as any, 0.05);
effects.createSpawnBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
effects.createRainbowBurst(new Vector3(5, 5, 5) as any, new Color(0xff00ff) as any);
effects.createShockwave(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any, camera);
effects.createConnectionFlash(
new Vector3(0, 0, 0) as any,
new Vector3(10, 0, 0) as any,
new Color(0x00d4ff) as any
);
effects.createImplosion(new Vector3(-5, -5, -5) as any, new Color(0xff4757) as any);
effects.createRippleWave(new Vector3(0, 0, 0) as any);
// Should not throw for 200 frames
for (let f = 0; f < 200; f++) {
effects.update(nodeMeshMap, camera, nodePositions);
}
// All effects should have cleaned up
expect(scene.children.length).toBe(0);
});
});
describe('dispose', () => {
it('cleans up all active effects', () => {
effects.createSpawnBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
effects.createRainbowBurst(new Vector3(0, 0, 0) as any, new Color(0xff00ff) as any);
effects.createImplosion(new Vector3(0, 0, 0) as any, new Color(0xff4757) as any);
effects.createShockwave(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any, camera);
effects.createConnectionFlash(
new Vector3(0, 0, 0) as any,
new Vector3(10, 0, 0) as any,
new Color(0x00d4ff) as any
);
effects.dispose();
expect(effects.pulseEffects.length).toBe(0);
});
});
});

View file

@ -0,0 +1,864 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('three', async () => {
const mock = await import('./three-mock');
return { ...mock };
});
import { mapEventToEffects, resetLiveSpawnTracking, type GraphMutationContext, type GraphMutation } from '../events';
import { NodeManager } from '../nodes';
import { EdgeManager } from '../edges';
import { EffectManager } from '../effects';
import { ForceSimulation } from '../force-sim';
import { Vector3, Scene } from './three-mock';
import { makeNode, makeEdge, makeEvent, resetNodeCounter } from './helpers';
import type { GraphNode, VestigeEvent } from '$types';
describe('Event-to-Mutation Pipeline', () => {
let nodeManager: NodeManager;
let edgeManager: EdgeManager;
let effects: EffectManager;
let forceSim: ForceSimulation;
let scene: InstanceType<typeof Scene>;
let camera: any;
let mutations: GraphMutation[];
let allNodes: GraphNode[];
let ctx: GraphMutationContext;
beforeEach(() => {
resetNodeCounter();
resetLiveSpawnTracking();
scene = new Scene();
camera = { position: new Vector3(0, 30, 80) };
nodeManager = new NodeManager();
edgeManager = new EdgeManager();
effects = new EffectManager(scene as any);
mutations = [];
// Create initial graph with 5 nodes
const initialNodes = [
makeNode({ id: 'n1', type: 'fact', tags: ['rust', 'bug-fix'] }),
makeNode({ id: 'n2', type: 'concept', tags: ['architecture'] }),
makeNode({ id: 'n3', type: 'decision', tags: ['rust'] }),
makeNode({ id: 'n4', type: 'fact', tags: ['testing'] }),
makeNode({ id: 'n5', type: 'event', tags: ['session'] }),
];
const positions = nodeManager.createNodes(initialNodes);
edgeManager.createEdges(
[makeEdge('n1', 'n2'), makeEdge('n2', 'n3'), makeEdge('n3', 'n4')],
positions
);
forceSim = new ForceSimulation(positions);
allNodes = [...initialNodes];
ctx = {
effects,
nodeManager,
edgeManager,
forceSim,
camera,
onMutation: (m: GraphMutation) => mutations.push(m),
};
});
describe('MemoryCreated', () => {
it('creates a new node in all managers', () => {
const event = makeEvent('MemoryCreated', {
id: 'new-1',
content: 'I love Rust',
node_type: 'fact',
tags: ['rust', 'preference'],
retention: 0.9,
});
mapEventToEffects(event, ctx, allNodes);
expect(nodeManager.meshMap.has('new-1')).toBe(true);
expect(nodeManager.positions.has('new-1')).toBe(true);
expect(forceSim.positions.has('new-1')).toBe(true);
});
it('emits nodeAdded mutation', () => {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'new-2',
content: 'test',
node_type: 'fact',
}),
ctx,
allNodes
);
const nodeAdded = mutations.find((m) => m.type === 'nodeAdded');
expect(nodeAdded).toBeDefined();
expect((nodeAdded as any).node.id).toBe('new-2');
});
it('builds GraphNode from event data', () => {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'new-3',
content: 'Complex memory about architecture decisions in Rust systems',
node_type: 'decision',
tags: ['architecture', 'rust'],
retention: 0.75,
}),
ctx,
allNodes
);
const mutation = mutations.find((m) => m.type === 'nodeAdded') as any;
expect(mutation.node.type).toBe('decision');
expect(mutation.node.tags).toEqual(['architecture', 'rust']);
expect(mutation.node.retention).toBe(0.75);
expect(mutation.node.label).toBe('Complex memory about architecture decisions in Rust systems');
expect(mutation.node.isCenter).toBe(false);
});
it('truncates label to 60 characters', () => {
const longContent = 'A'.repeat(100);
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'long',
content: longContent,
node_type: 'fact',
}),
ctx,
allNodes
);
const mutation = mutations.find((m) => m.type === 'nodeAdded') as any;
expect(mutation.node.label.length).toBe(60);
});
it('spawns node near related nodes (tag overlap scoring)', () => {
// Create a memory with rust tag — should spawn near n1 (which has rust tag)
const n1Pos = nodeManager.positions.get('n1')!;
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'rust-memory',
content: 'Rust borrow checker tip',
node_type: 'fact',
tags: ['rust'],
}),
ctx,
allNodes
);
const newPos = nodeManager.positions.get('rust-memory')!;
const distToN1 = newPos.distanceTo(n1Pos);
// Should be relatively close to n1 (within jitter range ~10 units)
expect(distToN1).toBeLessThan(20);
});
it('triggers rainbow burst effect', () => {
const childrenBefore = scene.children.length;
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'new-burst',
content: 'test',
node_type: 'fact',
}),
ctx,
allNodes
);
// Scene should have new particles (rainbow burst + shockwave + possibly more)
expect(scene.children.length).toBeGreaterThan(childrenBefore);
});
it('triggers double shockwave (second delayed)', () => {
vi.useFakeTimers();
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'double-shock',
content: 'test',
node_type: 'fact',
}),
ctx,
allNodes
);
const initialChildren = scene.children.length;
// Advance past the setTimeout
vi.advanceTimersByTime(200);
// Second shockwave should have been added
expect(scene.children.length).toBeGreaterThan(initialChildren);
vi.useRealTimers();
});
it('uses default values when event data is incomplete', () => {
mapEventToEffects(
makeEvent('MemoryCreated', { id: 'minimal' }),
ctx,
allNodes
);
const mutation = mutations.find((m) => m.type === 'nodeAdded') as any;
expect(mutation.node.type).toBe('fact');
expect(mutation.node.retention).toBe(0.9);
expect(mutation.node.tags).toEqual([]);
});
it('ignores event without id', () => {
mapEventToEffects(
makeEvent('MemoryCreated', { content: 'no id' }),
ctx,
allNodes
);
expect(mutations.length).toBe(0);
});
});
describe('FIFO eviction', () => {
it('evicts oldest live node when exceeding 50 cap', () => {
// Create 51 live nodes
for (let i = 0; i < 51; i++) {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: `live-${i}`,
content: `Memory ${i}`,
node_type: 'fact',
}),
ctx,
allNodes
);
}
// First live node should have been evicted
const removedMutations = mutations.filter((m) => m.type === 'nodeRemoved');
expect(removedMutations.length).toBeGreaterThan(0);
expect((removedMutations[0] as any).nodeId).toBe('live-0');
});
it('evicted node is removed from all managers', () => {
for (let i = 0; i < 51; i++) {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: `evict-${i}`,
content: `Memory ${i}`,
node_type: 'fact',
}),
ctx,
allNodes
);
}
// First node should be gone from node manager and force sim
expect(forceSim.positions.has('evict-0')).toBe(false);
});
it('initial nodes are NOT subject to FIFO eviction', () => {
// Even after adding 50 live nodes, initial nodes should still exist
for (let i = 0; i < 50; i++) {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: `extra-${i}`,
content: `Memory ${i}`,
node_type: 'fact',
}),
ctx,
allNodes
);
}
expect(nodeManager.meshMap.has('n1')).toBe(true);
expect(nodeManager.meshMap.has('n2')).toBe(true);
expect(nodeManager.meshMap.has('n3')).toBe(true);
});
});
describe('ConnectionDiscovered', () => {
it('adds edge with growth animation', () => {
mapEventToEffects(
makeEvent('ConnectionDiscovered', {
source_id: 'n1',
target_id: 'n4',
weight: 0.8,
connection_type: 'causal',
}),
ctx,
allNodes
);
// Edge should have been added
expect(edgeManager.group.children.length).toBeGreaterThan(3); // 3 initial + 1 new
});
it('emits edgeAdded mutation with correct data', () => {
mapEventToEffects(
makeEvent('ConnectionDiscovered', {
source_id: 'n1',
target_id: 'n5',
weight: 0.7,
connection_type: 'semantic',
}),
ctx,
allNodes
);
const edgeMutation = mutations.find((m) => m.type === 'edgeAdded') as any;
expect(edgeMutation).toBeDefined();
expect(edgeMutation.edge.source).toBe('n1');
expect(edgeMutation.edge.target).toBe('n5');
expect(edgeMutation.edge.weight).toBe(0.7);
expect(edgeMutation.edge.type).toBe('semantic');
});
it('creates connection flash between endpoints', () => {
const childrenBefore = scene.children.length;
mapEventToEffects(
makeEvent('ConnectionDiscovered', {
source_id: 'n1',
target_id: 'n2',
}),
ctx,
allNodes
);
expect(scene.children.length).toBeGreaterThan(childrenBefore);
});
it('pulses both endpoint nodes', () => {
mapEventToEffects(
makeEvent('ConnectionDiscovered', {
source_id: 'n1',
target_id: 'n2',
}),
ctx,
allNodes
);
const n1Pulse = effects.pulseEffects.find((p) => p.nodeId === 'n1');
const n2Pulse = effects.pulseEffects.find((p) => p.nodeId === 'n2');
expect(n1Pulse).toBeDefined();
expect(n2Pulse).toBeDefined();
});
it('uses default weight and type when not provided', () => {
mapEventToEffects(
makeEvent('ConnectionDiscovered', {
source_id: 'n1',
target_id: 'n5',
}),
ctx,
allNodes
);
const edgeMutation = mutations.find((m) => m.type === 'edgeAdded') as any;
expect(edgeMutation.edge.weight).toBe(0.5);
expect(edgeMutation.edge.type).toBe('semantic');
});
});
describe('MemoryDeleted', () => {
it('removes node from all managers', () => {
mapEventToEffects(
makeEvent('MemoryDeleted', { id: 'n1' }),
ctx,
allNodes
);
// Force sim should have removed the node
expect(forceSim.positions.has('n1')).toBe(false);
});
it('removes connected edges', () => {
mapEventToEffects(
makeEvent('MemoryDeleted', { id: 'n2' }),
ctx,
allNodes
);
// Should emit both edgesRemoved and nodeRemoved mutations
const edgesRemoved = mutations.find((m) => m.type === 'edgesRemoved');
const nodeRemoved = mutations.find((m) => m.type === 'nodeRemoved');
expect(edgesRemoved).toBeDefined();
expect(nodeRemoved).toBeDefined();
});
it('creates implosion effect at node position', () => {
const childrenBefore = scene.children.length;
mapEventToEffects(
makeEvent('MemoryDeleted', { id: 'n3' }),
ctx,
allNodes
);
// Should have added implosion particles
expect(scene.children.length).toBeGreaterThan(childrenBefore);
});
it('removes from live tracking if was live-spawned', () => {
// First, create a live node
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'temp-live',
content: 'temporary',
node_type: 'fact',
}),
ctx,
allNodes
);
expect(nodeManager.meshMap.has('temp-live')).toBe(true);
// Now delete it
mutations = [];
mapEventToEffects(
makeEvent('MemoryDeleted', { id: 'temp-live' }),
ctx,
allNodes
);
const nodeRemoved = mutations.find((m) => m.type === 'nodeRemoved');
expect(nodeRemoved).toBeDefined();
});
it('ignores event without id', () => {
mapEventToEffects(
makeEvent('MemoryDeleted', {}),
ctx,
allNodes
);
expect(mutations.length).toBe(0);
});
});
describe('MemoryPromoted', () => {
it('grows the node to new retention', () => {
mapEventToEffects(
makeEvent('MemoryPromoted', { id: 'n1', new_retention: 0.95 }),
ctx,
allNodes
);
// Should have updated userData
expect(nodeManager.meshMap.get('n1')!.userData.retention).toBe(0.95);
});
it('emits nodeUpdated mutation', () => {
mapEventToEffects(
makeEvent('MemoryPromoted', { id: 'n2', new_retention: 0.98 }),
ctx,
allNodes
);
const mutation = mutations.find((m) => m.type === 'nodeUpdated') as any;
expect(mutation).toBeDefined();
expect(mutation.nodeId).toBe('n2');
expect(mutation.retention).toBe(0.98);
});
it('creates green pulse + shockwave + spawn burst', () => {
const childrenBefore = scene.children.length;
mapEventToEffects(
makeEvent('MemoryPromoted', { id: 'n1' }),
ctx,
allNodes
);
// Should have green pulse
const greenPulse = effects.pulseEffects.find((p) => p.nodeId === 'n1');
expect(greenPulse).toBeDefined();
// Should have added visual effects to scene
expect(scene.children.length).toBeGreaterThan(childrenBefore);
});
it('uses default retention when not provided', () => {
mapEventToEffects(
makeEvent('MemoryPromoted', { id: 'n1' }),
ctx,
allNodes
);
const mutation = mutations.find((m) => m.type === 'nodeUpdated') as any;
expect(mutation.retention).toBe(0.95); // default
});
it('ignores nonexistent node', () => {
mapEventToEffects(
makeEvent('MemoryPromoted', { id: 'nonexistent' }),
ctx,
allNodes
);
expect(mutations.length).toBe(0);
});
});
describe('MemoryDemoted', () => {
it('shrinks the node', () => {
mapEventToEffects(
makeEvent('MemoryDemoted', { id: 'n1', new_retention: 0.3 }),
ctx,
allNodes
);
expect(nodeManager.meshMap.get('n1')!.userData.retention).toBe(0.3);
});
it('emits nodeUpdated mutation', () => {
mapEventToEffects(
makeEvent('MemoryDemoted', { id: 'n2', new_retention: 0.2 }),
ctx,
allNodes
);
const mutation = mutations.find((m) => m.type === 'nodeUpdated') as any;
expect(mutation).toBeDefined();
expect(mutation.retention).toBe(0.2);
});
it('creates red pulse (subtler than promotion)', () => {
mapEventToEffects(
makeEvent('MemoryDemoted', { id: 'n1', new_retention: 0.3 }),
ctx,
allNodes
);
const pulse = effects.pulseEffects.find((p) => p.nodeId === 'n1');
expect(pulse).toBeDefined();
expect(pulse!.decay).toBe(0.03); // faster decay = subtler
});
});
describe('MemoryUpdated', () => {
it('creates blue pulse on existing node', () => {
mapEventToEffects(
makeEvent('MemoryUpdated', { id: 'n1' }),
ctx,
allNodes
);
const pulse = effects.pulseEffects.find((p) => p.nodeId === 'n1');
expect(pulse).toBeDefined();
});
it('updates retention if provided', () => {
mapEventToEffects(
makeEvent('MemoryUpdated', { id: 'n1', retention: 0.85 }),
ctx,
allNodes
);
const mutation = mutations.find((m) => m.type === 'nodeUpdated') as any;
expect(mutation).toBeDefined();
expect(mutation.retention).toBe(0.85);
});
it('does not emit mutation without retention data', () => {
mapEventToEffects(
makeEvent('MemoryUpdated', { id: 'n1' }),
ctx,
allNodes
);
expect(mutations.length).toBe(0);
});
it('ignores nonexistent node', () => {
mapEventToEffects(
makeEvent('MemoryUpdated', { id: 'nonexistent' }),
ctx,
allNodes
);
expect(mutations.length).toBe(0);
expect(effects.pulseEffects.length).toBe(0);
});
});
describe('SearchPerformed', () => {
it('pulses all nodes', () => {
mapEventToEffects(
makeEvent('SearchPerformed', {}),
ctx,
allNodes
);
expect(effects.pulseEffects.length).toBe(5); // 5 initial nodes
});
});
describe('DreamStarted', () => {
it('pulses all nodes with purple', () => {
mapEventToEffects(
makeEvent('DreamStarted', {}),
ctx,
allNodes
);
expect(effects.pulseEffects.length).toBe(5);
// Purple pulse with slow decay
effects.pulseEffects.forEach((p) => {
expect(p.decay).toBe(0.005);
});
});
});
describe('DreamProgress', () => {
it('pulses specific memory with high intensity', () => {
mapEventToEffects(
makeEvent('DreamProgress', { memory_id: 'n3' }),
ctx,
allNodes
);
const pulse = effects.pulseEffects.find((p) => p.nodeId === 'n3');
expect(pulse).toBeDefined();
expect(pulse!.intensity).toBe(1.5);
});
it('ignores nonexistent memory', () => {
mapEventToEffects(
makeEvent('DreamProgress', { memory_id: 'nonexistent' }),
ctx,
allNodes
);
expect(effects.pulseEffects.length).toBe(0);
});
});
describe('DreamCompleted', () => {
it('creates center burst + shockwave', () => {
const childrenBefore = scene.children.length;
mapEventToEffects(
makeEvent('DreamCompleted', {}),
ctx,
allNodes
);
expect(scene.children.length).toBeGreaterThan(childrenBefore);
});
});
describe('RetentionDecayed', () => {
it('adds red pulse to decayed node', () => {
mapEventToEffects(
makeEvent('RetentionDecayed', { id: 'n2' }),
ctx,
allNodes
);
const pulse = effects.pulseEffects.find((p) => p.nodeId === 'n2');
expect(pulse).toBeDefined();
});
});
describe('ConsolidationCompleted', () => {
it('pulses all nodes with orange', () => {
mapEventToEffects(
makeEvent('ConsolidationCompleted', {}),
ctx,
allNodes
);
expect(effects.pulseEffects.length).toBe(5);
});
});
describe('ActivationSpread', () => {
it('creates flashes from source to all targets', () => {
const childrenBefore = scene.children.length;
mapEventToEffects(
makeEvent('ActivationSpread', {
source_id: 'n1',
target_ids: ['n2', 'n3', 'n4'],
}),
ctx,
allNodes
);
expect(scene.children.length).toBe(childrenBefore + 3);
});
});
describe('spawn position scoring', () => {
it('type match scores higher than tag match', () => {
// n1 is type: 'fact', tags: ['rust', 'bug-fix']
// n2 is type: 'concept', tags: ['architecture']
// Creating a 'fact' with 'architecture' tag — should favor n1 (type match = 2 points)
// vs n2 (tag match = 1 point)
const n1Pos = nodeManager.positions.get('n1')!;
const n2Pos = nodeManager.positions.get('n2')!;
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'type-vs-tag',
content: 'test',
node_type: 'fact',
tags: ['architecture'],
}),
ctx,
allNodes
);
const newPos = nodeManager.positions.get('type-vs-tag')!;
const distToN1 = newPos.distanceTo(n1Pos);
const distToN2 = newPos.distanceTo(n2Pos);
// Should be closer to n1 (type match wins)
expect(distToN1).toBeLessThan(distToN2);
});
it('falls back to random position when no matches', () => {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'no-match',
content: 'test',
node_type: 'place', // no existing 'place' nodes
tags: ['geography'], // no matching tags
}),
ctx,
allNodes
);
const pos = nodeManager.positions.get('no-match')!;
// Should be somewhere in the graph space
expect(Math.abs(pos.x)).toBeLessThan(100);
expect(Math.abs(pos.y)).toBeLessThan(100);
});
});
describe('full lifecycle integration', () => {
it('create → promote → delete lifecycle', () => {
// 1. Create
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'lifecycle',
content: 'lifecycle test',
node_type: 'fact',
retention: 0.7,
}),
ctx,
allNodes
);
expect(nodeManager.meshMap.has('lifecycle')).toBe(true);
// 2. Promote
mutations = [];
mapEventToEffects(
makeEvent('MemoryPromoted', { id: 'lifecycle', new_retention: 0.95 }),
ctx,
allNodes
);
expect(nodeManager.meshMap.get('lifecycle')!.userData.retention).toBe(0.95);
// 3. Delete
mutations = [];
mapEventToEffects(
makeEvent('MemoryDeleted', { id: 'lifecycle' }),
ctx,
allNodes
);
expect(forceSim.positions.has('lifecycle')).toBe(false);
});
it('rapid-fire 10 creates without errors', () => {
for (let i = 0; i < 10; i++) {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: `rapid-${i}`,
content: `Rapid memory ${i}`,
node_type: i % 2 === 0 ? 'fact' : 'concept',
tags: ['rapid'],
retention: 0.5 + Math.random() * 0.5,
}),
ctx,
allNodes
);
}
expect(nodeManager.meshMap.size).toBe(15); // 5 initial + 10 new
expect(forceSim.positions.size).toBe(15);
// All mutations should have been emitted
const nodeAdded = mutations.filter((m) => m.type === 'nodeAdded');
expect(nodeAdded.length).toBe(10);
});
it('create + connection discovered pipeline', () => {
// Create two new memories
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'connect-a',
content: 'Connection source',
node_type: 'fact',
}),
ctx,
allNodes
);
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'connect-b',
content: 'Connection target',
node_type: 'fact',
}),
ctx,
allNodes
);
// Then discover a connection between them
mutations = [];
mapEventToEffects(
makeEvent('ConnectionDiscovered', {
source_id: 'connect-a',
target_id: 'connect-b',
weight: 0.9,
}),
ctx,
allNodes
);
const edgeMutation = mutations.find((m) => m.type === 'edgeAdded');
expect(edgeMutation).toBeDefined();
});
it('dream sequence: start → progress → complete → connections', () => {
mapEventToEffects(makeEvent('DreamStarted', {}), ctx, allNodes);
expect(effects.pulseEffects.length).toBe(5);
mapEventToEffects(makeEvent('DreamProgress', { memory_id: 'n1' }), ctx, allNodes);
mapEventToEffects(makeEvent('DreamProgress', { memory_id: 'n3' }), ctx, allNodes);
mapEventToEffects(makeEvent('DreamCompleted', {}), ctx, allNodes);
// Connections discovered during dream
mapEventToEffects(
makeEvent('ConnectionDiscovered', {
source_id: 'n1',
target_id: 'n5',
weight: 0.6,
}),
ctx,
allNodes
);
// Should have emitted edgeAdded
expect(mutations.some((m) => m.type === 'edgeAdded')).toBe(true);
});
});
});

View file

@ -0,0 +1,257 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock three.js before any imports that use it
vi.mock('three', async () => {
const mock = await import('./three-mock');
return { ...mock };
});
import { ForceSimulation } from '../force-sim';
import { Vector3 } from './three-mock';
import { makeNode, makeEdge, resetNodeCounter, tickN } from './helpers';
describe('ForceSimulation', () => {
beforeEach(() => resetNodeCounter());
function createSim(nodeCount: number) {
const positions = new Map<string, InstanceType<typeof Vector3>>();
for (let i = 0; i < nodeCount; i++) {
positions.set(`n${i}`, new Vector3(i * 10, 0, 0));
}
return new ForceSimulation(positions);
}
describe('initialization', () => {
it('creates velocities for all positions', () => {
const sim = createSim(5);
expect(sim.velocities.size).toBe(5);
for (const vel of sim.velocities.values()) {
expect(vel.x).toBe(0);
expect(vel.y).toBe(0);
expect(vel.z).toBe(0);
}
});
it('starts running at step 0', () => {
const sim = createSim(3);
expect(sim.running).toBe(true);
expect(sim.step).toBe(0);
});
});
describe('tick', () => {
it('increments step count each tick', () => {
const sim = createSim(3);
sim.tick([]);
expect(sim.step).toBe(1);
sim.tick([]);
expect(sim.step).toBe(2);
});
it('stops ticking after maxSteps', () => {
const sim = createSim(2);
tickN(sim, [], 301);
const posAfter300 = sim.positions.get('n0')!.clone();
tickN(sim, [], 10);
expect(sim.positions.get('n0')!.x).toBe(posAfter300.x);
});
it('does not tick when not running', () => {
const sim = createSim(2);
sim.running = false;
const posBefore = sim.positions.get('n0')!.clone();
sim.tick([]);
expect(sim.step).toBe(0);
expect(sim.positions.get('n0')!.x).toBe(posBefore.x);
});
it('applies repulsion between nodes', () => {
const positions = new Map<string, InstanceType<typeof Vector3>>();
positions.set('a', new Vector3(0, 0, 0));
positions.set('b', new Vector3(1, 0, 0));
const sim = new ForceSimulation(positions);
sim.tick([]);
// After repulsion, nodes should have moved apart
const a = sim.positions.get('a')!;
const b = sim.positions.get('b')!;
expect(b.x - a.x).toBeGreaterThan(1); // farther apart
});
it('applies attraction along edges', () => {
const positions = new Map<string, InstanceType<typeof Vector3>>();
positions.set('a', new Vector3(0, 0, 0));
positions.set('b', new Vector3(100, 0, 0));
const sim = new ForceSimulation(positions);
const edges = [makeEdge('a', 'b', { weight: 1.0 })];
tickN(sim, edges, 50);
// After many ticks with attraction, nodes should be closer
const dist = sim.positions.get('a')!.distanceTo(sim.positions.get('b')!);
expect(dist).toBeLessThan(100);
});
it('applies centering force toward origin', () => {
const positions = new Map<string, InstanceType<typeof Vector3>>();
positions.set('far', new Vector3(1000, 1000, 1000));
const sim = new ForceSimulation(positions);
tickN(sim, [], 100);
const pos = sim.positions.get('far')!;
// Should have moved closer to origin
expect(pos.length()).toBeLessThan(1000 * Math.sqrt(3));
});
});
describe('addNode', () => {
it('adds position and velocity entries', () => {
const sim = createSim(2);
const newPos = new Vector3(5, 5, 5);
sim.addNode('new', newPos);
expect(sim.positions.has('new')).toBe(true);
expect(sim.velocities.has('new')).toBe(true);
expect(sim.positions.get('new')!.x).toBe(5);
});
it('clones the input position', () => {
const sim = createSim(1);
const input = new Vector3(10, 10, 10);
sim.addNode('new', input);
input.x = 999;
expect(sim.positions.get('new')!.x).toBe(10);
});
it('re-energizes physics so simulation stays alive', () => {
const sim = createSim(2);
// Exhaust the simulation
tickN(sim, [], 305);
expect(sim.step).toBe(301); // stopped at maxSteps+1
// Add a node
sim.addNode('live', new Vector3(0, 0, 0));
expect(sim.running).toBe(true);
// Should be able to tick again
const stepBefore = sim.step;
sim.tick([]);
expect(sim.step).toBe(stepBefore + 1);
});
it('extends maxSteps by cooldown amount', () => {
const sim = createSim(2);
tickN(sim, [], 250);
const stepAtAdd = sim.step;
sim.addNode('live', new Vector3(0, 0, 0));
// Should be able to tick at least 100 more times
let ticked = 0;
for (let i = 0; i < 100; i++) {
const stepBefore = sim.step;
sim.tick([]);
if (sim.step > stepBefore) ticked++;
}
expect(ticked).toBe(100);
});
});
describe('removeNode', () => {
it('removes position and velocity entries', () => {
const sim = createSim(3);
sim.removeNode('n1');
expect(sim.positions.has('n1')).toBe(false);
expect(sim.velocities.has('n1')).toBe(false);
expect(sim.positions.size).toBe(2);
});
it('simulation continues without removed node', () => {
const sim = createSim(3);
sim.removeNode('n1');
// Should not throw
tickN(sim, [], 10);
expect(sim.positions.has('n0')).toBe(true);
expect(sim.positions.has('n2')).toBe(true);
});
});
describe('reset', () => {
it('resets step count and running state', () => {
const sim = createSim(3);
tickN(sim, [], 100);
sim.running = false;
sim.reset();
expect(sim.step).toBe(0);
expect(sim.running).toBe(true);
});
it('zeroes all velocities', () => {
const sim = createSim(3);
tickN(sim, [], 10);
sim.reset();
for (const vel of sim.velocities.values()) {
expect(vel.x).toBe(0);
expect(vel.y).toBe(0);
expect(vel.z).toBe(0);
}
});
});
describe('physics convergence', () => {
it('two connected nodes reach equilibrium', () => {
const positions = new Map<string, InstanceType<typeof Vector3>>();
positions.set('a', new Vector3(-50, 0, 0));
positions.set('b', new Vector3(50, 0, 0));
const sim = new ForceSimulation(positions);
const edges = [makeEdge('a', 'b', { weight: 0.5 })];
tickN(sim, edges, 300);
// Should reach equilibrium — velocities near zero
const velA = sim.velocities.get('a')!;
const velB = sim.velocities.get('b')!;
expect(Math.abs(velA.x)).toBeLessThan(0.01);
expect(Math.abs(velB.x)).toBeLessThan(0.01);
});
it('unconnected nodes repel to stable separation', () => {
const positions = new Map<string, InstanceType<typeof Vector3>>();
positions.set('a', new Vector3(0, 0, 0));
positions.set('b', new Vector3(5, 0, 0));
const sim = new ForceSimulation(positions);
tickN(sim, [], 300);
const dist = sim.positions.get('a')!.distanceTo(sim.positions.get('b')!);
expect(dist).toBeGreaterThan(5); // repelled farther apart
});
it('multiple nodes form a spread-out cluster', () => {
const positions = new Map<string, InstanceType<typeof Vector3>>();
for (let i = 0; i < 10; i++) {
positions.set(`n${i}`, new Vector3(Math.random() * 2, Math.random() * 2, Math.random() * 2));
}
const sim = new ForceSimulation(positions);
const edges = [makeEdge('n0', 'n1'), makeEdge('n2', 'n3'), makeEdge('n4', 'n5')];
tickN(sim, edges, 300);
// All nodes should be spread out, no two should overlap
const ids = Array.from(positions.keys());
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const dist = sim.positions.get(ids[i])!.distanceTo(sim.positions.get(ids[j])!);
expect(dist).toBeGreaterThan(0.1);
}
}
});
});
});

View file

@ -0,0 +1,50 @@
/**
* Test helpers: factories for creating test data.
*/
import type { GraphNode, GraphEdge, VestigeEvent, VestigeEventType } from '$types';
let nodeCounter = 0;
export function makeNode(overrides: Partial<GraphNode> = {}): GraphNode {
nodeCounter++;
return {
id: `node-${nodeCounter}`,
label: `Test Node ${nodeCounter}`,
type: 'fact',
retention: 0.8,
tags: ['test'],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isCenter: false,
...overrides,
};
}
export function makeEdge(
source: string,
target: string,
overrides: Partial<GraphEdge> = {}
): GraphEdge {
return {
source,
target,
weight: 0.5,
type: 'semantic',
...overrides,
};
}
export function makeEvent(type: VestigeEventType, data: Record<string, unknown> = {}): VestigeEvent {
return { type, data };
}
export function resetNodeCounter() {
nodeCounter = 0;
}
/** Run simulation for N ticks */
export function tickN(sim: { tick: (edges: GraphEdge[]) => void }, edges: GraphEdge[], n: number) {
for (let i = 0; i < n; i++) {
sim.tick(edges);
}
}

View file

@ -0,0 +1,456 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('three', async () => {
const mock = await import('./three-mock');
return { ...mock };
});
import { NodeManager } from '../nodes';
import { Vector3 } from './three-mock';
import { makeNode, resetNodeCounter } from './helpers';
describe('NodeManager', () => {
let manager: NodeManager;
beforeEach(() => {
resetNodeCounter();
manager = new NodeManager();
});
describe('createNodes', () => {
it('creates meshes, glows, and labels for all nodes', () => {
const nodes = [makeNode({ id: 'a' }), makeNode({ id: 'b' }), makeNode({ id: 'c' })];
const positions = manager.createNodes(nodes);
expect(positions.size).toBe(3);
expect(manager.meshMap.size).toBe(3);
expect(manager.glowMap.size).toBe(3);
expect(manager.labelSprites.size).toBe(3);
});
it('positions center node at origin', () => {
const nodes = [
makeNode({ id: 'center', isCenter: true }),
makeNode({ id: 'other' }),
];
const positions = manager.createNodes(nodes);
const centerPos = positions.get('center')!;
expect(centerPos.x).toBe(0);
expect(centerPos.y).toBe(0);
expect(centerPos.z).toBe(0);
});
it('scales mesh size by retention', () => {
const highRet = makeNode({ id: 'high', retention: 1.0 });
const lowRet = makeNode({ id: 'low', retention: 0.1 });
manager.createNodes([highRet, lowRet]);
// SphereGeometry size = 0.5 + retention * 2
// High retention should have larger geometry (indirectly via userData)
const highMesh = manager.meshMap.get('high')!;
const lowMesh = manager.meshMap.get('low')!;
expect(highMesh.userData.retention).toBe(1.0);
expect(lowMesh.userData.retention).toBe(0.1);
});
it('uses Fibonacci sphere distribution for initial positions', () => {
const nodes = Array.from({ length: 20 }, (_, i) => makeNode({ id: `n${i}` }));
const positions = manager.createNodes(nodes);
// No two nodes should be at the same position
const posArr = Array.from(positions.values());
for (let i = 0; i < posArr.length; i++) {
for (let j = i + 1; j < posArr.length; j++) {
const dist = posArr[i].distanceTo(posArr[j]);
expect(dist).toBeGreaterThan(0.1);
}
}
});
it('stores node type in userData', () => {
const nodes = [
makeNode({ id: 'fact', type: 'fact' }),
makeNode({ id: 'concept', type: 'concept' }),
makeNode({ id: 'decision', type: 'decision' }),
];
manager.createNodes(nodes);
expect(manager.meshMap.get('fact')!.userData.type).toBe('fact');
expect(manager.meshMap.get('concept')!.userData.type).toBe('concept');
expect(manager.meshMap.get('decision')!.userData.type).toBe('decision');
});
});
describe('addNode — materialization', () => {
it('adds a new node at specified position', () => {
const node = makeNode({ id: 'live-1' });
const pos = new Vector3(10, 20, 30);
const result = manager.addNode(node, pos);
expect(manager.meshMap.has('live-1')).toBe(true);
expect(manager.glowMap.has('live-1')).toBe(true);
expect(manager.labelSprites.has('live-1')).toBe(true);
expect(manager.positions.has('live-1')).toBe(true);
expect(result.x).toBe(10);
expect(result.y).toBe(20);
expect(result.z).toBe(30);
});
it('starts node at near-zero scale (not zero to avoid GPU issues)', () => {
const node = makeNode({ id: 'live-2' });
manager.addNode(node);
const mesh = manager.meshMap.get('live-2')!;
expect(mesh.scale.x).toBeCloseTo(0.001, 3);
});
it('generates random position if none provided', () => {
const node = makeNode({ id: 'live-3' });
const pos = manager.addNode(node);
// Should be within ±20 range
expect(Math.abs(pos.x)).toBeLessThanOrEqual(20);
expect(Math.abs(pos.y)).toBeLessThanOrEqual(20);
expect(Math.abs(pos.z)).toBeLessThanOrEqual(20);
});
it('clones the input position to prevent external mutation', () => {
const node = makeNode({ id: 'live-4' });
const input = new Vector3(5, 5, 5);
manager.addNode(node, input);
input.x = 999;
expect(manager.positions.get('live-4')!.x).toBe(5);
});
it('label starts fully transparent', () => {
const node = makeNode({ id: 'live-5' });
manager.addNode(node);
const label = manager.labelSprites.get('live-5')!;
expect((label.material as any).opacity).toBe(0);
});
it('glow starts fully transparent', () => {
const node = makeNode({ id: 'live-6' });
manager.addNode(node);
const glow = manager.glowMap.get('live-6')!;
expect((glow.material as any).opacity).toBe(0);
});
});
describe('materialization animation choreography', () => {
function setupAndAnimate(frames: number) {
const nodes = [makeNode({ id: 'existing', retention: 0.5 })];
manager.createNodes(nodes);
const liveNode = makeNode({ id: 'live', retention: 0.9 });
manager.addNode(liveNode);
const allNodes = [...nodes, liveNode];
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < frames; f++) {
manager.animate(f * 0.016, allNodes, camera);
}
return {
mesh: manager.meshMap.get('live')!,
glow: manager.glowMap.get('live')!,
label: manager.labelSprites.get('live')!,
};
}
it('mesh scale increases during first 30 frames', () => {
const { mesh } = setupAndAnimate(15);
expect(mesh.scale.x).toBeGreaterThan(0.001);
});
it('mesh reaches approximately full scale by frame 30', () => {
const { mesh } = setupAndAnimate(30);
// easeOutElastic should be near 1.0 at t=1
expect(mesh.scale.x).toBeGreaterThan(0.8);
});
it('glow starts fading in at frame 5', () => {
// Before frame 5: opacity should be 0
const before = setupAndAnimate(4);
expect((before.glow.material as any).opacity).toBe(0);
// After frame 7: opacity should be positive
const after = setupAndAnimate(8);
expect((after.glow.material as any).opacity).toBeGreaterThan(0);
});
it('label starts fading in after frame 40', () => {
// At frame 39: label should still be transparent
const before = setupAndAnimate(39);
expect((before.label.material as any).opacity).toBe(0);
// At frame 50: label should have some opacity
const after = setupAndAnimate(50);
expect((after.label.material as any).opacity).toBeGreaterThan(0);
});
it('label has positive opacity at frame 55 (during materialization window)', () => {
// Label fade-in runs from frame 40 to 60 (during materialization).
// After frame 60, distance-based visibility takes over which depends on camera position.
// Test within the materialization window itself.
const { label } = setupAndAnimate(55);
expect((label.material as any).opacity).toBeGreaterThan(0);
});
it('elastic overshoot occurs during materialization', () => {
// easeOutElastic should cause scale > 1.0 at some point
let maxScale = 0;
const nodes = [makeNode({ id: 'existing' })];
manager.createNodes(nodes);
const liveNode = makeNode({ id: 'elastic', retention: 0.5 });
manager.addNode(liveNode);
const allNodes = [...nodes, liveNode];
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < 30; f++) {
manager.animate(f * 0.016, allNodes, camera);
const mesh = manager.meshMap.get('elastic')!;
if (mesh.scale.x > maxScale) maxScale = mesh.scale.x;
}
// Elastic should overshoot past 1.0
expect(maxScale).toBeGreaterThan(1.0);
});
});
describe('removeNode — dissolution', () => {
function setupWithNode() {
const nodes = [makeNode({ id: 'a' }), makeNode({ id: 'b' })];
manager.createNodes(nodes);
return nodes;
}
it('marks node for dissolution without immediate removal', () => {
setupWithNode();
manager.removeNode('a');
// Mesh should still exist during dissolution animation
expect(manager.meshMap.has('a')).toBe(true);
});
it('node is fully removed after dissolution animation completes (60 frames)', () => {
const nodes = setupWithNode();
manager.removeNode('a');
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < 65; f++) {
manager.animate(f * 0.016, nodes, camera);
}
expect(manager.meshMap.has('a')).toBe(false);
expect(manager.glowMap.has('a')).toBe(false);
expect(manager.labelSprites.has('a')).toBe(false);
expect(manager.positions.has('a')).toBe(false);
});
it('node shrinks during dissolution using easeInBack', () => {
const nodes = setupWithNode();
manager.removeNode('a');
const camera = { position: new Vector3(0, 30, 80) } as any;
// Run to near completion (frame 55/60) where shrink is deep
for (let f = 0; f < 55; f++) {
manager.animate(f * 0.016, nodes, camera);
}
const currentScale = manager.meshMap.get('a')!.scale.x;
// At frame 55/60, easeInBack(0.917) ≈ 0.87, shrink = 1-0.87 = 0.13
// The originalScale from breathing was ~1.0, scale should be very small
expect(currentScale).toBeLessThan(1.0);
});
it('opacity fades during dissolution', () => {
const nodes = setupWithNode();
manager.removeNode('a');
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < 50; f++) {
manager.animate(f * 0.016, nodes, camera);
}
const mesh = manager.meshMap.get('a');
if (mesh) {
expect((mesh.material as any).opacity).toBeLessThan(0.5);
}
});
it('cancels materialization if node removed during spawn', () => {
const nodes = [makeNode({ id: 'base' })];
manager.createNodes(nodes);
const liveNode = makeNode({ id: 'spawn-then-die' });
manager.addNode(liveNode);
// Immediately remove before materialization finishes
manager.removeNode('spawn-then-die');
const allNodes = [...nodes, liveNode];
const camera = { position: new Vector3(0, 30, 80) } as any;
// Run past both animation durations
for (let f = 0; f < 70; f++) {
manager.animate(f * 0.016, allNodes, camera);
}
expect(manager.meshMap.has('spawn-then-die')).toBe(false);
});
});
describe('growNode — retention change animation', () => {
it('grows node to new retention scale', () => {
const nodes = [makeNode({ id: 'grow', retention: 0.3 })];
manager.createNodes(nodes);
const originalScale = manager.meshMap.get('grow')!.scale.x;
manager.growNode('grow', 0.9);
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < 35; f++) {
manager.animate(f * 0.016, nodes, camera);
}
// Target scale = 0.5 + 0.9 * 2 = 2.3
const mesh = manager.meshMap.get('grow')!;
// Should be near target scale after animation completes
expect(mesh.scale.x).toBeGreaterThan(originalScale);
});
it('shrinks node when retention decreases (demotion)', () => {
const nodes = [makeNode({ id: 'shrink', retention: 0.9 })];
manager.createNodes(nodes);
manager.growNode('shrink', 0.2);
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < 35; f++) {
manager.animate(f * 0.016, nodes, camera);
}
// Target scale = 0.5 + 0.2 * 2 = 0.9 (less than 0.5 + 0.9*2 = 2.3)
const mesh = manager.meshMap.get('shrink')!;
expect(mesh.userData.retention).toBe(0.2);
});
it('also grows the glow sprite', () => {
const nodes = [makeNode({ id: 'glow-grow', retention: 0.3 })];
manager.createNodes(nodes);
const originalGlowScale = manager.glowMap.get('glow-grow')!.scale.x;
manager.growNode('glow-grow', 0.95);
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < 35; f++) {
manager.animate(f * 0.016, nodes, camera);
}
const newGlowScale = manager.glowMap.get('glow-grow')!.scale.x;
expect(newGlowScale).toBeGreaterThan(originalGlowScale);
});
it('handles nonexistent node gracefully', () => {
expect(() => manager.growNode('nonexistent', 0.5)).not.toThrow();
});
});
describe('breathing animation', () => {
it('breathing only affects non-animating nodes', () => {
const nodes = [makeNode({ id: 'normal' })];
manager.createNodes(nodes);
const liveNode = makeNode({ id: 'materializing' });
manager.addNode(liveNode);
const allNodes = [...nodes, liveNode];
const camera = { position: new Vector3(0, 30, 80) } as any;
// During first few frames, materializing node should use animation scale
manager.animate(0.016, allNodes, camera);
// The materializing node's scale should be from the animation, not breathing
const matMesh = manager.meshMap.get('materializing')!;
// Its scale should be small (just started materializing)
expect(matMesh.scale.x).toBeLessThan(0.5);
});
it('hover increases emissive intensity', () => {
const nodes = [makeNode({ id: 'hover-test', retention: 0.5 })];
manager.createNodes(nodes);
manager.hoveredNode = 'hover-test';
const camera = { position: new Vector3(0, 30, 80) } as any;
manager.animate(0, nodes, camera);
const mat = manager.meshMap.get('hover-test')!.material as any;
expect(mat.emissiveIntensity).toBe(1.0);
});
it('selected node gets elevated emissive intensity', () => {
const nodes = [makeNode({ id: 'sel-test', retention: 0.5 })];
manager.createNodes(nodes);
manager.selectedNode = 'sel-test';
const camera = { position: new Vector3(0, 30, 80) } as any;
manager.animate(0, nodes, camera);
const mat = manager.meshMap.get('sel-test')!.material as any;
expect(mat.emissiveIntensity).toBe(0.8);
});
});
describe('label visibility', () => {
it('labels visible for nearby nodes', () => {
const nodes = [makeNode({ id: 'near', retention: 0.5 })];
manager.createNodes(nodes);
// Camera very close to the node
const nodePos = manager.positions.get('near')!;
const camera = { position: nodePos.clone().add(new Vector3(0, 0, 10)) } as any;
for (let f = 0; f < 30; f++) {
manager.animate(f * 0.016, nodes, camera);
}
const label = manager.labelSprites.get('near')!;
expect((label.material as any).opacity).toBeGreaterThan(0.5);
});
it('labels invisible for distant nodes', () => {
const nodes = [makeNode({ id: 'far', retention: 0.5 })];
manager.createNodes(nodes);
const nodePos = manager.positions.get('far')!;
const camera = { position: nodePos.clone().add(new Vector3(0, 0, 200)) } as any;
for (let f = 0; f < 30; f++) {
manager.animate(f * 0.016, nodes, camera);
}
const label = manager.labelSprites.get('far')!;
expect((label.material as any).opacity).toBeLessThan(0.1);
});
});
describe('dispose', () => {
it('clears all animation queues', () => {
const nodes = [makeNode({ id: 'a' })];
manager.createNodes(nodes);
manager.addNode(makeNode({ id: 'b' }));
manager.removeNode('a');
manager.dispose();
// Internal arrays should be empty (tested indirectly by no errors on next animate)
// The dispose method clears materializingNodes, dissolvingNodes, growingNodes
});
});
});

View file

@ -0,0 +1,37 @@
/**
* Test setup: minimal DOM stubs for canvas-based text rendering.
*/
import { vi } from 'vitest';
// Minimal canvas 2D context mock
const mockContext2D = {
clearRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn(() => ({ width: 100 })),
font: '',
textAlign: '',
textBaseline: '',
fillStyle: '',
shadowColor: '',
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
};
// Minimal canvas element mock
const mockCanvas = {
width: 512,
height: 64,
getContext: vi.fn(() => mockContext2D),
toDataURL: vi.fn(() => 'data:image/png;base64,'),
};
// Stub document.createElement for canvas
if (typeof globalThis.document === 'undefined') {
(globalThis as any).document = {
createElement: vi.fn((tag: string) => {
if (tag === 'canvas') return { ...mockCanvas };
return {};
}),
};
}

View file

@ -0,0 +1,438 @@
/**
* Lightweight Three.js mock for unit/integration tests.
* Implements the subset of Three.js APIs used by the graph modules.
*/
import { vi } from 'vitest';
export class Vector3 {
x: number;
y: number;
z: number;
constructor(x = 0, y = 0, z = 0) {
this.x = x;
this.y = y;
this.z = z;
}
clone() {
return new Vector3(this.x, this.y, this.z);
}
copy(v: Vector3) {
this.x = v.x;
this.y = v.y;
this.z = v.z;
return this;
}
set(x: number, y: number, z: number) {
this.x = x;
this.y = y;
this.z = z;
return this;
}
add(v: Vector3) {
this.x += v.x;
this.y += v.y;
this.z += v.z;
return this;
}
sub(v: Vector3) {
this.x -= v.x;
this.y -= v.y;
this.z -= v.z;
return this;
}
subVectors(a: Vector3, b: Vector3) {
this.x = a.x - b.x;
this.y = a.y - b.y;
this.z = a.z - b.z;
return this;
}
multiplyScalar(s: number) {
this.x *= s;
this.y *= s;
this.z *= s;
return this;
}
normalize() {
const len = this.length() || 1;
this.x /= len;
this.y /= len;
this.z /= len;
return this;
}
length() {
return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z);
}
distanceTo(v: Vector3) {
const dx = this.x - v.x;
const dy = this.y - v.y;
const dz = this.z - v.z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
lerp(v: Vector3, alpha: number) {
this.x += (v.x - this.x) * alpha;
this.y += (v.y - this.y) * alpha;
this.z += (v.z - this.z) * alpha;
return this;
}
setScalar(s: number) {
this.x = s;
this.y = s;
this.z = s;
return this;
}
}
export class Vector2 {
x: number;
y: number;
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
}
}
export class Color {
r: number;
g: number;
b: number;
constructor(colorOrR?: number | string, g?: number, b?: number) {
if (typeof colorOrR === 'string') {
this.r = 1;
this.g = 1;
this.b = 1;
} else if (typeof colorOrR === 'number' && g === undefined) {
this.r = ((colorOrR >> 16) & 255) / 255;
this.g = ((colorOrR >> 8) & 255) / 255;
this.b = (colorOrR & 255) / 255;
} else {
this.r = colorOrR ?? 1;
this.g = g ?? 1;
this.b = b ?? 1;
}
}
clone() {
const c = new Color();
c.r = this.r;
c.g = this.g;
c.b = this.b;
return c;
}
copy(c: Color) {
this.r = c.r;
this.g = c.g;
this.b = c.b;
return this;
}
lerp(c: Color, alpha: number) {
this.r += (c.r - this.r) * alpha;
this.g += (c.g - this.g) * alpha;
this.b += (c.b - this.b) * alpha;
return this;
}
setHSL(h: number, s: number, l: number) {
this.r = h;
this.g = s;
this.b = l;
return this;
}
offsetHSL(_h: number, _s: number, _l: number) {
return this;
}
}
export class BufferAttribute {
array: Float32Array;
itemSize: number;
count: number;
needsUpdate = false;
constructor(array: Float32Array, itemSize: number) {
this.array = array;
this.itemSize = itemSize;
this.count = array.length / itemSize;
}
getX(index: number) {
return this.array[index * this.itemSize];
}
getY(index: number) {
return this.array[index * this.itemSize + 1];
}
getZ(index: number) {
return this.array[index * this.itemSize + 2];
}
setX(index: number, x: number) {
this.array[index * this.itemSize] = x;
}
setY(index: number, y: number) {
this.array[index * this.itemSize + 1] = y;
}
setZ(index: number, z: number) {
this.array[index * this.itemSize + 2] = z;
}
setXYZ(index: number, x: number, y: number, z: number) {
const i = index * this.itemSize;
this.array[i] = x;
this.array[i + 1] = y;
this.array[i + 2] = z;
}
}
export class BufferGeometry {
attributes: Record<string, BufferAttribute> = {};
setAttribute(name: string, attr: BufferAttribute) {
this.attributes[name] = attr;
return this;
}
getAttribute(name: string) {
return this.attributes[name];
}
setFromPoints(points: Vector3[]) {
const arr = new Float32Array(points.length * 3);
points.forEach((p, i) => {
arr[i * 3] = p.x;
arr[i * 3 + 1] = p.y;
arr[i * 3 + 2] = p.z;
});
this.setAttribute('position', new BufferAttribute(arr, 3));
return this;
}
dispose() {}
}
export class SphereGeometry extends BufferGeometry {
constructor(_radius?: number, _w?: number, _h?: number) {
super();
}
}
export class RingGeometry extends BufferGeometry {
constructor(_inner?: number, _outer?: number, _segments?: number) {
super();
}
}
class BaseMaterial {
color = new Color();
transparent = false;
opacity = 1;
blending = 0;
side = 0;
map: { dispose: () => void } | null = null;
emissive = new Color();
emissiveIntensity = 0;
roughness = 0;
metalness = 0;
depthTest = true;
sizeAttenuation = true;
size = 1;
needsUpdate = false;
dispose() {}
}
export class MeshStandardMaterial extends BaseMaterial {
constructor(params?: Record<string, unknown>) {
super();
if (params) {
if (params.color instanceof Color) this.color = params.color;
if (params.emissive instanceof Color) this.emissive = params.emissive;
if (typeof params.emissiveIntensity === 'number') this.emissiveIntensity = params.emissiveIntensity;
if (typeof params.opacity === 'number') this.opacity = params.opacity;
if (typeof params.transparent === 'boolean') this.transparent = params.transparent;
if (typeof params.roughness === 'number') this.roughness = params.roughness;
if (typeof params.metalness === 'number') this.metalness = params.metalness;
}
}
}
export class MeshBasicMaterial extends BaseMaterial {
constructor(params?: Record<string, unknown>) {
super();
if (params) {
if (typeof params.opacity === 'number') this.opacity = params.opacity;
if (typeof params.transparent === 'boolean') this.transparent = params.transparent;
}
}
}
export class LineBasicMaterial extends BaseMaterial {
constructor(params?: Record<string, unknown>) {
super();
if (params) {
if (typeof params.opacity === 'number') this.opacity = params.opacity;
}
}
}
export class PointsMaterial extends BaseMaterial {
constructor(params?: Record<string, unknown>) {
super();
if (params) {
if (params.color instanceof Color) this.color = params.color;
else if (typeof params.color === 'number') this.color = new Color(params.color);
if (typeof params.size === 'number') this.size = params.size;
if (typeof params.opacity === 'number') this.opacity = params.opacity;
}
}
}
export class SpriteMaterial extends BaseMaterial {
constructor(params?: Record<string, unknown>) {
super();
if (params) {
if (typeof params.opacity === 'number') this.opacity = params.opacity;
}
}
}
export class Object3D {
position = new Vector3();
scale = new Vector3(1, 1, 1);
userData: Record<string, unknown> = {};
children: Object3D[] = [];
parent: Object3D | null = null;
add(child: Object3D) {
this.children.push(child);
child.parent = this;
return this;
}
remove(child: Object3D) {
const idx = this.children.indexOf(child);
if (idx !== -1) {
this.children.splice(idx, 1);
child.parent = null;
}
return this;
}
traverse(callback: (obj: Object3D) => void) {
callback(this);
for (const child of this.children) {
child.traverse(callback);
}
}
lookAt(_target: Vector3) {}
}
export class Group extends Object3D {}
export class Scene extends Object3D {}
export class Mesh extends Object3D {
geometry: BufferGeometry;
material: BaseMaterial;
constructor(geometry?: BufferGeometry, material?: BaseMaterial) {
super();
this.geometry = geometry ?? new BufferGeometry();
this.material = material ?? new BaseMaterial();
}
}
export class Line extends Object3D {
geometry: BufferGeometry;
material: BaseMaterial;
constructor(geometry?: BufferGeometry, material?: BaseMaterial) {
super();
this.geometry = geometry ?? new BufferGeometry();
this.material = material ?? new BaseMaterial();
}
}
export class Points extends Object3D {
geometry: BufferGeometry;
material: BaseMaterial;
constructor(geometry?: BufferGeometry, material?: BaseMaterial) {
super();
this.geometry = geometry ?? new BufferGeometry();
this.material = material ?? new BaseMaterial();
}
}
export class Sprite extends Object3D {
material: BaseMaterial;
constructor(material?: BaseMaterial) {
super();
this.material = material ?? new BaseMaterial();
}
}
export class PerspectiveCamera extends Object3D {
fov = 60;
aspect = 1;
near = 0.1;
far = 2000;
}
export class Camera extends Object3D {}
export class CanvasTexture {
needsUpdate = false;
constructor(_canvas: unknown) {}
dispose() {}
}
// Blending constants
export const AdditiveBlending = 2;
export const DoubleSide = 2;
// Install mock globally for 'three' imports
export function installThreeMock() {
vi.mock('three', () => ({
Vector3,
Vector2,
Color,
BufferAttribute,
BufferGeometry,
SphereGeometry,
RingGeometry,
MeshStandardMaterial,
MeshBasicMaterial,
LineBasicMaterial,
PointsMaterial,
SpriteMaterial,
Object3D,
Group,
Scene,
Mesh,
Line,
Points,
Sprite,
PerspectiveCamera,
Camera,
CanvasTexture,
AdditiveBlending,
DoubleSide,
}));
}

View file

@ -1,8 +1,28 @@
import * as THREE from 'three';
import type { GraphEdge } from '$types';
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
interface GrowingEdge {
line: THREE.Line;
source: string;
target: string;
frame: number;
totalFrames: number;
}
interface DissolvingEdge {
line: THREE.Line;
frame: number;
totalFrames: number;
}
export class EdgeManager {
group: THREE.Group;
private growingEdges: GrowingEdge[] = [];
private dissolvingEdges: DissolvingEdge[] = [];
constructor() {
this.group = new THREE.Group();
@ -29,9 +49,101 @@ export class EdgeManager {
}
}
addEdge(edge: GraphEdge, positions: Map<string, THREE.Vector3>) {
const sourcePos = positions.get(edge.source);
const targetPos = positions.get(edge.target);
if (!sourcePos || !targetPos) return;
// Start with zero-length line at source position
const points = [sourcePos.clone(), sourcePos.clone()];
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color: 0x4a4a7a,
transparent: true,
opacity: 0,
blending: THREE.AdditiveBlending,
});
const line = new THREE.Line(geometry, material);
line.userData = { source: edge.source, target: edge.target };
this.group.add(line);
this.growingEdges.push({
line,
source: edge.source,
target: edge.target,
frame: 0,
totalFrames: 45,
});
}
removeEdgesForNode(nodeId: string) {
const toDissolve: THREE.Line[] = [];
this.group.children.forEach((child) => {
const line = child as THREE.Line;
if (line.userData.source === nodeId || line.userData.target === nodeId) {
toDissolve.push(line);
}
});
for (const line of toDissolve) {
// Remove from growing if still animating
this.growingEdges = this.growingEdges.filter((g) => g.line !== line);
this.dissolvingEdges.push({ line, frame: 0, totalFrames: 40 });
}
}
animateEdges(positions: Map<string, THREE.Vector3>) {
// Growing edges — interpolate endpoint from source to target
for (let i = this.growingEdges.length - 1; i >= 0; i--) {
const g = this.growingEdges[i];
g.frame++;
const progress = easeOutCubic(Math.min(g.frame / g.totalFrames, 1));
const sourcePos = positions.get(g.source);
const targetPos = positions.get(g.target);
if (!sourcePos || !targetPos) continue;
const currentEnd = sourcePos.clone().lerp(targetPos, progress);
const attrs = g.line.geometry.attributes.position as THREE.BufferAttribute;
attrs.setXYZ(0, sourcePos.x, sourcePos.y, sourcePos.z);
attrs.setXYZ(1, currentEnd.x, currentEnd.y, currentEnd.z);
attrs.needsUpdate = true;
const mat = g.line.material as THREE.LineBasicMaterial;
mat.opacity = progress * 0.5;
if (g.frame >= g.totalFrames) {
// Final opacity from weight
mat.opacity = 0.5;
this.growingEdges.splice(i, 1);
}
}
// Dissolving edges — fade out
for (let i = this.dissolvingEdges.length - 1; i >= 0; i--) {
const d = this.dissolvingEdges[i];
d.frame++;
const progress = d.frame / d.totalFrames;
const mat = d.line.material as THREE.LineBasicMaterial;
mat.opacity = Math.max(0, 0.5 * (1 - progress));
if (d.frame >= d.totalFrames) {
this.group.remove(d.line);
d.line.geometry.dispose();
(d.line.material as THREE.Material).dispose();
this.dissolvingEdges.splice(i, 1);
}
}
}
updatePositions(positions: Map<string, THREE.Vector3>) {
this.group.children.forEach((child) => {
const line = child as THREE.Line;
// Skip lines currently being animated by animateEdges
if (this.growingEdges.some((g) => g.line === line)) return;
if (this.dissolvingEdges.some((d) => d.line === line)) return;
const sourcePos = positions.get(line.userData.source);
const targetPos = positions.get(line.userData.target);
if (sourcePos && targetPos) {
@ -49,5 +161,7 @@ export class EdgeManager {
line.geometry?.dispose();
(line.material as THREE.Material)?.dispose();
});
this.growingEdges = [];
this.dissolvingEdges = [];
}
}

View file

@ -13,6 +13,31 @@ interface SpawnBurst {
particles: THREE.Points;
}
interface RainbowBurst {
position: THREE.Vector3;
age: number;
maxAge: number;
particles: THREE.Points;
baseColor: THREE.Color;
}
interface RippleWave {
origin: THREE.Vector3;
radius: number;
speed: number;
age: number;
maxAge: number;
pulsedNodes: Set<string>;
}
interface ImplosionEffect {
position: THREE.Vector3;
age: number;
maxAge: number;
particles: THREE.Points;
flash: THREE.Mesh | null;
}
interface Shockwave {
mesh: THREE.Mesh;
age: number;
@ -27,6 +52,9 @@ interface ConnectionFlash {
export class EffectManager {
pulseEffects: PulseEffect[] = [];
private spawnBursts: SpawnBurst[] = [];
private rainbowBursts: RainbowBurst[] = [];
private rippleWaves: RippleWave[] = [];
private implosions: ImplosionEffect[] = [];
private shockwaves: Shockwave[] = [];
private connectionFlashes: ConnectionFlash[] = [];
private scene: THREE.Scene;
@ -90,6 +118,105 @@ export class EffectManager {
this.shockwaves.push({ mesh: ring, age: 0, maxAge: 60 });
}
createRainbowBurst(position: THREE.Vector3, baseColor: THREE.Color) {
const count = 120;
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const velocities = new Float32Array(count * 3);
const hueOffsets = new Float32Array(count);
for (let i = 0; i < count; i++) {
positions[i * 3] = position.x;
positions[i * 3 + 1] = position.y;
positions[i * 3 + 2] = position.z;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const speed = 0.2 + Math.random() * 0.6;
velocities[i * 3] = Math.sin(phi) * Math.cos(theta) * speed;
velocities[i * 3 + 1] = Math.sin(phi) * Math.sin(theta) * speed;
velocities[i * 3 + 2] = Math.cos(phi) * speed;
hueOffsets[i] = Math.random();
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('velocity', new THREE.BufferAttribute(velocities, 3));
geo.setAttribute('hueOffset', new THREE.BufferAttribute(hueOffsets, 1));
const mat = new THREE.PointsMaterial({
color: baseColor,
size: 0.8,
transparent: true,
opacity: 1.0,
blending: THREE.AdditiveBlending,
sizeAttenuation: true,
});
const pts = new THREE.Points(geo, mat);
this.scene.add(pts);
this.rainbowBursts.push({
position: position.clone(),
age: 0,
maxAge: 180, // 3 seconds at 60fps
particles: pts,
baseColor: baseColor.clone(),
});
}
createRippleWave(origin: THREE.Vector3) {
this.rippleWaves.push({
origin: origin.clone(),
radius: 0,
speed: 1.2,
age: 0,
maxAge: 90,
pulsedNodes: new Set(),
});
}
createImplosion(position: THREE.Vector3, color: THREE.Color) {
const count = 40;
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const velocities = new Float32Array(count * 3);
// Particles start at random positions in a sphere around the target
const startRadius = 8;
for (let i = 0; i < count; i++) {
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const r = startRadius * (0.5 + Math.random() * 0.5);
positions[i * 3] = position.x + Math.sin(phi) * Math.cos(theta) * r;
positions[i * 3 + 1] = position.y + Math.sin(phi) * Math.sin(theta) * r;
positions[i * 3 + 2] = position.z + Math.cos(phi) * r;
// Velocity points INWARD toward the center
velocities[i * 3] = (position.x - positions[i * 3]) * 0.04;
velocities[i * 3 + 1] = (position.y - positions[i * 3 + 1]) * 0.04;
velocities[i * 3 + 2] = (position.z - positions[i * 3 + 2]) * 0.04;
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('velocity', new THREE.BufferAttribute(velocities, 3));
const mat = new THREE.PointsMaterial({
color,
size: 0.5,
transparent: true,
opacity: 1.0,
blending: THREE.AdditiveBlending,
sizeAttenuation: true,
});
const pts = new THREE.Points(geo, mat);
this.scene.add(pts);
this.implosions.push({
position: position.clone(),
age: 0,
maxAge: 60,
particles: pts,
flash: null,
});
}
createConnectionFlash(from: THREE.Vector3, to: THREE.Vector3, color: THREE.Color) {
const points = [from.clone(), to.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
@ -104,7 +231,11 @@ export class EffectManager {
this.connectionFlashes.push({ line, intensity: 1.0 });
}
update(nodeMeshMap: Map<string, THREE.Mesh>, camera: THREE.Camera) {
update(
nodeMeshMap: Map<string, THREE.Mesh>,
camera: THREE.Camera,
nodePositions?: Map<string, THREE.Vector3>
) {
// Pulse effects
for (let i = this.pulseEffects.length - 1; i >= 0; i--) {
const pulse = this.pulseEffects[i];
@ -121,7 +252,7 @@ export class EffectManager {
}
}
// Spawn bursts
// Spawn bursts (original)
for (let i = this.spawnBursts.length - 1; i >= 0; i--) {
const burst = this.spawnBursts[i];
burst.age++;
@ -148,6 +279,131 @@ export class EffectManager {
mat.size = 0.6 * (1 - burst.age / 200);
}
// Rainbow bursts — HSL cycling, pulsing size, 3-second lifespan
for (let i = this.rainbowBursts.length - 1; i >= 0; i--) {
const rb = this.rainbowBursts[i];
rb.age++;
if (rb.age > rb.maxAge) {
this.scene.remove(rb.particles);
rb.particles.geometry.dispose();
(rb.particles.material as THREE.Material).dispose();
this.rainbowBursts.splice(i, 1);
continue;
}
const positions = rb.particles.geometry.attributes.position as THREE.BufferAttribute;
const vels = rb.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));
vels.setX(j, vels.getX(j) * 0.98);
vels.setY(j, vels.getY(j) * 0.98);
vels.setZ(j, vels.getZ(j) * 0.98);
}
positions.needsUpdate = true;
const progress = rb.age / rb.maxAge;
const mat = rb.particles.material as THREE.PointsMaterial;
// Rainbow HSL cycling blended with base color
const hue = (rb.age * 0.02) % 1;
const rainbowColor = new THREE.Color().setHSL(hue, 1.0, 0.6);
mat.color.copy(rb.baseColor).lerp(rainbowColor, 0.6);
mat.opacity = Math.max(0, 1 - progress * progress);
// Pulsing size
mat.size = 0.8 * (1 - progress * 0.5) * (1 + Math.sin(rb.age * 0.3) * 0.2);
}
// Ripple waves — expanding wavefront, pulse nearby nodes on contact
if (nodePositions) {
for (let i = this.rippleWaves.length - 1; i >= 0; i--) {
const rw = this.rippleWaves[i];
rw.age++;
rw.radius += rw.speed;
if (rw.age > rw.maxAge) {
this.rippleWaves.splice(i, 1);
continue;
}
// Check nodes in range of the expanding wavefront
const waveFront = rw.radius;
const waveWidth = 3.0;
nodePositions.forEach((pos, id) => {
if (rw.pulsedNodes.has(id)) return;
const dist = pos.distanceTo(rw.origin);
if (dist >= waveFront - waveWidth && dist <= waveFront + waveWidth) {
rw.pulsedNodes.add(id);
// Mini-pulse on contact
this.addPulse(id, 0.8, new THREE.Color(0x00ffd1), 0.03);
// Mini scale bump on the mesh
const mesh = nodeMeshMap.get(id);
if (mesh) {
mesh.scale.multiplyScalar(1.3);
}
}
});
}
}
// Implosion effects — particles rush inward, converge, then flash
for (let i = this.implosions.length - 1; i >= 0; i--) {
const imp = this.implosions[i];
imp.age++;
if (imp.age > imp.maxAge + 20) {
this.scene.remove(imp.particles);
imp.particles.geometry.dispose();
(imp.particles.material as THREE.Material).dispose();
if (imp.flash) {
this.scene.remove(imp.flash);
imp.flash.geometry.dispose();
(imp.flash.material as THREE.Material).dispose();
}
this.implosions.splice(i, 1);
continue;
}
if (imp.age <= imp.maxAge) {
const positions = imp.particles.geometry.attributes.position as THREE.BufferAttribute;
const vels = imp.particles.geometry.attributes.velocity as THREE.BufferAttribute;
// Accelerate inward
const accelFactor = 1 + imp.age * 0.02;
for (let j = 0; j < positions.count; j++) {
positions.setX(j, positions.getX(j) + vels.getX(j) * accelFactor);
positions.setY(j, positions.getY(j) + vels.getY(j) * accelFactor);
positions.setZ(j, positions.getZ(j) + vels.getZ(j) * accelFactor);
}
positions.needsUpdate = true;
const mat = imp.particles.material as THREE.PointsMaterial;
mat.opacity = Math.min(1.0, imp.age / 15);
mat.size = 0.5 + (imp.age / imp.maxAge) * 0.3;
}
// Flash at convergence point
if (imp.age === imp.maxAge && !imp.flash) {
const flashGeo = new THREE.SphereGeometry(2, 16, 16);
const flashMat = new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 1.0,
blending: THREE.AdditiveBlending,
});
imp.flash = new THREE.Mesh(flashGeo, flashMat);
imp.flash.position.copy(imp.position);
this.scene.add(imp.flash);
// Hide particles
(imp.particles.material as THREE.PointsMaterial).opacity = 0;
}
// Flash fade out
if (imp.flash && imp.age > imp.maxAge) {
const flashProgress = (imp.age - imp.maxAge) / 20;
(imp.flash.material as THREE.MeshBasicMaterial).opacity = Math.max(0, 1 - flashProgress);
imp.flash.scale.setScalar(1 + flashProgress * 3);
}
}
// Shockwaves
for (let i = this.shockwaves.length - 1; i >= 0; i--) {
const sw = this.shockwaves[i];
@ -186,6 +442,21 @@ export class EffectManager {
burst.particles.geometry.dispose();
(burst.particles.material as THREE.Material).dispose();
}
for (const rb of this.rainbowBursts) {
this.scene.remove(rb.particles);
rb.particles.geometry.dispose();
(rb.particles.material as THREE.Material).dispose();
}
for (const imp of this.implosions) {
this.scene.remove(imp.particles);
imp.particles.geometry.dispose();
(imp.particles.material as THREE.Material).dispose();
if (imp.flash) {
this.scene.remove(imp.flash);
imp.flash.geometry.dispose();
(imp.flash.material as THREE.Material).dispose();
}
}
for (const sw of this.shockwaves) {
this.scene.remove(sw.mesh);
sw.mesh.geometry.dispose();
@ -198,6 +469,9 @@ export class EffectManager {
}
this.pulseEffects = [];
this.spawnBursts = [];
this.rainbowBursts = [];
this.rippleWaves = [];
this.implosions = [];
this.shockwaves = [];
this.connectionFlashes = [];
}

View file

@ -1,41 +1,288 @@
import * as THREE from 'three';
import type { VestigeEvent } from '$types';
import type { VestigeEvent, GraphNode, GraphEdge } from '$types';
import { NODE_TYPE_COLORS } from '$types';
import type { EffectManager } from './effects';
import type { NodeManager } from './nodes';
import type { EdgeManager } from './edges';
import type { ForceSimulation } from './force-sim';
/** Maximum number of live-spawned nodes before FIFO eviction */
const MAX_LIVE_NODES = 50;
export interface GraphMutationContext {
effects: EffectManager;
nodeManager: NodeManager;
edgeManager: EdgeManager;
forceSim: ForceSimulation;
camera: THREE.Camera;
onMutation: (mutation: GraphMutation) => void;
}
export type GraphMutation =
| { type: 'nodeAdded'; node: GraphNode }
| { type: 'nodeRemoved'; nodeId: string }
| { type: 'edgeAdded'; edge: GraphEdge }
| { type: 'edgesRemoved'; nodeId: string }
| { type: 'nodeUpdated'; nodeId: string; retention: number };
/** Track live-spawned node IDs in insertion order for FIFO eviction */
const liveSpawnedNodes: string[] = [];
/** Reset live spawn tracking (for tests) */
export function resetLiveSpawnTracking() {
liveSpawnedNodes.length = 0;
}
function findSpawnPosition(
newNode: { tags?: string[]; type?: string },
existingNodes: GraphNode[],
positions: Map<string, THREE.Vector3>
): THREE.Vector3 {
const tags = newNode.tags ?? [];
const type = newNode.type ?? '';
// Score existing nodes by tag overlap + type match
let bestId: string | null = null;
let bestScore = 0;
for (const existing of existingNodes) {
let score = 0;
if (existing.type === type) score += 2;
for (const tag of existing.tags) {
if (tags.includes(tag)) score += 1;
}
if (score > bestScore) {
bestScore = score;
bestId = existing.id;
}
}
if (bestId && bestScore > 0) {
const nearPos = positions.get(bestId);
if (nearPos) {
// Spawn nearby with some jitter
return new THREE.Vector3(
nearPos.x + (Math.random() - 0.5) * 10,
nearPos.y + (Math.random() - 0.5) * 10,
nearPos.z + (Math.random() - 0.5) * 10
);
}
}
// Fallback: random position in graph space
return new THREE.Vector3(
(Math.random() - 0.5) * 40,
(Math.random() - 0.5) * 40,
(Math.random() - 0.5) * 40
);
}
function evictOldestLiveNode(ctx: GraphMutationContext, allNodes: GraphNode[]) {
if (liveSpawnedNodes.length <= MAX_LIVE_NODES) return;
const evictId = liveSpawnedNodes.shift()!;
ctx.edgeManager.removeEdgesForNode(evictId);
ctx.nodeManager.removeNode(evictId);
ctx.forceSim.removeNode(evictId);
ctx.onMutation({ type: 'edgesRemoved', nodeId: evictId });
ctx.onMutation({ type: 'nodeRemoved', nodeId: evictId });
// Remove from allNodes tracking
const idx = allNodes.findIndex((n) => n.id === evictId);
if (idx !== -1) allNodes.splice(idx, 1);
}
export function mapEventToEffects(
event: VestigeEvent,
effects: EffectManager,
nodePositions: Map<string, THREE.Vector3>,
nodeMeshMap: Map<string, THREE.Mesh>,
camera: THREE.Camera
ctx: GraphMutationContext,
allNodes: GraphNode[]
) {
const { effects, nodeManager, edgeManager, forceSim, camera, onMutation } = ctx;
const nodePositions = nodeManager.positions;
const nodeMeshMap = nodeManager.meshMap;
switch (event.type) {
case 'MemoryCreated': {
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
);
effects.createSpawnBurst(burstPos, new THREE.Color(0x00ffd1));
effects.createShockwave(burstPos, new THREE.Color(0x00ffd1), camera);
const data = event.data as {
id?: string;
content?: string;
node_type?: string;
tags?: string[];
retention?: number;
};
if (!data.id) break;
// Build a GraphNode from event data
const newNode: GraphNode = {
id: data.id,
label: (data.content ?? '').slice(0, 60),
type: data.node_type ?? 'fact',
retention: data.retention ?? 0.9,
tags: data.tags ?? [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isCenter: false,
};
// Find spawn position near related nodes
const spawnPos = findSpawnPosition(newNode, allNodes, nodePositions);
// Add to all managers
const pos = nodeManager.addNode(newNode, spawnPos);
forceSim.addNode(data.id, pos);
// FIFO eviction
liveSpawnedNodes.push(data.id);
evictOldestLiveNode(ctx, allNodes);
// Spectacular effects: rainbow burst + double shockwave + ripple wave
const color = new THREE.Color(NODE_TYPE_COLORS[newNode.type] || '#00ffd1');
effects.createRainbowBurst(spawnPos, color);
effects.createShockwave(spawnPos, color, camera);
// Second shockwave, hue-shifted, delayed via smaller initial scale
const hueShifted = color.clone();
hueShifted.offsetHSL(0.15, 0, 0);
setTimeout(() => {
effects.createShockwave(spawnPos, hueShifted, camera);
}, 166); // ~10 frames at 60fps
effects.createRippleWave(spawnPos);
onMutation({ type: 'nodeAdded', node: newNode });
break;
}
case 'ConnectionDiscovered': {
const data = event.data as {
source_id?: string;
target_id?: string;
weight?: number;
connection_type?: string;
};
if (!data.source_id || !data.target_id) break;
const srcPos = nodePositions.get(data.source_id);
const tgtPos = nodePositions.get(data.target_id);
const newEdge: GraphEdge = {
source: data.source_id,
target: data.target_id,
weight: data.weight ?? 0.5,
type: data.connection_type ?? 'semantic',
};
// Add edge with growth animation
edgeManager.addEdge(newEdge, nodePositions);
// Cyan flash + pulse both endpoints
if (srcPos && tgtPos) {
effects.createConnectionFlash(srcPos, tgtPos, new THREE.Color(0x00d4ff));
}
if (data.source_id && nodeMeshMap.has(data.source_id)) {
effects.addPulse(data.source_id, 1.0, new THREE.Color(0x00d4ff), 0.02);
}
if (data.target_id && nodeMeshMap.has(data.target_id)) {
effects.addPulse(data.target_id, 1.0, new THREE.Color(0x00d4ff), 0.02);
}
onMutation({ type: 'edgeAdded', edge: newEdge });
break;
}
case 'MemoryDeleted': {
const data = event.data as { id?: string };
if (!data.id) break;
const pos = nodePositions.get(data.id);
if (pos) {
// Implosion effect first
const color = new THREE.Color(0xff4757);
effects.createImplosion(pos, color);
}
// Dissolve edges then node
edgeManager.removeEdgesForNode(data.id);
nodeManager.removeNode(data.id);
forceSim.removeNode(data.id);
// Remove from live tracking
const liveIdx = liveSpawnedNodes.indexOf(data.id);
if (liveIdx !== -1) liveSpawnedNodes.splice(liveIdx, 1);
onMutation({ type: 'edgesRemoved', nodeId: data.id });
onMutation({ type: 'nodeRemoved', nodeId: data.id });
break;
}
case 'MemoryPromoted': {
const data = event.data as { id?: string; new_retention?: number };
const promoId = data?.id;
if (!promoId) break;
const newRetention = data.new_retention ?? 0.95;
if (nodeMeshMap.has(promoId)) {
// Grow the node
nodeManager.growNode(promoId, newRetention);
// Green pulse + shockwave + mini burst
effects.addPulse(promoId, 1.2, new THREE.Color(0x00ff88), 0.01);
const promoPos = nodePositions.get(promoId);
if (promoPos) {
effects.createShockwave(promoPos, new THREE.Color(0x00ff88), camera);
effects.createSpawnBurst(promoPos, new THREE.Color(0x00ff88));
}
onMutation({ type: 'nodeUpdated', nodeId: promoId, retention: newRetention });
}
break;
}
case 'MemoryDemoted': {
const data = event.data as { id?: string; new_retention?: number };
const demoteId = data?.id;
if (!demoteId) break;
const newRetention = data.new_retention ?? 0.3;
if (nodeMeshMap.has(demoteId)) {
// Shrink the node
nodeManager.growNode(demoteId, newRetention);
// Red pulse — subtle
effects.addPulse(demoteId, 0.8, new THREE.Color(0xff4757), 0.03);
onMutation({ type: 'nodeUpdated', nodeId: demoteId, retention: newRetention });
}
break;
}
case 'MemoryUpdated': {
const data = event.data as { id?: string; retention?: number };
const updateId = data?.id;
if (!updateId || !nodeMeshMap.has(updateId)) break;
// Subtle blue pulse on update
effects.addPulse(updateId, 0.6, new THREE.Color(0x818cf8), 0.02);
if (data.retention !== undefined) {
nodeManager.growNode(updateId, data.retention);
onMutation({ type: 'nodeUpdated', nodeId: updateId, retention: data.retention });
}
break;
}
case 'SearchPerformed': {
nodeMeshMap.forEach((_, id) => {
effects.addPulse(id, 0.6 + Math.random() * 0.4, new THREE.Color(0x818cf8), 0.02);
});
break;
}
case 'DreamStarted': {
nodeMeshMap.forEach((_, id) => {
effects.addPulse(id, 1.0, new THREE.Color(0xa855f7), 0.005);
});
break;
}
case 'DreamProgress': {
const memoryId = (event.data as { memory_id?: string })?.memory_id;
if (memoryId && nodeMeshMap.has(memoryId)) {
@ -43,20 +290,13 @@ export function mapEventToEffects(
}
break;
}
case 'DreamCompleted': {
effects.createSpawnBurst(new THREE.Vector3(0, 0, 0), new THREE.Color(0xa855f7));
effects.createShockwave(new THREE.Vector3(0, 0, 0), new THREE.Color(0xa855f7), camera);
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) {
effects.createConnectionFlash(srcPos, tgtPos, new THREE.Color(0x00d4ff));
}
break;
}
case 'RetentionDecayed': {
const decayId = (event.data as { id?: string })?.id;
if (decayId && nodeMeshMap.has(decayId)) {
@ -64,21 +304,14 @@ export function mapEventToEffects(
}
break;
}
case 'MemoryPromoted': {
const promoId = (event.data as { id?: string })?.id;
if (promoId && nodeMeshMap.has(promoId)) {
effects.addPulse(promoId, 1.2, new THREE.Color(0x00ff88), 0.01);
const promoPos = nodePositions.get(promoId);
if (promoPos) effects.createShockwave(promoPos, new THREE.Color(0x00ff88), camera);
}
break;
}
case 'ConsolidationCompleted': {
nodeMeshMap.forEach((_, id) => {
effects.addPulse(id, 0.4 + Math.random() * 0.3, new THREE.Color(0xffb800), 0.015);
});
break;
}
case 'ActivationSpread': {
const spreadData = event.data as { source_id?: string; target_ids?: string[] };
if (spreadData.source_id && spreadData.target_ids) {

View file

@ -10,7 +10,9 @@ export class ForceSimulation {
private readonly repulsionStrength = 500;
private readonly attractionStrength = 0.01;
private readonly dampening = 0.9;
private readonly maxSteps = 300;
private readonly baseMaxSteps = 300;
private maxSteps = 300;
private cooldownExtension = 0;
constructor(positions: Map<string, THREE.Vector3>) {
this.positions = positions;
@ -20,8 +22,30 @@ export class ForceSimulation {
}
}
addNode(id: string, position: THREE.Vector3) {
this.positions.set(id, position.clone());
this.velocities.set(id, new THREE.Vector3());
// Re-energize: rewind step count to keep physics alive for 100 more frames
this.cooldownExtension = 100;
this.maxSteps = Math.max(this.maxSteps, this.step + this.cooldownExtension);
this.running = true;
}
removeNode(id: string) {
this.positions.delete(id);
this.velocities.delete(id);
}
tick(edges: GraphEdge[]) {
if (!this.running || this.step > this.maxSteps) return;
if (!this.running) return;
if (this.step > this.maxSteps) {
// Decay cooldown extension, settle back to base
if (this.cooldownExtension > 0) {
this.cooldownExtension = 0;
this.maxSteps = this.baseMaxSteps;
}
return;
}
this.step++;
const alpha = Math.max(0.001, 1 - this.step / this.maxSteps);

View file

@ -2,14 +2,58 @@ import * as THREE from 'three';
import type { GraphNode } from '$types';
import { NODE_TYPE_COLORS } from '$types';
function easeOutElastic(t: number): number {
if (t === 0 || t === 1) return t;
const p = 0.3;
return Math.pow(2, -10 * t) * Math.sin(((t - p / 4) * (2 * Math.PI)) / p) + 1;
}
function easeInBack(t: number): number {
const s = 1.70158;
return t * t * ((s + 1) * t - s);
}
interface MaterializingNode {
id: string;
frame: number;
totalFrames: number;
mesh: THREE.Mesh;
glow: THREE.Sprite;
label: THREE.Sprite;
targetScale: number;
}
interface DissolvingNode {
id: string;
frame: number;
totalFrames: number;
mesh: THREE.Mesh;
glow: THREE.Sprite;
label: THREE.Sprite;
originalScale: number;
}
interface GrowingNode {
id: string;
frame: number;
totalFrames: number;
startScale: number;
targetScale: number;
}
export class NodeManager {
group: THREE.Group;
meshMap = new Map<string, THREE.Mesh>();
glowMap = new Map<string, THREE.Sprite>();
positions = new Map<string, THREE.Vector3>();
labelSprites = new Map<string, THREE.Sprite>();
hoveredNode: string | null = null;
selectedNode: string | null = null;
private materializingNodes: MaterializingNode[] = [];
private dissolvingNodes: DissolvingNode[] = [];
private growingNodes: GrowingNode[] = [];
constructor() {
this.group = new THREE.Group();
}
@ -36,54 +80,129 @@ export class NodeManager {
if (node.isCenter) pos.set(0, 0, 0);
this.positions.set(node.id, pos);
const size = 0.5 + node.retention * 2;
const color = NODE_TYPE_COLORS[node.type] || '#8B95A5';
// Node mesh
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 };
this.meshMap.set(node.id, mesh);
this.group.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 };
this.group.add(sprite);
// Text label sprite
const labelText = node.label || node.type;
const labelSprite = this.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 };
this.group.add(labelSprite);
this.labelSprites.set(node.id, labelSprite);
this.createNodeMeshes(node, pos, 1.0);
}
return this.positions;
}
private createNodeMeshes(node: GraphNode, pos: THREE.Vector3, initialScale: number) {
const size = 0.5 + node.retention * 2;
const color = NODE_TYPE_COLORS[node.type] || '#8B95A5';
// Node mesh
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.scale.setScalar(initialScale);
mesh.userData = { nodeId: node.id, type: node.type, retention: node.retention };
this.meshMap.set(node.id, mesh);
this.group.add(mesh);
// Glow sprite
const spriteMat = new THREE.SpriteMaterial({
color: new THREE.Color(color),
transparent: true,
opacity: initialScale > 0 ? 0.15 + node.retention * 0.2 : 0,
blending: THREE.AdditiveBlending,
});
const sprite = new THREE.Sprite(spriteMat);
sprite.scale.set(size * 4 * initialScale, size * 4 * initialScale, 1);
sprite.position.copy(pos);
sprite.userData = { isGlow: true, nodeId: node.id };
this.glowMap.set(node.id, sprite);
this.group.add(sprite);
// Text label sprite
const labelText = node.label || node.type;
const labelSprite = this.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 };
this.group.add(labelSprite);
this.labelSprites.set(node.id, labelSprite);
return { mesh, glow: sprite, label: labelSprite, size };
}
addNode(node: GraphNode, initialPosition?: THREE.Vector3): THREE.Vector3 {
const pos =
initialPosition?.clone() ??
new THREE.Vector3(
(Math.random() - 0.5) * 40,
(Math.random() - 0.5) * 40,
(Math.random() - 0.5) * 40
);
this.positions.set(node.id, pos);
// Create meshes at scale 0
const { mesh, glow, label } = this.createNodeMeshes(node, pos, 0);
mesh.scale.setScalar(0.001); // Avoid zero-scale issues
glow.scale.set(0.001, 0.001, 1);
(glow.material as THREE.SpriteMaterial).opacity = 0;
(label.material as THREE.SpriteMaterial).opacity = 0;
this.materializingNodes.push({
id: node.id,
frame: 0,
totalFrames: 30,
mesh,
glow,
label,
targetScale: 0.5 + node.retention * 2,
});
return pos;
}
removeNode(id: string) {
const mesh = this.meshMap.get(id);
const glow = this.glowMap.get(id);
const label = this.labelSprites.get(id);
if (!mesh || !glow || !label) return;
// Cancel any active materialization
this.materializingNodes = this.materializingNodes.filter((m) => m.id !== id);
this.dissolvingNodes.push({
id,
frame: 0,
totalFrames: 60,
mesh,
glow,
label,
originalScale: mesh.scale.x,
});
}
growNode(id: string, newRetention: number) {
const mesh = this.meshMap.get(id);
if (!mesh) return;
const currentScale = mesh.scale.x;
const targetScale = 0.5 + newRetention * 2;
mesh.userData.retention = newRetention;
this.growingNodes.push({
id,
frame: 0,
totalFrames: 30,
startScale: currentScale,
targetScale,
});
}
private createTextSprite(text: string, color: string): THREE.Sprite {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
@ -138,8 +257,105 @@ export class NodeManager {
}
animate(time: number, nodes: GraphNode[], camera: THREE.PerspectiveCamera) {
// Node breathing
// Materialization animations — elastic scale-up from 0
for (let i = this.materializingNodes.length - 1; i >= 0; i--) {
const mn = this.materializingNodes[i];
mn.frame++;
const t = Math.min(mn.frame / mn.totalFrames, 1);
const scale = easeOutElastic(t);
// Mesh scales up with elastic spring
mn.mesh.scale.setScalar(Math.max(0.001, scale));
// Glow fades in between frames 5-10
if (mn.frame >= 5) {
const glowT = Math.min((mn.frame - 5) / 5, 1);
const glowMat = mn.glow.material as THREE.SpriteMaterial;
glowMat.opacity = glowT * 0.25;
const glowSize = mn.targetScale * 4 * scale;
mn.glow.scale.set(glowSize, glowSize, 1);
}
// Label fades in after frame 40 (10 frames after mesh finishes)
if (mn.frame >= 40) {
const labelT = Math.min((mn.frame - 40) / 20, 1);
(mn.label.material as THREE.SpriteMaterial).opacity = labelT * 0.9;
}
if (mn.frame >= 60) {
this.materializingNodes.splice(i, 1);
}
}
// Dissolution animations — easeInBack shrink
for (let i = this.dissolvingNodes.length - 1; i >= 0; i--) {
const dn = this.dissolvingNodes[i];
dn.frame++;
const t = Math.min(dn.frame / dn.totalFrames, 1);
const shrink = 1 - easeInBack(t);
const scale = Math.max(0.001, dn.originalScale * shrink);
dn.mesh.scale.setScalar(scale);
const glowScale = scale * 4;
dn.glow.scale.set(glowScale, glowScale, 1);
// Fade opacity
const mat = dn.mesh.material as THREE.MeshStandardMaterial;
mat.opacity *= 0.97;
(dn.glow.material as THREE.SpriteMaterial).opacity *= 0.95;
(dn.label.material as THREE.SpriteMaterial).opacity *= 0.93;
if (dn.frame >= dn.totalFrames) {
// Clean up
this.group.remove(dn.mesh);
this.group.remove(dn.glow);
this.group.remove(dn.label);
dn.mesh.geometry.dispose();
(dn.mesh.material as THREE.Material).dispose();
(dn.glow.material as THREE.SpriteMaterial).map?.dispose();
(dn.glow.material as THREE.Material).dispose();
(dn.label.material as THREE.SpriteMaterial).map?.dispose();
(dn.label.material as THREE.Material).dispose();
this.meshMap.delete(dn.id);
this.glowMap.delete(dn.id);
this.labelSprites.delete(dn.id);
this.positions.delete(dn.id);
this.dissolvingNodes.splice(i, 1);
}
}
// Growth animations — smooth scale transition for promoted nodes
for (let i = this.growingNodes.length - 1; i >= 0; i--) {
const gn = this.growingNodes[i];
gn.frame++;
const t = Math.min(gn.frame / gn.totalFrames, 1);
const scale = gn.startScale + (gn.targetScale - gn.startScale) * easeOutElastic(t);
const mesh = this.meshMap.get(gn.id);
if (mesh) mesh.scale.setScalar(scale);
const glow = this.glowMap.get(gn.id);
if (glow) {
const glowSize = scale * 4;
glow.scale.set(glowSize, glowSize, 1);
}
if (gn.frame >= gn.totalFrames) {
this.growingNodes.splice(i, 1);
}
}
// Node breathing (skip nodes being animated)
const animatingIds = new Set([
...this.materializingNodes.map((m) => m.id),
...this.dissolvingNodes.map((d) => d.id),
...this.growingNodes.map((g) => g.id),
]);
this.meshMap.forEach((mesh, id) => {
if (animatingIds.has(id)) return;
const node = nodes.find((n) => n.id === id);
if (!node) return;
const breathe =
@ -152,7 +368,6 @@ export class NodeManager {
} else if (id === this.selectedNode) {
mat.emissiveIntensity = 0.8;
} else {
// Low-retention nodes breathe slower
const baseIntensity = 0.3 + node.retention * 0.5;
const breatheIntensity =
baseIntensity + Math.sin(time * (0.8 + node.retention * 0.7)) * 0.1 * node.retention;
@ -162,6 +377,7 @@ export class NodeManager {
// Distance-based label visibility
this.labelSprites.forEach((sprite, id) => {
if (animatingIds.has(id)) return;
const pos = this.positions.get(id);
if (!pos) return;
const dist = camera.position.distanceTo(pos);
@ -192,5 +408,8 @@ export class NodeManager {
(obj.material as THREE.Material)?.dispose();
}
});
this.materializingNodes = [];
this.dissolvingNodes = [];
this.growingNodes = [];
}
}