@cyoda/workflow-viewer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,913 @@
1
+ // src/components/WorkflowViewer.tsx
2
+ import { useMemo, useState as useState2 } from "react";
3
+
4
+ // src/theme/tokens.ts
5
+ var workflowPalette = {
6
+ neutrals: {
7
+ white: "#FFFFFF",
8
+ white95: "#FFFFFFF2",
9
+ white75: "#FFFFFFBF",
10
+ slate200: "#E2E8F0",
11
+ slate300: "#CBD5E1",
12
+ slate500: "#64748B",
13
+ slate600: "#475569",
14
+ slate900: "#0F172A"
15
+ },
16
+ node: {
17
+ default: {
18
+ fill: "#F0FDFA",
19
+ border: "#2DD4BF",
20
+ meta: "#0F766E",
21
+ title: "#0F172A"
22
+ },
23
+ initial: {
24
+ fill: "#D1FAE5",
25
+ border: "#059669",
26
+ meta: "#047857",
27
+ title: "#022C22"
28
+ },
29
+ terminal: {
30
+ fill: "#FFF1F2",
31
+ border: "#FDA4AF",
32
+ meta: "#BE123C",
33
+ title: "#4C0519",
34
+ innerRing: "#FFFFFFBF"
35
+ },
36
+ manualReview: {
37
+ fill: "#F5F3FF",
38
+ border: "#C4B5FD",
39
+ meta: "#6D28D9",
40
+ title: "#2E1065"
41
+ },
42
+ processing: {
43
+ fill: "#F0F9FF",
44
+ border: "#7DD3FC",
45
+ meta: "#0369A1",
46
+ title: "#082F49"
47
+ }
48
+ },
49
+ edge: {
50
+ automated: "#64748B",
51
+ manual: "#8B5CF6",
52
+ conditional: "#F59E0B",
53
+ processing: "#0EA5E9",
54
+ terminal: "#FB7185",
55
+ loop: "#14B8A6",
56
+ disabled: "#CBD5E1",
57
+ arrowhead: "#64748B"
58
+ },
59
+ edgeLabel: {
60
+ fill: "#FFFFFFF2",
61
+ border: "#E2E8F0",
62
+ text: "#475569"
63
+ },
64
+ badge: {
65
+ manual: { fill: "#F5F3FF", border: "#DDD6FE" },
66
+ processor: { fill: "#F0F9FF", border: "#BAE6FD" },
67
+ criterion: { fill: "#FFFBEB", border: "#FDE68A" },
68
+ disabled: { fill: "#F8FAFC", border: "#E2E8F0" },
69
+ text: "#475569"
70
+ }
71
+ };
72
+ var typography = {
73
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", system-ui, sans-serif',
74
+ monoFamily: 'ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace',
75
+ stateCategory: { size: 10, weight: 700, tracking: "0.12em" },
76
+ stateTitle: { size: 14, weight: 700, tracking: "0.01em" },
77
+ edgeLabel: { size: 9, weight: 700, tracking: "0.04em" },
78
+ badge: { size: 8, weight: 600, tracking: "0.04em" }
79
+ };
80
+ var geometry = {
81
+ node: {
82
+ width: 144,
83
+ height: 72,
84
+ radius: 8,
85
+ strokeWidth: 1.5,
86
+ terminalInset: 3,
87
+ terminalInnerRadius: 6
88
+ },
89
+ edge: {
90
+ strokeWidth: 1.8,
91
+ loopStrokeWidth: 1.6,
92
+ arrowheadSize: 6
93
+ },
94
+ labelPill: {
95
+ paddingX: 6,
96
+ paddingY: 3,
97
+ radius: 6,
98
+ shadowOpacity: 0.08
99
+ }
100
+ };
101
+
102
+ // src/layout.ts
103
+ function simpleLayout(graph) {
104
+ const { width: nodeW, height: nodeH } = geometry.node;
105
+ const hGap = 48;
106
+ const vGap = 48;
107
+ const positions = /* @__PURE__ */ new Map();
108
+ const nodesByWorkflow = groupByWorkflow(graph.nodes);
109
+ let yCursor = 24;
110
+ let maxWidth = 0;
111
+ for (const wfNodes of nodesByWorkflow.values()) {
112
+ const layers = layerByBFS(wfNodes, graph);
113
+ let y = yCursor;
114
+ for (const layer of layers) {
115
+ const layerWidth = layer.length * nodeW + (layer.length - 1) * hGap;
116
+ maxWidth = Math.max(maxWidth, layerWidth + 48);
117
+ let x = Math.max(24, (maxWidth - layerWidth) / 2);
118
+ for (const node of layer) {
119
+ positions.set(node.id, { id: node.id, x, y, width: nodeW, height: nodeH });
120
+ x += nodeW + hGap;
121
+ }
122
+ y += nodeH + vGap;
123
+ }
124
+ yCursor = y + vGap;
125
+ }
126
+ return { positions, width: maxWidth + 24, height: yCursor };
127
+ }
128
+ function groupByWorkflow(nodes) {
129
+ const out = /* @__PURE__ */ new Map();
130
+ for (const n of nodes) {
131
+ const wf = "workflow" in n ? n.workflow : "";
132
+ const list = out.get(wf) ?? [];
133
+ list.push(n);
134
+ out.set(wf, list);
135
+ }
136
+ return out;
137
+ }
138
+ function layerByBFS(nodes, graph) {
139
+ const stateNodes = nodes.filter((n) => n.kind === "state");
140
+ const markers = nodes.filter((n) => n.kind === "startMarker");
141
+ if (stateNodes.length === 0) return markers.length > 0 ? [markers] : [];
142
+ const adj = /* @__PURE__ */ new Map();
143
+ const indeg = /* @__PURE__ */ new Map();
144
+ for (const n of stateNodes) {
145
+ adj.set(n.id, /* @__PURE__ */ new Set());
146
+ indeg.set(n.id, 0);
147
+ }
148
+ for (const e of graph.edges) {
149
+ if (e.kind !== "transition" || e.isLoopback) continue;
150
+ if (!adj.has(e.sourceId) || !adj.has(e.targetId)) continue;
151
+ const set = adj.get(e.sourceId);
152
+ if (!set.has(e.targetId)) {
153
+ set.add(e.targetId);
154
+ indeg.set(e.targetId, (indeg.get(e.targetId) ?? 0) + 1);
155
+ }
156
+ }
157
+ const layers = [];
158
+ if (markers.length > 0) layers.push(markers);
159
+ const byId = new Map(stateNodes.map((n) => [n.id, n]));
160
+ let frontier = stateNodes.filter((n) => (indeg.get(n.id) ?? 0) === 0);
161
+ const placed = /* @__PURE__ */ new Set();
162
+ while (frontier.length > 0) {
163
+ layers.push(frontier);
164
+ const next = [];
165
+ for (const n of frontier) {
166
+ placed.add(n.id);
167
+ for (const succ of adj.get(n.id) ?? []) {
168
+ const remaining2 = (indeg.get(succ) ?? 0) - 1;
169
+ indeg.set(succ, remaining2);
170
+ if (remaining2 === 0 && !placed.has(succ)) {
171
+ const node = byId.get(succ);
172
+ if (node) next.push(node);
173
+ }
174
+ }
175
+ }
176
+ frontier = next;
177
+ }
178
+ const remaining = stateNodes.filter((n) => !placed.has(n.id));
179
+ if (remaining.length > 0) layers.push(remaining);
180
+ return layers;
181
+ }
182
+
183
+ // src/hooks/usePanZoom.ts
184
+ import { useCallback, useRef, useState } from "react";
185
+ var MIN_SCALE = 0.25;
186
+ var MAX_SCALE = 4;
187
+ var ZOOM_STEP = 1.1;
188
+ function usePanZoom(initial) {
189
+ const [transform, setTransform] = useState({
190
+ x: initial?.x ?? 0,
191
+ y: initial?.y ?? 0,
192
+ scale: initial?.scale ?? 1
193
+ });
194
+ const dragStart = useRef(null);
195
+ const onWheel = useCallback((e) => {
196
+ if (!e.ctrlKey && !e.metaKey) return;
197
+ e.preventDefault();
198
+ const delta = e.deltaY > 0 ? 1 / ZOOM_STEP : ZOOM_STEP;
199
+ setTransform((t) => {
200
+ const nextScale = clamp(t.scale * delta, MIN_SCALE, MAX_SCALE);
201
+ const ratio = nextScale / t.scale;
202
+ const rect = e.currentTarget.getBoundingClientRect();
203
+ const px = e.clientX - rect.left;
204
+ const py = e.clientY - rect.top;
205
+ return {
206
+ scale: nextScale,
207
+ x: px - (px - t.x) * ratio,
208
+ y: py - (py - t.y) * ratio
209
+ };
210
+ });
211
+ }, []);
212
+ const onMouseDown = useCallback((e) => {
213
+ if (e.button !== 0) return;
214
+ dragStart.current = { x: e.clientX, y: e.clientY, vx: transform.x, vy: transform.y };
215
+ }, [transform.x, transform.y]);
216
+ const onMouseMove = useCallback((e) => {
217
+ if (!dragStart.current) return;
218
+ const dx = e.clientX - dragStart.current.x;
219
+ const dy = e.clientY - dragStart.current.y;
220
+ setTransform((t) => ({ ...t, x: dragStart.current.vx + dx, y: dragStart.current.vy + dy }));
221
+ }, []);
222
+ const onMouseUp = useCallback(() => {
223
+ dragStart.current = null;
224
+ }, []);
225
+ const reset = useCallback(() => {
226
+ setTransform({ x: 0, y: 0, scale: 1 });
227
+ }, []);
228
+ return { transform, onWheel, onMouseDown, onMouseMove, onMouseUp, reset, setTransform };
229
+ }
230
+ function clamp(n, min, max) {
231
+ return Math.min(max, Math.max(min, n));
232
+ }
233
+
234
+ // src/theme/lane.ts
235
+ function laneColor(edge, opts) {
236
+ const e = workflowPalette.edge;
237
+ if (edge.disabled) return e.disabled;
238
+ if (edge.isLoopback) return e.loop;
239
+ if (opts.targetIsTerminal) return e.terminal;
240
+ if (edge.summary.processor && edge.summary.processor.kind !== "none") {
241
+ return e.processing;
242
+ }
243
+ if (edge.manual) return e.manual;
244
+ if (edge.summary.criterion) return e.conditional;
245
+ return e.automated;
246
+ }
247
+ function laneDashArray(edge) {
248
+ if (edge.disabled) return "3 2";
249
+ if (edge.isLoopback) return "6 4";
250
+ return void 0;
251
+ }
252
+
253
+ // src/components/EdgePath.tsx
254
+ import { jsx, jsxs } from "react/jsx-runtime";
255
+ function polylineToPath(points) {
256
+ if (points.length === 0) return "";
257
+ const [first, ...rest] = points;
258
+ let d = `M ${first.x} ${first.y}`;
259
+ for (const p of rest) d += ` L ${p.x} ${p.y}`;
260
+ return d;
261
+ }
262
+ function computeEdgeGeometry(edge, source, target) {
263
+ const sx = source.x + source.width / 2;
264
+ const sy = source.y + source.height / 2;
265
+ const tx = target.x + target.width / 2;
266
+ const ty = target.y + target.height / 2;
267
+ if (edge.isSelf) {
268
+ const rightX = source.x + source.width;
269
+ const topY = source.y + source.height / 3;
270
+ const bottomY = source.y + source.height * 2 / 3;
271
+ const loopX = rightX + 28;
272
+ const d2 = `M ${rightX} ${topY} C ${loopX} ${topY}, ${loopX} ${bottomY}, ${rightX} ${bottomY}`;
273
+ return { d: d2, midX: loopX, midY: (topY + bottomY) / 2 };
274
+ }
275
+ const offsetStep = 18;
276
+ const half = Math.floor(edge.parallelGroupSize / 2);
277
+ const signed = edge.parallelIndex - half;
278
+ const offset = signed * offsetStep;
279
+ const mx = (sx + tx) / 2 + offset;
280
+ const my = (sy + ty) / 2;
281
+ const d = `M ${sx} ${sy} Q ${mx} ${my}, ${tx} ${ty}`;
282
+ return { d, midX: mx, midY: my };
283
+ }
284
+ function EdgePath({
285
+ edge,
286
+ source,
287
+ target,
288
+ route,
289
+ targetIsTerminal,
290
+ highlighted,
291
+ dimmed,
292
+ selected,
293
+ onSelect,
294
+ onHoverEnter,
295
+ onHoverLeave
296
+ }) {
297
+ const color = laneColor(edge, { targetIsTerminal });
298
+ const dash = laneDashArray(edge);
299
+ const d = route && route.points.length >= 2 ? polylineToPath(route.points) : computeEdgeGeometry(edge, source, target).d;
300
+ const strokeWidth = selected || highlighted ? geometry.edge.strokeWidth + 0.8 : edge.isLoopback ? geometry.edge.loopStrokeWidth : geometry.edge.strokeWidth;
301
+ const opacity = dimmed ? 0.25 : 1;
302
+ const isManualSolid = edge.manual && !edge.disabled && !edge.isLoopback;
303
+ return /* @__PURE__ */ jsxs(
304
+ "g",
305
+ {
306
+ opacity,
307
+ onClick: (e) => {
308
+ e.stopPropagation();
309
+ onSelect(edge.id);
310
+ },
311
+ onMouseEnter: () => onHoverEnter(edge.id),
312
+ onMouseLeave: onHoverLeave,
313
+ style: { cursor: "pointer" },
314
+ "data-testid": `edge-${edge.id}`,
315
+ children: [
316
+ /* @__PURE__ */ jsx("path", { d, fill: "none", stroke: "transparent", strokeWidth: 12 }),
317
+ /* @__PURE__ */ jsx(
318
+ "path",
319
+ {
320
+ d,
321
+ fill: "none",
322
+ stroke: color,
323
+ strokeWidth,
324
+ strokeDasharray: dash,
325
+ markerEnd: `url(#wf-arrow-${colorKey(color)})`
326
+ }
327
+ ),
328
+ isManualSolid && /* @__PURE__ */ jsx(
329
+ "path",
330
+ {
331
+ d,
332
+ fill: "none",
333
+ stroke: workflowPalette.neutrals.white,
334
+ strokeWidth: 0.6,
335
+ pointerEvents: "none"
336
+ }
337
+ )
338
+ ]
339
+ }
340
+ );
341
+ }
342
+ function colorKey(color) {
343
+ return color.replace("#", "").toLowerCase();
344
+ }
345
+ var laneColorSet = Object.values(workflowPalette.edge);
346
+
347
+ // src/components/Defs.tsx
348
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
349
+ function Defs() {
350
+ const size = geometry.edge.arrowheadSize;
351
+ const unique = Array.from(new Set(laneColorSet));
352
+ return /* @__PURE__ */ jsxs2("defs", { children: [
353
+ unique.map((color) => /* @__PURE__ */ jsx2(
354
+ "marker",
355
+ {
356
+ id: `wf-arrow-${colorKey(color)}`,
357
+ viewBox: `0 0 ${size * 2} ${size * 2}`,
358
+ refX: size * 1.85,
359
+ refY: size,
360
+ markerWidth: size,
361
+ markerHeight: size,
362
+ orient: "auto-start-reverse",
363
+ children: /* @__PURE__ */ jsx2(
364
+ "path",
365
+ {
366
+ d: `M 0 ${size / 2} L ${size * 2} ${size} L 0 ${size * 1.5} z`,
367
+ fill: color
368
+ }
369
+ )
370
+ },
371
+ color
372
+ )),
373
+ /* @__PURE__ */ jsx2("filter", { id: "wf-node-shadow", x: "-10%", y: "-10%", width: "120%", height: "140%", children: /* @__PURE__ */ jsx2(
374
+ "feDropShadow",
375
+ {
376
+ dx: 0,
377
+ dy: 2,
378
+ stdDeviation: 2,
379
+ floodColor: workflowPalette.neutrals.slate900,
380
+ floodOpacity: 0.08
381
+ }
382
+ ) }),
383
+ /* @__PURE__ */ jsx2(
384
+ "filter",
385
+ {
386
+ id: "wf-node-shadow-strong",
387
+ x: "-10%",
388
+ y: "-10%",
389
+ width: "120%",
390
+ height: "140%",
391
+ children: /* @__PURE__ */ jsx2(
392
+ "feDropShadow",
393
+ {
394
+ dx: 0,
395
+ dy: 3,
396
+ stdDeviation: 3,
397
+ floodColor: workflowPalette.neutrals.slate900,
398
+ floodOpacity: 0.18
399
+ }
400
+ )
401
+ }
402
+ ),
403
+ /* @__PURE__ */ jsx2("filter", { id: "wf-label-shadow", x: "-10%", y: "-10%", width: "120%", height: "140%", children: /* @__PURE__ */ jsx2(
404
+ "feDropShadow",
405
+ {
406
+ dx: 0,
407
+ dy: 1,
408
+ stdDeviation: 1.2,
409
+ floodColor: workflowPalette.neutrals.slate900,
410
+ floodOpacity: geometry.labelPill.shadowOpacity
411
+ }
412
+ ) })
413
+ ] });
414
+ }
415
+
416
+ // src/components/StartMarker.tsx
417
+ import { jsx as jsx3 } from "react/jsx-runtime";
418
+ function StartMarker({ position }) {
419
+ const cx = position.x + position.width / 2;
420
+ const cy = position.y + position.height / 2;
421
+ const r = Math.min(position.width, position.height) / 3;
422
+ return /* @__PURE__ */ jsx3("g", { "aria-hidden": "true", children: /* @__PURE__ */ jsx3(
423
+ "circle",
424
+ {
425
+ cx,
426
+ cy,
427
+ r,
428
+ fill: workflowPalette.node.initial.border,
429
+ stroke: workflowPalette.node.initial.meta,
430
+ strokeWidth: 1.5
431
+ }
432
+ ) });
433
+ }
434
+
435
+ // src/theme/role-label.ts
436
+ function roleCategoryLabel(node) {
437
+ if (node.role === "initial" || node.role === "initial-terminal") return "INITIAL";
438
+ if (node.role === "terminal") return "TERMINAL";
439
+ if (node.category === "MANUAL_REVIEW") return "MANUAL REVIEW";
440
+ if (node.category === "PROCESSING_STATE") return "PROCESSING STATE";
441
+ return "STATE";
442
+ }
443
+
444
+ // src/theme/node-palette.ts
445
+ function paletteFor(node) {
446
+ const p = workflowPalette.node;
447
+ if (node.role === "terminal" || node.role === "initial-terminal") return p.terminal;
448
+ if (node.role === "initial") return p.initial;
449
+ if (node.category === "MANUAL_REVIEW") return p.manualReview;
450
+ if (node.category === "PROCESSING_STATE") return p.processing;
451
+ return p.default;
452
+ }
453
+
454
+ // src/theme/badges.ts
455
+ function badgesFor(summary, flags) {
456
+ const out = [];
457
+ if (flags.manual) out.push({ key: "manual", label: "Manual" });
458
+ if (summary.processor) {
459
+ if (summary.processor.kind === "single") {
460
+ out.push({ key: "processor", label: summary.processor.name });
461
+ } else if (summary.processor.kind === "multiple") {
462
+ out.push({ key: "processor", label: `${summary.processor.count} processors` });
463
+ }
464
+ }
465
+ if (summary.criterion) {
466
+ const c = summary.criterion;
467
+ if (c.kind === "group") {
468
+ out.push({ key: "criterion", label: `${c.operator} \xB7 ${c.count}` });
469
+ } else {
470
+ out.push({ key: "criterion", label: "Criterion" });
471
+ }
472
+ }
473
+ if (summary.execution?.kind === "sync") {
474
+ out.push({ key: "execution", label: "SYNC" });
475
+ } else if (summary.execution?.kind === "asyncSameTx") {
476
+ out.push({ key: "execution", label: "ASYNC_SAME_TX" });
477
+ }
478
+ if (flags.disabled) out.push({ key: "disabled", label: "Disabled" });
479
+ return out;
480
+ }
481
+
482
+ // src/components/StateNode.tsx
483
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
484
+ function StateNodeView({
485
+ node,
486
+ position,
487
+ selected,
488
+ highlighted,
489
+ dimmed,
490
+ onSelect,
491
+ onHoverEnter,
492
+ onHoverLeave
493
+ }) {
494
+ const palette = paletteFor(node);
495
+ const { radius, strokeWidth, terminalInset, terminalInnerRadius } = geometry.node;
496
+ const { width, height } = position;
497
+ const isTerminal = node.role === "terminal" || node.role === "initial-terminal";
498
+ const isInitialTerminal = node.role === "initial-terminal";
499
+ const category = roleCategoryLabel(node);
500
+ const opacity = dimmed ? 0.35 : 1;
501
+ const outerStroke = selected ? workflowPalette.neutrals.slate900 : palette.border;
502
+ const outerStrokeWidth = selected ? strokeWidth + 1 : strokeWidth;
503
+ return /* @__PURE__ */ jsxs3(
504
+ "g",
505
+ {
506
+ transform: `translate(${position.x}, ${position.y})`,
507
+ opacity,
508
+ onClick: (e) => {
509
+ e.stopPropagation();
510
+ onSelect(node.id);
511
+ },
512
+ onMouseEnter: () => onHoverEnter(node.id),
513
+ onMouseLeave: onHoverLeave,
514
+ style: { cursor: "pointer" },
515
+ "data-testid": `state-node-${node.stateCode}`,
516
+ "aria-label": `${category} ${node.stateCode}`,
517
+ role: "button",
518
+ tabIndex: 0,
519
+ children: [
520
+ /* @__PURE__ */ jsx4(
521
+ "rect",
522
+ {
523
+ x: 0,
524
+ y: 0,
525
+ width,
526
+ height,
527
+ rx: radius,
528
+ ry: radius,
529
+ fill: palette.fill,
530
+ stroke: outerStroke,
531
+ strokeWidth: outerStrokeWidth,
532
+ filter: highlighted || selected ? "url(#wf-node-shadow-strong)" : "url(#wf-node-shadow)"
533
+ }
534
+ ),
535
+ isTerminal && /* @__PURE__ */ jsx4(
536
+ "rect",
537
+ {
538
+ x: terminalInset,
539
+ y: terminalInset,
540
+ width: width - terminalInset * 2,
541
+ height: height - terminalInset * 2,
542
+ rx: terminalInnerRadius,
543
+ ry: terminalInnerRadius,
544
+ fill: "none",
545
+ stroke: "innerRing" in palette ? palette.innerRing : workflowPalette.neutrals.white75,
546
+ strokeWidth: 1
547
+ }
548
+ ),
549
+ isInitialTerminal && /* @__PURE__ */ jsx4(
550
+ "rect",
551
+ {
552
+ x: terminalInset,
553
+ y: terminalInset,
554
+ width: width - terminalInset * 2,
555
+ height: height - terminalInset * 2,
556
+ rx: terminalInnerRadius,
557
+ ry: terminalInnerRadius,
558
+ fill: "none",
559
+ stroke: workflowPalette.node.initial.border,
560
+ strokeWidth: 1,
561
+ strokeDasharray: "3 3"
562
+ }
563
+ ),
564
+ /* @__PURE__ */ jsx4(
565
+ "text",
566
+ {
567
+ x: width / 2,
568
+ y: height / 2 - 8,
569
+ textAnchor: "middle",
570
+ fill: palette.meta,
571
+ fontFamily: typography.fontFamily,
572
+ fontSize: typography.stateCategory.size,
573
+ fontWeight: typography.stateCategory.weight,
574
+ letterSpacing: typography.stateCategory.tracking,
575
+ children: category
576
+ }
577
+ ),
578
+ /* @__PURE__ */ jsx4(
579
+ "text",
580
+ {
581
+ x: width / 2,
582
+ y: height / 2 + 12,
583
+ textAnchor: "middle",
584
+ fill: palette.title,
585
+ fontFamily: typography.monoFamily,
586
+ fontSize: typography.stateTitle.size,
587
+ fontWeight: typography.stateTitle.weight,
588
+ letterSpacing: typography.stateTitle.tracking,
589
+ children: truncate(node.stateCode, 18)
590
+ }
591
+ )
592
+ ]
593
+ }
594
+ );
595
+ }
596
+ function truncate(s, max) {
597
+ if (s.length <= max) return s;
598
+ return `${s.slice(0, max - 1)}\u2026`;
599
+ }
600
+
601
+ // src/components/EdgeLabel.tsx
602
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
603
+ var BADGE_HEIGHT = 14;
604
+ var BADGE_GAP = 4;
605
+ var LABEL_PADDING_X = geometry.labelPill.paddingX;
606
+ var LABEL_PADDING_Y = geometry.labelPill.paddingY;
607
+ var BADGE_TEXT_PADDING_X = 6;
608
+ function estimateWidth(text, fontSize) {
609
+ return Math.ceil(text.length * fontSize * 0.58);
610
+ }
611
+ function EdgeLabel({ edge, x, y, width, height, dimmed }) {
612
+ const title = edge.summary.display;
613
+ const badges = badgesFor(edge.summary, {
614
+ manual: edge.manual,
615
+ disabled: edge.disabled
616
+ });
617
+ const titleW = estimateWidth(title, typography.edgeLabel.size);
618
+ const badgeWidths = badges.map(
619
+ (b) => estimateWidth(b.label, typography.badge.size) + BADGE_TEXT_PADDING_X * 2
620
+ );
621
+ const badgesTotalW = badgeWidths.reduce((a, b) => a + b, 0) + Math.max(0, badges.length - 1) * BADGE_GAP;
622
+ const pillW = width ?? Math.max(titleW, badgesTotalW) + LABEL_PADDING_X * 2;
623
+ const hasBadges = badges.length > 0;
624
+ const pillH = height ?? typography.edgeLabel.size + LABEL_PADDING_Y * 2 + (hasBadges ? BADGE_HEIGHT + BADGE_GAP : 0);
625
+ const pillX = x - pillW / 2;
626
+ const pillY = y - pillH / 2;
627
+ const titleY = pillY + LABEL_PADDING_Y + typography.edgeLabel.size - 2;
628
+ const badgeY = titleY + BADGE_GAP + 2;
629
+ const opacity = dimmed ? 0.4 : 1;
630
+ return /* @__PURE__ */ jsxs4("g", { opacity, pointerEvents: "none", children: [
631
+ /* @__PURE__ */ jsx5(
632
+ "rect",
633
+ {
634
+ x: pillX,
635
+ y: pillY,
636
+ width: pillW,
637
+ height: pillH,
638
+ rx: geometry.labelPill.radius,
639
+ ry: geometry.labelPill.radius,
640
+ fill: workflowPalette.edgeLabel.fill,
641
+ stroke: workflowPalette.edgeLabel.border,
642
+ strokeWidth: 1,
643
+ filter: "url(#wf-label-shadow)"
644
+ }
645
+ ),
646
+ /* @__PURE__ */ jsx5(
647
+ "text",
648
+ {
649
+ x,
650
+ y: titleY,
651
+ textAnchor: "middle",
652
+ fill: workflowPalette.edgeLabel.text,
653
+ fontFamily: typography.fontFamily,
654
+ fontSize: typography.edgeLabel.size,
655
+ fontWeight: typography.edgeLabel.weight,
656
+ letterSpacing: typography.edgeLabel.tracking,
657
+ children: title
658
+ }
659
+ ),
660
+ hasBadges && renderBadges(badges, badgeWidths, pillX, pillW, badgeY)
661
+ ] });
662
+ }
663
+ function renderBadges(badges, widths, pillX, pillW, y) {
664
+ const totalW = widths.reduce((a, b) => a + b, 0) + Math.max(0, badges.length - 1) * BADGE_GAP;
665
+ let cursor = pillX + (pillW - totalW) / 2;
666
+ return /* @__PURE__ */ jsx5("g", { children: badges.map((b, i) => {
667
+ const w = widths[i];
668
+ const slot = pickBadgePalette(b.key);
669
+ const node = /* @__PURE__ */ jsxs4("g", { children: [
670
+ /* @__PURE__ */ jsx5(
671
+ "rect",
672
+ {
673
+ x: cursor,
674
+ y,
675
+ width: w,
676
+ height: BADGE_HEIGHT,
677
+ rx: BADGE_HEIGHT / 2,
678
+ ry: BADGE_HEIGHT / 2,
679
+ fill: slot.fill,
680
+ stroke: slot.border,
681
+ strokeWidth: 1
682
+ }
683
+ ),
684
+ /* @__PURE__ */ jsx5(
685
+ "text",
686
+ {
687
+ x: cursor + w / 2,
688
+ y: y + BADGE_HEIGHT - 4,
689
+ textAnchor: "middle",
690
+ fill: workflowPalette.badge.text,
691
+ fontFamily: typography.fontFamily,
692
+ fontSize: typography.badge.size,
693
+ fontWeight: typography.badge.weight,
694
+ letterSpacing: typography.badge.tracking,
695
+ children: b.label
696
+ }
697
+ )
698
+ ] }, `${b.key}-${i}`);
699
+ cursor += w + BADGE_GAP;
700
+ return node;
701
+ }) });
702
+ }
703
+ function pickBadgePalette(key) {
704
+ switch (key) {
705
+ case "manual":
706
+ return workflowPalette.badge.manual;
707
+ case "processor":
708
+ return workflowPalette.badge.processor;
709
+ case "criterion":
710
+ return workflowPalette.badge.criterion;
711
+ case "execution":
712
+ return workflowPalette.badge.processor;
713
+ case "disabled":
714
+ return workflowPalette.badge.disabled;
715
+ }
716
+ }
717
+
718
+ // src/components/WorkflowViewer.tsx
719
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
720
+ function WorkflowViewer({
721
+ graph,
722
+ layout,
723
+ width = "100%",
724
+ height = "100%",
725
+ selectedId,
726
+ onSelectionChange,
727
+ className
728
+ }) {
729
+ const effectiveLayout = useMemo(
730
+ () => layout ?? simpleLayout(graph),
731
+ [graph, layout]
732
+ );
733
+ const pan = usePanZoom();
734
+ const [internalSelection, setInternalSelection] = useState2(null);
735
+ const [hovered, setHovered] = useState2(null);
736
+ const selection = selectedId ?? internalSelection;
737
+ const stateNodes = useMemo(
738
+ () => graph.nodes.filter((n) => n.kind === "state"),
739
+ [graph.nodes]
740
+ );
741
+ const stateById = useMemo(() => {
742
+ const m = /* @__PURE__ */ new Map();
743
+ for (const n of stateNodes) m.set(n.id, n);
744
+ return m;
745
+ }, [stateNodes]);
746
+ const transitionEdges = useMemo(
747
+ () => graph.edges.filter((e) => e.kind === "transition"),
748
+ [graph.edges]
749
+ );
750
+ const highlightSet = useMemo(
751
+ () => computeHighlightSet(hovered ?? selection, graph.nodes, graph.edges),
752
+ [hovered, selection, graph.nodes, graph.edges]
753
+ );
754
+ const anythingFocused = highlightSet !== null;
755
+ const handleSelect = (id) => {
756
+ setInternalSelection(id);
757
+ onSelectionChange?.(id);
758
+ };
759
+ const handleBackgroundClick = () => {
760
+ setInternalSelection(null);
761
+ onSelectionChange?.(null);
762
+ };
763
+ return /* @__PURE__ */ jsxs5(
764
+ "svg",
765
+ {
766
+ width,
767
+ height,
768
+ viewBox: `0 0 ${effectiveLayout.width} ${effectiveLayout.height}`,
769
+ preserveAspectRatio: "xMidYMid meet",
770
+ onClick: handleBackgroundClick,
771
+ onWheel: pan.onWheel,
772
+ onMouseDown: pan.onMouseDown,
773
+ onMouseMove: pan.onMouseMove,
774
+ onMouseUp: pan.onMouseUp,
775
+ onMouseLeave: pan.onMouseUp,
776
+ className,
777
+ style: {
778
+ background: workflowPalette.neutrals.white,
779
+ fontFamily: "inherit",
780
+ userSelect: "none"
781
+ },
782
+ "data-testid": "workflow-viewer",
783
+ children: [
784
+ /* @__PURE__ */ jsx6(Defs, {}),
785
+ /* @__PURE__ */ jsxs5(
786
+ "g",
787
+ {
788
+ transform: `translate(${pan.transform.x}, ${pan.transform.y}) scale(${pan.transform.scale})`,
789
+ children: [
790
+ transitionEdges.map((edge) => {
791
+ const source = effectiveLayout.positions.get(edge.sourceId);
792
+ const target = effectiveLayout.positions.get(edge.targetId);
793
+ if (!source || !target) return null;
794
+ const targetNode = stateById.get(edge.targetId);
795
+ const route = effectiveLayout.edges?.get(edge.id);
796
+ const isEdgeSelected = selection === edge.id;
797
+ const isHighlighted = highlightSet?.has(edge.id) ?? false;
798
+ const isDimmed = anythingFocused && !isHighlighted;
799
+ return /* @__PURE__ */ jsx6(
800
+ EdgePath,
801
+ {
802
+ edge,
803
+ source,
804
+ target,
805
+ route,
806
+ targetIsTerminal: targetNode?.role === "terminal" || targetNode?.role === "initial-terminal",
807
+ highlighted: isHighlighted,
808
+ dimmed: isDimmed,
809
+ selected: isEdgeSelected,
810
+ onSelect: handleSelect,
811
+ onHoverEnter: setHovered,
812
+ onHoverLeave: () => setHovered(null)
813
+ },
814
+ edge.id
815
+ );
816
+ }),
817
+ transitionEdges.map((edge) => {
818
+ const source = effectiveLayout.positions.get(edge.sourceId);
819
+ const target = effectiveLayout.positions.get(edge.targetId);
820
+ if (!source || !target) return null;
821
+ const route = effectiveLayout.edges?.get(edge.id);
822
+ const labelPos = route ? { midX: route.labelX, midY: route.labelY } : computeEdgeGeometry(edge, source, target);
823
+ const isHighlighted = highlightSet?.has(edge.id) ?? false;
824
+ const isDimmed = anythingFocused && !isHighlighted;
825
+ return /* @__PURE__ */ jsx6(
826
+ EdgeLabel,
827
+ {
828
+ edge,
829
+ x: labelPos.midX,
830
+ y: labelPos.midY,
831
+ width: route?.labelWidth,
832
+ height: route?.labelHeight,
833
+ dimmed: isDimmed
834
+ },
835
+ `label-${edge.id}`
836
+ );
837
+ }),
838
+ graph.nodes.map((node) => renderNode(node, effectiveLayout, {
839
+ selection,
840
+ highlightSet,
841
+ anythingFocused,
842
+ onSelect: handleSelect,
843
+ onHoverEnter: setHovered,
844
+ onHoverLeave: () => setHovered(null)
845
+ }))
846
+ ]
847
+ }
848
+ )
849
+ ]
850
+ }
851
+ );
852
+ }
853
+ function renderNode(node, layout, ctx) {
854
+ const pos = layout.positions.get(node.id);
855
+ if (!pos) return null;
856
+ if (node.kind === "startMarker") {
857
+ return /* @__PURE__ */ jsx6(StartMarker, { position: smallPositionForMarker(pos) }, node.id);
858
+ }
859
+ const isHighlighted = ctx.highlightSet?.has(node.id) ?? false;
860
+ const isDimmed = ctx.anythingFocused && !isHighlighted;
861
+ return /* @__PURE__ */ jsx6(
862
+ StateNodeView,
863
+ {
864
+ node,
865
+ position: pos,
866
+ selected: ctx.selection === node.id,
867
+ highlighted: isHighlighted,
868
+ dimmed: isDimmed,
869
+ onSelect: ctx.onSelect,
870
+ onHoverEnter: ctx.onHoverEnter,
871
+ onHoverLeave: ctx.onHoverLeave
872
+ },
873
+ node.id
874
+ );
875
+ }
876
+ function smallPositionForMarker(pos) {
877
+ const size = 16;
878
+ return {
879
+ id: pos.id,
880
+ x: pos.x + pos.width / 2 - size / 2,
881
+ y: pos.y + pos.height / 2 - size / 2,
882
+ width: size,
883
+ height: size
884
+ };
885
+ }
886
+ function computeHighlightSet(focusedId, nodes, edges) {
887
+ if (!focusedId) return null;
888
+ const set = /* @__PURE__ */ new Set();
889
+ set.add(focusedId);
890
+ const node = nodes.find((n) => n.id === focusedId);
891
+ if (node) {
892
+ for (const e of edges) {
893
+ if (e.kind !== "transition") continue;
894
+ if (e.sourceId === focusedId || e.targetId === focusedId) {
895
+ set.add(e.id);
896
+ set.add(e.sourceId);
897
+ set.add(e.targetId);
898
+ }
899
+ }
900
+ return set;
901
+ }
902
+ const edge = edges.find((e) => e.id === focusedId);
903
+ if (edge && edge.kind === "transition") {
904
+ set.add(edge.sourceId);
905
+ set.add(edge.targetId);
906
+ }
907
+ return set;
908
+ }
909
+ export {
910
+ WorkflowViewer,
911
+ simpleLayout
912
+ };
913
+ //# sourceMappingURL=index.js.map