feat: simplify paths
This commit is contained in:
201
frontend/src/lib/graph/graphologyPath.ts
Normal file
201
frontend/src/lib/graph/graphologyPath.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 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<string, GraphologyNodeAttributes>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph build (with cache)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let cachedEdgesRef: GraphEdge[] | null = null
|
||||
let cachedGraph: DirectedGraph<GraphologyNodeAttributes, { source: string; target: string; edgeId?: string }> | null =
|
||||
null
|
||||
|
||||
function buildGraph(
|
||||
edges: GraphEdge[],
|
||||
nodeAttributes?: NodeAttributesMap
|
||||
): DirectedGraph<GraphologyNodeAttributes, { source: string; target: string; edgeId?: string }> {
|
||||
if (edges === cachedEdgesRef && cachedGraph !== null) return cachedGraph
|
||||
|
||||
const graph = new DirectedGraph<GraphologyNodeAttributes, { source: string; target: string; edgeId?: string }>()
|
||||
const nodeIds = new Set<string>()
|
||||
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<string> {
|
||||
const out = new Set<string>()
|
||||
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<string> {
|
||||
const out = new Set<string>()
|
||||
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<string> {
|
||||
const downstreamOfTrigger = getDownstreamNodeIds(graph, triggerNodeIds)
|
||||
const upstreamOfUpdating = getUpstreamNodeIds(graph, updatingNodeIds)
|
||||
const downstreamOfUpdating = getDownstreamNodeIds(graph, updatingNodeIds)
|
||||
const path = new Set<string>()
|
||||
downstreamOfTrigger.forEach((id) => {
|
||||
if (upstreamOfUpdating.has(id) || downstreamOfUpdating.has(id)) path.add(id)
|
||||
})
|
||||
return path
|
||||
}
|
||||
|
||||
function getPathToPausedNodeIds(
|
||||
graph: DirectedGraph,
|
||||
triggerNodeIds: string[],
|
||||
pausedNodeIds: string[]
|
||||
): Set<string> {
|
||||
const upstreamOfPaused = getUpstreamNodeIds(graph, pausedNodeIds)
|
||||
const downstreamOfTrigger = getDownstreamNodeIds(graph, triggerNodeIds)
|
||||
const path = new Set<string>()
|
||||
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<string> {
|
||||
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<string>(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<string> {
|
||||
if (!pulseActive || triggerNodeIds.length === 0) return new Set()
|
||||
const graph = buildGraph(edges, nodeAttributes)
|
||||
return getDownstreamNodeIds(graph, triggerNodeIds)
|
||||
}
|
||||
Reference in New Issue
Block a user