@bpmnkit/core 0.1.1 → 0.1.2

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.
Files changed (56) hide show
  1. package/README.md +2 -0
  2. package/dist/bpmn/agentic.d.ts +121 -0
  3. package/dist/bpmn/agentic.js +97 -0
  4. package/dist/bpmn/auto-layout.d.ts +5 -5
  5. package/dist/bpmn/auto-layout.js +592 -36
  6. package/dist/bpmn/bpmn-builder.d.ts +56 -0
  7. package/dist/bpmn/bpmn-builder.js +148 -182
  8. package/dist/bpmn/bpmn-model.d.ts +4 -0
  9. package/dist/bpmn/bpmn-parser.js +9 -1
  10. package/dist/bpmn/bpmn-serializer.js +6 -0
  11. package/dist/bpmn/optimize/agentic.d.ts +10 -0
  12. package/dist/bpmn/optimize/agentic.js +88 -0
  13. package/dist/bpmn/optimize/deploy.d.ts +16 -0
  14. package/dist/bpmn/optimize/deploy.js +143 -0
  15. package/dist/bpmn/optimize/feel-syntax.d.ts +12 -0
  16. package/dist/bpmn/optimize/feel-syntax.js +87 -0
  17. package/dist/bpmn/optimize/feel.js +5 -2
  18. package/dist/bpmn/optimize/flow.js +22 -2
  19. package/dist/bpmn/optimize/index.js +20 -9
  20. package/dist/bpmn/optimize/types.d.ts +10 -1
  21. package/dist/bpmn/zeebe-extensions.d.ts +27 -0
  22. package/dist/bpmn/zeebe-extensions.js +38 -0
  23. package/dist/index.d.ts +6 -1
  24. package/dist/index.js +2 -0
  25. package/dist/layout/annotations.js +36 -1
  26. package/dist/layout/collaboration/alignment.d.ts +26 -0
  27. package/dist/layout/collaboration/alignment.js +66 -0
  28. package/dist/layout/collaboration/ordering.d.ts +21 -0
  29. package/dist/layout/collaboration/ordering.js +102 -0
  30. package/dist/layout/index.d.ts +1 -0
  31. package/dist/layout/layout-engine.d.ts +13 -3
  32. package/dist/layout/layout-engine.js +9 -4
  33. package/dist/layout/semantic/bands.d.ts +19 -0
  34. package/dist/layout/semantic/bands.js +324 -0
  35. package/dist/layout/semantic/graph.d.ts +29 -0
  36. package/dist/layout/semantic/graph.js +217 -0
  37. package/dist/layout/semantic/index.d.ts +13 -0
  38. package/dist/layout/semantic/index.js +181 -0
  39. package/dist/layout/semantic/place.d.ts +40 -0
  40. package/dist/layout/semantic/place.js +271 -0
  41. package/dist/layout/semantic/route.d.ts +14 -0
  42. package/dist/layout/semantic/route.js +454 -0
  43. package/dist/layout/types.d.ts +17 -0
  44. package/dist/plan/compile.d.ts +39 -0
  45. package/dist/plan/compile.js +380 -0
  46. package/dist/plan/extract.d.ts +31 -0
  47. package/dist/plan/extract.js +248 -0
  48. package/dist/plan/index.d.ts +6 -0
  49. package/dist/plan/index.js +5 -0
  50. package/dist/plan/merge.d.ts +13 -0
  51. package/dist/plan/merge.js +80 -0
  52. package/dist/plan/slug.d.ts +5 -0
  53. package/dist/plan/slug.js +22 -0
  54. package/dist/plan/types.d.ts +225 -0
  55. package/dist/plan/types.js +13 -0
  56. package/package.json +2 -2
@@ -1,4 +1,7 @@
1
1
  import { associationWaypoints, packAnnotations } from "../layout/annotations.js";
2
+ import { alignPools } from "../layout/collaboration/alignment.js";
3
+ import { orderPools } from "../layout/collaboration/ordering.js";
4
+ import { collapseCollinear } from "../layout/grid/grid-router.js";
2
5
  import { layoutProcess } from "../layout/layout-engine.js";
3
6
  const POOL_HEADER = 30;
4
7
  const LANE_HEADER = 30;
@@ -166,10 +169,30 @@ function addAnnotationShapes(process, layoutNodes, annLocalBounds, allShapes, al
166
169
  : annLocalBounds.has(assoc.targetRef)
167
170
  ? assoc.targetRef
168
171
  : undefined;
172
+ // An association does not have to involve a text annotation — a data
173
+ // object may be associated with an activity. Both ends are placed, so
174
+ // the same docking works.
175
+ if (!annId) {
176
+ const source = nodeById.get(assoc.sourceRef);
177
+ const target = nodeById.get(assoc.targetRef);
178
+ if (!source || !target)
179
+ continue;
180
+ const { pElem, pAnn } = associationWaypoints(source.bounds, target.bounds);
181
+ allEdges.push({
182
+ id: `${assoc.id}_di`,
183
+ bpmnElement: assoc.id,
184
+ waypoints: [
185
+ { x: Math.round(pElem.x + dx), y: Math.round(pElem.y + dy) },
186
+ { x: Math.round(pAnn.x + dx), y: Math.round(pAnn.y + dy) },
187
+ ],
188
+ unknownAttributes: {},
189
+ });
190
+ continue;
191
+ }
169
192
  const elId = annId === assoc.sourceRef ? assoc.targetRef : assoc.sourceRef;
170
- const annB = annId ? annLocalBounds.get(annId) : undefined;
193
+ const annB = annLocalBounds.get(annId);
171
194
  const elNode = nodeById.get(elId);
172
- if (!annB || !annId || !elNode)
195
+ if (!annB || !elNode)
173
196
  continue;
174
197
  const { pElem, pAnn } = associationWaypoints(elNode.bounds, annB);
175
198
  // Shift into diagram space and honour the original sourceRef→targetRef order.
@@ -188,6 +211,246 @@ function addAnnotationShapes(process, layoutNodes, annLocalBounds, allShapes, al
188
211
  });
189
212
  }
190
213
  }
214
+ const EMPTY_POOL_HEIGHT = 60;
215
+ /** Sideways step when nudging a message flow out of a shape. */
216
+ const MESSAGE_FLOW_STEP = 10;
217
+ /** Stub a message flow leaves its element by before it may jog sideways. */
218
+ const MESSAGE_FLOW_STEM = 20;
219
+ /** Stagger between the stems of consecutive flows. */
220
+ const MESSAGE_FLOW_STEM_STEP = 8;
221
+ /** Step and reach of that sideways jog. */
222
+ const MESSAGE_FLOW_JOG = 20;
223
+ const MESSAGE_FLOW_MAX_JOG = 2400;
224
+ const EMPTY_POOL_WIDTH = 300;
225
+ /**
226
+ * Sub-processes the source diagram draws collapsed. A collapsed sub-process
227
+ * keeps its activity shape on the parent plane and carries its contents on a
228
+ * plane of its own, so the layout must not inline them.
229
+ *
230
+ * Either marker counts: an explicit `isExpanded="false"` on the shape, or the
231
+ * presence of a separate plane for that sub-process.
232
+ */
233
+ function collapsedSubProcesses(defs) {
234
+ const containers = new Set();
235
+ const walk = (elements) => {
236
+ for (const el of elements) {
237
+ const sub = el;
238
+ if (sub.flowElements) {
239
+ containers.add(el.id);
240
+ walk(sub.flowElements);
241
+ }
242
+ }
243
+ };
244
+ for (const process of defs.processes)
245
+ walk(process.flowElements);
246
+ const collapsed = new Set();
247
+ for (const diagram of defs.diagrams) {
248
+ if (containers.has(diagram.plane.bpmnElement))
249
+ collapsed.add(diagram.plane.bpmnElement);
250
+ for (const shape of diagram.plane.shapes) {
251
+ if (shape.isExpanded === false && containers.has(shape.bpmnElement)) {
252
+ collapsed.add(shape.bpmnElement);
253
+ }
254
+ }
255
+ }
256
+ return collapsed;
257
+ }
258
+ /** One `BPMNDiagram` per collapsed sub-process, its contents laid out at the origin. */
259
+ function childPlaneDiagrams(planes, defs, collapsed) {
260
+ const existing = new Map(defs.diagrams.map((d) => [d.plane.bpmnElement, d]));
261
+ const diagrams = [];
262
+ const emitted = new Set(planes.map((plane) => plane.elementId));
263
+ // A collapsed sub-process that holds nothing still owns its plane; dropping it
264
+ // would lose the drill-down target the source diagram declared.
265
+ for (const [elementId, diagram] of existing) {
266
+ if (!collapsed.has(elementId) || emitted.has(elementId))
267
+ continue;
268
+ diagrams.push({
269
+ id: diagram.id,
270
+ plane: { id: diagram.plane.id, bpmnElement: elementId, shapes: [], edges: [] },
271
+ });
272
+ }
273
+ for (const plane of planes) {
274
+ const { minX, minY } = contentBbox(plane.result.nodes);
275
+ if (!Number.isFinite(minX))
276
+ continue;
277
+ const dx = PADDING - minX;
278
+ const dy = PADDING - minY;
279
+ const previous = existing.get(plane.elementId);
280
+ diagrams.push({
281
+ id: previous?.id ?? `BPMNDiagram_${plane.elementId}`,
282
+ plane: {
283
+ id: previous?.plane.id ?? `BPMNPlane_${plane.elementId}`,
284
+ bpmnElement: plane.elementId,
285
+ shapes: plane.result.nodes.map((node) => nodeToShape(node, dx, dy)),
286
+ edges: plane.result.edges.map((edge) => edgeToShape(edge, dx, dy)),
287
+ },
288
+ });
289
+ }
290
+ return diagrams;
291
+ }
292
+ /** Vertical bands between pools — clear ground for a message flow to cross in. */
293
+ function poolBands(shapes, participantIds) {
294
+ const pools = shapes
295
+ .filter((s) => participantIds.has(s.bpmnElement))
296
+ .map((s) => ({ top: s.bounds.y, bottom: s.bounds.y + s.bounds.height }))
297
+ .sort((a, b) => a.top - b.top);
298
+ const gaps = [];
299
+ for (let i = 0; i + 1 < pools.length; i++) {
300
+ const above = pools[i];
301
+ const below = pools[i + 1];
302
+ if (above && below && below.top > above.bottom) {
303
+ gaps.push({ top: above.bottom, bottom: below.top });
304
+ }
305
+ }
306
+ return gaps;
307
+ }
308
+ /** True when a vertical run at `x` between two heights would cross a shape. */
309
+ function columnBlocked(x, fromY, toY, obstacles, own) {
310
+ const top = Math.min(fromY, toY);
311
+ const bottom = Math.max(fromY, toY);
312
+ return obstacles.some((o) => !own.includes(o) && o.x < x && x < o.x + o.width && o.y < bottom && top < o.y + o.height);
313
+ }
314
+ /**
315
+ * Shift a vertical run sideways until it misses every shape it would otherwise
316
+ * pass through. The run has to stay on the element it docks onto, so the search
317
+ * is bounded by that element's own width.
318
+ */
319
+ function clearColumn(x, fromY, toY, obstacles, own, dock) {
320
+ if (!columnBlocked(x, fromY, toY, obstacles, own))
321
+ return x;
322
+ const limit = Math.floor(dock.width / 2);
323
+ for (let step = MESSAGE_FLOW_STEP; step <= limit; step += MESSAGE_FLOW_STEP) {
324
+ if (!columnBlocked(x + step, fromY, toY, obstacles, own))
325
+ return x + step;
326
+ if (!columnBlocked(x - step, fromY, toY, obstacles, own))
327
+ return x - step;
328
+ }
329
+ return x;
330
+ }
331
+ /**
332
+ * One vertical leg of a message flow, from its dock to the crossing band.
333
+ *
334
+ * A straight drop is used when the column is clear. Otherwise the leg leaves
335
+ * the element by a short stem and jogs sideways to a column that is clear for
336
+ * the rest of the descent — the same move a modeller makes by hand, and the
337
+ * only way past a shape sitting directly below the dock.
338
+ */
339
+ function messageFlowLeg(dockX, fromY, toY, obstacles, own, dock,
340
+ /** Staggers the stem so two flows jogging side by side do not share a line. */
341
+ stemOffset = 0) {
342
+ const straight = clearColumn(dockX, fromY, toY, obstacles, own, dock);
343
+ if (!columnBlocked(straight, fromY, toY, obstacles, own)) {
344
+ return {
345
+ points: [
346
+ { x: straight, y: fromY },
347
+ { x: straight, y: toY },
348
+ ],
349
+ blocked: false,
350
+ };
351
+ }
352
+ const down = toY > fromY;
353
+ const stem = MESSAGE_FLOW_STEM + stemOffset;
354
+ const stemY = down ? fromY + stem : fromY - stem;
355
+ const rowBlocked = (x) => obstacles.some((o) => !own.includes(o) &&
356
+ o.x < Math.max(dockX, x) &&
357
+ Math.min(dockX, x) < o.x + o.width &&
358
+ o.y < stemY &&
359
+ stemY < o.y + o.height);
360
+ for (let offset = MESSAGE_FLOW_JOG; offset <= MESSAGE_FLOW_MAX_JOG; offset += MESSAGE_FLOW_JOG) {
361
+ for (const candidate of [dockX + offset, dockX - offset]) {
362
+ if (rowBlocked(candidate))
363
+ continue;
364
+ if (columnBlocked(candidate, stemY, toY, obstacles, own))
365
+ continue;
366
+ return {
367
+ points: [
368
+ { x: dockX, y: fromY },
369
+ { x: dockX, y: stemY },
370
+ { x: candidate, y: stemY },
371
+ { x: candidate, y: toY },
372
+ ],
373
+ blocked: false,
374
+ };
375
+ }
376
+ }
377
+ return {
378
+ points: [
379
+ { x: straight, y: fromY },
380
+ { x: straight, y: toY },
381
+ ],
382
+ blocked: true,
383
+ };
384
+ }
385
+ /**
386
+ * Pick where a message flow crosses between pools and how each of its two legs
387
+ * gets there.
388
+ *
389
+ * Bands are tried nearest-first: the gaps between pools are clear ground, so a
390
+ * route that reaches one has nothing left to cross. Each leg is checked over
391
+ * its own stretch rather than the whole run, which is what lets a flow slip
392
+ * past shapes on the far side of the band.
393
+ */
394
+ function messageFlowRoute(src, tgt, srcIsPool, tgtIsPool, gaps, obstacles, stemOffset,
395
+ /** Containers holding an endpoint: a route out of one has to cross it. */
396
+ containers = []) {
397
+ const srcBelow = src.y + src.height / 2 > tgt.y + tgt.height / 2;
398
+ const sy = srcBelow ? src.y : src.y + src.height;
399
+ const ty = srcBelow ? tgt.y + tgt.height : tgt.y;
400
+ // The endpoints themselves, plus any expanded sub-process they sit inside:
401
+ // leaving an element means crossing the border of whatever contains it, so
402
+ // treating that border as an obstacle would leave the route no way out.
403
+ const own = [src, tgt, ...containers];
404
+ const midpoint = (sy + ty) / 2;
405
+ const low = Math.min(sy, ty);
406
+ const high = Math.max(sy, ty);
407
+ const build = (bandY) => {
408
+ const source = messageFlowLeg(Math.round(src.x + src.width / 2), sy, bandY, obstacles, own, src, stemOffset);
409
+ const target = messageFlowLeg(Math.round(tgt.x + tgt.width / 2), ty, bandY, obstacles, own, tgt, stemOffset);
410
+ // A pool has no position of its own to respect: dock it under whatever it
411
+ // is talking to, so the flow drops straight instead of fanning into the
412
+ // pool's centre along with every other flow.
413
+ if (tgtIsPool && !srcIsPool) {
414
+ const x = source.points[source.points.length - 1]?.x ?? 0;
415
+ return {
416
+ source: source.points,
417
+ target: [
418
+ { x, y: ty },
419
+ { x, y: bandY },
420
+ ],
421
+ blocked: source.blocked,
422
+ };
423
+ }
424
+ if (srcIsPool && !tgtIsPool) {
425
+ const x = target.points[target.points.length - 1]?.x ?? 0;
426
+ return {
427
+ source: [
428
+ { x, y: sy },
429
+ { x, y: bandY },
430
+ ],
431
+ target: target.points,
432
+ blocked: target.blocked,
433
+ };
434
+ }
435
+ return {
436
+ source: source.points,
437
+ target: target.points,
438
+ blocked: source.blocked || target.blocked,
439
+ };
440
+ };
441
+ const candidates = gaps
442
+ .map((gap, index) => ({ index, y: (gap.top + gap.bottom) / 2 }))
443
+ .filter((candidate) => candidate.y >= low && candidate.y <= high)
444
+ .sort((a, b) => Math.abs(a.y - midpoint) - Math.abs(b.y - midpoint));
445
+ for (const candidate of candidates) {
446
+ const route = build(candidate.y);
447
+ if (!route.blocked)
448
+ return { source: route.source, target: route.target, gap: candidate.index };
449
+ }
450
+ const fallback = candidates[0];
451
+ const route = build(fallback?.y ?? midpoint);
452
+ return { source: route.source, target: route.target, gap: fallback?.index ?? -1 };
453
+ }
191
454
  /**
192
455
  * Apply auto-layout to all processes in a BpmnDefinitions, replacing the
193
456
  * diagram interchange (BPMNDi) with freshly computed positions.
@@ -195,7 +458,118 @@ function addAnnotationShapes(process, layoutNodes, annLocalBounds, allShapes, al
195
458
  * - Handles plain processes (no collaboration) and collaborations with pools.
196
459
  * - When pools have lanes, lane shapes are tiled vertically around the process content.
197
460
  */
198
- export function applyAutoLayout(defs) {
461
+ /** Every element id in a collaboration mapped to the index of the pool holding it. */
462
+ function poolOwners(defs, collab) {
463
+ const owners = new Map();
464
+ const processById = new Map(defs.processes.map((p) => [p.id, p]));
465
+ for (let index = 0; index < collab.participants.length; index++) {
466
+ const participant = collab.participants[index];
467
+ if (!participant)
468
+ continue;
469
+ owners.set(participant.id, index);
470
+ const process = participant.processRef ? processById.get(participant.processRef) : undefined;
471
+ if (!process)
472
+ continue;
473
+ const walk = (elements) => {
474
+ for (const element of elements) {
475
+ owners.set(element.id, index);
476
+ const container = element;
477
+ if (container.flowElements?.length)
478
+ walk(container.flowElements);
479
+ }
480
+ };
481
+ walk(process.flowElements);
482
+ }
483
+ return owners;
484
+ }
485
+ /** Message flows collapsed into weighted pool-to-pool relationships. */
486
+ function poolLinks(collab, owners) {
487
+ const weights = new Map();
488
+ for (const flow of collab.messageFlows) {
489
+ const from = owners.get(flow.sourceRef);
490
+ const to = owners.get(flow.targetRef);
491
+ if (from === undefined || to === undefined || from === to)
492
+ continue;
493
+ const key = from < to ? `${from}:${to}` : `${to}:${from}`;
494
+ const existing = weights.get(key);
495
+ if (existing)
496
+ existing.weight++;
497
+ else
498
+ weights.set(key, { from, to, weight: 1 });
499
+ }
500
+ return [...weights.values()];
501
+ }
502
+ /** Width of a laid-out process, ignoring where in space it happens to sit. */
503
+ function contentWidth(layout) {
504
+ if (layout.nodes.length === 0)
505
+ return 0;
506
+ const { minX, maxX } = contentBbox(layout.nodes);
507
+ return maxX - minX;
508
+ }
509
+ /**
510
+ * Message flows expressed as the x centres of the two elements they connect,
511
+ * measured inside each pool's own content so alignment can shift pools freely.
512
+ */
513
+ function messageLinks(collab, pools, layouts) {
514
+ if (!collab)
515
+ return [];
516
+ const centres = new Map();
517
+ for (let index = 0; index < pools.length; index++) {
518
+ const layout = layouts[index];
519
+ const pool = pools[index];
520
+ if (!layout || !pool?.participantId || layout.nodes.length === 0)
521
+ continue;
522
+ const { minX } = contentBbox(layout.nodes);
523
+ const hasLanes = (pool.process?.laneSet?.lanes.length ?? 0) > 0;
524
+ const elemX = POOL_HEADER + (hasLanes ? LANE_HEADER : 0) + PADDING;
525
+ for (const node of layout.nodes) {
526
+ centres.set(node.id, {
527
+ pool: index,
528
+ x: elemX + node.bounds.x + node.bounds.width / 2 - minX,
529
+ });
530
+ }
531
+ }
532
+ const links = [];
533
+ for (const flow of collab.messageFlows) {
534
+ const from = centres.get(flow.sourceRef);
535
+ const to = centres.get(flow.targetRef);
536
+ if (!from || !to || from.pool === to.pool)
537
+ continue;
538
+ links.push({ fromPool: from.pool, toPool: to.pool, fromX: from.x, toX: to.x });
539
+ }
540
+ return links;
541
+ }
542
+ /**
543
+ * Every element inside a collaboration mapped to the element that would contain
544
+ * it on a plane: its sub-process, or the participant of its process. Following
545
+ * the chain finds the nearest ancestor a collapsed scope leaves visible.
546
+ */
547
+ function ancestorIndex(defs, collab) {
548
+ const parents = new Map();
549
+ const processById = new Map(defs.processes.map((p) => [p.id, p]));
550
+ for (const participant of collab.participants) {
551
+ const process = participant.processRef ? processById.get(participant.processRef) : undefined;
552
+ if (!process)
553
+ continue;
554
+ const walk = (elements, parent) => {
555
+ for (const element of elements) {
556
+ parents.set(element.id, parent);
557
+ const container = element;
558
+ if (container.flowElements?.length)
559
+ walk(container.flowElements, element.id);
560
+ }
561
+ };
562
+ walk(process.flowElements, participant.id);
563
+ }
564
+ return parents;
565
+ }
566
+ /**
567
+ * Replace every diagram-interchange position in `defs` with a computed layout.
568
+ *
569
+ * @param engine - Which process layout algorithm to run. Defaults to `semantic`;
570
+ * `grid` runs the older cell-grid walk and is used to compare the two.
571
+ */
572
+ export function applyAutoLayout(defs, engine = "semantic") {
199
573
  if (defs.processes.length === 0)
200
574
  return defs;
201
575
  const collab = defs.collaborations[0];
@@ -209,39 +583,128 @@ export function applyAutoLayout(defs) {
209
583
  }
210
584
  const allShapes = [];
211
585
  const allEdges = [];
586
+ const childPlanes = [];
587
+ const collapsed = collapsedSubProcesses(defs);
212
588
  let poolY = 0;
213
- for (const process of defs.processes) {
214
- const participantId = processToParticipant.get(process.id);
215
- const lanes = process.laneSet?.lanes ?? [];
589
+ // Walk the pools in declaration order so a black-box participant keeps its
590
+ // place in the stack; processes no participant references follow.
591
+ const processById = new Map(defs.processes.map((p) => [p.id, p]));
592
+ const pools = [];
593
+ if (collab) {
594
+ const participantPools = collab.participants.map((participant) => ({
595
+ participantId: participant.id,
596
+ process: participant.processRef ? processById.get(participant.processRef) : undefined,
597
+ }));
598
+ // Pools that exchange messages read better next to each other, so the
599
+ // stack follows the message flows rather than the declaration order.
600
+ const order = orderPools(participantPools.length, poolLinks(collab, poolOwners(defs, collab)));
601
+ for (const index of order) {
602
+ const pool = participantPools[index];
603
+ if (pool)
604
+ pools.push(pool);
605
+ }
606
+ for (const process of defs.processes) {
607
+ if (!processToParticipant.has(process.id))
608
+ pools.push({ process });
609
+ }
610
+ }
611
+ else {
612
+ for (const process of defs.processes)
613
+ pools.push({ process });
614
+ }
615
+ // Lay every pool out first: alignment needs to see all of them before any
616
+ // geometry is committed.
617
+ const layouts = pools.map((pool) => pool.process ? layoutProcess(pool.process, engine, collapsed) : { nodes: [], edges: [] });
618
+ const alignment = alignPools(pools.length, layouts.map((layout) => contentWidth(layout)), messageLinks(collab, pools, layouts));
619
+ /** Pools with nothing to draw; widened to match the others once all are placed. */
620
+ const blackBoxPools = [];
621
+ /** Root processes that are not the primary one, each on a plane of its own. */
622
+ const rootPlanes = [];
623
+ const primaryProcessId = defs.processes[0]?.id;
624
+ for (let poolIndex = 0; poolIndex < pools.length; poolIndex++) {
625
+ const pool = pools[poolIndex];
626
+ if (!pool)
627
+ continue;
628
+ const { participantId, process } = pool;
629
+ const lanes = process?.laneSet?.lanes ?? [];
216
630
  const hasLanes = lanes.length > 0;
217
- const result = layoutProcess(process);
218
- if (result.nodes.length === 0)
631
+ const result = layouts[poolIndex] ?? { nodes: [], edges: [] };
632
+ const alignDx = alignment[poolIndex] ?? 0;
633
+ // A participant with no process, or one whose process has no flow nodes,
634
+ // is a black box: it still needs a pool of its own to dock message flows
635
+ // onto, and to stay visible at all.
636
+ if (result.nodes.length === 0) {
637
+ // A process can be nothing but annotations; they still belong on the plane.
638
+ if (!participantId && process && process.textAnnotations.length > 0) {
639
+ const annOnly = packAnnotations(process, []);
640
+ const bbox = contentBbox([], annOnly.values());
641
+ addAnnotationShapes(process, [], annOnly, allShapes, allEdges, PADDING - bbox.minX, PADDING - bbox.minY);
642
+ continue;
643
+ }
644
+ if (participantId) {
645
+ const shape = {
646
+ id: `${participantId}_di`,
647
+ bpmnElement: participantId,
648
+ isHorizontal: true,
649
+ bounds: { x: 0, y: poolY, width: EMPTY_POOL_WIDTH, height: EMPTY_POOL_HEIGHT },
650
+ unknownAttributes: {},
651
+ };
652
+ allShapes.push(shape);
653
+ blackBoxPools.push(shape);
654
+ poolY += EMPTY_POOL_HEIGHT + POOL_GAP;
655
+ }
219
656
  continue;
657
+ }
658
+ childPlanes.push(...(result.planes ?? []));
659
+ // The engine reports lane bands when it placed nodes by lane membership;
660
+ // they replace the proportional tiling below.
661
+ const engineLanes = result.lanes;
220
662
  // Pre-compute annotation positions in layout space so they're included in the bbox
221
- const annBounds = packAnnotations(process, result.nodes);
663
+ const annBounds = process ? packAnnotations(process, result.nodes) : new Map();
222
664
  const { minX, minY, maxX, maxY } = contentBbox(result.nodes, annBounds.values());
223
665
  const contentW = maxX - minX;
224
666
  const contentH = maxY - minY;
667
+ // Engine lane bands already stack the content vertically, so the pool
668
+ // aligns to the band space rather than to the content bounding box —
669
+ // otherwise the shapes drift out of the lanes drawn around them.
670
+ const laneBands = participantId && hasLanes ? engineLanes : undefined;
671
+ const bandTop = laneBands?.[0]?.bounds.y ?? 0;
672
+ const bandHeight = laneBands ? laneBands.reduce((sum, lane) => sum + lane.bounds.height, 0) : 0;
225
673
  let dx;
226
674
  let dy;
227
675
  if (participantId) {
228
676
  const elemX = POOL_HEADER + (hasLanes ? LANE_HEADER : 0) + PADDING;
229
- const elemY = poolY + PADDING;
230
- dx = elemX - minX;
231
- dy = elemY - minY;
677
+ dx = elemX - minX + alignDx;
678
+ dy = laneBands ? poolY - bandTop : poolY + PADDING - minY;
232
679
  }
233
680
  else {
234
681
  dx = PADDING - minX;
235
682
  dy = PADDING - minY;
236
683
  }
684
+ // Only the primary process shares the root plane; any other root process
685
+ // owns one, instead of being stacked on top of the first at the origin.
686
+ if (!participantId && process && process.id !== primaryProcessId) {
687
+ const shapes = [];
688
+ const edges = [];
689
+ for (const node of result.nodes)
690
+ shapes.push(nodeToShape(node, dx, dy));
691
+ for (const edge of result.edges)
692
+ edges.push(edgeToShape(edge, dx, dy));
693
+ addAnnotationShapes(process, result.nodes, annBounds, shapes, edges, dx, dy);
694
+ rootPlanes.push({ elementId: process.id, shapes, edges });
695
+ continue;
696
+ }
237
697
  for (const node of result.nodes)
238
698
  allShapes.push(nodeToShape(node, dx, dy));
239
699
  for (const edge of result.edges)
240
700
  allEdges.push(edgeToShape(edge, dx, dy));
241
- addAnnotationShapes(process, result.nodes, annBounds, allShapes, allEdges, dx, dy);
701
+ if (process) {
702
+ addAnnotationShapes(process, result.nodes, annBounds, allShapes, allEdges, dx, dy);
703
+ }
242
704
  if (participantId) {
243
- const innerW = (hasLanes ? LANE_HEADER : 0) + contentW + 2 * PADDING;
244
- const innerH = contentH + 2 * PADDING;
705
+ const innerW = (hasLanes ? LANE_HEADER : 0) + contentW + 2 * PADDING + alignDx;
706
+ // Lanes must tile the pool exactly, so the pool takes their height.
707
+ const innerH = laneBands ? bandHeight : contentH + 2 * PADDING;
245
708
  const poolW = POOL_HEADER + innerW;
246
709
  allShapes.push({
247
710
  id: `${participantId}_di`,
@@ -251,37 +714,117 @@ export function applyAutoLayout(defs) {
251
714
  unknownAttributes: {},
252
715
  });
253
716
  if (hasLanes) {
254
- const laneShapes = buildLaneShapes(lanes, result.nodes, dx, dy, poolY, POOL_HEADER, innerW, innerH);
717
+ const laneShapes = laneBands
718
+ ? laneBands.map((lane) => ({
719
+ id: `${lane.id}_di`,
720
+ bpmnElement: lane.id,
721
+ isHorizontal: true,
722
+ bounds: {
723
+ x: Math.round(POOL_HEADER),
724
+ y: Math.round(lane.bounds.y + dy),
725
+ width: Math.round(innerW),
726
+ height: Math.round(lane.bounds.height),
727
+ },
728
+ unknownAttributes: {},
729
+ }))
730
+ : buildLaneShapes(lanes, result.nodes, dx, dy, poolY, POOL_HEADER, innerW, innerH);
255
731
  allShapes.push(...laneShapes);
256
732
  }
257
733
  poolY += innerH + POOL_GAP;
258
734
  }
259
735
  }
736
+ // Give the black boxes the width of the widest pool that has content, so the
737
+ // stack reads as one diagram rather than a ragged column.
738
+ if (blackBoxPools.length > 0) {
739
+ const participantIds = new Set(collab?.participants.map((p) => p.id) ?? []);
740
+ const widest = allShapes
741
+ .filter((s) => participantIds.has(s.bpmnElement) && !blackBoxPools.includes(s))
742
+ .reduce((max, s) => Math.max(max, s.bounds.width), EMPTY_POOL_WIDTH);
743
+ for (const shape of blackBoxPools)
744
+ shape.bounds.width = widest;
745
+ }
260
746
  if (collab && collab.messageFlows.length > 0) {
261
747
  const shapeByElement = new Map(allShapes.map((s) => [s.bpmnElement, s.bounds]));
748
+ // An endpoint inside a collapsed sub-process has no shape on this plane;
749
+ // the message docks on the nearest ancestor that does.
750
+ const visibleAncestors = ancestorIndex(defs, collab);
751
+ const resolve = (id) => {
752
+ let current = id;
753
+ while (current !== undefined && !shapeByElement.has(current)) {
754
+ current = visibleAncestors.get(current);
755
+ }
756
+ return current ?? id;
757
+ };
758
+ const participantIds = new Set(collab.participants.map((p) => p.id));
759
+ const laneIds = new Set(defs.processes.flatMap((p) => (p.laneSet?.lanes ?? []).map((lane) => lane.id)));
760
+ // Pools and lanes are containers; message flows cross them by design. It is
761
+ // the elements inside that a route has to miss.
762
+ const obstacles = allShapes
763
+ .filter((s) => !participantIds.has(s.bpmnElement) && !laneIds.has(s.bpmnElement))
764
+ .map((s) => s.bounds);
765
+ const poolGaps = poolBands(allShapes, participantIds);
766
+ const runs = [];
767
+ /** Bounds of every expanded scope an element sits inside. */
768
+ const containersOf = (id) => {
769
+ const out = [];
770
+ let parent = visibleAncestors.get(id);
771
+ while (parent !== undefined) {
772
+ const bounds = shapeByElement.get(parent);
773
+ if (bounds)
774
+ out.push(bounds);
775
+ parent = visibleAncestors.get(parent);
776
+ }
777
+ return out;
778
+ };
779
+ let stemOffset = 0;
262
780
  for (const mf of collab.messageFlows) {
263
- const src = shapeByElement.get(mf.sourceRef);
264
- const tgt = shapeByElement.get(mf.targetRef);
781
+ const sourceRef = resolve(mf.sourceRef);
782
+ const targetRef = resolve(mf.targetRef);
783
+ const src = shapeByElement.get(sourceRef);
784
+ const tgt = shapeByElement.get(targetRef);
265
785
  if (!src || !tgt)
266
786
  continue;
267
- const srcBelow = src.y + src.height / 2 > tgt.y + tgt.height / 2;
268
- const sx = Math.round(src.x + src.width / 2);
269
- const tx = Math.round(tgt.x + tgt.width / 2);
270
- const sy = srcBelow ? src.y : src.y + src.height;
271
- const ty = srcBelow ? tgt.y + tgt.height : tgt.y;
272
- const midY = Math.round((sy + ty) / 2);
273
- const waypoints = sx === tx
274
- ? [
275
- { x: sx, y: sy },
276
- { x: tx, y: ty },
277
- ]
278
- : [
279
- { x: sx, y: sy },
280
- { x: sx, y: midY },
281
- { x: tx, y: midY },
282
- { x: tx, y: ty },
283
- ];
284
- allEdges.push({ id: `${mf.id}_di`, bpmnElement: mf.id, waypoints, unknownAttributes: {} });
787
+ const run = {
788
+ id: mf.id,
789
+ ...messageFlowRoute(src, tgt, participantIds.has(sourceRef), participantIds.has(targetRef), poolGaps, obstacles, stemOffset, [...containersOf(sourceRef), ...containersOf(targetRef)]),
790
+ };
791
+ runs.push(run);
792
+ stemOffset = (stemOffset + MESSAGE_FLOW_STEM_STEP) % (MESSAGE_FLOW_STEM_STEP * 4);
793
+ }
794
+ // Flows sharing a gap get their own line inside it, so parallel runs do not
795
+ // pile onto one row and cross every other flow's riser.
796
+ const perGap = new Map();
797
+ for (const run of runs) {
798
+ const list = perGap.get(run.gap);
799
+ if (list)
800
+ list.push(run);
801
+ else
802
+ perGap.set(run.gap, [run]);
803
+ }
804
+ const endX = (points) => points[points.length - 1]?.x ?? 0;
805
+ for (const [index, list] of perGap) {
806
+ const gap = poolGaps[index];
807
+ if (!gap)
808
+ continue;
809
+ list.sort((a, b) => Math.min(endX(a.source), endX(a.target)) - Math.min(endX(b.source), endX(b.target)));
810
+ for (let i = 0; i < list.length; i++) {
811
+ const run = list[i];
812
+ if (!run)
813
+ continue;
814
+ // The gap is clear ground, so moving the crossing inside it cannot
815
+ // introduce a collision.
816
+ const y = Math.round(gap.top + ((i + 1) * (gap.bottom - gap.top)) / (list.length + 1));
817
+ const last = run.source[run.source.length - 1];
818
+ const lastTarget = run.target[run.target.length - 1];
819
+ if (last)
820
+ last.y = y;
821
+ if (lastTarget)
822
+ lastTarget.y = y;
823
+ }
824
+ }
825
+ for (const run of runs) {
826
+ const waypoints = collapseCollinear([...run.source, ...[...run.target].reverse()]);
827
+ allEdges.push({ id: `${run.id}_di`, bpmnElement: run.id, waypoints, unknownAttributes: {} });
285
828
  }
286
829
  }
287
830
  const planeBpmnElement = collab?.id ?? defs.processes[0]?.id ?? "plane";
@@ -298,6 +841,19 @@ export function applyAutoLayout(defs) {
298
841
  edges: allEdges,
299
842
  },
300
843
  },
844
+ ...rootPlanes.map((plane) => {
845
+ const previous = defs.diagrams.find((d) => d.plane.bpmnElement === plane.elementId);
846
+ return {
847
+ id: previous?.id ?? `BPMNDiagram_${plane.elementId}`,
848
+ plane: {
849
+ id: previous?.plane.id ?? `BPMNPlane_${plane.elementId}`,
850
+ bpmnElement: plane.elementId,
851
+ shapes: plane.shapes,
852
+ edges: plane.edges,
853
+ },
854
+ };
855
+ }),
856
+ ...childPlaneDiagrams(childPlanes, defs, collapsed),
301
857
  ],
302
858
  };
303
859
  }