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 +401 -264
- package/dist/element-auto.cjs +399 -256
- package/dist/element-auto.js +399 -256
- package/dist/element.cjs +399 -256
- package/dist/element.js +399 -256
- package/dist/index.cjs +428 -268
- package/dist/index.d.cts +41 -3
- package/dist/index.d.ts +41 -3
- package/dist/index.js +428 -268
- package/dist/png.cjs +379 -250
- package/dist/png.d.cts +19 -3
- package/dist/png.d.ts +19 -3
- package/dist/png.js +379 -250
- package/package.json +1 -1
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
|
-
|
|
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 =
|
|
1558
|
-
const loopSpread =
|
|
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
|
-
*
|
|
1702
|
-
*
|
|
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, "&").replace(/\u00A0/g, " ").replace(/"/g, """);
|
|
1819
|
+
return value.replace(/&/g, "&").replace(/\u00A0/g, " ").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
3129
|
-
|
|
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
|
-
|
|
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 {
|