/** * Path derivation for connection status using graphology. * Builds a DirectedGraph from canvas edges (with optional node attributes), * uses BFS (outbound/inbound) to compute path sets for propagation animation. * * @see https://graphology.github.io/ * @see https://graphology.github.io/standard-library/traversal.html */ import DirectedGraph from 'graphology' import { bfsFromNode } from 'graphology-traversal' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type GraphEdge = { source: string; target: string; id?: string } /** Optional attributes stored on graphology nodes (e.g. for sink detection). */ export type GraphologyNodeAttributes = { nodeType?: string updateMode?: 'auto' | 'manual' } /** Optional: pass when building so graph has node type/updateMode for future use. */ export type NodeAttributesMap = Record // --------------------------------------------------------------------------- // Graph build (with cache) // --------------------------------------------------------------------------- let cachedEdgesRef: GraphEdge[] | null = null let cachedGraph: DirectedGraph | null = null function buildGraph( edges: GraphEdge[], nodeAttributes?: NodeAttributesMap ): DirectedGraph { if (edges === cachedEdgesRef && cachedGraph !== null) return cachedGraph const graph = new DirectedGraph() const nodeIds = new Set() for (const e of edges) { nodeIds.add(e.source) nodeIds.add(e.target) } for (const id of nodeIds) { const attrs: GraphologyNodeAttributes = { ...nodeAttributes?.[id] } graph.mergeNode(id, attrs) } for (const e of edges) { const key = e.id ?? `${e.source}->${e.target}` if (!graph.hasEdge(e.source, e.target)) { graph.addEdgeWithKey(key, e.source, e.target, { source: e.source, target: e.target, edgeId: e.id, }) } } cachedEdgesRef = edges cachedGraph = graph return graph } /** Call when graph structure changes from outside (e.g. store reset) to clear cache. */ export function clearGraphologyPathCache(): void { cachedEdgesRef = null cachedGraph = null } // --------------------------------------------------------------------------- // Traversal helpers (BFS via graphology-traversal) // --------------------------------------------------------------------------- /** Outbound BFS: collect all node ids reachable from seeds following edges forward (source → target). */ function getDownstreamNodeIds( graph: DirectedGraph, seedIds: string[] ): Set { const out = new Set() for (const id of seedIds) { if (!graph.hasNode(id)) continue bfsFromNode( graph, id, (node) => { out.add(node) return false }, { mode: 'outbound' } ) } return out } /** Inbound BFS: collect all node ids that can reach any seed (following edges backward). */ function getUpstreamNodeIds( graph: DirectedGraph, seedIds: string[] ): Set { const out = new Set() for (const id of seedIds) { if (!graph.hasNode(id)) continue bfsFromNode( graph, id, (node) => { out.add(node) return false }, { mode: 'inbound' } ) } return out } // --------------------------------------------------------------------------- // Path-to-updating / path-to-paused (same semantics as graphPath.ts) // --------------------------------------------------------------------------- function getPathToUpdatingNodeIds( graph: DirectedGraph, triggerNodeIds: string[], updatingNodeIds: string[] ): Set { const downstreamOfTrigger = getDownstreamNodeIds(graph, triggerNodeIds) const upstreamOfUpdating = getUpstreamNodeIds(graph, updatingNodeIds) const downstreamOfUpdating = getDownstreamNodeIds(graph, updatingNodeIds) const path = new Set() downstreamOfTrigger.forEach((id) => { if (upstreamOfUpdating.has(id) || downstreamOfUpdating.has(id)) path.add(id) }) return path } function getPathToPausedNodeIds( graph: DirectedGraph, triggerNodeIds: string[], pausedNodeIds: string[] ): Set { const upstreamOfPaused = getUpstreamNodeIds(graph, pausedNodeIds) const downstreamOfTrigger = getDownstreamNodeIds(graph, triggerNodeIds) const path = new Set() upstreamOfPaused.forEach((id) => { if (downstreamOfTrigger.has(id)) path.add(id) }) return path } // --------------------------------------------------------------------------- // Public API (same signatures as graphPath.ts for drop-in replacement) // --------------------------------------------------------------------------- /** * All node ids on the path of an update (downstream of trigger, or path to updating nodes). */ export function getPathNodeIds( edges: GraphEdge[], updatingNodeIds: string[], triggerNodeIds?: string[], _pausedNodeIds?: string[], nodeAttributes?: NodeAttributesMap ): Set { const hasUpdating = updatingNodeIds.length > 0 const hasTrigger = triggerNodeIds != null && triggerNodeIds.length > 0 const graph = buildGraph(edges, nodeAttributes) if (hasTrigger && hasUpdating) { return getPathToUpdatingNodeIds(graph, triggerNodeIds!, updatingNodeIds) } if (hasUpdating) { const upstream = getUpstreamNodeIds(graph, updatingNodeIds) const downstream = getDownstreamNodeIds(graph, updatingNodeIds) const path = new Set(upstream) downstream.forEach((id) => path.add(id)) return path } if (hasTrigger) { return getDownstreamNodeIds(graph, triggerNodeIds!) } return new Set() } /** * Path nodes that show "updating" during the time-bound pulse. * When pulseActive is true, returns downstream(trigger); otherwise empty. */ export function getPathToUpdatingSegmentNodeIds( edges: GraphEdge[], triggerNodeIds: string[], pulseActive: boolean, nodeAttributes?: NodeAttributesMap ): Set { if (!pulseActive || triggerNodeIds.length === 0) return new Set() const graph = buildGraph(edges, nodeAttributes) return getDownstreamNodeIds(graph, triggerNodeIds) }