Files
cameleer-server/ui/src/components/ProcessDiagram/ProcessDiagram.tsx

222 lines
6.7 KiB
TypeScript
Raw Normal View History

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;
// Fit to view on first data load
useEffect(() => {
if (totalWidth > 0 && totalHeight > 0) {
zoom.fitToView(contentWidth, contentHeight);
}
}, [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 (
<div className={`${styles.container} ${className ?? ''}`}>
<div className={styles.loading}>Loading diagram...</div>
</div>
);
}
if (error) {
return (
<div className={`${styles.container} ${className ?? ''}`}>
<div className={styles.error}>Failed to load diagram</div>
</div>
);
}
if (sections.length === 0) {
return (
<div className={`${styles.container} ${className ?? ''}`}>
<div className={styles.loading}>No diagram data available</div>
</div>
);
}
const mainSection = sections[0];
const errorSections = sections.slice(1);
return (
<div
ref={zoom.containerRef}
className={`${styles.container} ${className ?? ''}`}
>
<svg
className={styles.svg}
viewBox={zoom.viewBox(contentWidth, contentHeight)}
onWheel={zoom.onWheel}
onPointerDown={zoom.onPointerDown}
onPointerMove={zoom.onPointerMove}
onPointerUp={zoom.onPointerUp}
onKeyDown={handleKeyDown}
tabIndex={0}
onClick={() => onNodeSelect?.('')}
>
<defs>
<marker
id="arrowhead"
markerWidth="8"
markerHeight="6"
refX="7"
refY="3"
orient="auto"
>
<polygon points="0 0, 8 3, 0 6" fill="#9CA3AF" />
</marker>
</defs>
<g transform={`translate(${PADDING}, ${PADDING})`}>
{/* Main section edges */}
<g className="edges">
{mainSection.edges.map((edge, i) => (
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} />
))}
</g>
{/* Main section nodes */}
<g className="nodes">
{mainSection.nodes.map(node => {
if (isCompoundType(node.type) && node.children && node.children.length > 0) {
return (
<CompoundNode
key={node.id}
node={node}
selectedNodeId={selectedNodeId}
hoveredNodeId={toolbar.hoveredNodeId}
nodeConfigs={nodeConfigs}
onNodeClick={handleNodeClick}
onNodeEnter={toolbar.onNodeEnter}
onNodeLeave={toolbar.onNodeLeave}
/>
);
}
return (
<DiagramNode
key={node.id}
node={node}
isHovered={toolbar.hoveredNodeId === node.id}
isSelected={selectedNodeId === node.id}
config={node.id ? nodeConfigs?.get(node.id) : undefined}
onClick={() => node.id && handleNodeClick(node.id)}
onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)}
onMouseLeave={toolbar.onNodeLeave}
/>
);
})}
</g>
{/* Toolbar for hovered node */}
{toolbar.hoveredNodeId && onNodeAction && (() => {
const hNode = findNodeById(sections, toolbar.hoveredNodeId!);
if (!hNode) return null;
return (
<NodeToolbar
nodeId={toolbar.hoveredNodeId!}
nodeX={hNode.x ?? 0}
nodeY={hNode.y ?? 0}
nodeWidth={hNode.width ?? 120}
onAction={handleNodeAction}
onMouseEnter={toolbar.onToolbarEnter}
onMouseLeave={toolbar.onToolbarLeave}
/>
);
})()}
{/* Error handler sections */}
{errorSections.map((section, i) => (
<ErrorSection
key={`error-${i}`}
section={section}
totalWidth={totalWidth}
selectedNodeId={selectedNodeId}
hoveredNodeId={toolbar.hoveredNodeId}
nodeConfigs={nodeConfigs}
onNodeClick={handleNodeClick}
onNodeEnter={toolbar.onNodeEnter}
onNodeLeave={toolbar.onNodeLeave}
/>
))}
</g>
</svg>
<ZoomControls
onZoomIn={zoom.zoomIn}
onZoomOut={zoom.zoomOut}
onFitToView={() => zoom.fitToView(contentWidth, contentHeight)}
scale={zoom.state.scale}
/>
</div>
);
}
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 child = node.children.find(c => c.id === nodeId);
if (child) return child;
}
}
}
return undefined;
}