Each of the ~40 node types now has a distinct, semantically meaningful lucide icon rendered as crisp SVG paths. Compound node headers also show their icon left-aligned in the header bar. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
190 lines
6.1 KiB
TypeScript
190 lines
6.1 KiB
TypeScript
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
|
||
import type { NodeConfig } from './types';
|
||
import type { NodeExecutionState } from '../ExecutionDiagram/types';
|
||
import { colorForType, iconForType, type IconElement } from './node-colors';
|
||
import { ConfigBadge } from './ConfigBadge';
|
||
|
||
const TOP_BAR_HEIGHT = 6;
|
||
const TEXT_LEFT = 32;
|
||
const TEXT_RIGHT_PAD = 24;
|
||
const CORNER_RADIUS = 4;
|
||
|
||
interface DiagramNodeProps {
|
||
node: DiagramNodeType;
|
||
isHovered: boolean;
|
||
isSelected: boolean;
|
||
config?: NodeConfig;
|
||
executionState?: NodeExecutionState;
|
||
overlayActive?: boolean;
|
||
onClick: () => void;
|
||
onDoubleClick?: () => void;
|
||
onMouseEnter: () => void;
|
||
onMouseLeave: () => void;
|
||
}
|
||
|
||
function formatDuration(ms: number): string {
|
||
if (ms < 1000) return `${ms}ms`;
|
||
return `${(ms / 1000).toFixed(1)}s`;
|
||
}
|
||
|
||
export function DiagramNode({
|
||
node, isHovered, isSelected, config,
|
||
executionState, overlayActive,
|
||
onClick, onDoubleClick, 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 iconElements = iconForType(node.type);
|
||
|
||
// Extract label parts: type name and detail
|
||
const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? '';
|
||
const detail = node.label || '';
|
||
|
||
// Overlay state derivation
|
||
const isCompleted = executionState?.status === 'COMPLETED';
|
||
const isFailed = executionState?.status === 'FAILED';
|
||
const isSkipped = overlayActive && !executionState;
|
||
|
||
// Colors based on execution state
|
||
let cardFill = isHovered ? '#F5F0EA' : 'white';
|
||
let borderStroke = isHovered || isSelected ? color : '#E4DFD8';
|
||
let borderWidth = isHovered || isSelected ? 1.5 : 1;
|
||
let topBarColor = color;
|
||
let labelColor = '#1A1612';
|
||
|
||
if (isCompleted) {
|
||
cardFill = isHovered ? '#E4F5E6' : '#F0F9F1';
|
||
borderStroke = '#3D7C47';
|
||
borderWidth = 1.5;
|
||
topBarColor = '#3D7C47';
|
||
} else if (isFailed) {
|
||
cardFill = isHovered ? '#F9E4E1' : '#FDF2F0';
|
||
borderStroke = '#C0392B';
|
||
borderWidth = 2;
|
||
topBarColor = '#C0392B';
|
||
labelColor = '#C0392B';
|
||
}
|
||
|
||
const statusColor = isCompleted ? '#3D7C47' : isFailed ? '#C0392B' : undefined;
|
||
|
||
return (
|
||
<g
|
||
data-node-id={node.id}
|
||
transform={`translate(${x}, ${y})`}
|
||
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||
onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick?.(); }}
|
||
onMouseEnter={onMouseEnter}
|
||
onMouseLeave={onMouseLeave}
|
||
style={{ cursor: 'pointer' }}
|
||
opacity={isSkipped ? 0.35 : undefined}
|
||
>
|
||
{/* 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={cardFill}
|
||
stroke={borderStroke}
|
||
strokeWidth={borderWidth}
|
||
/>
|
||
|
||
{/* Colored top bar */}
|
||
<rect x={0} y={0} width={w} height={TOP_BAR_HEIGHT} rx={CORNER_RADIUS} fill={topBarColor} />
|
||
<rect x={CORNER_RADIUS} y={0} width={w - CORNER_RADIUS * 2} height={TOP_BAR_HEIGHT} fill={topBarColor} />
|
||
|
||
{/* Clip path for text area */}
|
||
<clipPath id={`clip-${node.id}`}>
|
||
<rect x={TEXT_LEFT} y={TOP_BAR_HEIGHT} width={w - TEXT_LEFT - TEXT_RIGHT_PAD} height={h - TOP_BAR_HEIGHT} />
|
||
</clipPath>
|
||
|
||
{/* Icon (lucide 24×24 scaled to 14px) */}
|
||
<g transform={`translate(6, ${h / 2 - 7}) scale(0.583)`}>
|
||
{iconElements.map((el: IconElement, i: number) =>
|
||
'd' in el
|
||
? <path key={i} d={el.d} fill="none" stroke={statusColor ?? color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||
: <circle key={i} cx={el.cx} cy={el.cy} r={el.r} fill="none" stroke={statusColor ?? color} strokeWidth={2} />
|
||
)}
|
||
</g>
|
||
|
||
{/* Type name + detail (clipped to available width) */}
|
||
<g clipPath={`url(#clip-${node.id})`}>
|
||
<text x={TEXT_LEFT} y={h / 2 - 1} fill={labelColor} fontSize={11} fontWeight={600}>
|
||
{typeName}
|
||
</text>
|
||
{detail && detail !== typeName && (
|
||
<text x={TEXT_LEFT} y={h / 2 + 12} fill={isFailed ? '#C0392B' : '#5C5347'} fontSize={10}>
|
||
{detail}
|
||
</text>
|
||
)}
|
||
</g>
|
||
|
||
{/* Config badges */}
|
||
{config && <ConfigBadge nodeWidth={w} config={config} />}
|
||
|
||
{/* Execution overlay: status badge inside card, top-right corner */}
|
||
{isCompleted && (
|
||
<>
|
||
<circle cx={w - 10} cy={TOP_BAR_HEIGHT + 8} r={6} fill="#3D7C47" />
|
||
<path
|
||
d={`M${w - 13} ${TOP_BAR_HEIGHT + 8} l2 2 4-4`}
|
||
fill="none" stroke="white" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round"
|
||
/>
|
||
</>
|
||
)}
|
||
{isFailed && (
|
||
<>
|
||
<circle cx={w - 10} cy={TOP_BAR_HEIGHT + 8} r={6} fill="#C0392B" />
|
||
<path
|
||
d={`M${w - 10} ${TOP_BAR_HEIGHT + 5} v4 M${w - 10} ${TOP_BAR_HEIGHT + 10.5} v0.5`}
|
||
fill="none" stroke="white" strokeWidth={1.5} strokeLinecap="round"
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{/* Execution overlay: duration text at bottom-right */}
|
||
{executionState && statusColor && (
|
||
<text
|
||
x={w - 6}
|
||
y={h - 4}
|
||
textAnchor="end"
|
||
fill={statusColor}
|
||
fontSize={9}
|
||
fontWeight={500}
|
||
>
|
||
{formatDuration(executionState.durationMs)}
|
||
</text>
|
||
)}
|
||
|
||
{/* Sub-route failure: drill-down arrow at bottom-left */}
|
||
{isFailed && executionState?.subRouteFailed && (
|
||
<g transform={`translate(4, ${h - 14})`}>
|
||
<path
|
||
d="M2 2 v5 a3 3 0 003 3 h5"
|
||
fill="none" stroke="#C0392B" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round"
|
||
/>
|
||
<path d="M8 8 l2 2 -2 2" fill="none" stroke="#C0392B" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||
</g>
|
||
)}
|
||
</g>
|
||
);
|
||
}
|