@bpmn-nova/studio 0.3.0-preview → 0.3.2-preview

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 (63) hide show
  1. package/README.md +245 -20
  2. package/dist/canvas.js +7 -4
  3. package/dist/context-menu.js +2 -2
  4. package/dist/controller.js +84 -6
  5. package/dist/index.d.ts +138 -38
  6. package/dist/index.js +12 -11
  7. package/dist/interactions.js +1 -1
  8. package/dist/modules/bpmn-model/index.d.ts +11 -0
  9. package/dist/modules/bpmn-model/index.js +703 -0
  10. package/dist/modules/core/containment.js +590 -0
  11. package/dist/modules/core/gateway.js +72 -0
  12. package/dist/modules/core/history.js +32 -0
  13. package/dist/modules/core/index.d.ts +268 -0
  14. package/dist/modules/core/index.js +7 -0
  15. package/dist/modules/core/layout.js +644 -0
  16. package/dist/modules/core/model.js +287 -0
  17. package/dist/modules/core/runtime-transition-route.js +360 -0
  18. package/dist/modules/core/scope.js +99 -0
  19. package/dist/modules/designer/index.d.ts +79 -0
  20. package/dist/modules/designer/index.js +607 -0
  21. package/dist/modules/engine-activiti/index.d.ts +19 -0
  22. package/dist/modules/engine-activiti/index.js +160 -0
  23. package/dist/modules/engine-flowable/index.d.ts +19 -0
  24. package/dist/modules/engine-flowable/index.js +160 -0
  25. package/dist/modules/export-svg/index.d.ts +112 -0
  26. package/dist/modules/export-svg/index.js +2 -0
  27. package/dist/modules/export-svg/preview.js +327 -0
  28. package/dist/modules/export-svg/render.js +718 -0
  29. package/dist/modules/icons/index.d.ts +24 -0
  30. package/dist/modules/icons/index.js +264 -0
  31. package/dist/modules/palette/index.d.ts +74 -0
  32. package/dist/modules/palette/index.js +99 -0
  33. package/dist/modules/palette/panel.js +99 -0
  34. package/dist/modules/properties/index.d.ts +20 -0
  35. package/dist/modules/properties/index.js +19 -0
  36. package/dist/modules/properties-activiti/index.d.ts +3 -0
  37. package/dist/modules/properties-activiti/index.js +97 -0
  38. package/dist/modules/properties-bpmn/index.d.ts +3 -0
  39. package/dist/modules/properties-bpmn/index.js +518 -0
  40. package/dist/modules/properties-core/index.d.ts +124 -0
  41. package/dist/modules/properties-core/index.js +312 -0
  42. package/dist/modules/properties-flowable/index.d.ts +3 -0
  43. package/dist/modules/properties-flowable/index.js +114 -0
  44. package/dist/modules/properties-renderer/index.d.ts +25 -0
  45. package/dist/modules/properties-renderer/index.js +491 -0
  46. package/dist/modules/renderer-svg/index.d.ts +118 -0
  47. package/dist/modules/renderer-svg/index.js +1460 -0
  48. package/dist/modules/runtime/index.d.ts +169 -0
  49. package/dist/modules/runtime/index.js +535 -0
  50. package/dist/modules/theme/index.d.ts +95 -0
  51. package/dist/modules/theme/index.js +368 -0
  52. package/dist/modules/viewer/index.d.ts +265 -0
  53. package/dist/modules/viewer/index.js +1011 -0
  54. package/dist/modules/viewer/runtime-content.js +123 -0
  55. package/dist/modules/viewer/runtime-details-motion.js +228 -0
  56. package/dist/modules/viewer/runtime-trace.js +574 -0
  57. package/dist/modules/viewer/timeline.js +276 -0
  58. package/dist/selection-layout.js +1 -1
  59. package/dist/shell.js +210 -26
  60. package/dist/styles.css +116 -7
  61. package/llms-full.txt +3082 -0
  62. package/llms.txt +225 -0
  63. package/package.json +39 -16
@@ -0,0 +1,644 @@
1
+ import { getNode, NODE_DEFINITIONS } from './model.js';
2
+ import {
3
+ applyContainmentOperation,
4
+ CONTAINMENT_LIMITS,
5
+ getSwimlaneContentBounds,
6
+ getSwimlaneContentInsets,
7
+ resolveContainment,
8
+ } from './containment.js';
9
+
10
+ function center(node) {
11
+ return { x: node.x + node.width / 2, y: node.y + node.height / 2 };
12
+ }
13
+
14
+ function clamp(value, min, max) {
15
+ return Math.max(min, Math.min(max, value));
16
+ }
17
+
18
+ function anchor(node, side, offset = 0) {
19
+ if (side === 'left') return { x: node.x, y: node.y + node.height / 2 + clamp(offset, -node.height / 2 + 13, node.height / 2 - 13) };
20
+ if (side === 'right') return { x: node.x + node.width, y: node.y + node.height / 2 + clamp(offset, -node.height / 2 + 13, node.height / 2 - 13) };
21
+ if (side === 'top') return { x: node.x + node.width / 2 + clamp(offset, -node.width / 2 + 13, node.width / 2 - 13), y: node.y };
22
+ return { x: node.x + node.width / 2 + clamp(offset, -node.width / 2 + 13, node.width / 2 - 13), y: node.y + node.height };
23
+ }
24
+
25
+ export function smartAnchors(source, target) {
26
+ const s = center(source);
27
+ const t = center(target);
28
+ const dx = t.x - s.x;
29
+ const dy = t.y - s.y;
30
+ if (Math.abs(dx) >= Math.abs(dy) * 0.72) {
31
+ return dx >= 0
32
+ ? { sourceSide: 'right', targetSide: 'left' }
33
+ : { sourceSide: 'left', targetSide: 'right' };
34
+ }
35
+ return dy >= 0
36
+ ? { sourceSide: 'bottom', targetSide: 'top' }
37
+ : { sourceSide: 'top', targetSide: 'bottom' };
38
+ }
39
+
40
+ function flowNode(node) {
41
+ const kind = NODE_DEFINITIONS[node?.type]?.kind;
42
+ return ['event', 'boundary', 'task', 'container', 'gateway'].includes(kind);
43
+ }
44
+
45
+ function bestAlignment(rawValue, candidates, threshold) {
46
+ let best = null;
47
+ for (const candidate of candidates) {
48
+ const delta = Math.abs(candidate.value - rawValue);
49
+ if (delta > threshold) continue;
50
+ const score = delta + (candidate.priority || 0) * (threshold + 1);
51
+ if (!best || score < best.score) best = { ...candidate, delta, score };
52
+ }
53
+ return best;
54
+ }
55
+
56
+ /**
57
+ * Pixel-smooth drag snapping with magnetic alignment guides.
58
+ * The visible dotted grid is deliberately independent from drag snapping so
59
+ * different BPMN node sizes can still share exact center lines.
60
+ */
61
+ export function snapNodePosition(model, node, x, y, options = {}) {
62
+ if (model.settings?.alignmentGuides === false) return { x, y, guides: [] };
63
+ const threshold = options.threshold ?? model.settings?.alignmentThreshold ?? 7;
64
+ const others = model.nodes.filter((candidate) => candidate.id !== node.id && flowNode(candidate));
65
+ if (!others.length || options.disabled) return { x, y, guides: [] };
66
+
67
+ const centerX = x + node.width / 2;
68
+ const centerY = y + node.height / 2;
69
+ const xCandidates = [];
70
+ const yCandidates = [];
71
+
72
+ for (const other of others) {
73
+ const otherCenterX = other.x + other.width / 2;
74
+ const otherCenterY = other.y + other.height / 2;
75
+
76
+ // Center alignment is the primary workflow-layout signal.
77
+ xCandidates.push({ value: otherCenterX - node.width / 2, guide: otherCenterX, priority: 0, kind: 'center' });
78
+ yCandidates.push({ value: otherCenterY - node.height / 2, guide: otherCenterY, priority: 0, kind: 'center' });
79
+
80
+ // Edge alignment remains useful for swimlanes, stacked tasks and manual layout.
81
+ xCandidates.push({ value: other.x, guide: other.x, priority: 1, kind: 'edge' });
82
+ xCandidates.push({ value: other.x + other.width - node.width, guide: other.x + other.width, priority: 1, kind: 'edge' });
83
+ yCandidates.push({ value: other.y, guide: other.y, priority: 1, kind: 'edge' });
84
+ yCandidates.push({ value: other.y + other.height - node.height, guide: other.y + other.height, priority: 1, kind: 'edge' });
85
+ }
86
+
87
+ const xSnap = bestAlignment(x, xCandidates, threshold);
88
+ const ySnap = bestAlignment(y, yCandidates, threshold);
89
+ const nextX = xSnap ? xSnap.value : x;
90
+ const nextY = ySnap ? ySnap.value : y;
91
+ const guides = [];
92
+ if (xSnap) guides.push({ orientation: 'vertical', position: xSnap.guide, kind: xSnap.kind });
93
+ if (ySnap) guides.push({ orientation: 'horizontal', position: ySnap.guide, kind: ySnap.kind });
94
+
95
+ return { x: nextX, y: nextY, guides };
96
+ }
97
+
98
+ function connectionOffset(model, edge, end, side) {
99
+ const nodeId = end === 'source' ? edge.source : edge.target;
100
+ const node = getNode(model, nodeId);
101
+ const nodeKind = NODE_DEFINITIONS[node?.type]?.kind;
102
+ const peers = model.edges
103
+ .filter((candidate) => (candidate.type || 'sequenceFlow') === (edge.type || 'sequenceFlow'))
104
+ .filter((candidate) => (end === 'source' ? candidate.source === nodeId : candidate.target === nodeId));
105
+ // Gateways share their branch anchor in both directions. Any node with
106
+ // multiple incoming flows also shares one centered ingress anchor so the
107
+ // branches converge before a single visual trunk/arrow enters the node.
108
+ if (model.settings?.mergeGatewayConnections !== false && (nodeKind === 'gateway' || (end === 'target' && peers.length > 1))) return 0;
109
+ if (peers.length <= 1) return 0;
110
+ const horizontalSide = side === 'left' || side === 'right';
111
+ const scored = peers.map((candidate) => {
112
+ const otherId = end === 'source' ? candidate.target : candidate.source;
113
+ const other = getNode(model, otherId);
114
+ const c = other ? center(other) : { x: 0, y: 0 };
115
+ return { id: candidate.id, score: horizontalSide ? c.y : c.x };
116
+ }).sort((a, b) => a.score - b.score || a.id.localeCompare(b.id));
117
+ const index = Math.max(0, scored.findIndex((item) => item.id === edge.id));
118
+ const step = peers.length > 4 ? 10 : 14;
119
+ return (index - (peers.length - 1) / 2) * step;
120
+ }
121
+
122
+ function boxesIntersectSegment(a, b, node, padding = 22) {
123
+ const left = node.x - padding;
124
+ const right = node.x + node.width + padding;
125
+ const top = node.y - padding;
126
+ const bottom = node.y + node.height + padding;
127
+ if (Math.abs(a.x - b.x) < 0.01) {
128
+ if (a.x < left || a.x > right) return false;
129
+ const minY = Math.min(a.y, b.y);
130
+ const maxY = Math.max(a.y, b.y);
131
+ return maxY >= top && minY <= bottom;
132
+ }
133
+ if (Math.abs(a.y - b.y) < 0.01) {
134
+ if (a.y < top || a.y > bottom) return false;
135
+ const minX = Math.min(a.x, b.x);
136
+ const maxX = Math.max(a.x, b.x);
137
+ return maxX >= left && minX <= right;
138
+ }
139
+ return false;
140
+ }
141
+
142
+ function routeHitsNode(model, points, edge) {
143
+ const blockers = model.nodes.filter((node) => node.id !== edge.source && node.id !== edge.target && flowNode(node));
144
+ for (let i = 0; i < points.length - 1; i += 1) {
145
+ for (const blocker of blockers) {
146
+ if (boxesIntersectSegment(points[i], points[i + 1], blocker)) return blocker;
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+
152
+
153
+ function routedAnchors(model, source, target) {
154
+ const direction = model.settings?.direction || 'horizontal';
155
+ const tolerance = 8;
156
+ if (direction === 'horizontal') {
157
+ // As long as the target is on the next horizontal side, keep right -> left
158
+ // anchors even when the user drags it far up/down. This avoids an abrupt
159
+ // connector-style flip from horizontal to vertical during manual layout.
160
+ if (target.x >= source.x + source.width - tolerance) return { sourceSide: 'right', targetSide: 'left' };
161
+ if (source.x >= target.x + target.width - tolerance) return { sourceSide: 'left', targetSide: 'right' };
162
+ const s = center(source);
163
+ const t = center(target);
164
+ return t.y >= s.y
165
+ ? { sourceSide: 'bottom', targetSide: 'top' }
166
+ : { sourceSide: 'top', targetSide: 'bottom' };
167
+ }
168
+ if (direction === 'vertical') {
169
+ if (target.y >= source.y + source.height - tolerance) return { sourceSide: 'bottom', targetSide: 'top' };
170
+ if (source.y >= target.y + target.height - tolerance) return { sourceSide: 'top', targetSide: 'bottom' };
171
+ const s = center(source);
172
+ const t = center(target);
173
+ return t.x >= s.x
174
+ ? { sourceSide: 'right', targetSide: 'left' }
175
+ : { sourceSide: 'left', targetSide: 'right' };
176
+ }
177
+ return smartAnchors(source, target);
178
+ }
179
+
180
+ function adaptiveLead(distance, preferred = 30) {
181
+ // Fixed 38px leads caused a classic router failure: when users moved two nodes
182
+ // closer than 76px, sourceLead and targetLead crossed and the connector formed a
183
+ // large U-shaped loop. Keep leads proportional to the actual free gap instead.
184
+ if (distance <= 0) return preferred;
185
+ const safeHalf = Math.max(2, distance / 2 - 2);
186
+ return Math.min(preferred, safeHalf, Math.max(6, distance * 0.28));
187
+ }
188
+
189
+ function horizontalFacingGap(sourceSide, targetSide, s, t) {
190
+ if (sourceSide === 'right' && targetSide === 'left') return t.x - s.x;
191
+ if (sourceSide === 'left' && targetSide === 'right') return s.x - t.x;
192
+ return -1;
193
+ }
194
+
195
+ function verticalFacingGap(sourceSide, targetSide, s, t) {
196
+ if (sourceSide === 'bottom' && targetSide === 'top') return t.y - s.y;
197
+ if (sourceSide === 'top' && targetSide === 'bottom') return s.y - t.y;
198
+ return -1;
199
+ }
200
+
201
+ function orthogonalRoute(model, edge, source, target) {
202
+ const { sourceSide, targetSide } = routedAnchors(model, source, target);
203
+ const s = anchor(source, sourceSide, connectionOffset(model, edge, 'source', sourceSide));
204
+ const t = anchor(target, targetSide, connectionOffset(model, edge, 'target', targetSide));
205
+ const horizontal = ['left', 'right'].includes(sourceSide);
206
+ let points;
207
+
208
+ if (horizontal && ['left', 'right'].includes(targetSide)) {
209
+ const facingGap = horizontalFacingGap(sourceSide, targetSide, s, t);
210
+ const sourceDirection = sourceSide === 'right' ? 1 : -1;
211
+ const targetDirection = targetSide === 'left' ? -1 : 1;
212
+
213
+ if (facingGap >= 0) {
214
+ // Forward / normally facing nodes. Leads always stay inside the available gap,
215
+ // therefore the route is monotonic on the primary axis and cannot produce a
216
+ // backtracking spike after manual node movement.
217
+ const lead = adaptiveLead(facingGap);
218
+ const sourceLead = { x: s.x + sourceDirection * lead, y: s.y };
219
+ const targetLead = { x: t.x + targetDirection * lead, y: t.y };
220
+
221
+ if (Math.abs(s.y - t.y) < 0.5) {
222
+ points = [s, t];
223
+ } else {
224
+ const mx = (sourceLead.x + targetLead.x) / 2;
225
+ points = [s, sourceLead, { x: mx, y: s.y }, { x: mx, y: t.y }, targetLead, t];
226
+ }
227
+
228
+ const blocker = routeHitsNode(model, points, edge);
229
+ if (blocker) {
230
+ const margin = 34;
231
+ const above = Math.min(source.y, target.y, blocker.y) - margin;
232
+ const below = Math.max(source.y + source.height, target.y + target.height, blocker.y + blocker.height) + margin;
233
+ const detourY = Math.abs(above - s.y) + Math.abs(above - t.y) < Math.abs(below - s.y) + Math.abs(below - t.y) ? above : below;
234
+ points = [s, sourceLead, { x: sourceLead.x, y: detourY }, { x: targetLead.x, y: detourY }, targetLead, t];
235
+ }
236
+ } else {
237
+ // True back-edge / overlapping ranks. Route around the pair, but keep the
238
+ // detour close to the nodes instead of using the old oversized 76px loop.
239
+ const lead = 24;
240
+ const sourceLead = { x: s.x + sourceDirection * lead, y: s.y };
241
+ const targetLead = { x: t.x + targetDirection * lead, y: t.y };
242
+ const margin = 32;
243
+ const above = Math.min(source.y, target.y) - margin;
244
+ const below = Math.max(source.y + source.height, target.y + target.height) + margin;
245
+ const detourY = Math.abs(above - s.y) + Math.abs(above - t.y) < Math.abs(below - s.y) + Math.abs(below - t.y) ? above : below;
246
+ points = [s, sourceLead, { x: sourceLead.x, y: detourY }, { x: targetLead.x, y: detourY }, targetLead, t];
247
+ }
248
+ } else if (!horizontal && ['top', 'bottom'].includes(targetSide)) {
249
+ const facingGap = verticalFacingGap(sourceSide, targetSide, s, t);
250
+ const sourceDirection = sourceSide === 'bottom' ? 1 : -1;
251
+ const targetDirection = targetSide === 'top' ? -1 : 1;
252
+
253
+ if (facingGap >= 0) {
254
+ const lead = adaptiveLead(facingGap);
255
+ const sourceLead = { x: s.x, y: s.y + sourceDirection * lead };
256
+ const targetLead = { x: t.x, y: t.y + targetDirection * lead };
257
+ if (Math.abs(s.x - t.x) < 0.5) {
258
+ points = [s, t];
259
+ } else {
260
+ const my = (sourceLead.y + targetLead.y) / 2;
261
+ points = [s, sourceLead, { x: s.x, y: my }, { x: t.x, y: my }, targetLead, t];
262
+ }
263
+ const blocker = routeHitsNode(model, points, edge);
264
+ if (blocker) {
265
+ const margin = 34;
266
+ const left = Math.min(source.x, target.x, blocker.x) - margin;
267
+ const right = Math.max(source.x + source.width, target.x + target.width, blocker.x + blocker.width) + margin;
268
+ const detourX = Math.abs(left - s.x) + Math.abs(left - t.x) < Math.abs(right - s.x) + Math.abs(right - t.x) ? left : right;
269
+ points = [s, sourceLead, { x: detourX, y: sourceLead.y }, { x: detourX, y: targetLead.y }, targetLead, t];
270
+ }
271
+ } else {
272
+ const lead = 24;
273
+ const sourceLead = { x: s.x, y: s.y + sourceDirection * lead };
274
+ const targetLead = { x: t.x, y: t.y + targetDirection * lead };
275
+ const margin = 32;
276
+ const left = Math.min(source.x, target.x) - margin;
277
+ const right = Math.max(source.x + source.width, target.x + target.width) + margin;
278
+ const detourX = Math.abs(left - s.x) + Math.abs(left - t.x) < Math.abs(right - s.x) + Math.abs(right - t.x) ? left : right;
279
+ points = [s, sourceLead, { x: detourX, y: sourceLead.y }, { x: detourX, y: targetLead.y }, targetLead, t];
280
+ }
281
+ } else {
282
+ // Mixed-side route (usually nodes overlap on the primary axis). Keep a compact
283
+ // two-bend connector and let collinear cleanup remove redundant points.
284
+ if (horizontal) {
285
+ const mx = (s.x + t.x) / 2;
286
+ points = [s, { x: mx, y: s.y }, { x: mx, y: t.y }, t];
287
+ } else {
288
+ const my = (s.y + t.y) / 2;
289
+ points = [s, { x: s.x, y: my }, { x: t.x, y: my }, t];
290
+ }
291
+ }
292
+ return points;
293
+ }
294
+
295
+ function dedupeCollinear(points) {
296
+ if (points.length <= 2) return points;
297
+ const out = [points[0]];
298
+ for (let i = 1; i < points.length - 1; i += 1) {
299
+ const a = out[out.length - 1];
300
+ const b = points[i];
301
+ const c = points[i + 1];
302
+ if (Math.hypot(a.x - b.x, a.y - b.y) < 0.1) continue;
303
+ const collinearX = Math.abs(a.x - b.x) < 0.01 && Math.abs(b.x - c.x) < 0.01;
304
+ const collinearY = Math.abs(a.y - b.y) < 0.01 && Math.abs(b.y - c.y) < 0.01;
305
+ if (!collinearX && !collinearY) out.push(b);
306
+ }
307
+ const last = points.at(-1);
308
+ if (Math.hypot(out.at(-1).x - last.x, out.at(-1).y - last.y) >= 0.1) out.push(last);
309
+ return out;
310
+ }
311
+
312
+ export function edgeWaypoints(model, edge) {
313
+ const source = getNode(model, edge.source);
314
+ const target = getNode(model, edge.target);
315
+ if (!source || !target) return [];
316
+ if (edge.waypoints?.length >= 2) return edge.waypoints;
317
+ return dedupeCollinear(orthogonalRoute(model, edge, source, target));
318
+ }
319
+
320
+ export function roundedPath(points, radius = 14) {
321
+ if (!points.length) return '';
322
+ if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
323
+ if (points.length === 2) return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`;
324
+
325
+ const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
326
+ let d = `M ${points[0].x} ${points[0].y}`;
327
+ for (let i = 1; i < points.length - 1; i += 1) {
328
+ const prev = points[i - 1];
329
+ const cur = points[i];
330
+ const next = points[i + 1];
331
+ const r = Math.min(radius, distance(prev, cur) / 2, distance(cur, next) / 2);
332
+ const before = {
333
+ x: cur.x + (prev.x - cur.x) * (r / Math.max(distance(prev, cur), 1)),
334
+ y: cur.y + (prev.y - cur.y) * (r / Math.max(distance(prev, cur), 1)),
335
+ };
336
+ const after = {
337
+ x: cur.x + (next.x - cur.x) * (r / Math.max(distance(next, cur), 1)),
338
+ y: cur.y + (next.y - cur.y) * (r / Math.max(distance(next, cur), 1)),
339
+ };
340
+ d += ` L ${before.x} ${before.y} Q ${cur.x} ${cur.y} ${after.x} ${after.y}`;
341
+ }
342
+ const last = points.at(-1);
343
+ d += ` L ${last.x} ${last.y}`;
344
+ return d;
345
+ }
346
+
347
+ export function smoothPath(points, radius = 24) {
348
+ return roundedPath(points, Math.max(20, radius));
349
+ }
350
+
351
+ function graphRanks(model, candidates = null) {
352
+ const layoutNodes = candidates || model.nodes.filter(flowNode);
353
+ const byId = new Map(layoutNodes.map((node) => [node.id, node]));
354
+ const indegree = new Map(layoutNodes.map((node) => [node.id, 0]));
355
+ const incoming = new Map(layoutNodes.map((node) => [node.id, []]));
356
+ const outgoing = new Map(layoutNodes.map((node) => [node.id, []]));
357
+ const flows = model.edges.filter((edge) => (edge.type || 'sequenceFlow') === 'sequenceFlow' && byId.has(edge.source) && byId.has(edge.target));
358
+ for (const edge of flows) {
359
+ indegree.set(edge.target, indegree.get(edge.target) + 1);
360
+ incoming.get(edge.target).push(edge.source);
361
+ outgoing.get(edge.source).push(edge.target);
362
+ }
363
+
364
+ const priority = (node) => NODE_DEFINITIONS[node.type]?.eventStage === 'start' ? -100000 : (node.x + node.y / 10000);
365
+ const queue = layoutNodes.filter((node) => indegree.get(node.id) === 0).sort((a, b) => priority(a) - priority(b)).map((node) => node.id);
366
+ if (!queue.length && layoutNodes[0]) queue.push(layoutNodes[0].id);
367
+ const rank = new Map(queue.map((id) => [id, 0]));
368
+ const processed = new Set();
369
+
370
+ while (queue.length) {
371
+ const id = queue.shift();
372
+ if (processed.has(id)) continue;
373
+ processed.add(id);
374
+ const base = rank.get(id) || 0;
375
+ for (const target of outgoing.get(id) || []) {
376
+ rank.set(target, Math.max(rank.get(target) ?? 0, base + 1));
377
+ indegree.set(target, Math.max(0, indegree.get(target) - 1));
378
+ if (indegree.get(target) === 0) queue.push(target);
379
+ }
380
+ }
381
+
382
+ // Keep cycles compact: unresolved nodes receive a rank relative to the nearest processed predecessor.
383
+ let fallback = Math.max(0, ...rank.values());
384
+ for (const node of layoutNodes) {
385
+ if (rank.has(node.id)) continue;
386
+ const predecessorRanks = (incoming.get(node.id) || []).map((id) => rank.get(id)).filter((value) => value !== undefined);
387
+ rank.set(node.id, predecessorRanks.length ? Math.max(...predecessorRanks) + 1 : ++fallback);
388
+ }
389
+
390
+ return { layoutNodes, rank, incoming };
391
+ }
392
+
393
+ function orderRanks(layoutNodes, rank, incoming, direction) {
394
+ const groups = new Map();
395
+ for (const node of layoutNodes) {
396
+ const value = rank.get(node.id) || 0;
397
+ if (!groups.has(value)) groups.set(value, []);
398
+ groups.get(value).push(node);
399
+ }
400
+ const secondary = (node) => direction === 'vertical' ? node.x : node.y;
401
+ const placedIndex = new Map();
402
+ for (const [value, nodes] of [...groups.entries()].sort((a, b) => a[0] - b[0])) {
403
+ nodes.sort((a, b) => {
404
+ const aPred = (incoming.get(a.id) || []).map((id) => placedIndex.get(id)).filter((v) => v !== undefined);
405
+ const bPred = (incoming.get(b.id) || []).map((id) => placedIndex.get(id)).filter((v) => v !== undefined);
406
+ const aBary = aPred.length ? aPred.reduce((s, v) => s + v, 0) / aPred.length : secondary(a) / 100;
407
+ const bBary = bPred.length ? bPred.reduce((s, v) => s + v, 0) / bPred.length : secondary(b) / 100;
408
+ return aBary - bBary || secondary(a) - secondary(b);
409
+ });
410
+ nodes.forEach((node, index) => placedIndex.set(node.id, index));
411
+ groups.set(value, nodes);
412
+ }
413
+ return groups;
414
+ }
415
+
416
+ export const LAYOUT_DENSITIES = Object.freeze({
417
+ // rankGap is the visible gap between adjacent ranks, not center-to-center distance.
418
+ // Keep the default deliberately compact for enterprise approval diagrams; users can
419
+ // expand to spacious when presenting on a large canvas.
420
+ compact: Object.freeze({ rankGap: 68, nodeGap: 30, label: '紧凑' }),
421
+ balanced: Object.freeze({ rankGap: 92, nodeGap: 48, label: '标准' }),
422
+ spacious: Object.freeze({ rankGap: 136, nodeGap: 78, label: '舒展' }),
423
+ });
424
+
425
+ function laneAwareAutoLayout(model, options, densityPreset) {
426
+ const containment = resolveContainment(model);
427
+ const lanes = model.nodes.filter((node) => NODE_DEFINITIONS[node.type]?.kind === 'lane');
428
+ if (!lanes.length) return false;
429
+ const boundaryNodes = model.nodes.filter((node) => NODE_DEFINITIONS[node.type]?.kind === 'boundary');
430
+ const layoutNodes = model.nodes.filter((node) => flowNode(node) && NODE_DEFINITIONS[node.type]?.kind !== 'boundary');
431
+ const byId = new Map(layoutNodes.map((node) => [node.id, node]));
432
+ const { rank, incoming } = graphRanks(model, layoutNodes);
433
+ const orderedRanks = orderRanks(layoutNodes, rank, incoming, 'horizontal');
434
+ const rankGap = options.rankGap ?? densityPreset.rankGap;
435
+ const nodeGap = options.nodeGap ?? densityPreset.nodeGap;
436
+ const padding = CONTAINMENT_LIMITS.participantContentPadding;
437
+ const originalPositions = new Map(model.nodes.map((node) => [node.id, { x: node.x, y: node.y }]));
438
+ const assigned = new Set();
439
+ const membersByLane = new Map();
440
+
441
+ for (const lane of lanes) {
442
+ const laneInsets = getSwimlaneContentInsets(model, lane);
443
+ const members = (lane.properties?.flowNodeRefs || []).map((id) => byId.get(id)).filter(Boolean);
444
+ membersByLane.set(lane.id, members);
445
+ for (const member of members) assigned.add(member.id);
446
+ const groups = new Map();
447
+ for (const member of members) {
448
+ const value = rank.get(member.id) || 0;
449
+ const group = groups.get(value) || [];
450
+ group.push(member);
451
+ groups.set(value, group);
452
+ }
453
+ const requiredHeight = Math.max(
454
+ CONTAINMENT_LIMITS.laneMinHeight + laneInsets.top + laneInsets.bottom,
455
+ ...[...groups.values()].map((group) => (
456
+ group.reduce((sum, node) => sum + node.height, 0)
457
+ + Math.max(0, group.length - 1) * nodeGap
458
+ + padding * 2
459
+ + laneInsets.top
460
+ + laneInsets.bottom
461
+ )),
462
+ );
463
+ lane.height = Math.max(lane.height, requiredHeight);
464
+ }
465
+
466
+ for (const [participantId, participantLanes] of containment.lanesByParticipant) {
467
+ const participant = containment.nodesById.get(participantId);
468
+ if (!participant) continue;
469
+ const participantInsets = getSwimlaneContentInsets(model, participant);
470
+ participant.height = Math.max(
471
+ CONTAINMENT_LIMITS.participantMinHeight,
472
+ participantInsets.top
473
+ + participantLanes.reduce((sum, lane) => sum + lane.height, 0)
474
+ + participantInsets.bottom,
475
+ );
476
+ }
477
+ applyContainmentOperation(model, { type: 'reconcile', normalize: true });
478
+
479
+ const rankWidths = new Map();
480
+ for (const [value, nodes] of orderedRanks) {
481
+ const eligible = nodes.filter((node) => assigned.has(node.id));
482
+ if (eligible.length) rankWidths.set(value, Math.max(...eligible.map((node) => node.width), 56));
483
+ }
484
+ const ranks = [...rankWidths.keys()].sort((a, b) => a - b);
485
+ const rankOffsets = new Map();
486
+ let cursor = 0;
487
+ for (const value of ranks) {
488
+ rankOffsets.set(value, cursor);
489
+ cursor += rankWidths.get(value) + rankGap;
490
+ }
491
+ const contentWidth = Math.max(0, cursor - rankGap) + padding * 2;
492
+
493
+ for (const [participantId, participantLanes] of containment.lanesByParticipant) {
494
+ const participant = containment.nodesById.get(participantId);
495
+ if (!participant) continue;
496
+ const participantInsets = getSwimlaneContentInsets(model, participant);
497
+ const laneInsets = participantLanes[0]
498
+ ? getSwimlaneContentInsets(model, participantLanes[0])
499
+ : { top: 0, right: 0, bottom: 0, left: 0 };
500
+ participant.width = Math.max(
501
+ participant.width,
502
+ contentWidth + participantInsets.left + participantInsets.right + laneInsets.left + laneInsets.right,
503
+ CONTAINMENT_LIMITS.participantMinWidth,
504
+ );
505
+ }
506
+ for (const lane of lanes.filter((candidate) => !candidate.containerId)) {
507
+ const laneInsets = getSwimlaneContentInsets(model, lane);
508
+ lane.width = Math.max(
509
+ lane.width,
510
+ contentWidth + laneInsets.left + laneInsets.right,
511
+ CONTAINMENT_LIMITS.laneMinWidth,
512
+ );
513
+ }
514
+ applyContainmentOperation(model, { type: 'reconcile', normalize: true });
515
+
516
+ for (const lane of lanes) {
517
+ const groups = new Map();
518
+ for (const member of membersByLane.get(lane.id) || []) {
519
+ const value = rank.get(member.id) || 0;
520
+ const group = groups.get(value) || [];
521
+ group.push(member);
522
+ groups.set(value, group);
523
+ }
524
+ for (const [value, group] of groups) {
525
+ const content = getSwimlaneContentBounds(model, lane);
526
+ group.sort((a, b) => {
527
+ const aPred = (incoming.get(a.id) || []).map((id) => originalPositions.get(id)?.y).filter(Number.isFinite);
528
+ const bPred = (incoming.get(b.id) || []).map((id) => originalPositions.get(id)?.y).filter(Number.isFinite);
529
+ const aScore = aPred.length ? aPred.reduce((sum, y) => sum + y, 0) / aPred.length : originalPositions.get(a.id)?.y || 0;
530
+ const bScore = bPred.length ? bPred.reduce((sum, y) => sum + y, 0) / bPred.length : originalPositions.get(b.id)?.y || 0;
531
+ return aScore - bScore || a.id.localeCompare(b.id);
532
+ });
533
+ const totalHeight = group.reduce((sum, node) => sum + node.height, 0) + Math.max(0, group.length - 1) * nodeGap;
534
+ let y = content.y + Math.max(padding, (content.height - totalHeight) / 2);
535
+ const baseX = content.x + padding;
536
+ const maxWidth = rankWidths.get(value) || Math.max(...group.map((node) => node.width));
537
+ for (const member of group) {
538
+ member.x = Math.round(baseX + (rankOffsets.get(value) || 0) + (maxWidth - member.width) / 2);
539
+ member.y = Math.round(y);
540
+ y += member.height + nodeGap;
541
+ }
542
+ }
543
+ }
544
+
545
+ const unassigned = layoutNodes.filter((node) => !assigned.has(node.id));
546
+ if (unassigned.length) {
547
+ const bottom = Math.max(options.startY ?? 112, ...lanes.map((lane) => lane.y + lane.height + nodeGap));
548
+ autoLayout({
549
+ ...model,
550
+ nodes: unassigned,
551
+ edges: model.edges.filter((edge) => unassigned.some((node) => node.id === edge.source) && unassigned.some((node) => node.id === edge.target)),
552
+ }, { ...options, startY: bottom });
553
+ }
554
+
555
+ for (const boundary of boundaryNodes) {
556
+ const host = byId.get(boundary.properties?.attachedToRef);
557
+ const before = originalPositions.get(host?.id);
558
+ if (!host || !before) continue;
559
+ boundary.x = Math.round(boundary.x + host.x - before.x);
560
+ boundary.y = Math.round(boundary.y + host.y - before.y);
561
+ }
562
+ for (const edge of model.edges) edge.waypoints = null;
563
+ return true;
564
+ }
565
+
566
+ export function autoLayout(model, options = {}) {
567
+ const direction = options.direction || model.settings?.direction || 'horizontal';
568
+ const startX = options.startX ?? 112;
569
+ const startY = options.startY ?? 112;
570
+ const density = options.density || model.settings?.layoutDensity || (options.compact ? 'compact' : 'balanced');
571
+ const densityPreset = LAYOUT_DENSITIES[density] || LAYOUT_DENSITIES.balanced;
572
+ if (laneAwareAutoLayout(model, options, densityPreset)) return model;
573
+ const rankGap = options.rankGap ?? densityPreset.rankGap;
574
+ const nodeGap = options.nodeGap ?? densityPreset.nodeGap;
575
+ const { layoutNodes, rank, incoming } = graphRanks(model);
576
+ const groups = orderRanks(layoutNodes, rank, incoming, direction);
577
+ const ranks = [...groups.keys()].sort((a, b) => a - b);
578
+
579
+ if (direction === 'vertical') {
580
+ let y = startY;
581
+ for (const value of ranks) {
582
+ const nodes = groups.get(value);
583
+ const maxHeight = Math.max(...nodes.map((node) => node.height), 56);
584
+ const totalWidth = nodes.reduce((sum, node) => sum + node.width, 0) + Math.max(0, nodes.length - 1) * nodeGap;
585
+ let x = startX + Math.max(0, (920 - totalWidth) / 2);
586
+ for (const node of nodes) {
587
+ node.x = Math.round(x);
588
+ node.y = Math.round(y + (maxHeight - node.height) / 2);
589
+ x += node.width + nodeGap;
590
+ }
591
+ y += maxHeight + rankGap;
592
+ }
593
+ } else {
594
+ let x = startX;
595
+ for (const value of ranks) {
596
+ const nodes = groups.get(value);
597
+ const maxWidth = Math.max(...nodes.map((node) => node.width), 56);
598
+ const totalHeight = nodes.reduce((sum, node) => sum + node.height, 0) + Math.max(0, nodes.length - 1) * nodeGap;
599
+ let y = startY + Math.max(0, (640 - totalHeight) / 2);
600
+ for (const node of nodes) {
601
+ node.x = Math.round(x + (maxWidth - node.width) / 2);
602
+ node.y = Math.round(y);
603
+ y += node.height + nodeGap;
604
+ }
605
+ x += maxWidth + rankGap;
606
+ }
607
+ }
608
+
609
+ for (const edge of model.edges) edge.waypoints = null;
610
+ return model;
611
+ }
612
+
613
+ export function beautify(model, options = {}) {
614
+ autoLayout(model, options);
615
+ const grid = options.gridSize ?? model.settings?.gridSize ?? 16;
616
+ const snapToGrid = options.snapToGrid ?? model.settings?.snapToGrid ?? false;
617
+ // Do not quantize top-left coordinates by default. BPMN elements have
618
+ // different dimensions (events, gateways, tasks); top-left grid snapping
619
+ // destroys their center-line alignment even after a perfect auto layout.
620
+ if (snapToGrid) {
621
+ for (const node of model.nodes) {
622
+ if (!flowNode(node)) continue;
623
+ node.x = Math.round(node.x / grid) * grid;
624
+ node.y = Math.round(node.y / grid) * grid;
625
+ }
626
+ }
627
+ model.settings = {
628
+ ...(model.settings || {}),
629
+ direction: options.direction || model.settings?.direction || 'horizontal',
630
+ layoutDensity: options.density || model.settings?.layoutDensity || (options.compact ? 'compact' : 'balanced'),
631
+ edgeStyle: options.edgeStyle || 'rounded',
632
+ cornerRadius: options.cornerRadius ?? 16,
633
+ gridSize: grid,
634
+ snapToGrid,
635
+ alignmentThreshold: options.alignmentThreshold ?? model.settings?.alignmentThreshold ?? 7,
636
+ mergeGatewayConnections: options.mergeGatewayConnections ?? model.settings?.mergeGatewayConnections ?? true,
637
+ };
638
+ for (const edge of model.edges) {
639
+ edge.waypoints = null;
640
+ edge.routeStyle = model.settings.edgeStyle;
641
+ edge.cornerRadius = model.settings.cornerRadius;
642
+ }
643
+ return model;
644
+ }