feat: replace Unicode diagram icons with lucide SVG icons
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 58s
CI / docker (push) Successful in 54s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s

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>
This commit is contained in:
hsiegeln
2026-03-29 11:56:19 +02:00
parent 09a60c5a6c
commit 5103f40196
3 changed files with 257 additions and 21 deletions

View File

@@ -1,7 +1,7 @@
import type { DiagramNode as DiagramNodeType, DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams'; import type { DiagramNode as DiagramNodeType, DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams';
import type { NodeConfig } from './types'; import type { NodeConfig } from './types';
import type { NodeExecutionState, IterationInfo } from '../ExecutionDiagram/types'; import type { NodeExecutionState, IterationInfo } from '../ExecutionDiagram/types';
import { colorForType, isCompoundType } from './node-colors'; import { colorForType, isCompoundType, iconForType, type IconElement } from './node-colors';
import { DiagramNode } from './DiagramNode'; import { DiagramNode } from './DiagramNode';
import { DiagramEdge } from './DiagramEdge'; import { DiagramEdge } from './DiagramEdge';
import styles from './ProcessDiagram.module.css'; import styles from './ProcessDiagram.module.css';
@@ -118,7 +118,15 @@ export function CompoundNode({
<rect x={0} y={0} width={w} height={HEADER_HEIGHT} rx={CORNER_RADIUS} fill={color} /> <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} /> <rect x={CORNER_RADIUS} y={CORNER_RADIUS} width={w - CORNER_RADIUS * 2} height={HEADER_HEIGHT - CORNER_RADIUS} fill={color} />
{/* Header label */} {/* 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 <text
x={w / 2} x={w / 2}
y={HEADER_HEIGHT / 2 + 4} y={HEADER_HEIGHT / 2 + 4}

View File

@@ -1,7 +1,7 @@
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams'; import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
import type { NodeConfig } from './types'; import type { NodeConfig } from './types';
import type { NodeExecutionState } from '../ExecutionDiagram/types'; import type { NodeExecutionState } from '../ExecutionDiagram/types';
import { colorForType, iconForType } from './node-colors'; import { colorForType, iconForType, type IconElement } from './node-colors';
import { ConfigBadge } from './ConfigBadge'; import { ConfigBadge } from './ConfigBadge';
const TOP_BAR_HEIGHT = 6; const TOP_BAR_HEIGHT = 6;
@@ -37,7 +37,7 @@ export function DiagramNode({
const w = node.width ?? 120; const w = node.width ?? 120;
const h = node.height ?? 40; const h = node.height ?? 40;
const color = colorForType(node.type); const color = colorForType(node.type);
const icon = iconForType(node.type); const iconElements = iconForType(node.type);
// Extract label parts: type name and detail // Extract label parts: type name and detail
const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? ''; const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? '';
@@ -116,10 +116,14 @@ export function DiagramNode({
<rect x={TEXT_LEFT} y={TOP_BAR_HEIGHT} width={w - TEXT_LEFT - TEXT_RIGHT_PAD} height={h - TOP_BAR_HEIGHT} /> <rect x={TEXT_LEFT} y={TOP_BAR_HEIGHT} width={w - TEXT_LEFT - TEXT_RIGHT_PAD} height={h - TOP_BAR_HEIGHT} />
</clipPath> </clipPath>
{/* Icon */} {/* Icon (lucide 24×24 scaled to 14px) */}
<text x={14} y={h / 2 + 4} fill={statusColor ?? color} fontSize={14}> <g transform={`translate(6, ${h / 2 - 7}) scale(0.583)`}>
{icon} {iconElements.map((el: IconElement, i: number) =>
</text> '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) */} {/* Type name + detail (clipped to available width) */}
<g clipPath={`url(#clip-${node.id})`}> <g clipPath={`url(#clip-${node.id})`}>

View File

@@ -91,17 +91,241 @@ export function isCompletionCompoundType(type: string | undefined): boolean {
return !!type && COMPLETION_COMPOUND_TYPES.has(type); return !!type && COMPLETION_COMPOUND_TYPES.has(type);
} }
/** Icon character for a node type */ /**
export function iconForType(type: string | undefined): string { * Lucide-based icon definitions for node types.
if (!type) return '\u2699'; // gear * Each icon is an array of SVG element descriptors (path d-strings or circles).
const t = type.toUpperCase(); */
if (t === 'ENDPOINT') return '\u25B6'; // play export type IconElement = { d: string } | { cx: number; cy: number; r: number };
if (t === 'TO' || t === 'TO_DYNAMIC' || t === 'DIRECT' || t === 'SEDA') return '\u25A0'; // square
if (t.startsWith('EIP_CHOICE') || t === 'EIP_WHEN' || t === 'EIP_OTHERWISE') return '\u25C6'; // diamond // Lucide icon path data (24×24 viewBox, stroke-based)
if (t === 'ON_COMPLETION') return '\u2714'; // checkmark const ICONS: Record<string, IconElement[]> = {
if (t === 'ON_EXCEPTION' || t === 'ERROR_HANDLER' || t.startsWith('TRY') || t.startsWith('DO_')) return '\u26A0'; // warning // Play — ENDPOINT
if (t === 'EIP_SPLIT' || t === 'EIP_MULTICAST') return '\u2442'; // fork play: [{ d: 'M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z' }],
if (t === 'EIP_LOOP') return '\u21BA'; // loop arrow // Send — TO
if (t === 'EIP_WIRE_TAP' || t === 'EIP_ENRICH' || t === 'EIP_POLL_ENRICH') return '\u2197'; // arrow send: [
return '\u2699'; // gear { d: 'M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z' },
{ d: 'm21.854 2.147-10.94 10.939' },
],
// Shuffle — TO_DYNAMIC
shuffle: [
{ d: 'm18 14 4 4-4 4' },
{ d: 'm18 2 4 4-4 4' },
{ d: 'M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22' },
{ d: 'M2 6h1.972a4 4 0 0 1 3.6 2.2' },
{ d: 'M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45' },
],
// Zap — DIRECT
zap: [{ d: 'M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z' }],
// Layers — SEDA
layers: [
{ d: 'M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z' },
{ d: 'M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12' },
{ d: 'M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17' },
],
// Cog — PROCESSOR (default)
cog: [
{ d: 'M11 10.27 7 3.34' }, { d: 'm11 13.73-4 6.93' },
{ d: 'M12 22v-2' }, { d: 'M12 2v2' }, { d: 'M14 12h8' },
{ d: 'm17 20.66-1-1.73' }, { d: 'm17 3.34-1 1.73' },
{ d: 'M2 12h2' }, { d: 'm20.66 17-1.73-1' }, { d: 'm20.66 7-1.73 1' },
{ d: 'm3.34 17 1.73-1' }, { d: 'm3.34 7 1.73 1' },
{ cx: 12, cy: 12, r: 3 },
],
// Coffee — BEAN
coffee: [
{ d: 'M10 2v2' }, { d: 'M14 2v2' }, { d: 'M6 2v2' },
{ d: 'M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1' },
],
// FileText — LOG
fileText: [
{ d: 'M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z' },
{ d: 'M14 2v5a1 1 0 0 0 1 1h5' },
{ d: 'M10 9H8' }, { d: 'M16 13H8' }, { d: 'M16 17H8' },
],
// Tag — SET_HEADER
tag: [{ d: 'M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z' }, { cx: 7.5, cy: 7.5, r: 1 }],
// PenLine — SET_BODY
penLine: [
{ d: 'M13 21h8' },
{ d: 'M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z' },
],
// RefreshCw — TRANSFORM
refreshCw: [
{ d: 'M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8' },
{ d: 'M21 3v5h-5' },
{ d: 'M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16' },
{ d: 'M8 16H3v5' },
],
// Package — MARSHAL
package: [
{ d: 'M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z' },
{ d: 'M12 22V12' }, { d: 'm7.5 4.27 9 5.15' },
],
// PackageOpen — UNMARSHAL
packageOpen: [
{ d: 'M12 22v-9' },
{ d: 'M15.17 2.21a1.67 1.67 0 0 1 1.63 0L21 4.57a1.93 1.93 0 0 1 0 3.36L8.82 14.79a1.655 1.655 0 0 1-1.64 0L3 12.43a1.93 1.93 0 0 1 0-3.36z' },
{ d: 'M20 13v3.87a2.06 2.06 0 0 1-1.11 1.83l-6 3.08a1.93 1.93 0 0 1-1.78 0l-6-3.08A2.06 2.06 0 0 1 4 16.87V13' },
{ d: 'M21 12.43a1.93 1.93 0 0 0 0-3.36L8.83 2.2a1.64 1.64 0 0 0-1.63 0L3 4.57a1.93 1.93 0 0 0 0 3.36l12.18 6.86a1.636 1.636 0 0 0 1.63 0z' },
],
// GitBranch — EIP_CHOICE
gitBranch: [{ d: 'M15 6a9 9 0 0 0-9 9V3' }, { cx: 18, cy: 6, r: 3 }, { cx: 6, cy: 18, r: 3 }],
// CircleAlert — EIP_WHEN (condition)
circleAlert: [{ cx: 12, cy: 12, r: 10 }, { d: 'M12 8v4' }, { d: 'M12 16h.01' }],
// CircleDot — EIP_OTHERWISE (default)
circleDot: [{ cx: 12, cy: 12, r: 10 }, { cx: 12, cy: 12, r: 1 }],
// Split — EIP_SPLIT
split: [
{ d: 'M16 3h5v5' }, { d: 'M8 3H3v5' },
{ d: 'M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3' },
{ d: 'm15 9 6-6' },
],
// Share2 — EIP_MULTICAST
share2: [
{ cx: 18, cy: 5, r: 3 }, { cx: 6, cy: 12, r: 3 }, { cx: 18, cy: 19, r: 3 },
{ d: 'M8.59 13.51 15.42 17.49' }, { d: 'M15.41 6.51 8.59 10.49' },
],
// Repeat — EIP_LOOP
repeat: [
{ d: 'm17 2 4 4-4 4' },
{ d: 'M3 11v-1a4 4 0 0 1 4-4h14' },
{ d: 'm7 22-4-4 4-4' },
{ d: 'M21 13v1a4 4 0 0 1-4 4H3' },
],
// Merge — EIP_AGGREGATE
merge: [{ d: 'm8 6 4-4 4 4' }, { d: 'M12 2v10.3a4 4 0 0 1-1.172 2.872L4 22' }, { d: 'm20 22-5-5' }],
// Funnel — EIP_FILTER
funnel: [{ d: 'M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z' }],
// Users — EIP_RECIPIENT_LIST
users: [
{ d: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2' },
{ cx: 9, cy: 7, r: 4 },
{ d: 'M16 3.128a4 4 0 0 1 0 7.744' },
{ d: 'M22 21v-2a4 4 0 0 0-3-3.87' },
],
// Navigation — EIP_ROUTING_SLIP
navigation: [{ d: 'M3,11 L22,2 L13,21 L11,13Z' }],
// Waypoints — EIP_DYNAMIC_ROUTER
waypoints: [
{ d: 'm10.586 5.414-5.172 5.172' }, { d: 'm18.586 13.414-5.172 5.172' },
{ d: 'M6 12h12' },
{ cx: 12, cy: 20, r: 2 }, { cx: 12, cy: 4, r: 2 },
{ cx: 20, cy: 12, r: 2 }, { cx: 4, cy: 12, r: 2 },
],
// Scale — EIP_LOAD_BALANCE
scale: [
{ d: 'M12 3v18' },
{ d: 'm19 8 3 8a5 5 0 0 1-6 0zV7' },
{ d: 'M3 7h1a17 17 0 0 0 8-2 17 17 0 0 0 8 2h1' },
{ d: 'm5 8 3 8a5 5 0 0 1-6 0zV7' },
{ d: 'M7 21h10' },
],
// Gauge — EIP_THROTTLE
gauge: [{ d: 'm12 14 4-4' }, { d: 'M3.34 19a10 10 0 1 1 17.32 0' }],
// Clock — EIP_DELAY
clock: [{ cx: 12, cy: 12, r: 10 }, { d: 'M12 6v6l4 2' }],
// Hash — EIP_IDEMPOTENT_CONSUMER
hash: [{ d: 'M4 9L20 9' }, { d: 'M4 15L20 15' }, { d: 'M10 3L8 21' }, { d: 'M16 3L14 21' }],
// ZapOff — EIP_CIRCUIT_BREAKER
zapOff: [
{ d: 'M10.513 4.856 13.12 2.17a.5.5 0 0 1 .86.46l-1.377 4.317' },
{ d: 'M15.656 10H20a1 1 0 0 1 .78 1.63l-1.72 1.773' },
{ d: 'M16.273 16.273 10.88 21.83a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14H4a1 1 0 0 1-.78-1.63l4.507-4.643' },
{ d: 'm2 2 20 20' },
],
// Workflow — EIP_PIPELINE
workflow: [
{ d: 'M5,3h4a2,2 0 0 1 2,2v4a2,2 0 0 1-2,2h-4a2,2 0 0 1-2,-2v-4a2,2 0 0 1 2,-2Z' },
{ d: 'M7 11v4a2 2 0 0 0 2 2h4' },
{ d: 'M15,13h4a2,2 0 0 1 2,2v4a2,2 0 0 1-2,2h-4a2,2 0 0 1-2,-2v-4a2,2 0 0 1 2,-2Z' },
],
// ShieldAlert — ERROR_HANDLER
shieldAlert: [
{ d: 'M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z' },
{ d: 'M12 8v4' }, { d: 'M12 16h.01' },
],
// OctagonAlert — ON_EXCEPTION
octagonAlert: [
{ d: 'M15.312 2a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586l-4.688-4.688A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2z' },
{ d: 'M12 8v4' }, { d: 'M12 16h.01' },
],
// Shield — DO_TRY
shield: [{ d: 'M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z' }],
// Hand — DO_CATCH
hand: [
{ d: 'M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2' },
{ d: 'M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2' },
{ d: 'M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8' },
{ d: 'M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15' },
],
// ListChecks — DO_FINALLY
listChecks: [
{ d: 'M13 5h8' }, { d: 'M13 12h8' }, { d: 'M13 19h8' },
{ d: 'm3 17 2 2 4-4' }, { d: 'm3 7 2 2 4-4' },
],
// Eye — EIP_WIRE_TAP
eye: [
{ d: 'M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0' },
{ cx: 12, cy: 12, r: 3 },
],
// CirclePlus — EIP_ENRICH
circlePlus: [{ cx: 12, cy: 12, r: 10 }, { d: 'M8 12h8' }, { d: 'M12 8v8' }],
// CircleArrowDown — EIP_POLL_ENRICH
circleArrowDown: [{ cx: 12, cy: 12, r: 10 }, { d: 'M12 8v8' }, { d: 'm8 12 4 4 4-4' }],
// CircleCheck — ON_COMPLETION
circleCheck: [{ cx: 12, cy: 12, r: 10 }, { d: 'm9 12 2 2 4-4' }],
};
const ICON_MAP: Record<string, IconElement[]> = {
ENDPOINT: ICONS.play,
TO: ICONS.send,
TO_DYNAMIC: ICONS.shuffle,
DIRECT: ICONS.zap,
SEDA: ICONS.layers,
PROCESSOR: ICONS.cog,
BEAN: ICONS.coffee,
LOG: ICONS.fileText,
SET_HEADER: ICONS.tag,
SET_BODY: ICONS.penLine,
TRANSFORM: ICONS.refreshCw,
MARSHAL: ICONS.package,
UNMARSHAL: ICONS.packageOpen,
EIP_CHOICE: ICONS.gitBranch,
EIP_WHEN: ICONS.circleAlert,
EIP_OTHERWISE: ICONS.circleDot,
EIP_SPLIT: ICONS.split,
EIP_MULTICAST: ICONS.share2,
EIP_LOOP: ICONS.repeat,
EIP_AGGREGATE: ICONS.merge,
EIP_FILTER: ICONS.funnel,
EIP_RECIPIENT_LIST: ICONS.users,
EIP_ROUTING_SLIP: ICONS.navigation,
EIP_DYNAMIC_ROUTER: ICONS.waypoints,
EIP_LOAD_BALANCE: ICONS.scale,
EIP_THROTTLE: ICONS.gauge,
EIP_DELAY: ICONS.clock,
EIP_IDEMPOTENT_CONSUMER: ICONS.hash,
EIP_CIRCUIT_BREAKER: ICONS.zapOff,
EIP_PIPELINE: ICONS.workflow,
ERROR_HANDLER: ICONS.shieldAlert,
ON_EXCEPTION: ICONS.octagonAlert,
TRY_CATCH: ICONS.shield,
DO_TRY: ICONS.shield,
DO_CATCH: ICONS.hand,
DO_FINALLY: ICONS.listChecks,
EIP_WIRE_TAP: ICONS.eye,
EIP_ENRICH: ICONS.circlePlus,
EIP_POLL_ENRICH: ICONS.circleArrowDown,
ON_COMPLETION: ICONS.circleCheck,
};
/** Lucide SVG icon elements for a node type (24×24 viewBox, stroke-based) */
export function iconForType(type: string | undefined): IconElement[] {
if (!type) return ICONS.cog;
return ICON_MAP[type.toUpperCase()] ?? ICONS.cog;
} }