import React from 'react'; import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams'; import type { NodeConfig, LatencyHeatmapEntry } from './types'; import type { NodeExecutionState } from '../ExecutionDiagram/types'; import { colorForType, iconForType, type IconElement } from './node-colors'; import { formatDurationShort } from '../../utils/format-utils'; 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; heatmapEntry?: LatencyHeatmapEntry; onClick: () => void; onDoubleClick?: () => void; onMouseEnter: () => void; onMouseLeave: () => void; } /** Interpolate green (120°) → yellow (60°) → red (0°) based on pctOfRoute */ function heatmapColor(pct: number): string { const clamped = Math.max(0, Math.min(100, pct)); // 0% → hue 120 (green), 50% → hue 60 (yellow), 100% → hue 0 (red) const hue = 120 - (clamped / 100) * 120; return `hsl(${Math.round(hue)}, 70%, 92%)`; } function heatmapBorderColor(pct: number): string { const clamped = Math.max(0, Math.min(100, pct)); const hue = 120 - (clamped / 100) * 120; return `hsl(${Math.round(hue)}, 60%, 50%)`; } export function DiagramNode({ node, isHovered, isSelected, config, executionState, overlayActive, heatmapEntry, 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 || ''; const resolvedUri = executionState?.resolvedEndpointUri; // Overlay state derivation const isCompleted = executionState?.status === 'COMPLETED'; const isFailed = executionState?.status === 'FAILED'; const isSkipped = overlayActive && !executionState; // Colors based on execution state (heatmap takes priority when no execution overlay) let cardFill = isHovered ? 'var(--bg-hover)' : 'var(--bg-surface)'; let borderStroke = isHovered || isSelected ? color : 'var(--border-subtle)'; let borderWidth = isHovered || isSelected ? 1.5 : 1; let topBarColor = color; let labelColor = 'var(--text-primary)'; if (isCompleted) { cardFill = isHovered ? 'color-mix(in srgb, var(--success) 15%, var(--bg-surface))' : 'color-mix(in srgb, var(--success) 10%, var(--bg-surface))'; borderStroke = 'var(--success)'; borderWidth = 1.5; topBarColor = 'var(--success)'; } else if (isFailed) { cardFill = isHovered ? 'color-mix(in srgb, var(--error) 15%, var(--bg-surface))' : 'color-mix(in srgb, var(--error) 10%, var(--bg-surface))'; borderStroke = 'var(--error)'; borderWidth = 2; topBarColor = 'var(--error)'; labelColor = 'var(--error)'; } else if (heatmapEntry && !overlayActive) { cardFill = heatmapColor(heatmapEntry.pctOfRoute); borderStroke = heatmapBorderColor(heatmapEntry.pctOfRoute); borderWidth = 1.5; topBarColor = heatmapBorderColor(heatmapEntry.pctOfRoute); } const statusColor = isCompleted ? 'var(--success)' : isFailed ? 'var(--error)' : undefined; return ( { e.stopPropagation(); onClick(); }} onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick?.(); }} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} style={{ cursor: 'pointer' }} opacity={isSkipped ? 0.35 : undefined} > {/* Selection ring */} {isSelected && ( )} {/* Card background */} {/* Colored top bar */} {/* Clip path for text area */} {/* Icon (lucide 24×24 scaled to 14px) */} {iconElements.map((el: IconElement, i: number) => 'd' in el ? : )} {/* Type name + detail + resolved URI (clipped to available width) */} {resolvedUri ? ( <> {typeName} {detail && detail !== typeName && ( {detail} )} → {resolvedUri.split('?')[0]} ) : ( <> {typeName} {detail && detail !== typeName && ( {detail} )} )} {/* Inline badges row: hasTrace, hasTap, status — inside card, top-right */} {(() => { const BADGE_R = 6; const BADGE_D = BADGE_R * 2; const BADGE_GAP = 3; const cy = TOP_BAR_HEIGHT + BADGE_R + 2; const showTrace = config?.traceEnabled || executionState?.hasTraceData; const showTap = !!config?.tapExpression; if (!showTrace && !showTap && !isCompleted && !isFailed) return null; const badges: React.ReactNode[] = []; let slot = 0; // Status badge (rightmost, only during overlay) const statusCx = w - BADGE_R - 4; if (isCompleted) { badges.push( ); slot++; } else if (isFailed) { badges.push( ); slot++; } // Tap badge (before status) if (showTap) { const tapCx = statusCx - slot * (BADGE_D + BADGE_GAP); badges.push( ); slot++; } // Trace badge (leftmost) if (showTrace) { const traceCx = statusCx - slot * (BADGE_D + BADGE_GAP); const tracePulse = overlayActive && executionState?.hasTraceData; const traceHasData = executionState?.hasTraceData; badges.push( {tracePulse && ( <> )} ); } return <>{badges}; })()} {/* Execution overlay: duration text at bottom-right */} {executionState && statusColor && ( {formatDurationShort(executionState.durationMs)} )} {/* Heatmap: avg duration label at bottom-right */} {heatmapEntry && !overlayActive && !executionState && ( {formatDurationShort(heatmapEntry.avgDurationMs)} )} {/* Sub-route failure: drill-down arrow at bottom-left */} {isFailed && executionState?.subRouteFailed && ( )} ); }