bpmn-canvas 1.0.0 → 1.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.
@@ -0,0 +1,752 @@
1
+ /**
2
+ * Copyright 2026 Don Hussey
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * AutoLayoutManager
19
+ * Zero-dependency topological and hierarchical layout engine for BPMN 2.0 workflows.
20
+ * Reorganizes nodes and edge routing into a clean, left-to-right (or top-to-bottom) DAG,
21
+ * honoring boundary event docking, nested subprocess containers, cycle/feedback edges,
22
+ * and canvas absolute positioning invariants.
23
+ */
24
+ export class AutoLayoutManager {
25
+ /**
26
+ * @param {Object} options
27
+ * @param {Map<string, Object>} options.nodes - Map of all nodes on canvas
28
+ * @param {Map<string, Object>} options.edges - Map of all edges on canvas
29
+ * @param {Function} options.getNode - Lookup node by ID
30
+ * @param {Function} [options.updateConnectedEdges] - Refresh edges connected to a node
31
+ * @param {Function} [options.updateAttachedBoundaryPositions] - Re-dock boundary events to host
32
+ * @param {Function} [options.ensureSubprocessEnclosesChildren] - Resize container to wrap children
33
+ * @param {Function} [options.fitView] - Optional canvas fitView callback
34
+ */
35
+ constructor({
36
+ nodes,
37
+ edges,
38
+ getNode,
39
+ updateConnectedEdges,
40
+ updateAttachedBoundaryPositions,
41
+ ensureSubprocessEnclosesChildren,
42
+ fitView
43
+ }) {
44
+ this.nodes = nodes;
45
+ this.edges = edges;
46
+ this.getNode = getNode || ((id) => this.nodes.get(id));
47
+ this.updateConnectedEdges = updateConnectedEdges || (() => {});
48
+ this.updateAttachedBoundaryPositions = updateAttachedBoundaryPositions || (() => {});
49
+ this.ensureSubprocessEnclosesChildren = ensureSubprocessEnclosesChildren || (() => {});
50
+ this.fitView = fitView || (() => {});
51
+ }
52
+
53
+ /**
54
+ * Executes auto-layout on the canvas graph.
55
+ * @param {Object} [options={}]
56
+ * @param {'LR'|'TB'} [options.direction='LR'] - Flow orientation: Left-to-Right or Top-to-Bottom
57
+ * @param {number} [options.horizontalGap=100] - Space between horizontal columns (px)
58
+ * @param {number} [options.verticalGap=80] - Space between parallel vertical rows (px)
59
+ * @param {number} [options.startX=100] - Top-left starting X coordinate
60
+ * @param {number} [options.startY=140] - Baseline center Y coordinate for primary flow
61
+ * @param {number} [options.gridSize=20] - Grid snap size in px for column positions
62
+ * @param {boolean} [options.optimizePorts=true] - Whether to reassign edge ports to natural flow
63
+ * @param {boolean} [options.fitView=true] - Whether to fit viewport to graph after arrangement
64
+ * @returns {boolean} Whether any layout modifications were made
65
+ */
66
+ autoArrange(options = {}) {
67
+ const config = Object.assign({
68
+ direction: 'LR',
69
+ horizontalGap: 100,
70
+ verticalGap: 80,
71
+ startX: 100,
72
+ startY: 140,
73
+ gridSize: 20,
74
+ optimizePorts: true,
75
+ fitView: true
76
+ }, options);
77
+
78
+ if (this.nodes.size === 0) return false;
79
+
80
+ // 1. Separate nodes: boundary events vs container children vs top-level nodes
81
+ const allNodesList = Array.from(this.nodes.values());
82
+ const boundaryNodes = new Set();
83
+ const childrenByParent = new Map();
84
+
85
+ allNodesList.forEach(node => {
86
+ if (this.isBoundary(node)) {
87
+ boundaryNodes.add(node);
88
+ } else if (node.data?.parentId) {
89
+ if (!childrenByParent.has(node.data.parentId)) {
90
+ childrenByParent.set(node.data.parentId, []);
91
+ }
92
+ childrenByParent.get(node.data.parentId).push(node);
93
+ }
94
+ });
95
+
96
+ // 2. Bottom-up: Pre-layout children inside each container in local coordinates and compute container dimensions
97
+ const childRelativeOffsets = new Map();
98
+
99
+ // Order containers deepest first if nested
100
+ const containerNodes = Array.from(childrenByParent.keys())
101
+ .map(id => this.getNode(id))
102
+ .filter(Boolean);
103
+
104
+ const getDepth = (node) => {
105
+ let d = 0;
106
+ let curr = node;
107
+ while (curr && curr.data?.parentId) {
108
+ d++;
109
+ curr = this.getNode(curr.data.parentId);
110
+ }
111
+ return d;
112
+ };
113
+ containerNodes.sort((a, b) => getDepth(b) - getDepth(a));
114
+
115
+ containerNodes.forEach(parentNode => {
116
+ const children = childrenByParent.get(parentNode.id) || [];
117
+ if (!children.length) return;
118
+
119
+ const isExpanded = parentNode.data?.expanded !== false;
120
+ if (!isExpanded) return; // Collapsed subprocesses hide children
121
+
122
+ // Layout children in relative coordinates starting at (0, 0)
123
+ this.layoutComponent(children, {
124
+ ...config,
125
+ startX: 0,
126
+ startY: 0,
127
+ horizontalGap: 70,
128
+ verticalGap: 50,
129
+ gridSize: 0
130
+ });
131
+
132
+ // Measure bounding box of laid-out children
133
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
134
+ children.forEach(c => {
135
+ const w = c.width || 100;
136
+ const h = c.height || 80;
137
+ minX = Math.min(minX, c.x);
138
+ minY = Math.min(minY, c.y);
139
+ maxX = Math.max(maxX, c.x + w);
140
+ maxY = Math.max(maxY, c.y + h);
141
+ });
142
+
143
+ const contentW = maxX - minX;
144
+ const contentH = maxY - minY;
145
+
146
+ const padLeft = 50;
147
+ const padRight = 50;
148
+ const padTop = 56; // Clearance below the 36px header bar
149
+ const padBottom = 34; // Clearance above bottom resize grip
150
+
151
+ const requiredW = Math.max(parentNode.width || 300, contentW + padLeft + padRight);
152
+ const requiredH = Math.max(parentNode.height || 240, contentH + padTop + padBottom);
153
+
154
+ const grid = config.gridSize > 0 ? config.gridSize : 20;
155
+ parentNode.width = Math.ceil(requiredW / grid) * grid;
156
+ parentNode.height = Math.ceil(requiredH / grid) * grid;
157
+
158
+ if (parentNode.domElement) {
159
+ parentNode.domElement.style.width = `${parentNode.width}px`;
160
+ parentNode.domElement.style.minHeight = `${parentNode.height}px`;
161
+ }
162
+ if (typeof parentNode.createPorts === 'function') {
163
+ parentNode.createPorts();
164
+ }
165
+
166
+ children.forEach(c => {
167
+ childRelativeOffsets.set(c.id, {
168
+ parentId: parentNode.id,
169
+ relX: padLeft + (c.x - minX),
170
+ relCenterY: c.y + c.height / 2
171
+ });
172
+ });
173
+ });
174
+
175
+ // 3. Collect top-level layout candidate nodes (non-boundary, not nested inside another container)
176
+ const topLevelNodes = allNodesList.filter(n => !boundaryNodes.has(n) && !n.data?.parentId);
177
+ if (topLevelNodes.length === 0) return false;
178
+
179
+ // 4. Decompose top-level nodes into Weakly Connected Components (WCC)
180
+ const components = this.findConnectedComponents(topLevelNodes, boundaryNodes);
181
+
182
+ // 5. Layout each component sequentially stacked along the cross-axis
183
+ let currentBaselineY = config.startY;
184
+ let currentBaselineX = config.startX;
185
+
186
+ components.forEach(componentNodes => {
187
+ const bbox = this.layoutComponent(componentNodes, {
188
+ ...config,
189
+ startX: config.direction === 'LR' ? config.startX : currentBaselineX,
190
+ startY: config.direction === 'LR' ? currentBaselineY : config.startY
191
+ });
192
+
193
+ if (config.direction === 'LR') {
194
+ currentBaselineY = bbox.maxY + config.verticalGap + 40;
195
+ } else {
196
+ currentBaselineX = bbox.maxX + config.horizontalGap + 40;
197
+ }
198
+ });
199
+
200
+ // 6. Top-down: Position children inside parent container with center-line alignment
201
+ childRelativeOffsets.forEach((offset, childId) => {
202
+ const child = this.getNode(childId);
203
+ const parent = this.getNode(offset.parentId);
204
+ if (child && parent) {
205
+ let posX = parent.x + offset.relX;
206
+ let parentCenterY = parent.y + parent.height / 2;
207
+ let posY = (parentCenterY + offset.relCenterY) - child.height / 2;
208
+
209
+ if (config.gridSize > 0) {
210
+ posX = Math.round(posX / config.gridSize) * config.gridSize;
211
+ }
212
+ child.setPosition(posX, posY);
213
+ }
214
+ });
215
+
216
+ containerNodes.forEach(parentNode => {
217
+ this.ensureSubprocessEnclosesChildren(parentNode.id);
218
+ });
219
+
220
+ // 7. Dock all attached boundary events securely to their new host positions
221
+ allNodesList.forEach(node => {
222
+ if (!this.isBoundary(node)) {
223
+ this.updateAttachedBoundaryPositions(node, node.width, node.height);
224
+ }
225
+ });
226
+
227
+ // 8. Re-align edge ports if requested and re-render all edges
228
+ if (config.optimizePorts) {
229
+ this.optimizeEdgePorts(config.direction);
230
+ }
231
+
232
+ allNodesList.forEach(node => {
233
+ this.updateConnectedEdges(node.id);
234
+ });
235
+
236
+ if (config.fitView && typeof this.fitView === 'function') {
237
+ this.fitView();
238
+ }
239
+
240
+ return true;
241
+ }
242
+
243
+ /**
244
+ * Checks whether a node is an attached boundary event.
245
+ */
246
+ isBoundary(node) {
247
+ if (!node) return false;
248
+ return Boolean(node.type?.startsWith('boundary-') || node.data?.hostNodeId);
249
+ }
250
+
251
+ /**
252
+ * Decomposes nodes into weakly connected components.
253
+ */
254
+ findConnectedComponents(nodes, boundaryNodes) {
255
+ const nodeMap = new Map(nodes.map(n => [n.id, n]));
256
+ const adjacency = new Map(nodes.map(n => [n.id, new Set()]));
257
+
258
+ this.edges.forEach(edge => {
259
+ let srcId = edge.sourceId;
260
+ let tgtId = edge.targetId;
261
+
262
+ // If source or target is a boundary event, treat it as its host activity
263
+ const srcNode = this.getNode(srcId);
264
+ const tgtNode = this.getNode(tgtId);
265
+
266
+ if (srcNode && this.isBoundary(srcNode) && srcNode.data?.hostNodeId) {
267
+ srcId = srcNode.data.hostNodeId;
268
+ }
269
+ if (tgtNode && this.isBoundary(tgtNode) && tgtNode.data?.hostNodeId) {
270
+ tgtId = tgtNode.data.hostNodeId;
271
+ }
272
+
273
+ if (adjacency.has(srcId) && adjacency.has(tgtId)) {
274
+ adjacency.get(srcId).add(tgtId);
275
+ adjacency.get(tgtId).add(srcId);
276
+ }
277
+ });
278
+
279
+ const visited = new Set();
280
+ const components = [];
281
+
282
+ nodes.forEach(node => {
283
+ if (visited.has(node.id)) return;
284
+
285
+ const comp = [];
286
+ const queue = [node.id];
287
+ visited.add(node.id);
288
+
289
+ while (queue.length > 0) {
290
+ const currId = queue.shift();
291
+ const currNode = nodeMap.get(currId);
292
+ if (currNode) comp.push(currNode);
293
+
294
+ const neighbors = adjacency.get(currId) || [];
295
+ neighbors.forEach(neighborId => {
296
+ if (!visited.has(neighborId)) {
297
+ visited.add(neighborId);
298
+ queue.push(neighborId);
299
+ }
300
+ });
301
+ }
302
+
303
+ components.push(comp);
304
+ });
305
+
306
+ // Order components so primary flows (containing start events or larger node counts) come first
307
+ components.sort((a, b) => {
308
+ const aHasStart = a.some(n => n.type?.startsWith('start-'));
309
+ const bHasStart = b.some(n => n.type?.startsWith('start-'));
310
+ if (aHasStart && !bHasStart) return -1;
311
+ if (!aHasStart && bHasStart) return 1;
312
+ return b.length - a.length;
313
+ });
314
+
315
+ return components;
316
+ }
317
+
318
+ /**
319
+ * Lays out a single connected component of nodes with center-line alignment.
320
+ */
321
+ layoutComponent(nodes, config) {
322
+ const isLR = config.direction === 'LR';
323
+ const nodeMap = new Map(nodes.map(n => [n.id, n]));
324
+ const nodeIds = new Set(nodes.map(n => n.id));
325
+
326
+ // 1. Build directed graph representation among nodes in this component
327
+ const outEdges = new Map(nodes.map(n => [n.id, []]));
328
+ const inDegree = new Map(nodes.map(n => [n.id, 0]));
329
+
330
+ this.edges.forEach(edge => {
331
+ let srcId = edge.sourceId;
332
+ let tgtId = edge.targetId;
333
+
334
+ const srcNode = this.getNode(srcId);
335
+ const tgtNode = this.getNode(tgtId);
336
+
337
+ if (srcNode && this.isBoundary(srcNode) && srcNode.data?.hostNodeId) {
338
+ srcId = srcNode.data.hostNodeId;
339
+ }
340
+ if (tgtNode && this.isBoundary(tgtNode) && tgtNode.data?.hostNodeId) {
341
+ tgtId = tgtNode.data.hostNodeId;
342
+ }
343
+
344
+ if (nodeIds.has(srcId) && nodeIds.has(tgtId) && srcId !== tgtId) {
345
+ outEdges.get(srcId).push(tgtId);
346
+ inDegree.set(tgtId, (inDegree.get(tgtId) || 0) + 1);
347
+ }
348
+ });
349
+
350
+ // 2. Identify and separate feedback (cycle) edges via DFS
351
+ const feedbackEdges = new Set();
352
+ const dfsState = new Map();
353
+
354
+ const detectCycles = (u) => {
355
+ dfsState.set(u, 1);
356
+ const neighbors = outEdges.get(u) || [];
357
+ for (const v of neighbors) {
358
+ const state = dfsState.get(v) || 0;
359
+ if (state === 1) {
360
+ feedbackEdges.add(`${u}->${v}`);
361
+ } else if (state === 0) {
362
+ detectCycles(v);
363
+ }
364
+ }
365
+ dfsState.set(u, 2);
366
+ };
367
+
368
+ nodes.slice().sort((a, b) => {
369
+ const aStart = a.type?.startsWith('start-') ? 1 : 0;
370
+ const bStart = b.type?.startsWith('start-') ? 1 : 0;
371
+ return bStart - aStart;
372
+ }).forEach(n => {
373
+ if (!dfsState.has(n.id)) {
374
+ detectCycles(n.id);
375
+ }
376
+ });
377
+
378
+ // 3. Compute topological ranks (longest path) ignoring feedback edges
379
+ const forwardInDegree = new Map(nodes.map(n => [n.id, 0]));
380
+ const forwardOut = new Map(nodes.map(n => [n.id, []]));
381
+
382
+ outEdges.forEach((targets, src) => {
383
+ targets.forEach(tgt => {
384
+ if (!feedbackEdges.has(`${src}->${tgt}`)) {
385
+ forwardOut.get(src).push(tgt);
386
+ forwardInDegree.set(tgt, forwardInDegree.get(tgt) + 1);
387
+ }
388
+ });
389
+ });
390
+
391
+ const layers = new Map();
392
+ const queue = [];
393
+
394
+ nodes.forEach(n => {
395
+ if (forwardInDegree.get(n.id) === 0) {
396
+ layers.set(n.id, 0);
397
+ queue.push(n.id);
398
+ }
399
+ });
400
+
401
+ if (queue.length === 0 && nodes.length > 0) {
402
+ layers.set(nodes[0].id, 0);
403
+ queue.push(nodes[0].id);
404
+ }
405
+
406
+ while (queue.length > 0) {
407
+ const u = queue.shift();
408
+ const currentLayer = layers.get(u);
409
+ const neighbors = forwardOut.get(u) || [];
410
+
411
+ for (const v of neighbors) {
412
+ const nextLayer = Math.max(layers.get(v) || 0, currentLayer + 1);
413
+ layers.set(v, nextLayer);
414
+
415
+ forwardInDegree.set(v, forwardInDegree.get(v) - 1);
416
+ if (forwardInDegree.get(v) === 0) {
417
+ queue.push(v);
418
+ }
419
+ }
420
+ }
421
+
422
+ nodes.forEach(n => {
423
+ if (!layers.has(n.id)) {
424
+ layers.set(n.id, 0);
425
+ }
426
+ });
427
+
428
+ // Identify long edges spanning across intermediate layers to reserve track clearance
429
+ const edgeDummiesByLayer = new Map();
430
+ this.edges.forEach(edge => {
431
+ let sId = edge.sourceId;
432
+ let tId = edge.targetId;
433
+ const sNode = this.getNode(sId);
434
+ const tNode = this.getNode(tId);
435
+ if (sNode && this.isBoundary(sNode) && sNode.data?.hostNodeId) sId = sNode.data.hostNodeId;
436
+ if (tNode && this.isBoundary(tNode) && tNode.data?.hostNodeId) tId = tNode.data.hostNodeId;
437
+
438
+ if (nodeIds.has(sId) && nodeIds.has(tId)) {
439
+ const sLayer = layers.get(sId);
440
+ const tLayer = layers.get(tId);
441
+ if (sLayer !== undefined && tLayer !== undefined && tLayer - sLayer > 1 && !feedbackEdges.has(`${sId}->${tId}`)) {
442
+ for (let k = sLayer + 1; k < tLayer; k++) {
443
+ if (!edgeDummiesByLayer.has(k)) {
444
+ edgeDummiesByLayer.set(k, []);
445
+ }
446
+ const dummy = {
447
+ id: `__dummy_${edge.id}_${k}`,
448
+ isDummy: true,
449
+ edge,
450
+ sourceId: sId,
451
+ targetId: tId,
452
+ width: 0,
453
+ height: 0
454
+ };
455
+ edgeDummiesByLayer.get(k).push(dummy);
456
+ nodeMap.set(dummy.id, dummy);
457
+ }
458
+ }
459
+ }
460
+ });
461
+
462
+ const layerBuckets = new Map();
463
+ nodes.forEach(n => {
464
+ const layer = layers.get(n.id);
465
+ if (!layerBuckets.has(layer)) {
466
+ layerBuckets.set(layer, []);
467
+ }
468
+ layerBuckets.get(layer).push(n);
469
+ });
470
+
471
+ edgeDummiesByLayer.forEach((dummies, layer) => {
472
+ if (!layerBuckets.has(layer)) {
473
+ layerBuckets.set(layer, []);
474
+ }
475
+ dummies.forEach(d => layerBuckets.get(layer).push(d));
476
+ });
477
+
478
+ const sortedLayerKeys = Array.from(layerBuckets.keys()).sort((a, b) => a - b);
479
+
480
+ let currentMainAxisPos = isLR ? config.startX : config.startY;
481
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
482
+
483
+ sortedLayerKeys.forEach(layerIdx => {
484
+ const bucket = layerBuckets.get(layerIdx);
485
+
486
+ // Compute barycenters from already-positioned lower-layer predecessors
487
+ bucket.forEach(node => {
488
+ const incomingNodes = [];
489
+ if (node.isDummy) {
490
+ const sLayer = layers.get(node.sourceId);
491
+ const prev = (layerIdx - 1 === sLayer)
492
+ ? nodeMap.get(node.sourceId)
493
+ : nodeMap.get(`__dummy_${node.edge.id}_${layerIdx - 1}`);
494
+ if (prev) incomingNodes.push(prev);
495
+ } else {
496
+ this.edges.forEach(edge => {
497
+ let sId = edge.sourceId;
498
+ let tId = edge.targetId;
499
+ const sNode = this.getNode(sId);
500
+ if (sNode && this.isBoundary(sNode) && sNode.data?.hostNodeId) sId = sNode.data.hostNodeId;
501
+
502
+ if (tId === node.id && nodeMap.has(sId)) {
503
+ const sLayer = layers.get(sId);
504
+ if (sLayer === layerIdx - 1) {
505
+ incomingNodes.push(nodeMap.get(sId));
506
+ } else if (sLayer !== undefined && sLayer < layerIdx - 1) {
507
+ const dummy = nodeMap.get(`__dummy_${edge.id}_${layerIdx - 1}`);
508
+ if (dummy) incomingNodes.push(dummy);
509
+ }
510
+ }
511
+ });
512
+ }
513
+
514
+ if (incomingNodes.length > 0) {
515
+ const dummyPred = incomingNodes.find(p => p.isDummy);
516
+ if (dummyPred && incomingNodes.length > 1) {
517
+ node._barycenter = isLR
518
+ ? (dummyPred.y + (dummyPred.height || 0) / 2)
519
+ : (dummyPred.x + (dummyPred.width || 0) / 2);
520
+ } else {
521
+ const sumPos = incomingNodes.reduce((acc, p) => acc + (isLR ? (p.y + (p.height || 0) / 2) : (p.x + (p.width || 0) / 2)), 0);
522
+ node._barycenter = sumPos / incomingNodes.length;
523
+ }
524
+ } else {
525
+ node._barycenter = null;
526
+ }
527
+ });
528
+
529
+ if (layerIdx === 0) {
530
+ bucket.sort((a, b) => {
531
+ const aStart = a.type?.startsWith('start-') ? 1 : 0;
532
+ const bStart = b.type?.startsWith('start-') ? 1 : 0;
533
+ if (aStart !== bStart) return bStart - aStart;
534
+ return isLR ? a.y - b.y : a.x - b.x;
535
+ });
536
+ } else {
537
+ bucket.sort((a, b) => {
538
+ const diff = (a._barycenter ?? 0) - (b._barycenter ?? 0);
539
+ if (Math.abs(diff) > 1) return diff;
540
+ // Prioritize long edge corridors (dummies) on the straight path over secondary branches
541
+ if (a.isDummy !== b.isDummy) return a.isDummy ? -1 : 1;
542
+ return 0;
543
+ });
544
+ }
545
+
546
+ // Authoritative width/height directly from model (only real nodes)
547
+ const realNodes = bucket.filter(n => !n.isDummy);
548
+ const maxMainAxisSize = Math.max(
549
+ 0,
550
+ ...realNodes.map(n => isLR ? (n.width || 100) : (n.height || 80))
551
+ );
552
+
553
+ const crossBaseline = isLR ? config.startY : config.startX;
554
+
555
+ if (bucket.length === 1) {
556
+ const node = bucket[0];
557
+ const nodeW = node.isDummy ? 0 : (node.width || 100);
558
+ const nodeH = node.isDummy ? 0 : (node.height || 80);
559
+
560
+ // Single node aligns its center precisely with predecessor center or baseline
561
+ const targetCenter = (node._barycenter !== null && node._barycenter !== undefined)
562
+ ? node._barycenter
563
+ : crossBaseline;
564
+
565
+ let posX = isLR ? currentMainAxisPos : (targetCenter - nodeW / 2);
566
+ let posY = isLR ? (targetCenter - nodeH / 2) : currentMainAxisPos;
567
+
568
+ if (config.gridSize > 0) {
569
+ if (isLR) posX = Math.round(posX / config.gridSize) * config.gridSize;
570
+ else posY = Math.round(posY / config.gridSize) * config.gridSize;
571
+ }
572
+
573
+ if (!node.isDummy) {
574
+ node.setPosition(posX, posY);
575
+ minX = Math.min(minX, posX);
576
+ minY = Math.min(minY, posY);
577
+ maxX = Math.max(maxX, posX + nodeW);
578
+ maxY = Math.max(maxY, posY + nodeH);
579
+ } else {
580
+ node.x = posX;
581
+ node.y = posY;
582
+ }
583
+ } else {
584
+ // Multi-node column (gateway branches, bypass tracks, etc.)
585
+ const maxNodeCrossSize = Math.max(
586
+ 0,
587
+ ...realNodes.map(n => isLR ? (n.height || 80) : (n.width || 100))
588
+ ) || 80;
589
+ const rowPitch = maxNodeCrossSize + (isLR ? config.verticalGap : config.horizontalGap);
590
+ const totalSpan = (bucket.length - 1) * rowPitch;
591
+ const startCenter = crossBaseline - totalSpan / 2;
592
+
593
+ // Check if all nodes in bucket have distinct, non-overlapping predecessor barycenters
594
+ const allHaveBary = bucket.every(n => n._barycenter !== null && n._barycenter !== undefined);
595
+ let canUseBarycenters = allHaveBary;
596
+ if (allHaveBary && bucket.length > 1) {
597
+ for (let i = 0; i < bucket.length - 1; i++) {
598
+ if (bucket[i + 1]._barycenter - bucket[i]._barycenter < maxNodeCrossSize + 20) {
599
+ canUseBarycenters = false;
600
+ break;
601
+ }
602
+ }
603
+ }
604
+
605
+ const hasDummy = bucket.some(n => n.isDummy);
606
+ const anchorCenter = (hasDummy && bucket[0]._barycenter !== null)
607
+ ? bucket[0]._barycenter
608
+ : startCenter;
609
+
610
+ bucket.forEach((node, i) => {
611
+ const nodeW = node.isDummy ? 0 : (node.width || 100);
612
+ const nodeH = node.isDummy ? 0 : (node.height || 80);
613
+
614
+ let targetCenter = canUseBarycenters
615
+ ? node._barycenter
616
+ : (hasDummy ? (anchorCenter + i * rowPitch) : (startCenter + i * rowPitch));
617
+
618
+ let posX = isLR ? currentMainAxisPos : (targetCenter - nodeW / 2);
619
+ let posY = isLR ? (targetCenter - nodeH / 2) : currentMainAxisPos;
620
+
621
+ if (config.gridSize > 0) {
622
+ if (isLR) posX = Math.round(posX / config.gridSize) * config.gridSize;
623
+ else posY = Math.round(posY / config.gridSize) * config.gridSize;
624
+ }
625
+
626
+ if (!node.isDummy) {
627
+ node.setPosition(posX, posY);
628
+ minX = Math.min(minX, posX);
629
+ minY = Math.min(minY, posY);
630
+ maxX = Math.max(maxX, posX + nodeW);
631
+ maxY = Math.max(maxY, posY + nodeH);
632
+ } else {
633
+ node.x = posX;
634
+ node.y = posY;
635
+ }
636
+ });
637
+ }
638
+
639
+ bucket.forEach(n => delete n._barycenter);
640
+
641
+ // Ample clearance after subprocess containers to guarantee edges don't double back
642
+ const isSubproc = bucket.some(n => n.type === 'subprocess' || n.data?.expanded);
643
+ const gap = isSubproc ? Math.max(config.horizontalGap, 130) : config.horizontalGap;
644
+ currentMainAxisPos += maxMainAxisSize + gap;
645
+ });
646
+
647
+ return { minX, minY, maxX, maxY };
648
+ }
649
+
650
+ /**
651
+ * Optimizes sequence edge connector ports for smooth Left-to-Right or Top-to-Bottom flow.
652
+ */
653
+ optimizeEdgePorts(direction = 'LR') {
654
+ const isLR = direction === 'LR';
655
+
656
+ // Index forward outgoing and incoming edges for branching and convergence resolution
657
+ const outgoingForward = new Map();
658
+ const incomingForward = new Map();
659
+
660
+ this.edges.forEach(edge => {
661
+ const sNode = this.getNode(edge.sourceId);
662
+ const tNode = this.getNode(edge.targetId);
663
+ if (!sNode || !tNode) return;
664
+
665
+ if (isLR) {
666
+ if (tNode.x - sNode.x > 30) {
667
+ if (!outgoingForward.has(sNode.id)) outgoingForward.set(sNode.id, []);
668
+ outgoingForward.get(sNode.id).push({ edge, target: tNode });
669
+
670
+ if (!incomingForward.has(tNode.id)) incomingForward.set(tNode.id, []);
671
+ incomingForward.get(tNode.id).push({ edge, source: sNode });
672
+ }
673
+ } else {
674
+ if (tNode.y - sNode.y > 30) {
675
+ if (!outgoingForward.has(sNode.id)) outgoingForward.set(sNode.id, []);
676
+ outgoingForward.get(sNode.id).push({ edge, target: tNode });
677
+
678
+ if (!incomingForward.has(tNode.id)) incomingForward.set(tNode.id, []);
679
+ incomingForward.get(tNode.id).push({ edge, source: sNode });
680
+ }
681
+ }
682
+ });
683
+
684
+ this.edges.forEach(edge => {
685
+ const sourceNode = this.getNode(edge.sourceId);
686
+ const targetNode = this.getNode(edge.targetId);
687
+
688
+ if (!sourceNode || !targetNode) return;
689
+
690
+ if (this.isBoundary(sourceNode)) {
691
+ const dockEdge = sourceNode.data?.dockEdge || 'bottom';
692
+ let sPort = dockEdge;
693
+ if (dockEdge === 'bottom') sPort = 'bottom';
694
+ else if (dockEdge === 'right') sPort = 'right';
695
+
696
+ let tPort = isLR ? 'left' : 'top';
697
+ edge.updateConnection({ sourcePort: sPort, targetPort: tPort });
698
+ return;
699
+ }
700
+
701
+ if (isLR) {
702
+ const dx = targetNode.x - sourceNode.x;
703
+ const dy = targetNode.y - sourceNode.y;
704
+
705
+ if (dx > 30) {
706
+ let sPort = 'right';
707
+ let tPort = 'left';
708
+
709
+ const outEdges = outgoingForward.get(sourceNode.id) || [];
710
+ if (outEdges.length > 1) {
711
+ const sorted = outEdges.slice().sort((a, b) => a.target.y - b.target.y);
712
+ if (edge.id === sorted[sorted.length - 1].edge.id && targetNode.y > sourceNode.y + 20) {
713
+ sPort = 'bottom';
714
+ } else if (edge.id === sorted[0].edge.id && targetNode.y < sourceNode.y - 20) {
715
+ sPort = 'top';
716
+ }
717
+ }
718
+
719
+ const inEdges = incomingForward.get(targetNode.id) || [];
720
+ if (inEdges.length > 1) {
721
+ const sorted = inEdges.slice().sort((a, b) => a.source.y - b.source.y);
722
+ if (edge.id === sorted[sorted.length - 1].edge.id && sourceNode.y > targetNode.y + 20) {
723
+ tPort = 'bottom';
724
+ } else if (edge.id === sorted[0].edge.id && sourceNode.y < targetNode.y - 20) {
725
+ tPort = 'top';
726
+ }
727
+ }
728
+
729
+ edge.updateConnection({ sourcePort: sPort, targetPort: tPort });
730
+ } else if (dx < -30) {
731
+ // Backward / loopback flow: route cleanly underneath via bottom -> bottom
732
+ edge.updateConnection({ sourcePort: 'bottom', targetPort: 'bottom' });
733
+ } else {
734
+ // Vertical neighbor in same column
735
+ if (dy > 0) {
736
+ edge.updateConnection({ sourcePort: 'bottom', targetPort: 'top' });
737
+ } else {
738
+ edge.updateConnection({ sourcePort: 'top', targetPort: 'bottom' });
739
+ }
740
+ }
741
+ } else {
742
+ // Top-to-Bottom
743
+ const dy = targetNode.y - sourceNode.y;
744
+ if (dy > 30) {
745
+ edge.updateConnection({ sourcePort: 'bottom', targetPort: 'top' });
746
+ } else if (dy < -30) {
747
+ edge.updateConnection({ sourcePort: 'right', targetPort: 'right' });
748
+ }
749
+ }
750
+ });
751
+ }
752
+ }