@bpmnkit/core 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +32 -1
  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 +265 -3
  7. package/dist/bpmn/bpmn-builder.js +603 -197
  8. package/dist/bpmn/bpmn-model.d.ts +114 -0
  9. package/dist/bpmn/bpmn-parser.js +1414 -521
  10. package/dist/bpmn/bpmn-serializer.js +107 -19
  11. package/dist/bpmn/compact.d.ts +17 -2
  12. package/dist/bpmn/compact.js +3 -3
  13. package/dist/bpmn/full-operations.d.ts +89 -0
  14. package/dist/bpmn/full-operations.js +478 -0
  15. package/dist/bpmn/index.d.ts +19 -0
  16. package/dist/bpmn/index.js +21 -0
  17. package/dist/bpmn/optimize/agentic.d.ts +10 -0
  18. package/dist/bpmn/optimize/agentic.js +88 -0
  19. package/dist/bpmn/optimize/deploy.d.ts +16 -0
  20. package/dist/bpmn/optimize/deploy.js +143 -0
  21. package/dist/bpmn/optimize/feel-syntax.d.ts +12 -0
  22. package/dist/bpmn/optimize/feel-syntax.js +87 -0
  23. package/dist/bpmn/optimize/feel.js +7 -4
  24. package/dist/bpmn/optimize/flow.js +22 -2
  25. package/dist/bpmn/optimize/index.js +20 -9
  26. package/dist/bpmn/optimize/patterns.js +23 -16
  27. package/dist/bpmn/optimize/tasks.js +30 -7
  28. package/dist/bpmn/optimize/types.d.ts +10 -1
  29. package/dist/bpmn/optimize/utils.js +2 -4
  30. package/dist/bpmn/optimize/variable-flow.js +58 -67
  31. package/dist/bpmn/semantic-hash.d.ts +93 -0
  32. package/dist/bpmn/semantic-hash.js +155 -0
  33. package/dist/bpmn/sha256.d.ts +17 -0
  34. package/dist/bpmn/sha256.js +95 -0
  35. package/dist/bpmn/zeebe-extensions.d.ts +83 -0
  36. package/dist/bpmn/zeebe-extensions.js +117 -0
  37. package/dist/bpmn/zeebe-placement.d.ts +12 -0
  38. package/dist/bpmn/zeebe-placement.js +140 -0
  39. package/dist/errors.d.ts +40 -1
  40. package/dist/errors.js +41 -0
  41. package/dist/index.d.ts +16 -5
  42. package/dist/index.js +9 -3
  43. package/dist/layout/annotations.js +36 -1
  44. package/dist/layout/collaboration/alignment.d.ts +26 -0
  45. package/dist/layout/collaboration/alignment.js +66 -0
  46. package/dist/layout/collaboration/ordering.d.ts +21 -0
  47. package/dist/layout/collaboration/ordering.js +102 -0
  48. package/dist/layout/index.d.ts +1 -0
  49. package/dist/layout/layout-engine.d.ts +13 -3
  50. package/dist/layout/layout-engine.js +9 -4
  51. package/dist/layout/semantic/bands.d.ts +19 -0
  52. package/dist/layout/semantic/bands.js +324 -0
  53. package/dist/layout/semantic/graph.d.ts +37 -0
  54. package/dist/layout/semantic/graph.js +242 -0
  55. package/dist/layout/semantic/index.d.ts +13 -0
  56. package/dist/layout/semantic/index.js +181 -0
  57. package/dist/layout/semantic/place.d.ts +40 -0
  58. package/dist/layout/semantic/place.js +271 -0
  59. package/dist/layout/semantic/route.d.ts +14 -0
  60. package/dist/layout/semantic/route.js +514 -0
  61. package/dist/layout/types.d.ts +17 -0
  62. package/dist/node/index.d.ts +10 -0
  63. package/dist/node/index.js +9 -0
  64. package/dist/node/write.d.ts +81 -0
  65. package/dist/node/write.js +167 -0
  66. package/dist/plan/compile.d.ts +39 -0
  67. package/dist/plan/compile.js +380 -0
  68. package/dist/plan/extract.d.ts +31 -0
  69. package/dist/plan/extract.js +248 -0
  70. package/dist/plan/index.d.ts +6 -0
  71. package/dist/plan/index.js +5 -0
  72. package/dist/plan/merge.d.ts +13 -0
  73. package/dist/plan/merge.js +80 -0
  74. package/dist/plan/slug.d.ts +5 -0
  75. package/dist/plan/slug.js +22 -0
  76. package/dist/plan/types.d.ts +225 -0
  77. package/dist/plan/types.js +13 -0
  78. package/dist/types/id-generator.js +11 -3
  79. package/dist/xml/index.d.ts +3 -1
  80. package/dist/xml/index.js +2 -1
  81. package/dist/xml/xml-parser.d.ts +32 -0
  82. package/dist/xml/xml-parser.js +394 -143
  83. package/package.json +9 -2
@@ -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
@@ -0,0 +1,37 @@
1
+ import type { BpmnBoundaryEvent, BpmnFlowElement, BpmnSequenceFlow } from "../../bpmn/bpmn-model.js";
2
+ /**
3
+ * Adjacency for one process scope, with cycles broken and ranks assigned.
4
+ *
5
+ * Boundary events are not ranked themselves: they dock onto their host, and an
6
+ * implicit host → handler edge carries their successors forward instead. That
7
+ * keeps a handler path strictly to the right of the activity it guards.
8
+ */
9
+ export interface SemanticGraph {
10
+ /** Rankable flow nodes, in declaration order. Boundary events excluded. */
11
+ nodes: BpmnFlowElement[];
12
+ byId: Map<string, BpmnFlowElement>;
13
+ outgoing: Map<string, BpmnSequenceFlow[]>;
14
+ incoming: Map<string, BpmnSequenceFlow[]>;
15
+ /** hostId → boundary events attached to it, in declaration order. */
16
+ attachers: Map<string, BpmnBoundaryEvent[]>;
17
+ /** Ids of sequence flows that close a cycle; excluded from ranking. */
18
+ backEdges: Set<string>;
19
+ /** Weakly connected components, each a node-id list in declaration order. */
20
+ components: string[][];
21
+ /** Semantic start node id per component, index-aligned with `components`. */
22
+ starts: string[];
23
+ /** Longest-path rank per node id. */
24
+ ranks: Map<string, number>;
25
+ /** Lazily built: ids from which an end (or terminal) node is reachable. See {@link reachesEnd}. */
26
+ endReach?: Set<string>;
27
+ }
28
+ export declare function buildSemanticGraph(flowElements: BpmnFlowElement[], sequenceFlows: BpmnSequenceFlow[]): SemanticGraph;
29
+ /**
30
+ * True when an end event is reachable from `id` without traversing a back edge.
31
+ * A node other than `id` itself that has no outgoing flow also ends the path.
32
+ *
33
+ * The reverse reachability set is computed once per graph, so repeated calls
34
+ * from the spine tracer stay O(1) instead of re-walking the graph each time.
35
+ */
36
+ export declare function reachesEnd(graph: SemanticGraph, id: string): boolean;
37
+ //# sourceMappingURL=graph.d.ts.map