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.
- package/README.md +323 -111
- package/package.json +1 -2
- package/src/AutoLayoutManager.js +752 -0
- package/src/BpmnXmlSerializer.js +892 -0
- package/src/Canvas.js +407 -663
- package/src/Edge.js +44 -47
- package/src/ImageExporter.js +499 -0
- package/src/InlineEditor.js +110 -0
- package/src/Inspector.js +53 -37
- package/src/Node.js +36 -230
- package/src/NodeRenderer.js +266 -0
- package/src/NodeTypeRegistry.js +213 -2
- package/src/Palette.js +1 -1
- package/src/SelectionManager.js +5 -2
- package/src/SemanticAdapter.js +1724 -0
- package/src/SimulationEngine.js +401 -0
- package/src/SubprocessLayoutManager.js +343 -0
- package/src/index.js +8 -0
- package/src/styles/canvas.css +2 -1
- package/src/styles/nodes.css +338 -15
- package/src/styles/ui.css +129 -5
- package/src/types.js +172 -0
|
@@ -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
|
+
}
|