'use client'; /** * Relationship Graph Component - Focus Mode * * Clean visualization: click a memory to see its direct connections. * Unselected state shows all nodes dimmed, selected shows focus view. */ import { useEffect, useRef, useState, useMemo } from 'react'; import { useMemoryLinks, useMemories } from '@/hooks/useMemories'; interface Node { id: number; title: string; category: string; salience: number; x: number; y: number; } interface Edge { source: number; target: number; relationship: string; strength: number; } const RELATIONSHIP_COLORS: Record = { related: '#6366f1', extends: '#22c55e', references: '#3b82f6', contradicts: '#ef4444', }; const CATEGORY_COLORS: Record = { architecture: '#8b5cf6', pattern: '#3b82f6', error: '#ef4444', learning: '#22c55e', preference: '#f59e0b', context: '#6366f1', todo: '#ec4899', note: '#64748b', relationship: '#14b8a6', custom: '#64748b', }; export function RelationshipGraph() { const canvasRef = useRef(null); const [selectedNodeId, setSelectedNodeId] = useState(null); const [hoveredNodeId, setHoveredNodeId] = useState(null); const [relationshipFilter, setRelationshipFilter] = useState(null); const { data: links = [] } = useMemoryLinks(); const { data: memories = [] } = useMemories({ limit: 200 }); // Build graph data with stable positions const { nodes, edges, nodeMap } = useMemo(() => { const linkedIds = new Set(); for (const link of links) { linkedIds.add(link.source_id); linkedIds.add(link.target_id); } const linkedMemories = memories.filter((m) => linkedIds.has(m.id)); // Create stable grid layout const cols = Math.ceil(Math.sqrt(linkedMemories.length)); const cellWidth = 100; const cellHeight = 80; const nodes: Node[] = linkedMemories.map((m, i) => ({ id: m.id, title: m.title, category: m.category, salience: m.salience, x: (i % cols) * cellWidth + cellWidth / 2 + 50, y: Math.floor(i / cols) * cellHeight + cellHeight / 2 + 50, })); const edges: Edge[] = links.map((l) => ({ source: l.source_id, target: l.target_id, relationship: l.relationship, strength: l.strength, })); const nodeMap = new Map(nodes.map((n) => [n.id, n])); return { nodes, edges, nodeMap }; }, [memories, links]); // Filter edges const filteredEdges = relationshipFilter ? edges.filter((e) => e.relationship === relationshipFilter) : edges; // Get connections for selected node const selectedConnections = useMemo(() => { if (!selectedNodeId) return { connectedIds: new Set(), connectedEdges: [] }; const connectedIds = new Set(); const connectedEdges: Edge[] = []; for (const edge of filteredEdges) { if (edge.source === selectedNodeId) { connectedIds.add(edge.target); connectedEdges.push(edge); } else if (edge.target === selectedNodeId) { connectedIds.add(edge.source); connectedEdges.push(edge); } } return { connectedIds, connectedEdges }; }, [selectedNodeId, filteredEdges]); // Get unique relationship types const relationshipTypes = [...new Set(edges.map((e) => e.relationship))]; // Get selected node details const selectedNode = selectedNodeId ? nodeMap.get(selectedNodeId) : null; // Canvas rendering useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * window.devicePixelRatio; canvas.height = rect.height * window.devicePixelRatio; ctx.scale(window.devicePixelRatio, window.devicePixelRatio); const width = rect.width; const height = rect.height; // Clear ctx.fillStyle = '#0f172a'; ctx.fillRect(0, 0, width, height); if (nodes.length === 0) return; // Calculate layout to fit in canvas const padding = 60; const cols = Math.ceil(Math.sqrt(nodes.length)); const rows = Math.ceil(nodes.length / cols); const cellWidth = (width - padding * 2) / cols; const cellHeight = (height - padding * 2) / rows; // Update positions to fit canvas nodes.forEach((node, i) => { node.x = (i % cols) * cellWidth + cellWidth / 2 + padding; node.y = Math.floor(i / cols) * cellHeight + cellHeight / 2 + padding; }); const { connectedIds, connectedEdges } = selectedConnections; // Draw edges (only for selected node, or all dimmed if none selected) if (selectedNodeId) { // Draw connected edges prominently ctx.lineWidth = 2; for (const edge of connectedEdges) { const source = nodeMap.get(edge.source); const target = nodeMap.get(edge.target); if (!source || !target) continue; ctx.strokeStyle = RELATIONSHIP_COLORS[edge.relationship] || '#475569'; ctx.globalAlpha = 0.8; ctx.beginPath(); ctx.moveTo(source.x, source.y); ctx.lineTo(target.x, target.y); ctx.stroke(); // Draw relationship label at midpoint const midX = (source.x + target.x) / 2; const midY = (source.y + target.y) / 2; ctx.font = '10px system-ui'; ctx.fillStyle = RELATIONSHIP_COLORS[edge.relationship] || '#94a3b8'; ctx.globalAlpha = 1; ctx.textAlign = 'center'; ctx.fillText(edge.relationship, midX, midY - 4); } } else { // Show all edges very dimmed when nothing selected ctx.lineWidth = 1; ctx.globalAlpha = 0.15; for (const edge of filteredEdges) { const source = nodeMap.get(edge.source); const target = nodeMap.get(edge.target); if (!source || !target) continue; ctx.strokeStyle = RELATIONSHIP_COLORS[edge.relationship] || '#475569'; ctx.beginPath(); ctx.moveTo(source.x, source.y); ctx.lineTo(target.x, target.y); ctx.stroke(); } } ctx.globalAlpha = 1; // Draw nodes for (const node of nodes) { const isSelected = node.id === selectedNodeId; const isConnected = connectedIds.has(node.id); const isHovered = node.id === hoveredNodeId; const isHighlighted = isSelected || isConnected || !selectedNodeId; const baseRadius = 6 + node.salience * 6; const radius = isSelected ? baseRadius + 4 : isHovered ? baseRadius + 2 : baseRadius; // Determine opacity const opacity = isHighlighted ? 1 : 0.2; // Draw node ctx.beginPath(); ctx.arc(node.x, node.y, radius, 0, Math.PI * 2); ctx.fillStyle = CATEGORY_COLORS[node.category] || '#64748b'; ctx.globalAlpha = opacity; ctx.fill(); // Selection/hover ring if (isSelected || isHovered) { ctx.strokeStyle = isSelected ? '#f472b6' : '#94a3b8'; ctx.lineWidth = isSelected ? 3 : 2; ctx.globalAlpha = 1; ctx.stroke(); } // Draw label for highlighted nodes if (isHighlighted && (isSelected || isConnected || isHovered || node.salience > 0.5)) { ctx.globalAlpha = opacity; const label = node.title.length > 20 ? node.title.slice(0, 20) + '...' : node.title; ctx.font = isSelected ? 'bold 11px system-ui' : '10px system-ui'; ctx.textAlign = 'center'; // Background const textWidth = ctx.measureText(label).width; ctx.fillStyle = 'rgba(15, 23, 42, 0.9)'; ctx.fillRect(node.x - textWidth / 2 - 4, node.y + radius + 4, textWidth + 8, 16); // Text ctx.fillStyle = isSelected ? '#f472b6' : isConnected ? '#e2e8f0' : '#94a3b8'; ctx.fillText(label, node.x, node.y + radius + 16); } } ctx.globalAlpha = 1; }, [nodes, nodeMap, filteredEdges, selectedNodeId, hoveredNodeId, selectedConnections]); // Mouse handling useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const getNodeAt = (x: number, y: number): Node | null => { for (const node of nodes) { const radius = 6 + node.salience * 6 + 4; const dx = node.x - x; const dy = node.y - y; if (dx * dx + dy * dy < radius * radius) { return node; } } return null; }; const handleClick = (e: MouseEvent) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const node = getNodeAt(x, y); if (node) { setSelectedNodeId(node.id === selectedNodeId ? null : node.id); } else { setSelectedNodeId(null); } }; const handleMouseMove = (e: MouseEvent) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const node = getNodeAt(x, y); setHoveredNodeId(node?.id || null); canvas.style.cursor = node ? 'pointer' : 'default'; }; canvas.addEventListener('click', handleClick); canvas.addEventListener('mousemove', handleMouseMove); return () => { canvas.removeEventListener('click', handleClick); canvas.removeEventListener('mousemove', handleMouseMove); }; }, [nodes, selectedNodeId]); return (
{/* Controls */}
Filter: {relationshipTypes.map((type) => ( ))}
{/* Instructions */} {selectedNodeId ? 'Click elsewhere to deselect' : 'Click a node to focus'} {/* Legend */}
{Object.entries(RELATIONSHIP_COLORS).map(([type, color]) => (
{type}
))}
{/* Graph */}
{/* Selected Node Details */} {selectedNode && (
{selectedNode.title}
Category: {selectedNode.category}
Salience: {(selectedNode.salience * 100).toFixed(0)}%
Connections: {selectedConnections.connectedIds.size}
{selectedConnections.connectedEdges.length > 0 && (
Connected to:
{selectedConnections.connectedEdges.map((edge, i) => { const otherId = edge.source === selectedNodeId ? edge.target : edge.source; const other = nodeMap.get(otherId); return (
{edge.relationship}: {other?.title || 'Unknown'}
); })}
)}
)} {/* Empty state */} {nodes.length === 0 && (
No relationships to display
)}
); }