import { useCallback, useEffect, useState } from 'react'; import type { ProcessDiagramProps } from './types'; import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams'; 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 { Minimap } from './Minimap'; import { isCompoundType } from './node-colors'; import styles from './ProcessDiagram.module.css'; const PADDING = 40; /** Types that support drill-down — double-click navigates to the target route */ const DRILLDOWN_TYPES = new Set(['DIRECT', 'SEDA']); /** Extract the target endpoint name from a node's label */ function extractTargetEndpoint(node: DiagramNodeType): string | null { // Labels like "to: direct:orderProcessing" or "direct:orderProcessing" const label = node.label ?? ''; const match = label.match(/(?:to:\s*)?(?:direct|seda):(\S+)/i); return match ? match[1] : null; } /** Convert camelCase to kebab-case: "callGetProduct" → "call-get-product" */ function camelToKebab(s: string): string { return s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); } /** * Resolve a direct/seda endpoint name to a routeId. * Tries: exact match, kebab-case conversion, then gives up. */ function resolveRouteId(endpoint: string, knownRouteIds: Set): string | null { if (knownRouteIds.has(endpoint)) return endpoint; const kebab = camelToKebab(endpoint); if (knownRouteIds.has(kebab)) return kebab; return null; } export function ProcessDiagram({ application, routeId, direction = 'LR', selectedNodeId, onNodeSelect, onNodeAction, nodeConfigs, knownRouteIds, className, }: ProcessDiagramProps) { // Route stack for drill-down navigation const [routeStack, setRouteStack] = useState([routeId]); // Reset stack when the external routeId prop changes useEffect(() => { setRouteStack([routeId]); }, [routeId]); const currentRouteId = routeStack[routeStack.length - 1]; const { sections, totalWidth, totalHeight, isLoading, error } = useDiagramData( application, currentRouteId, direction, ); const zoom = useZoomPan(); const toolbar = useToolbarHover(); const contentWidth = totalWidth + PADDING * 2; const contentHeight = totalHeight + PADDING * 2; // Reset to 100% at top-left when route changes useEffect(() => { if (totalWidth > 0 && totalHeight > 0) { zoom.resetView(); } }, [totalWidth, totalHeight, currentRouteId]); // eslint-disable-line react-hooks/exhaustive-deps const handleNodeClick = useCallback( (nodeId: string) => { onNodeSelect?.(nodeId); }, [onNodeSelect], ); const handleNodeDoubleClick = useCallback( (nodeId: string) => { const node = findNodeById(sections, nodeId); if (!node || !DRILLDOWN_TYPES.has(node.type ?? '')) return; const endpoint = extractTargetEndpoint(node); if (!endpoint) return; const resolved = knownRouteIds ? resolveRouteId(endpoint, knownRouteIds) : endpoint; if (resolved) { onNodeSelect?.(''); setRouteStack(prev => [...prev, resolved]); } }, [sections, onNodeSelect, knownRouteIds], ); const handleBreadcrumbClick = useCallback( (index: number) => { onNodeSelect?.(''); setRouteStack(prev => prev.slice(0, index + 1)); }, [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') { if (routeStack.length > 1) { // Go back one level setRouteStack(prev => prev.slice(0, -1)); } else { onNodeSelect?.(''); } return; } zoom.onKeyDown(e, contentWidth, contentHeight); }, [onNodeSelect, zoom, contentWidth, contentHeight, routeStack.length], ); 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 handlerSections = sections.slice(1); return (
{/* Breadcrumb bar — only shown when drilled down */} {routeStack.length > 1 && (
{routeStack.map((route, i) => ( {i > 0 && /} {i < routeStack.length - 1 ? ( ) : ( {route} )} ))}
)} 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)} onDoubleClick={() => node.id && handleNodeDoubleClick(node.id)} onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)} onMouseLeave={toolbar.onNodeLeave} /> ); })} {/* Toolbar rendered as HTML overlay below */} {/* Handler sections (completion, then error) */} {handlerSections.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; 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, ): DiagramNodeType | 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: DiagramNodeType[], nodeId: string, ): DiagramNodeType | 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; } function topLevelEdge( edge: import('../../api/queries/diagrams').DiagramEdge, nodes: DiagramNodeType[], ): boolean { 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: DiagramNodeType[], set: Set) { for (const n of nodes) { if (n.id) set.add(n.id); if (n.children) collectDescendantIds(n.children, set); } }