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 = ({ data, config, onBack, title = "Graph Visualization", subtitle, className = "", children, }) => { const containerRef = useRef(null); const cytoscapeRef = useRef(null); // State management const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [interactionState, setInteractionState] = useState({ selectedElement: null, selectedElementType: null, hoveredElement: null, searchTerm: "", highlightedElements: new Set(), }); // Graph statistics const [stats, setStats] = useState({ nodeCount: 0, linkCount: 0, nodeTypes: {}, linkTypes: {}, averageDegree: 0, maxDegree: 0, connectedComponents: 1, }); // Calculate graph statistics const calculateStats = useCallback( (graphData: UniversalGraphData): GraphStats => { const nodeTypes: Record = {}; const linkTypes: Record = {}; const nodeDegrees: Record = {}; // 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 (

Loading visualization...

); } if (error) { return (
⚠️

Visualization Error

{error}

); } return (
{/* Header */}
{onBack && ( )}

{title}

{subtitle && (

{subtitle}

)}
{/* Toolbar */} {config.showToolbar && (
{config.enableSearch && (
handleSearch(e.target.value)} className="pl-9 w-48" />
)}
)}
{/* Main content */}
{/* Graph container */}
{/* Cytoscape container - no SVG needed */}
{/* Sidebar */} {config.showSidebar && (
{/* Statistics */} {config.showStats && ( Graph Overview
Nodes: {stats.nodeCount}
Links: {stats.linkCount}
Avg Degree: {stats.averageDegree}
)} {/* Node Types */} {Object.keys(stats.nodeTypes).length > 0 && ( Node Types
{Object.entries(stats.nodeTypes).map(([type, count]) => (
{type} {count}
))}
)} {/* Selected Element Info */} {interactionState.selectedElement && ( {interactionState.selectedElementType === "node" ? "Node" : "Link"}{" "} Details
ID:

{interactionState.selectedElement.id}

{/* Show name only for nodes or links that have name/label */} {interactionState.selectedElementType === "node" && (interactionState.selectedElement as UniversalNode) .name && (
Name:

{ ( interactionState.selectedElement as UniversalNode ).name }

)} {interactionState.selectedElement.type && (
Type: {interactionState.selectedElement.type}
)} {/* Link-specific info */} {interactionState.selectedElementType === "link" && ( <>
Source:

{(() => { const link = interactionState.selectedElement as UniversalLink; return typeof link.source === "string" ? link.source : link.source.id; })()}

Target:

{(() => { const link = interactionState.selectedElement as UniversalLink; return typeof link.target === "string" ? link.target : link.target.id; })()}

)}
)} {/* Custom children content */} {children}
)}
); };