sfn-diagram 0.5.1 → 0.6.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/README.md +20 -3
- package/dist/cli.js +220 -29
- package/dist/index.cjs +210 -23
- package/dist/index.d.cts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +210 -23
- package/dist/png.cjs +220 -30
- package/dist/png.d.cts +4 -1
- package/dist/png.d.ts +4 -1
- package/dist/png.js +220 -29
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Generate beautiful, interactive diagrams from AWS Step Functions ASL (Amazon Sta
|
|
|
12
12
|
|
|
13
13
|
- **Multiple Output Formats**: SVG (D3.js), Mermaid syntax, and PNG
|
|
14
14
|
- **Automatic Layout**: Smart graph positioning using Dagre layout engine
|
|
15
|
-
- **Full ASL Support**: All state types (Pass, Task, Choice, Wait, Succeed, Fail, Parallel, Map)
|
|
15
|
+
- **Full ASL Support**: All state types (Pass, Task, Choice, Wait, Succeed, Fail, Parallel, Map), both JSONPath and JSONata query languages
|
|
16
16
|
- **Customizable Themes**: AWS light/dark themes plus custom theme support
|
|
17
17
|
- **Flexible Layouts**: Top-bottom, left-right, right-left, bottom-top
|
|
18
18
|
- **Type-Safe**: Full TypeScript support with comprehensive type definitions
|
|
@@ -26,6 +26,12 @@ Generate beautiful, interactive diagrams from AWS Step Functions ASL (Amazon Sta
|
|
|
26
26
|
npm install sfn-diagram
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
The core package pulls in **no browser engine** — SVG and Mermaid generation stay lightweight. PNG export relies on a headless browser, provided by the optional peer dependency `node-html-to-image`. Install it only if you use `sfn-diagram/png`:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install sfn-diagram node-html-to-image
|
|
33
|
+
```
|
|
34
|
+
|
|
29
35
|
## Runtime Support
|
|
30
36
|
|
|
31
37
|
The core entry (`sfn-diagram`) builds SVG with a DOM-free string renderer, so
|
|
@@ -34,7 +40,9 @@ run in **Node, browsers, and edge runtimes** (Cloudflare Workers, Vercel Edge, D
|
|
|
34
40
|
with no DOM polyfill.
|
|
35
41
|
|
|
36
42
|
PNG export (`sfn-diagram/png`) and the CLI are **Node-only** — they rely on a headless
|
|
37
|
-
browser (`node-html-to-image`) and Node's filesystem respectively.
|
|
43
|
+
browser (`node-html-to-image`) and Node's filesystem respectively. Because `node-html-to-image`
|
|
44
|
+
is an **optional peer dependency**, install it alongside `sfn-diagram` when you need PNG output;
|
|
45
|
+
`exportPng` throws an actionable error if it is missing.
|
|
38
46
|
|
|
39
47
|
```ts
|
|
40
48
|
// Works in Node, browser, and edge:
|
|
@@ -363,7 +371,16 @@ All AWS Step Functions state types are fully supported:
|
|
|
363
371
|
| **Succeed** | Circle | Terminates successfully |
|
|
364
372
|
| **Fail** | Circle | Terminates with failure |
|
|
365
373
|
| **Parallel** | Rectangle | Executes branches in parallel |
|
|
366
|
-
| **Map** | Rectangle | Iterates over array items |
|
|
374
|
+
| **Map** | Rectangle | Iterates over array items (legacy `Iterator` and modern `ItemProcessor`/Distributed Map) |
|
|
375
|
+
|
|
376
|
+
### Error handling & retries
|
|
377
|
+
|
|
378
|
+
- **`Catch`** blocks render as dashed error edges to their handler states.
|
|
379
|
+
- **`Retry`** policies render as a labelled self-loop on the state (e.g. `↻ States.Timeout (4x); States.ALL (2x)`) — a self-transition in Mermaid output.
|
|
380
|
+
|
|
381
|
+
### Query languages
|
|
382
|
+
|
|
383
|
+
Both JSONPath and JSONata (`QueryLanguage: "JSONata"`) definitions are supported. Choice branch labels are derived from JSONPath comparison operators (`$.score >= 90`, `And`/`Or`/`Not`, `Is*` checks) or from JSONata `Condition` expressions, whichever the state uses.
|
|
367
384
|
|
|
368
385
|
## Examples
|
|
369
386
|
|
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,6 @@ import { resolve } from "node:path";
|
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import dagre from "@dagrejs/dagre";
|
|
6
6
|
import { curveBasis, line } from "d3-shape";
|
|
7
|
-
import nodeHtmlToImage from "node-html-to-image";
|
|
8
7
|
//#region src/config/themes.ts
|
|
9
8
|
/**
|
|
10
9
|
* AWS Light Theme - matches the AWS Step Functions console light mode
|
|
@@ -49,7 +48,8 @@ const AWS_LIGHT_THEME = {
|
|
|
49
48
|
choice: "#7b1fa2",
|
|
50
49
|
default: "#9c27b0",
|
|
51
50
|
error: "#f44336",
|
|
52
|
-
normal: "#546e7a"
|
|
51
|
+
normal: "#546e7a",
|
|
52
|
+
retry: "#f9a825"
|
|
53
53
|
},
|
|
54
54
|
textColor: "#212121",
|
|
55
55
|
fontSize: 14,
|
|
@@ -98,7 +98,8 @@ const AWS_DARK_THEME = {
|
|
|
98
98
|
choice: "#ce93d8",
|
|
99
99
|
default: "#ba68c8",
|
|
100
100
|
error: "#ef5350",
|
|
101
|
-
normal: "#90a4ae"
|
|
101
|
+
normal: "#90a4ae",
|
|
102
|
+
retry: "#ffca28"
|
|
102
103
|
},
|
|
103
104
|
textColor: "#e0e0e0",
|
|
104
105
|
fontSize: 14,
|
|
@@ -181,8 +182,21 @@ const EDGE_LABELS = {
|
|
|
181
182
|
CONDITION_FALLBACK: "Condition",
|
|
182
183
|
DEFAULT: "Default",
|
|
183
184
|
ERROR_PREFIX: "Error:",
|
|
184
|
-
ITERATOR: "Iterator"
|
|
185
|
+
ITERATOR: "Iterator",
|
|
186
|
+
RETRY_SYMBOL: "↻"
|
|
185
187
|
};
|
|
188
|
+
/** Default MaxAttempts when a Retry block omits it (per the ASL spec). */
|
|
189
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
190
|
+
/**
|
|
191
|
+
* Summarize a state's Retry blocks into a compact self-loop label,
|
|
192
|
+
* e.g. "↻ States.TaskFailed (3x)" or "↻ States.ALL (2x); Timeout (5x)".
|
|
193
|
+
*/
|
|
194
|
+
function getRetryLabel(retryBlocks) {
|
|
195
|
+
const parts = retryBlocks.map((retry) => {
|
|
196
|
+
return `${retry.ErrorEquals?.join(", ") || "All"} (${retry.MaxAttempts ?? DEFAULT_MAX_ATTEMPTS}x)`;
|
|
197
|
+
});
|
|
198
|
+
return `${EDGE_LABELS.RETRY_SYMBOL} ${parts.join("; ")}`;
|
|
199
|
+
}
|
|
186
200
|
/**
|
|
187
201
|
* Generates an error label with error types
|
|
188
202
|
*/
|
|
@@ -506,7 +520,7 @@ function createStateNode(params) {
|
|
|
506
520
|
const baseNode = {
|
|
507
521
|
id: name,
|
|
508
522
|
isContainer,
|
|
509
|
-
label: state.Comment || name,
|
|
523
|
+
label: options?.includeComments !== false ? state.Comment || name : name,
|
|
510
524
|
style: getNodeStyle({
|
|
511
525
|
stateType: state.Type,
|
|
512
526
|
stylePreset
|
|
@@ -560,6 +574,13 @@ function extractEdgesFromState(params) {
|
|
|
560
574
|
});
|
|
561
575
|
break;
|
|
562
576
|
}
|
|
577
|
+
if (state.Retry && state.Retry.length > 0) edges.push({
|
|
578
|
+
from: stateName,
|
|
579
|
+
label: getRetryLabel(state.Retry),
|
|
580
|
+
to: stateName,
|
|
581
|
+
type: "retry",
|
|
582
|
+
visualOnly: true
|
|
583
|
+
});
|
|
563
584
|
if (state.Catch) state.Catch.forEach((catchBlock, index) => {
|
|
564
585
|
if (catchBlock.Next) edges.push({
|
|
565
586
|
from: stateName,
|
|
@@ -574,13 +595,87 @@ function extractEdgesFromState(params) {
|
|
|
574
595
|
});
|
|
575
596
|
return edges;
|
|
576
597
|
}
|
|
598
|
+
/** ASL data-type prefixes for typed comparison operators */
|
|
599
|
+
const COMPARISON_TYPE_PREFIXES = [
|
|
600
|
+
"String",
|
|
601
|
+
"Numeric",
|
|
602
|
+
"Boolean",
|
|
603
|
+
"Timestamp"
|
|
604
|
+
];
|
|
605
|
+
/** Operator suffixes mapped to display symbols (longest suffixes first for matching) */
|
|
606
|
+
const COMPARISON_OPERATORS = [
|
|
607
|
+
["LessThanEquals", "<="],
|
|
608
|
+
["GreaterThanEquals", ">="],
|
|
609
|
+
["LessThan", "<"],
|
|
610
|
+
["GreaterThan", ">"],
|
|
611
|
+
["Equals", "=="],
|
|
612
|
+
["Matches", "matches"]
|
|
613
|
+
];
|
|
614
|
+
/** Unary presence/type checks mapped to display phrases */
|
|
615
|
+
const IS_CHECKS = {
|
|
616
|
+
IsBoolean: "is boolean",
|
|
617
|
+
IsNull: "is null",
|
|
618
|
+
IsNumeric: "is numeric",
|
|
619
|
+
IsPresent: "is present",
|
|
620
|
+
IsString: "is string",
|
|
621
|
+
IsTimestamp: "is timestamp"
|
|
622
|
+
};
|
|
623
|
+
/** Strip JSONata delimiters (`{% ... %}`) from a condition expression. */
|
|
624
|
+
function cleanJsonataExpression(expression) {
|
|
625
|
+
return expression.replace(/^\{%\s*/, "").replace(/\s*%\}$/, "").trim();
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Format a single comparison operator on a Choice rule into a readable label,
|
|
629
|
+
* or return null if the key is not a recognized comparison operator.
|
|
630
|
+
*/
|
|
631
|
+
function formatComparison(variable, operatorKey, value) {
|
|
632
|
+
const isCheckPhrase = IS_CHECKS[operatorKey];
|
|
633
|
+
if (isCheckPhrase) return value === false ? `${variable} ${isCheckPhrase.replace("is ", "is not ")}` : `${variable} ${isCheckPhrase}`;
|
|
634
|
+
const isPath = operatorKey.endsWith("Path");
|
|
635
|
+
const baseKey = isPath ? operatorKey.slice(0, -4) : operatorKey;
|
|
636
|
+
const prefix = COMPARISON_TYPE_PREFIXES.find((typePrefix) => baseKey.startsWith(typePrefix));
|
|
637
|
+
if (!prefix) return null;
|
|
638
|
+
const suffix = baseKey.slice(prefix.length);
|
|
639
|
+
const operator = COMPARISON_OPERATORS.find(([name]) => name === suffix);
|
|
640
|
+
if (!operator) return null;
|
|
641
|
+
const formattedValue = !isPath && (prefix === "String" || prefix === "Timestamp") ? JSON.stringify(value) : String(value);
|
|
642
|
+
return `${variable} ${operator[1]} ${formattedValue}`;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Recursively describe a Choice rule, handling And/Or/Not combinators,
|
|
646
|
+
* the full set of typed comparison operators, presence checks, and JSONata conditions.
|
|
647
|
+
*/
|
|
648
|
+
function describeChoiceRule(rule) {
|
|
649
|
+
if (rule.Condition !== void 0) return typeof rule.Condition === "string" ? cleanJsonataExpression(rule.Condition) : String(rule.Condition);
|
|
650
|
+
if (Array.isArray(rule.And)) {
|
|
651
|
+
const parts = rule.And.map(describeChoiceRule).filter(Boolean);
|
|
652
|
+
return parts.length > 0 ? parts.join(" AND ") : "";
|
|
653
|
+
}
|
|
654
|
+
if (Array.isArray(rule.Or)) {
|
|
655
|
+
const parts = rule.Or.map(describeChoiceRule).filter(Boolean);
|
|
656
|
+
return parts.length > 0 ? parts.join(" OR ") : "";
|
|
657
|
+
}
|
|
658
|
+
if (rule.Not && typeof rule.Not === "object") {
|
|
659
|
+
const inner = describeChoiceRule(rule.Not);
|
|
660
|
+
return inner ? `NOT (${inner})` : "";
|
|
661
|
+
}
|
|
662
|
+
const variable = rule.Variable || "";
|
|
663
|
+
for (const [operatorKey, value] of Object.entries(rule)) {
|
|
664
|
+
const formatted = formatComparison(variable, operatorKey, value);
|
|
665
|
+
if (formatted) return formatted;
|
|
666
|
+
}
|
|
667
|
+
return "";
|
|
668
|
+
}
|
|
577
669
|
function extractConditionLabel(choice) {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
670
|
+
return describeChoiceRule(choice) || EDGE_LABELS.CONDITION_FALLBACK;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Resolve a Map state's inline processor definition.
|
|
674
|
+
* Prefers the modern `ItemProcessor` field (used by inline and Distributed Map)
|
|
675
|
+
* and falls back to the legacy `Iterator` field for pre-2022 definitions.
|
|
676
|
+
*/
|
|
677
|
+
function getMapProcessor(state) {
|
|
678
|
+
return state.ItemProcessor ?? state.Iterator;
|
|
584
679
|
}
|
|
585
680
|
/**
|
|
586
681
|
* Recursively extract all states including those nested in Parallel branches and Map iterators
|
|
@@ -626,8 +721,9 @@ function extractStatesRecursively(params) {
|
|
|
626
721
|
nodeIndex
|
|
627
722
|
});
|
|
628
723
|
});
|
|
629
|
-
|
|
630
|
-
|
|
724
|
+
const mapProcessor = state.Type === "Map" ? getMapProcessor(state) : void 0;
|
|
725
|
+
if (state.Type === "Map" && mapProcessor) {
|
|
726
|
+
const iterator = mapProcessor;
|
|
631
727
|
extractStatesRecursively({
|
|
632
728
|
definition: iterator,
|
|
633
729
|
nodeIndex,
|
|
@@ -716,15 +812,16 @@ function extractNestedEdges(params) {
|
|
|
716
812
|
options
|
|
717
813
|
});
|
|
718
814
|
});
|
|
719
|
-
|
|
815
|
+
const mapProcessor = state.Type === "Map" ? getMapProcessor(state) : void 0;
|
|
816
|
+
if (state.Type === "Map" && mapProcessor) {
|
|
720
817
|
const endNodeId = `${stateName}__iterator__end`;
|
|
721
818
|
edges.push({
|
|
722
819
|
from: stateName,
|
|
723
|
-
to:
|
|
820
|
+
to: mapProcessor.StartAt,
|
|
724
821
|
type: "normal",
|
|
725
822
|
visualOnly: true
|
|
726
823
|
});
|
|
727
|
-
for (const [iteratorStateName, iteratorState] of Object.entries(
|
|
824
|
+
for (const [iteratorStateName, iteratorState] of Object.entries(mapProcessor.States)) {
|
|
728
825
|
const iteratorEdges = extractEdgesFromState({
|
|
729
826
|
catchLabelStyle: options?.catchLabelStyle,
|
|
730
827
|
state: iteratorState,
|
|
@@ -751,7 +848,7 @@ function extractNestedEdges(params) {
|
|
|
751
848
|
});
|
|
752
849
|
}
|
|
753
850
|
extractNestedEdges({
|
|
754
|
-
definition:
|
|
851
|
+
definition: mapProcessor,
|
|
755
852
|
edges,
|
|
756
853
|
options
|
|
757
854
|
});
|
|
@@ -781,6 +878,14 @@ var DagreLayout = class {
|
|
|
781
878
|
ranksep: this.options.rankSeparation || 50
|
|
782
879
|
});
|
|
783
880
|
graph.setDefaultEdgeLabel(() => ({}));
|
|
881
|
+
const containerIds = new Set(nodes.filter((node) => node.isContainer).map((node) => node.id));
|
|
882
|
+
const containerChildren = new Map(nodes.filter((node) => node.isContainer).map((node) => [node.id, new Set(node.children || [])]));
|
|
883
|
+
const entryChildrenByContainer = /* @__PURE__ */ new Map();
|
|
884
|
+
for (const edge of edges) if (edge.visualOnly && containerChildren.get(edge.from)?.has(edge.to)) {
|
|
885
|
+
const entries = entryChildrenByContainer.get(edge.from) ?? [];
|
|
886
|
+
entries.push(edge.to);
|
|
887
|
+
entryChildrenByContainer.set(edge.from, entries);
|
|
888
|
+
}
|
|
784
889
|
const layoutNodes = nodes.filter((node) => !node.isContainer);
|
|
785
890
|
layoutNodes.forEach((node) => {
|
|
786
891
|
const dimensions = this.getNodeDimensions(node);
|
|
@@ -792,6 +897,16 @@ var DagreLayout = class {
|
|
|
792
897
|
});
|
|
793
898
|
});
|
|
794
899
|
edges.filter((edge) => !edge.visualOnly).forEach((edge) => {
|
|
900
|
+
const toIsContainer = containerIds.has(edge.to);
|
|
901
|
+
const fromIsContainer = containerIds.has(edge.from);
|
|
902
|
+
if (toIsContainer && !fromIsContainer) {
|
|
903
|
+
for (const child of entryChildrenByContainer.get(edge.to) ?? []) graph.setEdge(edge.from, child, {
|
|
904
|
+
label: edge.label,
|
|
905
|
+
type: edge.type
|
|
906
|
+
});
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (fromIsContainer || toIsContainer) return;
|
|
795
910
|
graph.setEdge(edge.from, edge.to, {
|
|
796
911
|
label: edge.label,
|
|
797
912
|
type: edge.type
|
|
@@ -816,7 +931,10 @@ var DagreLayout = class {
|
|
|
816
931
|
const allPositionedNodes = [...positionedNodes, ...containerNodes];
|
|
817
932
|
const positionedNodesById = new Map(allPositionedNodes.map((node) => [node.id, node]));
|
|
818
933
|
const routedEdges = edges.map((edge) => {
|
|
819
|
-
|
|
934
|
+
const fromNode = positionedNodesById.get(edge.from);
|
|
935
|
+
const toNode = positionedNodesById.get(edge.to);
|
|
936
|
+
const touchesContainer = Boolean(fromNode?.isContainer || toNode?.isContainer);
|
|
937
|
+
if (edge.visualOnly || touchesContainer) return {
|
|
820
938
|
...edge,
|
|
821
939
|
points: this.calculateVisualEdgePoints({
|
|
822
940
|
edge,
|
|
@@ -826,7 +944,7 @@ var DagreLayout = class {
|
|
|
826
944
|
const dagEdge = graph.edge(edge.from, edge.to);
|
|
827
945
|
return {
|
|
828
946
|
...edge,
|
|
829
|
-
points: dagEdge
|
|
947
|
+
points: dagEdge?.points ?? []
|
|
830
948
|
};
|
|
831
949
|
});
|
|
832
950
|
const graphDims = graph.graph();
|
|
@@ -894,6 +1012,26 @@ var DagreLayout = class {
|
|
|
894
1012
|
const fromNode = positionedNodesById.get(edge.from);
|
|
895
1013
|
const toNode = positionedNodesById.get(edge.to);
|
|
896
1014
|
if (!fromNode || !toNode) return [];
|
|
1015
|
+
if (edge.from === edge.to) {
|
|
1016
|
+
const rightX = (fromNode.x || 0) + (fromNode.width || 0) / 2;
|
|
1017
|
+
const centerY = fromNode.y || 0;
|
|
1018
|
+
const loopReach = 40;
|
|
1019
|
+
const loopSpread = 12;
|
|
1020
|
+
return [
|
|
1021
|
+
{
|
|
1022
|
+
x: rightX,
|
|
1023
|
+
y: centerY - loopSpread
|
|
1024
|
+
},
|
|
1025
|
+
{
|
|
1026
|
+
x: rightX + loopReach,
|
|
1027
|
+
y: centerY
|
|
1028
|
+
},
|
|
1029
|
+
{
|
|
1030
|
+
x: rightX,
|
|
1031
|
+
y: centerY + loopSpread
|
|
1032
|
+
}
|
|
1033
|
+
];
|
|
1034
|
+
}
|
|
897
1035
|
if (fromNode.isContainer && fromNode.children?.includes(edge.to)) {
|
|
898
1036
|
const headerHeight = 50;
|
|
899
1037
|
const fromX = fromNode.x || 0;
|
|
@@ -1036,6 +1174,7 @@ var SvgRenderer = class {
|
|
|
1036
1174
|
defs.append("marker").attr("id", "arrowhead-error").attr("markerWidth", 10).attr("markerHeight", 10).attr("refX", 9).attr("refY", 3).attr("orient", "auto").append("polygon").attr("points", "0 0, 10 3, 0 6").attr("fill", this.theme.edgeColors.error);
|
|
1037
1175
|
defs.append("marker").attr("id", "arrowhead-choice").attr("markerWidth", 10).attr("markerHeight", 10).attr("refX", 9).attr("refY", 3).attr("orient", "auto").append("polygon").attr("points", "0 0, 10 3, 0 6").attr("fill", this.theme.edgeColors.choice);
|
|
1038
1176
|
defs.append("marker").attr("id", "arrowhead-default").attr("markerWidth", 10).attr("markerHeight", 10).attr("refX", 9).attr("refY", 3).attr("orient", "auto").append("polygon").attr("points", "0 0, 10 3, 0 6").attr("fill", this.theme.edgeColors.default);
|
|
1177
|
+
defs.append("marker").attr("id", "arrowhead-retry").attr("markerWidth", 10).attr("markerHeight", 10).attr("refX", 9).attr("refY", 3).attr("orient", "auto").append("polygon").attr("points", "0 0, 10 3, 0 6").attr("fill", this.resolveEdgeColor("retry"));
|
|
1039
1178
|
const edgesGroup = svg.append("g").attr("class", "edges");
|
|
1040
1179
|
const containersGroup = svg.append("g").attr("class", "containers");
|
|
1041
1180
|
const nodesGroup = svg.append("g").attr("class", "nodes");
|
|
@@ -1090,13 +1229,14 @@ var SvgRenderer = class {
|
|
|
1090
1229
|
});
|
|
1091
1230
|
layout.edges.forEach((edge) => {
|
|
1092
1231
|
if (edge.points) edge.points.forEach((point) => {
|
|
1232
|
+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
|
|
1093
1233
|
minX = Math.min(minX, point.x);
|
|
1094
1234
|
minY = Math.min(minY, point.y);
|
|
1095
1235
|
maxX = Math.max(maxX, point.x);
|
|
1096
1236
|
maxY = Math.max(maxY, point.y);
|
|
1097
1237
|
});
|
|
1098
1238
|
if (edge.label && edge.points && edge.points.length > 0) {
|
|
1099
|
-
const midpoint = this.
|
|
1239
|
+
const midpoint = this.edgeLabelCenter(edge);
|
|
1100
1240
|
const labelDimensions = this.calculateLabelDimensions(edge.label);
|
|
1101
1241
|
const labelMinX = midpoint.x - labelDimensions.width / 2;
|
|
1102
1242
|
const labelMaxX = midpoint.x + labelDimensions.width / 2;
|
|
@@ -1271,19 +1411,55 @@ var SvgRenderer = class {
|
|
|
1271
1411
|
renderEdge(params) {
|
|
1272
1412
|
const { edge, group } = params;
|
|
1273
1413
|
if (!edge.points || edge.points.length < 2) return;
|
|
1274
|
-
const edgeColor = this.
|
|
1414
|
+
const edgeColor = this.resolveEdgeColor(edge.type);
|
|
1275
1415
|
const markerType = edge.type || "normal";
|
|
1276
|
-
const
|
|
1416
|
+
const pathData = edge.from === edge.to ? this.buildSelfLoopPath(edge.points) : this.pathGenerator(edge.points) ?? "";
|
|
1417
|
+
const pathElement = group.append("path").attr("d", pathData).attr("fill", "none").attr("stroke", edgeColor).attr("stroke-width", edge.type === "error" ? 2 : 1.5).attr("marker-end", `url(#arrowhead-${markerType})`);
|
|
1277
1418
|
if (edge.type === "error") pathElement.attr("stroke-dasharray", "5,5");
|
|
1278
1419
|
else if (edge.type === "default") pathElement.attr("stroke-dasharray", "8,4");
|
|
1420
|
+
else if (edge.type === "retry") pathElement.attr("stroke-dasharray", "4,3");
|
|
1279
1421
|
if (edge.label) {
|
|
1280
|
-
const midpoint = this.
|
|
1422
|
+
const midpoint = this.edgeLabelCenter(edge);
|
|
1281
1423
|
const labelDimensions = this.calculateLabelDimensions(edge.label);
|
|
1282
1424
|
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);
|
|
1283
1425
|
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);
|
|
1284
1426
|
}
|
|
1285
1427
|
}
|
|
1286
1428
|
/**
|
|
1429
|
+
* Resolve the stroke colour for an edge type, falling back to the error colour
|
|
1430
|
+
* (then normal) so themes that predate a given edge type still render.
|
|
1431
|
+
*/
|
|
1432
|
+
resolveEdgeColor(type) {
|
|
1433
|
+
const colors = this.theme.edgeColors;
|
|
1434
|
+
return colors[type || "normal"] ?? colors.error ?? colors.normal;
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Build an SVG path for a self-loop edge from its [entry, apex, exit] points,
|
|
1438
|
+
* curving out to the apex and back so the arrow re-enters the node.
|
|
1439
|
+
*/
|
|
1440
|
+
buildSelfLoopPath(points) {
|
|
1441
|
+
const [entry, apex, exit] = points;
|
|
1442
|
+
return `M ${entry.x},${entry.y} C ${apex.x},${apex.y} ${apex.x},${apex.y} ${exit.x},${exit.y}`;
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Compute where an edge's label should be centered. Normal edges center on the
|
|
1446
|
+
* path midpoint; self-loops (Retry) shift the label to the right of the loop apex
|
|
1447
|
+
* so a long policy label never overlaps the node it belongs to.
|
|
1448
|
+
*/
|
|
1449
|
+
edgeLabelCenter(edge) {
|
|
1450
|
+
const points = edge.points ?? [];
|
|
1451
|
+
const midpoint = this.getPathMidpoint(points);
|
|
1452
|
+
if (edge.from === edge.to && edge.label) {
|
|
1453
|
+
const gap = 8;
|
|
1454
|
+
const labelWidth = this.calculateLabelDimensions(edge.label).width;
|
|
1455
|
+
return {
|
|
1456
|
+
x: midpoint.x + gap + labelWidth / 2,
|
|
1457
|
+
y: midpoint.y
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
return midpoint;
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1287
1463
|
* Get the midpoint of a path for label placement
|
|
1288
1464
|
*/
|
|
1289
1465
|
getPathMidpoint(points) {
|
|
@@ -1578,6 +1754,20 @@ function generateMermaid(params) {
|
|
|
1578
1754
|
//#endregion
|
|
1579
1755
|
//#region src/exporters/PngExporter.ts
|
|
1580
1756
|
/**
|
|
1757
|
+
* Lazily load node-html-to-image, which is an optional peer dependency.
|
|
1758
|
+
* Keeping it out of the static import graph means SVG/Mermaid-only consumers
|
|
1759
|
+
* never pull Puppeteer/Chromium, and the runtime error is actionable when it is
|
|
1760
|
+
* genuinely missing.
|
|
1761
|
+
*/
|
|
1762
|
+
async function loadRenderer() {
|
|
1763
|
+
try {
|
|
1764
|
+
const mod = await import("node-html-to-image");
|
|
1765
|
+
return typeof mod === "function" ? mod : mod.default;
|
|
1766
|
+
} catch {
|
|
1767
|
+
throw new Error("PNG export requires the optional peer dependency 'node-html-to-image'. Install it with: npm install node-html-to-image");
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1581
1771
|
* PngExporter - Converts SVG to PNG using headless rendering
|
|
1582
1772
|
*/
|
|
1583
1773
|
var PngExporter = class {
|
|
@@ -1594,13 +1784,14 @@ var PngExporter = class {
|
|
|
1594
1784
|
*/
|
|
1595
1785
|
async convert(params) {
|
|
1596
1786
|
const { svg, width, height } = params;
|
|
1787
|
+
const html = this.wrapSvgInHtml({
|
|
1788
|
+
svg,
|
|
1789
|
+
width,
|
|
1790
|
+
height
|
|
1791
|
+
});
|
|
1597
1792
|
return {
|
|
1598
|
-
buffer: await
|
|
1599
|
-
html
|
|
1600
|
-
svg,
|
|
1601
|
-
width,
|
|
1602
|
-
height
|
|
1603
|
-
}),
|
|
1793
|
+
buffer: await (await loadRenderer())({
|
|
1794
|
+
html,
|
|
1604
1795
|
puppeteerArgs: { args: ["--no-sandbox", "--disable-setuid-sandbox"] },
|
|
1605
1796
|
quality: this.options.pngQuality || 90,
|
|
1606
1797
|
transparent: this.options.backgroundColor === "transparent",
|