vestige/apps/dashboard/src/lib/components/Graph3D.svelte

269 lines
8.7 KiB
Svelte
Raw Normal View History

<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';
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
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;
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
/// 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;
}
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
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;
feat(v2.3-terrarium): Memory Birth Ritual + event pipeline fix v2.3 "Terrarium" headline feature. When a MemoryCreated event arrives, a glowing orb materialises in the cosmic center (camera-relative z=-40), gestates for ~800ms growing from a tiny spark into a full orb, then arcs along a dynamic quadratic Bezier curve to the live position of the real node, and on arrival hands off to the existing RainbowBurst + Shockwave + RippleWave cascade. The target position is re-resolved every frame so the force simulation can move the destination during flight without the orb losing its mark. **New primitive — EffectManager.createBirthOrb()** (effects.ts): Accepts a camera, a color, a live target-position getter, and an arrival callback. Owns a sprite pair (outer halo + inner bright core), both depthTest:false with renderOrder 999/1000 so the orb is always visible through the starfield and the graph. - Gestation phase: easeOutCubic growth + sinusoidal pulse, halo tints from neutral to event color as the ritual charges. - Flight phase: QuadraticBezierCurve3 with control point at midpoint raised on Y by 30 + 15% of orb-to-target distance (shooting-star arc). Sampled with easeInOutQuad. Orb shrinks ~35% approaching target. - Arrival: fires onArrive callback once, then fades out over 8 frames while expanding slightly (energy dispersal). - Caller's onArrive triggers the burst cascade at arrivePos (NOT the original spawnPos — the force sim may have moved the target during the ritual, so we re-read nodeManager.positions on arrival). - Dispose path integrated with existing EffectManager.dispose(). **Event pipeline fix — Graph3D.processEvents()**: Previously tracked `processedEventCount` assuming APPEND order, but websocket.ts PREPENDS new events (index 0) and caps the array at MAX_EVENTS. Result: only the first MemoryCreated event after page load fired correctly; subsequent ones reprocessed the oldest entry. Fixed to walk from index 0 until hitting the last-processed event by reference identity — correct regardless of array direction or eviction pressure. Events are then processed oldest-first so causes precede effects. Found while wiring the v2.3 demo button; would have manifested as "first orb only" in production. **Demo trigger** (Settings -> Birth Ritual Preview): Button that calls websocket.injectEvent() with a synthetic MemoryCreated event, cycling through node types (fact / concept / pattern / decision / person / place) to showcase the type-color mapping. Downstream consumers can't distinguish synthetic from real, so this drives the full ritual end-to-end. Intended for demo clip recording for the Wednesday launch. **Test coverage:** - events.test.ts now tests the v2.3 birth ritual path: spawns 2+ sprites in the scene immediately, and fires the full arrival cascade after driving the effects.update() loop past the ritual duration. - three-mock.ts extended with Vector3.addVectors, Vector3.applyQuaternion, Color.multiplyScalar, Quaternion, QuadraticBezierCurve3, Texture, and Object3D.quaternion/renderOrder so production code runs unaltered in tests. Build + typecheck: - npm run check: 0 errors, 0 warnings across 583 files - npm test: 251/251 pass (net +0 from v2.2) - npm run build: clean adapter-static output The Sanhedrin Shatter (anti-birth ritual for hallucination veto) needs server-side event plumbing and is deferred. Ship this as the Wednesday visual mic-drop.
2026-04-20 12:47:37 -05:00
// Event tracking — we track the last-processed event by reference identity
// rather than by count, because the WebSocket store PREPENDS new events
// at index 0 and CAPS the array at MAX_EVENTS, so a numeric high-water
// mark would drift out of alignment (and did for ~3 versions — v2.3
// demo uncovered this while trying to fire multiple MemoryCreated events
// in sequence).
let lastProcessedEvent: VestigeEvent | null = null;
// 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();
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
// 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);
});
fix(v2.3): 5 FATAL bugs + 4 god-tier upgrades from post-ship audit Post-ship audit surfaced 6 FATALs and 4 upgrades. Shipping 5 of the 6 + all 4 upgrades. FATAL 4 (VRAM hemorrhage from un-pooled label canvases in createTextSprite) is pre-existing, not from this session, and scoped separately for a proper texture-pool refactor. **FATAL 1 — Toast Silent Lobotomy** (stores/toast.ts) Subscriber tracked events[0] only. When Svelte batched multiple events in one update tick (swarm firing DreamCompleted + ConnectionDiscovered within the same millisecond), every event but the newest got silently dropped. Fixed to walk from index 0 until hitting lastSeen — same pattern as Graph3D.processEvents. Processes oldest-first to preserve narrative order. **FATAL 2 — Premature Birth** (graph/nodes.ts + graph/events.ts) Orb flight is 138 frames; materialization was 30 frames. Node popped fully grown ~100 frames before orb arrived — cheap UI glitch instead of a biological birth. Added `addNode(..., { isBirthRitual: true })` option that reserves the physics slot but hides mesh/glow/label and skips the materializing queue. New `igniteNode(id)` flips visibility and enqueues materialization. events.ts onArrive now calls igniteNode at the exact docking moment, so the elastic spring-up peaks on impact. **FATAL 3 — 120Hz ProMotion Time-Bomb** (components/Graph3D.svelte) All physics + effect counters are frame-based. On a 120Hz display every ritual ran at 2x speed. Added a `lastTime`-based governor in animate() that early-returns if dt < 16ms, clamping effective rate to ~60fps. `- (dt % 16)` carry avoids long-term drift. Zero API changes; tonight's fast fix until physics is rewritten to use dt. **FATAL 5 — Bezier GC Panic** (graph/effects.ts birth-orb update) Flight phase allocated a new Vector3 (control point) and a new QuadraticBezierCurve3 every frame per orb. With 3 orbs in flight that's 360 objects/sec for the GC to collect. Rewrote as inline algebraic evaluation — zero allocations per frame, identical curve. **FATAL 6 — Phantom Shockwave** (graph/events.ts) A 166ms setTimeout fired the 2nd shockwave. If the user navigated away during that window the scene was disposed, the timer still fired, and .add() on a dead scene threw unhandled rejection. Dropped the setTimeout entirely; both shockwaves fire immediately in onArrive with different scales/colors for the same layered-crash feel. **UPGRADE 1 — Sanhedrin Shatter** (graph/effects.ts birth-orb update) If getTargetPos() returns undefined AFTER gestation (target node was deleted mid-ritual — Stop hook sniping a hallucination), the orb turns blood-red, triggers a violent implosion in place, and skips the arrival cascade. Cognitive immune system made visible. **UPGRADE 2 — Newton's Cradle** (graph/events.ts onArrive) On docking the target mesh's scale gets bumped 1.8×, so the elastic materialization + force-sim springs physically recoil instead of the orb landing silently. The graph flinches when an idea is born into it. **UPGRADE 3 — Hover Panic** (stores/toast.ts + InsightToast.svelte) Paused dwell timer on mouseenter/focus, resume on mouseleave/blur. Stored remaining ms at pause so resume schedules a correctly-sized timer. CSS pairs via `animation-play-state: paused` on the progress bar. A toast the user is reading no longer dismisses mid-sentence. **UPGRADE 4 — Event Horizon Guard** (components/Graph3D.svelte) If >MAX_EVENTS (200) events arrive in one tick, lastProcessedEvent falls off the end of the array and the walk consumes ALL 200 entries as "fresh" — GPU meltdown from 200 simultaneous births. Detect the overflow and drop the batch with a console.warn, advancing the high-water mark so next frame is normal. Build + test: - npm run check: 0 errors, 0 warnings - npm test: 251/251 pass - npm run build: clean static build
2026-04-20 16:33:25 -05:00
// 120Hz Governor. All physics and effect counters are frame-based
// (orb.age++, forceSim.tick, materialization frames). On a ProMotion
// display the browser drives rAF at 120 FPS, which would double-speed
// every ritual. Clamping to ~60 FPS keeps the visual timing identical
// across displays without rewriting every counter to use delta time.
// The `- (dt % 16)` carry avoids long-term drift.
let govLastTime = 0;
function animate() {
animationId = requestAnimationFrame(animate);
fix(v2.3): 5 FATAL bugs + 4 god-tier upgrades from post-ship audit Post-ship audit surfaced 6 FATALs and 4 upgrades. Shipping 5 of the 6 + all 4 upgrades. FATAL 4 (VRAM hemorrhage from un-pooled label canvases in createTextSprite) is pre-existing, not from this session, and scoped separately for a proper texture-pool refactor. **FATAL 1 — Toast Silent Lobotomy** (stores/toast.ts) Subscriber tracked events[0] only. When Svelte batched multiple events in one update tick (swarm firing DreamCompleted + ConnectionDiscovered within the same millisecond), every event but the newest got silently dropped. Fixed to walk from index 0 until hitting lastSeen — same pattern as Graph3D.processEvents. Processes oldest-first to preserve narrative order. **FATAL 2 — Premature Birth** (graph/nodes.ts + graph/events.ts) Orb flight is 138 frames; materialization was 30 frames. Node popped fully grown ~100 frames before orb arrived — cheap UI glitch instead of a biological birth. Added `addNode(..., { isBirthRitual: true })` option that reserves the physics slot but hides mesh/glow/label and skips the materializing queue. New `igniteNode(id)` flips visibility and enqueues materialization. events.ts onArrive now calls igniteNode at the exact docking moment, so the elastic spring-up peaks on impact. **FATAL 3 — 120Hz ProMotion Time-Bomb** (components/Graph3D.svelte) All physics + effect counters are frame-based. On a 120Hz display every ritual ran at 2x speed. Added a `lastTime`-based governor in animate() that early-returns if dt < 16ms, clamping effective rate to ~60fps. `- (dt % 16)` carry avoids long-term drift. Zero API changes; tonight's fast fix until physics is rewritten to use dt. **FATAL 5 — Bezier GC Panic** (graph/effects.ts birth-orb update) Flight phase allocated a new Vector3 (control point) and a new QuadraticBezierCurve3 every frame per orb. With 3 orbs in flight that's 360 objects/sec for the GC to collect. Rewrote as inline algebraic evaluation — zero allocations per frame, identical curve. **FATAL 6 — Phantom Shockwave** (graph/events.ts) A 166ms setTimeout fired the 2nd shockwave. If the user navigated away during that window the scene was disposed, the timer still fired, and .add() on a dead scene threw unhandled rejection. Dropped the setTimeout entirely; both shockwaves fire immediately in onArrive with different scales/colors for the same layered-crash feel. **UPGRADE 1 — Sanhedrin Shatter** (graph/effects.ts birth-orb update) If getTargetPos() returns undefined AFTER gestation (target node was deleted mid-ritual — Stop hook sniping a hallucination), the orb turns blood-red, triggers a violent implosion in place, and skips the arrival cascade. Cognitive immune system made visible. **UPGRADE 2 — Newton's Cradle** (graph/events.ts onArrive) On docking the target mesh's scale gets bumped 1.8×, so the elastic materialization + force-sim springs physically recoil instead of the orb landing silently. The graph flinches when an idea is born into it. **UPGRADE 3 — Hover Panic** (stores/toast.ts + InsightToast.svelte) Paused dwell timer on mouseenter/focus, resume on mouseleave/blur. Stored remaining ms at pause so resume schedules a correctly-sized timer. CSS pairs via `animation-play-state: paused` on the progress bar. A toast the user is reading no longer dismisses mid-sentence. **UPGRADE 4 — Event Horizon Guard** (components/Graph3D.svelte) If >MAX_EVENTS (200) events arrive in one tick, lastProcessedEvent falls off the end of the array and the walk consumes ALL 200 entries as "fresh" — GPU meltdown from 200 simultaneous births. Detect the overflow and drop the batch with a console.warn, advancing the high-water mark so next frame is normal. Build + test: - npm run check: 0 errors, 0 warnings - npm test: 251/251 pass - npm run build: clean static build
2026-04-20 16:33:25 -05:00
const now = performance.now();
if (govLastTime === 0) govLastTime = now;
const dt = now - govLastTime;
if (dt < 16) return;
govLastTime = now - (dt % 16);
const time = 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() {
feat(v2.3-terrarium): Memory Birth Ritual + event pipeline fix v2.3 "Terrarium" headline feature. When a MemoryCreated event arrives, a glowing orb materialises in the cosmic center (camera-relative z=-40), gestates for ~800ms growing from a tiny spark into a full orb, then arcs along a dynamic quadratic Bezier curve to the live position of the real node, and on arrival hands off to the existing RainbowBurst + Shockwave + RippleWave cascade. The target position is re-resolved every frame so the force simulation can move the destination during flight without the orb losing its mark. **New primitive — EffectManager.createBirthOrb()** (effects.ts): Accepts a camera, a color, a live target-position getter, and an arrival callback. Owns a sprite pair (outer halo + inner bright core), both depthTest:false with renderOrder 999/1000 so the orb is always visible through the starfield and the graph. - Gestation phase: easeOutCubic growth + sinusoidal pulse, halo tints from neutral to event color as the ritual charges. - Flight phase: QuadraticBezierCurve3 with control point at midpoint raised on Y by 30 + 15% of orb-to-target distance (shooting-star arc). Sampled with easeInOutQuad. Orb shrinks ~35% approaching target. - Arrival: fires onArrive callback once, then fades out over 8 frames while expanding slightly (energy dispersal). - Caller's onArrive triggers the burst cascade at arrivePos (NOT the original spawnPos — the force sim may have moved the target during the ritual, so we re-read nodeManager.positions on arrival). - Dispose path integrated with existing EffectManager.dispose(). **Event pipeline fix — Graph3D.processEvents()**: Previously tracked `processedEventCount` assuming APPEND order, but websocket.ts PREPENDS new events (index 0) and caps the array at MAX_EVENTS. Result: only the first MemoryCreated event after page load fired correctly; subsequent ones reprocessed the oldest entry. Fixed to walk from index 0 until hitting the last-processed event by reference identity — correct regardless of array direction or eviction pressure. Events are then processed oldest-first so causes precede effects. Found while wiring the v2.3 demo button; would have manifested as "first orb only" in production. **Demo trigger** (Settings -> Birth Ritual Preview): Button that calls websocket.injectEvent() with a synthetic MemoryCreated event, cycling through node types (fact / concept / pattern / decision / person / place) to showcase the type-color mapping. Downstream consumers can't distinguish synthetic from real, so this drives the full ritual end-to-end. Intended for demo clip recording for the Wednesday launch. **Test coverage:** - events.test.ts now tests the v2.3 birth ritual path: spawns 2+ sprites in the scene immediately, and fires the full arrival cascade after driving the effects.update() loop past the ritual duration. - three-mock.ts extended with Vector3.addVectors, Vector3.applyQuaternion, Color.multiplyScalar, Quaternion, QuadraticBezierCurve3, Texture, and Object3D.quaternion/renderOrder so production code runs unaltered in tests. Build + typecheck: - npm run check: 0 errors, 0 warnings across 583 files - npm test: 251/251 pass (net +0 from v2.2) - npm run build: clean adapter-static output The Sanhedrin Shatter (anti-birth ritual for hallucination veto) needs server-side event plumbing and is deferred. Ship this as the Wednesday visual mic-drop.
2026-04-20 12:47:37 -05:00
if (!events || events.length === 0) return;
// Walk the feed from newest (index 0) backward until we hit the last
// event we already processed. Everything between is fresh. This is
// robust against both (a) prepend ordering and (b) the MAX_EVENTS cap
// dropping old entries off the tail.
const fresh: VestigeEvent[] = [];
for (const e of events) {
if (e === lastProcessedEvent) break;
fresh.push(e);
}
if (fresh.length === 0) return;
fix(v2.3): 5 FATAL bugs + 4 god-tier upgrades from post-ship audit Post-ship audit surfaced 6 FATALs and 4 upgrades. Shipping 5 of the 6 + all 4 upgrades. FATAL 4 (VRAM hemorrhage from un-pooled label canvases in createTextSprite) is pre-existing, not from this session, and scoped separately for a proper texture-pool refactor. **FATAL 1 — Toast Silent Lobotomy** (stores/toast.ts) Subscriber tracked events[0] only. When Svelte batched multiple events in one update tick (swarm firing DreamCompleted + ConnectionDiscovered within the same millisecond), every event but the newest got silently dropped. Fixed to walk from index 0 until hitting lastSeen — same pattern as Graph3D.processEvents. Processes oldest-first to preserve narrative order. **FATAL 2 — Premature Birth** (graph/nodes.ts + graph/events.ts) Orb flight is 138 frames; materialization was 30 frames. Node popped fully grown ~100 frames before orb arrived — cheap UI glitch instead of a biological birth. Added `addNode(..., { isBirthRitual: true })` option that reserves the physics slot but hides mesh/glow/label and skips the materializing queue. New `igniteNode(id)` flips visibility and enqueues materialization. events.ts onArrive now calls igniteNode at the exact docking moment, so the elastic spring-up peaks on impact. **FATAL 3 — 120Hz ProMotion Time-Bomb** (components/Graph3D.svelte) All physics + effect counters are frame-based. On a 120Hz display every ritual ran at 2x speed. Added a `lastTime`-based governor in animate() that early-returns if dt < 16ms, clamping effective rate to ~60fps. `- (dt % 16)` carry avoids long-term drift. Zero API changes; tonight's fast fix until physics is rewritten to use dt. **FATAL 5 — Bezier GC Panic** (graph/effects.ts birth-orb update) Flight phase allocated a new Vector3 (control point) and a new QuadraticBezierCurve3 every frame per orb. With 3 orbs in flight that's 360 objects/sec for the GC to collect. Rewrote as inline algebraic evaluation — zero allocations per frame, identical curve. **FATAL 6 — Phantom Shockwave** (graph/events.ts) A 166ms setTimeout fired the 2nd shockwave. If the user navigated away during that window the scene was disposed, the timer still fired, and .add() on a dead scene threw unhandled rejection. Dropped the setTimeout entirely; both shockwaves fire immediately in onArrive with different scales/colors for the same layered-crash feel. **UPGRADE 1 — Sanhedrin Shatter** (graph/effects.ts birth-orb update) If getTargetPos() returns undefined AFTER gestation (target node was deleted mid-ritual — Stop hook sniping a hallucination), the orb turns blood-red, triggers a violent implosion in place, and skips the arrival cascade. Cognitive immune system made visible. **UPGRADE 2 — Newton's Cradle** (graph/events.ts onArrive) On docking the target mesh's scale gets bumped 1.8×, so the elastic materialization + force-sim springs physically recoil instead of the orb landing silently. The graph flinches when an idea is born into it. **UPGRADE 3 — Hover Panic** (stores/toast.ts + InsightToast.svelte) Paused dwell timer on mouseenter/focus, resume on mouseleave/blur. Stored remaining ms at pause so resume schedules a correctly-sized timer. CSS pairs via `animation-play-state: paused` on the progress bar. A toast the user is reading no longer dismisses mid-sentence. **UPGRADE 4 — Event Horizon Guard** (components/Graph3D.svelte) If >MAX_EVENTS (200) events arrive in one tick, lastProcessedEvent falls off the end of the array and the walk consumes ALL 200 entries as "fresh" — GPU meltdown from 200 simultaneous births. Detect the overflow and drop the batch with a console.warn, advancing the high-water mark so next frame is normal. Build + test: - npm run check: 0 errors, 0 warnings - npm test: 251/251 pass - npm run build: clean static build
2026-04-20 16:33:25 -05:00
// Event Horizon Guard. If the last-processed reference fell off the
// end of the capped array (burst of >MAX_EVENTS events in one tick),
// the walk above consumed the ENTIRE buffer — we'd try to animate
// 200 simultaneous births and melt the GPU. Detect the overflow and
// drop this batch on the floor; state is already current via
// lastProcessedEvent pointing forward.
if (fresh.length === events.length && events.length >= 200) {
// eslint-disable-next-line no-console
console.warn('[vestige] Event horizon overflow: dropping visuals for', fresh.length, 'events');
lastProcessedEvent = events[0];
return;
}
feat(v2.3-terrarium): Memory Birth Ritual + event pipeline fix v2.3 "Terrarium" headline feature. When a MemoryCreated event arrives, a glowing orb materialises in the cosmic center (camera-relative z=-40), gestates for ~800ms growing from a tiny spark into a full orb, then arcs along a dynamic quadratic Bezier curve to the live position of the real node, and on arrival hands off to the existing RainbowBurst + Shockwave + RippleWave cascade. The target position is re-resolved every frame so the force simulation can move the destination during flight without the orb losing its mark. **New primitive — EffectManager.createBirthOrb()** (effects.ts): Accepts a camera, a color, a live target-position getter, and an arrival callback. Owns a sprite pair (outer halo + inner bright core), both depthTest:false with renderOrder 999/1000 so the orb is always visible through the starfield and the graph. - Gestation phase: easeOutCubic growth + sinusoidal pulse, halo tints from neutral to event color as the ritual charges. - Flight phase: QuadraticBezierCurve3 with control point at midpoint raised on Y by 30 + 15% of orb-to-target distance (shooting-star arc). Sampled with easeInOutQuad. Orb shrinks ~35% approaching target. - Arrival: fires onArrive callback once, then fades out over 8 frames while expanding slightly (energy dispersal). - Caller's onArrive triggers the burst cascade at arrivePos (NOT the original spawnPos — the force sim may have moved the target during the ritual, so we re-read nodeManager.positions on arrival). - Dispose path integrated with existing EffectManager.dispose(). **Event pipeline fix — Graph3D.processEvents()**: Previously tracked `processedEventCount` assuming APPEND order, but websocket.ts PREPENDS new events (index 0) and caps the array at MAX_EVENTS. Result: only the first MemoryCreated event after page load fired correctly; subsequent ones reprocessed the oldest entry. Fixed to walk from index 0 until hitting the last-processed event by reference identity — correct regardless of array direction or eviction pressure. Events are then processed oldest-first so causes precede effects. Found while wiring the v2.3 demo button; would have manifested as "first orb only" in production. **Demo trigger** (Settings -> Birth Ritual Preview): Button that calls websocket.injectEvent() with a synthetic MemoryCreated event, cycling through node types (fact / concept / pattern / decision / person / place) to showcase the type-color mapping. Downstream consumers can't distinguish synthetic from real, so this drives the full ritual end-to-end. Intended for demo clip recording for the Wednesday launch. **Test coverage:** - events.test.ts now tests the v2.3 birth ritual path: spawns 2+ sprites in the scene immediately, and fires the full arrival cascade after driving the effects.update() loop past the ritual duration. - three-mock.ts extended with Vector3.addVectors, Vector3.applyQuaternion, Color.multiplyScalar, Quaternion, QuadraticBezierCurve3, Texture, and Object3D.quaternion/renderOrder so production code runs unaltered in tests. Build + typecheck: - npm run check: 0 errors, 0 warnings across 583 files - npm test: 251/251 pass (net +0 from v2.2) - npm run build: clean adapter-static output The Sanhedrin Shatter (anti-birth ritual for hallucination veto) needs server-side event plumbing and is deferred. Ship this as the Wednesday visual mic-drop.
2026-04-20 12:47:37 -05:00
lastProcessedEvent = events[0];
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);
},
};
feat(v2.3-terrarium): Memory Birth Ritual + event pipeline fix v2.3 "Terrarium" headline feature. When a MemoryCreated event arrives, a glowing orb materialises in the cosmic center (camera-relative z=-40), gestates for ~800ms growing from a tiny spark into a full orb, then arcs along a dynamic quadratic Bezier curve to the live position of the real node, and on arrival hands off to the existing RainbowBurst + Shockwave + RippleWave cascade. The target position is re-resolved every frame so the force simulation can move the destination during flight without the orb losing its mark. **New primitive — EffectManager.createBirthOrb()** (effects.ts): Accepts a camera, a color, a live target-position getter, and an arrival callback. Owns a sprite pair (outer halo + inner bright core), both depthTest:false with renderOrder 999/1000 so the orb is always visible through the starfield and the graph. - Gestation phase: easeOutCubic growth + sinusoidal pulse, halo tints from neutral to event color as the ritual charges. - Flight phase: QuadraticBezierCurve3 with control point at midpoint raised on Y by 30 + 15% of orb-to-target distance (shooting-star arc). Sampled with easeInOutQuad. Orb shrinks ~35% approaching target. - Arrival: fires onArrive callback once, then fades out over 8 frames while expanding slightly (energy dispersal). - Caller's onArrive triggers the burst cascade at arrivePos (NOT the original spawnPos — the force sim may have moved the target during the ritual, so we re-read nodeManager.positions on arrival). - Dispose path integrated with existing EffectManager.dispose(). **Event pipeline fix — Graph3D.processEvents()**: Previously tracked `processedEventCount` assuming APPEND order, but websocket.ts PREPENDS new events (index 0) and caps the array at MAX_EVENTS. Result: only the first MemoryCreated event after page load fired correctly; subsequent ones reprocessed the oldest entry. Fixed to walk from index 0 until hitting the last-processed event by reference identity — correct regardless of array direction or eviction pressure. Events are then processed oldest-first so causes precede effects. Found while wiring the v2.3 demo button; would have manifested as "first orb only" in production. **Demo trigger** (Settings -> Birth Ritual Preview): Button that calls websocket.injectEvent() with a synthetic MemoryCreated event, cycling through node types (fact / concept / pattern / decision / person / place) to showcase the type-color mapping. Downstream consumers can't distinguish synthetic from real, so this drives the full ritual end-to-end. Intended for demo clip recording for the Wednesday launch. **Test coverage:** - events.test.ts now tests the v2.3 birth ritual path: spawns 2+ sprites in the scene immediately, and fires the full arrival cascade after driving the effects.update() loop past the ritual duration. - three-mock.ts extended with Vector3.addVectors, Vector3.applyQuaternion, Color.multiplyScalar, Quaternion, QuadraticBezierCurve3, Texture, and Object3D.quaternion/renderOrder so production code runs unaltered in tests. Build + typecheck: - npm run check: 0 errors, 0 warnings across 583 files - npm test: 251/251 pass (net +0 from v2.2) - npm run build: clean adapter-static output The Sanhedrin Shatter (anti-birth ritual for hallucination veto) needs server-side event plumbing and is deferred. Ship this as the Wednesday visual mic-drop.
2026-04-20 12:47:37 -05:00
// Process oldest-first so cause precedes effect (e.g. MemoryCreated
// fires before a ConnectionDiscovered that references the new node).
// `fresh` is newest-first from the walk above, so iterate reversed.
for (let i = fresh.length - 1; i >= 0; i--) {
mapEventToEffects(fresh[i], 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>