import React, { useState, useEffect, useCallback, useMemo, useRef, } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Eye, EyeOff, Split, Layers } from "lucide-react"; import { LoadingSpinner } from "@/components/shared/LoadingSpinner"; import { api } from "@/lib/api"; import { AvailableGraph, GraphComparisonResults, UniversalGraphData, GraphDetailsResponse, } from "@/types"; import { getGraphDataAdapter } from "@/lib/graph-data-adapters"; import { CytoscapeGraphCore } from "@/lib/cytoscape-graph-core"; import { createKnowledgeGraphConfig } from "@/lib/graph-config-factory"; import { GraphSelectionCallbacks } from "@/types/graph-visualization"; const truncateGraphName = ( name: string, maxLength: number = 30 ): string => { if (!name) return ""; if (name.length <= maxLength) return name; const start = name.substring(0, Math.floor(maxLength * 0.6)); const end = name.substring(name.length - Math.floor(maxLength * 0.3)); return `${start}...${end}`; }; // Prefer human-friendly system name where available const getGraphDisplayName = (graph?: AvailableGraph | null) => { if (!graph) return ""; return graph.system_name && graph.system_name.trim().length > 0 ? graph.system_name : graph.filename; }; interface VisualComparisonProps { graph1: AvailableGraph; graph2: AvailableGraph; comparisonResults?: GraphComparisonResults | null; } interface MergedGraphData extends UniversalGraphData { mergeStats: { totalNodes: number; totalLinks: number; commonNodes: number; commonLinks: number; graph1UniqueNodes: number; graph1UniqueLinks: number; graph2UniqueNodes: number; graph2UniqueLinks: number; }; } // Component wrapper for CytoscapeGraphCore const CytoscapeWrapper: React.FC<{ data: UniversalGraphData; width?: number; height?: number; }> = ({ data, width = 800, height = 600 }) => { const containerRef = useRef(null); const cytoscapeRef = useRef(null); const [isInitialized, setIsInitialized] = useState(false); useEffect(() => { const initializeVisualization = async () => { if (!containerRef.current || !data || data.nodes.length === 0) return; try { // Wait for layout to settle await new Promise((resolve) => setTimeout(resolve, 100)); const config = createKnowledgeGraphConfig({ width, height, showToolbar: false, showSidebar: false, showStats: false, enableSearch: false, enableZoom: true, enablePan: true, enableDrag: true, enableSelection: true, }); const selectionCallbacks: GraphSelectionCallbacks = { onNodeSelect: (node) => { console.log("Node selected:", node); }, onLinkSelect: (link) => { console.log("Link selected:", link); }, onClearSelection: () => { console.log("Selection cleared"); }, }; // Clean up existing instance if (cytoscapeRef.current) { cytoscapeRef.current.destroy(); cytoscapeRef.current = null; } cytoscapeRef.current = new CytoscapeGraphCore( containerRef.current, config, selectionCallbacks ); cytoscapeRef.current.updateGraph(data, true); setIsInitialized(true); } catch (err) { console.error("Error initializing Cytoscape visualization:", err); } }; initializeVisualization(); return () => { if (cytoscapeRef.current) { cytoscapeRef.current.destroy(); cytoscapeRef.current = null; } setIsInitialized(false); }; }, [data, width, height]); return (
{!isInitialized && (
)}
); }; export const VisualComparison: React.FC = ({ graph1, graph2, comparisonResults, }) => { const [graph1Data, setGraph1Data] = useState(null); const [graph2Data, setGraph2Data] = useState(null); const [mergedData, setMergedData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [viewMode, setViewMode] = useState<"side-by-side" | "overlay">( "side-by-side" ); const [highlightMode, setHighlightMode] = useState< "none" | "common" | "unique" >("none"); // Function to merge two graphs for overlay visualization const mergeGraphsForOverlay = useCallback( ( graph1Data: UniversalGraphData, graph2Data: UniversalGraphData ): MergedGraphData => { const entityMap = new Map(); const relationMap = new Map(); // Add graph1 entities graph1Data.nodes.forEach((entity) => { const key = `${entity.type || "unknown"}:${ entity.label || entity.name || entity.id }`.toLowerCase(); if (!entityMap.has(key)) { entityMap.set(key, { ...entity, id: `merged-${entity.id}`, originalId: entity.id, source: "graph1", isCommon: false, graphSource: "graph1", }); } }); // Add graph2 entities and mark common ones graph2Data.nodes.forEach((entity) => { const key = `${entity.type || "unknown"}:${ entity.label || entity.name || entity.id }`.toLowerCase(); if (entityMap.has(key)) { const existing = entityMap.get(key); existing.isCommon = true; existing.source = "common"; existing.graphSource = "common"; } else { entityMap.set(key, { ...entity, id: `merged-${entity.id}`, originalId: entity.id, source: "graph2", isCommon: false, graphSource: "graph2", }); } }); const allEntities = Array.from(entityMap.values()); // Create mapping from original IDs to merged entities const originalToMergedMap = new Map(); allEntities.forEach((entity) => { originalToMergedMap.set(entity.originalId, entity); }); // Add graph1 relations graph1Data.links.forEach((relation) => { const sourceId = typeof relation.source === "string" ? relation.source : relation.source.id; const targetId = typeof relation.target === "string" ? relation.target : relation.target.id; const sourceEntity = originalToMergedMap.get(sourceId); const targetEntity = originalToMergedMap.get(targetId); if (sourceEntity && targetEntity) { const key = `${relation.type || relation.label || "unknown"}:${ sourceEntity.label }:${targetEntity.label}`.toLowerCase(); if (!relationMap.has(key)) { relationMap.set(key, { ...relation, id: `merged-${relation.id}`, originalId: relation.id, source: sourceEntity.id, target: targetEntity.id, graphSource: "graph1", isCommon: false, }); } } }); // Add graph2 relations and mark common ones graph2Data.links.forEach((relation) => { const sourceId = typeof relation.source === "string" ? relation.source : relation.source.id; const targetId = typeof relation.target === "string" ? relation.target : relation.target.id; const sourceEntity = originalToMergedMap.get(sourceId); const targetEntity = originalToMergedMap.get(targetId); if (sourceEntity && targetEntity) { const key = `${relation.type || relation.label || "unknown"}:${ sourceEntity.label }:${targetEntity.label}`.toLowerCase(); if (relationMap.has(key)) { const existing = relationMap.get(key); existing.isCommon = true; existing.graphSource = "common"; } else { relationMap.set(key, { ...relation, id: `merged-${relation.id}`, originalId: relation.id, source: sourceEntity.id, target: targetEntity.id, graphSource: "graph2", isCommon: false, }); } } }); const allRelations = Array.from(relationMap.values()); // Calculate statistics const commonNodes = allEntities.filter((e) => e.isCommon).length; const graph1UniqueNodes = allEntities.filter( (e) => e.graphSource === "graph1" ).length; const graph2UniqueNodes = allEntities.filter( (e) => e.graphSource === "graph2" ).length; const commonLinks = allRelations.filter((r) => r.isCommon).length; const graph1UniqueLinks = allRelations.filter( (r) => r.graphSource === "graph1" ).length; const graph2UniqueLinks = allRelations.filter( (r) => r.graphSource === "graph2" ).length; return { nodes: allEntities, links: allRelations, metadata: { ...graph1Data.metadata, merged: true, graph1Name: getGraphDisplayName(graph1), graph2Name: getGraphDisplayName(graph2), }, mergeStats: { totalNodes: allEntities.length, totalLinks: allRelations.length, commonNodes, commonLinks, graph1UniqueNodes, graph1UniqueLinks, graph2UniqueNodes, graph2UniqueLinks, }, }; }, [graph1.filename, graph2.filename, graph1.system_name, graph2.system_name] ); // Function to apply overlay coloring based on element source const applyOverlayColoring = useCallback( (data: MergedGraphData, mode: string): UniversalGraphData => { return { ...data, nodes: data.nodes.map((node) => ({ ...node, color: getOverlayNodeColor(node, mode), })), links: data.links.map((link) => ({ ...link, color: getOverlayLinkColor(link, mode), })), }; }, [] ); const getOverlayNodeColor = (node: any, mode: string): string => { if (mode === "none") return "#4f46e5"; // Default blue if (node.isCommon) { return mode === "common" ? "#22c55e" : "#94a3b8"; // Green for common, gray when highlighting unique } else { if (mode === "unique") { return node.graphSource === "graph1" ? "#3b82f6" : "#f59e0b"; // Blue for graph1, orange for graph2 } else { return "#94a3b8"; // Gray when highlighting common } } }; const getOverlayLinkColor = (link: any, mode: string): string => { if (mode === "none") return "#64748b"; // Default gray if (link.isCommon) { return mode === "common" ? "#22c55e" : "#94a3b8"; } else { if (mode === "unique") { return link.graphSource === "graph1" ? "#3b82f6" : "#f59e0b"; } else { return "#94a3b8"; } } }; const loadGraphData = useCallback(async () => { try { setIsLoading(true); setError(null); const [data1, data2] = (await Promise.all([ api.graphComparison.getGraphDetails(graph1.id), api.graphComparison.getGraphDetails(graph2.id), ])) as [GraphDetailsResponse, GraphDetailsResponse]; // Convert to UniversalGraphData format using auto-detecting adapters const adapter1 = getGraphDataAdapter(undefined, data1); const adapter2 = getGraphDataAdapter(undefined, data2); const universalData1 = adapter1.adapt(data1); const universalData2 = adapter2.adapt(data2); console.log("VisualComparison: Graph data loaded successfully", { graph1: { nodes: universalData1.nodes.length, links: universalData1.links.length, }, graph2: { nodes: universalData2.nodes.length, links: universalData2.links.length, }, }); setGraph1Data(universalData1); setGraph2Data(universalData2); // Create merged data for overlay const merged = mergeGraphsForOverlay(universalData1, universalData2); setMergedData(merged); console.log("VisualComparison: Merged data created", { totalNodes: merged.mergeStats.totalNodes, totalLinks: merged.mergeStats.totalLinks, commonNodes: merged.mergeStats.commonNodes, commonLinks: merged.mergeStats.commonLinks, }); } catch (err) { setError( err instanceof Error ? err.message : "Failed to load graph data" ); } finally { setIsLoading(false); } }, [graph1.id, graph2.id, mergeGraphsForOverlay]); useEffect(() => { // Add a small delay to ensure the layout has settled before loading data const timer = setTimeout(loadGraphData, 100); return () => clearTimeout(timer); }, [loadGraphData]); // Define getHighlightedData before using it in useMemo hooks const getHighlightedData = useCallback( ( originalData: UniversalGraphData, otherData: UniversalGraphData, mode: string ): UniversalGraphData => { if (mode === "none") return originalData; // Compare nodes by label (normalized) const otherNodeLabels = new Set( otherData.nodes.map((n) => (n.label || n.name || n.id || "").toLowerCase() ) ); // Compare links by normalized source label, link label, and target label const otherLinkKeys = new Set( otherData.links.map((link) => { const sourceId = typeof link.source === "string" ? link.source : link.source.id; const targetId = typeof link.target === "string" ? link.target : link.target.id; // Find the actual node labels for source and target const sourceNode = otherData.nodes.find((n) => n.id === sourceId); const targetNode = otherData.nodes.find((n) => n.id === targetId); const sourceLabel = ( sourceNode?.label || sourceNode?.name || sourceId || "" ).toLowerCase(); const targetLabel = ( targetNode?.label || targetNode?.name || targetId || "" ).toLowerCase(); const linkLabel = (link.label || link.type || "").toLowerCase(); return `${sourceLabel}-${linkLabel}-${targetLabel}`; }) ); return { ...originalData, nodes: originalData.nodes.map((node) => { const nodeLabel = ( node.label || node.name || node.id || "" ).toLowerCase(); const isCommon = otherNodeLabels.has(nodeLabel); return { ...node, color: mode === "common" ? isCommon ? "#22c55e" : "#94a3b8" : isCommon ? "#94a3b8" : "#3b82f6", }; }), links: originalData.links.map((link) => { const sourceId = typeof link.source === "string" ? link.source : link.source.id; const targetId = typeof link.target === "string" ? link.target : link.target.id; // Find the actual node labels for source and target const sourceNode = originalData.nodes.find((n) => n.id === sourceId); const targetNode = originalData.nodes.find((n) => n.id === targetId); const sourceLabel = ( sourceNode?.label || sourceNode?.name || sourceId || "" ).toLowerCase(); const targetLabel = ( targetNode?.label || targetNode?.name || targetId || "" ).toLowerCase(); const linkLabel = (link.label || link.type || "").toLowerCase(); const linkKey = `${sourceLabel}-${linkLabel}-${targetLabel}`; const isCommon = otherLinkKeys.has(linkKey); return { ...link, color: mode === "common" ? isCommon ? "#22c55e" : "#94a3b8" : isCommon ? "#94a3b8" : "#3b82f6", }; }), }; }, [] ); // Memoize highlighted data to prevent unnecessary re-renders const highlightedGraph1Data = useMemo(() => { if (!graph1Data || !graph2Data) return graph1Data; return getHighlightedData(graph1Data, graph2Data, highlightMode); }, [graph1Data, graph2Data, highlightMode, getHighlightedData]); const highlightedGraph2Data = useMemo(() => { if (!graph1Data || !graph2Data) return graph2Data; return getHighlightedData(graph2Data, graph1Data, highlightMode); }, [graph1Data, graph2Data, highlightMode, getHighlightedData]); const overlayData = useMemo(() => { if (!mergedData) return null; return applyOverlayColoring(mergedData, highlightMode); }, [mergedData, highlightMode, applyOverlayColoring]); if (isLoading) { return (
); } if (error) { return (

{error}

); } if (!graph1Data || !graph2Data) { return (

No graph data available

); } return (
{/* Controls */}
View:
Highlight:
{comparisonResults && comparisonResults.overall_metrics && ( {( comparisonResults.overall_metrics.overall_similarity * 100 ).toFixed(1)} % Similar )}
{/* Visualization */}
{viewMode === "side-by-side" ? (
{/* Graph 1 */} {getGraphDisplayName(graph1)}
{graph1Data.nodes.length} nodes {graph1Data.links.length} links
{highlightedGraph1Data && ( )}
{/* Graph 2 */} {getGraphDisplayName(graph2)}
{graph2Data.nodes.length} nodes {graph2Data.links.length} links
{highlightedGraph2Data && ( )}
) : (
Overlay Comparison {mergedData && (
{mergedData.mergeStats.totalNodes} nodes {mergedData.mergeStats.totalLinks} links
)}
{overlayData ? (
) : (
)}
)}
{/* Legend */} {highlightMode !== "none" && (
Legend: {viewMode === "overlay" ? ( // Overlay-specific legend highlightMode === "common" ? ( <>
Common elements
Unique elements
) : ( <>
Graph 1 unique
Graph 2 unique
Common elements
) ) : // Side-by-side legend highlightMode === "common" ? ( <>
Common elements
Unique elements
) : ( <>
Unique elements
Common elements
)}
)}
); };