@bpmnkit/core 0.0.8
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 +185 -0
- package/dist/bpmn/auto-layout.d.ts +10 -0
- package/dist/bpmn/auto-layout.js +203 -0
- package/dist/bpmn/bpmn-builder.d.ts +381 -0
- package/dist/bpmn/bpmn-builder.js +1223 -0
- package/dist/bpmn/bpmn-model.d.ts +403 -0
- package/dist/bpmn/bpmn-model.js +2 -0
- package/dist/bpmn/bpmn-parser.d.ts +4 -0
- package/dist/bpmn/bpmn-parser.js +642 -0
- package/dist/bpmn/bpmn-serializer.d.ts +4 -0
- package/dist/bpmn/bpmn-serializer.js +515 -0
- package/dist/bpmn/compact.d.ts +91 -0
- package/dist/bpmn/compact.js +349 -0
- package/dist/bpmn/di-color.d.ts +9 -0
- package/dist/bpmn/di-color.js +32 -0
- package/dist/bpmn/index.d.ts +105 -0
- package/dist/bpmn/index.js +176 -0
- package/dist/bpmn/optimize/feel.d.ts +4 -0
- package/dist/bpmn/optimize/feel.js +186 -0
- package/dist/bpmn/optimize/flow.d.ts +4 -0
- package/dist/bpmn/optimize/flow.js +323 -0
- package/dist/bpmn/optimize/index.d.ts +5 -0
- package/dist/bpmn/optimize/index.js +51 -0
- package/dist/bpmn/optimize/naming.d.ts +8 -0
- package/dist/bpmn/optimize/naming.js +145 -0
- package/dist/bpmn/optimize/tasks.d.ts +4 -0
- package/dist/bpmn/optimize/tasks.js +273 -0
- package/dist/bpmn/optimize/types.d.ts +44 -0
- package/dist/bpmn/optimize/types.js +2 -0
- package/dist/bpmn/optimize/utils.d.ts +23 -0
- package/dist/bpmn/optimize/utils.js +129 -0
- package/dist/bpmn/rest-connector.d.ts +48 -0
- package/dist/bpmn/rest-connector.js +69 -0
- package/dist/bpmn/svg.d.ts +35 -0
- package/dist/bpmn/svg.js +557 -0
- package/dist/bpmn/type-guards.d.ts +136 -0
- package/dist/bpmn/type-guards.js +211 -0
- package/dist/bpmn/utils.d.ts +88 -0
- package/dist/bpmn/utils.js +207 -0
- package/dist/bpmn/zeebe-extensions.d.ts +72 -0
- package/dist/bpmn/zeebe-extensions.js +96 -0
- package/dist/dmn/compact.d.ts +44 -0
- package/dist/dmn/compact.js +125 -0
- package/dist/dmn/dmn-builder.d.ts +59 -0
- package/dist/dmn/dmn-builder.js +144 -0
- package/dist/dmn/dmn-layout.d.ts +57 -0
- package/dist/dmn/dmn-layout.js +256 -0
- package/dist/dmn/dmn-model.d.ts +158 -0
- package/dist/dmn/dmn-model.js +2 -0
- package/dist/dmn/dmn-parser.d.ts +4 -0
- package/dist/dmn/dmn-parser.js +275 -0
- package/dist/dmn/dmn-serializer.d.ts +4 -0
- package/dist/dmn/dmn-serializer.js +296 -0
- package/dist/dmn/index.d.ts +28 -0
- package/dist/dmn/index.js +51 -0
- package/dist/errors.d.ts +77 -0
- package/dist/errors.js +66 -0
- package/dist/form/compact.d.ts +20 -0
- package/dist/form/compact.js +146 -0
- package/dist/form/form-builder.d.ts +111 -0
- package/dist/form/form-builder.js +347 -0
- package/dist/form/form-model.d.ts +221 -0
- package/dist/form/form-model.js +2 -0
- package/dist/form/form-parser.d.ts +4 -0
- package/dist/form/form-parser.js +514 -0
- package/dist/form/form-serializer.d.ts +4 -0
- package/dist/form/form-serializer.js +240 -0
- package/dist/form/index.d.ts +27 -0
- package/dist/form/index.js +43 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +19 -0
- package/dist/layout/bench.d.ts +86 -0
- package/dist/layout/bench.js +225 -0
- package/dist/layout/coordinates.d.ts +62 -0
- package/dist/layout/coordinates.js +868 -0
- package/dist/layout/crossing.d.ts +8 -0
- package/dist/layout/crossing.js +60 -0
- package/dist/layout/graph.d.ts +28 -0
- package/dist/layout/graph.js +126 -0
- package/dist/layout/index.d.ts +7 -0
- package/dist/layout/index.js +5 -0
- package/dist/layout/layers.d.ts +13 -0
- package/dist/layout/layers.js +49 -0
- package/dist/layout/layout-engine.d.ts +21 -0
- package/dist/layout/layout-engine.js +191 -0
- package/dist/layout/overlap.d.ts +10 -0
- package/dist/layout/overlap.js +70 -0
- package/dist/layout/routing.d.ts +26 -0
- package/dist/layout/routing.js +439 -0
- package/dist/layout/subprocess.d.ts +14 -0
- package/dist/layout/subprocess.js +77 -0
- package/dist/layout/types.d.ts +72 -0
- package/dist/layout/types.js +37 -0
- package/dist/types/id-generator.d.ts +8 -0
- package/dist/types/id-generator.js +31 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.js +2 -0
- package/dist/types/xml-element.d.ts +12 -0
- package/dist/types/xml-element.js +2 -0
- package/dist/xml/index.d.ts +2 -0
- package/dist/xml/index.js +2 -0
- package/dist/xml/xml-parser.d.ts +13 -0
- package/dist/xml/xml-parser.js +271 -0
- package/package.json +38 -0
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
import { ELEMENT_SIZES, GRID_CELL_HEIGHT, GRID_CELL_WIDTH } from "./types.js";
|
|
2
|
+
/** Get the fixed size for a BPMN element type. */
|
|
3
|
+
export function getElementSize(type) {
|
|
4
|
+
return ELEMENT_SIZES[type] ?? { width: 100, height: 80 };
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Assign x,y coordinates to all nodes based on a virtual grid.
|
|
8
|
+
* Each grid cell is GRID_CELL_WIDTH × GRID_CELL_HEIGHT.
|
|
9
|
+
* Elements are centered within their grid cell.
|
|
10
|
+
* If an element is larger than a single cell, adjacent cells are merged.
|
|
11
|
+
*/
|
|
12
|
+
export function assignCoordinates(orderedLayers, nodeIndex) {
|
|
13
|
+
const layoutNodes = [];
|
|
14
|
+
// Determine how many grid columns each layer needs (for oversized elements)
|
|
15
|
+
const layerGridCols = [];
|
|
16
|
+
for (const layer of orderedLayers) {
|
|
17
|
+
let maxCols = 1;
|
|
18
|
+
for (const nodeId of layer) {
|
|
19
|
+
const node = nodeIndex.get(nodeId);
|
|
20
|
+
if (node) {
|
|
21
|
+
const size = getElementSize(node.type);
|
|
22
|
+
const cols = Math.ceil(size.width / GRID_CELL_WIDTH);
|
|
23
|
+
if (cols > maxCols)
|
|
24
|
+
maxCols = cols;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
layerGridCols.push(maxCols);
|
|
28
|
+
}
|
|
29
|
+
// Calculate x offset for each layer based on grid columns
|
|
30
|
+
const layerXOffsets = [];
|
|
31
|
+
let gridCol = 0;
|
|
32
|
+
for (let layerIdx = 0; layerIdx < orderedLayers.length; layerIdx++) {
|
|
33
|
+
layerXOffsets.push(gridCol * GRID_CELL_WIDTH);
|
|
34
|
+
gridCol += layerGridCols[layerIdx] ?? 1;
|
|
35
|
+
}
|
|
36
|
+
// Determine how many grid rows each position needs within each layer
|
|
37
|
+
for (let layerIdx = 0; layerIdx < orderedLayers.length; layerIdx++) {
|
|
38
|
+
const layer = orderedLayers[layerIdx];
|
|
39
|
+
if (!layer)
|
|
40
|
+
continue;
|
|
41
|
+
const layerX = layerXOffsets[layerIdx];
|
|
42
|
+
if (layerX === undefined)
|
|
43
|
+
continue;
|
|
44
|
+
const cellSpanW = (layerGridCols[layerIdx] ?? 1) * GRID_CELL_WIDTH;
|
|
45
|
+
let gridRow = 0;
|
|
46
|
+
for (let posIdx = 0; posIdx < layer.length; posIdx++) {
|
|
47
|
+
const nodeId = layer[posIdx];
|
|
48
|
+
if (!nodeId)
|
|
49
|
+
continue;
|
|
50
|
+
const node = nodeIndex.get(nodeId);
|
|
51
|
+
if (!node)
|
|
52
|
+
continue;
|
|
53
|
+
const size = getElementSize(node.type);
|
|
54
|
+
const rowsNeeded = Math.ceil(size.height / GRID_CELL_HEIGHT);
|
|
55
|
+
const cellSpanH = rowsNeeded * GRID_CELL_HEIGHT;
|
|
56
|
+
// Center element within its grid cell(s)
|
|
57
|
+
const cellX = layerX;
|
|
58
|
+
const cellY = gridRow * GRID_CELL_HEIGHT;
|
|
59
|
+
const xOffset = (cellSpanW - size.width) / 2;
|
|
60
|
+
const yOffset = (cellSpanH - size.height) / 2;
|
|
61
|
+
const bounds = {
|
|
62
|
+
x: cellX + xOffset,
|
|
63
|
+
y: cellY + yOffset,
|
|
64
|
+
width: size.width,
|
|
65
|
+
height: size.height,
|
|
66
|
+
};
|
|
67
|
+
const labelBounds = computeLabelBounds(node, bounds);
|
|
68
|
+
layoutNodes.push({
|
|
69
|
+
id: nodeId,
|
|
70
|
+
type: node.type,
|
|
71
|
+
bounds,
|
|
72
|
+
layer: layerIdx,
|
|
73
|
+
position: posIdx,
|
|
74
|
+
label: node.name,
|
|
75
|
+
labelBounds,
|
|
76
|
+
});
|
|
77
|
+
gridRow += rowsNeeded;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Center the layout vertically so all layers are balanced
|
|
81
|
+
centerLayersVertically(layoutNodes, orderedLayers);
|
|
82
|
+
return layoutNodes;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Center each layer vertically around the midpoint of the tallest layer.
|
|
86
|
+
*/
|
|
87
|
+
function centerLayersVertically(nodes, orderedLayers) {
|
|
88
|
+
// Find the total height of each layer
|
|
89
|
+
const layerHeights = [];
|
|
90
|
+
for (const layer of orderedLayers) {
|
|
91
|
+
const layerNodes = nodes.filter((n) => layer.includes(n.id));
|
|
92
|
+
if (layerNodes.length === 0) {
|
|
93
|
+
layerHeights.push(0);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const minY = Math.min(...layerNodes.map((n) => n.bounds.y));
|
|
97
|
+
const maxY = Math.max(...layerNodes.map((n) => n.bounds.y + n.bounds.height));
|
|
98
|
+
layerHeights.push(maxY - minY);
|
|
99
|
+
}
|
|
100
|
+
const maxHeight = Math.max(...layerHeights, 0);
|
|
101
|
+
// Shift each layer so it's centered relative to the tallest layer
|
|
102
|
+
for (let i = 0; i < orderedLayers.length; i++) {
|
|
103
|
+
const layer = orderedLayers[i];
|
|
104
|
+
if (!layer)
|
|
105
|
+
continue;
|
|
106
|
+
const layerHeight = layerHeights[i];
|
|
107
|
+
if (layerHeight === undefined)
|
|
108
|
+
continue;
|
|
109
|
+
const yShift = (maxHeight - layerHeight) / 2;
|
|
110
|
+
if (yShift > 0) {
|
|
111
|
+
for (const node of nodes) {
|
|
112
|
+
if (layer.includes(node.id)) {
|
|
113
|
+
node.bounds.y += yShift;
|
|
114
|
+
if (node.labelBounds) {
|
|
115
|
+
node.labelBounds.y += yShift;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Compute label bounds for a node based on its type. */
|
|
123
|
+
function computeLabelBounds(node, bounds) {
|
|
124
|
+
if (!node.name)
|
|
125
|
+
return undefined;
|
|
126
|
+
// Cap label width to one grid cell so labels don't overlap adjacent elements
|
|
127
|
+
const labelWidth = Math.min(Math.max(node.name.length * 7, 40), GRID_CELL_WIDTH);
|
|
128
|
+
const labelHeight = 14;
|
|
129
|
+
switch (node.type) {
|
|
130
|
+
case "startEvent":
|
|
131
|
+
case "endEvent":
|
|
132
|
+
case "intermediateThrowEvent":
|
|
133
|
+
case "intermediateCatchEvent":
|
|
134
|
+
// Labels centered below events
|
|
135
|
+
return {
|
|
136
|
+
x: bounds.x + bounds.width / 2 - labelWidth / 2,
|
|
137
|
+
y: bounds.y + bounds.height + 4,
|
|
138
|
+
width: labelWidth,
|
|
139
|
+
height: labelHeight,
|
|
140
|
+
};
|
|
141
|
+
case "exclusiveGateway":
|
|
142
|
+
case "parallelGateway":
|
|
143
|
+
case "inclusiveGateway":
|
|
144
|
+
case "eventBasedGateway":
|
|
145
|
+
// Labels centered below gateway diamond (standard BPMN convention)
|
|
146
|
+
return {
|
|
147
|
+
x: bounds.x + bounds.width / 2 - labelWidth / 2,
|
|
148
|
+
y: bounds.y + bounds.height + 4,
|
|
149
|
+
width: labelWidth,
|
|
150
|
+
height: labelHeight,
|
|
151
|
+
};
|
|
152
|
+
default:
|
|
153
|
+
// Tasks/activities: labels centered inside — no separate label bounds needed
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Re-assign x-coordinates after sub-process expansion.
|
|
159
|
+
* Walks layers left-to-right, shifting each layer to avoid overlap with the previous one.
|
|
160
|
+
*/
|
|
161
|
+
export function reassignXCoordinates(layoutNodes, orderedLayers) {
|
|
162
|
+
const nodeMap = new Map();
|
|
163
|
+
for (const n of layoutNodes) {
|
|
164
|
+
nodeMap.set(n.id, n);
|
|
165
|
+
}
|
|
166
|
+
for (let i = 1; i < orderedLayers.length; i++) {
|
|
167
|
+
const prevLayer = orderedLayers[i - 1];
|
|
168
|
+
if (!prevLayer)
|
|
169
|
+
continue;
|
|
170
|
+
const currLayer = orderedLayers[i];
|
|
171
|
+
if (!currLayer)
|
|
172
|
+
continue;
|
|
173
|
+
// Find the rightmost edge of the previous layer
|
|
174
|
+
let prevMaxRight = 0;
|
|
175
|
+
for (const id of prevLayer) {
|
|
176
|
+
const n = nodeMap.get(id);
|
|
177
|
+
if (n) {
|
|
178
|
+
const right = n.bounds.x + n.bounds.width;
|
|
179
|
+
if (right > prevMaxRight)
|
|
180
|
+
prevMaxRight = right;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Find the leftmost edge of the current layer
|
|
184
|
+
let currMinLeft = Number.POSITIVE_INFINITY;
|
|
185
|
+
for (const id of currLayer) {
|
|
186
|
+
const n = nodeMap.get(id);
|
|
187
|
+
if (n && n.bounds.x < currMinLeft)
|
|
188
|
+
currMinLeft = n.bounds.x;
|
|
189
|
+
}
|
|
190
|
+
// Snap to next grid boundary
|
|
191
|
+
const prevCellEnd = Math.ceil(prevMaxRight / GRID_CELL_WIDTH) * GRID_CELL_WIDTH;
|
|
192
|
+
const requiredX = prevCellEnd;
|
|
193
|
+
const shift = requiredX - currMinLeft;
|
|
194
|
+
if (shift > 0) {
|
|
195
|
+
for (let j = i; j < orderedLayers.length; j++) {
|
|
196
|
+
for (const id of orderedLayers[j] ?? []) {
|
|
197
|
+
const n = nodeMap.get(id);
|
|
198
|
+
if (n) {
|
|
199
|
+
n.bounds.x += shift;
|
|
200
|
+
if (n.labelBounds)
|
|
201
|
+
n.labelBounds.x += shift;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const GATEWAY_TYPE_SET = new Set([
|
|
209
|
+
"exclusiveGateway",
|
|
210
|
+
"parallelGateway",
|
|
211
|
+
"inclusiveGateway",
|
|
212
|
+
"eventBasedGateway",
|
|
213
|
+
]);
|
|
214
|
+
/** Build a set of "sourceRef->targetRef" strings from original back edges. */
|
|
215
|
+
function buildBackEdgeOriginals(backEdges) {
|
|
216
|
+
const s = new Set();
|
|
217
|
+
for (const be of backEdges) {
|
|
218
|
+
s.add(`${be.sourceRef}->${be.targetRef}`);
|
|
219
|
+
}
|
|
220
|
+
return s;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Get the "true" forward successors of a node in the DAG,
|
|
224
|
+
* excluding successors that were added by reversing back edges.
|
|
225
|
+
* A DAG edge (node→s) is a reversed back edge if the original back edge was (s→node).
|
|
226
|
+
*/
|
|
227
|
+
function getTrueSuccessors(nodeId, dag, backEdgeOriginals) {
|
|
228
|
+
return (dag.successors.get(nodeId) ?? []).filter((s) => !backEdgeOriginals.has(`${s}->${nodeId}`));
|
|
229
|
+
}
|
|
230
|
+
/** Count total forward-reachable nodes from startId in the DAG. */
|
|
231
|
+
function countForwardReachable(startId, dag) {
|
|
232
|
+
const seen = new Set();
|
|
233
|
+
const queue = [startId];
|
|
234
|
+
while (queue.length > 0) {
|
|
235
|
+
const id = queue.shift();
|
|
236
|
+
if (!id || seen.has(id))
|
|
237
|
+
continue;
|
|
238
|
+
seen.add(id);
|
|
239
|
+
for (const s of dag.successors.get(id) ?? []) {
|
|
240
|
+
queue.push(s);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return seen.size;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Align nodes in linear sequences to a common y-baseline.
|
|
247
|
+
* A "linear" node has ≤1 predecessor and ≤1 successor, and is not a gateway.
|
|
248
|
+
* Walks forward from each chain root, setting successors to the same center-y.
|
|
249
|
+
*/
|
|
250
|
+
export function alignBranchBaselines(layoutNodes, dag) {
|
|
251
|
+
const nodeMap = new Map();
|
|
252
|
+
for (const n of layoutNodes) {
|
|
253
|
+
nodeMap.set(n.id, n);
|
|
254
|
+
}
|
|
255
|
+
const visited = new Set();
|
|
256
|
+
for (const n of layoutNodes) {
|
|
257
|
+
if (visited.has(n.id))
|
|
258
|
+
continue;
|
|
259
|
+
if (GATEWAY_TYPE_SET.has(n.type))
|
|
260
|
+
continue;
|
|
261
|
+
// Walk backward to find the chain root
|
|
262
|
+
let rootId = n.id;
|
|
263
|
+
for (;;) {
|
|
264
|
+
const preds = dag.predecessors.get(rootId) ?? [];
|
|
265
|
+
if (preds.length !== 1)
|
|
266
|
+
break;
|
|
267
|
+
const pred = preds[0];
|
|
268
|
+
if (!pred)
|
|
269
|
+
break;
|
|
270
|
+
const predNode = nodeMap.get(pred);
|
|
271
|
+
if (!predNode || GATEWAY_TYPE_SET.has(predNode.type))
|
|
272
|
+
break;
|
|
273
|
+
const predSuccs = dag.successors.get(pred) ?? [];
|
|
274
|
+
if (predSuccs.length !== 1 && GATEWAY_TYPE_SET.has(predNode.type))
|
|
275
|
+
break;
|
|
276
|
+
rootId = pred;
|
|
277
|
+
}
|
|
278
|
+
// Walk forward from root, aligning to root's center-y
|
|
279
|
+
const rootNode = nodeMap.get(rootId);
|
|
280
|
+
if (!rootNode)
|
|
281
|
+
continue;
|
|
282
|
+
const baselineCenterY = rootNode.bounds.y + rootNode.bounds.height / 2;
|
|
283
|
+
let currentId = rootId;
|
|
284
|
+
while (currentId) {
|
|
285
|
+
if (visited.has(currentId))
|
|
286
|
+
break;
|
|
287
|
+
visited.add(currentId);
|
|
288
|
+
const current = nodeMap.get(currentId);
|
|
289
|
+
if (!current)
|
|
290
|
+
break;
|
|
291
|
+
if (GATEWAY_TYPE_SET.has(current.type))
|
|
292
|
+
break;
|
|
293
|
+
const dy = baselineCenterY - (current.bounds.y + current.bounds.height / 2);
|
|
294
|
+
if (Math.abs(dy) > 0.5) {
|
|
295
|
+
current.bounds.y += dy;
|
|
296
|
+
if (current.labelBounds)
|
|
297
|
+
current.labelBounds.y += dy;
|
|
298
|
+
}
|
|
299
|
+
const succs = dag.successors.get(currentId) ?? [];
|
|
300
|
+
let nextId;
|
|
301
|
+
if (succs.length === 1) {
|
|
302
|
+
nextId = succs[0];
|
|
303
|
+
}
|
|
304
|
+
else if (succs.length > 1 && !GATEWAY_TYPE_SET.has(current.type)) {
|
|
305
|
+
// Non-gateway with multiple successors (back-edge reversal artifact).
|
|
306
|
+
// Follow the unique-predecessor successor — the main flow continuation.
|
|
307
|
+
nextId = succs.find((s) => (dag.predecessors.get(s) ?? []).length === 1);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
if (!nextId)
|
|
313
|
+
break;
|
|
314
|
+
const nextNode = nodeMap.get(nextId);
|
|
315
|
+
if (!nextNode || GATEWAY_TYPE_SET.has(nextNode.type))
|
|
316
|
+
break;
|
|
317
|
+
const nextPreds = dag.predecessors.get(nextId) ?? [];
|
|
318
|
+
if (nextPreds.length !== 1)
|
|
319
|
+
break;
|
|
320
|
+
currentId = nextId;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Align split/join gateway pairs to the same y-coordinate.
|
|
326
|
+
* A split gateway fans out to multiple successors; the corresponding join gateway
|
|
327
|
+
* is the nearest downstream gateway where all branches reconverge.
|
|
328
|
+
*/
|
|
329
|
+
export function alignSplitJoinPairs(layoutNodes, dag, backEdges = []) {
|
|
330
|
+
const backEdgeOriginals = buildBackEdgeOriginals(backEdges);
|
|
331
|
+
const nodeMap = new Map();
|
|
332
|
+
for (const n of layoutNodes) {
|
|
333
|
+
nodeMap.set(n.id, n);
|
|
334
|
+
}
|
|
335
|
+
for (const n of layoutNodes) {
|
|
336
|
+
if (!GATEWAY_TYPE_SET.has(n.type))
|
|
337
|
+
continue;
|
|
338
|
+
// Use true successors (exclude reversed back-edge successors) to detect real splits
|
|
339
|
+
const trueSuccs = getTrueSuccessors(n.id, dag, backEdgeOriginals);
|
|
340
|
+
if (trueSuccs.length < 2)
|
|
341
|
+
continue;
|
|
342
|
+
// This is a split gateway — find its merge partner
|
|
343
|
+
const joinId = findJoinGateway(n.id, dag, nodeMap);
|
|
344
|
+
if (!joinId)
|
|
345
|
+
continue;
|
|
346
|
+
const joinNode = nodeMap.get(joinId);
|
|
347
|
+
if (!joinNode)
|
|
348
|
+
continue;
|
|
349
|
+
// Force join gateway to same center-y as split gateway
|
|
350
|
+
const splitCenterY = n.bounds.y + n.bounds.height / 2;
|
|
351
|
+
const joinCenterY = joinNode.bounds.y + joinNode.bounds.height / 2;
|
|
352
|
+
const dy = splitCenterY - joinCenterY;
|
|
353
|
+
if (Math.abs(dy) > 0.5) {
|
|
354
|
+
joinNode.bounds.y += dy;
|
|
355
|
+
if (joinNode.labelBounds)
|
|
356
|
+
joinNode.labelBounds.y += dy;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Find the merge gateway for a given split gateway.
|
|
362
|
+
* Walks forward from each successor until all paths converge at a common gateway.
|
|
363
|
+
*/
|
|
364
|
+
function findJoinGateway(splitId, dag, nodeMap) {
|
|
365
|
+
const succs = dag.successors.get(splitId) ?? [];
|
|
366
|
+
if (succs.length < 2)
|
|
367
|
+
return undefined;
|
|
368
|
+
// For each branch, walk forward to find the first downstream gateway
|
|
369
|
+
const branchEndpoints = new Map();
|
|
370
|
+
for (const startId of succs) {
|
|
371
|
+
const reachableGateways = new Set();
|
|
372
|
+
const queue = [startId];
|
|
373
|
+
const seen = new Set();
|
|
374
|
+
while (queue.length > 0) {
|
|
375
|
+
const id = queue.shift();
|
|
376
|
+
if (!id || seen.has(id))
|
|
377
|
+
continue;
|
|
378
|
+
seen.add(id);
|
|
379
|
+
const node = nodeMap.get(id);
|
|
380
|
+
if (!node)
|
|
381
|
+
continue;
|
|
382
|
+
if (GATEWAY_TYPE_SET.has(node.type) && id !== splitId) {
|
|
383
|
+
reachableGateways.add(id);
|
|
384
|
+
continue; // Don't traverse past gateways
|
|
385
|
+
}
|
|
386
|
+
for (const next of dag.successors.get(id) ?? []) {
|
|
387
|
+
if (next !== splitId)
|
|
388
|
+
queue.push(next);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
branchEndpoints.set(startId, reachableGateways);
|
|
392
|
+
}
|
|
393
|
+
// Find the gateway reachable from ALL branches
|
|
394
|
+
const allBranches = [...branchEndpoints.values()];
|
|
395
|
+
if (allBranches.length === 0)
|
|
396
|
+
return undefined;
|
|
397
|
+
const firstSet = allBranches[0];
|
|
398
|
+
if (!firstSet)
|
|
399
|
+
return undefined;
|
|
400
|
+
for (const candidate of firstSet) {
|
|
401
|
+
if (allBranches.every((s) => s.has(candidate))) {
|
|
402
|
+
return candidate;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Ensure early-return branches (shorter paths from split to join) are never on the baseline.
|
|
409
|
+
* The baseline is the split gateway's center-y. If the shortest branch sits on the baseline,
|
|
410
|
+
* swap it with a longer branch.
|
|
411
|
+
*/
|
|
412
|
+
export function ensureEarlyReturnOffBaseline(layoutNodes, dag, backEdges = []) {
|
|
413
|
+
const backEdgeOriginals = buildBackEdgeOriginals(backEdges);
|
|
414
|
+
const nodeMap = new Map();
|
|
415
|
+
for (const n of layoutNodes) {
|
|
416
|
+
nodeMap.set(n.id, n);
|
|
417
|
+
}
|
|
418
|
+
for (const n of layoutNodes) {
|
|
419
|
+
if (!GATEWAY_TYPE_SET.has(n.type))
|
|
420
|
+
continue;
|
|
421
|
+
// Only process true split gateways (not join gateways with reversed back edges)
|
|
422
|
+
const trueSuccs = getTrueSuccessors(n.id, dag, backEdgeOriginals);
|
|
423
|
+
if (trueSuccs.length < 2)
|
|
424
|
+
continue;
|
|
425
|
+
const joinId = findJoinGateway(n.id, dag, nodeMap);
|
|
426
|
+
// Measure branch length (number of nodes from split successor to join)
|
|
427
|
+
const branchLengths = new Map();
|
|
428
|
+
for (const startId of trueSuccs) {
|
|
429
|
+
let length = 0;
|
|
430
|
+
let currentId = startId;
|
|
431
|
+
const seen = new Set();
|
|
432
|
+
while (currentId && !seen.has(currentId)) {
|
|
433
|
+
seen.add(currentId);
|
|
434
|
+
length++;
|
|
435
|
+
if (currentId === joinId)
|
|
436
|
+
break;
|
|
437
|
+
const nextSuccs = dag.successors.get(currentId) ?? [];
|
|
438
|
+
currentId = nextSuccs[0];
|
|
439
|
+
}
|
|
440
|
+
branchLengths.set(startId, length);
|
|
441
|
+
}
|
|
442
|
+
const minLength = Math.min(...branchLengths.values());
|
|
443
|
+
const maxLength = Math.max(...branchLengths.values());
|
|
444
|
+
if (minLength >= maxLength)
|
|
445
|
+
continue; // All branches same length
|
|
446
|
+
const splitCenterY = n.bounds.y + n.bounds.height / 2;
|
|
447
|
+
// Find early-return branches (shortest) that are on the baseline
|
|
448
|
+
const earlyReturnOnBaseline = [];
|
|
449
|
+
let longestOffBaseline;
|
|
450
|
+
for (const startId of trueSuccs) {
|
|
451
|
+
const branchNode = nodeMap.get(startId);
|
|
452
|
+
if (!branchNode)
|
|
453
|
+
continue;
|
|
454
|
+
const branchCenterY = branchNode.bounds.y + branchNode.bounds.height / 2;
|
|
455
|
+
const onBaseline = Math.abs(branchCenterY - splitCenterY) < 1;
|
|
456
|
+
if (branchLengths.get(startId) === minLength && onBaseline) {
|
|
457
|
+
earlyReturnOnBaseline.push(startId);
|
|
458
|
+
}
|
|
459
|
+
if (branchLengths.get(startId) === maxLength && !onBaseline) {
|
|
460
|
+
longestOffBaseline = startId;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (earlyReturnOnBaseline.length === 0 || !longestOffBaseline)
|
|
464
|
+
continue;
|
|
465
|
+
// Swap the first early-return branch with the longest off-baseline branch
|
|
466
|
+
const earlyId = earlyReturnOnBaseline[0];
|
|
467
|
+
if (!earlyId)
|
|
468
|
+
continue;
|
|
469
|
+
const swapId = longestOffBaseline;
|
|
470
|
+
swapBranchPositions(earlyId, swapId, dag, nodeMap, joinId);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Find the baseline path — the "spine" of the process that all paths share.
|
|
475
|
+
* At split gateways, jumps directly to the corresponding join gateway.
|
|
476
|
+
* Returns the ordered list of node IDs on the baseline.
|
|
477
|
+
*/
|
|
478
|
+
export function findBaselinePath(layoutNodes, dag, backEdges = []) {
|
|
479
|
+
const backEdgeOriginals = buildBackEdgeOriginals(backEdges);
|
|
480
|
+
const nodeMap = new Map();
|
|
481
|
+
for (const n of layoutNodes) {
|
|
482
|
+
nodeMap.set(n.id, n);
|
|
483
|
+
}
|
|
484
|
+
// Find start event (first node with no predecessors)
|
|
485
|
+
let startId;
|
|
486
|
+
for (const n of layoutNodes) {
|
|
487
|
+
if (n.type === "startEvent") {
|
|
488
|
+
startId = n.id;
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (!startId) {
|
|
493
|
+
// Fallback: first node with no predecessors
|
|
494
|
+
for (const n of layoutNodes) {
|
|
495
|
+
const preds = dag.predecessors.get(n.id) ?? [];
|
|
496
|
+
if (preds.length === 0) {
|
|
497
|
+
startId = n.id;
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (!startId)
|
|
503
|
+
return [];
|
|
504
|
+
const path = [];
|
|
505
|
+
const visited = new Set();
|
|
506
|
+
let currentId = startId;
|
|
507
|
+
while (currentId && !visited.has(currentId)) {
|
|
508
|
+
visited.add(currentId);
|
|
509
|
+
path.push(currentId);
|
|
510
|
+
const succs = dag.successors.get(currentId) ?? [];
|
|
511
|
+
if (succs.length === 0)
|
|
512
|
+
break;
|
|
513
|
+
if (succs.length === 1) {
|
|
514
|
+
currentId = succs[0];
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
const currentNode = nodeMap.get(currentId);
|
|
518
|
+
if (currentNode && GATEWAY_TYPE_SET.has(currentNode.type)) {
|
|
519
|
+
// Check true successors (excluding reversed back-edge stubs)
|
|
520
|
+
const trueSuccs = getTrueSuccessors(currentId, dag, backEdgeOriginals);
|
|
521
|
+
if (trueSuccs.length === 1) {
|
|
522
|
+
// Join gateway (e.g. loop merge): only 1 real forward successor → treat as passthrough
|
|
523
|
+
currentId = trueSuccs[0];
|
|
524
|
+
}
|
|
525
|
+
else if (trueSuccs.length >= 2) {
|
|
526
|
+
// True split gateway: find join and jump to it
|
|
527
|
+
const joinId = findJoinGateway(currentId, dag, nodeMap);
|
|
528
|
+
if (joinId) {
|
|
529
|
+
// If the join is a direct successor (bypass edge exists), follow the longer
|
|
530
|
+
// task-bearing branch instead of jumping over it. This keeps task nodes on
|
|
531
|
+
// the baseline so they stay flat (same y as the gateways).
|
|
532
|
+
if (trueSuccs.includes(joinId)) {
|
|
533
|
+
const nonBypass = trueSuccs.filter((s) => s !== joinId);
|
|
534
|
+
currentId =
|
|
535
|
+
nonBypass.length === 1
|
|
536
|
+
? nonBypass[0]
|
|
537
|
+
: (findContinuationSuccessor(nonBypass, dag, nodeMap, visited) ?? joinId);
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
currentId = joinId;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
currentId = findContinuationSuccessor(trueSuccs, dag, nodeMap, visited);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
// Non-gateway with multiple successors (back-edge reversal artifact).
|
|
553
|
+
// Follow the unique-predecessor successor — the main flow continuation.
|
|
554
|
+
currentId =
|
|
555
|
+
succs.find((s) => !visited.has(s) && (dag.predecessors.get(s) ?? []).length === 1) ??
|
|
556
|
+
succs.find((s) => !visited.has(s));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return path;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Among split-gateway successors, find the one that continues the main flow.
|
|
564
|
+
* Prefers gateway-type successors (merge points), then falls back to the
|
|
565
|
+
* successor with the most forward-reachable nodes (deepest path).
|
|
566
|
+
* Loop-back stubs are dead-ends in the DAG and will have fewest reachable nodes.
|
|
567
|
+
*/
|
|
568
|
+
function findContinuationSuccessor(succs, dag, nodeMap, visited) {
|
|
569
|
+
// Prefer gateway-type successors (likely the merge/join point)
|
|
570
|
+
for (const s of succs) {
|
|
571
|
+
const node = nodeMap.get(s);
|
|
572
|
+
if (node && GATEWAY_TYPE_SET.has(node.type)) {
|
|
573
|
+
return s;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// Fall back: pick the successor with the most forward-reachable nodes.
|
|
577
|
+
// Loop-back stubs (reversed back edges) are DAG dead-ends with 0–1 reachable nodes.
|
|
578
|
+
let bestId;
|
|
579
|
+
let bestDepth = -1;
|
|
580
|
+
for (const s of succs) {
|
|
581
|
+
if (visited.has(s))
|
|
582
|
+
continue;
|
|
583
|
+
const depth = countForwardReachable(s, dag);
|
|
584
|
+
if (depth > bestDepth) {
|
|
585
|
+
bestDepth = depth;
|
|
586
|
+
bestId = s;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return bestId;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Align all nodes on the baseline path to the same center-Y.
|
|
593
|
+
* Uses the first node's (start event) center-Y as the baseline.
|
|
594
|
+
*/
|
|
595
|
+
export function alignBaselinePath(layoutNodes, dag, backEdges = []) {
|
|
596
|
+
const baselinePath = findBaselinePath(layoutNodes, dag, backEdges);
|
|
597
|
+
if (baselinePath.length === 0)
|
|
598
|
+
return;
|
|
599
|
+
const nodeMap = new Map();
|
|
600
|
+
for (const n of layoutNodes) {
|
|
601
|
+
nodeMap.set(n.id, n);
|
|
602
|
+
}
|
|
603
|
+
// Use the start event's center-Y as the baseline
|
|
604
|
+
const firstId = baselinePath[0];
|
|
605
|
+
if (!firstId)
|
|
606
|
+
return;
|
|
607
|
+
const firstNode = nodeMap.get(firstId);
|
|
608
|
+
if (!firstNode)
|
|
609
|
+
return;
|
|
610
|
+
const baselineY = firstNode.bounds.y + firstNode.bounds.height / 2;
|
|
611
|
+
for (const id of baselinePath) {
|
|
612
|
+
const node = nodeMap.get(id);
|
|
613
|
+
if (!node)
|
|
614
|
+
continue;
|
|
615
|
+
const currentCenterY = node.bounds.y + node.bounds.height / 2;
|
|
616
|
+
const dy = baselineY - currentCenterY;
|
|
617
|
+
if (Math.abs(dy) > 0.5) {
|
|
618
|
+
node.bounds.y += dy;
|
|
619
|
+
if (node.labelBounds)
|
|
620
|
+
node.labelBounds.y += dy;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Distribute branches of split gateways symmetrically around the gateway center Y.
|
|
626
|
+
* Pass 1: multi-branch gateways (2+) — symmetric distribution.
|
|
627
|
+
* Pass 2: single-branch gateways — placed one full grid row away, with peer-aware gap enforcement.
|
|
628
|
+
*/
|
|
629
|
+
export function distributeSplitBranches(layoutNodes, dag, backEdges = []) {
|
|
630
|
+
const backEdgeOriginals = buildBackEdgeOriginals(backEdges);
|
|
631
|
+
const nodeMap = new Map();
|
|
632
|
+
for (const n of layoutNodes) {
|
|
633
|
+
nodeMap.set(n.id, n);
|
|
634
|
+
}
|
|
635
|
+
const baselinePath = findBaselinePath(layoutNodes, dag, backEdges);
|
|
636
|
+
const baselineSet = new Set(baselinePath);
|
|
637
|
+
// Collect true split gateways with their non-baseline branch info
|
|
638
|
+
const splitGateways = [];
|
|
639
|
+
for (const n of layoutNodes) {
|
|
640
|
+
if (!GATEWAY_TYPE_SET.has(n.type))
|
|
641
|
+
continue;
|
|
642
|
+
// Only process true split gateways (not join gateways with reversed back edges)
|
|
643
|
+
const trueSuccs = getTrueSuccessors(n.id, dag, backEdgeOriginals);
|
|
644
|
+
if (trueSuccs.length < 2)
|
|
645
|
+
continue;
|
|
646
|
+
const joinId = findJoinGateway(n.id, dag, nodeMap);
|
|
647
|
+
const branchStarts = [];
|
|
648
|
+
for (const s of trueSuccs) {
|
|
649
|
+
if (!baselineSet.has(s))
|
|
650
|
+
branchStarts.push(s);
|
|
651
|
+
}
|
|
652
|
+
if (branchStarts.length === 0)
|
|
653
|
+
continue;
|
|
654
|
+
splitGateways.push({ node: n, succs: trueSuccs, branchStarts, joinId });
|
|
655
|
+
}
|
|
656
|
+
// Pass 1: multi-branch gateways (distribute symmetrically)
|
|
657
|
+
for (const { node: n, branchStarts, joinId } of splitGateways) {
|
|
658
|
+
if (branchStarts.length < 2)
|
|
659
|
+
continue;
|
|
660
|
+
const gatewayCY = n.bounds.y + n.bounds.height / 2;
|
|
661
|
+
branchStarts.sort((a, b) => {
|
|
662
|
+
const na = nodeMap.get(a);
|
|
663
|
+
const nb = nodeMap.get(b);
|
|
664
|
+
if (!na || !nb)
|
|
665
|
+
return 0;
|
|
666
|
+
return na.bounds.y + na.bounds.height / 2 - (nb.bounds.y + nb.bounds.height / 2);
|
|
667
|
+
});
|
|
668
|
+
const count = branchStarts.length;
|
|
669
|
+
const spacing = GRID_CELL_HEIGHT;
|
|
670
|
+
const startOffset = -((count - 1) / 2) * spacing;
|
|
671
|
+
for (let i = 0; i < count; i++) {
|
|
672
|
+
const branchId = branchStarts[i];
|
|
673
|
+
if (!branchId)
|
|
674
|
+
continue;
|
|
675
|
+
const branchNode = nodeMap.get(branchId);
|
|
676
|
+
if (!branchNode)
|
|
677
|
+
continue;
|
|
678
|
+
const targetCY = gatewayCY + startOffset + i * spacing;
|
|
679
|
+
const currentCY = branchNode.bounds.y + branchNode.bounds.height / 2;
|
|
680
|
+
const dy = targetCY - currentCY;
|
|
681
|
+
if (Math.abs(dy) > 0.5) {
|
|
682
|
+
const chain = collectBranchChain(branchId, dag, joinId);
|
|
683
|
+
for (const bid of chain) {
|
|
684
|
+
const bnode = nodeMap.get(bid);
|
|
685
|
+
if (!bnode)
|
|
686
|
+
continue;
|
|
687
|
+
bnode.bounds.y += dy;
|
|
688
|
+
if (bnode.labelBounds)
|
|
689
|
+
bnode.labelBounds.y += dy;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// Pass 2: single-branch gateways (peer-aware gap enforcement)
|
|
695
|
+
for (const { node: n, branchStarts, joinId } of splitGateways) {
|
|
696
|
+
if (branchStarts.length !== 1)
|
|
697
|
+
continue;
|
|
698
|
+
const gatewayCY = n.bounds.y + n.bounds.height / 2;
|
|
699
|
+
const branchId = branchStarts[0];
|
|
700
|
+
if (!branchId)
|
|
701
|
+
continue;
|
|
702
|
+
const branchNode = nodeMap.get(branchId);
|
|
703
|
+
if (!branchNode)
|
|
704
|
+
continue;
|
|
705
|
+
const currentCY = branchNode.bounds.y + branchNode.bounds.height / 2;
|
|
706
|
+
const direction = currentCY < gatewayCY ? -1 : 1;
|
|
707
|
+
let targetCY = gatewayCY + direction * GRID_CELL_HEIGHT;
|
|
708
|
+
const chain = collectBranchChain(branchId, dag, joinId);
|
|
709
|
+
const chainSet = new Set(chain);
|
|
710
|
+
const minGap = GRID_CELL_HEIGHT / 2;
|
|
711
|
+
const initialDy = targetCY - currentCY;
|
|
712
|
+
let extraDy = 0;
|
|
713
|
+
for (const chainNodeId of chain) {
|
|
714
|
+
const chainNode = nodeMap.get(chainNodeId);
|
|
715
|
+
if (!chainNode)
|
|
716
|
+
continue;
|
|
717
|
+
const chainNodeCY = chainNode.bounds.y + chainNode.bounds.height / 2;
|
|
718
|
+
const chainNodeNewCY = chainNodeCY + initialDy;
|
|
719
|
+
for (const peer of layoutNodes) {
|
|
720
|
+
if (peer.layer !== chainNode.layer || chainSet.has(peer.id))
|
|
721
|
+
continue;
|
|
722
|
+
const peerCY = peer.bounds.y + peer.bounds.height / 2;
|
|
723
|
+
const minDist = (chainNode.bounds.height + peer.bounds.height) / 2 + minGap;
|
|
724
|
+
if (Math.abs(chainNodeNewCY + extraDy - peerCY) < minDist) {
|
|
725
|
+
if (direction === -1) {
|
|
726
|
+
const needed = peerCY - minDist - chainNodeNewCY;
|
|
727
|
+
if (needed < extraDy)
|
|
728
|
+
extraDy = needed;
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
const needed = peerCY + minDist - chainNodeNewCY;
|
|
732
|
+
if (needed > extraDy)
|
|
733
|
+
extraDy = needed;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
targetCY += extraDy;
|
|
739
|
+
const dy = targetCY - currentCY;
|
|
740
|
+
if (Math.abs(dy) > 0.5) {
|
|
741
|
+
for (const bid of chain) {
|
|
742
|
+
const bnode = nodeMap.get(bid);
|
|
743
|
+
if (!bnode)
|
|
744
|
+
continue;
|
|
745
|
+
bnode.bounds.y += dy;
|
|
746
|
+
if (bnode.labelBounds)
|
|
747
|
+
bnode.labelBounds.y += dy;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/** Collect all nodes in a branch subtree, stopping at the join gateway. */
|
|
753
|
+
function collectBranchChain(startId, dag, joinId) {
|
|
754
|
+
const ids = [];
|
|
755
|
+
const queue = [startId];
|
|
756
|
+
const seen = new Set();
|
|
757
|
+
while (queue.length > 0) {
|
|
758
|
+
const id = queue.shift();
|
|
759
|
+
if (!id || seen.has(id))
|
|
760
|
+
continue;
|
|
761
|
+
if (joinId && id === joinId)
|
|
762
|
+
continue;
|
|
763
|
+
seen.add(id);
|
|
764
|
+
ids.push(id);
|
|
765
|
+
for (const s of dag.successors.get(id) ?? []) {
|
|
766
|
+
if (!seen.has(s))
|
|
767
|
+
queue.push(s);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return ids;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Resolve overlaps within each layer by pushing nodes apart.
|
|
774
|
+
* Sorts nodes by Y within each layer and ensures minimum gap.
|
|
775
|
+
* Also normalizes coordinates so no node has negative Y.
|
|
776
|
+
*/
|
|
777
|
+
export function resolveLayerOverlaps(layoutNodes) {
|
|
778
|
+
const byLayer = new Map();
|
|
779
|
+
for (const n of layoutNodes) {
|
|
780
|
+
const arr = byLayer.get(n.layer) ?? [];
|
|
781
|
+
arr.push(n);
|
|
782
|
+
byLayer.set(n.layer, arr);
|
|
783
|
+
}
|
|
784
|
+
for (const [, nodes] of byLayer) {
|
|
785
|
+
if (nodes.length < 2)
|
|
786
|
+
continue;
|
|
787
|
+
nodes.sort((a, b) => a.bounds.y - b.bounds.y);
|
|
788
|
+
for (let i = 1; i < nodes.length; i++) {
|
|
789
|
+
const prev = nodes[i - 1];
|
|
790
|
+
const curr = nodes[i];
|
|
791
|
+
if (!prev || !curr)
|
|
792
|
+
continue;
|
|
793
|
+
// Account for the previous element's below-element label (e.g. gateway labels)
|
|
794
|
+
// so that labels don't overlap the next element in the same layer.
|
|
795
|
+
const prevLabelBottom = prev.labelBounds && prev.labelBounds.y > prev.bounds.y
|
|
796
|
+
? prev.labelBounds.y + prev.labelBounds.height
|
|
797
|
+
: 0;
|
|
798
|
+
const prevBottom = Math.max(prev.bounds.y + prev.bounds.height, prevLabelBottom);
|
|
799
|
+
if (curr.bounds.y < prevBottom + 1) {
|
|
800
|
+
const shift = prevBottom + 1 - curr.bounds.y;
|
|
801
|
+
curr.bounds.y += shift;
|
|
802
|
+
if (curr.labelBounds)
|
|
803
|
+
curr.labelBounds.y += shift;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
// Normalize: ensure no node has negative y
|
|
808
|
+
let minY = 0;
|
|
809
|
+
for (const n of layoutNodes) {
|
|
810
|
+
const y = n.labelBounds ? Math.min(n.bounds.y, n.labelBounds.y) : n.bounds.y;
|
|
811
|
+
if (y < minY)
|
|
812
|
+
minY = y;
|
|
813
|
+
}
|
|
814
|
+
if (minY < 0) {
|
|
815
|
+
const shift = -minY;
|
|
816
|
+
for (const n of layoutNodes) {
|
|
817
|
+
n.bounds.y += shift;
|
|
818
|
+
if (n.labelBounds)
|
|
819
|
+
n.labelBounds.y += shift;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
/** Swap the y-positions of all nodes along two branches. */
|
|
824
|
+
function swapBranchPositions(branchA, branchB, dag, nodeMap, joinId) {
|
|
825
|
+
const collectBranch = (startId) => {
|
|
826
|
+
const ids = [];
|
|
827
|
+
let currentId = startId;
|
|
828
|
+
const seen = new Set();
|
|
829
|
+
while (currentId && !seen.has(currentId)) {
|
|
830
|
+
if (currentId === joinId)
|
|
831
|
+
break;
|
|
832
|
+
seen.add(currentId);
|
|
833
|
+
ids.push(currentId);
|
|
834
|
+
const succs = dag.successors.get(currentId) ?? [];
|
|
835
|
+
currentId = succs.length === 1 ? succs[0] : undefined;
|
|
836
|
+
}
|
|
837
|
+
return ids;
|
|
838
|
+
};
|
|
839
|
+
const nodesA = collectBranch(branchA);
|
|
840
|
+
const nodesB = collectBranch(branchB);
|
|
841
|
+
// Swap y-positions pairwise; if one branch is shorter, remaining nodes keep position
|
|
842
|
+
const swapCount = Math.min(nodesA.length, nodesB.length);
|
|
843
|
+
for (let i = 0; i < swapCount; i++) {
|
|
844
|
+
const idA = nodesA[i];
|
|
845
|
+
const idB = nodesB[i];
|
|
846
|
+
if (!idA || !idB)
|
|
847
|
+
continue;
|
|
848
|
+
const a = nodeMap.get(idA);
|
|
849
|
+
const b = nodeMap.get(idB);
|
|
850
|
+
if (!a || !b)
|
|
851
|
+
continue;
|
|
852
|
+
const tmpY = a.bounds.y;
|
|
853
|
+
a.bounds.y = b.bounds.y;
|
|
854
|
+
b.bounds.y = tmpY;
|
|
855
|
+
if (a.labelBounds && b.labelBounds) {
|
|
856
|
+
const tmpLabelY = a.labelBounds.y;
|
|
857
|
+
a.labelBounds.y = b.labelBounds.y;
|
|
858
|
+
b.labelBounds.y = tmpLabelY;
|
|
859
|
+
}
|
|
860
|
+
else if (a.labelBounds) {
|
|
861
|
+
a.labelBounds.y += b.bounds.y - a.bounds.y;
|
|
862
|
+
}
|
|
863
|
+
else if (b.labelBounds) {
|
|
864
|
+
b.labelBounds.y += a.bounds.y - b.bounds.y;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
//# sourceMappingURL=coordinates.js.map
|