Files
zui/frontend/src/app/canvas/useCanvasConnectionPath.ts
2026-03-14 07:52:34 +01:00

156 lines
6.0 KiB
TypeScript

/**
* Hook that holds all connection-path state and callbacks for the canvas.
* Used to show the "ant trail" along the path of an update (e.g. config → agent → render).
* Extracted from CanvasPage to keep the page focused on composition.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getPathNodeIds, getPathToUpdatingSegmentNodeIds } from '@/lib/graph/graphPath'
/** Serialize set to a stable string for equality. */
function setToStableKey(s: Set<string>): string {
return Array.from(s).sort().join(',')
}
export type EdgeLike = { source: string; target: string }
const PULSE_MS = 1500
export type UseCanvasConnectionPathResult = {
connectionPathUpdatingNodeIds: string[]
connectionPathTriggerNodeIds: string[]
connectionPathPausedNodeIds: string[]
connectionPathErrorNodeIds: string[]
connectionPathNodeIds: Set<string>
connectionPathPausedSegmentNodeIds: Set<string>
connectionPathActiveSegmentNodeIds: Set<string>
startConnectionPathUpdate: (nodeId: string) => void
endConnectionPathUpdate: (nodeId: string) => void
addConnectionPathTrigger: (nodeId: string) => void
addConnectionPathPausedNode: (nodeId: string) => void
removeConnectionPathPausedNode: (nodeId: string) => void
addConnectionPathError: (nodeId: string) => void
removeConnectionPathError: (nodeId: string) => void
}
export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionPathResult {
const [connectionPathTriggerNodeIds, setConnectionPathTriggerNodeIds] = useState<string[]>([])
const [pulseEndsAt, setPulseEndsAt] = useState<number | null>(null)
const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = useState<string[]>([])
const pathTriggerBatchRef = useRef<Set<string>>(new Set())
const pathTriggerScheduledRef = useRef(false)
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const addConnectionPathTrigger = useCallback((nodeId: string) => {
pathTriggerBatchRef.current.add(nodeId)
if (pathTriggerScheduledRef.current) return
pathTriggerScheduledRef.current = true
requestAnimationFrame(() => {
pathTriggerScheduledRef.current = false
const batch = new Set(pathTriggerBatchRef.current)
pathTriggerBatchRef.current = new Set()
if (batch.size === 0) return
setConnectionPathTriggerNodeIds((prev) => {
const next = new Set(prev)
batch.forEach((id) => next.add(id))
return next.size === prev.length && prev.every((id) => next.has(id)) ? prev : Array.from(next)
})
setPulseEndsAt(Date.now() + PULSE_MS)
})
}, [])
useEffect(() => {
if (pulseEndsAt == null) return
const delay = Math.max(0, pulseEndsAt - Date.now())
if (pulseTimerRef.current != null) clearTimeout(pulseTimerRef.current)
pulseTimerRef.current = setTimeout(() => {
pulseTimerRef.current = null
setPulseEndsAt(null)
setConnectionPathTriggerNodeIds([])
}, delay)
return () => {
if (pulseTimerRef.current != null) {
clearTimeout(pulseTimerRef.current)
pulseTimerRef.current = null
}
}
}, [pulseEndsAt])
const connectionPathNodeIdsRaw = useMemo(
() =>
getPathNodeIds(edges, [], connectionPathTriggerNodeIds, undefined),
[edges, connectionPathTriggerNodeIds]
)
const connectionPathNodeIdsRef = useRef<Set<string>>(connectionPathNodeIdsRaw)
const connectionPathNodeIdsKeyRef = useRef<string>('')
const connectionPathNodeIds =
setToStableKey(connectionPathNodeIdsRaw) === connectionPathNodeIdsKeyRef.current
? connectionPathNodeIdsRef.current
: (() => {
connectionPathNodeIdsKeyRef.current = setToStableKey(connectionPathNodeIdsRaw)
connectionPathNodeIdsRef.current = connectionPathNodeIdsRaw
return connectionPathNodeIdsRaw
})()
const EMPTY_PAUSED_SEGMENT = useMemo(() => new Set<string>(), [])
const connectionPathPausedSegmentNodeIds = EMPTY_PAUSED_SEGMENT
const pulseActive = pulseEndsAt != null
const connectionPathActiveSegmentNodeIdsRaw = useMemo(
() =>
getPathToUpdatingSegmentNodeIds(
edges,
connectionPathTriggerNodeIds,
pulseActive
),
[edges, connectionPathTriggerNodeIds, pulseActive]
)
const connectionPathActiveSegmentNodeIdsRef = useRef<Set<string>>(
connectionPathActiveSegmentNodeIdsRaw
)
const connectionPathActiveSegmentNodeIdsKeyRef = useRef<string>('')
const connectionPathActiveSegmentNodeIds =
setToStableKey(connectionPathActiveSegmentNodeIdsRaw) ===
connectionPathActiveSegmentNodeIdsKeyRef.current
? connectionPathActiveSegmentNodeIdsRef.current
: (() => {
connectionPathActiveSegmentNodeIdsKeyRef.current = setToStableKey(
connectionPathActiveSegmentNodeIdsRaw
)
connectionPathActiveSegmentNodeIdsRef.current = connectionPathActiveSegmentNodeIdsRaw
return connectionPathActiveSegmentNodeIdsRaw
})()
const addConnectionPathPausedNode = useCallback((_nodeId: string) => {}, [])
const removeConnectionPathPausedNode = useCallback((_nodeId: string) => {}, [])
const startConnectionPathUpdate = useCallback((_nodeId: string) => {}, [])
const endConnectionPathUpdate = useCallback((_nodeId: string) => {}, [])
const addConnectionPathError = useCallback((nodeId: string) => {
setConnectionPathErrorNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))
}, [])
const removeConnectionPathError = useCallback((nodeId: string) => {
setConnectionPathErrorNodeIds((prev) => prev.filter((id) => id !== nodeId))
}, [])
return {
connectionPathUpdatingNodeIds: [],
connectionPathTriggerNodeIds,
connectionPathPausedNodeIds: [],
connectionPathErrorNodeIds,
connectionPathNodeIds,
connectionPathPausedSegmentNodeIds,
connectionPathActiveSegmentNodeIds,
startConnectionPathUpdate,
endConnectionPathUpdate,
addConnectionPathTrigger,
addConnectionPathPausedNode,
removeConnectionPathPausedNode,
addConnectionPathError,
removeConnectionPathError,
}
}