|
| 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 | +}; |
0 commit comments