/** * MemoryMapTab/MemoryMapView.tsx — D3 memory map with force-directed graph. * Node shape encodes nodeType: checkpoint=filled circle, turn=hollow circle, * turn-content=hollow+ring, memory=diamond. * Fetches from /api/memory-map, renders SVG force layout. */ import type React from "react"; import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import { renderNodeShape, NODE_COLORS, NODE_TYPE_LABELS } from "../../memory-map-shapes.js"; import type { NodeType } from "../../memory-map-shapes.js"; import { buildLayout, applyForces } from "../../memory-map-layout.js"; import type { LayoutNode, LayoutEdge } from "../../memory-map-layout.js"; // Types (mirror of api-contracts/memory-map.ts — client-side only) interface GraphNode { id: string; sessionId: string; label: string; summaryTruncated: string; tokenEstimate: number; timestamp: number; dedupStatus: string | undefined; raptorLevel: number; topicSummary: string | undefined; decisionCount: number; textSnippet: string; /** Source type discriminator for UI node-shape encoding. */ nodeType: "checkpoint" | "turn" | "turn-content" | "memory"; } interface GraphEdge { source: string; target: string; weight: number; type: "temporal" | "semantic" | "raptor_parent"; } interface GraphValidationReport { readonly gatesRun: number; readonly gatesPassed: number; readonly dropped: { nodes: number; edges: number }; readonly warnings: Array<{ gate: string; code: string; count: number }>; readonly sources: { checkpoint: number; turn: number; turnContent: number; memory: number; }; readonly builtAt: number; } interface GraphMetadata { totalNodes: number; totalEdges: number; avgWeight: number; nodeTypeBreakdown: Record; edgeTypeBreakdown: Record; } interface MemoryMapResponse { nodes: GraphNode[]; edges: GraphEdge[]; metadata: GraphMetadata; /** Validation report from the 9-gate pipeline. Optional for backward compat. */ validation?: GraphValidationReport; } // Simulation rendering constants (layout math is in memory-map-layout.ts) const MAX_ITERATIONS = 300; const EDGE_ALPHA = 0.3; const SVG_WIDTH = 900; const SVG_HEIGHT = 600; const PADDING = 40; // Helpers function edgeColor(type: GraphEdge["type"]): string { switch (type) { case "temporal": return "#6366f1"; // indigo case "semantic": return "#22c55e"; // green case "raptor_parent": return "#f59e0b"; // amber } } // Minimal legend swatch component interface SwatchProps { type: "node" | "edge"; color: string; shape: "filled-circle" | "hollow-circle" | "hollow-ring" | "diamond" | "line"; label: string; } const Swatch: React.FC = ({ color, shape, label }) => { const base: React.CSSProperties = { display: "inline-block", width: "10px", height: "10px", verticalAlign: "middle", marginRight: "3px", borderRadius: shape.startsWith("hollow") || shape === "filled-circle" ? "50%" : undefined, }; let swatch: JSX.Element; if (shape === "line") { return {label}; } else if (shape === "filled-circle") { swatch = ; } else if (shape === "hollow-circle") { swatch = ; } else if (shape === "hollow-ring") { swatch = ( ); } else { swatch = ; } return <>{swatch}{label} ; }; // Force simulation engine (delegated to memory-map-layout.ts) const MemoryMapView: React.FC = () => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [filterSession] = useState(""); const [frame, setFrame] = useState(0); const layoutRef = useRef<{ nodes: LayoutNode[]; edges: LayoutEdge[] } | null>(null); const animRef = useRef(0); // Fetch graph data when filterSession changes useEffect(() => { let cancelled = false; const params = new URLSearchParams(); if (filterSession) params.set("sessionId", filterSession); params.set("threshold", "0.7"); params.set("maxEdgesPerNode", "4"); setLoading(true); setError(null); fetch(`/api/memory-map?${params.toString()}`) .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise; }) .then((d) => { if (!cancelled) { setData(d); layoutRef.current = buildLayout(d); setFrame(1); setLoading(false); } }) .catch((e: unknown) => { if (!cancelled) { setError(e instanceof Error ? e.message : "Unknown error"); setLoading(false); } }); return () => { cancelled = true; cancelAnimationFrame(animRef.current); }; }, [filterSession]); // Run force simulation via requestAnimationFrame useEffect(() => { if (!layoutRef.current || frame === 0) return; const layout = layoutRef.current; let iter = 0; const tick = () => { if (iter >= MAX_ITERATIONS) return; applyForces(layout); iter++; setFrame(iter); animRef.current = requestAnimationFrame(tick); }; animRef.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(animRef.current); }, [frame]); // Toggle pin / select a node const handleNodeClick = useCallback((node: LayoutNode, index: number) => { const layout = layoutRef.current; if (!layout) return; layout.nodes[index].pinned = !layout.nodes[index].pinned; if (!layout.nodes[index].pinned) { layout.nodes[index].vx = 0; layout.nodes[index].vy = 0; } setSelectedNode((prev) => (prev?.id === node.id ? null : (node as unknown as GraphNode))); setFrame((s) => s + 1); }, []); // Filtered node indices for search const filteredSet = useMemo | null>(() => { const layout = layoutRef.current; if (!layout || !searchQuery) return null; const q = searchQuery.toLowerCase(); return new Set( layout.nodes .map((n, i) => n.summaryTruncated.toLowerCase().includes(q) || n.label.toLowerCase().includes(q) ? i : -1, ) .filter((i) => i >= 0), ); }, [searchQuery]); // Health indicator helpers (D3) /** * Derive graph health from validation report: * green = all gates passed + no warnings * yellow = warnings present but no critical gate failed * red = a critical gate (gatesRun > gatesPassed) failed */ const graphHealth = useMemo<{ level: "green" | "yellow" | "red"; label: string }>(() => { const v = data?.validation; if (!v) return { level: "yellow", label: "No validation" }; if (v.gatesPassed < v.gatesRun) return { level: "red", label: "Gate failure" }; if (v.warnings.length > 0) return { level: "yellow", label: `${v.warnings.length} warning(s)` }; return { level: "green", label: "Healthy" }; }, [data?.validation]); const healthColor: Record = { green: "#22c55e", yellow: "#eab308", red: "#ef4444", }; // Source availability indicators (D3) /** Count badge fragments from validation.sources. */ const countBadge = useMemo(() => { const v = data?.validation; if (!v) { const totalNodes = data?.metadata.totalNodes ?? 0; return `${totalNodes} nodes`; } const parts: string[] = []; if (v.sources.checkpoint > 0) parts.push(`${v.sources.checkpoint} checkpoints`); if (v.sources.turn > 0) parts.push(`${v.sources.turn} turns`); if (v.sources.turnContent > 0) parts.push(`${v.sources.turnContent} turn-content`); if (v.sources.memory > 0) parts.push(`${v.sources.memory} memories`); if (parts.length === 0) { parts.push(`${data?.metadata.totalNodes ?? 0} nodes`); } return parts.join(" · "); }, [data?.validation, data?.metadata.totalNodes]); /** Per-source availability: ✓ for available sources, ✗ for empty ones. */ const sourceAvail = useMemo(() => { const v = data?.validation; if (!v) return ""; const parts: string[] = []; parts.push(v.sources.checkpoint > 0 ? "✓ checkpoints" : "✗ checkpoints"); parts.push(v.sources.turn > 0 ? "✓ turns" : "✗ turns"); parts.push(v.sources.turnContent > 0 ? "✓ turn-content" : "✗ turn-content"); parts.push(v.sources.memory > 0 ? "✓ memories" : "✗ memories"); return parts.join(" | "); }, [data?.validation]); /** Whether all four sources are empty (true empty state). */ const allSourcesEmpty = useMemo(() => { const v = data?.validation; if (!v) return data?.metadata.totalNodes === 0; return ( v.sources.checkpoint === 0 && v.sources.turn === 0 && v.sources.turnContent === 0 && v.sources.memory === 0 ); }, [data?.validation, data?.metadata.totalNodes]); // Loading state if (loading) { return (
Loading memory graph...
); } // Error state if (error) { return (

Failed to load memory map: {error}

); } // Empty state (D2) if (!data || !layoutRef.current || allSourcesEmpty) { return (

Memories appear after your first compaction. The graph shows checkpoints (compaction summaries) linked by semantic similarity and time. Run a longer session or lower the compaction tier to see it sooner.

); } const layout = layoutRef.current; // Render return (
{/* Toolbar */}
) => setSearchQuery((e.target as HTMLInputElement).value) } className="w-56 rounded-md border border-border bg-bg-elevated/50 px-3 py-2 text-sm outline-none transition-colors focus:border-primary" /> {countBadge} {" · "} {data.edges.length} edges {/* Graph-health indicator */} {graphHealth.level === "green" ? "Healthy" : graphHealth.level === "yellow" ? "Warnings" : "Critical"}
{/* Source availability indicators */} {sourceAvail ? (
{sourceAvail}
) : null} {/* Legend — node types + edge types */}
Nodes: Edges:
{/* SVG graph */} {/* Edges */} {layout.edges.map((e, i) => { const src = layout.nodes[e.source]; const tgt = layout.nodes[e.target]; const color = edgeColor(e.type); const strokeW = Math.max(1, e.weight * 2.5); return ( ); })} {/* Nodes — shape encodes nodeType */} {layout.nodes.map((n, i) => { const cx = PADDING + (n.pos.x / (layout.nodes.length || 1)) * (SVG_WIDTH - 2 * PADDING); const cy = PADDING + (n.pos.y / (layout.nodes.length || 1)) * (SVG_HEIGHT - 2 * PADDING); const radius = Math.min(20, 8 + n.tokenEstimate / 500); const isFiltered = filteredSet ? filteredSet.has(i) : true; const isSelected = selectedNode?.id === n.id; const nodeLabel = n.label.length > 20 ? n.label.slice(0, 18) + "..." : n.label; return ( handleNodeClick(n, i)} style={{ cursor: "pointer" }} > {renderNodeShape(cx, cy, radius, n.nodeType as NodeType, isSelected, isFiltered, n.dedupStatus)} {nodeLabel} ); })} {/* Node detail panel */} {selectedNode ? (

{NODE_TYPE_LABELS[selectedNode.nodeType]} {selectedNode.label}

{selectedNode.summaryTruncated}

{selectedNode.topicSummary ? (

Topic: {selectedNode.topicSummary}

) : null}

Tokens: {selectedNode.tokenEstimate} | Decisions: {selectedNode.decisionCount}

Session: {selectedNode.sessionId}

Raptor Level: {selectedNode.raptorLevel}

{selectedNode.textSnippet}

) : null}
); }; export default MemoryMapView;