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

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

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

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

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

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

3
.gitignore vendored
View file

@ -60,6 +60,9 @@ pnpm-debug.log*
coverage/
.nyc_output/
*.lcov
apps/dashboard/test-results/
apps/dashboard/playwright-report/
apps/dashboard/e2e/screenshots/
# =============================================================================
# IDEs and Editors

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{l as o,a as r}from"../chunks/gFolWfSi.js";export{o as load_css,r as start};

View file

@ -0,0 +1,2 @@
)€import{l as o,a as r}from"../chunks/gFolWfSi.js";export{o as load_css,r as start};


View file

@ -1 +0,0 @@
import{l as o,a as r}from"../chunks/C9fAJV5Y.js";export{o as load_css,r as start};

View file

@ -1,2 +0,0 @@
)€import{l as o,a as r}from"../chunks/C9fAJV5Y.js";export{o as load_css,r as start};


View file

@ -1 +0,0 @@
f`Œ”.o­ÙtÏ2%E[r¯W@~ Š”¿¿•w€<јÂo]õ­ã8©´Ôšl÷snm=Ý _G^ëõúž¸VÔ?,knœ_÷Ìh<C38C>w•Ñ¢¶ÞÈ=²"þ^…Áüzd>ÖH… S 6 <09> ¤@Dá¥'q´üœ¸¹þHD—~Cß/ƒ7Åú$+ßþfc‹š¿^ž?Xäß8Ê0ÔƒË×é‡ò|û°-òJSâoUVÊ<56>ãô0vCDɪ§.¡ª¦aV/TÌž{e¡¾ÁEqù ߀µšïìô<C3AC>áO¹Ñ¬Ñ<C2AC>9ñû!¨äb¥<62>K<EFBFBD><>Äü<04>B.CŒ$ñ ˜!ƒ+ð?œ7¢„H  €|·â™*Ì }_<>|Hƒ±W†£ÁJé…Ðgl<67>rP)FJ%7,q¨ænX

View file

@ -1 +1 @@
import"../chunks/Bzak7iHL.js";import{i as h}from"../chunks/_Va07L2l.js";import{p as g,f as d,t as l,a as v,d as _,e as s,r as o}from"../chunks/C9Z4nxhR.js";import{s as p}from"../chunks/DP9qWekZ.js";import{a as x,f as $}from"../chunks/DPfxVJHQ.js";import{s as k,p as m}from"../chunks/C9fAJV5Y.js";const b={get error(){return m.error},get status(){return m.status}};k.updated.check;const i=b;var E=$("<h1> </h1> <p> </p>",1);function B(f,n){g(n,!1),h();var t=E(),r=d(t),c=s(r,!0);o(r);var a=_(r,2),u=s(a,!0);o(a),l(()=>{var e;p(c,i.status),p(u,(e=i.error)==null?void 0:e.message)}),x(f,t),v()}export{B as component};
import"../chunks/Bzak7iHL.js";import{i as h}from"../chunks/_Va07L2l.js";import{p as g,f as d,t as l,a as v,d as _,e as s,r as o}from"../chunks/C9Z4nxhR.js";import{s as p}from"../chunks/DP9qWekZ.js";import{a as x,f as $}from"../chunks/DPfxVJHQ.js";import{s as k,p as m}from"../chunks/gFolWfSi.js";const b={get error(){return m.error},get status(){return m.status}};k.updated.check;const i=b;var E=$("<h1> </h1> <p> </p>",1);function B(f,n){g(n,!1),h();var t=E(),r=d(t),c=s(r,!0);o(r);var a=_(r,2),u=s(a,!0);o(a),l(()=>{var e;p(c,i.status),p(u,(e=i.error)==null?void 0:e.message)}),x(f,t),v()}export{B as component};

View file

@ -1 +1 @@
import"../chunks/Bzak7iHL.js";import{i as p}from"../chunks/_Va07L2l.js";import{o as r}from"../chunks/CkyfbJUz.js";import{p as t,a}from"../chunks/C9Z4nxhR.js";import{g as m}from"../chunks/C9fAJV5Y.js";function g(i,o){t(o,!1),r(()=>m("/graph",{replaceState:!0})),p(),a()}export{g as component};
import"../chunks/Bzak7iHL.js";import{i as p}from"../chunks/_Va07L2l.js";import{o as r}from"../chunks/CkyfbJUz.js";import{p as t,a}from"../chunks/C9Z4nxhR.js";import{g as m}from"../chunks/gFolWfSi.js";function g(i,o){t(o,!1),r(()=>m("/graph",{replaceState:!0})),p(),a()}export{g as component};

View file

@ -0,0 +1 @@
$ ei[<5B>àÒ†û$3³Ùù¡Y×x[$Qà+ÿùÝÆpyžRÑŠ¼w×D¥éê~Ø^¥äg¼µÖù²¸¹Ohß5=¾Ò§ÄiR†<52>±l•ÕŽìEImßI±þÁé|ãÇ”œŽ~x­*X®{<7B>¸k.ªë¨ï} kþˆßyFAþ€A¸"}t/·žCý¹ŒüGäO€üð?eŠyÈ¿G

View file

@ -1 +1 @@
{"version":"1772420685161"}
{"version":"1772567999839"}

View file

@ -11,12 +11,12 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" />
<link href="/dashboard/_app/immutable/entry/start.DK-jGlmm.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/C9fAJV5Y.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/entry/start.DFI9HUYp.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/gFolWfSi.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/C9Z4nxhR.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/DnKV7_Y9.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/CkyfbJUz.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/entry/app.D66lMUMV.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/entry/app.DfAGudnT.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/DP9qWekZ.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/DPfxVJHQ.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/Bzak7iHL.js" rel="modulepreload">
@ -32,7 +32,7 @@
<div style="display: contents">
<script>
{
__sveltekit_1xpb8v1 = {
__sveltekit_lu49g9 = {
base: "/dashboard",
assets: "/dashboard"
};
@ -40,8 +40,8 @@
const element = document.currentScript.parentElement;
Promise.all([
import("/dashboard/_app/immutable/entry/start.DK-jGlmm.js"),
import("/dashboard/_app/immutable/entry/app.D66lMUMV.js")
import("/dashboard/_app/immutable/entry/start.DFI9HUYp.js"),
import("/dashboard/_app/immutable/entry/app.DfAGudnT.js")
]).then(([kit, app]) => {
kit.start(app, element);
});

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,413 @@
import { test, expect, type Page } from '@playwright/test';
import { readFileSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
const API = 'http://127.0.0.1:3927';
const MCP = 'http://127.0.0.1:3928/mcp';
const GRAPH_URL = '/dashboard/graph';
// ─────────────────────────────────────────────────
// MCP CLIENT — for creating memories
// ─────────────────────────────────────────────────
let mcpSessionId: string | null = null;
let authToken: string | null = null;
function getAuthToken(): string {
if (authToken) return authToken;
const tokenPath = join(homedir(), 'Library', 'Application Support', 'com.vestige.core', 'auth_token');
authToken = readFileSync(tokenPath, 'utf-8').trim();
return authToken;
}
async function initMcpSession(): Promise<string> {
if (mcpSessionId) return mcpSessionId;
const token = getAuthToken();
// Initialize
const initRes = await fetch(MCP, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'e2e-playwright', version: '1.0.0' },
},
}),
});
mcpSessionId = initRes.headers.get('mcp-session-id')!;
// Send initialized notification
await fetch(MCP, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Mcp-Session-Id': mcpSessionId,
},
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
});
return mcpSessionId;
}
let mcpCallId = 10;
async function mcpCall(toolName: string, args: Record<string, unknown>): Promise<unknown> {
const sessionId = await initMcpSession();
const token = getAuthToken();
const id = mcpCallId++;
const res = await fetch(MCP, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Mcp-Session-Id': sessionId,
},
body: JSON.stringify({
jsonrpc: '2.0', id, method: 'tools/call',
params: { name: toolName, arguments: args },
}),
});
const data = await res.json() as { result?: { content?: Array<{ text: string }> }; error?: unknown };
if (data.error) throw new Error(`MCP error: ${JSON.stringify(data.error)}`);
const text = data.result?.content?.[0]?.text;
return text ? JSON.parse(text) : data.result;
}
// ─────────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────────
async function waitForGraphReady(page: Page) {
await page.waitForSelector('canvas', { timeout: 15_000 });
await page.waitForTimeout(2000);
}
async function createMemory(content: string, tags: string[] = [], nodeType = 'fact') {
const result = await mcpCall('smart_ingest', { content, tags, node_type: nodeType }) as {
nodeId?: string; success?: boolean;
};
return { id: result.nodeId!, success: result.success };
}
async function searchMemory(query: string) {
const res = await fetch(`${API}/api/search?q=${encodeURIComponent(query)}&limit=5`);
return res.json();
}
async function promoteMemory(id: string) {
const res = await fetch(`${API}/api/memories/${id}/promote`, { method: 'POST' });
return res.json();
}
async function deleteMemory(id: string) {
const res = await fetch(`${API}/api/memories/${id}`, { method: 'DELETE' });
return res.ok;
}
async function triggerDream() {
const res = await fetch(`${API}/api/dream`, { method: 'POST' });
return res.json();
}
// ─────────────────────────────────────────────────
// TESTS
// ─────────────────────────────────────────────────
test.describe('Live Memory Materialization — Visual Proof', () => {
test.describe.configure({ mode: 'serial' });
let createdMemoryId: string;
let secondMemoryId: string;
test('1. Graph page loads with existing nodes', async ({ page }) => {
await page.goto(GRAPH_URL);
await waitForGraphReady(page);
const canvas = page.locator('canvas');
await expect(canvas).toBeVisible();
const stats = page.locator('.absolute.bottom-4.left-4');
await expect(stats).toContainText('nodes');
await expect(stats).toContainText('edges');
await page.screenshot({
path: 'e2e/screenshots/01-graph-loaded.png',
fullPage: true,
});
});
test('2. Memory materializes with rainbow burst when created', async ({ page }) => {
await page.goto(GRAPH_URL);
await waitForGraphReady(page);
const statsBefore = await page.locator('.absolute.bottom-4.left-4').textContent();
const nodeCountBefore = parseInt(statsBefore?.match(/(\d+) nodes/)?.[1] ?? '0');
await page.screenshot({
path: 'e2e/screenshots/02a-before-creation.png',
fullPage: true,
});
// Create a memory via MCP — fires WebSocket MemoryCreated event
const result = await createMemory(
'E2E TEST: Rust ownership model prevents data races at compile time',
['rust', 'memory-safety', 'e2e-test'],
'fact'
);
createdMemoryId = result.id;
// Wait for materialization animation (rainbow burst + elastic scale-up)
await page.waitForTimeout(3000);
await page.screenshot({
path: 'e2e/screenshots/02b-after-creation-materialized.png',
fullPage: true,
});
// Verify node count increased
const statsAfter = await page.locator('.absolute.bottom-4.left-4').textContent();
const nodeCountAfter = parseInt(statsAfter?.match(/(\d+) nodes/)?.[1] ?? '0');
expect(nodeCountAfter).toBeGreaterThanOrEqual(nodeCountBefore);
});
test('3. Second memory materializes and spawns near related node', async ({ page }) => {
await page.goto(GRAPH_URL);
await waitForGraphReady(page);
const result = await createMemory(
'E2E TEST: Rust lifetimes ensure references are always valid',
['rust', 'lifetimes', 'e2e-test'],
'fact'
);
secondMemoryId = result.id;
await page.waitForTimeout(3000);
await page.screenshot({
path: 'e2e/screenshots/03-second-node-spawned.png',
fullPage: true,
});
});
test('4. Search triggers pulse effect across all nodes', async ({ page }) => {
await page.goto(GRAPH_URL);
await waitForGraphReady(page);
await page.screenshot({
path: 'e2e/screenshots/04a-before-search.png',
fullPage: true,
});
// Trigger search — fires SearchPerformed WebSocket event
await searchMemory('rust ownership');
await page.waitForTimeout(1500);
await page.screenshot({
path: 'e2e/screenshots/04b-search-pulse.png',
fullPage: true,
});
});
test('5. Memory promotion triggers green glow + node growth', async ({ page }) => {
test.skip(!createdMemoryId, 'No memory to promote');
await page.goto(GRAPH_URL);
await waitForGraphReady(page);
await page.screenshot({
path: 'e2e/screenshots/05a-before-promotion.png',
fullPage: true,
});
await promoteMemory(createdMemoryId);
await page.waitForTimeout(2500);
await page.screenshot({
path: 'e2e/screenshots/05b-after-promotion-green-glow.png',
fullPage: true,
});
});
test('6. Dream cycle triggers purple effects and connection discoveries', async ({ page }) => {
await page.goto(GRAPH_URL);
await waitForGraphReady(page);
await page.screenshot({
path: 'e2e/screenshots/06a-before-dream.png',
fullPage: true,
});
// Trigger dream — fires DreamStarted, DreamProgress, DreamCompleted,
// and ConnectionDiscovered events
await triggerDream();
// Wait for dream effects (purple pulses cascade, connections appear)
await page.waitForTimeout(5000);
await page.screenshot({
path: 'e2e/screenshots/06b-after-dream-connections.png',
fullPage: true,
});
});
test('7. Memory deletion triggers implosion effect', async ({ page }) => {
test.skip(!secondMemoryId, 'No memory to delete');
await page.goto(GRAPH_URL);
await waitForGraphReady(page);
await page.screenshot({
path: 'e2e/screenshots/07a-before-deletion.png',
fullPage: true,
});
await deleteMemory(secondMemoryId);
await page.waitForTimeout(2500);
await page.screenshot({
path: 'e2e/screenshots/07b-after-deletion-implosion.png',
fullPage: true,
});
});
test('8. Rapid-fire creation: 5 memories spawn smoothly', async ({ page }) => {
await page.goto(GRAPH_URL);
await waitForGraphReady(page);
const statsBefore = await page.locator('.absolute.bottom-4.left-4').textContent();
const nodeCountBefore = parseInt(statsBefore?.match(/(\d+) nodes/)?.[1] ?? '0');
await page.screenshot({
path: 'e2e/screenshots/08a-before-rapid-fire.png',
fullPage: true,
});
const rapidIds: string[] = [];
for (let i = 0; i < 5; i++) {
const result = await createMemory(
`E2E RAPID ${i}: Testing live materialization performance #${i}`,
['e2e-rapid', 'performance'],
i % 2 === 0 ? 'fact' : 'concept'
);
rapidIds.push(result.id);
await page.waitForTimeout(500);
}
// Wait for all animations to complete
await page.waitForTimeout(4000);
await page.screenshot({
path: 'e2e/screenshots/08b-after-rapid-fire-5-nodes.png',
fullPage: true,
});
const statsAfter = await page.locator('.absolute.bottom-4.left-4').textContent();
const nodeCountAfter = parseInt(statsAfter?.match(/(\d+) nodes/)?.[1] ?? '0');
expect(nodeCountAfter).toBeGreaterThanOrEqual(nodeCountBefore);
// Cleanup
for (const id of rapidIds) {
await deleteMemory(id);
}
await page.waitForTimeout(2000);
await page.screenshot({
path: 'e2e/screenshots/08c-after-rapid-fire-cleanup.png',
fullPage: true,
});
});
test('9. Node selection works on live-spawned nodes', async ({ page }) => {
await page.goto(GRAPH_URL);
await waitForGraphReady(page);
const result = await createMemory(
'E2E TEST: Interactive node selection verification',
['e2e-test', 'interaction'],
'note'
);
await page.waitForTimeout(3000);
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
await page.waitForTimeout(500);
}
await page.screenshot({
path: 'e2e/screenshots/09-node-interaction.png',
fullPage: true,
});
await deleteMemory(result.id);
});
test('10. Stats bar updates live during mutations', async ({ page }) => {
await page.goto(GRAPH_URL);
await waitForGraphReady(page);
const initialStats = await page.locator('.absolute.bottom-4.left-4').textContent();
const initialNodes = parseInt(initialStats?.match(/(\d+) nodes/)?.[1] ?? '0');
const result = await createMemory(
'E2E TEST: Stats bar live update verification',
['e2e-test'],
'fact'
);
await page.waitForTimeout(2000);
const afterCreate = await page.locator('.absolute.bottom-4.left-4').textContent();
const afterNodes = parseInt(afterCreate?.match(/(\d+) nodes/)?.[1] ?? '0');
expect(afterNodes).toBeGreaterThanOrEqual(initialNodes);
await page.screenshot({
path: 'e2e/screenshots/10-live-stats-update.png',
fullPage: true,
});
await deleteMemory(result.id);
});
test('11. Cleanup: remove e2e test memories', async ({ page }) => {
if (createdMemoryId) {
await deleteMemory(createdMemoryId);
}
// Search for remaining e2e test memories and clean them up
const results = await searchMemory('E2E TEST');
if (results.results) {
for (const r of results.results) {
if (r.content?.includes('E2E TEST') || r.content?.includes('E2E RAPID')) {
await deleteMemory(r.id);
}
}
}
await page.goto(GRAPH_URL);
await waitForGraphReady(page);
await page.screenshot({
path: 'e2e/screenshots/11-final-clean-state.png',
fullPage: true,
});
});
});

1252
apps/dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,27 +1,32 @@
{
"name": "@vestige/dashboard",
"version": "2.0.0",
"version": "2.0.3",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"three": "^0.172.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.20.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/three": "^0.172.0",
"@vitest/coverage-v8": "^4.0.18",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
"vite": "^6.0.0",
"vitest": "^4.0.18"
}
}

View file

@ -0,0 +1,27 @@
import { defineConfig } from '@playwright/test';
const PORT = 5199;
export default defineConfig({
testDir: './e2e',
timeout: 60_000,
expect: { timeout: 10_000 },
fullyParallel: false,
retries: 0,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: `http://localhost:${PORT}`,
screenshot: 'on',
video: 'retain-on-failure',
trace: 'retain-on-failure',
launchOptions: {
args: ['--use-gl=angle', '--ignore-gpu-blocklist'],
},
},
webServer: {
command: `npx vite dev --port ${PORT}`,
port: PORT,
reuseExistingServer: true,
timeout: 30_000,
},
});

View file

@ -8,7 +8,7 @@
import { ParticleSystem } from '$lib/graph/particles';
import { EffectManager } from '$lib/graph/effects';
import { DreamMode } from '$lib/graph/dream-mode';
import { mapEventToEffects } from '$lib/graph/events';
import { mapEventToEffects, type GraphMutationContext, type GraphMutation } from '$lib/graph/events';
import { createNebulaBackground, updateNebula } from '$lib/graph/shaders/nebula.frag';
import { createPostProcessing, updatePostProcessing, type PostProcessingStack } from '$lib/graph/shaders/post-processing';
import type * as THREE from 'three';
@ -20,9 +20,10 @@
events?: VestigeEvent[];
isDreaming?: boolean;
onSelect?: (nodeId: string) => void;
onGraphMutation?: (mutation: GraphMutation) => void;
}
let { nodes, edges, centerId, events = [], isDreaming = false, onSelect }: Props = $props();
let { nodes, edges, centerId, events = [], isDreaming = false, onSelect, onGraphMutation }: Props = $props();
let container: HTMLDivElement;
let ctx: SceneContext;
@ -41,6 +42,9 @@
// Event tracking
let processedEventCount = 0;
// Internal tracking: initial nodes + live-added nodes
let allNodes: GraphNode[] = [];
onMount(() => {
ctx = createScene(container);
@ -63,6 +67,9 @@
edgeManager.createEdges(edges, positions);
forceSim = new ForceSimulation(positions);
// Track all nodes (initial set)
allNodes = [...nodes];
ctx.scene.add(edgeManager.group);
ctx.scene.add(nodeManager.group);
@ -96,9 +103,12 @@
nodeManager.updatePositions();
edgeManager.updatePositions(nodeManager.positions);
// Animate edge growth/dissolution
edgeManager.animateEdges(nodeManager.positions);
// Animate
particles.animate(time);
nodeManager.animate(time, nodes, ctx.camera);
nodeManager.animate(time, allNodes, ctx.camera);
// Dream mode
dreamMode.setActive(isDreaming);
@ -116,7 +126,7 @@
// Events + effects
processEvents();
effects.update(nodeManager.meshMap, ctx.camera);
effects.update(nodeManager.meshMap, ctx.camera, nodeManager.positions);
ctx.controls.update();
ctx.composer.render();
@ -128,8 +138,26 @@
const newEvents = events.slice(processedEventCount);
processedEventCount = events.length;
const mutationCtx: GraphMutationContext = {
effects,
nodeManager,
edgeManager,
forceSim,
camera: ctx.camera,
onMutation: (mutation: GraphMutation) => {
// Update internal allNodes tracking
if (mutation.type === 'nodeAdded') {
allNodes = [...allNodes, mutation.node];
} else if (mutation.type === 'nodeRemoved') {
allNodes = allNodes.filter((n) => n.id !== mutation.nodeId);
}
// Notify parent
onGraphMutation?.(mutation);
},
};
for (const event of newEvents) {
mapEventToEffects(event, effects, nodeManager.positions, nodeManager.meshMap, ctx.camera);
mapEventToEffects(event, mutationCtx, allNodes);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@
import { api } from '$stores/api';
import { eventFeed } from '$stores/websocket';
import type { GraphResponse, GraphNode, GraphEdge, Memory } from '$types';
import type { GraphMutation } from '$lib/graph/events';
import { filterByDate } from '$lib/graph/temporal';
let graphData: GraphResponse | null = $state(null);
@ -18,6 +19,10 @@
let temporalEnabled = $state(false);
let temporalDate = $state(new Date());
// Live counts that update on mutations
let liveNodeCount = $state(0);
let liveEdgeCount = $state(0);
// Filtered graph data based on temporal mode
let displayNodes = $derived.by((): GraphNode[] => {
if (!graphData) return [];
@ -31,6 +36,40 @@
return filterByDate(graphData.nodes, graphData.edges, temporalDate).visibleEdges;
});
function handleGraphMutation(mutation: GraphMutation) {
if (!graphData) return;
switch (mutation.type) {
case 'nodeAdded':
graphData.nodes = [...graphData.nodes, mutation.node];
graphData.nodeCount = graphData.nodes.length;
liveNodeCount = graphData.nodeCount;
break;
case 'nodeRemoved':
graphData.nodes = graphData.nodes.filter((n) => n.id !== mutation.nodeId);
graphData.nodeCount = graphData.nodes.length;
liveNodeCount = graphData.nodeCount;
break;
case 'edgeAdded':
graphData.edges = [...graphData.edges, mutation.edge];
graphData.edgeCount = graphData.edges.length;
liveEdgeCount = graphData.edgeCount;
break;
case 'edgesRemoved':
graphData.edges = graphData.edges.filter(
(e) => e.source !== mutation.nodeId && e.target !== mutation.nodeId
);
graphData.edgeCount = graphData.edges.length;
liveEdgeCount = graphData.edgeCount;
break;
case 'nodeUpdated': {
const node = graphData.nodes.find((n) => n.id === mutation.nodeId);
if (node) node.retention = mutation.retention;
break;
}
}
}
onMount(() => loadGraph());
async function loadGraph(query?: string, centerId?: string) {
@ -43,6 +82,10 @@
query: query || undefined,
center_id: centerId || undefined
});
if (graphData) {
liveNodeCount = graphData.nodeCount;
liveEdgeCount = graphData.edgeCount;
}
} catch {
error = 'No memories yet. Start using Vestige to populate your graph.';
} finally {
@ -96,6 +139,7 @@
events={$eventFeed}
{isDreaming}
onSelect={onNodeSelect}
onGraphMutation={handleGraphMutation}
/>
{/if}
@ -149,9 +193,9 @@
<!-- Bottom stats -->
<div class="absolute bottom-4 left-4 z-10 text-xs text-dim glass rounded-xl px-3 py-2">
{#if graphData}
<span>{displayNodes.length} nodes</span>
<span>{liveNodeCount} nodes</span>
<span class="mx-2 text-subtle">·</span>
<span>{displayEdges.length} edges</span>
<span>{liveEdgeCount} edges</span>
<span class="mx-2 text-subtle">·</span>
<span>depth {graphData.depth}</span>
{/if}

View file

@ -148,8 +148,8 @@
</nav>
<!-- Main content -->
<main class="flex-1 overflow-y-auto pb-16 md:pb-0">
<div class="animate-page-in">
<main class="flex-1 flex flex-col min-h-0 pb-16 md:pb-0">
<div class="animate-page-in flex-1 min-h-0 overflow-y-auto">
{@render children()}
</div>
</main>

View file

@ -10,5 +10,6 @@
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
},
"exclude": ["src/**/__tests__/**"]
}

View file

@ -1,3 +1,4 @@
/// <reference types="vitest/config" />
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
@ -16,5 +17,16 @@ export default defineConfig({
ws: true
}
}
}
},
test: {
include: ['src/**/*.test.ts'],
environment: 'node',
setupFiles: ['src/lib/graph/__tests__/setup.ts'],
alias: {
$lib: new URL('./src/lib', import.meta.url).pathname,
$components: new URL('./src/lib/components', import.meta.url).pathname,
$stores: new URL('./src/lib/stores', import.meta.url).pathname,
$types: new URL('./src/lib/types', import.meta.url).pathname,
},
},
});

View file

@ -1,6 +1,6 @@
[package]
name = "vestige-core"
version = "2.0.2"
version = "2.0.3"
edition = "2024"
rust-version = "1.91"
authors = ["Vestige Team"]

View file

@ -3532,6 +3532,21 @@ impl Storage {
Ok(result)
}
/// Get the memory with the most connections (best center node for graph visualization)
pub fn get_most_connected_memory(&self) -> Result<Option<String>> {
let reader = self.reader.lock()
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
let mut stmt = reader.prepare(
"SELECT id, COUNT(*) as cnt FROM (
SELECT source_id as id FROM memory_connections
UNION ALL
SELECT target_id as id FROM memory_connections
) GROUP BY id ORDER BY cnt DESC LIMIT 1"
)?;
let result = stmt.query_row([], |row| row.get::<_, String>(0)).optional()?;
Ok(result)
}
/// Get memories with their connection data for graph visualization
pub fn get_memory_subgraph(&self, center_id: &str, depth: u32, max_nodes: usize) -> Result<(Vec<KnowledgeNode>, Vec<ConnectionRecord>)> {
let mut visited_ids: std::collections::HashSet<String> = std::collections::HashSet::new();

View file

@ -1,6 +1,6 @@
[package]
name = "vestige-mcp"
version = "2.0.2"
version = "2.0.3"
edition = "2024"
description = "Cognitive memory MCP server for Claude - FSRS-6, spreading activation, synaptic tagging, 3D dashboard, and 130 years of memory research"
authors = ["samvallad33"]
@ -32,7 +32,7 @@ path = "src/bin/cli.rs"
# ============================================================================
# Includes: FSRS-6, spreading activation, synaptic tagging, hippocampal indexing,
# memory states, context memory, importance signals, dreams, and more
vestige-core = { version = "2.0.2", path = "../vestige-core", default-features = false, features = ["bundled-sqlite"] }
vestige-core = { version = "2.0.3", path = "../vestige-core", default-features = false, features = ["bundled-sqlite"] }
# ============================================================================
# MCP Server Dependencies

View file

@ -4,7 +4,7 @@
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{Html, Json};
use axum::response::{Json, Redirect};
use chrono::{Duration, Utc};
use serde::Deserialize;
use serde_json::Value;
@ -12,9 +12,9 @@ use serde_json::Value;
use super::events::VestigeEvent;
use super::state::AppState;
/// Serve the dashboard HTML
pub async fn serve_dashboard() -> Html<&'static str> {
Html(include_str!("../dashboard.html"))
/// Redirect root to the SvelteKit dashboard
pub async fn serve_dashboard() -> Redirect {
Redirect::permanent("/dashboard")
}
#[derive(Debug, Deserialize)]
@ -328,9 +328,9 @@ pub async fn health_check(
// MEMORY GRAPH
// ============================================================================
/// Serve the memory graph visualization HTML
pub async fn serve_graph() -> Html<&'static str> {
Html(include_str!("../graph.html"))
/// Redirect legacy graph to SvelteKit dashboard graph page
pub async fn serve_graph() -> Redirect {
Redirect::permanent("/dashboard/graph")
}
#[derive(Debug, Deserialize)]
@ -360,13 +360,21 @@ pub async fn get_graph(
.map(|n| n.id.clone())
.ok_or(StatusCode::NOT_FOUND)?
} else {
// Default: most recent memory
let recent = state.storage
.get_all_nodes(1, 0)
// Default: most connected memory (for a rich initial graph)
let most_connected = state.storage
.get_most_connected_memory()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
recent.first()
.map(|n| n.id.clone())
.ok_or(StatusCode::NOT_FOUND)?
if let Some(id) = most_connected {
id
} else {
// Fallback: most recent memory
let recent = state.storage
.get_all_nodes(1, 0)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
recent.first()
.map(|n| n.id.clone())
.ok_or(StatusCode::NOT_FOUND)?
}
};
// Get subgraph