Spaces:
Running
Running
| import React, { | |
| useRef, | |
| useEffect, | |
| useState, | |
| useCallback, | |
| useMemo, | |
| } from "react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { Separator } from "@/components/ui/separator"; | |
| import { | |
| ArrowLeft, | |
| Search, | |
| ZoomIn, | |
| ZoomOut, | |
| RotateCcw, | |
| Download, | |
| } from "lucide-react"; | |
| import { | |
| UniversalGraphData, | |
| UniversalNode, | |
| UniversalLink, | |
| GraphVisualizationConfig, | |
| GraphSelectionCallbacks, | |
| GraphInteractionState, | |
| GraphStats, | |
| } from "@/types/graph-visualization"; | |
| import { CytoscapeGraphCore } from "@/lib/cytoscape-graph-core"; | |
| interface BaseGraphVisualizerProps { | |
| data: UniversalGraphData; | |
| config: GraphVisualizationConfig; | |
| onBack?: () => void; | |
| title?: string; | |
| subtitle?: string; | |
| className?: string; | |
| children?: React.ReactNode; | |
| } | |
| export const BaseGraphVisualizer: React.FC<BaseGraphVisualizerProps> = ({ | |
| data, | |
| config, | |
| onBack, | |
| title = "Graph Visualization", | |
| subtitle, | |
| className = "", | |
| children, | |
| }) => { | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const cytoscapeRef = useRef<CytoscapeGraphCore | null>(null); | |
| // State management | |
| const [loading, setLoading] = useState(true); | |
| const [error, setError] = useState<string | null>(null); | |
| const [interactionState, setInteractionState] = | |
| useState<GraphInteractionState>({ | |
| selectedElement: null, | |
| selectedElementType: null, | |
| hoveredElement: null, | |
| searchTerm: "", | |
| highlightedElements: new Set(), | |
| }); | |
| // Graph statistics | |
| const [stats, setStats] = useState<GraphStats>({ | |
| nodeCount: 0, | |
| linkCount: 0, | |
| nodeTypes: {}, | |
| linkTypes: {}, | |
| averageDegree: 0, | |
| maxDegree: 0, | |
| connectedComponents: 1, | |
| }); | |
| // Calculate graph statistics | |
| const calculateStats = useCallback( | |
| (graphData: UniversalGraphData): GraphStats => { | |
| const nodeTypes: Record<string, number> = {}; | |
| const linkTypes: Record<string, number> = {}; | |
| const nodeDegrees: Record<string, number> = {}; | |
| // Count node types | |
| graphData.nodes.forEach((node) => { | |
| const type = node.type || "Unknown"; | |
| nodeTypes[type] = (nodeTypes[type] || 0) + 1; | |
| nodeDegrees[node.id] = 0; | |
| }); | |
| // Count link types and node degrees | |
| graphData.links.forEach((link) => { | |
| const type = link.type || "Unknown"; | |
| linkTypes[type] = (linkTypes[type] || 0) + 1; | |
| const sourceId = | |
| typeof link.source === "string" ? link.source : link.source.id; | |
| const targetId = | |
| typeof link.target === "string" ? link.target : link.target.id; | |
| nodeDegrees[sourceId] = (nodeDegrees[sourceId] || 0) + 1; | |
| nodeDegrees[targetId] = (nodeDegrees[targetId] || 0) + 1; | |
| }); | |
| const degrees = Object.values(nodeDegrees); | |
| const averageDegree = | |
| degrees.length > 0 | |
| ? degrees.reduce((a, b) => a + b, 0) / degrees.length | |
| : 0; | |
| const maxDegree = degrees.length > 0 ? Math.max(...degrees) : 0; | |
| return { | |
| nodeCount: graphData.nodes.length, | |
| linkCount: graphData.links.length, | |
| nodeTypes, | |
| linkTypes, | |
| averageDegree: Math.round(averageDegree * 100) / 100, | |
| maxDegree, | |
| connectedComponents: 1, // Simplified for now | |
| }; | |
| }, | |
| [] | |
| ); | |
| // Selection callbacks - wrapped in useMemo to prevent recreation on every render | |
| const selectionCallbacks = useMemo( | |
| (): GraphSelectionCallbacks => ({ | |
| onNodeSelect: (node: UniversalNode) => { | |
| setInteractionState((prev) => ({ | |
| ...prev, | |
| selectedElement: node, | |
| selectedElementType: "node", | |
| })); | |
| }, | |
| onLinkSelect: (link: UniversalLink) => { | |
| setInteractionState((prev) => ({ | |
| ...prev, | |
| selectedElement: link, | |
| selectedElementType: "link", | |
| })); | |
| }, | |
| onClearSelection: () => { | |
| setInteractionState((prev) => ({ | |
| ...prev, | |
| selectedElement: null, | |
| selectedElementType: null, | |
| })); | |
| }, | |
| }), | |
| [] | |
| ); | |
| // Initialize Cytoscape visualization | |
| useEffect(() => { | |
| console.log("BaseGraphVisualizer: useEffect triggered", { | |
| containerRef: !!containerRef.current, | |
| dataNodes: data.nodes.length, | |
| dataLinks: data.links.length, | |
| }); | |
| const initializeVisualization = async () => { | |
| try { | |
| if (!containerRef.current) { | |
| console.log( | |
| "BaseGraphVisualizer: Container ref not ready during initialization" | |
| ); | |
| return; | |
| } | |
| // Wait for layout to settle | |
| await new Promise((resolve) => setTimeout(resolve, 200)); | |
| const container = containerRef.current; | |
| let width = container.clientWidth || config.width; | |
| let height = container.clientHeight || config.height; | |
| // If container has zero dimensions, try to get parent dimensions | |
| if (width === 0 || height === 0) { | |
| const parent = container.parentElement; | |
| if (parent) { | |
| width = parent.clientWidth || config.width; | |
| height = parent.clientHeight || config.height; | |
| console.log("BaseGraphVisualizer: Using parent dimensions", { | |
| parentWidth: width, | |
| parentHeight: height, | |
| }); | |
| } | |
| } | |
| const finalConfig = { | |
| ...config, | |
| width: Math.max(width, 350), | |
| height: Math.max(height, 350), | |
| }; | |
| console.log( | |
| "BaseGraphVisualizer: Initializing with config", | |
| finalConfig | |
| ); | |
| cytoscapeRef.current = new CytoscapeGraphCore( | |
| containerRef.current, | |
| finalConfig, | |
| selectionCallbacks | |
| ); | |
| // Load data | |
| console.log("BaseGraphVisualizer: Loading data", { | |
| nodes: data.nodes.length, | |
| links: data.links.length, | |
| }); | |
| cytoscapeRef.current.updateGraph(data, true); | |
| // Calculate and set statistics | |
| setStats(calculateStats(data)); | |
| console.log( | |
| "BaseGraphVisualizer: Initialization complete, setting loading to false" | |
| ); | |
| setLoading(false); | |
| } catch (err) { | |
| console.error("BaseGraphVisualizer: Error initializing:", err); | |
| setError( | |
| `Failed to initialize visualization: ${ | |
| err instanceof Error ? err.message : String(err) | |
| }` | |
| ); | |
| setLoading(false); | |
| } | |
| }; | |
| // Function to check if refs are ready and initialize | |
| const checkAndInitialize = () => { | |
| if (!containerRef.current) { | |
| console.log("BaseGraphVisualizer: Missing container ref, retrying..."); | |
| return false; | |
| } | |
| return true; | |
| }; | |
| // If refs not ready immediately, set up a retry mechanism | |
| if (!checkAndInitialize()) { | |
| let retryCount = 0; | |
| const maxRetries = 20; | |
| const retryInterval = setInterval(() => { | |
| retryCount++; | |
| console.log( | |
| `BaseGraphVisualizer: Retry attempt ${retryCount}/${maxRetries}` | |
| ); | |
| if (checkAndInitialize()) { | |
| clearInterval(retryInterval); | |
| initializeVisualization(); | |
| } else if (retryCount >= maxRetries) { | |
| clearInterval(retryInterval); | |
| console.error("BaseGraphVisualizer: Max retries reached, giving up"); | |
| setError( | |
| "Failed to initialize visualization: DOM elements not ready" | |
| ); | |
| setLoading(false); | |
| } | |
| }, 200); | |
| return () => { | |
| clearInterval(retryInterval); | |
| if (cytoscapeRef.current) { | |
| cytoscapeRef.current.destroy(); | |
| cytoscapeRef.current = null; | |
| } | |
| }; | |
| } | |
| // If refs are ready, initialize immediately | |
| const timeoutId = setTimeout(initializeVisualization, 100); | |
| // Cleanup | |
| return () => { | |
| clearTimeout(timeoutId); | |
| if (cytoscapeRef.current) { | |
| cytoscapeRef.current.destroy(); | |
| cytoscapeRef.current = null; | |
| } | |
| }; | |
| }, [data, config, calculateStats, selectionCallbacks]); | |
| // Handle search | |
| const handleSearch = useCallback( | |
| (searchTerm: string) => { | |
| setInteractionState((prev) => ({ | |
| ...prev, | |
| searchTerm, | |
| highlightedElements: new Set( | |
| data.nodes | |
| .filter( | |
| (node) => | |
| node.name?.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| node.label?.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| node.id.toLowerCase().includes(searchTerm.toLowerCase()) | |
| ) | |
| .map((node) => node.id) | |
| ), | |
| })); | |
| }, | |
| [data.nodes] | |
| ); | |
| // Control handlers | |
| const handleZoomIn = () => cytoscapeRef.current?.zoomIn(); | |
| const handleZoomOut = () => cytoscapeRef.current?.zoomOut(); | |
| const handleResetZoom = () => cytoscapeRef.current?.resetZoom(); | |
| const handleDownload = () => { | |
| if (!cytoscapeRef.current) return; | |
| // For Cytoscape, we'll export as PNG instead of SVG | |
| const cy = cytoscapeRef.current.getCytoscape(); | |
| if (!cy) return; | |
| const pngData = cy.png({ scale: 2, full: true }); | |
| const downloadLink = document.createElement("a"); | |
| downloadLink.href = pngData; | |
| downloadLink.download = `graph-${Date.now()}.png`; | |
| document.body.appendChild(downloadLink); | |
| downloadLink.click(); | |
| document.body.removeChild(downloadLink); | |
| }; | |
| // Handle window resize | |
| useEffect(() => { | |
| const handleResize = () => { | |
| if (cytoscapeRef.current && containerRef.current) { | |
| const container = containerRef.current; | |
| const width = container.clientWidth; | |
| const height = container.clientHeight; | |
| cytoscapeRef.current.resize(width, height); | |
| } | |
| }; | |
| window.addEventListener("resize", handleResize); | |
| return () => window.removeEventListener("resize", handleResize); | |
| }, []); | |
| if (loading) { | |
| return ( | |
| <div className="flex items-center justify-center h-96"> | |
| <div className="text-center"> | |
| <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div> | |
| <p className="text-muted-foreground">Loading visualization...</p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| if (error) { | |
| return ( | |
| <div className="flex items-center justify-center h-96"> | |
| <div className="text-center"> | |
| <div className="text-red-500 mb-4">⚠️</div> | |
| <p className="text-red-600 font-medium">Visualization Error</p> | |
| <p className="text-muted-foreground text-sm mt-2">{error}</p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className={`flex flex-col h-full ${className}`}> | |
| {/* Header */} | |
| <div className="flex items-center justify-between p-4 border-b"> | |
| <div className="flex items-center space-x-4"> | |
| {onBack && ( | |
| <Button variant="ghost" size="sm" onClick={onBack}> | |
| <ArrowLeft className="h-4 w-4 mr-2" /> | |
| Back | |
| </Button> | |
| )} | |
| <div> | |
| <h1 className="text-xl font-semibold">{title}</h1> | |
| {subtitle && ( | |
| <p className="text-sm text-muted-foreground">{subtitle}</p> | |
| )} | |
| </div> | |
| </div> | |
| {/* Toolbar */} | |
| {config.showToolbar && ( | |
| <div className="flex items-center space-x-2"> | |
| {config.enableSearch && ( | |
| <div className="relative"> | |
| <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> | |
| <Input | |
| placeholder="Search nodes..." | |
| value={interactionState.searchTerm} | |
| onChange={(e) => handleSearch(e.target.value)} | |
| className="pl-9 w-48" | |
| /> | |
| </div> | |
| )} | |
| <Button variant="outline" size="sm" onClick={handleZoomIn}> | |
| <ZoomIn className="h-4 w-4" /> | |
| </Button> | |
| <Button variant="outline" size="sm" onClick={handleZoomOut}> | |
| <ZoomOut className="h-4 w-4" /> | |
| </Button> | |
| <Button variant="outline" size="sm" onClick={handleResetZoom}> | |
| <RotateCcw className="h-4 w-4" /> | |
| </Button> | |
| <Button variant="outline" size="sm" onClick={handleDownload}> | |
| <Download className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| {/* Main content */} | |
| <div className="flex flex-1 overflow-hidden"> | |
| {/* Graph container */} | |
| <div className="flex-1 relative"> | |
| <div | |
| ref={containerRef} | |
| className="w-full h-full" | |
| style={{ minHeight: "400px" }} | |
| > | |
| {/* Cytoscape container - no SVG needed */} | |
| </div> | |
| </div> | |
| {/* Sidebar */} | |
| {config.showSidebar && ( | |
| <div className="w-96 border-l bg-gray-50 overflow-y-auto"> | |
| <div className="p-4 space-y-4"> | |
| {/* Statistics */} | |
| {config.showStats && ( | |
| <Card> | |
| <CardHeader> | |
| <CardTitle className="text-sm">Graph Overview</CardTitle> | |
| </CardHeader> | |
| <CardContent className="space-y-2"> | |
| <div className="flex justify-between"> | |
| <span className="text-sm text-muted-foreground"> | |
| Nodes: | |
| </span> | |
| <Badge variant="secondary">{stats.nodeCount}</Badge> | |
| </div> | |
| <div className="flex justify-between"> | |
| <span className="text-sm text-muted-foreground"> | |
| Links: | |
| </span> | |
| <Badge variant="secondary">{stats.linkCount}</Badge> | |
| </div> | |
| <div className="flex justify-between"> | |
| <span className="text-sm text-muted-foreground"> | |
| Avg Degree: | |
| </span> | |
| <Badge variant="outline">{stats.averageDegree}</Badge> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| )} | |
| {/* Node Types */} | |
| {Object.keys(stats.nodeTypes).length > 0 && ( | |
| <Card> | |
| <CardHeader> | |
| <CardTitle className="text-sm">Node Types</CardTitle> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="space-y-2"> | |
| {Object.entries(stats.nodeTypes).map(([type, count]) => ( | |
| <div | |
| key={type} | |
| className="flex justify-between items-center" | |
| > | |
| <span className="text-sm">{type}</span> | |
| <Badge variant="outline">{count}</Badge> | |
| </div> | |
| ))} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| )} | |
| {/* Selected Element Info */} | |
| {interactionState.selectedElement && ( | |
| <Card> | |
| <CardHeader> | |
| <CardTitle className="text-sm"> | |
| {interactionState.selectedElementType === "node" | |
| ? "Node" | |
| : "Link"}{" "} | |
| Details | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="space-y-2"> | |
| <div> | |
| <span className="text-sm font-medium">ID:</span> | |
| <p className="text-sm text-muted-foreground break-all"> | |
| {interactionState.selectedElement.id} | |
| </p> | |
| </div> | |
| {/* Show name only for nodes or links that have name/label */} | |
| {interactionState.selectedElementType === "node" && | |
| (interactionState.selectedElement as UniversalNode) | |
| .name && ( | |
| <div> | |
| <span className="text-sm font-medium">Name:</span> | |
| <p className="text-sm text-muted-foreground"> | |
| { | |
| ( | |
| interactionState.selectedElement as UniversalNode | |
| ).name | |
| } | |
| </p> | |
| </div> | |
| )} | |
| {interactionState.selectedElement.type && ( | |
| <div> | |
| <span className="text-sm font-medium">Type:</span> | |
| <Badge variant="secondary" className="ml-2"> | |
| {interactionState.selectedElement.type} | |
| </Badge> | |
| </div> | |
| )} | |
| {/* Link-specific info */} | |
| {interactionState.selectedElementType === "link" && ( | |
| <> | |
| <Separator /> | |
| <div> | |
| <span className="text-sm font-medium">Source:</span> | |
| <p className="text-sm text-muted-foreground"> | |
| {(() => { | |
| const link = | |
| interactionState.selectedElement as UniversalLink; | |
| return typeof link.source === "string" | |
| ? link.source | |
| : link.source.id; | |
| })()} | |
| </p> | |
| </div> | |
| <div> | |
| <span className="text-sm font-medium">Target:</span> | |
| <p className="text-sm text-muted-foreground"> | |
| {(() => { | |
| const link = | |
| interactionState.selectedElement as UniversalLink; | |
| return typeof link.target === "string" | |
| ? link.target | |
| : link.target.id; | |
| })()} | |
| </p> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| )} | |
| {/* Custom children content */} | |
| {children} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |