Spaces:
Sleeping
Sleeping
open-notebook
/
frontend
/src
/app
/(dashboard)
/knowledge-graph
/components
/KnowledgeGraphViewer.tsx
| 'use client' | |
| import { useState, useCallback, useRef, useEffect } from 'react' | |
| import dynamic from 'next/dynamic' | |
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' | |
| import { Button } from '@/components/ui/button' | |
| import { Badge } from '@/components/ui/badge' | |
| import { Separator } from '@/components/ui/separator' | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogDescription, | |
| DialogHeader, | |
| DialogTitle, | |
| } from '@/components/ui/dialog' | |
| import { | |
| Tooltip, | |
| TooltipContent, | |
| TooltipProvider, | |
| TooltipTrigger, | |
| } from '@/components/ui/tooltip' | |
| import { useKnowledgeGraph, useNodeDetails } from '@/lib/hooks/use-knowledge-graph' | |
| import { LoadingSpinner } from '@/components/common/LoadingSpinner' | |
| import { | |
| Network, | |
| ZoomIn, | |
| ZoomOut, | |
| Maximize2, | |
| Circle, | |
| ArrowRight, | |
| Search, | |
| Sparkles | |
| } from 'lucide-react' | |
| import { Input } from '@/components/ui/input' | |
| import type { GraphNode, GraphLink, NodeType } from '@/lib/types/knowledge-graph' | |
| // Dynamically import ForceGraph2D to avoid SSR issues | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| const ForceGraph2D = dynamic<any>( | |
| () => import('react-force-graph-2d').then((mod) => mod.default), | |
| { | |
| ssr: false, | |
| loading: () => ( | |
| <div className="flex items-center justify-center h-[600px]"> | |
| <LoadingSpinner /> | |
| </div> | |
| ), | |
| } | |
| ) | |
| interface KnowledgeGraphViewerProps { | |
| notebookId: string | |
| } | |
| const nodeTypeColors: Record<NodeType, string> = { | |
| concept: '#60a5fa', // blue-400 - brighter, cleaner | |
| person: '#a78bfa', // violet-400 | |
| event: '#fbbf24', // amber-400 | |
| place: '#34d399', // emerald-400 | |
| organization: '#f87171', // red-400 | |
| } | |
| export function KnowledgeGraphViewer({ notebookId }: KnowledgeGraphViewerProps) { | |
| const { data: graphData, isLoading, error } = useKnowledgeGraph(notebookId) | |
| const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null) | |
| const [highlightNodes, setHighlightNodes] = useState<Set<string>>(new Set()) | |
| const [highlightLinks, setHighlightLinks] = useState<Set<string>>(new Set()) | |
| const [hoverNode, setHoverNode] = useState<GraphNode | null>(null) | |
| const graphRef = useRef<any>(null) | |
| const { data: nodeDetails } = useNodeDetails(selectedNodeId) | |
| // Handle node click | |
| const handleNodeClick = useCallback((node: GraphNode) => { | |
| setSelectedNodeId(node.id) | |
| }, []) | |
| // Handle node hover | |
| const handleNodeHover = useCallback((node: GraphNode | null) => { | |
| setHoverNode(node) | |
| if (node) { | |
| const connectedNodes = new Set<string>() | |
| const connectedLinks = new Set<string>() | |
| connectedNodes.add(node.id) | |
| graphData?.links.forEach((link) => { | |
| const sourceId = typeof link.source === 'string' ? link.source : link.source.id | |
| const targetId = typeof link.target === 'string' ? link.target : link.target.id | |
| if (sourceId === node.id || targetId === node.id) { | |
| connectedNodes.add(sourceId) | |
| connectedNodes.add(targetId) | |
| connectedLinks.add(`${sourceId}-${targetId}`) | |
| } | |
| }) | |
| setHighlightNodes(connectedNodes) | |
| setHighlightLinks(connectedLinks) | |
| } else { | |
| setHighlightNodes(new Set()) | |
| setHighlightLinks(new Set()) | |
| } | |
| }, [graphData]) | |
| // Zoom controls | |
| const handleZoomIn = () => { | |
| if (graphRef.current) { | |
| graphRef.current.zoom(graphRef.current.zoom() * 1.5, 300) | |
| } | |
| } | |
| const handleZoomOut = () => { | |
| if (graphRef.current) { | |
| graphRef.current.zoom(graphRef.current.zoom() / 1.5, 300) | |
| } | |
| } | |
| const handleFitView = () => { | |
| if (graphRef.current) { | |
| graphRef.current.zoomToFit(400) | |
| } | |
| } | |
| // Custom node rendering with better labels | |
| const nodeCanvasObject = useCallback((node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => { | |
| // Validate node positions are finite before rendering | |
| if (!Number.isFinite(node.x) || !Number.isFinite(node.y)) { | |
| return | |
| } | |
| const label = node.label | |
| const fontSize = Math.max(12 / globalScale, 5) | |
| const baseNodeSize = 10 // Increased from 6 for better visibility | |
| // Scale node size based on importance and mentions | |
| const nodeSize = baseNodeSize + Math.sqrt(node.importance * 30 + node.mentions * 5) | |
| // Validate nodeSize is finite | |
| if (!Number.isFinite(nodeSize)) { | |
| return | |
| } | |
| const isHighlighted = highlightNodes.has(node.id) | |
| const baseColor = nodeTypeColors[node.type] || '#94a3b8' | |
| // Draw outer glow for important or highlighted nodes | |
| if (node.importance > 0.6 || isHighlighted) { | |
| const glowSize = isHighlighted ? 25 : 15 | |
| const gradient = ctx.createRadialGradient(node.x!, node.y!, nodeSize, node.x!, node.y!, nodeSize + glowSize) | |
| gradient.addColorStop(0, `${baseColor}60`) | |
| gradient.addColorStop(1, `${baseColor}00`) | |
| ctx.fillStyle = gradient | |
| ctx.beginPath() | |
| ctx.arc(node.x!, node.y!, nodeSize + glowSize, 0, 2 * Math.PI) | |
| ctx.fill() | |
| } | |
| // Main node circle | |
| ctx.beginPath() | |
| ctx.arc(node.x!, node.y!, nodeSize, 0, 2 * Math.PI) | |
| // Create gradient for node | |
| const gradient = ctx.createRadialGradient( | |
| node.x! - nodeSize * 0.3, node.y! - nodeSize * 0.3, nodeSize * 0.1, | |
| node.x!, node.y!, nodeSize | |
| ) | |
| gradient.addColorStop(0, `${baseColor}ff`) | |
| gradient.addColorStop(1, `${baseColor}cc`) | |
| ctx.fillStyle = gradient | |
| ctx.fill() | |
| // Stronger border for highlighted nodes | |
| ctx.strokeStyle = isHighlighted ? '#ffffff' : `${baseColor}99` | |
| ctx.lineWidth = (isHighlighted ? 3 : 1.5) / globalScale | |
| ctx.stroke() | |
| // Inner highlight for very important nodes | |
| if (node.importance > 0.8) { | |
| ctx.beginPath() | |
| ctx.arc(node.x!, node.y!, nodeSize * 0.4, 0, 2 * Math.PI) | |
| ctx.fillStyle = '#ffffff50' | |
| ctx.fill() | |
| } | |
| // Only show labels for: | |
| // - Hovered node | |
| // - Highlighted nodes (connected to hovered) | |
| // - Very important concepts (>80% importance) | |
| // - When significantly zoomed in | |
| const shouldShowLabel = isHighlighted || | |
| hoverNode?.id === node.id || | |
| node.importance > 0.8 || | |
| globalScale > 2.5 | |
| if (shouldShowLabel) { | |
| // Enhanced label with better readability | |
| ctx.font = `600 ${fontSize}px Inter, -apple-system, sans-serif` | |
| const textMetrics = ctx.measureText(label) | |
| const textWidth = textMetrics.width | |
| const textHeight = fontSize * 1.2 | |
| const padding = 5 | |
| const labelY = node.y! + nodeSize + fontSize + 4 | |
| // Semi-transparent background with rounded corners effect | |
| ctx.fillStyle = isHighlighted ? 'rgba(0, 0, 0, 0.85)' : 'rgba(15, 23, 42, 0.75)' | |
| ctx.beginPath() | |
| ctx.roundRect( | |
| node.x! - textWidth / 2 - padding, | |
| labelY - textHeight / 2 - padding, | |
| textWidth + padding * 2, | |
| textHeight + padding * 2, | |
| 3 | |
| ) | |
| ctx.fill() | |
| // Label text with subtle shadow | |
| ctx.shadowColor = 'rgba(0, 0, 0, 0.8)' | |
| ctx.shadowBlur = 2 | |
| ctx.shadowOffsetY = 1 | |
| ctx.textAlign = 'center' | |
| ctx.textBaseline = 'middle' | |
| ctx.fillStyle = isHighlighted ? '#ffffff' : '#f1f5f9' | |
| ctx.fillText(label, node.x!, labelY) | |
| ctx.shadowBlur = 0 | |
| ctx.shadowOffsetY = 0 | |
| } | |
| // Mentions badge for frequently mentioned nodes | |
| if (node.mentions > 2) { | |
| const badgeRadius = Math.min(nodeSize * 0.5, 10) | |
| const badgeX = node.x! + nodeSize * 0.6 | |
| const badgeY = node.y! - nodeSize * 0.6 | |
| // Badge circle | |
| ctx.beginPath() | |
| ctx.arc(badgeX, badgeY, badgeRadius, 0, 2 * Math.PI) | |
| ctx.fillStyle = '#ef4444' | |
| ctx.fill() | |
| ctx.strokeStyle = '#ffffff' | |
| ctx.lineWidth = 2 / globalScale | |
| ctx.stroke() | |
| // Badge text | |
| ctx.font = `bold ${Math.max(badgeRadius * 1.2, 8)}px Inter, sans-serif` | |
| ctx.fillStyle = '#ffffff' | |
| ctx.textAlign = 'center' | |
| ctx.textBaseline = 'middle' | |
| ctx.fillText(String(node.mentions), badgeX, badgeY) | |
| } | |
| }, [highlightNodes]) | |
| // Custom link rendering with relationship labels | |
| const linkCanvasObject = useCallback((link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => { | |
| const source = typeof link.source === 'string' ? null : link.source | |
| const target = typeof link.target === 'string' ? null : link.target | |
| if (!source || !target) return | |
| // Validate positions are finite before rendering | |
| if (!Number.isFinite(source.x) || !Number.isFinite(source.y) || | |
| !Number.isFinite(target.x) || !Number.isFinite(target.y)) { | |
| return | |
| } | |
| const sourceId = source.id | |
| const targetId = target.id | |
| const isHighlighted = highlightLinks.has(`${sourceId}-${targetId}`) || | |
| highlightLinks.has(`${targetId}-${sourceId}`) | |
| // Calculate angle and distance | |
| const dx = target.x! - source.x! | |
| const dy = target.y! - source.y! | |
| const angle = Math.atan2(dy, dx) | |
| const distance = Math.sqrt(dx * dx + dy * dy) | |
| // Draw line with gradient | |
| const gradient = ctx.createLinearGradient(source.x!, source.y!, target.x!, target.y!) | |
| if (isHighlighted) { | |
| gradient.addColorStop(0, '#60a5fa') | |
| gradient.addColorStop(1, '#a78bfa') | |
| ctx.strokeStyle = gradient | |
| ctx.lineWidth = 2.5 / globalScale | |
| } else { | |
| gradient.addColorStop(0, '#475569') | |
| gradient.addColorStop(1, '#334155') | |
| ctx.strokeStyle = gradient | |
| ctx.lineWidth = 1 / globalScale | |
| } | |
| ctx.beginPath() | |
| ctx.moveTo(source.x!, source.y!) | |
| ctx.lineTo(target.x!, target.y!) | |
| ctx.stroke() | |
| // Draw animated particles for highlighted links | |
| if (isHighlighted) { | |
| const particleCount = 3 | |
| const time = Date.now() / 1000 | |
| for (let i = 0; i < particleCount; i++) { | |
| const progress = ((time * 0.3 + i / particleCount) % 1) | |
| const particleX = source.x! + dx * progress | |
| const particleY = source.y! + dy * progress | |
| ctx.beginPath() | |
| ctx.arc(particleX, particleY, 2.5 / globalScale, 0, 2 * Math.PI) | |
| ctx.fillStyle = '#ffffff' | |
| ctx.fill() | |
| } | |
| } | |
| // Draw arrow at midpoint | |
| const midX = (source.x! + target.x!) / 2 | |
| const midY = (source.y! + target.y!) / 2 | |
| const arrowLength = 12 / globalScale | |
| const arrowAngle = Math.PI / 6 | |
| ctx.beginPath() | |
| ctx.moveTo(midX, midY) | |
| ctx.lineTo( | |
| midX - arrowLength * Math.cos(angle - arrowAngle), | |
| midY - arrowLength * Math.sin(angle - arrowAngle) | |
| ) | |
| ctx.lineTo( | |
| midX - arrowLength * Math.cos(angle + arrowAngle), | |
| midY - arrowLength * Math.sin(angle + arrowAngle) | |
| ) | |
| ctx.closePath() | |
| ctx.fillStyle = isHighlighted ? '#ffffff' : '#64748b' | |
| ctx.fill() | |
| // Draw relationship label when highlighted or zoomed in | |
| if (isHighlighted || globalScale > 1.5) { | |
| const fontSize = Math.max(10 / globalScale, 4) | |
| ctx.font = `500 ${fontSize}px Inter, sans-serif` | |
| ctx.textAlign = 'center' | |
| ctx.textBaseline = 'middle' | |
| // Format label | |
| const labelText = link.relationship.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) | |
| const textMetrics = ctx.measureText(labelText) | |
| const padding = 4 | |
| // Position label slightly offset from midpoint to avoid arrow | |
| const offsetDistance = 15 / globalScale | |
| const labelX = midX + offsetDistance * Math.cos(angle + Math.PI / 2) | |
| const labelY = midY + offsetDistance * Math.sin(angle + Math.PI / 2) | |
| // Background with rounded corners | |
| ctx.fillStyle = isHighlighted ? 'rgba(0, 0, 0, 0.9)' : 'rgba(15, 23, 42, 0.8)' | |
| ctx.beginPath() | |
| ctx.roundRect( | |
| labelX - textMetrics.width / 2 - padding, | |
| labelY - fontSize / 2 - padding, | |
| textMetrics.width + padding * 2, | |
| fontSize + padding * 2, | |
| 2 | |
| ) | |
| ctx.fill() | |
| // Border for highlighted | |
| if (isHighlighted) { | |
| ctx.strokeStyle = '#60a5fa' | |
| ctx.lineWidth = 1 / globalScale | |
| ctx.stroke() | |
| } | |
| // Text | |
| ctx.fillStyle = isHighlighted ? '#ffffff' : '#cbd5e1' | |
| ctx.fillText(labelText, labelX, labelY) | |
| } | |
| }, [highlightLinks]) | |
| if (isLoading) { | |
| return ( | |
| <Card> | |
| <CardContent className="py-12 flex justify-center"> | |
| <LoadingSpinner /> | |
| </CardContent> | |
| </Card> | |
| ) | |
| } | |
| if (error) { | |
| return ( | |
| <Card> | |
| <CardContent className="py-12 text-center text-destructive"> | |
| Failed to load knowledge graph | |
| </CardContent> | |
| </Card> | |
| ) | |
| } | |
| if (!graphData || graphData.nodes.length === 0) { | |
| return ( | |
| <Card> | |
| <CardContent className="py-12 text-center"> | |
| <Network className="h-12 w-12 mx-auto text-muted-foreground mb-4" /> | |
| <h3 className="font-medium mb-2">No Knowledge Graph</h3> | |
| <p className="text-muted-foreground"> | |
| Build a knowledge graph to visualize concepts and relationships | |
| </p> | |
| </CardContent> | |
| </Card> | |
| ) | |
| } | |
| return ( | |
| <div className="space-y-4"> | |
| {/* Stats and Legend Row */} | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| {/* Graph Statistics */} | |
| <Card> | |
| <CardHeader className="pb-3"> | |
| <CardTitle className="text-sm font-medium">Graph Statistics</CardTitle> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="grid grid-cols-3 gap-4"> | |
| <div className="space-y-1"> | |
| <div className="text-2xl font-bold text-blue-500">{graphData.nodes.length}</div> | |
| <div className="text-xs text-muted-foreground">Total Nodes</div> | |
| </div> | |
| <div className="space-y-1"> | |
| <div className="text-2xl font-bold text-green-500">{graphData.links.length}</div> | |
| <div className="text-xs text-muted-foreground">Connections</div> | |
| </div> | |
| <div className="space-y-1"> | |
| <div className="text-2xl font-bold text-purple-500"> | |
| {Object.keys(nodeTypeColors).length} | |
| </div> | |
| <div className="text-xs text-muted-foreground">Node Types</div> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Legend */} | |
| <Card> | |
| <CardHeader className="pb-3"> | |
| <CardTitle className="text-sm font-medium">Node Types</CardTitle> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="grid grid-cols-2 gap-3"> | |
| {Object.entries(nodeTypeColors).map(([type, color]) => { | |
| const count = graphData.nodes.filter(n => n.type === type).length | |
| return ( | |
| <div key={type} className="flex items-center justify-between gap-2"> | |
| <div className="flex items-center gap-2"> | |
| <div | |
| className="w-4 h-4 rounded-full ring-2 ring-offset-2 ring-offset-background" | |
| style={{ backgroundColor: color }} | |
| /> | |
| <span className="text-sm capitalize">{type}</span> | |
| </div> | |
| <span className="text-xs text-muted-foreground font-medium">{count}</span> | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| {/* Graph */} | |
| <Card className="relative overflow-hidden"> | |
| <CardContent className="p-0 relative overflow-hidden"> | |
| {/* Instructions Overlay */} | |
| <div className="absolute bottom-4 left-4 z-10 bg-background/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs text-muted-foreground"> | |
| <span className="hidden sm:inline">💡 Hover to explore • Click to detail • Drag to move • Scroll to zoom</span> | |
| <span className="sm:hidden">💡 Tap node for details</span> | |
| </div> | |
| {/* Zoom Controls */} | |
| <div className="absolute top-4 right-4 z-10 flex flex-col gap-2"> | |
| <TooltipProvider> | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| <Button variant="secondary" size="icon" onClick={handleZoomIn}> | |
| <ZoomIn className="h-4 w-4" /> | |
| </Button> | |
| </TooltipTrigger> | |
| <TooltipContent side="left">Zoom In</TooltipContent> | |
| </Tooltip> | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| <Button variant="secondary" size="icon" onClick={handleZoomOut}> | |
| <ZoomOut className="h-4 w-4" /> | |
| </Button> | |
| </TooltipTrigger> | |
| <TooltipContent side="left">Zoom Out</TooltipContent> | |
| </Tooltip> | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| <Button variant="secondary" size="icon" onClick={handleFitView}> | |
| <Maximize2 className="h-4 w-4" /> | |
| </Button> | |
| </TooltipTrigger> | |
| <TooltipContent side="left">Fit to View</TooltipContent> | |
| </Tooltip> | |
| </TooltipProvider> | |
| </div> | |
| {/* Hover Info */} | |
| {hoverNode && ( | |
| <div className="absolute top-4 left-4 z-10 bg-background/95 backdrop-blur-md border-2 rounded-lg p-4 max-w-sm shadow-2xl"> | |
| <div className="flex items-start gap-3 mb-2"> | |
| <div | |
| className="w-5 h-5 rounded-full ring-2 ring-white flex-shrink-0 mt-0.5" | |
| style={{ backgroundColor: nodeTypeColors[hoverNode.type] }} | |
| /> | |
| <div className="flex-1 min-w-0"> | |
| <h4 className="font-bold text-base mb-1 break-words">{hoverNode.label}</h4> | |
| <div className="flex gap-2 flex-wrap"> | |
| <Badge variant="secondary" className="capitalize text-xs"> | |
| {hoverNode.type} | |
| </Badge> | |
| <Badge variant="outline" className="text-xs"> | |
| Importance: {Math.round(hoverNode.importance * 100)}% | |
| </Badge> | |
| </div> | |
| </div> | |
| </div> | |
| {hoverNode.description && ( | |
| <p className="text-sm text-muted-foreground mb-3 leading-relaxed"> | |
| {hoverNode.description} | |
| </p> | |
| )} | |
| <div className="flex items-center gap-4 text-xs text-muted-foreground pt-2 border-t"> | |
| <span className="flex items-center gap-1"> | |
| <Circle className="h-3 w-3 fill-current" /> | |
| {hoverNode.mentions} mention{hoverNode.mentions !== 1 ? 's' : ''} | |
| </span> | |
| <span className="text-xs opacity-60">Click to see connections</span> | |
| </div> | |
| </div> | |
| )} | |
| {/* Search and Controls Overlay */} | |
| <div className="absolute top-4 left-4 z-10 flex flex-col gap-4 w-72 pointer-events-none"> | |
| {/* Search */} | |
| <Card className="pointer-events-auto shadow-lg bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-slate-800"> | |
| <CardContent className="p-3"> | |
| <div className="relative"> | |
| <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> | |
| <Input | |
| placeholder="Search concepts..." | |
| className="pl-8 h-9 bg-slate-900/50 border-slate-700" | |
| onChange={(e) => { | |
| const term = e.target.value.toLowerCase() | |
| if (!term) { | |
| setHighlightNodes(new Set()) | |
| return | |
| } | |
| const matches = new Set<string>() | |
| graphData?.nodes.forEach(n => { | |
| if (n.label.toLowerCase().includes(term)) { | |
| matches.add(n.id) | |
| } | |
| }) | |
| setHighlightNodes(matches) | |
| }} | |
| /> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Top Topics */} | |
| <Card className="pointer-events-auto shadow-lg bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 max-h-[300px] overflow-hidden flex flex-col border-slate-800"> | |
| <CardHeader className="p-3 pb-2"> | |
| <CardTitle className="text-sm font-medium flex items-center gap-2"> | |
| <Sparkles className="h-4 w-4 text-yellow-500" /> | |
| Top Topics | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="p-3 pt-0 overflow-y-auto custom-scrollbar"> | |
| <div className="flex flex-wrap gap-2"> | |
| {graphData?.nodes | |
| .sort((a, b) => b.importance - a.importance) | |
| .slice(0, 10) | |
| .map(node => ( | |
| <Badge | |
| key={node.id} | |
| variant="secondary" | |
| className="cursor-pointer hover:bg-primary/20 transition-colors border-slate-700" | |
| onClick={() => { | |
| handleNodeClick(node) | |
| if (graphRef.current) { | |
| graphRef.current.centerAt(node.x, node.y, 1000) | |
| graphRef.current.zoom(3, 2000) | |
| } | |
| }} | |
| > | |
| {node.label} | |
| </Badge> | |
| ))} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Legend */} | |
| <Card className="pointer-events-auto shadow-lg bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-slate-800"> | |
| <CardContent className="p-3 space-y-2"> | |
| <p className="text-xs font-medium text-muted-foreground mb-2">Node Types</p> | |
| {Object.entries(nodeTypeColors).map(([type, color]) => ( | |
| <div key={type} className="flex items-center gap-2 text-xs"> | |
| <div | |
| className="w-3 h-3 rounded-full shadow-sm ring-1 ring-white/10" | |
| style={{ backgroundColor: color }} | |
| /> | |
| <span className="capitalize">{type}</span> | |
| </div> | |
| ))} | |
| </CardContent> | |
| </Card> | |
| </div> | |
| {/* Force Graph */} | |
| <div className="h-[600px] w-full bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 rounded-lg overflow-hidden relative"> | |
| <ForceGraph2D | |
| ref={graphRef} | |
| graphData={graphData} | |
| nodeId="id" | |
| nodeLabel={(node: GraphNode) => `${node.label} (${node.type})`} | |
| nodeRelSize={6} | |
| nodeVal={(node: GraphNode) => node.importance * 20 + node.mentions * 3} | |
| nodeCanvasObject={nodeCanvasObject} | |
| nodeCanvasObjectMode={() => 'replace'} | |
| onNodeClick={handleNodeClick} | |
| onNodeHover={handleNodeHover} | |
| linkLabel={(link: GraphLink) => link.relationship} | |
| linkCanvasObject={linkCanvasObject} | |
| linkCanvasObjectMode={() => 'replace'} | |
| linkDirectionalParticles={0} | |
| linkDirectionalParticleWidth={0} | |
| backgroundColor="transparent" | |
| cooldownTicks={150} | |
| warmupTicks={100} | |
| d3AlphaDecay={0.015} | |
| d3VelocityDecay={0.25} | |
| d3Force={{ | |
| charge: { strength: -300 }, // Increased repulsion for better spacing | |
| link: { distance: 150 }, // Increased distance between connected nodes | |
| center: { strength: 0.05 } // Weakened center pull to allow spread | |
| }} | |
| enableNodeDrag={true} | |
| enableZoomInteraction={true} | |
| enablePanInteraction={true} | |
| width={undefined} | |
| height={600} | |
| /> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Node Details Dialog */} | |
| <Dialog open={!!selectedNodeId} onOpenChange={(open: boolean) => !open && setSelectedNodeId(null)}> | |
| <DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto"> | |
| <DialogHeader> | |
| <DialogTitle className="flex items-center gap-2"> | |
| {nodeDetails?.node && ( | |
| <> | |
| <div | |
| className="w-4 h-4 rounded-full" | |
| style={{ backgroundColor: nodeTypeColors[nodeDetails.node.type as NodeType] }} | |
| /> | |
| {nodeDetails.node.label} | |
| </> | |
| )} | |
| </DialogTitle> | |
| <DialogDescription> | |
| {nodeDetails?.node?.type && ( | |
| <Badge variant="secondary" className="capitalize"> | |
| {nodeDetails.node.type} | |
| </Badge> | |
| )} | |
| </DialogDescription> | |
| </DialogHeader> | |
| {nodeDetails && ( | |
| <div className="mt-4 space-y-6"> | |
| {/* Description */} | |
| {nodeDetails.node.description && ( | |
| <div> | |
| <h4 className="font-medium mb-2">Description</h4> | |
| <p className="text-sm text-muted-foreground"> | |
| {nodeDetails.node.description} | |
| </p> | |
| </div> | |
| )} | |
| {/* Stats */} | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div className="p-3 rounded-lg bg-muted"> | |
| <div className="text-2xl font-bold">{nodeDetails.node.mentions}</div> | |
| <div className="text-sm text-muted-foreground">Mentions</div> | |
| </div> | |
| <div className="p-3 rounded-lg bg-muted"> | |
| <div className="text-2xl font-bold"> | |
| {Math.round(nodeDetails.node.importance * 100)}% | |
| </div> | |
| <div className="text-sm text-muted-foreground">Importance</div> | |
| </div> | |
| </div> | |
| <Separator /> | |
| {/* Connections */} | |
| <div> | |
| <h4 className="font-medium mb-3"> | |
| Connections ({nodeDetails.connections.length}) | |
| </h4> | |
| <div className="space-y-2 max-h-[200px] overflow-y-auto"> | |
| {nodeDetails.edges.map((edge, i) => { | |
| const isSource = edge.source_node === nodeDetails.node.id | |
| const connectedNode = nodeDetails.connections.find( | |
| c => c.id === (isSource ? edge.target_node : edge.source_node) | |
| ) | |
| return ( | |
| <div | |
| key={i} | |
| className="flex items-center gap-2 p-2 rounded-lg bg-muted/50 text-sm" | |
| > | |
| <div | |
| className="w-2 h-2 rounded-full" | |
| style={{ backgroundColor: nodeTypeColors[connectedNode?.type as NodeType] || '#666' }} | |
| /> | |
| <span className="font-medium"> | |
| {connectedNode?.label || 'Unknown'} | |
| </span> | |
| <ArrowRight className="h-3 w-3 text-muted-foreground" /> | |
| <span className="text-muted-foreground"> | |
| {edge.relationship} | |
| </span> | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </DialogContent> | |
| </Dialog> | |
| </div> | |
| ) | |
| } | |