Spaces:
Running
Running
| 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<HTMLDivElement>(null); | |
| const cytoscapeRef = useRef<CytoscapeGraphCore | null>(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 ( | |
| <div | |
| ref={containerRef} | |
| className="w-full h-full bg-white border rounded-lg relative overflow-hidden" | |
| style={{ minHeight: `${height}px` }} | |
| > | |
| {!isInitialized && ( | |
| <div className="absolute inset-0 flex items-center justify-center"> | |
| <LoadingSpinner size="sm" /> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export const VisualComparison: React.FC<VisualComparisonProps> = ({ | |
| graph1, | |
| graph2, | |
| comparisonResults, | |
| }) => { | |
| const [graph1Data, setGraph1Data] = useState<UniversalGraphData | null>(null); | |
| const [graph2Data, setGraph2Data] = useState<UniversalGraphData | null>(null); | |
| const [mergedData, setMergedData] = useState<MergedGraphData | null>(null); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [error, setError] = useState<string | null>(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<string, any>(); | |
| const relationMap = new Map<string, any>(); | |
| // 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<string, any>(); | |
| 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 ( | |
| <div className="flex items-center justify-center h-64"> | |
| <LoadingSpinner size="lg" /> | |
| </div> | |
| ); | |
| } | |
| if (error) { | |
| return ( | |
| <Card className="m-4"> | |
| <CardContent className="p-8 text-center"> | |
| <p className="text-destructive">{error}</p> | |
| <Button onClick={loadGraphData} className="mt-4"> | |
| Retry | |
| </Button> | |
| </CardContent> | |
| </Card> | |
| ); | |
| } | |
| if (!graph1Data || !graph2Data) { | |
| return ( | |
| <Card className="m-4"> | |
| <CardContent className="p-8 text-center"> | |
| <p className="text-muted-foreground">No graph data available</p> | |
| </CardContent> | |
| </Card> | |
| ); | |
| } | |
| return ( | |
| <div className="h-full flex flex-col"> | |
| {/* Controls */} | |
| <div className="border-b p-4 bg-background"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-4"> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-sm font-medium">View:</span> | |
| <Button | |
| variant={viewMode === "side-by-side" ? "default" : "outline"} | |
| size="sm" | |
| onClick={() => setViewMode("side-by-side")} | |
| > | |
| <Split className="h-4 w-4 mr-1" /> | |
| Side by Side | |
| </Button> | |
| <Button | |
| variant={viewMode === "overlay" ? "default" : "outline"} | |
| size="sm" | |
| onClick={() => setViewMode("overlay")} | |
| > | |
| <Layers className="h-4 w-4 mr-1" /> | |
| Overlay | |
| </Button> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-sm font-medium">Highlight:</span> | |
| <Button | |
| variant={highlightMode === "none" ? "default" : "outline"} | |
| size="sm" | |
| onClick={() => setHighlightMode("none")} | |
| className="transition-all duration-200" | |
| > | |
| <EyeOff className="h-4 w-4 mr-1" /> | |
| None | |
| </Button> | |
| <Button | |
| variant={highlightMode === "common" ? "default" : "outline"} | |
| size="sm" | |
| onClick={() => setHighlightMode("common")} | |
| className="transition-all duration-200" | |
| > | |
| <Eye className="h-4 w-4 mr-1" /> | |
| Common | |
| </Button> | |
| <Button | |
| variant={highlightMode === "unique" ? "default" : "outline"} | |
| size="sm" | |
| onClick={() => setHighlightMode("unique")} | |
| className="transition-all duration-200" | |
| > | |
| <Eye className="h-4 w-4 mr-1" /> | |
| Unique | |
| </Button> | |
| </div> | |
| </div> | |
| {comparisonResults && comparisonResults.overall_metrics && ( | |
| <Badge variant="secondary"> | |
| {( | |
| comparisonResults.overall_metrics.overall_similarity * 100 | |
| ).toFixed(1)} | |
| % Similar | |
| </Badge> | |
| )} | |
| </div> | |
| </div> | |
| {/* Visualization */} | |
| <div className="flex-1 overflow-hidden"> | |
| {viewMode === "side-by-side" ? ( | |
| <div className="h-full grid grid-cols-2 gap-4 p-4"> | |
| {/* Graph 1 */} | |
| <Card className="h-full"> | |
| <CardHeader className="pb-2"> | |
| <CardTitle className="text-base flex items-start justify-between gap-2"> | |
| <span | |
| title={getGraphDisplayName(graph1)} | |
| className="min-w-0 flex-1 whitespace-normal break-words" | |
| > | |
| {getGraphDisplayName(graph1)} | |
| </span> | |
| <div className="flex gap-1 flex-shrink-0"> | |
| <Badge variant="outline" className="text-xs"> | |
| {graph1Data.nodes.length} nodes | |
| </Badge> | |
| <Badge variant="outline" className="text-xs"> | |
| {graph1Data.links.length} links | |
| </Badge> | |
| </div> | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="h-full p-2"> | |
| <div className="h-full" style={{ minHeight: "650px" }}> | |
| {highlightedGraph1Data && ( | |
| <CytoscapeWrapper | |
| data={highlightedGraph1Data} | |
| width={400} // Adjust width for side-by-side | |
| height={650} | |
| /> | |
| )} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Graph 2 */} | |
| <Card className="h-full"> | |
| <CardHeader className="pb-2"> | |
| <CardTitle className="text-base flex items-start justify-between gap-2"> | |
| <span | |
| title={getGraphDisplayName(graph2)} | |
| className="min-w-0 flex-1 whitespace-normal break-words" | |
| > | |
| {getGraphDisplayName(graph2)} | |
| </span> | |
| <div className="flex gap-1 flex-shrink-0"> | |
| <Badge variant="outline" className="text-xs"> | |
| {graph2Data.nodes.length} nodes | |
| </Badge> | |
| <Badge variant="outline" className="text-xs"> | |
| {graph2Data.links.length} links | |
| </Badge> | |
| </div> | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="h-full p-2"> | |
| <div className="h-full" style={{ minHeight: "650px" }}> | |
| {highlightedGraph2Data && ( | |
| <CytoscapeWrapper | |
| data={highlightedGraph2Data} | |
| width={400} // Adjust width for side-by-side | |
| height={650} | |
| /> | |
| )} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| ) : ( | |
| <div className="h-full p-2"> | |
| <Card className="h-full"> | |
| <CardHeader className="pb-2"> | |
| <CardTitle className="text-base flex items-start justify-between gap-2"> | |
| <span | |
| title={`${getGraphDisplayName(graph1)} + ${getGraphDisplayName(graph2)}`} | |
| className="min-w-0 flex-1 whitespace-normal break-words" | |
| > | |
| Overlay Comparison | |
| </span> | |
| {mergedData && ( | |
| <div className="flex gap-1 flex-shrink-0"> | |
| <Badge variant="outline" className="text-xs"> | |
| {mergedData.mergeStats.totalNodes} nodes | |
| </Badge> | |
| <Badge variant="outline" className="text-xs"> | |
| {mergedData.mergeStats.totalLinks} links | |
| </Badge> | |
| </div> | |
| )} | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="h-full p-1"> | |
| {overlayData ? ( | |
| <div className="h-full" style={{ minHeight: "750px" }}> | |
| <CytoscapeWrapper | |
| data={overlayData} | |
| width={800} // Full width for overlay | |
| height={750} | |
| /> | |
| </div> | |
| ) : ( | |
| <div className="flex items-center justify-center h-64"> | |
| <LoadingSpinner size="lg" /> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| </div> | |
| )} | |
| </div> | |
| {/* Legend */} | |
| {highlightMode !== "none" && ( | |
| <div className="border-t p-4 bg-muted/50 transition-all duration-300"> | |
| <div className="flex items-center gap-6"> | |
| <span className="text-sm font-semibold">Legend:</span> | |
| {viewMode === "overlay" ? ( | |
| // Overlay-specific legend | |
| highlightMode === "common" ? ( | |
| <> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-4 h-4 rounded-full bg-green-500 shadow-sm"></div> | |
| <span className="text-sm font-medium">Common elements</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-4 h-4 rounded-full bg-gray-400 shadow-sm"></div> | |
| <span className="text-sm font-medium">Unique elements</span> | |
| </div> | |
| </> | |
| ) : ( | |
| <> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-4 h-4 rounded-full bg-blue-500 shadow-sm"></div> | |
| <span className="text-sm font-medium">Graph 1 unique</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-4 h-4 rounded-full bg-orange-500 shadow-sm"></div> | |
| <span className="text-sm font-medium">Graph 2 unique</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-4 h-4 rounded-full bg-gray-400 shadow-sm"></div> | |
| <span className="text-sm font-medium">Common elements</span> | |
| </div> | |
| </> | |
| ) | |
| ) : // Side-by-side legend | |
| highlightMode === "common" ? ( | |
| <> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-4 h-4 rounded-full bg-green-500 shadow-sm"></div> | |
| <span className="text-sm font-medium">Common elements</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-4 h-4 rounded-full bg-gray-400 shadow-sm"></div> | |
| <span className="text-sm font-medium">Unique elements</span> | |
| </div> | |
| </> | |
| ) : ( | |
| <> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-4 h-4 rounded-full bg-blue-500 shadow-sm"></div> | |
| <span className="text-sm font-medium">Unique elements</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-4 h-4 rounded-full bg-gray-400 shadow-sm"></div> | |
| <span className="text-sm font-medium">Common elements</span> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |