import { useCallback, useEffect, useRef } from 'react'; import { ANIM_SPEED, NODE } from '../constants/canvas-constants'; import { getStateColor } from '../constants/colors'; import { buildStableSlotLayoutSnapshot, resolveNearestSlotAssignment, snapshotToWorldBounds, translateSlotFrame, validateStableSlotLayout, type StableSlotLayoutSnapshot, type StableRect, type SlotFrame, } from '../layout/stableSlots'; import { KanbanLayoutEngine } from '../layout/kanbanLayout'; import type { GraphEdge, GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment, GraphParticle } from '../ports/types'; import type { WorldBounds } from '../layout/launchAnchor'; import { createCompleteEffect, createSpawnEffect, type VisualEffect } from '../canvas/draw-effects'; export interface SimulationState { nodes: GraphNode[]; edges: GraphEdge[]; particles: GraphParticle[]; effects: VisualEffect[]; time: number; } export interface UseGraphSimulationResult { stateRef: { current: SimulationState }; updateData: ( nodes: GraphNode[], edges: GraphEdge[], particles: GraphParticle[], teamName: string, layout?: GraphLayoutPort ) => void; tick: (dt: number) => void; setNodePosition: (nodeId: string, x: number, y: number) => void; clearNodePosition: (nodeId: string) => void; clearTransientOwnerPositions: () => void; resolveNearestOwnerSlot: ( nodeId: string, x: number, y: number ) => { assignment: GraphOwnerSlotAssignment; displacedOwnerId?: string; displacedAssignment?: GraphOwnerSlotAssignment; previewOwnerX: number; previewOwnerY: number; } | null; getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null; getActivityWorldRect: (nodeId: string) => StableRect | null; getExtraWorldBounds: () => WorldBounds[]; } export function useGraphSimulation(): UseGraphSimulationResult { const stateRef = useRef({ nodes: [], edges: [], particles: [], effects: [], time: 0, }); const teamNameRef = useRef(''); const layoutRef = useRef(undefined); const layoutSnapshotRef = useRef(null); const lastValidSnapshotByTeamRef = useRef(new Map()); const dragOwnerPositionsRef = useRef(new Map()); const launchAnchorPositionsRef = useRef(new Map()); const activityRectByNodeIdRef = useRef(new Map()); const extraWorldBoundsRef = useRef([]); const prevNodeIdsRef = useRef(new Set()); const prevNodeStatesRef = useRef(new Map()); const allKnownNodeIdsRef = useRef(new Set()); const applyCurrentLayout = useCallback(() => { const state = stateRef.current; const nextSnapshot = buildStableSlotLayoutSnapshot({ teamName: teamNameRef.current, nodes: state.nodes, layout: layoutRef.current, }); if (nextSnapshot) { const validation = validateStableSlotLayout(nextSnapshot); if (validation.valid) { commitSnapshotGeometry({ nodes: state.nodes, snapshot: nextSnapshot, teamName: teamNameRef.current, layoutSnapshotRef, lastValidSnapshotByTeamRef, dragOwnerPositionsRef, launchAnchorPositionsRef, activityRectByNodeIdRef, extraWorldBoundsRef, }); return; } console.warn( `[agent-graph] invalid stable slot layout for team=${teamNameRef.current}: ${validation.reason ?? 'unknown reason'}` ); const lastValidSnapshot = lastValidSnapshotByTeamRef.current.get(teamNameRef.current); if (lastValidSnapshot) { commitSnapshotGeometry({ nodes: state.nodes, snapshot: lastValidSnapshot, teamName: teamNameRef.current, layoutSnapshotRef, lastValidSnapshotByTeamRef, dragOwnerPositionsRef, launchAnchorPositionsRef, activityRectByNodeIdRef, extraWorldBoundsRef, fillMissingFallbackPositions: true, }); return; } } resetToFallbackLayout({ nodes: state.nodes, layoutSnapshotRef, launchAnchorPositionsRef, activityRectByNodeIdRef, extraWorldBoundsRef, }); }, []); const updateData = useCallback( ( nodes: GraphNode[], edges: GraphEdge[], particles: GraphParticle[], teamName: string, layout?: GraphLayoutPort ) => { const state = stateRef.current; teamNameRef.current = teamName; layoutRef.current = layout; preserveReusableNodePositions(nodes, state.nodes); recordNodeLifecycleEffects(state.effects, nodes, prevNodeStatesRef.current, allKnownNodeIdsRef.current); prevNodeIdsRef.current = new Set(nodes.map((node) => node.id)); prevNodeStatesRef.current = new Map(nodes.map((node) => [node.id, node.state])); state.nodes = nodes; state.edges = edges; state.particles = mergeParticles(state.particles, particles); applyCurrentLayout(); }, [applyCurrentLayout] ); const tick = useCallback((dt: number) => { const state = stateRef.current; state.time += dt; const nextParticles: GraphParticle[] = []; for (const particle of state.particles) { particle.progress += dt * ANIM_SPEED.particleSpeed * 0.5; if (particle.progress < 1) { nextParticles.push(particle); } } state.particles = nextParticles; const nextEffects: VisualEffect[] = []; for (const effect of state.effects) { effect.age += dt; if (effect.age < effect.duration) { nextEffects.push(effect); } } state.effects = nextEffects; }, []); const setNodePosition = useCallback( (nodeId: string, x: number, y: number) => { const node = stateRef.current.nodes.find((candidate) => candidate.id === nodeId); if (node?.kind !== 'member') { return; } dragOwnerPositionsRef.current.set(nodeId, { x, y }); applyCurrentLayout(); }, [applyCurrentLayout] ); const clearNodePosition = useCallback( (nodeId: string) => { if (!dragOwnerPositionsRef.current.delete(nodeId)) { return; } applyCurrentLayout(); }, [applyCurrentLayout] ); const clearTransientOwnerPositions = useCallback(() => { if (dragOwnerPositionsRef.current.size === 0) { return; } dragOwnerPositionsRef.current.clear(); applyCurrentLayout(); }, [applyCurrentLayout]); const resolveNearestOwnerSlot = useCallback( (nodeId: string, x: number, y: number) => { const snapshot = layoutSnapshotRef.current; if (!snapshot) { return null; } return resolveNearestSlotAssignment({ ownerId: nodeId, ownerX: x, ownerY: y, nodes: stateRef.current.nodes, snapshot, layout: layoutRef.current, }); }, [] ); useEffect(() => { return () => { dragOwnerPositionsRef.current.clear(); launchAnchorPositionsRef.current.clear(); activityRectByNodeIdRef.current.clear(); extraWorldBoundsRef.current = []; layoutSnapshotRef.current = null; lastValidSnapshotByTeamRef.current.clear(); }; }, []); return { stateRef, updateData, tick, setNodePosition, clearNodePosition, clearTransientOwnerPositions, resolveNearestOwnerSlot, getLaunchAnchorWorldPosition: (leadNodeId: string) => launchAnchorPositionsRef.current.get(leadNodeId) ?? null, getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null, getExtraWorldBounds: () => extraWorldBoundsRef.current, }; } function applySnapshotToNodes( nodes: GraphNode[], snapshot: StableSlotLayoutSnapshot, dragOwnerPositions: ReadonlyMap ): void { const translatedFrames = getTranslatedMemberFrames(snapshot, dragOwnerPositions); const translatedFrameByOwnerId = new Map( translatedFrames.map((frame) => [frame.ownerId, frame] as const) ); const leadFrame = snapshot.leadSlotFrame; const leadId = snapshot.leadNodeId; for (const node of nodes) { if (node.kind === 'lead' && node.id === leadId) { node.x = leadFrame.ownerX; node.y = leadFrame.ownerY; node.fx = leadFrame.ownerX; node.fy = leadFrame.ownerY; node.vx = 0; node.vy = 0; continue; } if (node.kind === 'member') { const frame = translatedFrameByOwnerId.get(node.id); if (!frame) { continue; } node.x = frame.ownerX; node.y = frame.ownerY; node.fx = frame.ownerX; node.fy = frame.ownerY; node.vx = 0; node.vy = 0; } } positionProcessNodes(nodes, [snapshot.leadSlotFrame, ...translatedFrames]); KanbanLayoutEngine.layout(nodes, { memberSlotFrames: translatedFrames, leadSlotFrame: snapshot.leadSlotFrame, unassignedTaskRect: snapshot.unassignedTaskRect, }); positionCrossTeamNodes(nodes, snapshot.fitBounds); } function commitSnapshotGeometry(args: { nodes: GraphNode[]; snapshot: StableSlotLayoutSnapshot; teamName: string; layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null }; lastValidSnapshotByTeamRef: { current: Map }; dragOwnerPositionsRef: { current: ReadonlyMap }; launchAnchorPositionsRef: { current: Map }; activityRectByNodeIdRef: { current: Map }; extraWorldBoundsRef: { current: WorldBounds[] }; fillMissingFallbackPositions?: boolean; }): void { const { nodes, snapshot, teamName, layoutSnapshotRef, lastValidSnapshotByTeamRef, dragOwnerPositionsRef, launchAnchorPositionsRef, activityRectByNodeIdRef, extraWorldBoundsRef, fillMissingFallbackPositions = false, } = args; layoutSnapshotRef.current = snapshot; lastValidSnapshotByTeamRef.current.set(teamName, snapshot); applySnapshotToNodes(nodes, snapshot, dragOwnerPositionsRef.current); if (fillMissingFallbackPositions) { fallbackPositionNodes(nodes); } launchAnchorPositionsRef.current.clear(); activityRectByNodeIdRef.current.clear(); extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot); for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) { activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect); } if (snapshot.leadNodeId) { activityRectByNodeIdRef.current.set( snapshot.leadNodeId, snapshot.leadSlotFrame.activityColumnRect ); } } function resetToFallbackLayout(args: { nodes: GraphNode[]; layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null }; launchAnchorPositionsRef: { current: Map }; activityRectByNodeIdRef: { current: Map }; extraWorldBoundsRef: { current: WorldBounds[] }; }): void { const { nodes, layoutSnapshotRef, launchAnchorPositionsRef, activityRectByNodeIdRef, extraWorldBoundsRef, } = args; layoutSnapshotRef.current = null; launchAnchorPositionsRef.current.clear(); activityRectByNodeIdRef.current.clear(); extraWorldBoundsRef.current = []; fallbackPositionNodes(nodes); KanbanLayoutEngine.layout(nodes); } function preserveReusableNodePositions( nodes: GraphNode[], previousNodes: GraphNode[] ): void { const previousPositionById = new Map( previousNodes .filter((node) => node.x != null && node.y != null) .map((node) => [ node.id, { x: node.x!, y: node.y!, vx: node.vx ?? 0, vy: node.vy ?? 0 }, ] as const) ); for (const node of nodes) { const previous = previousPositionById.get(node.id); if ( !previous || node.kind === 'lead' || node.kind === 'member' || node.kind === 'task' || node.kind === 'process' ) { continue; } node.x = previous.x; node.y = previous.y; node.vx = previous.vx; node.vy = previous.vy; } } function recordNodeLifecycleEffects( effects: VisualEffect[], nodes: GraphNode[], prevStates: ReadonlyMap, allKnown: Set ): void { for (const node of nodes) { if (!allKnown.has(node.id) && node.x != null && node.y != null) { const nodeRadius = resolveNodeEffectRadius(node); effects.push( createSpawnEffect(node.x, node.y, node.color ?? getStateColor(node.state), nodeRadius) ); } const prevState = prevStates.get(node.id); if ( prevState && prevState !== 'complete' && node.state === 'complete' && node.x != null && node.y != null ) { effects.push(createCompleteEffect(node.x, node.y, node.color ?? getStateColor(node.state))); } allKnown.add(node.id); } } function resolveNodeEffectRadius(node: GraphNode): number | undefined { if (node.kind === 'lead') { return NODE.radiusLead; } if (node.kind === 'member') { return NODE.radiusMember; } return undefined; } function getTranslatedMemberFrames( snapshot: StableSlotLayoutSnapshot, dragOwnerPositions: ReadonlyMap ): SlotFrame[] { return snapshot.memberSlotFrames.map((frame) => { const dragPosition = dragOwnerPositions.get(frame.ownerId); if (!dragPosition) { return frame; } return translateSlotFrame(frame, dragPosition.x - frame.ownerX, dragPosition.y - frame.ownerY); }); } function positionProcessNodes(nodes: GraphNode[], frames: readonly SlotFrame[]): void { const frameByOwnerId = new Map(frames.map((frame) => [frame.ownerId, frame] as const)); const processNodesByOwnerId = new Map(); for (const node of nodes) { if (node.kind !== 'process' || !node.ownerId) { continue; } const existing = processNodesByOwnerId.get(node.ownerId) ?? []; existing.push(node); processNodesByOwnerId.set(node.ownerId, existing); } for (const [ownerId, processNodes] of processNodesByOwnerId) { const frame = frameByOwnerId.get(ownerId); if (!frame) { continue; } const gap = 42; const totalWidth = Math.max(0, (processNodes.length - 1) * gap); for (const [index, node] of processNodes.entries()) { const x = frame.ownerX - totalWidth / 2 + index * gap; const y = frame.processBandRect.top + frame.processBandRect.height / 2; node.x = x; node.y = y; node.fx = x; node.fy = y; node.vx = 0; node.vy = 0; } } } function positionCrossTeamNodes(nodes: GraphNode[], fitBounds: StableSlotLayoutSnapshot['fitBounds']): void { const crossTeamNodes = nodes.filter((node) => node.kind === 'crossteam'); if (crossTeamNodes.length === 0) { return; } const radius = Math.max( Math.abs(fitBounds.left), Math.abs(fitBounds.right), Math.abs(fitBounds.top), Math.abs(fitBounds.bottom) ) + 220; const startAngle = (-150 * Math.PI) / 180; const endAngle = (150 * Math.PI) / 180; crossTeamNodes.forEach((node, index) => { const t = crossTeamNodes.length === 1 ? 0.5 : index / Math.max(crossTeamNodes.length - 1, 1); const angle = startAngle + (endAngle - startAngle) * t; const x = Math.cos(angle) * radius; const y = Math.sin(angle) * radius; node.x = x; node.y = y; node.fx = x; node.fy = y; node.vx = 0; node.vy = 0; }); } function fallbackPositionNodes(nodes: GraphNode[]): void { nodes.forEach((node, index) => { if (node.kind === 'task') { return; } if (node.x != null && node.y != null) { return; } const row = Math.floor(index / 4); const col = index % 4; const x = (col - 1.5) * 220; const y = (row - 1) * 220; node.x = x; node.y = y; node.fx = x; node.fy = y; node.vx = 0; node.vy = 0; }); } function mergeParticles(existing: GraphParticle[], incoming: GraphParticle[]): GraphParticle[] { if (existing.length === 0) return incoming; if (incoming.length === 0) return existing; const merged = existing.slice(); const seen = new Set(existing.map((particle) => particle.id)); for (const particle of incoming) { if (seen.has(particle.id)) continue; merged.push(particle); seen.add(particle.id); } return merged; }