mirror of
https://github.com/samvallad33/vestige.git
synced 2026-05-17 18:35:17 +02:00
feat: dashboard v2.1 glassmorphism + graph decomposition + fix flaky macOS vector test
Dashboard v2.1 "Nuclear" upgrade: - Dark glassmorphism UI system (4-tier glass utilities, ambient orbs, nav glow) - Graph3D decomposed from 806-line monolith into 10 focused modules - Custom GLSL shaders (nebula FBM background, chromatic aberration, film grain, vignette) - Enhanced dream mode with smooth 2s lerped transitions and aurora cycling - Cognitive pipeline visualizer (7-stage search cascade animation) - Temporal playback slider (scrub through memory evolution over time) - Bioluminescent color palette for node types and events Fix flaky CI test on macOS: - vector::tests::test_add_and_search used near-identical test vectors (additive phase shift) - Changed to multiplicative frequency so each seed produces a distinct vector Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2c1f499a8b
commit
d98cf6136a
241 changed files with 6262 additions and 4884 deletions
|
|
@ -55,7 +55,7 @@ body {
|
|||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-abyss);
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-subtle);
|
||||
|
|
@ -65,7 +65,54 @@ body {
|
|||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
/* Glow effects */
|
||||
/* ═══════════════════════════════════════════
|
||||
GLASSMORPHISM SYSTEM
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
.glass {
|
||||
background: rgba(22, 22, 56, 0.45);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(99, 102, 241, 0.08);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.03),
|
||||
0 4px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.glass-subtle {
|
||||
background: rgba(16, 16, 42, 0.4);
|
||||
backdrop-filter: blur(12px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(150%);
|
||||
border: 1px solid rgba(99, 102, 241, 0.06);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.02),
|
||||
0 2px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.glass-sidebar {
|
||||
background: rgba(10, 10, 26, 0.6);
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||
border-right: 1px solid rgba(99, 102, 241, 0.1);
|
||||
box-shadow:
|
||||
inset -1px 0 0 0 rgba(255, 255, 255, 0.02),
|
||||
4px 0 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: rgba(10, 10, 26, 0.8);
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||
border: 1px solid rgba(99, 102, 241, 0.1);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.03),
|
||||
0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
GLOW EFFECTS
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
.glow-synapse {
|
||||
box-shadow: 0 0 20px rgba(99, 102, 241, 0.3), 0 0 60px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
|
@ -76,6 +123,10 @@ body {
|
|||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3), 0 0 60px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
ANIMATIONS
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
/* Pulse animation for live indicators */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { opacity: 1; }
|
||||
|
|
@ -85,6 +136,91 @@ body {
|
|||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Ambient background orbs */
|
||||
@keyframes orb-float-1 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
25% { transform: translate(60px, -40px) scale(1.1); }
|
||||
50% { transform: translate(-30px, -80px) scale(0.95); }
|
||||
75% { transform: translate(-60px, -20px) scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes orb-float-2 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
25% { transform: translate(-50px, 30px) scale(1.08); }
|
||||
50% { transform: translate(40px, 60px) scale(0.92); }
|
||||
75% { transform: translate(20px, -40px) scale(1.03); }
|
||||
}
|
||||
|
||||
@keyframes orb-float-3 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
25% { transform: translate(30px, 50px) scale(1.05); }
|
||||
50% { transform: translate(-60px, 20px) scale(0.98); }
|
||||
75% { transform: translate(40px, -30px) scale(1.1); }
|
||||
}
|
||||
|
||||
.ambient-orb {
|
||||
position: fixed;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.ambient-orb-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: radial-gradient(circle, rgba(168, 85, 247, 0.4), transparent 70%);
|
||||
top: -10%;
|
||||
right: -5%;
|
||||
animation: orb-float-1 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ambient-orb-2 {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
background: radial-gradient(circle, rgba(99, 102, 241, 0.35), transparent 70%);
|
||||
bottom: -15%;
|
||||
left: -5%;
|
||||
animation: orb-float-2 25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ambient-orb-3 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: radial-gradient(circle, rgba(245, 158, 11, 0.2), transparent 70%);
|
||||
top: 40%;
|
||||
left: 40%;
|
||||
animation: orb-float-3 22s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Active nav indicator with animated gradient border */
|
||||
.nav-active-border {
|
||||
position: relative;
|
||||
}
|
||||
.nav-active-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 2px;
|
||||
border-radius: 1px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--color-synapse),
|
||||
var(--color-dream),
|
||||
var(--color-synapse)
|
||||
);
|
||||
background-size: 100% 200%;
|
||||
animation: gradient-shift 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient-shift {
|
||||
0%, 100% { background-position: 0% 0%; }
|
||||
50% { background-position: 0% 100%; }
|
||||
}
|
||||
|
||||
/* Neural particle animation */
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0) translateX(0); }
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
||||
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
||||
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
||||
import type { GraphNode, GraphEdge, VestigeEvent } from '$types';
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
import { createScene, resizeScene, disposeScene, type SceneContext } from '$lib/graph/scene';
|
||||
import { ForceSimulation } from '$lib/graph/force-sim';
|
||||
import { NodeManager } 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 } 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[];
|
||||
|
|
@ -20,45 +25,47 @@
|
|||
let { nodes, edges, centerId, events = [], isDreaming = false, onSelect }: Props = $props();
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
let scene: THREE.Scene;
|
||||
let camera: THREE.PerspectiveCamera;
|
||||
let controls: OrbitControls;
|
||||
let composer: EffectComposer;
|
||||
let bloomPass: UnrealBloomPass;
|
||||
let raycaster: THREE.Raycaster;
|
||||
let mouse: THREE.Vector2;
|
||||
let ctx: SceneContext;
|
||||
let animationId: number;
|
||||
let nodeGroup: THREE.Group;
|
||||
let edgeGroup: THREE.Group;
|
||||
let particleSystem: THREE.Points;
|
||||
let starField: THREE.Points;
|
||||
|
||||
// Maps for lookup
|
||||
let nodeMeshMap = new Map<string, THREE.Mesh>();
|
||||
let nodePositions = new Map<string, THREE.Vector3>();
|
||||
let labelSprites = new Map<string, THREE.Sprite>();
|
||||
let hoveredNode: string | null = null;
|
||||
let selectedNode: string | null = null;
|
||||
// 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;
|
||||
|
||||
// Force simulation state
|
||||
let velocities = new Map<string, THREE.Vector3>();
|
||||
let simulationRunning = true;
|
||||
let simulationStep = 0;
|
||||
|
||||
// Event-driven animation state
|
||||
// Event tracking
|
||||
let processedEventCount = 0;
|
||||
let pulseEffects: { nodeId: string; intensity: number; color: THREE.Color; decay: number }[] = [];
|
||||
let connectionFlashes: { line: THREE.Line; intensity: number }[] = [];
|
||||
let spawnBursts: { position: THREE.Vector3; age: number; particles: THREE.Points }[] = [];
|
||||
let dreamTrails: { points: THREE.Vector3[]; line: THREE.Line; age: number }[] = [];
|
||||
let shockwaves: { mesh: THREE.Mesh; age: number; maxAge: number }[] = [];
|
||||
|
||||
onMount(() => {
|
||||
initScene();
|
||||
createStarField();
|
||||
createGraph();
|
||||
createParticleSystem();
|
||||
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();
|
||||
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);
|
||||
|
||||
ctx.scene.add(edgeManager.group);
|
||||
ctx.scene.add(nodeManager.group);
|
||||
|
||||
animate();
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
|
@ -71,432 +78,48 @@
|
|||
window.removeEventListener('resize', onResize);
|
||||
container?.removeEventListener('pointermove', onPointerMove);
|
||||
container?.removeEventListener('click', onClick);
|
||||
// Dispose Three.js resources to prevent GPU memory leaks
|
||||
scene?.traverse((obj: THREE.Object3D) => {
|
||||
if (obj instanceof THREE.Mesh || obj instanceof THREE.InstancedMesh) {
|
||||
obj.geometry?.dispose();
|
||||
if (Array.isArray(obj.material)) {
|
||||
obj.material.forEach((m: THREE.Material) => m.dispose());
|
||||
} else if (obj.material) {
|
||||
(obj.material as THREE.Material).dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
renderer?.dispose();
|
||||
composer?.dispose();
|
||||
effects?.dispose();
|
||||
particles?.dispose();
|
||||
nodeManager?.dispose();
|
||||
edgeManager?.dispose();
|
||||
if (ctx) disposeScene(ctx);
|
||||
});
|
||||
|
||||
function initScene() {
|
||||
// Scene
|
||||
scene = new THREE.Scene();
|
||||
scene.fog = new THREE.FogExp2(0x050510, 0.008);
|
||||
|
||||
// Camera
|
||||
camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 0.1, 2000);
|
||||
camera.position.set(0, 30, 80);
|
||||
|
||||
// Renderer (WebGL2 — WebGPU requires async init, use WebGL for now with bloom)
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
powerPreference: 'high-performance'
|
||||
});
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.2;
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// Controls
|
||||
controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.rotateSpeed = 0.5;
|
||||
controls.zoomSpeed = 0.8;
|
||||
controls.minDistance = 10;
|
||||
controls.maxDistance = 500;
|
||||
controls.autoRotate = true;
|
||||
controls.autoRotateSpeed = 0.3;
|
||||
|
||||
// Post-processing: Bloom
|
||||
composer = new EffectComposer(renderer);
|
||||
composer.addPass(new RenderPass(scene, camera));
|
||||
bloomPass = new UnrealBloomPass(
|
||||
new THREE.Vector2(container.clientWidth, container.clientHeight),
|
||||
0.8, // strength
|
||||
0.4, // radius
|
||||
0.85 // threshold
|
||||
);
|
||||
composer.addPass(bloomPass);
|
||||
|
||||
// Lighting
|
||||
const ambient = new THREE.AmbientLight(0x1a1a3a, 0.5);
|
||||
scene.add(ambient);
|
||||
|
||||
const point1 = new THREE.PointLight(0x6366f1, 1.5, 200);
|
||||
point1.position.set(50, 50, 50);
|
||||
scene.add(point1);
|
||||
|
||||
const point2 = new THREE.PointLight(0xa855f7, 1, 200);
|
||||
point2.position.set(-50, -30, -50);
|
||||
scene.add(point2);
|
||||
|
||||
// Raycaster
|
||||
raycaster = new THREE.Raycaster();
|
||||
raycaster.params.Points = { threshold: 2 };
|
||||
mouse = new THREE.Vector2();
|
||||
}
|
||||
|
||||
function createStarField() {
|
||||
const starGeo = new THREE.BufferGeometry();
|
||||
const starCount = 3000;
|
||||
const positions = new Float32Array(starCount * 3);
|
||||
const sizes = new Float32Array(starCount);
|
||||
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
positions[i * 3] = (Math.random() - 0.5) * 1000;
|
||||
positions[i * 3 + 1] = (Math.random() - 0.5) * 1000;
|
||||
positions[i * 3 + 2] = (Math.random() - 0.5) * 1000;
|
||||
sizes[i] = Math.random() * 1.5;
|
||||
}
|
||||
|
||||
starGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
starGeo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
|
||||
|
||||
const starMat = new THREE.PointsMaterial({
|
||||
color: 0x6366f1,
|
||||
size: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0.4,
|
||||
sizeAttenuation: true,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
|
||||
starField = new THREE.Points(starGeo, starMat);
|
||||
scene.add(starField);
|
||||
}
|
||||
|
||||
function createGraph() {
|
||||
nodeGroup = new THREE.Group();
|
||||
edgeGroup = new THREE.Group();
|
||||
|
||||
// Position nodes using force-directed initial layout
|
||||
const nodeCount = nodes.length;
|
||||
const phi = (1 + Math.sqrt(5)) / 2; // Golden ratio for sphere distribution
|
||||
|
||||
nodes.forEach((node, i) => {
|
||||
// Fibonacci sphere distribution for initial positions
|
||||
const y = 1 - (2 * i) / (nodeCount - 1 || 1);
|
||||
const radius = Math.sqrt(1 - y * y);
|
||||
const theta = 2 * Math.PI * i / phi;
|
||||
const spread = 30 + nodeCount * 0.5;
|
||||
|
||||
const pos = new THREE.Vector3(
|
||||
radius * Math.cos(theta) * spread,
|
||||
y * spread,
|
||||
radius * Math.sin(theta) * spread
|
||||
);
|
||||
|
||||
// Center node at origin
|
||||
if (node.isCenter) pos.set(0, 0, 0);
|
||||
|
||||
nodePositions.set(node.id, pos);
|
||||
velocities.set(node.id, new THREE.Vector3());
|
||||
|
||||
// Create node mesh
|
||||
const size = 0.5 + node.retention * 2;
|
||||
const color = NODE_TYPE_COLORS[node.type] || '#6b7280';
|
||||
|
||||
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.userData = { nodeId: node.id, type: node.type, retention: node.retention };
|
||||
|
||||
nodeMeshMap.set(node.id, mesh);
|
||||
nodeGroup.add(mesh);
|
||||
|
||||
// Glow sprite
|
||||
const spriteMat = new THREE.SpriteMaterial({
|
||||
color: new THREE.Color(color),
|
||||
transparent: true,
|
||||
opacity: 0.15 + node.retention * 0.2,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const sprite = new THREE.Sprite(spriteMat);
|
||||
sprite.scale.set(size * 4, size * 4, 1);
|
||||
sprite.position.copy(pos);
|
||||
sprite.userData = { isGlow: true, nodeId: node.id };
|
||||
nodeGroup.add(sprite);
|
||||
|
||||
// Text label sprite (distance-faded)
|
||||
const labelText = node.label || node.type;
|
||||
const labelSprite = 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 };
|
||||
nodeGroup.add(labelSprite);
|
||||
labelSprites.set(node.id, labelSprite);
|
||||
});
|
||||
|
||||
// Create edges
|
||||
edges.forEach(edge => {
|
||||
const sourcePos = nodePositions.get(edge.source);
|
||||
const targetPos = nodePositions.get(edge.target);
|
||||
if (!sourcePos || !targetPos) return;
|
||||
|
||||
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 };
|
||||
edgeGroup.add(line);
|
||||
});
|
||||
|
||||
scene.add(edgeGroup);
|
||||
scene.add(nodeGroup);
|
||||
}
|
||||
|
||||
function createParticleSystem() {
|
||||
const particleCount = 500;
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
const positions = new Float32Array(particleCount * 3);
|
||||
const colors = new Float32Array(particleCount * 3);
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
positions[i * 3] = (Math.random() - 0.5) * 100;
|
||||
positions[i * 3 + 1] = (Math.random() - 0.5) * 100;
|
||||
positions[i * 3 + 2] = (Math.random() - 0.5) * 100;
|
||||
// Purple-blue neural particles
|
||||
colors[i * 3] = 0.4 + Math.random() * 0.3;
|
||||
colors[i * 3 + 1] = 0.3 + Math.random() * 0.2;
|
||||
colors[i * 3 + 2] = 0.8 + Math.random() * 0.2;
|
||||
}
|
||||
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
|
||||
const material = new THREE.PointsMaterial({
|
||||
size: 0.3,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.4,
|
||||
blending: THREE.AdditiveBlending,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
|
||||
particleSystem = new THREE.Points(geometry, material);
|
||||
scene.add(particleSystem);
|
||||
}
|
||||
|
||||
function createTextSprite(text: string, color: string): THREE.Sprite {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
canvas.width = 512;
|
||||
canvas.height = 64;
|
||||
|
||||
// Truncate text
|
||||
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';
|
||||
|
||||
// Shadow for readability
|
||||
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;
|
||||
}
|
||||
|
||||
function runForceSimulation() {
|
||||
if (!simulationRunning || simulationStep > 300) return;
|
||||
simulationStep++;
|
||||
|
||||
const alpha = Math.max(0.001, 1 - simulationStep / 300);
|
||||
const repulsionStrength = 500;
|
||||
const attractionStrength = 0.01;
|
||||
const dampening = 0.9;
|
||||
|
||||
// Repulsion between all nodes
|
||||
const nodeIds = Array.from(nodePositions.keys());
|
||||
for (let i = 0; i < nodeIds.length; i++) {
|
||||
for (let j = i + 1; j < nodeIds.length; j++) {
|
||||
const posA = nodePositions.get(nodeIds[i])!;
|
||||
const posB = nodePositions.get(nodeIds[j])!;
|
||||
const diff = new THREE.Vector3().subVectors(posA, posB);
|
||||
const dist = diff.length() || 1;
|
||||
const force = repulsionStrength / (dist * dist) * alpha;
|
||||
const dir = diff.normalize().multiplyScalar(force);
|
||||
|
||||
velocities.get(nodeIds[i])!.add(dir);
|
||||
velocities.get(nodeIds[j])!.sub(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Attraction along edges
|
||||
edges.forEach(edge => {
|
||||
const posA = nodePositions.get(edge.source);
|
||||
const posB = nodePositions.get(edge.target);
|
||||
if (!posA || !posB) return;
|
||||
|
||||
const diff = new THREE.Vector3().subVectors(posB, posA);
|
||||
const dist = diff.length();
|
||||
const force = dist * attractionStrength * edge.weight * alpha;
|
||||
const dir = diff.normalize().multiplyScalar(force);
|
||||
|
||||
velocities.get(edge.source)!.add(dir);
|
||||
velocities.get(edge.target)!.sub(dir);
|
||||
});
|
||||
|
||||
// Centering force
|
||||
nodeIds.forEach(id => {
|
||||
const pos = nodePositions.get(id)!;
|
||||
const vel = velocities.get(id)!;
|
||||
vel.sub(pos.clone().multiplyScalar(0.001 * alpha));
|
||||
vel.multiplyScalar(dampening);
|
||||
pos.add(vel);
|
||||
|
||||
// Update mesh positions
|
||||
const mesh = nodeMeshMap.get(id);
|
||||
if (mesh) mesh.position.copy(pos);
|
||||
});
|
||||
|
||||
// Update glow sprite and label positions
|
||||
nodeGroup.children.forEach(child => {
|
||||
if (child.userData.nodeId) {
|
||||
const pos = nodePositions.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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update edge positions
|
||||
edgeGroup.children.forEach(child => {
|
||||
const line = child as THREE.Line;
|
||||
const sourcePos = nodePositions.get(line.userData.source);
|
||||
const targetPos = nodePositions.get(line.userData.target);
|
||||
if (sourcePos && targetPos) {
|
||||
const positions = line.geometry.attributes.position as THREE.BufferAttribute;
|
||||
positions.setXYZ(0, sourcePos.x, sourcePos.y, sourcePos.z);
|
||||
positions.setXYZ(1, targetPos.x, targetPos.y, targetPos.z);
|
||||
positions.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function animate() {
|
||||
animationId = requestAnimationFrame(animate);
|
||||
|
||||
const time = performance.now() * 0.001;
|
||||
|
||||
// Force simulation
|
||||
runForceSimulation();
|
||||
forceSim.tick(edges);
|
||||
|
||||
// Animate particles
|
||||
if (particleSystem) {
|
||||
const positions = particleSystem.geometry.attributes.position as THREE.BufferAttribute;
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
positions.setY(i, positions.getY(i) + Math.sin(time + i * 0.1) * 0.02);
|
||||
positions.setX(i, positions.getX(i) + Math.cos(time + i * 0.05) * 0.01);
|
||||
}
|
||||
positions.needsUpdate = true;
|
||||
}
|
||||
// Update positions
|
||||
nodeManager.updatePositions();
|
||||
edgeManager.updatePositions(nodeManager.positions);
|
||||
|
||||
// Slow star rotation
|
||||
if (starField) {
|
||||
starField.rotation.y += 0.0001;
|
||||
starField.rotation.x += 0.00005;
|
||||
}
|
||||
// Animate
|
||||
particles.animate(time);
|
||||
nodeManager.animate(time, nodes, ctx.camera);
|
||||
|
||||
// Node breathing (retention-based pulse)
|
||||
nodeMeshMap.forEach((mesh, id) => {
|
||||
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.05 * node.retention;
|
||||
mesh.scale.setScalar(breathe);
|
||||
// Dream mode
|
||||
dreamMode.setActive(isDreaming);
|
||||
dreamMode.update(ctx.scene, ctx.bloomPass, ctx.controls, ctx.lights, time);
|
||||
|
||||
// Highlight hovered
|
||||
const mat = mesh.material as THREE.MeshStandardMaterial;
|
||||
if (id === hoveredNode) {
|
||||
mat.emissiveIntensity = 1.0;
|
||||
} else if (id === selectedNode) {
|
||||
mat.emissiveIntensity = 0.8;
|
||||
} else {
|
||||
mat.emissiveIntensity = 0.3 + node.retention * 0.5;
|
||||
}
|
||||
});
|
||||
// Nebula + post-processing
|
||||
updateNebula(
|
||||
nebulaMaterial,
|
||||
time,
|
||||
dreamMode.current.nebulaIntensity,
|
||||
container.clientWidth,
|
||||
container.clientHeight
|
||||
);
|
||||
updatePostProcessing(postStack, time, dreamMode.current.nebulaIntensity);
|
||||
|
||||
// Distance-based label visibility
|
||||
labelSprites.forEach((sprite, id) => {
|
||||
const pos = nodePositions.get(id);
|
||||
if (!pos) return;
|
||||
const dist = camera.position.distanceTo(pos);
|
||||
const mat = sprite.material as THREE.SpriteMaterial;
|
||||
// Fade in when close (< 40 units), fade out when far (> 80 units)
|
||||
const targetOpacity = id === hoveredNode || id === selectedNode
|
||||
? 1.0
|
||||
: dist < 40 ? 0.9 : dist < 80 ? 0.9 * (1 - (dist - 40) / 40) : 0;
|
||||
mat.opacity += (targetOpacity - mat.opacity) * 0.1;
|
||||
});
|
||||
|
||||
// Dream mode: slower rotation, purple tint, stronger bloom
|
||||
if (isDreaming) {
|
||||
controls.autoRotateSpeed = 0.1;
|
||||
bloomPass.strength = 1.5;
|
||||
scene.fog = new THREE.FogExp2(0x0a0520, 0.006);
|
||||
} else {
|
||||
controls.autoRotateSpeed = 0.3;
|
||||
bloomPass.strength = 0.8;
|
||||
}
|
||||
|
||||
// Process incoming events
|
||||
// Events + effects
|
||||
processEvents();
|
||||
effects.update(nodeManager.meshMap, ctx.camera);
|
||||
|
||||
// Update visual effects
|
||||
updateEffects(time);
|
||||
|
||||
controls.update();
|
||||
composer.render();
|
||||
ctx.controls.update();
|
||||
ctx.composer.render();
|
||||
}
|
||||
|
||||
function processEvents() {
|
||||
|
|
@ -506,300 +129,40 @@
|
|||
processedEventCount = events.length;
|
||||
|
||||
for (const event of newEvents) {
|
||||
switch (event.type) {
|
||||
case 'MemoryCreated': {
|
||||
// Spawn burst: ring of particles expanding outward
|
||||
const nodeId = (event.data as { id?: string })?.id;
|
||||
const pos = nodeId ? nodePositions.get(nodeId) : null;
|
||||
const burstPos = pos?.clone() ?? new THREE.Vector3(
|
||||
(Math.random() - 0.5) * 40,
|
||||
(Math.random() - 0.5) * 40,
|
||||
(Math.random() - 0.5) * 40
|
||||
);
|
||||
createSpawnBurst(burstPos, new THREE.Color(0x10b981));
|
||||
|
||||
// Also create a shockwave ring
|
||||
createShockwave(burstPos, new THREE.Color(0x10b981));
|
||||
break;
|
||||
}
|
||||
case 'SearchPerformed': {
|
||||
// Pulse all visible nodes with blue ripple
|
||||
const query = (event.data as { query?: string })?.query;
|
||||
nodeMeshMap.forEach((_, id) => {
|
||||
pulseEffects.push({
|
||||
nodeId: id,
|
||||
intensity: 0.6 + Math.random() * 0.4,
|
||||
color: new THREE.Color(0x3b82f6),
|
||||
decay: 0.02
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'DreamStarted': {
|
||||
// Dramatic: pulse everything purple, slow time
|
||||
nodeMeshMap.forEach((_, id) => {
|
||||
pulseEffects.push({
|
||||
nodeId: id,
|
||||
intensity: 1.0,
|
||||
color: new THREE.Color(0xa855f7),
|
||||
decay: 0.005
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'DreamProgress': {
|
||||
// Light up specific memories as they're "replayed"
|
||||
const memoryId = (event.data as { memory_id?: string })?.memory_id;
|
||||
if (memoryId && nodeMeshMap.has(memoryId)) {
|
||||
pulseEffects.push({
|
||||
nodeId: memoryId,
|
||||
intensity: 1.5,
|
||||
color: new THREE.Color(0xc084fc),
|
||||
decay: 0.01
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'DreamCompleted': {
|
||||
// Celebration burst from center
|
||||
createSpawnBurst(new THREE.Vector3(0, 0, 0), new THREE.Color(0xa855f7));
|
||||
createShockwave(new THREE.Vector3(0, 0, 0), new THREE.Color(0xa855f7));
|
||||
break;
|
||||
}
|
||||
case 'ConnectionDiscovered': {
|
||||
const data = event.data as { source_id?: string; target_id?: string };
|
||||
const srcPos = data.source_id ? nodePositions.get(data.source_id) : null;
|
||||
const tgtPos = data.target_id ? nodePositions.get(data.target_id) : null;
|
||||
if (srcPos && tgtPos) {
|
||||
createConnectionFlash(srcPos, tgtPos, new THREE.Color(0xf59e0b));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'RetentionDecayed': {
|
||||
const decayId = (event.data as { id?: string })?.id;
|
||||
if (decayId && nodeMeshMap.has(decayId)) {
|
||||
pulseEffects.push({
|
||||
nodeId: decayId,
|
||||
intensity: 0.8,
|
||||
color: new THREE.Color(0xef4444),
|
||||
decay: 0.03
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'MemoryPromoted': {
|
||||
const promoId = (event.data as { id?: string })?.id;
|
||||
if (promoId && nodeMeshMap.has(promoId)) {
|
||||
pulseEffects.push({
|
||||
nodeId: promoId,
|
||||
intensity: 1.2,
|
||||
color: new THREE.Color(0x10b981),
|
||||
decay: 0.01
|
||||
});
|
||||
const promoPos = nodePositions.get(promoId);
|
||||
if (promoPos) createShockwave(promoPos, new THREE.Color(0x10b981));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ConsolidationCompleted': {
|
||||
// Global shimmer effect
|
||||
nodeMeshMap.forEach((_, id) => {
|
||||
pulseEffects.push({
|
||||
nodeId: id,
|
||||
intensity: 0.4 + Math.random() * 0.3,
|
||||
color: new THREE.Color(0xf59e0b),
|
||||
decay: 0.015
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createSpawnBurst(position: THREE.Vector3, color: THREE.Color) {
|
||||
const count = 60;
|
||||
const geo = new THREE.BufferGeometry();
|
||||
const positions = new Float32Array(count * 3);
|
||||
const velocitiesArr = new Float32Array(count * 3);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
positions[i * 3] = position.x;
|
||||
positions[i * 3 + 1] = position.y;
|
||||
positions[i * 3 + 2] = position.z;
|
||||
// Random outward velocity
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
const speed = 0.3 + Math.random() * 0.5;
|
||||
velocitiesArr[i * 3] = Math.sin(phi) * Math.cos(theta) * speed;
|
||||
velocitiesArr[i * 3 + 1] = Math.sin(phi) * Math.sin(theta) * speed;
|
||||
velocitiesArr[i * 3 + 2] = Math.cos(phi) * speed;
|
||||
}
|
||||
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('velocity', new THREE.BufferAttribute(velocitiesArr, 3));
|
||||
|
||||
const mat = new THREE.PointsMaterial({
|
||||
color,
|
||||
size: 0.6,
|
||||
transparent: true,
|
||||
opacity: 1.0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
|
||||
const pts = new THREE.Points(geo, mat);
|
||||
scene.add(pts);
|
||||
spawnBursts.push({ position: position.clone(), age: 0, particles: pts });
|
||||
}
|
||||
|
||||
function createShockwave(position: THREE.Vector3, color: THREE.Color) {
|
||||
const geo = new THREE.RingGeometry(0.1, 0.5, 64);
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
side: THREE.DoubleSide,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const ring = new THREE.Mesh(geo, mat);
|
||||
ring.position.copy(position);
|
||||
// Face camera
|
||||
ring.lookAt(camera.position);
|
||||
scene.add(ring);
|
||||
shockwaves.push({ mesh: ring, age: 0, maxAge: 60 });
|
||||
}
|
||||
|
||||
function createConnectionFlash(from: THREE.Vector3, to: THREE.Vector3, color: THREE.Color) {
|
||||
const points = [from.clone(), to.clone()];
|
||||
const geo = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const mat = new THREE.LineBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: 1.0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const line = new THREE.Line(geo, mat);
|
||||
scene.add(line);
|
||||
connectionFlashes.push({ line, intensity: 1.0 });
|
||||
}
|
||||
|
||||
function updateEffects(time: number) {
|
||||
// Update pulse effects on nodes
|
||||
for (let i = pulseEffects.length - 1; i >= 0; i--) {
|
||||
const pulse = pulseEffects[i];
|
||||
pulse.intensity -= pulse.decay;
|
||||
if (pulse.intensity <= 0) {
|
||||
pulseEffects.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
const mesh = nodeMeshMap.get(pulse.nodeId);
|
||||
if (mesh) {
|
||||
const mat = mesh.material as THREE.MeshStandardMaterial;
|
||||
mat.emissive.lerp(pulse.color, pulse.intensity * 0.3);
|
||||
mat.emissiveIntensity = Math.max(mat.emissiveIntensity, pulse.intensity);
|
||||
}
|
||||
}
|
||||
|
||||
// Update spawn burst particles
|
||||
for (let i = spawnBursts.length - 1; i >= 0; i--) {
|
||||
const burst = spawnBursts[i];
|
||||
burst.age++;
|
||||
if (burst.age > 120) {
|
||||
scene.remove(burst.particles);
|
||||
burst.particles.geometry.dispose();
|
||||
(burst.particles.material as THREE.Material).dispose();
|
||||
spawnBursts.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
const positions = burst.particles.geometry.attributes.position as THREE.BufferAttribute;
|
||||
const vels = burst.particles.geometry.attributes.velocity as THREE.BufferAttribute;
|
||||
for (let j = 0; j < positions.count; j++) {
|
||||
positions.setX(j, positions.getX(j) + vels.getX(j));
|
||||
positions.setY(j, positions.getY(j) + vels.getY(j));
|
||||
positions.setZ(j, positions.getZ(j) + vels.getZ(j));
|
||||
// Dampen velocity
|
||||
vels.setX(j, vels.getX(j) * 0.97);
|
||||
vels.setY(j, vels.getY(j) * 0.97);
|
||||
vels.setZ(j, vels.getZ(j) * 0.97);
|
||||
}
|
||||
positions.needsUpdate = true;
|
||||
const mat = burst.particles.material as THREE.PointsMaterial;
|
||||
mat.opacity = Math.max(0, 1 - burst.age / 120);
|
||||
mat.size = 0.6 * (1 - burst.age / 200);
|
||||
}
|
||||
|
||||
// Update shockwave rings
|
||||
for (let i = shockwaves.length - 1; i >= 0; i--) {
|
||||
const sw = shockwaves[i];
|
||||
sw.age++;
|
||||
if (sw.age > sw.maxAge) {
|
||||
scene.remove(sw.mesh);
|
||||
sw.mesh.geometry.dispose();
|
||||
(sw.mesh.material as THREE.Material).dispose();
|
||||
shockwaves.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
const progress = sw.age / sw.maxAge;
|
||||
const scale = 1 + progress * 20;
|
||||
sw.mesh.scale.setScalar(scale);
|
||||
(sw.mesh.material as THREE.MeshBasicMaterial).opacity = 0.8 * (1 - progress);
|
||||
sw.mesh.lookAt(camera.position);
|
||||
}
|
||||
|
||||
// Update connection flash lines
|
||||
for (let i = connectionFlashes.length - 1; i >= 0; i--) {
|
||||
const flash = connectionFlashes[i];
|
||||
flash.intensity -= 0.015;
|
||||
if (flash.intensity <= 0) {
|
||||
scene.remove(flash.line);
|
||||
flash.line.geometry.dispose();
|
||||
(flash.line.material as THREE.Material).dispose();
|
||||
connectionFlashes.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
(flash.line.material as THREE.LineBasicMaterial).opacity = flash.intensity;
|
||||
mapEventToEffects(event, effects, nodeManager.positions, nodeManager.meshMap, ctx.camera);
|
||||
}
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
if (!container) return;
|
||||
const w = container.clientWidth;
|
||||
const h = container.clientHeight;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
composer.setSize(w, h);
|
||||
if (!container || !ctx) return;
|
||||
resizeScene(ctx, container);
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
ctx.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
ctx.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const meshes = Array.from(nodeMeshMap.values());
|
||||
const intersects = raycaster.intersectObjects(meshes);
|
||||
ctx.raycaster.setFromCamera(ctx.mouse, ctx.camera);
|
||||
const intersects = ctx.raycaster.intersectObjects(nodeManager.getMeshes());
|
||||
|
||||
if (intersects.length > 0) {
|
||||
hoveredNode = intersects[0].object.userData.nodeId;
|
||||
nodeManager.hoveredNode = intersects[0].object.userData.nodeId;
|
||||
container.style.cursor = 'pointer';
|
||||
} else {
|
||||
hoveredNode = null;
|
||||
nodeManager.hoveredNode = null;
|
||||
container.style.cursor = 'grab';
|
||||
}
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
if (hoveredNode) {
|
||||
selectedNode = hoveredNode;
|
||||
onSelect?.(hoveredNode);
|
||||
if (nodeManager.hoveredNode) {
|
||||
nodeManager.selectedNode = nodeManager.hoveredNode;
|
||||
onSelect?.(nodeManager.hoveredNode);
|
||||
|
||||
// Fly camera to selected node
|
||||
const pos = nodePositions.get(hoveredNode);
|
||||
const pos = nodeManager.positions.get(nodeManager.hoveredNode);
|
||||
if (pos) {
|
||||
const target = pos.clone();
|
||||
controls.target.lerp(target, 0.5);
|
||||
ctx.controls.target.lerp(pos.clone(), 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
127
apps/dashboard/src/lib/components/PipelineVisualizer.svelte
Normal file
127
apps/dashboard/src/lib/components/PipelineVisualizer.svelte
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
resultCount?: number;
|
||||
durationMs?: number;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
let { resultCount = 0, durationMs = 0, active = false }: Props = $props();
|
||||
|
||||
const stages = [
|
||||
{ name: 'Overfetch', icon: '◎', color: '#818CF8', desc: 'Pull 3x results from hybrid search' },
|
||||
{ name: 'Rerank', icon: '⟿', color: '#00A8FF', desc: 'Re-score by relevance quality' },
|
||||
{ name: 'Temporal', icon: '◷', color: '#00D4FF', desc: 'Recent memories get recency bonus' },
|
||||
{ name: 'Access', icon: '◇', color: '#00FFD1', desc: 'FSRS-6 retention threshold filter' },
|
||||
{ name: 'Context', icon: '◬', color: '#FFB800', desc: 'Encoding specificity matching' },
|
||||
{ name: 'Compete', icon: '⬡', color: '#FF3CAC', desc: 'Retrieval-induced forgetting' },
|
||||
{ name: 'Activate', icon: '◈', color: '#9D00FF', desc: 'Spreading activation cascade' },
|
||||
];
|
||||
|
||||
let activeStage = $state(-1);
|
||||
let animating = $state(false);
|
||||
let showResult = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (active && !animating) {
|
||||
startAnimation();
|
||||
}
|
||||
});
|
||||
|
||||
function startAnimation() {
|
||||
animating = true;
|
||||
activeStage = -1;
|
||||
showResult = false;
|
||||
|
||||
// Stretch animation to 2x actual duration for visibility (min 1.5s)
|
||||
const totalDuration = Math.max(1500, (durationMs || 50) * 2);
|
||||
const stageDelay = totalDuration / (stages.length + 1);
|
||||
|
||||
stages.forEach((_, i) => {
|
||||
setTimeout(() => {
|
||||
activeStage = i;
|
||||
}, stageDelay * (i + 1));
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
showResult = true;
|
||||
animating = false;
|
||||
}, totalDuration);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="glass-subtle rounded-xl p-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] text-synapse-glow uppercase tracking-wider font-medium">Cognitive Search Pipeline</span>
|
||||
{#if showResult}
|
||||
<span class="text-[10px] text-recall">{resultCount} results in {durationMs}ms</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 7-stage pipeline visualization -->
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each stages as stage, i}
|
||||
{@const isActive = i <= activeStage}
|
||||
{@const isCurrent = i === activeStage && animating}
|
||||
|
||||
<!-- Stage node -->
|
||||
<div class="flex flex-col items-center gap-1 flex-1 min-w-0">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center text-xs transition-all duration-300
|
||||
{isCurrent ? 'scale-125' : ''}"
|
||||
style="background: {isActive ? stage.color + '25' : 'rgba(255,255,255,0.03)'};
|
||||
border: 1.5px solid {isActive ? stage.color : 'rgba(255,255,255,0.06)'};
|
||||
color: {isActive ? stage.color : '#4a4a7a'};
|
||||
box-shadow: {isCurrent ? '0 0 12px ' + stage.color + '40' : 'none'}"
|
||||
title={stage.desc}
|
||||
>
|
||||
{stage.icon}
|
||||
</div>
|
||||
<span class="text-[8px] truncate w-full text-center transition-colors duration-300"
|
||||
style="color: {isActive ? stage.color : '#4a4a7a'}">
|
||||
{stage.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Connecting line -->
|
||||
{#if i < stages.length - 1}
|
||||
<div class="h-px flex-shrink-0 w-2 mt-[-12px] transition-all duration-300"
|
||||
style="background: {i < activeStage ? stages[i + 1].color + '60' : 'rgba(255,255,255,0.06)'}">
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Energy pulse bar -->
|
||||
<div class="h-1 bg-white/[0.03] rounded-full overflow-hidden">
|
||||
{#if animating || showResult}
|
||||
<div
|
||||
class="h-full rounded-full transition-all ease-out"
|
||||
style="width: {showResult ? '100' : ((activeStage + 1) / stages.length * 100).toFixed(0)}%;
|
||||
background: linear-gradient(90deg, #818CF8, #00FFD1, #9D00FF);
|
||||
transition-duration: {animating ? '300ms' : '500ms'}"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Result burst -->
|
||||
{#if showResult}
|
||||
<div class="flex items-center gap-2 pt-1 animate-fade-in">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-recall animate-pulse-glow"></div>
|
||||
<span class="text-[10px] text-dim">
|
||||
Pipeline complete: {resultCount} memories surfaced from {stages.length}-stage cognitive cascade
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
144
apps/dashboard/src/lib/components/TimeSlider.svelte
Normal file
144
apps/dashboard/src/lib/components/TimeSlider.svelte
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { GraphNode } from '$types';
|
||||
import { getDateRange } from '$lib/graph/temporal';
|
||||
|
||||
interface Props {
|
||||
nodes: GraphNode[];
|
||||
onDateChange: (date: Date) => void;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
let { nodes, onDateChange, onToggle }: Props = $props();
|
||||
|
||||
let enabled = $state(false);
|
||||
let playing = $state(false);
|
||||
let speed = $state(1); // days per second
|
||||
let sliderValue = $state(100); // 0-100 percentage
|
||||
let animFrameId: number;
|
||||
let lastTime = 0;
|
||||
|
||||
let dateRange = $derived(getDateRange(nodes));
|
||||
let currentDate = $derived.by(() => {
|
||||
const oldest = dateRange.oldest.getTime();
|
||||
const newest = dateRange.newest.getTime();
|
||||
const range = newest - oldest || 1;
|
||||
return new Date(oldest + (sliderValue / 100) * range);
|
||||
});
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
enabled = !enabled;
|
||||
onToggle(enabled);
|
||||
if (enabled) {
|
||||
sliderValue = 100;
|
||||
onDateChange(currentDate);
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
playing = !playing;
|
||||
if (playing) {
|
||||
sliderValue = 0;
|
||||
lastTime = performance.now();
|
||||
playLoop();
|
||||
} else {
|
||||
cancelAnimationFrame(animFrameId);
|
||||
}
|
||||
}
|
||||
|
||||
function playLoop() {
|
||||
animFrameId = requestAnimationFrame((now) => {
|
||||
const deltaSeconds = (now - lastTime) / 1000;
|
||||
lastTime = now;
|
||||
|
||||
const oldest = dateRange.oldest.getTime();
|
||||
const newest = dateRange.newest.getTime();
|
||||
const totalDays = (newest - oldest) / (24 * 60 * 60 * 1000) || 1;
|
||||
|
||||
// Advance by speed days per second
|
||||
const percentPerSecond = (speed / totalDays) * 100;
|
||||
sliderValue = Math.min(100, sliderValue + percentPerSecond * deltaSeconds);
|
||||
|
||||
onDateChange(currentDate);
|
||||
|
||||
if (sliderValue >= 100) {
|
||||
playing = false;
|
||||
return;
|
||||
}
|
||||
playLoop();
|
||||
});
|
||||
}
|
||||
|
||||
function onSliderInput() {
|
||||
onDateChange(currentDate);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
cancelAnimationFrame(animFrameId);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if enabled}
|
||||
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 w-[90%] max-w-xl">
|
||||
<div class="glass-panel rounded-xl p-3 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={togglePlay}
|
||||
class="w-7 h-7 rounded-lg bg-synapse/20 border border-synapse/30 text-synapse-glow text-xs flex items-center justify-center hover:bg-synapse/30 transition"
|
||||
>
|
||||
{playing ? '⏸' : '▶'}
|
||||
</button>
|
||||
|
||||
<select
|
||||
bind:value={speed}
|
||||
class="px-2 py-1 bg-white/[0.03] border border-synapse/10 rounded-lg text-[10px] text-dim focus:outline-none"
|
||||
>
|
||||
<option value={1}>1x</option>
|
||||
<option value={7}>7x</option>
|
||||
<option value={30}>30x</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<span class="text-xs text-bright font-medium">{formatDate(currentDate)}</span>
|
||||
|
||||
<button
|
||||
onclick={toggle}
|
||||
class="text-[10px] text-muted hover:text-text transition"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
bind:value={sliderValue}
|
||||
oninput={onSliderInput}
|
||||
class="w-full h-1.5 appearance-none bg-white/[0.06] rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-synapse-glow
|
||||
[&::-webkit-slider-thumb]:shadow-[0_0_8px_rgba(129,140,248,0.4)]"
|
||||
/>
|
||||
|
||||
<div class="flex justify-between text-[9px] text-muted">
|
||||
<span>{formatDate(dateRange.oldest)}</span>
|
||||
<span>{formatDate(dateRange.newest)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={toggle}
|
||||
class="absolute bottom-4 right-4 z-10 px-3 py-2 glass rounded-xl text-dim text-xs hover:text-text transition flex items-center gap-1.5"
|
||||
>
|
||||
<span>◷</span>
|
||||
<span>Timeline</span>
|
||||
</button>
|
||||
{/if}
|
||||
102
apps/dashboard/src/lib/graph/dream-mode.ts
Normal file
102
apps/dashboard/src/lib/graph/dream-mode.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import * as THREE from 'three';
|
||||
import type { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
||||
import type { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
export interface DreamConfig {
|
||||
bloomStrength: number;
|
||||
rotateSpeed: number;
|
||||
fogColor: number;
|
||||
fogDensity: number;
|
||||
nebulaIntensity: number;
|
||||
chromaticIntensity: number;
|
||||
vignetteRadius: number;
|
||||
breatheAmplitude: number;
|
||||
}
|
||||
|
||||
const NORMAL_CONFIG: DreamConfig = {
|
||||
bloomStrength: 0.8,
|
||||
rotateSpeed: 0.3,
|
||||
fogColor: 0x050510,
|
||||
fogDensity: 0.008,
|
||||
nebulaIntensity: 0,
|
||||
chromaticIntensity: 0.002,
|
||||
vignetteRadius: 0.9,
|
||||
breatheAmplitude: 1.0,
|
||||
};
|
||||
|
||||
const DREAM_CONFIG: DreamConfig = {
|
||||
bloomStrength: 1.8,
|
||||
rotateSpeed: 0.08,
|
||||
fogColor: 0x0a0520,
|
||||
fogDensity: 0.006,
|
||||
nebulaIntensity: 1.0,
|
||||
chromaticIntensity: 0.005,
|
||||
vignetteRadius: 0.7,
|
||||
breatheAmplitude: 2.0,
|
||||
};
|
||||
|
||||
export class DreamMode {
|
||||
active = false;
|
||||
private transition = 0; // 0 = normal, 1 = dream
|
||||
private transitionSpeed = 0.008; // ~2 seconds at 60fps
|
||||
current: DreamConfig;
|
||||
private auroraHue = 0;
|
||||
|
||||
constructor() {
|
||||
this.current = { ...NORMAL_CONFIG };
|
||||
}
|
||||
|
||||
setActive(active: boolean) {
|
||||
this.active = active;
|
||||
}
|
||||
|
||||
update(
|
||||
scene: THREE.Scene,
|
||||
bloomPass: UnrealBloomPass,
|
||||
controls: OrbitControls,
|
||||
lights: { point1: THREE.PointLight; point2: THREE.PointLight },
|
||||
_time: number
|
||||
) {
|
||||
// Smooth transition
|
||||
const target = this.active ? 1 : 0;
|
||||
this.transition += (target - this.transition) * this.transitionSpeed * 60 * (1 / 60);
|
||||
this.transition = Math.max(0, Math.min(1, this.transition));
|
||||
|
||||
const t = this.transition;
|
||||
|
||||
// Lerp all config values
|
||||
this.current.bloomStrength = this.lerp(NORMAL_CONFIG.bloomStrength, DREAM_CONFIG.bloomStrength, t);
|
||||
this.current.rotateSpeed = this.lerp(NORMAL_CONFIG.rotateSpeed, DREAM_CONFIG.rotateSpeed, t);
|
||||
this.current.fogDensity = this.lerp(NORMAL_CONFIG.fogDensity, DREAM_CONFIG.fogDensity, t);
|
||||
this.current.nebulaIntensity = this.lerp(NORMAL_CONFIG.nebulaIntensity, DREAM_CONFIG.nebulaIntensity, t);
|
||||
this.current.chromaticIntensity = this.lerp(NORMAL_CONFIG.chromaticIntensity, DREAM_CONFIG.chromaticIntensity, t);
|
||||
this.current.vignetteRadius = this.lerp(NORMAL_CONFIG.vignetteRadius, DREAM_CONFIG.vignetteRadius, t);
|
||||
this.current.breatheAmplitude = this.lerp(NORMAL_CONFIG.breatheAmplitude, DREAM_CONFIG.breatheAmplitude, t);
|
||||
|
||||
// Apply
|
||||
bloomPass.strength = this.current.bloomStrength;
|
||||
controls.autoRotateSpeed = this.current.rotateSpeed;
|
||||
|
||||
// Fog color lerp
|
||||
const normalFog = new THREE.Color(NORMAL_CONFIG.fogColor);
|
||||
const dreamFog = new THREE.Color(DREAM_CONFIG.fogColor);
|
||||
const fogColor = normalFog.clone().lerp(dreamFog, t);
|
||||
scene.fog = new THREE.FogExp2(fogColor, this.current.fogDensity);
|
||||
|
||||
// Aurora color cycling during dream
|
||||
if (t > 0.01) {
|
||||
this.auroraHue = (_time * 0.1) % 1;
|
||||
const auroraColor1 = new THREE.Color().setHSL(0.75 + this.auroraHue * 0.15, 0.8, 0.5);
|
||||
const auroraColor2 = new THREE.Color().setHSL(0.55 + this.auroraHue * 0.2, 0.7, 0.4);
|
||||
lights.point1.color.lerp(auroraColor1, t * 0.3);
|
||||
lights.point2.color.lerp(auroraColor2, t * 0.3);
|
||||
} else {
|
||||
lights.point1.color.set(0x6366f1);
|
||||
lights.point2.color.set(0xa855f7);
|
||||
}
|
||||
}
|
||||
|
||||
private lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
}
|
||||
53
apps/dashboard/src/lib/graph/edges.ts
Normal file
53
apps/dashboard/src/lib/graph/edges.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import * as THREE from 'three';
|
||||
import type { GraphEdge } from '$types';
|
||||
|
||||
export class EdgeManager {
|
||||
group: THREE.Group;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
updatePositions(positions: Map<string, THREE.Vector3>) {
|
||||
this.group.children.forEach((child) => {
|
||||
const line = child as THREE.Line;
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
204
apps/dashboard/src/lib/graph/effects.ts
Normal file
204
apps/dashboard/src/lib/graph/effects.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import * as THREE from 'three';
|
||||
|
||||
export interface PulseEffect {
|
||||
nodeId: string;
|
||||
intensity: number;
|
||||
color: THREE.Color;
|
||||
decay: number;
|
||||
}
|
||||
|
||||
interface SpawnBurst {
|
||||
position: THREE.Vector3;
|
||||
age: number;
|
||||
particles: THREE.Points;
|
||||
}
|
||||
|
||||
interface Shockwave {
|
||||
mesh: THREE.Mesh;
|
||||
age: number;
|
||||
maxAge: number;
|
||||
}
|
||||
|
||||
interface ConnectionFlash {
|
||||
line: THREE.Line;
|
||||
intensity: number;
|
||||
}
|
||||
|
||||
export class EffectManager {
|
||||
pulseEffects: PulseEffect[] = [];
|
||||
private spawnBursts: SpawnBurst[] = [];
|
||||
private shockwaves: Shockwave[] = [];
|
||||
private connectionFlashes: ConnectionFlash[] = [];
|
||||
private scene: THREE.Scene;
|
||||
|
||||
constructor(scene: THREE.Scene) {
|
||||
this.scene = scene;
|
||||
}
|
||||
|
||||
addPulse(nodeId: string, intensity: number, color: THREE.Color, decay: number) {
|
||||
this.pulseEffects.push({ nodeId, intensity, color, decay });
|
||||
}
|
||||
|
||||
createSpawnBurst(position: THREE.Vector3, color: THREE.Color) {
|
||||
const count = 60;
|
||||
const geo = new THREE.BufferGeometry();
|
||||
const positions = new Float32Array(count * 3);
|
||||
const velocities = new Float32Array(count * 3);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
positions[i * 3] = position.x;
|
||||
positions[i * 3 + 1] = position.y;
|
||||
positions[i * 3 + 2] = position.z;
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
const speed = 0.3 + Math.random() * 0.5;
|
||||
velocities[i * 3] = Math.sin(phi) * Math.cos(theta) * speed;
|
||||
velocities[i * 3 + 1] = Math.sin(phi) * Math.sin(theta) * speed;
|
||||
velocities[i * 3 + 2] = Math.cos(phi) * speed;
|
||||
}
|
||||
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('velocity', new THREE.BufferAttribute(velocities, 3));
|
||||
|
||||
const mat = new THREE.PointsMaterial({
|
||||
color,
|
||||
size: 0.6,
|
||||
transparent: true,
|
||||
opacity: 1.0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
|
||||
const pts = new THREE.Points(geo, mat);
|
||||
this.scene.add(pts);
|
||||
this.spawnBursts.push({ position: position.clone(), age: 0, particles: pts });
|
||||
}
|
||||
|
||||
createShockwave(position: THREE.Vector3, color: THREE.Color, camera: THREE.Camera) {
|
||||
const geo = new THREE.RingGeometry(0.1, 0.5, 64);
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
side: THREE.DoubleSide,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const ring = new THREE.Mesh(geo, mat);
|
||||
ring.position.copy(position);
|
||||
ring.lookAt(camera.position);
|
||||
this.scene.add(ring);
|
||||
this.shockwaves.push({ mesh: ring, age: 0, maxAge: 60 });
|
||||
}
|
||||
|
||||
createConnectionFlash(from: THREE.Vector3, to: THREE.Vector3, color: THREE.Color) {
|
||||
const points = [from.clone(), to.clone()];
|
||||
const geo = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const mat = new THREE.LineBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: 1.0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const line = new THREE.Line(geo, mat);
|
||||
this.scene.add(line);
|
||||
this.connectionFlashes.push({ line, intensity: 1.0 });
|
||||
}
|
||||
|
||||
update(nodeMeshMap: Map<string, THREE.Mesh>, camera: THREE.Camera) {
|
||||
// Pulse effects
|
||||
for (let i = this.pulseEffects.length - 1; i >= 0; i--) {
|
||||
const pulse = this.pulseEffects[i];
|
||||
pulse.intensity -= pulse.decay;
|
||||
if (pulse.intensity <= 0) {
|
||||
this.pulseEffects.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
const mesh = nodeMeshMap.get(pulse.nodeId);
|
||||
if (mesh) {
|
||||
const mat = mesh.material as THREE.MeshStandardMaterial;
|
||||
mat.emissive.lerp(pulse.color, pulse.intensity * 0.3);
|
||||
mat.emissiveIntensity = Math.max(mat.emissiveIntensity, pulse.intensity);
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn bursts
|
||||
for (let i = this.spawnBursts.length - 1; i >= 0; i--) {
|
||||
const burst = this.spawnBursts[i];
|
||||
burst.age++;
|
||||
if (burst.age > 120) {
|
||||
this.scene.remove(burst.particles);
|
||||
burst.particles.geometry.dispose();
|
||||
(burst.particles.material as THREE.Material).dispose();
|
||||
this.spawnBursts.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
const positions = burst.particles.geometry.attributes.position as THREE.BufferAttribute;
|
||||
const vels = burst.particles.geometry.attributes.velocity as THREE.BufferAttribute;
|
||||
for (let j = 0; j < positions.count; j++) {
|
||||
positions.setX(j, positions.getX(j) + vels.getX(j));
|
||||
positions.setY(j, positions.getY(j) + vels.getY(j));
|
||||
positions.setZ(j, positions.getZ(j) + vels.getZ(j));
|
||||
vels.setX(j, vels.getX(j) * 0.97);
|
||||
vels.setY(j, vels.getY(j) * 0.97);
|
||||
vels.setZ(j, vels.getZ(j) * 0.97);
|
||||
}
|
||||
positions.needsUpdate = true;
|
||||
const mat = burst.particles.material as THREE.PointsMaterial;
|
||||
mat.opacity = Math.max(0, 1 - burst.age / 120);
|
||||
mat.size = 0.6 * (1 - burst.age / 200);
|
||||
}
|
||||
|
||||
// Shockwaves
|
||||
for (let i = this.shockwaves.length - 1; i >= 0; i--) {
|
||||
const sw = this.shockwaves[i];
|
||||
sw.age++;
|
||||
if (sw.age > sw.maxAge) {
|
||||
this.scene.remove(sw.mesh);
|
||||
sw.mesh.geometry.dispose();
|
||||
(sw.mesh.material as THREE.Material).dispose();
|
||||
this.shockwaves.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
const progress = sw.age / sw.maxAge;
|
||||
sw.mesh.scale.setScalar(1 + progress * 20);
|
||||
(sw.mesh.material as THREE.MeshBasicMaterial).opacity = 0.8 * (1 - progress);
|
||||
sw.mesh.lookAt(camera.position);
|
||||
}
|
||||
|
||||
// Connection flashes
|
||||
for (let i = this.connectionFlashes.length - 1; i >= 0; i--) {
|
||||
const flash = this.connectionFlashes[i];
|
||||
flash.intensity -= 0.015;
|
||||
if (flash.intensity <= 0) {
|
||||
this.scene.remove(flash.line);
|
||||
flash.line.geometry.dispose();
|
||||
(flash.line.material as THREE.Material).dispose();
|
||||
this.connectionFlashes.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
(flash.line.material as THREE.LineBasicMaterial).opacity = flash.intensity;
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
for (const burst of this.spawnBursts) {
|
||||
this.scene.remove(burst.particles);
|
||||
burst.particles.geometry.dispose();
|
||||
(burst.particles.material as THREE.Material).dispose();
|
||||
}
|
||||
for (const sw of this.shockwaves) {
|
||||
this.scene.remove(sw.mesh);
|
||||
sw.mesh.geometry.dispose();
|
||||
(sw.mesh.material as THREE.Material).dispose();
|
||||
}
|
||||
for (const flash of this.connectionFlashes) {
|
||||
this.scene.remove(flash.line);
|
||||
flash.line.geometry.dispose();
|
||||
(flash.line.material as THREE.Material).dispose();
|
||||
}
|
||||
this.pulseEffects = [];
|
||||
this.spawnBursts = [];
|
||||
this.shockwaves = [];
|
||||
this.connectionFlashes = [];
|
||||
}
|
||||
}
|
||||
98
apps/dashboard/src/lib/graph/events.ts
Normal file
98
apps/dashboard/src/lib/graph/events.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import * as THREE from 'three';
|
||||
import type { VestigeEvent } from '$types';
|
||||
import type { EffectManager } from './effects';
|
||||
|
||||
export function mapEventToEffects(
|
||||
event: VestigeEvent,
|
||||
effects: EffectManager,
|
||||
nodePositions: Map<string, THREE.Vector3>,
|
||||
nodeMeshMap: Map<string, THREE.Mesh>,
|
||||
camera: THREE.Camera
|
||||
) {
|
||||
switch (event.type) {
|
||||
case 'MemoryCreated': {
|
||||
const nodeId = (event.data as { id?: string })?.id;
|
||||
const pos = nodeId ? nodePositions.get(nodeId) : null;
|
||||
const burstPos =
|
||||
pos?.clone() ??
|
||||
new THREE.Vector3(
|
||||
(Math.random() - 0.5) * 40,
|
||||
(Math.random() - 0.5) * 40,
|
||||
(Math.random() - 0.5) * 40
|
||||
);
|
||||
effects.createSpawnBurst(burstPos, new THREE.Color(0x00ffd1));
|
||||
effects.createShockwave(burstPos, new THREE.Color(0x00ffd1), camera);
|
||||
break;
|
||||
}
|
||||
case 'SearchPerformed': {
|
||||
nodeMeshMap.forEach((_, id) => {
|
||||
effects.addPulse(id, 0.6 + Math.random() * 0.4, new THREE.Color(0x818cf8), 0.02);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'DreamStarted': {
|
||||
nodeMeshMap.forEach((_, id) => {
|
||||
effects.addPulse(id, 1.0, new THREE.Color(0xa855f7), 0.005);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'DreamProgress': {
|
||||
const memoryId = (event.data as { memory_id?: string })?.memory_id;
|
||||
if (memoryId && nodeMeshMap.has(memoryId)) {
|
||||
effects.addPulse(memoryId, 1.5, new THREE.Color(0xc084fc), 0.01);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'DreamCompleted': {
|
||||
effects.createSpawnBurst(new THREE.Vector3(0, 0, 0), new THREE.Color(0xa855f7));
|
||||
effects.createShockwave(new THREE.Vector3(0, 0, 0), new THREE.Color(0xa855f7), camera);
|
||||
break;
|
||||
}
|
||||
case 'ConnectionDiscovered': {
|
||||
const data = event.data as { source_id?: string; target_id?: string };
|
||||
const srcPos = data.source_id ? nodePositions.get(data.source_id) : null;
|
||||
const tgtPos = data.target_id ? nodePositions.get(data.target_id) : null;
|
||||
if (srcPos && tgtPos) {
|
||||
effects.createConnectionFlash(srcPos, tgtPos, new THREE.Color(0x00d4ff));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'RetentionDecayed': {
|
||||
const decayId = (event.data as { id?: string })?.id;
|
||||
if (decayId && nodeMeshMap.has(decayId)) {
|
||||
effects.addPulse(decayId, 0.8, new THREE.Color(0xff4757), 0.03);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'MemoryPromoted': {
|
||||
const promoId = (event.data as { id?: string })?.id;
|
||||
if (promoId && nodeMeshMap.has(promoId)) {
|
||||
effects.addPulse(promoId, 1.2, new THREE.Color(0x00ff88), 0.01);
|
||||
const promoPos = nodePositions.get(promoId);
|
||||
if (promoPos) effects.createShockwave(promoPos, new THREE.Color(0x00ff88), camera);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ConsolidationCompleted': {
|
||||
nodeMeshMap.forEach((_, id) => {
|
||||
effects.addPulse(id, 0.4 + Math.random() * 0.3, new THREE.Color(0xffb800), 0.015);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'ActivationSpread': {
|
||||
const spreadData = event.data as { source_id?: string; target_ids?: string[] };
|
||||
if (spreadData.source_id && spreadData.target_ids) {
|
||||
const srcPos = nodePositions.get(spreadData.source_id);
|
||||
if (srcPos) {
|
||||
for (const targetId of spreadData.target_ids) {
|
||||
const tgtPos = nodePositions.get(targetId);
|
||||
if (tgtPos) {
|
||||
effects.createConnectionFlash(srcPos, tgtPos, new THREE.Color(0x14e8c6));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
77
apps/dashboard/src/lib/graph/force-sim.ts
Normal file
77
apps/dashboard/src/lib/graph/force-sim.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import * as THREE from 'three';
|
||||
import type { GraphEdge } from '$types';
|
||||
|
||||
export class ForceSimulation {
|
||||
positions: Map<string, THREE.Vector3>;
|
||||
velocities: Map<string, THREE.Vector3>;
|
||||
running = true;
|
||||
step = 0;
|
||||
|
||||
private readonly repulsionStrength = 500;
|
||||
private readonly attractionStrength = 0.01;
|
||||
private readonly dampening = 0.9;
|
||||
private readonly maxSteps = 300;
|
||||
|
||||
constructor(positions: Map<string, THREE.Vector3>) {
|
||||
this.positions = positions;
|
||||
this.velocities = new Map();
|
||||
for (const id of positions.keys()) {
|
||||
this.velocities.set(id, new THREE.Vector3());
|
||||
}
|
||||
}
|
||||
|
||||
tick(edges: GraphEdge[]) {
|
||||
if (!this.running || this.step > this.maxSteps) return;
|
||||
this.step++;
|
||||
|
||||
const alpha = Math.max(0.001, 1 - this.step / this.maxSteps);
|
||||
const nodeIds = Array.from(this.positions.keys());
|
||||
|
||||
// Repulsion between all nodes
|
||||
for (let i = 0; i < nodeIds.length; i++) {
|
||||
for (let j = i + 1; j < nodeIds.length; j++) {
|
||||
const posA = this.positions.get(nodeIds[i])!;
|
||||
const posB = this.positions.get(nodeIds[j])!;
|
||||
const diff = new THREE.Vector3().subVectors(posA, posB);
|
||||
const dist = diff.length() || 1;
|
||||
const force = (this.repulsionStrength / (dist * dist)) * alpha;
|
||||
const dir = diff.normalize().multiplyScalar(force);
|
||||
|
||||
this.velocities.get(nodeIds[i])!.add(dir);
|
||||
this.velocities.get(nodeIds[j])!.sub(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Attraction along edges
|
||||
for (const edge of edges) {
|
||||
const posA = this.positions.get(edge.source);
|
||||
const posB = this.positions.get(edge.target);
|
||||
if (!posA || !posB) continue;
|
||||
|
||||
const diff = new THREE.Vector3().subVectors(posB, posA);
|
||||
const dist = diff.length();
|
||||
const force = dist * this.attractionStrength * edge.weight * alpha;
|
||||
const dir = diff.normalize().multiplyScalar(force);
|
||||
|
||||
this.velocities.get(edge.source)!.add(dir);
|
||||
this.velocities.get(edge.target)!.sub(dir);
|
||||
}
|
||||
|
||||
// Centering force + velocity integration
|
||||
for (const id of nodeIds) {
|
||||
const pos = this.positions.get(id)!;
|
||||
const vel = this.velocities.get(id)!;
|
||||
vel.sub(pos.clone().multiplyScalar(0.001 * alpha));
|
||||
vel.multiplyScalar(this.dampening);
|
||||
pos.add(vel);
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.step = 0;
|
||||
this.running = true;
|
||||
for (const vel of this.velocities.values()) {
|
||||
vel.set(0, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
196
apps/dashboard/src/lib/graph/nodes.ts
Normal file
196
apps/dashboard/src/lib/graph/nodes.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import * as THREE from 'three';
|
||||
import type { GraphNode } from '$types';
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
|
||||
export class NodeManager {
|
||||
group: THREE.Group;
|
||||
meshMap = new Map<string, THREE.Mesh>();
|
||||
positions = new Map<string, THREE.Vector3>();
|
||||
labelSprites = new Map<string, THREE.Sprite>();
|
||||
hoveredNode: string | null = null;
|
||||
selectedNode: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.group = new THREE.Group();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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.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: 0.15 + node.retention * 0.2,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const sprite = new THREE.Sprite(spriteMat);
|
||||
sprite.scale.set(size * 4, size * 4, 1);
|
||||
sprite.position.copy(pos);
|
||||
sprite.userData = { isGlow: true, nodeId: node.id };
|
||||
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 this.positions;
|
||||
}
|
||||
|
||||
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) {
|
||||
// Node breathing
|
||||
this.meshMap.forEach((mesh, id) => {
|
||||
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 {
|
||||
// Low-retention nodes breathe slower
|
||||
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) => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
92
apps/dashboard/src/lib/graph/particles.ts
Normal file
92
apps/dashboard/src/lib/graph/particles.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import * as THREE from 'three';
|
||||
|
||||
export class ParticleSystem {
|
||||
starField: THREE.Points;
|
||||
neuralParticles: THREE.Points;
|
||||
|
||||
constructor(scene: THREE.Scene) {
|
||||
this.starField = this.createStarField();
|
||||
this.neuralParticles = this.createNeuralParticles();
|
||||
scene.add(this.starField);
|
||||
scene.add(this.neuralParticles);
|
||||
}
|
||||
|
||||
private createStarField(): THREE.Points {
|
||||
const count = 3000;
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
const positions = new Float32Array(count * 3);
|
||||
const sizes = new Float32Array(count);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
positions[i * 3] = (Math.random() - 0.5) * 1000;
|
||||
positions[i * 3 + 1] = (Math.random() - 0.5) * 1000;
|
||||
positions[i * 3 + 2] = (Math.random() - 0.5) * 1000;
|
||||
sizes[i] = Math.random() * 1.5;
|
||||
}
|
||||
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
|
||||
|
||||
const material = new THREE.PointsMaterial({
|
||||
color: 0x6366f1,
|
||||
size: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0.4,
|
||||
sizeAttenuation: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
|
||||
return new THREE.Points(geometry, material);
|
||||
}
|
||||
|
||||
private createNeuralParticles(): THREE.Points {
|
||||
const count = 500;
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
const positions = new Float32Array(count * 3);
|
||||
const colors = new Float32Array(count * 3);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
positions[i * 3] = (Math.random() - 0.5) * 100;
|
||||
positions[i * 3 + 1] = (Math.random() - 0.5) * 100;
|
||||
positions[i * 3 + 2] = (Math.random() - 0.5) * 100;
|
||||
colors[i * 3] = 0.4 + Math.random() * 0.3;
|
||||
colors[i * 3 + 1] = 0.3 + Math.random() * 0.2;
|
||||
colors[i * 3 + 2] = 0.8 + Math.random() * 0.2;
|
||||
}
|
||||
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
|
||||
const material = new THREE.PointsMaterial({
|
||||
size: 0.3,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.4,
|
||||
blending: THREE.AdditiveBlending,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
|
||||
return new THREE.Points(geometry, material);
|
||||
}
|
||||
|
||||
animate(time: number) {
|
||||
// Star rotation
|
||||
this.starField.rotation.y += 0.0001;
|
||||
this.starField.rotation.x += 0.00005;
|
||||
|
||||
// Neural particle motion
|
||||
const positions = this.neuralParticles.geometry.attributes.position as THREE.BufferAttribute;
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
positions.setY(i, positions.getY(i) + Math.sin(time + i * 0.1) * 0.02);
|
||||
positions.setX(i, positions.getX(i) + Math.cos(time + i * 0.05) * 0.01);
|
||||
}
|
||||
positions.needsUpdate = true;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.starField.geometry.dispose();
|
||||
(this.starField.material as THREE.Material).dispose();
|
||||
this.neuralParticles.geometry.dispose();
|
||||
(this.neuralParticles.material as THREE.Material).dispose();
|
||||
}
|
||||
}
|
||||
116
apps/dashboard/src/lib/graph/scene.ts
Normal file
116
apps/dashboard/src/lib/graph/scene.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
||||
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
||||
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
||||
|
||||
export interface SceneContext {
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
renderer: THREE.WebGLRenderer;
|
||||
controls: OrbitControls;
|
||||
composer: EffectComposer;
|
||||
bloomPass: UnrealBloomPass;
|
||||
raycaster: THREE.Raycaster;
|
||||
mouse: THREE.Vector2;
|
||||
lights: {
|
||||
ambient: THREE.AmbientLight;
|
||||
point1: THREE.PointLight;
|
||||
point2: THREE.PointLight;
|
||||
};
|
||||
}
|
||||
|
||||
export function createScene(container: HTMLDivElement): SceneContext {
|
||||
const scene = new THREE.Scene();
|
||||
scene.fog = new THREE.FogExp2(0x050510, 0.008);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
60,
|
||||
container.clientWidth / container.clientHeight,
|
||||
0.1,
|
||||
2000
|
||||
);
|
||||
camera.position.set(0, 30, 80);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
powerPreference: 'high-performance',
|
||||
});
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.2;
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.rotateSpeed = 0.5;
|
||||
controls.zoomSpeed = 0.8;
|
||||
controls.minDistance = 10;
|
||||
controls.maxDistance = 500;
|
||||
controls.autoRotate = true;
|
||||
controls.autoRotateSpeed = 0.3;
|
||||
|
||||
const composer = new EffectComposer(renderer);
|
||||
composer.addPass(new RenderPass(scene, camera));
|
||||
const bloomPass = new UnrealBloomPass(
|
||||
new THREE.Vector2(container.clientWidth, container.clientHeight),
|
||||
0.8,
|
||||
0.4,
|
||||
0.85
|
||||
);
|
||||
composer.addPass(bloomPass);
|
||||
|
||||
const ambient = new THREE.AmbientLight(0x1a1a3a, 0.5);
|
||||
scene.add(ambient);
|
||||
|
||||
const point1 = new THREE.PointLight(0x6366f1, 1.5, 200);
|
||||
point1.position.set(50, 50, 50);
|
||||
scene.add(point1);
|
||||
|
||||
const point2 = new THREE.PointLight(0xa855f7, 1, 200);
|
||||
point2.position.set(-50, -30, -50);
|
||||
scene.add(point2);
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.params.Points = { threshold: 2 };
|
||||
const mouse = new THREE.Vector2();
|
||||
|
||||
return {
|
||||
scene,
|
||||
camera,
|
||||
renderer,
|
||||
controls,
|
||||
composer,
|
||||
bloomPass,
|
||||
raycaster,
|
||||
mouse,
|
||||
lights: { ambient, point1, point2 },
|
||||
};
|
||||
}
|
||||
|
||||
export function resizeScene(ctx: SceneContext, container: HTMLDivElement) {
|
||||
const w = container.clientWidth;
|
||||
const h = container.clientHeight;
|
||||
ctx.camera.aspect = w / h;
|
||||
ctx.camera.updateProjectionMatrix();
|
||||
ctx.renderer.setSize(w, h);
|
||||
ctx.composer.setSize(w, h);
|
||||
}
|
||||
|
||||
export function disposeScene(ctx: SceneContext) {
|
||||
ctx.scene.traverse((obj: THREE.Object3D) => {
|
||||
if (obj instanceof THREE.Mesh || obj instanceof THREE.InstancedMesh) {
|
||||
obj.geometry?.dispose();
|
||||
if (Array.isArray(obj.material)) {
|
||||
obj.material.forEach((m: THREE.Material) => m.dispose());
|
||||
} else if (obj.material) {
|
||||
(obj.material as THREE.Material).dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
ctx.renderer.dispose();
|
||||
ctx.composer.dispose();
|
||||
}
|
||||
146
apps/dashboard/src/lib/graph/shaders/nebula.frag.ts
Normal file
146
apps/dashboard/src/lib/graph/shaders/nebula.frag.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import * as THREE from 'three';
|
||||
|
||||
// Domain-warped FBM noise nebula background shader
|
||||
const vertexShader = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform float uTime;
|
||||
uniform vec2 uResolution;
|
||||
uniform float uDreamIntensity;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
// Simplex-style hash
|
||||
vec3 hash33(vec3 p3) {
|
||||
p3 = fract(p3 * vec3(0.1031, 0.1030, 0.0973));
|
||||
p3 += dot(p3, p3.yxz + 33.33);
|
||||
return fract((p3.xxy + p3.yxx) * p3.zyx);
|
||||
}
|
||||
|
||||
// 3D value noise
|
||||
float noise(vec3 p) {
|
||||
vec3 i = floor(p);
|
||||
vec3 f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
|
||||
float n = i.x + i.y * 157.0 + 113.0 * i.z;
|
||||
|
||||
vec4 v1 = fract(sin(vec4(n + 0.0, n + 1.0, n + 157.0, n + 158.0)) * 43758.5453);
|
||||
vec4 v2 = fract(sin(vec4(n + 113.0, n + 114.0, n + 270.0, n + 271.0)) * 43758.5453);
|
||||
|
||||
vec4 a = mix(v1, v2, f.z);
|
||||
vec2 b = mix(a.xy, a.zw, f.y);
|
||||
return mix(b.x, b.y, f.x);
|
||||
}
|
||||
|
||||
// FBM with 5 octaves
|
||||
float fbm(vec3 p) {
|
||||
float value = 0.0;
|
||||
float amplitude = 0.5;
|
||||
float frequency = 1.0;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
value += amplitude * noise(p * frequency);
|
||||
frequency *= 2.0;
|
||||
amplitude *= 0.5;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// IQ cosine palette
|
||||
vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
|
||||
return a + b * cos(6.28318 * (c * t + d));
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution.xy) / min(uResolution.x, uResolution.y);
|
||||
float t = uTime * 0.05;
|
||||
|
||||
// Domain warping: fbm(p + fbm(p + fbm(p)))
|
||||
vec3 p = vec3(uv * 2.0, t);
|
||||
|
||||
float warp1 = fbm(p);
|
||||
float warp2 = fbm(p + warp1 * 3.0 + vec3(1.7, 9.2, t * 0.3));
|
||||
float warp3 = fbm(p + warp2 * 2.5 + vec3(8.3, 2.8, t * 0.2));
|
||||
|
||||
// Final noise value
|
||||
float f = fbm(p + warp3 * 2.0);
|
||||
|
||||
// Color: cosmic palette that shifts during dream mode
|
||||
vec3 normalA = vec3(0.02, 0.01, 0.05);
|
||||
vec3 normalB = vec3(0.03, 0.02, 0.08);
|
||||
vec3 normalC = vec3(1.0, 1.0, 1.0);
|
||||
vec3 normalD = vec3(0.70, 0.55, 0.80);
|
||||
|
||||
vec3 dreamA = vec3(0.05, 0.01, 0.08);
|
||||
vec3 dreamB = vec3(0.06, 0.03, 0.12);
|
||||
vec3 dreamC = vec3(1.0, 0.8, 1.0);
|
||||
vec3 dreamD = vec3(0.80, 0.40, 0.90);
|
||||
|
||||
vec3 a = mix(normalA, dreamA, uDreamIntensity);
|
||||
vec3 b = mix(normalB, dreamB, uDreamIntensity);
|
||||
vec3 c = mix(normalC, dreamC, uDreamIntensity);
|
||||
vec3 d = mix(normalD, dreamD, uDreamIntensity);
|
||||
|
||||
vec3 color = palette(f + warp2 * 0.5, a, b, c, d);
|
||||
|
||||
// Add subtle star-like highlights
|
||||
float stars = smoothstep(0.97, 1.0, noise(vec3(uv * 50.0, t * 0.1)));
|
||||
color += stars * 0.15;
|
||||
|
||||
// Intensity modulation
|
||||
float intensity = 0.15 + 0.1 * uDreamIntensity;
|
||||
color *= intensity;
|
||||
|
||||
// Vignette
|
||||
float dist = length(uv);
|
||||
color *= smoothstep(1.5, 0.3, dist);
|
||||
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export function createNebulaBackground(scene: THREE.Scene): {
|
||||
mesh: THREE.Mesh;
|
||||
material: THREE.ShaderMaterial;
|
||||
} {
|
||||
const geometry = new THREE.PlaneGeometry(2, 2);
|
||||
const material = new THREE.ShaderMaterial({
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
|
||||
uDreamIntensity: { value: 0 },
|
||||
},
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
transparent: false,
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.frustumCulled = false;
|
||||
mesh.renderOrder = -1000;
|
||||
scene.add(mesh);
|
||||
|
||||
return { mesh, material };
|
||||
}
|
||||
|
||||
export function updateNebula(
|
||||
material: THREE.ShaderMaterial,
|
||||
time: number,
|
||||
dreamIntensity: number,
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
material.uniforms.uTime.value = time;
|
||||
material.uniforms.uDreamIntensity.value = dreamIntensity;
|
||||
material.uniforms.uResolution.value.set(width, height);
|
||||
}
|
||||
147
apps/dashboard/src/lib/graph/shaders/post-processing.ts
Normal file
147
apps/dashboard/src/lib/graph/shaders/post-processing.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import * as THREE from 'three';
|
||||
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
|
||||
import type { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
||||
|
||||
// Chromatic Aberration
|
||||
const ChromaticAberrationShader = {
|
||||
uniforms: {
|
||||
tDiffuse: { value: null },
|
||||
uIntensity: { value: 0.002 },
|
||||
},
|
||||
vertexShader: /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform sampler2D tDiffuse;
|
||||
uniform float uIntensity;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec2 center = vec2(0.5);
|
||||
vec2 dir = vUv - center;
|
||||
float dist = length(dir);
|
||||
|
||||
float rOffset = uIntensity * dist;
|
||||
float gOffset = 0.0;
|
||||
float bOffset = -uIntensity * dist;
|
||||
|
||||
vec2 rUv = vUv + dir * rOffset;
|
||||
vec2 gUv = vUv + dir * gOffset;
|
||||
vec2 bUv = vUv + dir * bOffset;
|
||||
|
||||
float r = texture2D(tDiffuse, rUv).r;
|
||||
float g = texture2D(tDiffuse, gUv).g;
|
||||
float b = texture2D(tDiffuse, bUv).b;
|
||||
|
||||
gl_FragColor = vec4(r, g, b, 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
// Film Grain
|
||||
const FilmGrainShader = {
|
||||
uniforms: {
|
||||
tDiffuse: { value: null },
|
||||
uTime: { value: 0 },
|
||||
uIntensity: { value: 0.04 },
|
||||
},
|
||||
vertexShader: /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform sampler2D tDiffuse;
|
||||
uniform float uTime;
|
||||
uniform float uIntensity;
|
||||
varying vec2 vUv;
|
||||
|
||||
float rand(vec2 co) {
|
||||
return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 color = texture2D(tDiffuse, vUv);
|
||||
float grain = rand(vUv + vec2(uTime)) * 2.0 - 1.0;
|
||||
color.rgb += grain * uIntensity;
|
||||
gl_FragColor = color;
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
// Vignette
|
||||
const VignetteShader = {
|
||||
uniforms: {
|
||||
tDiffuse: { value: null },
|
||||
uRadius: { value: 0.9 },
|
||||
uSoftness: { value: 0.5 },
|
||||
},
|
||||
vertexShader: /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform sampler2D tDiffuse;
|
||||
uniform float uRadius;
|
||||
uniform float uSoftness;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec4 color = texture2D(tDiffuse, vUv);
|
||||
vec2 center = vec2(0.5);
|
||||
float dist = distance(vUv, center) * 1.414;
|
||||
float vignette = smoothstep(uRadius, uRadius - uSoftness, dist);
|
||||
color.rgb *= vignette;
|
||||
gl_FragColor = color;
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export interface PostProcessingStack {
|
||||
chromatic: ShaderPass;
|
||||
grain: ShaderPass;
|
||||
vignette: ShaderPass;
|
||||
}
|
||||
|
||||
export function createPostProcessing(composer: EffectComposer): PostProcessingStack {
|
||||
const chromatic = new ShaderPass(ChromaticAberrationShader);
|
||||
const grain = new ShaderPass(FilmGrainShader);
|
||||
const vignette = new ShaderPass(VignetteShader);
|
||||
|
||||
composer.addPass(chromatic);
|
||||
composer.addPass(grain);
|
||||
composer.addPass(vignette);
|
||||
|
||||
return { chromatic, grain, vignette };
|
||||
}
|
||||
|
||||
export function updatePostProcessing(
|
||||
stack: PostProcessingStack,
|
||||
time: number,
|
||||
dreamIntensity: number
|
||||
) {
|
||||
// Chromatic aberration: doubles during dream
|
||||
const chromaticBase = 0.002;
|
||||
const chromaticDream = 0.005;
|
||||
stack.chromatic.uniforms.uIntensity.value =
|
||||
chromaticBase + (chromaticDream - chromaticBase) * dreamIntensity;
|
||||
|
||||
// Film grain: animated
|
||||
stack.grain.uniforms.uTime.value = time;
|
||||
stack.grain.uniforms.uIntensity.value = 0.04 + dreamIntensity * 0.02;
|
||||
|
||||
// Vignette: tighter during dream
|
||||
const vignetteBase = 0.9;
|
||||
const vignetteDream = 0.7;
|
||||
stack.vignette.uniforms.uRadius.value =
|
||||
vignetteBase + (vignetteDream - vignetteBase) * dreamIntensity;
|
||||
}
|
||||
93
apps/dashboard/src/lib/graph/temporal.ts
Normal file
93
apps/dashboard/src/lib/graph/temporal.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import type { GraphNode, GraphEdge } from '$types';
|
||||
|
||||
export interface TemporalState {
|
||||
visibleNodes: GraphNode[];
|
||||
visibleEdges: GraphEdge[];
|
||||
nodeOpacities: Map<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter nodes and edges by a temporal cutoff date.
|
||||
* Nodes are visible if createdAt <= cutoffDate.
|
||||
* Edges are visible if both endpoints are visible.
|
||||
*/
|
||||
export function filterByDate(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
cutoffDate: Date
|
||||
): TemporalState {
|
||||
const cutoff = cutoffDate.getTime();
|
||||
const visibleNodeIds = new Set<string>();
|
||||
const nodeOpacities = new Map<string, number>();
|
||||
|
||||
const visibleNodes = nodes.filter((node) => {
|
||||
const created = new Date(node.createdAt).getTime();
|
||||
if (created <= cutoff) {
|
||||
visibleNodeIds.add(node.id);
|
||||
|
||||
// Nodes created near the cutoff date get a fade-in opacity
|
||||
const age = cutoff - created;
|
||||
const fadeWindow = 24 * 60 * 60 * 1000; // 1 day fade window
|
||||
const opacity = age < fadeWindow ? 0.3 + 0.7 * (age / fadeWindow) : 1.0;
|
||||
nodeOpacities.set(node.id, opacity);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const visibleEdges = edges.filter(
|
||||
(edge) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target)
|
||||
);
|
||||
|
||||
return { visibleNodes, visibleEdges, nodeOpacities };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate what retention would have been at a given historical date.
|
||||
* Uses FSRS-6 decay formula: R(t) = exp(-t / S)
|
||||
*/
|
||||
export function retentionAtDate(
|
||||
currentRetention: number,
|
||||
stability: number,
|
||||
nodeCreatedAt: string,
|
||||
targetDate: Date,
|
||||
now: Date = new Date()
|
||||
): number {
|
||||
const S = Math.max(stability, 0.1);
|
||||
const nowMs = now.getTime();
|
||||
const targetMs = targetDate.getTime();
|
||||
const createdMs = new Date(nodeCreatedAt).getTime();
|
||||
|
||||
if (targetMs < createdMs) return 0;
|
||||
|
||||
// Time elapsed from creation to target date (in days)
|
||||
const elapsedDays = (targetMs - createdMs) / (24 * 60 * 60 * 1000);
|
||||
|
||||
// R(t) = e^(-t/S)
|
||||
return Math.exp(-elapsedDays / S);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the date range from a set of nodes (oldest to newest).
|
||||
*/
|
||||
export function getDateRange(nodes: GraphNode[]): { oldest: Date; newest: Date } {
|
||||
if (nodes.length === 0) {
|
||||
const now = new Date();
|
||||
return { oldest: now, newest: now };
|
||||
}
|
||||
|
||||
let oldest = Infinity;
|
||||
let newest = -Infinity;
|
||||
|
||||
for (const node of nodes) {
|
||||
const ts = new Date(node.createdAt).getTime();
|
||||
if (ts < oldest) oldest = ts;
|
||||
if (ts > newest) newest = ts;
|
||||
}
|
||||
|
||||
return {
|
||||
oldest: new Date(oldest),
|
||||
newest: new Date(newest),
|
||||
};
|
||||
}
|
||||
49
apps/dashboard/src/lib/stores/graph-state.svelte.ts
Normal file
49
apps/dashboard/src/lib/stores/graph-state.svelte.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Shared graph state using Svelte 5 $state runes
|
||||
// This store manages temporal playback and dream mode state
|
||||
|
||||
export const graphState = createGraphState();
|
||||
|
||||
function createGraphState() {
|
||||
let temporalEnabled = $state(false);
|
||||
let temporalDate = $state<Date>(new Date());
|
||||
let temporalPlaying = $state(false);
|
||||
let temporalSpeed = $state(1); // days per second: 1, 7, 30
|
||||
let dreamMode = $state(false);
|
||||
|
||||
return {
|
||||
get temporalEnabled() {
|
||||
return temporalEnabled;
|
||||
},
|
||||
set temporalEnabled(v: boolean) {
|
||||
temporalEnabled = v;
|
||||
},
|
||||
|
||||
get temporalDate() {
|
||||
return temporalDate;
|
||||
},
|
||||
set temporalDate(v: Date) {
|
||||
temporalDate = v;
|
||||
},
|
||||
|
||||
get temporalPlaying() {
|
||||
return temporalPlaying;
|
||||
},
|
||||
set temporalPlaying(v: boolean) {
|
||||
temporalPlaying = v;
|
||||
},
|
||||
|
||||
get temporalSpeed() {
|
||||
return temporalSpeed;
|
||||
},
|
||||
set temporalSpeed(v: number) {
|
||||
temporalSpeed = v;
|
||||
},
|
||||
|
||||
get dreamMode() {
|
||||
return dreamMode;
|
||||
},
|
||||
set dreamMode(v: boolean) {
|
||||
dreamMode = v;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -180,33 +180,33 @@ export interface IntentionItem {
|
|||
snoozed_until?: string;
|
||||
}
|
||||
|
||||
// Node type colors for visualization
|
||||
// Node type colors for visualization — bioluminescent palette
|
||||
export const NODE_TYPE_COLORS: Record<string, string> = {
|
||||
fact: '#3b82f6', // blue
|
||||
concept: '#8b5cf6', // purple
|
||||
event: '#f59e0b', // amber
|
||||
person: '#10b981', // emerald
|
||||
place: '#06b6d4', // cyan
|
||||
note: '#6b7280', // gray
|
||||
pattern: '#ec4899', // pink
|
||||
decision: '#ef4444', // red
|
||||
fact: '#00A8FF', // electric blue
|
||||
concept: '#9D00FF', // deep violet
|
||||
event: '#FFB800', // golden amber
|
||||
person: '#00FFD1', // bioluminescent cyan
|
||||
place: '#00D4FF', // bright cyan
|
||||
note: '#8B95A5', // soft steel
|
||||
pattern: '#FF3CAC', // hot pink
|
||||
decision: '#FF4757', // vivid red
|
||||
};
|
||||
|
||||
export const EVENT_TYPE_COLORS: Record<string, string> = {
|
||||
MemoryCreated: '#10b981',
|
||||
MemoryUpdated: '#3b82f6',
|
||||
MemoryDeleted: '#ef4444',
|
||||
MemoryPromoted: '#22c55e',
|
||||
MemoryDemoted: '#f97316',
|
||||
SearchPerformed: '#6366f1',
|
||||
DreamStarted: '#8b5cf6',
|
||||
DreamProgress: '#7c3aed',
|
||||
DreamCompleted: '#a855f7',
|
||||
ConsolidationStarted: '#f59e0b',
|
||||
ConsolidationCompleted: '#f97316',
|
||||
RetentionDecayed: '#ef4444',
|
||||
ConnectionDiscovered: '#06b6d4',
|
||||
ActivationSpread: '#14b8a6',
|
||||
ImportanceScored: '#ec4899',
|
||||
Heartbeat: '#6b7280',
|
||||
MemoryCreated: '#00FFD1',
|
||||
MemoryUpdated: '#00A8FF',
|
||||
MemoryDeleted: '#FF4757',
|
||||
MemoryPromoted: '#00FF88',
|
||||
MemoryDemoted: '#FF6B35',
|
||||
SearchPerformed: '#818CF8',
|
||||
DreamStarted: '#9D00FF',
|
||||
DreamProgress: '#B44AFF',
|
||||
DreamCompleted: '#C084FC',
|
||||
ConsolidationStarted: '#FFB800',
|
||||
ConsolidationCompleted: '#FF9500',
|
||||
RetentionDecayed: '#FF4757',
|
||||
ConnectionDiscovered: '#00D4FF',
|
||||
ActivationSpread: '#14E8C6',
|
||||
ImportanceScored: '#FF3CAC',
|
||||
Heartbeat: '#8B95A5',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -74,10 +74,10 @@
|
|||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each (['associations', 'chains', 'bridges'] as const) as m}
|
||||
<button onclick={() => switchMode(m)}
|
||||
class="flex flex-col items-center gap-1 p-3 rounded-lg text-sm transition
|
||||
class="flex flex-col items-center gap-1 p-3 rounded-xl text-sm transition
|
||||
{mode === m
|
||||
? 'bg-synapse/15 text-synapse-glow border border-synapse/40'
|
||||
: 'bg-surface/30 text-dim border border-subtle/20 hover:border-subtle/40'}">
|
||||
? 'glass !border-synapse/30 text-synapse-glow'
|
||||
: 'glass-subtle text-dim hover:bg-white/[0.03]'}">
|
||||
<span class="text-xl">{MODE_INFO[m].icon}</span>
|
||||
<span class="font-medium">{m.charAt(0).toUpperCase() + m.slice(1)}</span>
|
||||
<span class="text-[10px] text-muted text-center">{MODE_INFO[m].desc}</span>
|
||||
|
|
@ -87,22 +87,22 @@
|
|||
|
||||
<!-- Search for source memory -->
|
||||
<div class="space-y-3">
|
||||
<label class="text-xs text-dim font-medium">Source Memory</label>
|
||||
<span class="text-xs text-dim font-medium">Source Memory</span>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" placeholder="Search for a memory to explore from..."
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => e.key === 'Enter' && findSource()}
|
||||
class="flex-1 px-4 py-2.5 bg-surface border border-subtle/40 rounded-lg text-text text-sm
|
||||
placeholder:text-muted focus:outline-none focus:border-synapse/60 transition" />
|
||||
class="flex-1 px-4 py-2.5 bg-white/[0.03] border border-synapse/10 rounded-xl text-text text-sm
|
||||
placeholder:text-muted focus:outline-none focus:border-synapse/40 transition backdrop-blur-sm" />
|
||||
<button onclick={findSource}
|
||||
class="px-4 py-2.5 bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm rounded-lg hover:bg-synapse/30 transition">
|
||||
class="px-4 py-2.5 bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm rounded-xl hover:bg-synapse/30 transition">
|
||||
Find
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if sourceMemory}
|
||||
<div class="p-3 bg-synapse/10 border border-synapse/30 rounded-lg">
|
||||
<div class="p-3 glass rounded-xl !border-synapse/20">
|
||||
<div class="text-[10px] text-synapse-glow mb-1 uppercase tracking-wider">Source</div>
|
||||
<p class="text-sm text-text">{sourceMemory.content.slice(0, 200)}</p>
|
||||
<div class="flex gap-2 mt-1.5 text-[10px] text-muted">
|
||||
|
|
@ -115,22 +115,22 @@
|
|||
<!-- Target memory (for chains/bridges) -->
|
||||
{#if mode === 'chains' || mode === 'bridges'}
|
||||
<div class="space-y-3">
|
||||
<label class="text-xs text-dim font-medium">Target Memory <span class="text-muted">(for {mode})</span></label>
|
||||
<span class="text-xs text-dim font-medium">Target Memory <span class="text-muted">(for {mode})</span></span>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" placeholder="Search for the target memory..."
|
||||
bind:value={targetQuery}
|
||||
onkeydown={(e) => e.key === 'Enter' && findTarget()}
|
||||
class="flex-1 px-4 py-2.5 bg-surface border border-subtle/40 rounded-lg text-text text-sm
|
||||
placeholder:text-muted focus:outline-none focus:border-dream/60 transition" />
|
||||
class="flex-1 px-4 py-2.5 bg-white/[0.03] border border-synapse/10 rounded-xl text-text text-sm
|
||||
placeholder:text-muted focus:outline-none focus:border-dream/40 transition backdrop-blur-sm" />
|
||||
<button onclick={findTarget}
|
||||
class="px-4 py-2.5 bg-dream/20 border border-dream/40 text-dream-glow text-sm rounded-lg hover:bg-dream/30 transition">
|
||||
class="px-4 py-2.5 bg-dream/20 border border-dream/40 text-dream-glow text-sm rounded-xl hover:bg-dream/30 transition">
|
||||
Find
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if targetMemory}
|
||||
<div class="p-3 bg-dream/10 border border-dream/30 rounded-lg">
|
||||
<div class="p-3 glass rounded-xl !border-dream/20">
|
||||
<div class="text-[10px] text-dream-glow mb-1 uppercase tracking-wider">Target</div>
|
||||
<p class="text-sm text-text">{targetMemory.content.slice(0, 200)}</p>
|
||||
<div class="flex gap-2 mt-1.5 text-[10px] text-muted">
|
||||
|
|
@ -155,14 +155,14 @@
|
|||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each associations as assoc, i}
|
||||
<div class="p-3 bg-surface/40 border border-subtle/20 rounded-lg flex items-start gap-3 hover:border-subtle/40 transition">
|
||||
<div class="p-3 glass-subtle rounded-xl flex items-start gap-3 hover:bg-white/[0.03] transition">
|
||||
<div class="w-6 h-6 rounded-full bg-synapse/15 text-synapse-glow text-xs flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
{i + 1}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-text line-clamp-2">{assoc.content}</p>
|
||||
<div class="flex flex-wrap gap-3 mt-1.5 text-xs text-muted">
|
||||
{#if assoc.nodeType}<span class="px-1.5 py-0.5 bg-deep rounded">{assoc.nodeType}</span>{/if}
|
||||
{#if assoc.nodeType}<span class="px-1.5 py-0.5 bg-white/[0.04] rounded">{assoc.nodeType}</span>{/if}
|
||||
{#if assoc.score}<span>Score: {Number(assoc.score).toFixed(3)}</span>{/if}
|
||||
{#if assoc.similarity}<span>Similarity: {Number(assoc.similarity).toFixed(3)}</span>{/if}
|
||||
{#if assoc.retention}<span>{(Number(assoc.retention) * 100).toFixed(0)}% retention</span>{/if}
|
||||
|
|
@ -182,29 +182,29 @@
|
|||
{/if}
|
||||
|
||||
<!-- Importance Scorer -->
|
||||
<div class="pt-8 border-t border-subtle/20">
|
||||
<div class="pt-8 border-t border-synapse/10">
|
||||
<h2 class="text-lg text-bright font-semibold mb-4">Importance Scorer</h2>
|
||||
<p class="text-xs text-muted mb-3">4-channel neuroscience scoring: novelty, arousal, reward, attention</p>
|
||||
<textarea
|
||||
bind:value={importanceText}
|
||||
placeholder="Paste any text to score its importance..."
|
||||
class="w-full h-24 px-4 py-3 bg-surface border border-subtle/40 rounded-lg text-text text-sm
|
||||
placeholder:text-muted resize-none focus:outline-none focus:border-synapse/60 transition"
|
||||
class="w-full h-24 px-4 py-3 bg-white/[0.03] border border-synapse/10 rounded-xl text-text text-sm
|
||||
placeholder:text-muted resize-none focus:outline-none focus:border-synapse/40 transition backdrop-blur-sm"
|
||||
></textarea>
|
||||
<button onclick={scoreImportance}
|
||||
class="mt-2 px-4 py-2 bg-dream/20 border border-dream/40 text-dream-glow text-sm rounded-lg hover:bg-dream/30 transition">
|
||||
class="mt-2 px-4 py-2 bg-dream/20 border border-dream/40 text-dream-glow text-sm rounded-xl hover:bg-dream/30 transition">
|
||||
Score
|
||||
</button>
|
||||
|
||||
{#if importanceResult}
|
||||
{@const channels = importanceResult.channels as Record<string, number> | undefined}
|
||||
{@const composite = Number(importanceResult.composite || importanceResult.compositeScore || 0)}
|
||||
<div class="mt-4 p-4 bg-surface/30 border border-subtle/20 rounded-lg">
|
||||
<div class="mt-4 p-4 glass rounded-xl">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="text-3xl text-bright font-bold">{composite.toFixed(2)}</span>
|
||||
<span class="px-2 py-1 rounded text-xs {composite > 0.6
|
||||
<span class="px-2 py-1 rounded-lg text-xs {composite > 0.6
|
||||
? 'bg-recall/20 text-recall border border-recall/30'
|
||||
: 'bg-surface text-dim border border-subtle/30'}">
|
||||
: 'bg-white/[0.04] text-dim border border-subtle/20'}">
|
||||
{composite > 0.6 ? 'SAVE' : 'SKIP'}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { eventFeed, websocket } from '$stores/websocket';
|
||||
import { EVENT_TYPE_COLORS, type VestigeEvent } from '$types';
|
||||
import PipelineVisualizer from '$components/PipelineVisualizer.svelte';
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
return new Date(ts).toLocaleTimeString();
|
||||
|
|
@ -66,22 +67,31 @@
|
|||
<div class="space-y-2">
|
||||
{#each $eventFeed as event, i (i)}
|
||||
<div
|
||||
class="flex items-start gap-3 p-3 bg-surface/40 border border-subtle/15 rounded-lg
|
||||
hover:border-subtle/30 transition-all duration-200"
|
||||
style="border-left: 3px solid {EVENT_TYPE_COLORS[event.type] || '#6b7280'}"
|
||||
class="flex items-start gap-3 p-3 glass-subtle rounded-xl
|
||||
hover:bg-white/[0.03] transition-all duration-200"
|
||||
style="border-left: 3px solid {EVENT_TYPE_COLORS[event.type] || '#8B95A5'}"
|
||||
>
|
||||
<div class="w-6 h-6 rounded flex items-center justify-center text-xs flex-shrink-0"
|
||||
style="background: {EVENT_TYPE_COLORS[event.type] || '#6b7280'}20; color: {EVENT_TYPE_COLORS[event.type] || '#6b7280'}">
|
||||
style="background: {EVENT_TYPE_COLORS[event.type] || '#8B95A5'}15; color: {EVENT_TYPE_COLORS[event.type] || '#8B95A5'}">
|
||||
{eventIcon(event.type)}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span class="text-xs font-medium" style="color: {EVENT_TYPE_COLORS[event.type] || '#6b7280'}">{event.type}</span>
|
||||
<span class="text-xs font-medium" style="color: {EVENT_TYPE_COLORS[event.type] || '#8B95A5'}">{event.type}</span>
|
||||
{#if event.data.timestamp}
|
||||
<span class="text-xs text-muted">{formatTime(String(event.data.timestamp))}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-dim">{eventSummary(event)}</p>
|
||||
{#if event.type === 'SearchPerformed'}
|
||||
<div class="mt-2">
|
||||
<PipelineVisualizer
|
||||
resultCount={Number(event.data.result_count) || 0}
|
||||
durationMs={Number(event.data.duration_ms) || 0}
|
||||
active={true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
import { onMount } from 'svelte';
|
||||
import Graph3D from '$components/Graph3D.svelte';
|
||||
import RetentionCurve from '$components/RetentionCurve.svelte';
|
||||
import TimeSlider from '$components/TimeSlider.svelte';
|
||||
import { api } from '$stores/api';
|
||||
import { eventFeed } from '$stores/websocket';
|
||||
import type { GraphResponse, Memory } from '$types';
|
||||
import type { GraphResponse, GraphNode, GraphEdge, Memory } from '$types';
|
||||
import { filterByDate } from '$lib/graph/temporal';
|
||||
|
||||
let graphData: GraphResponse | null = $state(null);
|
||||
let selectedMemory: Memory | null = $state(null);
|
||||
|
|
@ -13,6 +15,21 @@
|
|||
let isDreaming = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let maxNodes = $state(150);
|
||||
let temporalEnabled = $state(false);
|
||||
let temporalDate = $state(new Date());
|
||||
|
||||
// Filtered graph data based on temporal mode
|
||||
let displayNodes = $derived.by((): GraphNode[] => {
|
||||
if (!graphData) return [];
|
||||
if (!temporalEnabled) return graphData.nodes;
|
||||
return filterByDate(graphData.nodes, graphData.edges, temporalDate).visibleNodes;
|
||||
});
|
||||
|
||||
let displayEdges = $derived.by((): GraphEdge[] => {
|
||||
if (!graphData) return [];
|
||||
if (!temporalEnabled) return graphData.edges;
|
||||
return filterByDate(graphData.nodes, graphData.edges, temporalDate).visibleEdges;
|
||||
});
|
||||
|
||||
onMount(() => loadGraph());
|
||||
|
||||
|
|
@ -73,8 +90,8 @@
|
|||
</div>
|
||||
{:else if graphData}
|
||||
<Graph3D
|
||||
nodes={graphData.nodes}
|
||||
edges={graphData.edges}
|
||||
nodes={displayNodes}
|
||||
edges={displayEdges}
|
||||
centerId={graphData.center_id}
|
||||
events={$eventFeed}
|
||||
{isDreaming}
|
||||
|
|
@ -91,11 +108,11 @@
|
|||
placeholder="Center graph on..."
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => e.key === 'Enter' && searchGraph()}
|
||||
class="flex-1 px-3 py-2 bg-abyss/80 backdrop-blur-sm border border-subtle/30 rounded-lg text-text text-sm
|
||||
placeholder:text-muted focus:outline-none focus:border-synapse/50 transition"
|
||||
class="flex-1 px-3 py-2 glass rounded-xl text-text text-sm
|
||||
placeholder:text-muted focus:outline-none focus:!border-synapse/40 transition"
|
||||
/>
|
||||
<button onclick={searchGraph}
|
||||
class="px-3 py-2 bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm rounded-lg hover:bg-synapse/30 transition backdrop-blur-sm">
|
||||
class="px-3 py-2 bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm rounded-xl hover:bg-synapse/30 transition backdrop-blur-sm">
|
||||
Focus
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -103,7 +120,7 @@
|
|||
<div class="flex gap-2 ml-auto">
|
||||
<!-- Node count -->
|
||||
<select bind:value={maxNodes} onchange={() => loadGraph()}
|
||||
class="px-2 py-2 bg-abyss/80 backdrop-blur-sm border border-subtle/30 rounded-lg text-dim text-xs">
|
||||
class="px-2 py-2 glass rounded-xl text-dim text-xs">
|
||||
<option value={50}>50 nodes</option>
|
||||
<option value={100}>100 nodes</option>
|
||||
<option value={150}>150 nodes</option>
|
||||
|
|
@ -114,7 +131,7 @@
|
|||
<button
|
||||
onclick={triggerDream}
|
||||
disabled={isDreaming}
|
||||
class="px-4 py-2 rounded-lg bg-dream/20 border border-dream/40 text-dream-glow text-sm
|
||||
class="px-4 py-2 rounded-xl bg-dream/20 border border-dream/40 text-dream-glow text-sm
|
||||
hover:bg-dream/30 transition-all backdrop-blur-sm disabled:opacity-50
|
||||
{isDreaming ? 'glow-dream animate-pulse-glow' : ''}"
|
||||
>
|
||||
|
|
@ -123,26 +140,35 @@
|
|||
|
||||
<!-- Reload -->
|
||||
<button onclick={() => loadGraph()}
|
||||
class="px-3 py-2 bg-abyss/80 backdrop-blur-sm border border-subtle/30 rounded-lg text-dim text-sm hover:text-text transition">
|
||||
class="px-3 py-2 glass rounded-xl text-dim text-sm hover:text-text transition">
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom stats -->
|
||||
<div class="absolute bottom-4 left-4 z-10 text-xs text-dim backdrop-blur-sm bg-abyss/60 rounded-lg px-3 py-2 border border-subtle/20">
|
||||
<div class="absolute bottom-4 left-4 z-10 text-xs text-dim glass rounded-xl px-3 py-2">
|
||||
{#if graphData}
|
||||
<span>{graphData.nodeCount} nodes</span>
|
||||
<span>{displayNodes.length} nodes</span>
|
||||
<span class="mx-2 text-subtle">·</span>
|
||||
<span>{graphData.edgeCount} edges</span>
|
||||
<span>{displayEdges.length} edges</span>
|
||||
<span class="mx-2 text-subtle">·</span>
|
||||
<span>depth {graphData.depth}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Temporal playback slider -->
|
||||
{#if graphData}
|
||||
<TimeSlider
|
||||
nodes={graphData.nodes}
|
||||
onDateChange={(date) => { temporalDate = date; }}
|
||||
onToggle={(enabled) => { temporalEnabled = enabled; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Selected memory panel -->
|
||||
{#if selectedMemory}
|
||||
<div class="absolute right-0 top-0 h-full w-96 bg-abyss/95 backdrop-blur-xl border-l border-subtle/30 p-6 overflow-y-auto z-20
|
||||
<div class="absolute right-0 top-0 h-full w-96 glass-panel p-6 overflow-y-auto z-20
|
||||
transition-transform duration-300">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<h3 class="text-bright text-sm font-semibold">Memory Detail</h3>
|
||||
|
|
@ -151,9 +177,9 @@
|
|||
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<span class="px-2 py-0.5 rounded text-xs bg-synapse/20 text-synapse-glow">{selectedMemory.nodeType}</span>
|
||||
<span class="px-2 py-0.5 rounded-lg text-xs bg-synapse/20 text-synapse-glow">{selectedMemory.nodeType}</span>
|
||||
{#each selectedMemory.tags as tag}
|
||||
<span class="px-2 py-0.5 rounded text-xs bg-surface text-dim">{tag}</span>
|
||||
<span class="px-2 py-0.5 rounded-lg text-xs bg-white/[0.04] text-dim">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
|
@ -171,7 +197,7 @@
|
|||
<span>{bar.label}</span>
|
||||
<span>{(bar.value * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-surface rounded-full overflow-hidden">
|
||||
<div class="h-1.5 bg-white/[0.04] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
style="width: {bar.value * 100}%; background: {
|
||||
|
|
@ -205,13 +231,13 @@
|
|||
<div class="flex gap-2 pt-2">
|
||||
<button
|
||||
onclick={() => { if (selectedMemory) { api.memories.promote(selectedMemory.id); } }}
|
||||
class="flex-1 px-3 py-2 rounded bg-recall/20 text-recall text-xs hover:bg-recall/30 transition"
|
||||
class="flex-1 px-3 py-2 rounded-xl bg-recall/20 text-recall text-xs hover:bg-recall/30 transition"
|
||||
>
|
||||
↑ Promote
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { if (selectedMemory) { api.memories.demote(selectedMemory.id); } }}
|
||||
class="flex-1 px-3 py-2 rounded bg-decay/20 text-decay text-xs hover:bg-decay/30 transition"
|
||||
class="flex-1 px-3 py-2 rounded-xl bg-decay/20 text-decay text-xs hover:bg-decay/30 transition"
|
||||
>
|
||||
↓ Demote
|
||||
</button>
|
||||
|
|
@ -220,7 +246,7 @@
|
|||
<!-- Explore from this node -->
|
||||
<a
|
||||
href="/explore"
|
||||
class="block text-center px-3 py-2 rounded bg-dream/10 text-dream-glow text-xs hover:bg-dream/20 transition border border-dream/20"
|
||||
class="block text-center px-3 py-2 rounded-xl bg-dream/10 text-dream-glow text-xs hover:bg-dream/20 transition border border-dream/20"
|
||||
>
|
||||
◬ Explore Connections
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
const STATUS_COLORS: Record<string, string> = {
|
||||
active: 'text-synapse-glow bg-synapse/10 border-synapse/30',
|
||||
fulfilled: 'text-recall bg-recall/10 border-recall/30',
|
||||
cancelled: 'text-dim bg-surface border-subtle/30',
|
||||
cancelled: 'text-dim bg-white/[0.03] border-subtle/20',
|
||||
snoozed: 'text-dream-glow bg-dream/10 border-dream/30',
|
||||
};
|
||||
|
||||
|
|
@ -76,9 +76,9 @@
|
|||
{#each ['active', 'fulfilled', 'snoozed', 'cancelled', 'all'] as status}
|
||||
<button
|
||||
onclick={() => changeFilter(status)}
|
||||
class="px-3 py-1.5 rounded-lg text-xs transition {statusFilter === status
|
||||
class="px-3 py-1.5 rounded-xl text-xs transition {statusFilter === status
|
||||
? 'bg-synapse/20 text-synapse-glow border border-synapse/40'
|
||||
: 'bg-surface/40 text-dim border border-subtle/20 hover:border-subtle/40'}"
|
||||
: 'glass-subtle text-dim hover:bg-white/[0.03]'}"
|
||||
>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</button>
|
||||
|
|
@ -88,7 +88,7 @@
|
|||
{#if loading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(4) as _}
|
||||
<div class="h-16 bg-surface/50 rounded-lg animate-pulse"></div>
|
||||
<div class="h-16 glass-subtle rounded-xl animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if intentions.length === 0}
|
||||
|
|
@ -100,10 +100,10 @@
|
|||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each intentions as intention}
|
||||
<div class="p-4 bg-surface/30 border border-subtle/20 rounded-lg">
|
||||
<div class="p-4 glass-subtle rounded-xl">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Trigger icon -->
|
||||
<div class="w-8 h-8 rounded-lg bg-deep flex items-center justify-center text-lg flex-shrink-0">
|
||||
<div class="w-8 h-8 rounded-lg bg-white/[0.04] flex items-center justify-center text-lg flex-shrink-0">
|
||||
{TRIGGER_ICONS[intention.trigger_type] || '◇'}
|
||||
</div>
|
||||
|
||||
|
|
@ -111,7 +111,7 @@
|
|||
<p class="text-sm text-text">{intention.content}</p>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
<!-- Status badge -->
|
||||
<span class="px-2 py-0.5 text-[10px] rounded border {STATUS_COLORS[intention.status] || 'text-dim bg-surface border-subtle/30'}">
|
||||
<span class="px-2 py-0.5 text-[10px] rounded-lg border {STATUS_COLORS[intention.status] || 'text-dim bg-white/[0.03] border-subtle/20'}">
|
||||
{intention.status}
|
||||
</span>
|
||||
<!-- Priority -->
|
||||
|
|
@ -146,7 +146,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Predictions Section -->
|
||||
<div class="pt-6 border-t border-subtle/20 space-y-4">
|
||||
<div class="pt-6 border-t border-synapse/10 space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-sm text-bright font-semibold">Predicted Needs</h2>
|
||||
<span class="text-xs text-muted">What you might need next</span>
|
||||
|
|
@ -160,7 +160,7 @@
|
|||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each predictions as pred, i}
|
||||
<div class="p-3 bg-surface/40 border border-subtle/20 rounded-lg flex items-start gap-3">
|
||||
<div class="p-3 glass-subtle rounded-xl flex items-start gap-3">
|
||||
<div class="w-6 h-6 rounded-full bg-dream/20 text-dream-glow text-xs flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
{i + 1}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -57,11 +57,11 @@
|
|||
placeholder="Search memories..."
|
||||
bind:value={searchQuery}
|
||||
oninput={onSearch}
|
||||
class="flex-1 min-w-64 px-4 py-2.5 bg-surface border border-subtle/40 rounded-lg text-text text-sm
|
||||
placeholder:text-muted focus:outline-none focus:border-synapse/60 focus:ring-1 focus:ring-synapse/30 transition"
|
||||
class="flex-1 min-w-64 px-4 py-2.5 bg-white/[0.03] border border-synapse/10 rounded-xl text-text text-sm
|
||||
placeholder:text-muted focus:outline-none focus:border-synapse/40 focus:ring-1 focus:ring-synapse/20 transition backdrop-blur-sm"
|
||||
/>
|
||||
<select bind:value={selectedType} onchange={loadMemories}
|
||||
class="px-3 py-2.5 bg-surface border border-subtle/40 rounded-lg text-dim text-sm focus:outline-none">
|
||||
class="px-3 py-2.5 bg-white/[0.03] border border-synapse/10 rounded-xl text-dim text-sm focus:outline-none backdrop-blur-sm">
|
||||
<option value="">All types</option>
|
||||
<option value="fact">Fact</option>
|
||||
<option value="concept">Concept</option>
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
{#if loading}
|
||||
<div class="grid gap-3">
|
||||
{#each Array(8) as _}
|
||||
<div class="h-24 bg-surface/50 rounded-lg animate-pulse"></div>
|
||||
<div class="h-24 glass-subtle rounded-xl animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
|
|
@ -92,17 +92,17 @@
|
|||
{#each memories as memory (memory.id)}
|
||||
<button
|
||||
onclick={() => selectedMemory = selectedMemory?.id === memory.id ? null : memory}
|
||||
class="text-left p-4 bg-surface/50 border border-subtle/20 rounded-lg hover:border-synapse/30
|
||||
hover:bg-surface transition-all duration-200 group
|
||||
{selectedMemory?.id === memory.id ? 'border-synapse/50 glow-synapse' : ''}"
|
||||
class="text-left p-4 glass-subtle rounded-xl hover:bg-white/[0.04]
|
||||
transition-all duration-200 group
|
||||
{selectedMemory?.id === memory.id ? '!border-synapse/40 glow-synapse' : ''}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="w-2 h-2 rounded-full" style="background: {NODE_TYPE_COLORS[memory.nodeType] || '#6b7280'}"></span>
|
||||
<span class="w-2 h-2 rounded-full" style="background: {NODE_TYPE_COLORS[memory.nodeType] || '#8B95A5'}"></span>
|
||||
<span class="text-xs text-dim">{memory.nodeType}</span>
|
||||
{#each memory.tags.slice(0, 3) as tag}
|
||||
<span class="text-xs px-1.5 py-0.5 bg-deep rounded text-muted">{tag}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 bg-white/[0.04] rounded text-muted">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-sm text-text leading-relaxed line-clamp-2">{memory.content}</p>
|
||||
|
|
@ -116,7 +116,7 @@
|
|||
</div>
|
||||
|
||||
{#if selectedMemory?.id === memory.id}
|
||||
<div class="mt-4 pt-4 border-t border-subtle/20 space-y-3">
|
||||
<div class="mt-4 pt-4 border-t border-synapse/10 space-y-3">
|
||||
<p class="text-sm text-text whitespace-pre-wrap">{memory.content}</p>
|
||||
<div class="grid grid-cols-3 gap-3 text-xs text-dim">
|
||||
<div>Storage: {(memory.storageStrength * 100).toFixed(1)}%</div>
|
||||
|
|
@ -126,13 +126,13 @@
|
|||
<div class="flex gap-2">
|
||||
<span role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); api.memories.promote(memory.id); }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); api.memories.promote(memory.id); } }}
|
||||
class="px-3 py-1.5 bg-recall/20 text-recall text-xs rounded hover:bg-recall/30 cursor-pointer select-none">Promote</span>
|
||||
class="px-3 py-1.5 bg-recall/20 text-recall text-xs rounded-lg hover:bg-recall/30 cursor-pointer select-none">Promote</span>
|
||||
<span role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); api.memories.demote(memory.id); }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); api.memories.demote(memory.id); } }}
|
||||
class="px-3 py-1.5 bg-decay/20 text-decay text-xs rounded hover:bg-decay/30 cursor-pointer select-none">Demote</span>
|
||||
class="px-3 py-1.5 bg-decay/20 text-decay text-xs rounded-lg hover:bg-decay/30 cursor-pointer select-none">Demote</span>
|
||||
<span role="button" tabindex="0" onclick={async (e) => { e.stopPropagation(); await api.memories.delete(memory.id); loadMemories(); }}
|
||||
onkeydown={async (e) => { if (e.key === 'Enter') { e.stopPropagation(); await api.memories.delete(memory.id); loadMemories(); } }}
|
||||
class="px-3 py-1.5 bg-decay/10 text-decay/60 text-xs rounded hover:bg-decay/20 ml-auto cursor-pointer select-none">Delete</span>
|
||||
class="px-3 py-1.5 bg-decay/10 text-decay/60 text-xs rounded-lg hover:bg-decay/20 ml-auto cursor-pointer select-none">Delete</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -66,23 +66,23 @@
|
|||
|
||||
<!-- System Health Overview -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="p-4 bg-surface/30 border border-subtle/20 rounded-lg text-center">
|
||||
<div class="p-4 glass rounded-xl text-center">
|
||||
<div class="text-2xl text-bright font-bold">{$memoryCount}</div>
|
||||
<div class="text-xs text-dim mt-1">Memories</div>
|
||||
</div>
|
||||
<div class="p-4 bg-surface/30 border border-subtle/20 rounded-lg text-center">
|
||||
<div class="p-4 glass rounded-xl text-center">
|
||||
<div class="text-2xl font-bold" style="color: {$avgRetention > 0.7 ? '#10b981' : $avgRetention > 0.4 ? '#f59e0b' : '#ef4444'}">{($avgRetention * 100).toFixed(1)}%</div>
|
||||
<div class="text-xs text-dim mt-1">Avg Retention</div>
|
||||
</div>
|
||||
<div class="p-4 bg-surface/30 border border-subtle/20 rounded-lg text-center">
|
||||
<div class="p-4 glass rounded-xl text-center">
|
||||
<div class="text-2xl text-bright font-bold flex items-center justify-center gap-2">
|
||||
<div class="w-2.5 h-2.5 rounded-full {$isConnected ? 'bg-recall animate-pulse-glow' : 'bg-decay'}"></div>
|
||||
<span class="text-sm">{$isConnected ? 'Online' : 'Offline'}</span>
|
||||
</div>
|
||||
<div class="text-xs text-dim mt-1">WebSocket</div>
|
||||
</div>
|
||||
<div class="p-4 bg-surface/30 border border-subtle/20 rounded-lg text-center">
|
||||
<div class="text-2xl text-synapse-glow font-bold">v2.0</div>
|
||||
<div class="p-4 glass rounded-xl text-center">
|
||||
<div class="text-2xl text-synapse-glow font-bold">v2.1</div>
|
||||
<div class="text-xs text-dim mt-1">Vestige</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -94,14 +94,14 @@
|
|||
</h2>
|
||||
|
||||
<!-- Consolidation -->
|
||||
<div class="p-4 bg-surface/30 border border-subtle/20 rounded-lg space-y-3">
|
||||
<div class="p-4 glass rounded-xl space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-text font-medium">FSRS-6 Consolidation</div>
|
||||
<div class="text-xs text-dim">Apply spaced-repetition decay, regenerate embeddings, run maintenance</div>
|
||||
</div>
|
||||
<button onclick={runConsolidation} disabled={consolidating}
|
||||
class="px-4 py-2 bg-warning/20 border border-warning/40 text-warning text-sm rounded-lg hover:bg-warning/30 transition disabled:opacity-50 flex items-center gap-2">
|
||||
class="px-4 py-2 bg-warning/20 border border-warning/40 text-warning text-sm rounded-xl hover:bg-warning/30 transition disabled:opacity-50 flex items-center gap-2">
|
||||
{#if consolidating}
|
||||
<span class="w-3 h-3 border border-warning/50 border-t-warning rounded-full animate-spin"></span>
|
||||
Running...
|
||||
|
|
@ -111,7 +111,7 @@
|
|||
</button>
|
||||
</div>
|
||||
{#if consolidationResult}
|
||||
<div class="bg-deep/50 p-3 rounded-lg border border-subtle/10">
|
||||
<div class="bg-white/[0.02] p-3 rounded-lg border border-synapse/10">
|
||||
<div class="grid grid-cols-3 gap-3 text-center">
|
||||
{#if consolidationResult.nodesProcessed !== undefined}
|
||||
<div>
|
||||
|
|
@ -137,14 +137,14 @@
|
|||
</div>
|
||||
|
||||
<!-- Dream -->
|
||||
<div class="p-4 bg-surface/30 border border-subtle/20 rounded-lg space-y-3">
|
||||
<div class="p-4 glass rounded-xl space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-text font-medium">Memory Dream Cycle</div>
|
||||
<div class="text-xs text-dim">Replay memories, discover hidden connections, synthesize insights</div>
|
||||
</div>
|
||||
<button onclick={runDream} disabled={dreaming}
|
||||
class="px-4 py-2 bg-dream/20 border border-dream/40 text-dream-glow text-sm rounded-lg hover:bg-dream/30 transition disabled:opacity-50 flex items-center gap-2
|
||||
class="px-4 py-2 bg-dream/20 border border-dream/40 text-dream-glow text-sm rounded-xl hover:bg-dream/30 transition disabled:opacity-50 flex items-center gap-2
|
||||
{dreaming ? 'glow-dream animate-pulse-glow' : ''}">
|
||||
{#if dreaming}
|
||||
<span class="w-3 h-3 border border-dream/50 border-t-dream rounded-full animate-spin"></span>
|
||||
|
|
@ -155,11 +155,11 @@
|
|||
</button>
|
||||
</div>
|
||||
{#if dreamResult}
|
||||
<div class="bg-deep/50 p-3 rounded-lg border border-subtle/10 space-y-2">
|
||||
<div class="bg-white/[0.02] p-3 rounded-lg border border-synapse/10 space-y-2">
|
||||
{#if dreamResult.insights && Array.isArray(dreamResult.insights)}
|
||||
<div class="text-xs text-bright font-medium">Insights Discovered:</div>
|
||||
{#each dreamResult.insights as insight}
|
||||
<div class="text-xs text-dim bg-dream/5 border border-dream/10 rounded p-2">
|
||||
<div class="text-xs text-dim bg-dream/5 border border-dream/10 rounded-lg p-2">
|
||||
{typeof insight === 'string' ? insight : JSON.stringify(insight)}
|
||||
</div>
|
||||
{/each}
|
||||
|
|
@ -181,7 +181,7 @@
|
|||
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
|
||||
<span class="text-recall">◫</span> Retention Distribution
|
||||
</h2>
|
||||
<div class="p-4 bg-surface/30 border border-subtle/20 rounded-lg">
|
||||
<div class="p-4 glass rounded-xl">
|
||||
{#if retentionDist.distribution && Array.isArray(retentionDist.distribution)}
|
||||
<div class="flex items-end gap-1 h-32">
|
||||
{#each retentionDist.distribution as bucket, i}
|
||||
|
|
@ -208,7 +208,7 @@
|
|||
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
|
||||
<span class="text-synapse">⌨</span> Keyboard Shortcuts
|
||||
</h2>
|
||||
<div class="p-4 bg-surface/30 border border-subtle/20 rounded-lg">
|
||||
<div class="p-4 glass-subtle rounded-xl">
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
{#each [
|
||||
{ key: '⌘ K', desc: 'Command palette' },
|
||||
|
|
@ -221,7 +221,7 @@
|
|||
{ key: 'S', desc: 'Go to Stats' },
|
||||
] as shortcut}
|
||||
<div class="flex items-center gap-2 py-1">
|
||||
<kbd class="px-1.5 py-0.5 bg-deep rounded text-[10px] font-mono text-muted min-w-[2rem] text-center">{shortcut.key}</kbd>
|
||||
<kbd class="px-1.5 py-0.5 bg-white/[0.04] rounded text-[10px] font-mono text-muted min-w-[2rem] text-center">{shortcut.key}</kbd>
|
||||
<span class="text-dim">{shortcut.desc}</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
|
@ -234,17 +234,17 @@
|
|||
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
|
||||
<span class="text-memory">◎</span> About
|
||||
</h2>
|
||||
<div class="p-4 bg-surface/30 border border-subtle/20 rounded-lg space-y-3">
|
||||
<div class="p-4 glass rounded-xl space-y-3">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-dream to-synapse flex items-center justify-center text-bright text-xl font-bold shadow-lg shadow-synapse/20">
|
||||
V
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-bright font-semibold">Vestige v2.0 "Cognitive Leap"</div>
|
||||
<div class="text-sm text-bright font-semibold">Vestige v2.1 "Nuclear Dashboard"</div>
|
||||
<div class="text-xs text-dim">Your AI's long-term memory system</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs text-dim pt-2 border-t border-subtle/10">
|
||||
<div class="grid grid-cols-2 gap-2 text-xs text-dim pt-2 border-t border-synapse/10">
|
||||
<div>29 cognitive modules</div>
|
||||
<div>FSRS-6 spaced repetition</div>
|
||||
<div>Nomic Embed v1.5 (256d)</div>
|
||||
|
|
|
|||
|
|
@ -42,12 +42,12 @@
|
|||
{#if loading}
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{#each Array(8) as _}
|
||||
<div class="h-24 bg-surface/50 rounded-lg animate-pulse"></div>
|
||||
<div class="h-24 glass-subtle rounded-xl animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if stats && health}
|
||||
<!-- Status banner -->
|
||||
<div class="flex items-center gap-3 p-4 rounded-lg border" style="border-color: {statusColor(health.status)}40; background: {statusColor(health.status)}10">
|
||||
<div class="flex items-center gap-3 p-4 glass rounded-xl" style="border-color: {statusColor(health.status)}30">
|
||||
<div class="w-3 h-3 rounded-full animate-pulse-glow" style="background: {statusColor(health.status)}"></div>
|
||||
<span class="text-sm font-medium" style="color: {statusColor(health.status)}">{health.status.toUpperCase()}</span>
|
||||
<span class="text-xs text-dim">v{health.version}</span>
|
||||
|
|
@ -55,19 +55,19 @@
|
|||
|
||||
<!-- Key metrics -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="p-4 bg-surface/50 border border-subtle/20 rounded-lg">
|
||||
<div class="p-4 glass rounded-xl">
|
||||
<div class="text-2xl text-bright font-bold">{stats.totalMemories}</div>
|
||||
<div class="text-xs text-dim mt-1">Total Memories</div>
|
||||
</div>
|
||||
<div class="p-4 bg-surface/50 border border-subtle/20 rounded-lg">
|
||||
<div class="p-4 glass rounded-xl">
|
||||
<div class="text-2xl font-bold" style="color: {stats.averageRetention > 0.7 ? '#10b981' : stats.averageRetention > 0.4 ? '#f59e0b' : '#ef4444'}">{(stats.averageRetention * 100).toFixed(1)}%</div>
|
||||
<div class="text-xs text-dim mt-1">Avg Retention</div>
|
||||
</div>
|
||||
<div class="p-4 bg-surface/50 border border-subtle/20 rounded-lg">
|
||||
<div class="p-4 glass rounded-xl">
|
||||
<div class="text-2xl text-bright font-bold">{stats.dueForReview}</div>
|
||||
<div class="text-xs text-dim mt-1">Due for Review</div>
|
||||
</div>
|
||||
<div class="p-4 bg-surface/50 border border-subtle/20 rounded-lg">
|
||||
<div class="p-4 glass rounded-xl">
|
||||
<div class="text-2xl text-bright font-bold">{stats.embeddingCoverage.toFixed(0)}%</div>
|
||||
<div class="text-xs text-dim mt-1">Embedding Coverage</div>
|
||||
</div>
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
|
||||
<!-- Retention Distribution -->
|
||||
{#if retention}
|
||||
<div class="p-6 bg-surface/30 border border-subtle/20 rounded-lg">
|
||||
<div class="p-6 glass rounded-xl">
|
||||
<h2 class="text-sm text-bright font-semibold mb-4">Retention Distribution</h2>
|
||||
<div class="flex items-end gap-1 h-40">
|
||||
{#each retention.distribution as bucket, i}
|
||||
|
|
@ -92,12 +92,12 @@
|
|||
</div>
|
||||
|
||||
<!-- Type breakdown -->
|
||||
<div class="p-6 bg-surface/30 border border-subtle/20 rounded-lg">
|
||||
<div class="p-6 glass-subtle rounded-xl">
|
||||
<h2 class="text-sm text-bright font-semibold mb-4">Memory Types</h2>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{#each Object.entries(retention.byType) as [type, count]}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<div class="w-3 h-3 rounded-full" style="background: {({'fact':'#3b82f6','concept':'#8b5cf6','event':'#f59e0b','person':'#10b981','note':'#6b7280','pattern':'#ec4899','decision':'#ef4444'})[type] || '#6b7280'}"></div>
|
||||
<div class="w-3 h-3 rounded-full" style="background: {({'fact':'#00A8FF','concept':'#9D00FF','event':'#FFB800','person':'#00FFD1','note':'#8B95A5','pattern':'#FF3CAC','decision':'#FF4757'})[type] || '#8B95A5'}"></div>
|
||||
<span class="text-dim">{type}</span>
|
||||
<span class="text-muted ml-auto">{count}</span>
|
||||
</div>
|
||||
|
|
@ -107,7 +107,7 @@
|
|||
|
||||
<!-- Endangered memories -->
|
||||
{#if retention.endangered.length > 0}
|
||||
<div class="p-6 bg-decay/5 border border-decay/20 rounded-lg">
|
||||
<div class="p-6 glass rounded-xl !border-decay/20">
|
||||
<h2 class="text-sm text-decay font-semibold mb-3">Endangered Memories ({retention.endangered.length})</h2>
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto">
|
||||
{#each retention.endangered.slice(0, 20) as m}
|
||||
|
|
@ -124,7 +124,7 @@
|
|||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button onclick={runConsolidation}
|
||||
class="px-4 py-2 bg-warning/20 border border-warning/40 text-warning text-sm rounded-lg hover:bg-warning/30 transition">
|
||||
class="px-4 py-2 bg-warning/20 border border-warning/40 text-warning text-sm rounded-xl hover:bg-warning/30 transition">
|
||||
Run Consolidation
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl text-bright font-semibold">Timeline</h1>
|
||||
<select bind:value={days} onchange={loadTimeline}
|
||||
class="px-3 py-2 bg-surface border border-subtle/40 rounded-lg text-dim text-sm">
|
||||
class="px-3 py-2 bg-white/[0.03] border border-synapse/10 rounded-xl text-dim text-sm focus:outline-none backdrop-blur-sm">
|
||||
<option value={7}>7 days</option>
|
||||
<option value={14}>14 days</option>
|
||||
<option value={30}>30 days</option>
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
{#if loading}
|
||||
<div class="space-y-4">
|
||||
{#each Array(7) as _}
|
||||
<div class="h-16 bg-surface/50 rounded-lg animate-pulse"></div>
|
||||
<div class="h-16 glass-subtle rounded-xl animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if timeline.length === 0}
|
||||
|
|
@ -49,18 +49,18 @@
|
|||
{:else}
|
||||
<div class="relative">
|
||||
<!-- Timeline line -->
|
||||
<div class="absolute left-6 top-0 bottom-0 w-px bg-subtle/30"></div>
|
||||
<div class="absolute left-6 top-0 bottom-0 w-px bg-synapse/15"></div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each timeline as day (day.date)}
|
||||
<div class="relative pl-14">
|
||||
<!-- Dot -->
|
||||
<div class="absolute left-4 top-3 w-5 h-5 rounded-full border-2 border-synapse bg-abyss flex items-center justify-center">
|
||||
<div class="absolute left-4 top-3 w-5 h-5 rounded-full border-2 border-synapse bg-void flex items-center justify-center">
|
||||
<div class="w-2 h-2 rounded-full bg-synapse"></div>
|
||||
</div>
|
||||
|
||||
<button onclick={() => expandedDay = expandedDay === day.date ? null : day.date}
|
||||
class="w-full text-left p-4 bg-surface/40 border border-subtle/20 rounded-lg hover:border-synapse/30 transition-all">
|
||||
class="w-full text-left p-4 glass-subtle rounded-xl hover:bg-white/[0.03] transition-all">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-sm text-bright font-medium">{day.date}</span>
|
||||
|
|
@ -69,7 +69,7 @@
|
|||
<!-- Dots for memory types -->
|
||||
<div class="flex gap-1">
|
||||
{#each day.memories.slice(0, 10) as m}
|
||||
<div class="w-2 h-2 rounded-full" style="background: {NODE_TYPE_COLORS[m.nodeType] || '#6b7280'}; opacity: {0.3 + m.retentionStrength * 0.7}"></div>
|
||||
<div class="w-2 h-2 rounded-full" style="background: {NODE_TYPE_COLORS[m.nodeType] || '#8B95A5'}; opacity: {0.3 + m.retentionStrength * 0.7}"></div>
|
||||
{/each}
|
||||
{#if day.memories.length > 10}
|
||||
<span class="text-xs text-muted">+{day.memories.length - 10}</span>
|
||||
|
|
@ -78,10 +78,10 @@
|
|||
</div>
|
||||
|
||||
{#if expandedDay === day.date}
|
||||
<div class="mt-3 pt-3 border-t border-subtle/20 space-y-2">
|
||||
<div class="mt-3 pt-3 border-t border-synapse/10 space-y-2">
|
||||
{#each day.memories as m}
|
||||
<div class="flex items-start gap-2 text-sm">
|
||||
<div class="w-2 h-2 mt-1.5 rounded-full flex-shrink-0" style="background: {NODE_TYPE_COLORS[m.nodeType] || '#6b7280'}"></div>
|
||||
<div class="w-2 h-2 mt-1.5 rounded-full flex-shrink-0" style="background: {NODE_TYPE_COLORS[m.nodeType] || '#8B95A5'}"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-dim line-clamp-1">{m.content}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
let { children } = $props();
|
||||
let showCommandPalette = $state(false);
|
||||
let cmdQuery = $state('');
|
||||
let cmdInput: HTMLInputElement;
|
||||
let cmdInput = $state<HTMLInputElement>(undefined as unknown as HTMLInputElement);
|
||||
|
||||
onMount(() => {
|
||||
websocket.connect();
|
||||
|
|
@ -87,14 +87,19 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<!-- Ambient background orbs -->
|
||||
<div class="ambient-orb ambient-orb-1" aria-hidden="true"></div>
|
||||
<div class="ambient-orb ambient-orb-2" aria-hidden="true"></div>
|
||||
<div class="ambient-orb ambient-orb-3" aria-hidden="true"></div>
|
||||
|
||||
<!-- Desktop: sidebar + content -->
|
||||
<!-- Mobile: content + bottom nav -->
|
||||
<div class="flex flex-col md:flex-row h-screen overflow-hidden bg-void">
|
||||
<div class="flex flex-col md:flex-row h-screen overflow-hidden bg-void relative z-[1]">
|
||||
<!-- Desktop Sidebar (hidden on mobile) -->
|
||||
<nav class="hidden md:flex w-16 lg:w-56 flex-shrink-0 bg-abyss border-r border-subtle/30 flex-col">
|
||||
<nav class="hidden md:flex w-16 lg:w-56 flex-shrink-0 glass-sidebar flex-col">
|
||||
<!-- Logo -->
|
||||
<a href="/graph" class="flex items-center gap-3 px-4 py-5 border-b border-subtle/20">
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-dream to-synapse flex items-center justify-center text-bright text-sm font-bold">
|
||||
<a href="/graph" class="flex items-center gap-3 px-4 py-5 border-b border-synapse/10">
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-dream to-synapse flex items-center justify-center text-bright text-sm font-bold shadow-lg shadow-synapse/20">
|
||||
V
|
||||
</div>
|
||||
<span class="hidden lg:block text-sm font-semibold text-bright tracking-wide">VESTIGE</span>
|
||||
|
|
@ -108,8 +113,8 @@
|
|||
href={item.href}
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 text-sm
|
||||
{active
|
||||
? 'bg-synapse/15 text-synapse-glow border border-synapse/30 shadow-[0_0_12px_rgba(99,102,241,0.15)]'
|
||||
: 'text-dim hover:text-text hover:bg-surface border border-transparent'}"
|
||||
? 'bg-synapse/15 text-synapse-glow border border-synapse/30 shadow-[0_0_12px_rgba(99,102,241,0.15)] nav-active-border'
|
||||
: 'text-dim hover:text-text hover:bg-white/[0.03] border border-transparent'}"
|
||||
>
|
||||
<span class="text-base w-5 text-center">{item.icon}</span>
|
||||
<span class="hidden lg:block">{item.label}</span>
|
||||
|
|
@ -122,15 +127,15 @@
|
|||
<div class="px-2 pb-2">
|
||||
<button
|
||||
onclick={() => { showCommandPalette = true; cmdQuery = ''; requestAnimationFrame(() => cmdInput?.focus()); }}
|
||||
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-muted hover:text-dim hover:bg-surface/50 transition border border-subtle/20"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-muted hover:text-dim hover:bg-white/[0.03] transition border border-subtle/15"
|
||||
>
|
||||
<span class="text-[10px] font-mono bg-surface/60 px-1.5 py-0.5 rounded">⌘K</span>
|
||||
<span class="text-[10px] font-mono bg-white/[0.04] px-1.5 py-0.5 rounded">⌘K</span>
|
||||
<span class="hidden lg:block">Command</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status footer -->
|
||||
<div class="px-3 py-4 border-t border-subtle/20 space-y-2">
|
||||
<div class="px-3 py-4 border-t border-synapse/10 space-y-2">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<div class="w-2 h-2 rounded-full {$isConnected ? 'bg-recall animate-pulse-glow' : 'bg-decay'}"></div>
|
||||
<span class="hidden lg:block text-dim">{$isConnected ? 'Connected' : 'Offline'}</span>
|
||||
|
|
@ -150,7 +155,7 @@
|
|||
</main>
|
||||
|
||||
<!-- Mobile Bottom Nav (hidden on desktop) -->
|
||||
<nav class="md:hidden fixed bottom-0 inset-x-0 bg-abyss/95 backdrop-blur-xl border-t border-subtle/30 z-40 safe-bottom">
|
||||
<nav class="md:hidden fixed bottom-0 inset-x-0 glass border-t border-synapse/10 z-40 safe-bottom">
|
||||
<div class="flex items-center justify-around px-2 py-1">
|
||||
{#each mobileNav as item}
|
||||
{@const active = isActive(item.href, $page.url.pathname)}
|
||||
|
|
@ -183,8 +188,8 @@
|
|||
onkeydown={(e) => { if (e.key === 'Escape') showCommandPalette = false; }}
|
||||
onclick={(e) => { if (e.target === e.currentTarget) showCommandPalette = false; }}
|
||||
>
|
||||
<div class="w-full max-w-lg bg-abyss border border-subtle/40 rounded-xl shadow-2xl shadow-synapse/10 overflow-hidden">
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-subtle/20">
|
||||
<div class="w-full max-w-lg glass-panel rounded-xl shadow-2xl shadow-synapse/10 overflow-hidden">
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-synapse/10">
|
||||
<span class="text-synapse text-sm">◎</span>
|
||||
<input
|
||||
bind:this={cmdInput}
|
||||
|
|
@ -198,13 +203,13 @@
|
|||
}
|
||||
}}
|
||||
/>
|
||||
<span class="text-[10px] text-muted font-mono bg-surface/40 px-1.5 py-0.5 rounded">esc</span>
|
||||
<span class="text-[10px] text-muted font-mono bg-white/[0.04] px-1.5 py-0.5 rounded">esc</span>
|
||||
</div>
|
||||
<div class="max-h-72 overflow-y-auto py-1">
|
||||
{#each filteredNav as item}
|
||||
<button
|
||||
onclick={() => cmdNavigate(item.href)}
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-dim hover:text-text hover:bg-surface/40 transition"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-dim hover:text-text hover:bg-white/[0.04] transition"
|
||||
>
|
||||
<span class="text-base w-5 text-center">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue