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

263 lines
9.4 KiB
TypeScript
Raw Normal View History

import type { DiagramNode as DiagramNodeType, DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams';
import type { NodeConfig } from './types';
import type { NodeExecutionState, IterationInfo } from '../ExecutionDiagram/types';
import { colorForType, isCompoundType, iconForType, type IconElement } from './node-colors';
import { DiagramNode } from './DiagramNode';
import { DiagramEdge } from './DiagramEdge';
import styles from './ProcessDiagram.module.css';
const HEADER_HEIGHT = 22;
const CORNER_RADIUS = 4;
interface CompoundNodeProps {
node: DiagramNodeType;
/** All edges for this section — compound filters to its own internal edges */
edges: DiagramEdgeType[];
/** Absolute offset of the nearest compound ancestor (for coordinate adjustment) */
parentX?: number;
parentY?: number;
selectedNodeId?: string;
hoveredNodeId: string | null;
nodeConfigs?: Map<string, NodeConfig>;
/** Execution overlay for edge traversal coloring */
executionOverlay?: Map<string, NodeExecutionState>;
/** Whether an execution overlay is active (enables dimming of skipped nodes) */
overlayActive?: boolean;
/** Per-compound iteration state */
iterationState?: Map<string, IterationInfo>;
/** Called when user changes iteration on a compound stepper */
onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void;
onNodeClick: (nodeId: string) => void;
onNodeDoubleClick?: (nodeId: string) => void;
onNodeEnter: (nodeId: string) => void;
onNodeLeave: () => void;
}
export function CompoundNode({
node, edges, parentX = 0, parentY = 0,
selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay,
overlayActive, iterationState, onIterationChange,
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
}: CompoundNodeProps) {
const x = (node.x ?? 0) - parentX;
const y = (node.y ?? 0) - parentY;
const absX = node.x ?? 0;
const absY = 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;
const iterationInfo = node.id ? iterationState?.get(node.id) : undefined;
const headerWidth = w;
// Collect all descendant node IDs to filter edges that belong inside this compound
const descendantIds = new Set<string>();
collectIds(node.children ?? [], descendantIds);
const internalEdges = edges.filter(
e => descendantIds.has(e.sourceId) && descendantIds.has(e.targetId),
);
const childProps = {
edges, selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay,
overlayActive, iterationState, onIterationChange,
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
};
// _TRY_BODY / _CB_MAIN: transparent wrapper — no header, no border, just layout
if (node.type === '_TRY_BODY' || node.type === '_CB_MAIN') {
return (
<g transform={`translate(${x}, ${y})`}>
{renderInternalEdges(internalEdges, absX, absY, executionOverlay)}
{renderChildren(node, absX, absY, childProps)}
</g>
);
}
// _CB_FALLBACK: section styling with EIP purple
if (node.type === '_CB_FALLBACK') {
const fallbackColor = '#7C3AED'; // EIP purple
return (
<g data-node-id={node.id} transform={`translate(${x}, ${y})`}>
<rect x={0} y={0} width={w} height={h} rx={CORNER_RADIUS}
fill={fallbackColor} fillOpacity={0.06} />
<rect x={0} y={0} width={w} height={h} rx={CORNER_RADIUS}
fill="none" stroke={fallbackColor} strokeWidth={1} strokeOpacity={0.4} strokeDasharray="4 2" />
<text x={8} y={12} fill={fallbackColor} fontSize={10} fontWeight={600}>
fallback
</text>
{renderInternalEdges(internalEdges, absX, absY, executionOverlay)}
{renderChildren(node, absX, absY, childProps)}
</g>
);
}
// DO_CATCH / DO_FINALLY: section-like styling (tinted bg, thin border, label)
if (node.type === 'DO_CATCH' || node.type === 'DO_FINALLY') {
const sectionLabel = node.type === 'DO_CATCH'
? (node.label ? `catch: ${node.label}` : 'catch')
: (node.label ? `finally: ${node.label}` : 'finally');
return (
<g data-node-id={node.id} transform={`translate(${x}, ${y})`}>
{/* Tinted background */}
<rect x={0} y={0} width={w} height={h} rx={CORNER_RADIUS}
fill={color} fillOpacity={0.06} />
{/* Border */}
<rect x={0} y={0} width={w} height={h} rx={CORNER_RADIUS}
fill="none" stroke={color} strokeWidth={1} strokeOpacity={0.4} />
{/* Section label */}
<text x={8} y={12} fill={color} fontSize={10} fontWeight={600}>
{sectionLabel}
</text>
{renderInternalEdges(internalEdges, absX, absY, executionOverlay)}
{renderChildren(node, absX, absY, childProps)}
</g>
);
}
// Default compound rendering (DO_TRY, EIP_CHOICE, etc.)
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 icon (left-aligned) */}
<g transform={`translate(6, ${HEADER_HEIGHT / 2 - 5}) scale(0.417)`}>
{iconForType(node.type).map((el: IconElement, i: number) =>
'd' in el
? <path key={i} d={el.d} fill="none" stroke="white" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
: <circle key={i} cx={el.cx} cy={el.cy} r={el.r} fill="none" stroke="white" strokeWidth={2} />
)}
</g>
{/* Header label (centered) */}
<text
x={w / 2}
y={HEADER_HEIGHT / 2 + 4}
fill="white"
fontSize={10}
fontWeight={600}
textAnchor="middle"
>
{label}
</text>
{/* Iteration stepper (for LOOP, SPLIT, MULTICAST with overlay data) */}
{iterationInfo && (
<foreignObject x={headerWidth - 80} y={1} width={75} height={20}>
<div className={styles.iterationStepper}>
<button
onClick={(e) => { e.stopPropagation(); onIterationChange?.(node.id!, iterationInfo.current - 1); }}
disabled={iterationInfo.current <= 0}
>
&lsaquo;
</button>
<span>{iterationInfo.current + 1} / {iterationInfo.total}</span>
<button
onClick={(e) => { e.stopPropagation(); onIterationChange?.(node.id!, iterationInfo.current + 1); }}
disabled={iterationInfo.current >= iterationInfo.total - 1}
>
&rsaquo;
</button>
</div>
</foreignObject>
)}
{renderInternalEdges(internalEdges, absX, absY, executionOverlay)}
{renderChildren(node, absX, absY, childProps)}
</g>
);
}
/** Render internal edges adjusted for compound coordinates */
function renderInternalEdges(
internalEdges: DiagramEdgeType[],
absX: number, absY: number,
executionOverlay?: Map<string, NodeExecutionState>,
) {
return (
<g className="edges">
{internalEdges.map((edge, i) => {
const isTraversed = executionOverlay
? (executionOverlay.has(edge.sourceId) && executionOverlay.has(edge.targetId))
: undefined;
return (
<DiagramEdge
key={`${edge.sourceId}-${edge.targetId}-${i}`}
edge={{
...edge,
points: edge.points.map(p => [p[0] - absX, p[1] - absY]),
}}
traversed={isTraversed}
/>
);
})}
</g>
);
}
/** Render children — compounds recurse, leaves become DiagramNode */
function renderChildren(
node: DiagramNodeType,
absX: number, absY: number,
props: Omit<CompoundNodeProps, 'node' | 'parentX' | 'parentY'>,
) {
return (
<>
{node.children?.map(child => {
if (isCompoundType(child.type) && child.children && child.children.length > 0) {
return (
<CompoundNode
key={child.id}
node={child}
parentX={absX}
parentY={absY}
{...props}
/>
);
}
return (
<DiagramNode
key={child.id}
node={{
...child,
x: (child.x ?? 0) - absX,
y: (child.y ?? 0) - absY,
}}
isHovered={props.hoveredNodeId === child.id}
isSelected={props.selectedNodeId === child.id}
config={child.id ? props.nodeConfigs?.get(child.id) : undefined}
executionState={props.executionOverlay?.get(child.id ?? '')}
overlayActive={props.overlayActive}
onClick={() => child.id && props.onNodeClick(child.id)}
onDoubleClick={() => child.id && props.onNodeDoubleClick?.(child.id)}
onMouseEnter={() => child.id && props.onNodeEnter(child.id)}
onMouseLeave={props.onNodeLeave}
/>
);
})}
</>
);
}
function collectIds(nodes: DiagramNodeType[], set: Set<string>) {
for (const n of nodes) {
if (n.id) set.add(n.id);
if (n.children) collectIds(n.children, set);
}
}