Spaces:
Runtime error
Runtime error
| import { useEffect, useRef, useState } from "react"; | |
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; | |
| import { Slider } from "@/components/ui/slider"; | |
| import { Switch } from "@/components/ui/switch"; | |
| import { Label } from "@/components/ui/label"; | |
| import { Network, GitBranch, Zap, Eye, Play, Pause, Search } from "lucide-react"; | |
| import { Input } from "@/components/ui/input"; | |
| import ForceGraph2D from "react-force-graph-2d"; | |
| import ForceGraph3D from "react-force-graph-3d"; | |
| import * as d3 from "d3"; | |
| interface GraphNode { | |
| id: string; | |
| label: string; | |
| type: "document" | "concept" | "author" | "topic" | "query"; | |
| size: number; | |
| color: string; | |
| metadata?: any; | |
| x?: number; | |
| y?: number; | |
| z?: number; | |
| } | |
| interface GraphLink { | |
| source: string | GraphNode; | |
| target: string | GraphNode; | |
| relationship: string; | |
| strength: number; | |
| color: string; | |
| } | |
| interface GraphData { | |
| nodes: GraphNode[]; | |
| links: GraphLink[]; | |
| } | |
| interface GraphStats { | |
| totalDocuments: number; | |
| totalConcepts: number; | |
| totalResearchTeams: number; | |
| totalSourceTypes: number; | |
| } | |
| const NODE_COLORS = { | |
| document: "#3b82f6", // blue | |
| concept: "#10b981", // emerald | |
| author: "#f59e0b", // amber | |
| topic: "#8b5cf6", // violet | |
| query: "#ef4444", // red | |
| }; | |
| const RELATIONSHIP_COLORS = { | |
| "cites": "#64748b", | |
| "authored_by": "#f59e0b", | |
| "contains_concept": "#10b981", | |
| "related_to": "#8b5cf6", | |
| "searched_for": "#ef4444", | |
| "similar_to": "#06b6d4", | |
| }; | |
| export function KnowledgeGraph() { | |
| const [graphData, setGraphData] = useState<GraphData>({ nodes: [], links: [] }); | |
| const [view3D, setView3D] = useState(false); | |
| const [isAnimating, setIsAnimating] = useState(true); | |
| const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null); | |
| const [linkDistance, setLinkDistance] = useState([150]); | |
| const [chargeStrength, setChargeStrength] = useState([-300]); | |
| const [showLabels, setShowLabels] = useState(true); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [stats, setStats] = useState<GraphStats>({ totalDocuments: 0, totalConcepts: 0, totalResearchTeams: 0, totalSourceTypes: 0 }); | |
| const [searchTerm, setSearchTerm] = useState(""); | |
| const [filteredGraphData, setFilteredGraphData] = useState<GraphData>({ nodes: [], links: [] }); | |
| const [hoveredNode, setHoveredNode] = useState<string | null>(null); | |
| const [highlightNodes, setHighlightNodes] = useState(new Set<string>()); | |
| const [highlightLinks, setHighlightLinks] = useState(new Set<string>()); | |
| const fgRef = useRef<any>(); | |
| useEffect(() => { | |
| fetchKnowledgeGraph(); | |
| }, []); | |
| useEffect(() => { | |
| filterGraphData(); | |
| }, [searchTerm, graphData]); | |
| const filterGraphData = () => { | |
| if (!searchTerm) { | |
| setFilteredGraphData({ nodes: [], links: [] }); | |
| return; | |
| } | |
| const term = searchTerm.toLowerCase(); | |
| const filteredNodes = graphData.nodes.filter(node => | |
| node.label.toLowerCase().includes(term) || | |
| node.type.toLowerCase().includes(term) || | |
| (node.metadata?.concept && node.metadata.concept.toLowerCase().includes(term)) || | |
| (node.metadata?.title && node.metadata.title.toLowerCase().includes(term)) | |
| ); | |
| const nodeIds = new Set(filteredNodes.map(n => n.id)); | |
| const filteredLinks = graphData.links.filter(link => { | |
| const sourceId = typeof link.source === 'string' ? link.source : (link.source as GraphNode).id; | |
| const targetId = typeof link.target === 'string' ? link.target : (link.target as GraphNode).id; | |
| return nodeIds.has(sourceId) && nodeIds.has(targetId); | |
| }); | |
| setFilteredGraphData({ nodes: filteredNodes, links: filteredLinks }); | |
| }; | |
| const fetchKnowledgeGraph = async () => { | |
| try { | |
| setIsLoading(true); | |
| const response = await fetch('/api/knowledge-graph'); | |
| const data = await response.json(); | |
| if (data.nodes && data.links) { | |
| setGraphData({ nodes: data.nodes, links: data.links }); | |
| setStats(data.stats || { totalDocuments: 0, totalConcepts: 0, totalResearchTeams: 0, totalSourceTypes: 0 }); | |
| } else { | |
| // Fallback to sample data if API fails | |
| generateKnowledgeGraph(); | |
| } | |
| } catch (error) { | |
| console.error('Failed to fetch knowledge graph:', error); | |
| // Fallback to sample data | |
| generateKnowledgeGraph(); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| const generateKnowledgeGraph = () => { | |
| // Extract concepts from KnowledgeBridge sample documents | |
| const documents = [ | |
| { id: "deepseek-r1", title: "DeepSeek-R1: Advanced Reasoning Model", concepts: ["reasoning", "thinking", "chain-of-thought"], authors: ["DeepSeek Team"], year: 2024, sourceType: "arxiv" }, | |
| { id: "nebius-ai", title: "Nebius AI Platform", concepts: ["cloud-ai", "scaling", "embeddings"], authors: ["Nebius Team"], year: 2024, sourceType: "github" }, | |
| { id: "semantic-search", title: "Semantic Search with BGE Models", concepts: ["embeddings", "similarity", "retrieval"], authors: ["BAAI Team"], year: 2024, sourceType: "arxiv" }, | |
| { id: "modal-compute", title: "Modal Distributed Computing", concepts: ["serverless", "scaling", "distributed"], authors: ["Modal Team"], year: 2024, sourceType: "github" }, | |
| { id: "url-validation", title: "Smart URL Validation Systems", concepts: ["validation", "filtering", "content-verification"], authors: ["Web Research Group"], year: 2024, sourceType: "web" }, | |
| { id: "multi-source", title: "Multi-Source Information Retrieval", concepts: ["search", "aggregation", "ranking"], authors: ["IR Conference"], year: 2024, sourceType: "academic" }, | |
| { id: "ai-security", title: "AI Application Security", concepts: ["rate-limiting", "validation", "middleware"], authors: ["Security Researchers"], year: 2024, sourceType: "github" }, | |
| { id: "react-query", title: "TanStack Query for Data Fetching", concepts: ["caching", "state-management", "performance"], authors: ["TanStack Team"], year: 2024, sourceType: "github" }, | |
| { id: "typescript-safety", title: "TypeScript for Type Safety", concepts: ["type-safety", "compilation", "validation"], authors: ["TypeScript Team"], year: 2024, sourceType: "web" }, | |
| { id: "knowledge-graphs", title: "Interactive Knowledge Graphs", concepts: ["visualization", "relationships", "d3js"], authors: ["DataViz Community"], year: 2024, sourceType: "github" }, | |
| { id: "github-api", title: "GitHub API for Repository Search", concepts: ["api-integration", "search", "filtering"], authors: ["GitHub Team"], year: 2024, sourceType: "web" }, | |
| { id: "arxiv-papers", title: "ArXiv Academic Paper Search", concepts: ["academic-search", "paper-validation", "research"], authors: ["arXiv Team"], year: 2024, sourceType: "academic" }, | |
| ]; | |
| const nodes: GraphNode[] = []; | |
| const links: GraphLink[] = []; | |
| // Add document nodes | |
| documents.forEach(doc => { | |
| nodes.push({ | |
| id: doc.id, | |
| label: doc.title, | |
| type: "document", | |
| size: 12, | |
| color: NODE_COLORS.document, | |
| metadata: doc | |
| }); | |
| }); | |
| // Extract unique concepts and create concept nodes | |
| const allConcepts = new Set<string>(); | |
| documents.forEach(doc => doc.concepts.forEach(concept => allConcepts.add(concept))); | |
| allConcepts.forEach(concept => { | |
| const relatedDocs = documents.filter(doc => doc.concepts.includes(concept)); | |
| nodes.push({ | |
| id: `concept_${concept}`, | |
| label: concept, | |
| type: "concept", | |
| size: 8 + relatedDocs.length * 2, | |
| color: NODE_COLORS.concept, | |
| metadata: { relatedDocuments: relatedDocs.length } | |
| }); | |
| // Link concepts to documents | |
| relatedDocs.forEach(doc => { | |
| links.push({ | |
| source: doc.id, | |
| target: `concept_${concept}`, | |
| relationship: "contains_concept", | |
| strength: 1, | |
| color: RELATIONSHIP_COLORS.contains_concept | |
| }); | |
| }); | |
| }); | |
| // Extract unique authors and create author nodes | |
| const allAuthors = new Set<string>(); | |
| documents.forEach(doc => doc.authors.forEach(author => allAuthors.add(author))); | |
| allAuthors.forEach(author => { | |
| const authoredDocs = documents.filter(doc => doc.authors.includes(author)); | |
| nodes.push({ | |
| id: `author_${author}`, | |
| label: author, | |
| type: "author", | |
| size: 6 + authoredDocs.length, | |
| color: NODE_COLORS.author, | |
| metadata: { publications: authoredDocs.length } | |
| }); | |
| // Link authors to documents | |
| authoredDocs.forEach(doc => { | |
| links.push({ | |
| source: `author_${author}`, | |
| target: doc.id, | |
| relationship: "authored_by", | |
| strength: 0.8, | |
| color: RELATIONSHIP_COLORS.authored_by | |
| }); | |
| }); | |
| }); | |
| // Create topic clusters for KnowledgeBridge features | |
| const topics = [ | |
| { id: "ai_models", name: "AI Models & Processing", docs: ["deepseek-r1", "nebius-ai", "semantic-search"] }, | |
| { id: "infrastructure", name: "Infrastructure & Scaling", docs: ["modal-compute", "ai-security", "react-query"] }, | |
| { id: "search_systems", name: "Search & Retrieval", docs: ["multi-source", "url-validation", "github-api"] }, | |
| { id: "data_visualization", name: "Data & Visualization", docs: ["knowledge-graphs", "typescript-safety"] }, | |
| { id: "academic_integration", name: "Academic Integration", docs: ["arxiv-papers", "semantic-search"] }, | |
| { id: "web_technologies", name: "Web Technologies", docs: ["react-query", "typescript-safety", "ai-security"] } | |
| ]; | |
| topics.forEach(topic => { | |
| nodes.push({ | |
| id: topic.id, | |
| label: topic.name, | |
| type: "topic", | |
| size: 10, | |
| color: NODE_COLORS.topic, | |
| metadata: { documentCount: topic.docs.length } | |
| }); | |
| // Link topics to documents | |
| topic.docs.forEach(docId => { | |
| if (documents.find(d => d.id === docId)) { | |
| links.push({ | |
| source: topic.id, | |
| target: docId, | |
| relationship: "related_to", | |
| strength: 0.6, | |
| color: RELATIONSHIP_COLORS.related_to | |
| }); | |
| } | |
| }); | |
| }); | |
| // Add similarity links between documents with shared concepts | |
| documents.forEach(doc1 => { | |
| documents.forEach(doc2 => { | |
| if (doc1.id !== doc2.id) { | |
| const sharedConcepts = doc1.concepts.filter(c => doc2.concepts.includes(c)); | |
| if (sharedConcepts.length >= 2) { | |
| links.push({ | |
| source: doc1.id, | |
| target: doc2.id, | |
| relationship: "similar_to", | |
| strength: sharedConcepts.length * 0.3, | |
| color: RELATIONSHIP_COLORS.similar_to | |
| }); | |
| } | |
| } | |
| }); | |
| }); | |
| // Add sample search queries for KnowledgeBridge | |
| const sampleQueries = [ | |
| { id: "query_semantic", text: "semantic search with embeddings", relatedDocs: ["semantic-search", "nebius-ai"] }, | |
| { id: "query_security", text: "AI application security middleware", relatedDocs: ["ai-security", "url-validation"] }, | |
| { id: "query_distributed", text: "distributed AI processing", relatedDocs: ["modal-compute", "nebius-ai"] } | |
| ]; | |
| sampleQueries.forEach(query => { | |
| nodes.push({ | |
| id: query.id, | |
| label: query.text, | |
| type: "query", | |
| size: 6, | |
| color: NODE_COLORS.query, | |
| metadata: { searchText: query.text } | |
| }); | |
| query.relatedDocs.forEach(docId => { | |
| links.push({ | |
| source: query.id, | |
| target: docId, | |
| relationship: "searched_for", | |
| strength: 0.4, | |
| color: RELATIONSHIP_COLORS.searched_for | |
| }); | |
| }); | |
| }); | |
| setGraphData({ nodes, links }); | |
| }; | |
| const handleNodeClick = (node: any) => { | |
| setSelectedNode(node); | |
| if (fgRef.current) { | |
| // Center the camera on the clicked node | |
| if (view3D) { | |
| fgRef.current.cameraPosition( | |
| { x: node.x, y: node.y, z: node.z + 100 }, | |
| node, | |
| 3000 | |
| ); | |
| } else { | |
| fgRef.current.centerAt(node.x, node.y, 1000); | |
| fgRef.current.zoom(2, 1000); | |
| } | |
| } | |
| }; | |
| const updateHighlight = () => { | |
| const currentData = filteredGraphData.nodes.length > 0 ? filteredGraphData : graphData; | |
| setHighlightNodes(highlightNodes); | |
| setHighlightLinks(highlightLinks); | |
| }; | |
| const handleNodeHover = (node: any) => { | |
| if (!node) { | |
| setHoveredNode(null); | |
| setHighlightNodes(new Set()); | |
| setHighlightLinks(new Set()); | |
| return; | |
| } | |
| setHoveredNode(node.id); | |
| const newHighlightNodes = new Set<string>(); | |
| const newHighlightLinks = new Set<string>(); | |
| newHighlightNodes.add(node.id); | |
| const currentData = filteredGraphData.nodes.length > 0 ? filteredGraphData : graphData; | |
| currentData.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) { | |
| newHighlightLinks.add(`${sourceId}-${targetId}`); | |
| newHighlightNodes.add(sourceId); | |
| newHighlightNodes.add(targetId); | |
| } | |
| }); | |
| setHighlightNodes(newHighlightNodes); | |
| setHighlightLinks(newHighlightLinks); | |
| }; | |
| const resetView = () => { | |
| if (fgRef.current) { | |
| if (view3D) { | |
| fgRef.current.cameraPosition({ x: 0, y: 0, z: 300 }, { x: 0, y: 0, z: 0 }, 2000); | |
| } else { | |
| fgRef.current.zoomToFit(2000); | |
| } | |
| } | |
| setSelectedNode(null); | |
| }; | |
| const toggleAnimation = () => { | |
| setIsAnimating(!isAnimating); | |
| if (fgRef.current) { | |
| if (isAnimating) { | |
| fgRef.current.pauseAnimation(); | |
| } else { | |
| fgRef.current.resumeAnimation(); | |
| } | |
| } | |
| }; | |
| const getNodeLabel = (node: any) => { | |
| if (!showLabels) return ""; | |
| return node.label.length > 20 ? node.label.substring(0, 20) + "..." : node.label; | |
| }; | |
| const getNodeSize = (node: any) => { | |
| return node.size || 8; | |
| }; | |
| const getNodeColor = (node: any) => { | |
| if (hoveredNode === node.id) return "#ff6b6b"; // Red for hovered node | |
| if (highlightNodes.has(node.id)) return "#4ecdc4"; // Teal for connected nodes | |
| return node.color; // Original color | |
| }; | |
| const getLinkWidth = (link: any) => { | |
| const baseWidth = Math.max(0.5, link.strength * 3); | |
| const linkId = `${link.source}-${link.target}`; | |
| return highlightLinks.has(linkId) ? baseWidth * 2 : baseWidth; | |
| }; | |
| const getLinkColor = (link: any) => { | |
| const linkId = `${link.source}-${link.target}`; | |
| return highlightLinks.has(linkId) ? "#ff6b6b" : link.color; | |
| }; | |
| return ( | |
| <div className="space-y-6"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <h2 className="text-2xl font-bold flex items-center gap-2"> | |
| <Network className="h-6 w-6" /> | |
| KnowledgeBridge Knowledge Graph | |
| </h2> | |
| <p className="text-muted-foreground"> | |
| Explore relationships between technologies, research sources, and concepts in KnowledgeBridge | |
| </p> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <Button variant="outline" size="sm" onClick={fetchKnowledgeGraph} disabled={isLoading}> | |
| <GitBranch className="h-4 w-4 mr-2" /> | |
| Refresh Graph | |
| </Button> | |
| <Button variant="outline" size="sm" onClick={resetView}> | |
| <Eye className="h-4 w-4 mr-2" /> | |
| Reset View | |
| </Button> | |
| <Button variant="outline" size="sm" onClick={toggleAnimation}> | |
| {isAnimating ? <Pause className="h-4 w-4 mr-2" /> : <Play className="h-4 w-4 mr-2" />} | |
| {isAnimating ? "Pause" : "Resume"} | |
| </Button> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 lg:grid-cols-4 gap-6"> | |
| {/* Controls Panel */} | |
| <Card className="lg:col-span-1"> | |
| <CardHeader> | |
| <CardTitle className="text-lg">Graph Controls</CardTitle> | |
| </CardHeader> | |
| <CardContent className="space-y-4"> | |
| {/* Search Filter */} | |
| <div className="space-y-2"> | |
| <Label className="text-sm font-medium">Search Graph</Label> | |
| <div className="relative"> | |
| <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> | |
| <Input | |
| placeholder="Filter nodes..." | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| className="pl-8" | |
| /> | |
| </div> | |
| {filteredGraphData.nodes.length > 0 && ( | |
| <div className="text-xs text-muted-foreground"> | |
| Showing {filteredGraphData.nodes.length} of {graphData.nodes.length} nodes | |
| </div> | |
| )} | |
| </div> | |
| {/* View Toggle */} | |
| <div className="flex items-center justify-between"> | |
| <Label htmlFor="view-toggle" className="text-sm font-medium"> | |
| 3D View | |
| </Label> | |
| <Switch | |
| id="view-toggle" | |
| checked={view3D} | |
| onCheckedChange={setView3D} | |
| /> | |
| </div> | |
| {/* Show Labels Toggle */} | |
| <div className="flex items-center justify-between"> | |
| <Label htmlFor="labels-toggle" className="text-sm font-medium"> | |
| Show Labels | |
| </Label> | |
| <Switch | |
| id="labels-toggle" | |
| checked={showLabels} | |
| onCheckedChange={setShowLabels} | |
| /> | |
| </div> | |
| {/* Link Distance */} | |
| <div className="space-y-2"> | |
| <Label className="text-sm font-medium">Link Distance</Label> | |
| <Slider | |
| value={linkDistance} | |
| onValueChange={setLinkDistance} | |
| max={300} | |
| min={50} | |
| step={10} | |
| className="w-full" | |
| /> | |
| <div className="text-xs text-muted-foreground text-center"> | |
| {linkDistance[0]}px | |
| </div> | |
| </div> | |
| {/* Charge Strength */} | |
| <div className="space-y-2"> | |
| <Label className="text-sm font-medium">Node Repulsion</Label> | |
| <Slider | |
| value={chargeStrength} | |
| onValueChange={setChargeStrength} | |
| max={-50} | |
| min={-500} | |
| step={10} | |
| className="w-full" | |
| /> | |
| <div className="text-xs text-muted-foreground text-center"> | |
| {chargeStrength[0]} | |
| </div> | |
| </div> | |
| {/* Legend */} | |
| <div className="space-y-2"> | |
| <Label className="text-sm font-medium">Node Types</Label> | |
| <div className="grid grid-cols-2 gap-1 text-xs"> | |
| {Object.entries(NODE_COLORS).map(([type, color]) => ( | |
| <div key={type} className="flex items-center gap-1"> | |
| <div | |
| className="w-3 h-3 rounded-full" | |
| style={{ backgroundColor: color }} | |
| /> | |
| <span className="capitalize">{type}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Graph Stats */} | |
| <div className="pt-4 border-t space-y-1 text-sm"> | |
| <div className="flex justify-between"> | |
| <span>Nodes:</span> | |
| <span className="font-medium">{graphData.nodes.length}</span> | |
| </div> | |
| <div className="flex justify-between"> | |
| <span>Links:</span> | |
| <span className="font-medium">{graphData.links.length}</span> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Graph Visualization */} | |
| <Card className="lg:col-span-2"> | |
| <CardContent className="p-0"> | |
| <div className="h-[600px] w-full bg-background rounded-lg overflow-hidden relative flex items-center justify-center"> | |
| {isLoading && ( | |
| <div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10"> | |
| <div className="text-center"> | |
| <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div> | |
| <p className="text-sm text-muted-foreground">Building knowledge graph...</p> | |
| </div> | |
| </div> | |
| )} | |
| {view3D ? ( | |
| <ForceGraph3D | |
| ref={fgRef} | |
| graphData={filteredGraphData.nodes.length > 0 ? filteredGraphData : graphData} | |
| nodeLabel={getNodeLabel} | |
| nodeColor={getNodeColor} | |
| nodeVal={getNodeSize} | |
| linkColor={getLinkColor} | |
| linkWidth={getLinkWidth} | |
| linkDirectionalParticles={2} | |
| linkDirectionalParticleSpeed={0.01} | |
| onNodeClick={handleNodeClick} | |
| onNodeHover={handleNodeHover} | |
| d3AlphaDecay={0.01} | |
| d3VelocityDecay={0.1} | |
| enableNodeDrag={true} | |
| enableNavigationControls={true} | |
| controlType="orbit" | |
| backgroundColor="rgba(0,0,0,0)" | |
| onEngineStop={() => { | |
| if (fgRef.current) { | |
| // Center the 3D graph with proper camera positioning | |
| setTimeout(() => { | |
| const distance = Math.max(400, graphData.nodes.length * 20); | |
| fgRef.current.cameraPosition({ x: 0, y: 0, z: distance }, { x: 0, y: 0, z: 0 }, 1500); | |
| }, 100); | |
| } | |
| }} | |
| /> | |
| ) : ( | |
| <ForceGraph2D | |
| ref={fgRef} | |
| graphData={filteredGraphData.nodes.length > 0 ? filteredGraphData : graphData} | |
| nodeLabel={getNodeLabel} | |
| nodeColor={getNodeColor} | |
| nodeVal={getNodeSize} | |
| linkColor={getLinkColor} | |
| linkWidth={getLinkWidth} | |
| linkDirectionalParticles={1} | |
| linkDirectionalParticleSpeed={0.005} | |
| onNodeClick={handleNodeClick} | |
| onNodeHover={handleNodeHover} | |
| d3AlphaDecay={0.01} | |
| d3VelocityDecay={0.1} | |
| enableNodeDrag={true} | |
| enableZoomInteraction={true} | |
| backgroundColor="rgba(0,0,0,0)" | |
| linkDirectionalArrowLength={3} | |
| linkDirectionalArrowRelPos={1} | |
| onEngineStop={() => { | |
| if (fgRef.current) { | |
| // Center the graph with proper zoom | |
| setTimeout(() => { | |
| fgRef.current.zoomToFit(400, 80); | |
| }, 100); | |
| } | |
| }} | |
| /> | |
| )} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Node Details Panel */} | |
| <Card className="lg:col-span-1"> | |
| <CardHeader className="pb-3"> | |
| <CardTitle className="text-lg">Node Details</CardTitle> | |
| <CardDescription> | |
| Click on a node to view details | |
| </CardDescription> | |
| </CardHeader> | |
| <CardContent className="pt-0"> | |
| {selectedNode ? ( | |
| <div className="space-y-4"> | |
| <div> | |
| <Badge variant="outline" className="mb-2"> | |
| {selectedNode.type} | |
| </Badge> | |
| <h3 className="font-semibold text-lg">{selectedNode.label}</h3> | |
| </div> | |
| {selectedNode.metadata && ( | |
| <div className="space-y-2 text-sm"> | |
| {selectedNode.type === "document" && ( | |
| <> | |
| {selectedNode.metadata.year && ( | |
| <div> | |
| <span className="font-medium">Year:</span> {selectedNode.metadata.year} | |
| </div> | |
| )} | |
| {selectedNode.metadata.authors && Array.isArray(selectedNode.metadata.authors) && ( | |
| <div> | |
| <span className="font-medium">Authors:</span> | |
| <div className="flex flex-wrap gap-1 mt-1"> | |
| {selectedNode.metadata.authors.map((author: string) => ( | |
| <Badge key={author} variant="secondary" className="text-xs"> | |
| {author} | |
| </Badge> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {selectedNode.metadata.venue && ( | |
| <div> | |
| <span className="font-medium">Venue:</span> {selectedNode.metadata.venue} | |
| </div> | |
| )} | |
| {selectedNode.metadata.sourceType && ( | |
| <div> | |
| <span className="font-medium">Source Type:</span> {selectedNode.metadata.sourceType} | |
| </div> | |
| )} | |
| {selectedNode.metadata.title && selectedNode.metadata.title !== selectedNode.label && ( | |
| <div> | |
| <span className="font-medium">Full Title:</span> | |
| <div className="text-xs text-muted-foreground mt-1"> | |
| {selectedNode.metadata.title} | |
| </div> | |
| </div> | |
| )} | |
| </> | |
| )} | |
| {selectedNode.type === "concept" && ( | |
| <div> | |
| <span className="font-medium">Related Documents:</span> {selectedNode.metadata.relatedDocuments} | |
| </div> | |
| )} | |
| {selectedNode.type === "author" && ( | |
| <> | |
| {selectedNode.metadata.teamName && ( | |
| <div> | |
| <span className="font-medium">Team:</span> {selectedNode.metadata.teamName} | |
| </div> | |
| )} | |
| {selectedNode.metadata.publicationCount && ( | |
| <div> | |
| <span className="font-medium">Publications:</span> {selectedNode.metadata.publicationCount} | |
| </div> | |
| )} | |
| {selectedNode.metadata.publications && ( | |
| <div> | |
| <span className="font-medium">Publications:</span> {selectedNode.metadata.publications} | |
| </div> | |
| )} | |
| </> | |
| )} | |
| {selectedNode.type === "topic" && ( | |
| <div> | |
| <span className="font-medium">Documents:</span> {selectedNode.metadata.documentCount} | |
| </div> | |
| )} | |
| {selectedNode.type === "query" && ( | |
| <div> | |
| <span className="font-medium">Search Text:</span> {selectedNode.metadata.searchText} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* Connected Nodes */} | |
| <div> | |
| <h4 className="font-medium mb-2">Connected Nodes</h4> | |
| <div className="space-y-1 max-h-32 overflow-y-auto"> | |
| {graphData.links | |
| .filter(link => { | |
| // Handle both string and object references for source/target | |
| const sourceId = typeof link.source === 'string' ? link.source : (link.source as GraphNode).id; | |
| const targetId = typeof link.target === 'string' ? link.target : (link.target as GraphNode).id; | |
| return sourceId === selectedNode.id || targetId === selectedNode.id; | |
| }) | |
| .map((link, idx) => { | |
| const sourceId = typeof link.source === 'string' ? link.source : (link.source as GraphNode).id; | |
| const targetId = typeof link.target === 'string' ? link.target : (link.target as GraphNode).id; | |
| const connectedNodeId = sourceId === selectedNode.id ? targetId : sourceId; | |
| const connectedNode = graphData.nodes.find(n => n.id === connectedNodeId); | |
| return connectedNode ? ( | |
| <div key={idx} className="text-xs p-2 bg-muted rounded cursor-pointer hover:bg-muted/80" | |
| onClick={() => setSelectedNode(connectedNode)}> | |
| <div className="font-medium">{connectedNode.label}</div> | |
| <div className="text-muted-foreground"> | |
| {link.relationship?.replace(/_/g, ' ') || 'related'} | |
| </div> | |
| </div> | |
| ) : null; | |
| })} | |
| {graphData.links.filter(link => { | |
| const sourceId = typeof link.source === 'string' ? link.source : (link.source as GraphNode).id; | |
| const targetId = typeof link.target === 'string' ? link.target : (link.target as GraphNode).id; | |
| return sourceId === selectedNode.id || targetId === selectedNode.id; | |
| }).length === 0 && ( | |
| <div className="text-xs text-muted-foreground p-2"> | |
| No connected nodes found | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="text-center text-muted-foreground py-8"> | |
| <GitBranch className="h-12 w-12 mx-auto mb-4 opacity-50" /> | |
| <p>Select a node to view its details and connections</p> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| </div> | |
| {/* Insights Panel */} | |
| <Card> | |
| <CardHeader> | |
| <CardTitle className="flex items-center gap-2"> | |
| <Zap className="h-5 w-5" /> | |
| Graph Insights | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> | |
| <div className="text-center"> | |
| <div className="text-2xl font-bold text-blue-600"> | |
| {stats.totalDocuments || graphData.nodes.filter(n => n.type === "document").length} | |
| </div> | |
| <div className="text-sm text-muted-foreground">Knowledge Sources</div> | |
| </div> | |
| <div className="text-center"> | |
| <div className="text-2xl font-bold text-emerald-600"> | |
| {stats.totalConcepts || graphData.nodes.filter(n => n.type === "concept").length} | |
| </div> | |
| <div className="text-sm text-muted-foreground">Technical Concepts</div> | |
| </div> | |
| <div className="text-center"> | |
| <div className="text-2xl font-bold text-amber-600"> | |
| {stats.totalResearchTeams || graphData.nodes.filter(n => n.type === "author").length} | |
| </div> | |
| <div className="text-sm text-muted-foreground">Technology Teams</div> | |
| </div> | |
| <div className="text-center"> | |
| <div className="text-2xl font-bold text-violet-600"> | |
| {stats.totalSourceTypes || graphData.nodes.filter(n => n.type === "topic").length} | |
| </div> | |
| <div className="text-sm text-muted-foreground">Technology Areas</div> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| ); | |
| } |