| |
| |
| |
| |
| |
| |
|
|
| import { useEffect, useRef, useState } from 'react'; |
| import * as d3 from 'd3'; |
| import type { GraphData, GraphNode } from '../api/client'; |
|
|
| const TYPE_COLORS: Record<string, string> = { |
| 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<SimNode> { |
| similarity: number; |
| } |
|
|
| export default function MemoryGraph({ |
| data, |
| width = 600, |
| height = 500, |
| onNodeClick, |
| onForget, |
| }: MemoryGraphProps) { |
| const svgRef = useRef<SVGSVGElement>(null); |
| const containerRef = useRef<HTMLDivElement>(null); |
| |
| const simulationRef = useRef<d3.Simulation<SimNode, SimEdge> | null>(null); |
| const zoomGroupRef = useRef<d3.Selection<SVGGElement, unknown, null, undefined> | null>(null); |
| |
| const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null); |
| const [dimensions, setDimensions] = useState({ width, height }); |
|
|
| |
| 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(); |
| }, []); |
|
|
| |
| useEffect(() => { |
| if (!svgRef.current) return; |
| const svg = d3.select(svgRef.current); |
| svg.selectAll('*').remove(); |
|
|
| const g = svg.append('g'); |
| zoomGroupRef.current = g; |
|
|
| |
| const zoom = d3.zoom<SVGSVGElement, unknown>() |
| .scaleExtent([0.3, 4]) |
| .on('zoom', (event) => { |
| g.attr('transform', event.transform); |
| }); |
| svg.call(zoom); |
|
|
| |
| simulationRef.current = d3.forceSimulation<SimNode>() |
| .force('link', d3.forceLink<SimNode, SimEdge>().id((d) => d.id).distance((d) => 120 * (1 - d.similarity))) |
| .force('charge', d3.forceManyBody().strength(-150)) |
| |
| .force('x', d3.forceX().strength(0.08)) |
| .force('y', d3.forceY().strength(0.08)) |
| |
| .force('collide', d3.forceCollide<SimNode>().radius((d) => 30 + (d.importance * 15))); |
|
|
| return () => { |
| simulationRef.current?.stop(); |
| }; |
| }, []); |
|
|
| |
| 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]); |
|
|
| |
| useEffect(() => { |
| if (!simulationRef.current || !zoomGroupRef.current) return; |
| const g = zoomGroupRef.current; |
|
|
| |
| |
| 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, |
| })); |
|
|
| |
| 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<d3.ForceLink<SimNode, SimEdge>>('link'); |
| if (linkForce) linkForce.links(links); |
|
|
| |
| let linkElements = g.selectAll<SVGLineElement, SimEdge>('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); |
|
|
| |
| let nodeElements = g.selectAll<SVGGElement, SimNode>('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); |
|
|
| |
| nodeEnter.append('circle') |
| .attr('r', 2) |
| .attr('fill', (d) => TYPE_COLORS[d.memory_type] || 'var(--color-text-muted)'); |
|
|
| |
| 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); |
|
|
| |
| 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); |
| }); |
|
|
| |
| const drag = d3.drag<SVGGElement, SimNode>() |
| .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); |
|
|
| |
| 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 ( |
| <div ref={containerRef} style={{ position: 'relative', width: '100%', height: '100%' }}> |
| <svg |
| ref={svgRef} |
| width={dimensions.width} |
| height={dimensions.height} |
| style={{ |
| background: 'var(--color-surface-alt)', |
| }} |
| /> |
| |
| {/* Selected node panel */} |
| {selectedNode && ( |
| <div |
| className="font-mono" |
| style={{ |
| position: 'absolute', |
| bottom: '40px', |
| right: '40px', |
| width: '300px', |
| background: 'var(--color-surface)', |
| padding: '24px', |
| border: '1px solid var(--color-border)', |
| boxShadow: '0 10px 40px rgba(0,0,0,0.5)', |
| }} |
| > |
| <div style={{ fontSize: '10px', color: TYPE_COLORS[selectedNode.memory_type], marginBottom: '12px', letterSpacing: '0.1em' }}> |
| [{selectedNode.memory_type.toUpperCase()}] |
| </div> |
| <p style={{ fontSize: '13px', color: 'var(--color-text-primary)', marginBottom: '16px', lineHeight: 1.5, wordWrap: 'break-word' }}> |
| {selectedNode.content} |
| </p> |
| <div style={{ fontSize: '10px', color: 'var(--color-text-muted)', marginBottom: '16px' }}> |
| WEIGHT: {selectedNode.importance.toFixed(2)} |
| </div> |
| <div style={{ display: 'flex', gap: '12px' }}> |
| {onForget && ( |
| <button |
| onClick={() => { |
| onForget(selectedNode.id); |
| setSelectedNode(null); |
| }} |
| style={{ |
| color: 'var(--color-text-muted)', |
| fontSize: '11px', |
| background: 'transparent', |
| border: 'none', |
| cursor: 'pointer', |
| textDecoration: 'underline', |
| }} |
| > |
| [ FORGET ] |
| </button> |
| )} |
| <button |
| onClick={() => setSelectedNode(null)} |
| style={{ |
| color: 'var(--color-text-primary)', |
| fontSize: '11px', |
| background: 'transparent', |
| border: 'none', |
| cursor: 'pointer', |
| textDecoration: 'underline', |
| }} |
| > |
| [ CLOSE ] |
| </button> |
| </div> |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|