import { useEffect, useRef, useState } from 'react'; import type { TopicGraph, TopicCluster } from '../../types'; interface ForceGraphProps { graph: TopicGraph; width?: number; height?: number; onNodeClick?: (topic: TopicCluster) => void; } interface SimNode extends TopicCluster { x: number; y: number; vx: number; vy: number; } interface SimLink { source: SimNode; target: SimNode; weight: number; } export function ForceGraph({ graph, width = 600, height = 400, onNodeClick }: ForceGraphProps) { const svgRef = useRef(null); const [nodes, setNodes] = useState([]); const [links, setLinks] = useState([]); const [hoveredNode, setHoveredNode] = useState(null); const [transform, setTransform] = useState({ x: 0, y: 0, k: 1 }); const animRef = useRef(0); useEffect(() => { if (!graph.nodes.length) return; const simNodes: SimNode[] = graph.nodes .filter((n) => n.topic_id !== -1) .map((n) => ({ ...n, x: width / 2 + (Math.random() - 0.5) * 200, y: height / 2 + (Math.random() - 0.5) * 200, vx: 0, vy: 0, })); const nodeMap = new Map(simNodes.map((n) => [n.topic_id, n])); const simLinks: SimLink[] = graph.links .filter((l) => nodeMap.has(l.source) && nodeMap.has(l.target)) .map((l) => ({ source: nodeMap.get(l.source)!, target: nodeMap.get(l.target)!, weight: l.weight, })); // Simple force simulation const alpha = { value: 1 }; const centerX = width / 2; const centerY = height / 2; function tick() { if (alpha.value < 0.001) return; alpha.value *= 0.99; // Repulsion for (let i = 0; i < simNodes.length; i++) { for (let j = i + 1; j < simNodes.length; j++) { const a = simNodes[i]!; const b = simNodes[j]!; const dx = b.x - a.x; const dy = b.y - a.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const force = (500 * alpha.value) / (dist * dist); a.vx -= (dx / dist) * force; a.vy -= (dy / dist) * force; b.vx += (dx / dist) * force; b.vy += (dy / dist) * force; } } // Attraction (links) for (const link of simLinks) { const dx = link.target.x - link.source.x; const dy = link.target.y - link.source.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const force = (dist - 100) * 0.01 * alpha.value * link.weight; link.source.vx += (dx / dist) * force; link.source.vy += (dy / dist) * force; link.target.vx -= (dx / dist) * force; link.target.vy -= (dy / dist) * force; } // Center gravity for (const node of simNodes) { node.vx += (centerX - node.x) * 0.01 * alpha.value; node.vy += (centerY - node.y) * 0.01 * alpha.value; node.vx *= 0.9; node.vy *= 0.9; node.x += node.vx; node.y += node.vy; } setNodes([...simNodes]); setLinks([...simLinks]); animRef.current = requestAnimationFrame(tick); } animRef.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(animRef.current); }, [graph, width, height]); const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); const scaleFactor = e.deltaY > 0 ? 0.9 : 1.1; setTransform((prev) => ({ ...prev, k: Math.max(0.2, Math.min(3, prev.k * scaleFactor)), })); }; const getNodeRadius = (size: number) => Math.max(8, Math.min(30, Math.sqrt(size) * 3)); const getSentimentColor = (sentiment: number) => { if (sentiment > 0.6) return 'var(--success)'; if (sentiment < 0.4) return 'var(--danger)'; return 'var(--warning)'; }; if (!graph.nodes.length || nodes.length === 0) { return (
No topic graph data available
); } return ( {/* Links */} {links.map((link, i) => ( ))} {/* Nodes */} {nodes.map((node) => { const r = getNodeRadius(node.size); const isHovered = hoveredNode === node.topic_id; return ( setHoveredNode(node.topic_id)} onMouseLeave={() => setHoveredNode(null)} onClick={() => onNodeClick?.(node)} style={{ cursor: 'pointer', transition: 'fill-opacity 0.2s' }} role="button" tabIndex={0} aria-label={`Topic: ${node.label}, Size: ${node.size}`} /> {(isHovered || node.size > 20) && ( {node.label.length > 20 ? node.label.slice(0, 20) + '…' : node.label} )} {isHovered && ( {node.size} entries • sentiment: {node.avg_sentiment.toFixed(2)} )} ); })} ); }