|
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", |
|
concept: "#10b981", |
|
author: "#f59e0b", |
|
topic: "#8b5cf6", |
|
query: "#ef4444", |
|
}; |
|
|
|
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 { |
|
|
|
generateKnowledgeGraph(); |
|
} |
|
} catch (error) { |
|
console.error('Failed to fetch knowledge graph:', error); |
|
|
|
generateKnowledgeGraph(); |
|
} finally { |
|
setIsLoading(false); |
|
} |
|
}; |
|
|
|
const generateKnowledgeGraph = () => { |
|
|
|
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[] = []; |
|
|
|
|
|
documents.forEach(doc => { |
|
nodes.push({ |
|
id: doc.id, |
|
label: doc.title, |
|
type: "document", |
|
size: 12, |
|
color: NODE_COLORS.document, |
|
metadata: doc |
|
}); |
|
}); |
|
|
|
|
|
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 } |
|
}); |
|
|
|
|
|
relatedDocs.forEach(doc => { |
|
links.push({ |
|
source: doc.id, |
|
target: `concept_${concept}`, |
|
relationship: "contains_concept", |
|
strength: 1, |
|
color: RELATIONSHIP_COLORS.contains_concept |
|
}); |
|
}); |
|
}); |
|
|
|
|
|
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 } |
|
}); |
|
|
|
|
|
authoredDocs.forEach(doc => { |
|
links.push({ |
|
source: `author_${author}`, |
|
target: doc.id, |
|
relationship: "authored_by", |
|
strength: 0.8, |
|
color: RELATIONSHIP_COLORS.authored_by |
|
}); |
|
}); |
|
}); |
|
|
|
|
|
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 } |
|
}); |
|
|
|
|
|
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 |
|
}); |
|
} |
|
}); |
|
}); |
|
|
|
|
|
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 |
|
}); |
|
} |
|
} |
|
}); |
|
}); |
|
|
|
|
|
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) { |
|
|
|
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"; |
|
if (highlightNodes.has(node.id)) return "#4ecdc4"; |
|
return node.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> |
|
|
|
{} |
|
<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> |
|
); |
|
} |