bpmn-auto-layout-extended 1.0.2 → 1.0.5

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,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/krasucki/bpmn-auto-layout-extended/actions/workflows/CI.yml/badge.svg)](https://github.com/krasucki/bpmn-auto-layout-extended/actions/workflows/CI.yml)
4
4
 
5
- Extended fork of [bpmn-auto-layout](https://github.com/bpmn-io/bpmn-auto-layout) with support for collaborations, message flows, text annotations, groups, and sub-process expansion.
5
+ Extended fork of [bpmn-auto-layout](https://github.com/bpmn-io/bpmn-auto-layout) with support for collaborations, message flows, text annotations, groups, lanes, and sub-process expansion.
6
6
 
7
7
  Create and layout the graphical representation of a BPMN diagram.
8
8
 
@@ -11,7 +11,7 @@ Create and layout the graphical representation of a BPMN diagram.
11
11
  This library works with [Node.js](https://nodejs.org/) and in the browser.
12
12
 
13
13
  ```javascript
14
- import { layoutProcess } from 'bpmn-auto-layout';
14
+ import { layoutProcess } from 'bpmn-auto-layout-extended';
15
15
 
16
16
  import diagramXML from './diagram.bpmn';
17
17
 
@@ -36,6 +36,7 @@ await layoutProcess(diagramXML, options);
36
36
  * **Message flows** — message flows between participants are routed as orthogonal edges.
37
37
  * **Text annotations and associations** — process-level and collaboration-level annotations are positioned above their associated element.
38
38
  * **Groups** — group shapes are emitted around their member elements.
39
+ * **Lanes (swimlanes)** — when a process defines a `laneSet`, elements are reorganized into lane-specific row bands and `BPMNShape` DI entries are emitted for each lane. Works both inside collaborations and for standalone processes (a collaboration+participant wrapper is synthesized automatically). Elements not explicitly assigned to a lane are inferred from their connections.
39
40
  * **Original participant gap preserved** — the gap between pool lanes from the input is carried over (capped at 100 px); annotations are accommodated by expanding the pool height.
40
41
  * **Sub-process expand support** — collapsed sub-processes retain their inner layout so they can be expanded; use `expandSubProcesses: true` to expand them inline during layout.
41
42
 
package/dist/index.cjs CHANGED
@@ -947,6 +947,7 @@ var incomingHandler = {
947
947
  const handlers = [ elementHandler, incomingHandler, outgoingHandler, attacherHandler ];
948
948
 
949
949
  const PARTICIPANT_LABEL_WIDTH = 30;
950
+ const LANE_LABEL_WIDTH = 30;
950
951
 
951
952
  class Layouter {
952
953
  constructor() {
@@ -979,19 +980,43 @@ class Layouter {
979
980
  const firstRootProcess = this.getProcess();
980
981
 
981
982
  if (firstRootProcess) {
982
- this.setExpandedPropertyToModdleElements(moddleObj, options);
983
- this.setExecutedProcesses(firstRootProcess);
984
- this.createGridsForProcesses();
985
- this.cleanDi();
986
- this.createRootDi(firstRootProcess);
987
- this.drawProcesses();
988
-
989
- // Draw artifacts and data associations for each laid out process
990
- for (const process of this.layoutedProcesses) {
991
- const diagram = this.diagram.diagrams.find(d => d.plane.bpmnElement === process)
992
- || this.diagram.diagrams[0];
993
- this.generateArtifactsDi(process, diagram);
994
- this.generateDataAssociationsDi(process, diagram);
983
+ const hasLanes = firstRootProcess.laneSets?.[0]?.lanes?.length > 0;
984
+
985
+ if (hasLanes) {
986
+
987
+ // Standalone process with lanes — synthesize a collaboration wrapper
988
+ // so layoutCollaboration() handles lane grid reorganization and DI.
989
+ const participant = this.moddle.create('bpmn:Participant', {
990
+ id: 'Participant_' + firstRootProcess.id,
991
+ name: firstRootProcess.name || firstRootProcess.id,
992
+ processRef: firstRootProcess
993
+ });
994
+ const collaboration = this.moddle.create('bpmn:Collaboration', {
995
+ id: 'Collaboration_' + firstRootProcess.id,
996
+ participants: [ participant ]
997
+ });
998
+ participant.$parent = collaboration;
999
+ collaboration.$parent = this.diagram;
1000
+ this.diagram.get('rootElements').push(collaboration);
1001
+
1002
+ this.setExpandedPropertyToModdleElements(moddleObj, options);
1003
+ this.cleanDi();
1004
+ this.layoutCollaboration(collaboration, options);
1005
+ } else {
1006
+ this.setExpandedPropertyToModdleElements(moddleObj, options);
1007
+ this.setExecutedProcesses(firstRootProcess);
1008
+ this.createGridsForProcesses();
1009
+ this.cleanDi();
1010
+ this.createRootDi(firstRootProcess);
1011
+ this.drawProcesses();
1012
+
1013
+ // Draw artifacts and data associations for each laid out process
1014
+ for (const process of this.layoutedProcesses) {
1015
+ const diagram = this.diagram.diagrams.find(d => d.plane.bpmnElement === process)
1016
+ || this.diagram.diagrams[0];
1017
+ this.generateArtifactsDi(process, diagram);
1018
+ this.generateDataAssociationsDi(process, diagram);
1019
+ }
995
1020
  }
996
1021
  }
997
1022
  }
@@ -1005,17 +1030,26 @@ class Layouter {
1005
1030
  // Build grids per participant's process
1006
1031
  const participantLayouts = collaboration.participants.map(participant => {
1007
1032
  const process = participant.processRef;
1008
- if (!process) return { participant, process: null, layoutedProcesses: [], grid: null };
1033
+ if (!process) return { participant, process: null, layoutedProcesses: [], grid: null, laneInfo: null };
1009
1034
 
1010
1035
  this.layoutedProcesses = [];
1011
1036
  this.setExecutedProcesses(process);
1012
1037
  this.createGridsForProcesses();
1013
1038
 
1039
+ // Detect lanes and reorganize grid so each lane occupies distinct row bands
1040
+ let laneInfo = null;
1041
+ const laneMapping = this.getLaneMapping(process);
1042
+ if (laneMapping && process.grid) {
1043
+ const laneRowRanges = this.reorganizeGridByLanes(process.grid, laneMapping);
1044
+ laneInfo = { lanes: laneMapping.lanes, laneRowRanges };
1045
+ }
1046
+
1014
1047
  return {
1015
1048
  participant,
1016
1049
  process,
1017
1050
  layoutedProcesses: [ ...this.layoutedProcesses ],
1018
- grid: process.grid
1051
+ grid: process.grid,
1052
+ laneInfo
1019
1053
  };
1020
1054
  });
1021
1055
 
@@ -1025,17 +1059,18 @@ class Layouter {
1025
1059
  const participantFloors = new Map();
1026
1060
  let currentY = 0;
1027
1061
 
1028
- for (const { participant, process, layoutedProcesses, grid } of participantLayouts) {
1062
+ for (const { participant, process, layoutedProcesses, grid, laneInfo } of participantLayouts) {
1029
1063
  if (process) participantFloors.set(process, currentY);
1030
1064
 
1031
1065
  let participantWidth, participantHeight;
1066
+ const hasLanes = !!laneInfo;
1032
1067
 
1033
1068
  if (!grid) {
1034
1069
  participantWidth = 400;
1035
1070
  participantHeight = DEFAULT_CELL_HEIGHT;
1036
1071
  } else {
1037
1072
  const [ rows, cols ] = grid.getGridDimensions();
1038
- participantWidth = PARTICIPANT_LABEL_WIDTH + Math.max(cols, 1) * DEFAULT_CELL_WIDTH;
1073
+ participantWidth = PARTICIPANT_LABEL_WIDTH + (hasLanes ? LANE_LABEL_WIDTH : 0) + Math.max(cols, 1) * DEFAULT_CELL_WIDTH;
1039
1074
  participantHeight = Math.max(rows, 1) * DEFAULT_CELL_HEIGHT;
1040
1075
  }
1041
1076
 
@@ -1061,8 +1096,20 @@ class Layouter {
1061
1096
 
1062
1097
  if (grid) {
1063
1098
 
1099
+ // Emit lane shapes BEFORE element shapes — bpmn-js uses DI order to
1100
+ // establish parent-child containment (participant → lanes → elements → edges)
1101
+ if (hasLanes) {
1102
+ this.generateLanesDi(
1103
+ laneInfo.lanes,
1104
+ laneInfo.laneRowRanges,
1105
+ { x: 0, y: currentY, width: participantWidth, height: participantHeight },
1106
+ annotationPadding,
1107
+ collaborationDi
1108
+ );
1109
+ }
1110
+
1064
1111
  // Draw flow elements with participant offset (shifted down by annotation padding)
1065
- const shift = { x: PARTICIPANT_LABEL_WIDTH, y: currentY + annotationPadding };
1112
+ const shift = { x: PARTICIPANT_LABEL_WIDTH + (hasLanes ? LANE_LABEL_WIDTH : 0), y: currentY + annotationPadding };
1066
1113
  this.generateDi(grid, shift, collaborationDi);
1067
1114
 
1068
1115
  // Draw expanded sub-processes within this participant
@@ -1468,8 +1515,10 @@ class Layouter {
1468
1515
  const participants = collaboration.participants;
1469
1516
  if (participants.length < 2) return 0;
1470
1517
 
1471
- const shapes = this.diagram.diagrams
1472
- .flatMap(d => d.plane.planeElement)
1518
+ // Guard: when input XML for some reason has no DI section diagrams and planeElement are undefined
1519
+ // until cleanDi() rebuilds them.
1520
+ const shapes = (this.diagram.diagrams || [])
1521
+ .flatMap(d => d.plane.planeElement || [])
1473
1522
  .filter(el => el.$type === 'bpmndi:BPMNShape' && participants.includes(el.bpmnElement));
1474
1523
 
1475
1524
  if (shapes.length < 2) return 0;
@@ -1560,6 +1609,154 @@ class Layouter {
1560
1609
  getProcDi(element) {
1561
1610
  return this.diagram.diagrams.find(diagram => diagram.plane.planeElement.includes(element));
1562
1611
  }
1612
+
1613
+ /**
1614
+ * Detect lanes in a process and build element-to-lane mapping.
1615
+ * @param {Object} process - BPMN process moddle element
1616
+ * @returns {{ lanes: Object[], elementToLaneIdx: Map }|null}
1617
+ */
1618
+ getLaneMapping(process) {
1619
+ const laneSet = (process.laneSets || [])[0];
1620
+ if (!laneSet) return null;
1621
+
1622
+ const lanes = laneSet.lanes || [];
1623
+ if (lanes.length === 0) return null;
1624
+
1625
+ // bpmn-moddle stores flowNodeRef as string IDs (not object references)
1626
+ const idToLaneIdx = new Map();
1627
+ lanes.forEach((lane, idx) => {
1628
+ (lane.flowNodeRef || []).forEach(ref => {
1629
+ const id = typeof ref === 'string' ? ref : ref.id;
1630
+ idToLaneIdx.set(id, idx);
1631
+ });
1632
+ });
1633
+
1634
+ return { lanes, idToLaneIdx };
1635
+ }
1636
+
1637
+ /**
1638
+ * Reorganize grid rows so each lane's elements occupy distinct, non-overlapping
1639
+ * row bands. Elements keep their column positions; only rows are remapped.
1640
+ *
1641
+ * @param {Grid} grid - The process grid (modified in place)
1642
+ * @param {{ lanes: Object[], elementToLaneIdx: Map }} laneMapping
1643
+ * @returns {Array<{ startRow: number, rowCount: number }>} Row range per lane
1644
+ */
1645
+ reorganizeGridByLanes(grid, laneMapping) {
1646
+ const { lanes, idToLaneIdx } = laneMapping;
1647
+ const positioned = grid.elementsByPosition();
1648
+
1649
+ // Group elements by lane index (lookup by element ID)
1650
+ const byLane = new Map();
1651
+ positioned.forEach(({ element, row, col }) => {
1652
+ let laneIdx = idToLaneIdx.get(element.id);
1653
+
1654
+ // Infer lane for unassigned elements from connected elements
1655
+ if (laneIdx === undefined) {
1656
+ laneIdx = this.inferLane(element, idToLaneIdx) ?? 0;
1657
+ }
1658
+
1659
+ if (!byLane.has(laneIdx)) byLane.set(laneIdx, []);
1660
+ byLane.get(laneIdx).push({ element, origRow: row, col });
1661
+ });
1662
+
1663
+ // Build new grid data with lanes in separate row bands
1664
+ const newGridData = [];
1665
+ const laneRowRanges = [];
1666
+
1667
+ for (let i = 0; i < lanes.length; i++) {
1668
+ const group = byLane.get(i) || [];
1669
+ const startRow = newGridData.length;
1670
+
1671
+ if (group.length === 0) {
1672
+
1673
+ // Empty lane still gets one row for visual presence
1674
+ newGridData.push([]);
1675
+ laneRowRanges.push({ startRow, rowCount: 1 });
1676
+ continue;
1677
+ }
1678
+
1679
+ // Map original rows to consecutive new rows within this lane band
1680
+ const origRows = [ ...new Set(group.map(g => g.origRow)) ].sort((a, b) => a - b);
1681
+ const rowMap = new Map();
1682
+ origRows.forEach((r, idx) => rowMap.set(r, startRow + idx));
1683
+
1684
+ // Initialize empty rows
1685
+ for (let j = 0; j < origRows.length; j++) newGridData.push([]);
1686
+
1687
+ // Place elements in their new positions
1688
+ group.forEach(({ element, origRow, col }) => {
1689
+ const newRow = rowMap.get(origRow);
1690
+ while (newGridData[newRow].length <= col) newGridData[newRow].push(undefined);
1691
+ newGridData[newRow][col] = element;
1692
+ });
1693
+
1694
+ laneRowRanges.push({ startRow, rowCount: origRows.length });
1695
+ }
1696
+
1697
+ // Replace grid's internal data
1698
+ grid.grid = newGridData;
1699
+ return laneRowRanges;
1700
+ }
1701
+
1702
+ /**
1703
+ * Infer lane index for an element not explicitly assigned to any lane,
1704
+ * by checking its connected elements.
1705
+ */
1706
+ inferLane(element, idToLaneIdx) {
1707
+ const outgoing = (element.outgoing || []).map(e => e.targetRef).filter(Boolean);
1708
+ const incoming = (element.incoming || []).map(e => e.sourceRef).filter(Boolean);
1709
+
1710
+ for (const connected of [ ...outgoing, ...incoming ]) {
1711
+ const idx = idToLaneIdx.get(connected.id);
1712
+ if (idx !== undefined) return idx;
1713
+ }
1714
+
1715
+ return undefined;
1716
+ }
1717
+
1718
+ /**
1719
+ * Generate BPMNShape DI entries for lanes within a participant.
1720
+ *
1721
+ * @param {Object[]} lanes - Lane moddle elements
1722
+ * @param {Array<{ startRow: number, rowCount: number }>} laneRowRanges
1723
+ * @param {{ x: number, y: number, width: number, height: number }} participantBounds
1724
+ * @param {number} annotationPadding - Extra top padding for annotations
1725
+ * @param {Object} collaborationDi - Target diagram for DI shapes
1726
+ */
1727
+ generateLanesDi(lanes, laneRowRanges, participantBounds, annotationPadding, collaborationDi) {
1728
+ const planeElement = collaborationDi.plane.get('planeElement');
1729
+
1730
+ const laneX = participantBounds.x + PARTICIPANT_LABEL_WIDTH;
1731
+ const laneWidth = participantBounds.width - PARTICIPANT_LABEL_WIDTH;
1732
+
1733
+ let cumulativeY = participantBounds.y;
1734
+
1735
+ lanes.forEach((lane, idx) => {
1736
+ const range = laneRowRanges[idx];
1737
+ let laneHeight = range.rowCount * DEFAULT_CELL_HEIGHT;
1738
+
1739
+ // First lane absorbs annotation padding
1740
+ if (idx === 0) {
1741
+ laneHeight += annotationPadding;
1742
+ }
1743
+
1744
+ const shape = this.diFactory.createDiShape(lane, {
1745
+ x: laneX,
1746
+ y: cumulativeY,
1747
+ width: laneWidth,
1748
+ height: laneHeight
1749
+ }, {
1750
+ id: lane.id + '_di',
1751
+ isHorizontal: true
1752
+ });
1753
+
1754
+ lane.di = shape;
1755
+ planeElement.push(shape);
1756
+
1757
+ cumulativeY += laneHeight;
1758
+ });
1759
+ }
1563
1760
  }
1564
1761
 
1565
1762
  function containsElement(process, element) {