diff --git a/understand-anything-plugin/packages/dashboard/src/components/PathFinderModal.tsx b/understand-anything-plugin/packages/dashboard/src/components/PathFinderModal.tsx index 3e0e3c01..a1db4b23 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/PathFinderModal.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/PathFinderModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useDashboardStore } from "../store"; interface PathFinderModalProps { @@ -15,6 +15,50 @@ export default function PathFinderModal({ isOpen, onClose }: PathFinderModalProp const [searching, setSearching] = useState(false); const modalRef = useRef(null); + // Undirected adjacency, memoized per graph. Matches the bidirectional + // traversal used by findPath so reachability and path-finding agree. + const adjacency = useMemo(() => { + const adj = new Map(); + if (!graph) return adj; + for (const edge of graph.edges) { + if (!adj.has(edge.source)) adj.set(edge.source, []); + adj.get(edge.source)!.push(edge.target); + if (!adj.has(edge.target)) adj.set(edge.target, []); + adj.get(edge.target)!.push(edge.source); + } + return adj; + }, [graph]); + + // Set of node ids reachable from the selected "From" node (excluding itself). + // null when no source is selected — caller treats null as "no filtering yet". + const reachableFromSource = useMemo(() => { + if (!fromNodeId) return null; + const reachable = new Set(); + const queue: string[] = [fromNodeId]; + reachable.add(fromNodeId); + while (queue.length > 0) { + const nodeId = queue.shift()!; + const neighbors = adjacency.get(nodeId) ?? []; + for (const n of neighbors) { + if (!reachable.has(n)) { + reachable.add(n); + queue.push(n); + } + } + } + reachable.delete(fromNodeId); + return reachable; + }, [fromNodeId, adjacency]); + + // If "From" changes and the previously chosen "To" is no longer reachable, + // clear "To" so the select doesn't display a stale, invalid value. + useEffect(() => { + if (toNodeId && reachableFromSource && !reachableFromSource.has(toNodeId)) { + setToNodeId(""); + setPath(null); + } + }, [reachableFromSource, toNodeId]); + // Close on outside click useEffect(() => { if (!isOpen) return; @@ -46,7 +90,13 @@ export default function PathFinderModal({ isOpen, onClose }: PathFinderModalProp if (!isOpen || !graph) return null; const nodes = graph.nodes; - const edges = graph.edges; + + // Nodes shown in the "To" dropdown. Before a source is picked, show all + // nodes; once a source is picked, only show nodes reachable from it so + // the user can't select a destination with no path. + const toCandidates = reachableFromSource + ? nodes.filter((n) => reachableFromSource.has(n.id)) + : nodes; // BFS to find shortest path const findPath = () => { @@ -57,21 +107,7 @@ export default function PathFinderModal({ isOpen, onClose }: PathFinderModalProp setSearching(true); - // Build adjacency list (bidirectional traversal for path finding) - const adjacency = new Map(); - for (const edge of edges) { - if (!adjacency.has(edge.source)) { - adjacency.set(edge.source, []); - } - adjacency.get(edge.source)!.push(edge.target); - // Also traverse in reverse so we can find paths through backward edges - if (!adjacency.has(edge.target)) { - adjacency.set(edge.target, []); - } - adjacency.get(edge.target)!.push(edge.source); - } - - // BFS + // BFS using the memoized undirected adjacency const queue: Array<{ nodeId: string; path: string[] }> = [ { nodeId: fromNodeId, path: [fromNodeId] }, ]; @@ -166,19 +202,33 @@ export default function PathFinderModal({ isOpen, onClose }: PathFinderModalProp {/* To Node */}
- +
+ + {fromNodeId && ( + + {toCandidates.length} reachable + + )} +