mirror of
https://github.com/samvallad33/vestige.git
synced 2026-04-25 00:36:22 +02:00
feat: live memory materialization — nodes spawn in 3D graph in real-time
When memories are created, promoted, deleted, or dreamed via MCP tools, the 3D graph now shows spectacular live animations: - Rainbow particle burst + elastic scale-up on MemoryCreated - Ripple wave cascading to nearby nodes - Green pulse + node growth on MemoryPromoted - Implosion + dissolution on MemoryDeleted - Edge growth animation on ConnectionDiscovered - Purple cascade on DreamStarted/DreamProgress/DreamCompleted - FIFO eviction at 50 live nodes to guard performance Also: graph center defaults to most-connected node, legacy HTML redirects to SvelteKit dashboard, CSS height chain fix in layout. Testing: 150 unit tests (vitest), 11 e2e tests (Playwright with MCP Streamable HTTP client), 22 proof screenshots. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
816b577f69
commit
9bdcc69ce3
76 changed files with 5915 additions and 332 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
apps/dashboard/build/_app/immutable/assets/0.CJ5F-1SC.css.br
Normal file
BIN
apps/dashboard/build/_app/immutable/assets/0.CJ5F-1SC.css.br
Normal file
Binary file not shown.
BIN
apps/dashboard/build/_app/immutable/assets/0.CJ5F-1SC.css.gz
Normal file
BIN
apps/dashboard/build/_app/immutable/assets/0.CJ5F-1SC.css.gz
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
1
apps/dashboard/build/_app/immutable/chunks/gFolWfSi.js
Normal file
1
apps/dashboard/build/_app/immutable/chunks/gFolWfSi.js
Normal file
File diff suppressed because one or more lines are too long
BIN
apps/dashboard/build/_app/immutable/chunks/gFolWfSi.js.br
Normal file
BIN
apps/dashboard/build/_app/immutable/chunks/gFolWfSi.js.br
Normal file
Binary file not shown.
BIN
apps/dashboard/build/_app/immutable/chunks/gFolWfSi.js.gz
Normal file
BIN
apps/dashboard/build/_app/immutable/chunks/gFolWfSi.js.gz
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
apps/dashboard/build/_app/immutable/entry/app.DfAGudnT.js.br
Normal file
BIN
apps/dashboard/build/_app/immutable/entry/app.DfAGudnT.js.br
Normal file
Binary file not shown.
BIN
apps/dashboard/build/_app/immutable/entry/app.DfAGudnT.js.gz
Normal file
BIN
apps/dashboard/build/_app/immutable/entry/app.DfAGudnT.js.gz
Normal file
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
import{l as o,a as r}from"../chunks/gFolWfSi.js";export{o as load_css,r as start};
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
)€import{l as o,a as r}from"../chunks/gFolWfSi.js";export{o as load_css,r as start};
|
||||
|
||||
BIN
apps/dashboard/build/_app/immutable/entry/start.DFI9HUYp.js.gz
Normal file
BIN
apps/dashboard/build/_app/immutable/entry/start.DFI9HUYp.js.gz
Normal file
Binary file not shown.
|
|
@ -1 +0,0 @@
|
|||
import{l as o,a as r}from"../chunks/C9fAJV5Y.js";export{o as load_css,r as start};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
)€import{l as o,a as r}from"../chunks/C9fAJV5Y.js";export{o as load_css,r as start};
|
||||
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
apps/dashboard/build/_app/immutable/nodes/0.DexH1k-p.js.br
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/0.DexH1k-p.js.br
Normal file
Binary file not shown.
BIN
apps/dashboard/build/_app/immutable/nodes/0.DexH1k-p.js.gz
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/0.DexH1k-p.js.gz
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1 +0,0 @@
|
|||
f`Œ”.oÙtÏ2%E[r¯W@~ Š”¿¿•w€<јÂo]õã8©´Ôšl÷snm=Ý’ _G’^ëõú’ž¸VÔ?,knœ_÷Ìh<C38C>‘w•Ñ¢¶ÞÈ=²"þ^…Áüzd>ÖH… S6 <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Œ$ñBê˜!ƒ+ð?œ7¢„H’
€|·â™*̉}_<>|Hnœ#烱W†£ÁJé…Ðgl<67>rP)FJ%7,q¨ænX
|
||||
Binary file not shown.
|
|
@ -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};
|
||||
BIN
apps/dashboard/build/_app/immutable/nodes/1.F9_eX3ZM.js.br
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/1.F9_eX3ZM.js.br
Normal file
Binary file not shown.
BIN
apps/dashboard/build/_app/immutable/nodes/1.F9_eX3ZM.js.gz
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/1.F9_eX3ZM.js.gz
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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};
|
||||
|
|
@ -0,0 +1 @@
|
|||
$ ei[<5B>’àÒ†û›$3³Ùù¡–Y×x[$Qà+ÿùÝÆpyžRÑŠ¼w×D¥éê~Ø^¥äg¼µÖù²¸¹Ohß5=¾Ò§ÄiR†<52>±l•ÕŽìEImß‹I±þÁé|ãÇ”œŽ~x*X®{<7B>eö¸k.ªë¨ï} kþˆßyFAþ€A¸"}t/·žCý¹ŒüGäO€üð?eŠyÈ¿G
|
||||
BIN
apps/dashboard/build/_app/immutable/nodes/3.GWPnnBpN.js.gz
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/3.GWPnnBpN.js.gz
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
apps/dashboard/build/_app/immutable/nodes/6.DXXEUSu1.js.br
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/6.DXXEUSu1.js.br
Normal file
Binary file not shown.
BIN
apps/dashboard/build/_app/immutable/nodes/6.DXXEUSu1.js.gz
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/6.DXXEUSu1.js.gz
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1 +1 @@
|
|||
{"version":"1772420685161"}
|
||||
{"version":"1772567999839"}
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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.
413
apps/dashboard/e2e/live-materialization.spec.ts
Normal file
413
apps/dashboard/e2e/live-materialization.spec.ts
Normal 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
1252
apps/dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
apps/dashboard/playwright.config.ts
Normal file
27
apps/dashboard/playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
310
apps/dashboard/src/lib/graph/__tests__/edges.test.ts
Normal file
310
apps/dashboard/src/lib/graph/__tests__/edges.test.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('three', async () => {
|
||||
const mock = await import('./three-mock');
|
||||
return { ...mock };
|
||||
});
|
||||
|
||||
import { EdgeManager } from '../edges';
|
||||
import { Vector3 } from './three-mock';
|
||||
import { makeEdge, resetNodeCounter } from './helpers';
|
||||
|
||||
describe('EdgeManager', () => {
|
||||
let manager: EdgeManager;
|
||||
let positions: Map<string, InstanceType<typeof Vector3>>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetNodeCounter();
|
||||
manager = new EdgeManager();
|
||||
positions = new Map([
|
||||
['a', new Vector3(0, 0, 0)],
|
||||
['b', new Vector3(10, 0, 0)],
|
||||
['c', new Vector3(0, 10, 0)],
|
||||
['d', new Vector3(10, 10, 0)],
|
||||
]);
|
||||
});
|
||||
|
||||
describe('createEdges', () => {
|
||||
it('creates line objects for all edges', () => {
|
||||
const edges = [makeEdge('a', 'b'), makeEdge('b', 'c')];
|
||||
manager.createEdges(edges, positions);
|
||||
|
||||
expect(manager.group.children.length).toBe(2);
|
||||
});
|
||||
|
||||
it('skips edges with missing node positions', () => {
|
||||
const edges = [makeEdge('a', 'missing')];
|
||||
manager.createEdges(edges, positions);
|
||||
|
||||
expect(manager.group.children.length).toBe(0);
|
||||
});
|
||||
|
||||
it('stores source/target in userData', () => {
|
||||
const edges = [makeEdge('a', 'b')];
|
||||
manager.createEdges(edges, positions);
|
||||
|
||||
const line = manager.group.children[0];
|
||||
expect(line.userData.source).toBe('a');
|
||||
expect(line.userData.target).toBe('b');
|
||||
});
|
||||
|
||||
it('caps opacity at 0.6', () => {
|
||||
const edges = [makeEdge('a', 'b', { weight: 10.0 })];
|
||||
manager.createEdges(edges, positions);
|
||||
|
||||
const line = manager.group.children[0] as any;
|
||||
expect(line.material.opacity).toBeLessThanOrEqual(0.6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addEdge — growth animation', () => {
|
||||
it('adds a new line to the group', () => {
|
||||
const edge = makeEdge('a', 'b');
|
||||
manager.addEdge(edge, positions);
|
||||
|
||||
expect(manager.group.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('starts with zero-length line at source position', () => {
|
||||
const edge = makeEdge('a', 'b');
|
||||
manager.addEdge(edge, positions);
|
||||
|
||||
const line = manager.group.children[0] as any;
|
||||
const attrs = line.geometry.attributes.position;
|
||||
|
||||
// Both endpoints should be at source (a) position
|
||||
expect(attrs.getX(0)).toBe(0);
|
||||
expect(attrs.getX(1)).toBe(0); // not yet at target
|
||||
});
|
||||
|
||||
it('starts with zero opacity', () => {
|
||||
const edge = makeEdge('a', 'b');
|
||||
manager.addEdge(edge, positions);
|
||||
|
||||
const line = manager.group.children[0] as any;
|
||||
expect(line.material.opacity).toBe(0);
|
||||
});
|
||||
|
||||
it('grows to full length over 45 frames', () => {
|
||||
const edge = makeEdge('a', 'b');
|
||||
manager.addEdge(edge, positions);
|
||||
|
||||
// Animate through growth
|
||||
for (let f = 0; f < 45; f++) {
|
||||
manager.animateEdges(positions);
|
||||
}
|
||||
|
||||
const line = manager.group.children[0] as any;
|
||||
const attrs = line.geometry.attributes.position;
|
||||
|
||||
// End point should be at target position
|
||||
expect(attrs.getX(1)).toBeCloseTo(10, 0);
|
||||
});
|
||||
|
||||
it('opacity increases during growth', () => {
|
||||
const edge = makeEdge('a', 'b');
|
||||
manager.addEdge(edge, positions);
|
||||
|
||||
for (let f = 0; f < 25; f++) {
|
||||
manager.animateEdges(positions);
|
||||
}
|
||||
|
||||
const line = manager.group.children[0] as any;
|
||||
expect(line.material.opacity).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('reaches final opacity after growth completes', () => {
|
||||
const edge = makeEdge('a', 'b');
|
||||
manager.addEdge(edge, positions);
|
||||
|
||||
for (let f = 0; f < 46; f++) {
|
||||
manager.animateEdges(positions);
|
||||
}
|
||||
|
||||
const line = manager.group.children[0] as any;
|
||||
expect(line.material.opacity).toBe(0.5);
|
||||
});
|
||||
|
||||
it('uses easeOutCubic for smooth deceleration', () => {
|
||||
const edge = makeEdge('a', 'b');
|
||||
manager.addEdge(edge, positions);
|
||||
|
||||
// Record end point X at each frame
|
||||
const xValues: number[] = [];
|
||||
for (let f = 0; f < 45; f++) {
|
||||
manager.animateEdges(positions);
|
||||
const line = manager.group.children[0] as any;
|
||||
const attrs = line.geometry.attributes.position;
|
||||
xValues.push(attrs.getX(1));
|
||||
}
|
||||
|
||||
// easeOutCubic: fast start, slow end
|
||||
// First half should cover more than 50% of distance
|
||||
const midIdx = Math.floor(xValues.length / 2);
|
||||
const midProgress = xValues[midIdx] / 10;
|
||||
expect(midProgress).toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
it('skips edges with missing positions', () => {
|
||||
const edge = makeEdge('a', 'missing');
|
||||
manager.addEdge(edge, positions);
|
||||
|
||||
// Line should be created but with no geometry update
|
||||
expect(manager.group.children.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeEdgesForNode', () => {
|
||||
it('marks connected edges for dissolution', () => {
|
||||
const edges = [makeEdge('a', 'b'), makeEdge('b', 'c'), makeEdge('c', 'd')];
|
||||
manager.createEdges(edges, positions);
|
||||
expect(manager.group.children.length).toBe(3);
|
||||
|
||||
manager.removeEdgesForNode('b');
|
||||
|
||||
// After dissolution animation completes
|
||||
for (let f = 0; f < 45; f++) {
|
||||
manager.animateEdges(positions);
|
||||
}
|
||||
|
||||
// Only edge c->d should remain
|
||||
expect(manager.group.children.length).toBe(1);
|
||||
expect(manager.group.children[0].userData.source).toBe('c');
|
||||
});
|
||||
|
||||
it('dissolving edges fade out over 40 frames', () => {
|
||||
const edges = [makeEdge('a', 'b')];
|
||||
manager.createEdges(edges, positions);
|
||||
|
||||
const line = manager.group.children[0] as any;
|
||||
const initialOpacity = line.material.opacity;
|
||||
|
||||
manager.removeEdgesForNode('a');
|
||||
|
||||
// Midway through dissolution
|
||||
for (let f = 0; f < 20; f++) {
|
||||
manager.animateEdges(positions);
|
||||
}
|
||||
|
||||
expect(line.material.opacity).toBeLessThan(initialOpacity);
|
||||
expect(line.material.opacity).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('fully removes edge after dissolution completes', () => {
|
||||
const edges = [makeEdge('a', 'b')];
|
||||
manager.createEdges(edges, positions);
|
||||
|
||||
manager.removeEdgesForNode('a');
|
||||
|
||||
for (let f = 0; f < 45; f++) {
|
||||
manager.animateEdges(positions);
|
||||
}
|
||||
|
||||
expect(manager.group.children.length).toBe(0);
|
||||
});
|
||||
|
||||
it('cancels active growth animation if edge is dissolving', () => {
|
||||
const edge = makeEdge('a', 'b');
|
||||
manager.addEdge(edge, positions);
|
||||
|
||||
// Partially grow
|
||||
for (let f = 0; f < 10; f++) {
|
||||
manager.animateEdges(positions);
|
||||
}
|
||||
|
||||
// Then dissolve
|
||||
manager.removeEdgesForNode('a');
|
||||
|
||||
for (let f = 0; f < 45; f++) {
|
||||
manager.animateEdges(positions);
|
||||
}
|
||||
|
||||
expect(manager.group.children.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePositions', () => {
|
||||
it('updates static edge endpoints', () => {
|
||||
const edges = [makeEdge('a', 'b')];
|
||||
manager.createEdges(edges, positions);
|
||||
|
||||
// Move node a
|
||||
positions.set('a', new Vector3(5, 5, 5));
|
||||
manager.updatePositions(positions);
|
||||
|
||||
const line = manager.group.children[0] as any;
|
||||
const attrs = line.geometry.attributes.position;
|
||||
expect(attrs.getX(0)).toBe(5);
|
||||
expect(attrs.getY(0)).toBe(5);
|
||||
});
|
||||
|
||||
it('skips edges currently being animated', () => {
|
||||
// Add a growing edge
|
||||
const edge = makeEdge('a', 'b');
|
||||
manager.addEdge(edge, positions);
|
||||
|
||||
// updatePositions should not override the animation
|
||||
manager.updatePositions(positions);
|
||||
|
||||
// Growing edge should still be at its animated state
|
||||
const line = manager.group.children[0] as any;
|
||||
const attrs = line.geometry.attributes.position;
|
||||
// Point 1 should still be at source (zero-length start), not target
|
||||
expect(attrs.getX(1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple simultaneous edge animations', () => {
|
||||
it('handles multiple edges growing at once', () => {
|
||||
manager.addEdge(makeEdge('a', 'b'), positions);
|
||||
manager.addEdge(makeEdge('c', 'd'), positions);
|
||||
|
||||
for (let f = 0; f < 50; f++) {
|
||||
manager.animateEdges(positions);
|
||||
}
|
||||
|
||||
// Both should be fully grown
|
||||
expect(manager.group.children.length).toBe(2);
|
||||
manager.group.children.forEach((child) => {
|
||||
expect((child as any).material.opacity).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles mixed growing and dissolving edges', () => {
|
||||
// Create a static edge
|
||||
manager.createEdges([makeEdge('a', 'b')], positions);
|
||||
|
||||
// Add a growing edge
|
||||
manager.addEdge(makeEdge('c', 'd'), positions);
|
||||
|
||||
// Dissolve the static edge
|
||||
manager.removeEdgesForNode('a');
|
||||
|
||||
for (let f = 0; f < 50; f++) {
|
||||
manager.animateEdges(positions);
|
||||
}
|
||||
|
||||
// Only the new edge should remain
|
||||
expect(manager.group.children.length).toBe(1);
|
||||
expect(manager.group.children[0].userData.source).toBe('c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispose', () => {
|
||||
it('clears animation queues and disposes materials without error', () => {
|
||||
manager.createEdges([makeEdge('a', 'b')], positions);
|
||||
manager.addEdge(makeEdge('c', 'd'), positions);
|
||||
|
||||
// Dispose should not throw and should clean up materials
|
||||
expect(() => manager.dispose()).not.toThrow();
|
||||
|
||||
// After dispose, adding new animations should not interact with old state
|
||||
manager.addEdge(makeEdge('a', 'c'), positions);
|
||||
expect(() => {
|
||||
for (let f = 0; f < 50; f++) {
|
||||
manager.animateEdges(positions);
|
||||
}
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
499
apps/dashboard/src/lib/graph/__tests__/effects.test.ts
Normal file
499
apps/dashboard/src/lib/graph/__tests__/effects.test.ts
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('three', async () => {
|
||||
const mock = await import('./three-mock');
|
||||
return { ...mock };
|
||||
});
|
||||
|
||||
import { EffectManager } from '../effects';
|
||||
import { Vector3, Color, Scene } from './three-mock';
|
||||
|
||||
describe('EffectManager', () => {
|
||||
let scene: InstanceType<typeof Scene>;
|
||||
let effects: EffectManager;
|
||||
let nodeMeshMap: Map<string, any>;
|
||||
let camera: any;
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new Scene();
|
||||
effects = new EffectManager(scene as any);
|
||||
camera = { position: new Vector3(0, 30, 80) };
|
||||
nodeMeshMap = new Map();
|
||||
});
|
||||
|
||||
function createMockMesh(id: string, pos: InstanceType<typeof Vector3>) {
|
||||
const mesh = {
|
||||
scale: new Vector3(1, 1, 1),
|
||||
position: pos.clone(),
|
||||
material: {
|
||||
emissive: new Color(0x000000),
|
||||
emissiveIntensity: 0.5,
|
||||
},
|
||||
userData: { nodeId: id },
|
||||
};
|
||||
nodeMeshMap.set(id, mesh);
|
||||
return mesh;
|
||||
}
|
||||
|
||||
describe('pulse effects', () => {
|
||||
it('adds pulse and decays it over time', () => {
|
||||
createMockMesh('a', new Vector3(0, 0, 0));
|
||||
effects.addPulse('a', 1.0, new Color(0xff0000) as any, 0.1);
|
||||
|
||||
expect(effects.pulseEffects.length).toBe(1);
|
||||
|
||||
// Update a few times
|
||||
for (let i = 0; i < 5; i++) {
|
||||
effects.update(nodeMeshMap, camera);
|
||||
}
|
||||
|
||||
expect(effects.pulseEffects[0].intensity).toBeLessThan(1.0);
|
||||
});
|
||||
|
||||
it('removes pulse when intensity reaches zero', () => {
|
||||
createMockMesh('a', new Vector3(0, 0, 0));
|
||||
effects.addPulse('a', 0.5, new Color(0xff0000) as any, 0.1);
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
effects.update(nodeMeshMap, camera);
|
||||
}
|
||||
|
||||
expect(effects.pulseEffects.length).toBe(0);
|
||||
});
|
||||
|
||||
it('modulates mesh emissive color and intensity', () => {
|
||||
const mesh = createMockMesh('a', new Vector3(0, 0, 0));
|
||||
const pulseColor = new Color(0xff0000);
|
||||
effects.addPulse('a', 1.0, pulseColor as any, 0.05);
|
||||
|
||||
effects.update(nodeMeshMap, camera);
|
||||
|
||||
// Emissive intensity should be elevated
|
||||
expect(mesh.material.emissiveIntensity).toBeGreaterThan(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSpawnBurst', () => {
|
||||
it('adds particles to the scene', () => {
|
||||
const childCount = scene.children.length;
|
||||
effects.createSpawnBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
|
||||
|
||||
expect(scene.children.length).toBe(childCount + 1);
|
||||
});
|
||||
|
||||
it('creates 60 particles', () => {
|
||||
effects.createSpawnBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
|
||||
|
||||
const pts = scene.children[scene.children.length - 1] as any;
|
||||
expect(pts.geometry.attributes.position.count).toBe(60);
|
||||
});
|
||||
|
||||
it('particles move outward and fade over 120 frames', () => {
|
||||
effects.createSpawnBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
|
||||
|
||||
for (let i = 0; i < 121; i++) {
|
||||
effects.update(nodeMeshMap, camera);
|
||||
}
|
||||
|
||||
// Burst should be cleaned up
|
||||
expect(scene.children.length).toBe(0);
|
||||
});
|
||||
|
||||
it('particle opacity decreases over time', () => {
|
||||
effects.createSpawnBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
|
||||
|
||||
const pts = scene.children[0] as any;
|
||||
|
||||
effects.update(nodeMeshMap, camera);
|
||||
const earlyOpacity = pts.material.opacity;
|
||||
|
||||
for (let i = 0; i < 60; i++) {
|
||||
effects.update(nodeMeshMap, camera);
|
||||
}
|
||||
|
||||
expect(pts.material.opacity).toBeLessThan(earlyOpacity);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRainbowBurst', () => {
|
||||
it('creates 120 particles (2x normal burst)', () => {
|
||||
effects.createRainbowBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
|
||||
|
||||
const pts = scene.children[scene.children.length - 1] as any;
|
||||
expect(pts.geometry.attributes.position.count).toBe(120);
|
||||
});
|
||||
|
||||
it('has a 180-frame (3-second) lifespan', () => {
|
||||
effects.createRainbowBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
|
||||
|
||||
// Run for 179 frames — should still exist
|
||||
for (let i = 0; i < 179; i++) {
|
||||
effects.update(nodeMeshMap, camera);
|
||||
}
|
||||
expect(scene.children.length).toBe(1);
|
||||
|
||||
// Frame 180 — should be cleaned up
|
||||
effects.update(nodeMeshMap, camera);
|
||||
expect(scene.children.length).toBe(1); // age increments to 180
|
||||
|
||||
effects.update(nodeMeshMap, camera);
|
||||
expect(scene.children.length).toBe(0);
|
||||
});
|
||||
|
||||
it('color cycles through rainbow HSL', () => {
|
||||
effects.createRainbowBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
|
||||
|
||||
const pts = scene.children[0] as any;
|
||||
const initialColor = { r: pts.material.color.r, g: pts.material.color.g, b: pts.material.color.b };
|
||||
|
||||
// Advance several frames
|
||||
for (let i = 0; i < 30; i++) {
|
||||
effects.update(nodeMeshMap, camera);
|
||||
}
|
||||
|
||||
// Color should have changed due to HSL cycling
|
||||
const currentColor = pts.material.color;
|
||||
const colorChanged =
|
||||
Math.abs(currentColor.r - initialColor.r) > 0.01 ||
|
||||
Math.abs(currentColor.g - initialColor.g) > 0.01 ||
|
||||
Math.abs(currentColor.b - initialColor.b) > 0.01;
|
||||
expect(colorChanged).toBe(true);
|
||||
});
|
||||
|
||||
it('particle size pulses (not monotonically decreasing)', () => {
|
||||
effects.createRainbowBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
|
||||
|
||||
const pts = scene.children[0] as any;
|
||||
const sizes: number[] = [];
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
effects.update(nodeMeshMap, camera);
|
||||
sizes.push(pts.material.size);
|
||||
}
|
||||
|
||||
// Check that size varies (pulses) — not monotonically decreasing
|
||||
let monotonic = true;
|
||||
for (let i = 1; i < sizes.length; i++) {
|
||||
if (sizes[i] > sizes[i - 1]) {
|
||||
monotonic = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(monotonic).toBe(false);
|
||||
});
|
||||
|
||||
it('has hueOffset attribute for per-particle variation', () => {
|
||||
effects.createRainbowBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
|
||||
|
||||
const pts = scene.children[0] as any;
|
||||
expect(pts.geometry.attributes.hueOffset).toBeDefined();
|
||||
expect(pts.geometry.attributes.hueOffset.count).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRippleWave', () => {
|
||||
it('creates a ripple wave state', () => {
|
||||
const nodePositions = new Map<string, any>([
|
||||
['n1', new Vector3(5, 0, 0)],
|
||||
['n2', new Vector3(15, 0, 0)],
|
||||
]);
|
||||
effects.createRippleWave(new Vector3(0, 0, 0) as any);
|
||||
|
||||
// Ripple wave is internal state — verify it runs without error
|
||||
for (let i = 0; i < 100; i++) {
|
||||
effects.update(nodeMeshMap, camera, nodePositions);
|
||||
}
|
||||
});
|
||||
|
||||
it('pulses nearby nodes as wavefront reaches them', () => {
|
||||
const nodePositions = new Map<string, any>([
|
||||
['close', new Vector3(5, 0, 0)],
|
||||
['far', new Vector3(50, 0, 0)],
|
||||
]);
|
||||
|
||||
const closeMesh = createMockMesh('close', new Vector3(5, 0, 0));
|
||||
const farMesh = createMockMesh('far', new Vector3(50, 0, 0));
|
||||
|
||||
effects.createRippleWave(new Vector3(0, 0, 0) as any);
|
||||
|
||||
// Run until wavefront reaches "close" (dist=5, speed=1.2, ~4 frames)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
effects.update(nodeMeshMap, camera, nodePositions);
|
||||
}
|
||||
|
||||
// Should have pulsed the close node — check for pulse effect
|
||||
const closeHasPulse = effects.pulseEffects.some((p) => p.nodeId === 'close');
|
||||
expect(closeHasPulse).toBe(true);
|
||||
});
|
||||
|
||||
it('pulses each node only once', () => {
|
||||
const nodePositions = new Map<string, any>([
|
||||
['n1', new Vector3(3, 0, 0)],
|
||||
]);
|
||||
createMockMesh('n1', new Vector3(3, 0, 0));
|
||||
|
||||
effects.createRippleWave(new Vector3(0, 0, 0) as any);
|
||||
|
||||
// Run many frames
|
||||
for (let i = 0; i < 30; i++) {
|
||||
effects.update(nodeMeshMap, camera, nodePositions);
|
||||
}
|
||||
|
||||
// Count pulses for n1 — should be exactly 1
|
||||
const n1Pulses = effects.pulseEffects.filter((p) => p.nodeId === 'n1');
|
||||
// Could be 0 if already decayed, but should have been created once
|
||||
expect(n1Pulses.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('applies scale bump to contacted nodes', () => {
|
||||
const nodePositions = new Map<string, any>([
|
||||
['bump', new Vector3(3, 0, 0)],
|
||||
]);
|
||||
const mesh = createMockMesh('bump', new Vector3(3, 0, 0));
|
||||
|
||||
effects.createRippleWave(new Vector3(0, 0, 0) as any);
|
||||
|
||||
// Run until wavefront reaches the node
|
||||
for (let i = 0; i < 10; i++) {
|
||||
effects.update(nodeMeshMap, camera, nodePositions);
|
||||
}
|
||||
|
||||
// Scale should have been bumped (1.3x)
|
||||
expect(mesh.scale.x).toBeGreaterThan(1.0);
|
||||
});
|
||||
|
||||
it('completes and cleans up after 90 frames', () => {
|
||||
effects.createRippleWave(new Vector3(0, 0, 0) as any);
|
||||
|
||||
for (let i = 0; i < 95; i++) {
|
||||
effects.update(nodeMeshMap, camera, new Map());
|
||||
}
|
||||
|
||||
// Internal rippleWaves array should be empty (no way to check directly,
|
||||
// but running more frames should not cause any errors)
|
||||
effects.update(nodeMeshMap, camera, new Map());
|
||||
});
|
||||
});
|
||||
|
||||
describe('createImplosion', () => {
|
||||
it('creates 40 particles', () => {
|
||||
effects.createImplosion(new Vector3(5, 5, 5) as any, new Color(0xff4757) as any);
|
||||
|
||||
const pts = scene.children[scene.children.length - 1] as any;
|
||||
expect(pts.geometry.attributes.position.count).toBe(40);
|
||||
});
|
||||
|
||||
it('particles start spread out around the target', () => {
|
||||
const center = new Vector3(5, 5, 5);
|
||||
effects.createImplosion(center as any, new Color(0xff4757) as any);
|
||||
|
||||
const pts = scene.children[0] as any;
|
||||
const positions = pts.geometry.attributes.position;
|
||||
|
||||
// At least some particles should be far from center
|
||||
let maxDist = 0;
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
const px = positions.getX(i);
|
||||
const py = positions.getY(i);
|
||||
const pz = positions.getZ(i);
|
||||
const dist = Math.sqrt(
|
||||
(px - center.x) ** 2 + (py - center.y) ** 2 + (pz - center.z) ** 2
|
||||
);
|
||||
if (dist > maxDist) maxDist = dist;
|
||||
}
|
||||
expect(maxDist).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it('particles move INWARD toward center', () => {
|
||||
const center = new Vector3(0, 0, 0);
|
||||
effects.createImplosion(center as any, new Color(0xff4757) as any);
|
||||
|
||||
const pts = scene.children[0] as any;
|
||||
const positions = pts.geometry.attributes.position;
|
||||
|
||||
// Record initial average distance
|
||||
let initialAvgDist = 0;
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
const px = positions.getX(i);
|
||||
const py = positions.getY(i);
|
||||
const pz = positions.getZ(i);
|
||||
initialAvgDist += Math.sqrt(px * px + py * py + pz * pz);
|
||||
}
|
||||
initialAvgDist /= positions.count;
|
||||
|
||||
// Advance 30 frames
|
||||
for (let f = 0; f < 30; f++) {
|
||||
effects.update(nodeMeshMap, camera);
|
||||
}
|
||||
|
||||
// Record new average distance
|
||||
let newAvgDist = 0;
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
const px = positions.getX(i);
|
||||
const py = positions.getY(i);
|
||||
const pz = positions.getZ(i);
|
||||
newAvgDist += Math.sqrt(px * px + py * py + pz * pz);
|
||||
}
|
||||
newAvgDist /= positions.count;
|
||||
|
||||
expect(newAvgDist).toBeLessThan(initialAvgDist);
|
||||
});
|
||||
|
||||
it('creates a flash at convergence (frame 60)', () => {
|
||||
effects.createImplosion(new Vector3(0, 0, 0) as any, new Color(0xff4757) as any);
|
||||
|
||||
// Run to convergence
|
||||
for (let f = 0; f < 60; f++) {
|
||||
effects.update(nodeMeshMap, camera);
|
||||
}
|
||||
|
||||
// Should have particles + flash mesh
|
||||
expect(scene.children.length).toBe(2);
|
||||
});
|
||||
|
||||
it('flash fades out and everything cleans up by frame 80', () => {
|
||||
effects.createImplosion(new Vector3(0, 0, 0) as any, new Color(0xff4757) as any);
|
||||
|
||||
for (let f = 0; f < 85; f++) {
|
||||
effects.update(nodeMeshMap, camera);
|
||||
}
|
||||
|
||||
// Everything should be cleaned up
|
||||
expect(scene.children.length).toBe(0);
|
||||
});
|
||||
|
||||
it('flash sphere expands during fade-out', () => {
|
||||
effects.createImplosion(new Vector3(0, 0, 0) as any, new Color(0xff4757) as any);
|
||||
|
||||
for (let f = 0; f < 65; f++) {
|
||||
effects.update(nodeMeshMap, camera);
|
||||
}
|
||||
|
||||
// Find the flash mesh (should be the second child)
|
||||
const flash = scene.children.find(
|
||||
(c) => c instanceof Object && 'geometry' in c && !(('attributes' in (c as any).geometry))
|
||||
);
|
||||
|
||||
// Flash should have expanded beyond scale 1
|
||||
if (flash) {
|
||||
expect(flash.scale.x).toBeGreaterThan(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createShockwave', () => {
|
||||
it('adds a ring mesh to the scene', () => {
|
||||
effects.createShockwave(
|
||||
new Vector3(0, 0, 0) as any,
|
||||
new Color(0x00ffd1) as any,
|
||||
camera
|
||||
);
|
||||
|
||||
expect(scene.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('ring expands over time', () => {
|
||||
effects.createShockwave(
|
||||
new Vector3(0, 0, 0) as any,
|
||||
new Color(0x00ffd1) as any,
|
||||
camera
|
||||
);
|
||||
|
||||
const ring = scene.children[0] as any;
|
||||
const initialScale = ring.scale.x;
|
||||
|
||||
for (let f = 0; f < 30; f++) {
|
||||
effects.update(nodeMeshMap, camera);
|
||||
}
|
||||
|
||||
expect(ring.scale.x).toBeGreaterThan(initialScale);
|
||||
});
|
||||
|
||||
it('ring fades out and cleans up after 60 frames', () => {
|
||||
effects.createShockwave(
|
||||
new Vector3(0, 0, 0) as any,
|
||||
new Color(0x00ffd1) as any,
|
||||
camera
|
||||
);
|
||||
|
||||
for (let f = 0; f < 65; f++) {
|
||||
effects.update(nodeMeshMap, camera);
|
||||
}
|
||||
|
||||
expect(scene.children.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createConnectionFlash', () => {
|
||||
it('creates a line between two points', () => {
|
||||
effects.createConnectionFlash(
|
||||
new Vector3(0, 0, 0) as any,
|
||||
new Vector3(10, 10, 10) as any,
|
||||
new Color(0x00d4ff) as any
|
||||
);
|
||||
|
||||
expect(scene.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('fades out and cleans up', () => {
|
||||
effects.createConnectionFlash(
|
||||
new Vector3(0, 0, 0) as any,
|
||||
new Vector3(10, 10, 10) as any,
|
||||
new Color(0x00d4ff) as any
|
||||
);
|
||||
|
||||
for (let f = 0; f < 100; f++) {
|
||||
effects.update(nodeMeshMap, camera);
|
||||
}
|
||||
|
||||
expect(scene.children.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple simultaneous effects', () => {
|
||||
it('handles all effect types simultaneously', () => {
|
||||
const nodePositions = new Map<string, any>([
|
||||
['n1', new Vector3(5, 0, 0)],
|
||||
]);
|
||||
createMockMesh('n1', new Vector3(5, 0, 0));
|
||||
|
||||
effects.addPulse('n1', 1.0, new Color(0xff0000) as any, 0.05);
|
||||
effects.createSpawnBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
|
||||
effects.createRainbowBurst(new Vector3(5, 5, 5) as any, new Color(0xff00ff) as any);
|
||||
effects.createShockwave(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any, camera);
|
||||
effects.createConnectionFlash(
|
||||
new Vector3(0, 0, 0) as any,
|
||||
new Vector3(10, 0, 0) as any,
|
||||
new Color(0x00d4ff) as any
|
||||
);
|
||||
effects.createImplosion(new Vector3(-5, -5, -5) as any, new Color(0xff4757) as any);
|
||||
effects.createRippleWave(new Vector3(0, 0, 0) as any);
|
||||
|
||||
// Should not throw for 200 frames
|
||||
for (let f = 0; f < 200; f++) {
|
||||
effects.update(nodeMeshMap, camera, nodePositions);
|
||||
}
|
||||
|
||||
// All effects should have cleaned up
|
||||
expect(scene.children.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispose', () => {
|
||||
it('cleans up all active effects', () => {
|
||||
effects.createSpawnBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
|
||||
effects.createRainbowBurst(new Vector3(0, 0, 0) as any, new Color(0xff00ff) as any);
|
||||
effects.createImplosion(new Vector3(0, 0, 0) as any, new Color(0xff4757) as any);
|
||||
effects.createShockwave(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any, camera);
|
||||
effects.createConnectionFlash(
|
||||
new Vector3(0, 0, 0) as any,
|
||||
new Vector3(10, 0, 0) as any,
|
||||
new Color(0x00d4ff) as any
|
||||
);
|
||||
|
||||
effects.dispose();
|
||||
|
||||
expect(effects.pulseEffects.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
864
apps/dashboard/src/lib/graph/__tests__/events.test.ts
Normal file
864
apps/dashboard/src/lib/graph/__tests__/events.test.ts
Normal file
|
|
@ -0,0 +1,864 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('three', async () => {
|
||||
const mock = await import('./three-mock');
|
||||
return { ...mock };
|
||||
});
|
||||
|
||||
import { mapEventToEffects, resetLiveSpawnTracking, type GraphMutationContext, type GraphMutation } from '../events';
|
||||
import { NodeManager } from '../nodes';
|
||||
import { EdgeManager } from '../edges';
|
||||
import { EffectManager } from '../effects';
|
||||
import { ForceSimulation } from '../force-sim';
|
||||
import { Vector3, Scene } from './three-mock';
|
||||
import { makeNode, makeEdge, makeEvent, resetNodeCounter } from './helpers';
|
||||
import type { GraphNode, VestigeEvent } from '$types';
|
||||
|
||||
describe('Event-to-Mutation Pipeline', () => {
|
||||
let nodeManager: NodeManager;
|
||||
let edgeManager: EdgeManager;
|
||||
let effects: EffectManager;
|
||||
let forceSim: ForceSimulation;
|
||||
let scene: InstanceType<typeof Scene>;
|
||||
let camera: any;
|
||||
let mutations: GraphMutation[];
|
||||
let allNodes: GraphNode[];
|
||||
let ctx: GraphMutationContext;
|
||||
|
||||
beforeEach(() => {
|
||||
resetNodeCounter();
|
||||
resetLiveSpawnTracking();
|
||||
scene = new Scene();
|
||||
camera = { position: new Vector3(0, 30, 80) };
|
||||
nodeManager = new NodeManager();
|
||||
edgeManager = new EdgeManager();
|
||||
effects = new EffectManager(scene as any);
|
||||
mutations = [];
|
||||
|
||||
// Create initial graph with 5 nodes
|
||||
const initialNodes = [
|
||||
makeNode({ id: 'n1', type: 'fact', tags: ['rust', 'bug-fix'] }),
|
||||
makeNode({ id: 'n2', type: 'concept', tags: ['architecture'] }),
|
||||
makeNode({ id: 'n3', type: 'decision', tags: ['rust'] }),
|
||||
makeNode({ id: 'n4', type: 'fact', tags: ['testing'] }),
|
||||
makeNode({ id: 'n5', type: 'event', tags: ['session'] }),
|
||||
];
|
||||
|
||||
const positions = nodeManager.createNodes(initialNodes);
|
||||
edgeManager.createEdges(
|
||||
[makeEdge('n1', 'n2'), makeEdge('n2', 'n3'), makeEdge('n3', 'n4')],
|
||||
positions
|
||||
);
|
||||
forceSim = new ForceSimulation(positions);
|
||||
|
||||
allNodes = [...initialNodes];
|
||||
|
||||
ctx = {
|
||||
effects,
|
||||
nodeManager,
|
||||
edgeManager,
|
||||
forceSim,
|
||||
camera,
|
||||
onMutation: (m: GraphMutation) => mutations.push(m),
|
||||
};
|
||||
});
|
||||
|
||||
describe('MemoryCreated', () => {
|
||||
it('creates a new node in all managers', () => {
|
||||
const event = makeEvent('MemoryCreated', {
|
||||
id: 'new-1',
|
||||
content: 'I love Rust',
|
||||
node_type: 'fact',
|
||||
tags: ['rust', 'preference'],
|
||||
retention: 0.9,
|
||||
});
|
||||
|
||||
mapEventToEffects(event, ctx, allNodes);
|
||||
|
||||
expect(nodeManager.meshMap.has('new-1')).toBe(true);
|
||||
expect(nodeManager.positions.has('new-1')).toBe(true);
|
||||
expect(forceSim.positions.has('new-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('emits nodeAdded mutation', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'new-2',
|
||||
content: 'test',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const nodeAdded = mutations.find((m) => m.type === 'nodeAdded');
|
||||
expect(nodeAdded).toBeDefined();
|
||||
expect((nodeAdded as any).node.id).toBe('new-2');
|
||||
});
|
||||
|
||||
it('builds GraphNode from event data', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'new-3',
|
||||
content: 'Complex memory about architecture decisions in Rust systems',
|
||||
node_type: 'decision',
|
||||
tags: ['architecture', 'rust'],
|
||||
retention: 0.75,
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const mutation = mutations.find((m) => m.type === 'nodeAdded') as any;
|
||||
expect(mutation.node.type).toBe('decision');
|
||||
expect(mutation.node.tags).toEqual(['architecture', 'rust']);
|
||||
expect(mutation.node.retention).toBe(0.75);
|
||||
expect(mutation.node.label).toBe('Complex memory about architecture decisions in Rust systems');
|
||||
expect(mutation.node.isCenter).toBe(false);
|
||||
});
|
||||
|
||||
it('truncates label to 60 characters', () => {
|
||||
const longContent = 'A'.repeat(100);
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'long',
|
||||
content: longContent,
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const mutation = mutations.find((m) => m.type === 'nodeAdded') as any;
|
||||
expect(mutation.node.label.length).toBe(60);
|
||||
});
|
||||
|
||||
it('spawns node near related nodes (tag overlap scoring)', () => {
|
||||
// Create a memory with rust tag — should spawn near n1 (which has rust tag)
|
||||
const n1Pos = nodeManager.positions.get('n1')!;
|
||||
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'rust-memory',
|
||||
content: 'Rust borrow checker tip',
|
||||
node_type: 'fact',
|
||||
tags: ['rust'],
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const newPos = nodeManager.positions.get('rust-memory')!;
|
||||
const distToN1 = newPos.distanceTo(n1Pos);
|
||||
|
||||
// Should be relatively close to n1 (within jitter range ~10 units)
|
||||
expect(distToN1).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it('triggers rainbow burst effect', () => {
|
||||
const childrenBefore = scene.children.length;
|
||||
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'new-burst',
|
||||
content: 'test',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
// Scene should have new particles (rainbow burst + shockwave + possibly more)
|
||||
expect(scene.children.length).toBeGreaterThan(childrenBefore);
|
||||
});
|
||||
|
||||
it('triggers double shockwave (second delayed)', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'double-shock',
|
||||
content: 'test',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const initialChildren = scene.children.length;
|
||||
|
||||
// Advance past the setTimeout
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
// Second shockwave should have been added
|
||||
expect(scene.children.length).toBeGreaterThan(initialChildren);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses default values when event data is incomplete', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', { id: 'minimal' }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const mutation = mutations.find((m) => m.type === 'nodeAdded') as any;
|
||||
expect(mutation.node.type).toBe('fact');
|
||||
expect(mutation.node.retention).toBe(0.9);
|
||||
expect(mutation.node.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores event without id', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', { content: 'no id' }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
expect(mutations.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FIFO eviction', () => {
|
||||
it('evicts oldest live node when exceeding 50 cap', () => {
|
||||
// Create 51 live nodes
|
||||
for (let i = 0; i < 51; i++) {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: `live-${i}`,
|
||||
content: `Memory ${i}`,
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
}
|
||||
|
||||
// First live node should have been evicted
|
||||
const removedMutations = mutations.filter((m) => m.type === 'nodeRemoved');
|
||||
expect(removedMutations.length).toBeGreaterThan(0);
|
||||
expect((removedMutations[0] as any).nodeId).toBe('live-0');
|
||||
});
|
||||
|
||||
it('evicted node is removed from all managers', () => {
|
||||
for (let i = 0; i < 51; i++) {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: `evict-${i}`,
|
||||
content: `Memory ${i}`,
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
}
|
||||
|
||||
// First node should be gone from node manager and force sim
|
||||
expect(forceSim.positions.has('evict-0')).toBe(false);
|
||||
});
|
||||
|
||||
it('initial nodes are NOT subject to FIFO eviction', () => {
|
||||
// Even after adding 50 live nodes, initial nodes should still exist
|
||||
for (let i = 0; i < 50; i++) {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: `extra-${i}`,
|
||||
content: `Memory ${i}`,
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
}
|
||||
|
||||
expect(nodeManager.meshMap.has('n1')).toBe(true);
|
||||
expect(nodeManager.meshMap.has('n2')).toBe(true);
|
||||
expect(nodeManager.meshMap.has('n3')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConnectionDiscovered', () => {
|
||||
it('adds edge with growth animation', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('ConnectionDiscovered', {
|
||||
source_id: 'n1',
|
||||
target_id: 'n4',
|
||||
weight: 0.8,
|
||||
connection_type: 'causal',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
// Edge should have been added
|
||||
expect(edgeManager.group.children.length).toBeGreaterThan(3); // 3 initial + 1 new
|
||||
});
|
||||
|
||||
it('emits edgeAdded mutation with correct data', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('ConnectionDiscovered', {
|
||||
source_id: 'n1',
|
||||
target_id: 'n5',
|
||||
weight: 0.7,
|
||||
connection_type: 'semantic',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const edgeMutation = mutations.find((m) => m.type === 'edgeAdded') as any;
|
||||
expect(edgeMutation).toBeDefined();
|
||||
expect(edgeMutation.edge.source).toBe('n1');
|
||||
expect(edgeMutation.edge.target).toBe('n5');
|
||||
expect(edgeMutation.edge.weight).toBe(0.7);
|
||||
expect(edgeMutation.edge.type).toBe('semantic');
|
||||
});
|
||||
|
||||
it('creates connection flash between endpoints', () => {
|
||||
const childrenBefore = scene.children.length;
|
||||
|
||||
mapEventToEffects(
|
||||
makeEvent('ConnectionDiscovered', {
|
||||
source_id: 'n1',
|
||||
target_id: 'n2',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
expect(scene.children.length).toBeGreaterThan(childrenBefore);
|
||||
});
|
||||
|
||||
it('pulses both endpoint nodes', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('ConnectionDiscovered', {
|
||||
source_id: 'n1',
|
||||
target_id: 'n2',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const n1Pulse = effects.pulseEffects.find((p) => p.nodeId === 'n1');
|
||||
const n2Pulse = effects.pulseEffects.find((p) => p.nodeId === 'n2');
|
||||
expect(n1Pulse).toBeDefined();
|
||||
expect(n2Pulse).toBeDefined();
|
||||
});
|
||||
|
||||
it('uses default weight and type when not provided', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('ConnectionDiscovered', {
|
||||
source_id: 'n1',
|
||||
target_id: 'n5',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const edgeMutation = mutations.find((m) => m.type === 'edgeAdded') as any;
|
||||
expect(edgeMutation.edge.weight).toBe(0.5);
|
||||
expect(edgeMutation.edge.type).toBe('semantic');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MemoryDeleted', () => {
|
||||
it('removes node from all managers', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryDeleted', { id: 'n1' }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
// Force sim should have removed the node
|
||||
expect(forceSim.positions.has('n1')).toBe(false);
|
||||
});
|
||||
|
||||
it('removes connected edges', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryDeleted', { id: 'n2' }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
// Should emit both edgesRemoved and nodeRemoved mutations
|
||||
const edgesRemoved = mutations.find((m) => m.type === 'edgesRemoved');
|
||||
const nodeRemoved = mutations.find((m) => m.type === 'nodeRemoved');
|
||||
expect(edgesRemoved).toBeDefined();
|
||||
expect(nodeRemoved).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates implosion effect at node position', () => {
|
||||
const childrenBefore = scene.children.length;
|
||||
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryDeleted', { id: 'n3' }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
// Should have added implosion particles
|
||||
expect(scene.children.length).toBeGreaterThan(childrenBefore);
|
||||
});
|
||||
|
||||
it('removes from live tracking if was live-spawned', () => {
|
||||
// First, create a live node
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'temp-live',
|
||||
content: 'temporary',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
expect(nodeManager.meshMap.has('temp-live')).toBe(true);
|
||||
|
||||
// Now delete it
|
||||
mutations = [];
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryDeleted', { id: 'temp-live' }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const nodeRemoved = mutations.find((m) => m.type === 'nodeRemoved');
|
||||
expect(nodeRemoved).toBeDefined();
|
||||
});
|
||||
|
||||
it('ignores event without id', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryDeleted', {}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
expect(mutations.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MemoryPromoted', () => {
|
||||
it('grows the node to new retention', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryPromoted', { id: 'n1', new_retention: 0.95 }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
// Should have updated userData
|
||||
expect(nodeManager.meshMap.get('n1')!.userData.retention).toBe(0.95);
|
||||
});
|
||||
|
||||
it('emits nodeUpdated mutation', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryPromoted', { id: 'n2', new_retention: 0.98 }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const mutation = mutations.find((m) => m.type === 'nodeUpdated') as any;
|
||||
expect(mutation).toBeDefined();
|
||||
expect(mutation.nodeId).toBe('n2');
|
||||
expect(mutation.retention).toBe(0.98);
|
||||
});
|
||||
|
||||
it('creates green pulse + shockwave + spawn burst', () => {
|
||||
const childrenBefore = scene.children.length;
|
||||
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryPromoted', { id: 'n1' }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
// Should have green pulse
|
||||
const greenPulse = effects.pulseEffects.find((p) => p.nodeId === 'n1');
|
||||
expect(greenPulse).toBeDefined();
|
||||
|
||||
// Should have added visual effects to scene
|
||||
expect(scene.children.length).toBeGreaterThan(childrenBefore);
|
||||
});
|
||||
|
||||
it('uses default retention when not provided', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryPromoted', { id: 'n1' }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const mutation = mutations.find((m) => m.type === 'nodeUpdated') as any;
|
||||
expect(mutation.retention).toBe(0.95); // default
|
||||
});
|
||||
|
||||
it('ignores nonexistent node', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryPromoted', { id: 'nonexistent' }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
expect(mutations.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MemoryDemoted', () => {
|
||||
it('shrinks the node', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryDemoted', { id: 'n1', new_retention: 0.3 }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
expect(nodeManager.meshMap.get('n1')!.userData.retention).toBe(0.3);
|
||||
});
|
||||
|
||||
it('emits nodeUpdated mutation', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryDemoted', { id: 'n2', new_retention: 0.2 }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const mutation = mutations.find((m) => m.type === 'nodeUpdated') as any;
|
||||
expect(mutation).toBeDefined();
|
||||
expect(mutation.retention).toBe(0.2);
|
||||
});
|
||||
|
||||
it('creates red pulse (subtler than promotion)', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryDemoted', { id: 'n1', new_retention: 0.3 }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const pulse = effects.pulseEffects.find((p) => p.nodeId === 'n1');
|
||||
expect(pulse).toBeDefined();
|
||||
expect(pulse!.decay).toBe(0.03); // faster decay = subtler
|
||||
});
|
||||
});
|
||||
|
||||
describe('MemoryUpdated', () => {
|
||||
it('creates blue pulse on existing node', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryUpdated', { id: 'n1' }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const pulse = effects.pulseEffects.find((p) => p.nodeId === 'n1');
|
||||
expect(pulse).toBeDefined();
|
||||
});
|
||||
|
||||
it('updates retention if provided', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryUpdated', { id: 'n1', retention: 0.85 }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const mutation = mutations.find((m) => m.type === 'nodeUpdated') as any;
|
||||
expect(mutation).toBeDefined();
|
||||
expect(mutation.retention).toBe(0.85);
|
||||
});
|
||||
|
||||
it('does not emit mutation without retention data', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryUpdated', { id: 'n1' }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
expect(mutations.length).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores nonexistent node', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryUpdated', { id: 'nonexistent' }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
expect(mutations.length).toBe(0);
|
||||
expect(effects.pulseEffects.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchPerformed', () => {
|
||||
it('pulses all nodes', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('SearchPerformed', {}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
expect(effects.pulseEffects.length).toBe(5); // 5 initial nodes
|
||||
});
|
||||
});
|
||||
|
||||
describe('DreamStarted', () => {
|
||||
it('pulses all nodes with purple', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('DreamStarted', {}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
expect(effects.pulseEffects.length).toBe(5);
|
||||
// Purple pulse with slow decay
|
||||
effects.pulseEffects.forEach((p) => {
|
||||
expect(p.decay).toBe(0.005);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DreamProgress', () => {
|
||||
it('pulses specific memory with high intensity', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('DreamProgress', { memory_id: 'n3' }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const pulse = effects.pulseEffects.find((p) => p.nodeId === 'n3');
|
||||
expect(pulse).toBeDefined();
|
||||
expect(pulse!.intensity).toBe(1.5);
|
||||
});
|
||||
|
||||
it('ignores nonexistent memory', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('DreamProgress', { memory_id: 'nonexistent' }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
expect(effects.pulseEffects.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DreamCompleted', () => {
|
||||
it('creates center burst + shockwave', () => {
|
||||
const childrenBefore = scene.children.length;
|
||||
|
||||
mapEventToEffects(
|
||||
makeEvent('DreamCompleted', {}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
expect(scene.children.length).toBeGreaterThan(childrenBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RetentionDecayed', () => {
|
||||
it('adds red pulse to decayed node', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('RetentionDecayed', { id: 'n2' }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const pulse = effects.pulseEffects.find((p) => p.nodeId === 'n2');
|
||||
expect(pulse).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConsolidationCompleted', () => {
|
||||
it('pulses all nodes with orange', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('ConsolidationCompleted', {}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
expect(effects.pulseEffects.length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ActivationSpread', () => {
|
||||
it('creates flashes from source to all targets', () => {
|
||||
const childrenBefore = scene.children.length;
|
||||
|
||||
mapEventToEffects(
|
||||
makeEvent('ActivationSpread', {
|
||||
source_id: 'n1',
|
||||
target_ids: ['n2', 'n3', 'n4'],
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
expect(scene.children.length).toBe(childrenBefore + 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawn position scoring', () => {
|
||||
it('type match scores higher than tag match', () => {
|
||||
// n1 is type: 'fact', tags: ['rust', 'bug-fix']
|
||||
// n2 is type: 'concept', tags: ['architecture']
|
||||
// Creating a 'fact' with 'architecture' tag — should favor n1 (type match = 2 points)
|
||||
// vs n2 (tag match = 1 point)
|
||||
const n1Pos = nodeManager.positions.get('n1')!;
|
||||
const n2Pos = nodeManager.positions.get('n2')!;
|
||||
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'type-vs-tag',
|
||||
content: 'test',
|
||||
node_type: 'fact',
|
||||
tags: ['architecture'],
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const newPos = nodeManager.positions.get('type-vs-tag')!;
|
||||
const distToN1 = newPos.distanceTo(n1Pos);
|
||||
const distToN2 = newPos.distanceTo(n2Pos);
|
||||
|
||||
// Should be closer to n1 (type match wins)
|
||||
expect(distToN1).toBeLessThan(distToN2);
|
||||
});
|
||||
|
||||
it('falls back to random position when no matches', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'no-match',
|
||||
content: 'test',
|
||||
node_type: 'place', // no existing 'place' nodes
|
||||
tags: ['geography'], // no matching tags
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const pos = nodeManager.positions.get('no-match')!;
|
||||
// Should be somewhere in the graph space
|
||||
expect(Math.abs(pos.x)).toBeLessThan(100);
|
||||
expect(Math.abs(pos.y)).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('full lifecycle integration', () => {
|
||||
it('create → promote → delete lifecycle', () => {
|
||||
// 1. Create
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'lifecycle',
|
||||
content: 'lifecycle test',
|
||||
node_type: 'fact',
|
||||
retention: 0.7,
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
expect(nodeManager.meshMap.has('lifecycle')).toBe(true);
|
||||
|
||||
// 2. Promote
|
||||
mutations = [];
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryPromoted', { id: 'lifecycle', new_retention: 0.95 }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
expect(nodeManager.meshMap.get('lifecycle')!.userData.retention).toBe(0.95);
|
||||
|
||||
// 3. Delete
|
||||
mutations = [];
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryDeleted', { id: 'lifecycle' }),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
expect(forceSim.positions.has('lifecycle')).toBe(false);
|
||||
});
|
||||
|
||||
it('rapid-fire 10 creates without errors', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: `rapid-${i}`,
|
||||
content: `Rapid memory ${i}`,
|
||||
node_type: i % 2 === 0 ? 'fact' : 'concept',
|
||||
tags: ['rapid'],
|
||||
retention: 0.5 + Math.random() * 0.5,
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
}
|
||||
|
||||
expect(nodeManager.meshMap.size).toBe(15); // 5 initial + 10 new
|
||||
expect(forceSim.positions.size).toBe(15);
|
||||
|
||||
// All mutations should have been emitted
|
||||
const nodeAdded = mutations.filter((m) => m.type === 'nodeAdded');
|
||||
expect(nodeAdded.length).toBe(10);
|
||||
});
|
||||
|
||||
it('create + connection discovered pipeline', () => {
|
||||
// Create two new memories
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'connect-a',
|
||||
content: 'Connection source',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'connect-b',
|
||||
content: 'Connection target',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
// Then discover a connection between them
|
||||
mutations = [];
|
||||
mapEventToEffects(
|
||||
makeEvent('ConnectionDiscovered', {
|
||||
source_id: 'connect-a',
|
||||
target_id: 'connect-b',
|
||||
weight: 0.9,
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const edgeMutation = mutations.find((m) => m.type === 'edgeAdded');
|
||||
expect(edgeMutation).toBeDefined();
|
||||
});
|
||||
|
||||
it('dream sequence: start → progress → complete → connections', () => {
|
||||
mapEventToEffects(makeEvent('DreamStarted', {}), ctx, allNodes);
|
||||
expect(effects.pulseEffects.length).toBe(5);
|
||||
|
||||
mapEventToEffects(makeEvent('DreamProgress', { memory_id: 'n1' }), ctx, allNodes);
|
||||
mapEventToEffects(makeEvent('DreamProgress', { memory_id: 'n3' }), ctx, allNodes);
|
||||
|
||||
mapEventToEffects(makeEvent('DreamCompleted', {}), ctx, allNodes);
|
||||
|
||||
// Connections discovered during dream
|
||||
mapEventToEffects(
|
||||
makeEvent('ConnectionDiscovered', {
|
||||
source_id: 'n1',
|
||||
target_id: 'n5',
|
||||
weight: 0.6,
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
// Should have emitted edgeAdded
|
||||
expect(mutations.some((m) => m.type === 'edgeAdded')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
257
apps/dashboard/src/lib/graph/__tests__/force-sim.test.ts
Normal file
257
apps/dashboard/src/lib/graph/__tests__/force-sim.test.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock three.js before any imports that use it
|
||||
vi.mock('three', async () => {
|
||||
const mock = await import('./three-mock');
|
||||
return { ...mock };
|
||||
});
|
||||
|
||||
import { ForceSimulation } from '../force-sim';
|
||||
import { Vector3 } from './three-mock';
|
||||
import { makeNode, makeEdge, resetNodeCounter, tickN } from './helpers';
|
||||
|
||||
describe('ForceSimulation', () => {
|
||||
beforeEach(() => resetNodeCounter());
|
||||
|
||||
function createSim(nodeCount: number) {
|
||||
const positions = new Map<string, InstanceType<typeof Vector3>>();
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
positions.set(`n${i}`, new Vector3(i * 10, 0, 0));
|
||||
}
|
||||
return new ForceSimulation(positions);
|
||||
}
|
||||
|
||||
describe('initialization', () => {
|
||||
it('creates velocities for all positions', () => {
|
||||
const sim = createSim(5);
|
||||
expect(sim.velocities.size).toBe(5);
|
||||
for (const vel of sim.velocities.values()) {
|
||||
expect(vel.x).toBe(0);
|
||||
expect(vel.y).toBe(0);
|
||||
expect(vel.z).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('starts running at step 0', () => {
|
||||
const sim = createSim(3);
|
||||
expect(sim.running).toBe(true);
|
||||
expect(sim.step).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tick', () => {
|
||||
it('increments step count each tick', () => {
|
||||
const sim = createSim(3);
|
||||
sim.tick([]);
|
||||
expect(sim.step).toBe(1);
|
||||
sim.tick([]);
|
||||
expect(sim.step).toBe(2);
|
||||
});
|
||||
|
||||
it('stops ticking after maxSteps', () => {
|
||||
const sim = createSim(2);
|
||||
tickN(sim, [], 301);
|
||||
const posAfter300 = sim.positions.get('n0')!.clone();
|
||||
tickN(sim, [], 10);
|
||||
expect(sim.positions.get('n0')!.x).toBe(posAfter300.x);
|
||||
});
|
||||
|
||||
it('does not tick when not running', () => {
|
||||
const sim = createSim(2);
|
||||
sim.running = false;
|
||||
const posBefore = sim.positions.get('n0')!.clone();
|
||||
sim.tick([]);
|
||||
expect(sim.step).toBe(0);
|
||||
expect(sim.positions.get('n0')!.x).toBe(posBefore.x);
|
||||
});
|
||||
|
||||
it('applies repulsion between nodes', () => {
|
||||
const positions = new Map<string, InstanceType<typeof Vector3>>();
|
||||
positions.set('a', new Vector3(0, 0, 0));
|
||||
positions.set('b', new Vector3(1, 0, 0));
|
||||
const sim = new ForceSimulation(positions);
|
||||
|
||||
sim.tick([]);
|
||||
|
||||
// After repulsion, nodes should have moved apart
|
||||
const a = sim.positions.get('a')!;
|
||||
const b = sim.positions.get('b')!;
|
||||
expect(b.x - a.x).toBeGreaterThan(1); // farther apart
|
||||
});
|
||||
|
||||
it('applies attraction along edges', () => {
|
||||
const positions = new Map<string, InstanceType<typeof Vector3>>();
|
||||
positions.set('a', new Vector3(0, 0, 0));
|
||||
positions.set('b', new Vector3(100, 0, 0));
|
||||
const sim = new ForceSimulation(positions);
|
||||
|
||||
const edges = [makeEdge('a', 'b', { weight: 1.0 })];
|
||||
tickN(sim, edges, 50);
|
||||
|
||||
// After many ticks with attraction, nodes should be closer
|
||||
const dist = sim.positions.get('a')!.distanceTo(sim.positions.get('b')!);
|
||||
expect(dist).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('applies centering force toward origin', () => {
|
||||
const positions = new Map<string, InstanceType<typeof Vector3>>();
|
||||
positions.set('far', new Vector3(1000, 1000, 1000));
|
||||
const sim = new ForceSimulation(positions);
|
||||
|
||||
tickN(sim, [], 100);
|
||||
|
||||
const pos = sim.positions.get('far')!;
|
||||
// Should have moved closer to origin
|
||||
expect(pos.length()).toBeLessThan(1000 * Math.sqrt(3));
|
||||
});
|
||||
});
|
||||
|
||||
describe('addNode', () => {
|
||||
it('adds position and velocity entries', () => {
|
||||
const sim = createSim(2);
|
||||
const newPos = new Vector3(5, 5, 5);
|
||||
sim.addNode('new', newPos);
|
||||
|
||||
expect(sim.positions.has('new')).toBe(true);
|
||||
expect(sim.velocities.has('new')).toBe(true);
|
||||
expect(sim.positions.get('new')!.x).toBe(5);
|
||||
});
|
||||
|
||||
it('clones the input position', () => {
|
||||
const sim = createSim(1);
|
||||
const input = new Vector3(10, 10, 10);
|
||||
sim.addNode('new', input);
|
||||
|
||||
input.x = 999;
|
||||
expect(sim.positions.get('new')!.x).toBe(10);
|
||||
});
|
||||
|
||||
it('re-energizes physics so simulation stays alive', () => {
|
||||
const sim = createSim(2);
|
||||
// Exhaust the simulation
|
||||
tickN(sim, [], 305);
|
||||
expect(sim.step).toBe(301); // stopped at maxSteps+1
|
||||
|
||||
// Add a node
|
||||
sim.addNode('live', new Vector3(0, 0, 0));
|
||||
expect(sim.running).toBe(true);
|
||||
|
||||
// Should be able to tick again
|
||||
const stepBefore = sim.step;
|
||||
sim.tick([]);
|
||||
expect(sim.step).toBe(stepBefore + 1);
|
||||
});
|
||||
|
||||
it('extends maxSteps by cooldown amount', () => {
|
||||
const sim = createSim(2);
|
||||
tickN(sim, [], 250);
|
||||
const stepAtAdd = sim.step;
|
||||
|
||||
sim.addNode('live', new Vector3(0, 0, 0));
|
||||
|
||||
// Should be able to tick at least 100 more times
|
||||
let ticked = 0;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const stepBefore = sim.step;
|
||||
sim.tick([]);
|
||||
if (sim.step > stepBefore) ticked++;
|
||||
}
|
||||
expect(ticked).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeNode', () => {
|
||||
it('removes position and velocity entries', () => {
|
||||
const sim = createSim(3);
|
||||
sim.removeNode('n1');
|
||||
|
||||
expect(sim.positions.has('n1')).toBe(false);
|
||||
expect(sim.velocities.has('n1')).toBe(false);
|
||||
expect(sim.positions.size).toBe(2);
|
||||
});
|
||||
|
||||
it('simulation continues without removed node', () => {
|
||||
const sim = createSim(3);
|
||||
sim.removeNode('n1');
|
||||
|
||||
// Should not throw
|
||||
tickN(sim, [], 10);
|
||||
expect(sim.positions.has('n0')).toBe(true);
|
||||
expect(sim.positions.has('n2')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('resets step count and running state', () => {
|
||||
const sim = createSim(3);
|
||||
tickN(sim, [], 100);
|
||||
sim.running = false;
|
||||
sim.reset();
|
||||
|
||||
expect(sim.step).toBe(0);
|
||||
expect(sim.running).toBe(true);
|
||||
});
|
||||
|
||||
it('zeroes all velocities', () => {
|
||||
const sim = createSim(3);
|
||||
tickN(sim, [], 10);
|
||||
sim.reset();
|
||||
|
||||
for (const vel of sim.velocities.values()) {
|
||||
expect(vel.x).toBe(0);
|
||||
expect(vel.y).toBe(0);
|
||||
expect(vel.z).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('physics convergence', () => {
|
||||
it('two connected nodes reach equilibrium', () => {
|
||||
const positions = new Map<string, InstanceType<typeof Vector3>>();
|
||||
positions.set('a', new Vector3(-50, 0, 0));
|
||||
positions.set('b', new Vector3(50, 0, 0));
|
||||
const sim = new ForceSimulation(positions);
|
||||
|
||||
const edges = [makeEdge('a', 'b', { weight: 0.5 })];
|
||||
tickN(sim, edges, 300);
|
||||
|
||||
// Should reach equilibrium — velocities near zero
|
||||
const velA = sim.velocities.get('a')!;
|
||||
const velB = sim.velocities.get('b')!;
|
||||
expect(Math.abs(velA.x)).toBeLessThan(0.01);
|
||||
expect(Math.abs(velB.x)).toBeLessThan(0.01);
|
||||
});
|
||||
|
||||
it('unconnected nodes repel to stable separation', () => {
|
||||
const positions = new Map<string, InstanceType<typeof Vector3>>();
|
||||
positions.set('a', new Vector3(0, 0, 0));
|
||||
positions.set('b', new Vector3(5, 0, 0));
|
||||
const sim = new ForceSimulation(positions);
|
||||
|
||||
tickN(sim, [], 300);
|
||||
|
||||
const dist = sim.positions.get('a')!.distanceTo(sim.positions.get('b')!);
|
||||
expect(dist).toBeGreaterThan(5); // repelled farther apart
|
||||
});
|
||||
|
||||
it('multiple nodes form a spread-out cluster', () => {
|
||||
const positions = new Map<string, InstanceType<typeof Vector3>>();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
positions.set(`n${i}`, new Vector3(Math.random() * 2, Math.random() * 2, Math.random() * 2));
|
||||
}
|
||||
const sim = new ForceSimulation(positions);
|
||||
|
||||
const edges = [makeEdge('n0', 'n1'), makeEdge('n2', 'n3'), makeEdge('n4', 'n5')];
|
||||
tickN(sim, edges, 300);
|
||||
|
||||
// All nodes should be spread out, no two should overlap
|
||||
const ids = Array.from(positions.keys());
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
const dist = sim.positions.get(ids[i])!.distanceTo(sim.positions.get(ids[j])!);
|
||||
expect(dist).toBeGreaterThan(0.1);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
50
apps/dashboard/src/lib/graph/__tests__/helpers.ts
Normal file
50
apps/dashboard/src/lib/graph/__tests__/helpers.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Test helpers: factories for creating test data.
|
||||
*/
|
||||
import type { GraphNode, GraphEdge, VestigeEvent, VestigeEventType } from '$types';
|
||||
|
||||
let nodeCounter = 0;
|
||||
|
||||
export function makeNode(overrides: Partial<GraphNode> = {}): GraphNode {
|
||||
nodeCounter++;
|
||||
return {
|
||||
id: `node-${nodeCounter}`,
|
||||
label: `Test Node ${nodeCounter}`,
|
||||
type: 'fact',
|
||||
retention: 0.8,
|
||||
tags: ['test'],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
isCenter: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeEdge(
|
||||
source: string,
|
||||
target: string,
|
||||
overrides: Partial<GraphEdge> = {}
|
||||
): GraphEdge {
|
||||
return {
|
||||
source,
|
||||
target,
|
||||
weight: 0.5,
|
||||
type: 'semantic',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeEvent(type: VestigeEventType, data: Record<string, unknown> = {}): VestigeEvent {
|
||||
return { type, data };
|
||||
}
|
||||
|
||||
export function resetNodeCounter() {
|
||||
nodeCounter = 0;
|
||||
}
|
||||
|
||||
/** Run simulation for N ticks */
|
||||
export function tickN(sim: { tick: (edges: GraphEdge[]) => void }, edges: GraphEdge[], n: number) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
sim.tick(edges);
|
||||
}
|
||||
}
|
||||
456
apps/dashboard/src/lib/graph/__tests__/nodes.test.ts
Normal file
456
apps/dashboard/src/lib/graph/__tests__/nodes.test.ts
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('three', async () => {
|
||||
const mock = await import('./three-mock');
|
||||
return { ...mock };
|
||||
});
|
||||
|
||||
import { NodeManager } from '../nodes';
|
||||
import { Vector3 } from './three-mock';
|
||||
import { makeNode, resetNodeCounter } from './helpers';
|
||||
|
||||
describe('NodeManager', () => {
|
||||
let manager: NodeManager;
|
||||
|
||||
beforeEach(() => {
|
||||
resetNodeCounter();
|
||||
manager = new NodeManager();
|
||||
});
|
||||
|
||||
describe('createNodes', () => {
|
||||
it('creates meshes, glows, and labels for all nodes', () => {
|
||||
const nodes = [makeNode({ id: 'a' }), makeNode({ id: 'b' }), makeNode({ id: 'c' })];
|
||||
const positions = manager.createNodes(nodes);
|
||||
|
||||
expect(positions.size).toBe(3);
|
||||
expect(manager.meshMap.size).toBe(3);
|
||||
expect(manager.glowMap.size).toBe(3);
|
||||
expect(manager.labelSprites.size).toBe(3);
|
||||
});
|
||||
|
||||
it('positions center node at origin', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 'center', isCenter: true }),
|
||||
makeNode({ id: 'other' }),
|
||||
];
|
||||
const positions = manager.createNodes(nodes);
|
||||
|
||||
const centerPos = positions.get('center')!;
|
||||
expect(centerPos.x).toBe(0);
|
||||
expect(centerPos.y).toBe(0);
|
||||
expect(centerPos.z).toBe(0);
|
||||
});
|
||||
|
||||
it('scales mesh size by retention', () => {
|
||||
const highRet = makeNode({ id: 'high', retention: 1.0 });
|
||||
const lowRet = makeNode({ id: 'low', retention: 0.1 });
|
||||
manager.createNodes([highRet, lowRet]);
|
||||
|
||||
// SphereGeometry size = 0.5 + retention * 2
|
||||
// High retention should have larger geometry (indirectly via userData)
|
||||
const highMesh = manager.meshMap.get('high')!;
|
||||
const lowMesh = manager.meshMap.get('low')!;
|
||||
expect(highMesh.userData.retention).toBe(1.0);
|
||||
expect(lowMesh.userData.retention).toBe(0.1);
|
||||
});
|
||||
|
||||
it('uses Fibonacci sphere distribution for initial positions', () => {
|
||||
const nodes = Array.from({ length: 20 }, (_, i) => makeNode({ id: `n${i}` }));
|
||||
const positions = manager.createNodes(nodes);
|
||||
|
||||
// No two nodes should be at the same position
|
||||
const posArr = Array.from(positions.values());
|
||||
for (let i = 0; i < posArr.length; i++) {
|
||||
for (let j = i + 1; j < posArr.length; j++) {
|
||||
const dist = posArr[i].distanceTo(posArr[j]);
|
||||
expect(dist).toBeGreaterThan(0.1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('stores node type in userData', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 'fact', type: 'fact' }),
|
||||
makeNode({ id: 'concept', type: 'concept' }),
|
||||
makeNode({ id: 'decision', type: 'decision' }),
|
||||
];
|
||||
manager.createNodes(nodes);
|
||||
|
||||
expect(manager.meshMap.get('fact')!.userData.type).toBe('fact');
|
||||
expect(manager.meshMap.get('concept')!.userData.type).toBe('concept');
|
||||
expect(manager.meshMap.get('decision')!.userData.type).toBe('decision');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addNode — materialization', () => {
|
||||
it('adds a new node at specified position', () => {
|
||||
const node = makeNode({ id: 'live-1' });
|
||||
const pos = new Vector3(10, 20, 30);
|
||||
const result = manager.addNode(node, pos);
|
||||
|
||||
expect(manager.meshMap.has('live-1')).toBe(true);
|
||||
expect(manager.glowMap.has('live-1')).toBe(true);
|
||||
expect(manager.labelSprites.has('live-1')).toBe(true);
|
||||
expect(manager.positions.has('live-1')).toBe(true);
|
||||
|
||||
expect(result.x).toBe(10);
|
||||
expect(result.y).toBe(20);
|
||||
expect(result.z).toBe(30);
|
||||
});
|
||||
|
||||
it('starts node at near-zero scale (not zero to avoid GPU issues)', () => {
|
||||
const node = makeNode({ id: 'live-2' });
|
||||
manager.addNode(node);
|
||||
|
||||
const mesh = manager.meshMap.get('live-2')!;
|
||||
expect(mesh.scale.x).toBeCloseTo(0.001, 3);
|
||||
});
|
||||
|
||||
it('generates random position if none provided', () => {
|
||||
const node = makeNode({ id: 'live-3' });
|
||||
const pos = manager.addNode(node);
|
||||
|
||||
// Should be within ±20 range
|
||||
expect(Math.abs(pos.x)).toBeLessThanOrEqual(20);
|
||||
expect(Math.abs(pos.y)).toBeLessThanOrEqual(20);
|
||||
expect(Math.abs(pos.z)).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
it('clones the input position to prevent external mutation', () => {
|
||||
const node = makeNode({ id: 'live-4' });
|
||||
const input = new Vector3(5, 5, 5);
|
||||
manager.addNode(node, input);
|
||||
|
||||
input.x = 999;
|
||||
expect(manager.positions.get('live-4')!.x).toBe(5);
|
||||
});
|
||||
|
||||
it('label starts fully transparent', () => {
|
||||
const node = makeNode({ id: 'live-5' });
|
||||
manager.addNode(node);
|
||||
|
||||
const label = manager.labelSprites.get('live-5')!;
|
||||
expect((label.material as any).opacity).toBe(0);
|
||||
});
|
||||
|
||||
it('glow starts fully transparent', () => {
|
||||
const node = makeNode({ id: 'live-6' });
|
||||
manager.addNode(node);
|
||||
|
||||
const glow = manager.glowMap.get('live-6')!;
|
||||
expect((glow.material as any).opacity).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('materialization animation choreography', () => {
|
||||
function setupAndAnimate(frames: number) {
|
||||
const nodes = [makeNode({ id: 'existing', retention: 0.5 })];
|
||||
manager.createNodes(nodes);
|
||||
|
||||
const liveNode = makeNode({ id: 'live', retention: 0.9 });
|
||||
manager.addNode(liveNode);
|
||||
|
||||
const allNodes = [...nodes, liveNode];
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
|
||||
for (let f = 0; f < frames; f++) {
|
||||
manager.animate(f * 0.016, allNodes, camera);
|
||||
}
|
||||
|
||||
return {
|
||||
mesh: manager.meshMap.get('live')!,
|
||||
glow: manager.glowMap.get('live')!,
|
||||
label: manager.labelSprites.get('live')!,
|
||||
};
|
||||
}
|
||||
|
||||
it('mesh scale increases during first 30 frames', () => {
|
||||
const { mesh } = setupAndAnimate(15);
|
||||
expect(mesh.scale.x).toBeGreaterThan(0.001);
|
||||
});
|
||||
|
||||
it('mesh reaches approximately full scale by frame 30', () => {
|
||||
const { mesh } = setupAndAnimate(30);
|
||||
// easeOutElastic should be near 1.0 at t=1
|
||||
expect(mesh.scale.x).toBeGreaterThan(0.8);
|
||||
});
|
||||
|
||||
it('glow starts fading in at frame 5', () => {
|
||||
// Before frame 5: opacity should be 0
|
||||
const before = setupAndAnimate(4);
|
||||
expect((before.glow.material as any).opacity).toBe(0);
|
||||
|
||||
// After frame 7: opacity should be positive
|
||||
const after = setupAndAnimate(8);
|
||||
expect((after.glow.material as any).opacity).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('label starts fading in after frame 40', () => {
|
||||
// At frame 39: label should still be transparent
|
||||
const before = setupAndAnimate(39);
|
||||
expect((before.label.material as any).opacity).toBe(0);
|
||||
|
||||
// At frame 50: label should have some opacity
|
||||
const after = setupAndAnimate(50);
|
||||
expect((after.label.material as any).opacity).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('label has positive opacity at frame 55 (during materialization window)', () => {
|
||||
// Label fade-in runs from frame 40 to 60 (during materialization).
|
||||
// After frame 60, distance-based visibility takes over which depends on camera position.
|
||||
// Test within the materialization window itself.
|
||||
const { label } = setupAndAnimate(55);
|
||||
expect((label.material as any).opacity).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('elastic overshoot occurs during materialization', () => {
|
||||
// easeOutElastic should cause scale > 1.0 at some point
|
||||
let maxScale = 0;
|
||||
const nodes = [makeNode({ id: 'existing' })];
|
||||
manager.createNodes(nodes);
|
||||
const liveNode = makeNode({ id: 'elastic', retention: 0.5 });
|
||||
manager.addNode(liveNode);
|
||||
const allNodes = [...nodes, liveNode];
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
|
||||
for (let f = 0; f < 30; f++) {
|
||||
manager.animate(f * 0.016, allNodes, camera);
|
||||
const mesh = manager.meshMap.get('elastic')!;
|
||||
if (mesh.scale.x > maxScale) maxScale = mesh.scale.x;
|
||||
}
|
||||
|
||||
// Elastic should overshoot past 1.0
|
||||
expect(maxScale).toBeGreaterThan(1.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeNode — dissolution', () => {
|
||||
function setupWithNode() {
|
||||
const nodes = [makeNode({ id: 'a' }), makeNode({ id: 'b' })];
|
||||
manager.createNodes(nodes);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
it('marks node for dissolution without immediate removal', () => {
|
||||
setupWithNode();
|
||||
manager.removeNode('a');
|
||||
|
||||
// Mesh should still exist during dissolution animation
|
||||
expect(manager.meshMap.has('a')).toBe(true);
|
||||
});
|
||||
|
||||
it('node is fully removed after dissolution animation completes (60 frames)', () => {
|
||||
const nodes = setupWithNode();
|
||||
manager.removeNode('a');
|
||||
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
for (let f = 0; f < 65; f++) {
|
||||
manager.animate(f * 0.016, nodes, camera);
|
||||
}
|
||||
|
||||
expect(manager.meshMap.has('a')).toBe(false);
|
||||
expect(manager.glowMap.has('a')).toBe(false);
|
||||
expect(manager.labelSprites.has('a')).toBe(false);
|
||||
expect(manager.positions.has('a')).toBe(false);
|
||||
});
|
||||
|
||||
it('node shrinks during dissolution using easeInBack', () => {
|
||||
const nodes = setupWithNode();
|
||||
manager.removeNode('a');
|
||||
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
// Run to near completion (frame 55/60) where shrink is deep
|
||||
for (let f = 0; f < 55; f++) {
|
||||
manager.animate(f * 0.016, nodes, camera);
|
||||
}
|
||||
|
||||
const currentScale = manager.meshMap.get('a')!.scale.x;
|
||||
// At frame 55/60, easeInBack(0.917) ≈ 0.87, shrink = 1-0.87 = 0.13
|
||||
// The originalScale from breathing was ~1.0, scale should be very small
|
||||
expect(currentScale).toBeLessThan(1.0);
|
||||
});
|
||||
|
||||
it('opacity fades during dissolution', () => {
|
||||
const nodes = setupWithNode();
|
||||
manager.removeNode('a');
|
||||
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
for (let f = 0; f < 50; f++) {
|
||||
manager.animate(f * 0.016, nodes, camera);
|
||||
}
|
||||
|
||||
const mesh = manager.meshMap.get('a');
|
||||
if (mesh) {
|
||||
expect((mesh.material as any).opacity).toBeLessThan(0.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('cancels materialization if node removed during spawn', () => {
|
||||
const nodes = [makeNode({ id: 'base' })];
|
||||
manager.createNodes(nodes);
|
||||
|
||||
const liveNode = makeNode({ id: 'spawn-then-die' });
|
||||
manager.addNode(liveNode);
|
||||
|
||||
// Immediately remove before materialization finishes
|
||||
manager.removeNode('spawn-then-die');
|
||||
|
||||
const allNodes = [...nodes, liveNode];
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
|
||||
// Run past both animation durations
|
||||
for (let f = 0; f < 70; f++) {
|
||||
manager.animate(f * 0.016, allNodes, camera);
|
||||
}
|
||||
|
||||
expect(manager.meshMap.has('spawn-then-die')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('growNode — retention change animation', () => {
|
||||
it('grows node to new retention scale', () => {
|
||||
const nodes = [makeNode({ id: 'grow', retention: 0.3 })];
|
||||
manager.createNodes(nodes);
|
||||
const originalScale = manager.meshMap.get('grow')!.scale.x;
|
||||
|
||||
manager.growNode('grow', 0.9);
|
||||
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
for (let f = 0; f < 35; f++) {
|
||||
manager.animate(f * 0.016, nodes, camera);
|
||||
}
|
||||
|
||||
// Target scale = 0.5 + 0.9 * 2 = 2.3
|
||||
const mesh = manager.meshMap.get('grow')!;
|
||||
// Should be near target scale after animation completes
|
||||
expect(mesh.scale.x).toBeGreaterThan(originalScale);
|
||||
});
|
||||
|
||||
it('shrinks node when retention decreases (demotion)', () => {
|
||||
const nodes = [makeNode({ id: 'shrink', retention: 0.9 })];
|
||||
manager.createNodes(nodes);
|
||||
|
||||
manager.growNode('shrink', 0.2);
|
||||
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
for (let f = 0; f < 35; f++) {
|
||||
manager.animate(f * 0.016, nodes, camera);
|
||||
}
|
||||
|
||||
// Target scale = 0.5 + 0.2 * 2 = 0.9 (less than 0.5 + 0.9*2 = 2.3)
|
||||
const mesh = manager.meshMap.get('shrink')!;
|
||||
expect(mesh.userData.retention).toBe(0.2);
|
||||
});
|
||||
|
||||
it('also grows the glow sprite', () => {
|
||||
const nodes = [makeNode({ id: 'glow-grow', retention: 0.3 })];
|
||||
manager.createNodes(nodes);
|
||||
const originalGlowScale = manager.glowMap.get('glow-grow')!.scale.x;
|
||||
|
||||
manager.growNode('glow-grow', 0.95);
|
||||
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
for (let f = 0; f < 35; f++) {
|
||||
manager.animate(f * 0.016, nodes, camera);
|
||||
}
|
||||
|
||||
const newGlowScale = manager.glowMap.get('glow-grow')!.scale.x;
|
||||
expect(newGlowScale).toBeGreaterThan(originalGlowScale);
|
||||
});
|
||||
|
||||
it('handles nonexistent node gracefully', () => {
|
||||
expect(() => manager.growNode('nonexistent', 0.5)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('breathing animation', () => {
|
||||
it('breathing only affects non-animating nodes', () => {
|
||||
const nodes = [makeNode({ id: 'normal' })];
|
||||
manager.createNodes(nodes);
|
||||
const liveNode = makeNode({ id: 'materializing' });
|
||||
manager.addNode(liveNode);
|
||||
|
||||
const allNodes = [...nodes, liveNode];
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
|
||||
// During first few frames, materializing node should use animation scale
|
||||
manager.animate(0.016, allNodes, camera);
|
||||
|
||||
// The materializing node's scale should be from the animation, not breathing
|
||||
const matMesh = manager.meshMap.get('materializing')!;
|
||||
// Its scale should be small (just started materializing)
|
||||
expect(matMesh.scale.x).toBeLessThan(0.5);
|
||||
});
|
||||
|
||||
it('hover increases emissive intensity', () => {
|
||||
const nodes = [makeNode({ id: 'hover-test', retention: 0.5 })];
|
||||
manager.createNodes(nodes);
|
||||
|
||||
manager.hoveredNode = 'hover-test';
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
manager.animate(0, nodes, camera);
|
||||
|
||||
const mat = manager.meshMap.get('hover-test')!.material as any;
|
||||
expect(mat.emissiveIntensity).toBe(1.0);
|
||||
});
|
||||
|
||||
it('selected node gets elevated emissive intensity', () => {
|
||||
const nodes = [makeNode({ id: 'sel-test', retention: 0.5 })];
|
||||
manager.createNodes(nodes);
|
||||
|
||||
manager.selectedNode = 'sel-test';
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
manager.animate(0, nodes, camera);
|
||||
|
||||
const mat = manager.meshMap.get('sel-test')!.material as any;
|
||||
expect(mat.emissiveIntensity).toBe(0.8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('label visibility', () => {
|
||||
it('labels visible for nearby nodes', () => {
|
||||
const nodes = [makeNode({ id: 'near', retention: 0.5 })];
|
||||
manager.createNodes(nodes);
|
||||
|
||||
// Camera very close to the node
|
||||
const nodePos = manager.positions.get('near')!;
|
||||
const camera = { position: nodePos.clone().add(new Vector3(0, 0, 10)) } as any;
|
||||
|
||||
for (let f = 0; f < 30; f++) {
|
||||
manager.animate(f * 0.016, nodes, camera);
|
||||
}
|
||||
|
||||
const label = manager.labelSprites.get('near')!;
|
||||
expect((label.material as any).opacity).toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
it('labels invisible for distant nodes', () => {
|
||||
const nodes = [makeNode({ id: 'far', retention: 0.5 })];
|
||||
manager.createNodes(nodes);
|
||||
|
||||
const nodePos = manager.positions.get('far')!;
|
||||
const camera = { position: nodePos.clone().add(new Vector3(0, 0, 200)) } as any;
|
||||
|
||||
for (let f = 0; f < 30; f++) {
|
||||
manager.animate(f * 0.016, nodes, camera);
|
||||
}
|
||||
|
||||
const label = manager.labelSprites.get('far')!;
|
||||
expect((label.material as any).opacity).toBeLessThan(0.1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispose', () => {
|
||||
it('clears all animation queues', () => {
|
||||
const nodes = [makeNode({ id: 'a' })];
|
||||
manager.createNodes(nodes);
|
||||
manager.addNode(makeNode({ id: 'b' }));
|
||||
manager.removeNode('a');
|
||||
|
||||
manager.dispose();
|
||||
|
||||
// Internal arrays should be empty (tested indirectly by no errors on next animate)
|
||||
// The dispose method clears materializingNodes, dissolvingNodes, growingNodes
|
||||
});
|
||||
});
|
||||
});
|
||||
37
apps/dashboard/src/lib/graph/__tests__/setup.ts
Normal file
37
apps/dashboard/src/lib/graph/__tests__/setup.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Test setup: minimal DOM stubs for canvas-based text rendering.
|
||||
*/
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Minimal canvas 2D context mock
|
||||
const mockContext2D = {
|
||||
clearRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 100 })),
|
||||
font: '',
|
||||
textAlign: '',
|
||||
textBaseline: '',
|
||||
fillStyle: '',
|
||||
shadowColor: '',
|
||||
shadowBlur: 0,
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
};
|
||||
|
||||
// Minimal canvas element mock
|
||||
const mockCanvas = {
|
||||
width: 512,
|
||||
height: 64,
|
||||
getContext: vi.fn(() => mockContext2D),
|
||||
toDataURL: vi.fn(() => 'data:image/png;base64,'),
|
||||
};
|
||||
|
||||
// Stub document.createElement for canvas
|
||||
if (typeof globalThis.document === 'undefined') {
|
||||
(globalThis as any).document = {
|
||||
createElement: vi.fn((tag: string) => {
|
||||
if (tag === 'canvas') return { ...mockCanvas };
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
}
|
||||
438
apps/dashboard/src/lib/graph/__tests__/three-mock.ts
Normal file
438
apps/dashboard/src/lib/graph/__tests__/three-mock.ts
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
/**
|
||||
* Lightweight Three.js mock for unit/integration tests.
|
||||
* Implements the subset of Three.js APIs used by the graph modules.
|
||||
*/
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export class Vector3 {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
|
||||
constructor(x = 0, y = 0, z = 0) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new Vector3(this.x, this.y, this.z);
|
||||
}
|
||||
|
||||
copy(v: Vector3) {
|
||||
this.x = v.x;
|
||||
this.y = v.y;
|
||||
this.z = v.z;
|
||||
return this;
|
||||
}
|
||||
|
||||
set(x: number, y: number, z: number) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
return this;
|
||||
}
|
||||
|
||||
add(v: Vector3) {
|
||||
this.x += v.x;
|
||||
this.y += v.y;
|
||||
this.z += v.z;
|
||||
return this;
|
||||
}
|
||||
|
||||
sub(v: Vector3) {
|
||||
this.x -= v.x;
|
||||
this.y -= v.y;
|
||||
this.z -= v.z;
|
||||
return this;
|
||||
}
|
||||
|
||||
subVectors(a: Vector3, b: Vector3) {
|
||||
this.x = a.x - b.x;
|
||||
this.y = a.y - b.y;
|
||||
this.z = a.z - b.z;
|
||||
return this;
|
||||
}
|
||||
|
||||
multiplyScalar(s: number) {
|
||||
this.x *= s;
|
||||
this.y *= s;
|
||||
this.z *= s;
|
||||
return this;
|
||||
}
|
||||
|
||||
normalize() {
|
||||
const len = this.length() || 1;
|
||||
this.x /= len;
|
||||
this.y /= len;
|
||||
this.z /= len;
|
||||
return this;
|
||||
}
|
||||
|
||||
length() {
|
||||
return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z);
|
||||
}
|
||||
|
||||
distanceTo(v: Vector3) {
|
||||
const dx = this.x - v.x;
|
||||
const dy = this.y - v.y;
|
||||
const dz = this.z - v.z;
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
lerp(v: Vector3, alpha: number) {
|
||||
this.x += (v.x - this.x) * alpha;
|
||||
this.y += (v.y - this.y) * alpha;
|
||||
this.z += (v.z - this.z) * alpha;
|
||||
return this;
|
||||
}
|
||||
|
||||
setScalar(s: number) {
|
||||
this.x = s;
|
||||
this.y = s;
|
||||
this.z = s;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class Vector2 {
|
||||
x: number;
|
||||
y: number;
|
||||
constructor(x = 0, y = 0) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
}
|
||||
|
||||
export class Color {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
|
||||
constructor(colorOrR?: number | string, g?: number, b?: number) {
|
||||
if (typeof colorOrR === 'string') {
|
||||
this.r = 1;
|
||||
this.g = 1;
|
||||
this.b = 1;
|
||||
} else if (typeof colorOrR === 'number' && g === undefined) {
|
||||
this.r = ((colorOrR >> 16) & 255) / 255;
|
||||
this.g = ((colorOrR >> 8) & 255) / 255;
|
||||
this.b = (colorOrR & 255) / 255;
|
||||
} else {
|
||||
this.r = colorOrR ?? 1;
|
||||
this.g = g ?? 1;
|
||||
this.b = b ?? 1;
|
||||
}
|
||||
}
|
||||
|
||||
clone() {
|
||||
const c = new Color();
|
||||
c.r = this.r;
|
||||
c.g = this.g;
|
||||
c.b = this.b;
|
||||
return c;
|
||||
}
|
||||
|
||||
copy(c: Color) {
|
||||
this.r = c.r;
|
||||
this.g = c.g;
|
||||
this.b = c.b;
|
||||
return this;
|
||||
}
|
||||
|
||||
lerp(c: Color, alpha: number) {
|
||||
this.r += (c.r - this.r) * alpha;
|
||||
this.g += (c.g - this.g) * alpha;
|
||||
this.b += (c.b - this.b) * alpha;
|
||||
return this;
|
||||
}
|
||||
|
||||
setHSL(h: number, s: number, l: number) {
|
||||
this.r = h;
|
||||
this.g = s;
|
||||
this.b = l;
|
||||
return this;
|
||||
}
|
||||
|
||||
offsetHSL(_h: number, _s: number, _l: number) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class BufferAttribute {
|
||||
array: Float32Array;
|
||||
itemSize: number;
|
||||
count: number;
|
||||
needsUpdate = false;
|
||||
|
||||
constructor(array: Float32Array, itemSize: number) {
|
||||
this.array = array;
|
||||
this.itemSize = itemSize;
|
||||
this.count = array.length / itemSize;
|
||||
}
|
||||
|
||||
getX(index: number) {
|
||||
return this.array[index * this.itemSize];
|
||||
}
|
||||
getY(index: number) {
|
||||
return this.array[index * this.itemSize + 1];
|
||||
}
|
||||
getZ(index: number) {
|
||||
return this.array[index * this.itemSize + 2];
|
||||
}
|
||||
|
||||
setX(index: number, x: number) {
|
||||
this.array[index * this.itemSize] = x;
|
||||
}
|
||||
setY(index: number, y: number) {
|
||||
this.array[index * this.itemSize + 1] = y;
|
||||
}
|
||||
setZ(index: number, z: number) {
|
||||
this.array[index * this.itemSize + 2] = z;
|
||||
}
|
||||
|
||||
setXYZ(index: number, x: number, y: number, z: number) {
|
||||
const i = index * this.itemSize;
|
||||
this.array[i] = x;
|
||||
this.array[i + 1] = y;
|
||||
this.array[i + 2] = z;
|
||||
}
|
||||
}
|
||||
|
||||
export class BufferGeometry {
|
||||
attributes: Record<string, BufferAttribute> = {};
|
||||
|
||||
setAttribute(name: string, attr: BufferAttribute) {
|
||||
this.attributes[name] = attr;
|
||||
return this;
|
||||
}
|
||||
|
||||
getAttribute(name: string) {
|
||||
return this.attributes[name];
|
||||
}
|
||||
|
||||
setFromPoints(points: Vector3[]) {
|
||||
const arr = new Float32Array(points.length * 3);
|
||||
points.forEach((p, i) => {
|
||||
arr[i * 3] = p.x;
|
||||
arr[i * 3 + 1] = p.y;
|
||||
arr[i * 3 + 2] = p.z;
|
||||
});
|
||||
this.setAttribute('position', new BufferAttribute(arr, 3));
|
||||
return this;
|
||||
}
|
||||
|
||||
dispose() {}
|
||||
}
|
||||
|
||||
export class SphereGeometry extends BufferGeometry {
|
||||
constructor(_radius?: number, _w?: number, _h?: number) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class RingGeometry extends BufferGeometry {
|
||||
constructor(_inner?: number, _outer?: number, _segments?: number) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
class BaseMaterial {
|
||||
color = new Color();
|
||||
transparent = false;
|
||||
opacity = 1;
|
||||
blending = 0;
|
||||
side = 0;
|
||||
map: { dispose: () => void } | null = null;
|
||||
emissive = new Color();
|
||||
emissiveIntensity = 0;
|
||||
roughness = 0;
|
||||
metalness = 0;
|
||||
depthTest = true;
|
||||
sizeAttenuation = true;
|
||||
size = 1;
|
||||
needsUpdate = false;
|
||||
|
||||
dispose() {}
|
||||
}
|
||||
|
||||
export class MeshStandardMaterial extends BaseMaterial {
|
||||
constructor(params?: Record<string, unknown>) {
|
||||
super();
|
||||
if (params) {
|
||||
if (params.color instanceof Color) this.color = params.color;
|
||||
if (params.emissive instanceof Color) this.emissive = params.emissive;
|
||||
if (typeof params.emissiveIntensity === 'number') this.emissiveIntensity = params.emissiveIntensity;
|
||||
if (typeof params.opacity === 'number') this.opacity = params.opacity;
|
||||
if (typeof params.transparent === 'boolean') this.transparent = params.transparent;
|
||||
if (typeof params.roughness === 'number') this.roughness = params.roughness;
|
||||
if (typeof params.metalness === 'number') this.metalness = params.metalness;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MeshBasicMaterial extends BaseMaterial {
|
||||
constructor(params?: Record<string, unknown>) {
|
||||
super();
|
||||
if (params) {
|
||||
if (typeof params.opacity === 'number') this.opacity = params.opacity;
|
||||
if (typeof params.transparent === 'boolean') this.transparent = params.transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class LineBasicMaterial extends BaseMaterial {
|
||||
constructor(params?: Record<string, unknown>) {
|
||||
super();
|
||||
if (params) {
|
||||
if (typeof params.opacity === 'number') this.opacity = params.opacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PointsMaterial extends BaseMaterial {
|
||||
constructor(params?: Record<string, unknown>) {
|
||||
super();
|
||||
if (params) {
|
||||
if (params.color instanceof Color) this.color = params.color;
|
||||
else if (typeof params.color === 'number') this.color = new Color(params.color);
|
||||
if (typeof params.size === 'number') this.size = params.size;
|
||||
if (typeof params.opacity === 'number') this.opacity = params.opacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SpriteMaterial extends BaseMaterial {
|
||||
constructor(params?: Record<string, unknown>) {
|
||||
super();
|
||||
if (params) {
|
||||
if (typeof params.opacity === 'number') this.opacity = params.opacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Object3D {
|
||||
position = new Vector3();
|
||||
scale = new Vector3(1, 1, 1);
|
||||
userData: Record<string, unknown> = {};
|
||||
children: Object3D[] = [];
|
||||
parent: Object3D | null = null;
|
||||
|
||||
add(child: Object3D) {
|
||||
this.children.push(child);
|
||||
child.parent = this;
|
||||
return this;
|
||||
}
|
||||
|
||||
remove(child: Object3D) {
|
||||
const idx = this.children.indexOf(child);
|
||||
if (idx !== -1) {
|
||||
this.children.splice(idx, 1);
|
||||
child.parent = null;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
traverse(callback: (obj: Object3D) => void) {
|
||||
callback(this);
|
||||
for (const child of this.children) {
|
||||
child.traverse(callback);
|
||||
}
|
||||
}
|
||||
|
||||
lookAt(_target: Vector3) {}
|
||||
}
|
||||
|
||||
export class Group extends Object3D {}
|
||||
export class Scene extends Object3D {}
|
||||
|
||||
export class Mesh extends Object3D {
|
||||
geometry: BufferGeometry;
|
||||
material: BaseMaterial;
|
||||
|
||||
constructor(geometry?: BufferGeometry, material?: BaseMaterial) {
|
||||
super();
|
||||
this.geometry = geometry ?? new BufferGeometry();
|
||||
this.material = material ?? new BaseMaterial();
|
||||
}
|
||||
}
|
||||
|
||||
export class Line extends Object3D {
|
||||
geometry: BufferGeometry;
|
||||
material: BaseMaterial;
|
||||
|
||||
constructor(geometry?: BufferGeometry, material?: BaseMaterial) {
|
||||
super();
|
||||
this.geometry = geometry ?? new BufferGeometry();
|
||||
this.material = material ?? new BaseMaterial();
|
||||
}
|
||||
}
|
||||
|
||||
export class Points extends Object3D {
|
||||
geometry: BufferGeometry;
|
||||
material: BaseMaterial;
|
||||
|
||||
constructor(geometry?: BufferGeometry, material?: BaseMaterial) {
|
||||
super();
|
||||
this.geometry = geometry ?? new BufferGeometry();
|
||||
this.material = material ?? new BaseMaterial();
|
||||
}
|
||||
}
|
||||
|
||||
export class Sprite extends Object3D {
|
||||
material: BaseMaterial;
|
||||
|
||||
constructor(material?: BaseMaterial) {
|
||||
super();
|
||||
this.material = material ?? new BaseMaterial();
|
||||
}
|
||||
}
|
||||
|
||||
export class PerspectiveCamera extends Object3D {
|
||||
fov = 60;
|
||||
aspect = 1;
|
||||
near = 0.1;
|
||||
far = 2000;
|
||||
}
|
||||
|
||||
export class Camera extends Object3D {}
|
||||
|
||||
export class CanvasTexture {
|
||||
needsUpdate = false;
|
||||
constructor(_canvas: unknown) {}
|
||||
dispose() {}
|
||||
}
|
||||
|
||||
// Blending constants
|
||||
export const AdditiveBlending = 2;
|
||||
export const DoubleSide = 2;
|
||||
|
||||
// Install mock globally for 'three' imports
|
||||
export function installThreeMock() {
|
||||
vi.mock('three', () => ({
|
||||
Vector3,
|
||||
Vector2,
|
||||
Color,
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
SphereGeometry,
|
||||
RingGeometry,
|
||||
MeshStandardMaterial,
|
||||
MeshBasicMaterial,
|
||||
LineBasicMaterial,
|
||||
PointsMaterial,
|
||||
SpriteMaterial,
|
||||
Object3D,
|
||||
Group,
|
||||
Scene,
|
||||
Mesh,
|
||||
Line,
|
||||
Points,
|
||||
Sprite,
|
||||
PerspectiveCamera,
|
||||
Camera,
|
||||
CanvasTexture,
|
||||
AdditiveBlending,
|
||||
DoubleSide,
|
||||
}));
|
||||
}
|
||||
|
|
@ -1,8 +1,28 @@
|
|||
import * as THREE from 'three';
|
||||
import type { GraphEdge } from '$types';
|
||||
|
||||
function easeOutCubic(t: number): number {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
interface GrowingEdge {
|
||||
line: THREE.Line;
|
||||
source: string;
|
||||
target: string;
|
||||
frame: number;
|
||||
totalFrames: number;
|
||||
}
|
||||
|
||||
interface DissolvingEdge {
|
||||
line: THREE.Line;
|
||||
frame: number;
|
||||
totalFrames: number;
|
||||
}
|
||||
|
||||
export class EdgeManager {
|
||||
group: THREE.Group;
|
||||
private growingEdges: GrowingEdge[] = [];
|
||||
private dissolvingEdges: DissolvingEdge[] = [];
|
||||
|
||||
constructor() {
|
||||
this.group = new THREE.Group();
|
||||
|
|
@ -29,9 +49,101 @@ export class EdgeManager {
|
|||
}
|
||||
}
|
||||
|
||||
addEdge(edge: GraphEdge, positions: Map<string, THREE.Vector3>) {
|
||||
const sourcePos = positions.get(edge.source);
|
||||
const targetPos = positions.get(edge.target);
|
||||
if (!sourcePos || !targetPos) return;
|
||||
|
||||
// Start with zero-length line at source position
|
||||
const points = [sourcePos.clone(), sourcePos.clone()];
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const material = new THREE.LineBasicMaterial({
|
||||
color: 0x4a4a7a,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
|
||||
const line = new THREE.Line(geometry, material);
|
||||
line.userData = { source: edge.source, target: edge.target };
|
||||
this.group.add(line);
|
||||
|
||||
this.growingEdges.push({
|
||||
line,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
frame: 0,
|
||||
totalFrames: 45,
|
||||
});
|
||||
}
|
||||
|
||||
removeEdgesForNode(nodeId: string) {
|
||||
const toDissolve: THREE.Line[] = [];
|
||||
this.group.children.forEach((child) => {
|
||||
const line = child as THREE.Line;
|
||||
if (line.userData.source === nodeId || line.userData.target === nodeId) {
|
||||
toDissolve.push(line);
|
||||
}
|
||||
});
|
||||
|
||||
for (const line of toDissolve) {
|
||||
// Remove from growing if still animating
|
||||
this.growingEdges = this.growingEdges.filter((g) => g.line !== line);
|
||||
this.dissolvingEdges.push({ line, frame: 0, totalFrames: 40 });
|
||||
}
|
||||
}
|
||||
|
||||
animateEdges(positions: Map<string, THREE.Vector3>) {
|
||||
// Growing edges — interpolate endpoint from source to target
|
||||
for (let i = this.growingEdges.length - 1; i >= 0; i--) {
|
||||
const g = this.growingEdges[i];
|
||||
g.frame++;
|
||||
const progress = easeOutCubic(Math.min(g.frame / g.totalFrames, 1));
|
||||
|
||||
const sourcePos = positions.get(g.source);
|
||||
const targetPos = positions.get(g.target);
|
||||
if (!sourcePos || !targetPos) continue;
|
||||
|
||||
const currentEnd = sourcePos.clone().lerp(targetPos, progress);
|
||||
const attrs = g.line.geometry.attributes.position as THREE.BufferAttribute;
|
||||
attrs.setXYZ(0, sourcePos.x, sourcePos.y, sourcePos.z);
|
||||
attrs.setXYZ(1, currentEnd.x, currentEnd.y, currentEnd.z);
|
||||
attrs.needsUpdate = true;
|
||||
|
||||
const mat = g.line.material as THREE.LineBasicMaterial;
|
||||
mat.opacity = progress * 0.5;
|
||||
|
||||
if (g.frame >= g.totalFrames) {
|
||||
// Final opacity from weight
|
||||
mat.opacity = 0.5;
|
||||
this.growingEdges.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Dissolving edges — fade out
|
||||
for (let i = this.dissolvingEdges.length - 1; i >= 0; i--) {
|
||||
const d = this.dissolvingEdges[i];
|
||||
d.frame++;
|
||||
const progress = d.frame / d.totalFrames;
|
||||
const mat = d.line.material as THREE.LineBasicMaterial;
|
||||
mat.opacity = Math.max(0, 0.5 * (1 - progress));
|
||||
|
||||
if (d.frame >= d.totalFrames) {
|
||||
this.group.remove(d.line);
|
||||
d.line.geometry.dispose();
|
||||
(d.line.material as THREE.Material).dispose();
|
||||
this.dissolvingEdges.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatePositions(positions: Map<string, THREE.Vector3>) {
|
||||
this.group.children.forEach((child) => {
|
||||
const line = child as THREE.Line;
|
||||
// Skip lines currently being animated by animateEdges
|
||||
if (this.growingEdges.some((g) => g.line === line)) return;
|
||||
if (this.dissolvingEdges.some((d) => d.line === line)) return;
|
||||
|
||||
const sourcePos = positions.get(line.userData.source);
|
||||
const targetPos = positions.get(line.userData.target);
|
||||
if (sourcePos && targetPos) {
|
||||
|
|
@ -49,5 +161,7 @@ export class EdgeManager {
|
|||
line.geometry?.dispose();
|
||||
(line.material as THREE.Material)?.dispose();
|
||||
});
|
||||
this.growingEdges = [];
|
||||
this.dissolvingEdges = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,31 @@ interface SpawnBurst {
|
|||
particles: THREE.Points;
|
||||
}
|
||||
|
||||
interface RainbowBurst {
|
||||
position: THREE.Vector3;
|
||||
age: number;
|
||||
maxAge: number;
|
||||
particles: THREE.Points;
|
||||
baseColor: THREE.Color;
|
||||
}
|
||||
|
||||
interface RippleWave {
|
||||
origin: THREE.Vector3;
|
||||
radius: number;
|
||||
speed: number;
|
||||
age: number;
|
||||
maxAge: number;
|
||||
pulsedNodes: Set<string>;
|
||||
}
|
||||
|
||||
interface ImplosionEffect {
|
||||
position: THREE.Vector3;
|
||||
age: number;
|
||||
maxAge: number;
|
||||
particles: THREE.Points;
|
||||
flash: THREE.Mesh | null;
|
||||
}
|
||||
|
||||
interface Shockwave {
|
||||
mesh: THREE.Mesh;
|
||||
age: number;
|
||||
|
|
@ -27,6 +52,9 @@ interface ConnectionFlash {
|
|||
export class EffectManager {
|
||||
pulseEffects: PulseEffect[] = [];
|
||||
private spawnBursts: SpawnBurst[] = [];
|
||||
private rainbowBursts: RainbowBurst[] = [];
|
||||
private rippleWaves: RippleWave[] = [];
|
||||
private implosions: ImplosionEffect[] = [];
|
||||
private shockwaves: Shockwave[] = [];
|
||||
private connectionFlashes: ConnectionFlash[] = [];
|
||||
private scene: THREE.Scene;
|
||||
|
|
@ -90,6 +118,105 @@ export class EffectManager {
|
|||
this.shockwaves.push({ mesh: ring, age: 0, maxAge: 60 });
|
||||
}
|
||||
|
||||
createRainbowBurst(position: THREE.Vector3, baseColor: THREE.Color) {
|
||||
const count = 120;
|
||||
const geo = new THREE.BufferGeometry();
|
||||
const positions = new Float32Array(count * 3);
|
||||
const velocities = new Float32Array(count * 3);
|
||||
const hueOffsets = new Float32Array(count);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
positions[i * 3] = position.x;
|
||||
positions[i * 3 + 1] = position.y;
|
||||
positions[i * 3 + 2] = position.z;
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
const speed = 0.2 + Math.random() * 0.6;
|
||||
velocities[i * 3] = Math.sin(phi) * Math.cos(theta) * speed;
|
||||
velocities[i * 3 + 1] = Math.sin(phi) * Math.sin(theta) * speed;
|
||||
velocities[i * 3 + 2] = Math.cos(phi) * speed;
|
||||
hueOffsets[i] = Math.random();
|
||||
}
|
||||
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('velocity', new THREE.BufferAttribute(velocities, 3));
|
||||
geo.setAttribute('hueOffset', new THREE.BufferAttribute(hueOffsets, 1));
|
||||
|
||||
const mat = new THREE.PointsMaterial({
|
||||
color: baseColor,
|
||||
size: 0.8,
|
||||
transparent: true,
|
||||
opacity: 1.0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
|
||||
const pts = new THREE.Points(geo, mat);
|
||||
this.scene.add(pts);
|
||||
this.rainbowBursts.push({
|
||||
position: position.clone(),
|
||||
age: 0,
|
||||
maxAge: 180, // 3 seconds at 60fps
|
||||
particles: pts,
|
||||
baseColor: baseColor.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
createRippleWave(origin: THREE.Vector3) {
|
||||
this.rippleWaves.push({
|
||||
origin: origin.clone(),
|
||||
radius: 0,
|
||||
speed: 1.2,
|
||||
age: 0,
|
||||
maxAge: 90,
|
||||
pulsedNodes: new Set(),
|
||||
});
|
||||
}
|
||||
|
||||
createImplosion(position: THREE.Vector3, color: THREE.Color) {
|
||||
const count = 40;
|
||||
const geo = new THREE.BufferGeometry();
|
||||
const positions = new Float32Array(count * 3);
|
||||
const velocities = new Float32Array(count * 3);
|
||||
|
||||
// Particles start at random positions in a sphere around the target
|
||||
const startRadius = 8;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
const r = startRadius * (0.5 + Math.random() * 0.5);
|
||||
positions[i * 3] = position.x + Math.sin(phi) * Math.cos(theta) * r;
|
||||
positions[i * 3 + 1] = position.y + Math.sin(phi) * Math.sin(theta) * r;
|
||||
positions[i * 3 + 2] = position.z + Math.cos(phi) * r;
|
||||
// Velocity points INWARD toward the center
|
||||
velocities[i * 3] = (position.x - positions[i * 3]) * 0.04;
|
||||
velocities[i * 3 + 1] = (position.y - positions[i * 3 + 1]) * 0.04;
|
||||
velocities[i * 3 + 2] = (position.z - positions[i * 3 + 2]) * 0.04;
|
||||
}
|
||||
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('velocity', new THREE.BufferAttribute(velocities, 3));
|
||||
|
||||
const mat = new THREE.PointsMaterial({
|
||||
color,
|
||||
size: 0.5,
|
||||
transparent: true,
|
||||
opacity: 1.0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
|
||||
const pts = new THREE.Points(geo, mat);
|
||||
this.scene.add(pts);
|
||||
this.implosions.push({
|
||||
position: position.clone(),
|
||||
age: 0,
|
||||
maxAge: 60,
|
||||
particles: pts,
|
||||
flash: null,
|
||||
});
|
||||
}
|
||||
|
||||
createConnectionFlash(from: THREE.Vector3, to: THREE.Vector3, color: THREE.Color) {
|
||||
const points = [from.clone(), to.clone()];
|
||||
const geo = new THREE.BufferGeometry().setFromPoints(points);
|
||||
|
|
@ -104,7 +231,11 @@ export class EffectManager {
|
|||
this.connectionFlashes.push({ line, intensity: 1.0 });
|
||||
}
|
||||
|
||||
update(nodeMeshMap: Map<string, THREE.Mesh>, camera: THREE.Camera) {
|
||||
update(
|
||||
nodeMeshMap: Map<string, THREE.Mesh>,
|
||||
camera: THREE.Camera,
|
||||
nodePositions?: Map<string, THREE.Vector3>
|
||||
) {
|
||||
// Pulse effects
|
||||
for (let i = this.pulseEffects.length - 1; i >= 0; i--) {
|
||||
const pulse = this.pulseEffects[i];
|
||||
|
|
@ -121,7 +252,7 @@ export class EffectManager {
|
|||
}
|
||||
}
|
||||
|
||||
// Spawn bursts
|
||||
// Spawn bursts (original)
|
||||
for (let i = this.spawnBursts.length - 1; i >= 0; i--) {
|
||||
const burst = this.spawnBursts[i];
|
||||
burst.age++;
|
||||
|
|
@ -148,6 +279,131 @@ export class EffectManager {
|
|||
mat.size = 0.6 * (1 - burst.age / 200);
|
||||
}
|
||||
|
||||
// Rainbow bursts — HSL cycling, pulsing size, 3-second lifespan
|
||||
for (let i = this.rainbowBursts.length - 1; i >= 0; i--) {
|
||||
const rb = this.rainbowBursts[i];
|
||||
rb.age++;
|
||||
if (rb.age > rb.maxAge) {
|
||||
this.scene.remove(rb.particles);
|
||||
rb.particles.geometry.dispose();
|
||||
(rb.particles.material as THREE.Material).dispose();
|
||||
this.rainbowBursts.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
const positions = rb.particles.geometry.attributes.position as THREE.BufferAttribute;
|
||||
const vels = rb.particles.geometry.attributes.velocity as THREE.BufferAttribute;
|
||||
for (let j = 0; j < positions.count; j++) {
|
||||
positions.setX(j, positions.getX(j) + vels.getX(j));
|
||||
positions.setY(j, positions.getY(j) + vels.getY(j));
|
||||
positions.setZ(j, positions.getZ(j) + vels.getZ(j));
|
||||
vels.setX(j, vels.getX(j) * 0.98);
|
||||
vels.setY(j, vels.getY(j) * 0.98);
|
||||
vels.setZ(j, vels.getZ(j) * 0.98);
|
||||
}
|
||||
positions.needsUpdate = true;
|
||||
|
||||
const progress = rb.age / rb.maxAge;
|
||||
const mat = rb.particles.material as THREE.PointsMaterial;
|
||||
// Rainbow HSL cycling blended with base color
|
||||
const hue = (rb.age * 0.02) % 1;
|
||||
const rainbowColor = new THREE.Color().setHSL(hue, 1.0, 0.6);
|
||||
mat.color.copy(rb.baseColor).lerp(rainbowColor, 0.6);
|
||||
mat.opacity = Math.max(0, 1 - progress * progress);
|
||||
// Pulsing size
|
||||
mat.size = 0.8 * (1 - progress * 0.5) * (1 + Math.sin(rb.age * 0.3) * 0.2);
|
||||
}
|
||||
|
||||
// Ripple waves — expanding wavefront, pulse nearby nodes on contact
|
||||
if (nodePositions) {
|
||||
for (let i = this.rippleWaves.length - 1; i >= 0; i--) {
|
||||
const rw = this.rippleWaves[i];
|
||||
rw.age++;
|
||||
rw.radius += rw.speed;
|
||||
|
||||
if (rw.age > rw.maxAge) {
|
||||
this.rippleWaves.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check nodes in range of the expanding wavefront
|
||||
const waveFront = rw.radius;
|
||||
const waveWidth = 3.0;
|
||||
nodePositions.forEach((pos, id) => {
|
||||
if (rw.pulsedNodes.has(id)) return;
|
||||
const dist = pos.distanceTo(rw.origin);
|
||||
if (dist >= waveFront - waveWidth && dist <= waveFront + waveWidth) {
|
||||
rw.pulsedNodes.add(id);
|
||||
// Mini-pulse on contact
|
||||
this.addPulse(id, 0.8, new THREE.Color(0x00ffd1), 0.03);
|
||||
// Mini scale bump on the mesh
|
||||
const mesh = nodeMeshMap.get(id);
|
||||
if (mesh) {
|
||||
mesh.scale.multiplyScalar(1.3);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Implosion effects — particles rush inward, converge, then flash
|
||||
for (let i = this.implosions.length - 1; i >= 0; i--) {
|
||||
const imp = this.implosions[i];
|
||||
imp.age++;
|
||||
|
||||
if (imp.age > imp.maxAge + 20) {
|
||||
this.scene.remove(imp.particles);
|
||||
imp.particles.geometry.dispose();
|
||||
(imp.particles.material as THREE.Material).dispose();
|
||||
if (imp.flash) {
|
||||
this.scene.remove(imp.flash);
|
||||
imp.flash.geometry.dispose();
|
||||
(imp.flash.material as THREE.Material).dispose();
|
||||
}
|
||||
this.implosions.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (imp.age <= imp.maxAge) {
|
||||
const positions = imp.particles.geometry.attributes.position as THREE.BufferAttribute;
|
||||
const vels = imp.particles.geometry.attributes.velocity as THREE.BufferAttribute;
|
||||
// Accelerate inward
|
||||
const accelFactor = 1 + imp.age * 0.02;
|
||||
for (let j = 0; j < positions.count; j++) {
|
||||
positions.setX(j, positions.getX(j) + vels.getX(j) * accelFactor);
|
||||
positions.setY(j, positions.getY(j) + vels.getY(j) * accelFactor);
|
||||
positions.setZ(j, positions.getZ(j) + vels.getZ(j) * accelFactor);
|
||||
}
|
||||
positions.needsUpdate = true;
|
||||
|
||||
const mat = imp.particles.material as THREE.PointsMaterial;
|
||||
mat.opacity = Math.min(1.0, imp.age / 15);
|
||||
mat.size = 0.5 + (imp.age / imp.maxAge) * 0.3;
|
||||
}
|
||||
|
||||
// Flash at convergence point
|
||||
if (imp.age === imp.maxAge && !imp.flash) {
|
||||
const flashGeo = new THREE.SphereGeometry(2, 16, 16);
|
||||
const flashMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
transparent: true,
|
||||
opacity: 1.0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
imp.flash = new THREE.Mesh(flashGeo, flashMat);
|
||||
imp.flash.position.copy(imp.position);
|
||||
this.scene.add(imp.flash);
|
||||
// Hide particles
|
||||
(imp.particles.material as THREE.PointsMaterial).opacity = 0;
|
||||
}
|
||||
|
||||
// Flash fade out
|
||||
if (imp.flash && imp.age > imp.maxAge) {
|
||||
const flashProgress = (imp.age - imp.maxAge) / 20;
|
||||
(imp.flash.material as THREE.MeshBasicMaterial).opacity = Math.max(0, 1 - flashProgress);
|
||||
imp.flash.scale.setScalar(1 + flashProgress * 3);
|
||||
}
|
||||
}
|
||||
|
||||
// Shockwaves
|
||||
for (let i = this.shockwaves.length - 1; i >= 0; i--) {
|
||||
const sw = this.shockwaves[i];
|
||||
|
|
@ -186,6 +442,21 @@ export class EffectManager {
|
|||
burst.particles.geometry.dispose();
|
||||
(burst.particles.material as THREE.Material).dispose();
|
||||
}
|
||||
for (const rb of this.rainbowBursts) {
|
||||
this.scene.remove(rb.particles);
|
||||
rb.particles.geometry.dispose();
|
||||
(rb.particles.material as THREE.Material).dispose();
|
||||
}
|
||||
for (const imp of this.implosions) {
|
||||
this.scene.remove(imp.particles);
|
||||
imp.particles.geometry.dispose();
|
||||
(imp.particles.material as THREE.Material).dispose();
|
||||
if (imp.flash) {
|
||||
this.scene.remove(imp.flash);
|
||||
imp.flash.geometry.dispose();
|
||||
(imp.flash.material as THREE.Material).dispose();
|
||||
}
|
||||
}
|
||||
for (const sw of this.shockwaves) {
|
||||
this.scene.remove(sw.mesh);
|
||||
sw.mesh.geometry.dispose();
|
||||
|
|
@ -198,6 +469,9 @@ export class EffectManager {
|
|||
}
|
||||
this.pulseEffects = [];
|
||||
this.spawnBursts = [];
|
||||
this.rainbowBursts = [];
|
||||
this.rippleWaves = [];
|
||||
this.implosions = [];
|
||||
this.shockwaves = [];
|
||||
this.connectionFlashes = [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,288 @@
|
|||
import * as THREE from 'three';
|
||||
import type { VestigeEvent } from '$types';
|
||||
import type { VestigeEvent, GraphNode, GraphEdge } from '$types';
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
import type { EffectManager } from './effects';
|
||||
import type { NodeManager } from './nodes';
|
||||
import type { EdgeManager } from './edges';
|
||||
import type { ForceSimulation } from './force-sim';
|
||||
|
||||
/** Maximum number of live-spawned nodes before FIFO eviction */
|
||||
const MAX_LIVE_NODES = 50;
|
||||
|
||||
export interface GraphMutationContext {
|
||||
effects: EffectManager;
|
||||
nodeManager: NodeManager;
|
||||
edgeManager: EdgeManager;
|
||||
forceSim: ForceSimulation;
|
||||
camera: THREE.Camera;
|
||||
onMutation: (mutation: GraphMutation) => void;
|
||||
}
|
||||
|
||||
export type GraphMutation =
|
||||
| { type: 'nodeAdded'; node: GraphNode }
|
||||
| { type: 'nodeRemoved'; nodeId: string }
|
||||
| { type: 'edgeAdded'; edge: GraphEdge }
|
||||
| { type: 'edgesRemoved'; nodeId: string }
|
||||
| { type: 'nodeUpdated'; nodeId: string; retention: number };
|
||||
|
||||
/** Track live-spawned node IDs in insertion order for FIFO eviction */
|
||||
const liveSpawnedNodes: string[] = [];
|
||||
|
||||
/** Reset live spawn tracking (for tests) */
|
||||
export function resetLiveSpawnTracking() {
|
||||
liveSpawnedNodes.length = 0;
|
||||
}
|
||||
|
||||
function findSpawnPosition(
|
||||
newNode: { tags?: string[]; type?: string },
|
||||
existingNodes: GraphNode[],
|
||||
positions: Map<string, THREE.Vector3>
|
||||
): THREE.Vector3 {
|
||||
const tags = newNode.tags ?? [];
|
||||
const type = newNode.type ?? '';
|
||||
|
||||
// Score existing nodes by tag overlap + type match
|
||||
let bestId: string | null = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const existing of existingNodes) {
|
||||
let score = 0;
|
||||
if (existing.type === type) score += 2;
|
||||
for (const tag of existing.tags) {
|
||||
if (tags.includes(tag)) score += 1;
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestId = existing.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestId && bestScore > 0) {
|
||||
const nearPos = positions.get(bestId);
|
||||
if (nearPos) {
|
||||
// Spawn nearby with some jitter
|
||||
return new THREE.Vector3(
|
||||
nearPos.x + (Math.random() - 0.5) * 10,
|
||||
nearPos.y + (Math.random() - 0.5) * 10,
|
||||
nearPos.z + (Math.random() - 0.5) * 10
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: random position in graph space
|
||||
return new THREE.Vector3(
|
||||
(Math.random() - 0.5) * 40,
|
||||
(Math.random() - 0.5) * 40,
|
||||
(Math.random() - 0.5) * 40
|
||||
);
|
||||
}
|
||||
|
||||
function evictOldestLiveNode(ctx: GraphMutationContext, allNodes: GraphNode[]) {
|
||||
if (liveSpawnedNodes.length <= MAX_LIVE_NODES) return;
|
||||
const evictId = liveSpawnedNodes.shift()!;
|
||||
ctx.edgeManager.removeEdgesForNode(evictId);
|
||||
ctx.nodeManager.removeNode(evictId);
|
||||
ctx.forceSim.removeNode(evictId);
|
||||
ctx.onMutation({ type: 'edgesRemoved', nodeId: evictId });
|
||||
ctx.onMutation({ type: 'nodeRemoved', nodeId: evictId });
|
||||
// Remove from allNodes tracking
|
||||
const idx = allNodes.findIndex((n) => n.id === evictId);
|
||||
if (idx !== -1) allNodes.splice(idx, 1);
|
||||
}
|
||||
|
||||
export function mapEventToEffects(
|
||||
event: VestigeEvent,
|
||||
effects: EffectManager,
|
||||
nodePositions: Map<string, THREE.Vector3>,
|
||||
nodeMeshMap: Map<string, THREE.Mesh>,
|
||||
camera: THREE.Camera
|
||||
ctx: GraphMutationContext,
|
||||
allNodes: GraphNode[]
|
||||
) {
|
||||
const { effects, nodeManager, edgeManager, forceSim, camera, onMutation } = ctx;
|
||||
const nodePositions = nodeManager.positions;
|
||||
const nodeMeshMap = nodeManager.meshMap;
|
||||
|
||||
switch (event.type) {
|
||||
case 'MemoryCreated': {
|
||||
const nodeId = (event.data as { id?: string })?.id;
|
||||
const pos = nodeId ? nodePositions.get(nodeId) : null;
|
||||
const burstPos =
|
||||
pos?.clone() ??
|
||||
new THREE.Vector3(
|
||||
(Math.random() - 0.5) * 40,
|
||||
(Math.random() - 0.5) * 40,
|
||||
(Math.random() - 0.5) * 40
|
||||
);
|
||||
effects.createSpawnBurst(burstPos, new THREE.Color(0x00ffd1));
|
||||
effects.createShockwave(burstPos, new THREE.Color(0x00ffd1), camera);
|
||||
const data = event.data as {
|
||||
id?: string;
|
||||
content?: string;
|
||||
node_type?: string;
|
||||
tags?: string[];
|
||||
retention?: number;
|
||||
};
|
||||
if (!data.id) break;
|
||||
|
||||
// Build a GraphNode from event data
|
||||
const newNode: GraphNode = {
|
||||
id: data.id,
|
||||
label: (data.content ?? '').slice(0, 60),
|
||||
type: data.node_type ?? 'fact',
|
||||
retention: data.retention ?? 0.9,
|
||||
tags: data.tags ?? [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
isCenter: false,
|
||||
};
|
||||
|
||||
// Find spawn position near related nodes
|
||||
const spawnPos = findSpawnPosition(newNode, allNodes, nodePositions);
|
||||
|
||||
// Add to all managers
|
||||
const pos = nodeManager.addNode(newNode, spawnPos);
|
||||
forceSim.addNode(data.id, pos);
|
||||
|
||||
// FIFO eviction
|
||||
liveSpawnedNodes.push(data.id);
|
||||
evictOldestLiveNode(ctx, allNodes);
|
||||
|
||||
// Spectacular effects: rainbow burst + double shockwave + ripple wave
|
||||
const color = new THREE.Color(NODE_TYPE_COLORS[newNode.type] || '#00ffd1');
|
||||
effects.createRainbowBurst(spawnPos, color);
|
||||
effects.createShockwave(spawnPos, color, camera);
|
||||
// Second shockwave, hue-shifted, delayed via smaller initial scale
|
||||
const hueShifted = color.clone();
|
||||
hueShifted.offsetHSL(0.15, 0, 0);
|
||||
setTimeout(() => {
|
||||
effects.createShockwave(spawnPos, hueShifted, camera);
|
||||
}, 166); // ~10 frames at 60fps
|
||||
effects.createRippleWave(spawnPos);
|
||||
|
||||
onMutation({ type: 'nodeAdded', node: newNode });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ConnectionDiscovered': {
|
||||
const data = event.data as {
|
||||
source_id?: string;
|
||||
target_id?: string;
|
||||
weight?: number;
|
||||
connection_type?: string;
|
||||
};
|
||||
if (!data.source_id || !data.target_id) break;
|
||||
|
||||
const srcPos = nodePositions.get(data.source_id);
|
||||
const tgtPos = nodePositions.get(data.target_id);
|
||||
|
||||
const newEdge: GraphEdge = {
|
||||
source: data.source_id,
|
||||
target: data.target_id,
|
||||
weight: data.weight ?? 0.5,
|
||||
type: data.connection_type ?? 'semantic',
|
||||
};
|
||||
|
||||
// Add edge with growth animation
|
||||
edgeManager.addEdge(newEdge, nodePositions);
|
||||
|
||||
// Cyan flash + pulse both endpoints
|
||||
if (srcPos && tgtPos) {
|
||||
effects.createConnectionFlash(srcPos, tgtPos, new THREE.Color(0x00d4ff));
|
||||
}
|
||||
if (data.source_id && nodeMeshMap.has(data.source_id)) {
|
||||
effects.addPulse(data.source_id, 1.0, new THREE.Color(0x00d4ff), 0.02);
|
||||
}
|
||||
if (data.target_id && nodeMeshMap.has(data.target_id)) {
|
||||
effects.addPulse(data.target_id, 1.0, new THREE.Color(0x00d4ff), 0.02);
|
||||
}
|
||||
|
||||
onMutation({ type: 'edgeAdded', edge: newEdge });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'MemoryDeleted': {
|
||||
const data = event.data as { id?: string };
|
||||
if (!data.id) break;
|
||||
|
||||
const pos = nodePositions.get(data.id);
|
||||
if (pos) {
|
||||
// Implosion effect first
|
||||
const color = new THREE.Color(0xff4757);
|
||||
effects.createImplosion(pos, color);
|
||||
}
|
||||
|
||||
// Dissolve edges then node
|
||||
edgeManager.removeEdgesForNode(data.id);
|
||||
nodeManager.removeNode(data.id);
|
||||
forceSim.removeNode(data.id);
|
||||
|
||||
// Remove from live tracking
|
||||
const liveIdx = liveSpawnedNodes.indexOf(data.id);
|
||||
if (liveIdx !== -1) liveSpawnedNodes.splice(liveIdx, 1);
|
||||
|
||||
onMutation({ type: 'edgesRemoved', nodeId: data.id });
|
||||
onMutation({ type: 'nodeRemoved', nodeId: data.id });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'MemoryPromoted': {
|
||||
const data = event.data as { id?: string; new_retention?: number };
|
||||
const promoId = data?.id;
|
||||
if (!promoId) break;
|
||||
|
||||
const newRetention = data.new_retention ?? 0.95;
|
||||
|
||||
if (nodeMeshMap.has(promoId)) {
|
||||
// Grow the node
|
||||
nodeManager.growNode(promoId, newRetention);
|
||||
|
||||
// Green pulse + shockwave + mini burst
|
||||
effects.addPulse(promoId, 1.2, new THREE.Color(0x00ff88), 0.01);
|
||||
const promoPos = nodePositions.get(promoId);
|
||||
if (promoPos) {
|
||||
effects.createShockwave(promoPos, new THREE.Color(0x00ff88), camera);
|
||||
effects.createSpawnBurst(promoPos, new THREE.Color(0x00ff88));
|
||||
}
|
||||
|
||||
onMutation({ type: 'nodeUpdated', nodeId: promoId, retention: newRetention });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'MemoryDemoted': {
|
||||
const data = event.data as { id?: string; new_retention?: number };
|
||||
const demoteId = data?.id;
|
||||
if (!demoteId) break;
|
||||
|
||||
const newRetention = data.new_retention ?? 0.3;
|
||||
|
||||
if (nodeMeshMap.has(demoteId)) {
|
||||
// Shrink the node
|
||||
nodeManager.growNode(demoteId, newRetention);
|
||||
|
||||
// Red pulse — subtle
|
||||
effects.addPulse(demoteId, 0.8, new THREE.Color(0xff4757), 0.03);
|
||||
|
||||
onMutation({ type: 'nodeUpdated', nodeId: demoteId, retention: newRetention });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'MemoryUpdated': {
|
||||
const data = event.data as { id?: string; retention?: number };
|
||||
const updateId = data?.id;
|
||||
if (!updateId || !nodeMeshMap.has(updateId)) break;
|
||||
|
||||
// Subtle blue pulse on update
|
||||
effects.addPulse(updateId, 0.6, new THREE.Color(0x818cf8), 0.02);
|
||||
|
||||
if (data.retention !== undefined) {
|
||||
nodeManager.growNode(updateId, data.retention);
|
||||
onMutation({ type: 'nodeUpdated', nodeId: updateId, retention: data.retention });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'SearchPerformed': {
|
||||
nodeMeshMap.forEach((_, id) => {
|
||||
effects.addPulse(id, 0.6 + Math.random() * 0.4, new THREE.Color(0x818cf8), 0.02);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'DreamStarted': {
|
||||
nodeMeshMap.forEach((_, id) => {
|
||||
effects.addPulse(id, 1.0, new THREE.Color(0xa855f7), 0.005);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'DreamProgress': {
|
||||
const memoryId = (event.data as { memory_id?: string })?.memory_id;
|
||||
if (memoryId && nodeMeshMap.has(memoryId)) {
|
||||
|
|
@ -43,20 +290,13 @@ export function mapEventToEffects(
|
|||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'DreamCompleted': {
|
||||
effects.createSpawnBurst(new THREE.Vector3(0, 0, 0), new THREE.Color(0xa855f7));
|
||||
effects.createShockwave(new THREE.Vector3(0, 0, 0), new THREE.Color(0xa855f7), camera);
|
||||
break;
|
||||
}
|
||||
case 'ConnectionDiscovered': {
|
||||
const data = event.data as { source_id?: string; target_id?: string };
|
||||
const srcPos = data.source_id ? nodePositions.get(data.source_id) : null;
|
||||
const tgtPos = data.target_id ? nodePositions.get(data.target_id) : null;
|
||||
if (srcPos && tgtPos) {
|
||||
effects.createConnectionFlash(srcPos, tgtPos, new THREE.Color(0x00d4ff));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'RetentionDecayed': {
|
||||
const decayId = (event.data as { id?: string })?.id;
|
||||
if (decayId && nodeMeshMap.has(decayId)) {
|
||||
|
|
@ -64,21 +304,14 @@ export function mapEventToEffects(
|
|||
}
|
||||
break;
|
||||
}
|
||||
case 'MemoryPromoted': {
|
||||
const promoId = (event.data as { id?: string })?.id;
|
||||
if (promoId && nodeMeshMap.has(promoId)) {
|
||||
effects.addPulse(promoId, 1.2, new THREE.Color(0x00ff88), 0.01);
|
||||
const promoPos = nodePositions.get(promoId);
|
||||
if (promoPos) effects.createShockwave(promoPos, new THREE.Color(0x00ff88), camera);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ConsolidationCompleted': {
|
||||
nodeMeshMap.forEach((_, id) => {
|
||||
effects.addPulse(id, 0.4 + Math.random() * 0.3, new THREE.Color(0xffb800), 0.015);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ActivationSpread': {
|
||||
const spreadData = event.data as { source_id?: string; target_ids?: string[] };
|
||||
if (spreadData.source_id && spreadData.target_ids) {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ export class ForceSimulation {
|
|||
private readonly repulsionStrength = 500;
|
||||
private readonly attractionStrength = 0.01;
|
||||
private readonly dampening = 0.9;
|
||||
private readonly maxSteps = 300;
|
||||
private readonly baseMaxSteps = 300;
|
||||
private maxSteps = 300;
|
||||
private cooldownExtension = 0;
|
||||
|
||||
constructor(positions: Map<string, THREE.Vector3>) {
|
||||
this.positions = positions;
|
||||
|
|
@ -20,8 +22,30 @@ export class ForceSimulation {
|
|||
}
|
||||
}
|
||||
|
||||
addNode(id: string, position: THREE.Vector3) {
|
||||
this.positions.set(id, position.clone());
|
||||
this.velocities.set(id, new THREE.Vector3());
|
||||
// Re-energize: rewind step count to keep physics alive for 100 more frames
|
||||
this.cooldownExtension = 100;
|
||||
this.maxSteps = Math.max(this.maxSteps, this.step + this.cooldownExtension);
|
||||
this.running = true;
|
||||
}
|
||||
|
||||
removeNode(id: string) {
|
||||
this.positions.delete(id);
|
||||
this.velocities.delete(id);
|
||||
}
|
||||
|
||||
tick(edges: GraphEdge[]) {
|
||||
if (!this.running || this.step > this.maxSteps) return;
|
||||
if (!this.running) return;
|
||||
if (this.step > this.maxSteps) {
|
||||
// Decay cooldown extension, settle back to base
|
||||
if (this.cooldownExtension > 0) {
|
||||
this.cooldownExtension = 0;
|
||||
this.maxSteps = this.baseMaxSteps;
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.step++;
|
||||
|
||||
const alpha = Math.max(0.001, 1 - this.step / this.maxSteps);
|
||||
|
|
|
|||
|
|
@ -2,14 +2,58 @@ import * as THREE from 'three';
|
|||
import type { GraphNode } from '$types';
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
|
||||
function easeOutElastic(t: number): number {
|
||||
if (t === 0 || t === 1) return t;
|
||||
const p = 0.3;
|
||||
return Math.pow(2, -10 * t) * Math.sin(((t - p / 4) * (2 * Math.PI)) / p) + 1;
|
||||
}
|
||||
|
||||
function easeInBack(t: number): number {
|
||||
const s = 1.70158;
|
||||
return t * t * ((s + 1) * t - s);
|
||||
}
|
||||
|
||||
interface MaterializingNode {
|
||||
id: string;
|
||||
frame: number;
|
||||
totalFrames: number;
|
||||
mesh: THREE.Mesh;
|
||||
glow: THREE.Sprite;
|
||||
label: THREE.Sprite;
|
||||
targetScale: number;
|
||||
}
|
||||
|
||||
interface DissolvingNode {
|
||||
id: string;
|
||||
frame: number;
|
||||
totalFrames: number;
|
||||
mesh: THREE.Mesh;
|
||||
glow: THREE.Sprite;
|
||||
label: THREE.Sprite;
|
||||
originalScale: number;
|
||||
}
|
||||
|
||||
interface GrowingNode {
|
||||
id: string;
|
||||
frame: number;
|
||||
totalFrames: number;
|
||||
startScale: number;
|
||||
targetScale: number;
|
||||
}
|
||||
|
||||
export class NodeManager {
|
||||
group: THREE.Group;
|
||||
meshMap = new Map<string, THREE.Mesh>();
|
||||
glowMap = new Map<string, THREE.Sprite>();
|
||||
positions = new Map<string, THREE.Vector3>();
|
||||
labelSprites = new Map<string, THREE.Sprite>();
|
||||
hoveredNode: string | null = null;
|
||||
selectedNode: string | null = null;
|
||||
|
||||
private materializingNodes: MaterializingNode[] = [];
|
||||
private dissolvingNodes: DissolvingNode[] = [];
|
||||
private growingNodes: GrowingNode[] = [];
|
||||
|
||||
constructor() {
|
||||
this.group = new THREE.Group();
|
||||
}
|
||||
|
|
@ -36,54 +80,129 @@ export class NodeManager {
|
|||
if (node.isCenter) pos.set(0, 0, 0);
|
||||
|
||||
this.positions.set(node.id, pos);
|
||||
|
||||
const size = 0.5 + node.retention * 2;
|
||||
const color = NODE_TYPE_COLORS[node.type] || '#8B95A5';
|
||||
|
||||
// Node mesh
|
||||
const geometry = new THREE.SphereGeometry(size, 16, 16);
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(color),
|
||||
emissive: new THREE.Color(color),
|
||||
emissiveIntensity: 0.3 + node.retention * 0.5,
|
||||
roughness: 0.3,
|
||||
metalness: 0.1,
|
||||
transparent: true,
|
||||
opacity: 0.3 + node.retention * 0.7,
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.position.copy(pos);
|
||||
mesh.userData = { nodeId: node.id, type: node.type, retention: node.retention };
|
||||
this.meshMap.set(node.id, mesh);
|
||||
this.group.add(mesh);
|
||||
|
||||
// Glow sprite
|
||||
const spriteMat = new THREE.SpriteMaterial({
|
||||
color: new THREE.Color(color),
|
||||
transparent: true,
|
||||
opacity: 0.15 + node.retention * 0.2,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const sprite = new THREE.Sprite(spriteMat);
|
||||
sprite.scale.set(size * 4, size * 4, 1);
|
||||
sprite.position.copy(pos);
|
||||
sprite.userData = { isGlow: true, nodeId: node.id };
|
||||
this.group.add(sprite);
|
||||
|
||||
// Text label sprite
|
||||
const labelText = node.label || node.type;
|
||||
const labelSprite = this.createTextSprite(labelText, '#e2e8f0');
|
||||
labelSprite.position.copy(pos);
|
||||
labelSprite.position.y += size * 2 + 1.5;
|
||||
labelSprite.userData = { isLabel: true, nodeId: node.id, offset: size * 2 + 1.5 };
|
||||
this.group.add(labelSprite);
|
||||
this.labelSprites.set(node.id, labelSprite);
|
||||
this.createNodeMeshes(node, pos, 1.0);
|
||||
}
|
||||
|
||||
return this.positions;
|
||||
}
|
||||
|
||||
private createNodeMeshes(node: GraphNode, pos: THREE.Vector3, initialScale: number) {
|
||||
const size = 0.5 + node.retention * 2;
|
||||
const color = NODE_TYPE_COLORS[node.type] || '#8B95A5';
|
||||
|
||||
// Node mesh
|
||||
const geometry = new THREE.SphereGeometry(size, 16, 16);
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(color),
|
||||
emissive: new THREE.Color(color),
|
||||
emissiveIntensity: 0.3 + node.retention * 0.5,
|
||||
roughness: 0.3,
|
||||
metalness: 0.1,
|
||||
transparent: true,
|
||||
opacity: 0.3 + node.retention * 0.7,
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.position.copy(pos);
|
||||
mesh.scale.setScalar(initialScale);
|
||||
mesh.userData = { nodeId: node.id, type: node.type, retention: node.retention };
|
||||
this.meshMap.set(node.id, mesh);
|
||||
this.group.add(mesh);
|
||||
|
||||
// Glow sprite
|
||||
const spriteMat = new THREE.SpriteMaterial({
|
||||
color: new THREE.Color(color),
|
||||
transparent: true,
|
||||
opacity: initialScale > 0 ? 0.15 + node.retention * 0.2 : 0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const sprite = new THREE.Sprite(spriteMat);
|
||||
sprite.scale.set(size * 4 * initialScale, size * 4 * initialScale, 1);
|
||||
sprite.position.copy(pos);
|
||||
sprite.userData = { isGlow: true, nodeId: node.id };
|
||||
this.glowMap.set(node.id, sprite);
|
||||
this.group.add(sprite);
|
||||
|
||||
// Text label sprite
|
||||
const labelText = node.label || node.type;
|
||||
const labelSprite = this.createTextSprite(labelText, '#e2e8f0');
|
||||
labelSprite.position.copy(pos);
|
||||
labelSprite.position.y += size * 2 + 1.5;
|
||||
labelSprite.userData = { isLabel: true, nodeId: node.id, offset: size * 2 + 1.5 };
|
||||
this.group.add(labelSprite);
|
||||
this.labelSprites.set(node.id, labelSprite);
|
||||
|
||||
return { mesh, glow: sprite, label: labelSprite, size };
|
||||
}
|
||||
|
||||
addNode(node: GraphNode, initialPosition?: THREE.Vector3): THREE.Vector3 {
|
||||
const pos =
|
||||
initialPosition?.clone() ??
|
||||
new THREE.Vector3(
|
||||
(Math.random() - 0.5) * 40,
|
||||
(Math.random() - 0.5) * 40,
|
||||
(Math.random() - 0.5) * 40
|
||||
);
|
||||
|
||||
this.positions.set(node.id, pos);
|
||||
|
||||
// Create meshes at scale 0
|
||||
const { mesh, glow, label } = this.createNodeMeshes(node, pos, 0);
|
||||
mesh.scale.setScalar(0.001); // Avoid zero-scale issues
|
||||
glow.scale.set(0.001, 0.001, 1);
|
||||
(glow.material as THREE.SpriteMaterial).opacity = 0;
|
||||
(label.material as THREE.SpriteMaterial).opacity = 0;
|
||||
|
||||
this.materializingNodes.push({
|
||||
id: node.id,
|
||||
frame: 0,
|
||||
totalFrames: 30,
|
||||
mesh,
|
||||
glow,
|
||||
label,
|
||||
targetScale: 0.5 + node.retention * 2,
|
||||
});
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
removeNode(id: string) {
|
||||
const mesh = this.meshMap.get(id);
|
||||
const glow = this.glowMap.get(id);
|
||||
const label = this.labelSprites.get(id);
|
||||
if (!mesh || !glow || !label) return;
|
||||
|
||||
// Cancel any active materialization
|
||||
this.materializingNodes = this.materializingNodes.filter((m) => m.id !== id);
|
||||
|
||||
this.dissolvingNodes.push({
|
||||
id,
|
||||
frame: 0,
|
||||
totalFrames: 60,
|
||||
mesh,
|
||||
glow,
|
||||
label,
|
||||
originalScale: mesh.scale.x,
|
||||
});
|
||||
}
|
||||
|
||||
growNode(id: string, newRetention: number) {
|
||||
const mesh = this.meshMap.get(id);
|
||||
if (!mesh) return;
|
||||
|
||||
const currentScale = mesh.scale.x;
|
||||
const targetScale = 0.5 + newRetention * 2;
|
||||
mesh.userData.retention = newRetention;
|
||||
|
||||
this.growingNodes.push({
|
||||
id,
|
||||
frame: 0,
|
||||
totalFrames: 30,
|
||||
startScale: currentScale,
|
||||
targetScale,
|
||||
});
|
||||
}
|
||||
|
||||
private createTextSprite(text: string, color: string): THREE.Sprite {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
|
@ -138,8 +257,105 @@ export class NodeManager {
|
|||
}
|
||||
|
||||
animate(time: number, nodes: GraphNode[], camera: THREE.PerspectiveCamera) {
|
||||
// Node breathing
|
||||
// Materialization animations — elastic scale-up from 0
|
||||
for (let i = this.materializingNodes.length - 1; i >= 0; i--) {
|
||||
const mn = this.materializingNodes[i];
|
||||
mn.frame++;
|
||||
const t = Math.min(mn.frame / mn.totalFrames, 1);
|
||||
const scale = easeOutElastic(t);
|
||||
|
||||
// Mesh scales up with elastic spring
|
||||
mn.mesh.scale.setScalar(Math.max(0.001, scale));
|
||||
|
||||
// Glow fades in between frames 5-10
|
||||
if (mn.frame >= 5) {
|
||||
const glowT = Math.min((mn.frame - 5) / 5, 1);
|
||||
const glowMat = mn.glow.material as THREE.SpriteMaterial;
|
||||
glowMat.opacity = glowT * 0.25;
|
||||
const glowSize = mn.targetScale * 4 * scale;
|
||||
mn.glow.scale.set(glowSize, glowSize, 1);
|
||||
}
|
||||
|
||||
// Label fades in after frame 40 (10 frames after mesh finishes)
|
||||
if (mn.frame >= 40) {
|
||||
const labelT = Math.min((mn.frame - 40) / 20, 1);
|
||||
(mn.label.material as THREE.SpriteMaterial).opacity = labelT * 0.9;
|
||||
}
|
||||
|
||||
if (mn.frame >= 60) {
|
||||
this.materializingNodes.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Dissolution animations — easeInBack shrink
|
||||
for (let i = this.dissolvingNodes.length - 1; i >= 0; i--) {
|
||||
const dn = this.dissolvingNodes[i];
|
||||
dn.frame++;
|
||||
const t = Math.min(dn.frame / dn.totalFrames, 1);
|
||||
const shrink = 1 - easeInBack(t);
|
||||
const scale = Math.max(0.001, dn.originalScale * shrink);
|
||||
|
||||
dn.mesh.scale.setScalar(scale);
|
||||
const glowScale = scale * 4;
|
||||
dn.glow.scale.set(glowScale, glowScale, 1);
|
||||
|
||||
// Fade opacity
|
||||
const mat = dn.mesh.material as THREE.MeshStandardMaterial;
|
||||
mat.opacity *= 0.97;
|
||||
(dn.glow.material as THREE.SpriteMaterial).opacity *= 0.95;
|
||||
(dn.label.material as THREE.SpriteMaterial).opacity *= 0.93;
|
||||
|
||||
if (dn.frame >= dn.totalFrames) {
|
||||
// Clean up
|
||||
this.group.remove(dn.mesh);
|
||||
this.group.remove(dn.glow);
|
||||
this.group.remove(dn.label);
|
||||
dn.mesh.geometry.dispose();
|
||||
(dn.mesh.material as THREE.Material).dispose();
|
||||
(dn.glow.material as THREE.SpriteMaterial).map?.dispose();
|
||||
(dn.glow.material as THREE.Material).dispose();
|
||||
(dn.label.material as THREE.SpriteMaterial).map?.dispose();
|
||||
(dn.label.material as THREE.Material).dispose();
|
||||
|
||||
this.meshMap.delete(dn.id);
|
||||
this.glowMap.delete(dn.id);
|
||||
this.labelSprites.delete(dn.id);
|
||||
this.positions.delete(dn.id);
|
||||
|
||||
this.dissolvingNodes.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Growth animations — smooth scale transition for promoted nodes
|
||||
for (let i = this.growingNodes.length - 1; i >= 0; i--) {
|
||||
const gn = this.growingNodes[i];
|
||||
gn.frame++;
|
||||
const t = Math.min(gn.frame / gn.totalFrames, 1);
|
||||
const scale = gn.startScale + (gn.targetScale - gn.startScale) * easeOutElastic(t);
|
||||
|
||||
const mesh = this.meshMap.get(gn.id);
|
||||
if (mesh) mesh.scale.setScalar(scale);
|
||||
|
||||
const glow = this.glowMap.get(gn.id);
|
||||
if (glow) {
|
||||
const glowSize = scale * 4;
|
||||
glow.scale.set(glowSize, glowSize, 1);
|
||||
}
|
||||
|
||||
if (gn.frame >= gn.totalFrames) {
|
||||
this.growingNodes.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Node breathing (skip nodes being animated)
|
||||
const animatingIds = new Set([
|
||||
...this.materializingNodes.map((m) => m.id),
|
||||
...this.dissolvingNodes.map((d) => d.id),
|
||||
...this.growingNodes.map((g) => g.id),
|
||||
]);
|
||||
|
||||
this.meshMap.forEach((mesh, id) => {
|
||||
if (animatingIds.has(id)) return;
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
if (!node) return;
|
||||
const breathe =
|
||||
|
|
@ -152,7 +368,6 @@ export class NodeManager {
|
|||
} else if (id === this.selectedNode) {
|
||||
mat.emissiveIntensity = 0.8;
|
||||
} else {
|
||||
// Low-retention nodes breathe slower
|
||||
const baseIntensity = 0.3 + node.retention * 0.5;
|
||||
const breatheIntensity =
|
||||
baseIntensity + Math.sin(time * (0.8 + node.retention * 0.7)) * 0.1 * node.retention;
|
||||
|
|
@ -162,6 +377,7 @@ export class NodeManager {
|
|||
|
||||
// Distance-based label visibility
|
||||
this.labelSprites.forEach((sprite, id) => {
|
||||
if (animatingIds.has(id)) return;
|
||||
const pos = this.positions.get(id);
|
||||
if (!pos) return;
|
||||
const dist = camera.position.distanceTo(pos);
|
||||
|
|
@ -192,5 +408,8 @@ export class NodeManager {
|
|||
(obj.material as THREE.Material)?.dispose();
|
||||
}
|
||||
});
|
||||
this.materializingNodes = [];
|
||||
this.dissolvingNodes = [];
|
||||
this.growingNodes = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -10,5 +10,6 @@
|
|||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
},
|
||||
"exclude": ["src/**/__tests__/**"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue