sfn-diagram 1.4.0 → 1.5.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/cli.js CHANGED
@@ -209,6 +209,57 @@ function extractAslFromTemplate(params) {
209
209
  };
210
210
  }
211
211
  //#endregion
212
+ //#region src/config/defaults.ts
213
+ /**
214
+ * Default diagram options - used when options not provided
215
+ */
216
+ const DEFAULT_DIAGRAM_OPTIONS = {
217
+ format: "svg",
218
+ theme: "light",
219
+ customColors: void 0,
220
+ layout: "TB",
221
+ rankSeparation: 50,
222
+ nodeSeparation: 50,
223
+ width: void 0,
224
+ height: void 0,
225
+ nodeWidth: 120,
226
+ nodeHeight: 60,
227
+ padding: 20,
228
+ includeComments: true,
229
+ showStateTypes: false,
230
+ showVariables: true,
231
+ edgeStyle: "curved",
232
+ catchHandling: "show",
233
+ catchLabelStyle: "error-type",
234
+ collapse: void 0,
235
+ stylePreset: "aws-standard",
236
+ iconPosition: "left",
237
+ iconResolver: void 0,
238
+ iconSize: 24,
239
+ showIcons: false,
240
+ pngQuality: 90,
241
+ backgroundColor: "transparent",
242
+ nodeOverrides: void 0,
243
+ edgeOverrides: void 0,
244
+ nodeAnnotations: void 0
245
+ };
246
+ /**
247
+ * Merge user-provided options with defaults
248
+ */
249
+ function mergeOptions(options = {}) {
250
+ return {
251
+ ...DEFAULT_DIAGRAM_OPTIONS,
252
+ ...options
253
+ };
254
+ }
255
+ function mergeRecordOptions(base, override) {
256
+ if (!base && !override) return void 0;
257
+ return {
258
+ ...base,
259
+ ...override
260
+ };
261
+ }
262
+ //#endregion
212
263
  //#region src/config/themes.ts
213
264
  /**
214
265
  * AWS Light Theme - matches the AWS Step Functions console light mode
@@ -320,10 +371,7 @@ function getTheme(theme, customColors) {
320
371
  else baseTheme = theme;
321
372
  if (customColors) return {
322
373
  ...baseTheme,
323
- nodeColors: {
324
- ...baseTheme.nodeColors,
325
- ...customColors
326
- }
374
+ nodeColors: mergeRecordOptions(baseTheme.nodeColors, customColors)
327
375
  };
328
376
  return baseTheme;
329
377
  }
@@ -696,6 +744,228 @@ function detectServiceFromResource(params) {
696
744
  };
697
745
  }
698
746
  //#endregion
747
+ //#region src/graph/collapseContainers.ts
748
+ /** Synthetic marker node types that don't count as real states. */
749
+ const SYNTHETIC_MARKER_TYPES = new Set(["BranchEnd", "IteratorEnd"]);
750
+ /**
751
+ * Resolve a collapse selection against a graph: which containers end up with a
752
+ * placeholder, and which node ids each one hides. Shared by {@link applyCollapse}
753
+ * (which rewrites the graph) and callers that only need to know what a collapse
754
+ * *would* do — e.g. deciding whether a second render is worth paying for, or
755
+ * checking whether a diff-changed state falls inside a placeholder.
756
+ *
757
+ * Descendant membership is derived by walking `StateNode.children` recursively —
758
+ * `parent` is never populated by the parser, and a container's own `children` only
759
+ * lists its direct level (entry state, end marker, direct branch/iterator states),
760
+ * not further-nested containers' own descendants. Recursing into any visited child
761
+ * that `isContainer` produces the full multi-level closure.
762
+ */
763
+ function computeCollapsePlan(params) {
764
+ const { collapse, edges, nodes } = params;
765
+ const nodesById = new Map(nodes.map((node) => [node.id, node]));
766
+ const containerIds = new Set(nodes.filter((node) => node.isContainer).map((node) => node.id));
767
+ const requestedTargets = !collapse ? /* @__PURE__ */ new Set() : collapse === true ? containerIds : new Set(collapse.filter((name) => containerIds.has(name)));
768
+ if (requestedTargets.size === 0) return {
769
+ effectiveTargets: requestedTargets,
770
+ hiddenIdsByTarget: /* @__PURE__ */ new Map(),
771
+ removedIds: /* @__PURE__ */ new Set()
772
+ };
773
+ const closureFor = (containerId) => {
774
+ const closure = /* @__PURE__ */ new Set();
775
+ const queue = [...nodesById.get(containerId)?.children ?? []];
776
+ while (queue.length > 0) {
777
+ const currentId = queue.shift();
778
+ if (closure.has(currentId)) continue;
779
+ closure.add(currentId);
780
+ const current = nodesById.get(currentId);
781
+ if (current?.isContainer) queue.push(...current.children ?? []);
782
+ }
783
+ return closure;
784
+ };
785
+ const hiddenIdsByTarget = /* @__PURE__ */ new Map();
786
+ const removedIds = /* @__PURE__ */ new Set();
787
+ for (const targetId of requestedTargets) {
788
+ const closure = closureFor(targetId);
789
+ hiddenIdsByTarget.set(targetId, closure);
790
+ for (const id of closure) removedIds.add(id);
791
+ }
792
+ for (const edge of edges) {
793
+ const fromNode = nodesById.get(edge.from);
794
+ const toNode = nodesById.get(edge.to);
795
+ if (fromNode && MAP_IO_NODE_TYPES.has(fromNode.type) && removedIds.has(edge.to)) removedIds.add(edge.from);
796
+ if (toNode && MAP_IO_NODE_TYPES.has(toNode.type) && removedIds.has(edge.from)) removedIds.add(edge.to);
797
+ }
798
+ return {
799
+ effectiveTargets: new Set([...requestedTargets].filter((targetId) => !removedIds.has(targetId))),
800
+ hiddenIdsByTarget,
801
+ removedIds
802
+ };
803
+ }
804
+ /**
805
+ * Replace each targeted Parallel/Map container's subgraph with a placeholder node.
806
+ *
807
+ * No edge is ever rerouted, only deleted: every edge that should still exist after
808
+ * a collapse already targets the container's own id (external entry edges, the
809
+ * container -> Next visual edge, and Catch/Retry on the container state are all
810
+ * anchored at the container id by `AslParser`, never at a descendant).
811
+ */
812
+ function applyCollapse(params) {
813
+ const { collapse, edges, nodes } = params;
814
+ if (!collapse) return {
815
+ edges,
816
+ nodes
817
+ };
818
+ const { effectiveTargets, hiddenIdsByTarget, removedIds } = computeCollapsePlan({
819
+ collapse,
820
+ edges,
821
+ nodes
822
+ });
823
+ if (effectiveTargets.size === 0) return {
824
+ edges,
825
+ nodes
826
+ };
827
+ const nodesById = new Map(nodes.map((node) => [node.id, node]));
828
+ const resultNodes = nodes.filter((node) => !removedIds.has(node.id)).map((node) => {
829
+ if (!effectiveTargets.has(node.id)) return node;
830
+ const closure = hiddenIdsByTarget.get(node.id);
831
+ let collapsedCount = 0;
832
+ for (const id of closure) {
833
+ const descendant = nodesById.get(id);
834
+ if (descendant && !SYNTHETIC_MARKER_TYPES.has(descendant.type)) collapsedCount += 1;
835
+ }
836
+ return {
837
+ ...node,
838
+ children: [],
839
+ collapsed: true,
840
+ collapsedCount
841
+ };
842
+ });
843
+ return {
844
+ edges: edges.filter((edge) => !removedIds.has(edge.from) && !removedIds.has(edge.to)),
845
+ nodes: resultNodes
846
+ };
847
+ }
848
+ //#endregion
849
+ //#region src/graph/catchHandling.ts
850
+ /**
851
+ * Apply catch-branch handling to a parsed graph.
852
+ *
853
+ * In 'hide' mode, removes every error edge and then any node that is not
854
+ * reachable from the start state by following the remaining (non-error) edges.
855
+ * This drops catch-handler chains entirely, including ones that only reference
856
+ * each other (e.g. a cyclic handler chain unreachable from the happy path), since
857
+ * reachability is computed with a single forward pass from the start state rather
858
+ * than an incoming-edge check. If no start state id is provided, every node that
859
+ * still has a surviving incoming or outgoing edge is kept instead. In 'show' mode
860
+ * the graph is returned unchanged.
861
+ *
862
+ * Distributed Map `ItemReader` / `ResultWriter` satellites are exempt from the
863
+ * forward-reachability rule: they are attached to their Map structurally, and an
864
+ * ItemReader is a graph source that no forward pass can reach. Each is kept
865
+ * whenever the node it connects to survives.
866
+ */
867
+ function applyCatchHandling(params) {
868
+ const { edges, mode, nodes, startStateId } = params;
869
+ if (mode === "show") return {
870
+ edges,
871
+ nodes
872
+ };
873
+ const keptEdges = edges.filter((edge) => edge.type !== "error");
874
+ let reachableIds;
875
+ if (startStateId !== void 0) {
876
+ const adjacency = /* @__PURE__ */ new Map();
877
+ for (const edge of keptEdges) {
878
+ const targets = adjacency.get(edge.from);
879
+ if (targets) targets.push(edge.to);
880
+ else adjacency.set(edge.from, [edge.to]);
881
+ }
882
+ reachableIds = new Set([startStateId]);
883
+ const queue = [startStateId];
884
+ while (queue.length > 0) {
885
+ const currentId = queue.shift();
886
+ for (const targetId of adjacency.get(currentId) ?? []) if (!reachableIds.has(targetId)) {
887
+ reachableIds.add(targetId);
888
+ queue.push(targetId);
889
+ }
890
+ }
891
+ } else {
892
+ reachableIds = /* @__PURE__ */ new Set();
893
+ for (const edge of keptEdges) {
894
+ reachableIds.add(edge.from);
895
+ reachableIds.add(edge.to);
896
+ }
897
+ }
898
+ const nodesById = new Map(nodes.map((node) => [node.id, node]));
899
+ for (const edge of keptEdges) {
900
+ const source = nodesById.get(edge.from);
901
+ if (source && MAP_IO_NODE_TYPES.has(source.type) && reachableIds.has(edge.to)) reachableIds.add(edge.from);
902
+ }
903
+ const survivingNodes = nodes.filter((node) => reachableIds.has(node.id));
904
+ const survivingIds = new Set(survivingNodes.map((node) => node.id));
905
+ return {
906
+ edges: keptEdges.filter((edge) => survivingIds.has(edge.from) && survivingIds.has(edge.to)),
907
+ nodes: survivingNodes
908
+ };
909
+ }
910
+ //#endregion
911
+ //#region src/graph/containers.ts
912
+ /**
913
+ * True for a container node that is still drawn as a group with its own child
914
+ * nodes — a collapsed container is a plain placeholder node instead, and every
915
+ * layout/render call site that branches on "is this an open container" needs
916
+ * both halves of that check.
917
+ */
918
+ function isOpenContainer(node) {
919
+ return node.isContainer === true && !node.collapsed;
920
+ }
921
+ //#endregion
922
+ //#region src/graph/edgeIdentity.ts
923
+ /**
924
+ * Stamp each edge with a stable identity, unique across the whole graph.
925
+ *
926
+ * Several legitimate ASL shapes produce two edges with the same `from`/`to` — two
927
+ * Choice rules sharing a `Next`, a `Retry` self-loop beside a genuine self-transition,
928
+ * a `Catch` pointing back at its own state. Without a discriminator those edges collapse
929
+ * onto one dagre edge, one routing path, and one `edgeOverrides` key.
930
+ *
931
+ * The id is `${from}->${to}#${type}#${ordinal}`, where `type` falls back to `'normal'`
932
+ * and `ordinal` counts edges already seen with that same `(from, to, type)` triple.
933
+ * Normalizing the type before counting matters: an edge with no `type` and one typed
934
+ * `'normal'` must share a counter, or they would render identical ids.
935
+ *
936
+ * Run this once, over the fully assembled edge array, so ordinals are global and
937
+ * deterministic. Downstream transforms (`applyCollapse`, `applyCatchHandling`) only
938
+ * filter edges, so ids survive them untouched — gaps in the ordinal sequence are
939
+ * intentional, since renumbering would silently invalidate a caller's override key.
940
+ *
941
+ * @param params - Object parameters
942
+ * @param params.edges - Every edge in the graph, in parser order
943
+ * @returns The same edges, in the same order, each with an `id`
944
+ *
945
+ * @example
946
+ * ```typescript
947
+ * assignEdgeIds({ edges: [
948
+ * { from: 'Route', to: 'Work', type: 'choice' },
949
+ * { from: 'Route', to: 'Work', type: 'choice' },
950
+ * ] });
951
+ * // ids: 'Route->Work#choice#0', 'Route->Work#choice#1'
952
+ * ```
953
+ */
954
+ function assignEdgeIds(params) {
955
+ const { edges } = params;
956
+ const ordinals = /* @__PURE__ */ new Map();
957
+ return edges.map((edge) => {
958
+ const type = edge.type ?? "normal";
959
+ const pairKey = `${edge.from}->${edge.to}#${type}`;
960
+ const ordinal = ordinals.get(pairKey) ?? 0;
961
+ ordinals.set(pairKey, ordinal + 1);
962
+ return {
963
+ ...edge,
964
+ id: `${pairKey}#${ordinal}`
965
+ };
966
+ });
967
+ }
968
+ //#endregion
699
969
  //#region src/AslParser.ts
700
970
  /**
701
971
  * Error thrown when ASL validation fails
@@ -803,7 +1073,7 @@ function parseAsl(params) {
803
1073
  options
804
1074
  });
805
1075
  return {
806
- edges,
1076
+ edges: assignEdgeIds({ edges }),
807
1077
  nodes
808
1078
  };
809
1079
  }
@@ -1217,182 +1487,12 @@ function extractNestedEdges(params) {
1217
1487
  }
1218
1488
  }
1219
1489
  //#endregion
1220
- //#region src/graph/collapseContainers.ts
1221
- /** Synthetic marker node types that don't count as real states. */
1222
- const SYNTHETIC_MARKER_TYPES = new Set(["BranchEnd", "IteratorEnd"]);
1223
- /**
1224
- * Resolve a collapse selection against a graph: which containers end up with a
1225
- * placeholder, and which node ids each one hides. Shared by {@link applyCollapse}
1226
- * (which rewrites the graph) and callers that only need to know what a collapse
1227
- * *would* do — e.g. deciding whether a second render is worth paying for, or
1228
- * checking whether a diff-changed state falls inside a placeholder.
1229
- *
1230
- * Descendant membership is derived by walking `StateNode.children` recursively —
1231
- * `parent` is never populated by the parser, and a container's own `children` only
1232
- * lists its direct level (entry state, end marker, direct branch/iterator states),
1233
- * not further-nested containers' own descendants. Recursing into any visited child
1234
- * that `isContainer` produces the full multi-level closure.
1235
- */
1236
- function computeCollapsePlan(params) {
1237
- const { collapse, edges, nodes } = params;
1238
- const nodesById = new Map(nodes.map((node) => [node.id, node]));
1239
- const containerIds = new Set(nodes.filter((node) => node.isContainer).map((node) => node.id));
1240
- const requestedTargets = !collapse ? /* @__PURE__ */ new Set() : collapse === true ? containerIds : new Set(collapse.filter((name) => containerIds.has(name)));
1241
- if (requestedTargets.size === 0) return {
1242
- effectiveTargets: requestedTargets,
1243
- hiddenIdsByTarget: /* @__PURE__ */ new Map(),
1244
- removedIds: /* @__PURE__ */ new Set()
1245
- };
1246
- const closureFor = (containerId) => {
1247
- const closure = /* @__PURE__ */ new Set();
1248
- const queue = [...nodesById.get(containerId)?.children ?? []];
1249
- while (queue.length > 0) {
1250
- const currentId = queue.shift();
1251
- if (closure.has(currentId)) continue;
1252
- closure.add(currentId);
1253
- const current = nodesById.get(currentId);
1254
- if (current?.isContainer) queue.push(...current.children ?? []);
1255
- }
1256
- return closure;
1257
- };
1258
- const hiddenIdsByTarget = /* @__PURE__ */ new Map();
1259
- const removedIds = /* @__PURE__ */ new Set();
1260
- for (const targetId of requestedTargets) {
1261
- const closure = closureFor(targetId);
1262
- hiddenIdsByTarget.set(targetId, closure);
1263
- for (const id of closure) removedIds.add(id);
1264
- }
1265
- for (const edge of edges) {
1266
- const fromNode = nodesById.get(edge.from);
1267
- const toNode = nodesById.get(edge.to);
1268
- if (fromNode && MAP_IO_NODE_TYPES.has(fromNode.type) && removedIds.has(edge.to)) removedIds.add(edge.from);
1269
- if (toNode && MAP_IO_NODE_TYPES.has(toNode.type) && removedIds.has(edge.from)) removedIds.add(edge.to);
1270
- }
1271
- return {
1272
- effectiveTargets: new Set([...requestedTargets].filter((targetId) => !removedIds.has(targetId))),
1273
- hiddenIdsByTarget,
1274
- removedIds
1275
- };
1276
- }
1277
- /**
1278
- * Replace each targeted Parallel/Map container's subgraph with a placeholder node.
1279
- *
1280
- * No edge is ever rerouted, only deleted: every edge that should still exist after
1281
- * a collapse already targets the container's own id (external entry edges, the
1282
- * container -> Next visual edge, and Catch/Retry on the container state are all
1283
- * anchored at the container id by `AslParser`, never at a descendant).
1284
- */
1285
- function applyCollapse(params) {
1286
- const { collapse, edges, nodes } = params;
1287
- if (!collapse) return {
1288
- edges,
1289
- nodes
1290
- };
1291
- const { effectiveTargets, hiddenIdsByTarget, removedIds } = computeCollapsePlan({
1292
- collapse,
1293
- edges,
1294
- nodes
1295
- });
1296
- if (effectiveTargets.size === 0) return {
1297
- edges,
1298
- nodes
1299
- };
1300
- const nodesById = new Map(nodes.map((node) => [node.id, node]));
1301
- const resultNodes = nodes.filter((node) => !removedIds.has(node.id)).map((node) => {
1302
- if (!effectiveTargets.has(node.id)) return node;
1303
- const closure = hiddenIdsByTarget.get(node.id);
1304
- let collapsedCount = 0;
1305
- for (const id of closure) {
1306
- const descendant = nodesById.get(id);
1307
- if (descendant && !SYNTHETIC_MARKER_TYPES.has(descendant.type)) collapsedCount += 1;
1308
- }
1309
- return {
1310
- ...node,
1311
- children: [],
1312
- collapsed: true,
1313
- collapsedCount
1314
- };
1315
- });
1316
- return {
1317
- edges: edges.filter((edge) => !removedIds.has(edge.from) && !removedIds.has(edge.to)),
1318
- nodes: resultNodes
1319
- };
1320
- }
1321
- //#endregion
1322
- //#region src/graph/catchHandling.ts
1323
- /**
1324
- * Apply catch-branch handling to a parsed graph.
1325
- *
1326
- * In 'hide' mode, removes every error edge and then any node that is not
1327
- * reachable from the start state by following the remaining (non-error) edges.
1328
- * This drops catch-handler chains entirely, including ones that only reference
1329
- * each other (e.g. a cyclic handler chain unreachable from the happy path), since
1330
- * reachability is computed with a single forward pass from the start state rather
1331
- * than an incoming-edge check. If no start state id is provided, every node that
1332
- * still has a surviving incoming or outgoing edge is kept instead. In 'show' mode
1333
- * the graph is returned unchanged.
1334
- *
1335
- * Distributed Map `ItemReader` / `ResultWriter` satellites are exempt from the
1336
- * forward-reachability rule: they are attached to their Map structurally, and an
1337
- * ItemReader is a graph source that no forward pass can reach. Each is kept
1338
- * whenever the node it connects to survives.
1339
- */
1340
- function applyCatchHandling(params) {
1341
- const { edges, mode, nodes, startStateId } = params;
1342
- if (mode === "show") return {
1343
- edges,
1344
- nodes
1345
- };
1346
- const keptEdges = edges.filter((edge) => edge.type !== "error");
1347
- let reachableIds;
1348
- if (startStateId !== void 0) {
1349
- const adjacency = /* @__PURE__ */ new Map();
1350
- for (const edge of keptEdges) {
1351
- const targets = adjacency.get(edge.from);
1352
- if (targets) targets.push(edge.to);
1353
- else adjacency.set(edge.from, [edge.to]);
1354
- }
1355
- reachableIds = new Set([startStateId]);
1356
- const queue = [startStateId];
1357
- while (queue.length > 0) {
1358
- const currentId = queue.shift();
1359
- for (const targetId of adjacency.get(currentId) ?? []) if (!reachableIds.has(targetId)) {
1360
- reachableIds.add(targetId);
1361
- queue.push(targetId);
1362
- }
1363
- }
1364
- } else {
1365
- reachableIds = /* @__PURE__ */ new Set();
1366
- for (const edge of keptEdges) {
1367
- reachableIds.add(edge.from);
1368
- reachableIds.add(edge.to);
1369
- }
1370
- }
1371
- const nodesById = new Map(nodes.map((node) => [node.id, node]));
1372
- for (const edge of keptEdges) {
1373
- const source = nodesById.get(edge.from);
1374
- if (source && MAP_IO_NODE_TYPES.has(source.type) && reachableIds.has(edge.to)) reachableIds.add(edge.from);
1375
- }
1376
- const survivingNodes = nodes.filter((node) => reachableIds.has(node.id));
1377
- const survivingIds = new Set(survivingNodes.map((node) => node.id));
1378
- return {
1379
- edges: keptEdges.filter((edge) => survivingIds.has(edge.from) && survivingIds.has(edge.to)),
1380
- nodes: survivingNodes
1381
- };
1382
- }
1383
- //#endregion
1384
- //#region src/graph/containers.ts
1385
- /**
1386
- * True for a container node that is still drawn as a group with its own child
1387
- * nodes — a collapsed container is a plain placeholder node instead, and every
1388
- * layout/render call site that branches on "is this an open container" needs
1389
- * both halves of that check.
1390
- */
1391
- function isOpenContainer(node) {
1392
- return node.isContainer === true && !node.collapsed;
1393
- }
1394
- //#endregion
1395
1490
  //#region src/layout/DagreLayout.ts
1491
+ /** Self-loop arc geometry. Each additional loop on a node nests one step further out. */
1492
+ const LOOP_BASE_REACH = 40;
1493
+ const LOOP_REACH_STEP = 22;
1494
+ const LOOP_BASE_SPREAD = 12;
1495
+ const LOOP_SPREAD_STEP = 5;
1396
1496
  /**
1397
1497
  * DagreLayout - Calculates node positions and edge routing using Dagre algorithm
1398
1498
  */
@@ -1405,7 +1505,7 @@ var DagreLayout = class {
1405
1505
  * Calculate layout positions for nodes and edges
1406
1506
  */
1407
1507
  calculate(nodes, edges) {
1408
- const graph = new dagre.graphlib.Graph();
1508
+ const graph = new dagre.graphlib.Graph({ multigraph: true });
1409
1509
  graph.setGraph({
1410
1510
  marginx: this.options.padding || 20,
1411
1511
  marginy: this.options.padding || 20,
@@ -1443,14 +1543,14 @@ var DagreLayout = class {
1443
1543
  for (const child of entryChildrenByContainer.get(edge.to) ?? []) graph.setEdge(edge.from, child, {
1444
1544
  label: edge.label,
1445
1545
  type: edge.type
1446
- });
1546
+ }, `${edge.id}#entry#${child}`);
1447
1547
  return;
1448
1548
  }
1449
1549
  if (fromIsContainer || toIsContainer) return;
1450
1550
  graph.setEdge(edge.from, edge.to, {
1451
1551
  label: edge.label,
1452
1552
  type: edge.type
1453
- });
1553
+ }, edge.id);
1454
1554
  });
1455
1555
  dagre.layout(graph);
1456
1556
  const positionedNodes = layoutNodes.map((node) => {
@@ -1470,18 +1570,29 @@ var DagreLayout = class {
1470
1570
  });
1471
1571
  const allPositionedNodes = [...positionedNodes, ...containerNodes];
1472
1572
  const positionedNodesById = new Map(allPositionedNodes.map((node) => [node.id, node]));
1573
+ const loopIndexById = /* @__PURE__ */ new Map();
1574
+ const loopCountByNode = /* @__PURE__ */ new Map();
1575
+ for (const edge of edges) {
1576
+ if (edge.from !== edge.to) continue;
1577
+ const loopIndex = loopCountByNode.get(edge.from) ?? 0;
1578
+ loopIndexById.set(edge.id, loopIndex);
1579
+ loopCountByNode.set(edge.from, loopIndex + 1);
1580
+ }
1473
1581
  const routedEdges = edges.map((edge) => {
1474
1582
  const fromNode = positionedNodesById.get(edge.from);
1475
1583
  const toNode = positionedNodesById.get(edge.to);
1476
1584
  const touchesContainer = Boolean(fromNode && isOpenContainer(fromNode) || toNode && isOpenContainer(toNode));
1477
- if (edge.visualOnly || touchesContainer || edge.from === edge.to) return {
1585
+ const isSelfLoop = edge.from === edge.to;
1586
+ if (edge.visualOnly || touchesContainer || isSelfLoop) return {
1478
1587
  ...edge,
1588
+ ...isSelfLoop ? { loopIndex: loopIndexById.get(edge.id) ?? 0 } : {},
1479
1589
  points: this.calculateVisualEdgePoints({
1480
1590
  edge,
1591
+ loopIndex: loopIndexById.get(edge.id) ?? 0,
1481
1592
  positionedNodesById
1482
1593
  })
1483
1594
  };
1484
- const dagEdge = graph.edge(edge.from, edge.to);
1595
+ const dagEdge = graph.edge(edge.from, edge.to, edge.id);
1485
1596
  return {
1486
1597
  ...edge,
1487
1598
  points: dagEdge?.points ?? []
@@ -1548,14 +1659,14 @@ var DagreLayout = class {
1548
1659
  * Calculate routing points for visual-only edges
1549
1660
  */
1550
1661
  calculateVisualEdgePoints(params) {
1551
- const { edge, positionedNodesById } = params;
1662
+ const { edge, loopIndex, positionedNodesById } = params;
1552
1663
  const fromNode = positionedNodesById.get(edge.from);
1553
1664
  const toNode = positionedNodesById.get(edge.to);
1554
1665
  if (!fromNode || !toNode) return [];
1555
1666
  if (edge.from === edge.to) {
1556
1667
  const rankdir = this.options.layout || "TB";
1557
- const loopReach = 40;
1558
- const loopSpread = 12;
1668
+ const loopReach = LOOP_BASE_REACH + loopIndex * LOOP_REACH_STEP;
1669
+ const loopSpread = LOOP_BASE_SPREAD + loopIndex * LOOP_SPREAD_STEP;
1559
1670
  if (rankdir === "LR" || rankdir === "RL") {
1560
1671
  const topY = (fromNode.y || 0) - (fromNode.height || 0) / 2;
1561
1672
  const centerX = fromNode.x || 0;
@@ -1698,11 +1809,14 @@ function estimateTextWidth(text, fontSize) {
1698
1809
  /**
1699
1810
  * Escape a string for use as a double-quoted HTML attribute value.
1700
1811
  *
1701
- * Matches the HTML serialization algorithm: only `&`, U+00A0, and `"` are escaped
1702
- * (`<` and `>` are intentionally left untouched inside attribute values).
1812
+ * Goes one step further than the HTML serialization algorithm, which leaves `<` and
1813
+ * `>` untouched inside attribute values. An edge id is `${from}->${to}#...`, so a raw
1814
+ * `>` would put a tag-looking character inside every edge's `data-edge-id` and break
1815
+ * the `<path[^>]*>` style scans callers run over the serialized SVG. The escaping is
1816
+ * transparent to any XML/DOM parser, which hands back the original string.
1703
1817
  */
1704
1818
  function escapeAttribute(value) {
1705
- return value.replace(/&/g, "&amp;").replace(/\u00A0/g, "&nbsp;").replace(/"/g, "&quot;");
1819
+ return value.replace(/&/g, "&amp;").replace(/\u00A0/g, "&nbsp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1706
1820
  }
1707
1821
  /**
1708
1822
  * Escape a string for use as element text content.
@@ -1772,7 +1886,11 @@ var SvgRenderer = class {
1772
1886
  * Render the diagram to SVG string
1773
1887
  */
1774
1888
  render(layout) {
1775
- const bounds = this.calculateBounds(layout);
1889
+ const selfLoopLabelWidths = this.calculateSelfLoopLabelWidths(layout.edges);
1890
+ const bounds = this.calculateBounds({
1891
+ layout,
1892
+ selfLoopLabelWidths
1893
+ });
1776
1894
  const svg = new SvgElement("svg").attr("width", bounds.width).attr("height", bounds.height).attr("xmlns", "http://www.w3.org/2000/svg").attr("viewBox", `${bounds.minX} ${bounds.minY} ${bounds.width} ${bounds.height}`);
1777
1895
  if (this.theme.background && this.theme.background !== "transparent") svg.append("rect").attr("x", bounds.minX).attr("y", bounds.minY).attr("width", bounds.width).attr("height", bounds.height).attr("fill", this.theme.background);
1778
1896
  const defs = svg.append("defs");
@@ -1793,7 +1911,8 @@ var SvgRenderer = class {
1793
1911
  this.renderEdge({
1794
1912
  edge,
1795
1913
  group: edgesGroup,
1796
- nodes: layout.nodes
1914
+ nodes: layout.nodes,
1915
+ selfLoopLabelWidths
1797
1916
  });
1798
1917
  });
1799
1918
  containerNodes.forEach((node) => {
@@ -1821,7 +1940,8 @@ var SvgRenderer = class {
1821
1940
  /**
1822
1941
  * Calculate bounding box including all nodes and edge points
1823
1942
  */
1824
- calculateBounds(layout) {
1943
+ calculateBounds(params) {
1944
+ const { layout, selfLoopLabelWidths } = params;
1825
1945
  let minX = Infinity;
1826
1946
  let minY = Infinity;
1827
1947
  let maxX = -Infinity;
@@ -1843,7 +1963,11 @@ var SvgRenderer = class {
1843
1963
  maxY = Math.max(maxY, point.y);
1844
1964
  });
1845
1965
  if (edge.label && edge.points && edge.points.length > 0) {
1846
- const midpoint = this.edgeLabelCenter(edge, layout.nodes);
1966
+ const midpoint = this.edgeLabelCenter({
1967
+ edge,
1968
+ nodes: layout.nodes,
1969
+ selfLoopLabelWidths
1970
+ });
1847
1971
  const labelDimensions = this.calculateLabelDimensions(edge.label);
1848
1972
  const labelMinX = midpoint.x - labelDimensions.width / 2;
1849
1973
  const labelMaxX = midpoint.x + labelDimensions.width / 2;
@@ -2032,21 +2156,30 @@ var SvgRenderer = class {
2032
2156
  * Render an edge
2033
2157
  */
2034
2158
  renderEdge(params) {
2035
- const { edge, group, nodes } = params;
2159
+ const { edge, group, nodes, selfLoopLabelWidths } = params;
2036
2160
  if (!edge.points || edge.points.length < 2) return;
2037
2161
  const edgeColor = this.resolveEdgeColor(edge.type);
2038
2162
  const markerType = edge.type || "normal";
2039
2163
  const pathData = edge.from === edge.to ? this.buildSelfLoopPath(edge.points) : this.pathGenerator(edge.points) ?? "";
2040
- const override = this.options.edgeOverrides?.[`${edge.from}->${edge.to}`];
2164
+ const broadOverride = this.options.edgeOverrides?.[`${edge.from}->${edge.to}`];
2165
+ const exactOverride = this.options.edgeOverrides?.[edge.id];
2166
+ const override = broadOverride || exactOverride ? {
2167
+ ...broadOverride,
2168
+ ...exactOverride
2169
+ } : void 0;
2041
2170
  const strokeColor = override?.stroke ?? edgeColor;
2042
2171
  const strokeWidth = override?.strokeWidth ?? (edge.type === "error" ? 2 : 1.5);
2043
- const pathElement = group.append("path").attr("d", pathData).attr("fill", "none").attr("stroke", strokeColor).attr("stroke-width", strokeWidth).attr("marker-end", `url(#arrowhead-${markerType})`);
2172
+ const pathElement = group.append("path").attr("d", pathData).attr("data-edge-id", edge.id).attr("fill", "none").attr("stroke", strokeColor).attr("stroke-width", strokeWidth).attr("marker-end", `url(#arrowhead-${markerType})`);
2044
2173
  if (override?.strokeOpacity !== void 0) pathElement.attr("stroke-opacity", override.strokeOpacity);
2045
2174
  if (edge.type === "error") pathElement.attr("stroke-dasharray", "5,5");
2046
2175
  else if (edge.type === "default") pathElement.attr("stroke-dasharray", "8,4");
2047
2176
  else if (edge.type === "retry") pathElement.attr("stroke-dasharray", "4,3");
2048
2177
  if (edge.label) {
2049
- const midpoint = this.edgeLabelCenter(edge, nodes);
2178
+ const midpoint = this.edgeLabelCenter({
2179
+ edge,
2180
+ nodes,
2181
+ selfLoopLabelWidths
2182
+ });
2050
2183
  const labelDimensions = this.calculateLabelDimensions(edge.label);
2051
2184
  group.append("rect").attr("x", midpoint.x - labelDimensions.width / 2).attr("y", midpoint.y - labelDimensions.height / 2).attr("width", labelDimensions.width).attr("height", labelDimensions.height).attr("fill", this.theme.background || "#ffffff").attr("stroke", edgeColor).attr("stroke-width", .5).attr("rx", 3);
2052
2185
  group.append("text").attr("x", midpoint.x).attr("y", midpoint.y).attr("text-anchor", "middle").attr("dominant-baseline", "middle").attr("fill", edgeColor).attr("font-size", this.theme.fontSize - 2).text(edge.label);
@@ -2073,16 +2206,40 @@ var SvgRenderer = class {
2073
2206
  * path midpoint; self-loops anchor off the loop's apex instead, falling back to a
2074
2207
  * spot clear of neighbouring nodes if the default placement would land on one.
2075
2208
  */
2076
- edgeLabelCenter(edge, nodes = []) {
2209
+ edgeLabelCenter(params) {
2210
+ const { edge, nodes, selfLoopLabelWidths } = params;
2077
2211
  const points = edge.points ?? [];
2078
2212
  if (edge.from === edge.to && edge.label && points.length >= 3) return this.selfLoopLabelCenter({
2079
2213
  edge,
2080
2214
  nodes,
2081
- points
2215
+ points,
2216
+ selfLoopLabelWidths
2082
2217
  });
2083
2218
  return this.getPathMidpoint(points);
2084
2219
  }
2085
2220
  /**
2221
+ * Measure the widest self-loop label on each node, keyed by the looping node's id.
2222
+ *
2223
+ * `selfLoopLabelCenter` staggers nested self-loop labels by one step per loop index,
2224
+ * and a step only guarantees separation if it is at least as large as the labels it
2225
+ * is separating. Label *height* is fixed by the font size, so the horizontal-bulge
2226
+ * arm can step by its own label's height safely; label *width* is per-edge, so the
2227
+ * vertical-bulge arm has to step by the widest label on the node instead of its own.
2228
+ *
2229
+ * @param edges - Every routed edge in the layout
2230
+ * @returns Widest measured label width per looping node id; nodes with no labelled
2231
+ * self-loop are absent
2232
+ */
2233
+ calculateSelfLoopLabelWidths(edges) {
2234
+ const widths = /* @__PURE__ */ new Map();
2235
+ for (const edge of edges) {
2236
+ if (edge.from !== edge.to || !edge.label) continue;
2237
+ const { width } = this.calculateLabelDimensions(edge.label);
2238
+ widths.set(edge.from, Math.max(widths.get(edge.from) ?? 0, width));
2239
+ }
2240
+ return widths;
2241
+ }
2242
+ /**
2086
2243
  * Self-loop labels can't use the path midpoint the way normal edges do: for a
2087
2244
  * 3-point self-loop path the midpoint *is* the apex, but the apex is a Bezier
2088
2245
  * *control* point that `buildSelfLoopPath` never actually draws through. The
@@ -2091,12 +2248,28 @@ var SvgRenderer = class {
2091
2248
  * If that would overlap another node - a tight nodesep can leave less room than
2092
2249
  * the loop needs - the label is stacked on the loop's other axis instead, staying
2093
2250
  * over the looping node's own footprint rather than reaching toward a neighbour.
2251
+ *
2252
+ * Nested loops on the same node all bulge along the same axis and land on the same
2253
+ * peak coordinate along that axis, so without further adjustment their labels would
2254
+ * sit exactly on top of one another. `edge.loopIndex` (stamped by DagreLayout for
2255
+ * self-loops) staggers each loop's label one step further along the axis
2256
+ * *perpendicular* to the bulge, so nested loops fan their labels apart the same way
2257
+ * they fan their arcs. Index 0 gets a zero offset, so a node with exactly one loop
2258
+ * renders in exactly the same place as before loopIndex existed.
2259
+ *
2260
+ * The step has to be a quantity shared by every loop on the node, not each edge's
2261
+ * own label size, or a short inner label can land inside a long outer one's rect and
2262
+ * occlude it. Height is shared already (fixed font size); width is not, so the
2263
+ * vertical-bulge arm steps by the node's widest self-loop label
2264
+ * (`selfLoopLabelWidths`) rather than by this edge's width.
2094
2265
  */
2095
2266
  selfLoopLabelCenter(params) {
2096
- const { edge, nodes, points } = params;
2267
+ const { edge, nodes, points, selfLoopLabelWidths } = params;
2097
2268
  const [entry, apex, exit] = points;
2098
2269
  const gap = 8;
2099
2270
  const { width: labelWidth, height: labelHeight } = this.calculateLabelDimensions(edge.label ?? "");
2271
+ const loopIndex = edge.loopIndex ?? 0;
2272
+ const widthStep = (selfLoopLabelWidths.get(edge.from) ?? labelWidth) + gap;
2100
2273
  const peak = {
2101
2274
  x: entry.x * .125 + apex.x * .75 + exit.x * .125,
2102
2275
  y: entry.y * .125 + apex.y * .75 + exit.y * .125
@@ -2104,9 +2277,9 @@ var SvgRenderer = class {
2104
2277
  const bulgesHorizontally = Math.abs(apex.x - entry.x) >= Math.abs(apex.y - entry.y);
2105
2278
  const primary = bulgesHorizontally ? {
2106
2279
  x: peak.x + gap + labelWidth / 2,
2107
- y: peak.y
2280
+ y: peak.y + loopIndex * (labelHeight + gap)
2108
2281
  } : {
2109
- x: peak.x,
2282
+ x: peak.x + loopIndex * widthStep,
2110
2283
  y: peak.y - gap - labelHeight / 2
2111
2284
  };
2112
2285
  const otherNodes = nodes.filter((node) => node.id !== edge.from);
@@ -2118,10 +2291,10 @@ var SvgRenderer = class {
2118
2291
  const loopingNode = nodes.find((node) => node.id === edge.from);
2119
2292
  if (bulgesHorizontally) return {
2120
2293
  x: loopingNode?.x ?? peak.x,
2121
- y: (loopingNode?.y ?? peak.y) + (loopingNode?.height || 0) / 2 + gap + labelHeight / 2
2294
+ y: (loopingNode?.y ?? peak.y) + (loopingNode?.height || 0) / 2 + gap + labelHeight / 2 + loopIndex * (labelHeight + gap)
2122
2295
  };
2123
2296
  return {
2124
- x: peak.x + gap + labelWidth / 2,
2297
+ x: peak.x + gap + labelWidth / 2 + loopIndex * widthStep,
2125
2298
  y: peak.y
2126
2299
  };
2127
2300
  }
@@ -2687,50 +2860,6 @@ ${stateDataScript}<script>${buildViewerScript({ hasStateData })}<\/script>
2687
2860
  </html>`;
2688
2861
  }
2689
2862
  //#endregion
2690
- //#region src/config/defaults.ts
2691
- /**
2692
- * Default diagram options - used when options not provided
2693
- */
2694
- const DEFAULT_DIAGRAM_OPTIONS = {
2695
- format: "svg",
2696
- theme: "light",
2697
- customColors: void 0,
2698
- layout: "TB",
2699
- rankSeparation: 50,
2700
- nodeSeparation: 50,
2701
- width: void 0,
2702
- height: void 0,
2703
- nodeWidth: 120,
2704
- nodeHeight: 60,
2705
- padding: 20,
2706
- includeComments: true,
2707
- showStateTypes: false,
2708
- showVariables: true,
2709
- edgeStyle: "curved",
2710
- catchHandling: "show",
2711
- catchLabelStyle: "error-type",
2712
- collapse: void 0,
2713
- stylePreset: "aws-standard",
2714
- iconPosition: "left",
2715
- iconResolver: void 0,
2716
- iconSize: 24,
2717
- showIcons: false,
2718
- pngQuality: 90,
2719
- backgroundColor: "transparent",
2720
- nodeOverrides: void 0,
2721
- edgeOverrides: void 0,
2722
- nodeAnnotations: void 0
2723
- };
2724
- /**
2725
- * Merge user-provided options with defaults
2726
- */
2727
- function mergeOptions(options = {}) {
2728
- return {
2729
- ...DEFAULT_DIAGRAM_OPTIONS,
2730
- ...options
2731
- };
2732
- }
2733
- //#endregion
2734
2863
  //#region src/utils/iconEmbedder.ts
2735
2864
  /** Default timeout for icon fetch operations in milliseconds */
2736
2865
  const DEFAULT_FETCH_TIMEOUT_MS = 5e3;
@@ -3096,6 +3225,16 @@ function computeOverlay(history) {
3096
3225
  * @param params - Any additional {@link DiagramOptions}
3097
3226
  * @returns {@link ExecutionOutput} with SVG markup and a per-status summary
3098
3227
  *
3228
+ * @remarks
3229
+ * A caller-supplied `edgeOverrides` / `nodeAnnotations` / `nodeOverrides` entry wins
3230
+ * over the overlay's computed value for that same key; every key the caller does not
3231
+ * name keeps the overlay's styling. A legacy bare `${from}->${to}` edge key claims
3232
+ * every edge between that pair, suppressing the overlay's own styling for all of them.
3233
+ *
3234
+ * Execution history records transitions as `{ from, to }` only, so two Choice rules
3235
+ * sharing a `Next` are both highlighted when either fires. `Retry` self-loops are
3236
+ * never highlighted — retries emit no transition event.
3237
+ *
3099
3238
  * @example
3100
3239
  * ```typescript
3101
3240
  * import { generateExecution } from 'sfn-diagram';
@@ -3103,7 +3242,7 @@ function computeOverlay(history) {
3103
3242
  * ```
3104
3243
  */
3105
3244
  function generateExecution(params) {
3106
- const { aslDefinition, history, ...options } = params;
3245
+ const { aslDefinition, edgeOverrides: callerEdgeOverrides, history, nodeAnnotations: callerNodeAnnotations, nodeOverrides: callerNodeOverrides, ...options } = params;
3107
3246
  const aslObj = typeof aslDefinition === "string" ? JSON.parse(aslDefinition) : aslDefinition;
3108
3247
  const overlay = computeOverlay(history);
3109
3248
  const mergedOptions = mergeOptions(options);
@@ -3122,17 +3261,21 @@ function generateExecution(params) {
3122
3261
  if (annotation) nodeAnnotations[node.id] = annotation;
3123
3262
  }
3124
3263
  }
3264
+ const pairKeys = new Set(edges.map((edge) => `${edge.from}->${edge.to}`));
3265
+ const callerBarePairs = new Set(Object.keys(callerEdgeOverrides ?? {}).filter((key) => pairKeys.has(key)));
3125
3266
  const takenKeys = new Set(overlay.takenEdges.map((edge) => `${edge.from}->${edge.to}`));
3126
3267
  const edgeOverrides = {};
3127
3268
  for (const edge of edges) {
3128
- const key = `${edge.from}->${edge.to}`;
3129
- edgeOverrides[key] = takenKeys.has(key) ? TAKEN_EDGE_STYLE : UNTAKEN_EDGE_STYLE;
3269
+ const pairKey = `${edge.from}->${edge.to}`;
3270
+ if (callerBarePairs.has(pairKey)) continue;
3271
+ const isTaken = edge.type !== "retry" && takenKeys.has(pairKey);
3272
+ edgeOverrides[edge.id] = isTaken ? TAKEN_EDGE_STYLE : UNTAKEN_EDGE_STYLE;
3130
3273
  }
3131
3274
  const renderOptions = {
3132
3275
  ...mergedOptions,
3133
- edgeOverrides,
3134
- nodeAnnotations,
3135
- nodeOverrides
3276
+ edgeOverrides: mergeRecordOptions(edgeOverrides, callerEdgeOverrides),
3277
+ nodeAnnotations: mergeRecordOptions(nodeAnnotations, callerNodeAnnotations),
3278
+ nodeOverrides: mergeRecordOptions(nodeOverrides, callerNodeOverrides)
3136
3279
  };
3137
3280
  const positioned = new DagreLayout(renderOptions).calculate(nodes, edges);
3138
3281
  const svgOutput = new SvgRenderer(renderOptions).render(positioned);
@@ -3648,14 +3791,8 @@ function generateDiff(params) {
3648
3791
  }
3649
3792
  const svgOutput = generateSvg({
3650
3793
  aslDefinition: mergedAsl,
3651
- nodeAnnotations: {
3652
- ...containerAnnotations,
3653
- ...callerAnnotations
3654
- },
3655
- nodeOverrides: {
3656
- ...nodeOverrides,
3657
- ...callerOverrides
3658
- },
3794
+ nodeAnnotations: mergeRecordOptions(containerAnnotations, callerAnnotations),
3795
+ nodeOverrides: mergeRecordOptions(nodeOverrides, callerOverrides),
3659
3796
  ...options
3660
3797
  });
3661
3798
  return {