@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.
- package/README.md +32 -1
- 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 +265 -3
- package/dist/bpmn/bpmn-builder.js +603 -197
- package/dist/bpmn/bpmn-model.d.ts +114 -0
- package/dist/bpmn/bpmn-parser.js +1414 -521
- package/dist/bpmn/bpmn-serializer.js +107 -19
- package/dist/bpmn/compact.d.ts +17 -2
- package/dist/bpmn/compact.js +3 -3
- package/dist/bpmn/full-operations.d.ts +89 -0
- package/dist/bpmn/full-operations.js +478 -0
- package/dist/bpmn/index.d.ts +19 -0
- package/dist/bpmn/index.js +21 -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 +7 -4
- package/dist/bpmn/optimize/flow.js +22 -2
- package/dist/bpmn/optimize/index.js +20 -9
- package/dist/bpmn/optimize/patterns.js +23 -16
- package/dist/bpmn/optimize/tasks.js +30 -7
- package/dist/bpmn/optimize/types.d.ts +10 -1
- package/dist/bpmn/optimize/utils.js +2 -4
- package/dist/bpmn/optimize/variable-flow.js +58 -67
- package/dist/bpmn/semantic-hash.d.ts +93 -0
- package/dist/bpmn/semantic-hash.js +155 -0
- package/dist/bpmn/sha256.d.ts +17 -0
- package/dist/bpmn/sha256.js +95 -0
- package/dist/bpmn/zeebe-extensions.d.ts +83 -0
- package/dist/bpmn/zeebe-extensions.js +117 -0
- package/dist/bpmn/zeebe-placement.d.ts +12 -0
- package/dist/bpmn/zeebe-placement.js +140 -0
- package/dist/errors.d.ts +40 -1
- package/dist/errors.js +41 -0
- package/dist/index.d.ts +16 -5
- package/dist/index.js +9 -3
- 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 +37 -0
- package/dist/layout/semantic/graph.js +242 -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 +514 -0
- package/dist/layout/types.d.ts +17 -0
- package/dist/node/index.d.ts +10 -0
- package/dist/node/index.js +9 -0
- package/dist/node/write.d.ts +81 -0
- package/dist/node/write.js +167 -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/dist/types/id-generator.js +11 -3
- package/dist/xml/index.d.ts +3 -1
- package/dist/xml/index.js +2 -1
- package/dist/xml/xml-parser.d.ts +32 -0
- package/dist/xml/xml-parser.js +394 -143
- package/package.json +9 -2
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { BOUNDARY_SIZE, H_GAP } from "./place.js";
|
|
2
|
+
/** Clearance kept between a routed segment and the shapes it passes. */
|
|
3
|
+
const ROUTING_MARGIN = 20;
|
|
4
|
+
/** Stub length before a boundary-event route turns. */
|
|
5
|
+
const BOUNDARY_STEM = 20;
|
|
6
|
+
/** Shapes may be grazed by this much before a route counts as blocked. */
|
|
7
|
+
const HIT_TOLERANCE = 2;
|
|
8
|
+
/** Crossings a direct route may make before a detour is considered instead. */
|
|
9
|
+
const CROSSING_TOLERANCE = 0;
|
|
10
|
+
/** How many crossings going around has to save to be worth the extra bends. */
|
|
11
|
+
const DETOUR_PENALTY = 0;
|
|
12
|
+
/** Vertical spacing between two detours sharing the same stretch of diagram. */
|
|
13
|
+
const CORRIDOR_SPACING = 20;
|
|
14
|
+
/** How many lanes deep a corridor may stack before routes are allowed to share. */
|
|
15
|
+
const CORRIDOR_LANES = 4;
|
|
16
|
+
/** Width of one bucket in the x-axis indexes below. */
|
|
17
|
+
const INDEX_CELL = 128;
|
|
18
|
+
/**
|
|
19
|
+
* Items bucketed by the x-range they cover, so a query for a segment's x-span
|
|
20
|
+
* visits only the shapes and routes near it instead of every one in the
|
|
21
|
+
* diagram. Orthogonal routes are mostly short, so this turns the per-edge
|
|
22
|
+
* obstacle and crossing checks from O(n) into near-constant work.
|
|
23
|
+
*/
|
|
24
|
+
class XIndex {
|
|
25
|
+
cells = new Map();
|
|
26
|
+
stamp = 0;
|
|
27
|
+
insert(minX, maxX, item) {
|
|
28
|
+
const entry = { item, stamp: 0 };
|
|
29
|
+
const lo = Math.floor(Math.min(minX, maxX) / INDEX_CELL);
|
|
30
|
+
const hi = Math.floor(Math.max(minX, maxX) / INDEX_CELL);
|
|
31
|
+
for (let c = lo; c <= hi; c++) {
|
|
32
|
+
const cell = this.cells.get(c);
|
|
33
|
+
if (cell)
|
|
34
|
+
cell.push(entry);
|
|
35
|
+
else
|
|
36
|
+
this.cells.set(c, [entry]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Visit every item whose x-range may overlap [minX, maxX]; stop early when `visit` returns true. */
|
|
40
|
+
query(minX, maxX, visit) {
|
|
41
|
+
const stamp = ++this.stamp;
|
|
42
|
+
const lo = Math.floor(Math.min(minX, maxX) / INDEX_CELL);
|
|
43
|
+
const hi = Math.floor(Math.max(minX, maxX) / INDEX_CELL);
|
|
44
|
+
for (let c = lo; c <= hi; c++) {
|
|
45
|
+
const cell = this.cells.get(c);
|
|
46
|
+
if (!cell)
|
|
47
|
+
continue;
|
|
48
|
+
for (const entry of cell) {
|
|
49
|
+
if (entry.stamp === stamp)
|
|
50
|
+
continue;
|
|
51
|
+
entry.stamp = stamp;
|
|
52
|
+
if (visit(entry.item))
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function centre(b) {
|
|
60
|
+
return { x: b.x + b.width / 2, y: b.y + b.height / 2 };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Route every sequence flow orthogonally.
|
|
64
|
+
*
|
|
65
|
+
* Each edge proposes candidate polylines from most to least desirable and takes
|
|
66
|
+
* the first one that clears every unrelated shape: a straight run along the
|
|
67
|
+
* spine, a turn out of the source's top or bottom for a branch, and a turn in
|
|
68
|
+
* the empty gutter in front of the target's column as the general fallback.
|
|
69
|
+
*/
|
|
70
|
+
export function routeFlows(graph, flows, bounds, bandLayout, gutterX) {
|
|
71
|
+
const obstacles = new XIndex();
|
|
72
|
+
for (const [id, b] of bounds)
|
|
73
|
+
obstacles.insert(b.x, b.x + b.width, { id, b });
|
|
74
|
+
const ctx = {
|
|
75
|
+
graph,
|
|
76
|
+
bounds,
|
|
77
|
+
obstacles,
|
|
78
|
+
gutterX,
|
|
79
|
+
reserved: [],
|
|
80
|
+
routed: new XIndex(),
|
|
81
|
+
};
|
|
82
|
+
const routed = new Map();
|
|
83
|
+
const pending = [];
|
|
84
|
+
for (const flow of flows) {
|
|
85
|
+
const source = bounds.get(flow.sourceRef);
|
|
86
|
+
const target = bounds.get(flow.targetRef);
|
|
87
|
+
if (!source || !target)
|
|
88
|
+
continue;
|
|
89
|
+
const fromBoundary = graph.byId.get(flow.sourceRef)?.type === "boundaryEvent";
|
|
90
|
+
const sourceRank = graph.ranks.get(fromBoundary ? hostOf(graph, flow.sourceRef) : flow.sourceRef);
|
|
91
|
+
const targetRank = graph.ranks.get(flow.targetRef);
|
|
92
|
+
const isBackEdge = graph.backEdges.has(flow.id);
|
|
93
|
+
const direct = isBackEdge
|
|
94
|
+
? []
|
|
95
|
+
: fromBoundary
|
|
96
|
+
? fromBoundaryCandidates(ctx, source, target, targetRank)
|
|
97
|
+
: forwardCandidates(ctx, source, target, sourceRank, targetRank);
|
|
98
|
+
const clear = pick(direct, ctx, flow.sourceRef, flow.targetRef);
|
|
99
|
+
if (clear && clear.crossings <= CROSSING_TOLERANCE) {
|
|
100
|
+
commit(ctx, routed, flow.id, clear.waypoints);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
pending.push({
|
|
104
|
+
flow,
|
|
105
|
+
source,
|
|
106
|
+
target,
|
|
107
|
+
sourceRank,
|
|
108
|
+
targetRank,
|
|
109
|
+
below: isBackEdge,
|
|
110
|
+
span: Math.abs(target.x - source.x),
|
|
111
|
+
direct: clear,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// Widest detours claim the outermost corridors, so long routes nest around
|
|
115
|
+
// short ones instead of crossing them.
|
|
116
|
+
pending.sort((a, b) => b.span - a.span);
|
|
117
|
+
for (const item of pending) {
|
|
118
|
+
const around = detour(ctx, item.source, item.target, item.sourceRank, item.targetRank, item.below, item.flow.sourceRef, item.flow.targetRef);
|
|
119
|
+
// Going around costs bends and length, so it has to save more than one
|
|
120
|
+
// crossing to be worth taking.
|
|
121
|
+
const cost = crossingCount(around, ctx) + DETOUR_PENALTY;
|
|
122
|
+
const waypoints = item.direct && item.direct.crossings <= cost ? item.direct.waypoints : around;
|
|
123
|
+
commit(ctx, routed, item.flow.id, waypoints);
|
|
124
|
+
}
|
|
125
|
+
// Emit in declaration order, whichever pass produced the route.
|
|
126
|
+
const edges = [];
|
|
127
|
+
for (const flow of flows) {
|
|
128
|
+
const waypoints = routed.get(flow.id);
|
|
129
|
+
if (!waypoints)
|
|
130
|
+
continue;
|
|
131
|
+
const edge = {
|
|
132
|
+
id: flow.id,
|
|
133
|
+
sourceRef: flow.sourceRef,
|
|
134
|
+
targetRef: flow.targetRef,
|
|
135
|
+
waypoints: collapse(waypoints),
|
|
136
|
+
};
|
|
137
|
+
if (flow.name)
|
|
138
|
+
edge.label = flow.name;
|
|
139
|
+
edges.push(edge);
|
|
140
|
+
}
|
|
141
|
+
return edges;
|
|
142
|
+
}
|
|
143
|
+
/** Record a chosen route so later edges can steer around it. */
|
|
144
|
+
function commit(ctx, routed, id, waypoints) {
|
|
145
|
+
routed.set(id, waypoints);
|
|
146
|
+
for (let i = 0; i + 1 < waypoints.length; i++) {
|
|
147
|
+
const a = waypoints[i];
|
|
148
|
+
const b = waypoints[i + 1];
|
|
149
|
+
if (a && b)
|
|
150
|
+
ctx.routed.insert(Math.min(a.x, b.x), Math.max(a.x, b.x), [a, b]);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function hostOf(graph, eventId) {
|
|
154
|
+
for (const [hostId, events] of graph.attachers) {
|
|
155
|
+
if (events.some((e) => e.id === eventId))
|
|
156
|
+
return hostId;
|
|
157
|
+
}
|
|
158
|
+
return eventId;
|
|
159
|
+
}
|
|
160
|
+
/** First candidate that crosses no shape other than its own endpoints. */
|
|
161
|
+
function pick(candidates, ctx, sourceId, targetId) {
|
|
162
|
+
let best = null;
|
|
163
|
+
for (const candidate of candidates) {
|
|
164
|
+
// An expanded container legitimately holds its children's routes, so only
|
|
165
|
+
// the two endpoints are exempt from the obstacle check.
|
|
166
|
+
if (blocked(ctx, candidate, sourceId, targetId))
|
|
167
|
+
continue;
|
|
168
|
+
const crossings = crossingCount(candidate, ctx);
|
|
169
|
+
if (crossings === 0)
|
|
170
|
+
return { waypoints: candidate, crossings };
|
|
171
|
+
if (!best || crossings < best.crossings)
|
|
172
|
+
best = { waypoints: candidate, crossings };
|
|
173
|
+
}
|
|
174
|
+
return best;
|
|
175
|
+
}
|
|
176
|
+
/** Proper intersection between two segments — touching endpoints do not count. */
|
|
177
|
+
function intersects(p, q, r, t) {
|
|
178
|
+
const side = (o, a, b) => Math.sign((a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x));
|
|
179
|
+
return side(p, q, r) !== side(p, q, t) && side(r, t, p) !== side(r, t, q);
|
|
180
|
+
}
|
|
181
|
+
/** How many already-routed edges a candidate would cut across. */
|
|
182
|
+
function crossingCount(waypoints, ctx) {
|
|
183
|
+
let count = 0;
|
|
184
|
+
for (let i = 0; i + 1 < waypoints.length; i++) {
|
|
185
|
+
const a = waypoints[i];
|
|
186
|
+
const b = waypoints[i + 1];
|
|
187
|
+
if (!a || !b)
|
|
188
|
+
continue;
|
|
189
|
+
ctx.routed.query(Math.min(a.x, b.x), Math.max(a.x, b.x), ([c, d]) => {
|
|
190
|
+
if (intersects(a, b, c, d))
|
|
191
|
+
count++;
|
|
192
|
+
return false;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return count;
|
|
196
|
+
}
|
|
197
|
+
/** True when the segment a→b passes through `o`, grazing allowed up to HIT_TOLERANCE. */
|
|
198
|
+
function segmentHits(a, b, o) {
|
|
199
|
+
const minX = Math.min(a.x, b.x) + HIT_TOLERANCE;
|
|
200
|
+
const maxX = Math.max(a.x, b.x) - HIT_TOLERANCE;
|
|
201
|
+
const minY = Math.min(a.y, b.y) + HIT_TOLERANCE;
|
|
202
|
+
const maxY = Math.max(a.y, b.y) - HIT_TOLERANCE;
|
|
203
|
+
if (maxX <= o.x || o.x + o.width <= minX)
|
|
204
|
+
return false;
|
|
205
|
+
if (maxY <= o.y || o.y + o.height <= minY)
|
|
206
|
+
return false;
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
/** How many shapes a route passes through, its own endpoints excepted. */
|
|
210
|
+
function hitCount(ctx, waypoints, sourceId, targetId) {
|
|
211
|
+
const hit = new Set();
|
|
212
|
+
for (let i = 0; i + 1 < waypoints.length; i++) {
|
|
213
|
+
const a = waypoints[i];
|
|
214
|
+
const b = waypoints[i + 1];
|
|
215
|
+
if (!a || !b)
|
|
216
|
+
continue;
|
|
217
|
+
ctx.obstacles.query(Math.min(a.x, b.x), Math.max(a.x, b.x), (o) => {
|
|
218
|
+
if (o.id !== sourceId && o.id !== targetId && segmentHits(a, b, o.b))
|
|
219
|
+
hit.add(o);
|
|
220
|
+
return false;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return hit.size;
|
|
224
|
+
}
|
|
225
|
+
/** True when any segment of the route passes through a shape other than its endpoints. */
|
|
226
|
+
function blocked(ctx, waypoints, sourceId, targetId) {
|
|
227
|
+
for (let i = 0; i + 1 < waypoints.length; i++) {
|
|
228
|
+
const a = waypoints[i];
|
|
229
|
+
const b = waypoints[i + 1];
|
|
230
|
+
if (!a || !b)
|
|
231
|
+
continue;
|
|
232
|
+
const hit = ctx.obstacles.query(Math.min(a.x, b.x), Math.max(a.x, b.x), (o) => o.id !== sourceId && o.id !== targetId && segmentHits(a, b, o.b));
|
|
233
|
+
if (hit)
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Candidates for a normal forward flow, best first: a straight spine segment,
|
|
240
|
+
* a branch leaving through the source's top or bottom, then the gutter turn.
|
|
241
|
+
*/
|
|
242
|
+
function forwardCandidates(ctx, source, target, sourceRank, targetRank) {
|
|
243
|
+
const from = centre(source);
|
|
244
|
+
const to = centre(target);
|
|
245
|
+
const rightOf = target.x >= source.x + source.width;
|
|
246
|
+
if (Math.abs(from.y - to.y) < 1 && rightOf) {
|
|
247
|
+
return [
|
|
248
|
+
[
|
|
249
|
+
{ x: source.x + source.width, y: from.y },
|
|
250
|
+
{ x: target.x, y: to.y },
|
|
251
|
+
],
|
|
252
|
+
];
|
|
253
|
+
}
|
|
254
|
+
// Same column, different band: drop straight between the two.
|
|
255
|
+
if (sourceRank !== undefined && sourceRank === targetRank) {
|
|
256
|
+
const down = to.y > from.y;
|
|
257
|
+
return [
|
|
258
|
+
[
|
|
259
|
+
{ x: from.x, y: down ? source.y + source.height : source.y },
|
|
260
|
+
{ x: to.x, y: down ? target.y : target.y + target.height },
|
|
261
|
+
],
|
|
262
|
+
];
|
|
263
|
+
}
|
|
264
|
+
if (!rightOf)
|
|
265
|
+
return [];
|
|
266
|
+
const down = to.y > from.y;
|
|
267
|
+
const gutter = ctx.gutterX.get(targetRank ?? 0) ?? target.x - H_GAP / 2;
|
|
268
|
+
const behind = sourceRank !== undefined
|
|
269
|
+
? (ctx.gutterX.get(sourceRank + 1) ?? source.x + source.width + H_GAP / 2)
|
|
270
|
+
: source.x + source.width + H_GAP / 2;
|
|
271
|
+
const candidates = [
|
|
272
|
+
// Branch: leave through the side facing the target band, then run in.
|
|
273
|
+
[
|
|
274
|
+
{ x: from.x, y: down ? source.y + source.height : source.y },
|
|
275
|
+
{ x: from.x, y: to.y },
|
|
276
|
+
{ x: target.x, y: to.y },
|
|
277
|
+
],
|
|
278
|
+
// Gutter turn in front of the target: out of the right edge, across, in.
|
|
279
|
+
[
|
|
280
|
+
{ x: source.x + source.width, y: from.y },
|
|
281
|
+
{ x: gutter, y: from.y },
|
|
282
|
+
{ x: gutter, y: to.y },
|
|
283
|
+
{ x: target.x, y: to.y },
|
|
284
|
+
],
|
|
285
|
+
];
|
|
286
|
+
// Turning in the gutter right behind the source instead reaches the target
|
|
287
|
+
// band early; which of the two crosses less depends on what is already
|
|
288
|
+
// routed, so both are offered.
|
|
289
|
+
if (Math.abs(behind - gutter) > 1) {
|
|
290
|
+
candidates.push([
|
|
291
|
+
{ x: source.x + source.width, y: from.y },
|
|
292
|
+
{ x: behind, y: from.y },
|
|
293
|
+
{ x: behind, y: to.y },
|
|
294
|
+
{ x: target.x, y: to.y },
|
|
295
|
+
]);
|
|
296
|
+
}
|
|
297
|
+
return candidates;
|
|
298
|
+
}
|
|
299
|
+
/** A boundary event leaves through its outward side, never into its host. */
|
|
300
|
+
function fromBoundaryCandidates(ctx, source, target, targetRank) {
|
|
301
|
+
const from = centre(source);
|
|
302
|
+
const to = centre(target);
|
|
303
|
+
const down = to.y >= from.y;
|
|
304
|
+
const exitY = down ? source.y + source.height : source.y;
|
|
305
|
+
const stem = down ? exitY + BOUNDARY_STEM : exitY - BOUNDARY_STEM;
|
|
306
|
+
if (Math.abs(from.x - to.x) < 1) {
|
|
307
|
+
return [
|
|
308
|
+
[
|
|
309
|
+
{ x: from.x, y: exitY },
|
|
310
|
+
{ x: to.x, y: down ? target.y : target.y + target.height },
|
|
311
|
+
],
|
|
312
|
+
];
|
|
313
|
+
}
|
|
314
|
+
if (target.x >= from.x) {
|
|
315
|
+
const gutter = ctx.gutterX.get(targetRank ?? 0) ?? target.x - H_GAP / 2;
|
|
316
|
+
return [
|
|
317
|
+
[
|
|
318
|
+
{ x: from.x, y: exitY },
|
|
319
|
+
{ x: from.x, y: to.y },
|
|
320
|
+
{ x: target.x, y: to.y },
|
|
321
|
+
],
|
|
322
|
+
[
|
|
323
|
+
{ x: from.x, y: exitY },
|
|
324
|
+
{ x: from.x, y: stem },
|
|
325
|
+
{ x: gutter, y: stem },
|
|
326
|
+
{ x: gutter, y: to.y },
|
|
327
|
+
{ x: target.x, y: to.y },
|
|
328
|
+
],
|
|
329
|
+
];
|
|
330
|
+
}
|
|
331
|
+
return [
|
|
332
|
+
[
|
|
333
|
+
{ x: from.x, y: exitY },
|
|
334
|
+
{ x: from.x, y: stem },
|
|
335
|
+
{ x: target.x + target.width, y: stem },
|
|
336
|
+
{ x: target.x + target.width, y: to.y },
|
|
337
|
+
],
|
|
338
|
+
];
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* The universal fallback: leave through the gutter behind the source, cross in
|
|
342
|
+
* the nearest horizontal corridor that clears every shape between the two
|
|
343
|
+
* columns, and come back through the gutter in front of the target. Used for
|
|
344
|
+
* feedback edges and for any forward edge whose direct routes are blocked.
|
|
345
|
+
*/
|
|
346
|
+
function detour(ctx, source, target, sourceRank, targetRank,
|
|
347
|
+
/** A loop reads as a loop when it runs clear of the flow it repeats. */
|
|
348
|
+
isLoop = false, sourceId = "", targetId = "") {
|
|
349
|
+
const from = centre(source);
|
|
350
|
+
const to = centre(target);
|
|
351
|
+
const dropX = sourceRank !== undefined
|
|
352
|
+
? (ctx.gutterX.get(sourceRank + 1) ?? source.x + source.width + H_GAP / 2)
|
|
353
|
+
: source.x + source.width + H_GAP / 2;
|
|
354
|
+
const riseX = ctx.gutterX.get(targetRank ?? 0) ?? target.x - H_GAP / 2;
|
|
355
|
+
const left = Math.min(dropX, riseX, from.x, to.x);
|
|
356
|
+
const right = Math.max(dropX, riseX, from.x, to.x);
|
|
357
|
+
// A loop leaves the band it repeats: below by convention, but above when the
|
|
358
|
+
// space below is busy and the space above is not. Anything else takes the
|
|
359
|
+
// nearest clear corridor, whichever side that is on.
|
|
360
|
+
const middle = (from.y + to.y) / 2;
|
|
361
|
+
const bottom = Math.max(source.y + source.height, target.y + target.height);
|
|
362
|
+
const top = Math.min(source.y, target.y);
|
|
363
|
+
const options = isLoop
|
|
364
|
+
? [
|
|
365
|
+
corridor(ctx, left, right, middle, bottom),
|
|
366
|
+
corridor(ctx, left, right, middle, undefined, top),
|
|
367
|
+
]
|
|
368
|
+
: [corridor(ctx, left, right, middle)];
|
|
369
|
+
let base = options[0] ?? middle;
|
|
370
|
+
if (isLoop) {
|
|
371
|
+
let fewest = Number.POSITIVE_INFINITY;
|
|
372
|
+
for (const option of options) {
|
|
373
|
+
const crossings = crossingCount([
|
|
374
|
+
{ x: left, y: option },
|
|
375
|
+
{ x: right, y: option },
|
|
376
|
+
], ctx);
|
|
377
|
+
if (crossings < fewest) {
|
|
378
|
+
fewest = crossings;
|
|
379
|
+
base = option;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const goesDown = base > middle;
|
|
384
|
+
const corridorY = reserve(ctx, left, right, base, goesDown ? 1 : -1);
|
|
385
|
+
const exitY = goesDown ? source.y + source.height : source.y;
|
|
386
|
+
const entryY = goesDown ? target.y + target.height : target.y;
|
|
387
|
+
// Leaving through the source's own top or bottom keeps the route out of the
|
|
388
|
+
// horizontal corridor its neighbours flow along; the gutter variant is the
|
|
389
|
+
// fallback for when that vertical is occupied.
|
|
390
|
+
const straightOut = [
|
|
391
|
+
{ x: from.x, y: exitY },
|
|
392
|
+
{ x: from.x, y: corridorY },
|
|
393
|
+
{ x: to.x, y: corridorY },
|
|
394
|
+
{ x: to.x, y: entryY },
|
|
395
|
+
];
|
|
396
|
+
const viaGutter = [
|
|
397
|
+
{ x: source.x + source.width, y: from.y },
|
|
398
|
+
{ x: dropX, y: from.y },
|
|
399
|
+
{ x: dropX, y: corridorY },
|
|
400
|
+
{ x: riseX, y: corridorY },
|
|
401
|
+
{ x: riseX, y: to.y },
|
|
402
|
+
{ x: target.x, y: to.y },
|
|
403
|
+
];
|
|
404
|
+
const chosen = pick([straightOut, viaGutter], ctx, sourceId, targetId);
|
|
405
|
+
if (chosen)
|
|
406
|
+
return chosen.waypoints;
|
|
407
|
+
// Neither is clear: keep whichever grazes fewer shapes.
|
|
408
|
+
return hitCount(ctx, straightOut, sourceId, targetId) <=
|
|
409
|
+
hitCount(ctx, viaGutter, sourceId, targetId)
|
|
410
|
+
? straightOut
|
|
411
|
+
: viaGutter;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Keep parallel detours apart: step away from the flow until this stretch of
|
|
415
|
+
* corridor is free, so two long routes stack instead of merging into one line.
|
|
416
|
+
*/
|
|
417
|
+
function reserve(ctx, left, right, base, direction) {
|
|
418
|
+
const hitsShape = (candidate) => ctx.obstacles.query(left, right, ({ b }) => b.x + b.width > left &&
|
|
419
|
+
b.x < right &&
|
|
420
|
+
b.y - HIT_TOLERANCE < candidate &&
|
|
421
|
+
candidate < b.y + b.height + HIT_TOLERANCE);
|
|
422
|
+
let y = base;
|
|
423
|
+
let free = base;
|
|
424
|
+
for (let lane = 0; lane < CORRIDOR_LANES; lane++) {
|
|
425
|
+
if (!hitsShape(y)) {
|
|
426
|
+
free = y;
|
|
427
|
+
const taken = ctx.reserved.some((r) => r.left < right && left < r.right && Math.abs(r.y - y) < CORRIDOR_SPACING);
|
|
428
|
+
if (!taken)
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
y += direction * CORRIDOR_SPACING;
|
|
432
|
+
}
|
|
433
|
+
// Sharing a corridor with another route is a lesser evil than running the
|
|
434
|
+
// route through a shape, so fall back to the last line that was clear.
|
|
435
|
+
if (hitsShape(y))
|
|
436
|
+
y = free;
|
|
437
|
+
ctx.reserved.push({ left, right, y });
|
|
438
|
+
return y;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* A horizontal line between `left` and `right` that misses every shape, chosen
|
|
442
|
+
* as close to `preferredY` as possible so detours stay local instead of
|
|
443
|
+
* sweeping around the whole diagram.
|
|
444
|
+
*/
|
|
445
|
+
function corridor(ctx, left, right, preferredY,
|
|
446
|
+
/** Only consider corridors below this line. */
|
|
447
|
+
floor,
|
|
448
|
+
/** Only consider corridors above this line. */
|
|
449
|
+
ceiling) {
|
|
450
|
+
const spans = [];
|
|
451
|
+
ctx.obstacles.query(left, right, ({ b }) => {
|
|
452
|
+
if (b.x + b.width > left && b.x < right) {
|
|
453
|
+
spans.push([b.y - ROUTING_MARGIN, b.y + b.height + ROUTING_MARGIN]);
|
|
454
|
+
}
|
|
455
|
+
return false;
|
|
456
|
+
});
|
|
457
|
+
if (spans.length === 0)
|
|
458
|
+
return preferredY;
|
|
459
|
+
spans.sort((a, b) => a[0] - b[0]);
|
|
460
|
+
// Merge the blocked bands, then take the gap centre nearest the preference.
|
|
461
|
+
const merged = [];
|
|
462
|
+
for (const span of spans) {
|
|
463
|
+
const last = merged[merged.length - 1];
|
|
464
|
+
if (last && span[0] <= last[1])
|
|
465
|
+
last[1] = Math.max(last[1], span[1]);
|
|
466
|
+
else
|
|
467
|
+
merged.push([span[0], span[1]]);
|
|
468
|
+
}
|
|
469
|
+
const options = [];
|
|
470
|
+
const first = merged[0];
|
|
471
|
+
const last = merged[merged.length - 1];
|
|
472
|
+
if (first)
|
|
473
|
+
options.push(first[0] - ROUTING_MARGIN);
|
|
474
|
+
if (last)
|
|
475
|
+
options.push(last[1] + ROUTING_MARGIN);
|
|
476
|
+
for (let i = 0; i + 1 < merged.length; i++) {
|
|
477
|
+
const a = merged[i];
|
|
478
|
+
const b = merged[i + 1];
|
|
479
|
+
if (a && b)
|
|
480
|
+
options.push((a[1] + b[0]) / 2);
|
|
481
|
+
}
|
|
482
|
+
let allowed = floor === undefined ? options : options.filter((y) => y > floor);
|
|
483
|
+
if (ceiling !== undefined)
|
|
484
|
+
allowed = allowed.filter((y) => y < ceiling);
|
|
485
|
+
const usable = allowed.length > 0 ? allowed : options;
|
|
486
|
+
let best = usable[0] ?? preferredY;
|
|
487
|
+
for (const option of usable) {
|
|
488
|
+
if (Math.abs(option - preferredY) < Math.abs(best - preferredY))
|
|
489
|
+
best = option;
|
|
490
|
+
}
|
|
491
|
+
return best;
|
|
492
|
+
}
|
|
493
|
+
/** Drop duplicate points and mid-points that sit on a straight run. */
|
|
494
|
+
function collapse(waypoints) {
|
|
495
|
+
const out = [];
|
|
496
|
+
for (const wp of waypoints) {
|
|
497
|
+
const last = out[out.length - 1];
|
|
498
|
+
if (last && last.x === wp.x && last.y === wp.y)
|
|
499
|
+
continue;
|
|
500
|
+
out.push(wp);
|
|
501
|
+
}
|
|
502
|
+
for (let i = out.length - 2; i > 0; i--) {
|
|
503
|
+
const prev = out[i - 1];
|
|
504
|
+
const cur = out[i];
|
|
505
|
+
const next = out[i + 1];
|
|
506
|
+
if (!prev || !cur || !next)
|
|
507
|
+
continue;
|
|
508
|
+
const collinear = (prev.x === cur.x && cur.x === next.x) || (prev.y === cur.y && cur.y === next.y);
|
|
509
|
+
if (collinear)
|
|
510
|
+
out.splice(i, 1);
|
|
511
|
+
}
|
|
512
|
+
return out;
|
|
513
|
+
}
|
|
514
|
+
//# sourceMappingURL=route.js.map
|
package/dist/layout/types.d.ts
CHANGED
|
@@ -74,5 +74,22 @@ export interface SubProcessChildResult {
|
|
|
74
74
|
export interface LayoutResult {
|
|
75
75
|
nodes: LayoutNode[];
|
|
76
76
|
edges: LayoutEdge[];
|
|
77
|
+
/**
|
|
78
|
+
* Lane bands in top-to-bottom order, when the engine placed nodes by lane
|
|
79
|
+
* membership. Absent when the scope has no lanes.
|
|
80
|
+
*/
|
|
81
|
+
lanes?: Array<{
|
|
82
|
+
id: string;
|
|
83
|
+
bounds: Bounds;
|
|
84
|
+
}>;
|
|
85
|
+
/**
|
|
86
|
+
* Layouts that belong on their own plane rather than on this one: the
|
|
87
|
+
* contents of each collapsed sub-process, laid out at the origin. Nested
|
|
88
|
+
* collapsed scopes are flattened into this list.
|
|
89
|
+
*/
|
|
90
|
+
planes?: Array<{
|
|
91
|
+
elementId: string;
|
|
92
|
+
result: LayoutResult;
|
|
93
|
+
}>;
|
|
77
94
|
}
|
|
78
95
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-only entry point.
|
|
3
|
+
*
|
|
4
|
+
* Everything here touches the filesystem, so it lives behind the
|
|
5
|
+
* `@bpmnkit/core/node` subpath. Importing `@bpmnkit/core` itself stays free of
|
|
6
|
+
* `node:` builtins and keeps working in browsers, workers and edge runtimes.
|
|
7
|
+
*/
|
|
8
|
+
export { writeBpmn } from "./write.js";
|
|
9
|
+
export type { WriteBpmnOptions, WriteBpmnResult } from "./write.js";
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-only entry point.
|
|
3
|
+
*
|
|
4
|
+
* Everything here touches the filesystem, so it lives behind the
|
|
5
|
+
* `@bpmnkit/core/node` subpath. Importing `@bpmnkit/core` itself stays free of
|
|
6
|
+
* `node:` builtins and keeps working in browsers, workers and edge runtimes.
|
|
7
|
+
*/
|
|
8
|
+
export { writeBpmn } from "./write.js";
|
|
9
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { BpmnDefinitions } from "../bpmn/bpmn-model.js";
|
|
2
|
+
import { type SemanticDiff } from "../bpmn/semantic-hash.js";
|
|
3
|
+
/**
|
|
4
|
+
* The only place in this package that writes a BPMN file, and the only one that
|
|
5
|
+
* checks what it wrote.
|
|
6
|
+
*
|
|
7
|
+
* Node-only — it is reached through the `@bpmnkit/core/node` subpath so that
|
|
8
|
+
* importing `@bpmnkit/core` in a browser never pulls `node:fs` in.
|
|
9
|
+
*
|
|
10
|
+
* **What the verification does and does not cover.** Before anything reaches
|
|
11
|
+
* disk, the model is serialised, parsed back, and the two semantic hashes are
|
|
12
|
+
* compared. That catches the serialiser dropping or mangling something. It
|
|
13
|
+
* cannot catch the *parser* dropping something on the way in: content the
|
|
14
|
+
* parser never saw is absent from both sides and the hashes agree. Guarding
|
|
15
|
+
* that is the round-trip corpus gate's job (`tests/roundtrip-corpus.test.ts`),
|
|
16
|
+
* not this function's.
|
|
17
|
+
*
|
|
18
|
+
* There is deliberately no option to skip verification. Turning it off would
|
|
19
|
+
* only ever be used to get past the bug it exists to report; callers who want
|
|
20
|
+
* unchecked serialisation can still use `Bpmn.export()` and write it
|
|
21
|
+
* themselves.
|
|
22
|
+
*/
|
|
23
|
+
export interface WriteBpmnOptions {
|
|
24
|
+
/** Path to write to. */
|
|
25
|
+
output: string;
|
|
26
|
+
/**
|
|
27
|
+
* Replace `output` if it already exists. Default `false`, which refuses
|
|
28
|
+
* rather than overwrite.
|
|
29
|
+
*/
|
|
30
|
+
force?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* `"preserve"` (default) writes the diagram the model already carries.
|
|
33
|
+
* `"auto"` regenerates it first — the model is unchanged either way, which
|
|
34
|
+
* the verification step proves.
|
|
35
|
+
*/
|
|
36
|
+
layout?: "preserve" | "auto";
|
|
37
|
+
}
|
|
38
|
+
export interface WriteBpmnResult {
|
|
39
|
+
/** Absolute path written. */
|
|
40
|
+
destination: string;
|
|
41
|
+
/** Size of the written file in bytes. */
|
|
42
|
+
bytes: number;
|
|
43
|
+
/** SHA-256 of the exact bytes written. */
|
|
44
|
+
outputSha256: string;
|
|
45
|
+
/** Semantic hash of the model, as verified after reading it back. */
|
|
46
|
+
semanticHash: string;
|
|
47
|
+
/**
|
|
48
|
+
* What this write changed about the file that was already there, or
|
|
49
|
+
* `undefined` when the destination was newly created or the previous
|
|
50
|
+
* contents could not be parsed.
|
|
51
|
+
*/
|
|
52
|
+
changes?: SemanticDiff;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Serialises a model, verifies it survives a round trip, and writes it
|
|
56
|
+
* atomically.
|
|
57
|
+
*
|
|
58
|
+
* The file appears complete or not at all: the contents go to a temporary file
|
|
59
|
+
* in the destination's own directory and are then renamed into place, so an
|
|
60
|
+
* interrupted write cannot leave a half-written model behind. Without `force`
|
|
61
|
+
* the final step is a hard link, which fails if the destination appeared in the
|
|
62
|
+
* meantime rather than silently replacing it.
|
|
63
|
+
*
|
|
64
|
+
* @param definitions - The model to write.
|
|
65
|
+
* @param options - Destination and write behaviour.
|
|
66
|
+
* @returns Where it went, what it hashes to, and what it changed.
|
|
67
|
+
* @throws {WriteVerificationError} If reading the output back does not
|
|
68
|
+
* reproduce the model. Nothing is written.
|
|
69
|
+
* @throws {WriteError} If the destination exists and `force` was not given, or
|
|
70
|
+
* the filesystem refused the write.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```typescript
|
|
74
|
+
* import { writeBpmn } from "@bpmnkit/core/node"
|
|
75
|
+
*
|
|
76
|
+
* const result = await writeBpmn(definitions, { output: "flow.bpmn" })
|
|
77
|
+
* console.log(result.semanticHash, result.changes?.changed.length ?? 0)
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export declare function writeBpmn(definitions: BpmnDefinitions, options: WriteBpmnOptions): Promise<WriteBpmnResult>;
|
|
81
|
+
//# sourceMappingURL=write.d.ts.map
|