AgentGraph / frontend /src /components /shared /BaseGraphVisualizer.tsx
wu981526092's picture
feat: optimize graph visualization sizes for better UI layout
cc3c5e9
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>
);
};