@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.
- package/README.md +2 -0
- package/dist/bpmn/agentic.d.ts +121 -0
- package/dist/bpmn/agentic.js +97 -0
- package/dist/bpmn/auto-layout.d.ts +5 -5
- package/dist/bpmn/auto-layout.js +592 -36
- package/dist/bpmn/bpmn-builder.d.ts +56 -0
- package/dist/bpmn/bpmn-builder.js +148 -182
- package/dist/bpmn/bpmn-model.d.ts +4 -0
- package/dist/bpmn/bpmn-parser.js +9 -1
- package/dist/bpmn/bpmn-serializer.js +6 -0
- package/dist/bpmn/optimize/agentic.d.ts +10 -0
- package/dist/bpmn/optimize/agentic.js +88 -0
- package/dist/bpmn/optimize/deploy.d.ts +16 -0
- package/dist/bpmn/optimize/deploy.js +143 -0
- package/dist/bpmn/optimize/feel-syntax.d.ts +12 -0
- package/dist/bpmn/optimize/feel-syntax.js +87 -0
- package/dist/bpmn/optimize/feel.js +5 -2
- package/dist/bpmn/optimize/flow.js +22 -2
- package/dist/bpmn/optimize/index.js +20 -9
- package/dist/bpmn/optimize/types.d.ts +10 -1
- package/dist/bpmn/zeebe-extensions.d.ts +27 -0
- package/dist/bpmn/zeebe-extensions.js +38 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +2 -0
- package/dist/layout/annotations.js +36 -1
- package/dist/layout/collaboration/alignment.d.ts +26 -0
- package/dist/layout/collaboration/alignment.js +66 -0
- package/dist/layout/collaboration/ordering.d.ts +21 -0
- package/dist/layout/collaboration/ordering.js +102 -0
- package/dist/layout/index.d.ts +1 -0
- package/dist/layout/layout-engine.d.ts +13 -3
- package/dist/layout/layout-engine.js +9 -4
- package/dist/layout/semantic/bands.d.ts +19 -0
- package/dist/layout/semantic/bands.js +324 -0
- package/dist/layout/semantic/graph.d.ts +29 -0
- package/dist/layout/semantic/graph.js +217 -0
- package/dist/layout/semantic/index.d.ts +13 -0
- package/dist/layout/semantic/index.js +181 -0
- package/dist/layout/semantic/place.d.ts +40 -0
- package/dist/layout/semantic/place.js +271 -0
- package/dist/layout/semantic/route.d.ts +14 -0
- package/dist/layout/semantic/route.js +454 -0
- package/dist/layout/types.d.ts +17 -0
- package/dist/plan/compile.d.ts +39 -0
- package/dist/plan/compile.js +380 -0
- package/dist/plan/extract.d.ts +31 -0
- package/dist/plan/extract.js +248 -0
- package/dist/plan/index.d.ts +6 -0
- package/dist/plan/index.js +5 -0
- package/dist/plan/merge.d.ts +13 -0
- package/dist/plan/merge.js +80 -0
- package/dist/plan/slug.d.ts +5 -0
- package/dist/plan/slug.js +22 -0
- package/dist/plan/types.d.ts +225 -0
- package/dist/plan/types.js +13 -0
- package/package.json +2 -2
package/dist/bpmn/auto-layout.js
CHANGED
|
@@ -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 =
|
|
193
|
+
const annB = annLocalBounds.get(annId);
|
|
171
194
|
const elNode = nodeById.get(elId);
|
|
172
|
-
if (!annB || !
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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 =
|
|
218
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
264
|
-
const
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
}
|