feat: simplify paths
This commit is contained in:
@@ -20,7 +20,6 @@ export {
|
||||
selectNodes,
|
||||
selectEdges,
|
||||
selectPathNodeIds,
|
||||
selectPathPausedSegmentNodeIds,
|
||||
selectPathActiveSegmentNodeIds,
|
||||
selectConnectionStatusForEdge,
|
||||
selectPathRoleForNode,
|
||||
|
||||
@@ -14,10 +14,11 @@ const initialGraph: GraphSlice = {
|
||||
edges: [],
|
||||
}
|
||||
|
||||
const PULSE_MS = 1000
|
||||
|
||||
const initialPath: PathSlice = {
|
||||
updatingNodeIds: [],
|
||||
triggerNodeIds: [],
|
||||
pausedNodeIds: [],
|
||||
pulseEndsAt: null,
|
||||
errorNodeIds: [],
|
||||
}
|
||||
|
||||
@@ -65,33 +66,14 @@ function reducePath(prev: PathSlice, cmd: CanvasCommand): PathSlice {
|
||||
case 'path/addTrigger': {
|
||||
const id = cmd.payload
|
||||
if (prev.triggerNodeIds.includes(id)) return prev
|
||||
return { ...prev, triggerNodeIds: [...prev.triggerNodeIds, id] }
|
||||
return {
|
||||
...prev,
|
||||
triggerNodeIds: [...prev.triggerNodeIds, id],
|
||||
pulseEndsAt: Date.now() + PULSE_MS,
|
||||
}
|
||||
}
|
||||
case 'path/clearTriggers':
|
||||
return { ...prev, triggerNodeIds: [] }
|
||||
case 'path/startUpdate': {
|
||||
const id = cmd.payload
|
||||
if (prev.updatingNodeIds.includes(id)) return prev
|
||||
return { ...prev, updatingNodeIds: [...prev.updatingNodeIds, id] }
|
||||
}
|
||||
case 'path/endUpdate': {
|
||||
const id = cmd.payload
|
||||
return {
|
||||
...prev,
|
||||
updatingNodeIds: prev.updatingNodeIds.filter((x) => x !== id),
|
||||
}
|
||||
}
|
||||
case 'path/setPaused': {
|
||||
const { nodeId, paused } = cmd.payload
|
||||
const has = prev.pausedNodeIds.includes(nodeId)
|
||||
if (paused === has) return prev
|
||||
return {
|
||||
...prev,
|
||||
pausedNodeIds: paused
|
||||
? [...prev.pausedNodeIds, nodeId]
|
||||
: prev.pausedNodeIds.filter((x) => x !== nodeId),
|
||||
}
|
||||
}
|
||||
return { ...prev, triggerNodeIds: [], pulseEndsAt: null }
|
||||
case 'path/setError': {
|
||||
const { nodeId, error } = cmd.payload
|
||||
const has = prev.errorNodeIds.includes(nodeId)
|
||||
@@ -104,10 +86,7 @@ function reducePath(prev: PathSlice, cmd: CanvasCommand): PathSlice {
|
||||
}
|
||||
}
|
||||
case 'path/clearPathSession':
|
||||
return {
|
||||
...initialPath,
|
||||
errorNodeIds: prev.errorNodeIds,
|
||||
}
|
||||
return { ...initialPath, errorNodeIds: prev.errorNodeIds }
|
||||
case 'path/clearErrors':
|
||||
return { ...prev, errorNodeIds: [] }
|
||||
default:
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
* Derived path sets (pathNodeIds, pausedSegmentNodeIds, activeSegmentNodeIds) are computed here.
|
||||
*/
|
||||
|
||||
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
|
||||
import {
|
||||
getPathNodeIds,
|
||||
getPathToUpdatingSegmentNodeIds,
|
||||
type NodeAttributesMap,
|
||||
} from '@/lib/graph/graphologyPath'
|
||||
import { getConnectionStatus, type ConnectionStatus } from '@/lib/graph/connectionStatus'
|
||||
import type { CanvasStore } from './canvasStore.types'
|
||||
import type { AppNode } from '@/lib/graph/nodeTypes'
|
||||
|
||||
export type ConnectionPathRole = 'trigger' | 'updating' | 'on-path' | null
|
||||
export type ConnectionPathRole = 'trigger' | 'on-path' | null
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw slices
|
||||
@@ -40,38 +45,44 @@ export function selectEdges(state: CanvasStore) {
|
||||
const emptySet = new Set<string>()
|
||||
|
||||
function edgesAsGraphEdges(edges: CanvasStore['graph']['edges']) {
|
||||
return edges.map((e) => ({ source: e.source, target: e.target }))
|
||||
return edges.map((e) => ({ source: e.source, target: e.target, id: e.id }))
|
||||
}
|
||||
|
||||
/** Build node attributes for graphology (nodeType, updateMode) from canvas nodes. */
|
||||
function buildNodeAttributesMap(nodes: AppNode[]): NodeAttributesMap {
|
||||
const map: NodeAttributesMap = {}
|
||||
for (const n of nodes) {
|
||||
const data = n.data as { updateMode?: 'auto' | 'manual' } | undefined
|
||||
map[n.id] = {
|
||||
nodeType: n.type ?? undefined,
|
||||
updateMode: data?.updateMode,
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export function selectPathNodeIds(state: CanvasStore): Set<string> {
|
||||
const { edges } = state.graph
|
||||
const { updatingNodeIds, triggerNodeIds, pausedNodeIds } = state.path
|
||||
const { nodes, edges } = state.graph
|
||||
const { triggerNodeIds } = state.path
|
||||
return getPathNodeIds(
|
||||
edgesAsGraphEdges(edges),
|
||||
updatingNodeIds,
|
||||
[],
|
||||
triggerNodeIds,
|
||||
pausedNodeIds
|
||||
)
|
||||
}
|
||||
|
||||
export function selectPathPausedSegmentNodeIds(state: CanvasStore): Set<string> {
|
||||
const pathNodeIds = selectPathNodeIds(state)
|
||||
const { edges } = state.graph
|
||||
const { triggerNodeIds, pausedNodeIds } = state.path
|
||||
return getPausedSegmentNodeIds(
|
||||
edgesAsGraphEdges(edges),
|
||||
pathNodeIds,
|
||||
triggerNodeIds,
|
||||
pausedNodeIds
|
||||
undefined,
|
||||
buildNodeAttributesMap(nodes)
|
||||
)
|
||||
}
|
||||
|
||||
export function selectPathActiveSegmentNodeIds(state: CanvasStore): Set<string> {
|
||||
const pathNodeIds = selectPathNodeIds(state)
|
||||
const pausedSegment = selectPathPausedSegmentNodeIds(state)
|
||||
const active = new Set(pathNodeIds)
|
||||
pausedSegment.forEach((id) => active.delete(id))
|
||||
return active
|
||||
const { nodes, edges } = state.graph
|
||||
const { triggerNodeIds, pulseEndsAt } = state.path
|
||||
const pulseActive = pulseEndsAt != null
|
||||
return getPathToUpdatingSegmentNodeIds(
|
||||
edgesAsGraphEdges(edges),
|
||||
triggerNodeIds,
|
||||
pulseActive,
|
||||
buildNodeAttributesMap(nodes)
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -84,14 +95,12 @@ export function selectConnectionStatusForEdge(
|
||||
target: string
|
||||
): ConnectionStatus {
|
||||
const pathNodeIds = selectPathNodeIds(state)
|
||||
const pausedSegmentNodeIds = selectPathPausedSegmentNodeIds(state)
|
||||
const activeSegmentNodeIds = selectPathActiveSegmentNodeIds(state)
|
||||
const errorTargetNodeIds = new Set(state.path.errorNodeIds)
|
||||
return getConnectionStatus({
|
||||
source,
|
||||
target,
|
||||
pathNodeIds,
|
||||
pausedSegmentNodeIds,
|
||||
activeSegmentNodeIds,
|
||||
errorTargetNodeIds,
|
||||
})
|
||||
@@ -108,7 +117,6 @@ export function selectPathRoleForNode(
|
||||
const pathNodeIds = selectPathNodeIds(state)
|
||||
if (!pathNodeIds.has(nodeId)) return null
|
||||
if (state.path.triggerNodeIds.includes(nodeId)) return 'trigger'
|
||||
if (state.path.updatingNodeIds.includes(nodeId)) return 'updating'
|
||||
return 'on-path'
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from './canvasStore'
|
||||
import {
|
||||
selectPathNodeIds,
|
||||
selectPathPausedSegmentNodeIds,
|
||||
selectPathActiveSegmentNodeIds,
|
||||
selectConnectionStatusForEdge,
|
||||
selectPathRoleForNode,
|
||||
@@ -141,42 +140,15 @@ describe('canvasStoreReducer', () => {
|
||||
expect(next.path.triggerNodeIds).toEqual([])
|
||||
})
|
||||
|
||||
it('path/startUpdate adds to updatingNodeIds', () => {
|
||||
it('path/addTrigger sets pulseEndsAt', () => {
|
||||
const state: CanvasStore = { ...initialCanvasStore }
|
||||
const before = Date.now()
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'path/startUpdate',
|
||||
type: 'path/addTrigger',
|
||||
payload: 'n1',
|
||||
})
|
||||
expect(next.path.updatingNodeIds).toEqual(['n1'])
|
||||
})
|
||||
|
||||
it('path/endUpdate removes from updatingNodeIds', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
path: {
|
||||
...initialCanvasStore.path,
|
||||
updatingNodeIds: ['n1', 'n2'],
|
||||
},
|
||||
}
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'path/endUpdate',
|
||||
payload: 'n1',
|
||||
})
|
||||
expect(next.path.updatingNodeIds).toEqual(['n2'])
|
||||
})
|
||||
|
||||
it('path/setPaused adds and removes paused node', () => {
|
||||
const state: CanvasStore = { ...initialCanvasStore }
|
||||
let next = canvasStoreReducer(state, {
|
||||
type: 'path/setPaused',
|
||||
payload: { nodeId: 'n1', paused: true },
|
||||
})
|
||||
expect(next.path.pausedNodeIds).toEqual(['n1'])
|
||||
next = canvasStoreReducer(next, {
|
||||
type: 'path/setPaused',
|
||||
payload: { nodeId: 'n1', paused: false },
|
||||
})
|
||||
expect(next.path.pausedNodeIds).toEqual([])
|
||||
expect(next.path.triggerNodeIds).toEqual(['n1'])
|
||||
expect(next.path.pulseEndsAt).toBeGreaterThanOrEqual(before + 1000)
|
||||
})
|
||||
|
||||
it('path/setError adds and removes error node', () => {
|
||||
@@ -193,20 +165,18 @@ describe('canvasStoreReducer', () => {
|
||||
expect(next.path.errorNodeIds).toEqual([])
|
||||
})
|
||||
|
||||
it('path/clearPathSession resets updating, trigger, paused; keeps error', () => {
|
||||
it('path/clearPathSession resets trigger and pulse; keeps error', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
path: {
|
||||
updatingNodeIds: ['u1'],
|
||||
triggerNodeIds: ['t1'],
|
||||
pausedNodeIds: ['p1'],
|
||||
pulseEndsAt: Date.now() + 1000,
|
||||
errorNodeIds: ['e1'],
|
||||
},
|
||||
}
|
||||
const next = canvasStoreReducer(state, { type: 'path/clearPathSession' })
|
||||
expect(next.path.updatingNodeIds).toEqual([])
|
||||
expect(next.path.triggerNodeIds).toEqual([])
|
||||
expect(next.path.pausedNodeIds).toEqual([])
|
||||
expect(next.path.pulseEndsAt).toBeNull()
|
||||
expect(next.path.errorNodeIds).toEqual(['e1'])
|
||||
})
|
||||
})
|
||||
@@ -280,9 +250,8 @@ describe('canvasStore selectors', () => {
|
||||
],
|
||||
},
|
||||
path: {
|
||||
updatingNodeIds: ['c'],
|
||||
triggerNodeIds: ['a'],
|
||||
pausedNodeIds: [],
|
||||
pulseEndsAt: null,
|
||||
errorNodeIds: [],
|
||||
},
|
||||
}
|
||||
@@ -311,15 +280,14 @@ describe('canvasStore selectors', () => {
|
||||
path: {
|
||||
...initialCanvasStore.path,
|
||||
triggerNodeIds: ['a'],
|
||||
updatingNodeIds: ['b'],
|
||||
pausedNodeIds: [],
|
||||
pulseEndsAt: null,
|
||||
errorNodeIds: ['b'],
|
||||
},
|
||||
}
|
||||
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('error')
|
||||
})
|
||||
|
||||
it('selectPathRoleForNode returns trigger when node in triggerNodeIds', () => {
|
||||
it('selectPathRoleForNode returns trigger or on-path', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: {
|
||||
@@ -329,13 +297,12 @@ describe('canvasStore selectors', () => {
|
||||
path: {
|
||||
...initialCanvasStore.path,
|
||||
triggerNodeIds: ['a'],
|
||||
updatingNodeIds: ['b'],
|
||||
pausedNodeIds: [],
|
||||
pulseEndsAt: null,
|
||||
errorNodeIds: [],
|
||||
},
|
||||
}
|
||||
expect(selectPathRoleForNode(state, 'a')).toBe('trigger')
|
||||
expect(selectPathRoleForNode(state, 'b')).toBe('updating')
|
||||
expect(selectPathRoleForNode(state, 'b')).toBe('on-path')
|
||||
expect(selectPathRoleForNode(state, 'x')).toBe(null)
|
||||
})
|
||||
|
||||
@@ -378,14 +345,13 @@ describe('canvas store integration', () => {
|
||||
expect(state.graph.nodes[0].id).toBe('test-1')
|
||||
})
|
||||
|
||||
it('dispatch path/addTrigger updates path and selectPathNodeIds', () => {
|
||||
it('dispatch path/addTrigger updates path and starts pulse', () => {
|
||||
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [makeNode('a'), makeNode('b')] })
|
||||
dispatchCanvasCommand({ type: 'graph/setEdges', payload: [makeEdge('e1', 'a', 'b')] })
|
||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: 'a' })
|
||||
dispatchCanvasCommand({ type: 'path/startUpdate', payload: 'b' })
|
||||
const state = getCanvasStore()
|
||||
expect(state.path.triggerNodeIds).toContain('a')
|
||||
expect(state.path.updatingNodeIds).toContain('b')
|
||||
expect(state.path.pulseEndsAt).not.toBeNull()
|
||||
const pathIds = selectPathNodeIds(state)
|
||||
expect(pathIds.has('a')).toBe(true)
|
||||
expect(pathIds.has('b')).toBe(true)
|
||||
@@ -396,4 +362,53 @@ describe('canvas store integration', () => {
|
||||
const state = getCanvasStore()
|
||||
expect(state.ui.fullscreenNodeId).toBe('full-node')
|
||||
})
|
||||
|
||||
// Pulse: addTrigger starts a short "updating" pulse along downstream path.
|
||||
it('path-per-sink: addTrigger starts pulse, all downstream edges show updating', () => {
|
||||
const nodes: AppNode[] = [
|
||||
{
|
||||
id: 'var_001',
|
||||
type: 'variable',
|
||||
position: { x: -270, y: 210 },
|
||||
data: { value: 'sss', valueType: 'string' },
|
||||
},
|
||||
{
|
||||
id: 'cfg_001',
|
||||
type: 'config',
|
||||
position: { x: 180, y: 330 },
|
||||
data: { configType: 'plantuml', content: '@startuml\nactor Mulis\n@enduml\n', title: 'cfg_001' },
|
||||
},
|
||||
{
|
||||
id: 'rnd_001',
|
||||
type: 'render',
|
||||
position: { x: 780, y: 540 },
|
||||
data: { updateMode: 'manual', runTrigger: 1, lastRunSourceSignature: 'sig1' },
|
||||
},
|
||||
{
|
||||
id: 'rnd_002',
|
||||
type: 'render',
|
||||
position: { x: 780, y: 180 },
|
||||
data: { updateMode: 'auto', runTrigger: 2, lastRunSourceSignature: 'sig2' },
|
||||
},
|
||||
]
|
||||
const edges: AppEdge[] = [
|
||||
{ id: 'xy-edge__var_001out-cfg_001ain', source: 'var_001', target: 'cfg_001', data: { targetType: 'config' } },
|
||||
{ id: 'xy-edge__cfg_001out-rnd_001ain', source: 'cfg_001', target: 'rnd_001', data: { targetType: 'render' } },
|
||||
{ id: 'xy-edge__cfg_001out-rnd_002ain', source: 'cfg_001', target: 'rnd_002', data: { targetType: 'render' } },
|
||||
]
|
||||
dispatchCanvasCommand({ type: 'graph/apply', payload: { nodes, edges } })
|
||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: 'var_001' })
|
||||
|
||||
const state = getCanvasStore()
|
||||
const pathIds = selectPathNodeIds(state)
|
||||
expect(pathIds.has('var_001')).toBe(true)
|
||||
expect(pathIds.has('cfg_001')).toBe(true)
|
||||
expect(pathIds.has('rnd_001')).toBe(true)
|
||||
expect(pathIds.has('rnd_002')).toBe(true)
|
||||
|
||||
// During pulse, all path edges show "updating".
|
||||
expect(selectConnectionStatusForEdge(state, 'var_001', 'cfg_001')).toBe('updating')
|
||||
expect(selectConnectionStatusForEdge(state, 'cfg_001', 'rnd_001')).toBe('updating')
|
||||
expect(selectConnectionStatusForEdge(state, 'cfg_001', 'rnd_002')).toBe('updating')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,10 +18,11 @@ export type GraphSlice = {
|
||||
// Path slice (primitive arrays; derived Sets are in selectors)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Pulse: "updating" shows along path for a short time after a trigger; no node start/end. */
|
||||
export type PathSlice = {
|
||||
updatingNodeIds: string[]
|
||||
triggerNodeIds: string[]
|
||||
pausedNodeIds: string[]
|
||||
/** When non-null, path from triggers shows "updating" until this time (ms). Cleared by timer. */
|
||||
pulseEndsAt: number | null
|
||||
errorNodeIds: string[]
|
||||
}
|
||||
|
||||
@@ -57,9 +58,6 @@ export type CanvasCommand =
|
||||
| { type: 'graph/apply'; payload: { nodes?: AppNode[]; edges?: AppEdge[] } }
|
||||
| { type: 'path/addTrigger'; payload: string }
|
||||
| { type: 'path/clearTriggers' }
|
||||
| { type: 'path/startUpdate'; payload: string }
|
||||
| { type: 'path/endUpdate'; payload: string }
|
||||
| { type: 'path/setPaused'; payload: { nodeId: string; paused: boolean } }
|
||||
| { type: 'path/setError'; payload: { nodeId: string; error: boolean } }
|
||||
| { type: 'path/clearPathSession' }
|
||||
| { type: 'path/clearErrors' }
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
|
||||
import { getPathNodeIds, getPathToUpdatingSegmentNodeIds } from '@/lib/graph/graphPath'
|
||||
|
||||
/** Serialize set to a stable string for equality. */
|
||||
function setToStableKey(s: Set<string>): string {
|
||||
@@ -14,8 +14,7 @@ function setToStableKey(s: Set<string>): string {
|
||||
|
||||
export type EdgeLike = { source: string; target: string }
|
||||
|
||||
/** Short tail (ms) after last updating node ends so the path doesn't vanish instantly. */
|
||||
const CONNECTION_PATH_UPDATE_TAIL_MS = 200
|
||||
const PULSE_MS = 1500
|
||||
|
||||
export type UseCanvasConnectionPathResult = {
|
||||
connectionPathUpdatingNodeIds: string[]
|
||||
@@ -35,51 +34,13 @@ export type UseCanvasConnectionPathResult = {
|
||||
}
|
||||
|
||||
export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionPathResult {
|
||||
const [connectionPathUpdatingNodeIds, setConnectionPathUpdatingNodeIds] = useState<string[]>([])
|
||||
const [connectionPathTriggerNodeIds, setConnectionPathTriggerNodeIds] = useState<string[]>([])
|
||||
const [connectionPathPausedNodeIds, setConnectionPathPausedNodeIds] = useState<string[]>([])
|
||||
const [pulseEndsAt, setPulseEndsAt] = useState<number | null>(null)
|
||||
const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = useState<string[]>([])
|
||||
|
||||
const pathUpdateNodeIdsRef = useRef<Set<string>>(new Set())
|
||||
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const connectionPathPausedNodeIdsRef = useRef<string[]>([])
|
||||
connectionPathPausedNodeIdsRef.current = connectionPathPausedNodeIds
|
||||
|
||||
const pathTriggerBatchRef = useRef<Set<string>>(new Set())
|
||||
const pathTriggerScheduledRef = useRef(false)
|
||||
|
||||
const clearPathUpdateSession = useCallback(() => {
|
||||
setConnectionPathUpdatingNodeIds([])
|
||||
if (connectionPathPausedNodeIdsRef.current.length === 0) {
|
||||
setConnectionPathTriggerNodeIds([])
|
||||
setConnectionPathPausedNodeIds([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startConnectionPathUpdate = useCallback((nodeId: string) => {
|
||||
const ref = pathUpdateNodeIdsRef.current
|
||||
ref.add(nodeId)
|
||||
if (ref.size === 1) {
|
||||
if (pathUpdateEndTimeoutRef.current != null) {
|
||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
||||
pathUpdateEndTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
setConnectionPathUpdatingNodeIds(Array.from(ref))
|
||||
}, [])
|
||||
|
||||
const endConnectionPathUpdate = useCallback((nodeId: string) => {
|
||||
const ref = pathUpdateNodeIdsRef.current
|
||||
ref.delete(nodeId)
|
||||
if (ref.size > 0) {
|
||||
setConnectionPathUpdatingNodeIds(Array.from(ref))
|
||||
return
|
||||
}
|
||||
pathUpdateEndTimeoutRef.current = setTimeout(() => {
|
||||
pathUpdateEndTimeoutRef.current = null
|
||||
clearPathUpdateSession()
|
||||
}, CONNECTION_PATH_UPDATE_TAIL_MS)
|
||||
}, [clearPathUpdateSession])
|
||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const addConnectionPathTrigger = useCallback((nodeId: string) => {
|
||||
pathTriggerBatchRef.current.add(nodeId)
|
||||
@@ -95,27 +56,31 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
||||
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 (pathUpdateEndTimeoutRef.current != null) {
|
||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
||||
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,
|
||||
connectionPathUpdatingNodeIds,
|
||||
connectionPathTriggerNodeIds,
|
||||
connectionPathPausedNodeIds
|
||||
),
|
||||
[edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds]
|
||||
getPathNodeIds(edges, [], connectionPathTriggerNodeIds, undefined),
|
||||
[edges, connectionPathTriggerNodeIds]
|
||||
)
|
||||
const connectionPathNodeIdsRef = useRef<Set<string>>(connectionPathNodeIdsRaw)
|
||||
const connectionPathNodeIdsKeyRef = useRef<string>('')
|
||||
@@ -128,37 +93,19 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
||||
return connectionPathNodeIdsRaw
|
||||
})()
|
||||
|
||||
const connectionPathPausedSegmentNodeIdsRaw = useMemo(
|
||||
() =>
|
||||
getPausedSegmentNodeIds(
|
||||
edges,
|
||||
connectionPathNodeIds,
|
||||
connectionPathTriggerNodeIds,
|
||||
connectionPathPausedNodeIds
|
||||
),
|
||||
[edges, connectionPathNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds]
|
||||
)
|
||||
const connectionPathPausedSegmentNodeIdsRef = useRef<Set<string>>(
|
||||
connectionPathPausedSegmentNodeIdsRaw
|
||||
)
|
||||
const connectionPathPausedSegmentNodeIdsKeyRef = useRef<string>('')
|
||||
const connectionPathPausedSegmentNodeIds =
|
||||
setToStableKey(connectionPathPausedSegmentNodeIdsRaw) ===
|
||||
connectionPathPausedSegmentNodeIdsKeyRef.current
|
||||
? connectionPathPausedSegmentNodeIdsRef.current
|
||||
: (() => {
|
||||
connectionPathPausedSegmentNodeIdsKeyRef.current = setToStableKey(
|
||||
connectionPathPausedSegmentNodeIdsRaw
|
||||
)
|
||||
connectionPathPausedSegmentNodeIdsRef.current = connectionPathPausedSegmentNodeIdsRaw
|
||||
return connectionPathPausedSegmentNodeIdsRaw
|
||||
})()
|
||||
const EMPTY_PAUSED_SEGMENT = useMemo(() => new Set<string>(), [])
|
||||
const connectionPathPausedSegmentNodeIds = EMPTY_PAUSED_SEGMENT
|
||||
|
||||
const connectionPathActiveSegmentNodeIdsRaw = useMemo(() => {
|
||||
const active = new Set(connectionPathNodeIds)
|
||||
connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id))
|
||||
return active
|
||||
}, [connectionPathNodeIds, connectionPathPausedSegmentNodeIds])
|
||||
const pulseActive = pulseEndsAt != null
|
||||
const connectionPathActiveSegmentNodeIdsRaw = useMemo(
|
||||
() =>
|
||||
getPathToUpdatingSegmentNodeIds(
|
||||
edges,
|
||||
connectionPathTriggerNodeIds,
|
||||
pulseActive
|
||||
),
|
||||
[edges, connectionPathTriggerNodeIds, pulseActive]
|
||||
)
|
||||
const connectionPathActiveSegmentNodeIdsRef = useRef<Set<string>>(
|
||||
connectionPathActiveSegmentNodeIdsRaw
|
||||
)
|
||||
@@ -175,13 +122,11 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
||||
return connectionPathActiveSegmentNodeIdsRaw
|
||||
})()
|
||||
|
||||
const addConnectionPathPausedNode = useCallback((nodeId: string) => {
|
||||
setConnectionPathPausedNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))
|
||||
}, [])
|
||||
const addConnectionPathPausedNode = useCallback((_nodeId: string) => {}, [])
|
||||
const removeConnectionPathPausedNode = useCallback((_nodeId: string) => {}, [])
|
||||
|
||||
const removeConnectionPathPausedNode = useCallback((nodeId: string) => {
|
||||
setConnectionPathPausedNodeIds((prev) => prev.filter((id) => id !== nodeId))
|
||||
}, [])
|
||||
const startConnectionPathUpdate = useCallback((_nodeId: string) => {}, [])
|
||||
const endConnectionPathUpdate = useCallback((_nodeId: string) => {}, [])
|
||||
|
||||
const addConnectionPathError = useCallback((nodeId: string) => {
|
||||
setConnectionPathErrorNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))
|
||||
@@ -192,9 +137,9 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
||||
}, [])
|
||||
|
||||
return {
|
||||
connectionPathUpdatingNodeIds,
|
||||
connectionPathUpdatingNodeIds: [],
|
||||
connectionPathTriggerNodeIds,
|
||||
connectionPathPausedNodeIds,
|
||||
connectionPathPausedNodeIds: [],
|
||||
connectionPathErrorNodeIds,
|
||||
connectionPathNodeIds,
|
||||
connectionPathPausedSegmentNodeIds,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Connection-path state and callbacks backed by the canvas store.
|
||||
* Replaces useCanvasConnectionPath when the store is the source of truth for path.
|
||||
* Sync graph to store (nodes, edges) from CanvasPage so path selectors have current edges.
|
||||
*
|
||||
* Subscribes only to path and edges (stable refs); derived Sets are computed in useMemo
|
||||
* so getSnapshot stays stable and we avoid "Maximum update depth" / getSnapshot loops.
|
||||
* "Updating" is time-bound: when a trigger is added, the path pulses for a short time
|
||||
* and then clears. No node start/end reporting.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
@@ -16,10 +13,10 @@ import {
|
||||
selectPathRoleForNode,
|
||||
type ConnectionPathRole,
|
||||
} from '@/app/canvas/canvasStore.selectors'
|
||||
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
|
||||
import { getPathNodeIds, getPathToUpdatingSegmentNodeIds } from '@/lib/graph/graphologyPath'
|
||||
import type { UseCanvasConnectionPathResult } from './useCanvasConnectionPath'
|
||||
|
||||
const CONNECTION_PATH_UPDATE_TAIL_MS = 200
|
||||
const EMPTY_PAUSED_SEGMENT = new Set<string>()
|
||||
|
||||
function edgesAsGraphEdges(
|
||||
edges: Array<{ source: string; target: string }>
|
||||
@@ -30,66 +27,63 @@ function edgesAsGraphEdges(
|
||||
export function useCanvasConnectionPathFromStore(): UseCanvasConnectionPathResult {
|
||||
const path = useCanvasStore((s) => s.path)
|
||||
const edges = useCanvasStore((s) => s.graph.edges)
|
||||
const pulseEndsAtRef = useRef<number | null>(null)
|
||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const pathNodeIds = useMemo(
|
||||
() =>
|
||||
getPathNodeIds(
|
||||
edgesAsGraphEdges(edges),
|
||||
path.updatingNodeIds,
|
||||
[],
|
||||
path.triggerNodeIds,
|
||||
path.pausedNodeIds
|
||||
undefined
|
||||
),
|
||||
[
|
||||
edges,
|
||||
path.updatingNodeIds,
|
||||
path.triggerNodeIds,
|
||||
path.pausedNodeIds,
|
||||
]
|
||||
[edges, path.triggerNodeIds]
|
||||
)
|
||||
const connectionPathPausedSegmentNodeIds = useMemo(
|
||||
const pulseActive = path.pulseEndsAt != null
|
||||
const connectionPathActiveSegmentNodeIds = useMemo(
|
||||
() =>
|
||||
getPausedSegmentNodeIds(
|
||||
getPathToUpdatingSegmentNodeIds(
|
||||
edgesAsGraphEdges(edges),
|
||||
pathNodeIds,
|
||||
path.triggerNodeIds,
|
||||
path.pausedNodeIds
|
||||
pulseActive
|
||||
),
|
||||
[edges, pathNodeIds, path.triggerNodeIds, path.pausedNodeIds]
|
||||
[edges, path.triggerNodeIds, pulseActive]
|
||||
)
|
||||
const connectionPathActiveSegmentNodeIds = useMemo(() => {
|
||||
const active = new Set(pathNodeIds)
|
||||
connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id))
|
||||
return active
|
||||
}, [pathNodeIds, connectionPathPausedSegmentNodeIds])
|
||||
|
||||
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const prevUpdatingLengthRef = useRef(path.updatingNodeIds.length)
|
||||
|
||||
const startConnectionPathUpdate = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({ type: 'path/startUpdate', payload: nodeId })
|
||||
}, [])
|
||||
|
||||
const endConnectionPathUpdate = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const endsAt = path.pulseEndsAt
|
||||
if (endsAt == null) {
|
||||
if (pulseTimerRef.current != null) {
|
||||
clearTimeout(pulseTimerRef.current)
|
||||
pulseTimerRef.current = null
|
||||
}
|
||||
pulseEndsAtRef.current = null
|
||||
return
|
||||
}
|
||||
if (endsAt === pulseEndsAtRef.current) return
|
||||
pulseEndsAtRef.current = endsAt
|
||||
const delay = Math.max(0, endsAt - Date.now())
|
||||
if (pulseTimerRef.current != null) clearTimeout(pulseTimerRef.current)
|
||||
pulseTimerRef.current = setTimeout(() => {
|
||||
pulseTimerRef.current = null
|
||||
pulseEndsAtRef.current = null
|
||||
dispatchCanvasCommand({ type: 'path/clearPathSession' })
|
||||
}, delay)
|
||||
return () => {
|
||||
if (pulseTimerRef.current != null) {
|
||||
clearTimeout(pulseTimerRef.current)
|
||||
pulseTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [path.pulseEndsAt])
|
||||
|
||||
const addConnectionPathTrigger = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: nodeId })
|
||||
}, [])
|
||||
|
||||
const addConnectionPathPausedNode = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({
|
||||
type: 'path/setPaused',
|
||||
payload: { nodeId, paused: true },
|
||||
})
|
||||
}, [])
|
||||
|
||||
const removeConnectionPathPausedNode = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({
|
||||
type: 'path/setPaused',
|
||||
payload: { nodeId, paused: false },
|
||||
})
|
||||
}, [])
|
||||
const addConnectionPathPausedNode = useCallback((_nodeId: string) => {}, [])
|
||||
const removeConnectionPathPausedNode = useCallback((_nodeId: string) => {}, [])
|
||||
|
||||
const addConnectionPathError = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({
|
||||
@@ -105,36 +99,16 @@ export function useCanvasConnectionPathFromStore(): UseCanvasConnectionPathResul
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevUpdatingLengthRef.current
|
||||
const now = path.updatingNodeIds.length
|
||||
prevUpdatingLengthRef.current = now
|
||||
if (prev > 0 && now === 0) {
|
||||
if (pathUpdateEndTimeoutRef.current != null) {
|
||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
||||
}
|
||||
pathUpdateEndTimeoutRef.current = setTimeout(() => {
|
||||
pathUpdateEndTimeoutRef.current = null
|
||||
dispatchCanvasCommand({ type: 'path/clearPathSession' })
|
||||
}, CONNECTION_PATH_UPDATE_TAIL_MS)
|
||||
}
|
||||
return () => {
|
||||
if (pathUpdateEndTimeoutRef.current != null) {
|
||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [path.updatingNodeIds.length])
|
||||
|
||||
return {
|
||||
connectionPathUpdatingNodeIds: path.updatingNodeIds,
|
||||
connectionPathUpdatingNodeIds: [],
|
||||
connectionPathTriggerNodeIds: path.triggerNodeIds,
|
||||
connectionPathPausedNodeIds: path.pausedNodeIds,
|
||||
connectionPathPausedNodeIds: [],
|
||||
connectionPathErrorNodeIds: path.errorNodeIds,
|
||||
connectionPathNodeIds: pathNodeIds,
|
||||
connectionPathPausedSegmentNodeIds: connectionPathPausedSegmentNodeIds,
|
||||
connectionPathPausedSegmentNodeIds: EMPTY_PAUSED_SEGMENT,
|
||||
connectionPathActiveSegmentNodeIds: connectionPathActiveSegmentNodeIds,
|
||||
startConnectionPathUpdate,
|
||||
endConnectionPathUpdate,
|
||||
startConnectionPathUpdate: () => {},
|
||||
endConnectionPathUpdate: () => {},
|
||||
addConnectionPathTrigger,
|
||||
addConnectionPathPausedNode,
|
||||
removeConnectionPathPausedNode,
|
||||
|
||||
Reference in New Issue
Block a user