vestige/apps/dashboard/src/lib/graph/nodes.ts
Sam Valladares 9936928be9
Some checks failed
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
Test Suite / Unit Tests (push) Has been cancelled
Test Suite / MCP E2E Tests (push) Has been cancelled
Test Suite / Dashboard Build (push) Has been cancelled
Test Suite / Code Coverage (push) Has been cancelled
CI / Release Build (aarch64-apple-darwin) (push) Has been cancelled
CI / Release Build (x86_64-unknown-linux-gnu) (push) Has been cancelled
CI / Release Build (x86_64-apple-darwin) (push) Has been cancelled
Test Suite / User Journey Tests (push) Has been cancelled
v2.1.2 Honest Memory
Concrete search, irreversible purge, first-class contradictions tool, vestige update CLI, dense dream persistence fix, embedding-model upgrade repair, and a /dashboard/waitlist Pro early-access preview.

25 MCP tools. SQLite migration v13. Backwards compatible: 'delete' remains as a 'purge' alias.

Closes #50, #51.
2026-05-06 02:22:24 -05:00

709 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as THREE from 'three';
import type { GraphNode } from '$types';
import { NODE_TYPE_COLORS } from '$types';
// ============================================================================
// v2.0.8: Memory state coloring (FSRS accessibility bucket)
// ============================================================================
//
// Every knowledge_node has an FSRS accessibility score computed from
// (retention × 0.5 + retrieval × 0.3 + storage × 0.2). That score gates which
// memories surface in search and drives the Active / Dormant / Silent /
// Unavailable lifecycle documented by Bjork & Bjork 1992 dual-strength model.
//
// The backend computes all three channels, but `GraphNode` only carries
// `retention` — which is already the dominant weight (0.5 of 1.0). Using
// retention alone as a proxy is a known approximation; the buckets line up
// with the same thresholds `execute_system_status` uses server-side, so the
// visual labelling matches what `/api/stats` reports in its
// `stateDistribution` block.
export type MemoryState = 'active' | 'dormant' | 'silent' | 'unavailable';
/// Map an FSRS retention score to its accessibility bucket.
///
/// Thresholds match `execute_system_status` at the backend so the 3D graph's
/// colours line up with the numbers reported by `/api/stats`.
export function getMemoryState(retention: number): MemoryState {
if (retention >= 0.7) return 'active';
if (retention >= 0.4) return 'dormant';
if (retention >= 0.1) return 'silent';
return 'unavailable';
}
/// FSRS state palette. Distinct from NODE_TYPE_COLORS so the two modes can
/// coexist in the UI without overloading a single colour channel.
export const MEMORY_STATE_COLORS: Record<MemoryState, string> = {
active: '#10b981', // emerald — easily retrievable
dormant: '#f59e0b', // amber — retrievable with effort
silent: '#8b5cf6', // violet — difficult, needs cues
unavailable: '#6b7280', // slate — needs reinforcement
};
export const MEMORY_STATE_DESCRIPTIONS: Record<MemoryState, string> = {
active: 'Easily retrievable (retention ≥ 70%)',
dormant: 'Retrievable with effort (4070%)',
silent: 'Difficult, needs cues (1040%)',
unavailable: 'Needs reinforcement (< 10%)',
};
export type AhaGraphKind = 'aha' | 'confusion' | 'failure';
export const AHAGRAPH_COLORS: Record<AhaGraphKind, string> = {
aha: '#FFD700',
confusion: '#EF4444',
failure: '#9CA3AF',
};
export const AHAGRAPH_DESCRIPTIONS: Record<AhaGraphKind, string> = {
aha: 'Aha moments and breakthroughs',
confusion: 'Confusions and weak spots',
failure: 'Failures and guardrails',
};
/// Color mode controls whether node spheres are tinted by node type,
/// FSRS memory state, or AhaGraph learning tags.
/// Type mode is the long-standing default; state mode is the v2.0.8 addition.
export type ColorMode = 'type' | 'state' | 'ahagraph';
/// Pick a hex colour for a node given the active colour mode.
/// Falls back to the grey `unavailable` tone if the node's type is unknown.
export function getNodeColor(node: GraphNode, mode: ColorMode): string {
if (mode === 'state') {
return MEMORY_STATE_COLORS[getMemoryState(node.retention)];
}
if (mode === 'ahagraph') {
return getAhaGraphColor(node) ?? NODE_TYPE_COLORS[node.type] ?? '#8B95A5';
}
return NODE_TYPE_COLORS[node.type] || '#8B95A5';
}
export function getAhaGraphColor(node: Pick<GraphNode, 'tags'>): string | null {
const tags = new Set((node.tags ?? []).map((tag) => tag.toLowerCase()));
if (tags.has('aha')) return AHAGRAPH_COLORS.aha;
if (tags.has('confusion') || tags.has('weak-spot')) return AHAGRAPH_COLORS.confusion;
if (tags.has('failure') || tags.has('guardrail')) return AHAGRAPH_COLORS.failure;
return null;
}
// Shared radial-gradient texture used for every node's glow Sprite.
// Without a map, THREE.Sprite renders as a flat coloured plane — additive-
// blending + UnrealBloomPass then amplifies its square edges into the
// hard-edged "glowing cubes" artefact reported in issue #31. Using a
// soft radial gradient gives a real round halo and lets bloom do its job.
let sharedGlowTexture: THREE.Texture | null = null;
export function getGlowTexture(): THREE.Texture {
if (sharedGlowTexture) return sharedGlowTexture;
const size = 128;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) {
// Fallback: empty 1x1 texture; halos will be invisible but nothing crashes.
sharedGlowTexture = new THREE.Texture();
return sharedGlowTexture;
}
const gradient = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
gradient.addColorStop(0.0, 'rgba(255, 255, 255, 1.0)');
gradient.addColorStop(0.25, 'rgba(255, 255, 255, 0.7)');
gradient.addColorStop(0.55, 'rgba(255, 255, 255, 0.2)');
gradient.addColorStop(1.0, 'rgba(255, 255, 255, 0.0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
const tex = new THREE.CanvasTexture(canvas);
tex.needsUpdate = true;
sharedGlowTexture = tex;
return tex;
}
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;
/// Colour nodes by type, FSRS state, or AhaGraph learning tags.
/// Switched at runtime via `setColorMode`.
colorMode: ColorMode = 'type';
private materializingNodes: MaterializingNode[] = [];
private dissolvingNodes: DissolvingNode[] = [];
private growingNodes: GrowingNode[] = [];
constructor() {
this.group = new THREE.Group();
}
/// Switch the active colour mode and re-tint every live node in place.
/// Safe to call mid-animation — the mesh + glow materials are mutable.
/// Suppressed nodes keep their 20% opacity / zero-emissive treatment
/// since that is a separate visual channel (v2.0.5 SIF).
setColorMode(mode: ColorMode) {
if (this.colorMode === mode) return;
this.colorMode = mode;
for (const [id, mesh] of this.meshMap) {
const retention = (mesh.userData.retention as number | undefined) ?? 0;
const type = (mesh.userData.type as string | undefined) ?? 'fact';
const tags = Array.isArray(mesh.userData.tags)
? (mesh.userData.tags as string[])
: [];
const stubNode = {
id,
label: '',
type,
retention,
tags,
createdAt: '',
updatedAt: '',
isCenter: false,
} as GraphNode;
const hex = getNodeColor(stubNode, mode);
const newColor = new THREE.Color(hex);
const mat = mesh.material as THREE.MeshStandardMaterial;
mat.color.copy(newColor);
mat.emissive.copy(newColor);
const glow = this.glowMap.get(id);
if (glow) {
(glow.material as THREE.SpriteMaterial).color.copy(newColor);
}
}
}
createNodes(nodes: GraphNode[]): Map<string, THREE.Vector3> {
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;
// v2.0.8: respect the active colour mode. Newly-added nodes during the
// same session follow the mode toggled at the UI layer.
const color = getNodeColor(node, this.colorMode);
// v2.0.5 Active Forgetting: suppressed memories dim to 20% opacity
// and lose their emissive glow, mimicking inhibitory-control silencing.
const isSuppressed = (node.suppression_count ?? 0) > 0;
// 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: isSuppressed ? 0.0 : 0.3 + node.retention * 0.5,
roughness: 0.3,
metalness: 0.1,
transparent: true,
opacity: isSuppressed ? 0.2 : 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, tags: node.tags };
this.meshMap.set(node.id, mesh);
this.group.add(mesh);
// Glow sprite — radial-gradient texture kills the square-halo artefact
// from issue #31. depthWrite:false prevents z-fighting with the sphere.
const spriteMat = new THREE.SpriteMaterial({
map: getGlowTexture(),
color: new THREE.Color(color),
transparent: true,
opacity: initialScale > 0 ? (isSuppressed ? 0.1 : 0.3 + node.retention * 0.35) : 0,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const sprite = new THREE.Sprite(spriteMat);
// Slightly larger halo — the gradient falls off quickly so we need
// more screen real estate for a visible soft bloom footprint.
sprite.scale.set(size * 6 * initialScale, size * 6 * 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, '#94a3b8');
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,
options: { isBirthRitual?: boolean } = {}
): 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;
if (options.isBirthRitual) {
// v2.3 Birth Ritual: reserve the physics slot but don't show
// anything until the orb docks. Hiding via .visible keeps the
// force simulation + positions map fully active, so getTargetPos()
// can still resolve the live destination for the orb. `igniteNode`
// below flips visibility and kicks off the materialization anim.
mesh.visible = false;
glow.visible = false;
label.visible = false;
mesh.userData.birthRitualPending = {
totalFrames: 30,
targetScale: 0.5 + node.retention * 2,
};
} else {
this.materializingNodes.push({
id: node.id,
frame: 0,
totalFrames: 30,
mesh,
glow,
label,
targetScale: 0.5 + node.retention * 2,
});
}
return pos;
}
/**
* v2.3 Birth Ritual docking. Flip visibility and hand the node over to
* the materialization queue so it springs up via easeOutElastic at the
* exact moment the orb hits. No-op if the node wasn't created with
* `isBirthRitual:true` or was already ignited.
*/
igniteNode(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;
const pending = mesh.userData.birthRitualPending as
| { totalFrames: number; targetScale: number }
| undefined;
if (!pending) return;
mesh.visible = true;
glow.visible = true;
label.visible = true;
delete mesh.userData.birthRitualPending;
this.materializingNodes.push({
id,
frame: 0,
totalFrames: pending.totalFrames,
mesh,
glow,
label,
targetScale: pending.targetScale,
});
}
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,
});
}
/// Render a label as a dark rounded "pill" with dim slate text.
///
/// The scene runs an UnrealBloomPass with threshold 0.2, so any bright
/// canvas pixels get smeared into a halo. Previously the labels were
/// near-white (#e2e8f0) text on a transparent background, which bloomed
/// into unreadable white blobs (issue filed 2026-04-19). The fix:
///
/// 1. A ~85%-opaque dark pill under the text so the background is
/// well below the bloom threshold, stopping the halo before it
/// spreads past the label bounds.
/// 2. Mid-luminance slate text (#94a3b8 by default) — still legible
/// but dim enough that bloom only adds a soft glow, not a blast.
/// 3. Smaller font (22px) and tighter sprite scale (9×1.2) so labels
/// don't visually compete with the node spheres they annotate.
private createTextSprite(text: string, color: string): THREE.Sprite {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
const tex = new THREE.Texture();
return new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, opacity: 0 }));
}
canvas.width = 512;
canvas.height = 64;
const label = text.length > 40 ? text.slice(0, 37) + '...' : text;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Measure the label so the backing pill hugs the text instead of
// spanning the full canvas width (which would leave a giant empty
// dark bar on short labels like "fact" or "note").
ctx.font = '600 22px -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif';
const metrics = ctx.measureText(label);
const textWidth = metrics.width;
const padX = 14;
const padY = 9;
const pillW = Math.min(textWidth + padX * 2, canvas.width - 4);
const pillH = 40;
const pillX = (canvas.width - pillW) / 2;
const pillY = (canvas.height - pillH) / 2;
const radius = pillH / 2;
// Dark glass pill — low enough luminance that UnrealBloomPass at
// threshold 0.2 does not amplify its pixels.
ctx.fillStyle = 'rgba(10, 16, 28, 0.82)';
ctx.beginPath();
ctx.moveTo(pillX + radius, pillY);
ctx.lineTo(pillX + pillW - radius, pillY);
ctx.quadraticCurveTo(pillX + pillW, pillY, pillX + pillW, pillY + radius);
ctx.lineTo(pillX + pillW, pillY + pillH - radius);
ctx.quadraticCurveTo(
pillX + pillW,
pillY + pillH,
pillX + pillW - radius,
pillY + pillH
);
ctx.lineTo(pillX + radius, pillY + pillH);
ctx.quadraticCurveTo(pillX, pillY + pillH, pillX, pillY + pillH - radius);
ctx.lineTo(pillX, pillY + radius);
ctx.quadraticCurveTo(pillX, pillY, pillX + radius, pillY);
ctx.closePath();
ctx.fill();
// Hairline stroke for definition at small camera distances.
ctx.strokeStyle = 'rgba(148, 163, 184, 0.18)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = color;
ctx.fillText(label, canvas.width / 2, canvas.height / 2 + 1);
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(9, 1.2, 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,
brightness: number = 1.0
) {
// 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.4;
const glowSize = mn.targetScale * 6 * 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 * 6;
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 * 6;
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);
// Distance compensation: FogExp2 attenuates exponentially with camera
// distance, so nodes past ~80 units go nearly black unless we push
// emissive harder. Boost runs 1.0x at <60 units → ~2.4x at 200 units.
// Combined with the user brightness multiplier this gives a visible
// floor at every zoom level without blowing out close-up highlights.
const pos = this.positions.get(id);
const dist = pos ? camera.position.distanceTo(pos) : 0;
const distanceBoost = 1 + Math.min(1.4, Math.max(0, (dist - 60) / 100));
const mat = mesh.material as THREE.MeshStandardMaterial;
if (id === this.hoveredNode) {
mat.emissiveIntensity = 1.0 * brightness;
} else if (id === this.selectedNode) {
mat.emissiveIntensity = 0.8 * brightness;
} 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 * brightness * distanceBoost;
}
// Opacity also gets the distance boost (capped at 1.0) so the node
// body stays visible against the dark void at far zoom.
const baseOpacity = 0.3 + node.retention * 0.7;
mat.opacity = Math.min(1.0, baseOpacity * brightness * distanceBoost);
// Mirror the boost onto the glow sprite so the halo tracks the core.
const glow = this.glowMap.get(id);
if (glow) {
const glowMat = glow.material as THREE.SpriteMaterial;
const baseGlow = 0.3 + node.retention * 0.35;
glowMat.opacity = Math.min(0.95, baseGlow * brightness * distanceBoost);
}
});
// 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 = [];
}
}