@robotaccomplice/architext 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1484 @@
1
+ const SIDES = ["left", "right", "top", "bottom"];
2
+ const PORT_STUB = 18;
3
+ const PORT_SPACING = 6;
4
+ const HOP_RADIUS = 6;
5
+ const CORRIDOR_PADDING = 10;
6
+ const RAW_ROUTE_CACHE_LIMIT = 12;
7
+ const rawRouteCache = new Map();
8
+
9
+ export function anchorFor(rect, side) {
10
+ if (side === "left") return { x: rect.x, y: rect.y + rect.height / 2 };
11
+ if (side === "right") return { x: rect.x + rect.width, y: rect.y + rect.height / 2 };
12
+ if (side === "top") return { x: rect.x + rect.width / 2, y: rect.y };
13
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height };
14
+ }
15
+
16
+ export function sideVector(side) {
17
+ if (side === "left") return { x: -1, y: 0 };
18
+ if (side === "right") return { x: 1, y: 0 };
19
+ if (side === "top") return { x: 0, y: -1 };
20
+ return { x: 0, y: 1 };
21
+ }
22
+
23
+ function tangentVector(side) {
24
+ return side === "left" || side === "right"
25
+ ? { x: 0, y: 1 }
26
+ : { x: 1, y: 0 };
27
+ }
28
+
29
+ function clamp(value, min, max) {
30
+ return Math.min(max, Math.max(min, value));
31
+ }
32
+
33
+ function offsetForEndpointOrder(order) {
34
+ const lane = order % 7;
35
+ const band = Math.floor(order / 7);
36
+ return (lane - 3) * PORT_SPACING + band * PORT_SPACING * 7;
37
+ }
38
+
39
+ function portFor(rect, side, distance = PORT_STUB, rawOffset = 0) {
40
+ const anchor = anchorFor(rect, side);
41
+ const vector = sideVector(side);
42
+ const maxOffset = (side === "left" || side === "right" ? rect.height : rect.width) / 2 - 8;
43
+ const offset = clamp(rawOffset, -maxOffset, maxOffset);
44
+ const tangent = tangentVector(side);
45
+ const offsetAnchor = {
46
+ x: anchor.x + tangent.x * offset,
47
+ y: anchor.y + tangent.y * offset
48
+ };
49
+ return {
50
+ anchor: offsetAnchor,
51
+ port: {
52
+ x: offsetAnchor.x + vector.x * distance,
53
+ y: offsetAnchor.y + vector.y * distance
54
+ }
55
+ };
56
+ }
57
+
58
+ function portCandidatesFor(rect, side, offsets) {
59
+ const maxOffset = (side === "left" || side === "right" ? rect.height : rect.width) / 2 - 8;
60
+ return [...new Set(offsets.map((offset) => Math.round(clamp(offset, -maxOffset, maxOffset))))].map((offset) => portFor(rect, side, PORT_STUB, offset));
61
+ }
62
+
63
+ export function distanceToRect(point, rect) {
64
+ const dx = Math.max(rect.x - point.x, 0, point.x - (rect.x + rect.width));
65
+ const dy = Math.max(rect.y - point.y, 0, point.y - (rect.y + rect.height));
66
+ return Math.hypot(dx, dy);
67
+ }
68
+
69
+ function distanceToRectSquared(point, rect) {
70
+ const dx = Math.max(rect.x - point.x, 0, point.x - (rect.x + rect.width));
71
+ const dy = Math.max(rect.y - point.y, 0, point.y - (rect.y + rect.height));
72
+ return dx * dx + dy * dy;
73
+ }
74
+
75
+ export function lineSamples(points) {
76
+ const samples = [];
77
+ for (let index = 0; index < points.length - 1; index += 1) {
78
+ const start = points[index];
79
+ const end = points[index + 1];
80
+ for (let step = 1; step <= 10; step += 1) {
81
+ const t = step / 10;
82
+ samples.push({
83
+ x: start.x + (end.x - start.x) * t,
84
+ y: start.y + (end.y - start.y) * t
85
+ });
86
+ }
87
+ }
88
+ return samples;
89
+ }
90
+
91
+ function sampleLine(start, end, steps = 10) {
92
+ const samples = [];
93
+ for (let step = 1; step <= steps; step += 1) {
94
+ const t = step / steps;
95
+ samples.push({
96
+ x: start.x + (end.x - start.x) * t,
97
+ y: start.y + (end.y - start.y) * t
98
+ });
99
+ }
100
+ return samples;
101
+ }
102
+
103
+ function quadraticPoint(start, control, end, t) {
104
+ const inverse = 1 - t;
105
+ return {
106
+ x: inverse ** 2 * start.x + 2 * inverse * t * control.x + t ** 2 * end.x,
107
+ y: inverse ** 2 * start.y + 2 * inverse * t * control.y + t ** 2 * end.y
108
+ };
109
+ }
110
+
111
+ function sampleQuadratic(start, control, end, steps = 12) {
112
+ const samples = [];
113
+ for (let step = 1; step <= steps; step += 1) {
114
+ samples.push(quadraticPoint(start, control, end, step / steps));
115
+ }
116
+ return samples;
117
+ }
118
+
119
+ export function nearestSample(samples, target) {
120
+ return samples.reduce((nearest, sample) => {
121
+ const nearestDistance = Math.hypot(nearest.x - target.x, nearest.y - target.y);
122
+ const sampleDistance = Math.hypot(sample.x - target.x, sample.y - target.y);
123
+ return sampleDistance < nearestDistance ? sample : nearest;
124
+ }, samples[0] ?? target);
125
+ }
126
+
127
+ export function routeIntersectsRect(route, rect, padding = 0) {
128
+ if (route.sampleBounds && !rectsOverlap(route.sampleBounds, rect, padding)) return false;
129
+ if (route.style === "orthogonal" && route.points) {
130
+ for (let index = 0; index < route.points.length - 1; index += 1) {
131
+ if (segmentIntersectsRect(route.points[index], route.points[index + 1], rect, padding)) return true;
132
+ }
133
+ return false;
134
+ }
135
+ return route.samples.some((point) =>
136
+ point.x > rect.x - padding &&
137
+ point.x < rect.x + rect.width + padding &&
138
+ point.y > rect.y - padding &&
139
+ point.y < rect.y + rect.height + padding
140
+ );
141
+ }
142
+
143
+ function rectDistance(a, b) {
144
+ const dx = Math.max(a.x - (b.x + b.width), b.x - (a.x + a.width), 0);
145
+ const dy = Math.max(a.y - (b.y + b.height), b.y - (a.y + a.height), 0);
146
+ return Math.hypot(dx, dy);
147
+ }
148
+
149
+ function rectsOverlap(a, b, padding = 0) {
150
+ return (
151
+ a.x < b.x + b.width + padding &&
152
+ a.x + a.width > b.x - padding &&
153
+ a.y < b.y + b.height + padding &&
154
+ a.y + a.height > b.y - padding
155
+ );
156
+ }
157
+
158
+ function boundsForPoints(points) {
159
+ if (!points.length) return { x: 0, y: 0, width: 0, height: 0 };
160
+ let minX = points[0].x;
161
+ let maxX = points[0].x;
162
+ let minY = points[0].y;
163
+ let maxY = points[0].y;
164
+ for (let index = 1; index < points.length; index += 1) {
165
+ const point = points[index];
166
+ minX = Math.min(minX, point.x);
167
+ maxX = Math.max(maxX, point.x);
168
+ minY = Math.min(minY, point.y);
169
+ maxY = Math.max(maxY, point.y);
170
+ }
171
+ return {
172
+ x: minX,
173
+ y: minY,
174
+ width: maxX - minX,
175
+ height: maxY - minY
176
+ };
177
+ }
178
+
179
+ function estimatedLabelBox(labelPoint, relationship) {
180
+ if (!relationship) return null;
181
+ const text = relationship.label ?? relationship.id ?? "";
182
+ const width = Math.max(24, Math.min(180, text.length * 6 + 12));
183
+ const height = relationship.relationshipType === "flow" || relationship.stepId ? 24 : 18;
184
+ return {
185
+ x: labelPoint.x - width / 2,
186
+ y: labelPoint.y - height / 2,
187
+ width,
188
+ height
189
+ };
190
+ }
191
+
192
+ function uniqueRounded(values) {
193
+ return [...new Set(values.map((value) => Math.round(value)))];
194
+ }
195
+
196
+ function createMinHeap() {
197
+ const values = [];
198
+ const swap = (left, right) => {
199
+ const value = values[left];
200
+ values[left] = values[right];
201
+ values[right] = value;
202
+ };
203
+ const push = (item) => {
204
+ values.push(item);
205
+ let index = values.length - 1;
206
+ while (index > 0) {
207
+ const parent = Math.floor((index - 1) / 2);
208
+ if (values[parent].distance <= values[index].distance) break;
209
+ swap(parent, index);
210
+ index = parent;
211
+ }
212
+ };
213
+ const pop = () => {
214
+ if (values.length === 0) return null;
215
+ const root = values[0];
216
+ const last = values.pop();
217
+ if (values.length > 0) {
218
+ values[0] = last;
219
+ let index = 0;
220
+ while (true) {
221
+ const left = index * 2 + 1;
222
+ const right = left + 1;
223
+ let smallest = index;
224
+ if (left < values.length && values[left].distance < values[smallest].distance) smallest = left;
225
+ if (right < values.length && values[right].distance < values[smallest].distance) smallest = right;
226
+ if (smallest === index) break;
227
+ swap(index, smallest);
228
+ index = smallest;
229
+ }
230
+ }
231
+ return root;
232
+ };
233
+ return {
234
+ get size() {
235
+ return values.length;
236
+ },
237
+ push,
238
+ pop
239
+ };
240
+ }
241
+
242
+ function segmentIntersectsRect(start, end, rect, padding = 0) {
243
+ const left = rect.x - padding;
244
+ const right = rect.x + rect.width + padding;
245
+ const top = rect.y - padding;
246
+ const bottom = rect.y + rect.height + padding;
247
+ const minX = Math.min(start.x, end.x);
248
+ const maxX = Math.max(start.x, end.x);
249
+ const minY = Math.min(start.y, end.y);
250
+ const maxY = Math.max(start.y, end.y);
251
+
252
+ if (start.y === end.y) {
253
+ return start.y > top && start.y < bottom && maxX > left && minX < right;
254
+ }
255
+ if (start.x === end.x) {
256
+ return start.x > left && start.x < right && maxY > top && minY < bottom;
257
+ }
258
+ return false;
259
+ }
260
+
261
+ function horizontalVerticalIntersection(horizontalStart, horizontalEnd, verticalStart, verticalEnd) {
262
+ const minX = Math.min(horizontalStart.x, horizontalEnd.x);
263
+ const maxX = Math.max(horizontalStart.x, horizontalEnd.x);
264
+ const minY = Math.min(verticalStart.y, verticalEnd.y);
265
+ const maxY = Math.max(verticalStart.y, verticalEnd.y);
266
+ const x = verticalStart.x;
267
+ const y = horizontalStart.y;
268
+ if (x <= minX + HOP_RADIUS || x >= maxX - HOP_RADIUS || y <= minY + HOP_RADIUS || y >= maxY - HOP_RADIUS) {
269
+ return null;
270
+ }
271
+ return { x, y };
272
+ }
273
+
274
+ function orthogonalCrossings(points, previousRoutes) {
275
+ const crossings = new Map();
276
+ for (let index = 0; index < points.length - 1; index += 1) {
277
+ const start = points[index];
278
+ const end = points[index + 1];
279
+ if (start.x !== end.x && start.y !== end.y) continue;
280
+
281
+ for (const route of previousRoutes) {
282
+ for (let usedIndex = 0; usedIndex < route.points.length - 1; usedIndex += 1) {
283
+ const usedStart = route.points[usedIndex];
284
+ const usedEnd = route.points[usedIndex + 1];
285
+ if (usedStart.x !== usedEnd.x && usedStart.y !== usedEnd.y) continue;
286
+ if (start.y === end.y && usedStart.x === usedEnd.x) {
287
+ const crossing = horizontalVerticalIntersection(start, end, usedStart, usedEnd);
288
+ if (crossing) {
289
+ const direction = Math.sign(end.x - start.x);
290
+ crossings.set(index, [...(crossings.get(index) ?? []), { ...crossing, direction }]);
291
+ }
292
+ } else if (start.x === end.x && usedStart.y === usedEnd.y) {
293
+ const crossing = horizontalVerticalIntersection(usedStart, usedEnd, start, end);
294
+ if (crossing) {
295
+ const direction = Math.sign(end.y - start.y);
296
+ crossings.set(index, [...(crossings.get(index) ?? []), { ...crossing, direction }]);
297
+ }
298
+ }
299
+ }
300
+ }
301
+ }
302
+ return crossings;
303
+ }
304
+
305
+ function orthogonalCrossingStats(points, previousRoutes) {
306
+ const counts = new Map();
307
+ for (let index = 0; index < points.length - 1; index += 1) {
308
+ const start = points[index];
309
+ const end = points[index + 1];
310
+ if (start.x !== end.x && start.y !== end.y) continue;
311
+
312
+ previousRoutes.forEach((route, routeIndex) => {
313
+ for (let usedIndex = 0; usedIndex < route.points.length - 1; usedIndex += 1) {
314
+ const usedStart = route.points[usedIndex];
315
+ const usedEnd = route.points[usedIndex + 1];
316
+ if (usedStart.x !== usedEnd.x && usedStart.y !== usedEnd.y) continue;
317
+ const crossing = start.y === end.y && usedStart.x === usedEnd.x
318
+ ? horizontalVerticalIntersection(start, end, usedStart, usedEnd)
319
+ : start.x === end.x && usedStart.y === usedEnd.y
320
+ ? horizontalVerticalIntersection(usedStart, usedEnd, start, end)
321
+ : null;
322
+ if (crossing) counts.set(routeIndex, (counts.get(routeIndex) ?? 0) + 1);
323
+ }
324
+ });
325
+ }
326
+
327
+ const total = [...counts.values()].reduce((sum, count) => sum + count, 0);
328
+ const repeated = [...counts.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0);
329
+ return { total, repeated };
330
+ }
331
+
332
+ function createRouteIndex() {
333
+ const horizontal = [];
334
+ const vertical = [];
335
+ const startPoints = new Set();
336
+ const endPoints = new Set();
337
+
338
+ const add = (route, routeIndex) => {
339
+ if (!route?.points?.length) return;
340
+ startPoints.add(`${route.points[0].x},${route.points[0].y}`);
341
+ const last = route.points.at(-1);
342
+ endPoints.add(`${last.x},${last.y}`);
343
+ for (let index = 0; index < route.points.length - 1; index += 1) {
344
+ const start = route.points[index];
345
+ const end = route.points[index + 1];
346
+ if (start.y === end.y) {
347
+ horizontal.push({
348
+ routeIndex,
349
+ y: start.y,
350
+ minX: Math.min(start.x, end.x),
351
+ maxX: Math.max(start.x, end.x),
352
+ start,
353
+ end
354
+ });
355
+ } else if (start.x === end.x) {
356
+ vertical.push({
357
+ routeIndex,
358
+ x: start.x,
359
+ minY: Math.min(start.y, end.y),
360
+ maxY: Math.max(start.y, end.y),
361
+ start,
362
+ end
363
+ });
364
+ }
365
+ }
366
+ };
367
+
368
+ const crossingStats = (points) => {
369
+ const counts = new Map();
370
+ for (let index = 0; index < points.length - 1; index += 1) {
371
+ const start = points[index];
372
+ const end = points[index + 1];
373
+ if (start.x !== end.x && start.y !== end.y) continue;
374
+
375
+ if (start.y === end.y) {
376
+ const minX = Math.min(start.x, end.x);
377
+ const maxX = Math.max(start.x, end.x);
378
+ for (const segment of vertical) {
379
+ if (
380
+ segment.x > minX + HOP_RADIUS &&
381
+ segment.x < maxX - HOP_RADIUS &&
382
+ start.y > segment.minY + HOP_RADIUS &&
383
+ start.y < segment.maxY - HOP_RADIUS
384
+ ) {
385
+ counts.set(segment.routeIndex, (counts.get(segment.routeIndex) ?? 0) + 1);
386
+ }
387
+ }
388
+ } else {
389
+ const minY = Math.min(start.y, end.y);
390
+ const maxY = Math.max(start.y, end.y);
391
+ for (const segment of horizontal) {
392
+ if (
393
+ start.x > segment.minX + HOP_RADIUS &&
394
+ start.x < segment.maxX - HOP_RADIUS &&
395
+ segment.y > minY + HOP_RADIUS &&
396
+ segment.y < maxY - HOP_RADIUS
397
+ ) {
398
+ counts.set(segment.routeIndex, (counts.get(segment.routeIndex) ?? 0) + 1);
399
+ }
400
+ }
401
+ }
402
+ }
403
+
404
+ const total = [...counts.values()].reduce((sum, count) => sum + count, 0);
405
+ const repeated = [...counts.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0);
406
+ return { total, repeated };
407
+ };
408
+
409
+ const hasStackedEndpoint = (route) => {
410
+ if (!route?.points?.length) return false;
411
+ const start = route.points[0];
412
+ const end = route.points.at(-1);
413
+ return startPoints.has(`${start.x},${start.y}`) || endPoints.has(`${end.x},${end.y}`);
414
+ };
415
+
416
+ return { add, crossingStats, hasStackedEndpoint };
417
+ }
418
+
419
+ export function pathToSvgWithHops(points, previousRoutes) {
420
+ const crossings = orthogonalCrossings(points, previousRoutes);
421
+ if (crossings.size === 0) return pathToSvg(points);
422
+
423
+ const commands = [`M ${points[0].x} ${points[0].y}`];
424
+ for (let index = 0; index < points.length - 1; index += 1) {
425
+ const start = points[index];
426
+ const end = points[index + 1];
427
+ const segmentCrossings = (crossings.get(index) ?? []).sort((a, b) => (
428
+ start.x === end.x
429
+ ? Math.abs(a.y - start.y) - Math.abs(b.y - start.y)
430
+ : Math.abs(a.x - start.x) - Math.abs(b.x - start.x)
431
+ ));
432
+ for (const crossing of segmentCrossings) {
433
+ if (start.y === end.y) {
434
+ const before = { x: crossing.x - crossing.direction * HOP_RADIUS, y: crossing.y };
435
+ const after = { x: crossing.x + crossing.direction * HOP_RADIUS, y: crossing.y };
436
+ commands.push(`L ${before.x} ${before.y}`);
437
+ commands.push(`Q ${crossing.x} ${crossing.y - HOP_RADIUS * 1.6} ${after.x} ${after.y}`);
438
+ } else {
439
+ const before = { x: crossing.x, y: crossing.y - crossing.direction * HOP_RADIUS };
440
+ const after = { x: crossing.x, y: crossing.y + crossing.direction * HOP_RADIUS };
441
+ commands.push(`L ${before.x} ${before.y}`);
442
+ commands.push(`Q ${crossing.x + HOP_RADIUS * 1.6} ${crossing.y} ${after.x} ${after.y}`);
443
+ }
444
+ }
445
+ commands.push(`L ${end.x} ${end.y}`);
446
+ }
447
+ return commands.join(" ");
448
+ }
449
+
450
+ function simplifyOrthogonalPoints(points) {
451
+ const deduped = [];
452
+ for (const point of points) {
453
+ const previous = deduped[deduped.length - 1];
454
+ if (!previous || previous.x !== point.x || previous.y !== point.y) deduped.push(point);
455
+ }
456
+
457
+ const simplified = [];
458
+ for (let index = 0; index < deduped.length; index += 1) {
459
+ const point = deduped[index];
460
+ const previous = simplified[simplified.length - 1];
461
+ const beforePrevious = simplified[simplified.length - 2];
462
+ if (
463
+ index !== 2 &&
464
+ index !== deduped.length - 1 &&
465
+ previous &&
466
+ beforePrevious &&
467
+ ((beforePrevious.x === previous.x && previous.x === point.x) ||
468
+ (beforePrevious.y === previous.y && previous.y === point.y))
469
+ ) {
470
+ simplified[simplified.length - 1] = point;
471
+ } else {
472
+ simplified.push(point);
473
+ }
474
+ }
475
+ return simplified;
476
+ }
477
+
478
+ function pathToSvg(points) {
479
+ return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
480
+ }
481
+
482
+ function bendCount(points) {
483
+ let bends = 0;
484
+ for (let index = 1; index < points.length - 1; index += 1) {
485
+ const previous = points[index - 1];
486
+ const current = points[index];
487
+ const next = points[index + 1];
488
+ if (
489
+ (previous.x === current.x && current.x !== next.x) ||
490
+ (previous.y === current.y && current.y !== next.y)
491
+ ) {
492
+ bends += 1;
493
+ }
494
+ }
495
+ return bends;
496
+ }
497
+
498
+ function shallowJogCount(points) {
499
+ let count = 0;
500
+ for (let index = 1; index < points.length - 2; index += 1) {
501
+ const before = points[index - 1];
502
+ const start = points[index];
503
+ const end = points[index + 1];
504
+ const after = points[index + 2];
505
+ const middleLength = Math.hypot(end.x - start.x, end.y - start.y);
506
+ const horizontalJog = before.y === start.y && end.y === after.y && start.x === end.x;
507
+ const verticalJog = before.x === start.x && end.x === after.x && start.y === end.y;
508
+ if ((horizontalJog || verticalJog) && middleLength < 36) count += 1;
509
+ }
510
+ return count;
511
+ }
512
+
513
+ function routeLength(samples) {
514
+ let length = 0;
515
+ for (let index = 0; index < samples.length - 1; index += 1) {
516
+ length += Math.hypot(samples[index + 1].x - samples[index].x, samples[index + 1].y - samples[index].y);
517
+ }
518
+ return length;
519
+ }
520
+
521
+ function totalQualityCost(qualityCosts) {
522
+ return Object.values(qualityCosts).reduce((sum, value) => sum + value, 0);
523
+ }
524
+
525
+ function withQualityCosts(route, qualityCosts) {
526
+ const normalizedQualityCosts = {
527
+ lengthCost: 0,
528
+ boundaryCost: 0,
529
+ nodeClearanceCost: 0,
530
+ edgeProximityCost: 0,
531
+ labelNodeClearanceCost: 0,
532
+ pointCountCost: 0,
533
+ bendCost: 0,
534
+ doglegCost: 0,
535
+ perimeterFallbackCost: 0,
536
+ perimeterLengthCost: 0,
537
+ directnessReward: 0,
538
+ crossingCost: 0,
539
+ repeatedCrossingCost: 0,
540
+ monotonicBacktrackCost: 0,
541
+ fanOutDirectionCost: 0,
542
+ endpointStackCost: 0,
543
+ ...qualityCosts
544
+ };
545
+ return {
546
+ ...route,
547
+ qualityCosts: normalizedQualityCosts,
548
+ cost: totalQualityCost(normalizedQualityCosts)
549
+ };
550
+ }
551
+
552
+ function withReadableLabel(route) {
553
+ const length = routeLength(route.samples);
554
+ if (length >= 70) return route;
555
+
556
+ const start = route.points[0];
557
+ const isVertical = route.points.every((point) => point.x === start.x);
558
+ const isHorizontal = route.points.every((point) => point.y === start.y);
559
+ if (isVertical) {
560
+ return { ...route, labelX: route.labelX + 28 };
561
+ }
562
+ if (isHorizontal) {
563
+ return { ...route, labelY: route.labelY - 22 };
564
+ }
565
+ return route;
566
+ }
567
+
568
+ function monotonicBacktrackCost(points, fromRect, toRect) {
569
+ const fromCenter = {
570
+ x: fromRect.x + fromRect.width / 2,
571
+ y: fromRect.y + fromRect.height / 2
572
+ };
573
+ const toCenter = {
574
+ x: toRect.x + toRect.width / 2,
575
+ y: toRect.y + toRect.height / 2
576
+ };
577
+ const xDirection = Math.sign(toCenter.x - fromCenter.x);
578
+ const yDirection = Math.sign(toCenter.y - fromCenter.y);
579
+ let cost = 0;
580
+ for (let index = 0; index < points.length - 1; index += 1) {
581
+ const start = points[index];
582
+ const end = points[index + 1];
583
+ const dx = end.x - start.x;
584
+ const dy = end.y - start.y;
585
+ if (xDirection !== 0 && Math.sign(dx) === -xDirection) cost += Math.abs(dx) * 18;
586
+ if (yDirection !== 0 && Math.sign(dy) === -yDirection) cost += Math.abs(dy) * 18;
587
+ }
588
+ return cost;
589
+ }
590
+
591
+ function roundedOrthogonalRoute(points, radius = 14) {
592
+ if (points.length < 3) {
593
+ return { d: pathToSvg(points), samples: lineSamples(points) };
594
+ }
595
+
596
+ const commands = [`M ${points[0].x} ${points[0].y}`];
597
+ const samples = [];
598
+ let cursor = points[0];
599
+ for (let index = 1; index < points.length - 1; index += 1) {
600
+ const previous = points[index - 1];
601
+ const current = points[index];
602
+ const next = points[index + 1];
603
+ const incomingLength = Math.hypot(current.x - previous.x, current.y - previous.y);
604
+ const outgoingLength = Math.hypot(next.x - current.x, next.y - current.y);
605
+ const bendRadius = Math.min(radius, incomingLength / 2, outgoingLength / 2);
606
+
607
+ if (bendRadius <= 0 || incomingLength === 0 || outgoingLength === 0) {
608
+ commands.push(`L ${current.x} ${current.y}`);
609
+ samples.push(...sampleLine(cursor, current));
610
+ cursor = current;
611
+ continue;
612
+ }
613
+
614
+ const beforeBend = {
615
+ x: current.x - ((current.x - previous.x) / incomingLength) * bendRadius,
616
+ y: current.y - ((current.y - previous.y) / incomingLength) * bendRadius
617
+ };
618
+ const afterBend = {
619
+ x: current.x + ((next.x - current.x) / outgoingLength) * bendRadius,
620
+ y: current.y + ((next.y - current.y) / outgoingLength) * bendRadius
621
+ };
622
+
623
+ commands.push(`L ${beforeBend.x} ${beforeBend.y}`);
624
+ commands.push(`Q ${current.x} ${current.y} ${afterBend.x} ${afterBend.y}`);
625
+ samples.push(...sampleLine(cursor, beforeBend));
626
+ samples.push(...sampleQuadratic(beforeBend, current, afterBend));
627
+ cursor = afterBend;
628
+ }
629
+ const finalPoint = points[points.length - 1];
630
+ commands.push(`L ${finalPoint.x} ${finalPoint.y}`);
631
+ samples.push(...sampleLine(cursor, finalPoint));
632
+ return { d: commands.join(" "), samples };
633
+ }
634
+
635
+ function renderRoute(route, style, previousRoutes) {
636
+ if (style === "curved") {
637
+ const rendered = roundedOrthogonalRoute(route.points);
638
+ const label = nearestSample(rendered.samples, { x: route.labelX, y: route.labelY });
639
+ return withReadableLabel({ ...route, ...rendered, sampleBounds: boundsForPoints(rendered.samples), labelX: label.x, labelY: label.y, style });
640
+ }
641
+ return withReadableLabel({ ...route, d: pathToSvgWithHops(route.points, previousRoutes), sampleBounds: boundsForPoints(route.samples), style: "orthogonal" });
642
+ }
643
+
644
+ function mapEntries(map) {
645
+ return Array.from(map.entries()).sort(([left], [right]) => String(left).localeCompare(String(right)));
646
+ }
647
+
648
+ function routeCacheKey(input) {
649
+ return JSON.stringify({
650
+ relationships: input.relationships.map((relationship) => ({
651
+ id: relationship.id,
652
+ from: relationship.from,
653
+ to: relationship.to,
654
+ label: relationship.label,
655
+ relationshipType: relationship.relationshipType,
656
+ stepId: relationship.stepId,
657
+ flowId: relationship.flowId
658
+ })),
659
+ visibleNodeIds: Array.from(input.visibleNodeIds).sort(),
660
+ nodeRects: mapEntries(input.nodeRects),
661
+ laneIndexByNode: mapEntries(input.laneIndexByNode),
662
+ rowIndexByNode: mapEntries(input.rowIndexByNode),
663
+ canvasWidth: input.canvasWidth,
664
+ canvasHeight: input.canvasHeight,
665
+ marginY: input.marginY,
666
+ scoreEdgeProximity: Boolean(input.scoreEdgeProximity)
667
+ });
668
+ }
669
+
670
+ function getCachedRawRoutes(key) {
671
+ const cached = rawRouteCache.get(key);
672
+ if (!cached) return null;
673
+ rawRouteCache.delete(key);
674
+ rawRouteCache.set(key, cached);
675
+ return cached;
676
+ }
677
+
678
+ function setCachedRawRoutes(key, value) {
679
+ rawRouteCache.set(key, value);
680
+ while (rawRouteCache.size > RAW_ROUTE_CACHE_LIMIT) {
681
+ rawRouteCache.delete(rawRouteCache.keys().next().value);
682
+ }
683
+ }
684
+
685
+ function routePlannerContext(input) {
686
+ const visibleNodeIds = new Set(input.visibleNodeIds);
687
+ const rectFor = (nodeId) => input.nodeRects.get(nodeId);
688
+ const visibleRects = Array.from(visibleNodeIds).map(rectFor).filter(Boolean);
689
+ const blockerCache = new Map();
690
+ const stats = input.stats ?? null;
691
+ const blockerRects = (fromId, toId) => {
692
+ const key = `${fromId}\u0000${toId}`;
693
+ const cached = blockerCache.get(key);
694
+ if (cached) return cached;
695
+ const blockers = Array.from(visibleNodeIds)
696
+ .filter((nodeId) => nodeId !== fromId && nodeId !== toId)
697
+ .map(rectFor)
698
+ .filter(Boolean);
699
+ blockerCache.set(key, blockers);
700
+ return blockers;
701
+ };
702
+
703
+ const routeQualityFromSamples = (samples, label, fromId, toId, usedRoutes, relationship) => {
704
+ const blockers = blockerRects(fromId, toId);
705
+ const sampleBounds = boundsForPoints(samples);
706
+ const sampleBlockers = blockers.filter((rect) => rectsOverlap(sampleBounds, rect, 30));
707
+ const labelBox = estimatedLabelBox(label, relationship);
708
+ const labelBlockers = blockers.filter((rect) => {
709
+ const labelPointBounds = { x: label.x, y: label.y, width: 0, height: 0 };
710
+ return rectsOverlap(labelPointBounds, rect, 34) || (labelBox && rectsOverlap(labelBox, rect, 6));
711
+ });
712
+ const qualityCosts = {
713
+ lengthCost: 0,
714
+ boundaryCost: 0,
715
+ nodeClearanceCost: 0,
716
+ edgeProximityCost: 0,
717
+ labelNodeClearanceCost: 0
718
+ };
719
+ for (let index = 0; index < samples.length - 1; index += 1) {
720
+ qualityCosts.lengthCost += Math.hypot(samples[index + 1].x - samples[index].x, samples[index + 1].y - samples[index].y);
721
+ }
722
+ for (const point of samples) {
723
+ if (point.y < 30 || point.x < 16 || point.x > input.canvasWidth - 16 || point.y > input.canvasHeight - 16) {
724
+ qualityCosts.boundaryCost += 14000;
725
+ }
726
+ for (const rect of sampleBlockers) {
727
+ const distanceSquared = distanceToRectSquared(point, rect);
728
+ if (distanceSquared < 900) {
729
+ const distance = Math.sqrt(distanceSquared);
730
+ if (distance < 14) qualityCosts.nodeClearanceCost += 12000;
731
+ qualityCosts.nodeClearanceCost += (30 - distance) * 120;
732
+ }
733
+ }
734
+ if (input.scoreEdgeProximity) {
735
+ for (const usedRoute of usedRoutes) {
736
+ for (let usedIndex = 0; usedIndex < usedRoute.length; usedIndex += 2) {
737
+ const used = usedRoute[usedIndex];
738
+ const distance = Math.hypot(point.x - used.x, point.y - used.y);
739
+ if (distance < 26) qualityCosts.edgeProximityCost += 450;
740
+ if (distance < 12) qualityCosts.edgeProximityCost += 1600;
741
+ }
742
+ }
743
+ }
744
+ }
745
+ for (const rect of labelBlockers) {
746
+ if (distanceToRectSquared(label, rect) < 1156) qualityCosts.labelNodeClearanceCost += 24000;
747
+ if (labelBox && rectsOverlap(labelBox, rect, 6)) qualityCosts.labelNodeClearanceCost += 60000;
748
+ }
749
+ return qualityCosts;
750
+ };
751
+
752
+ const collisionCount = (route, fromId, toId, padding = 0) => {
753
+ let collisions = 0;
754
+ for (const rect of blockerRects(fromId, toId)) {
755
+ let collided = false;
756
+ for (let index = 0; index < route.points.length - 1; index += 1) {
757
+ if (segmentIntersectsRect(route.points[index], route.points[index + 1], rect, padding)) {
758
+ collided = true;
759
+ break;
760
+ }
761
+ }
762
+ if (collided) {
763
+ collisions += 1;
764
+ }
765
+ }
766
+ return collisions;
767
+ };
768
+
769
+ const gridRoute = (relationship, fromId, toId, startSide, endSide, routeOffset, usedRoutes, startPort, endPort) => {
770
+ if (stats) stats.gridRouteCalls = (stats.gridRouteCalls ?? 0) + 1;
771
+ const start = startPort.port;
772
+ const end = endPort.port;
773
+ const fromRect = rectFor(fromId);
774
+ const toRect = rectFor(toId);
775
+ const blockers = blockerRects(fromId, toId);
776
+ const padding = CORRIDOR_PADDING;
777
+ const minX = 24;
778
+ const maxX = input.canvasWidth - 24;
779
+ const minY = 30;
780
+ const maxY = input.canvasHeight - 24;
781
+ const add = (set, value, min, max) => set.add(Math.min(max, Math.max(min, Math.round(value))));
782
+ const xLines = new Set([Math.round(start.x), Math.round(end.x), minX, maxX]);
783
+ const yLines = new Set([Math.round(start.y), Math.round(end.y), minY, maxY]);
784
+
785
+ for (const rect of blockers) {
786
+ add(xLines, rect.x - padding - routeOffset, minX, maxX);
787
+ add(xLines, rect.x + rect.width + padding + routeOffset, minX, maxX);
788
+ add(yLines, rect.y - padding - routeOffset, minY, maxY);
789
+ add(yLines, rect.y + rect.height + padding + routeOffset, minY, maxY);
790
+ }
791
+
792
+ const xs = [...xLines].sort((a, b) => a - b);
793
+ const ys = [...yLines].sort((a, b) => a - b);
794
+ const points = [];
795
+ const pointIndex = new Map();
796
+ for (const x of xs) {
797
+ for (const y of ys) {
798
+ const key = `${x},${y}`;
799
+ pointIndex.set(key, points.length);
800
+ points.push({ x, y });
801
+ }
802
+ }
803
+
804
+ const pointKey = (point) => `${Math.round(point.x)},${Math.round(point.y)}`;
805
+ const startIndex = pointIndex.get(pointKey(start));
806
+ const endIndex = pointIndex.get(pointKey(end));
807
+ if (startIndex === undefined || endIndex === undefined) return null;
808
+
809
+ const neighbors = Array.from({ length: points.length }, () => []);
810
+ const horizontalBlockersByY = new Map(ys.map((y) => [
811
+ y,
812
+ blockers.filter((rect) => y > rect.y - padding && y < rect.y + rect.height + padding)
813
+ ]));
814
+ const verticalBlockersByX = new Map(xs.map((x) => [
815
+ x,
816
+ blockers.filter((rect) => x > rect.x - padding && x < rect.x + rect.width + padding)
817
+ ]));
818
+ const horizontalClear = (y, left, right) => {
819
+ const minX = Math.min(left, right);
820
+ const maxX = Math.max(left, right);
821
+ return (horizontalBlockersByY.get(y) ?? []).every((rect) => maxX <= rect.x - padding || minX >= rect.x + rect.width + padding);
822
+ };
823
+ const verticalClear = (x, top, bottom) => {
824
+ const minY = Math.min(top, bottom);
825
+ const maxY = Math.max(top, bottom);
826
+ return (verticalBlockersByX.get(x) ?? []).every((rect) => maxY <= rect.y - padding || minY >= rect.y + rect.height + padding);
827
+ };
828
+
829
+ for (const y of ys) {
830
+ for (let index = 0; index < xs.length - 1; index += 1) {
831
+ const a = pointIndex.get(`${xs[index]},${y}`);
832
+ const b = pointIndex.get(`${xs[index + 1]},${y}`);
833
+ if (horizontalClear(y, xs[index], xs[index + 1])) {
834
+ const distance = Math.abs(xs[index + 1] - xs[index]);
835
+ neighbors[a].push([b, distance]);
836
+ neighbors[b].push([a, distance]);
837
+ }
838
+ }
839
+ }
840
+ for (const x of xs) {
841
+ for (let index = 0; index < ys.length - 1; index += 1) {
842
+ const a = pointIndex.get(`${x},${ys[index]}`);
843
+ const b = pointIndex.get(`${x},${ys[index + 1]}`);
844
+ if (verticalClear(x, ys[index], ys[index + 1])) {
845
+ const distance = Math.abs(ys[index + 1] - ys[index]);
846
+ neighbors[a].push([b, distance]);
847
+ neighbors[b].push([a, distance]);
848
+ }
849
+ }
850
+ }
851
+
852
+ const distances = new Array(points.length).fill(Infinity);
853
+ const previous = new Array(points.length).fill(-1);
854
+ const visited = new Uint8Array(points.length);
855
+ const queue = createMinHeap();
856
+ distances[startIndex] = 0;
857
+ queue.push({ index: startIndex, distance: 0 });
858
+
859
+ while (queue.size > 0) {
860
+ const nextItem = queue.pop();
861
+ if (!nextItem || nextItem.distance !== distances[nextItem.index]) continue;
862
+ const current = nextItem.index;
863
+ if (current === endIndex) break;
864
+ if (visited[current]) continue;
865
+ visited[current] = 1;
866
+ for (const [next, distance] of neighbors[current]) {
867
+ if (visited[next]) continue;
868
+ const turnPenalty = previous[current] >= 0
869
+ ? (points[previous[current]].x !== points[current].x && points[current].x !== points[next].x) ||
870
+ (points[previous[current]].y !== points[current].y && points[current].y !== points[next].y)
871
+ ? 18
872
+ : 0
873
+ : 0;
874
+ const nextDistance = distances[current] + distance + turnPenalty;
875
+ if (nextDistance < distances[next]) {
876
+ distances[next] = nextDistance;
877
+ previous[next] = current;
878
+ queue.push({ index: next, distance: nextDistance });
879
+ }
880
+ }
881
+ }
882
+
883
+ if (!Number.isFinite(distances[endIndex])) return null;
884
+
885
+ const routePoints = [];
886
+ for (let cursor = endIndex; cursor !== -1; cursor = previous[cursor]) {
887
+ routePoints.unshift(points[cursor]);
888
+ }
889
+ const simplified = simplifyOrthogonalPoints([startPort.anchor, ...routePoints, endPort.anchor]);
890
+ const samples = lineSamples(simplified);
891
+ const label = samples[Math.floor(samples.length / 2)] ?? {
892
+ x: (start.x + end.x) / 2,
893
+ y: (start.y + end.y) / 2
894
+ };
895
+ const backtrackCost = monotonicBacktrackCost(simplified, fromRect, toRect);
896
+ return withQualityCosts({
897
+ d: pathToSvg(simplified),
898
+ labelX: label.x,
899
+ labelY: label.y,
900
+ bends: bendCount(simplified),
901
+ samples,
902
+ points: simplified
903
+ }, {
904
+ ...routeQualityFromSamples(samples, label, fromId, toId, usedRoutes, relationship),
905
+ pointCountCost: simplified.length * 24,
906
+ bendCost: bendCount(simplified) * 420,
907
+ doglegCost: shallowJogCount(simplified) * 14000,
908
+ monotonicBacktrackCost: backtrackCost
909
+ });
910
+ };
911
+
912
+ const perimeterRoute = (relationship, fromId, toId, side, routeOffset, usedRoutes, startPort, endPort) => {
913
+ const start = startPort.port;
914
+ const end = endPort.port;
915
+ const gutter = side === "left"
916
+ ? 24 + routeOffset
917
+ : side === "right"
918
+ ? input.canvasWidth - 24 - routeOffset
919
+ : side === "top"
920
+ ? 30 + routeOffset
921
+ : input.canvasHeight - 24 - routeOffset;
922
+ const points = side === "left" || side === "right"
923
+ ? [
924
+ startPort.anchor,
925
+ start,
926
+ { x: gutter, y: start.y },
927
+ { x: gutter, y: end.y },
928
+ end,
929
+ endPort.anchor
930
+ ]
931
+ : [
932
+ startPort.anchor,
933
+ start,
934
+ { x: start.x, y: gutter },
935
+ { x: end.x, y: gutter },
936
+ end,
937
+ endPort.anchor
938
+ ];
939
+ const simplified = simplifyOrthogonalPoints(points);
940
+ const samples = lineSamples(simplified);
941
+ const label = samples[Math.floor(samples.length / 2)] ?? {
942
+ x: (start.x + end.x) / 2,
943
+ y: (start.y + end.y) / 2
944
+ };
945
+ return withQualityCosts({
946
+ d: pathToSvg(simplified),
947
+ labelX: label.x,
948
+ labelY: label.y,
949
+ bends: bendCount(simplified),
950
+ samples,
951
+ points: simplified
952
+ }, {
953
+ ...routeQualityFromSamples(samples, label, fromId, toId, usedRoutes, relationship),
954
+ perimeterFallbackCost: 7000,
955
+ perimeterLengthCost: routeLength(samples) * 8,
956
+ pointCountCost: simplified.length * 24,
957
+ bendCost: bendCount(simplified) * 420,
958
+ doglegCost: shallowJogCount(simplified) * 14000
959
+ });
960
+ };
961
+
962
+ const cornerPerimeterRoutes = (relationship, fromId, toId, routeOffset, usedRoutes, startPort, endPort) => {
963
+ const boundaries = [
964
+ { x: 24 + routeOffset, y: 30 + routeOffset },
965
+ { x: input.canvasWidth - 24 - routeOffset, y: 30 + routeOffset },
966
+ { x: 24 + routeOffset, y: input.canvasHeight - 24 - routeOffset },
967
+ { x: input.canvasWidth - 24 - routeOffset, y: input.canvasHeight - 24 - routeOffset }
968
+ ];
969
+
970
+ const start = startPort.port;
971
+ const end = endPort.port;
972
+ return boundaries.flatMap((boundary) => [
973
+ [
974
+ startPort.anchor,
975
+ start,
976
+ { x: boundary.x, y: start.y },
977
+ boundary,
978
+ { x: boundary.x, y: end.y },
979
+ end,
980
+ endPort.anchor
981
+ ],
982
+ [
983
+ startPort.anchor,
984
+ start,
985
+ { x: start.x, y: boundary.y },
986
+ boundary,
987
+ { x: end.x, y: boundary.y },
988
+ end,
989
+ endPort.anchor
990
+ ]
991
+ ]).map((points) => {
992
+ const simplified = simplifyOrthogonalPoints(points);
993
+ const samples = lineSamples(simplified);
994
+ const start = simplified[0];
995
+ const end = simplified[simplified.length - 1];
996
+ const label = samples[Math.floor(samples.length / 2)] ?? {
997
+ x: (start.x + end.x) / 2,
998
+ y: (start.y + end.y) / 2
999
+ };
1000
+ return withQualityCosts({
1001
+ d: pathToSvg(simplified),
1002
+ labelX: label.x,
1003
+ labelY: label.y,
1004
+ bends: bendCount(simplified),
1005
+ samples,
1006
+ points: simplified
1007
+ }, {
1008
+ ...routeQualityFromSamples(samples, label, fromId, toId, usedRoutes, relationship),
1009
+ perimeterFallbackCost: 12000,
1010
+ perimeterLengthCost: routeLength(samples) * 10,
1011
+ pointCountCost: simplified.length * 24,
1012
+ bendCost: bendCount(simplified) * 420,
1013
+ doglegCost: shallowJogCount(simplified) * 14000
1014
+ });
1015
+ });
1016
+ };
1017
+
1018
+ const directPortCandidate = (relationship, fromId, toId, startSide, endSide, usedRoutes, startPort, endPort) => {
1019
+ const startVector = sideVector(startSide);
1020
+ const endVector = sideVector(endSide);
1021
+ const horizontal = startPort.port.y === endPort.port.y && startVector.y === 0 && endVector.y === 0;
1022
+ const vertical = startPort.port.x === endPort.port.x && startVector.x === 0 && endVector.x === 0;
1023
+ if (!horizontal && !vertical) return null;
1024
+ const points = simplifyOrthogonalPoints([startPort.anchor, startPort.port, endPort.port, endPort.anchor]);
1025
+ const samples = lineSamples(points);
1026
+ const label = samples[Math.floor(samples.length / 2)] ?? {
1027
+ x: (startPort.anchor.x + endPort.anchor.x) / 2,
1028
+ y: (startPort.anchor.y + endPort.anchor.y) / 2
1029
+ };
1030
+ return withQualityCosts({
1031
+ d: pathToSvg(points),
1032
+ labelX: label.x,
1033
+ labelY: label.y,
1034
+ bends: bendCount(points),
1035
+ samples,
1036
+ points
1037
+ }, {
1038
+ ...routeQualityFromSamples(samples, label, fromId, toId, usedRoutes, relationship),
1039
+ directnessReward: -2000,
1040
+ doglegCost: shallowJogCount(points) * 14000
1041
+ });
1042
+ };
1043
+
1044
+ const corridorCandidate = (relationship, fromId, toId, usedRoutes, startPort, endPort, corridor) => {
1045
+ const start = startPort.port;
1046
+ const end = endPort.port;
1047
+ const points = corridor.axis === "x"
1048
+ ? [
1049
+ startPort.anchor,
1050
+ start,
1051
+ { x: corridor.value, y: start.y },
1052
+ { x: corridor.value, y: end.y },
1053
+ end,
1054
+ endPort.anchor
1055
+ ]
1056
+ : [
1057
+ startPort.anchor,
1058
+ start,
1059
+ { x: start.x, y: corridor.value },
1060
+ { x: end.x, y: corridor.value },
1061
+ end,
1062
+ endPort.anchor
1063
+ ];
1064
+ const simplified = simplifyOrthogonalPoints(points);
1065
+ const blockers = blockerRects(fromId, toId);
1066
+ if (!blockers.every((rect) => simplified.slice(0, -1).every((point, index) => !segmentIntersectsRect(point, simplified[index + 1], rect, CORRIDOR_PADDING)))) {
1067
+ return null;
1068
+ }
1069
+ const samples = lineSamples(simplified);
1070
+ const label = samples[Math.floor(samples.length / 2)] ?? {
1071
+ x: (start.x + end.x) / 2,
1072
+ y: (start.y + end.y) / 2
1073
+ };
1074
+ return withQualityCosts({
1075
+ d: pathToSvg(simplified),
1076
+ labelX: label.x,
1077
+ labelY: label.y,
1078
+ bends: bendCount(simplified),
1079
+ samples,
1080
+ points: simplified
1081
+ }, {
1082
+ ...routeQualityFromSamples(samples, label, fromId, toId, usedRoutes, relationship),
1083
+ pointCountCost: simplified.length * 24,
1084
+ bendCost: bendCount(simplified) * 420,
1085
+ doglegCost: shallowJogCount(simplified) * 14000,
1086
+ monotonicBacktrackCost: monotonicBacktrackCost(simplified, rectFor(fromId), rectFor(toId))
1087
+ });
1088
+ };
1089
+
1090
+ const interiorCorridors = (fromRect, toRect) => {
1091
+ const corridors = [];
1092
+ const verticalGapStart = Math.min(fromRect.y, toRect.y) + Math.min(fromRect.height, toRect.height);
1093
+ const verticalGapEnd = Math.max(fromRect.y, toRect.y);
1094
+ if (verticalGapEnd - verticalGapStart > PORT_STUB * 2) {
1095
+ corridors.push({ axis: "y", value: Math.round((verticalGapStart + verticalGapEnd) / 2) });
1096
+ }
1097
+ const horizontalGapStart = Math.min(fromRect.x, toRect.x) + Math.min(fromRect.width, toRect.width);
1098
+ const horizontalGapEnd = Math.max(fromRect.x, toRect.x);
1099
+ if (horizontalGapEnd - horizontalGapStart > PORT_STUB * 2) {
1100
+ corridors.push({ axis: "x", value: Math.round((horizontalGapStart + horizontalGapEnd) / 2) });
1101
+ }
1102
+ return corridors;
1103
+ };
1104
+
1105
+ const mergeCorridors = (corridors) => {
1106
+ const seen = new Set();
1107
+ return corridors.filter((corridor) => {
1108
+ const key = `${corridor.axis}:${corridor.value}`;
1109
+ if (seen.has(key)) return false;
1110
+ seen.add(key);
1111
+ return true;
1112
+ });
1113
+ };
1114
+
1115
+ const freeSpaceCorridors = () => {
1116
+ const minX = 24;
1117
+ const maxX = input.canvasWidth - 24;
1118
+ const minY = 30;
1119
+ const maxY = input.canvasHeight - 24;
1120
+ const verticalEdges = uniqueRounded(visibleRects.flatMap((rect) => [rect.x, rect.x + rect.width])).sort((a, b) => a - b);
1121
+ const horizontalEdges = uniqueRounded(visibleRects.flatMap((rect) => [rect.y, rect.y + rect.height])).sort((a, b) => a - b);
1122
+ const corridors = [];
1123
+
1124
+ for (let index = 0; index < verticalEdges.length - 1; index += 1) {
1125
+ const left = verticalEdges[index];
1126
+ const right = verticalEdges[index + 1];
1127
+ if (right - left > CORRIDOR_PADDING * 3) {
1128
+ const value = Math.round((left + right) / 2);
1129
+ if (value > minX && value < maxX) corridors.push({ axis: "x", value });
1130
+ }
1131
+ }
1132
+ for (let index = 0; index < horizontalEdges.length - 1; index += 1) {
1133
+ const top = horizontalEdges[index];
1134
+ const bottom = horizontalEdges[index + 1];
1135
+ if (bottom - top > CORRIDOR_PADDING * 3) {
1136
+ const value = Math.round((top + bottom) / 2);
1137
+ if (value > minY && value < maxY) corridors.push({ axis: "y", value });
1138
+ }
1139
+ }
1140
+ return corridors;
1141
+ };
1142
+
1143
+ const diagramCorridors = freeSpaceCorridors();
1144
+
1145
+ const edgeCorridors = (fromRect, toRect) => {
1146
+ const minX = Math.min(fromRect.x, toRect.x) - PORT_STUB * 2;
1147
+ const maxX = Math.max(fromRect.x + fromRect.width, toRect.x + toRect.width) + PORT_STUB * 2;
1148
+ const minY = Math.min(fromRect.y, toRect.y) - PORT_STUB * 2;
1149
+ const maxY = Math.max(fromRect.y + fromRect.height, toRect.y + toRect.height) + PORT_STUB * 2;
1150
+ const midpoint = {
1151
+ x: (fromRect.x + fromRect.width / 2 + toRect.x + toRect.width / 2) / 2,
1152
+ y: (fromRect.y + fromRect.height / 2 + toRect.y + toRect.height / 2) / 2
1153
+ };
1154
+ const localCorridors = diagramCorridors.filter((corridor) => (
1155
+ corridor.axis === "x"
1156
+ ? corridor.value >= minX && corridor.value <= maxX
1157
+ : corridor.value >= minY && corridor.value <= maxY
1158
+ ));
1159
+ const closest = (axis) => localCorridors
1160
+ .filter((corridor) => corridor.axis === axis)
1161
+ .sort((left, right) => Math.abs(left.value - midpoint[axis]) - Math.abs(right.value - midpoint[axis]))
1162
+ .slice(0, 6);
1163
+ return mergeCorridors([
1164
+ ...interiorCorridors(fromRect, toRect),
1165
+ ...closest("x"),
1166
+ ...closest("y")
1167
+ ]);
1168
+ };
1169
+
1170
+ const candidatePorts = (fromRect, toRect, startSide, endSide, endpointOffsets, scope = "cheap") => {
1171
+ const fromCenter = {
1172
+ x: fromRect.x + fromRect.width / 2,
1173
+ y: fromRect.y + fromRect.height / 2
1174
+ };
1175
+ const toCenter = {
1176
+ x: toRect.x + toRect.width / 2,
1177
+ y: toRect.y + toRect.height / 2
1178
+ };
1179
+ const startTangent = tangentVector(startSide);
1180
+ const endTangent = tangentVector(endSide);
1181
+ const targetAlignedStartOffset = startTangent.y !== 0 ? toCenter.y - fromCenter.y : toCenter.x - fromCenter.x;
1182
+ const targetAlignedEndOffset = endTangent.y !== 0 ? fromCenter.y - toCenter.y : fromCenter.x - toCenter.x;
1183
+
1184
+ const sharedOffsets = scope === "grid"
1185
+ ? [
1186
+ 0,
1187
+ endpointOffsets.from,
1188
+ endpointOffsets.to
1189
+ ]
1190
+ : [
1191
+ 0,
1192
+ endpointOffsets.from,
1193
+ endpointOffsets.to,
1194
+ endpointOffsets.from + targetAlignedStartOffset,
1195
+ endpointOffsets.to + targetAlignedEndOffset
1196
+ ];
1197
+
1198
+ return {
1199
+ starts: portCandidatesFor(fromRect, startSide, sharedOffsets),
1200
+ ends: portCandidatesFor(toRect, endSide, sharedOffsets)
1201
+ };
1202
+ };
1203
+
1204
+ const sidePairsFor = (fromRect, toRect) => {
1205
+ const fromCenter = {
1206
+ x: fromRect.x + fromRect.width / 2,
1207
+ y: fromRect.y + fromRect.height / 2
1208
+ };
1209
+ const toCenter = {
1210
+ x: toRect.x + toRect.width / 2,
1211
+ y: toRect.y + toRect.height / 2
1212
+ };
1213
+ const horizontal = toCenter.x >= fromCenter.x ? ["right", "left"] : ["left", "right"];
1214
+ const vertical = toCenter.y >= fromCenter.y ? ["bottom", "top"] : ["top", "bottom"];
1215
+ const pairs = [
1216
+ Math.abs(toCenter.x - fromCenter.x) >= Math.abs(toCenter.y - fromCenter.y) ? horizontal : vertical,
1217
+ Math.abs(toCenter.x - fromCenter.x) >= Math.abs(toCenter.y - fromCenter.y) ? vertical : horizontal,
1218
+ ["left", "right"],
1219
+ ["right", "left"],
1220
+ ["top", "bottom"],
1221
+ ["bottom", "top"],
1222
+ ["left", "left"],
1223
+ ["right", "right"],
1224
+ ["top", "top"],
1225
+ ["bottom", "bottom"]
1226
+ ];
1227
+ const seen = new Set();
1228
+ return pairs.filter(([startSide, endSide]) => {
1229
+ const key = `${startSide}:${endSide}`;
1230
+ if (seen.has(key)) return false;
1231
+ seen.add(key);
1232
+ return true;
1233
+ });
1234
+ };
1235
+
1236
+ const portPairsFor = (ports) => {
1237
+ const pairs = [];
1238
+ const seen = new Set();
1239
+ const add = (start, end) => {
1240
+ if (!start || !end) return;
1241
+ const key = `${start.anchor.x},${start.anchor.y}:${end.anchor.x},${end.anchor.y}`;
1242
+ if (seen.has(key)) return;
1243
+ seen.add(key);
1244
+ pairs.push([start, end]);
1245
+ };
1246
+ for (const start of ports.starts) {
1247
+ for (const end of ports.ends) {
1248
+ add(start, end);
1249
+ }
1250
+ }
1251
+ return pairs;
1252
+ };
1253
+
1254
+ const edgePath = (relationship, index, pairIndex, usedRoutes, previousRoutes, routeIndex, endpointOffsets) => {
1255
+ const { from: fromId, to: toId } = relationship;
1256
+ const fromRect = rectFor(fromId);
1257
+ const toRect = rectFor(toId);
1258
+ const fromLane = input.laneIndexByNode.get(fromId) ?? 0;
1259
+ const toLane = input.laneIndexByNode.get(toId) ?? 0;
1260
+ const candidates = [];
1261
+ const candidateKeys = new Set();
1262
+ const addCandidate = (candidate) => {
1263
+ if (!candidate) return;
1264
+ const key = candidate.points.map((point) => `${point.x},${point.y}`).join("|");
1265
+ if (candidateKeys.has(key)) return;
1266
+ candidateKeys.add(key);
1267
+ candidates.push(candidate);
1268
+ };
1269
+ const corridors = edgeCorridors(fromRect, toRect);
1270
+ const sidePairs = sidePairsFor(fromRect, toRect);
1271
+ const cheapSidePairs = SIDES.flatMap((startSide) => SIDES.map((endSide) => [startSide, endSide]));
1272
+ const routeOffset = pairIndex * 40 + (index % 2) * 10;
1273
+ const topLimit = Math.min(fromRect.y, toRect.y);
1274
+ const bottomLimit = Math.max(fromRect.y + fromRect.height, toRect.y + toRect.height);
1275
+
1276
+ const scoreCandidates = (candidateList) => {
1277
+ candidateList.forEach((candidate) => {
1278
+ const travelsTop = candidate.samples.some((point) => point.y < topLimit - 4);
1279
+ const travelsBottom = candidate.samples.some((point) => point.y > bottomLimit + 4);
1280
+ candidate.collisions = collisionCount(candidate, fromId, toId, 0);
1281
+ candidate.paddedCollisions = collisionCount(candidate, fromId, toId, 8);
1282
+ const crossingStats = routeIndex.crossingStats(candidate.points);
1283
+ candidate.crossings = crossingStats.total;
1284
+ candidate.repeatedCrossings = crossingStats.repeated;
1285
+ candidate.qualityCosts.crossingCost = crossingStats.total * 3000;
1286
+ candidate.qualityCosts.repeatedCrossingCost = crossingStats.repeated * 40000;
1287
+ candidate.qualityCosts.endpointStackCost = routeIndex.hasStackedEndpoint(candidate) ? 90000 : 0;
1288
+ if (pairIndex % 2 === 1 && travelsTop) {
1289
+ candidate.qualityCosts.fanOutDirectionCost = (candidate.qualityCosts.fanOutDirectionCost ?? 0) + 25000;
1290
+ }
1291
+ if (pairIndex % 2 === 1 && !travelsBottom) {
1292
+ candidate.qualityCosts.fanOutDirectionCost = (candidate.qualityCosts.fanOutDirectionCost ?? 0) + 4000;
1293
+ }
1294
+ if (pairIndex % 2 === 0 && travelsBottom) {
1295
+ candidate.qualityCosts.fanOutDirectionCost = (candidate.qualityCosts.fanOutDirectionCost ?? 0) + 600;
1296
+ }
1297
+ candidate.cost = totalQualityCost(candidate.qualityCosts);
1298
+ });
1299
+ };
1300
+
1301
+ const sortedCandidates = (candidateList) => candidateList.sort((a, b) =>
1302
+ a.collisions - b.collisions ||
1303
+ a.paddedCollisions - b.paddedCollisions ||
1304
+ a.repeatedCrossings - b.repeatedCrossings ||
1305
+ a.crossings - b.crossings ||
1306
+ (a.qualityCosts.monotonicBacktrackCost > 0 ? 1 : 0) - (b.qualityCosts.monotonicBacktrackCost > 0 ? 1 : 0) ||
1307
+ (a.qualityCosts.endpointStackCost > 0 ? 1 : 0) - (b.qualityCosts.endpointStackCost > 0 ? 1 : 0) ||
1308
+ (a.qualityCosts.perimeterFallbackCost > 0 ? 1 : 0) - (b.qualityCosts.perimeterFallbackCost > 0 ? 1 : 0) ||
1309
+ a.bends - b.bends ||
1310
+ a.cost - b.cost
1311
+ );
1312
+
1313
+ const cleanCandidate = (candidate) => (
1314
+ candidate.collisions === 0 &&
1315
+ candidate.paddedCollisions === 0 &&
1316
+ candidate.repeatedCrossings === 0 &&
1317
+ candidate.crossings === 0 &&
1318
+ candidate.qualityCosts.endpointStackCost === 0 &&
1319
+ candidate.qualityCosts.perimeterFallbackCost === 0 &&
1320
+ candidate.qualityCosts.doglegCost === 0
1321
+ );
1322
+
1323
+ const cheapCandidates = [];
1324
+ const addCheapCandidate = (candidate) => {
1325
+ if (!candidate) return;
1326
+ const key = candidate.points.map((point) => `${point.x},${point.y}`).join("|");
1327
+ if (candidateKeys.has(key)) return;
1328
+ candidateKeys.add(key);
1329
+ cheapCandidates.push(candidate);
1330
+ };
1331
+
1332
+ const addCheapCandidatesForSidePairs = (pairs) => {
1333
+ pairs.forEach(([startSide, endSide]) => {
1334
+ const ports = candidatePorts(fromRect, toRect, startSide, endSide, endpointOffsets);
1335
+ for (const [startPort, endPort] of portPairsFor(ports)) {
1336
+ const direct = directPortCandidate(relationship, fromId, toId, startSide, endSide, usedRoutes, startPort, endPort);
1337
+ addCheapCandidate(direct);
1338
+ for (const corridor of corridors) {
1339
+ const corridorRoute = corridorCandidate(relationship, fromId, toId, usedRoutes, startPort, endPort, corridor);
1340
+ addCheapCandidate(corridorRoute);
1341
+ }
1342
+ }
1343
+ });
1344
+ };
1345
+
1346
+ addCheapCandidatesForSidePairs(cheapSidePairs);
1347
+ scoreCandidates(cheapCandidates);
1348
+ const hasCleanCheapCandidate = cheapCandidates.some(cleanCandidate);
1349
+ if (stats) {
1350
+ stats.edgesPlanned = (stats.edgesPlanned ?? 0) + 1;
1351
+ stats.cheapCandidateCount = (stats.cheapCandidateCount ?? 0) + cheapCandidates.length;
1352
+ if (!hasCleanCheapCandidate) {
1353
+ stats.gridEscalations = (stats.gridEscalations ?? 0) + 1;
1354
+ const bestCheap = sortedCandidates([...cheapCandidates])[0];
1355
+ const reasons = stats.cheapRejectionReasons ?? {};
1356
+ if (bestCheap) {
1357
+ if (bestCheap.collisions > 0) reasons.collisions = (reasons.collisions ?? 0) + 1;
1358
+ if (bestCheap.paddedCollisions > 0) reasons.paddedCollisions = (reasons.paddedCollisions ?? 0) + 1;
1359
+ if (bestCheap.repeatedCrossings > 0) reasons.repeatedCrossings = (reasons.repeatedCrossings ?? 0) + 1;
1360
+ if (bestCheap.crossings > 0) reasons.crossings = (reasons.crossings ?? 0) + 1;
1361
+ if (bestCheap.qualityCosts.endpointStackCost > 0) reasons.endpointStack = (reasons.endpointStack ?? 0) + 1;
1362
+ if (bestCheap.qualityCosts.doglegCost > 0) reasons.dogleg = (reasons.dogleg ?? 0) + 1;
1363
+ } else {
1364
+ reasons.noCandidate = (reasons.noCandidate ?? 0) + 1;
1365
+ }
1366
+ stats.cheapRejectionReasons = reasons;
1367
+ }
1368
+ }
1369
+ if (hasCleanCheapCandidate) {
1370
+ candidates.push(...cheapCandidates);
1371
+ } else {
1372
+ candidates.push(...cheapCandidates);
1373
+ sidePairs.forEach(([startSide, endSide]) => {
1374
+ const ports = candidatePorts(fromRect, toRect, startSide, endSide, endpointOffsets, "grid");
1375
+ for (const [startPort, endPort] of portPairsFor(ports)) {
1376
+ const orthogonal = gridRoute(relationship, fromId, toId, startSide, endSide, routeOffset, usedRoutes, startPort, endPort);
1377
+ addCandidate(orthogonal);
1378
+ }
1379
+ });
1380
+ }
1381
+
1382
+ if (!hasCleanCheapCandidate) {
1383
+ SIDES.forEach((side) => {
1384
+ const ports = candidatePorts(fromRect, toRect, side, side, endpointOffsets);
1385
+ for (const [startPort, endPort] of portPairsFor(ports)) {
1386
+ addCandidate(perimeterRoute(relationship, fromId, toId, side, routeOffset, usedRoutes, startPort, endPort));
1387
+ for (const perimeterCandidate of cornerPerimeterRoutes(relationship, fromId, toId, routeOffset, usedRoutes, startPort, endPort)) {
1388
+ addCandidate(perimeterCandidate);
1389
+ }
1390
+ }
1391
+ });
1392
+ }
1393
+
1394
+ scoreCandidates(candidates.filter((candidate) => candidate.collisions === undefined));
1395
+
1396
+ return sortedCandidates(candidates).map((candidate) => {
1397
+ const warnings = [];
1398
+ if (candidate.collisions > 0 || candidate.paddedCollisions > 0) {
1399
+ warnings.push({
1400
+ code: "least-bad-route",
1401
+ message: "No clean route was available for the current node arrangement."
1402
+ });
1403
+ }
1404
+ if (candidate.repeatedCrossings > 0) {
1405
+ warnings.push({
1406
+ code: "repeated-route-crossing",
1407
+ message: "Selected route crosses the same existing route more than once."
1408
+ });
1409
+ }
1410
+ if (candidate.qualityCosts.perimeterFallbackCost > 0) {
1411
+ warnings.push({
1412
+ code: "perimeter-fallback-route",
1413
+ message: "Selected route used a perimeter fallback instead of an interior corridor."
1414
+ });
1415
+ }
1416
+ if (rectDistance(fromRect, toRect) < PORT_STUB * 2) {
1417
+ warnings.push({
1418
+ code: "nodes-too-close",
1419
+ message: "Source and target nodes are too close for clean connector routing."
1420
+ });
1421
+ }
1422
+ return { ...candidate, warnings };
1423
+ })[0];
1424
+ };
1425
+
1426
+ return { edgePath };
1427
+ }
1428
+
1429
+ export function routeEdges(input) {
1430
+ const usedRoutes = [];
1431
+ const rawRoutes = [];
1432
+ const routeIndex = createRouteIndex();
1433
+ const pairCounts = new Map();
1434
+ const endpointCounts = new Map();
1435
+ const cacheKey = routeCacheKey(input);
1436
+ const cachedRawRoutes = getCachedRawRoutes(cacheKey);
1437
+ const plannedRawRoutes = cachedRawRoutes ?? [];
1438
+ const planner = cachedRawRoutes ? null : routePlannerContext(input);
1439
+ const style = input.style === "curved" ? "curved" : "orthogonal";
1440
+
1441
+ if (!cachedRawRoutes) {
1442
+ input.relationships.forEach((relationship, index) => {
1443
+ if (!input.laneIndexByNode.has(relationship.from) || !input.laneIndexByNode.has(relationship.to)) {
1444
+ return;
1445
+ }
1446
+
1447
+ const pairKey = [relationship.from, relationship.to].sort().join("<->");
1448
+ const pairIndex = pairCounts.get(pairKey) ?? 0;
1449
+ pairCounts.set(pairKey, pairIndex + 1);
1450
+
1451
+ const fromEndpointCount = endpointCounts.get(relationship.from) ?? 0;
1452
+ const toEndpointCount = endpointCounts.get(relationship.to) ?? 0;
1453
+ endpointCounts.set(relationship.from, fromEndpointCount + 1);
1454
+ endpointCounts.set(relationship.to, toEndpointCount + 1);
1455
+
1456
+ const route = planner.edgePath(
1457
+ relationship,
1458
+ index,
1459
+ pairIndex,
1460
+ usedRoutes,
1461
+ rawRoutes,
1462
+ routeIndex,
1463
+ {
1464
+ from: offsetForEndpointOrder(fromEndpointCount),
1465
+ to: offsetForEndpointOrder(toEndpointCount)
1466
+ }
1467
+ );
1468
+ plannedRawRoutes.push([relationship.id, route]);
1469
+ usedRoutes.push(route.samples);
1470
+ rawRoutes.push(route);
1471
+ routeIndex.add(route, rawRoutes.length - 1);
1472
+ });
1473
+ setCachedRawRoutes(cacheKey, plannedRawRoutes);
1474
+ }
1475
+
1476
+ const routes = new Map();
1477
+ const renderedRoutes = [];
1478
+ for (const [relationshipId, rawRoute] of plannedRawRoutes) {
1479
+ const route = renderRoute(rawRoute, style, renderedRoutes);
1480
+ routes.set(relationshipId, route);
1481
+ renderedRoutes.push(route);
1482
+ }
1483
+ return routes;
1484
+ }