@bpmnkit/core 0.0.26 → 0.1.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.
Files changed (48) hide show
  1. package/dist/bpmn/auto-layout.js +71 -139
  2. package/dist/bpmn/bpmn-builder.d.ts +30 -0
  3. package/dist/bpmn/bpmn-builder.js +343 -31
  4. package/dist/bpmn/di-check.d.ts +14 -0
  5. package/dist/bpmn/di-check.js +51 -0
  6. package/dist/bpmn/svg.js +14 -1
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.js +1 -0
  9. package/dist/layout/annotations.d.ts +27 -0
  10. package/dist/layout/annotations.js +251 -0
  11. package/dist/layout/grid/edge-labels.d.ts +8 -0
  12. package/dist/layout/grid/edge-labels.js +126 -0
  13. package/dist/layout/grid/flow-graph.d.ts +25 -0
  14. package/dist/layout/grid/flow-graph.js +99 -0
  15. package/dist/layout/grid/grid-engine.d.ts +4 -0
  16. package/dist/layout/grid/grid-engine.js +214 -0
  17. package/dist/layout/grid/grid-router.d.ts +36 -0
  18. package/dist/layout/grid/grid-router.js +190 -0
  19. package/dist/layout/grid/grid.d.ts +43 -0
  20. package/dist/layout/grid/grid.js +174 -0
  21. package/dist/layout/grid/walker.d.ts +11 -0
  22. package/dist/layout/grid/walker.js +126 -0
  23. package/dist/layout/index.d.ts +2 -5
  24. package/dist/layout/index.js +1 -4
  25. package/dist/layout/layout-engine.d.ts +5 -15
  26. package/dist/layout/layout-engine.js +8 -478
  27. package/dist/layout/types.d.ts +2 -2
  28. package/dist/layout/types.js +6 -2
  29. package/dist/xml/xml-parser.js +5 -0
  30. package/package.json +1 -1
  31. package/dist/layout/astar.d.ts +0 -15
  32. package/dist/layout/astar.js +0 -191
  33. package/dist/layout/block-builder.d.ts +0 -37
  34. package/dist/layout/block-builder.js +0 -154
  35. package/dist/layout/block-layout.d.ts +0 -9
  36. package/dist/layout/block-layout.js +0 -163
  37. package/dist/layout/coordinates.d.ts +0 -85
  38. package/dist/layout/coordinates.js +0 -1392
  39. package/dist/layout/crossing.d.ts +0 -8
  40. package/dist/layout/crossing.js +0 -60
  41. package/dist/layout/graph.d.ts +0 -28
  42. package/dist/layout/graph.js +0 -126
  43. package/dist/layout/layers.d.ts +0 -13
  44. package/dist/layout/layers.js +0 -49
  45. package/dist/layout/routing.d.ts +0 -33
  46. package/dist/layout/routing.js +0 -622
  47. package/dist/layout/subprocess.d.ts +0 -14
  48. package/dist/layout/subprocess.js +0 -77
@@ -1,1392 +0,0 @@
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
- * Crosses split/join gateway pairs to align the full branch spine.
250
- */
251
- export function alignBranchBaselines(layoutNodes, dag) {
252
- const nodeMap = new Map();
253
- for (const n of layoutNodes) {
254
- nodeMap.set(n.id, n);
255
- }
256
- const visited = new Set();
257
- for (const n of layoutNodes) {
258
- if (visited.has(n.id))
259
- continue;
260
- if (GATEWAY_TYPE_SET.has(n.type))
261
- continue;
262
- // Walk backward to find the chain root
263
- let rootId = n.id;
264
- for (;;) {
265
- const preds = dag.predecessors.get(rootId) ?? [];
266
- if (preds.length !== 1)
267
- break;
268
- const pred = preds[0];
269
- if (!pred)
270
- break;
271
- const predNode = nodeMap.get(pred);
272
- if (!predNode || GATEWAY_TYPE_SET.has(predNode.type))
273
- break;
274
- const predSuccs = dag.successors.get(pred) ?? [];
275
- if (predSuccs.length !== 1 && GATEWAY_TYPE_SET.has(predNode.type))
276
- break;
277
- rootId = pred;
278
- }
279
- // Walk forward from root, aligning to root's center-y
280
- const rootNode = nodeMap.get(rootId);
281
- if (!rootNode)
282
- continue;
283
- const baselineCenterY = rootNode.bounds.y + rootNode.bounds.height / 2;
284
- let currentId = rootId;
285
- while (currentId) {
286
- if (visited.has(currentId))
287
- break;
288
- visited.add(currentId);
289
- const current = nodeMap.get(currentId);
290
- if (!current)
291
- break;
292
- if (GATEWAY_TYPE_SET.has(current.type)) {
293
- // If this is a split gateway, align it, find its join, align that, continue after
294
- const trueSuccs = dag.successors.get(currentId) ?? [];
295
- if (trueSuccs.length >= 2) {
296
- const dy = baselineCenterY - (current.bounds.y + current.bounds.height / 2);
297
- if (Math.abs(dy) > 0.5) {
298
- current.bounds.y += dy;
299
- if (current.labelBounds)
300
- current.labelBounds.y += dy;
301
- }
302
- const joinId = findJoinGateway(currentId, dag, nodeMap);
303
- if (joinId && !visited.has(joinId)) {
304
- const joinNode = nodeMap.get(joinId);
305
- if (joinNode) {
306
- visited.add(joinId);
307
- const jdy = baselineCenterY - (joinNode.bounds.y + joinNode.bounds.height / 2);
308
- if (Math.abs(jdy) > 0.5) {
309
- joinNode.bounds.y += jdy;
310
- if (joinNode.labelBounds)
311
- joinNode.labelBounds.y += jdy;
312
- }
313
- // Continue after the join
314
- const joinSuccs = dag.successors.get(joinId) ?? [];
315
- currentId = joinSuccs.length === 1 ? joinSuccs[0] : undefined;
316
- continue;
317
- }
318
- }
319
- }
320
- break;
321
- }
322
- const dy = baselineCenterY - (current.bounds.y + current.bounds.height / 2);
323
- if (Math.abs(dy) > 0.5) {
324
- current.bounds.y += dy;
325
- if (current.labelBounds)
326
- current.labelBounds.y += dy;
327
- }
328
- const succs = dag.successors.get(currentId) ?? [];
329
- let nextId;
330
- if (succs.length === 1) {
331
- nextId = succs[0];
332
- }
333
- else if (succs.length > 1 && !GATEWAY_TYPE_SET.has(current.type)) {
334
- nextId = succs.find((s) => (dag.predecessors.get(s) ?? []).length === 1);
335
- }
336
- else {
337
- break;
338
- }
339
- if (!nextId)
340
- break;
341
- const nextNode = nodeMap.get(nextId);
342
- if (!nextNode)
343
- break;
344
- const nextPreds = dag.predecessors.get(nextId) ?? [];
345
- if (nextPreds.length !== 1 && !GATEWAY_TYPE_SET.has(nextNode.type))
346
- break;
347
- currentId = nextId;
348
- }
349
- }
350
- }
351
- /**
352
- * Align split/join gateway pairs to the same y-coordinate.
353
- * A split gateway fans out to multiple successors; the corresponding join gateway
354
- * is the nearest downstream gateway where all branches reconverge.
355
- */
356
- export function alignSplitJoinPairs(layoutNodes, dag, backEdges = []) {
357
- const backEdgeOriginals = buildBackEdgeOriginals(backEdges);
358
- const nodeMap = new Map();
359
- for (const n of layoutNodes) {
360
- nodeMap.set(n.id, n);
361
- }
362
- for (const n of layoutNodes) {
363
- if (!GATEWAY_TYPE_SET.has(n.type))
364
- continue;
365
- // Use true successors (exclude reversed back-edge successors) to detect real splits
366
- const trueSuccs = getTrueSuccessors(n.id, dag, backEdgeOriginals);
367
- if (trueSuccs.length < 2)
368
- continue;
369
- // This is a split gateway — find its merge partner
370
- const joinId = findJoinGateway(n.id, dag, nodeMap);
371
- if (!joinId)
372
- continue;
373
- const joinNode = nodeMap.get(joinId);
374
- if (!joinNode)
375
- continue;
376
- // Force join gateway to same center-y as split gateway
377
- const splitCenterY = n.bounds.y + n.bounds.height / 2;
378
- const joinCenterY = joinNode.bounds.y + joinNode.bounds.height / 2;
379
- const dy = splitCenterY - joinCenterY;
380
- if (Math.abs(dy) > 0.5) {
381
- joinNode.bounds.y += dy;
382
- if (joinNode.labelBounds)
383
- joinNode.labelBounds.y += dy;
384
- }
385
- }
386
- }
387
- /**
388
- * Find the merge gateway for a given split gateway.
389
- * Walks forward from each successor until all paths converge at a common gateway.
390
- */
391
- function findJoinGateway(splitId, dag, nodeMap) {
392
- const succs = dag.successors.get(splitId) ?? [];
393
- if (succs.length < 2)
394
- return undefined;
395
- // For each branch, walk forward to find the first downstream gateway
396
- const branchEndpoints = new Map();
397
- for (const startId of succs) {
398
- const reachableGateways = new Set();
399
- const queue = [startId];
400
- const seen = new Set();
401
- while (queue.length > 0) {
402
- const id = queue.shift();
403
- if (!id || seen.has(id))
404
- continue;
405
- seen.add(id);
406
- const node = nodeMap.get(id);
407
- if (!node)
408
- continue;
409
- if (GATEWAY_TYPE_SET.has(node.type) && id !== splitId) {
410
- reachableGateways.add(id);
411
- continue; // Don't traverse past gateways
412
- }
413
- for (const next of dag.successors.get(id) ?? []) {
414
- if (next !== splitId)
415
- queue.push(next);
416
- }
417
- }
418
- branchEndpoints.set(startId, reachableGateways);
419
- }
420
- // Find the gateway reachable from ALL branches
421
- const allBranches = [...branchEndpoints.values()];
422
- if (allBranches.length === 0)
423
- return undefined;
424
- const firstSet = allBranches[0];
425
- if (!firstSet)
426
- return undefined;
427
- for (const candidate of firstSet) {
428
- if (allBranches.every((s) => s.has(candidate))) {
429
- return candidate;
430
- }
431
- }
432
- return undefined;
433
- }
434
- /**
435
- * Ensure early-return branches (shorter paths from split to join) are never on the baseline.
436
- * The baseline is the split gateway's center-y. If the shortest branch sits on the baseline,
437
- * swap it with a longer branch.
438
- */
439
- export function ensureEarlyReturnOffBaseline(layoutNodes, dag, backEdges = []) {
440
- const backEdgeOriginals = buildBackEdgeOriginals(backEdges);
441
- const nodeMap = new Map();
442
- for (const n of layoutNodes) {
443
- nodeMap.set(n.id, n);
444
- }
445
- for (const n of layoutNodes) {
446
- if (!GATEWAY_TYPE_SET.has(n.type))
447
- continue;
448
- // Only process true split gateways (not join gateways with reversed back edges)
449
- const trueSuccs = getTrueSuccessors(n.id, dag, backEdgeOriginals);
450
- if (trueSuccs.length < 2)
451
- continue;
452
- const joinId = findJoinGateway(n.id, dag, nodeMap);
453
- // Measure branch length (number of nodes from split successor to join)
454
- const branchLengths = new Map();
455
- for (const startId of trueSuccs) {
456
- let length = 0;
457
- let currentId = startId;
458
- const seen = new Set();
459
- while (currentId && !seen.has(currentId)) {
460
- seen.add(currentId);
461
- length++;
462
- if (currentId === joinId)
463
- break;
464
- const nextSuccs = dag.successors.get(currentId) ?? [];
465
- currentId = nextSuccs[0];
466
- }
467
- branchLengths.set(startId, length);
468
- }
469
- const minLength = Math.min(...branchLengths.values());
470
- const maxLength = Math.max(...branchLengths.values());
471
- if (minLength >= maxLength)
472
- continue; // All branches same length
473
- const splitCenterY = n.bounds.y + n.bounds.height / 2;
474
- // Find early-return branches (shortest) that are on the baseline
475
- const earlyReturnOnBaseline = [];
476
- let longestOffBaseline;
477
- for (const startId of trueSuccs) {
478
- const branchNode = nodeMap.get(startId);
479
- if (!branchNode)
480
- continue;
481
- const branchCenterY = branchNode.bounds.y + branchNode.bounds.height / 2;
482
- const onBaseline = Math.abs(branchCenterY - splitCenterY) < 1;
483
- if (branchLengths.get(startId) === minLength && onBaseline) {
484
- earlyReturnOnBaseline.push(startId);
485
- }
486
- if (branchLengths.get(startId) === maxLength && !onBaseline) {
487
- longestOffBaseline = startId;
488
- }
489
- }
490
- if (earlyReturnOnBaseline.length === 0 || !longestOffBaseline)
491
- continue;
492
- // Swap the first early-return branch with the longest off-baseline branch
493
- const earlyId = earlyReturnOnBaseline[0];
494
- if (!earlyId)
495
- continue;
496
- const swapId = longestOffBaseline;
497
- swapBranchPositions(earlyId, swapId, dag, nodeMap, joinId);
498
- }
499
- }
500
- /**
501
- * Find the baseline path — the "spine" of the process that all paths share.
502
- * At split gateways, jumps directly to the corresponding join gateway.
503
- * Returns the ordered list of node IDs on the baseline.
504
- */
505
- export function findBaselinePath(layoutNodes, dag, backEdges = []) {
506
- const backEdgeOriginals = buildBackEdgeOriginals(backEdges);
507
- const nodeMap = new Map();
508
- for (const n of layoutNodes) {
509
- nodeMap.set(n.id, n);
510
- }
511
- // Find start event (first node with no predecessors)
512
- let startId;
513
- for (const n of layoutNodes) {
514
- if (n.type === "startEvent") {
515
- startId = n.id;
516
- break;
517
- }
518
- }
519
- if (!startId) {
520
- // Fallback: first node with no predecessors
521
- for (const n of layoutNodes) {
522
- const preds = dag.predecessors.get(n.id) ?? [];
523
- if (preds.length === 0) {
524
- startId = n.id;
525
- break;
526
- }
527
- }
528
- }
529
- if (!startId)
530
- return [];
531
- const path = [];
532
- const visited = new Set();
533
- let currentId = startId;
534
- while (currentId && !visited.has(currentId)) {
535
- visited.add(currentId);
536
- path.push(currentId);
537
- const succs = dag.successors.get(currentId) ?? [];
538
- if (succs.length === 0)
539
- break;
540
- if (succs.length === 1) {
541
- currentId = succs[0];
542
- }
543
- else {
544
- const currentNode = nodeMap.get(currentId);
545
- if (currentNode && GATEWAY_TYPE_SET.has(currentNode.type)) {
546
- // Check true successors (excluding reversed back-edge stubs)
547
- const trueSuccs = getTrueSuccessors(currentId, dag, backEdgeOriginals);
548
- if (trueSuccs.length === 1) {
549
- // Join gateway (e.g. loop merge): only 1 real forward successor → treat as passthrough
550
- currentId = trueSuccs[0];
551
- }
552
- else if (trueSuccs.length >= 2) {
553
- // True split gateway: find join and jump to it
554
- const joinId = findJoinGateway(currentId, dag, nodeMap);
555
- if (joinId) {
556
- // Always jump directly to the join gateway. Task-bearing branches are
557
- // off-baseline and distributed by distributeSplitBranches, so the direct
558
- // bypass edge never routes through task nodes.
559
- currentId = joinId;
560
- }
561
- else {
562
- currentId = findContinuationSuccessor(trueSuccs, dag, nodeMap, visited);
563
- }
564
- }
565
- else {
566
- break;
567
- }
568
- }
569
- else {
570
- // Non-gateway with multiple successors (back-edge reversal artifact).
571
- // Follow the unique-predecessor successor — the main flow continuation.
572
- currentId =
573
- succs.find((s) => !visited.has(s) && (dag.predecessors.get(s) ?? []).length === 1) ??
574
- succs.find((s) => !visited.has(s));
575
- }
576
- }
577
- }
578
- return path;
579
- }
580
- /**
581
- * Among split-gateway successors, find the one that continues the main flow.
582
- * Prefers gateway-type successors (merge points), then falls back to the
583
- * successor with the most forward-reachable nodes (deepest path).
584
- * Loop-back stubs are dead-ends in the DAG and will have fewest reachable nodes.
585
- */
586
- function findContinuationSuccessor(succs, dag, nodeMap, visited) {
587
- // Prefer gateway-type successors (likely the merge/join point)
588
- for (const s of succs) {
589
- const node = nodeMap.get(s);
590
- if (node && GATEWAY_TYPE_SET.has(node.type)) {
591
- return s;
592
- }
593
- }
594
- // Fall back: pick the successor with the most forward-reachable nodes.
595
- // Loop-back stubs (reversed back edges) are DAG dead-ends with 0–1 reachable nodes.
596
- let bestId;
597
- let bestDepth = -1;
598
- for (const s of succs) {
599
- if (visited.has(s))
600
- continue;
601
- const depth = countForwardReachable(s, dag);
602
- if (depth > bestDepth) {
603
- bestDepth = depth;
604
- bestId = s;
605
- }
606
- }
607
- return bestId;
608
- }
609
- /**
610
- * Align all nodes on the baseline path to the same center-Y.
611
- * Uses the first node's (start event) center-Y as the baseline.
612
- */
613
- export function alignBaselinePath(layoutNodes, dag, backEdges = []) {
614
- const baselinePath = findBaselinePath(layoutNodes, dag, backEdges);
615
- if (baselinePath.length === 0)
616
- return;
617
- const nodeMap = new Map();
618
- for (const n of layoutNodes) {
619
- nodeMap.set(n.id, n);
620
- }
621
- // Use the start event's center-Y as the baseline
622
- const firstId = baselinePath[0];
623
- if (!firstId)
624
- return;
625
- const firstNode = nodeMap.get(firstId);
626
- if (!firstNode)
627
- return;
628
- const baselineY = firstNode.bounds.y + firstNode.bounds.height / 2;
629
- for (const id of baselinePath) {
630
- const node = nodeMap.get(id);
631
- if (!node)
632
- continue;
633
- const currentCenterY = node.bounds.y + node.bounds.height / 2;
634
- const dy = baselineY - currentCenterY;
635
- if (Math.abs(dy) > 0.5) {
636
- node.bounds.y += dy;
637
- if (node.labelBounds)
638
- node.labelBounds.y += dy;
639
- }
640
- }
641
- }
642
- /**
643
- * Distribute branches of split gateways symmetrically around the gateway center Y.
644
- * Pass 1: multi-branch gateways (2+) — symmetric distribution.
645
- * Pass 2: single-branch gateways — placed one full grid row away, with peer-aware gap enforcement.
646
- */
647
- export function distributeSplitBranches(layoutNodes, dag, backEdges = []) {
648
- const backEdgeOriginals = buildBackEdgeOriginals(backEdges);
649
- const nodeMap = new Map();
650
- for (const n of layoutNodes) {
651
- nodeMap.set(n.id, n);
652
- }
653
- const baselinePath = findBaselinePath(layoutNodes, dag, backEdges);
654
- const baselineSet = new Set(baselinePath);
655
- // Collect true split gateways with their non-baseline branch info
656
- const splitGateways = [];
657
- for (const n of layoutNodes) {
658
- if (!GATEWAY_TYPE_SET.has(n.type))
659
- continue;
660
- // Only process true split gateways (not join gateways with reversed back edges)
661
- const trueSuccs = getTrueSuccessors(n.id, dag, backEdgeOriginals);
662
- if (trueSuccs.length < 2)
663
- continue;
664
- const joinId = findJoinGateway(n.id, dag, nodeMap);
665
- const branchStarts = [];
666
- for (const s of trueSuccs) {
667
- if (!baselineSet.has(s))
668
- branchStarts.push(s);
669
- }
670
- if (branchStarts.length === 0)
671
- continue;
672
- splitGateways.push({ node: n, succs: trueSuccs, branchStarts, joinId });
673
- }
674
- // Sort deepest-first so child gateway branches are distributed before
675
- // parent gateways compute branch heights (avoids underestimating sub-branch area).
676
- splitGateways.sort((a, b) => b.node.layer - a.node.layer);
677
- // Process all gateways deepest-first (single loop, handles both multi- and single-branch)
678
- for (const { node: n, succs, branchStarts, joinId } of splitGateways) {
679
- const gatewayCY = n.bounds.y + n.bounds.height / 2;
680
- // Collect baseline obstacles (actual element bboxes) for 2D collision detection.
681
- // Unlike the old corridor approach, this only pushes branches past elements
682
- // that actually overlap in both X and Y — not past the entire baseline extent.
683
- const branchSet = new Set(branchStarts);
684
- const baselineSucc = succs.find((s) => !branchSet.has(s));
685
- const baselineObstacles = [];
686
- if (baselineSucc) {
687
- for (const bid of collectBranchChain(baselineSucc, dag, joinId)) {
688
- const bnode = nodeMap.get(bid);
689
- if (!bnode)
690
- continue;
691
- const bottom = bnode.labelBounds
692
- ? Math.max(bnode.bounds.y + bnode.bounds.height, bnode.labelBounds.y + bnode.labelBounds.height)
693
- : bnode.bounds.y + bnode.bounds.height;
694
- baselineObstacles.push({
695
- x: bnode.bounds.x,
696
- y: bnode.bounds.y,
697
- right: bnode.bounds.x + bnode.bounds.width,
698
- bottom,
699
- });
700
- }
701
- }
702
- const yMargin = GRID_CELL_HEIGHT / 2;
703
- const xMargin = GRID_CELL_WIDTH / 2;
704
- if (branchStarts.length >= 2) {
705
- // Multi-branch: distribute symmetrically, heaviest branch at center
706
- const sorted = [...branchStarts].sort((a, b) => collectBranchChain(b, dag, joinId).length - collectBranchChain(a, dag, joinId).length);
707
- const count = sorted.length;
708
- const positioned = new Array(count);
709
- const m = Math.floor((count - 1) / 2);
710
- // biome-ignore lint/style/noNonNullAssertion: sorted is non-empty (count >= 2)
711
- positioned[m] = sorted[0];
712
- let above = m - 1;
713
- let below = m + 1;
714
- for (let si = 1; si < count;) {
715
- // biome-ignore lint/style/noNonNullAssertion: si < count ensures element exists
716
- if (below < count && si < count)
717
- positioned[below++] = sorted[si++];
718
- // biome-ignore lint/style/noNonNullAssertion: si < count ensures element exists
719
- if (above >= 0 && si < count)
720
- positioned[above--] = sorted[si++];
721
- }
722
- // Compute anchor-relative extents and X range per branch
723
- // Uses backbone extent for stacking height (initial placement),
724
- // and full subtree extent for collision detection (safety validation).
725
- const branchInfo = [];
726
- for (let i = 0; i < count; i++) {
727
- const branchId = positioned[i];
728
- if (!branchId) {
729
- branchInfo.push(null);
730
- continue;
731
- }
732
- const chain = collectBranchChain(branchId, dag, joinId);
733
- const startNode = nodeMap.get(branchId);
734
- if (!startNode) {
735
- branchInfo.push(null);
736
- continue;
737
- }
738
- const startCY = startNode.bounds.y + startNode.bounds.height / 2;
739
- let minY = Number.POSITIVE_INFINITY;
740
- let maxY = Number.NEGATIVE_INFINITY;
741
- let minX = Number.POSITIVE_INFINITY;
742
- let maxX = Number.NEGATIVE_INFINITY;
743
- for (const bid of chain) {
744
- if (baselineSet.has(bid))
745
- continue;
746
- const bnode = nodeMap.get(bid);
747
- if (!bnode)
748
- continue;
749
- minY = Math.min(minY, bnode.bounds.y);
750
- maxY = Math.max(maxY, bnode.bounds.y + bnode.bounds.height);
751
- if (bnode.labelBounds)
752
- maxY = Math.max(maxY, bnode.labelBounds.y + bnode.labelBounds.height);
753
- minX = Math.min(minX, bnode.bounds.x);
754
- maxX = Math.max(maxX, bnode.bounds.x + bnode.bounds.width);
755
- }
756
- const height = minY < maxY ? Math.max(maxY - minY, GRID_CELL_HEIGHT) : GRID_CELL_HEIGHT;
757
- // Backbone extent for stacking
758
- const backbone = collectBranchBackbone(branchId, dag, joinId, nodeMap);
759
- let bbMinY = Number.POSITIVE_INFINITY;
760
- let bbMaxY = Number.NEGATIVE_INFINITY;
761
- for (const bid of backbone) {
762
- if (baselineSet.has(bid))
763
- continue;
764
- const bnode = nodeMap.get(bid);
765
- if (!bnode)
766
- continue;
767
- bbMinY = Math.min(bbMinY, bnode.bounds.y);
768
- bbMaxY = Math.max(bbMaxY, bnode.bounds.y + bnode.bounds.height);
769
- if (bnode.labelBounds)
770
- bbMaxY = Math.max(bbMaxY, bnode.labelBounds.y + bnode.labelBounds.height);
771
- }
772
- const backboneHeight = bbMinY < bbMaxY ? Math.max(bbMaxY - bbMinY, GRID_CELL_HEIGHT) : height;
773
- branchInfo.push({
774
- chain,
775
- extAbove: minY < Number.POSITIVE_INFINITY ? startCY - minY : height / 2,
776
- extBelow: maxY > Number.NEGATIVE_INFINITY ? maxY - startCY : height / 2,
777
- backboneHeight,
778
- height,
779
- minX: minX < Number.POSITIVE_INFINITY ? minX : startNode.bounds.x,
780
- maxX: maxX > Number.NEGATIVE_INFINITY ? maxX : startNode.bounds.x + startNode.bounds.width,
781
- });
782
- }
783
- const minSpacing = GRID_CELL_HEIGHT / 2;
784
- // Use backbone height for initial stacking (tighter spacing)
785
- let totalH = 0;
786
- for (let i = 0; i < count; i++) {
787
- totalH += branchInfo[i]?.backboneHeight ?? GRID_CELL_HEIGHT;
788
- if (i < count - 1)
789
- totalH += minSpacing;
790
- }
791
- let currentTop = gatewayCY - totalH / 2;
792
- // Frontier tracking: placed branch extents for branch-to-branch spacing
793
- let aboveFrontierY = gatewayCY;
794
- let belowFrontierY = gatewayCY;
795
- for (let i = 0; i < count; i++) {
796
- const info = branchInfo[i];
797
- if (!info) {
798
- currentTop += GRID_CELL_HEIGHT + minSpacing;
799
- continue;
800
- }
801
- const branchId = positioned[i];
802
- if (!branchId)
803
- continue;
804
- const branchNode = nodeMap.get(branchId);
805
- if (!branchNode)
806
- continue;
807
- // Use backbone height for initial placement, full extent for collision
808
- const bh = info.backboneHeight;
809
- let targetCY = currentTop + bh / 2;
810
- // Resolve 2D collisions with baseline obstacles (full extent)
811
- targetCY = resolveBranchObstacles(targetCY, info.extAbove, info.extBelow, info.minX, info.maxX, gatewayCY, baselineObstacles, yMargin, xMargin);
812
- // Enforce frontier: prevent branch-to-branch overlap (full extent)
813
- if (targetCY >= gatewayCY) {
814
- const newTop = targetCY - info.extAbove;
815
- if (newTop < belowFrontierY + minSpacing) {
816
- targetCY = belowFrontierY + minSpacing + info.extAbove;
817
- }
818
- belowFrontierY = targetCY + info.extBelow;
819
- }
820
- else {
821
- const newBottom = targetCY + info.extBelow;
822
- if (newBottom > aboveFrontierY - minSpacing) {
823
- targetCY = aboveFrontierY - minSpacing - info.extBelow;
824
- }
825
- aboveFrontierY = targetCY - info.extAbove;
826
- }
827
- const currentCY = branchNode.bounds.y + branchNode.bounds.height / 2;
828
- const dy = targetCY - currentCY;
829
- if (Math.abs(dy) > 0.5) {
830
- for (const bid of info.chain) {
831
- if (baselineSet.has(bid))
832
- continue;
833
- const bnode = nodeMap.get(bid);
834
- if (!bnode)
835
- continue;
836
- bnode.bounds.y += dy;
837
- if (bnode.labelBounds)
838
- bnode.labelBounds.y += dy;
839
- }
840
- }
841
- currentTop += bh + minSpacing;
842
- }
843
- }
844
- else {
845
- // Single-branch: collision-based offset with peer-aware gap enforcement
846
- const branchId = branchStarts[0];
847
- if (!branchId)
848
- continue;
849
- const branchNode = nodeMap.get(branchId);
850
- if (!branchNode)
851
- continue;
852
- const chain = collectBranchChain(branchId, dag, joinId);
853
- // Compute full subtree extents (for collision validation)
854
- let branchMinY = Number.POSITIVE_INFINITY;
855
- let branchMaxY = Number.NEGATIVE_INFINITY;
856
- let branchMinX = Number.POSITIVE_INFINITY;
857
- let branchMaxX = Number.NEGATIVE_INFINITY;
858
- for (const bid of chain) {
859
- if (baselineSet.has(bid))
860
- continue;
861
- const bnode = nodeMap.get(bid);
862
- if (!bnode)
863
- continue;
864
- branchMinY = Math.min(branchMinY, bnode.bounds.y);
865
- branchMaxY = Math.max(branchMaxY, bnode.bounds.y + bnode.bounds.height);
866
- if (bnode.labelBounds) {
867
- branchMaxY = Math.max(branchMaxY, bnode.labelBounds.y + bnode.labelBounds.height);
868
- }
869
- branchMinX = Math.min(branchMinX, bnode.bounds.x);
870
- branchMaxX = Math.max(branchMaxX, bnode.bounds.x + bnode.bounds.width);
871
- }
872
- const currentCY = branchNode.bounds.y + branchNode.bounds.height / 2;
873
- const extAbove = branchMinY < Number.POSITIVE_INFINITY ? currentCY - branchMinY : GRID_CELL_HEIGHT / 2;
874
- const extBelow = branchMaxY > Number.NEGATIVE_INFINITY ? branchMaxY - currentCY : GRID_CELL_HEIGHT / 2;
875
- if (branchMinX === Number.POSITIVE_INFINITY)
876
- branchMinX = branchNode.bounds.x;
877
- if (branchMaxX === Number.NEGATIVE_INFINITY)
878
- branchMaxX = branchNode.bounds.x + branchNode.bounds.width;
879
- // Compute backbone extent (spine only, skipping sub-gateway branches)
880
- // for a tighter initial offset that places the branch closer to baseline.
881
- const backbone = collectBranchBackbone(branchId, dag, joinId, nodeMap);
882
- let bbMinY = Number.POSITIVE_INFINITY;
883
- let bbMaxY = Number.NEGATIVE_INFINITY;
884
- for (const bid of backbone) {
885
- if (baselineSet.has(bid))
886
- continue;
887
- const bnode = nodeMap.get(bid);
888
- if (!bnode)
889
- continue;
890
- bbMinY = Math.min(bbMinY, bnode.bounds.y);
891
- bbMaxY = Math.max(bbMaxY, bnode.bounds.y + bnode.bounds.height);
892
- if (bnode.labelBounds) {
893
- bbMaxY = Math.max(bbMaxY, bnode.labelBounds.y + bnode.labelBounds.height);
894
- }
895
- }
896
- const bbExtAbove = bbMinY < Number.POSITIVE_INFINITY ? currentCY - bbMinY : extAbove;
897
- const bbExtBelow = bbMaxY > Number.NEGATIVE_INFINITY ? bbMaxY - currentCY : extBelow;
898
- // Use backbone extent for initial offset (closer to baseline)
899
- const basicMinOffset = Math.max(GRID_CELL_HEIGHT, bbExtAbove + bbExtBelow + n.bounds.height / 2 + 20);
900
- const direction = currentCY < gatewayCY ? -1 : 1;
901
- let targetCY = gatewayCY + direction * basicMinOffset;
902
- // Validate using per-element collision against baseline obstacles.
903
- // Only backbone elements checked — sub-branches at different X
904
- // positions don't constrain placement.
905
- for (const obs of baselineObstacles) {
906
- for (const bid of backbone) {
907
- if (baselineSet.has(bid))
908
- continue;
909
- const bnode = nodeMap.get(bid);
910
- if (!bnode)
911
- continue;
912
- const elemRight = bnode.bounds.x + bnode.bounds.width;
913
- if (elemRight + xMargin < obs.x || bnode.bounds.x - xMargin > obs.right)
914
- continue;
915
- if (direction === 1) {
916
- const elemNewY = bnode.bounds.y + (targetCY - currentCY);
917
- if (elemNewY < obs.bottom + yMargin) {
918
- const needed = currentCY + obs.bottom + yMargin - bnode.bounds.y;
919
- if (needed > targetCY)
920
- targetCY = needed;
921
- }
922
- }
923
- else {
924
- const elemNewBottom = bnode.bounds.y + bnode.bounds.height + (targetCY - currentCY);
925
- if (elemNewBottom > obs.y - yMargin) {
926
- const needed = currentCY + obs.y - yMargin - bnode.bounds.y - bnode.bounds.height;
927
- if (needed < targetCY)
928
- targetCY = needed;
929
- }
930
- }
931
- }
932
- }
933
- // Peer-aware gap enforcement — only backbone elements checked against
934
- // same-layer peers. Sub-branch elements from nested gateways are
935
- // handled by the nested gateway's own distribution step.
936
- const chainSet = new Set(chain);
937
- const backboneSet = new Set(backbone);
938
- const minGap = GRID_CELL_HEIGHT / 2;
939
- const initialDy = targetCY - currentCY;
940
- let extraDy = 0;
941
- for (const chainNodeId of backbone) {
942
- if (baselineSet.has(chainNodeId))
943
- continue;
944
- const chainNode = nodeMap.get(chainNodeId);
945
- if (!chainNode)
946
- continue;
947
- const chainNodeCY = chainNode.bounds.y + chainNode.bounds.height / 2;
948
- const chainNodeNewCY = chainNodeCY + initialDy;
949
- for (const peer of layoutNodes) {
950
- if (peer.layer !== chainNode.layer || chainSet.has(peer.id))
951
- continue;
952
- const peerCY = peer.bounds.y + peer.bounds.height / 2;
953
- const minDist = (chainNode.bounds.height + peer.bounds.height) / 2 + minGap;
954
- if (Math.abs(chainNodeNewCY + extraDy - peerCY) < minDist) {
955
- if (direction === -1) {
956
- const needed = peerCY - minDist - chainNodeNewCY;
957
- if (needed < extraDy)
958
- extraDy = needed;
959
- }
960
- else {
961
- const needed = peerCY + minDist - chainNodeNewCY;
962
- if (needed > extraDy)
963
- extraDy = needed;
964
- }
965
- }
966
- }
967
- }
968
- targetCY += extraDy;
969
- const dy = targetCY - currentCY;
970
- if (Math.abs(dy) > 0.5) {
971
- for (const bid of chain) {
972
- if (baselineSet.has(bid))
973
- continue;
974
- const bnode = nodeMap.get(bid);
975
- if (!bnode)
976
- continue;
977
- bnode.bounds.y += dy;
978
- if (bnode.labelBounds)
979
- bnode.labelBounds.y += dy;
980
- }
981
- }
982
- }
983
- }
984
- }
985
- /**
986
- * Push targetCY away from gatewayCY until it no longer overlaps any obstacle in both X and Y.
987
- * Only elements that overlap the branch's X range (with margin) trigger a push.
988
- */
989
- function resolveBranchObstacles(initialCY, extAbove, extBelow, branchMinX, branchMaxX, gatewayCY, obstacles, yMargin, xMargin) {
990
- let targetCY = initialCY;
991
- const direction = targetCY >= gatewayCY ? 1 : -1;
992
- const sorted = direction > 0
993
- ? [...obstacles].sort((a, b) => a.bottom - b.bottom)
994
- : [...obstacles].sort((a, b) => b.y - a.y);
995
- for (const obs of sorted) {
996
- if (branchMaxX + xMargin < obs.x || branchMinX - xMargin > obs.right)
997
- continue;
998
- const newTop = targetCY - extAbove;
999
- const newBottom = targetCY + extBelow;
1000
- if (newBottom + yMargin <= obs.y || newTop - yMargin >= obs.bottom)
1001
- continue;
1002
- if (direction > 0) {
1003
- targetCY = obs.bottom + yMargin + extAbove;
1004
- }
1005
- else {
1006
- targetCY = obs.y - yMargin - extBelow;
1007
- }
1008
- }
1009
- return targetCY;
1010
- }
1011
- /** Centre-Y of a layout node. */
1012
- function getCY(node) {
1013
- return node.bounds.y + node.bounds.height / 2;
1014
- }
1015
- /**
1016
- * Assign integer grid-row indices to all nodes based on their final center-Y positions.
1017
- * Nodes whose center-Y values are within EPSILON pixels are placed in the same row.
1018
- * This eliminates pixel-tolerance guessing in port-side decisions: two nodes with the
1019
- * same gridRow are on the same horizontal row and should connect left-to-right.
1020
- *
1021
- * Called once, after ALL coordinate adjustments, just before edge routing.
1022
- */
1023
- export function assignGridRows(layoutNodes) {
1024
- if (layoutNodes.length === 0)
1025
- return;
1026
- const EPSILON = 5; // nodes within 5px center-Y share a row
1027
- const sorted = [...layoutNodes].sort((a, b) => getCY(a) - getCY(b));
1028
- const first = sorted[0];
1029
- if (!first)
1030
- return;
1031
- let rowIdx = 0;
1032
- let rowCY = getCY(first);
1033
- for (const node of sorted) {
1034
- const cy = getCY(node);
1035
- if (cy - rowCY > EPSILON) {
1036
- rowIdx++;
1037
- rowCY = cy;
1038
- }
1039
- node.gridRow = rowIdx;
1040
- }
1041
- }
1042
- /**
1043
- * Snap nodes to common Y rows for matrix-like alignment.
1044
- * Groups nodes that share a CY (from alignment passes), then merges
1045
- * close groups into a single row — moving entire groups as units.
1046
- * Boundary events are excluded (they are repositioned later).
1047
- */
1048
- export function snapToYRows(layoutNodes) {
1049
- if (layoutNodes.length < 2)
1050
- return;
1051
- const MERGE_THRESHOLD = 35;
1052
- const GROUP_EPSILON = 3;
1053
- // Exclude boundary events (repositioned later in auto-layout.ts)
1054
- const candidates = layoutNodes.filter((n) => n.type !== "boundaryEvent");
1055
- if (candidates.length < 2)
1056
- return;
1057
- // Step 1: Group by current CY (nodes aligned by earlier passes share exact CY)
1058
- const sorted = [...candidates].sort((a, b) => getCY(a) - getCY(b));
1059
- const first = sorted[0];
1060
- if (!first)
1061
- return;
1062
- const rows = [];
1063
- let currentGroup = [first];
1064
- let groupCY = getCY(first);
1065
- for (let i = 1; i < sorted.length; i++) {
1066
- const node = sorted[i];
1067
- if (!node)
1068
- continue;
1069
- const cy = getCY(node);
1070
- if (cy - groupCY <= GROUP_EPSILON) {
1071
- currentGroup.push(node);
1072
- }
1073
- else {
1074
- rows.push({ nodes: currentGroup, cy: groupCY });
1075
- currentGroup = [node];
1076
- groupCY = cy;
1077
- }
1078
- }
1079
- rows.push({ nodes: currentGroup, cy: groupCY });
1080
- // Step 2: Merge close consecutive rows (move smaller row to larger row's CY)
1081
- let changed = true;
1082
- while (changed) {
1083
- changed = false;
1084
- for (let i = 0; i < rows.length - 1; i++) {
1085
- const a = rows[i];
1086
- const b = rows[i + 1];
1087
- if (!a || !b)
1088
- continue;
1089
- if (b.cy - a.cy > MERGE_THRESHOLD)
1090
- continue;
1091
- // Skip if merge would create same-layer overlap
1092
- const aLayers = new Set(a.nodes.map((n) => n.layer));
1093
- if (b.nodes.some((n) => aLayers.has(n.layer)))
1094
- continue;
1095
- // Move smaller group to larger group's CY
1096
- const [target, source] = a.nodes.length >= b.nodes.length ? [a, b] : [b, a];
1097
- for (const node of source.nodes) {
1098
- const dy = target.cy - getCY(node);
1099
- if (Math.abs(dy) > 0.5) {
1100
- node.bounds.y += dy;
1101
- if (node.labelBounds)
1102
- node.labelBounds.y += dy;
1103
- }
1104
- }
1105
- rows[i] = { nodes: [...a.nodes, ...b.nodes], cy: target.cy };
1106
- rows.splice(i + 1, 1);
1107
- changed = true;
1108
- break;
1109
- }
1110
- }
1111
- }
1112
- /**
1113
- * Pull each branch subtree toward the baseline, closing unnecessary vertical gaps.
1114
- * Uses per-element 2D collision detection — only actual element-to-element overlaps
1115
- * constrain the movement, not the branch's full bounding box.
1116
- */
1117
- export function compactBranches(layoutNodes, dag, backEdges = []) {
1118
- const backEdgeOriginals = buildBackEdgeOriginals(backEdges);
1119
- const nodeMap = new Map();
1120
- for (const n of layoutNodes)
1121
- nodeMap.set(n.id, n);
1122
- const baselinePath = findBaselinePath(layoutNodes, dag, backEdges);
1123
- const baselineSet = new Set(baselinePath);
1124
- // Collect split gateways and their branches
1125
- const splitGateways = [];
1126
- for (const n of layoutNodes) {
1127
- if (!GATEWAY_TYPE_SET.has(n.type))
1128
- continue;
1129
- const trueSuccs = getTrueSuccessors(n.id, dag, backEdgeOriginals);
1130
- if (trueSuccs.length < 2)
1131
- continue;
1132
- const joinId = findJoinGateway(n.id, dag, nodeMap);
1133
- const branchStarts = trueSuccs.filter((s) => !baselineSet.has(s));
1134
- if (branchStarts.length === 0)
1135
- continue;
1136
- splitGateways.push({ node: n, branchStarts, joinId });
1137
- }
1138
- // Process outermost gateways first (shallowest-first)
1139
- splitGateways.sort((a, b) => a.node.layer - b.node.layer);
1140
- const margin = GRID_CELL_HEIGHT / 2;
1141
- const movedElements = new Set();
1142
- for (const { node: gw, branchStarts, joinId } of splitGateways) {
1143
- // Skip open-ended branches (joinId=undefined) — unbounded chains
1144
- if (!joinId)
1145
- continue;
1146
- const gatewayCY = getCY(gw);
1147
- for (const branchId of branchStarts) {
1148
- const chain = collectBranchChain(branchId, dag, joinId);
1149
- if (chain.some((id) => movedElements.has(id)))
1150
- continue;
1151
- // Get non-baseline elements in the chain
1152
- const chainElements = [];
1153
- for (const bid of chain) {
1154
- if (baselineSet.has(bid))
1155
- continue;
1156
- const bnode = nodeMap.get(bid);
1157
- if (bnode)
1158
- chainElements.push(bnode);
1159
- }
1160
- if (chainElements.length === 0)
1161
- continue;
1162
- const chainSet = new Set(chain);
1163
- const startNode = nodeMap.get(branchId);
1164
- if (!startNode)
1165
- continue;
1166
- const currentCY = getCY(startNode);
1167
- const direction = currentCY > gatewayCY ? 1 : -1;
1168
- // Compute minimum offset from gateway (backbone + half gateway)
1169
- const backbone = collectBranchBackbone(branchId, dag, joinId, nodeMap);
1170
- let bbMinY = Number.POSITIVE_INFINITY;
1171
- let bbMaxY = Number.NEGATIVE_INFINITY;
1172
- for (const bid of backbone) {
1173
- if (baselineSet.has(bid))
1174
- continue;
1175
- const bnode = nodeMap.get(bid);
1176
- if (!bnode)
1177
- continue;
1178
- bbMinY = Math.min(bbMinY, bnode.bounds.y);
1179
- bbMaxY = Math.max(bbMaxY, bnode.bounds.y + bnode.bounds.height);
1180
- }
1181
- const bbExtAbove = bbMinY < Number.POSITIVE_INFINITY ? currentCY - bbMinY : GRID_CELL_HEIGHT / 2;
1182
- const bbExtBelow = bbMaxY > Number.NEGATIVE_INFINITY ? bbMaxY - currentCY : GRID_CELL_HEIGHT / 2;
1183
- const closestCY = direction > 0 ? gatewayCY + bbExtAbove + margin : gatewayCY - bbExtBelow - margin;
1184
- if (direction > 0 ? closestCY >= currentCY : closestCY <= currentCY)
1185
- continue;
1186
- // Per-element collision detection: for each obstacle, check every element
1187
- // in the chain. Only actual X+Y overlaps constrain movement.
1188
- let bestCY = closestCY;
1189
- for (const obs of layoutNodes) {
1190
- if (chainSet.has(obs.id))
1191
- continue;
1192
- const obsLeft = obs.bounds.x;
1193
- const obsRight = obs.bounds.x + obs.bounds.width;
1194
- const obsTop = obs.labelBounds ? Math.min(obs.bounds.y, obs.labelBounds.y) : obs.bounds.y;
1195
- const obsBottom = obs.labelBounds
1196
- ? Math.max(obs.bounds.y + obs.bounds.height, obs.labelBounds.y + obs.labelBounds.height)
1197
- : obs.bounds.y + obs.bounds.height;
1198
- for (const elem of chainElements) {
1199
- const elemRight = elem.bounds.x + elem.bounds.width;
1200
- // X overlap check
1201
- if (elemRight < obsLeft - margin || elem.bounds.x > obsRight + margin)
1202
- continue;
1203
- if (direction > 0) {
1204
- // Element top (after moving) must be below obstacle bottom
1205
- // elem.bounds.y + dy ≥ obsBottom + margin
1206
- // bestCY ≥ currentCY + obsBottom + margin - elem.bounds.y
1207
- const needed = currentCY + obsBottom + margin - elem.bounds.y;
1208
- if (needed > bestCY)
1209
- bestCY = needed;
1210
- }
1211
- else {
1212
- // Element bottom (after moving) must be above obstacle top
1213
- const elemBottom = elem.bounds.y + elem.bounds.height;
1214
- const needed = currentCY + obsTop - margin - elemBottom;
1215
- if (needed < bestCY)
1216
- bestCY = needed;
1217
- }
1218
- }
1219
- }
1220
- // Only move if we're actually pulling closer
1221
- if (direction > 0 ? bestCY >= currentCY : bestCY <= currentCY)
1222
- continue;
1223
- const dy = bestCY - currentCY;
1224
- for (const bid of chain) {
1225
- if (baselineSet.has(bid))
1226
- continue;
1227
- const bnode = nodeMap.get(bid);
1228
- if (!bnode)
1229
- continue;
1230
- bnode.bounds.y += dy;
1231
- if (bnode.labelBounds)
1232
- bnode.labelBounds.y += dy;
1233
- movedElements.add(bid);
1234
- }
1235
- }
1236
- }
1237
- }
1238
- /** Collect all nodes in a branch subtree, stopping at the join gateway. */
1239
- function collectBranchChain(startId, dag, joinId) {
1240
- const ids = [];
1241
- const queue = [startId];
1242
- const seen = new Set();
1243
- while (queue.length > 0) {
1244
- const id = queue.shift();
1245
- if (!id || seen.has(id))
1246
- continue;
1247
- if (joinId && id === joinId)
1248
- continue;
1249
- seen.add(id);
1250
- ids.push(id);
1251
- for (const s of dag.successors.get(id) ?? []) {
1252
- if (!seen.has(s))
1253
- queue.push(s);
1254
- }
1255
- }
1256
- return ids;
1257
- }
1258
- /**
1259
- * Collect only the backbone (spine) of a branch — following the linear path
1260
- * and jumping over nested sub-gateways via their join.
1261
- * Used to compute a tighter extent estimate for distribution placement.
1262
- */
1263
- function collectBranchBackbone(startId, dag, joinId, nodeMap) {
1264
- const ids = [];
1265
- let currentId = startId;
1266
- const seen = new Set();
1267
- while (currentId && !seen.has(currentId)) {
1268
- if (joinId && currentId === joinId)
1269
- break;
1270
- seen.add(currentId);
1271
- ids.push(currentId);
1272
- const succs = dag.successors.get(currentId) ?? [];
1273
- if (succs.length === 0)
1274
- break;
1275
- if (succs.length === 1) {
1276
- currentId = succs[0];
1277
- }
1278
- else {
1279
- // At a sub-split gateway: jump to its join
1280
- const node = nodeMap.get(currentId);
1281
- if (node && GATEWAY_TYPE_SET.has(node.type)) {
1282
- const subJoinId = findJoinGateway(currentId, dag, nodeMap);
1283
- if (subJoinId) {
1284
- currentId = subJoinId;
1285
- }
1286
- else {
1287
- // No join found — stop backbone here, fall back to full extent
1288
- break;
1289
- }
1290
- }
1291
- else {
1292
- currentId = succs[0];
1293
- }
1294
- }
1295
- }
1296
- return ids;
1297
- }
1298
- /**
1299
- * Resolve overlaps within each layer by pushing nodes apart.
1300
- * Sorts nodes by Y within each layer and ensures minimum gap.
1301
- * Also normalizes coordinates so no node has negative Y.
1302
- */
1303
- export function resolveLayerOverlaps(layoutNodes) {
1304
- const byLayer = new Map();
1305
- for (const n of layoutNodes) {
1306
- const arr = byLayer.get(n.layer) ?? [];
1307
- arr.push(n);
1308
- byLayer.set(n.layer, arr);
1309
- }
1310
- for (const [, nodes] of byLayer) {
1311
- if (nodes.length < 2)
1312
- continue;
1313
- nodes.sort((a, b) => a.bounds.y - b.bounds.y);
1314
- for (let i = 1; i < nodes.length; i++) {
1315
- const prev = nodes[i - 1];
1316
- const curr = nodes[i];
1317
- if (!prev || !curr)
1318
- continue;
1319
- // Account for the previous element's below-element label (e.g. gateway labels)
1320
- // so that labels don't overlap the next element in the same layer.
1321
- const prevLabelBottom = prev.labelBounds && prev.labelBounds.y > prev.bounds.y
1322
- ? prev.labelBounds.y + prev.labelBounds.height
1323
- : 0;
1324
- const prevBottom = Math.max(prev.bounds.y + prev.bounds.height, prevLabelBottom);
1325
- if (curr.bounds.y < prevBottom + 1) {
1326
- const shift = prevBottom + 1 - curr.bounds.y;
1327
- curr.bounds.y += shift;
1328
- if (curr.labelBounds)
1329
- curr.labelBounds.y += shift;
1330
- }
1331
- }
1332
- }
1333
- // Normalize: ensure no node has negative y
1334
- let minY = 0;
1335
- for (const n of layoutNodes) {
1336
- const y = n.labelBounds ? Math.min(n.bounds.y, n.labelBounds.y) : n.bounds.y;
1337
- if (y < minY)
1338
- minY = y;
1339
- }
1340
- if (minY < 0) {
1341
- const shift = -minY;
1342
- for (const n of layoutNodes) {
1343
- n.bounds.y += shift;
1344
- if (n.labelBounds)
1345
- n.labelBounds.y += shift;
1346
- }
1347
- }
1348
- }
1349
- /** Swap the y-positions of all nodes along two branches. */
1350
- function swapBranchPositions(branchA, branchB, dag, nodeMap, joinId) {
1351
- const collectBranch = (startId) => {
1352
- const ids = [];
1353
- let currentId = startId;
1354
- const seen = new Set();
1355
- while (currentId && !seen.has(currentId)) {
1356
- if (currentId === joinId)
1357
- break;
1358
- seen.add(currentId);
1359
- ids.push(currentId);
1360
- const succs = dag.successors.get(currentId) ?? [];
1361
- currentId = succs.length === 1 ? succs[0] : undefined;
1362
- }
1363
- return ids;
1364
- };
1365
- const nodesA = collectBranch(branchA);
1366
- const nodesB = collectBranch(branchB);
1367
- // Swap center-Y pairwise (not raw Y) so elements of different heights stay aligned
1368
- const swapCount = Math.min(nodesA.length, nodesB.length);
1369
- for (let i = 0; i < swapCount; i++) {
1370
- const idA = nodesA[i];
1371
- const idB = nodesB[i];
1372
- if (!idA || !idB)
1373
- continue;
1374
- const a = nodeMap.get(idA);
1375
- const b = nodeMap.get(idB);
1376
- if (!a || !b)
1377
- continue;
1378
- const aCY = a.bounds.y + a.bounds.height / 2;
1379
- const bCY = b.bounds.y + b.bounds.height / 2;
1380
- const aNewY = bCY - a.bounds.height / 2;
1381
- const bNewY = aCY - b.bounds.height / 2;
1382
- if (a.labelBounds) {
1383
- a.labelBounds.y += aNewY - a.bounds.y;
1384
- }
1385
- if (b.labelBounds) {
1386
- b.labelBounds.y += bNewY - b.bounds.y;
1387
- }
1388
- a.bounds.y = aNewY;
1389
- b.bounds.y = bNewY;
1390
- }
1391
- }
1392
- //# sourceMappingURL=coordinates.js.map