@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
@@ -8,6 +8,8 @@ const ELEMENT_GAP = 30; // min gap between annotation and a non-annotation shape
8
8
  const PREFERRED_OFFSET = 50; // preferred gap to associated element
9
9
  const MIN_HEIGHT = 30;
10
10
  const HORIZONTAL_SHIFTS = [0, 60, -60, 120, -120, 180, -180, 240, -240];
11
+ /** Cost added to a candidate whose association line would cross a shape. */
12
+ const BLOCKED_LINE_COST = 10_000;
11
13
  function computeHeight(text, width) {
12
14
  if (!text || !text.trim())
13
15
  return MIN_HEIGHT;
@@ -189,7 +191,17 @@ export function packAnnotations(process, layoutNodes) {
189
191
  }
190
192
  }
191
193
  }
192
- const cost = Math.hypot(candidateX - naturalX, y - naturalY);
194
+ // A clear box is not enough: the association line drawn back to the
195
+ // element must not cut through anything either.
196
+ const candidate = {
197
+ x: candidateX,
198
+ y,
199
+ width: item.bounds.width,
200
+ height: item.bounds.height,
201
+ };
202
+ const { pElem, pAnn } = associationWaypoints(linked.bounds, candidate);
203
+ const crosses = obstacles.some((sh) => sh !== linked.bounds && segmentHitsBox(pElem, pAnn, sh));
204
+ const cost = Math.hypot(candidateX - naturalX, y - naturalY) + (crosses ? BLOCKED_LINE_COST : 0);
193
205
  if (cost < best.cost)
194
206
  best = { x: candidateX, y, cost };
195
207
  }
@@ -218,6 +230,29 @@ export function packAnnotations(process, layoutNodes) {
218
230
  }
219
231
  return result;
220
232
  }
233
+ /** Whether a straight segment passes through a box. */
234
+ function segmentHitsBox(a, b, box) {
235
+ const minX = Math.min(a.x, b.x);
236
+ const maxX = Math.max(a.x, b.x);
237
+ const minY = Math.min(a.y, b.y);
238
+ const maxY = Math.max(a.y, b.y);
239
+ if (maxX <= box.x || box.x + box.width <= minX)
240
+ return false;
241
+ if (maxY <= box.y || box.y + box.height <= minY)
242
+ return false;
243
+ if (a.x === b.x || a.y === b.y)
244
+ return true;
245
+ // Diagonal: the box is hit unless all four corners fall on one side of it.
246
+ const side = (p) => Math.sign((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x));
247
+ const corners = [
248
+ { x: box.x, y: box.y },
249
+ { x: box.x + box.width, y: box.y },
250
+ { x: box.x + box.width, y: box.y + box.height },
251
+ { x: box.x, y: box.y + box.height },
252
+ ];
253
+ const first = side(corners[0] ?? { x: 0, y: 0 });
254
+ return corners.some((corner) => side(corner) !== first);
255
+ }
221
256
  /**
222
257
  * Edge-to-edge, clamped association waypoints between a linked element and
223
258
  * its annotation. Port of `chooseWaypoints` (tmp/01-annotation-layouting.cjs:365-389).
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Horizontal alignment between the pools of a collaboration.
3
+ *
4
+ * Each process is laid out on its own, so two elements that exchange a message
5
+ * usually end up at unrelated x positions and the message has to travel sideways
6
+ * to reach its partner. Sliding a whole process sideways costs nothing — the
7
+ * pool grows with it — and a message that leaves straight down crosses far less
8
+ * than one that wanders across two pools first.
9
+ */
10
+ /** One message flow, as the two element centres it connects. */
11
+ export interface MessageLink {
12
+ fromPool: number;
13
+ toPool: number;
14
+ /** x centre of the source element within its pool's content. */
15
+ fromX: number;
16
+ /** x centre of the target element within its pool's content. */
17
+ toX: number;
18
+ }
19
+ /**
20
+ * Choose a horizontal offset per pool. The widest pool anchors the diagram and
21
+ * the rest slide to meet it, largest first, so the biggest process never moves
22
+ * to chase a small one. Offsets are normalised to keep every pool at or right of
23
+ * the origin.
24
+ */
25
+ export declare function alignPools(count: number, widths: readonly number[], links: readonly MessageLink[]): number[];
26
+ //# sourceMappingURL=alignment.d.ts.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Horizontal alignment between the pools of a collaboration.
3
+ *
4
+ * Each process is laid out on its own, so two elements that exchange a message
5
+ * usually end up at unrelated x positions and the message has to travel sideways
6
+ * to reach its partner. Sliding a whole process sideways costs nothing — the
7
+ * pool grows with it — and a message that leaves straight down crosses far less
8
+ * than one that wanders across two pools first.
9
+ */
10
+ /**
11
+ * Choose a horizontal offset per pool. The widest pool anchors the diagram and
12
+ * the rest slide to meet it, largest first, so the biggest process never moves
13
+ * to chase a small one. Offsets are normalised to keep every pool at or right of
14
+ * the origin.
15
+ */
16
+ export function alignPools(count, widths, links) {
17
+ const offsets = new Array(count).fill(0);
18
+ if (count < 2 || links.length === 0)
19
+ return offsets;
20
+ const order = Array.from({ length: count }, (_, i) => i).sort((a, b) => (widths[b] ?? 0) - (widths[a] ?? 0) || a - b);
21
+ const placed = new Set();
22
+ const anchor = order[0];
23
+ if (anchor === undefined)
24
+ return offsets;
25
+ placed.add(anchor);
26
+ for (let i = 1; i < order.length; i++) {
27
+ const pool = order[i];
28
+ if (pool === undefined)
29
+ continue;
30
+ const connected = links.filter((link) => (link.fromPool === pool && placed.has(link.toPool)) ||
31
+ (link.toPool === pool && placed.has(link.fromPool)));
32
+ if (connected.length === 0) {
33
+ placed.add(pool);
34
+ continue;
35
+ }
36
+ // Every connected message suggests the shift that would make it vertical;
37
+ // staying put is always in the running.
38
+ const candidates = new Set([0]);
39
+ for (const link of connected) {
40
+ const mine = link.fromPool === pool ? link.fromX : link.toX;
41
+ const theirs = link.fromPool === pool ? link.toX : link.fromX;
42
+ const otherPool = link.fromPool === pool ? link.toPool : link.fromPool;
43
+ candidates.add(theirs + (offsets[otherPool] ?? 0) - mine);
44
+ }
45
+ let best = 0;
46
+ let bestCost = Number.POSITIVE_INFINITY;
47
+ for (const candidate of [...candidates].sort((a, b) => Math.abs(a) - Math.abs(b) || a - b)) {
48
+ let cost = 0;
49
+ for (const link of connected) {
50
+ const mine = link.fromPool === pool ? link.fromX : link.toX;
51
+ const theirs = link.fromPool === pool ? link.toX : link.fromX;
52
+ const otherPool = link.fromPool === pool ? link.toPool : link.fromPool;
53
+ cost += Math.abs(mine + candidate - (theirs + (offsets[otherPool] ?? 0)));
54
+ }
55
+ if (cost < bestCost) {
56
+ bestCost = cost;
57
+ best = candidate;
58
+ }
59
+ }
60
+ offsets[pool] = Math.round(best);
61
+ placed.add(pool);
62
+ }
63
+ const min = Math.min(...offsets);
64
+ return min === 0 ? offsets : offsets.map((offset) => offset - min);
65
+ }
66
+ //# sourceMappingURL=alignment.js.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Vertical order of the pools in a collaboration.
3
+ *
4
+ * Message flows read best when the pools they connect sit next to each other,
5
+ * so the order is chosen to minimise how far messages travel vertically rather
6
+ * than left as declared. Small collaborations can afford an exhaustive search;
7
+ * larger ones use repeated remove-and-reinsert, which reaches the same answer on
8
+ * every realistic diagram and never depends on iteration order.
9
+ */
10
+ /** A message-flow relationship between two pools, by index into the input order. */
11
+ export interface PoolLink {
12
+ from: number;
13
+ to: number;
14
+ weight: number;
15
+ }
16
+ /**
17
+ * Order pools by their message-flow relationships. Returns indices into the
18
+ * input order; an input with no message flows comes back unchanged.
19
+ */
20
+ export declare function orderPools(count: number, links: readonly PoolLink[]): number[];
21
+ //# sourceMappingURL=ordering.d.ts.map
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Vertical order of the pools in a collaboration.
3
+ *
4
+ * Message flows read best when the pools they connect sit next to each other,
5
+ * so the order is chosen to minimise how far messages travel vertically rather
6
+ * than left as declared. Small collaborations can afford an exhaustive search;
7
+ * larger ones use repeated remove-and-reinsert, which reaches the same answer on
8
+ * every realistic diagram and never depends on iteration order.
9
+ */
10
+ /** Above this many pools, permuting every order costs more than it is worth. */
11
+ const EXHAUSTIVE_LIMIT = 8;
12
+ /**
13
+ * How far the messages travel in this order, weighted by how many there are.
14
+ * The declaration-order term is a tie-break: with nothing to gain from moving,
15
+ * the pools stay where the author put them.
16
+ */
17
+ function cost(order, links) {
18
+ const position = new Map();
19
+ for (let i = 0; i < order.length; i++) {
20
+ const id = order[i];
21
+ if (id !== undefined)
22
+ position.set(id, i);
23
+ }
24
+ let total = 0;
25
+ for (const link of links) {
26
+ const from = position.get(link.from);
27
+ const to = position.get(link.to);
28
+ if (from === undefined || to === undefined)
29
+ continue;
30
+ total += link.weight * Math.abs(from - to);
31
+ }
32
+ let drift = 0;
33
+ for (let i = 0; i < order.length; i++)
34
+ drift += Math.abs((order[i] ?? i) - i);
35
+ return total + drift / (order.length * order.length + 1);
36
+ }
37
+ /**
38
+ * Order pools by their message-flow relationships. Returns indices into the
39
+ * input order; an input with no message flows comes back unchanged.
40
+ */
41
+ export function orderPools(count, links) {
42
+ const identity = Array.from({ length: count }, (_, i) => i);
43
+ if (count < 3 || links.length === 0)
44
+ return identity;
45
+ return count <= EXHAUSTIVE_LIMIT ? exhaustive(identity, links) : refine(identity, links);
46
+ }
47
+ /** Every order, best first-found wins — so declaration order survives a tie. */
48
+ function exhaustive(identity, links) {
49
+ let best = identity;
50
+ let bestCost = cost(identity, links);
51
+ const permute = (prefix, rest) => {
52
+ if (rest.length === 0) {
53
+ const candidate = cost(prefix, links);
54
+ if (candidate < bestCost) {
55
+ best = [...prefix];
56
+ bestCost = candidate;
57
+ }
58
+ return;
59
+ }
60
+ for (let i = 0; i < rest.length; i++) {
61
+ const next = rest[i];
62
+ if (next === undefined)
63
+ continue;
64
+ permute([...prefix, next], [...rest.slice(0, i), ...rest.slice(i + 1)]);
65
+ }
66
+ };
67
+ permute([], identity);
68
+ return best;
69
+ }
70
+ /**
71
+ * Take each pool out and put it back wherever it fits best, repeating until a
72
+ * full sweep changes nothing.
73
+ */
74
+ function refine(identity, links) {
75
+ let order = [...identity];
76
+ let current = cost(order, links);
77
+ for (let sweep = 0; sweep < order.length; sweep++) {
78
+ let improved = false;
79
+ for (let from = 0; from < order.length; from++) {
80
+ const pool = order[from];
81
+ if (pool === undefined)
82
+ continue;
83
+ const without = [...order.slice(0, from), ...order.slice(from + 1)];
84
+ for (let to = 0; to <= without.length; to++) {
85
+ if (to === from)
86
+ continue;
87
+ const candidate = [...without.slice(0, to), pool, ...without.slice(to)];
88
+ const candidateCost = cost(candidate, links);
89
+ if (candidateCost < current) {
90
+ order = candidate;
91
+ current = candidateCost;
92
+ improved = true;
93
+ break;
94
+ }
95
+ }
96
+ }
97
+ if (!improved)
98
+ break;
99
+ }
100
+ return order;
101
+ }
102
+ //# sourceMappingURL=ordering.js.map
@@ -1,4 +1,5 @@
1
1
  export { layoutProcess, layoutFlowNodes } from "./layout-engine.js";
2
+ export type { LayoutEngine } from "./layout-engine.js";
2
3
  export { benchmarkLayout, compareLayouts, formatBenchmarkResult, generateAutoLayout, parseReferenceLayout, } from "./bench.js";
3
4
  export type { BenchmarkResult, BoundingBox, ElementComparison, ElementPosition, FlowOrderViolation, } from "./bench.js";
4
5
  export { assertNoOverlap } from "./overlap.js";
@@ -1,12 +1,22 @@
1
1
  import type { BpmnFlowElement, BpmnProcess, BpmnSequenceFlow } from "../bpmn/bpmn-model.js";
2
2
  import type { LayoutResult } from "./types.js";
3
3
  /**
4
- * Layout a full process (grid engine). Boundary events, expanded
5
- * subprocesses and edge labels are handled inside the engine.
4
+ * Which layout algorithm to run.
5
+ *
6
+ * `semantic` places nodes by rank and narrative band and honours lane
7
+ * membership. `grid` is the older cell-grid walk, kept for collaborations,
8
+ * where pool and message-flow geometry is still decided by the caller.
9
+ */
10
+ export type LayoutEngine = "semantic" | "grid";
11
+ /**
12
+ * Layout a full process. Boundary events, expanded subprocesses and edge
13
+ * labels are handled inside the engine.
6
14
  * Never throws on residual label overlap — call assertNoOverlap yourself
7
15
  * in tests that validate known-good fixtures.
8
16
  */
9
- export declare function layoutProcess(process: BpmnProcess): LayoutResult;
17
+ export declare function layoutProcess(process: BpmnProcess, engine?: LayoutEngine,
18
+ /** Sub-processes the diagram draws collapsed; their contents get their own plane. */
19
+ collapsed?: ReadonlySet<string>): LayoutResult;
10
20
  /** Layout a set of flow nodes and sequence flows (used by ascii, proxy, compact). */
11
21
  export declare function layoutFlowNodes(flowNodes: BpmnFlowElement[], sequenceFlows: BpmnSequenceFlow[]): LayoutResult;
12
22
  //# sourceMappingURL=layout-engine.d.ts.map
@@ -1,12 +1,17 @@
1
1
  import { gridLayoutFlowNodes } from "./grid/grid-engine.js";
2
+ import { semanticLayoutProcess } from "./semantic/index.js";
2
3
  /**
3
- * Layout a full process (grid engine). Boundary events, expanded
4
- * subprocesses and edge labels are handled inside the engine.
4
+ * Layout a full process. Boundary events, expanded subprocesses and edge
5
+ * labels are handled inside the engine.
5
6
  * Never throws on residual label overlap — call assertNoOverlap yourself
6
7
  * in tests that validate known-good fixtures.
7
8
  */
8
- export function layoutProcess(process) {
9
- return layoutFlowNodes(process.flowElements, process.sequenceFlows);
9
+ export function layoutProcess(process, engine = "semantic",
10
+ /** Sub-processes the diagram draws collapsed; their contents get their own plane. */
11
+ collapsed) {
12
+ if (engine === "grid")
13
+ return gridLayoutFlowNodes(process.flowElements, process.sequenceFlows);
14
+ return semanticLayoutProcess(process, collapsed);
10
15
  }
11
16
  /** Layout a set of flow nodes and sequence flows (used by ascii, proxy, compact). */
12
17
  export function layoutFlowNodes(flowNodes, sequenceFlows) {
@@ -0,0 +1,19 @@
1
+ import type { SemanticGraph } from "./graph.js";
2
+ /**
3
+ * Vertical narrative roles for one process scope.
4
+ *
5
+ * Band 0 is the spine — the primary path from the start event to an end event.
6
+ * Positive bands sit below it, negative bands above. Branch meaning decides the
7
+ * side: error handlers go down, escalation handlers go up, and plain
8
+ * alternatives alternate down/up so neither side runs away from the spine.
9
+ */
10
+ export interface BandLayout {
11
+ /** node id → band. 0 is the spine, > 0 below it, < 0 above it. */
12
+ bands: Map<string, number>;
13
+ /** Ids of the nodes on the primary path. */
14
+ spine: Set<string>;
15
+ /** Flow ids that continue the spine and should be routed as one segment. */
16
+ straightFlows: Set<string>;
17
+ }
18
+ export declare function assignBands(graph: SemanticGraph): BandLayout;
19
+ //# sourceMappingURL=bands.d.ts.map
@@ -0,0 +1,324 @@
1
+ import { reachesEnd } from "./graph.js";
2
+ function isDefaultFlow(source, flow) {
3
+ if (!source)
4
+ return false;
5
+ const gateway = source;
6
+ return gateway.default !== undefined && gateway.default === flow.id;
7
+ }
8
+ /** Escalation handlers read as "upward" exceptions; everything else reads downward. */
9
+ function boundarySide(event) {
10
+ return event.eventDefinitions.some((d) => d.type === "escalation") ? -1 : 1;
11
+ }
12
+ export function assignBands(graph) {
13
+ const bands = new Map();
14
+ const spine = new Set();
15
+ const straightFlows = new Set();
16
+ const branches = [];
17
+ for (const start of graph.starts) {
18
+ if (!start)
19
+ continue;
20
+ traceSpine(graph, start, spine, straightFlows);
21
+ }
22
+ for (const id of spine)
23
+ bands.set(id, 0);
24
+ // Branch off the spine first, then off already-placed branches, so a nested
25
+ // alternative always fans farther from the spine than its parent.
26
+ const sources = [];
27
+ for (const node of graph.nodes) {
28
+ if (spine.has(node.id))
29
+ sources.push({ id: node.id, depth: 0, side: 1 });
30
+ }
31
+ const assigned = new Set(spine);
32
+ for (let i = 0; i < sources.length; i++) {
33
+ const source = sources[i];
34
+ if (!source)
35
+ continue;
36
+ for (const branch of branchesFrom(graph, source, assigned)) {
37
+ branches.push(branch);
38
+ for (const id of branch.nodes) {
39
+ assigned.add(id);
40
+ sources.push({ id, depth: branch.depth, side: branch.side });
41
+ }
42
+ }
43
+ }
44
+ for (const [id, band] of compact(branches))
45
+ bands.set(id, band);
46
+ placeUnassigned(graph, bands, assigned);
47
+ reduceCrossings(graph, bands);
48
+ return { bands, spine, straightFlows };
49
+ }
50
+ /**
51
+ * Two edges cross when their rank spans overlap and their endpoints swap order
52
+ * vertically on the way across. Counting those inversions estimates the
53
+ * crossings a band assignment will produce, without routing anything.
54
+ */
55
+ function inversions(graph, bands) {
56
+ const edges = [];
57
+ for (const [host, flows] of graph.outgoing) {
58
+ for (const flow of flows) {
59
+ if (graph.backEdges.has(flow.id))
60
+ continue;
61
+ // A boundary event has no rank of its own; it travels with its host.
62
+ edges.push({
63
+ x1: graph.ranks.get(host) ?? 0,
64
+ y1: bands.get(host) ?? 0,
65
+ x2: graph.ranks.get(flow.targetRef) ?? 0,
66
+ y2: bands.get(flow.targetRef) ?? 0,
67
+ });
68
+ }
69
+ }
70
+ let count = 0;
71
+ for (let i = 0; i < edges.length; i++) {
72
+ const a = edges[i];
73
+ if (!a)
74
+ continue;
75
+ for (let j = i + 1; j < edges.length; j++) {
76
+ const b = edges[j];
77
+ if (!b)
78
+ continue;
79
+ // Only edges travelling over the same ranks can cross.
80
+ if (Math.min(a.x1, a.x2) >= Math.max(b.x1, b.x2))
81
+ continue;
82
+ if (Math.min(b.x1, b.x2) >= Math.max(a.x1, a.x2))
83
+ continue;
84
+ const left = Math.sign(a.y1 - b.y1);
85
+ const right = Math.sign(a.y2 - b.y2);
86
+ if (left !== 0 && right !== 0 && left !== right)
87
+ count++;
88
+ }
89
+ }
90
+ return count;
91
+ }
92
+ /**
93
+ * Swap neighbouring bands where doing so untangles the edges between them.
94
+ *
95
+ * Compaction picks a band from where a branch starts and how far from the spine
96
+ * it belongs, which says nothing about the edges running past it: two branches
97
+ * that leave in one order and rejoin in the other end up crossing. Trading two
98
+ * neighbouring bands keeps each on its own side of the spine, so the narrative
99
+ * survives while the crossings drop.
100
+ */
101
+ function reduceCrossings(graph, bands) {
102
+ const levels = [...new Set(bands.values())].filter((band) => band !== 0).sort((a, b) => a - b);
103
+ if (levels.length < 2)
104
+ return;
105
+ let best = inversions(graph, bands);
106
+ for (let sweep = 0; sweep < levels.length; sweep++) {
107
+ let improved = false;
108
+ for (let i = 0; i + 1 < levels.length; i++) {
109
+ const lower = levels[i];
110
+ const upper = levels[i + 1];
111
+ // Only bands on the same side of the spine may trade places.
112
+ if (lower === undefined || upper === undefined)
113
+ continue;
114
+ if (Math.sign(lower) !== Math.sign(upper))
115
+ continue;
116
+ const moved = new Map();
117
+ for (const [id, band] of bands) {
118
+ if (band === lower)
119
+ moved.set(id, upper);
120
+ else if (band === upper)
121
+ moved.set(id, lower);
122
+ }
123
+ if (moved.size === 0)
124
+ continue;
125
+ const previous = new Map();
126
+ for (const [id, band] of moved) {
127
+ previous.set(id, bands.get(id) ?? 0);
128
+ bands.set(id, band);
129
+ }
130
+ const candidate = inversions(graph, bands);
131
+ if (candidate < best) {
132
+ best = candidate;
133
+ improved = true;
134
+ }
135
+ else {
136
+ for (const [id, band] of previous)
137
+ bands.set(id, band);
138
+ }
139
+ }
140
+ if (!improved)
141
+ break;
142
+ }
143
+ }
144
+ /**
145
+ * Give the nodes no traversal reached — a scope entered only through a loop, say
146
+ * — a band of their own rather than dropping them on the spine, where they would
147
+ * land on top of whatever already occupies their rank.
148
+ */
149
+ function placeUnassigned(graph, bands, assigned) {
150
+ const taken = new Set();
151
+ for (const node of graph.nodes) {
152
+ if (!assigned.has(node.id))
153
+ continue;
154
+ taken.add(`${graph.ranks.get(node.id) ?? 0}:${bands.get(node.id) ?? 0}`);
155
+ }
156
+ for (const node of graph.nodes) {
157
+ if (assigned.has(node.id))
158
+ continue;
159
+ const rank = graph.ranks.get(node.id) ?? 0;
160
+ // Start from whatever this node is connected to, so it lands near it.
161
+ let preferred = 0;
162
+ const neighbours = [
163
+ ...(graph.outgoing.get(node.id) ?? []).map((f) => f.targetRef),
164
+ ...(graph.incoming.get(node.id) ?? []).map((f) => f.sourceRef),
165
+ ];
166
+ for (const neighbour of neighbours) {
167
+ if (!assigned.has(neighbour))
168
+ continue;
169
+ preferred = bands.get(neighbour) ?? 0;
170
+ break;
171
+ }
172
+ let band = preferred;
173
+ for (let step = 0; step <= graph.nodes.length; step++) {
174
+ band = preferred + (step % 2 === 0 ? step / 2 : -(step + 1) / 2);
175
+ if (!taken.has(`${rank}:${band}`))
176
+ break;
177
+ }
178
+ bands.set(node.id, band);
179
+ taken.add(`${rank}:${band}`);
180
+ }
181
+ }
182
+ /**
183
+ * The spine is picked one edge at a time: prefer a target that can still reach
184
+ * an end event, then the gateway's default flow, then declaration order. That
185
+ * stops a dead-end alternative from becoming the main narrative just because it
186
+ * was declared first.
187
+ */
188
+ function traceSpine(graph, start, spine, straightFlows) {
189
+ let current = start;
190
+ while (!spine.has(current)) {
191
+ spine.add(current);
192
+ const candidates = (graph.outgoing.get(current) ?? []).filter((f) => !graph.backEdges.has(f.id) && !spine.has(f.targetRef));
193
+ if (candidates.length === 0)
194
+ return;
195
+ const source = graph.byId.get(current);
196
+ const scored = candidates.map((flow, index) => ({
197
+ flow,
198
+ index,
199
+ // A handler path leaving a boundary event is an exception, never the
200
+ // narrative — it only continues the spine if nothing else can.
201
+ handler: flow.sourceRef === current ? 0 : 1,
202
+ ends: reachesEnd(graph, flow.targetRef) ? 0 : 1,
203
+ isDefault: isDefaultFlow(source, flow) ? 0 : 1,
204
+ }));
205
+ scored.sort((a, b) => a.handler - b.handler || a.ends - b.ends || a.isDefault - b.isDefault || a.index - b.index);
206
+ const next = scored[0];
207
+ if (!next)
208
+ return;
209
+ straightFlows.add(next.flow.id);
210
+ current = next.flow.targetRef;
211
+ }
212
+ }
213
+ /**
214
+ * Collect the alternatives leaving one node: its non-spine sequence flows plus
215
+ * the handler paths of any boundary event attached to it.
216
+ */
217
+ function branchesFrom(graph, source, assigned) {
218
+ const out = [];
219
+ const node = graph.byId.get(source.id);
220
+ const alternatives = (graph.outgoing.get(source.id) ?? []).filter((f) => !graph.backEdges.has(f.id) && !assigned.has(f.targetRef));
221
+ // A gateway with a default flow keeps its alternatives on one side; without
222
+ // one they alternate below / above / farther below / farther above.
223
+ const hasDefault = alternatives.some((f) => isDefaultFlow(node, f));
224
+ let below = 0;
225
+ let above = 0;
226
+ for (let i = 0; i < alternatives.length; i++) {
227
+ const flow = alternatives[i];
228
+ if (!flow)
229
+ continue;
230
+ const handler = handlerSideOf(graph, source.id, flow);
231
+ let side;
232
+ if (handler !== undefined)
233
+ side = handler;
234
+ else if (hasDefault)
235
+ side = 1;
236
+ else
237
+ side = i % 2 === 0 ? 1 : -1;
238
+ const step = side === 1 ? ++below : ++above;
239
+ const branch = follow(graph, flow.targetRef, side, source.depth + step, assigned);
240
+ if (branch)
241
+ out.push(branch);
242
+ }
243
+ return out;
244
+ }
245
+ /** The side a flow inherits when it leaves a boundary event of this host. */
246
+ function handlerSideOf(graph, hostId, flow) {
247
+ if (flow.sourceRef === hostId)
248
+ return undefined;
249
+ const event = (graph.attachers.get(hostId) ?? []).find((e) => e.id === flow.sourceRef);
250
+ return event ? boundarySide(event) : undefined;
251
+ }
252
+ /**
253
+ * Follow a branch forward until it rejoins placed flow or runs out.
254
+ *
255
+ * A branch reserves its complete span — out to the rank it rejoins at, not just
256
+ * the ranks its own nodes occupy — because the edge back to the join still has
257
+ * to travel along that band. Reserving only the nodes lets a second branch share
258
+ * the band and be crossed by the first one's last edge.
259
+ */
260
+ function follow(graph, entry, side, depth, assigned) {
261
+ const nodes = [];
262
+ const local = new Set();
263
+ let current = entry;
264
+ let minRank = Number.POSITIVE_INFINITY;
265
+ let maxRank = Number.NEGATIVE_INFINITY;
266
+ while (current !== undefined && !assigned.has(current) && !local.has(current)) {
267
+ nodes.push(current);
268
+ local.add(current);
269
+ const rank = graph.ranks.get(current) ?? 0;
270
+ minRank = Math.min(minRank, rank);
271
+ maxRank = Math.max(maxRank, rank);
272
+ const next = (graph.outgoing.get(current) ?? []).find((f) => !graph.backEdges.has(f.id) && !assigned.has(f.targetRef) && !local.has(f.targetRef));
273
+ current = next?.targetRef;
274
+ }
275
+ if (nodes.length === 0)
276
+ return null;
277
+ // The walk stops before the node the branch rejoins at, because that node is
278
+ // already placed. Reserve out to it anyway: the edge back into it still
279
+ // travels along this band.
280
+ const last = nodes[nodes.length - 1];
281
+ if (last !== undefined) {
282
+ for (const flow of graph.outgoing.get(last) ?? []) {
283
+ if (graph.backEdges.has(flow.id))
284
+ continue;
285
+ maxRank = Math.max(maxRank, graph.ranks.get(flow.targetRef) ?? maxRank);
286
+ }
287
+ }
288
+ return { nodes, side, depth, minRank, maxRank };
289
+ }
290
+ /**
291
+ * Pack branches into physical bands. A band reservation covers the ranks the
292
+ * branch spans, so two branches that never overlap horizontally can share one
293
+ * band; overlapping narratives cannot.
294
+ */
295
+ function compact(branches) {
296
+ const bands = new Map();
297
+ for (const side of [1, -1]) {
298
+ const mine = branches
299
+ .filter((b) => b.side === side)
300
+ .sort((a, b) => a.minRank - b.minRank || a.depth - b.depth);
301
+ /** physical level (1-based) → rank intervals already reserved on it. */
302
+ const reserved = [];
303
+ for (const branch of mine) {
304
+ let level = 0;
305
+ while (true) {
306
+ const slots = reserved[level];
307
+ if (!slots) {
308
+ reserved[level] = [[branch.minRank, branch.maxRank]];
309
+ break;
310
+ }
311
+ const overlaps = slots.some(([from, to]) => branch.minRank <= to && from <= branch.maxRank);
312
+ if (!overlaps) {
313
+ slots.push([branch.minRank, branch.maxRank]);
314
+ break;
315
+ }
316
+ level++;
317
+ }
318
+ for (const id of branch.nodes)
319
+ bands.set(id, side * (level + 1));
320
+ }
321
+ }
322
+ return bands;
323
+ }
324
+ //# sourceMappingURL=bands.js.map