@synergenius/flow-weaver 0.33.0 → 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.
- package/README.md +1 -0
- package/dist/cli/commands/diagram.js +1 -1
- package/dist/cli/flow-weaver.mjs +464 -413
- package/dist/diagram/geometry.d.ts +7 -2
- package/dist/diagram/geometry.js +15 -56
- package/dist/diagram/html-viewer.js +98 -42
- package/dist/diagram/orthogonal-router.d.ts +16 -51
- package/dist/diagram/orthogonal-router.js +138 -248
- package/dist/diagram/renderer.js +79 -65
- package/dist/diagram/theme.d.ts +2 -0
- package/dist/diagram/theme.js +17 -17
- package/dist/generated-version.d.ts +1 -1
- package/dist/generated-version.js +1 -1
- package/dist/mcp/server.js +2 -0
- package/dist/mcp/tools-workflow-run.d.ts +40 -0
- package/dist/mcp/tools-workflow-run.js +150 -0
- package/package.json +1 -1
|
@@ -1,163 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Orthogonal connection router for SVG diagram rendering.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
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
|
|
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)
|
|
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 &&
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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 &&
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
|
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 <
|
|
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
|
|
388
|
-
const
|
|
389
|
-
const
|
|
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 ${
|
|
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]
|
|
409
|
-
//
|
|
410
|
-
|
|
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
|
|
419
|
-
? clusterTop - padding
|
|
420
|
-
: clusterBottom + padding;
|
|
329
|
+
candidateY = distToTop < distToBottom ? clusterTop - padding : clusterBottom + padding;
|
|
421
330
|
}
|
|
422
331
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
357
|
+
candidateY = to[1];
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
candidateY = clearY;
|
|
453
361
|
}
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
468
|
-
const
|
|
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,
|
|
484
|
-
[entryX,
|
|
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)
|
|
510
|
-
escapeY =
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
447
|
+
export function calculateOrthogonalPathSafe(from, to, nodeBoxes, sourceNodeId, targetNodeId, options) {
|
|
558
448
|
try {
|
|
559
|
-
const path = calculateOrthogonalPath(from, to, nodeBoxes, sourceNodeId, targetNodeId, options
|
|
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;
|