vestige/apps/dashboard/src/lib/graph/edges.ts
Sam Valladares 9bdcc69ce3 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>
2026-03-03 14:04:31 -06:00

167 lines
4.9 KiB
TypeScript

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();
}
createEdges(edges: GraphEdge[], positions: Map<string, THREE.Vector3>) {
for (const edge of edges) {
const sourcePos = positions.get(edge.source);
const targetPos = positions.get(edge.target);
if (!sourcePos || !targetPos) continue;
const points = [sourcePos, targetPos];
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color: 0x4a4a7a,
transparent: true,
opacity: Math.min(0.1 + edge.weight * 0.5, 0.6),
blending: THREE.AdditiveBlending,
});
const line = new THREE.Line(geometry, material);
line.userData = { source: edge.source, target: edge.target };
this.group.add(line);
}
}
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) {
const attrs = line.geometry.attributes.position as THREE.BufferAttribute;
attrs.setXYZ(0, sourcePos.x, sourcePos.y, sourcePos.z);
attrs.setXYZ(1, targetPos.x, targetPos.y, targetPos.z);
attrs.needsUpdate = true;
}
});
}
dispose() {
this.group.children.forEach((child) => {
const line = child as THREE.Line;
line.geometry?.dispose();
(line.material as THREE.Material)?.dispose();
});
this.growingEdges = [];
this.dissolvingEdges = [];
}
}