'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( () => import('react-force-graph-2d').then((mod) => mod.default), { ssr: false, loading: () => (
), } ) interface KnowledgeGraphViewerProps { notebookId: string } const nodeTypeColors: Record = { 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(null) const [highlightNodes, setHighlightNodes] = useState>(new Set()) const [highlightLinks, setHighlightLinks] = useState>(new Set()) const [hoverNode, setHoverNode] = useState(null) const graphRef = useRef(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() const connectedLinks = new Set() 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 ( ) } if (error) { return ( Failed to load knowledge graph ) } if (!graphData || graphData.nodes.length === 0) { return (

No Knowledge Graph

Build a knowledge graph to visualize concepts and relationships

) } return (
{/* Stats and Legend Row */}
{/* Graph Statistics */} Graph Statistics
{graphData.nodes.length}
Total Nodes
{graphData.links.length}
Connections
{Object.keys(nodeTypeColors).length}
Node Types
{/* Legend */} Node Types
{Object.entries(nodeTypeColors).map(([type, color]) => { const count = graphData.nodes.filter(n => n.type === type).length return (
{type}
{count}
) })}
{/* Graph */} {/* Instructions Overlay */}
💡 Hover to explore • Click to detail • Drag to move • Scroll to zoom 💡 Tap node for details
{/* Zoom Controls */}
Zoom In Zoom Out Fit to View
{/* Hover Info */} {hoverNode && (

{hoverNode.label}

{hoverNode.type} Importance: {Math.round(hoverNode.importance * 100)}%
{hoverNode.description && (

{hoverNode.description}

)}
{hoverNode.mentions} mention{hoverNode.mentions !== 1 ? 's' : ''} Click to see connections
)} {/* Search and Controls Overlay */}
{/* Search */}
{ const term = e.target.value.toLowerCase() if (!term) { setHighlightNodes(new Set()) return } const matches = new Set() graphData?.nodes.forEach(n => { if (n.label.toLowerCase().includes(term)) { matches.add(n.id) } }) setHighlightNodes(matches) }} />
{/* Top Topics */} Top Topics
{graphData?.nodes .sort((a, b) => b.importance - a.importance) .slice(0, 10) .map(node => ( { handleNodeClick(node) if (graphRef.current) { graphRef.current.centerAt(node.x, node.y, 1000) graphRef.current.zoom(3, 2000) } }} > {node.label} ))}
{/* Legend */}

Node Types

{Object.entries(nodeTypeColors).map(([type, color]) => (
{type}
))}
{/* Force Graph */}
`${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} />
{/* Node Details Dialog */} !open && setSelectedNodeId(null)}> {nodeDetails?.node && ( <>
{nodeDetails.node.label} )} {nodeDetails?.node?.type && ( {nodeDetails.node.type} )} {nodeDetails && (
{/* Description */} {nodeDetails.node.description && (

Description

{nodeDetails.node.description}

)} {/* Stats */}
{nodeDetails.node.mentions}
Mentions
{Math.round(nodeDetails.node.importance * 100)}%
Importance
{/* Connections */}

Connections ({nodeDetails.connections.length})

{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 (
{connectedNode?.label || 'Unknown'} {edge.relationship}
) })}
)}
) }