Skip to content

Commit d74db96

Browse files
committed
feat(web): add path highlighting presets (task-16)
1 parent 7a74652 commit d74db96

File tree

4 files changed

+347
-0
lines changed

4 files changed

+347
-0
lines changed
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/**
2+
* Path Highlighting Presets Component
3+
*
4+
* Provides preset path analysis modes:
5+
* - Shortest path between two nodes (default)
6+
* - All outgoing paths from source
7+
* - All incoming paths to target
8+
* - All paths between source and target
9+
*
10+
* @module components/graph/path-presets
11+
*/
12+
13+
import type { GraphEdge, GraphNode } from '@bibgraph/types';
14+
import { Group, SegmentedControl, Stack, Text, Tooltip } from '@mantine/core';
15+
import { useCallback, useMemo } from 'react';
16+
17+
import { ICON_SIZE } from '@/config/style-constants';
18+
import { findReachableNodes, type PathPreset } from '@/lib/path-presets';
19+
20+
interface PathHighlightingPresetsProps {
21+
/** Currently selected preset */
22+
preset: PathPreset;
23+
/** Callback when preset changes */
24+
onPresetChange: (preset: PathPreset) => void;
25+
/** Source node ID */
26+
pathSource: string | null;
27+
/** Target node ID */
28+
pathTarget: string | null;
29+
/** All graph nodes */
30+
nodes: GraphNode[];
31+
/** All graph edges */
32+
edges: GraphEdge[];
33+
/** Callback to highlight nodes */
34+
onHighlightNodes: (nodeIds: string[]) => void;
35+
/** Callback to highlight path */
36+
onHighlightPath: (path: string[]) => void;
37+
/** Callback to clear highlights */
38+
onClearHighlights: () => void;
39+
}
40+
41+
/** Preset descriptions for tooltips */
42+
const PRESET_DESCRIPTIONS: Record<PathPreset, string> = {
43+
shortest: 'Find shortest path between source and target nodes',
44+
'outgoing-paths': 'Show all paths going out from source node',
45+
'incoming-paths': 'Show all paths coming into target node',
46+
'all-paths': 'Find all paths between source and target nodes',
47+
} as const;
48+
49+
/**
50+
* Path Highlighting Presets Component
51+
* @param root0
52+
* @param root0.preset
53+
* @param root0.onPresetChange
54+
* @param root0.pathSource
55+
* @param root0.pathTarget
56+
* @param root0.nodes
57+
* @param root0.edges
58+
* @param root0.onHighlightNodes
59+
* @param root0.onHighlightPath
60+
* @param root0.onClearHighlights
61+
*/
62+
export const PathHighlightingPresets: React.FC<PathHighlightingPresetsProps> = ({
63+
preset,
64+
onPresetChange,
65+
pathSource,
66+
pathTarget,
67+
nodes,
68+
edges,
69+
onHighlightNodes,
70+
onHighlightPath,
71+
onClearHighlights,
72+
}) => {
73+
// Build graph adjacency map for pathfinding
74+
const graph = useMemo(() => {
75+
const adjacency = new Map<string, Set<string>>();
76+
77+
// Initialize all nodes
78+
nodes.forEach((node) => {
79+
adjacency.set(node.id, new Set());
80+
});
81+
82+
// Build adjacency list from edges
83+
edges.forEach((edge) => {
84+
const sourceId = typeof edge.source === 'string' ? edge.source : String(edge.source);
85+
const targetId = typeof edge.target === 'string' ? edge.target : String(edge.target);
86+
87+
if (adjacency.has(sourceId)) {
88+
adjacency.get(sourceId)?.add(targetId);
89+
}
90+
});
91+
92+
return adjacency;
93+
}, [nodes, edges]);
94+
95+
// Find and highlight paths based on preset
96+
const applyPreset = useCallback(() => {
97+
if (!pathSource && preset !== 'shortest' && preset !== 'all-paths') {
98+
// For incoming/outgoing paths, we need at least source or target
99+
return;
100+
}
101+
102+
if (preset === 'shortest') {
103+
// Use existing shortest path logic (handled by pathSource/pathTarget)
104+
if (pathSource && pathTarget) {
105+
const path = findReachableNodes(graph, pathSource, pathTarget, 1);
106+
if (path.length > 0) {
107+
onHighlightPath(path);
108+
}
109+
}
110+
} else if (preset === 'outgoing-paths') {
111+
// Find all nodes reachable from source
112+
if (pathSource) {
113+
const reachableNodes = findReachableNodes(graph, pathSource);
114+
onHighlightNodes(reachableNodes);
115+
}
116+
} else if (preset === 'incoming-paths') {
117+
// Find all nodes that can reach target
118+
// This requires reversing the graph
119+
if (pathTarget) {
120+
const reversedGraph = new Map<string, Set<string>>();
121+
122+
// Build reversed adjacency list
123+
graph.forEach((neighbors, nodeId) => {
124+
reversedGraph.set(nodeId, new Set());
125+
});
126+
127+
graph.forEach((neighbors, fromNode) => {
128+
neighbors.forEach((toNode) => {
129+
if (reversedGraph.has(toNode)) {
130+
reversedGraph.get(toNode)?.add(fromNode);
131+
}
132+
});
133+
});
134+
135+
const incomingNodes = findReachableNodes(reversedGraph, pathTarget);
136+
onHighlightNodes(incomingNodes);
137+
}
138+
} else if (preset === 'all-paths' && pathSource && pathTarget) {
139+
// Find all nodes on all paths between source and target
140+
// This is a complex problem - for now, highlight nodes within 2 hops of shortest path
141+
const pathNodes = findReachableNodes(graph, pathSource, pathTarget, 3);
142+
onHighlightNodes(pathNodes);
143+
}
144+
}, [preset, pathSource, pathTarget, graph, onHighlightNodes, onHighlightPath]);
145+
146+
// Calculate path count
147+
const pathCount = useMemo(() => {
148+
if (preset === 'shortest' || preset === 'all-paths') {
149+
if (!pathSource || !pathTarget) return 0;
150+
const reachable = findReachableNodes(graph, pathSource, pathTarget, preset === 'all-paths' ? 10 : 1);
151+
return reachable.length;
152+
} else if (preset === 'outgoing-paths') {
153+
if (!pathSource) return 0;
154+
return findReachableNodes(graph, pathSource).length;
155+
} else if (preset === 'incoming-paths') {
156+
if (!pathTarget) return 0;
157+
const reversedGraph = new Map<string, Set<string>>();
158+
graph.forEach((_, nodeId) => {
159+
reversedGraph.set(nodeId, new Set());
160+
});
161+
graph.forEach((neighbors, fromNode) => {
162+
neighbors.forEach((toNode) => {
163+
reversedGraph.get(toNode)?.add(fromNode);
164+
});
165+
});
166+
return findReachableNodes(reversedGraph, pathTarget).length;
167+
}
168+
return 0;
169+
}, [preset, pathSource, pathTarget, graph]);
170+
171+
return (
172+
<Group gap="xs">
173+
<Tooltip label={PRESET_DESCRIPTIONS[preset]}>
174+
<SegmentedControl
175+
size="xs"
176+
value={preset}
177+
onChange={(value) => onPresetChange(value as PathPreset)}
178+
data={[
179+
{ label: 'Shortest', value: 'shortest' },
180+
{ label: 'Outgoing', value: 'outgoing-paths' },
181+
{ label: 'Incoming', value: 'incoming-paths' },
182+
{ label: 'All Paths', value: 'all-paths' },
183+
]}
184+
/>
185+
</Tooltip>
186+
187+
{/* Path count display */}
188+
{pathCount > 0 && (
189+
<Text size="xs" c="dimmed" ml="xs">
190+
{pathCount} node{pathCount === 1 ? '' : 's'}
191+
</Text>
192+
)}
193+
</Group>
194+
);
195+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Path Highlighting Presets Components
3+
* @module components/graph/path-presets
4+
*/
5+
6+
export { PathHighlightingPresets } from './PathHighlightingPresets';

apps/web/src/lib/path-presets.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* Path Highlighting Presets
3+
*
4+
* Types and utilities for path highlighting presets in graph visualization.
5+
* Provides different path analysis modes for exploring relationships.
6+
*
7+
* @module lib/path-presets
8+
*/
9+
10+
/** Path highlighting preset modes */
11+
export type PathPreset =
12+
| 'shortest' // Shortest path between source and target
13+
| 'outgoing-paths' // All paths from source node
14+
| 'incoming-paths' // All paths to target node
15+
| 'all-paths'; // All paths between source and target
16+
17+
/**
18+
* Find all nodes reachable from a source node using BFS
19+
* @param graph - Adjacency list representation of graph (nodeId -> Set of neighbor nodeIds)
20+
* @param sourceId - Source node ID
21+
* @param targetId - Optional target node ID to limit search depth
22+
* @param maxDepth - Maximum depth to traverse (default: unlimited)
23+
* @returns Array of reachable node IDs
24+
*/
25+
export const findReachableNodes = (
26+
graph: Map<string, Set<string>>,
27+
sourceId: string,
28+
targetId?: string,
29+
maxDepth?: number,
30+
): string[] => {
31+
const visited = new Set<string>();
32+
const queue: Array<{ nodeId: string; depth: number }> = [{ nodeId: sourceId, depth: 0 }];
33+
const result: string[] = [];
34+
35+
while (queue.length > 0) {
36+
const { nodeId, depth } = queue.shift() as { nodeId: string; depth: number };
37+
38+
if (visited.has(nodeId)) {
39+
continue;
40+
}
41+
42+
visited.add(nodeId);
43+
result.push(nodeId);
44+
45+
// Stop if we reached target
46+
if (targetId && nodeId === targetId) {
47+
break;
48+
}
49+
50+
// Check depth limit
51+
if (maxDepth !== undefined && depth >= maxDepth) {
52+
continue;
53+
}
54+
55+
// Add neighbors
56+
const neighbors = graph.get(nodeId);
57+
if (neighbors) {
58+
for (const neighborId of neighbors) {
59+
if (!visited.has(neighborId)) {
60+
queue.push({ nodeId: neighborId, depth: depth + 1 });
61+
}
62+
}
63+
}
64+
}
65+
66+
return result;
67+
};
68+
69+
/**
70+
* Find shortest path between two nodes using BFS
71+
* @param graph - Adjacency list representation of graph
72+
* @param sourceId - Source node ID
73+
* @param targetId - Target node ID
74+
* @returns Array of node IDs representing the path, or empty array if no path exists
75+
*/
76+
export const findShortestPath = (
77+
graph: Map<string, Set<string>>,
78+
sourceId: string,
79+
targetId: string,
80+
): string[] => {
81+
if (sourceId === targetId) {
82+
return [sourceId];
83+
}
84+
85+
const visited = new Set<string>();
86+
const parentMap = new Map<string, string | null>();
87+
const queue: string[] = [sourceId];
88+
89+
visited.add(sourceId);
90+
parentMap.set(sourceId, null);
91+
92+
while (queue.length > 0) {
93+
const nodeId = queue.shift() as string;
94+
95+
if (nodeId === targetId) {
96+
// Reconstruct path
97+
const path: string[] = [];
98+
let current: string | null = targetId;
99+
100+
while (current !== null) {
101+
path.unshift(current);
102+
current = parentMap.get(current) ?? null;
103+
}
104+
105+
return path;
106+
}
107+
108+
const neighbors = graph.get(nodeId);
109+
if (neighbors) {
110+
for (const neighborId of neighbors) {
111+
if (!visited.has(neighborId)) {
112+
visited.add(neighborId);
113+
parentMap.set(neighborId, nodeId);
114+
queue.push(neighborId);
115+
}
116+
}
117+
}
118+
}
119+
120+
// No path found
121+
return [];
122+
};

apps/web/src/routes/graph.lazy.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
NodeContextMenu,
6262
} from '@/components/graph/NodeContextMenu';
6363
import { OptimizedForceGraphVisualization } from '@/components/graph/OptimizedForceGraphVisualization';
64+
import { PathHighlightingPresets } from '@/components/graph/path-presets';
6465
import { GraphSnapshots } from '@/components/graph/snapshots';
6566
import type { DisplayMode } from '@/components/graph/types';
6667
import { ViewModeToggle } from '@/components/ui/ViewModeToggle';
@@ -70,6 +71,7 @@ import { type GraphMethods, useFitToView } from '@/hooks/useFitToView';
7071
import { useGraphAnnotations } from '@/hooks/useGraphAnnotations';
7172
import { type GraphLayoutType,useGraphLayout } from '@/hooks/useGraphLayout';
7273
import { useNodeExpansion } from '@/lib/graph-index';
74+
import type { PathPreset } from '@/lib/path-presets';
7375
import { downloadGraphSVG } from '@/utils/exportUtils';
7476

7577
/**
@@ -131,6 +133,8 @@ const EntityGraphPage = () => {
131133
clearHighlights,
132134
setPathSource,
133135
setPathTarget,
136+
highlightNodes,
137+
highlightPath,
134138
} = visualization;
135139

136140
// Graph methods ref for external control (zoomToFit, etc.)
@@ -149,6 +153,9 @@ const EntityGraphPage = () => {
149153
// Mini-map state
150154
const [cameraPosition, setCameraPosition] = useState({ zoom: 1, panX: 0, panY: 0 });
151155

156+
// Path highlighting preset state
157+
const [pathPreset, setPathPreset] = useState<PathPreset>('shortest');
158+
152159
// Fit-to-view operations (shared logic for 2D/3D)
153160
const { fitToViewAll, fitToViewSelected } = useFitToView({
154161
graphMethodsRef,
@@ -266,6 +273,11 @@ const EntityGraphPage = () => {
266273
// This requires extending the GraphVisualizationContext to support full state replacement
267274
}, []);
268275

276+
// Handle path preset change
277+
const handlePresetChange = useCallback((preset: PathPreset) => {
278+
setPathPreset(preset);
279+
}, []);
280+
269281
// Convert expandingNodeIds array to Set for visualization components
270282
const expandingNodeIdsSet = useMemo(() => new Set(expandingNodeIds), [expandingNodeIds]);
271283

@@ -545,6 +557,18 @@ const EntityGraphPage = () => {
545557
]}
546558
/>
547559

560+
<PathHighlightingPresets
561+
preset={pathPreset}
562+
onPresetChange={handlePresetChange}
563+
pathSource={pathSource}
564+
pathTarget={pathTarget}
565+
nodes={nodes}
566+
edges={edges}
567+
onHighlightNodes={highlightNodes}
568+
onHighlightPath={highlightPath}
569+
onClearHighlights={clearHighlights}
570+
/>
571+
548572
<LayoutSelector
549573
value={currentLayout}
550574
onChange={handleLayoutChange}

0 commit comments

Comments
 (0)