vestige/apps/dashboard/src/lib/components/Graph3D.svelte
Sam Valladares 4c2016596c feat(graph): FSRS memory-state colour mode + legend overlay
Closes Agent 1's audit gap #4: FSRS memory state (Active / Dormant /
Silent / Unavailable) was computed server-side per query but never
rendered in the 3D graph. Spheres always tinted by node type.

The new colour mode adds a second channel that users can toggle
between at runtime — Type (default, existing behaviour) and State
(new). The toggle is a radio-pair pill in the graph page's top-right
control bar next to the node-count selector + Dream button.

Buckets + palette:
- Active    ≥ 70%  emerald #10b981  easily retrievable
- Dormant  40-70%  amber   #f59e0b  retrievable with effort
- Silent   10-40%  violet  #8b5cf6  difficult, needs cues
- Unavail.  < 10%  slate   #6b7280  needs reinforcement

Thresholds match `execute_system_status` at the backend so the graph
colour bands line up exactly with what the Stats page reports in its
stateDistribution block. Using retention as the proxy for the full
accessibility formula (retention × 0.5 + retrieval × 0.3 + storage ×
0.2) is an approximation — retention is the dominant 0.5 weight and
it is the only FSRS channel the current GraphNode DTO carries. Swap
to the full formula in a future release if the DTO grows.

Implementation:
- `apps/dashboard/src/lib/graph/nodes.ts` — new `MemoryState` type,
  `getMemoryState(retention)`, `MEMORY_STATE_COLORS`,
  `MEMORY_STATE_DESCRIPTIONS`, `ColorMode`, `getNodeColor(node, mode)`.
- `NodeManager.colorMode` field (default `'type'`). `createNodeMeshes`
  now calls `getNodeColor(node, this.colorMode)` so newly-added nodes
  during the session follow the toggled mode.
- New `NodeManager.setColorMode(mode)` mutates every live mesh's
  material + glow sprite colour in place. Idempotent; cheap. Does NOT
  touch opacity/emissive-intensity so the v2.0.5 suppression dimming
  layer keeps working unchanged.
- New `MemoryStateLegend.svelte` floating overlay in the bottom-right
  when state mode is active (hidden in type mode so the legend doesn't
  compete with the node-type palette).
- `Graph3D.svelte` accepts a new `colorMode` prop (default `'type'`)
  and runs a `$effect` that calls `setColorMode` on every toggle.
- Dashboard rebuild picks up the new component + wiring.

Tests: 171 vitest, svelte-check 581 files / 0 errors. No backend
changes; this is pure dashboard code.
2026-04-19 20:45:08 -05:00

223 lines
6.5 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { GraphNode, GraphEdge, VestigeEvent } from '$types';
import { createScene, resizeScene, disposeScene, type SceneContext } from '$lib/graph/scene';
import { ForceSimulation } from '$lib/graph/force-sim';
import { NodeManager, type ColorMode } from '$lib/graph/nodes';
import { EdgeManager } from '$lib/graph/edges';
import { ParticleSystem } from '$lib/graph/particles';
import { EffectManager } from '$lib/graph/effects';
import { DreamMode } from '$lib/graph/dream-mode';
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';
interface Props {
nodes: GraphNode[];
edges: GraphEdge[];
centerId: string;
events?: VestigeEvent[];
isDreaming?: boolean;
/// v2.0.8: colour mode for node spheres. "type" tints by node type
/// (fact/concept/event/…); "state" tints by FSRS accessibility bucket
/// (active/dormant/silent/unavailable). Toggled live from the graph page.
colorMode?: ColorMode;
onSelect?: (nodeId: string) => void;
onGraphMutation?: (mutation: GraphMutation) => void;
}
let {
nodes,
edges,
centerId,
events = [],
isDreaming = false,
colorMode = 'type',
onSelect,
onGraphMutation,
}: Props = $props();
// Re-tint every live node whenever the color mode flips. The NodeManager's
// setColorMode is idempotent and mutates materials in place, so this
// effect runs once per toggle and doesn't rebuild the scene.
$effect(() => {
nodeManager?.setColorMode(colorMode);
});
let container: HTMLDivElement;
let ctx: SceneContext;
let animationId: number;
// Modules
let nodeManager: NodeManager;
let edgeManager: EdgeManager;
let particles: ParticleSystem;
let effects: EffectManager;
let forceSim: ForceSimulation;
let dreamMode: DreamMode;
let nebulaMaterial: THREE.ShaderMaterial;
let postStack: PostProcessingStack;
// Event tracking
let processedEventCount = 0;
// Internal tracking: initial nodes + live-added nodes
let allNodes: GraphNode[] = [];
onMount(() => {
ctx = createScene(container);
// Nebula background
const nebula = createNebulaBackground(ctx.scene);
nebulaMaterial = nebula.material;
// Post-processing (added after bloom)
postStack = createPostProcessing(ctx.composer);
// Modules
particles = new ParticleSystem(ctx.scene);
nodeManager = new NodeManager();
// Apply the initial colour mode before node creation so the first paint
// already reflects the user's prop choice. Prevents a visible flash from
// type-colour to state-colour on mount when the page defaults to state.
nodeManager.colorMode = colorMode;
edgeManager = new EdgeManager();
effects = new EffectManager(ctx.scene);
dreamMode = new DreamMode();
// Build graph
const positions = nodeManager.createNodes(nodes);
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);
animate();
window.addEventListener('resize', onResize);
container.addEventListener('pointermove', onPointerMove);
container.addEventListener('click', onClick);
});
onDestroy(() => {
cancelAnimationFrame(animationId);
window.removeEventListener('resize', onResize);
container?.removeEventListener('pointermove', onPointerMove);
container?.removeEventListener('click', onClick);
effects?.dispose();
particles?.dispose();
nodeManager?.dispose();
edgeManager?.dispose();
if (ctx) disposeScene(ctx);
});
function animate() {
animationId = requestAnimationFrame(animate);
const time = performance.now() * 0.001;
// Force simulation
forceSim.tick(edges);
// Update positions
nodeManager.updatePositions();
edgeManager.updatePositions(nodeManager.positions);
// Animate edge growth/dissolution
edgeManager.animateEdges(nodeManager.positions);
// Animate
particles.animate(time);
nodeManager.animate(time, allNodes, ctx.camera);
// Dream mode
dreamMode.setActive(isDreaming);
dreamMode.update(ctx.scene, ctx.bloomPass, ctx.controls, ctx.lights, time);
// Nebula + post-processing
updateNebula(
nebulaMaterial,
time,
dreamMode.current.nebulaIntensity,
container.clientWidth,
container.clientHeight
);
updatePostProcessing(postStack, time, dreamMode.current.nebulaIntensity);
// Events + effects
processEvents();
effects.update(nodeManager.meshMap, ctx.camera, nodeManager.positions);
ctx.controls.update();
ctx.composer.render();
}
function processEvents() {
if (!events || events.length <= processedEventCount) return;
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, mutationCtx, allNodes);
}
}
function onResize() {
if (!container || !ctx) return;
resizeScene(ctx, container);
}
function onPointerMove(event: PointerEvent) {
const rect = container.getBoundingClientRect();
ctx.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
ctx.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
ctx.raycaster.setFromCamera(ctx.mouse, ctx.camera);
const intersects = ctx.raycaster.intersectObjects(nodeManager.getMeshes());
if (intersects.length > 0) {
nodeManager.hoveredNode = intersects[0].object.userData.nodeId;
container.style.cursor = 'pointer';
} else {
nodeManager.hoveredNode = null;
container.style.cursor = 'grab';
}
}
function onClick() {
if (nodeManager.hoveredNode) {
nodeManager.selectedNode = nodeManager.hoveredNode;
onSelect?.(nodeManager.hoveredNode);
const pos = nodeManager.positions.get(nodeManager.hoveredNode);
if (pos) {
ctx.controls.target.lerp(pos.clone(), 0.5);
}
}
}
</script>
<div bind:this={container} class="w-full h-full"></div>