@statelyai/layout 0.3.0 → 0.4.1

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 (110) hide show
  1. package/NOTICE.md +4 -2
  2. package/README.md +150 -11
  3. package/dist/elkjs/index.cjs +2 -2
  4. package/dist/elkjs/index.d.cts +2 -0
  5. package/dist/elkjs/index.d.mts +2 -0
  6. package/dist/elkjs/index.mjs +2 -2
  7. package/dist/elkjs/main.cjs +1 -1
  8. package/dist/elkjs/main.mjs +1 -1
  9. package/dist/elkjs/worker.cjs +2 -2
  10. package/dist/elkjs/worker.mjs +2 -2
  11. package/dist/elkjs-BBfKN4Dw.mjs +3075 -0
  12. package/dist/elkjs-DNfXDPup.cjs +3104 -0
  13. package/dist/{index-BEA_pzeT.d.cts → index-B6cWNrMw.d.cts} +9 -1
  14. package/dist/{index-DNTLRsDl.d.mts → index-CJQLUKD9.d.mts} +9 -1
  15. package/dist/{index-DNixtE3h.d.mts → index-DT6lPMfS.d.cts} +85 -5
  16. package/dist/{index-D9dhxrRl.d.cts → index-F1U-fCTu.d.mts} +85 -5
  17. package/dist/index.cjs +6 -3
  18. package/dist/index.d.cts +3 -3
  19. package/dist/index.d.mts +3 -3
  20. package/dist/index.mjs +4 -4
  21. package/dist/layered/index.cjs +3 -1
  22. package/dist/layered/index.d.cts +2 -2
  23. package/dist/layered/index.d.mts +2 -2
  24. package/dist/layered/index.mjs +2 -2
  25. package/dist/{layered-B2buJqMf.cjs → layered-5RxQhryb.cjs} +11184 -2932
  26. package/dist/{layered-DGFTeQSl.mjs → layered-DSesbctm.mjs} +12059 -3903
  27. package/dist/{layout-engine-Dn1PiFwP.cjs → layout-engine-CLFySz3R.cjs} +5 -18
  28. package/dist/{layout-engine-Cs5tatA6.mjs → layout-engine-GpBIX6ng.mjs} +3 -10
  29. package/dist/routing/index.cjs +2 -1
  30. package/dist/routing/index.d.cts +2 -2
  31. package/dist/routing/index.d.mts +2 -2
  32. package/dist/routing/index.mjs +2 -2
  33. package/dist/{routing-A2hrK9MP.mjs → routing-CR8flv8b.mjs} +977 -593
  34. package/dist/{routing-ujEP7Qh6.cjs → routing-QIdGGFBe.cjs} +986 -596
  35. package/package.json +8 -2
  36. package/src/authoring/coordinates.ts +28 -8
  37. package/src/authoring/partial.ts +7 -2
  38. package/src/authoring/routing.ts +4 -1
  39. package/src/constraints.ts +174 -6
  40. package/src/elkjs/index.ts +1476 -449
  41. package/src/elkjs/layout-quality.ts +267 -0
  42. package/src/elkjs/opposing-tracks.ts +161 -0
  43. package/src/elkjs/orthogonal-junctions.ts +174 -0
  44. package/src/elkjs/statechart.ts +9 -3
  45. package/src/elkjs/types.ts +2 -0
  46. package/src/index.ts +2 -0
  47. package/src/internal/elkjs-compatibility.ts +4 -1
  48. package/src/internal/layered-trace.ts +48 -0
  49. package/src/internal/layout-engine.ts +2 -1
  50. package/src/java-random.ts +6 -0
  51. package/src/layered/bk-node-placement.ts +403 -92
  52. package/src/layered/center-labels.ts +398 -0
  53. package/src/layered/compaction-bounds.ts +26 -0
  54. package/src/layered/compaction-errors.ts +2 -0
  55. package/src/layered/compaction-scanline.ts +63 -0
  56. package/src/layered/compound-boundaries.ts +99 -0
  57. package/src/layered/compound.ts +35 -7
  58. package/src/layered/constraint-orientation.ts +141 -0
  59. package/src/layered/crossing-constraints.ts +243 -0
  60. package/src/layered/crossing-counter.ts +233 -0
  61. package/src/layered/crossing-graph.ts +401 -0
  62. package/src/layered/cycle-random.ts +22 -0
  63. package/src/layered/elk-port-lists.ts +243 -0
  64. package/src/layered/end-labels.ts +124 -0
  65. package/src/layered/external-port-dummy.ts +137 -0
  66. package/src/layered/feedback-chains.ts +114 -0
  67. package/src/layered/fixed-self-loop.ts +150 -0
  68. package/src/layered/greedy-switch-decider.ts +356 -0
  69. package/src/layered/grouped-compaction.ts +787 -0
  70. package/src/layered/gwt-sort.ts +53 -0
  71. package/src/layered/hierarchical-port-phases.ts +242 -0
  72. package/src/layered/hierarchical-port-restoration.ts +233 -0
  73. package/src/layered/hierarchical-port-routing.ts +179 -0
  74. package/src/layered/hierarchy-crossing.ts +121 -0
  75. package/src/layered/hierarchy-sweepiness.ts +79 -0
  76. package/src/layered/hints.ts +371 -0
  77. package/src/layered/hyperedge-dummy-merger.ts +195 -0
  78. package/src/layered/index.ts +875 -161
  79. package/src/layered/initial-model-order.ts +404 -0
  80. package/src/layered/inverted-ports.ts +207 -0
  81. package/src/layered/label-dummy-switcher.ts +200 -0
  82. package/src/layered/long-edges.ts +135 -44
  83. package/src/layered/loop-envelopes.ts +184 -0
  84. package/src/layered/native-routing.ts +275 -0
  85. package/src/layered/network-simplex.ts +11 -15
  86. package/src/layered/node-margins.ts +59 -0
  87. package/src/layered/north-south-ports.ts +271 -0
  88. package/src/layered/opposing-tracks.ts +1044 -0
  89. package/src/layered/orthogonal-cycle-order.ts +106 -0
  90. package/src/layered/orthogonal-hypersegments.ts +154 -0
  91. package/src/layered/orthogonal-junctions.ts +202 -0
  92. package/src/layered/orthogonal-segments.ts +243 -0
  93. package/src/layered/placement-orientation.ts +22 -0
  94. package/src/layered/port-distributor.ts +192 -0
  95. package/src/layered/port-edge-order.ts +20 -0
  96. package/src/layered/replace-routing.ts +45 -0
  97. package/src/layered/routing-coordinates.ts +32 -0
  98. package/src/layered/separate-exterior-labels.ts +24 -6
  99. package/src/layered/spacing.ts +34 -12
  100. package/src/layered/splitter-order.ts +106 -0
  101. package/src/layered/strategies.ts +2124 -886
  102. package/src/layered/types.ts +13 -0
  103. package/src/layered/weighted-compaction.ts +84 -0
  104. package/src/routing/adapters.ts +32 -0
  105. package/src/routing/algorithms.ts +433 -21
  106. package/src/routing/coordination.ts +8 -3
  107. package/src/routing/index.ts +1 -0
  108. package/src/routing/search.ts +93 -11
  109. package/dist/elkjs-DaHMISnQ.mjs +0 -1677
  110. package/dist/elkjs-dMghiiWW.cjs +0 -1706
@@ -13,6 +13,489 @@ var UnsupportedLayoutError = class extends LayoutError {
13
13
  }
14
14
  };
15
15
 
16
+ //#endregion
17
+ //#region src/authoring/coordinates.ts
18
+ /** Legacy sibling edges are parent-relative; native compound output marks world-space edges. */
19
+ function frames(graph) {
20
+ const nodes = new Map(graph.nodes.map((n) => [n.id, n]));
21
+ const offsets = /* @__PURE__ */ new Map();
22
+ const visiting = /* @__PURE__ */ new Set();
23
+ function offset(id) {
24
+ const cached = offsets.get(id);
25
+ if (cached) return cached;
26
+ if (visiting.has(id)) throw new LayoutError(`Cyclic parent hierarchy at ${id}`, "INVALID_GRAPH");
27
+ visiting.add(id);
28
+ const parentId = nodes.get(id)?.parentId;
29
+ if (parentId == null) {
30
+ const origin = {
31
+ x: 0,
32
+ y: 0
33
+ };
34
+ visiting.delete(id);
35
+ offsets.set(id, origin);
36
+ return origin;
37
+ }
38
+ const parent = nodes.get(parentId);
39
+ const ancestor = offset(parentId);
40
+ const result = {
41
+ x: ancestor.x + parent.x,
42
+ y: ancestor.y + parent.y
43
+ };
44
+ visiting.delete(id);
45
+ offsets.set(id, result);
46
+ return result;
47
+ }
48
+ for (const node$1 of graph.nodes) offsets.set(node$1.id, offset(node$1.id));
49
+ return {
50
+ nodes: offsets,
51
+ edges: new Map(graph.edges.map((edge) => {
52
+ const source = nodes.get(edge.sourceId);
53
+ const target = nodes.get(edge.targetId);
54
+ return [edge.id, graph.edgeCoordinateSpace !== "world" && (source.parentId ?? null) === (target.parentId ?? null) ? offset(source.id) : {
55
+ x: 0,
56
+ y: 0
57
+ }];
58
+ }))
59
+ };
60
+ }
61
+ function worldGeometry(graph) {
62
+ const offsets = frames(graph);
63
+ const base = { ...graph };
64
+ delete base.compoundRoutes;
65
+ delete base.compoundGeometry;
66
+ return {
67
+ ...base,
68
+ nodes: graph.nodes.map((n) => {
69
+ const p = offsets.nodes.get(n.id);
70
+ return p.x === 0 && p.y === 0 ? n : {
71
+ ...n,
72
+ x: n.x + p.x,
73
+ y: n.y + p.y
74
+ };
75
+ }),
76
+ edges: graph.edges.map((e) => {
77
+ const p = offsets.edges.get(e.id);
78
+ return p.x === 0 && p.y === 0 ? e : {
79
+ ...e,
80
+ x: e.x + p.x,
81
+ y: e.y + p.y,
82
+ ...e.points ? { points: e.points.map((v) => ({
83
+ x: v.x + p.x,
84
+ y: v.y + p.y
85
+ })) } : {}
86
+ };
87
+ })
88
+ };
89
+ }
90
+ /**
91
+ * Convert solved world geometry back to parent-relative coordinates, using
92
+ * each parent's solved position. Untouched coordinates stay exactly as authored.
93
+ */
94
+ function nativeGeometry(graph, original) {
95
+ const offsets = frames(original);
96
+ const world = worldGeometry(original);
97
+ const originalNodes = new Map(original.nodes.map((n) => [n.id, n]));
98
+ const originalEdges = new Map(original.edges.map((e) => [e.id, e]));
99
+ const worldNodes = new Map(world.nodes.map((n) => [n.id, n]));
100
+ const worldEdges = new Map(world.edges.map((e) => [e.id, e]));
101
+ const solved = new Map(graph.nodes.map((n) => [n.id, n]));
102
+ const frame = (id) => {
103
+ const parentId = originalNodes.get(id)?.parentId;
104
+ const parent = parentId == null ? void 0 : solved.get(parentId);
105
+ return parent ? {
106
+ x: parent.x,
107
+ y: parent.y
108
+ } : {
109
+ x: 0,
110
+ y: 0
111
+ };
112
+ };
113
+ const same = (a, b) => a.x === b.x && a.y === b.y;
114
+ return {
115
+ ...graph,
116
+ nodes: graph.nodes.map((n) => {
117
+ const p = frame(n.id), before = originalNodes.get(n.id), prior = worldNodes.get(n.id);
118
+ const kept = same(p, offsets.nodes.get(n.id));
119
+ return {
120
+ ...n,
121
+ x: kept && n.x === prior.x ? before.x : n.x - p.x,
122
+ y: kept && n.y === prior.y ? before.y : n.y - p.y
123
+ };
124
+ }),
125
+ edges: graph.edges.map((e) => {
126
+ const old = offsets.edges.get(e.id), before = originalEdges.get(e.id), prior = worldEdges.get(e.id);
127
+ const source = originalNodes.get(e.sourceId), target = originalNodes.get(e.targetId);
128
+ const p = original.edgeCoordinateSpace !== "world" && (source.parentId ?? null) === (target.parentId ?? null) ? frame(source.id) : {
129
+ x: 0,
130
+ y: 0
131
+ };
132
+ const kept = same(p, old);
133
+ const points = kept && JSON.stringify(e.points) === JSON.stringify(prior.points) ? before.points : e.points?.map((v) => ({
134
+ x: v.x - p.x,
135
+ y: v.y - p.y
136
+ }));
137
+ return {
138
+ ...e,
139
+ x: kept && e.x === prior.x ? before.x : e.x - p.x,
140
+ y: kept && e.y === prior.y ? before.y : e.y - p.y,
141
+ ...points === void 0 ? {} : { points }
142
+ };
143
+ })
144
+ };
145
+ }
146
+
147
+ //#endregion
148
+ //#region src/routing/layout-cache.ts
149
+ const geometryByRoutes = /* @__PURE__ */ new WeakMap();
150
+ /** Compare only geometry and topology; application data may contain cycles. */
151
+ function geometryKey(graph) {
152
+ return JSON.stringify({
153
+ nodes: graph.nodes.map(({ id, parentId, x, y, width, height: height$1, ports }) => ({
154
+ id,
155
+ parentId,
156
+ x,
157
+ y,
158
+ width,
159
+ height: height$1,
160
+ ports: ports?.map(({ name, x: x$1, y: y$1, width: width$1, height: height$2 }) => ({
161
+ name,
162
+ x: x$1,
163
+ y: y$1,
164
+ width: width$1,
165
+ height: height$2
166
+ }))
167
+ })),
168
+ edges: graph.edges.map(({ id, sourceId, targetId, sourcePort, targetPort, x, y, width, height: height$1, points, routing }) => ({
169
+ id,
170
+ sourceId,
171
+ targetId,
172
+ sourcePort,
173
+ targetPort,
174
+ x,
175
+ y,
176
+ width,
177
+ height: height$1,
178
+ points,
179
+ routing
180
+ }))
181
+ });
182
+ }
183
+ /** Associate structured sections with the geometry that produced them. */
184
+ function recordRouteGeometry(graph, routes) {
185
+ geometryByRoutes.set(routes, geometryKey(graph));
186
+ }
187
+ /** Graph edits invalidate cached sections, including edits made by external consumers. */
188
+ function hasCurrentRouteGeometry(graph, routes) {
189
+ return geometryByRoutes.get(routes) === geometryKey(graph);
190
+ }
191
+
192
+ //#endregion
193
+ //#region src/routing/persistent.ts
194
+ const height = (n) => n?.height ?? 0;
195
+ const size = (n) => n?.size ?? 0;
196
+ function node(key, value, left, right) {
197
+ return {
198
+ key,
199
+ value,
200
+ left,
201
+ right,
202
+ height: 1 + Math.max(height(left), height(right)),
203
+ size: 1 + size(left) + size(right)
204
+ };
205
+ }
206
+ function balance(n) {
207
+ if (height(n.left) - height(n.right) > 1) {
208
+ let l = n.left;
209
+ if (height(l.right) > height(l.left)) {
210
+ const r = l.right;
211
+ l = node(r.key, r.value, node(l.key, l.value, l.left, r.left), r.right);
212
+ }
213
+ return node(l.key, l.value, l.left, node(n.key, n.value, l.right, n.right));
214
+ }
215
+ if (height(n.right) - height(n.left) > 1) {
216
+ let r = n.right;
217
+ if (height(r.left) > height(r.right)) {
218
+ const l = r.left;
219
+ r = node(l.key, l.value, l.left, node(r.key, r.value, l.right, r.right));
220
+ }
221
+ return node(r.key, r.value, node(n.key, n.value, n.left, r.left), r.right);
222
+ }
223
+ return n;
224
+ }
225
+ function put(n, key, value) {
226
+ if (!n) return node(key, value);
227
+ if (key === n.key) return Object.is(value, n.value) ? n : node(key, value, n.left, n.right);
228
+ return balance(key < n.key ? node(n.key, n.value, put(n.left, key, value), n.right) : node(n.key, n.value, n.left, put(n.right, key, value)));
229
+ }
230
+ function remove(n, key) {
231
+ if (!n) return n;
232
+ if (key < n.key) return balance(node(n.key, n.value, remove(n.left, key), n.right));
233
+ if (key > n.key) return balance(node(n.key, n.value, n.left, remove(n.right, key)));
234
+ if (!n.left) return n.right;
235
+ if (!n.right) return n.left;
236
+ let successor = n.right;
237
+ while (successor.left) successor = successor.left;
238
+ return balance(node(successor.key, successor.value, n.left, remove(n.right, successor.key)));
239
+ }
240
+ function* entries(n) {
241
+ if (!n) return;
242
+ yield* entries(n.left);
243
+ yield [n.key, n.value];
244
+ yield* entries(n.right);
245
+ }
246
+ var PersistentMap = class PersistentMap {
247
+ #root;
248
+ constructor(root) {
249
+ this.#root = root;
250
+ Object.freeze(this);
251
+ }
252
+ get size() {
253
+ return size(this.#root);
254
+ }
255
+ get [Symbol.toStringTag]() {
256
+ return "PersistentMap";
257
+ }
258
+ get(key) {
259
+ let n = this.#root;
260
+ while (n) {
261
+ if (key === n.key) return n.value;
262
+ n = key < n.key ? n.left : n.right;
263
+ }
264
+ }
265
+ has(key) {
266
+ let n = this.#root;
267
+ while (n) {
268
+ if (key === n.key) return true;
269
+ n = key < n.key ? n.left : n.right;
270
+ }
271
+ return false;
272
+ }
273
+ set(key, value) {
274
+ return new PersistentMap(put(this.#root, key, value));
275
+ }
276
+ delete(key) {
277
+ return this.has(key) ? new PersistentMap(remove(this.#root, key)) : this;
278
+ }
279
+ *entries() {
280
+ yield* entries(this.#root);
281
+ }
282
+ *keys() {
283
+ for (const [key] of this) yield key;
284
+ }
285
+ *values() {
286
+ for (const [, value] of this) yield value;
287
+ }
288
+ [Symbol.iterator]() {
289
+ return this.entries();
290
+ }
291
+ forEach(callback, thisArg) {
292
+ for (const [key, value] of this) callback.call(thisArg, value, key, this);
293
+ }
294
+ };
295
+
296
+ //#endregion
297
+ //#region src/routing/spatial.ts
298
+ const intersects = (a, b) => a.x <= b.x + b.width && a.x + a.width >= b.x && a.y <= b.y + b.height && a.y + a.height >= b.y;
299
+ const inflate = (r, amount) => ({
300
+ x: r.x - amount,
301
+ y: r.y - amount,
302
+ width: r.width + amount * 2,
303
+ height: r.height + amount * 2
304
+ });
305
+ function union(a, b) {
306
+ const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
307
+ return {
308
+ x,
309
+ y,
310
+ width: Math.max(a.x + a.width, b.x + b.width) - x,
311
+ height: Math.max(a.y + a.height, b.y + b.height) - y
312
+ };
313
+ }
314
+ function cells(r) {
315
+ const x1 = Math.floor(r.x / 128), x2 = Math.floor((r.x + r.width) / 128), y1 = Math.floor(r.y / 128), y2 = Math.floor((r.y + r.height) / 128);
316
+ if ((x2 - x1 + 1) * (y2 - y1 + 1) > 4096) return void 0;
317
+ const result = [];
318
+ for (let x = 0; x <= x2 - x1; x++) for (let y = 0; y <= y2 - y1; y++) result.push(`${x1 + x}:${y1 + y}`);
319
+ return result;
320
+ }
321
+ /** Persistent uniform-grid broad phase; oversized rectangles live in an overflow index. */
322
+ var SpatialIndex = class SpatialIndex {
323
+ constructor(bounds = new PersistentMap(), buckets = new PersistentMap(), overflow = new PersistentMap()) {
324
+ this.bounds = bounds;
325
+ this.buckets = buckets;
326
+ this.overflow = overflow;
327
+ Object.freeze(this);
328
+ }
329
+ set(id, rect$1) {
330
+ let { bounds, buckets, overflow } = this;
331
+ const previous = bounds.get(id);
332
+ if (previous) {
333
+ const keys = cells(previous);
334
+ if (!keys) overflow = overflow.delete(id);
335
+ else for (const key of keys) {
336
+ const bucket = buckets.get(key).delete(id);
337
+ buckets = bucket.size ? buckets.set(key, bucket) : buckets.delete(key);
338
+ }
339
+ bounds = bounds.delete(id);
340
+ }
341
+ if (rect$1) {
342
+ const keys = cells(rect$1);
343
+ if (!keys) overflow = overflow.set(id, true);
344
+ else for (const key of keys) buckets = buckets.set(key, (buckets.get(key) ?? new PersistentMap()).set(id, true));
345
+ bounds = bounds.set(id, rect$1);
346
+ }
347
+ return new SpatialIndex(bounds, buckets, overflow);
348
+ }
349
+ query(rect$1) {
350
+ const keys = cells(rect$1), found = /* @__PURE__ */ new Set();
351
+ if (!keys) {
352
+ for (const [id, bounds] of this.bounds) if (intersects(rect$1, bounds)) found.add(id);
353
+ } else {
354
+ for (const key of keys) for (const id of this.buckets.get(key)?.keys() ?? []) if (intersects(rect$1, this.bounds.get(id))) found.add(id);
355
+ for (const id of this.overflow.keys()) if (intersects(rect$1, this.bounds.get(id))) found.add(id);
356
+ }
357
+ return [...found].sort();
358
+ }
359
+ };
360
+
361
+ //#endregion
362
+ //#region src/routing/model.ts
363
+ function settings(input = {}) {
364
+ const result = {
365
+ coordinateSpace: input.coordinateSpace ?? "parent",
366
+ clearance: input.clearance ?? 8,
367
+ radius: input.radius ?? 10,
368
+ edgeSpacing: input.edgeSpacing ?? 12,
369
+ bendPenalty: input.bendPenalty ?? 10,
370
+ crossingPenalty: input.crossingPenalty ?? 80,
371
+ overlapPenalty: input.overlapPenalty ?? 8,
372
+ maxSearchNodes: input.maxSearchNodes ?? 4e3,
373
+ organicIterations: input.organicIterations ?? 12,
374
+ edges: structuredClone(input.edges ?? {})
375
+ };
376
+ for (const [key, value] of Object.entries(result)) if (typeof value === "number" && (!Number.isFinite(value) || value < 0)) throw new RangeError(`${key} must be finite and non-negative`);
377
+ if (!Number.isInteger(result.maxSearchNodes) || result.maxSearchNodes < 1 || !Number.isInteger(result.organicIterations)) throw new RangeError("Search and iteration budgets must be integers");
378
+ return freeze(result);
379
+ }
380
+ function freeze(value) {
381
+ if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
382
+ for (const child of Object.values(value)) freeze(child);
383
+ Object.freeze(value);
384
+ }
385
+ return value;
386
+ }
387
+ function nodeGeometry(n) {
388
+ return freeze({
389
+ id: n.id,
390
+ parentId: n.parentId,
391
+ x: n.x,
392
+ y: n.y,
393
+ width: n.width,
394
+ height: n.height,
395
+ ports: n.ports?.map((p) => ({
396
+ name: p.name,
397
+ x: p.x,
398
+ y: p.y,
399
+ width: p.width,
400
+ height: p.height
401
+ }))
402
+ });
403
+ }
404
+ function edgeGeometry(e) {
405
+ return freeze({
406
+ id: e.id,
407
+ sourceId: e.sourceId ?? "",
408
+ targetId: e.targetId ?? "",
409
+ sourcePort: e.sourcePort,
410
+ targetPort: e.targetPort,
411
+ x: e.x,
412
+ y: e.y,
413
+ width: e.width,
414
+ height: e.height
415
+ });
416
+ }
417
+ function rect(value) {
418
+ const { x, y, width, height: height$1 } = value;
419
+ return [
420
+ x,
421
+ y,
422
+ width,
423
+ height$1
424
+ ].every((n) => typeof n === "number" && Number.isFinite(n)) && width >= 0 && height$1 >= 0 ? {
425
+ x,
426
+ y,
427
+ width,
428
+ height: height$1
429
+ } : void 0;
430
+ }
431
+ function members(index, key, id, add) {
432
+ const current = index.get(key) ?? new PersistentMap(), next = add ? current.set(id, true) : current.delete(id);
433
+ return next.size ? index.set(key, next) : index.delete(key);
434
+ }
435
+ function ancestors(id, state) {
436
+ const result = [], seen = new Set([id]);
437
+ let parent = state.nodes.get(id)?.parentId;
438
+ while (parent != null) {
439
+ if (seen.has(parent)) throw new Error(`Cyclic routing hierarchy at ${parent}`);
440
+ result.push(parent);
441
+ seen.add(parent);
442
+ parent = state.nodes.get(parent)?.parentId;
443
+ }
444
+ return result;
445
+ }
446
+ function worldRect(node$1, state) {
447
+ const bounds = rect(node$1);
448
+ if (!bounds) return;
449
+ if (state.settings.coordinateSpace === "world") return bounds;
450
+ let x = bounds.x, y = bounds.y;
451
+ for (const id of ancestors(node$1.id, state)) {
452
+ const parent = state.nodes.get(id), p = parent && rect(parent);
453
+ if (!p) return;
454
+ x += p.x;
455
+ y += p.y;
456
+ }
457
+ return {
458
+ ...bounds,
459
+ x,
460
+ y
461
+ };
462
+ }
463
+ function labelRect(edge, state) {
464
+ const bounds = rect(edge);
465
+ if (!bounds || bounds.width <= 0 || bounds.height <= 0) return;
466
+ const source = state.nodes.get(edge.sourceId), target = state.nodes.get(edge.targetId);
467
+ if (state.settings.coordinateSpace === "world" || !source || source.parentId == null || source.parentId !== target?.parentId) return bounds;
468
+ const parent = state.nodes.get(source.parentId), p = parent && worldRect(parent, state);
469
+ return p ? {
470
+ ...bounds,
471
+ x: bounds.x + p.x,
472
+ y: bounds.y + p.y
473
+ } : void 0;
474
+ }
475
+ const center = (r) => ({
476
+ x: r.x + r.width / 2,
477
+ y: r.y + r.height / 2
478
+ });
479
+ function sideToward(a, b) {
480
+ return Math.abs(a.x - b.x) >= Math.abs(a.y - b.y) ? b.x >= a.x ? "right" : "left" : b.y >= a.y ? "bottom" : "top";
481
+ }
482
+ const vector = (side) => ({
483
+ x: side === "right" ? 1 : side === "left" ? -1 : 0,
484
+ y: side === "bottom" ? 1 : side === "top" ? -1 : 0
485
+ });
486
+ function anchor(bounds, side) {
487
+ const c = center(bounds), v = vector(side);
488
+ return {
489
+ x: c.x + v.x * bounds.width / 2,
490
+ y: c.y + v.y * bounds.height / 2
491
+ };
492
+ }
493
+ function groupKey(edge, style, config) {
494
+ const group = config.edges[edge.id]?.group;
495
+ if (style === "bus" || style === "bundle" || style === "fan") return group !== void 0 ? `group:${group}` : `source:${JSON.stringify([edge.sourceId, edge.sourcePort ?? null])}`;
496
+ return `pair:${JSON.stringify([edge.sourceId, edge.targetId].sort())}`;
497
+ }
498
+
16
499
  //#endregion
17
500
  //#region src/authoring/routing.ts
18
501
  function overlaps(a, b, gap = 0) {
@@ -25,7 +508,7 @@ function crossesRect(a, b, rect$1) {
25
508
  const delta = b[axis] - a[axis];
26
509
  const min = rect$1[axis], max = min + (axis === "x" ? rect$1.width : rect$1.height);
27
510
  if (Math.abs(delta) < 1e-10) {
28
- if (a[axis] <= min || a[axis] >= max) return false;
511
+ if (a[axis] <= min + 1e-10 || a[axis] >= max - 1e-10) return false;
29
512
  } else {
30
513
  const t1 = (min - a[axis]) / delta, t2 = (max - a[axis]) / delta;
31
514
  low = Math.max(low, Math.min(t1, t2));
@@ -483,173 +966,138 @@ function segmentCrossesRect(start, segment, rect$1) {
483
966
  }
484
967
 
485
968
  //#endregion
486
- //#region src/routing/persistent.ts
487
- const height = (n) => n?.height ?? 0;
488
- const size = (n) => n?.size ?? 0;
489
- function node(key, value, left, right) {
490
- return {
491
- key,
492
- value,
493
- left,
494
- right,
495
- height: 1 + Math.max(height(left), height(right)),
496
- size: 1 + size(left) + size(right)
497
- };
498
- }
499
- function balance(n) {
500
- if (height(n.left) - height(n.right) > 1) {
501
- let l = n.left;
502
- if (height(l.right) > height(l.left)) {
503
- const r = l.right;
504
- l = node(r.key, r.value, node(l.key, l.value, l.left, r.left), r.right);
505
- }
506
- return node(l.key, l.value, l.left, node(n.key, n.value, l.right, n.right));
507
- }
508
- if (height(n.right) - height(n.left) > 1) {
509
- let r = n.right;
510
- if (height(r.left) > height(r.right)) {
511
- const l = r.left;
512
- r = node(l.key, l.value, l.left, node(r.key, r.value, l.right, r.right));
513
- }
514
- return node(r.key, r.value, node(n.key, n.value, n.left, r.left), r.right);
515
- }
516
- return n;
517
- }
518
- function put(n, key, value) {
519
- if (!n) return node(key, value);
520
- if (key === n.key) return Object.is(value, n.value) ? n : node(key, value, n.left, n.right);
521
- return balance(key < n.key ? node(n.key, n.value, put(n.left, key, value), n.right) : node(n.key, n.value, n.left, put(n.right, key, value)));
522
- }
523
- function remove(n, key) {
524
- if (!n) return n;
525
- if (key < n.key) return balance(node(n.key, n.value, remove(n.left, key), n.right));
526
- if (key > n.key) return balance(node(n.key, n.value, n.left, remove(n.right, key)));
527
- if (!n.left) return n.right;
528
- if (!n.right) return n.left;
529
- let successor = n.right;
530
- while (successor.left) successor = successor.left;
531
- return balance(node(successor.key, successor.value, n.left, remove(n.right, successor.key)));
532
- }
533
- function* entries(n) {
534
- if (!n) return;
535
- yield* entries(n.left);
536
- yield [n.key, n.value];
537
- yield* entries(n.right);
538
- }
539
- var PersistentMap = class PersistentMap {
540
- #root;
541
- constructor(root) {
542
- this.#root = root;
543
- Object.freeze(this);
544
- }
545
- get size() {
546
- return size(this.#root);
547
- }
548
- get [Symbol.toStringTag]() {
549
- return "PersistentMap";
550
- }
551
- get(key) {
552
- let n = this.#root;
553
- while (n) {
554
- if (key === n.key) return n.value;
555
- n = key < n.key ? n.left : n.right;
556
- }
557
- }
558
- has(key) {
559
- let n = this.#root;
560
- while (n) {
561
- if (key === n.key) return true;
562
- n = key < n.key ? n.left : n.right;
563
- }
564
- return false;
565
- }
566
- set(key, value) {
567
- return new PersistentMap(put(this.#root, key, value));
568
- }
569
- delete(key) {
570
- return this.has(key) ? new PersistentMap(remove(this.#root, key)) : this;
571
- }
572
- *entries() {
573
- yield* entries(this.#root);
574
- }
575
- *keys() {
576
- for (const [key] of this) yield key;
577
- }
578
- *values() {
579
- for (const [, value] of this) yield value;
580
- }
581
- [Symbol.iterator]() {
582
- return this.entries();
583
- }
584
- forEach(callback, thisArg) {
585
- for (const [key, value] of this) callback.call(thisArg, value, key, this);
586
- }
587
- };
588
-
589
- //#endregion
590
- //#region src/routing/spatial.ts
591
- const intersects = (a, b) => a.x <= b.x + b.width && a.x + a.width >= b.x && a.y <= b.y + b.height && a.y + a.height >= b.y;
592
- const inflate = (r, amount) => ({
593
- x: r.x - amount,
594
- y: r.y - amount,
595
- width: r.width + amount * 2,
596
- height: r.height + amount * 2
597
- });
598
- function union(a, b) {
599
- const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
969
+ //#region src/routing/adapters.ts
970
+ /** ELK/native spline output: start, then repeating control1/control2/end triples. */
971
+ function pathFromSplinePoints(points) {
972
+ if (points.length < 4 || (points.length - 1) % 3 !== 0) throw new RangeError("Cubic spline points require a start and control1/control2/end triples");
973
+ const segments = [];
974
+ for (let i = 1; i < points.length; i += 3) segments.push({
975
+ kind: "cubic",
976
+ control1: { ...points[i] },
977
+ control2: { ...points[i + 1] },
978
+ to: { ...points[i + 2] }
979
+ });
600
980
  return {
601
- x,
602
- y,
603
- width: Math.max(a.x + a.width, b.x + b.width) - x,
604
- height: Math.max(a.y + a.height, b.y + b.height) - y
981
+ start: { ...points[0] },
982
+ segments
605
983
  };
606
984
  }
607
- function cells(r) {
608
- const x1 = Math.floor(r.x / 128), x2 = Math.floor((r.x + r.width) / 128), y1 = Math.floor(r.y / 128), y2 = Math.floor((r.y + r.height) / 128);
609
- if ((x2 - x1 + 1) * (y2 - y1 + 1) > 4096) return void 0;
610
- const result = [];
611
- for (let x = 0; x <= x2 - x1; x++) for (let y = 0; y <= y2 - y1; y++) result.push(`${x1 + x}:${y1 + y}`);
612
- return result;
613
- }
614
- /** Persistent uniform-grid broad phase; oversized rectangles live in an overflow index. */
615
- var SpatialIndex = class SpatialIndex {
616
- constructor(bounds = new PersistentMap(), buckets = new PersistentMap(), overflow = new PersistentMap()) {
617
- this.bounds = bounds;
618
- this.buckets = buckets;
619
- this.overflow = overflow;
620
- Object.freeze(this);
621
- }
622
- set(id, rect$1) {
623
- let { bounds, buckets, overflow } = this;
624
- const previous = bounds.get(id);
625
- if (previous) {
626
- const keys = cells(previous);
627
- if (!keys) overflow = overflow.delete(id);
628
- else for (const key of keys) {
629
- const bucket = buckets.get(key).delete(id);
630
- buckets = bucket.size ? buckets.set(key, bucket) : buckets.delete(key);
631
- }
632
- bounds = bounds.delete(id);
633
- }
634
- if (rect$1) {
635
- const keys = cells(rect$1);
636
- if (!keys) overflow = overflow.set(id, true);
637
- else for (const key of keys) buckets = buckets.set(key, (buckets.get(key) ?? new PersistentMap()).set(id, true));
638
- bounds = bounds.set(id, rect$1);
985
+ /** Normalize routes from any native layout algorithm, without running layout again. */
986
+ function getLayoutRoutes(graph) {
987
+ const compound = graph;
988
+ if (compound.compoundRoutes?.size && hasCurrentRouteGeometry(graph, compound.compoundRoutes)) return compound.compoundRoutes;
989
+ const world = worldGeometry(graph), nodes = new Map(world.nodes.map((n) => [n.id, n]));
990
+ let result = new PersistentMap();
991
+ for (const edge of world.edges) {
992
+ const points = edge.points;
993
+ let path;
994
+ if (points?.length && points.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))) if (edge.routing === "splines") {
995
+ if (points.length >= 4 && (points.length - 1) % 3 === 0) path = pathFromSplinePoints(points);
996
+ } else path = pathFromPoints(points);
997
+ const routed = path !== void 0;
998
+ if (!path) {
999
+ const a = nodes.get(edge.sourceId), b = nodes.get(edge.targetId);
1000
+ path = pathFromPoints([{
1001
+ x: a ? a.x + a.width : 0,
1002
+ y: a ? a.y + a.height / 2 : 0
1003
+ }, {
1004
+ x: b?.x ?? 40,
1005
+ y: b ? b.y + b.height / 2 : 40
1006
+ }]);
639
1007
  }
640
- return new SpatialIndex(bounds, buckets, overflow);
1008
+ result = result.set(edge.id, freeze({
1009
+ edgeId: edge.id,
1010
+ status: routed ? "routed" : "fallback",
1011
+ diagnostics: routed ? [] : [{
1012
+ code: "MISSING_GEOMETRY",
1013
+ edgeId: edge.id,
1014
+ message: "Layout has no valid route; rendered a fallback connection"
1015
+ }],
1016
+ sections: [{
1017
+ id: `${edge.id}:0`,
1018
+ from: {
1019
+ kind: "node",
1020
+ nodeId: edge.sourceId,
1021
+ ...edge.sourcePort === void 0 ? {} : { port: edge.sourcePort }
1022
+ },
1023
+ to: {
1024
+ kind: "node",
1025
+ nodeId: edge.targetId,
1026
+ ...edge.targetPort === void 0 ? {} : { port: edge.targetPort }
1027
+ },
1028
+ path
1029
+ }]
1030
+ }));
641
1031
  }
642
- query(rect$1) {
643
- const keys = cells(rect$1), found = /* @__PURE__ */ new Set();
644
- if (!keys) {
645
- for (const [id, bounds] of this.bounds) if (intersects(rect$1, bounds)) found.add(id);
646
- } else {
647
- for (const key of keys) for (const id of this.buckets.get(key)?.keys() ?? []) if (intersects(rect$1, this.bounds.get(id))) found.add(id);
648
- for (const id of this.overflow.keys()) if (intersects(rect$1, this.bounds.get(id))) found.add(id);
1032
+ return result;
1033
+ }
1034
+ /** One polyline per section preserves label gaps and branching for simple renderers. */
1035
+ function routeToPolylines(route, options) {
1036
+ return route.sections.map((s) => flattenPath(s.path, options));
1037
+ }
1038
+ /**
1039
+ * One point list for consumers of `GraphEdge.points`. Sections are joined
1040
+ * through their gaps (an edge label, for example); between orthogonal
1041
+ * sections the join is orthogonal too, so it never adds a diagonal.
1042
+ */
1043
+ function routeToPoints(route, options) {
1044
+ const points = [];
1045
+ const axis = (a, b) => Math.abs(a.y - b.y) < 1e-9 ? "horizontal" : Math.abs(a.x - b.x) < 1e-9 ? "vertical" : void 0;
1046
+ for (const polyline of routeToPolylines(route, options)) {
1047
+ const a = points.at(-1), b = polyline[0];
1048
+ if (a && b && axis(a, b) === void 0) {
1049
+ const before = points.length > 1 ? axis(points.at(-2), a) : void 0;
1050
+ const after = polyline.length > 1 ? axis(b, polyline[1]) : void 0;
1051
+ if (before && after) if (before === "horizontal" && after === "horizontal") {
1052
+ const x = (a.x + b.x) / 2;
1053
+ points.push({
1054
+ x,
1055
+ y: a.y
1056
+ }, {
1057
+ x,
1058
+ y: b.y
1059
+ });
1060
+ } else if (before === "vertical" && after === "vertical") {
1061
+ const y = (a.y + b.y) / 2;
1062
+ points.push({
1063
+ x: a.x,
1064
+ y
1065
+ }, {
1066
+ x: b.x,
1067
+ y
1068
+ });
1069
+ } else points.push(before === "horizontal" ? {
1070
+ x: b.x,
1071
+ y: a.y
1072
+ } : {
1073
+ x: a.x,
1074
+ y: b.y
1075
+ });
649
1076
  }
650
- return [...found].sort();
1077
+ points.push(...polyline.map((point) => ({ ...point })));
651
1078
  }
652
- };
1079
+ return points;
1080
+ }
1081
+ /** Compatibility for single-section native consumers. Refuses to erase gaps or topology. */
1082
+ function routeToGraphPatch(route, options = {}) {
1083
+ if (route.sections.length !== 1 || route.sections[0].sharedId || route.sections[0].from.kind !== "node" || route.sections[0].to.kind !== "node") throw new Error("This route requires section-aware rendering; a single GraphEdge.points array would lose topology");
1084
+ const offset = options.offset ?? {
1085
+ x: 0,
1086
+ y: 0
1087
+ };
1088
+ const points = flattenPath(route.sections[0].path, options).map((p) => ({
1089
+ x: p.x - offset.x,
1090
+ y: p.y - offset.y
1091
+ }));
1092
+ return {
1093
+ op: "updateEdge",
1094
+ id: route.edgeId,
1095
+ data: {
1096
+ points,
1097
+ routing: "polyline"
1098
+ }
1099
+ };
1100
+ }
653
1101
 
654
1102
  //#endregion
655
1103
  //#region src/routing/search.ts
@@ -710,6 +1158,11 @@ var Queue = class {
710
1158
  };
711
1159
  /** Visibility graph + A*, with incoming direction in the state for bend costs. */
712
1160
  function findPath(start, end, style, context, terminals = {}) {
1161
+ const maxGridNodes = context.maxGridNodes ?? context.maxSearchNodes;
1162
+ const remainingDistance = (point) => {
1163
+ const dx = Math.abs(point.x - end.x), dy = Math.abs(point.y - end.y);
1164
+ return style === "orthogonal" ? dx + dy : style === "octilinear" ? Math.max(dx, dy) + (Math.SQRT2 - 1) * Math.min(dx, dy) : distance(point, end);
1165
+ };
713
1166
  const turnCost = (a, b) => {
714
1167
  if (!a || !b || !Math.hypot(a.x, a.y) || !Math.hypot(b.x, b.y)) return 0;
715
1168
  const dot = a.x * b.x + a.y * b.y, cross = a.x * b.y - a.y * b.x;
@@ -767,21 +1220,31 @@ function findPath(start, end, style, context, terminals = {}) {
767
1220
  ];
768
1221
  neighbors = () => points.map((_, i) => i);
769
1222
  } else {
770
- const xs = [...new Set([
1223
+ const coordinates = (values) => {
1224
+ const endpoints = values.slice(0, 2);
1225
+ const sorted = [...values].sort((a, b) => a - b);
1226
+ const unique = [];
1227
+ for (const value of sorted) {
1228
+ if (unique.length && Math.abs(value - unique.at(-1)) < 1e-8) continue;
1229
+ unique.push(value);
1230
+ }
1231
+ return unique.map((value) => endpoints.find((p) => Math.abs(p - value) < 1e-8) ?? value);
1232
+ };
1233
+ const xs = coordinates([
771
1234
  start.x,
772
1235
  end.x,
773
1236
  area.x,
774
1237
  area.x + area.width,
775
1238
  ...corners.map((p) => p.x)
776
- ])].sort((a, b) => a - b);
777
- const ys = [...new Set([
1239
+ ]);
1240
+ const ys = coordinates([
778
1241
  start.y,
779
1242
  end.y,
780
1243
  area.y,
781
1244
  area.y + area.height,
782
1245
  ...corners.map((p) => p.y)
783
- ])].sort((a, b) => a - b);
784
- if (xs.length * ys.length > context.maxSearchNodes) {
1246
+ ]);
1247
+ if (xs.length * ys.length > maxGridNodes) {
785
1248
  context.budgetExceeded = true;
786
1249
  return;
787
1250
  }
@@ -817,7 +1280,7 @@ function findPath(start, end, style, context, terminals = {}) {
817
1280
  const unique = new Map(points.map((p) => [`${p.x}:${p.y}`, p]));
818
1281
  for (const p of extra) unique.set(`${p.x}:${p.y}`, p);
819
1282
  points = [...unique.values()];
820
- if (points.length > context.maxSearchNodes) {
1283
+ if (points.length > maxGridNodes) {
821
1284
  context.budgetExceeded = true;
822
1285
  return;
823
1286
  }
@@ -851,7 +1314,7 @@ function findPath(start, end, style, context, terminals = {}) {
851
1314
  i + width < points.length ? i + width : -1
852
1315
  ].filter((v) => v >= 0);
853
1316
  }
854
- if (points.length > context.maxSearchNodes) {
1317
+ if (points.length > maxGridNodes) {
855
1318
  context.budgetExceeded = true;
856
1319
  return;
857
1320
  }
@@ -875,7 +1338,7 @@ function findPath(start, end, style, context, terminals = {}) {
875
1338
  previous: -1,
876
1339
  key: first,
877
1340
  cost: 0
878
- }, distance(start, end));
1341
+ }, remainingDistance(start));
879
1342
  while (queue.items.length && context.visited < context.maxSearchNodes) {
880
1343
  const current = queue.pop();
881
1344
  if (costs.get(current.key) !== current.cost) continue;
@@ -896,6 +1359,8 @@ function findPath(start, end, style, context, terminals = {}) {
896
1359
  if (!visible(current.index, next)) continue;
897
1360
  const before = current.previous < 0 ? terminals.incoming : delta(points[current.previous], a);
898
1361
  const step = delta(a, b);
1362
+ const reverses = (incoming, outgoing) => incoming && outgoing && Math.abs(incoming.x * outgoing.y - incoming.y * outgoing.x) < 1e-8 && incoming.x * outgoing.x + incoming.y * outgoing.y < -1e-8;
1363
+ if (reverses(before, step) || next === to && reverses(step, terminals.outgoing)) continue;
899
1364
  const bend = turnCost(before, step) + (next === to ? turnCost(step, terminals.outgoing) : 0);
900
1365
  const pair = Math.min(current.index, next) * points.length + Math.max(current.index, next);
901
1366
  let edgeCost = edgeCosts.get(pair);
@@ -914,12 +1379,33 @@ function findPath(start, end, style, context, terminals = {}) {
914
1379
  previous: current.index,
915
1380
  key,
916
1381
  cost
917
- }, cost + distance(b, end));
1382
+ }, cost + remainingDistance(b));
918
1383
  }
919
1384
  }
920
1385
  }
921
1386
  if (context.visited >= context.maxSearchNodes) context.budgetExceeded = true;
922
1387
  }
1388
+ const findPathBetweenLeads = (start, end, style, ctx, leads, directions = {}) => {
1389
+ const protectedContext = {
1390
+ ...ctx,
1391
+ edgeCost: (p, q) => {
1392
+ for (const [from, to] of leads) {
1393
+ const dx = q.x - p.x, dy = q.y - p.y;
1394
+ const length = Math.hypot(dx, dy);
1395
+ if (length < 1e-8) continue;
1396
+ if (Math.abs(dx * (to.y - from.y) - dy * (to.x - from.x)) > 1e-8 * length || Math.abs(dx * (from.y - p.y) - dy * (from.x - p.x)) > 1e-8 * length) continue;
1397
+ const lo = ((from.x - p.x) * dx + (from.y - p.y) * dy) / length;
1398
+ const hi = ((to.x - p.x) * dx + (to.y - p.y) * dy) / length;
1399
+ if (Math.min(length, Math.max(lo, hi)) - Math.max(0, Math.min(lo, hi)) > 1e-8) return Infinity;
1400
+ }
1401
+ return ctx.edgeCost?.(p, q) ?? 0;
1402
+ }
1403
+ };
1404
+ const result = findPath(start, end, style, protectedContext, directions);
1405
+ ctx.visited = protectedContext.visited;
1406
+ ctx.budgetExceeded = protectedContext.budgetExceeded;
1407
+ return result;
1408
+ };
923
1409
 
924
1410
  //#endregion
925
1411
  //#region src/routing/coordination.ts
@@ -962,7 +1448,7 @@ function conflictCost(a, b, other, config) {
962
1448
  return config.overlapPenalty * overlap * (1 - separation / spacing);
963
1449
  }
964
1450
  /** Remove only the common terminal's attachment region from soft reservations. */
965
- function outsideTerminal(segment, rect$1) {
1451
+ function outsideTerminal(segment, rect$1, closed = true) {
966
1452
  let lo = 0, hi = 1;
967
1453
  const dx = segment.b.x - segment.a.x, dy = segment.b.y - segment.a.y;
968
1454
  for (const [start, delta, min, max] of [[
@@ -976,13 +1462,13 @@ function outsideTerminal(segment, rect$1) {
976
1462
  rect$1.y,
977
1463
  rect$1.y + rect$1.height
978
1464
  ]]) if (Math.abs(delta) < 1e-8) {
979
- if (start < min || start > max) return [segment];
1465
+ if (closed ? start < min || start > max : start <= min + 1e-8 || start >= max - 1e-8) return [segment];
980
1466
  } else {
981
1467
  const a = (min - start) / delta, b = (max - start) / delta;
982
1468
  lo = Math.max(lo, Math.min(a, b));
983
1469
  hi = Math.min(hi, Math.max(a, b));
984
1470
  }
985
- if (lo > hi) return [segment];
1471
+ if (closed ? lo > hi : lo >= hi) return [segment];
986
1472
  const at = (t) => ({
987
1473
  x: segment.a.x + dx * t,
988
1474
  y: segment.a.y + dy * t
@@ -996,151 +1482,14 @@ function outsideTerminal(segment, rect$1) {
996
1482
  }] : []];
997
1483
  }
998
1484
 
999
- //#endregion
1000
- //#region src/routing/model.ts
1001
- function settings(input = {}) {
1002
- const result = {
1003
- coordinateSpace: input.coordinateSpace ?? "parent",
1004
- clearance: input.clearance ?? 8,
1005
- radius: input.radius ?? 10,
1006
- edgeSpacing: input.edgeSpacing ?? 12,
1007
- bendPenalty: input.bendPenalty ?? 10,
1008
- crossingPenalty: input.crossingPenalty ?? 80,
1009
- overlapPenalty: input.overlapPenalty ?? 8,
1010
- maxSearchNodes: input.maxSearchNodes ?? 4e3,
1011
- organicIterations: input.organicIterations ?? 12,
1012
- edges: structuredClone(input.edges ?? {})
1013
- };
1014
- for (const [key, value] of Object.entries(result)) if (typeof value === "number" && (!Number.isFinite(value) || value < 0)) throw new RangeError(`${key} must be finite and non-negative`);
1015
- if (!Number.isInteger(result.maxSearchNodes) || result.maxSearchNodes < 1 || !Number.isInteger(result.organicIterations)) throw new RangeError("Search and iteration budgets must be integers");
1016
- return freeze(result);
1017
- }
1018
- function freeze(value) {
1019
- if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
1020
- for (const child of Object.values(value)) freeze(child);
1021
- Object.freeze(value);
1022
- }
1023
- return value;
1024
- }
1025
- function nodeGeometry(n) {
1026
- return freeze({
1027
- id: n.id,
1028
- parentId: n.parentId,
1029
- x: n.x,
1030
- y: n.y,
1031
- width: n.width,
1032
- height: n.height,
1033
- ports: n.ports?.map((p) => ({
1034
- name: p.name,
1035
- x: p.x,
1036
- y: p.y,
1037
- width: p.width,
1038
- height: p.height
1039
- }))
1040
- });
1041
- }
1042
- function edgeGeometry(e) {
1043
- return freeze({
1044
- id: e.id,
1045
- sourceId: e.sourceId ?? "",
1046
- targetId: e.targetId ?? "",
1047
- sourcePort: e.sourcePort,
1048
- targetPort: e.targetPort,
1049
- x: e.x,
1050
- y: e.y,
1051
- width: e.width,
1052
- height: e.height
1053
- });
1054
- }
1055
- function rect(value) {
1056
- const { x, y, width, height: height$1 } = value;
1057
- return [
1058
- x,
1059
- y,
1060
- width,
1061
- height$1
1062
- ].every((n) => typeof n === "number" && Number.isFinite(n)) && width >= 0 && height$1 >= 0 ? {
1063
- x,
1064
- y,
1065
- width,
1066
- height: height$1
1067
- } : void 0;
1068
- }
1069
- function members(index, key, id, add) {
1070
- const current = index.get(key) ?? new PersistentMap(), next = add ? current.set(id, true) : current.delete(id);
1071
- return next.size ? index.set(key, next) : index.delete(key);
1072
- }
1073
- function ancestors(id, state) {
1074
- const result = [], seen = new Set([id]);
1075
- let parent = state.nodes.get(id)?.parentId;
1076
- while (parent != null) {
1077
- if (seen.has(parent)) throw new Error(`Cyclic routing hierarchy at ${parent}`);
1078
- result.push(parent);
1079
- seen.add(parent);
1080
- parent = state.nodes.get(parent)?.parentId;
1081
- }
1082
- return result;
1083
- }
1084
- function worldRect(node$1, state) {
1085
- const bounds = rect(node$1);
1086
- if (!bounds) return;
1087
- if (state.settings.coordinateSpace === "world") return bounds;
1088
- let x = bounds.x, y = bounds.y;
1089
- for (const id of ancestors(node$1.id, state)) {
1090
- const parent = state.nodes.get(id), p = parent && rect(parent);
1091
- if (!p) return;
1092
- x += p.x;
1093
- y += p.y;
1094
- }
1095
- return {
1096
- ...bounds,
1097
- x,
1098
- y
1099
- };
1100
- }
1101
- function labelRect(edge, state) {
1102
- const bounds = rect(edge);
1103
- if (!bounds || bounds.width <= 0 || bounds.height <= 0) return;
1104
- const source = state.nodes.get(edge.sourceId), target = state.nodes.get(edge.targetId);
1105
- if (state.settings.coordinateSpace === "world" || !source || source.parentId == null || source.parentId !== target?.parentId) return bounds;
1106
- const parent = state.nodes.get(source.parentId), p = parent && worldRect(parent, state);
1107
- return p ? {
1108
- ...bounds,
1109
- x: bounds.x + p.x,
1110
- y: bounds.y + p.y
1111
- } : void 0;
1112
- }
1113
- const center = (r) => ({
1114
- x: r.x + r.width / 2,
1115
- y: r.y + r.height / 2
1116
- });
1117
- function sideToward(a, b) {
1118
- return Math.abs(a.x - b.x) >= Math.abs(a.y - b.y) ? b.x >= a.x ? "right" : "left" : b.y >= a.y ? "bottom" : "top";
1119
- }
1120
- const vector = (side) => ({
1121
- x: side === "right" ? 1 : side === "left" ? -1 : 0,
1122
- y: side === "bottom" ? 1 : side === "top" ? -1 : 0
1123
- });
1124
- function anchor(bounds, side) {
1125
- const c = center(bounds), v = vector(side);
1126
- return {
1127
- x: c.x + v.x * bounds.width / 2,
1128
- y: c.y + v.y * bounds.height / 2
1129
- };
1130
- }
1131
- function groupKey(edge, style, config) {
1132
- const group = config.edges[edge.id]?.group;
1133
- if (style === "bus" || style === "bundle" || style === "fan") return group !== void 0 ? `group:${group}` : `source:${JSON.stringify([edge.sourceId, edge.sourcePort ?? null])}`;
1134
- return `pair:${JSON.stringify([edge.sourceId, edge.targetId].sort())}`;
1135
- }
1136
-
1137
1485
  //#endregion
1138
1486
  //#region src/routing/algorithms.ts
1139
1487
  function contextFor(state, excluded, endpoints, metrics$1, batch, padding = 0) {
1140
1488
  return {
1141
1489
  edgeCost: (a, b) => batch.edgeCost?.(a, b) ?? 0,
1142
1490
  guides: (bounds) => batch.guides?.(bounds) ?? [],
1143
- maxSearchNodes: state.settings.maxSearchNodes,
1491
+ maxSearchNodes: Math.max(1, Math.floor(state.settings.maxSearchNodes / 2)),
1492
+ maxGridNodes: state.settings.maxSearchNodes,
1144
1493
  bendPenalty: state.settings.bendPenalty,
1145
1494
  visited: 0,
1146
1495
  budgetExceeded: false,
@@ -1157,7 +1506,17 @@ function pathLength(path) {
1157
1506
  return pathReservations(path).reduce((sum, segment) => sum + distance(segment.a, segment.b), 0);
1158
1507
  }
1159
1508
  function pathCost(path, context) {
1160
- return pathReservations(path).reduce((sum, segment) => sum + distance(segment.a, segment.b) + (context.edgeCost?.(segment.a, segment.b) ?? 0), 0);
1509
+ const segments = pathReservations(path);
1510
+ const lengthAndConflicts = segments.reduce((sum, segment) => sum + distance(segment.a, segment.b) + (context.edgeCost?.(segment.a, segment.b) ?? 0), 0);
1511
+ if (path.segments.some((segment) => segment.kind !== "line")) return lengthAndConflicts;
1512
+ let turns = 0;
1513
+ for (let i = 1; i < segments.length; i++) {
1514
+ const a = segments[i - 1], b = segments[i];
1515
+ const ax = a.b.x - a.a.x, ay = a.b.y - a.a.y;
1516
+ const bx = b.b.x - b.a.x, by = b.b.y - b.a.y;
1517
+ turns += ax * bx + ay * by < -1e-8 ? 4 : Math.abs(ax * by - ay * bx) > 1e-8 ? 1 : 0;
1518
+ }
1519
+ return lengthAndConflicts + context.bendPenalty * turns;
1161
1520
  }
1162
1521
  function safe(path, context) {
1163
1522
  let start = path.start;
@@ -1426,6 +1785,38 @@ function routeEdge(edge, state, style, metrics$1, batch) {
1426
1785
  if (!(source$1 ? perEdge.sourceSide : perEdge.targetSide)) side = sideToward(center(outer), point);
1427
1786
  } else report("MISSING_PORT", `Port ${name} has no positioned geometry; used the node boundary`);
1428
1787
  }
1788
+ if (attachment && name === void 0 && !(source$1 ? perEdge.sourceSide : perEdge.targetSide)) {
1789
+ const excludedParents = new Set([...ancestors(edge.sourceId, state), ...ancestors(edge.targetId, state)].filter((id) => id !== edge.sourceId && id !== edge.targetId));
1790
+ const blocked = (p, s) => {
1791
+ const sign = attachment.facing === "inward" ? -1 : 1, v = vector(s);
1792
+ const lead = {
1793
+ x: p.x + v.x * sign,
1794
+ y: p.y + v.y * sign
1795
+ };
1796
+ return state.obstacles.query(segmentBounds(p, lead)).some((id) => {
1797
+ if (id === `n:${source$1 ? edge.sourceId : edge.targetId}` || id.startsWith("n:") && excludedParents.has(id.slice(2))) return false;
1798
+ return crossesRect(p, lead, state.obstacles.bounds.get(id));
1799
+ });
1800
+ };
1801
+ if (blocked(point, side)) {
1802
+ const toward = center(labelRect(edge, state) ?? other);
1803
+ for (const alternative of [
1804
+ "left",
1805
+ "right",
1806
+ "top",
1807
+ "bottom"
1808
+ ]) {
1809
+ const p = { ...anchor(bounds, alternative) };
1810
+ if (alternative === "left" || alternative === "right") p.y = Math.max(bounds.y, Math.min(bounds.y + bounds.height, toward.y));
1811
+ else p.x = Math.max(bounds.x, Math.min(bounds.x + bounds.width, toward.x));
1812
+ if (!blocked(p, alternative)) {
1813
+ side = alternative;
1814
+ point = p;
1815
+ break;
1816
+ }
1817
+ }
1818
+ }
1819
+ }
1429
1820
  return {
1430
1821
  point,
1431
1822
  side: attachment?.facing === "inward" ? {
@@ -1460,6 +1851,30 @@ function routeEdge(edge, state, style, metrics$1, batch) {
1460
1851
  `n:${edge.targetId}`,
1461
1852
  `e:${edge.id}`
1462
1853
  ]), metrics$1, batch, config.radius * 2 + lane * config.edgeSpacing) : context;
1854
+ let futureTargetCorridor;
1855
+ const protectFutureTarget = (ctx) => {
1856
+ const obstacles = ctx.obstacles;
1857
+ ctx.obstacles = (bounds) => {
1858
+ const result = obstacles(bounds);
1859
+ return futureTargetCorridor && intersects(bounds, futureTargetCorridor) ? [...result, futureTargetCorridor] : result;
1860
+ };
1861
+ return ctx;
1862
+ };
1863
+ for (const ctx of new Set([context, searchContext])) protectFutureTarget(ctx);
1864
+ const drawn = [];
1865
+ const selfCost = (a, b) => drawn.reduce((cost, segment) => {
1866
+ return cost + (conflictCost(a, b, segment, {
1867
+ ...config,
1868
+ edgeSpacing: 0,
1869
+ crossingPenalty: 0,
1870
+ overlapPenalty: 1
1871
+ }) > 1e-8 ? Infinity : conflictCost(a, b, segment, config));
1872
+ }, 0);
1873
+ for (const ctx of new Set([context, searchContext])) {
1874
+ const cost = ctx.edgeCost, guides = ctx.guides;
1875
+ ctx.edgeCost = (a, b) => (cost?.(a, b) ?? 0) + selfCost(a, b);
1876
+ ctx.guides = (bounds) => [...guides?.(bounds) ?? [], ...drawn.map((s) => reservationBounds(s, config.edgeSpacing))];
1877
+ }
1463
1878
  const labelAnchor = (toward) => {
1464
1879
  const side = sideToward(center(label), toward), point = anchor(label, side);
1465
1880
  if ((side === "left" || side === "right") && toward.y > label.y && toward.y < label.y + label.height) return {
@@ -1481,8 +1896,18 @@ function routeEdge(edge, state, style, metrics$1, batch) {
1481
1896
  }
1482
1897
  } : target;
1483
1898
  const lastSource = label ? {
1484
- point: labelAnchor(target.point),
1485
- side: sideToward(center(label), target.point),
1899
+ point: firstTarget.side === sideToward(center(label), target.point) ? anchor(label, {
1900
+ left: "right",
1901
+ right: "left",
1902
+ top: "bottom",
1903
+ bottom: "top"
1904
+ }[firstTarget.side]) : labelAnchor(target.point),
1905
+ side: firstTarget.side === sideToward(center(label), target.point) ? {
1906
+ left: "right",
1907
+ right: "left",
1908
+ top: "bottom",
1909
+ bottom: "top"
1910
+ }[firstTarget.side] : sideToward(center(label), target.point),
1486
1911
  ref: {
1487
1912
  kind: "label",
1488
1913
  edgeId: edge.id
@@ -1501,21 +1926,68 @@ function routeEdge(edge, state, style, metrics$1, batch) {
1501
1926
  function connection(a, b, via$1 = []) {
1502
1927
  let path, preferred;
1503
1928
  if (distance(a.point, b.point) < 1e-8 && !via$1.length) {
1504
- report("CONSTRAINT_VIOLATION", "Coincident terminals; rendered a visible loop fallback");
1929
+ const v = vector(a.side), tangent = {
1930
+ x: -v.y,
1931
+ y: v.x
1932
+ };
1505
1933
  const gap = config.clearance + config.edgeSpacing + 10;
1934
+ const candidates = [];
1935
+ for (const extent of [
1936
+ config.clearance + 1,
1937
+ (config.clearance + 1) * 2,
1938
+ gap,
1939
+ gap * 2,
1940
+ gap * 3
1941
+ ]) for (const sign of [1, -1]) {
1942
+ const lead = Math.min(config.clearance + 1, extent / 3);
1943
+ const at = (normal, lateral) => ({
1944
+ x: a.point.x + v.x * normal + tangent.x * lateral,
1945
+ y: a.point.y + v.y * normal + tangent.y * lateral
1946
+ });
1947
+ const candidate = pathFromPoints([
1948
+ a.point,
1949
+ at(lead, 0),
1950
+ at(lead, sign * extent),
1951
+ at(extent, sign * extent),
1952
+ at(extent, 0),
1953
+ b.point
1954
+ ]);
1955
+ if (safe(candidate, context)) candidates.push(candidate);
1956
+ }
1957
+ if (!candidates.length) {
1958
+ const hard = contextFor(state, excluded, new Set([
1959
+ `n:${edge.sourceId}`,
1960
+ `n:${edge.targetId}`,
1961
+ `e:${edge.id}`
1962
+ ]), metrics$1, batch, -config.clearance);
1963
+ for (const extent of [
1964
+ config.clearance + 1,
1965
+ gap,
1966
+ gap * 2
1967
+ ]) for (const sign of [1, -1]) {
1968
+ const at = (normal, lateral) => ({
1969
+ x: a.point.x + v.x * normal + tangent.x * lateral,
1970
+ y: a.point.y + v.y * normal + tangent.y * lateral
1971
+ });
1972
+ const candidate = pathFromPoints([
1973
+ a.point,
1974
+ at(1, 0),
1975
+ at(1, sign * extent),
1976
+ at(extent, sign * extent),
1977
+ at(extent, 0),
1978
+ b.point
1979
+ ]);
1980
+ if (safe(candidate, hard)) candidates.push(candidate);
1981
+ }
1982
+ }
1983
+ candidates.sort((x, y) => pathCost(x, context) - pathCost(y, context));
1984
+ if (candidates.length) return candidates[0];
1985
+ report("CONSTRAINT_VIOLATION", "Coincident terminals; no clear outward loop corridor");
1506
1986
  return pathFromPoints([
1507
1987
  a.point,
1508
1988
  {
1509
- x: a.point.x + gap,
1510
- y: a.point.y
1511
- },
1512
- {
1513
- x: a.point.x + gap,
1514
- y: a.point.y - gap
1515
- },
1516
- {
1517
- x: a.point.x,
1518
- y: a.point.y - gap
1989
+ x: a.point.x + v.x * gap,
1990
+ y: a.point.y + v.y * gap
1519
1991
  },
1520
1992
  b.point
1521
1993
  ]);
@@ -1554,16 +2026,19 @@ function routeEdge(edge, state, style, metrics$1, batch) {
1554
2026
  }
1555
2027
  }
1556
2028
  if (!path) {
1557
- const start = stub(a), end = stub(b), waypoints = [...via$1];
1558
- if (loop && !waypoints.length) {
1559
- const r = inflate(sourceBounds, config.clearance + config.edgeSpacing + 1);
1560
- waypoints.push({
1561
- x: r.x + r.width,
1562
- y: r.y
1563
- });
1564
- }
2029
+ const alignedFacing = (a.point.x === b.point.x || a.point.y === b.point.y) && (b.point.x - a.point.x) * vector(a.side).x + (b.point.y - a.point.y) * vector(a.side).y > 0 && (a.point.x - b.point.x) * vector(b.side).x + (a.point.y - b.point.y) * vector(b.side).y > 0;
2030
+ const boundedStub = (t) => {
2031
+ const p = stub(t), gap = distance(a.point, b.point) / 3;
2032
+ if (!alignedFacing || distance(t.point, p) <= gap) return p;
2033
+ const v = vector(t.side);
2034
+ return {
2035
+ x: t.point.x + v.x * gap,
2036
+ y: t.point.y + v.y * gap
2037
+ };
2038
+ };
2039
+ const start = boundedStub(a), end = boundedStub(b), waypoints = [...via$1];
1565
2040
  if (style === "parallel" && peers.length > 1) {
1566
- const base = findPath(start, end, "polyline", context);
2041
+ const base = findPathBetweenLeads(start, end, "polyline", context, [[a.point, start], [end, b.point]]);
1567
2042
  const mid = base && base.length > 2 ? base[Math.floor(base.length / 2)] : center(segmentBounds(start, end));
1568
2043
  const d = Math.max(1, distance(start, end)), rank = peers.indexOf(edge.id) + 1;
1569
2044
  const orientation = edge.sourceId < edge.targetId ? 1 : -1;
@@ -1595,13 +2070,13 @@ function routeEdge(edge, state, style, metrics$1, batch) {
1595
2070
  y: -vector(b.side).y
1596
2071
  } : void 0
1597
2072
  };
1598
- let found = findPath(anchors[i - 1], anchors[i], searchStyle, searchContext, directions);
2073
+ let found = findPathBetweenLeads(anchors[i - 1], anchors[i], searchStyle, searchContext, [[a.point, start], [end, b.point]], directions);
1599
2074
  if (!found && searchContext !== context && !searchContext.budgetExceeded) {
1600
2075
  const narrow = {
1601
2076
  ...searchContext,
1602
2077
  obstacles: context.obstacles
1603
2078
  };
1604
- found = findPath(anchors[i - 1], anchors[i], searchStyle, narrow, directions);
2079
+ found = findPathBetweenLeads(anchors[i - 1], anchors[i], searchStyle, narrow, [[a.point, start], [end, b.point]], directions);
1605
2080
  searchContext.visited = narrow.visited;
1606
2081
  searchContext.budgetExceeded = narrow.budgetExceeded;
1607
2082
  }
@@ -1609,13 +2084,140 @@ function routeEdge(edge, state, style, metrics$1, batch) {
1609
2084
  else ok = false;
1610
2085
  }
1611
2086
  points.push(b.point);
1612
- if (ok) {
1613
- const simple = simplify(points);
1614
- path = style === "curved" || style === "bundle" || style === "bezier" ? curve(simple, state, context) : style === "organic" ? organic(simple, state, context) : pathFromPoints(simple);
2087
+ if (ok) {
2088
+ const simple = simplify(points);
2089
+ path = style === "curved" || style === "bundle" || style === "bezier" ? curve(simple, state, context) : style === "organic" ? organic(simple, state, context) : pathFromPoints(simple);
2090
+ }
2091
+ }
2092
+ if (preferred && Number.isFinite(pathCost(preferred, context)) && (!path || !safe(path, context) || pathCost(preferred, context) <= pathCost(path, context))) return preferred;
2093
+ if (path && safe(path, context) && Number.isFinite(pathCost(path, context))) return path;
2094
+ if (![
2095
+ "straight",
2096
+ "bezier",
2097
+ "polyline",
2098
+ "organic",
2099
+ "parallel"
2100
+ ].includes(style)) {
2101
+ const hard = {
2102
+ ...contextFor(state, excluded, new Set([
2103
+ `n:${edge.sourceId}`,
2104
+ `n:${edge.targetId}`,
2105
+ `e:${edge.id}`
2106
+ ]), metrics$1, batch, -config.clearance),
2107
+ maxSearchNodes: Math.max(0, config.maxSearchNodes - searchContext.visited - (searchContext === context ? 0 : context.visited))
2108
+ };
2109
+ protectFutureTarget(hard);
2110
+ hard.guides = void 0;
2111
+ hard.edgeCost = selfCost;
2112
+ const lead = (t) => {
2113
+ const v = vector(t.side);
2114
+ for (let length = 1; length >= 1e-6; length /= 2) {
2115
+ const point = {
2116
+ x: t.point.x + v.x * length,
2117
+ y: t.point.y + v.y * length
2118
+ };
2119
+ if (clear(t.point, point, hard)) return point;
2120
+ }
2121
+ return t.point;
2122
+ };
2123
+ const start = lead(a), end = lead(b), anchors = [
2124
+ start,
2125
+ ...via$1,
2126
+ end
2127
+ ];
2128
+ const points = [a.point];
2129
+ let ok = clear(a.point, start, hard) && clear(end, b.point, hard);
2130
+ for (let i = 1; ok && i < anchors.length; i++) {
2131
+ const found = findPathBetweenLeads(anchors[i - 1], anchors[i], style === "octilinear" ? "octilinear" : "orthogonal", hard, [[a.point, start], [end, b.point]], {
2132
+ incoming: i === 1 ? vector(a.side) : void 0,
2133
+ outgoing: i === anchors.length - 1 ? {
2134
+ x: -vector(b.side).x,
2135
+ y: -vector(b.side).y
2136
+ } : void 0
2137
+ });
2138
+ if (found) points.push(...found.slice(i === 1 ? 0 : 1));
2139
+ else ok = false;
2140
+ }
2141
+ points.push(b.point);
2142
+ const retry = pathFromPoints(simplify(points));
2143
+ searchContext.visited += hard.visited;
2144
+ if (ok && safe(retry, hard) && Number.isFinite(pathCost(retry, hard))) {
2145
+ let separated = retry;
2146
+ for (let pass = 0; pass < 6; pass++) {
2147
+ const track = [separated.start, ...separated.segments.map((s) => s.to)];
2148
+ let best = separated, bestCost = pathCost(separated, context);
2149
+ for (let i = 1; i < track.length - 2; i++) {
2150
+ const p = track[i], q = track[i + 1];
2151
+ if ((context.edgeCost?.(p, q) ?? 0) === 0) continue;
2152
+ const horizontal = p.y === q.y;
2153
+ if (!horizontal && p.x !== q.x) continue;
2154
+ for (const offset of [
2155
+ -config.edgeSpacing,
2156
+ config.edgeSpacing,
2157
+ -config.edgeSpacing * 2,
2158
+ config.edgeSpacing * 2,
2159
+ -config.edgeSpacing / 2,
2160
+ config.edgeSpacing / 2,
2161
+ -config.edgeSpacing / 3,
2162
+ config.edgeSpacing / 3,
2163
+ -config.edgeSpacing * 4,
2164
+ config.edgeSpacing * 4
2165
+ ]) {
2166
+ const candidatePoints = track.map((r, j) => j === i || j === i + 1 ? horizontal ? {
2167
+ x: r.x,
2168
+ y: r.y + offset
2169
+ } : {
2170
+ x: r.x + offset,
2171
+ y: r.y
2172
+ } : r);
2173
+ if (candidatePoints.slice(1).some((r, j) => r.x !== candidatePoints[j].x && r.y !== candidatePoints[j].y)) continue;
2174
+ const candidate = pathFromPoints(simplify(candidatePoints));
2175
+ const segments = pathReservations(candidate);
2176
+ if (segments.some((s, j) => segments.slice(j + 1).some((t) => {
2177
+ if (s.a.x === s.b.x && t.a.x === t.b.x && s.a.x === t.a.x) return Math.min(Math.max(s.a.y, s.b.y), Math.max(t.a.y, t.b.y)) - Math.max(Math.min(s.a.y, s.b.y), Math.min(t.a.y, t.b.y)) > 1e-8;
2178
+ if (s.a.y === s.b.y && t.a.y === t.b.y && s.a.y === t.a.y) return Math.min(Math.max(s.a.x, s.b.x), Math.max(t.a.x, t.b.x)) - Math.max(Math.min(s.a.x, s.b.x), Math.min(t.a.x, t.b.x)) > 1e-8;
2179
+ return false;
2180
+ })) || !safe(candidate, hard)) continue;
2181
+ const cost = pathCost(candidate, context);
2182
+ if (cost < bestCost) {
2183
+ best = candidate;
2184
+ bestCost = cost;
2185
+ }
2186
+ }
2187
+ }
2188
+ if (best === separated) break;
2189
+ separated = best;
2190
+ }
2191
+ if (!via$1.length && pathCost(retry, context) > pathLength(retry)) {
2192
+ const remaining = Math.max(0, config.maxSearchNodes - searchContext.visited - (searchContext === context ? 0 : context.visited));
2193
+ const optimized = {
2194
+ ...hard,
2195
+ visited: 0,
2196
+ budgetExceeded: false,
2197
+ maxSearchNodes: Math.min(2e3, remaining),
2198
+ edgeCost: (a$1, b$1) => (batch.edgeCost?.(a$1, b$1) ?? 0) + selfCost(a$1, b$1),
2199
+ guides: (bounds) => (batch.guides?.(bounds) ?? []).slice(0, 8)
2200
+ };
2201
+ const found = findPathBetweenLeads(start, end, style === "octilinear" ? "octilinear" : "orthogonal", optimized, [[a.point, start], [end, b.point]], {
2202
+ incoming: vector(a.side),
2203
+ outgoing: {
2204
+ x: -vector(b.side).x,
2205
+ y: -vector(b.side).y
2206
+ }
2207
+ });
2208
+ searchContext.visited += optimized.visited;
2209
+ if (found) {
2210
+ const candidate = pathFromPoints(simplify([
2211
+ a.point,
2212
+ ...found,
2213
+ b.point
2214
+ ]));
2215
+ if (safe(candidate, hard) && pathCost(candidate, context) < pathCost(separated, context)) return candidate;
2216
+ }
2217
+ }
2218
+ return separated;
1615
2219
  }
1616
2220
  }
1617
- if (preferred && (!path || !safe(path, context) || pathCost(preferred, context) <= pathCost(path, context))) return preferred;
1618
- if (path && safe(path, context)) return path;
1619
2221
  report(searchContext.budgetExceeded ? "SEARCH_BUDGET" : "ROUTE_BLOCKED", "Preferred route unavailable; fallback may cross obstacles or violate routing constraints");
1620
2222
  if (loop && !via$1.length) {
1621
2223
  const r = inflate(sourceBounds, config.clearance + config.edgeSpacing + 1);
@@ -1644,6 +2246,25 @@ function routeEdge(edge, state, style, metrics$1, batch) {
1644
2246
  }
1645
2247
  const via = perEdge.waypoints ?? [];
1646
2248
  if (via.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y))) throw new RangeError(`Non-finite waypoint on ${edge.id}`);
2249
+ if (lastSource && !loop) {
2250
+ const hard = contextFor(state, excluded, new Set([
2251
+ `n:${edge.sourceId}`,
2252
+ `n:${edge.targetId}`,
2253
+ `e:${edge.id}`
2254
+ ]), metrics$1, batch, -config.clearance);
2255
+ const v = vector(target.side);
2256
+ for (let length = config.clearance + 1; length >= 1e-6; length /= 2) {
2257
+ const lead = {
2258
+ x: target.point.x + v.x * length,
2259
+ y: target.point.y + v.y * length
2260
+ };
2261
+ const corridor = inflate(segmentBounds(target.point, lead), Math.min(.5, length / 4));
2262
+ if (clear(target.point, lead, hard) && !intersects(segmentBounds(source.point, source.point), corridor) && !intersects(segmentBounds(firstTarget.point, firstTarget.point), corridor)) {
2263
+ futureTargetCorridor = corridor;
2264
+ break;
2265
+ }
2266
+ }
2267
+ }
1647
2268
  const plan = grouped ? groupPlan(group, state, metrics$1, batch, style) : void 0;
1648
2269
  if (grouped && !plan) report("ROUTE_BLOCKED", "Shared trunk unavailable; used individual fallback routing");
1649
2270
  if (plan) {
@@ -1687,12 +2308,19 @@ function routeEdge(edge, state, style, metrics$1, batch) {
1687
2308
  to: firstTarget.ref,
1688
2309
  path: connection(source, firstTarget, via)
1689
2310
  });
1690
- if (lastSource) sections.push({
1691
- id: `${edge.id}:label-target`,
1692
- from: lastSource.ref,
1693
- to: target.ref,
1694
- path: connection(lastSource, target)
1695
- });
2311
+ if (lastSource) {
2312
+ futureTargetCorridor = void 0;
2313
+ let segments = sections.flatMap((s) => pathReservations(s.path));
2314
+ segments = segments.flatMap((s) => outsideTerminal(s, label, false));
2315
+ if (loop) segments = segments.flatMap((s) => outsideTerminal(s, inflate(sourceBounds, config.clearance + 1)));
2316
+ drawn.push(...segments);
2317
+ sections.push({
2318
+ id: `${edge.id}:label-target`,
2319
+ from: lastSource.ref,
2320
+ to: target.ref,
2321
+ path: connection(lastSource, target)
2322
+ });
2323
+ }
1696
2324
  if (sections.some((section) => pathReservations(section.path).some((segment) => batch.conflicts?.(segment.a, segment.b)))) report("ROUTE_CONFLICT", "Route crosses or overlaps another edge; retained a compact visible path");
1697
2325
  metrics$1.searchNodes += searchContext.visited + (searchContext === context ? 0 : context.visited);
1698
2326
  return {
@@ -2119,250 +2747,6 @@ function applyRoutePatches(routes, patches) {
2119
2747
  return result;
2120
2748
  }
2121
2749
 
2122
- //#endregion
2123
- //#region src/routing/layout-cache.ts
2124
- const geometryByRoutes = /* @__PURE__ */ new WeakMap();
2125
- /** Compare only geometry and topology; application data may contain cycles. */
2126
- function geometryKey(graph) {
2127
- return JSON.stringify({
2128
- nodes: graph.nodes.map(({ id, parentId, x, y, width, height: height$1, ports }) => ({
2129
- id,
2130
- parentId,
2131
- x,
2132
- y,
2133
- width,
2134
- height: height$1,
2135
- ports: ports?.map(({ name, x: x$1, y: y$1, width: width$1, height: height$2 }) => ({
2136
- name,
2137
- x: x$1,
2138
- y: y$1,
2139
- width: width$1,
2140
- height: height$2
2141
- }))
2142
- })),
2143
- edges: graph.edges.map(({ id, sourceId, targetId, sourcePort, targetPort, x, y, width, height: height$1, points, routing }) => ({
2144
- id,
2145
- sourceId,
2146
- targetId,
2147
- sourcePort,
2148
- targetPort,
2149
- x,
2150
- y,
2151
- width,
2152
- height: height$1,
2153
- points,
2154
- routing
2155
- }))
2156
- });
2157
- }
2158
- /** Associate structured sections with the geometry that produced them. */
2159
- function recordRouteGeometry(graph, routes) {
2160
- geometryByRoutes.set(routes, geometryKey(graph));
2161
- }
2162
- /** Graph edits invalidate cached sections, including edits made by external consumers. */
2163
- function hasCurrentRouteGeometry(graph, routes) {
2164
- return geometryByRoutes.get(routes) === geometryKey(graph);
2165
- }
2166
-
2167
- //#endregion
2168
- //#region src/authoring/coordinates.ts
2169
- /** Legacy sibling edges are parent-relative; native compound output marks world-space edges. */
2170
- function frames(graph) {
2171
- const nodes = new Map(graph.nodes.map((n) => [n.id, n]));
2172
- const offsets = /* @__PURE__ */ new Map();
2173
- const visiting = /* @__PURE__ */ new Set();
2174
- function offset(id) {
2175
- const cached = offsets.get(id);
2176
- if (cached) return cached;
2177
- if (visiting.has(id)) throw new LayoutError(`Cyclic parent hierarchy at ${id}`, "INVALID_GRAPH");
2178
- visiting.add(id);
2179
- const parentId = nodes.get(id)?.parentId;
2180
- if (parentId == null) {
2181
- const origin = {
2182
- x: 0,
2183
- y: 0
2184
- };
2185
- visiting.delete(id);
2186
- offsets.set(id, origin);
2187
- return origin;
2188
- }
2189
- const parent = nodes.get(parentId);
2190
- const ancestor = offset(parentId);
2191
- const result = {
2192
- x: ancestor.x + parent.x,
2193
- y: ancestor.y + parent.y
2194
- };
2195
- visiting.delete(id);
2196
- offsets.set(id, result);
2197
- return result;
2198
- }
2199
- for (const node$1 of graph.nodes) offsets.set(node$1.id, offset(node$1.id));
2200
- return {
2201
- nodes: offsets,
2202
- edges: new Map(graph.edges.map((edge) => {
2203
- const source = nodes.get(edge.sourceId);
2204
- const target = nodes.get(edge.targetId);
2205
- return [edge.id, graph.edgeCoordinateSpace !== "world" && (source.parentId ?? null) === (target.parentId ?? null) ? offset(source.id) : {
2206
- x: 0,
2207
- y: 0
2208
- }];
2209
- }))
2210
- };
2211
- }
2212
- function worldGeometry(graph) {
2213
- const offsets = frames(graph);
2214
- const base = { ...graph };
2215
- delete base.compoundRoutes;
2216
- delete base.compoundGeometry;
2217
- return {
2218
- ...base,
2219
- nodes: graph.nodes.map((n) => {
2220
- const p = offsets.nodes.get(n.id);
2221
- return p.x === 0 && p.y === 0 ? n : {
2222
- ...n,
2223
- x: n.x + p.x,
2224
- y: n.y + p.y
2225
- };
2226
- }),
2227
- edges: graph.edges.map((e) => {
2228
- const p = offsets.edges.get(e.id);
2229
- return p.x === 0 && p.y === 0 ? e : {
2230
- ...e,
2231
- x: e.x + p.x,
2232
- y: e.y + p.y,
2233
- ...e.points ? { points: e.points.map((v) => ({
2234
- x: v.x + p.x,
2235
- y: v.y + p.y
2236
- })) } : {}
2237
- };
2238
- })
2239
- };
2240
- }
2241
- /** Ancestor frames stay fixed during authoring. Preserve untouched coordinates exactly. */
2242
- function nativeGeometry(graph, original) {
2243
- const offsets = frames(original);
2244
- const world = worldGeometry(original);
2245
- const originalNodes = new Map(original.nodes.map((n) => [n.id, n]));
2246
- const originalEdges = new Map(original.edges.map((e) => [e.id, e]));
2247
- const worldNodes = new Map(world.nodes.map((n) => [n.id, n]));
2248
- const worldEdges = new Map(world.edges.map((e) => [e.id, e]));
2249
- return {
2250
- ...graph,
2251
- nodes: graph.nodes.map((n) => {
2252
- const p = offsets.nodes.get(n.id), before = originalNodes.get(n.id), prior = worldNodes.get(n.id);
2253
- return {
2254
- ...n,
2255
- x: n.x === prior.x ? before.x : n.x - p.x,
2256
- y: n.y === prior.y ? before.y : n.y - p.y
2257
- };
2258
- }),
2259
- edges: graph.edges.map((e) => {
2260
- const p = offsets.edges.get(e.id), before = originalEdges.get(e.id), prior = worldEdges.get(e.id);
2261
- const points = JSON.stringify(e.points) === JSON.stringify(prior.points) ? before.points : e.points?.map((v) => ({
2262
- x: v.x - p.x,
2263
- y: v.y - p.y
2264
- }));
2265
- return {
2266
- ...e,
2267
- x: e.x === prior.x ? before.x : e.x - p.x,
2268
- y: e.y === prior.y ? before.y : e.y - p.y,
2269
- ...points === void 0 ? {} : { points }
2270
- };
2271
- })
2272
- };
2273
- }
2274
-
2275
- //#endregion
2276
- //#region src/routing/adapters.ts
2277
- /** ELK/native spline output: start, then repeating control1/control2/end triples. */
2278
- function pathFromSplinePoints(points) {
2279
- if (points.length < 4 || (points.length - 1) % 3 !== 0) throw new RangeError("Cubic spline points require a start and control1/control2/end triples");
2280
- const segments = [];
2281
- for (let i = 1; i < points.length; i += 3) segments.push({
2282
- kind: "cubic",
2283
- control1: { ...points[i] },
2284
- control2: { ...points[i + 1] },
2285
- to: { ...points[i + 2] }
2286
- });
2287
- return {
2288
- start: { ...points[0] },
2289
- segments
2290
- };
2291
- }
2292
- /** Normalize routes from any native layout algorithm, without running layout again. */
2293
- function getLayoutRoutes(graph) {
2294
- const compound = graph;
2295
- if (compound.compoundRoutes?.size && hasCurrentRouteGeometry(graph, compound.compoundRoutes)) return compound.compoundRoutes;
2296
- const world = worldGeometry(graph), nodes = new Map(world.nodes.map((n) => [n.id, n]));
2297
- let result = new PersistentMap();
2298
- for (const edge of world.edges) {
2299
- const points = edge.points;
2300
- let path;
2301
- if (points?.length && points.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))) if (edge.routing === "splines") {
2302
- if (points.length >= 4 && (points.length - 1) % 3 === 0) path = pathFromSplinePoints(points);
2303
- } else path = pathFromPoints(points);
2304
- const routed = path !== void 0;
2305
- if (!path) {
2306
- const a = nodes.get(edge.sourceId), b = nodes.get(edge.targetId);
2307
- path = pathFromPoints([{
2308
- x: a ? a.x + a.width : 0,
2309
- y: a ? a.y + a.height / 2 : 0
2310
- }, {
2311
- x: b?.x ?? 40,
2312
- y: b ? b.y + b.height / 2 : 40
2313
- }]);
2314
- }
2315
- result = result.set(edge.id, freeze({
2316
- edgeId: edge.id,
2317
- status: routed ? "routed" : "fallback",
2318
- diagnostics: routed ? [] : [{
2319
- code: "MISSING_GEOMETRY",
2320
- edgeId: edge.id,
2321
- message: "Layout has no valid route; rendered a fallback connection"
2322
- }],
2323
- sections: [{
2324
- id: `${edge.id}:0`,
2325
- from: {
2326
- kind: "node",
2327
- nodeId: edge.sourceId,
2328
- ...edge.sourcePort === void 0 ? {} : { port: edge.sourcePort }
2329
- },
2330
- to: {
2331
- kind: "node",
2332
- nodeId: edge.targetId,
2333
- ...edge.targetPort === void 0 ? {} : { port: edge.targetPort }
2334
- },
2335
- path
2336
- }]
2337
- }));
2338
- }
2339
- return result;
2340
- }
2341
- /** One polyline per section preserves label gaps and branching for simple renderers. */
2342
- function routeToPolylines(route, options) {
2343
- return route.sections.map((s) => flattenPath(s.path, options));
2344
- }
2345
- /** Compatibility for single-section native consumers. Refuses to erase gaps or topology. */
2346
- function routeToGraphPatch(route, options = {}) {
2347
- if (route.sections.length !== 1 || route.sections[0].sharedId || route.sections[0].from.kind !== "node" || route.sections[0].to.kind !== "node") throw new Error("This route requires section-aware rendering; a single GraphEdge.points array would lose topology");
2348
- const offset = options.offset ?? {
2349
- x: 0,
2350
- y: 0
2351
- };
2352
- const points = flattenPath(route.sections[0].path, options).map((p) => ({
2353
- x: p.x - offset.x,
2354
- y: p.y - offset.y
2355
- }));
2356
- return {
2357
- op: "updateEdge",
2358
- id: route.edgeId,
2359
- data: {
2360
- points,
2361
- routing: "polyline"
2362
- }
2363
- };
2364
- }
2365
-
2366
2750
  //#endregion
2367
2751
  //#region src/routing/index.ts
2368
2752
  const straightRouting = createRoutingStrategy("straight");
@@ -2393,4 +2777,4 @@ const routingStrategies = Object.freeze({
2393
2777
  });
2394
2778
 
2395
2779
  //#endregion
2396
- export { toSvgPath as A, flattenPath as C, getTangentAtLength as D, getPointAtLength as E, UnsupportedLayoutError as F, routeCrosses as M, routeOrthogonal as N, pathFromPoints as O, LayoutError as P, freeze as S, getPathLength as T, routeToPolylines as _, fanRouting as a, recordRouteGeometry as b, orthogonalRouting as c, routingStrategies as d, selfLoopRouting as f, routeToGraphPatch as g, pathFromSplinePoints as h, curvedRouting as i, overlaps as j, roundCorners as k, parallelRouting as l, getLayoutRoutes as m, bundleRouting as n, octilinearRouting as o, straightRouting as p, busRouting as r, organicRouting as s, bezierRouting as t, polylineRouting as u, nativeGeometry as v, getPathBounds as w, applyRoutePatches as x, worldGeometry as y };
2780
+ export { routeOrthogonal as A, getPointAtLength as C, toSvgPath as D, roundCorners as E, LayoutError as F, UnsupportedLayoutError as I, recordRouteGeometry as M, nativeGeometry as N, overlaps as O, worldGeometry as P, getPathLength as S, pathFromPoints as T, routeToGraphPatch as _, fanRouting as a, flattenPath as b, orthogonalRouting as c, routingStrategies as d, selfLoopRouting as f, pathFromSplinePoints as g, getLayoutRoutes as h, curvedRouting as i, freeze as j, routeCrosses as k, parallelRouting as l, applyRoutePatches as m, bundleRouting as n, octilinearRouting as o, straightRouting as p, busRouting as r, organicRouting as s, bezierRouting as t, polylineRouting as u, routeToPoints as v, getTangentAtLength as w, getPathBounds as x, routeToPolylines as y };