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 CHANGED
@@ -2,46 +2,65 @@
2
2
 
3
3
  [![CI](https://github.com/bpmn-io/bpmn-auto-layout/actions/workflows/CI.yml/badge.svg)](https://github.com/bpmn-io/bpmn-auto-layout/actions/workflows/CI.yml)
4
4
 
5
- Get a layouted diagram of a BPMN process without graphical representation.
6
-
5
+ Create and layout the graphical representation of a BPMN diagram.
7
6
 
8
7
  ## Usage
9
8
 
10
- This library works in [Node.js](https://nodejs.org/) and in the browser.
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
- const diagramXML = '<bpmn:defintions ...></bpmn:defintions>';
14
+ import diagramXML from './diagram.bpmn';
18
15
 
19
- const layoutedDiagramXML = await layoutProcess(diagramXML);
16
+ const diagramWithLayoutXML = await layoutProcess(diagramXML);
20
17
 
21
- console.log(layoutedDiagramXML);
18
+ console.log(diagramWithLayoutXML);
22
19
  ```
23
- ## Unsupported Concepts and elements
24
20
 
25
- The Tool can currently not properly layout diagrams containing any of the following:
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
- * [Issues](https://github.com/bpmn-io/bpmn-auto-layout/issues)
34
-
33
+ * [Issues](https://github.com/bpmn-io/bpmn-auto-layout/issues)
35
34
 
36
- ## Building
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
- As part of the test run, visual test cases are generated to `test/generated/test.html`.
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 top
460
+ // Route on bottom
327
461
  return [
328
- getDockingPoint(sourceMid, sourceBounds, 't'),
329
- { x: sourceMid.x, y: sourceMid.y - DEFAULT_CELL_HEIGHT / 2 },
330
- { x: targetMid.x, y: sourceMid.y - DEFAULT_CELL_HEIGHT / 2 },
331
- getDockingPoint(targetMid, targetBounds, 't')
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(att => att.outgoing.reverse())
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
- const nextElements = [];
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
- const handlers = [ elementHandler, outgoingHandler, attacherHandler ];
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
- this.cleanDi();
690
- this.handlePlane(root);
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
- while (stack.length > 0) {
731
- const currentElement = stack.pop();
936
+ this.handleGrid(grid,visited,stack);
732
937
 
733
- if (is(currentElement, 'bpmn:SubProcess')) {
734
- this.handlePlane(currentElement);
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');