@synergenius/flow-weaver 0.33.1 → 0.33.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,163 +1,51 @@
1
1
  /**
2
2
  * Orthogonal connection router for SVG diagram rendering.
3
3
  *
4
- * Ported from the original React-based editor's orthogonalRouter.ts.
5
- * Removed gl-matrix dependency — uses plain [number, number] tuples.
6
- *
7
- * Features:
8
- * - L-shape and S-shape routing with rounded corners
9
- * - Node collision avoidance via inflated bounding boxes
10
- * - Track allocator prevents parallel connections from overlapping
11
- * - Crossing minimization (evaluates up to 11 candidates per allocation)
12
- * - Per-port-index stub spacing
13
- * - Waypoint simplification (collinear removal, jog collapse)
14
- * - Backward/self connection escape routing
4
+ * 1-1 port of the platform's orthogonalRouter.ts.
5
+ * Only change: removed gl-matrix dependency — uses plain [number, number] tuples.
15
6
  */
16
- // Minimum spacing between parallel horizontal segments (px)
17
7
  const TRACK_SPACING = 15;
18
- // Clearance from inflated box edges when routing around nodes (px).
19
8
  const EDGE_OFFSET = 5;
20
- // ─── Track allocator ───
21
- // Maximum free candidates to evaluate in each direction for crossing minimization.
22
- const MAX_CANDIDATES = 5;
9
+ // Nudge the initial candidate Y downward so paths prefer routing below obstacles.
10
+ const BELOW_BIAS = 40;
11
+ // ─── Track Allocator ───
12
+ /**
13
+ * Prevents connections from running on the same horizontal track.
14
+ * Create one instance per render batch and pass it to all routing calls.
15
+ * Tracks are snapped to TRACK_SPACING grid; only connections whose X corridors
16
+ * overlap can conflict.
17
+ */
23
18
  export class TrackAllocator {
24
- claims = [];
25
- verticalClaims = [];
26
- /** Check if a Y value is too close to any claimed segment whose X range overlaps. */
27
- isOccupied(xMin, xMax, y) {
28
- for (const c of this.claims) {
29
- if (c.xMin < xMax && c.xMax > xMin && Math.abs(c.y - y) < TRACK_SPACING) {
30
- return true;
31
- }
32
- }
33
- return false;
34
- }
35
- /** Check if an X value is too close to any claimed vertical segment whose Y range overlaps. */
36
- isOccupiedVertical(yMin, yMax, x) {
37
- for (const c of this.verticalClaims) {
38
- if (c.yMin < yMax && c.yMax > yMin && Math.abs(c.x - x) < TRACK_SPACING) {
39
- return true;
40
- }
41
- }
42
- return false;
43
- }
44
- /** Check if a horizontal segment at Y passes through any inflated node box. */
45
- isBlockedByNode(xMin, xMax, y, boxes) {
46
- for (const box of boxes) {
47
- if (xMin < box.right && xMax > box.left && y >= box.top && y <= box.bottom) {
48
- return true;
49
- }
50
- }
51
- return false;
52
- }
53
- /** Check if a vertical segment at X passes through any inflated node box. */
54
- isBlockedByNodeVertical(yMin, yMax, x, boxes) {
55
- for (const box of boxes) {
56
- if (x >= box.left && x <= box.right && yMin < box.bottom && yMax > box.top) {
57
- return true;
58
- }
59
- }
60
- return false;
61
- }
62
- /** Count claimed vertical segments that a horizontal segment at Y would cross. */
63
- countHorizontalCrossings(xMin, xMax, y) {
64
- let count = 0;
65
- for (const c of this.verticalClaims) {
66
- if (c.x > xMin && c.x < xMax && y >= c.yMin && y <= c.yMax) {
67
- count++;
68
- }
69
- }
70
- return count;
71
- }
72
- /** Count claimed horizontal segments that a vertical segment at X would cross. */
73
- countVerticalCrossings(yMin, yMax, x) {
74
- let count = 0;
75
- for (const c of this.claims) {
76
- if (c.y > yMin && c.y < yMax && x >= c.xMin && x <= c.xMax) {
77
- count++;
78
- }
79
- }
80
- return count;
81
- }
82
- /**
83
- * Find the nearest free Y to candidateY in the given X range, preferring fewer crossings.
84
- * When nodeBoxes is provided, candidates inside inflated node boxes are rejected (hard constraint).
85
- */
86
- findFreeY(xMin, xMax, candidateY, nodeBoxes) {
87
- const isFree = (y) => !this.isOccupied(xMin, xMax, y) &&
88
- (!nodeBoxes || !this.isBlockedByNode(xMin, xMax, y, nodeBoxes));
89
- if (isFree(candidateY))
90
- return candidateY;
91
- const candidates = [];
92
- for (let offset = TRACK_SPACING; offset < 800 && candidates.length < MAX_CANDIDATES * 2; offset += TRACK_SPACING) {
93
- const above = candidateY - offset;
94
- if (isFree(above)) {
95
- candidates.push({ y: above, dist: offset });
96
- }
97
- const below = candidateY + offset;
98
- if (isFree(below)) {
99
- candidates.push({ y: below, dist: offset });
100
- }
101
- }
102
- if (candidates.length === 0)
103
- return candidateY;
104
- let bestY = candidates[0].y;
105
- let bestCrossings = this.countHorizontalCrossings(xMin, xMax, candidates[0].y);
106
- let bestDist = candidates[0].dist;
107
- for (let i = 1; i < candidates.length; i++) {
108
- const c = candidates[i];
109
- const crossings = this.countHorizontalCrossings(xMin, xMax, c.y);
110
- if (crossings < bestCrossings || (crossings === bestCrossings && c.dist < bestDist)) {
111
- bestY = c.y;
112
- bestCrossings = crossings;
113
- bestDist = c.dist;
114
- }
115
- }
116
- return bestY;
117
- }
118
- /**
119
- * Find the nearest free X to candidateX in the given Y range, preferring fewer crossings.
120
- * When nodeBoxes is provided, candidates inside inflated node boxes are rejected (hard constraint).
121
- */
122
- findFreeX(yMin, yMax, candidateX, nodeBoxes) {
123
- const isFree = (x) => !this.isOccupiedVertical(yMin, yMax, x) &&
124
- (!nodeBoxes || !this.isBlockedByNodeVertical(yMin, yMax, x, nodeBoxes));
125
- if (isFree(candidateX))
126
- return candidateX;
127
- const candidates = [];
128
- for (let offset = TRACK_SPACING; offset < 800 && candidates.length < MAX_CANDIDATES * 2; offset += TRACK_SPACING) {
129
- const left = candidateX - offset;
130
- if (isFree(left)) {
131
- candidates.push({ x: left, dist: offset });
132
- }
133
- const right = candidateX + offset;
134
- if (isFree(right)) {
135
- candidates.push({ x: right, dist: offset });
136
- }
137
- }
138
- if (candidates.length === 0)
139
- return candidateX;
140
- let bestX = candidates[0].x;
141
- let bestCrossings = this.countVerticalCrossings(yMin, yMax, candidates[0].x);
142
- let bestDist = candidates[0].dist;
143
- for (let i = 1; i < candidates.length; i++) {
144
- const c = candidates[i];
145
- const crossings = this.countVerticalCrossings(yMin, yMax, c.x);
146
- if (crossings < bestCrossings || (crossings === bestCrossings && c.dist < bestDist)) {
147
- bestX = c.x;
148
- bestCrossings = crossings;
149
- bestDist = c.dist;
19
+ claims = new Map();
20
+ claim(xMin, xMax, candidateY) {
21
+ const snap = (y) => Math.round(y / TRACK_SPACING) * TRACK_SPACING;
22
+ const overlaps = (slot) => {
23
+ const intervals = this.claims.get(slot);
24
+ if (!intervals)
25
+ return false;
26
+ return intervals.some(([a, b]) => xMin < b && xMax > a);
27
+ };
28
+ const take = (slot) => {
29
+ if (!this.claims.has(slot))
30
+ this.claims.set(slot, []);
31
+ const slotClaims = this.claims.get(slot);
32
+ if (slotClaims)
33
+ slotClaims.push([xMin, xMax]);
34
+ return slot;
35
+ };
36
+ const base = snap(candidateY);
37
+ // Search outward, preferring below (positive direction) on each step.
38
+ for (let i = 0; i < 60; i++) {
39
+ const below = base + i * TRACK_SPACING;
40
+ if (!overlaps(below))
41
+ return take(below);
42
+ if (i > 0) {
43
+ const above = base - i * TRACK_SPACING;
44
+ if (!overlaps(above))
45
+ return take(above);
150
46
  }
151
47
  }
152
- return bestX;
153
- }
154
- /** Claim a horizontal segment so later connections avoid it. */
155
- claim(xMin, xMax, y) {
156
- this.claims.push({ xMin, xMax, y });
157
- }
158
- /** Claim a vertical segment so later connections avoid it. */
159
- claimVertical(yMin, yMax, x) {
160
- this.verticalClaims.push({ yMin, yMax, x });
48
+ return candidateY;
161
49
  }
162
50
  }
163
51
  // ─── Node avoidance helpers ───
@@ -209,7 +97,8 @@ function findClearY(xMin, xMax, candidateY, boxes) {
209
97
  if (bestDist === Infinity) {
210
98
  const allMin = Math.min(...edges) - EDGE_OFFSET * 2;
211
99
  const allMax = Math.max(...edges) + EDGE_OFFSET * 2;
212
- bestY = Math.abs(allMin - candidateY) <= Math.abs(allMax - candidateY) ? allMin : allMax;
100
+ bestY = Math.abs(allMin - candidateY) < Math.abs(allMax - candidateY) ? allMin : allMax;
101
+ // Verify the extreme fallback is actually clear; search outward if not
213
102
  if (isBlocked(bestY)) {
214
103
  for (let offset = TRACK_SPACING; offset < 800; offset += TRACK_SPACING) {
215
104
  if (!isBlocked(bestY - offset)) {
@@ -227,6 +116,7 @@ function findClearY(xMin, xMax, candidateY, boxes) {
227
116
  }
228
117
  /**
229
118
  * Find an X clear of node boxes for a vertical segment spanning [yMin, yMax].
119
+ * Mirror of findClearY for the vertical axis.
230
120
  */
231
121
  function findClearX(yMin, yMax, candidateX, boxes) {
232
122
  const isBlocked = (x) => boxes.some((box) => x >= box.left && x <= box.right && yMin < box.bottom && yMax > box.top);
@@ -259,6 +149,7 @@ function findClearX(yMin, yMax, candidateX, boxes) {
259
149
  const allMin = Math.min(...edges) - EDGE_OFFSET * 2;
260
150
  const allMax = Math.max(...edges) + EDGE_OFFSET * 2;
261
151
  bestX = Math.abs(allMin - candidateX) <= Math.abs(allMax - candidateX) ? allMin : allMax;
152
+ // Verify the extreme fallback is actually clear; search outward if not
262
153
  if (isBlocked(bestX)) {
263
154
  for (let offset = TRACK_SPACING; offset < 800; offset += TRACK_SPACING) {
264
155
  if (!isBlocked(bestX - offset)) {
@@ -275,7 +166,9 @@ function findClearX(yMin, yMax, candidateX, boxes) {
275
166
  return bestX;
276
167
  }
277
168
  // ─── Waypoint utilities ───
169
+ // Minimum segment length: segments shorter than this are collapsed.
278
170
  const MIN_SEGMENT_LENGTH = 3;
171
+ // Minimum acceptable vertical/horizontal jog height/width.
279
172
  const JOG_THRESHOLD = 10;
280
173
  /** Remove collinear, duplicate, tiny-jog, and very-short-segment waypoints. */
281
174
  function simplifyWaypoints(waypoints) {
@@ -288,35 +181,40 @@ function simplifyWaypoints(waypoints) {
288
181
  jogFound = false;
289
182
  for (let i = 0; i < pts.length - 3; i++) {
290
183
  const a = pts[i], b = pts[i + 1], c = pts[i + 2], d = pts[i + 3];
291
- // Small vertical jog
184
+ // Small vertical jog: A→B horizontal, B→C vertical (short), C→D horizontal
292
185
  const jogH = Math.abs(b[1] - c[1]);
293
186
  if (Math.abs(a[1] - b[1]) < 0.5 &&
294
187
  Math.abs(b[0] - c[0]) < 0.5 &&
295
188
  Math.abs(c[1] - d[1]) < 0.5 &&
296
- jogH > 0.5 && jogH < JOG_THRESHOLD) {
297
- const jogMid = (b[1] + c[1]) / 2;
298
- const snapY = Math.abs(a[1] - jogMid) <= Math.abs(d[1] - jogMid) ? a[1] : d[1];
299
- const newPts = pts.slice();
300
- newPts[i + 1] = [b[0], snapY];
301
- newPts[i + 2] = [c[0], snapY];
302
- pts = newPts;
303
- jogFound = true;
304
- break;
189
+ jogH > 0.5 &&
190
+ jogH < JOG_THRESHOLD) {
191
+ // Only collapse if A and D share the same Y (internal jog).
192
+ if (Math.abs(a[1] - d[1]) < 0.5) {
193
+ const snapY = a[1];
194
+ const newPts = pts.slice();
195
+ newPts[i + 1] = [b[0], snapY];
196
+ newPts[i + 2] = [c[0], snapY];
197
+ pts = newPts;
198
+ jogFound = true;
199
+ break;
200
+ }
305
201
  }
306
- // Small horizontal jog
202
+ // Small horizontal jog: A→B vertical, B→C horizontal (short), C→D vertical
307
203
  const jogW = Math.abs(b[0] - c[0]);
308
204
  if (Math.abs(a[0] - b[0]) < 0.5 &&
309
205
  Math.abs(b[1] - c[1]) < 0.5 &&
310
206
  Math.abs(c[0] - d[0]) < 0.5 &&
311
- jogW > 0.5 && jogW < JOG_THRESHOLD) {
312
- const jogMid = (b[0] + c[0]) / 2;
313
- const snapX = Math.abs(a[0] - jogMid) <= Math.abs(d[0] - jogMid) ? a[0] : d[0];
314
- const newPts = pts.slice();
315
- newPts[i + 1] = [snapX, b[1]];
316
- newPts[i + 2] = [snapX, c[1]];
317
- pts = newPts;
318
- jogFound = true;
319
- break;
207
+ jogW > 0.5 &&
208
+ jogW < JOG_THRESHOLD) {
209
+ if (Math.abs(a[0] - d[0]) < 0.5) {
210
+ const snapX = a[0];
211
+ const newPts = pts.slice();
212
+ newPts[i + 1] = [snapX, b[1]];
213
+ newPts[i + 2] = [snapX, c[1]];
214
+ pts = newPts;
215
+ jogFound = true;
216
+ break;
217
+ }
320
218
  }
321
219
  }
322
220
  }
@@ -326,9 +224,14 @@ function simplifyWaypoints(waypoints) {
326
224
  const prev = result[result.length - 1];
327
225
  const curr = pts[i];
328
226
  const next = pts[i + 1];
227
+ // Skip near-duplicate points; but only if removing won't create a diagonal
329
228
  const distToPrev = Math.abs(prev[0] - curr[0]) + Math.abs(prev[1] - curr[1]);
330
- if (distToPrev < MIN_SEGMENT_LENGTH)
331
- continue;
229
+ if (distToPrev < MIN_SEGMENT_LENGTH) {
230
+ const wouldDiag = Math.abs(prev[0] - next[0]) > 0.5 && Math.abs(prev[1] - next[1]) > 0.5;
231
+ if (!wouldDiag)
232
+ continue;
233
+ }
234
+ // Skip collinear points (three points on the same axis)
332
235
  const sameX = Math.abs(prev[0] - curr[0]) < 0.01 && Math.abs(curr[0] - next[0]) < 0.01;
333
236
  const sameY = Math.abs(prev[1] - curr[1]) < 0.01 && Math.abs(curr[1] - next[1]) < 0.01;
334
237
  if (!sameX && !sameY) {
@@ -345,7 +248,8 @@ function waypointsToSvgPath(waypoints, cornerRadius) {
345
248
  if (waypoints.length === 2) {
346
249
  return `M ${waypoints[0][0]},${waypoints[0][1]} L ${waypoints[1][0]},${waypoints[1][1]}`;
347
250
  }
348
- // Pre-compute arc radii so adjacent corners sharing a segment never overlap
251
+ // Pre-compute arc radii so that two adjacent corners sharing a segment
252
+ // never consume more than the segment length combined.
349
253
  const radii = new Array(waypoints.length).fill(0);
350
254
  for (let i = 1; i < waypoints.length - 1; i++) {
351
255
  const prev = waypoints[i - 1];
@@ -356,7 +260,6 @@ function waypointsToSvgPath(waypoints, cornerRadius) {
356
260
  radii[i] =
357
261
  lenPrev < 0.01 || lenNext < 0.01 ? 0 : Math.min(cornerRadius, lenPrev / 2, lenNext / 2);
358
262
  }
359
- // Shrink radii so two corners sharing a segment together consume at most the full segment length
360
263
  for (let i = 1; i < waypoints.length - 2; i++) {
361
264
  const curr = waypoints[i];
362
265
  const next = waypoints[i + 1];
@@ -374,7 +277,7 @@ function waypointsToSvgPath(waypoints, cornerRadius) {
374
277
  const curr = waypoints[i];
375
278
  const next = waypoints[i + 1];
376
279
  const r = radii[i];
377
- if (r < 2) {
280
+ if (r < 0.01) {
378
281
  path += ` L ${curr[0]},${curr[1]}`;
379
282
  continue;
380
283
  }
@@ -384,30 +287,38 @@ function waypointsToSvgPath(waypoints, cornerRadius) {
384
287
  const lenNext = Math.sqrt(dNext[0] * dNext[0] + dNext[1] * dNext[1]);
385
288
  const uPrev = [dPrev[0] / lenPrev, dPrev[1] / lenPrev];
386
289
  const uNext = [dNext[0] / lenNext, dNext[1] / lenNext];
387
- const arcStart = [curr[0] + uPrev[0] * r, curr[1] + uPrev[1] * r];
388
- const arcEnd = [curr[0] + uNext[0] * r, curr[1] + uNext[1] * r];
389
- const cross = dPrev[0] * dNext[1] - dPrev[1] * dNext[0];
290
+ const cross = uPrev[0] * uNext[1] - uPrev[1] * uNext[0];
291
+ const deflection = Math.abs(cross);
292
+ const scaledR = r * deflection;
293
+ if (scaledR < 0.5) {
294
+ path += ` L ${curr[0]},${curr[1]}`;
295
+ continue;
296
+ }
297
+ const arcStart = [curr[0] + uPrev[0] * scaledR, curr[1] + uPrev[1] * scaledR];
298
+ const arcEnd = [curr[0] + uNext[0] * scaledR, curr[1] + uNext[1] * scaledR];
390
299
  const sweep = cross > 0 ? 0 : 1;
391
300
  path += ` L ${arcStart[0]},${arcStart[1]}`;
392
- path += ` A ${r} ${r} 0 0 ${sweep} ${arcEnd[0]},${arcEnd[1]}`;
301
+ path += ` A ${scaledR} ${scaledR} 0 0 ${sweep} ${arcEnd[0]},${arcEnd[1]}`;
393
302
  }
394
303
  const last = waypoints[waypoints.length - 1];
395
304
  path += ` L ${last[0]},${last[1]}`;
396
305
  return path;
397
306
  }
398
307
  // ─── Waypoint computation ───
399
- function computeWaypoints(from, to, nodeBoxes, sourceNodeId, targetNodeId, padding, exitStub, entryStub, allocator) {
400
- const isSelfConnection = sourceNodeId === targetNodeId;
401
- const inflatedBoxes = nodeBoxes
402
- .filter((box) => (isSelfConnection ? true : box.id !== sourceNodeId && box.id !== targetNodeId))
403
- .map((box) => inflateBox(box, padding));
308
+ function computeWaypoints(from, to, nodeBoxes, sourceNodeId, targetNodeId, padding, exitStub, entryStub, scopedInternal, allocator) {
309
+ const isSelfConnection = sourceNodeId === targetNodeId && !scopedInternal;
310
+ const inflatedBoxes = nodeBoxes.map((box) => inflateBox(box, padding));
404
311
  const stubExit = [from[0] + exitStub, from[1]];
405
312
  const stubEntry = [to[0] - entryStub, to[1]];
406
313
  const xMin = Math.min(stubExit[0], stubEntry[0]);
407
314
  const xMax = Math.max(stubExit[0], stubEntry[0]);
408
- if (!isSelfConnection && to[0] > from[0]) {
409
- // Forward connection
410
- let candidateY = (from[1] + to[1]) / 2;
315
+ if (!isSelfConnection && to[0] >= from[0] - exitStub) {
316
+ // Straight connection: if ports share the same Y and the direct path is clear
317
+ // of non-source/target obstacles, return a direct 2-point line.
318
+ // Static SVG: skip the straight-line early return. Opaque port labels cover
319
+ // flat connections. All paths go through BELOW_BIAS routing so they stay visible.
320
+ // (Platform uses [from, to] here because labels are interactive/hideable.)
321
+ let candidateY = (from[1] + to[1]) / 2 + BELOW_BIAS;
411
322
  const intermediateBoxes = inflatedBoxes.filter((box) => box.left < xMax && box.right > xMin);
412
323
  if (intermediateBoxes.length >= 2) {
413
324
  const clusterTop = Math.min(...intermediateBoxes.map((b) => b.top));
@@ -415,79 +326,67 @@ function computeWaypoints(from, to, nodeBoxes, sourceNodeId, targetNodeId, paddi
415
326
  if (candidateY > clusterTop && candidateY < clusterBottom) {
416
327
  const distToTop = candidateY - clusterTop;
417
328
  const distToBottom = clusterBottom - candidateY;
418
- candidateY = distToTop <= distToBottom
419
- ? clusterTop - padding
420
- : clusterBottom + padding;
329
+ candidateY = distToTop < distToBottom ? clusterTop - padding : clusterBottom + padding;
421
330
  }
422
331
  }
423
- let clearY = findClearY(xMin, xMax, candidateY, inflatedBoxes);
424
- if (Math.abs(from[1] - to[1]) < JOG_THRESHOLD && Math.abs(clearY - from[1]) < JOG_THRESHOLD) {
425
- return null; // Fall back to bezier
426
- }
427
- // Try center-corner: single vertical turn at the midpoint
428
- const midX = (stubExit[0] + stubEntry[0]) / 2;
332
+ const clearY = allocator
333
+ ? allocator.claim(xMin, xMax, findClearY(xMin, xMax, candidateY, inflatedBoxes))
334
+ : findClearY(xMin, xMax, candidateY, inflatedBoxes);
429
335
  const yMin = Math.min(from[1], to[1]);
430
336
  const yMax = Math.max(from[1], to[1]);
431
- const clearMidX = findClearX(yMin, yMax, midX, inflatedBoxes);
432
- const freeMidX = allocator.findFreeX(yMin, yMax, clearMidX, inflatedBoxes);
433
- if (yMax - yMin >= JOG_THRESHOLD &&
434
- freeMidX > stubExit[0] &&
435
- freeMidX < stubEntry[0] &&
337
+ const stubsCross = stubExit[0] >= stubEntry[0];
338
+ const midX = stubsCross
339
+ ? (from[0] + to[0]) / 2
340
+ : stubExit[0] + (stubEntry[0] - stubExit[0]) * 0.75;
341
+ const freeMidX = findClearX(yMin, yMax || yMin + 1, midX, inflatedBoxes);
342
+ const lShapeXValid = stubsCross
343
+ ? freeMidX >= Math.min(from[0], to[0]) && freeMidX <= Math.max(from[0], to[0])
344
+ : freeMidX > stubExit[0] && freeMidX < stubEntry[0];
345
+ if (lShapeXValid &&
436
346
  verticalSegmentClear(freeMidX, yMin, yMax, inflatedBoxes) &&
437
- allocator.findFreeY(from[0], freeMidX, from[1], inflatedBoxes) === from[1] &&
438
- allocator.findFreeY(freeMidX, to[0], to[1], inflatedBoxes) === to[1]) {
439
- allocator.claim(from[0], freeMidX, from[1]);
440
- allocator.claim(freeMidX, to[0], to[1]);
441
- allocator.claimVertical(yMin, yMax, freeMidX);
347
+ !inflatedBoxes.some((box) => segmentOverlapsBox(stubExit[0], freeMidX, from[1], box)) &&
348
+ !inflatedBoxes.some((box) => segmentOverlapsBox(freeMidX, stubEntry[0], to[1], box))) {
442
349
  return simplifyWaypoints([from, [freeMidX, from[1]], [freeMidX, to[1]], to]);
443
350
  }
444
- // S-shape with horizontal channel
445
- clearY = allocator.findFreeY(xMin, xMax, clearY, inflatedBoxes);
446
351
  if (Math.abs(clearY - from[1]) < JOG_THRESHOLD &&
447
352
  !inflatedBoxes.some((box) => segmentOverlapsBox(xMin, xMax, from[1], box))) {
448
- clearY = from[1];
353
+ candidateY = from[1];
449
354
  }
450
355
  else if (Math.abs(clearY - to[1]) < JOG_THRESHOLD &&
451
356
  !inflatedBoxes.some((box) => segmentOverlapsBox(xMin, xMax, to[1], box))) {
452
- clearY = to[1];
357
+ candidateY = to[1];
358
+ }
359
+ else {
360
+ candidateY = clearY;
453
361
  }
454
- allocator.claim(xMin, xMax, clearY);
455
- // Validate vertical segments
456
- const exitYMin = Math.min(from[1], clearY);
457
- const exitYMax = Math.max(from[1], clearY);
362
+ const exitYMin = Math.min(from[1], candidateY);
363
+ const exitYMax = Math.max(from[1], candidateY);
458
364
  let exitX = findClearX(exitYMin, exitYMax, stubExit[0], inflatedBoxes);
459
- exitX = allocator.findFreeX(exitYMin, exitYMax, exitX, inflatedBoxes);
460
365
  if (exitX < from[0]) {
461
366
  exitX = stubExit[0];
462
367
  if (!verticalSegmentClear(exitX, exitYMin, exitYMax, inflatedBoxes)) {
463
368
  exitX = findClearX(exitYMin, exitYMax, stubExit[0] + TRACK_SPACING, inflatedBoxes);
464
- exitX = allocator.findFreeX(exitYMin, exitYMax, exitX, inflatedBoxes);
465
369
  }
466
370
  }
467
- allocator.claimVertical(exitYMin, exitYMax, exitX);
468
- const entryYMin = Math.min(to[1], clearY);
469
- const entryYMax = Math.max(to[1], clearY);
371
+ const entryYMin = Math.min(to[1], candidateY);
372
+ const entryYMax = Math.max(to[1], candidateY);
470
373
  let entryX = findClearX(entryYMin, entryYMax, stubEntry[0], inflatedBoxes);
471
- entryX = allocator.findFreeX(entryYMin, entryYMax, entryX, inflatedBoxes);
472
374
  if (entryX > to[0]) {
473
375
  entryX = stubEntry[0];
474
376
  if (!verticalSegmentClear(entryX, entryYMin, entryYMax, inflatedBoxes)) {
475
377
  entryX = findClearX(entryYMin, entryYMax, stubEntry[0] - TRACK_SPACING, inflatedBoxes);
476
- entryX = allocator.findFreeX(entryYMin, entryYMax, entryX, inflatedBoxes);
477
378
  }
478
379
  }
479
- allocator.claimVertical(entryYMin, entryYMax, entryX);
480
380
  return simplifyWaypoints([
481
381
  from,
482
382
  [exitX, from[1]],
483
- [exitX, clearY],
484
- [entryX, clearY],
383
+ [exitX, candidateY],
384
+ [entryX, candidateY],
485
385
  [entryX, to[1]],
486
386
  to,
487
387
  ]);
488
388
  }
489
389
  else {
490
- // Backward connection or self-connection — escape vertically, loop around
491
390
  const sourceBox = nodeBoxes.find((b) => b.id === sourceNodeId);
492
391
  const targetBox = nodeBoxes.find((b) => b.id === targetNodeId);
493
392
  const corridorBoxes = inflatedBoxes.filter((box) => box.left < xMax && box.right > xMin);
@@ -503,23 +402,19 @@ function computeWaypoints(from, to, nodeBoxes, sourceNodeId, targetNodeId, paddi
503
402
  }
504
403
  const maxBottom = Math.max(...bottoms, from[1] + 50, to[1] + 50);
505
404
  const minTop = Math.min(...tops, from[1] - 50, to[1] - 50);
506
- const avgY = (from[1] + to[1]) / 2;
405
+ const avgY = (from[1] + to[1]) / 2 + BELOW_BIAS;
507
406
  const escapeBelow = maxBottom + padding;
508
407
  const escapeAbove = minTop - padding;
509
- let escapeY = Math.abs(escapeAbove - avgY) <= Math.abs(escapeBelow - avgY) ? escapeAbove : escapeBelow;
510
- escapeY = findClearY(xMin, xMax, escapeY, inflatedBoxes);
511
- escapeY = allocator.findFreeY(xMin, xMax, escapeY, inflatedBoxes);
512
- allocator.claim(xMin, xMax, escapeY);
408
+ let escapeY = Math.abs(escapeAbove - avgY) < Math.abs(escapeBelow - avgY) ? escapeAbove : escapeBelow;
409
+ escapeY = allocator
410
+ ? allocator.claim(xMin, xMax, findClearY(xMin, xMax, escapeY, inflatedBoxes))
411
+ : findClearY(xMin, xMax, escapeY, inflatedBoxes);
513
412
  const bwExitYMin = Math.min(from[1], escapeY);
514
413
  const bwExitYMax = Math.max(from[1], escapeY);
515
- let bwExitX = findClearX(bwExitYMin, bwExitYMax, stubExit[0], inflatedBoxes);
516
- bwExitX = allocator.findFreeX(bwExitYMin, bwExitYMax, bwExitX, inflatedBoxes);
517
- allocator.claimVertical(bwExitYMin, bwExitYMax, bwExitX);
414
+ const bwExitX = findClearX(bwExitYMin, bwExitYMax, stubExit[0], inflatedBoxes);
518
415
  const bwEntryYMin = Math.min(to[1], escapeY);
519
416
  const bwEntryYMax = Math.max(to[1], escapeY);
520
- let bwEntryX = findClearX(bwEntryYMin, bwEntryYMax, stubEntry[0], inflatedBoxes);
521
- bwEntryX = allocator.findFreeX(bwEntryYMin, bwEntryYMax, bwEntryX, inflatedBoxes);
522
- allocator.claimVertical(bwEntryYMin, bwEntryYMax, bwEntryX);
417
+ const bwEntryX = findClearX(bwEntryYMin, bwEntryYMax, stubEntry[0], inflatedBoxes);
523
418
  return simplifyWaypoints([
524
419
  from,
525
420
  [bwExitX, from[1]],
@@ -531,11 +426,7 @@ function computeWaypoints(from, to, nodeBoxes, sourceNodeId, targetNodeId, paddi
531
426
  }
532
427
  }
533
428
  // ─── Public API ───
534
- /**
535
- * Calculate an orthogonal SVG path between two ports.
536
- * Returns null if the path should fall back to bezier (e.g. nearly aligned ports).
537
- */
538
- export function calculateOrthogonalPath(from, to, nodeBoxes, sourceNodeId, targetNodeId, options, allocator) {
429
+ export function calculateOrthogonalPath(from, to, nodeBoxes, sourceNodeId, targetNodeId, options) {
539
430
  const cornerRadius = options?.cornerRadius ?? 10;
540
431
  const padding = options?.padding ?? 15;
541
432
  const stubLength = options?.stubLength ?? 20;
@@ -545,18 +436,17 @@ export function calculateOrthogonalPath(from, to, nodeBoxes, sourceNodeId, targe
545
436
  const toPortIndex = options?.toPortIndex ?? 0;
546
437
  const exitStub = Math.min(stubLength + fromPortIndex * stubSpacing, maxStubLength);
547
438
  const entryStub = Math.min(stubLength + toPortIndex * stubSpacing, maxStubLength);
548
- const alloc = allocator ?? new TrackAllocator();
549
- const waypoints = computeWaypoints(from, to, nodeBoxes, sourceNodeId, targetNodeId, padding, exitStub, entryStub, alloc);
439
+ const waypoints = computeWaypoints(from, to, nodeBoxes, sourceNodeId, targetNodeId, padding, exitStub, entryStub, options?.scopedInternal, options?.allocator);
550
440
  if (!waypoints)
551
441
  return null;
552
442
  return waypointsToSvgPath(waypoints, cornerRadius);
553
443
  }
554
444
  /**
555
- * Safe wrapper returns null if routing fails, caller falls back to bezier.
445
+ * Safe wrapper: returns null if routing fails, caller falls back to straight line.
556
446
  */
557
- export function calculateOrthogonalPathSafe(from, to, nodeBoxes, sourceNodeId, targetNodeId, options, allocator) {
447
+ export function calculateOrthogonalPathSafe(from, to, nodeBoxes, sourceNodeId, targetNodeId, options) {
558
448
  try {
559
- const path = calculateOrthogonalPath(from, to, nodeBoxes, sourceNodeId, targetNodeId, options, allocator);
449
+ const path = calculateOrthogonalPath(from, to, nodeBoxes, sourceNodeId, targetNodeId, options);
560
450
  if (!path || path.length < 5)
561
451
  return null;
562
452
  return path;