wu981526092's picture
add
738bb6b
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>
);
};