mirror of
https://github.com/samvallad33/vestige.git
synced 2026-04-27 09:46:22 +02:00
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:
parent
816b577f69
commit
9bdcc69ce3
76 changed files with 5915 additions and 332 deletions
310
apps/dashboard/src/lib/graph/__tests__/edges.test.ts
Normal file
310
apps/dashboard/src/lib/graph/__tests__/edges.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
499
apps/dashboard/src/lib/graph/__tests__/effects.test.ts
Normal file
499
apps/dashboard/src/lib/graph/__tests__/effects.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
864
apps/dashboard/src/lib/graph/__tests__/events.test.ts
Normal file
864
apps/dashboard/src/lib/graph/__tests__/events.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
257
apps/dashboard/src/lib/graph/__tests__/force-sim.test.ts
Normal file
257
apps/dashboard/src/lib/graph/__tests__/force-sim.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
50
apps/dashboard/src/lib/graph/__tests__/helpers.ts
Normal file
50
apps/dashboard/src/lib/graph/__tests__/helpers.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
456
apps/dashboard/src/lib/graph/__tests__/nodes.test.ts
Normal file
456
apps/dashboard/src/lib/graph/__tests__/nodes.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
37
apps/dashboard/src/lib/graph/__tests__/setup.ts
Normal file
37
apps/dashboard/src/lib/graph/__tests__/setup.ts
Normal 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 {};
|
||||
}),
|
||||
};
|
||||
}
|
||||
438
apps/dashboard/src/lib/graph/__tests__/three-mock.ts
Normal file
438
apps/dashboard/src/lib/graph/__tests__/three-mock.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue