import { useCallback, useEffect } from 'react'; import type { ProcessDiagramProps } from './types'; import { useDiagramData } from './useDiagramData'; import { useZoomPan } from './useZoomPan'; import { useToolbarHover, NodeToolbar } from './NodeToolbar'; import { DiagramNode } from './DiagramNode'; import { DiagramEdge } from './DiagramEdge'; import { CompoundNode } from './CompoundNode'; import { ErrorSection } from './ErrorSection'; import { ZoomControls } from './ZoomControls'; import { isCompoundType } from './node-colors'; import styles from './ProcessDiagram.module.css'; const PADDING = 40; export function ProcessDiagram({ application, routeId, direction = 'LR', selectedNodeId, onNodeSelect, onNodeAction, nodeConfigs, className, }: ProcessDiagramProps) { const { sections, totalWidth, totalHeight, isLoading, error } = useDiagramData( application, routeId, direction, ); const zoom = useZoomPan(); const toolbar = useToolbarHover(); const contentWidth = totalWidth + PADDING * 2; const contentHeight = totalHeight + PADDING * 2; // Reset to 100% at top-left on first data load useEffect(() => { if (totalWidth > 0 && totalHeight > 0) { zoom.resetView(); } }, [totalWidth, totalHeight]); // eslint-disable-line react-hooks/exhaustive-deps const handleNodeClick = useCallback( (nodeId: string) => { onNodeSelect?.(nodeId); }, [onNodeSelect], ); const handleNodeAction = useCallback( (nodeId: string, action: import('./types').NodeAction) => { if (action === 'inspect') onNodeSelect?.(nodeId); onNodeAction?.(nodeId, action); }, [onNodeSelect, onNodeAction], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Escape') { onNodeSelect?.(''); return; } zoom.onKeyDown(e, contentWidth, contentHeight); }, [onNodeSelect, zoom, contentWidth, contentHeight], ); if (isLoading) { return (
Loading diagram...
); } if (error) { return (
Failed to load diagram
); } if (sections.length === 0) { return (
No diagram data available
); } const mainSection = sections[0]; const errorSections = sections.slice(1); return (
onNodeSelect?.('')} > {/* Main section top-level edges (not inside compounds) */} {mainSection.edges.filter(e => topLevelEdge(e, mainSection.nodes)).map((edge, i) => ( ))} {/* Main section nodes */} {mainSection.nodes.map(node => { if (isCompoundType(node.type) && node.children && node.children.length > 0) { return ( ); } return ( node.id && handleNodeClick(node.id)} onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)} onMouseLeave={toolbar.onNodeLeave} /> ); })} {/* Toolbar rendered as HTML overlay below */} {/* Error handler sections */} {errorSections.map((section, i) => ( ))} {/* Node toolbar — HTML overlay, fixed size regardless of zoom */} {toolbar.hoveredNodeId && onNodeAction && (() => { const hNode = findNodeById(sections, toolbar.hoveredNodeId!); if (!hNode) return null; // Convert SVG coordinates to screen-space using zoom transform const nodeCenter = (hNode.x ?? 0) + (hNode.width ?? 160) / 2; const nodeTop = hNode.y ?? 0; const screenX = nodeCenter * zoom.state.scale + zoom.state.translateX; const screenY = nodeTop * zoom.state.scale + zoom.state.translateY; return ( ); })()} zoom.fitToView(contentWidth, contentHeight)} scale={zoom.state.scale} />
); } function findNodeById( sections: import('./types').DiagramSection[], nodeId: string, ): import('../../api/queries/diagrams').DiagramNode | undefined { for (const section of sections) { for (const node of section.nodes) { if (node.id === nodeId) return node; if (node.children) { const found = findInChildren(node.children, nodeId); if (found) return found; } } } return undefined; } function findInChildren( nodes: import('../../api/queries/diagrams').DiagramNode[], nodeId: string, ): import('../../api/queries/diagrams').DiagramNode | undefined { for (const n of nodes) { if (n.id === nodeId) return n; if (n.children) { const found = findInChildren(n.children, nodeId); if (found) return found; } } return undefined; } /** Returns true if the edge connects two top-level nodes (not inside any compound). */ function topLevelEdge( edge: import('../../api/queries/diagrams').DiagramEdge, nodes: import('../../api/queries/diagrams').DiagramNode[], ): boolean { // Collect all IDs that are children of compound nodes (at any depth) const compoundChildIds = new Set(); for (const n of nodes) { if (n.children && n.children.length > 0) { collectDescendantIds(n.children, compoundChildIds); } } return !compoundChildIds.has(edge.sourceId) && !compoundChildIds.has(edge.targetId); } function collectDescendantIds( nodes: import('../../api/queries/diagrams').DiagramNode[], set: Set, ) { for (const n of nodes) { if (n.id) set.add(n.id); if (n.children) collectDescendantIds(n.children, set); } }