Some checks failed
Add latencyHeatmap prop to ProcessDiagram that colors nodes green→yellow→red based on their relative contribution to route latency (pctOfRoute). Shows avg duration label on each node. Threaded through CompoundNode for nested EIP patterns. Heatmap is active only when no execution overlay is present. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
265 lines
9.6 KiB
TypeScript
265 lines
9.6 KiB
TypeScript
import type { DiagramNode as DiagramNodeType, DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams';
|
|
import type { NodeConfig, LatencyHeatmapEntry } 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;
|
|
latencyHeatmap?: Map<string, LatencyHeatmapEntry>;
|
|
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, latencyHeatmap,
|
|
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, latencyHeatmap,
|
|
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}
|
|
>
|
|
‹
|
|
</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}
|
|
>
|
|
›
|
|
</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}
|
|
heatmapEntry={child.id ? props.latencyHeatmap?.get(child.id) : undefined}
|
|
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);
|
|
}
|
|
}
|