/** * ╔═══════════════════════════════════════════════════════════════════════════╗ * ║ INTERACTIVE FORCE GRAPH ║ * ║═══════════════════════════════════════════════════════════════════════════║ * ║ Dynamic knowledge graph visualization with force-directed layout ║ * ║ Part of the Liquid UI Arsenal ║ * ╚═══════════════════════════════════════════════════════════════════════════╝ */ import { useRef, useCallback, useState, useEffect } from 'react'; import ForceGraph2D, { ForceGraphMethods, NodeObject, LinkObject } from 'react-force-graph-2d'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import { ZoomIn, ZoomOut, Maximize2, RefreshCw, MousePointer2, Move, Info } from 'lucide-react'; export interface GraphNode { id: string; label: string; type?: string; color?: string; size?: number; metadata?: Record; } export interface GraphLink { source: string; target: string; label?: string; type?: string; weight?: number; } export interface InteractiveForceGraphProps { nodes: GraphNode[]; links: GraphLink[]; title?: string; height?: number; onNodeClick?: (node: GraphNode) => void; onLinkClick?: (link: GraphLink) => void; highlightNodes?: string[]; highlightLinks?: string[]; } // Color palette for node types const nodeColors: Record = { concept: '#3b82f6', // Blue entity: '#10b981', // Green event: '#f59e0b', // Amber person: '#ec4899', // Pink document: '#8b5cf6', // Purple system: '#06b6d4', // Cyan gap: '#ef4444', // Red idea: '#f97316', // Orange default: '#6b7280', // Gray }; export function InteractiveForceGraph({ nodes, links, title = 'Knowledge Graph', height = 400, onNodeClick, onLinkClick, highlightNodes = [], highlightLinks = [], }: InteractiveForceGraphProps) { const graphRef = useRef(); const containerRef = useRef(null); const [hoveredNode, setHoveredNode] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const [dimensions, setDimensions] = useState({ width: 600, height }); // Update dimensions on resize useEffect(() => { const updateDimensions = () => { if (containerRef.current) { setDimensions({ width: containerRef.current.offsetWidth, height: height, }); } }; updateDimensions(); window.addEventListener('resize', updateDimensions); return () => window.removeEventListener('resize', updateDimensions); }, [height]); // Transform data for react-force-graph const graphData = { nodes: nodes.map(n => ({ ...n, color: n.color || nodeColors[n.type || 'default'] || nodeColors.default, val: n.size || 1, })), links: links.map(l => ({ ...l, color: highlightLinks.includes(`${l.source}-${l.target}`) ? '#fbbf24' : 'rgba(255,255,255,0.2)', })), }; // Node rendering const paintNode = useCallback((node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => { const gNode = node as NodeObject & GraphNode; const label = gNode.label || gNode.id; const fontSize = 12 / globalScale; const size = (gNode.size || 1) * 4; const isHighlighted = highlightNodes.includes(gNode.id as string); const isHovered = hoveredNode?.id === gNode.id; const isSelected = selectedNode?.id === gNode.id; // Node circle ctx.beginPath(); ctx.arc(node.x!, node.y!, size, 0, 2 * Math.PI); ctx.fillStyle = gNode.color || nodeColors.default; if (isHighlighted || isHovered || isSelected) { ctx.shadowColor = gNode.color || '#fff'; ctx.shadowBlur = 15; } ctx.fill(); ctx.shadowBlur = 0; // Border for selected/hovered if (isSelected) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 2 / globalScale; ctx.stroke(); } else if (isHovered) { ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 1 / globalScale; ctx.stroke(); } // Label if (globalScale > 0.5 || isHovered || isSelected) { ctx.font = `${fontSize}px ui-monospace, monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = 'rgba(255,255,255,0.9)'; ctx.fillText(label.substring(0, 20), node.x!, node.y! + size + 2); } // Type badge if ((isHovered || isSelected) && gNode.type) { ctx.font = `${fontSize * 0.7}px ui-monospace, monospace`; ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.fillText(`[${gNode.type}]`, node.x!, node.y! + size + fontSize + 4); } }, [highlightNodes, hoveredNode, selectedNode]); // Link rendering const paintLink = useCallback((link: LinkObject, ctx: CanvasRenderingContext2D, globalScale: number) => { const gLink = link as LinkObject & GraphLink; const start = link.source as NodeObject; const end = link.target as NodeObject; if (!start.x || !start.y || !end.x || !end.y) return; // Draw line ctx.beginPath(); ctx.moveTo(start.x, start.y); ctx.lineTo(end.x, end.y); ctx.strokeStyle = gLink.color || 'rgba(255,255,255,0.2)'; ctx.lineWidth = (gLink.weight || 1) / globalScale; ctx.stroke(); // Draw label if zoomed in if (globalScale > 1 && gLink.label) { const midX = (start.x + end.x) / 2; const midY = (start.y + end.y) / 2; const fontSize = 8 / globalScale; ctx.font = `${fontSize}px ui-monospace, monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.fillText(gLink.label, midX, midY); } }, []); // Handlers const handleNodeClick = useCallback((node: NodeObject) => { const gNode = node as NodeObject & GraphNode; setSelectedNode(gNode); onNodeClick?.(gNode); // Center on node graphRef.current?.centerAt(node.x, node.y, 500); graphRef.current?.zoom(2, 500); }, [onNodeClick]); const handleNodeHover = useCallback((node: NodeObject | null) => { setHoveredNode(node as GraphNode | null); if (containerRef.current) { containerRef.current.style.cursor = node ? 'pointer' : 'grab'; } }, []); const handleLinkClick = useCallback((link: LinkObject) => { const gLink = link as LinkObject & GraphLink; onLinkClick?.(gLink); }, [onLinkClick]); // Controls const zoomIn = () => graphRef.current?.zoom(graphRef.current?.zoom() * 1.5, 300); const zoomOut = () => graphRef.current?.zoom(graphRef.current?.zoom() / 1.5, 300); const resetView = () => { graphRef.current?.zoomToFit(400); setSelectedNode(null); }; return (
{/* Header */}
{title} {nodes.length} nodes / {links.length} links
{/* Graph container */}
{ const size = ((node as GraphNode).size || 1) * 4 + 4; ctx.fillStyle = color; ctx.beginPath(); ctx.arc(node.x!, node.y!, size, 0, 2 * Math.PI); ctx.fill(); }} linkDirectionalArrowLength={4} linkDirectionalArrowRelPos={1} d3AlphaDecay={0.02} d3VelocityDecay={0.3} warmupTicks={50} cooldownTicks={100} /> {/* Node info tooltip */} {(hoveredNode || selectedNode) && (
{(hoveredNode || selectedNode)?.label} {(hoveredNode || selectedNode)?.type && ( {(hoveredNode || selectedNode)?.type} )}
ID: {(hoveredNode || selectedNode)?.id}
)}
{/* Legend */}
{Object.entries(nodeColors).slice(0, 6).map(([type, color]) => (
{type}
))}
); } export default InteractiveForceGraph;