Skip to content

Commit 7a74652

Browse files
committed
feat(web): add graph comparison feature (task-15)
1 parent 4388dd4 commit 7a74652

File tree

4 files changed

+712
-0
lines changed

4 files changed

+712
-0
lines changed
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
/**
2+
* Graph Comparison Component
3+
*
4+
* Displays two graph visualizations side-by-side for comparison.
5+
* Highlights differences between nodes and edges.
6+
* Shows diff statistics and swap functionality.
7+
*
8+
* @module components/graph/comparison
9+
*/
10+
11+
import type { GraphEdge, GraphNode } from '@bibgraph/types';
12+
import {
13+
ActionIcon,
14+
Badge,
15+
Box,
16+
Button,
17+
Card,
18+
Container,
19+
Flex,
20+
Group,
21+
Stack,
22+
Text,
23+
Title,
24+
Tooltip,
25+
} from '@mantine/core';
26+
import { useDisclosure } from '@mantine/hooks';
27+
import { notifications } from '@mantine/notifications';
28+
import {
29+
IconArrowLeft,
30+
IconArrowsLeftRight,
31+
IconMinus,
32+
IconPlus,
33+
IconRefresh,
34+
IconX,
35+
} from '@tabler/icons-react';
36+
import { useCallback, useMemo, useState } from 'react';
37+
38+
import { ICON_SIZE } from '@/config/style-constants';
39+
import { type GraphLayoutType, useGraphLayout } from '@/hooks/useGraphLayout';
40+
import { OptimizedForceGraphVisualization } from '@/components/graph/OptimizedForceGraphVisualization';
41+
import type { DisplayMode } from '@/components/graph/types';
42+
43+
interface GraphComparisonProps {
44+
/** Left graph nodes */
45+
leftNodes: GraphNode[];
46+
/** Left graph edges */
47+
leftEdges: GraphEdge[];
48+
/** Right graph nodes */
49+
rightNodes: GraphNode[];
50+
/** Right graph edges */
51+
rightEdges: GraphEdge[];
52+
/** Left graph name */
53+
leftName?: string;
54+
/** Right graph name */
55+
rightName?: string;
56+
/** Layout type for both graphs */
57+
layoutType?: GraphLayoutType;
58+
/** Close comparison handler */
59+
onClose?: () => void;
60+
}
61+
62+
interface GraphDiff {
63+
/** Nodes only in left graph */
64+
removedNodes: GraphNode[];
65+
/** Nodes only in right graph */
66+
addedNodes: GraphNode[];
67+
/** Nodes in both graphs */
68+
commonNodes: GraphNode[];
69+
/** Edges only in left graph */
70+
removedEdges: GraphEdge[];
71+
/** Edges only in right graph */
72+
addedEdges: GraphEdge[];
73+
/** Edges in both graphs */
74+
commonEdges: GraphEdge[];
75+
}
76+
77+
/**
78+
* Graph Comparison Component
79+
* @param root0
80+
* @param root0.leftNodes
81+
* @param root0.leftEdges
82+
* @param root0.rightNodes
83+
* @param root0.rightEdges
84+
* @param root0.leftName
85+
* @param root0.rightName
86+
* @param root0.layoutType
87+
* @param root0.onClose
88+
*/
89+
export const GraphComparison: React.FC<GraphComparisonProps> = ({
90+
leftNodes,
91+
leftEdges,
92+
rightNodes,
93+
rightEdges,
94+
leftName = 'Graph A',
95+
rightName = 'Graph B',
96+
layoutType = 'force',
97+
onClose,
98+
}) => {
99+
const [swapped, swap] = useDisclosure();
100+
101+
// Calculate diff between graphs
102+
const diff = useMemo<GraphDiff>(() => {
103+
const leftNodeIds = new Set(leftNodes.map((n) => n.id));
104+
const rightNodeIds = new Set(rightNodes.map((n) => n.id));
105+
106+
const removedNodes = leftNodes.filter((n) => !rightNodeIds.has(n.id));
107+
const addedNodes = rightNodes.filter((n) => !leftNodeIds.has(n.id));
108+
const commonNodes = leftNodes.filter((n) => rightNodeIds.has(n.id));
109+
110+
const leftEdgeIds = new Set(leftEdges.map((e) => `${e.source}-${e.target}`));
111+
const rightEdgeIds = new Set(rightEdges.map((e) => `${e.source}-${e.target}`));
112+
113+
const removedEdges = leftEdges.filter((e) => !rightEdgeIds.has(`${e.source}-${e.target}`));
114+
const addedEdges = rightEdges.filter((e) => !leftEdgeIds.has(`${e.source}-${e.target}`));
115+
const commonEdges = leftEdges.filter((e) => rightEdgeIds.has(`${e.source}-${e.target}`));
116+
117+
return {
118+
removedNodes,
119+
addedNodes,
120+
commonNodes,
121+
removedEdges,
122+
addedEdges,
123+
commonEdges,
124+
};
125+
}, [leftNodes, rightNodes, leftEdges, rightEdges]);
126+
127+
// Highlighted nodes for left graph (show removed nodes in red)
128+
const leftHighlightedNodeIds = useMemo(() => {
129+
return new Set(diff.removedNodes.map((n) => n.id));
130+
}, [diff.removedNodes]);
131+
132+
// Highlighted nodes for right graph (show added nodes in green)
133+
const rightHighlightedNodeIds = useMemo(() => {
134+
return new Set(diff.addedNodes.map((n) => n.id));
135+
}, [diff.addedNodes]);
136+
137+
// Layout hooks for both graphs
138+
const leftLayout = useGraphLayout(leftNodes, leftEdges, layoutType);
139+
const rightLayout = useGraphLayout(rightNodes, rightEdges, layoutType);
140+
141+
// Apply layout
142+
const leftNodePositions = useMemo(() => {
143+
return layoutType === 'force' ? new Map<string, { x: number; y: number }>() : leftLayout.applyLayout(layoutType);
144+
}, [layoutType, leftLayout]);
145+
146+
const rightNodePositions = useMemo(() => {
147+
return layoutType === 'force' ? new Map<string, { x: number; y: number }>() : rightLayout.applyLayout(layoutType);
148+
}, [layoutType, rightLayout]);
149+
150+
// Handle swap
151+
const handleSwap = useCallback(() => {
152+
swap.toggle();
153+
}, [swap]);
154+
155+
// Current graphs (may be swapped)
156+
const currentLeftNodes = swapped ? rightNodes : leftNodes;
157+
const currentLeftEdges = swapped ? rightEdges : leftEdges;
158+
const currentLeftName = swapped ? rightName : leftName;
159+
const currentLeftHighlighted = swapped ? rightHighlightedNodeIds : leftHighlightedNodeIds;
160+
161+
const currentRightNodes = swapped ? leftNodes : rightNodes;
162+
const currentRightEdges = swapped ? leftEdges : rightEdges;
163+
const currentRightName = swapped ? leftName : rightName;
164+
const currentRightHighlighted = swapped ? leftHighlightedNodeIds : rightHighlightedNodeIds;
165+
166+
return (
167+
<Container size="xl" py="md">
168+
<Stack gap="md">
169+
{/* Header */}
170+
<Group justify="space-between" align="center">
171+
<Group>
172+
<Title order={2}>Graph Comparison</Title>
173+
{onClose && (
174+
<Tooltip label="Close comparison">
175+
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close comparison">
176+
<IconX size={ICON_SIZE.MD} />
177+
</ActionIcon>
178+
</Tooltip>
179+
)}
180+
</Group>
181+
<Group gap="xs">
182+
<Tooltip label="Swap graphs">
183+
<ActionIcon variant="light" onClick={handleSwap} aria-label="Swap graphs">
184+
<IconArrowsLeftRight size={ICON_SIZE.MD} />
185+
</ActionIcon>
186+
</Tooltip>
187+
</Group>
188+
</Group>
189+
190+
{/* Diff Statistics */}
191+
<Card withBorder p="md">
192+
<Group gap="lg" wrap="nowrap">
193+
<Flex direction="column" gap="xs">
194+
<Text size="sm" fw={500} c="dimmed">
195+
Nodes
196+
</Text>
197+
<Group gap="xs">
198+
<Badge color="red" variant="light" leftSection={<IconMinus size={ICON_SIZE.XS} />}>
199+
{diff.removedNodes.length} removed
200+
</Badge>
201+
<Badge color="green" variant="light" leftSection={<IconPlus size={ICON_SIZE.XS} />}>
202+
{diff.addedNodes.length} added
203+
</Badge>
204+
<Badge color="blue" variant="light">
205+
{diff.commonNodes.length} common
206+
</Badge>
207+
</Group>
208+
</Flex>
209+
<Flex direction="column" gap="xs">
210+
<Text size="sm" fw={500} c="dimmed">
211+
Edges
212+
</Text>
213+
<Group gap="xs">
214+
<Badge color="red" variant="light" leftSection={<IconMinus size={ICON_SIZE.XS} />}>
215+
{diff.removedEdges.length} removed
216+
</Badge>
217+
<Badge color="green" variant="light" leftSection={<IconPlus size={ICON_SIZE.XS} />}>
218+
{diff.addedEdges.length} added
219+
</Badge>
220+
<Badge color="blue" variant="light">
221+
{diff.commonEdges.length} common
222+
</Badge>
223+
</Group>
224+
</Flex>
225+
</Group>
226+
</Card>
227+
228+
{/* Side-by-side graphs */}
229+
<Flex gap="md" style={{ height: 'calc(100vh - 300px)', minHeight: 500 }}>
230+
{/* Left graph */}
231+
<Box flex={1} style={{ border: '1px solid var(--mantine-color-gray-2)', borderRadius: '4px', overflow: 'hidden' }}>
232+
<Box p="xs" bg="var(--mantine-color-gray-0)" style={{ borderBottom: '1px solid var(--mantine-color-gray-2)' }}>
233+
<Group justify="space-between">
234+
<Text size="sm" fw={500}>
235+
{currentLeftName}
236+
</Text>
237+
<Text size="xs" c="dimmed">
238+
{currentLeftNodes.length} nodes, {currentLeftEdges.length} edges
239+
</Text>
240+
</Group>
241+
</Box>
242+
<Box style={{ height: 'calc(100% - 40px)' }}>
243+
<OptimizedForceGraphVisualization
244+
nodes={currentLeftNodes}
245+
edges={currentLeftEdges}
246+
highlightedNodeIds={currentLeftHighlighted}
247+
highlightedPath={[]}
248+
communityAssignments={new Map()}
249+
communityColors={new Map()}
250+
expandingNodeIds={new Set()}
251+
_displayMode="filter"
252+
enableSimulation={layoutType === 'force'}
253+
nodePositions={swapped ? rightNodePositions : leftNodePositions}
254+
onNodeClick={() => {}}
255+
onNodeRightClick={() => {}}
256+
onBackgroundClick={() => {}}
257+
onGraphReady={() => {}}
258+
enableOptimizations={true}
259+
progressiveLoading={{
260+
enabled: true,
261+
batchSize: 50,
262+
batchDelayMs: 16,
263+
}}
264+
/>
265+
</Box>
266+
</Box>
267+
268+
{/* Right graph */}
269+
<Box flex={1} style={{ border: '1px solid var(--mantine-color-gray-2)', borderRadius: '4px', overflow: 'hidden' }}>
270+
<Box p="xs" bg="var(--mantine-color-gray-0)" style={{ borderBottom: '1px solid var(--mantine-color-gray-2)' }}>
271+
<Group justify="space-between">
272+
<Text size="sm" fw={500}>
273+
{currentRightName}
274+
</Text>
275+
<Text size="xs" c="dimmed">
276+
{currentRightNodes.length} nodes, {currentRightEdges.length} edges
277+
</Text>
278+
</Group>
279+
</Box>
280+
<Box style={{ height: 'calc(100% - 40px)' }}>
281+
<OptimizedForceGraphVisualization
282+
nodes={currentRightNodes}
283+
edges={currentRightEdges}
284+
highlightedNodeIds={currentRightHighlighted}
285+
highlightedPath={[]}
286+
communityAssignments={new Map()}
287+
communityColors={new Map()}
288+
expandingNodeIds={new Set()}
289+
_displayMode="filter"
290+
enableSimulation={layoutType === 'force'}
291+
nodePositions={swapped ? leftNodePositions : rightNodePositions}
292+
onNodeClick={() => {}}
293+
onNodeRightClick={() => {}}
294+
onBackgroundClick={() => {}}
295+
onGraphReady={() => {}}
296+
enableOptimizations={true}
297+
progressiveLoading={{
298+
enabled: true,
299+
batchSize: 50,
300+
batchDelayMs: 16,
301+
}}
302+
/>
303+
</Box>
304+
</Box>
305+
</Flex>
306+
307+
{/* Legend */}
308+
<Card withBorder p="sm">
309+
<Group gap="md">
310+
<Flex gap="xs" align="center">
311+
<Badge color="red" variant="light" circle size="xs" />
312+
<Text size="sm">Removed from left graph</Text>
313+
</Flex>
314+
<Flex gap="xs" align="center">
315+
<Badge color="green" variant="light" circle size="xs" />
316+
<Text size="sm">Added to right graph</Text>
317+
</Flex>
318+
<Flex gap="xs" align="center">
319+
<Badge color="blue" variant="light" circle size="xs" />
320+
<Text size="sm">Common to both graphs</Text>
321+
</Flex>
322+
</Group>
323+
</Card>
324+
</Stack>
325+
</Container>
326+
);
327+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Graph Comparison Components
3+
* @module components/graph/comparison
4+
*/
5+
6+
export { GraphComparison } from './GraphComparison';

0 commit comments

Comments
 (0)