bpmn-auto-layout 0.5.0 → 1.0.1
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 +36 -17
- package/dist/index.cjs +249 -29
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +249 -29
- package/dist/index.js.map +1 -1
- package/package.json +13 -4
package/README.md
CHANGED
|
@@ -2,46 +2,65 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/bpmn-io/bpmn-auto-layout/actions/workflows/CI.yml)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
Create and layout the graphical representation of a BPMN diagram.
|
|
7
6
|
|
|
8
7
|
## Usage
|
|
9
8
|
|
|
10
|
-
This library works
|
|
11
|
-
|
|
12
|
-
To layout diagrams these must have __exactly one single start event__.
|
|
9
|
+
This library works with [Node.js](https://nodejs.org/) and in the browser.
|
|
13
10
|
|
|
14
11
|
```javascript
|
|
15
12
|
import { layoutProcess } from 'bpmn-auto-layout';
|
|
16
13
|
|
|
17
|
-
|
|
14
|
+
import diagramXML from './diagram.bpmn';
|
|
18
15
|
|
|
19
|
-
const
|
|
16
|
+
const diagramWithLayoutXML = await layoutProcess(diagramXML);
|
|
20
17
|
|
|
21
|
-
console.log(
|
|
18
|
+
console.log(diagramWithLayoutXML);
|
|
22
19
|
```
|
|
23
|
-
## Unsupported Concepts and elements
|
|
24
20
|
|
|
25
|
-
|
|
26
|
-
- Pools
|
|
27
|
-
- Data/Message Flows and Objects, Data Stores
|
|
28
|
-
- event sub-processes
|
|
21
|
+
## Limitations
|
|
29
22
|
|
|
23
|
+
* Given a collaboration only the first participant's process will be laid out
|
|
24
|
+
* Sub-processes will be laid out as collapsed sub-processes
|
|
25
|
+
* The following elements are not laid out:
|
|
26
|
+
* Groups
|
|
27
|
+
* Text annotations
|
|
28
|
+
* Associations
|
|
29
|
+
* Message flows
|
|
30
30
|
|
|
31
31
|
## Resources
|
|
32
32
|
|
|
33
|
-
*
|
|
34
|
-
|
|
33
|
+
* [Issues](https://github.com/bpmn-io/bpmn-auto-layout/issues)
|
|
35
34
|
|
|
36
|
-
##
|
|
35
|
+
## Build and Run
|
|
37
36
|
|
|
38
37
|
```sh
|
|
38
|
+
# install dependencies
|
|
39
39
|
npm install
|
|
40
|
+
|
|
41
|
+
# build and run tests
|
|
40
42
|
npm run all
|
|
43
|
+
|
|
44
|
+
# run example
|
|
45
|
+
npm start
|
|
41
46
|
```
|
|
42
47
|
|
|
43
|
-
|
|
48
|
+
## Test
|
|
49
|
+
|
|
50
|
+
We use snapshot testing to verify old and new layout attempts. A mismatch is indicated as a test failure.
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
# run tests
|
|
54
|
+
npm test
|
|
55
|
+
|
|
56
|
+
# inspect the results
|
|
57
|
+
npm run test:inspect
|
|
58
|
+
|
|
59
|
+
# run update snapshots
|
|
60
|
+
npm run test:update-snapshots
|
|
61
|
+
```
|
|
44
62
|
|
|
63
|
+
Add new test cases to [`test/fixtures`](./test/fixtures) and they will be picked up automatically.
|
|
45
64
|
|
|
46
65
|
## License
|
|
47
66
|
|
package/dist/index.cjs
CHANGED
|
@@ -11,6 +11,27 @@ function isBoundaryEvent(element) {
|
|
|
11
11
|
return !!element.attachedToRef;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function findElementInTree(currentElement, targetElement, visited = new Set()) {
|
|
15
|
+
|
|
16
|
+
if (currentElement === targetElement) return true;
|
|
17
|
+
|
|
18
|
+
if (visited.has(currentElement)) return false;
|
|
19
|
+
|
|
20
|
+
visited.add(currentElement);
|
|
21
|
+
|
|
22
|
+
// If currentElement has no outgoing connections, return false
|
|
23
|
+
if (!currentElement.outgoing || currentElement.outgoing.length === 0) return false;
|
|
24
|
+
|
|
25
|
+
// Recursively check each outgoing element
|
|
26
|
+
for (let nextElement of currentElement.outgoing.map(out => out.targetRef)) {
|
|
27
|
+
if (findElementInTree(nextElement, targetElement, visited)) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
14
35
|
class Grid {
|
|
15
36
|
constructor() {
|
|
16
37
|
this.grid = [];
|
|
@@ -54,7 +75,6 @@ class Grid {
|
|
|
54
75
|
if (!element) {
|
|
55
76
|
this._addStart(newElement);
|
|
56
77
|
}
|
|
57
|
-
|
|
58
78
|
const [ row, col ] = this.find(element);
|
|
59
79
|
this.grid[row].splice(col + 1, 0, newElement);
|
|
60
80
|
}
|
|
@@ -124,6 +144,84 @@ class Grid {
|
|
|
124
144
|
return elements;
|
|
125
145
|
}
|
|
126
146
|
|
|
147
|
+
adjustGridPosition(element) {
|
|
148
|
+
let [ row, col ] = this.find(element);
|
|
149
|
+
const [ , maxCol ] = this.getGridDimensions();
|
|
150
|
+
|
|
151
|
+
if (col < maxCol - 1) {
|
|
152
|
+
|
|
153
|
+
// add element in next column
|
|
154
|
+
this.grid[row].length = maxCol;
|
|
155
|
+
this.grid[row][maxCol] = element;
|
|
156
|
+
this.grid[row][col] = null;
|
|
157
|
+
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
adjustRowForMultipleIncoming(elements, currentElement) {
|
|
162
|
+
const results = elements.map(element => this.find(element));
|
|
163
|
+
|
|
164
|
+
// filter only rows that currently exist, excluding any future or non-existent rows
|
|
165
|
+
const lowestRow = Math.min(...results
|
|
166
|
+
.map(result => result[0])
|
|
167
|
+
.filter(row => row >= 0));
|
|
168
|
+
|
|
169
|
+
const [ row , col ] = this.find(currentElement);
|
|
170
|
+
|
|
171
|
+
// if element doesn't already exist in current row, add element
|
|
172
|
+
if (lowestRow < row && !this.grid[lowestRow][col]) {
|
|
173
|
+
this.grid[lowestRow][col] = currentElement;
|
|
174
|
+
this.grid[row][col] = null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
adjustColumnForMultipleIncoming(elements, currentElement) {
|
|
179
|
+
const results = elements.map(element => this.find(element));
|
|
180
|
+
|
|
181
|
+
// filter only col that currently exist, excluding any future or non-existent col
|
|
182
|
+
const maxCol = Math.max(...results
|
|
183
|
+
.map(result => result[1])
|
|
184
|
+
.filter(col => col >= 0));
|
|
185
|
+
|
|
186
|
+
const [ row , col ] = this.find(currentElement);
|
|
187
|
+
|
|
188
|
+
// add to the next column
|
|
189
|
+
if (maxCol + 1 > col) {
|
|
190
|
+
this.grid[row][maxCol + 1] = currentElement;
|
|
191
|
+
this.grid[row][col] = null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
getAllElements() {
|
|
196
|
+
const elements = [];
|
|
197
|
+
|
|
198
|
+
for (let row = 0; row < this.grid.length; row++) {
|
|
199
|
+
for (let col = 0; col < this.grid[row].length; col++) {
|
|
200
|
+
const element = this.get(row, col);
|
|
201
|
+
|
|
202
|
+
if (element) {
|
|
203
|
+
elements.push(element);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return elements;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
getGridDimensions() {
|
|
212
|
+
const numRows = this.grid.length;
|
|
213
|
+
let maxCols = 0;
|
|
214
|
+
|
|
215
|
+
for (let i = 0; i < numRows; i++) {
|
|
216
|
+
const currentRowLength = this.grid[i].length;
|
|
217
|
+
if (currentRowLength > maxCols) {
|
|
218
|
+
maxCols = currentRowLength;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return [ numRows , maxCols ];
|
|
223
|
+
}
|
|
224
|
+
|
|
127
225
|
elementsByPosition() {
|
|
128
226
|
const elements = [];
|
|
129
227
|
|
|
@@ -142,6 +240,12 @@ class Grid {
|
|
|
142
240
|
|
|
143
241
|
return elements;
|
|
144
242
|
}
|
|
243
|
+
|
|
244
|
+
getElementsTotal() {
|
|
245
|
+
const flattenedGrid = this.grid.flat();
|
|
246
|
+
const uniqueElements = new Set(flattenedGrid.filter(value => value));
|
|
247
|
+
return uniqueElements.size;
|
|
248
|
+
}
|
|
145
249
|
}
|
|
146
250
|
|
|
147
251
|
class DiFactory {
|
|
@@ -319,16 +423,46 @@ function connectElements(source, target, layoutGrid) {
|
|
|
319
423
|
];
|
|
320
424
|
}
|
|
321
425
|
|
|
426
|
+
// negative dX indicates connection from future to past
|
|
427
|
+
if (dX < 0) {
|
|
428
|
+
const offsetY = DEFAULT_CELL_HEIGHT / 2;
|
|
429
|
+
|
|
430
|
+
let bendY;
|
|
431
|
+
if (sourceMid.y >= targetMid.y) {
|
|
432
|
+
|
|
433
|
+
// edge goes below
|
|
434
|
+
bendY = sourceMid.y + offsetY;
|
|
435
|
+
|
|
436
|
+
return [
|
|
437
|
+
getDockingPoint(sourceMid, sourceBounds, 'b'),
|
|
438
|
+
{ x: sourceMid.x, y: bendY },
|
|
439
|
+
{ x: targetMid.x, y: bendY },
|
|
440
|
+
getDockingPoint(targetMid, targetBounds, 'b')
|
|
441
|
+
];
|
|
442
|
+
} else {
|
|
443
|
+
|
|
444
|
+
// edge goes above
|
|
445
|
+
bendY = sourceMid.y - offsetY;
|
|
446
|
+
|
|
447
|
+
return [
|
|
448
|
+
getDockingPoint(sourceMid, sourceBounds, 't'),
|
|
449
|
+
{ x: sourceMid.x, y: bendY },
|
|
450
|
+
{ x: targetMid.x, y: bendY },
|
|
451
|
+
getDockingPoint(targetMid, targetBounds, 't')
|
|
452
|
+
];
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
322
456
|
// connect horizontally
|
|
323
457
|
if (dY === 0) {
|
|
324
458
|
if (isDirectPathBlocked(source, target, layoutGrid)) {
|
|
325
459
|
|
|
326
|
-
// Route on
|
|
460
|
+
// Route on bottom
|
|
327
461
|
return [
|
|
328
|
-
getDockingPoint(sourceMid, sourceBounds, '
|
|
329
|
-
{ x: sourceMid.x, y: sourceMid.y
|
|
330
|
-
{ x: targetMid.x, y: sourceMid.y
|
|
331
|
-
getDockingPoint(targetMid, targetBounds, '
|
|
462
|
+
getDockingPoint(sourceMid, sourceBounds, 'b'),
|
|
463
|
+
{ x: sourceMid.x, y: sourceMid.y + DEFAULT_CELL_HEIGHT / 2 },
|
|
464
|
+
{ x: targetMid.x, y: sourceMid.y + DEFAULT_CELL_HEIGHT / 2 },
|
|
465
|
+
getDockingPoint(targetMid, targetBounds, 'b')
|
|
332
466
|
];
|
|
333
467
|
} else {
|
|
334
468
|
|
|
@@ -477,7 +611,7 @@ var attacherHandler = {
|
|
|
477
611
|
const nextElements = [];
|
|
478
612
|
|
|
479
613
|
const attachedOutgoing = (element.attachers || [])
|
|
480
|
-
.map(
|
|
614
|
+
.map(attacher => (attacher.outgoing || []).reverse())
|
|
481
615
|
.flat()
|
|
482
616
|
.map(out => out.targetRef);
|
|
483
617
|
|
|
@@ -550,11 +684,6 @@ function insertIntoGrid(newElement, host, grid) {
|
|
|
550
684
|
grid.createRow(row);
|
|
551
685
|
}
|
|
552
686
|
|
|
553
|
-
// Host has element directly after, add space
|
|
554
|
-
if (grid.get(row, col + 1)) {
|
|
555
|
-
grid.addAfter(host, null);
|
|
556
|
-
}
|
|
557
|
-
|
|
558
687
|
grid.add(newElement, [ row + 1, col + 1 ]);
|
|
559
688
|
}
|
|
560
689
|
|
|
@@ -614,8 +743,8 @@ var elementHandler = {
|
|
|
614
743
|
};
|
|
615
744
|
|
|
616
745
|
var outgoingHandler = {
|
|
617
|
-
'addToGrid': ({ element, grid, visited }) => {
|
|
618
|
-
|
|
746
|
+
'addToGrid': ({ element, grid, visited, stack }) => {
|
|
747
|
+
let nextElements = [];
|
|
619
748
|
|
|
620
749
|
// Handle outgoing paths
|
|
621
750
|
const outgoing = (element.outgoing || [])
|
|
@@ -623,14 +752,28 @@ var outgoingHandler = {
|
|
|
623
752
|
.filter(el => el);
|
|
624
753
|
|
|
625
754
|
let previousElement = null;
|
|
755
|
+
|
|
756
|
+
if (outgoing.length > 1 && isNextElementTasks(outgoing)) {
|
|
757
|
+
grid.adjustGridPosition(element);
|
|
758
|
+
}
|
|
759
|
+
|
|
626
760
|
outgoing.forEach((nextElement, index, arr) => {
|
|
627
761
|
if (visited.has(nextElement)) {
|
|
628
762
|
return;
|
|
629
763
|
}
|
|
630
764
|
|
|
765
|
+
// Prevents revisiting future incoming elements and ensures proper traversal without early exit.
|
|
766
|
+
if ((previousElement || stack.length > 0) && isFutureIncoming(nextElement, visited) && !checkForLoop(nextElement, visited)) {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
631
770
|
if (!previousElement) {
|
|
632
771
|
grid.addAfter(element, nextElement);
|
|
633
772
|
}
|
|
773
|
+
|
|
774
|
+
else if (is(element, 'bpmn:ExclusiveGateway') && is(nextElement, 'bpmn:ExclusiveGateway')) {
|
|
775
|
+
grid.addAfter(previousElement, nextElement);
|
|
776
|
+
}
|
|
634
777
|
else {
|
|
635
778
|
grid.addBelow(arr[index - 1], nextElement);
|
|
636
779
|
}
|
|
@@ -644,8 +787,11 @@ var outgoingHandler = {
|
|
|
644
787
|
visited.add(nextElement);
|
|
645
788
|
});
|
|
646
789
|
|
|
790
|
+
// Sort elements by priority to ensure proper stack placement
|
|
791
|
+
nextElements = sortByType(nextElements, 'bpmn:ExclusiveGateway'); // TODO: sort by priority
|
|
647
792
|
return nextElements;
|
|
648
793
|
},
|
|
794
|
+
|
|
649
795
|
'createConnectionDi': ({ element, row, col, layoutGrid, diFactory }) => {
|
|
650
796
|
const outgoing = element.outgoing || [];
|
|
651
797
|
|
|
@@ -663,7 +809,59 @@ var outgoingHandler = {
|
|
|
663
809
|
}
|
|
664
810
|
};
|
|
665
811
|
|
|
666
|
-
|
|
812
|
+
|
|
813
|
+
// helpers /////
|
|
814
|
+
|
|
815
|
+
function sortByType(arr, type) {
|
|
816
|
+
const nonMatching = arr.filter(item => !is(item,type));
|
|
817
|
+
const matching = arr.filter(item => is(item,type));
|
|
818
|
+
|
|
819
|
+
return [ ...matching, ...nonMatching ];
|
|
820
|
+
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function checkForLoop(element, visited) {
|
|
824
|
+
for (const incomingElement of element.incoming) {
|
|
825
|
+
if (!visited.has(incomingElement.sourceRef)) {
|
|
826
|
+
return findElementInTree(element, incomingElement.sourceRef);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
function isFutureIncoming(element, visited) {
|
|
833
|
+
if (element.incoming.length > 1) {
|
|
834
|
+
for (const incomingElement of element.incoming) {
|
|
835
|
+
if (!visited.has(incomingElement.sourceRef)) {
|
|
836
|
+
return true;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function isNextElementTasks(elements) {
|
|
844
|
+
return elements.every(element => is(element, 'bpmn:Task'));
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
var incomingHandler = {
|
|
848
|
+
'addToGrid': ({ element, grid, visited }) => {
|
|
849
|
+
const nextElements = [];
|
|
850
|
+
|
|
851
|
+
const incoming = (element.incoming || [])
|
|
852
|
+
.map(out => out.sourceRef)
|
|
853
|
+
.filter(el => el);
|
|
854
|
+
|
|
855
|
+
// adjust the row if it is empty
|
|
856
|
+
if (incoming.length > 1) {
|
|
857
|
+
grid.adjustColumnForMultipleIncoming(incoming, element);
|
|
858
|
+
grid.adjustRowForMultipleIncoming(incoming, element);
|
|
859
|
+
}
|
|
860
|
+
return nextElements;
|
|
861
|
+
},
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
const handlers = [ elementHandler, incomingHandler, outgoingHandler, attacherHandler ];
|
|
667
865
|
|
|
668
866
|
class Layouter {
|
|
669
867
|
constructor() {
|
|
@@ -686,8 +884,10 @@ class Layouter {
|
|
|
686
884
|
|
|
687
885
|
const root = this.getProcess();
|
|
688
886
|
|
|
689
|
-
|
|
690
|
-
|
|
887
|
+
if (root) {
|
|
888
|
+
this.cleanDi();
|
|
889
|
+
this.handlePlane(root);
|
|
890
|
+
}
|
|
691
891
|
|
|
692
892
|
return (await this.moddle.toXML(this.diagram, { format: true })).xml;
|
|
693
893
|
}
|
|
@@ -704,7 +904,13 @@ class Layouter {
|
|
|
704
904
|
createGridLayout(root) {
|
|
705
905
|
const grid = new Grid();
|
|
706
906
|
|
|
707
|
-
const flowElements = root.flowElements;
|
|
907
|
+
const flowElements = root.flowElements || [];
|
|
908
|
+
const elements = flowElements.filter(el => !is(el,'bpmn:SequenceFlow'));
|
|
909
|
+
|
|
910
|
+
// check for empty process/subprocess
|
|
911
|
+
if (!flowElements) {
|
|
912
|
+
return grid;
|
|
913
|
+
}
|
|
708
914
|
|
|
709
915
|
const startingElements = flowElements.filter(el => {
|
|
710
916
|
return !isConnection(el) && !isBoundaryEvent(el) && (!el.incoming || el.length === 0);
|
|
@@ -727,19 +933,17 @@ class Layouter {
|
|
|
727
933
|
visited.add(el);
|
|
728
934
|
});
|
|
729
935
|
|
|
730
|
-
|
|
731
|
-
const currentElement = stack.pop();
|
|
936
|
+
this.handleGrid(grid,visited,stack);
|
|
732
937
|
|
|
733
|
-
|
|
734
|
-
|
|
938
|
+
if (grid.getElementsTotal() != elements.length) {
|
|
939
|
+
const gridElements = grid.getAllElements();
|
|
940
|
+
const missingElements = elements.filter(el => !gridElements.includes(el) && !isBoundaryEvent(el));
|
|
941
|
+
if (missingElements.length > 1) {
|
|
942
|
+
stack.push(missingElements[0]);
|
|
943
|
+
grid.add(missingElements[0]);
|
|
944
|
+
visited.add(missingElements[0]);
|
|
945
|
+
this.handleGrid(grid,visited,stack);
|
|
735
946
|
}
|
|
736
|
-
|
|
737
|
-
const nextElements = this.handle('addToGrid', { element: currentElement, grid, visited });
|
|
738
|
-
|
|
739
|
-
nextElements.flat().forEach(el => {
|
|
740
|
-
stack.push(el);
|
|
741
|
-
visited.add(el);
|
|
742
|
-
});
|
|
743
947
|
}
|
|
744
948
|
|
|
745
949
|
return grid;
|
|
@@ -784,6 +988,22 @@ class Layouter {
|
|
|
784
988
|
});
|
|
785
989
|
}
|
|
786
990
|
|
|
991
|
+
handleGrid(grid, visited, stack) {
|
|
992
|
+
while (stack.length > 0) {
|
|
993
|
+
const currentElement = stack.pop();
|
|
994
|
+
|
|
995
|
+
if (is(currentElement, 'bpmn:SubProcess')) {
|
|
996
|
+
this.handlePlane(currentElement);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const nextElements = this.handle('addToGrid', { element: currentElement, grid, visited, stack });
|
|
1000
|
+
|
|
1001
|
+
nextElements.flat().forEach(el => {
|
|
1002
|
+
stack.push(el);
|
|
1003
|
+
visited.add(el);
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
787
1007
|
|
|
788
1008
|
getProcess() {
|
|
789
1009
|
return this.diagram.get('rootElements').find(el => el.$type === 'bpmn:Process');
|