feat: add interactive ProcessDiagram SVG component (sub-project 1/3)
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m0s
CI / docker (push) Successful in 56s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s

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:
hsiegeln
2026-03-27 13:55:29 +01:00
parent 78e12f5cf9
commit ac32396a57
24 changed files with 7264 additions and 18 deletions

View File

@@ -0,0 +1,82 @@
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
import type { NodeConfig } from './types';
import { colorForType } from './node-colors';
import { DiagramNode } from './DiagramNode';
const HEADER_HEIGHT = 22;
const CORNER_RADIUS = 4;
interface CompoundNodeProps {
node: DiagramNodeType;
selectedNodeId?: string;
hoveredNodeId: string | null;
nodeConfigs?: Map<string, NodeConfig>;
onNodeClick: (nodeId: string) => void;
onNodeEnter: (nodeId: string) => void;
onNodeLeave: () => void;
}
export function CompoundNode({
node, selectedNodeId, hoveredNodeId, nodeConfigs,
onNodeClick, onNodeEnter, onNodeLeave,
}: CompoundNodeProps) {
const x = node.x ?? 0;
const y = node.y ?? 0;
const w = node.width ?? 200;
const h = node.height ?? 100;
const color = colorForType(node.type);
const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? '';
const label = node.label ? `${typeName}: ${node.label}` : typeName;
return (
<g data-node-id={node.id} transform={`translate(${x}, ${y})`}>
{/* Container body */}
<rect
x={0}
y={0}
width={w}
height={h}
rx={CORNER_RADIUS}
fill="white"
stroke={color}
strokeWidth={1.5}
/>
{/* Colored header bar */}
<rect x={0} y={0} width={w} height={HEADER_HEIGHT} rx={CORNER_RADIUS} fill={color} />
<rect x={CORNER_RADIUS} y={CORNER_RADIUS} width={w - CORNER_RADIUS * 2} height={HEADER_HEIGHT - CORNER_RADIUS} fill={color} />
{/* Header label */}
<text
x={w / 2}
y={HEADER_HEIGHT / 2 + 4}
fill="white"
fontSize={10}
fontWeight={600}
textAnchor="middle"
>
{label}
</text>
{/* Children nodes (positioned relative to compound) */}
{node.children?.map(child => (
<DiagramNode
key={child.id}
node={{
...child,
// Children have absolute coordinates from the backend,
// but since we're inside the compound's translate, subtract parent offset
x: (child.x ?? 0) - x,
y: (child.y ?? 0) - y,
}}
isHovered={hoveredNodeId === child.id}
isSelected={selectedNodeId === child.id}
config={child.id ? nodeConfigs?.get(child.id) : undefined}
onClick={() => child.id && onNodeClick(child.id)}
onMouseEnter={() => child.id && onNodeEnter(child.id)}
onMouseLeave={onNodeLeave}
/>
))}
</g>
);
}

View File

@@ -0,0 +1,48 @@
import type { NodeConfig } from './types';
const BADGE_HEIGHT = 14;
const BADGE_RADIUS = 7;
const BADGE_FONT_SIZE = 8;
const BADGE_GAP = 4;
interface ConfigBadgeProps {
nodeWidth: number;
config: NodeConfig;
}
export function ConfigBadge({ nodeWidth, config }: ConfigBadgeProps) {
const badges: { label: string; color: string }[] = [];
if (config.tapExpression) badges.push({ label: 'TAP', color: '#7C3AED' });
if (config.traceEnabled) badges.push({ label: 'TRACE', color: '#1A7F8E' });
if (badges.length === 0) return null;
let xOffset = nodeWidth;
return (
<g className="config-badges">
{badges.map((badge, i) => {
const textWidth = badge.label.length * 5.5 + 8;
xOffset -= textWidth + (i > 0 ? BADGE_GAP : 0);
return (
<g key={badge.label} transform={`translate(${xOffset}, ${-BADGE_HEIGHT / 2 - 2})`}>
<rect
width={textWidth}
height={BADGE_HEIGHT}
rx={BADGE_RADIUS}
fill={badge.color}
/>
<text
x={textWidth / 2}
y={BADGE_HEIGHT / 2 + 3}
fill="white"
fontSize={BADGE_FONT_SIZE}
fontWeight={600}
textAnchor="middle"
>
{badge.label}
</text>
</g>
);
})}
</g>
);
}

View File

@@ -0,0 +1,49 @@
import type { DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams';
interface DiagramEdgeProps {
edge: DiagramEdgeType;
offsetY?: number;
}
export function DiagramEdge({ edge, offsetY = 0 }: DiagramEdgeProps) {
const pts = edge.points;
if (!pts || pts.length < 2) return null;
// Build SVG path: move to first point, then cubic bezier or line to rest
let d = `M ${pts[0][0]} ${pts[0][1] + offsetY}`;
if (pts.length === 2) {
d += ` L ${pts[1][0]} ${pts[1][1] + offsetY}`;
} else if (pts.length === 4) {
// 4 points: start, control1, control2, end → cubic bezier
d += ` C ${pts[1][0]} ${pts[1][1] + offsetY}, ${pts[2][0]} ${pts[2][1] + offsetY}, ${pts[3][0]} ${pts[3][1] + offsetY}`;
} else {
// Multiple points: connect with line segments through intermediate points
for (let i = 1; i < pts.length; i++) {
d += ` L ${pts[i][0]} ${pts[i][1] + offsetY}`;
}
}
return (
<g className="diagram-edge">
<path
d={d}
fill="none"
stroke="#9CA3AF"
strokeWidth={1.5}
markerEnd="url(#arrowhead)"
/>
{edge.label && pts.length >= 2 && (
<text
x={(pts[0][0] + pts[pts.length - 1][0]) / 2}
y={(pts[0][1] + pts[pts.length - 1][1]) / 2 + offsetY - 6}
fill="#9C9184"
fontSize={9}
textAnchor="middle"
>
{edge.label}
</text>
)}
</g>
);
}

View File

@@ -0,0 +1,93 @@
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
import type { NodeConfig } from './types';
import { colorForType, iconForType } from './node-colors';
import { ConfigBadge } from './ConfigBadge';
const TOP_BAR_HEIGHT = 6;
const CORNER_RADIUS = 4;
interface DiagramNodeProps {
node: DiagramNodeType;
isHovered: boolean;
isSelected: boolean;
config?: NodeConfig;
onClick: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
export function DiagramNode({
node, isHovered, isSelected, config, onClick, onMouseEnter, onMouseLeave,
}: DiagramNodeProps) {
const x = node.x ?? 0;
const y = node.y ?? 0;
const w = node.width ?? 120;
const h = node.height ?? 40;
const color = colorForType(node.type);
const icon = iconForType(node.type);
// Extract label parts: type name and detail
const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? '';
const detail = node.label || '';
return (
<g
data-node-id={node.id}
transform={`translate(${x}, ${y})`}
onClick={(e) => { e.stopPropagation(); onClick(); }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={{ cursor: 'pointer' }}
>
{/* Selection ring */}
{isSelected && (
<rect
x={-2}
y={-2}
width={w + 4}
height={h + 4}
rx={CORNER_RADIUS + 2}
fill="none"
stroke="#C6820E"
strokeWidth={2.5}
/>
)}
{/* Card background */}
<rect
x={0}
y={0}
width={w}
height={h}
rx={CORNER_RADIUS}
fill={isHovered ? '#F5F0EA' : 'white'}
stroke={isHovered || isSelected ? color : '#E4DFD8'}
strokeWidth={isHovered || isSelected ? 1.5 : 1}
/>
{/* Colored top bar */}
<rect x={0} y={0} width={w} height={TOP_BAR_HEIGHT} rx={CORNER_RADIUS} fill={color} />
<rect x={CORNER_RADIUS} y={0} width={w - CORNER_RADIUS * 2} height={TOP_BAR_HEIGHT} fill={color} />
{/* Icon */}
<text x={14} y={h / 2 + 6} fill={color} fontSize={14}>
{icon}
</text>
{/* Type name */}
<text x={32} y={h / 2 + 1} fill="#1A1612" fontSize={11} fontWeight={600}>
{typeName}
</text>
{/* Detail label (truncated) */}
{detail && detail !== typeName && (
<text x={32} y={h / 2 + 14} fill="#5C5347" fontSize={10}>
{detail.length > 22 ? detail.slice(0, 20) + '...' : detail}
</text>
)}
{/* Config badges */}
{config && <ConfigBadge nodeWidth={w} config={config} />}
</g>
);
}

View File

@@ -0,0 +1,93 @@
import type { DiagramSection } from './types';
import type { NodeConfig } from './types';
import { DiagramEdge } from './DiagramEdge';
import { DiagramNode } from './DiagramNode';
import { CompoundNode } from './CompoundNode';
import { isCompoundType } from './node-colors';
interface ErrorSectionProps {
section: DiagramSection;
totalWidth: number;
selectedNodeId?: string;
hoveredNodeId: string | null;
nodeConfigs?: Map<string, NodeConfig>;
onNodeClick: (nodeId: string) => void;
onNodeEnter: (nodeId: string) => void;
onNodeLeave: () => void;
}
export function ErrorSection({
section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs,
onNodeClick, onNodeEnter, onNodeLeave,
}: ErrorSectionProps) {
return (
<g transform={`translate(0, ${section.offsetY})`}>
{/* Divider line */}
<line
x1={0}
y1={0}
x2={totalWidth}
y2={0}
stroke="#C0392B"
strokeWidth={1}
strokeDasharray="6 3"
opacity={0.5}
/>
{/* Section label */}
<text x={8} y={-6} fill="#C0392B" fontSize={11} fontWeight={600}>
{section.label}
</text>
{/* Subtle red tint background */}
<rect
x={0}
y={4}
width={totalWidth}
height={300}
fill="#C0392B"
opacity={0.03}
rx={4}
/>
{/* Edges */}
<g className="edges">
{section.edges.map((edge, i) => (
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} />
))}
</g>
{/* Nodes */}
<g className="nodes">
{section.nodes.map(node => {
if (isCompoundType(node.type) && node.children && node.children.length > 0) {
return (
<CompoundNode
key={node.id}
node={node}
selectedNodeId={selectedNodeId}
hoveredNodeId={hoveredNodeId}
nodeConfigs={nodeConfigs}
onNodeClick={onNodeClick}
onNodeEnter={onNodeEnter}
onNodeLeave={onNodeLeave}
/>
);
}
return (
<DiagramNode
key={node.id}
node={node}
isHovered={hoveredNodeId === node.id}
isSelected={selectedNodeId === node.id}
config={node.id ? nodeConfigs?.get(node.id) : undefined}
onClick={() => node.id && onNodeClick(node.id)}
onMouseEnter={() => node.id && onNodeEnter(node.id)}
onMouseLeave={onNodeLeave}
/>
);
})}
</g>
</g>
);
}

View File

@@ -0,0 +1,118 @@
import { useCallback, useRef, useState } from 'react';
import type { NodeAction } from './types';
const TOOLBAR_HEIGHT = 28;
const TOOLBAR_WIDTH = 140;
const HIDE_DELAY = 150;
interface NodeToolbarProps {
nodeId: string;
nodeX: number;
nodeY: number;
nodeWidth: number;
onAction: (nodeId: string, action: NodeAction) => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
const ACTIONS: { label: string; icon: string; action: NodeAction; title: string }[] = [
{ label: 'Inspect', icon: '\uD83D\uDD0D', action: 'inspect', title: 'Inspect node' },
{ label: 'Trace', icon: 'T', action: 'toggle-trace', title: 'Toggle tracing' },
{ label: 'Tap', icon: '\u270E', action: 'configure-tap', title: 'Configure tap' },
{ label: 'More', icon: '\u22EF', action: 'copy-id', title: 'Copy processor ID' },
];
export function NodeToolbar({
nodeId, nodeX, nodeY, nodeWidth, onAction, onMouseEnter, onMouseLeave,
}: NodeToolbarProps) {
const x = nodeX + (nodeWidth - TOOLBAR_WIDTH) / 2;
const y = nodeY - TOOLBAR_HEIGHT - 6;
return (
<foreignObject
x={x}
y={y}
width={TOOLBAR_WIDTH}
height={TOOLBAR_HEIGHT}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
height: TOOLBAR_HEIGHT,
background: 'rgba(26, 22, 18, 0.92)',
borderRadius: '6px',
padding: '0 8px',
}}
>
{ACTIONS.map(a => (
<button
key={a.action}
title={a.title}
onClick={(e) => {
e.stopPropagation();
onAction(nodeId, a.action);
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 22,
height: 22,
borderRadius: '50%',
border: 'none',
background: 'rgba(255, 255, 255, 0.15)',
color: 'white',
fontSize: '11px',
cursor: 'pointer',
padding: 0,
}}
>
{a.icon}
</button>
))}
</div>
</foreignObject>
);
}
/** Hook to manage toolbar visibility with hide delay */
export function useToolbarHover() {
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const onNodeEnter = useCallback((nodeId: string) => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setHoveredNodeId(nodeId);
}, []);
const onNodeLeave = useCallback(() => {
hideTimer.current = setTimeout(() => {
setHoveredNodeId(null);
hideTimer.current = null;
}, HIDE_DELAY);
}, []);
const onToolbarEnter = useCallback(() => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
}, []);
const onToolbarLeave = useCallback(() => {
hideTimer.current = setTimeout(() => {
setHoveredNodeId(null);
hideTimer.current = null;
}, HIDE_DELAY);
}, []);
return { hoveredNodeId, onNodeEnter, onNodeLeave, onToolbarEnter, onToolbarLeave };
}

View File

@@ -0,0 +1,79 @@
.container {
position: relative;
width: 100%;
height: 100%;
min-height: 300px;
overflow: hidden;
background: var(--bg-surface, #FFFFFF);
border: 1px solid var(--border, #E4DFD8);
border-radius: var(--radius-md, 8px);
}
.svg {
width: 100%;
height: 100%;
display: block;
outline: none;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 300px;
color: var(--text-muted, #9C9184);
font-size: 14px;
}
.error {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 300px;
color: var(--error, #C0392B);
font-size: 14px;
}
.zoomControls {
position: absolute;
bottom: 12px;
right: 12px;
display: flex;
align-items: center;
gap: 4px;
background: var(--bg-surface, #FFFFFF);
border: 1px solid var(--border, #E4DFD8);
border-radius: var(--radius-sm, 5px);
padding: 4px;
box-shadow: var(--shadow-md, 0 2px 8px rgba(44, 37, 32, 0.08));
}
.zoomBtn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--text-primary, #1A1612);
font-size: 16px;
cursor: pointer;
border-radius: var(--radius-sm, 5px);
}
.zoomBtn:hover {
background: var(--bg-hover, #F5F0EA);
}
.zoomLevel {
font-size: 11px;
color: var(--text-muted, #9C9184);
min-width: 36px;
text-align: center;
font-variant-numeric: tabular-nums;
}

View 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;
}

View File

@@ -0,0 +1,19 @@
import styles from './ProcessDiagram.module.css';
interface ZoomControlsProps {
onZoomIn: () => void;
onZoomOut: () => void;
onFitToView: () => void;
scale: number;
}
export function ZoomControls({ onZoomIn, onZoomOut, onFitToView, scale }: ZoomControlsProps) {
return (
<div className={styles.zoomControls}>
<button className={styles.zoomBtn} onClick={onZoomIn} title="Zoom in (+)">+</button>
<span className={styles.zoomLevel}>{Math.round(scale * 100)}%</span>
<button className={styles.zoomBtn} onClick={onZoomOut} title="Zoom out (-)"></button>
<button className={styles.zoomBtn} onClick={onFitToView} title="Fit to view (0)"></button>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { ProcessDiagram } from './ProcessDiagram';
export type { ProcessDiagramProps, NodeAction, NodeConfig } from './types';

View File

@@ -0,0 +1,93 @@
/** Maps backend NodeType strings to CSS color values using design system tokens. */
const ENDPOINT_COLOR = '#1A7F8E'; // --running
const PROCESSOR_COLOR = '#C6820E'; // --amber
const TARGET_COLOR = '#3D7C47'; // --success
const EIP_COLOR = '#7C3AED'; // --purple
const ERROR_COLOR = '#C0392B'; // --error
const CROSS_ROUTE_COLOR = '#06B6D4';
const DEFAULT_COLOR = '#9C9184'; // --text-muted
const TYPE_MAP: Record<string, string> = {
ENDPOINT: ENDPOINT_COLOR,
PROCESSOR: PROCESSOR_COLOR,
BEAN: PROCESSOR_COLOR,
LOG: PROCESSOR_COLOR,
SET_HEADER: PROCESSOR_COLOR,
SET_BODY: PROCESSOR_COLOR,
TRANSFORM: PROCESSOR_COLOR,
MARSHAL: PROCESSOR_COLOR,
UNMARSHAL: PROCESSOR_COLOR,
TO: TARGET_COLOR,
TO_DYNAMIC: TARGET_COLOR,
DIRECT: TARGET_COLOR,
SEDA: TARGET_COLOR,
EIP_CHOICE: EIP_COLOR,
EIP_WHEN: EIP_COLOR,
EIP_OTHERWISE: EIP_COLOR,
EIP_SPLIT: EIP_COLOR,
EIP_MULTICAST: EIP_COLOR,
EIP_LOOP: EIP_COLOR,
EIP_AGGREGATE: EIP_COLOR,
EIP_FILTER: EIP_COLOR,
EIP_RECIPIENT_LIST: EIP_COLOR,
EIP_ROUTING_SLIP: EIP_COLOR,
EIP_DYNAMIC_ROUTER: EIP_COLOR,
EIP_LOAD_BALANCE: EIP_COLOR,
EIP_THROTTLE: EIP_COLOR,
EIP_DELAY: EIP_COLOR,
EIP_IDEMPOTENT_CONSUMER: EIP_COLOR,
EIP_CIRCUIT_BREAKER: EIP_COLOR,
EIP_PIPELINE: EIP_COLOR,
ERROR_HANDLER: ERROR_COLOR,
ON_EXCEPTION: ERROR_COLOR,
TRY_CATCH: ERROR_COLOR,
DO_TRY: ERROR_COLOR,
DO_CATCH: ERROR_COLOR,
DO_FINALLY: ERROR_COLOR,
EIP_WIRE_TAP: CROSS_ROUTE_COLOR,
EIP_ENRICH: CROSS_ROUTE_COLOR,
EIP_POLL_ENRICH: CROSS_ROUTE_COLOR,
};
const COMPOUND_TYPES = new Set([
'EIP_CHOICE', 'EIP_SPLIT', 'TRY_CATCH', 'DO_TRY',
'EIP_LOOP', 'EIP_MULTICAST', 'EIP_AGGREGATE',
'ON_EXCEPTION', 'ERROR_HANDLER',
]);
const ERROR_COMPOUND_TYPES = new Set([
'ON_EXCEPTION', 'ERROR_HANDLER',
]);
export function colorForType(type: string | undefined): string {
if (!type) return DEFAULT_COLOR;
return TYPE_MAP[type] ?? DEFAULT_COLOR;
}
export function isCompoundType(type: string | undefined): boolean {
return !!type && COMPOUND_TYPES.has(type);
}
export function isErrorCompoundType(type: string | undefined): boolean {
return !!type && ERROR_COMPOUND_TYPES.has(type);
}
/** Icon character for a node type */
export function iconForType(type: string | undefined): string {
if (!type) return '\u2699'; // gear
const t = type.toUpperCase();
if (t === 'ENDPOINT') return '\u25B6'; // play
if (t === 'TO' || t === 'TO_DYNAMIC' || t === 'DIRECT' || t === 'SEDA') return '\u25A0'; // square
if (t.startsWith('EIP_CHOICE') || t === 'EIP_WHEN' || t === 'EIP_OTHERWISE') return '\u25C6'; // diamond
if (t === 'ON_EXCEPTION' || t === 'ERROR_HANDLER' || t.startsWith('TRY') || t.startsWith('DO_')) return '\u26A0'; // warning
if (t === 'EIP_SPLIT' || t === 'EIP_MULTICAST') return '\u2442'; // fork
if (t === 'EIP_LOOP') return '\u21BA'; // loop arrow
if (t === 'EIP_WIRE_TAP' || t === 'EIP_ENRICH' || t === 'EIP_POLL_ENRICH') return '\u2197'; // arrow
return '\u2699'; // gear
}

View File

@@ -0,0 +1,27 @@
import type { DiagramNode, DiagramEdge } from '../../api/queries/diagrams';
export type NodeAction = 'inspect' | 'toggle-trace' | 'configure-tap' | 'copy-id';
export interface NodeConfig {
traceEnabled?: boolean;
tapExpression?: string;
}
export interface DiagramSection {
label: string;
nodes: DiagramNode[];
edges: DiagramEdge[];
offsetY: number;
variant?: 'error';
}
export interface ProcessDiagramProps {
application: string;
routeId: string;
direction?: 'LR' | 'TB';
selectedNodeId?: string;
onNodeSelect?: (nodeId: string) => void;
onNodeAction?: (nodeId: string, action: NodeAction) => void;
nodeConfigs?: Map<string, NodeConfig>;
className?: string;
}

View File

@@ -0,0 +1,115 @@
import { useMemo } from 'react';
import { useDiagramByRoute } from '../../api/queries/diagrams';
import type { DiagramNode, DiagramEdge } from '../../api/queries/diagrams';
import type { DiagramSection } from './types';
import { isErrorCompoundType } from './node-colors';
const SECTION_GAP = 40;
export function useDiagramData(
application: string,
routeId: string,
direction: 'LR' | 'TB' = 'LR',
) {
const { data: layout, isLoading, error } = useDiagramByRoute(application, routeId, direction);
const result = useMemo(() => {
if (!layout?.nodes) {
return { sections: [], totalWidth: 0, totalHeight: 0 };
}
const allEdges = layout.edges ?? [];
// Separate main nodes from error handler compound sections
const mainNodes: DiagramNode[] = [];
const errorSections: { label: string; nodes: DiagramNode[] }[] = [];
for (const node of layout.nodes) {
if (isErrorCompoundType(node.type) && node.children && node.children.length > 0) {
errorSections.push({
label: node.label || 'Error Handler',
nodes: node.children,
});
} else {
mainNodes.push(node);
}
}
// Collect node IDs for edge filtering
const mainNodeIds = new Set<string>();
collectNodeIds(mainNodes, mainNodeIds);
const mainEdges = allEdges.filter(
e => mainNodeIds.has(e.sourceId) && mainNodeIds.has(e.targetId),
);
// Compute main section bounding box
const mainBounds = computeBounds(mainNodes);
const sections: DiagramSection[] = [
{
label: 'Main Route',
nodes: mainNodes,
edges: mainEdges,
offsetY: 0,
},
];
let currentY = mainBounds.maxY + SECTION_GAP;
for (const es of errorSections) {
const errorNodeIds = new Set<string>();
collectNodeIds(es.nodes, errorNodeIds);
const errorEdges = allEdges.filter(
e => errorNodeIds.has(e.sourceId) && errorNodeIds.has(e.targetId),
);
sections.push({
label: es.label,
nodes: es.nodes,
edges: errorEdges,
offsetY: currentY,
variant: 'error',
});
const errorBounds = computeBounds(es.nodes);
currentY += (errorBounds.maxY - errorBounds.minY) + SECTION_GAP;
}
const totalWidth = layout.width ?? mainBounds.maxX;
const totalHeight = currentY;
return { sections, totalWidth, totalHeight };
}, [layout]);
return { ...result, isLoading, error };
}
function collectNodeIds(nodes: DiagramNode[], set: Set<string>) {
for (const n of nodes) {
if (n.id) set.add(n.id);
if (n.children) collectNodeIds(n.children, set);
}
}
function computeBounds(nodes: DiagramNode[]): {
minX: number; minY: number; maxX: number; maxY: number;
} {
let minX = Infinity, minY = Infinity, maxX = 0, maxY = 0;
for (const n of nodes) {
const x = n.x ?? 0;
const y = n.y ?? 0;
const w = n.width ?? 80;
const h = n.height ?? 40;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x + w > maxX) maxX = x + w;
if (y + h > maxY) maxY = y + h;
if (n.children) {
const childBounds = computeBounds(n.children);
if (childBounds.maxX > maxX) maxX = childBounds.maxX;
if (childBounds.maxY > maxY) maxY = childBounds.maxY;
}
}
return { minX: minX === Infinity ? 0 : minX, minY: minY === Infinity ? 0 : minY, maxX, maxY };
}

View File

@@ -0,0 +1,171 @@
import { useCallback, useRef, useState } from 'react';
interface ZoomPanState {
scale: number;
translateX: number;
translateY: number;
}
const MIN_SCALE = 0.25;
const MAX_SCALE = 4.0;
const ZOOM_STEP = 0.15;
const FIT_PADDING = 40;
export function useZoomPan() {
const [state, setState] = useState<ZoomPanState>({
scale: 1,
translateX: 0,
translateY: 0,
});
const isPanning = useRef(false);
const panStart = useRef({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const clampScale = (s: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, s));
const viewBox = useCallback(
(contentWidth: number, contentHeight: number) => {
const vw = contentWidth / state.scale;
const vh = contentHeight / state.scale;
const vx = -state.translateX / state.scale;
const vy = -state.translateY / state.scale;
return `${vx} ${vy} ${vw} ${vh}`;
},
[state],
);
const onWheel = useCallback(
(e: React.WheelEvent<SVGSVGElement>) => {
e.preventDefault();
const direction = e.deltaY < 0 ? 1 : -1;
const factor = 1 + direction * ZOOM_STEP;
setState(prev => {
const newScale = clampScale(prev.scale * factor);
// Zoom centered on cursor
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
const cursorX = e.clientX - rect.left;
const cursorY = e.clientY - rect.top;
const scaleRatio = newScale / prev.scale;
const newTx = cursorX - scaleRatio * (cursorX - prev.translateX);
const newTy = cursorY - scaleRatio * (cursorY - prev.translateY);
return { scale: newScale, translateX: newTx, translateY: newTy };
});
},
[],
);
const onPointerDown = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
// Only pan on background click (not on nodes)
if ((e.target as Element).closest('[data-node-id]')) return;
isPanning.current = true;
panStart.current = { x: e.clientX - state.translateX, y: e.clientY - state.translateY };
(e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);
},
[state.translateX, state.translateY],
);
const onPointerMove = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (!isPanning.current) return;
setState(prev => ({
...prev,
translateX: e.clientX - panStart.current.x,
translateY: e.clientY - panStart.current.y,
}));
},
[],
);
const onPointerUp = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
isPanning.current = false;
(e.currentTarget as SVGSVGElement).releasePointerCapture(e.pointerId);
},
[],
);
const zoomIn = useCallback(() => {
setState(prev => {
const newScale = clampScale(prev.scale * (1 + ZOOM_STEP));
const container = containerRef.current;
if (!container) return { ...prev, scale: newScale };
const cx = container.clientWidth / 2;
const cy = container.clientHeight / 2;
const ratio = newScale / prev.scale;
return {
scale: newScale,
translateX: cx - ratio * (cx - prev.translateX),
translateY: cy - ratio * (cy - prev.translateY),
};
});
}, []);
const zoomOut = useCallback(() => {
setState(prev => {
const newScale = clampScale(prev.scale * (1 - ZOOM_STEP));
const container = containerRef.current;
if (!container) return { ...prev, scale: newScale };
const cx = container.clientWidth / 2;
const cy = container.clientHeight / 2;
const ratio = newScale / prev.scale;
return {
scale: newScale,
translateX: cx - ratio * (cx - prev.translateX),
translateY: cy - ratio * (cy - prev.translateY),
};
});
}, []);
const fitToView = useCallback(
(contentWidth: number, contentHeight: number) => {
const container = containerRef.current;
if (!container) return;
const cw = container.clientWidth - FIT_PADDING * 2;
const ch = container.clientHeight - FIT_PADDING * 2;
const scaleX = cw / contentWidth;
const scaleY = ch / contentHeight;
const newScale = clampScale(Math.min(scaleX, scaleY));
const tx = (container.clientWidth - contentWidth * newScale) / 2;
const ty = (container.clientHeight - contentHeight * newScale) / 2;
setState({ scale: newScale, translateX: tx, translateY: ty });
},
[],
);
const onKeyDown = useCallback(
(e: React.KeyboardEvent, contentWidth: number, contentHeight: number) => {
switch (e.key) {
case '+':
case '=':
e.preventDefault();
zoomIn();
break;
case '-':
e.preventDefault();
zoomOut();
break;
case '0':
e.preventDefault();
fitToView(contentWidth, contentHeight);
break;
}
},
[zoomIn, zoomOut, fitToView],
);
return {
state,
containerRef,
viewBox,
onWheel,
onPointerDown,
onPointerMove,
onPointerUp,
zoomIn,
zoomOut,
fitToView,
onKeyDown,
};
}