/** * MemoryGraph — Typographic Constellation * * Implements proper D3 Enter/Update/Exit to prevent visual reloading, * and optimized forceX/forceY gravity to keep labels centered and clickable. */ import { useEffect, useRef, useState } from 'react'; import * as d3 from 'd3'; import type { GraphData, GraphNode } from '../api/client'; const TYPE_COLORS: Record = { episodic: 'var(--color-episodic)', semantic: 'var(--color-semantic)', procedural: 'var(--color-procedural)', }; interface MemoryGraphProps { data: GraphData; width?: number; height?: number; onNodeClick?: (node: GraphNode) => void; onForget?: (nodeId: string) => void; } interface SimNode extends GraphNode, d3.SimulationNodeDatum {} interface SimEdge extends d3.SimulationLinkDatum { similarity: number; } export default function MemoryGraph({ data, width = 600, height = 500, onNodeClick, onForget, }: MemoryGraphProps) { const svgRef = useRef(null); const containerRef = useRef(null); const simulationRef = useRef | null>(null); const zoomGroupRef = useRef | null>(null); const [selectedNode, setSelectedNode] = useState(null); const [dimensions, setDimensions] = useState({ width, height }); // 1. Auto-resize observer useEffect(() => { if (!containerRef.current) return; const observer = new ResizeObserver((entries) => { for (const entry of entries) { setDimensions({ width: entry.contentRect.width, height: entry.contentRect.height, }); } }); observer.observe(containerRef.current); return () => observer.disconnect(); }, []); // 2. Initialize SVG and Simulation ONCE useEffect(() => { if (!svgRef.current) return; const svg = d3.select(svgRef.current); svg.selectAll('*').remove(); const g = svg.append('g'); zoomGroupRef.current = g; // Zoom behavior const zoom = d3.zoom() .scaleExtent([0.3, 4]) .on('zoom', (event) => { g.attr('transform', event.transform); }); svg.call(zoom); // Init Simulation simulationRef.current = d3.forceSimulation() .force('link', d3.forceLink().id((d) => d.id).distance((d) => 120 * (1 - d.similarity))) .force('charge', d3.forceManyBody().strength(-150)) // Gentle gravity towards center to keep things bunched .force('x', d3.forceX().strength(0.08)) .force('y', d3.forceY().strength(0.08)) // Prevent text overlap .force('collide', d3.forceCollide().radius((d) => 30 + (d.importance * 15))); return () => { simulationRef.current?.stop(); }; }, []); // 3. Update dimensions of forces useEffect(() => { if (!simulationRef.current || dimensions.width === 0) return; const { width: w, height: h } = dimensions; simulationRef.current.force('x', d3.forceX(w / 2).strength(0.08)); simulationRef.current.force('y', d3.forceY(h / 2).strength(0.08)); simulationRef.current.alpha(0.3).restart(); }, [dimensions]); // 4. Enter / Update / Exit Data useEffect(() => { if (!simulationRef.current || !zoomGroupRef.current) return; const g = zoomGroupRef.current; // We must pass D3 our data references, but keep them stable // Deep clone data to avoid mutating React state const nodes: SimNode[] = data.nodes.map((n) => ({ ...n })); const nodeMap = new Map(nodes.map((n) => [n.id, n])); const links: SimEdge[] = data.edges .filter((e) => nodeMap.has(e.source) && nodeMap.has(e.target)) .map((e) => ({ source: e.source, target: e.target, similarity: e.similarity, })); // Identify old nodes to preserve their x/y coordinates const oldNodesMap = new Map(simulationRef.current.nodes().map(n => [n.id, n])); for (const n of nodes) { if (oldNodesMap.has(n.id)) { const old = oldNodesMap.get(n.id)!; n.x = old.x; n.y = old.y; n.vx = old.vx; n.vy = old.vy; } } const simulation = simulationRef.current; simulation.nodes(nodes); const linkForce = simulation.force>('link'); if (linkForce) linkForce.links(links); // --- Edges --- let linkElements = g.selectAll('line.graph-link') .data(links, (d) => `${(d.source as any).id || d.source}-${(d.target as any).id || d.target}`); linkElements.exit().remove(); const linkEnter = linkElements.enter() .append('line') .attr('class', 'graph-link') .attr('stroke', 'var(--color-border)') .attr('stroke-opacity', 0.6) .attr('stroke-width', 1); linkElements = linkEnter.merge(linkElements); // --- Nodes --- let nodeElements = g.selectAll('g.graph-node') .data(nodes, (d) => d.id); nodeElements.exit() .transition().duration(400) .style('opacity', 0) .remove(); const nodeEnter = nodeElements.enter() .append('g') .attr('class', 'graph-node') .style('cursor', 'pointer') .style('opacity', 0); nodeEnter.transition().duration(600).style('opacity', 1); // Anchor dot nodeEnter.append('circle') .attr('r', 2) .attr('fill', (d) => TYPE_COLORS[d.memory_type] || 'var(--color-text-muted)'); // Typographic label nodeEnter.append('text') .text((d) => { const typeStr = `[${d.memory_type.substring(0,3).toUpperCase()}]`; const contentStr = d.content.substring(0, 20) + (d.content.length > 20 ? '...' : ''); return `${typeStr} ${contentStr}`; }) .attr('x', 6) .attr('y', 3) .style('font-family', '"JetBrains Mono", monospace') .style('font-size', '10px') .style('fill', (d) => TYPE_COLORS[d.memory_type] || 'var(--color-text-muted)') .style('user-select', 'none'); nodeElements = nodeEnter.merge(nodeElements); // Re-bind events nodeElements .on('mouseover', function (_event, _d) { d3.select(this).select('text').style('fill', 'var(--color-text-primary)').style('font-weight', 'bold'); }) .on('mouseout', function (_event, d) { d3.select(this).select('text').style('fill', TYPE_COLORS[d.memory_type] || 'var(--color-text-muted)').style('font-weight', 'normal'); }) .on('click', (_event, d) => { setSelectedNode(d); if (onNodeClick) onNodeClick(d); }); // Drag behavior const drag = d3.drag() .on('start', (event, d) => { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }) .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; }) .on('end', (event, d) => { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }); nodeElements.call(drag); // Tick update simulation.on('tick', () => { linkElements .attr('x1', (d) => (d.source as SimNode).x!) .attr('y1', (d) => (d.source as SimNode).y!) .attr('x2', (d) => (d.target as SimNode).x!) .attr('y2', (d) => (d.target as SimNode).y!); nodeElements.attr('transform', (d) => `translate(${d.x},${d.y})`); }); simulation.alpha(0.1).restart(); }, [data, onNodeClick]); return (
{/* Selected node panel */} {selectedNode && (
[{selectedNode.memory_type.toUpperCase()}]

{selectedNode.content}

WEIGHT: {selectedNode.importance.toFixed(2)}
{onForget && ( )}
)}
); }