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(); glowMap = new Map(); positions = new Map(); labelSprites = new Map(); hoveredNode: string | null = null; selectedNode: string | null = null; private materializingNodes: MaterializingNode[] = []; private dissolvingNodes: DissolvingNode[] = []; private growingNodes: GrowingNode[] = []; constructor() { this.group = new THREE.Group(); } createNodes(nodes: GraphNode[]): Map { const phi = (1 + Math.sqrt(5)) / 2; const count = nodes.length; for (let i = 0; i < count; i++) { const node = nodes[i]; // Fibonacci sphere distribution for initial positions const y = 1 - (2 * i) / (count - 1 || 1); const radius = Math.sqrt(1 - y * y); const theta = (2 * Math.PI * i) / phi; const spread = 30 + count * 0.5; const pos = new THREE.Vector3( radius * Math.cos(theta) * spread, y * spread, radius * Math.sin(theta) * spread ); if (node.isCenter) pos.set(0, 0, 0); this.positions.set(node.id, pos); 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')!; canvas.width = 512; canvas.height = 64; const label = text.length > 40 ? text.slice(0, 37) + '...' : text; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.font = 'bold 28px -apple-system, BlinkMacSystemFont, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.shadowColor = 'rgba(0, 0, 0, 0.8)'; ctx.shadowBlur = 6; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 2; ctx.fillStyle = color; ctx.fillText(label, canvas.width / 2, canvas.height / 2); const texture = new THREE.CanvasTexture(canvas); texture.needsUpdate = true; const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0, depthTest: false, sizeAttenuation: true, }); const sprite = new THREE.Sprite(mat); sprite.scale.set(12, 1.5, 1); return sprite; } updatePositions() { this.group.children.forEach((child) => { if (child.userData.nodeId) { const pos = this.positions.get(child.userData.nodeId); if (!pos) return; if (child.userData.isGlow) { child.position.copy(pos); } else if (child.userData.isLabel) { child.position.copy(pos); child.position.y += child.userData.offset; } else if (child instanceof THREE.Mesh) { child.position.copy(pos); } } }); } animate(time: number, nodes: GraphNode[], camera: THREE.PerspectiveCamera) { // 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 = 1 + Math.sin(time * 1.5 + nodes.indexOf(node) * 0.5) * 0.15 * node.retention; mesh.scale.setScalar(breathe); const mat = mesh.material as THREE.MeshStandardMaterial; if (id === this.hoveredNode) { mat.emissiveIntensity = 1.0; } else if (id === this.selectedNode) { mat.emissiveIntensity = 0.8; } else { const baseIntensity = 0.3 + node.retention * 0.5; const breatheIntensity = baseIntensity + Math.sin(time * (0.8 + node.retention * 0.7)) * 0.1 * node.retention; mat.emissiveIntensity = breatheIntensity; } }); // 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); const mat = sprite.material as THREE.SpriteMaterial; const targetOpacity = id === this.hoveredNode || id === this.selectedNode ? 1.0 : dist < 40 ? 0.9 : dist < 80 ? 0.9 * (1 - (dist - 40) / 40) : 0; mat.opacity += (targetOpacity - mat.opacity) * 0.1; }); } getMeshes(): THREE.Mesh[] { return Array.from(this.meshMap.values()); } dispose() { this.group.traverse((obj) => { if (obj instanceof THREE.Mesh) { obj.geometry?.dispose(); (obj.material as THREE.Material)?.dispose(); } else if (obj instanceof THREE.Sprite) { (obj.material as THREE.SpriteMaterial)?.map?.dispose(); (obj.material as THREE.Material)?.dispose(); } }); this.materializingNodes = []; this.dissolvingNodes = []; this.growingNodes = []; } }