@bpmnkit/core 0.0.21 → 0.0.23
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 +1 -0
- package/dist/bpmn/auto-layout.js +320 -21
- package/dist/bpmn/bpmn-builder.js +1 -0
- package/dist/bpmn/bpmn-model.d.ts +6 -0
- package/dist/bpmn/bpmn-parser.js +10 -0
- package/dist/bpmn/bpmn-serializer.js +10 -1
- package/dist/bpmn/compact.js +1 -0
- package/dist/bpmn/optimize/tasks.js +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/layout/astar.d.ts +15 -0
- package/dist/layout/astar.js +191 -0
- package/dist/layout/block-builder.d.ts +37 -0
- package/dist/layout/block-builder.js +154 -0
- package/dist/layout/block-layout.d.ts +9 -0
- package/dist/layout/block-layout.js +163 -0
- package/dist/layout/coordinates.d.ts +23 -0
- package/dist/layout/coordinates.js +612 -88
- package/dist/layout/index.d.ts +5 -0
- package/dist/layout/index.js +4 -0
- package/dist/layout/layout-engine.js +79 -28
- package/dist/layout/routing.d.ts +10 -3
- package/dist/layout/routing.js +254 -71
- package/dist/layout/types.d.ts +6 -0
- package/package.json +4 -3
package/dist/layout/index.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export { layoutProcess, layoutFlowNodes } from "./layout-engine.js";
|
|
2
|
+
export { buildBlockTree } from "./block-builder.js";
|
|
3
|
+
export type { FlowBlock, GatewayBlock, NodeBlock, SequenceBlock } from "./block-builder.js";
|
|
4
|
+
export { applyBlockLayout } from "./block-layout.js";
|
|
5
|
+
export { routeEdgeAstar } from "./astar.js";
|
|
2
6
|
export { benchmarkLayout, compareLayouts, formatBenchmarkResult, generateAutoLayout, parseReferenceLayout, } from "./bench.js";
|
|
3
7
|
export type { BenchmarkResult, BoundingBox, ElementComparison, ElementPosition, FlowOrderViolation, } from "./bench.js";
|
|
8
|
+
export { assignGridRows } from "./coordinates.js";
|
|
4
9
|
export { assertNoOverlap } from "./overlap.js";
|
|
5
10
|
export type { Bounds, LayoutEdge, LayoutNode, LayoutResult, Waypoint } from "./types.js";
|
|
6
11
|
export { ELEMENT_SIZES, GRID_CELL_HEIGHT, GRID_CELL_WIDTH, HORIZONTAL_SPACING, LABEL_CHAR_WIDTH, LABEL_HEIGHT, LABEL_MIN_WIDTH, LABEL_VERTICAL_OFFSET, SUBPROCESS_PADDING, VERTICAL_SPACING, } from "./types.js";
|
package/dist/layout/index.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
export { layoutProcess, layoutFlowNodes } from "./layout-engine.js";
|
|
2
|
+
export { buildBlockTree } from "./block-builder.js";
|
|
3
|
+
export { applyBlockLayout } from "./block-layout.js";
|
|
4
|
+
export { routeEdgeAstar } from "./astar.js";
|
|
2
5
|
export { benchmarkLayout, compareLayouts, formatBenchmarkResult, generateAutoLayout, parseReferenceLayout, } from "./bench.js";
|
|
6
|
+
export { assignGridRows } from "./coordinates.js";
|
|
3
7
|
export { assertNoOverlap } from "./overlap.js";
|
|
4
8
|
export { ELEMENT_SIZES, GRID_CELL_HEIGHT, GRID_CELL_WIDTH, HORIZONTAL_SPACING, LABEL_CHAR_WIDTH, LABEL_HEIGHT, LABEL_MIN_WIDTH, LABEL_VERTICAL_OFFSET, SUBPROCESS_PADDING, VERTICAL_SPACING, } from "./types.js";
|
|
5
9
|
//# sourceMappingURL=index.js.map
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { buildBlockTree } from "./block-builder.js";
|
|
2
|
+
import { applyBlockLayout } from "./block-layout.js";
|
|
3
|
+
import { alignBaselinePath, alignBranchBaselines, alignSplitJoinPairs, assignCoordinates, assignGridRows, compactBranches, distributeSplitBranches, ensureEarlyReturnOffBaseline, resolveLayerOverlaps, snapToYRows, } from "./coordinates.js";
|
|
2
4
|
import { minimizeCrossings } from "./crossing.js";
|
|
3
5
|
import { buildGraph, detectBackEdges, reverseBackEdges } from "./graph.js";
|
|
4
6
|
import { assignLayers, groupByLayer } from "./layers.js";
|
|
@@ -39,43 +41,46 @@ export function layoutFlowNodes(flowNodes, sequenceFlows) {
|
|
|
39
41
|
const graph = buildGraph(flowNodes, sequenceFlows);
|
|
40
42
|
const backEdges = detectBackEdges(graph, sequenceFlows);
|
|
41
43
|
const dag = backEdges.length > 0 ? reverseBackEdges(graph, backEdges) : graph;
|
|
42
|
-
// Phase 2:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
// Phase 4d: Align all baseline-path nodes to the same center-Y
|
|
54
|
-
alignBaselinePath(layoutNodes, dag, backEdges);
|
|
55
|
-
// Phase 4e: Ensure early-return branches are never on the baseline
|
|
56
|
-
ensureEarlyReturnOffBaseline(layoutNodes, dag, backEdges);
|
|
57
|
-
// Phase 4f: Distribute split gateway branches symmetrically
|
|
58
|
-
distributeSplitBranches(layoutNodes, dag, backEdges);
|
|
59
|
-
// Phase 4g: Resolve any layer overlaps from redistribution
|
|
60
|
-
resolveLayerOverlaps(layoutNodes);
|
|
61
|
-
// Phase 4h: Re-align baseline after overlap resolution (overlap resolution may push
|
|
62
|
-
// baseline nodes off-center when they share a layer with branch nodes)
|
|
63
|
-
alignBaselinePath(layoutNodes, dag, backEdges);
|
|
64
|
-
// Phase 4i: Final overlap resolution — baseline re-alignment may pull a node back into
|
|
65
|
-
// an overlap that resolveLayerOverlaps already fixed; one more pass eliminates these.
|
|
66
|
-
resolveLayerOverlaps(layoutNodes);
|
|
44
|
+
// Phase 2: Try block-based layout (primary path for structured processes)
|
|
45
|
+
// Block layout only works well for processes without back-edges (loops).
|
|
46
|
+
let layoutNodes;
|
|
47
|
+
const blockTree = backEdges.length === 0 ? buildBlockTree(dag, nodeIndex) : null;
|
|
48
|
+
const usedBlockLayout = blockTree !== null;
|
|
49
|
+
if (blockTree) {
|
|
50
|
+
layoutNodes = applyBlockLayout(blockTree, nodeIndex);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
layoutNodes = sugiyamaLayout(dag, nodeIndex, backEdges);
|
|
54
|
+
}
|
|
67
55
|
// Phase 5: Sub-process layout — expand containers and lay out children
|
|
68
56
|
const childResults = layoutSubProcesses(layoutNodes, nodeIndex);
|
|
69
|
-
// After subprocess expansion, push nodes that now overlap with expanded containers
|
|
57
|
+
// After subprocess expansion, push nodes that now overlap with expanded containers.
|
|
58
|
+
// For block layout, assign unique layer indices first so resolveLayerOverlaps works
|
|
59
|
+
// correctly (block layout nodes all start at layer=0).
|
|
60
|
+
if (usedBlockLayout && childResults.length > 0) {
|
|
61
|
+
// Assign each block-layout node a unique layer so overlap resolution doesn't
|
|
62
|
+
// collapse them all into the same bucket and push them vertically apart.
|
|
63
|
+
for (let idx = 0; idx < layoutNodes.length; idx++) {
|
|
64
|
+
const n = layoutNodes[idx];
|
|
65
|
+
if (n)
|
|
66
|
+
n.layer = idx;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
70
69
|
resolveSubProcessOverlaps(layoutNodes);
|
|
71
70
|
// Phase 5b: Resolve Y-direction overlaps caused by subprocess expansion.
|
|
72
71
|
// Expanded subprocesses grow in-place and can overlap same-layer siblings.
|
|
73
|
-
|
|
72
|
+
// For block layout without subprocesses, skip this — overlaps are impossible by construction.
|
|
73
|
+
if (!usedBlockLayout || childResults.length > 0) {
|
|
74
|
+
resolveLayerOverlaps(layoutNodes);
|
|
75
|
+
}
|
|
74
76
|
// Phase 5c: Sync child positions to their subprocess containers.
|
|
75
77
|
// resolveLayerOverlaps (including its Y-normalization pass) may have shifted
|
|
76
78
|
// subprocess containers after their children were already translated to
|
|
77
79
|
// absolute coordinates — children must follow.
|
|
78
80
|
syncSubProcessChildren(childResults, layoutNodes);
|
|
81
|
+
// Assign grid-row indices based on final center-Y positions (eliminates pixel-tolerance
|
|
82
|
+
// guessing in port-side decisions).
|
|
83
|
+
assignGridRows(layoutNodes);
|
|
79
84
|
// Phase 6: Edge routing (uses original back-edges for routing, not reversed)
|
|
80
85
|
const nodeMap = new Map();
|
|
81
86
|
for (const node of layoutNodes) {
|
|
@@ -95,6 +100,52 @@ export function layoutFlowNodes(flowNodes, sequenceFlows) {
|
|
|
95
100
|
}
|
|
96
101
|
return { nodes: allNodes, edges: allEdges };
|
|
97
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Run the Sugiyama layered layout pipeline.
|
|
105
|
+
* Used as fallback for unstructured or loop-containing processes.
|
|
106
|
+
*/
|
|
107
|
+
function sugiyamaLayout(dag, nodeIndex, backEdges) {
|
|
108
|
+
// Phase 2: Layer assignment
|
|
109
|
+
const layers = assignLayers(dag);
|
|
110
|
+
// Phase 3: Group by layer and minimize crossings
|
|
111
|
+
const layerGroups = groupByLayer(layers);
|
|
112
|
+
const orderedLayers = minimizeCrossings(layerGroups, dag);
|
|
113
|
+
// Phase 4: Coordinate assignment
|
|
114
|
+
const layoutNodes = assignCoordinates(orderedLayers, nodeIndex);
|
|
115
|
+
// Phase 4b: Align linear sequences to a common y-baseline
|
|
116
|
+
alignBranchBaselines(layoutNodes, dag);
|
|
117
|
+
// Phase 4c: Align split/join gateway pairs to same y-coordinate
|
|
118
|
+
alignSplitJoinPairs(layoutNodes, dag, backEdges);
|
|
119
|
+
// Phase 4d: Align all baseline-path nodes to the same center-Y
|
|
120
|
+
alignBaselinePath(layoutNodes, dag, backEdges);
|
|
121
|
+
// Phase 4e: Ensure early-return branches are never on the baseline
|
|
122
|
+
ensureEarlyReturnOffBaseline(layoutNodes, dag, backEdges);
|
|
123
|
+
// Re-align linear chains that may have been disrupted by position swaps
|
|
124
|
+
alignBranchBaselines(layoutNodes, dag);
|
|
125
|
+
// Phase 4f: Distribute split gateway branches symmetrically
|
|
126
|
+
distributeSplitBranches(layoutNodes, dag, backEdges);
|
|
127
|
+
// Re-align split/join pairs that may have been separated during branch distribution
|
|
128
|
+
alignSplitJoinPairs(layoutNodes, dag, backEdges);
|
|
129
|
+
// Re-align branch spines after distribution moved chains and alignSplitJoinPairs
|
|
130
|
+
// adjusted join gateways (continuation nodes after joins must follow)
|
|
131
|
+
alignBranchBaselines(layoutNodes, dag);
|
|
132
|
+
// Phase 4g: Resolve any layer overlaps from redistribution
|
|
133
|
+
resolveLayerOverlaps(layoutNodes);
|
|
134
|
+
// Phase 4h: Re-align baseline after overlap resolution (overlap resolution may push
|
|
135
|
+
// baseline nodes off-center when they share a layer with branch nodes)
|
|
136
|
+
alignBaselinePath(layoutNodes, dag, backEdges);
|
|
137
|
+
// Phase 4i: Final overlap resolution — baseline re-alignment may pull a node back into
|
|
138
|
+
// an overlap that resolveLayerOverlaps already fixed; one more pass eliminates these.
|
|
139
|
+
resolveLayerOverlaps(layoutNodes);
|
|
140
|
+
// Phase 4j: Branch compaction — pull branch subtrees toward baseline
|
|
141
|
+
compactBranches(layoutNodes, dag, backEdges);
|
|
142
|
+
resolveLayerOverlaps(layoutNodes);
|
|
143
|
+
alignBaselinePath(layoutNodes, dag, backEdges);
|
|
144
|
+
// Phase 4k: Row snapping — merge close Y rows for matrix-like alignment
|
|
145
|
+
snapToYRows(layoutNodes);
|
|
146
|
+
resolveLayerOverlaps(layoutNodes);
|
|
147
|
+
return layoutNodes;
|
|
148
|
+
}
|
|
98
149
|
/**
|
|
99
150
|
* After subprocess expansion, cascade-shift all subsequent layers
|
|
100
151
|
* so that inter-layer spacing is preserved.
|
package/dist/layout/routing.d.ts
CHANGED
|
@@ -8,13 +8,15 @@ export type PortSide = "right" | "top" | "bottom";
|
|
|
8
8
|
* Non-gateway targets always receive edges from the left side.
|
|
9
9
|
* Split gateways (starting): incoming always from the left.
|
|
10
10
|
* Join gateways (closing): incoming based on relative position (top/bottom/left).
|
|
11
|
+
*
|
|
12
|
+
* Uses gridRow (integer row index) when available for exact comparison;
|
|
13
|
+
* falls back to pixel-Y with PORT_SAME_Y_TOLERANCE for nodes without gridRow.
|
|
11
14
|
*/
|
|
12
15
|
export declare function resolveTargetPort(source: LayoutNode, target: LayoutNode, joinGateways: ReadonlySet<string>): "left" | "top" | "bottom";
|
|
13
16
|
/**
|
|
14
17
|
* Assign source ports for outgoing edges of a gateway.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* - Even count: upper half → top, lower half → bottom, no right port.
|
|
18
|
+
* Uses absolute direction: target above → top, below → bottom, same level → right.
|
|
19
|
+
* Single output always exits from the right port.
|
|
18
20
|
*/
|
|
19
21
|
export declare function assignGatewayPorts(outgoingFlows: BpmnSequenceFlow[], nodeMap: Map<string, LayoutNode>): Map<string, PortSide>;
|
|
20
22
|
/**
|
|
@@ -23,4 +25,9 @@ export declare function assignGatewayPorts(outgoingFlows: BpmnSequenceFlow[], no
|
|
|
23
25
|
* Gateway sources use port-based routing (top/right/bottom).
|
|
24
26
|
*/
|
|
25
27
|
export declare function routeEdges(sequenceFlows: BpmnSequenceFlow[], nodeMap: Map<string, LayoutNode>, backEdges: BackEdge[]): LayoutEdge[];
|
|
28
|
+
/**
|
|
29
|
+
* Post-process routed edges to avoid crossing through intermediate shapes.
|
|
30
|
+
* For each segment that passes through a shape, adds detour waypoints around it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveEdgeCrossings(edges: LayoutEdge[], nodeMap: Map<string, LayoutNode>): void;
|
|
26
33
|
//# sourceMappingURL=routing.d.ts.map
|
package/dist/layout/routing.js
CHANGED
|
@@ -5,11 +5,16 @@ const GATEWAY_TYPES = new Set([
|
|
|
5
5
|
"inclusiveGateway",
|
|
6
6
|
"eventBasedGateway",
|
|
7
7
|
]);
|
|
8
|
+
/** Tolerance for treating two CY values as "same level" in port decisions. */
|
|
9
|
+
const PORT_SAME_Y_TOLERANCE = 25;
|
|
8
10
|
/**
|
|
9
11
|
* Determine which side of the target a forward edge should connect to.
|
|
10
12
|
* Non-gateway targets always receive edges from the left side.
|
|
11
13
|
* Split gateways (starting): incoming always from the left.
|
|
12
14
|
* Join gateways (closing): incoming based on relative position (top/bottom/left).
|
|
15
|
+
*
|
|
16
|
+
* Uses gridRow (integer row index) when available for exact comparison;
|
|
17
|
+
* falls back to pixel-Y with PORT_SAME_Y_TOLERANCE for nodes without gridRow.
|
|
13
18
|
*/
|
|
14
19
|
export function resolveTargetPort(source, target, joinGateways) {
|
|
15
20
|
if (!GATEWAY_TYPES.has(target.type)) {
|
|
@@ -20,18 +25,23 @@ export function resolveTargetPort(source, target, joinGateways) {
|
|
|
20
25
|
return "left";
|
|
21
26
|
}
|
|
22
27
|
// Join/closing gateways: connect based on relative position
|
|
28
|
+
if (source.gridRow !== undefined && target.gridRow !== undefined) {
|
|
29
|
+
if (source.gridRow === target.gridRow)
|
|
30
|
+
return "left";
|
|
31
|
+
return source.gridRow < target.gridRow ? "top" : "bottom";
|
|
32
|
+
}
|
|
33
|
+
// Fallback: pixel-Y comparison with tolerance
|
|
23
34
|
const srcCy = source.bounds.y + source.bounds.height / 2;
|
|
24
35
|
const tgtCy = target.bounds.y + target.bounds.height / 2;
|
|
25
|
-
if (Math.abs(srcCy - tgtCy) <=
|
|
36
|
+
if (Math.abs(srcCy - tgtCy) <= PORT_SAME_Y_TOLERANCE) {
|
|
26
37
|
return "left";
|
|
27
38
|
}
|
|
28
39
|
return srcCy < tgtCy ? "top" : "bottom";
|
|
29
40
|
}
|
|
30
41
|
/**
|
|
31
42
|
* Assign source ports for outgoing edges of a gateway.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* - Even count: upper half → top, lower half → bottom, no right port.
|
|
43
|
+
* Uses absolute direction: target above → top, below → bottom, same level → right.
|
|
44
|
+
* Single output always exits from the right port.
|
|
35
45
|
*/
|
|
36
46
|
export function assignGatewayPorts(outgoingFlows, nodeMap) {
|
|
37
47
|
const portMap = new Map();
|
|
@@ -44,39 +54,40 @@ export function assignGatewayPorts(outgoingFlows, nodeMap) {
|
|
|
44
54
|
portMap.set(first.id, "right");
|
|
45
55
|
return portMap;
|
|
46
56
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
else if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
portMap.set(flow.id, "bottom");
|
|
69
|
-
}
|
|
57
|
+
const firstFlow = outgoingFlows[0];
|
|
58
|
+
if (!firstFlow)
|
|
59
|
+
return portMap;
|
|
60
|
+
const gateway = nodeMap.get(firstFlow.sourceRef);
|
|
61
|
+
if (!gateway)
|
|
62
|
+
return portMap;
|
|
63
|
+
const gatewayCY = gateway.bounds.y + gateway.bounds.height / 2;
|
|
64
|
+
const gatewayGridRow = gateway.gridRow;
|
|
65
|
+
for (const flow of outgoingFlows) {
|
|
66
|
+
const target = nodeMap.get(flow.targetRef);
|
|
67
|
+
if (!target)
|
|
68
|
+
continue;
|
|
69
|
+
let side;
|
|
70
|
+
if (gatewayGridRow !== undefined && target.gridRow !== undefined) {
|
|
71
|
+
// Exact integer row comparison — no tolerance needed
|
|
72
|
+
if (target.gridRow < gatewayGridRow)
|
|
73
|
+
side = "top";
|
|
74
|
+
else if (target.gridRow > gatewayGridRow)
|
|
75
|
+
side = "bottom";
|
|
76
|
+
else
|
|
77
|
+
side = "right";
|
|
70
78
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
else {
|
|
80
|
+
// Fallback: pixel-Y comparison with tolerance
|
|
81
|
+
const targetCY = target.bounds.y + target.bounds.height / 2;
|
|
82
|
+
const dy = targetCY - gatewayCY;
|
|
83
|
+
if (dy < -PORT_SAME_Y_TOLERANCE)
|
|
84
|
+
side = "top";
|
|
85
|
+
else if (dy > PORT_SAME_Y_TOLERANCE)
|
|
86
|
+
side = "bottom";
|
|
87
|
+
else
|
|
88
|
+
side = "right";
|
|
79
89
|
}
|
|
90
|
+
portMap.set(flow.id, side);
|
|
80
91
|
}
|
|
81
92
|
return portMap;
|
|
82
93
|
}
|
|
@@ -148,6 +159,8 @@ export function routeEdges(sequenceFlows, nodeMap, backEdges) {
|
|
|
148
159
|
labelBounds: undefined,
|
|
149
160
|
});
|
|
150
161
|
}
|
|
162
|
+
// Resolve edges that cross through intermediate shapes
|
|
163
|
+
resolveEdgeCrossings(edges, nodeMap);
|
|
151
164
|
// Collision-aware label placement
|
|
152
165
|
placeEdgeLabels(edges, nodeMap);
|
|
153
166
|
return edges;
|
|
@@ -187,39 +200,14 @@ function routeForwardEdge(source, target, joinGateways) {
|
|
|
187
200
|
{ x: targetLeft, y: targetCenterY },
|
|
188
201
|
];
|
|
189
202
|
}
|
|
190
|
-
/**
|
|
191
|
-
function countBends(waypoints) {
|
|
192
|
-
let bends = 0;
|
|
193
|
-
for (let i = 1; i < waypoints.length - 1; i++) {
|
|
194
|
-
const prev = waypoints[i - 1];
|
|
195
|
-
const curr = waypoints[i];
|
|
196
|
-
const next = waypoints[i + 1];
|
|
197
|
-
if (!prev || !curr || !next)
|
|
198
|
-
continue;
|
|
199
|
-
const dx1 = curr.x - prev.x;
|
|
200
|
-
const dy1 = curr.y - prev.y;
|
|
201
|
-
const dx2 = next.x - curr.x;
|
|
202
|
-
const dy2 = next.y - curr.y;
|
|
203
|
-
// Direction changes when we go from horizontal to vertical or vice versa
|
|
204
|
-
if ((Math.abs(dx1) > 0.1 && Math.abs(dy2) > 0.1) ||
|
|
205
|
-
(Math.abs(dy1) > 0.1 && Math.abs(dx2) > 0.1)) {
|
|
206
|
-
bends++;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
return bends;
|
|
210
|
-
}
|
|
211
|
-
/** Route a forward edge from a specific port side on the source node, choosing minimum bends. */
|
|
203
|
+
/** Route a forward edge from a specific port side on the source node. */
|
|
212
204
|
function routeFromPort(source, target, port, joinGateways) {
|
|
213
205
|
if (port === "right") {
|
|
214
206
|
return routeForwardEdge(source, target, joinGateways);
|
|
215
207
|
}
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const portBends = countBends(portRoute);
|
|
220
|
-
const rightBends = countBends(rightRoute);
|
|
221
|
-
// Prefer the assigned port route unless right route has strictly fewer bends
|
|
222
|
-
return rightBends < portBends ? rightRoute : portRoute;
|
|
208
|
+
// top/bottom ports are assigned because the target is genuinely above/below —
|
|
209
|
+
// always honour the assigned side rather than falling back to right-exit.
|
|
210
|
+
return routeFromPortDirect(source, target, port, joinGateways);
|
|
223
211
|
}
|
|
224
212
|
/** Route directly from top/bottom port, preferring L-shaped path. */
|
|
225
213
|
function routeFromPortDirect(source, target, port, joinGateways) {
|
|
@@ -260,6 +248,7 @@ function routeFromPortDirect(source, target, port, joinGateways) {
|
|
|
260
248
|
}
|
|
261
249
|
/**
|
|
262
250
|
* Route a back-edge (loop) above or below all nodes, choosing the shorter path.
|
|
251
|
+
* Gateway targets are entered from the right (since back-edges come from the right).
|
|
263
252
|
*/
|
|
264
253
|
function routeBackEdge(source, target, nodeMap) {
|
|
265
254
|
let minY = Number.POSITIVE_INFINITY;
|
|
@@ -274,17 +263,20 @@ function routeBackEdge(source, target, nodeMap) {
|
|
|
274
263
|
}
|
|
275
264
|
const sourceRight = source.bounds.x + source.bounds.width;
|
|
276
265
|
const sourceCenterY = source.bounds.y + source.bounds.height / 2;
|
|
277
|
-
const targetLeft = target.bounds.x;
|
|
278
266
|
const targetCenterY = target.bounds.y + target.bounds.height / 2;
|
|
267
|
+
// Gateways: enter from right side; non-gateways: enter from left side
|
|
268
|
+
const enterRight = GATEWAY_TYPES.has(target.type);
|
|
269
|
+
const entryX = enterRight ? target.bounds.x + target.bounds.width : target.bounds.x;
|
|
270
|
+
const stemX = enterRight ? entryX + 20 : entryX - 20;
|
|
279
271
|
// Route above
|
|
280
272
|
const routeAboveY = minY - 30;
|
|
281
273
|
const aboveRoute = [
|
|
282
274
|
{ x: sourceRight, y: sourceCenterY },
|
|
283
275
|
{ x: sourceRight + 20, y: sourceCenterY },
|
|
284
276
|
{ x: sourceRight + 20, y: routeAboveY },
|
|
285
|
-
{ x:
|
|
286
|
-
{ x:
|
|
287
|
-
{ x:
|
|
277
|
+
{ x: stemX, y: routeAboveY },
|
|
278
|
+
{ x: stemX, y: targetCenterY },
|
|
279
|
+
{ x: entryX, y: targetCenterY },
|
|
288
280
|
];
|
|
289
281
|
// Route below
|
|
290
282
|
const routeBelowY = maxY + 30;
|
|
@@ -292,9 +284,9 @@ function routeBackEdge(source, target, nodeMap) {
|
|
|
292
284
|
{ x: sourceRight, y: sourceCenterY },
|
|
293
285
|
{ x: sourceRight + 20, y: sourceCenterY },
|
|
294
286
|
{ x: sourceRight + 20, y: routeBelowY },
|
|
295
|
-
{ x:
|
|
296
|
-
{ x:
|
|
297
|
-
{ x:
|
|
287
|
+
{ x: stemX, y: routeBelowY },
|
|
288
|
+
{ x: stemX, y: targetCenterY },
|
|
289
|
+
{ x: entryX, y: targetCenterY },
|
|
298
290
|
];
|
|
299
291
|
// Compare total path length and pick shorter
|
|
300
292
|
const aboveLen = pathLength(aboveRoute);
|
|
@@ -312,6 +304,197 @@ function pathLength(waypoints) {
|
|
|
312
304
|
}
|
|
313
305
|
return len;
|
|
314
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* Post-process routed edges to avoid crossing through intermediate shapes.
|
|
309
|
+
* For each segment that passes through a shape, adds detour waypoints around it.
|
|
310
|
+
*/
|
|
311
|
+
export function resolveEdgeCrossings(edges, nodeMap) {
|
|
312
|
+
const margin = 20;
|
|
313
|
+
const allShapes = [];
|
|
314
|
+
for (const [id, node] of nodeMap) {
|
|
315
|
+
allShapes.push({
|
|
316
|
+
id,
|
|
317
|
+
x: node.bounds.x,
|
|
318
|
+
y: node.bounds.y,
|
|
319
|
+
right: node.bounds.x + node.bounds.width,
|
|
320
|
+
bottom: node.bounds.y + node.bounds.height,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
for (const edge of edges) {
|
|
324
|
+
const obstacles = allShapes.filter((s) => s.id !== edge.sourceRef && s.id !== edge.targetRef);
|
|
325
|
+
// Pass 1: Detour around obstacles crossing segments
|
|
326
|
+
for (let pass = 0; pass < 5; pass++) {
|
|
327
|
+
const fixed = fixOneCrossing(edge.waypoints, obstacles, margin);
|
|
328
|
+
if (!fixed)
|
|
329
|
+
break;
|
|
330
|
+
edge.waypoints = collapseCollinear(fixed);
|
|
331
|
+
}
|
|
332
|
+
// Pass 2: Fix corner waypoints that ended up inside obstacles
|
|
333
|
+
edge.waypoints = fixCornersInsideObstacles(edge.waypoints, obstacles, margin);
|
|
334
|
+
edge.waypoints = collapseCollinear(edge.waypoints);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Find the first segment that crosses an obstacle and return a new waypoint
|
|
339
|
+
* array with a detour around it. Returns undefined if no crossing found.
|
|
340
|
+
*/
|
|
341
|
+
function fixOneCrossing(waypoints, obstacles, margin) {
|
|
342
|
+
for (let i = 0; i < waypoints.length - 1; i++) {
|
|
343
|
+
const p1 = waypoints[i];
|
|
344
|
+
const p2 = waypoints[i + 1];
|
|
345
|
+
const crossing = findCrossing(p1, p2, obstacles);
|
|
346
|
+
if (!crossing)
|
|
347
|
+
continue;
|
|
348
|
+
const detour = buildDetour(p1, p2, crossing, margin, obstacles);
|
|
349
|
+
if (!detour)
|
|
350
|
+
continue;
|
|
351
|
+
const result = [...waypoints.slice(0, i + 1), ...detour, ...waypoints.slice(i + 1)];
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
/** Find the first obstacle that a segment crosses through (not just touches). */
|
|
357
|
+
function findCrossing(p1, p2, obstacles) {
|
|
358
|
+
const minX = Math.min(p1.x, p2.x);
|
|
359
|
+
const maxX = Math.max(p1.x, p2.x);
|
|
360
|
+
const minY = Math.min(p1.y, p2.y);
|
|
361
|
+
const maxY = Math.max(p1.y, p2.y);
|
|
362
|
+
const shrink = 3;
|
|
363
|
+
for (const obs of obstacles) {
|
|
364
|
+
if (maxX > obs.x + shrink &&
|
|
365
|
+
minX < obs.right - shrink &&
|
|
366
|
+
maxY > obs.y + shrink &&
|
|
367
|
+
minY < obs.bottom - shrink) {
|
|
368
|
+
return obs;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Build detour waypoints to route around an obstacle.
|
|
375
|
+
* For vertical segments: detour horizontally (left or right).
|
|
376
|
+
* For horizontal segments: detour vertically (above or below).
|
|
377
|
+
*/
|
|
378
|
+
function buildDetour(p1, p2, obs, margin, allObs) {
|
|
379
|
+
const isVertical = Math.abs(p1.x - p2.x) < 1;
|
|
380
|
+
const isHorizontal = Math.abs(p1.y - p2.y) < 1;
|
|
381
|
+
if (isVertical) {
|
|
382
|
+
const x = p1.x;
|
|
383
|
+
const goingDown = p2.y > p1.y;
|
|
384
|
+
const beforeY = goingDown ? obs.y - margin : obs.bottom + margin;
|
|
385
|
+
const afterY = goingDown ? obs.bottom + margin : obs.y - margin;
|
|
386
|
+
// Try both sides; pick the one with fewer new crossings
|
|
387
|
+
const leftX = obs.x - margin;
|
|
388
|
+
const rightX = obs.right + margin;
|
|
389
|
+
const leftCross = countNewCrossings([
|
|
390
|
+
{ x, y: beforeY },
|
|
391
|
+
{ x: leftX, y: beforeY },
|
|
392
|
+
{ x: leftX, y: afterY },
|
|
393
|
+
{ x, y: afterY },
|
|
394
|
+
], allObs);
|
|
395
|
+
const rightCross = countNewCrossings([
|
|
396
|
+
{ x, y: beforeY },
|
|
397
|
+
{ x: rightX, y: beforeY },
|
|
398
|
+
{ x: rightX, y: afterY },
|
|
399
|
+
{ x, y: afterY },
|
|
400
|
+
], allObs);
|
|
401
|
+
const detourX = leftCross <= rightCross ? leftX : rightX;
|
|
402
|
+
return [
|
|
403
|
+
{ x, y: beforeY },
|
|
404
|
+
{ x: detourX, y: beforeY },
|
|
405
|
+
{ x: detourX, y: afterY },
|
|
406
|
+
{ x, y: afterY },
|
|
407
|
+
];
|
|
408
|
+
}
|
|
409
|
+
if (isHorizontal) {
|
|
410
|
+
const y = p1.y;
|
|
411
|
+
const goingRight = p2.x > p1.x;
|
|
412
|
+
const beforeX = goingRight ? obs.x - margin : obs.right + margin;
|
|
413
|
+
const afterX = goingRight ? obs.right + margin : obs.x - margin;
|
|
414
|
+
const aboveY = obs.y - margin;
|
|
415
|
+
const belowY = obs.bottom + margin;
|
|
416
|
+
const aboveCross = countNewCrossings([
|
|
417
|
+
{ x: beforeX, y },
|
|
418
|
+
{ x: beforeX, y: aboveY },
|
|
419
|
+
{ x: afterX, y: aboveY },
|
|
420
|
+
{ x: afterX, y },
|
|
421
|
+
], allObs);
|
|
422
|
+
const belowCross = countNewCrossings([
|
|
423
|
+
{ x: beforeX, y },
|
|
424
|
+
{ x: beforeX, y: belowY },
|
|
425
|
+
{ x: afterX, y: belowY },
|
|
426
|
+
{ x: afterX, y },
|
|
427
|
+
], allObs);
|
|
428
|
+
const detourY = aboveCross <= belowCross ? aboveY : belowY;
|
|
429
|
+
return [
|
|
430
|
+
{ x: beforeX, y },
|
|
431
|
+
{ x: beforeX, y: detourY },
|
|
432
|
+
{ x: afterX, y: detourY },
|
|
433
|
+
{ x: afterX, y },
|
|
434
|
+
];
|
|
435
|
+
}
|
|
436
|
+
// Diagonal segment — skip (shouldn't happen in orthogonal routing)
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
/** Count how many obstacles a set of consecutive segments would cross. */
|
|
440
|
+
function countNewCrossings(points, obstacles) {
|
|
441
|
+
let count = 0;
|
|
442
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
443
|
+
const a = points[i];
|
|
444
|
+
const b = points[i + 1];
|
|
445
|
+
if (findCrossing(a, b, obstacles))
|
|
446
|
+
count++;
|
|
447
|
+
}
|
|
448
|
+
return count;
|
|
449
|
+
}
|
|
450
|
+
/** Remove collinear intermediate waypoints (same X or same Y in a row). */
|
|
451
|
+
function collapseCollinear(waypoints) {
|
|
452
|
+
if (waypoints.length <= 2)
|
|
453
|
+
return waypoints;
|
|
454
|
+
const result = [waypoints[0]];
|
|
455
|
+
for (let i = 1; i < waypoints.length - 1; i++) {
|
|
456
|
+
const prev = result[result.length - 1];
|
|
457
|
+
const curr = waypoints[i];
|
|
458
|
+
const next = waypoints[i + 1];
|
|
459
|
+
const sameX = Math.abs(prev.x - curr.x) < 0.5 && Math.abs(curr.x - next.x) < 0.5;
|
|
460
|
+
const sameY = Math.abs(prev.y - curr.y) < 0.5 && Math.abs(curr.y - next.y) < 0.5;
|
|
461
|
+
if (sameX || sameY)
|
|
462
|
+
continue;
|
|
463
|
+
result.push(curr);
|
|
464
|
+
}
|
|
465
|
+
result.push(waypoints[waypoints.length - 1]);
|
|
466
|
+
return result;
|
|
467
|
+
}
|
|
468
|
+
function isInsideRect(p, r) {
|
|
469
|
+
return p.x > r.x && p.x < r.right && p.y > r.y && p.y < r.bottom;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Fix corner waypoints that ended up inside obstacles after detours.
|
|
473
|
+
* Moves the corner below/above the obstacle while maintaining orthogonal routing.
|
|
474
|
+
*/
|
|
475
|
+
function fixCornersInsideObstacles(waypoints, obstacles, margin) {
|
|
476
|
+
const result = [...waypoints];
|
|
477
|
+
// Process backwards to maintain indices after splicing
|
|
478
|
+
for (let i = result.length - 2; i >= 1; i--) {
|
|
479
|
+
const wp = result[i];
|
|
480
|
+
const obs = obstacles.find((o) => isInsideRect(wp, o));
|
|
481
|
+
if (!obs)
|
|
482
|
+
continue;
|
|
483
|
+
const prev = result[i - 1];
|
|
484
|
+
const next = result[i + 1];
|
|
485
|
+
const isHorizToVert = Math.abs(prev.y - wp.y) < 1 && Math.abs(wp.x - next.x) < 1;
|
|
486
|
+
const isVertToHoriz = Math.abs(prev.x - wp.x) < 1 && Math.abs(wp.y - next.y) < 1;
|
|
487
|
+
if (isHorizToVert) {
|
|
488
|
+
const newY = next.y > wp.y ? obs.bottom + margin : obs.y - margin;
|
|
489
|
+
result.splice(i, 1, { x: prev.x, y: newY }, { x: wp.x, y: newY });
|
|
490
|
+
}
|
|
491
|
+
else if (isVertToHoriz) {
|
|
492
|
+
const newX = next.x > wp.x ? obs.right + margin : obs.x - margin;
|
|
493
|
+
result.splice(i, 1, { x: newX, y: prev.y }, { x: newX, y: wp.y });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return result;
|
|
497
|
+
}
|
|
315
498
|
/** Collision tolerance in pixels — small overlap allowed for rounding. */
|
|
316
499
|
const LABEL_COLLISION_TOLERANCE = 2;
|
|
317
500
|
/** Number of slide steps along a segment when searching for clear space. */
|
package/dist/layout/types.d.ts
CHANGED
|
@@ -38,6 +38,12 @@ export interface LayoutNode {
|
|
|
38
38
|
layer: number;
|
|
39
39
|
/** Position within the layer (row index). */
|
|
40
40
|
position: number;
|
|
41
|
+
/**
|
|
42
|
+
* Grid row index assigned after all coordinate adjustments, just before routing.
|
|
43
|
+
* Nodes with the same gridRow are on the same horizontal row and connect left-to-right.
|
|
44
|
+
* Set by assignGridRows(); used by port-side decisions in routing.ts.
|
|
45
|
+
*/
|
|
46
|
+
gridRow?: number;
|
|
41
47
|
/** Label text for the node. */
|
|
42
48
|
label?: string;
|
|
43
49
|
/** Label bounds for overlap checking. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bpmnkit/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.23",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"dist/**/*.d.ts"
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@bpmnkit/feel": "0.0.
|
|
20
|
+
"@bpmnkit/feel": "0.0.18"
|
|
21
21
|
},
|
|
22
22
|
"description": "TypeScript-first BPMN 2.0 SDK — parse, build, layout, and optimize diagrams",
|
|
23
23
|
"keywords": [
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"build": "tsc",
|
|
47
47
|
"typecheck": "tsc --noEmit",
|
|
48
48
|
"check": "biome check .",
|
|
49
|
-
"test": "vitest run"
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"format-bpmn": "node scripts/format-bpmn.mjs"
|
|
50
51
|
}
|
|
51
52
|
}
|