Spaces:
Running
Running
| import React, { | |
| useEffect, | |
| useRef, | |
| useCallback, | |
| useState, | |
| useMemo, | |
| } from "react"; | |
| import cytoscape, { Core } from "cytoscape"; | |
| import cola from "cytoscape-cola"; | |
| import { UniversalGraphData } from "@/types"; | |
| // Register the cola extension | |
| cytoscape.use(cola); | |
| // Global error handler to catch cytoscape errors | |
| const originalConsoleError = console.error; | |
| console.error = (...args) => { | |
| const message = args.join(" "); | |
| if ( | |
| message.includes("Cannot read properties of null") && | |
| (message.includes("notify") || message.includes("isHeadless")) | |
| ) { | |
| // Suppress cytoscape null reference errors | |
| console.warn("Cytoscape error suppressed:", ...args); | |
| return; | |
| } | |
| originalConsoleError(...args); | |
| }; | |
| interface SimpleGraphVisualizerProps { | |
| data: UniversalGraphData; | |
| width?: number; | |
| height?: number; | |
| className?: string; | |
| } | |
| // Inner component that can be completely unmounted | |
| const CytoscapeRenderer: React.FC<{ | |
| data: UniversalGraphData; | |
| width: number; | |
| height: number; | |
| className: string; | |
| instanceId: string; | |
| }> = ({ data, width, height, className, instanceId }) => { | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const cyRef = useRef<Core | null>(null); | |
| const layoutRef = useRef<any>(null); | |
| const isMountedRef = useRef(true); | |
| useEffect(() => { | |
| isMountedRef.current = true; | |
| if (!containerRef.current || !data.nodes.length) return; | |
| // Convert data to Cytoscape format using data attributes | |
| const elements = [ | |
| ...data.nodes.map((node) => { | |
| const label = node.label || node.name || node.id; | |
| const labelLength = label.length; | |
| const sizeCategory = | |
| labelLength <= 10 ? "small" : labelLength <= 20 ? "medium" : "large"; | |
| return { | |
| data: { | |
| id: `${instanceId}-${node.id}`, | |
| label: label, | |
| type: node.type || "default", | |
| sizeCategory: sizeCategory, | |
| color: node.color || null, | |
| }, | |
| }; | |
| }), | |
| ...data.links.map((link) => ({ | |
| data: { | |
| id: `${instanceId}-${link.id}`, | |
| source: `${instanceId}-${ | |
| typeof link.source === "string" ? link.source : link.source.id | |
| }`, | |
| target: `${instanceId}-${ | |
| typeof link.target === "string" ? link.target : link.target.id | |
| }`, | |
| label: link.label || link.type || "", | |
| color: link.color || null, | |
| }, | |
| })), | |
| ]; | |
| try { | |
| // Create Cytoscape instance with error handling | |
| cyRef.current = cytoscape({ | |
| container: containerRef.current, | |
| elements, | |
| style: [ | |
| { | |
| selector: "node", | |
| style: { | |
| label: "data(label)", | |
| "text-valign": "center", | |
| "text-halign": "center", | |
| "background-color": "#4f46e5", | |
| color: "#ffffff", | |
| "text-outline-width": 1, | |
| "text-outline-color": "#1e293b", | |
| "font-size": "16px", | |
| "font-weight": 600, | |
| "font-family": "system-ui, -apple-system, sans-serif", | |
| width: 50, | |
| height: 50, | |
| "border-width": 2, | |
| "border-color": "#1e293b", | |
| "text-wrap": "wrap", | |
| "text-max-width": "120px", | |
| }, | |
| }, | |
| { | |
| selector: "node[sizeCategory='small']", | |
| style: { width: 50, height: 50 }, | |
| }, | |
| { | |
| selector: "node[sizeCategory='medium']", | |
| style: { width: 65, height: 65 }, | |
| }, | |
| { | |
| selector: "node[sizeCategory='large']", | |
| style: { width: 80, height: 80 }, | |
| }, | |
| { | |
| selector: "edge", | |
| style: { | |
| width: 3, | |
| "line-color": "#64748b", | |
| "target-arrow-color": "#64748b", | |
| "target-arrow-shape": "triangle", | |
| "curve-style": "bezier", | |
| label: "data(label)", | |
| "font-size": "12px", | |
| "font-family": "system-ui, -apple-system, sans-serif", | |
| color: "#334155", | |
| "text-background-color": "#ffffff", | |
| "text-background-opacity": 0.9, | |
| "text-background-padding": "4px", | |
| "text-border-width": 1, | |
| "text-border-color": "#e2e8f0", | |
| }, | |
| }, | |
| { | |
| selector: "node:selected", | |
| style: { | |
| "border-width": 4, | |
| "border-color": "#0066cc", | |
| "background-color": "#1d4ed8", | |
| }, | |
| }, | |
| { | |
| selector: "edge:selected", | |
| style: { | |
| width: 5, | |
| "line-color": "#0066cc", | |
| "target-arrow-color": "#0066cc", | |
| }, | |
| }, | |
| // Highlighting styles | |
| { | |
| selector: "node[color='#22c55e']", | |
| style: { | |
| "background-color": "#22c55e", | |
| "border-color": "#16a34a", | |
| color: "#ffffff", | |
| }, | |
| }, | |
| { | |
| selector: "node[color='#3b82f6']", | |
| style: { | |
| "background-color": "#3b82f6", | |
| "border-color": "#2563eb", | |
| color: "#ffffff", | |
| }, | |
| }, | |
| { | |
| selector: "node[color='#94a3b8']", | |
| style: { | |
| "background-color": "#94a3b8", | |
| "border-color": "#64748b", | |
| color: "#ffffff", | |
| }, | |
| }, | |
| { | |
| selector: "node[color='#f59e0b']", | |
| style: { | |
| "background-color": "#f59e0b", | |
| "border-color": "#d97706", | |
| color: "#ffffff", | |
| }, | |
| }, | |
| { | |
| selector: "edge[color='#22c55e']", | |
| style: { | |
| "line-color": "#22c55e", | |
| "target-arrow-color": "#22c55e", | |
| }, | |
| }, | |
| { | |
| selector: "edge[color='#3b82f6']", | |
| style: { | |
| "line-color": "#3b82f6", | |
| "target-arrow-color": "#3b82f6", | |
| }, | |
| }, | |
| { | |
| selector: "edge[color='#94a3b8']", | |
| style: { | |
| "line-color": "#94a3b8", | |
| "target-arrow-color": "#94a3b8", | |
| }, | |
| }, | |
| { | |
| selector: "edge[color='#f59e0b']", | |
| style: { | |
| "line-color": "#f59e0b", | |
| "target-arrow-color": "#f59e0b", | |
| }, | |
| }, | |
| { | |
| selector: "edge[color='#94a3b8']", | |
| style: { | |
| "line-color": "#94a3b8", | |
| "target-arrow-color": "#94a3b8", | |
| }, | |
| }, | |
| ], | |
| layout: { | |
| name: "cose", // Use cose layout instead of cola for better stability | |
| animate: false, // Disable animation to prevent async issues | |
| fit: true, | |
| padding: 40, | |
| randomize: false, | |
| componentSpacing: 100, | |
| nodeRepulsion: () => 400000, | |
| nodeOverlap: 20, | |
| idealEdgeLength: () => 150, | |
| edgeElasticity: () => 100, | |
| nestingFactor: 5, | |
| gravity: 80, | |
| numIter: 1000, | |
| initialTemp: 200, | |
| coolingFactor: 0.95, | |
| minTemp: 1.0, | |
| }, | |
| minZoom: 0.2, | |
| maxZoom: 3, | |
| }); | |
| // Store layout reference for cleanup | |
| layoutRef.current = cyRef.current.layout({ | |
| name: "cose", | |
| animate: false, | |
| fit: true, | |
| padding: 40, | |
| }); | |
| // Run the layout | |
| if (isMountedRef.current) { | |
| layoutRef.current.run(); | |
| } | |
| // Fit the graph after a short delay | |
| setTimeout(() => { | |
| if (isMountedRef.current && cyRef.current) { | |
| cyRef.current.fit(); | |
| } | |
| }, 100); | |
| // Add error handling for cytoscape events | |
| cyRef.current.on("*", (event) => { | |
| try { | |
| // Let cytoscape handle the event normally | |
| } catch (error) { | |
| console.warn("Cytoscape event error caught and suppressed:", error); | |
| event.stopPropagation(); | |
| } | |
| }); | |
| } catch (error) { | |
| console.error("Error creating cytoscape instance:", error); | |
| cyRef.current = null; | |
| layoutRef.current = null; | |
| } | |
| // Cleanup function | |
| return () => { | |
| isMountedRef.current = false; | |
| // Stop layout first | |
| if (layoutRef.current) { | |
| try { | |
| layoutRef.current.stop(); | |
| layoutRef.current = null; | |
| } catch (error) { | |
| console.warn("Error stopping layout:", error); | |
| layoutRef.current = null; | |
| } | |
| } | |
| if (cyRef.current) { | |
| try { | |
| const cy = cyRef.current; | |
| // Stop all layouts first | |
| cy.stop(); | |
| // Disable interactions | |
| cy.userPanningEnabled(false); | |
| cy.userZoomingEnabled(false); | |
| cy.boxSelectionEnabled(false); | |
| cy.autoungrabify(true); | |
| cy.autolock(true); | |
| // Remove listeners | |
| cy.removeAllListeners(); | |
| // Remove elements | |
| cy.elements().remove(); | |
| // Destroy with a small delay to let any pending operations complete | |
| setTimeout(() => { | |
| try { | |
| if (cyRef.current === cy) { | |
| cy.destroy(); | |
| cyRef.current = null; | |
| } | |
| } catch (destroyError) { | |
| console.warn("Final destroy error:", destroyError); | |
| cyRef.current = null; | |
| } | |
| }, 50); | |
| } catch (error) { | |
| console.warn("Error during cytoscape cleanup:", error); | |
| cyRef.current = null; | |
| } | |
| } | |
| }; | |
| }, [data, instanceId, width, height]); | |
| return ( | |
| <div | |
| ref={containerRef} | |
| className={`w-full h-full ${className}`} | |
| style={{ | |
| width: width, | |
| height: height, | |
| minHeight: "300px", | |
| border: "1px solid #e2e8f0", | |
| borderRadius: "8px", | |
| backgroundColor: "#fafafa", | |
| }} | |
| /> | |
| ); | |
| }; | |
| // Error boundary component | |
| class CytoscapeErrorBoundary extends React.Component< | |
| { children: React.ReactNode; onError?: () => void }, | |
| { hasError: boolean } | |
| > { | |
| constructor(props: { children: React.ReactNode; onError?: () => void }) { | |
| super(props); | |
| this.state = { hasError: false }; | |
| } | |
| static getDerivedStateFromError() { | |
| return { hasError: true }; | |
| } | |
| componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { | |
| console.warn("Cytoscape error caught by boundary:", error, errorInfo); | |
| this.props.onError?.(); | |
| } | |
| render() { | |
| if (this.state.hasError) { | |
| return ( | |
| <div | |
| className="flex items-center justify-center text-muted-foreground bg-muted/20 rounded-lg border-2 border-dashed border-muted" | |
| style={{ minHeight: "300px" }} | |
| > | |
| Graph visualization temporarily unavailable | |
| </div> | |
| ); | |
| } | |
| return this.props.children; | |
| } | |
| } | |
| export const SimpleGraphVisualizer: React.FC<SimpleGraphVisualizerProps> = ({ | |
| data, | |
| width = 400, | |
| height = 500, | |
| className = "", | |
| }) => { | |
| const [renderKey, setRenderKey] = useState(0); | |
| const instanceId = useMemo( | |
| () => `cytoscape-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, | |
| [renderKey] | |
| ); | |
| const handleError = useCallback(() => { | |
| // Force complete remount on error | |
| setTimeout(() => { | |
| setRenderKey((prev) => prev + 1); | |
| }, 100); | |
| }, []); | |
| // Force remount when data changes significantly | |
| useEffect(() => { | |
| setRenderKey((prev) => prev + 1); | |
| }, [data]); | |
| return ( | |
| <CytoscapeErrorBoundary onError={handleError}> | |
| <CytoscapeRenderer | |
| key={renderKey} | |
| data={data} | |
| width={width} | |
| height={height} | |
| className={className} | |
| instanceId={instanceId} | |
| /> | |
| </CytoscapeErrorBoundary> | |
| ); | |
| }; | |