Spaces:
Paused
Paused
| /** | |
| * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * β 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<string, unknown>; | |
| } | |
| 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<string, string> = { | |
| 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<ForceGraphMethods>(); | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null); | |
| const [selectedNode, setSelectedNode] = useState<GraphNode | null>(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 ( | |
| <div className="rounded-lg border border-border/30 bg-background/50 overflow-hidden"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border/30"> | |
| <div className="flex items-center gap-3"> | |
| <span className="text-sm font-medium">{title}</span> | |
| <Badge variant="outline" className="text-[10px] font-mono"> | |
| {nodes.length} nodes / {links.length} links | |
| </Badge> | |
| </div> | |
| <div className="flex items-center gap-1"> | |
| <Button variant="ghost" size="sm" onClick={zoomIn} className="h-7 w-7 p-0"> | |
| <ZoomIn className="w-3 h-3" /> | |
| </Button> | |
| <Button variant="ghost" size="sm" onClick={zoomOut} className="h-7 w-7 p-0"> | |
| <ZoomOut className="w-3 h-3" /> | |
| </Button> | |
| <Button variant="ghost" size="sm" onClick={resetView} className="h-7 w-7 p-0"> | |
| <Maximize2 className="w-3 h-3" /> | |
| </Button> | |
| </div> | |
| </div> | |
| {/* Graph container */} | |
| <div ref={containerRef} className="relative bg-black/30" style={{ height }}> | |
| <ForceGraph2D | |
| ref={graphRef} | |
| graphData={graphData} | |
| width={dimensions.width} | |
| height={dimensions.height} | |
| backgroundColor="transparent" | |
| nodeCanvasObject={paintNode} | |
| linkCanvasObject={paintLink} | |
| onNodeClick={handleNodeClick} | |
| onNodeHover={handleNodeHover} | |
| onLinkClick={handleLinkClick} | |
| nodePointerAreaPaint={(node, color, ctx) => { | |
| 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) && ( | |
| <div className="absolute bottom-3 left-3 right-3 p-2 bg-black/80 rounded border border-border/30 text-xs"> | |
| <div className="flex items-center gap-2 mb-1"> | |
| <div | |
| className="w-2 h-2 rounded-full" | |
| style={{ backgroundColor: (hoveredNode || selectedNode)?.color }} | |
| /> | |
| <span className="font-medium">{(hoveredNode || selectedNode)?.label}</span> | |
| {(hoveredNode || selectedNode)?.type && ( | |
| <Badge variant="outline" className="text-[8px]"> | |
| {(hoveredNode || selectedNode)?.type} | |
| </Badge> | |
| )} | |
| </div> | |
| <span className="text-muted-foreground font-mono text-[10px]"> | |
| ID: {(hoveredNode || selectedNode)?.id} | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| {/* Legend */} | |
| <div className="px-4 py-2 bg-muted/20 border-t border-border/30 flex items-center gap-4 flex-wrap text-[10px]"> | |
| {Object.entries(nodeColors).slice(0, 6).map(([type, color]) => ( | |
| <div key={type} className="flex items-center gap-1"> | |
| <div className="w-2 h-2 rounded-full" style={{ backgroundColor: color }} /> | |
| <span className="text-muted-foreground capitalize">{type}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default InteractiveForceGraph; | |