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:
118
ui/src/components/ProcessDiagram/NodeToolbar.tsx
Normal file
118
ui/src/components/ProcessDiagram/NodeToolbar.tsx
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user