bpmn-auto-layout 0.4.0 → 1.0.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 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/fixures) 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 {
@@ -323,12 +427,12 @@ function connectElements(source, target, layoutGrid) {
323
427
  if (dY === 0) {
324
428
  if (isDirectPathBlocked(source, target, layoutGrid)) {
325
429
 
326
- // Route on top
430
+ // Route on bottom
327
431
  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')
432
+ getDockingPoint(sourceMid, sourceBounds, 'b'),
433
+ { x: sourceMid.x, y: sourceMid.y + DEFAULT_CELL_HEIGHT / 2 },
434
+ { x: targetMid.x, y: sourceMid.y + DEFAULT_CELL_HEIGHT / 2 },
435
+ getDockingPoint(targetMid, targetBounds, 'b')
332
436
  ];
333
437
  } else {
334
438
 
@@ -362,6 +466,15 @@ function connectElements(source, target, layoutGrid) {
362
466
  }
363
467
  }
364
468
 
469
+ // negative dX indicates connection from future to past
470
+ if (dX < 0 && dY <= 0) {
471
+ return [
472
+ getDockingPoint(sourceMid, sourceBounds, 'b'),
473
+ { x: sourceMid.x, y: sourceMid.y + DEFAULT_CELL_HEIGHT / 2 },
474
+ { x: targetMid.x, y: sourceMid.y + DEFAULT_CELL_HEIGHT / 2 },
475
+ getDockingPoint(targetMid, targetBounds, 'b')
476
+ ];
477
+ }
365
478
  const directManhattan = directManhattanConnect(source, target, layoutGrid);
366
479
 
367
480
  if (directManhattan) {
@@ -477,7 +590,7 @@ var attacherHandler = {
477
590
  const nextElements = [];
478
591
 
479
592
  const attachedOutgoing = (element.attachers || [])
480
- .map(att => att.outgoing.reverse())
593
+ .map(attacher => (attacher.outgoing || []).reverse())
481
594
  .flat()
482
595
  .map(out => out.targetRef);
483
596
 
@@ -550,11 +663,6 @@ function insertIntoGrid(newElement, host, grid) {
550
663
  grid.createRow(row);
551
664
  }
552
665
 
553
- // Host has element directly after, add space
554
- if (grid.get(row, col + 1)) {
555
- grid.addAfter(host, null);
556
- }
557
-
558
666
  grid.add(newElement, [ row + 1, col + 1 ]);
559
667
  }
560
668
 
@@ -614,8 +722,8 @@ var elementHandler = {
614
722
  };
615
723
 
616
724
  var outgoingHandler = {
617
- 'addToGrid': ({ element, grid, visited }) => {
618
- const nextElements = [];
725
+ 'addToGrid': ({ element, grid, visited, stack }) => {
726
+ let nextElements = [];
619
727
 
620
728
  // Handle outgoing paths
621
729
  const outgoing = (element.outgoing || [])
@@ -623,14 +731,28 @@ var outgoingHandler = {
623
731
  .filter(el => el);
624
732
 
625
733
  let previousElement = null;
734
+
735
+ if (outgoing.length > 1 && isNextElementTasks(outgoing)) {
736
+ grid.adjustGridPosition(element);
737
+ }
738
+
626
739
  outgoing.forEach((nextElement, index, arr) => {
627
740
  if (visited.has(nextElement)) {
628
741
  return;
629
742
  }
630
743
 
744
+ // Prevents revisiting future incoming elements and ensures proper traversal without early exit.
745
+ if ((previousElement || stack.length > 0) && isFutureIncoming(nextElement, visited) && !checkForLoop(nextElement, visited)) {
746
+ return;
747
+ }
748
+
631
749
  if (!previousElement) {
632
750
  grid.addAfter(element, nextElement);
633
751
  }
752
+
753
+ else if (is(element, 'bpmn:ExclusiveGateway') && is(nextElement, 'bpmn:ExclusiveGateway')) {
754
+ grid.addAfter(previousElement, nextElement);
755
+ }
634
756
  else {
635
757
  grid.addBelow(arr[index - 1], nextElement);
636
758
  }
@@ -644,8 +766,11 @@ var outgoingHandler = {
644
766
  visited.add(nextElement);
645
767
  });
646
768
 
769
+ // Sort elements by priority to ensure proper stack placement
770
+ nextElements = sortByType(nextElements, 'bpmn:ExclusiveGateway'); // TODO: sort by priority
647
771
  return nextElements;
648
772
  },
773
+
649
774
  'createConnectionDi': ({ element, row, col, layoutGrid, diFactory }) => {
650
775
  const outgoing = element.outgoing || [];
651
776
 
@@ -663,7 +788,59 @@ var outgoingHandler = {
663
788
  }
664
789
  };
665
790
 
666
- const handlers = [ elementHandler, outgoingHandler, attacherHandler ];
791
+
792
+ // helpers /////
793
+
794
+ function sortByType(arr, type) {
795
+ const nonMatching = arr.filter(item => !is(item,type));
796
+ const matching = arr.filter(item => is(item,type));
797
+
798
+ return [ ...matching, ...nonMatching ];
799
+
800
+ }
801
+
802
+ function checkForLoop(element, visited) {
803
+ for (const incomingElement of element.incoming) {
804
+ if (!visited.has(incomingElement.sourceRef)) {
805
+ return findElementInTree(element, incomingElement.sourceRef);
806
+ }
807
+ }
808
+ }
809
+
810
+
811
+ function isFutureIncoming(element, visited) {
812
+ if (element.incoming.length > 1) {
813
+ for (const incomingElement of element.incoming) {
814
+ if (!visited.has(incomingElement.sourceRef)) {
815
+ return true;
816
+ }
817
+ }
818
+ }
819
+ return false;
820
+ }
821
+
822
+ function isNextElementTasks(elements) {
823
+ return elements.every(element => is(element, 'bpmn:Task'));
824
+ }
825
+
826
+ var incomingHandler = {
827
+ 'addToGrid': ({ element, grid, visited }) => {
828
+ const nextElements = [];
829
+
830
+ const incoming = (element.incoming || [])
831
+ .map(out => out.sourceRef)
832
+ .filter(el => el);
833
+
834
+ // adjust the row if it is empty
835
+ if (incoming.length > 1) {
836
+ grid.adjustColumnForMultipleIncoming(incoming, element);
837
+ grid.adjustRowForMultipleIncoming(incoming, element);
838
+ }
839
+ return nextElements;
840
+ },
841
+ };
842
+
843
+ const handlers = [ elementHandler, incomingHandler, outgoingHandler, attacherHandler ];
667
844
 
668
845
  class Layouter {
669
846
  constructor() {
@@ -686,8 +863,10 @@ class Layouter {
686
863
 
687
864
  const root = this.getProcess();
688
865
 
689
- this.cleanDi();
690
- this.handlePlane(root);
866
+ if (root) {
867
+ this.cleanDi();
868
+ this.handlePlane(root);
869
+ }
691
870
 
692
871
  return (await this.moddle.toXML(this.diagram, { format: true })).xml;
693
872
  }
@@ -704,7 +883,13 @@ class Layouter {
704
883
  createGridLayout(root) {
705
884
  const grid = new Grid();
706
885
 
707
- const flowElements = root.flowElements;
886
+ const flowElements = root.flowElements || [];
887
+ const elements = flowElements.filter(el => !is(el,'bpmn:SequenceFlow'));
888
+
889
+ // check for empty process/subprocess
890
+ if (!flowElements) {
891
+ return grid;
892
+ }
708
893
 
709
894
  const startingElements = flowElements.filter(el => {
710
895
  return !isConnection(el) && !isBoundaryEvent(el) && (!el.incoming || el.length === 0);
@@ -727,19 +912,17 @@ class Layouter {
727
912
  visited.add(el);
728
913
  });
729
914
 
730
- while (stack.length > 0) {
731
- const currentElement = stack.pop();
915
+ this.handleGrid(grid,visited,stack);
732
916
 
733
- if (is(currentElement, 'bpmn:SubProcess')) {
734
- this.handlePlane(currentElement);
917
+ if (grid.getElementsTotal() != elements.length) {
918
+ const gridElements = grid.getAllElements();
919
+ const missingElements = elements.filter(el => !gridElements.includes(el) && !isBoundaryEvent(el));
920
+ if (missingElements.length > 1) {
921
+ stack.push(missingElements[0]);
922
+ grid.add(missingElements[0]);
923
+ visited.add(missingElements[0]);
924
+ this.handleGrid(grid,visited,stack);
735
925
  }
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
926
  }
744
927
 
745
928
  return grid;
@@ -784,6 +967,22 @@ class Layouter {
784
967
  });
785
968
  }
786
969
 
970
+ handleGrid(grid, visited, stack) {
971
+ while (stack.length > 0) {
972
+ const currentElement = stack.pop();
973
+
974
+ if (is(currentElement, 'bpmn:SubProcess')) {
975
+ this.handlePlane(currentElement);
976
+ }
977
+
978
+ const nextElements = this.handle('addToGrid', { element: currentElement, grid, visited, stack });
979
+
980
+ nextElements.flat().forEach(el => {
981
+ stack.push(el);
982
+ visited.add(el);
983
+ });
984
+ }
985
+ }
787
986
 
788
987
  getProcess() {
789
988
  return this.diagram.get('rootElements').find(el => el.$type === 'bpmn:Process');