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