diff --git a/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx b/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx index e19ddf7e..fc5378c2 100644 --- a/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx +++ b/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx @@ -80,8 +80,9 @@ export function ExecutionDiagram({ // 4. Compute overlay const overlay = useExecutionOverlay(detail?.processors, iterationState); - // 5. Manage selection + // 5. Manage selection + center-on-node const [selectedProcessorId, setSelectedProcessorId] = useState(''); + const [centerOnNodeId, setCenterOnNodeId] = useState(''); // 6. Resizable splitter state const [splitPercent, setSplitPercent] = useState(60); @@ -105,12 +106,15 @@ export function ExecutionDiagram({ document.addEventListener('pointerup', onUp); }, []); - // Jump to error: find first FAILED processor and select it + // Jump to error: find first FAILED processor, select it, and center the viewport const handleJumpToError = useCallback(() => { if (!detail?.processors) return; const failed = findFailedProcessor(detail.processors); if (failed?.processorId) { setSelectedProcessorId(failed.processorId); + // Use a unique value to re-trigger centering even if the same node + setCenterOnNodeId(''); + requestAnimationFrame(() => setCenterOnNodeId(failed.processorId)); } }, [detail?.processors]); @@ -187,6 +191,7 @@ export function ExecutionDiagram({ executionOverlay={overlay} iterationState={iterationState} onIterationChange={setIteration} + centerOnNodeId={centerOnNodeId} /> diff --git a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx index d1b33c00..382c22fb 100644 --- a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx +++ b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx @@ -56,6 +56,7 @@ export function ProcessDiagram({ executionOverlay, iterationState, onIterationChange, + centerOnNodeId, }: ProcessDiagramProps) { // Route stack for drill-down navigation const [routeStack, setRouteStack] = useState([routeId]); @@ -106,6 +107,33 @@ export function ProcessDiagram({ } }, [totalWidth, totalHeight, currentRouteId]); // eslint-disable-line react-hooks/exhaustive-deps + // Center on a specific node when centerOnNodeId changes + useEffect(() => { + if (!centerOnNodeId || sections.length === 0) return; + const node = findNodeById(sections, centerOnNodeId); + if (!node) return; + const container = zoom.containerRef.current; + if (!container) return; + // Compute the node center in diagram coordinates + const nodeX = (node.x ?? 0) + (node.width ?? 160) / 2; + const nodeY = (node.y ?? 0) + (node.height ?? 40) / 2; + // Find which section the node is in to add its offsetY + let sectionOffsetY = 0; + for (const section of sections) { + const found = findNodeInSection(section.nodes, centerOnNodeId); + if (found) { sectionOffsetY = section.offsetY; break; } + } + const adjustedY = nodeY + sectionOffsetY; + // Pan so the node center is at the viewport center + const cw = container.clientWidth; + const ch = container.clientHeight; + const scale = zoom.state.scale; + zoom.panTo( + cw / 2 - nodeX * scale, + ch / 2 - adjustedY * scale, + ); + }, [centerOnNodeId]); // eslint-disable-line react-hooks/exhaustive-deps + // Resolve execution state for a node. ENDPOINT nodes (the route's "from:") // don't appear in the processor execution tree, but should be marked as // COMPLETED when the route executed (i.e., overlay has any entries). @@ -415,6 +443,13 @@ function findInChildren( return undefined; } +function findNodeInSection( + nodes: DiagramNodeType[], + nodeId: string, +): boolean { + return !!findInChildren(nodes, nodeId) || nodes.some(n => n.id === nodeId); +} + function topLevelEdge( edge: import('../../api/queries/diagrams').DiagramEdge, nodes: DiagramNodeType[], diff --git a/ui/src/components/ProcessDiagram/types.ts b/ui/src/components/ProcessDiagram/types.ts index 01d3776d..e7a6e516 100644 --- a/ui/src/components/ProcessDiagram/types.ts +++ b/ui/src/components/ProcessDiagram/types.ts @@ -35,4 +35,6 @@ export interface ProcessDiagramProps { iterationState?: Map; /** Called when user changes iteration on a compound stepper */ onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void; + /** When set, the diagram pans to center this node in the viewport */ + centerOnNodeId?: string; }