@statelyai/layout 0.4.2 → 0.5.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.
Files changed (56) hide show
  1. package/README.md +40 -3
  2. package/dist/elkjs/index.cjs +2 -2
  3. package/dist/elkjs/index.d.cts +1 -1
  4. package/dist/elkjs/index.d.mts +1 -1
  5. package/dist/elkjs/index.mjs +2 -2
  6. package/dist/elkjs/main.cjs +1 -1
  7. package/dist/elkjs/main.mjs +1 -1
  8. package/dist/elkjs/worker.cjs +2 -2
  9. package/dist/elkjs/worker.mjs +2 -2
  10. package/dist/{elkjs-CSi7oB5J.mjs → elkjs-DBkalvt1.mjs} +25 -3
  11. package/dist/{elkjs-CnBvdyBu.cjs → elkjs-DonMqGjf.cjs} +25 -3
  12. package/dist/{index-B6cWNrMw.d.cts → index-0YVSvMvA.d.mts} +13 -3
  13. package/dist/{index-CJQLUKD9.d.mts → index-C0J4ttZe.d.cts} +13 -3
  14. package/dist/{index-DT6lPMfS.d.cts → index-Xc4R6y3z.d.cts} +9 -2
  15. package/dist/{index-F1U-fCTu.d.mts → index-lSp0oCJC.d.mts} +9 -2
  16. package/dist/index.cjs +7 -4
  17. package/dist/index.d.cts +4 -4
  18. package/dist/index.d.mts +4 -4
  19. package/dist/index.mjs +4 -4
  20. package/dist/layered/index.cjs +1 -1
  21. package/dist/layered/index.d.cts +2 -2
  22. package/dist/layered/index.d.mts +2 -2
  23. package/dist/layered/index.mjs +1 -1
  24. package/dist/{layered-BsAdm_u8.mjs → layered-CmL7oynw.mjs} +245 -69
  25. package/dist/{layered-BJu8A21s.cjs → layered-SB2mlAqt.cjs} +245 -69
  26. package/dist/{layout-engine-DnlmGxHL.mjs → layout-engine-BXoIpYL4.mjs} +1 -1
  27. package/dist/{layout-engine-BFP1TZbF.cjs → layout-engine-CAyJ-I-u.cjs} +1 -1
  28. package/dist/routing/index.cjs +5 -2
  29. package/dist/routing/index.d.cts +3 -3
  30. package/dist/routing/index.d.mts +3 -3
  31. package/dist/routing/index.mjs +2 -2
  32. package/dist/routing-D-LfMXOF.mjs +5189 -0
  33. package/dist/routing-Phxu1R0x.cjs +5423 -0
  34. package/dist/{types-CT2akdK0.d.mts → types-D8pYSn8I.d.cts} +112 -2
  35. package/dist/{types-uxPlGRof.d.cts → types-MAUtJfKQ.d.mts} +112 -2
  36. package/package.json +4 -1
  37. package/src/authoring/partial.ts +1 -7
  38. package/src/authoring/routing.ts +64 -19
  39. package/src/elkjs/index.ts +26 -0
  40. package/src/layered/feedback-chains.ts +22 -4
  41. package/src/layered/index.ts +173 -41
  42. package/src/layered/north-south-ports.ts +17 -4
  43. package/src/layered/strategies.ts +143 -15
  44. package/src/routing/algorithms.ts +1436 -148
  45. package/src/routing/bezier-fit.ts +605 -0
  46. package/src/routing/bezier-visibility.ts +107 -0
  47. package/src/routing/bezier.ts +596 -0
  48. package/src/routing/engine.ts +65 -13
  49. package/src/routing/index.ts +14 -0
  50. package/src/routing/model.ts +185 -1
  51. package/src/routing/path.ts +1 -1
  52. package/src/routing/policy-geometry.ts +536 -0
  53. package/src/routing/search.ts +10 -2
  54. package/src/routing/types.ts +77 -1
  55. package/dist/routing-CR8flv8b.mjs +0 -2780
  56. package/dist/routing-QIdGGFBe.cjs +0 -2996
@@ -0,0 +1,605 @@
1
+ import { pathFromPoints } from "./path";
2
+ import { shortestVisibilityPath } from "./bezier-visibility";
3
+ import type {
4
+ RouteBounds as Rect,
5
+ RoutePath,
6
+ RoutePoint as Point,
7
+ RouteSide as Direction,
8
+ } from "./types";
9
+
10
+ export interface BezierGuide {
11
+ readonly points: readonly Point[];
12
+ readonly sourceDirection: Direction;
13
+ readonly targetDirection: Direction;
14
+ readonly path?: RoutePath;
15
+ }
16
+
17
+ export type CubicBezier = {
18
+ start: Point;
19
+ control1: Point;
20
+ control2: Point;
21
+ end: Point;
22
+ };
23
+
24
+ export type BezierEndpoint =
25
+ | { kind: "floating"; bounds: Rect; sides: readonly Direction[] }
26
+ | {
27
+ kind: "port";
28
+ point: Point;
29
+ direction: Direction;
30
+ escape: Point;
31
+ owner: Rect;
32
+ };
33
+
34
+ export type BezierRoutingInput = {
35
+ source: BezierGuide;
36
+ target: BezierGuide;
37
+ label: Rect;
38
+ obstacles: readonly Rect[];
39
+ terminalBounds?: readonly Rect[];
40
+ avoidObstacles: boolean;
41
+ attachmentMode?: "floating" | "orthogonal";
42
+ labelTangent?: Point;
43
+ targetClearance?: number;
44
+ endpoints?: {
45
+ source: BezierEndpoint;
46
+ target: Extract<BezierEndpoint, { kind: "floating" }>;
47
+ };
48
+ };
49
+
50
+ const VECTORS: Record<Direction, Point> = {
51
+ left: { x: -1, y: 0 },
52
+ right: { x: 1, y: 0 },
53
+ top: { x: 0, y: -1 },
54
+ bottom: { x: 0, y: 1 },
55
+ };
56
+ const mix = (a: Point, b: Point, t: number): Point => ({
57
+ x: a.x + (b.x - a.x) * t,
58
+ y: a.y + (b.y - a.y) * t,
59
+ });
60
+ const distance = (a: Point, b: Point) => Math.hypot(b.x - a.x, b.y - a.y);
61
+ const offset = (point: Point, direction: Point, amount: number): Point => ({
62
+ x: point.x + direction.x * amount,
63
+ y: point.y + direction.y * amount,
64
+ });
65
+ function unit(from: Point, to: Point): Point {
66
+ const length = distance(from, to);
67
+ return length ? { x: (to.x - from.x) / length, y: (to.y - from.y) / length } : { x: 1, y: 0 };
68
+ }
69
+
70
+ export function splitBezier(curve: CubicBezier, t: number): [CubicBezier, CubicBezier] {
71
+ const a = mix(curve.start, curve.control1, t);
72
+ const b = mix(curve.control1, curve.control2, t);
73
+ const c = mix(curve.control2, curve.end, t);
74
+ const d = mix(a, b, t);
75
+ const e = mix(b, c, t);
76
+ const point = mix(d, e, t);
77
+ return [
78
+ { start: curve.start, control1: a, control2: d, end: point },
79
+ { start: point, control1: e, control2: c, end: curve.end },
80
+ ];
81
+ }
82
+
83
+ export function bezierAvoidsRect(curve: CubicBezier, rect: Rect, depth = 0): boolean {
84
+ const points = [curve.start, curve.control1, curve.control2, curve.end];
85
+ if (
86
+ Math.max(...points.map((p) => p.x)) <= rect.x ||
87
+ Math.min(...points.map((p) => p.x)) >= rect.x + rect.width ||
88
+ Math.max(...points.map((p) => p.y)) <= rect.y ||
89
+ Math.min(...points.map((p) => p.y)) >= rect.y + rect.height
90
+ )
91
+ return true;
92
+ if (depth >= 12) return false;
93
+ const [left, right] = splitBezier(curve, 0.5);
94
+ return bezierAvoidsRect(left, rect, depth + 1) && bezierAvoidsRect(right, rect, depth + 1);
95
+ }
96
+
97
+ function safe(curves: readonly CubicBezier[], obstacles: readonly Rect[]): boolean {
98
+ return curves.every((curve) => obstacles.every((rect) => bezierAvoidsRect(curve, rect)));
99
+ }
100
+
101
+ function straight(start: Point, end: Point): CubicBezier {
102
+ return {
103
+ start,
104
+ control1: mix(start, end, 1 / 3),
105
+ control2: mix(start, end, 2 / 3),
106
+ end,
107
+ };
108
+ }
109
+
110
+ function simpleCurve(start: Point, end: Point, departure: Point, arrival: Point): CubicBezier {
111
+ const handle = Math.max(12, distance(start, end) / 3);
112
+ return {
113
+ start,
114
+ control1: offset(start, departure, handle),
115
+ control2: offset(end, arrival, -handle),
116
+ end,
117
+ };
118
+ }
119
+
120
+ function coordinateRoots(a: number, b: number, c: number, d: number, value: number): number[] {
121
+ const cubic = -a + 3 * b - 3 * c + d;
122
+ const quadratic = 3 * a - 6 * b + 3 * c;
123
+ const linear = -3 * a + 3 * b;
124
+ const evaluate = (t: number) => ((cubic * t + quadratic) * t + linear) * t + a - value;
125
+ const critical: number[] = [];
126
+ if (Math.abs(cubic) < 1e-10) {
127
+ if (Math.abs(quadratic) > 1e-10) critical.push(-linear / (2 * quadratic));
128
+ } else {
129
+ const discriminant = quadratic * quadratic - 3 * cubic * linear;
130
+ if (discriminant >= 0) {
131
+ critical.push(
132
+ (-quadratic - Math.sqrt(discriminant)) / (3 * cubic),
133
+ (-quadratic + Math.sqrt(discriminant)) / (3 * cubic),
134
+ );
135
+ }
136
+ }
137
+ const intervals = [0, ...critical.filter((t) => t > 0 && t < 1).sort((x, y) => x - y), 1];
138
+ const roots: number[] = [];
139
+ for (let index = 1; index < intervals.length; index++) {
140
+ let low = intervals[index - 1]!;
141
+ let high = intervals[index]!;
142
+ if (Math.abs(evaluate(low)) < 1e-9) roots.push(low);
143
+ if (evaluate(low) * evaluate(high) > 0) continue;
144
+ const sign = evaluate(low) < 0;
145
+ for (let step = 0; step < 42; step++) {
146
+ const middle = (low + high) / 2;
147
+ if (evaluate(middle) < 0 === sign) low = middle;
148
+ else high = middle;
149
+ }
150
+ roots.push((low + high) / 2);
151
+ }
152
+ return roots;
153
+ }
154
+
155
+ function pointInLabel(point: Point, label: Rect): boolean {
156
+ return (
157
+ point.x >= label.x &&
158
+ point.x <= label.x + label.width &&
159
+ point.y >= label.y &&
160
+ point.y <= label.y + label.height
161
+ );
162
+ }
163
+
164
+ function labelIntersections(curve: CubicBezier, label: Rect): number[] {
165
+ const inside = (point: Point) =>
166
+ point.x >= label.x - 1e-6 &&
167
+ point.x <= label.x + label.width + 1e-6 &&
168
+ point.y >= label.y - 1e-6 &&
169
+ point.y <= label.y + label.height + 1e-6;
170
+ return [
171
+ ...[label.x, label.x + label.width].flatMap((x) =>
172
+ coordinateRoots(curve.start.x, curve.control1.x, curve.control2.x, curve.end.x, x),
173
+ ),
174
+ ...[label.y, label.y + label.height].flatMap((y) =>
175
+ coordinateRoots(curve.start.y, curve.control1.y, curve.control2.y, curve.end.y, y),
176
+ ),
177
+ ]
178
+ .filter((t) => t > 1e-9 && t < 1 - 1e-9 && inside(splitBezier(curve, t)[0].end))
179
+ .sort((a, b) => a - b);
180
+ }
181
+
182
+ function clipAtLabel(curve: CubicBezier, label: Rect, source: boolean): CubicBezier {
183
+ const intersections = labelIntersections(curve, label);
184
+ const t = source ? intersections[0] : intersections.at(-1);
185
+ return t === undefined ? curve : splitBezier(curve, t)[source ? 0 : 1];
186
+ }
187
+
188
+ function smoothGuide(path: BezierGuide, obstacles: readonly Rect[]): CubicBezier[] {
189
+ const points = path.points.filter(
190
+ (p, index, all) => !index || distance(p, all[index - 1]!) > 1e-8,
191
+ );
192
+ const first = points[0];
193
+ if (!first) return [];
194
+ const curves: CubicBezier[] = [];
195
+ let cursor = first;
196
+ for (let index = 1; index < points.length - 1; index++) {
197
+ const previous = points[index - 1]!;
198
+ const corner = points[index]!;
199
+ const next = points[index + 1]!;
200
+ let radius = Math.min(48, distance(previous, corner) / 3, distance(corner, next) / 3);
201
+ let rounded: CubicBezier | undefined;
202
+ for (let attempt = 0; attempt < 10; attempt++, radius /= 2) {
203
+ const start = offset(corner, unit(corner, previous), radius);
204
+ const end = offset(corner, unit(corner, next), radius);
205
+ const candidate = {
206
+ start,
207
+ control1: mix(start, corner, 0.55228475),
208
+ control2: mix(end, corner, 0.55228475),
209
+ end,
210
+ };
211
+ if (safe([candidate], obstacles)) {
212
+ rounded = candidate;
213
+ break;
214
+ }
215
+ }
216
+ if (rounded) {
217
+ curves.push(straight(cursor, rounded.start), rounded);
218
+ cursor = rounded.end;
219
+ } else {
220
+ curves.push(straight(cursor, corner));
221
+ cursor = corner;
222
+ }
223
+ }
224
+ const last = points.at(-1)!;
225
+ curves.push(straight(cursor, last));
226
+ return curves;
227
+ }
228
+
229
+ function toBezierGuide(guide: BezierGuide, curves: CubicBezier[]): BezierGuide {
230
+ if (!curves.length) return guide;
231
+ const start = curves[0]!.start;
232
+ return {
233
+ ...guide,
234
+ points: [start, ...curves.map((curve) => curve.end)],
235
+ path: {
236
+ start,
237
+ segments: curves.map((curve) => ({
238
+ kind: "cubic",
239
+ control1: curve.control1,
240
+ control2: curve.control2,
241
+ to: curve.end,
242
+ })),
243
+ },
244
+ };
245
+ }
246
+
247
+ function curvesToSvg(curves: readonly CubicBezier[]): string {
248
+ const start = curves[0]!.start;
249
+ return `M ${start.x} ${start.y} ${curves.map((curve) => `C ${curve.control1.x} ${curve.control1.y} ${curve.control2.x} ${curve.control2.y} ${curve.end.x} ${curve.end.y}`).join(" ")}`;
250
+ }
251
+
252
+ function curveLength(curve: CubicBezier, depth = 0): number {
253
+ const chord = distance(curve.start, curve.end);
254
+ const polygon =
255
+ distance(curve.start, curve.control1) +
256
+ distance(curve.control1, curve.control2) +
257
+ distance(curve.control2, curve.end);
258
+ if (polygon - chord < 0.001 || depth >= 12) return (polygon + chord) / 2;
259
+ const [left, right] = splitBezier(curve, 0.5);
260
+ return curveLength(left, depth + 1) + curveLength(right, depth + 1);
261
+ }
262
+
263
+ export function trimBezierPathStart(path: string, maximum = 20): string | undefined {
264
+ const tokens = path.match(/-?(?:\d*\.)?\d+(?:e[+-]?\d+)?|[a-z]/gi) ?? [];
265
+ let index = 0;
266
+ const point = (): Point | undefined => {
267
+ const x = Number(tokens[index++]);
268
+ const y = Number(tokens[index++]);
269
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined;
270
+ };
271
+ if (tokens[index++] !== "M") return undefined;
272
+ let start = point();
273
+ if (!start) return undefined;
274
+ const curves: CubicBezier[] = [];
275
+ while (index < tokens.length) {
276
+ const command = tokens[index++];
277
+ let curve: CubicBezier;
278
+ if (command === "C") {
279
+ const control1 = point();
280
+ const control2 = point();
281
+ const end = point();
282
+ if (!control1 || !control2 || !end) return undefined;
283
+ curve = { start, control1, control2, end };
284
+ } else if (command === "Q") {
285
+ const control = point();
286
+ const end = point();
287
+ if (!control || !end) return undefined;
288
+ curve = {
289
+ start,
290
+ control1: mix(start, control, 2 / 3),
291
+ control2: mix(end, control, 2 / 3),
292
+ end,
293
+ };
294
+ } else if (command === "L") {
295
+ const end = point();
296
+ if (!end) return undefined;
297
+ curve = straight(start, end);
298
+ } else return undefined;
299
+ curves.push(curve);
300
+ start = curve.end;
301
+ }
302
+ if (!curves.length) return undefined;
303
+ const lengths = curves.map((curve) => curveLength(curve));
304
+ let remaining = Math.min(maximum, lengths.reduce((sum, value) => sum + value, 0) / 2);
305
+ for (let index = 0; index < curves.length; index++) {
306
+ const curve = curves[index]!;
307
+ if (remaining >= lengths[index]!) {
308
+ remaining -= lengths[index]!;
309
+ continue;
310
+ }
311
+ let low = 0;
312
+ let high = 1;
313
+ for (let step = 0; step < 24; step++) {
314
+ const middle = (low + high) / 2;
315
+ if (curveLength(splitBezier(curve, middle)[0]) < remaining) low = middle;
316
+ else high = middle;
317
+ }
318
+ return curvesToSvg([splitBezier(curve, (low + high) / 2)[1], ...curves.slice(index + 1)]);
319
+ }
320
+ return curvesToSvg(curves);
321
+ }
322
+
323
+ function fitSemanticTransition({
324
+ source,
325
+ target,
326
+ label,
327
+ obstacles,
328
+ terminalBounds = [],
329
+ avoidObstacles,
330
+ labelTangent,
331
+ }: BezierRoutingInput): { source: BezierGuide; target: BezierGuide } {
332
+ const barriers = avoidObstacles ? [...obstacles, ...terminalBounds] : terminalBounds;
333
+ const start = source.points[0];
334
+ const end = target.points.at(-1);
335
+ if (!start || !end) return { source, target };
336
+ const center = {
337
+ x: label.x + label.width / 2,
338
+ y: label.y + label.height / 2,
339
+ };
340
+ const tangent = labelTangent
341
+ ? unit({ x: 0, y: 0 }, labelTangent)
342
+ : distance(start, end) > 1e-8
343
+ ? unit(start, end)
344
+ : unit(source.points.at(-1) ?? start, target.points[0] ?? end);
345
+ const sourceCurve = simpleCurve(start, center, VECTORS[source.sourceDirection], tangent);
346
+ const targetCurve = simpleCurve(
347
+ center,
348
+ end,
349
+ tangent,
350
+ offset({ x: 0, y: 0 }, VECTORS[target.targetDirection], -1),
351
+ );
352
+ const visibleSource = [clipAtLabel(sourceCurve, label, true)];
353
+ const visibleTarget = [clipAtLabel(targetCurve, label, false)];
354
+ if (safe([...visibleSource, ...visibleTarget], barriers)) {
355
+ return {
356
+ source: toBezierGuide(source, visibleSource),
357
+ target: toBezierGuide(target, visibleTarget),
358
+ };
359
+ }
360
+ const sourceGuide = smoothGuide(source, barriers);
361
+ const targetGuide = smoothGuide(target, barriers);
362
+ const sourceEnd = sourceGuide.at(-1);
363
+ const targetStart = targetGuide[0];
364
+ const hiddenHandle = Math.max(0, Math.min(label.width, label.height) / 4);
365
+ if (sourceEnd) {
366
+ const connector = {
367
+ start: sourceEnd.end,
368
+ control1: offset(sourceEnd.end, unit(sourceEnd.control2, sourceEnd.end), hiddenHandle),
369
+ control2: offset(center, tangent, -hiddenHandle),
370
+ end: center,
371
+ };
372
+ if (!pointInLabel(sourceEnd.end, label)) sourceGuide.push(clipAtLabel(connector, label, true));
373
+ }
374
+ if (targetStart) {
375
+ const connector = {
376
+ start: center,
377
+ control1: offset(center, tangent, hiddenHandle),
378
+ control2: offset(
379
+ targetStart.start,
380
+ unit(targetStart.start, targetStart.control1),
381
+ -hiddenHandle,
382
+ ),
383
+ end: targetStart.start,
384
+ };
385
+ if (!pointInLabel(targetStart.start, label))
386
+ targetGuide.unshift(clipAtLabel(connector, label, false));
387
+ }
388
+ const preserveGuide = (path: BezierGuide, curves: CubicBezier[]) =>
389
+ safe(curves, barriers)
390
+ ? toBezierGuide(path, curves)
391
+ : {
392
+ ...path,
393
+ path: path.path ?? pathFromPoints(path.points),
394
+ };
395
+ return {
396
+ source: preserveGuide(source, sourceGuide),
397
+ target: preserveGuide(target, targetGuide),
398
+ };
399
+ }
400
+
401
+ function endpointCandidates(endpoint: BezierEndpoint, toward: Point): Point[] {
402
+ if (endpoint.kind === "port") return [endpoint.escape];
403
+ const { bounds, sides } = endpoint;
404
+ const center = {
405
+ x: bounds.x + bounds.width / 2,
406
+ y: bounds.y + bounds.height / 2,
407
+ };
408
+ const dx = toward.x - center.x;
409
+ const dy = toward.y - center.y;
410
+ const scale = Math.min(
411
+ dx ? bounds.width / 2 / Math.abs(dx) : Infinity,
412
+ dy ? bounds.height / 2 / Math.abs(dy) : Infinity,
413
+ );
414
+ const ray = Number.isFinite(scale) ? offset(center, { x: dx, y: dy }, scale) : center;
415
+ const onSide = (point: Point, side: Direction) =>
416
+ side === "left"
417
+ ? Math.abs(point.x - bounds.x) < 1e-7
418
+ : side === "right"
419
+ ? Math.abs(point.x - bounds.x - bounds.width) < 1e-7
420
+ : side === "top"
421
+ ? Math.abs(point.y - bounds.y) < 1e-7
422
+ : Math.abs(point.y - bounds.y - bounds.height) < 1e-7;
423
+ const candidates = sides.some((side) => onSide(ray, side)) ? [ray] : [];
424
+ for (const side of sides) {
425
+ const horizontal = side === "top" || side === "bottom";
426
+ candidates.push({
427
+ x: horizontal
428
+ ? Math.max(bounds.x + bounds.width * 0.1, Math.min(toward.x, bounds.x + bounds.width * 0.9))
429
+ : side === "left"
430
+ ? bounds.x
431
+ : bounds.x + bounds.width,
432
+ y: horizontal
433
+ ? side === "top"
434
+ ? bounds.y
435
+ : bounds.y + bounds.height
436
+ : Math.max(
437
+ bounds.y + bounds.height * 0.1,
438
+ Math.min(toward.y, bounds.y + bounds.height * 0.9),
439
+ ),
440
+ });
441
+ }
442
+ return candidates;
443
+ }
444
+
445
+ function fitRun(
446
+ points: Point[],
447
+ departure: Point,
448
+ arrival: Point,
449
+ obstacles: readonly Rect[],
450
+ ): CubicBezier[] | undefined {
451
+ const first = points[0];
452
+ const last = points.at(-1);
453
+ if (!first || !last || points.length < 2) return undefined;
454
+ for (const factor of [1, 0.5, 0.25, 0.125]) {
455
+ const curve = simpleCurve(first, last, departure, arrival);
456
+ curve.control1 = mix(first, curve.control1, factor);
457
+ curve.control2 = mix(last, curve.control2, factor);
458
+ if (safe([curve], obstacles)) return [curve];
459
+ }
460
+ for (const factor of [0.8, 0.4, 0.2, 0.05, 0.005]) {
461
+ const result = points.slice(1).map((end, index) => {
462
+ const start = points[index]!;
463
+ const startTangent = index === 0 ? departure : unit(points[index - 1]!, end);
464
+ const endTangent = index === points.length - 2 ? arrival : unit(start, points[index + 2]!);
465
+ const handle = (distance(start, end) * factor) / 3;
466
+ return {
467
+ start,
468
+ control1: offset(start, startTangent, handle),
469
+ control2: offset(end, endTangent, -handle),
470
+ end,
471
+ };
472
+ });
473
+ if (safe(result, obstacles)) return result;
474
+ }
475
+ const straightRun = points.slice(1).map((end, index) => straight(points[index]!, end));
476
+ const parallel = (a: Point, b: Point) =>
477
+ Math.abs(a.x * b.y - a.y * b.x) < 1e-8 && a.x * b.x + a.y * b.y > 0;
478
+ return parallel(unit(first, points[1]!), departure) &&
479
+ parallel(unit(points.at(-2)!, last), arrival) &&
480
+ safe(straightRun, obstacles)
481
+ ? straightRun
482
+ : undefined;
483
+ }
484
+
485
+ export function fitBezierTransition(input: BezierRoutingInput): {
486
+ source: BezierGuide;
487
+ target: BezierGuide;
488
+ } {
489
+ const organic = tryFitOrganicTransition(input);
490
+ if (organic) return organic;
491
+ if (input.endpoints?.source.kind === "port")
492
+ return { source: input.source, target: input.target };
493
+ return fitSemanticTransition(input);
494
+ }
495
+
496
+ /** Floating attachments and shared label tangents use a visibility route. */
497
+ export function tryFitOrganicTransition(input: BezierRoutingInput):
498
+ | {
499
+ source: BezierGuide;
500
+ target: BezierGuide;
501
+ }
502
+ | undefined {
503
+ const { endpoints, source, target, label, avoidObstacles } = input;
504
+ if (!endpoints || !source.points.length || !target.points.length) return undefined;
505
+ const center = {
506
+ x: label.x + label.width / 2,
507
+ y: label.y + label.height / 2,
508
+ };
509
+ const terminalBounds = [
510
+ endpoints.target.bounds,
511
+ endpoints.source.kind === "floating" ? endpoints.source.bounds : endpoints.source.owner,
512
+ ];
513
+ const obstacles = avoidObstacles ? input.obstacles : terminalBounds;
514
+ const sourceCandidates = endpointCandidates(endpoints.source, center);
515
+ const targetCandidates = endpointCandidates(endpoints.target, center);
516
+ const sourcePoints = shortestVisibilityPath(sourceCandidates, center, obstacles, terminalBounds);
517
+ const targetPoints = shortestVisibilityPath(
518
+ targetCandidates,
519
+ center,
520
+ obstacles,
521
+ terminalBounds,
522
+ )?.reverse();
523
+ if (!sourcePoints || !targetPoints) return undefined;
524
+ const boundary = targetPoints.at(-1)!;
525
+ const targetArrival =
526
+ input.attachmentMode === "orthogonal"
527
+ ? offset(
528
+ { x: 0, y: 0 },
529
+ VECTORS[boundarySide(boundary, endpoints.target.bounds, endpoints.target.sides)],
530
+ -1,
531
+ )
532
+ : unit(targetPoints.at(-2)!, boundary);
533
+ const targetClearance = input.targetClearance ?? 10;
534
+ if (distance(targetPoints.at(-2)!, boundary) <= targetClearance) return undefined;
535
+ targetPoints[targetPoints.length - 1] = offset(boundary, targetArrival, -targetClearance);
536
+ const tangent = input.labelTangent
537
+ ? unit({ x: 0, y: 0 }, input.labelTangent)
538
+ : unit(sourcePoints.at(-2)!, targetPoints[1]!);
539
+ const departure =
540
+ endpoints.source.kind === "port"
541
+ ? VECTORS[endpoints.source.direction]
542
+ : input.attachmentMode === "orthogonal"
543
+ ? VECTORS[boundarySide(sourcePoints[0]!, endpoints.source.bounds, endpoints.source.sides)]
544
+ : unit(sourcePoints[0]!, sourcePoints[1]!);
545
+ const sourceCurves = fitRun(sourcePoints, departure, tangent, obstacles);
546
+ const targetCurves = fitRun(targetPoints, tangent, targetArrival, obstacles);
547
+ if (!sourceCurves || !targetCurves) return undefined;
548
+ if (endpoints.source.kind === "port") {
549
+ const port = endpoints.source;
550
+ const prefix = straight(port.point, port.escape);
551
+ const portEscapeAvoidsForeignObstacles = safe(
552
+ [prefix],
553
+ input.obstacles.filter(
554
+ (rect) =>
555
+ !(
556
+ rect.x === port.owner.x &&
557
+ rect.y === port.owner.y &&
558
+ rect.width === port.owner.width &&
559
+ rect.height === port.owner.height
560
+ ),
561
+ ),
562
+ );
563
+ if (avoidObstacles && !portEscapeAvoidsForeignObstacles) return undefined;
564
+ sourceCurves.unshift(prefix);
565
+ }
566
+ sourceCurves[sourceCurves.length - 1] = clipAtLabel(sourceCurves.at(-1)!, label, true);
567
+ targetCurves[0] = clipAtLabel(targetCurves[0]!, label, false);
568
+ return {
569
+ source: toBezierGuide(
570
+ {
571
+ ...source,
572
+ sourceDirection:
573
+ endpoints.source.kind === "port"
574
+ ? endpoints.source.direction
575
+ : boundarySide(sourceCurves[0]!.start, endpoints.source.bounds, endpoints.source.sides),
576
+ targetDirection: boundarySide(sourceCurves.at(-1)!.end, label),
577
+ },
578
+ sourceCurves,
579
+ ),
580
+ target: toBezierGuide(
581
+ {
582
+ ...target,
583
+ sourceDirection: boundarySide(targetCurves[0]!.start, label),
584
+ targetDirection: boundarySide(boundary, endpoints.target.bounds, endpoints.target.sides),
585
+ },
586
+ targetCurves,
587
+ ),
588
+ };
589
+ }
590
+
591
+ function boundarySide(
592
+ point: Point,
593
+ rect: Rect,
594
+ allowed: readonly Direction[] = ["left", "right", "top", "bottom"],
595
+ ): Direction {
596
+ const sides: [Direction, number][] = [
597
+ ["left", Math.abs(point.x - rect.x)],
598
+ ["right", Math.abs(point.x - rect.x - rect.width)],
599
+ ["top", Math.abs(point.y - rect.y)],
600
+ ["bottom", Math.abs(point.y - rect.y - rect.height)],
601
+ ];
602
+ return (
603
+ sides.filter(([side]) => allowed.includes(side)).sort((a, b) => a[1] - b[1])[0]?.[0] ?? "right"
604
+ );
605
+ }
@@ -0,0 +1,107 @@
1
+ import type { RouteBounds as Rect, RoutePoint as Point } from "./types";
2
+
3
+ const pointDistance = (a: Point, b: Point) => Math.hypot(b.x - a.x, b.y - a.y);
4
+
5
+ export function segmentAvoidsRect(a: Point, b: Point, rect: Rect): boolean {
6
+ let enter = 0;
7
+ let exit = 1;
8
+ for (const [start, delta, low, high] of [
9
+ [a.x, b.x - a.x, rect.x, rect.x + rect.width],
10
+ [a.y, b.y - a.y, rect.y, rect.y + rect.height],
11
+ ]) {
12
+ if (Math.abs(delta!) < 1e-12) {
13
+ if (start! <= low! || start! >= high!) return true;
14
+ continue;
15
+ }
16
+ const first = (low! - start!) / delta!;
17
+ const last = (high! - start!) / delta!;
18
+ enter = Math.max(enter, Math.min(first, last));
19
+ exit = Math.min(exit, Math.max(first, last));
20
+ if (enter >= exit - 1e-12) return true;
21
+ }
22
+ return enter >= exit - 1e-12;
23
+ }
24
+
25
+ // This multi-source search preserves attachment candidate order on ties and limits
26
+ // obstacle corners. The general router search has different attachment costs/budgets.
27
+ export function shortestVisibilityPath(
28
+ starts: readonly Point[],
29
+ end: Point,
30
+ obstacles: readonly Rect[],
31
+ terminalBounds: readonly Rect[],
32
+ ): Point[] | undefined {
33
+ const clear = (a: Point, b: Point, rects = obstacles) =>
34
+ rects.every((rect) => segmentAvoidsRect(a, b, rect));
35
+ let direct: Point | undefined;
36
+ for (const start of starts) {
37
+ if ((!direct || pointDistance(start, end) < pointDistance(direct, end)) && clear(start, end))
38
+ direct = start;
39
+ }
40
+ if (direct) return [direct, end];
41
+ if (!starts.length) return undefined;
42
+ const barriers = obstacles.map((rect) =>
43
+ terminalBounds.some(
44
+ (terminal) =>
45
+ terminal.x === rect.x &&
46
+ terminal.y === rect.y &&
47
+ terminal.width === rect.width &&
48
+ terminal.height === rect.height,
49
+ )
50
+ ? rect
51
+ : {
52
+ x: rect.x - 8,
53
+ y: rect.y - 8,
54
+ width: rect.width + 16,
55
+ height: rect.height + 16,
56
+ },
57
+ );
58
+ const relevance = (rect: Rect) =>
59
+ Math.min(
60
+ ...starts.map(
61
+ (start) =>
62
+ pointDistance(start, {
63
+ x: rect.x + rect.width / 2,
64
+ y: rect.y + rect.height / 2,
65
+ }) +
66
+ pointDistance(end, {
67
+ x: rect.x + rect.width / 2,
68
+ y: rect.y + rect.height / 2,
69
+ }),
70
+ ),
71
+ );
72
+ const corners = [...barriers]
73
+ .sort((a, b) => relevance(a) - relevance(b))
74
+ .slice(0, 48)
75
+ .flatMap((rect) => [
76
+ { x: rect.x, y: rect.y },
77
+ { x: rect.x + rect.width, y: rect.y },
78
+ { x: rect.x, y: rect.y + rect.height },
79
+ { x: rect.x + rect.width, y: rect.y + rect.height },
80
+ ]);
81
+ const points = [...starts, end, ...corners];
82
+ const costs = points.map((_, index) => (index < starts.length ? 0 : Infinity));
83
+ const previous = points.map(() => -1);
84
+ const visited = new Set<number>();
85
+ for (let step = 0; step < points.length; step++) {
86
+ let current = -1;
87
+ for (let index = 0; index < points.length; index++) {
88
+ if (!visited.has(index) && (current < 0 || costs[index]! < costs[current]!)) current = index;
89
+ }
90
+ if (current < 0 || !Number.isFinite(costs[current])) return undefined;
91
+ if (current === starts.length) {
92
+ const result: Point[] = [];
93
+ for (let index = current; index >= 0; index = previous[index]!) result.push(points[index]!);
94
+ return result.reverse();
95
+ }
96
+ visited.add(current);
97
+ for (let next = 0; next < points.length; next++) {
98
+ if (visited.has(next)) continue;
99
+ const candidate = costs[current]! + pointDistance(points[current]!, points[next]!);
100
+ if (candidate >= costs[next]!) continue;
101
+ if (!clear(points[current]!, points[next]!, barriers)) continue;
102
+ costs[next] = candidate;
103
+ previous[next] = current;
104
+ }
105
+ }
106
+ return undefined;
107
+ }