feat: add interactive ProcessDiagram SVG component (sub-project 1/3)
New interactive route diagram component with SVG rendering using server-computed ELK layout coordinates. TIBCO BW5-inspired top-bar card node style with zoom/pan, hover toolbars, config badges, and error handler sections below the main flow. Backend: add direction query parameter (LR/TB) to diagram render endpoints, defaulting to left-to-right layout. Frontend: 14-file ProcessDiagram component in ui/src/components/ with DiagramNode, CompoundNode, DiagramEdge, ConfigBadge, NodeToolbar, ErrorSection, ZoomControls, and supporting hooks. Dev test page at /dev/diagram for validation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
221
ui/src/components/ProcessDiagram/ProcessDiagram.tsx
Normal file
221
ui/src/components/ProcessDiagram/ProcessDiagram.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user