@statelyai/layout 0.4.3 → 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 (55) hide show
  1. package/README.md +32 -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-B5kN_zS6.mjs → elkjs-DBkalvt1.mjs} +3 -3
  11. package/dist/{elkjs-Cj-lWiK1.cjs → elkjs-DonMqGjf.cjs} +3 -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/layered/feedback-chains.ts +22 -4
  40. package/src/layered/index.ts +173 -41
  41. package/src/layered/north-south-ports.ts +17 -4
  42. package/src/layered/strategies.ts +143 -15
  43. package/src/routing/algorithms.ts +1436 -148
  44. package/src/routing/bezier-fit.ts +605 -0
  45. package/src/routing/bezier-visibility.ts +107 -0
  46. package/src/routing/bezier.ts +596 -0
  47. package/src/routing/engine.ts +65 -13
  48. package/src/routing/index.ts +14 -0
  49. package/src/routing/model.ts +185 -1
  50. package/src/routing/path.ts +1 -1
  51. package/src/routing/policy-geometry.ts +536 -0
  52. package/src/routing/search.ts +10 -2
  53. package/src/routing/types.ts +77 -1
  54. package/dist/routing-CR8flv8b.mjs +0 -2780
  55. package/dist/routing-QIdGGFBe.cjs +0 -2996
@@ -0,0 +1,536 @@
1
+ import { splitBezier } from "./bezier-fit";
2
+ import { arcCenter, distance, flattenPath, pathFromPoints, roundCorners } from "./path";
3
+ import type { RouteBounds, RoutePath, RoutePoint, RouteSegment } from "./types";
4
+
5
+ const subtract = (a: RoutePoint, b: RoutePoint): RoutePoint => ({ x: a.x - b.x, y: a.y - b.y });
6
+ const scale = (point: RoutePoint, factor: number): RoutePoint => ({
7
+ x: point.x * factor,
8
+ y: point.y * factor,
9
+ });
10
+ const add = (a: RoutePoint, b: RoutePoint): RoutePoint => ({ x: a.x + b.x, y: a.y + b.y });
11
+ const unit = (point: RoutePoint): RoutePoint => {
12
+ const length = Math.hypot(point.x, point.y);
13
+ return length ? scale(point, 1 / length) : { x: 0, y: 0 };
14
+ };
15
+ const interpolate = (a: RoutePoint, b: RoutePoint, t: number): RoutePoint =>
16
+ add(a, scale(subtract(b, a), t));
17
+
18
+ function distinctKnots(knots: readonly RoutePoint[], tangents: ReadonlyMap<number, RoutePoint>) {
19
+ const points: RoutePoint[] = [];
20
+ const directions = new Map<number, RoutePoint>();
21
+ for (const [index, point] of knots.entries()) {
22
+ const previous = points.at(-1);
23
+ if (!previous || distance(previous, point) > 0) points.push(point);
24
+ const direction = tangents.get(index);
25
+ if (direction) directions.set(points.length - 1, unit(direction));
26
+ }
27
+ if (!points.length) throw new RangeError("A path needs at least one point");
28
+ return { points, directions };
29
+ }
30
+
31
+ export function catmullRomPath(
32
+ knots: readonly RoutePoint[],
33
+ tangents: ReadonlyMap<number, RoutePoint> = new Map(),
34
+ ): RoutePath {
35
+ const { points, directions } = distinctKnots(knots, tangents);
36
+ const segments: RouteSegment[] = [];
37
+ for (let i = 0; i + 1 < points.length; i++) {
38
+ const p1 = points[i]!,
39
+ p2 = points[i + 1]!;
40
+ const p0 = points[i - 1] ?? add(p1, subtract(p1, p2));
41
+ const p3 = points[i + 2] ?? add(p2, subtract(p2, p1));
42
+ const a = Math.sqrt(distance(p0, p1));
43
+ const b = Math.sqrt(distance(p1, p2));
44
+ const c = Math.sqrt(distance(p2, p3));
45
+ const derivative = (
46
+ before: RoutePoint,
47
+ center: RoutePoint,
48
+ after: RoutePoint,
49
+ left: number,
50
+ right: number,
51
+ ) =>
52
+ scale(
53
+ add(
54
+ subtract(
55
+ scale(subtract(center, before), 1 / left),
56
+ scale(subtract(after, before), 1 / (left + right)),
57
+ ),
58
+ scale(subtract(after, center), 1 / right),
59
+ ),
60
+ b,
61
+ );
62
+ const startDirection = directions.get(i),
63
+ endDirection = directions.get(i + 1);
64
+ const startDerivative = startDirection
65
+ ? scale(startDirection, b * b)
66
+ : derivative(p0, p1, p2, a, b);
67
+ const endDerivative = endDirection ? scale(endDirection, b * b) : derivative(p1, p2, p3, b, c);
68
+ segments.push({
69
+ kind: "cubic",
70
+ control1: add(p1, scale(startDerivative, 1 / 3)),
71
+ control2: subtract(p2, scale(endDerivative, 1 / 3)),
72
+ to: p2,
73
+ });
74
+ }
75
+ return { start: points[0]!, segments };
76
+ }
77
+
78
+ export function bezierThroughPoints(
79
+ knots: readonly RoutePoint[],
80
+ tangents: ReadonlyMap<number, RoutePoint>,
81
+ curvature: number,
82
+ ): RoutePath {
83
+ const { points, directions } = distinctKnots(knots, tangents);
84
+ const tangentAt = (i: number): RoutePoint =>
85
+ directions.get(i) ?? unit(subtract(points[i + 1] ?? points[i]!, points[i - 1] ?? points[i]!));
86
+ return {
87
+ start: points[0]!,
88
+ segments: points.slice(1).map((to, i) => {
89
+ const from = points[i]!,
90
+ length = distance(from, to) * curvature;
91
+ return {
92
+ kind: "cubic",
93
+ control1: add(from, scale(tangentAt(i), length)),
94
+ control2: subtract(to, scale(tangentAt(i + 1), length)),
95
+ to,
96
+ };
97
+ }),
98
+ };
99
+ }
100
+
101
+ export function limitBezierHandles(path: RoutePath): RoutePath | undefined {
102
+ const limit = (from: RoutePoint, control: RoutePoint, to: RoutePoint) => {
103
+ let factor = 1;
104
+ for (const axis of ["x", "y"] as const) {
105
+ const delta = control[axis] - from[axis];
106
+ if (delta) factor = Math.min(factor, Math.max(0, (to[axis] - from[axis]) / delta));
107
+ }
108
+ return interpolate(from, control, factor);
109
+ };
110
+ let from = path.start;
111
+ let degenerate = false;
112
+ const result = {
113
+ start: path.start,
114
+ segments: path.segments.map((segment) => {
115
+ const result =
116
+ segment.kind === "cubic"
117
+ ? {
118
+ ...segment,
119
+ control1: limit(from, segment.control1, segment.to),
120
+ control2: limit(segment.to, segment.control2, from),
121
+ }
122
+ : segment;
123
+ if (
124
+ result.kind === "cubic" &&
125
+ (distance(from, result.control1) < 1e-6 || distance(result.to, result.control2) < 1e-6)
126
+ )
127
+ degenerate = true;
128
+ from = segment.to;
129
+ return result;
130
+ }),
131
+ };
132
+ return degenerate ? undefined : result;
133
+ }
134
+
135
+ export function roundedBezierPath(points: readonly RoutePoint[], radius: number): RoutePath {
136
+ const rounded = roundCorners(pathFromPoints(points), { radius });
137
+ let start = rounded.start;
138
+ return {
139
+ start,
140
+ segments: rounded.segments.map((segment) => {
141
+ const arc = segment.kind === "arc" ? arcCenter(start, segment) : undefined;
142
+ const geometry = segmentGeometry(start, segment);
143
+ const factor = arc ? ((4 / 3) * Math.tan(arc.delta / 4)) / arc.delta : 1 / 3;
144
+ const result: RouteSegment = {
145
+ kind: "cubic",
146
+ control1: add(start, scale(geometry.derivative(0), factor)),
147
+ control2: subtract(segment.to, scale(geometry.derivative(1), factor)),
148
+ to: segment.to,
149
+ };
150
+ start = segment.to;
151
+ return result;
152
+ }),
153
+ };
154
+ }
155
+
156
+ function segmentGeometry(start: RoutePoint, segment: RouteSegment) {
157
+ if (segment.kind === "arc") {
158
+ const arc = arcCenter(start, segment);
159
+ if (arc)
160
+ return {
161
+ point: (t: number): RoutePoint => {
162
+ if (t === 0) return start;
163
+ if (t === 1) return segment.to;
164
+ const angle = arc.theta + arc.delta * t;
165
+ return {
166
+ x: arc.center.x + arc.c * arc.rx * Math.cos(angle) - arc.s * arc.ry * Math.sin(angle),
167
+ y: arc.center.y + arc.s * arc.rx * Math.cos(angle) + arc.c * arc.ry * Math.sin(angle),
168
+ };
169
+ },
170
+ derivative: (t: number): RoutePoint => {
171
+ const angle = arc.theta + arc.delta * t;
172
+ return {
173
+ x: arc.delta * (-arc.c * arc.rx * Math.sin(angle) - arc.s * arc.ry * Math.cos(angle)),
174
+ y: arc.delta * (-arc.s * arc.rx * Math.sin(angle) + arc.c * arc.ry * Math.cos(angle)),
175
+ };
176
+ },
177
+ };
178
+ }
179
+ const controls =
180
+ segment.kind === "cubic"
181
+ ? [start, segment.control1, segment.control2, segment.to]
182
+ : segment.kind === "quadratic"
183
+ ? [start, segment.control, segment.to]
184
+ : [start, segment.to];
185
+ function evaluate(points: readonly RoutePoint[], t: number): RoutePoint {
186
+ let row = [...points];
187
+ while (row.length > 1) row = row.slice(1).map((point, i) => interpolate(row[i]!, point, t));
188
+ return row[0]!;
189
+ }
190
+ const derivatives = controls
191
+ .slice(1)
192
+ .map((point, i) => scale(subtract(point, controls[i]!), controls.length - 1));
193
+ return {
194
+ point: (t: number) => evaluate(controls, t),
195
+ derivative: (t: number) => evaluate(derivatives, t),
196
+ };
197
+ }
198
+
199
+ function endpointDirection(
200
+ start: RoutePoint,
201
+ segment: RouteSegment,
202
+ at: "start" | "end",
203
+ ): RoutePoint {
204
+ const derivative = segmentGeometry(start, segment).derivative(at === "start" ? 0 : 1);
205
+ if (Math.hypot(derivative.x, derivative.y)) return unit(derivative);
206
+ const controls =
207
+ segment.kind === "cubic"
208
+ ? [start, segment.control1, segment.control2, segment.to]
209
+ : segment.kind === "quadratic"
210
+ ? [start, segment.control, segment.to]
211
+ : [start, segment.to];
212
+ const endpoint = at === "start" ? start : segment.to;
213
+ for (const point of at === "start" ? controls : controls.toReversed()) {
214
+ const direction = at === "start" ? subtract(point, endpoint) : subtract(endpoint, point);
215
+ if (Math.hypot(direction.x, direction.y)) return unit(direction);
216
+ }
217
+ return { x: 0, y: 0 };
218
+ }
219
+
220
+ export function pathEndpointTangent(path: RoutePath, at: "start" | "end"): RoutePoint {
221
+ const segments = path.segments.map((segment, i) => ({
222
+ start: i ? path.segments[i - 1]!.to : path.start,
223
+ segment,
224
+ }));
225
+ for (const { start, segment } of at === "start" ? segments : segments.toReversed()) {
226
+ const direction = endpointDirection(start, segment, at);
227
+ if (Math.hypot(direction.x, direction.y)) return direction;
228
+ }
229
+ return { x: 0, y: 0 };
230
+ }
231
+
232
+ export function pathHasSelfIntersection(path: RoutePath): boolean {
233
+ let start = path.start;
234
+ for (const segment of path.segments) {
235
+ if (segment.kind === "cubic") {
236
+ const a = add(
237
+ subtract(segment.to, scale(segment.control2, 3)),
238
+ subtract(scale(segment.control1, 3), start),
239
+ );
240
+ const b = scale(add(subtract(start, scale(segment.control1, 2)), segment.control2), 3);
241
+ const c = scale(subtract(segment.control1, start), 3);
242
+ const cross = (p: RoutePoint, q: RoutePoint) => p.x * q.y - p.y * q.x;
243
+ const determinant = cross(a, b);
244
+ if (Math.abs(determinant) > 1e-10) {
245
+ const sum = -cross(a, c) / determinant;
246
+ const product =
247
+ sum * sum +
248
+ (Math.abs(a.x) > Math.abs(a.y) ? (b.x * sum + c.x) / a.x : (b.y * sum + c.y) / a.y);
249
+ const discriminant = sum * sum - 4 * product;
250
+ if (discriminant > 1e-12) {
251
+ const root = Math.sqrt(discriminant);
252
+ if ((sum - root) / 2 > 1e-8 && (sum + root) / 2 < 1 - 1e-8) return true;
253
+ }
254
+ }
255
+ }
256
+ start = segment.to;
257
+ }
258
+ const points = flattenPath(path, { tolerance: 0.1 });
259
+ for (let i = 1; i < points.length; i++) {
260
+ const a = points[i - 1]!,
261
+ b = points[i]!;
262
+ for (let j = i + 2; j < points.length; j++) {
263
+ const c = points[j - 1]!,
264
+ d = points[j]!;
265
+ const ab = subtract(b, a),
266
+ cd = subtract(d, c),
267
+ ac = subtract(c, a);
268
+ const cross = ab.x * cd.y - ab.y * cd.x;
269
+ if (Math.abs(cross) <= 1e-10) continue;
270
+ const t = (ac.x * cd.y - ac.y * cd.x) / cross;
271
+ const u = (ac.x * ab.y - ac.y * ab.x) / cross;
272
+ if (t > 1e-8 && t < 1 - 1e-8 && u > 1e-8 && u < 1 - 1e-8) return true;
273
+ }
274
+ }
275
+ return false;
276
+ }
277
+
278
+ export function pathCurveOvershoot(path: RoutePath): number {
279
+ let start = path.start,
280
+ overshoot = 0;
281
+ for (const segment of path.segments) {
282
+ overshoot += pathReverseTravel({ start, segments: [segment] }, "both", start, segment.to);
283
+ start = segment.to;
284
+ }
285
+ return overshoot;
286
+ }
287
+
288
+ function quadraticRoots(a: number, b: number, c: number): number[] {
289
+ if (a === 0) return b === 0 ? [] : [-c / b];
290
+ const discriminant = b * b - 4 * a * c;
291
+ if (discriminant < 0) return [];
292
+ if (discriminant === 0) return [-b / (2 * a)];
293
+ const q = -0.5 * (b + (b < 0 ? -1 : 1) * Math.sqrt(discriminant));
294
+ return [q / a, c / q];
295
+ }
296
+
297
+ function extrema(start: RoutePoint, segment: RouteSegment, axis: "x" | "y"): number[] {
298
+ const p0 = start[axis],
299
+ p3 = segment.to[axis];
300
+ if (segment.kind === "cubic") {
301
+ const p1 = segment.control1[axis],
302
+ p2 = segment.control2[axis];
303
+ return quadraticRoots(-p0 + 3 * p1 - 3 * p2 + p3, 2 * (p0 - 2 * p1 + p2), p1 - p0).filter(
304
+ (t) => t > 0 && t < 1,
305
+ );
306
+ }
307
+ if (segment.kind === "quadratic") {
308
+ const denominator = p0 - 2 * segment.control[axis] + p3;
309
+ const t = denominator ? (p0 - segment.control[axis]) / denominator : -1;
310
+ return t > 0 && t < 1 ? [t] : [];
311
+ }
312
+ if (segment.kind !== "arc") return [];
313
+ const arc = arcCenter(start, segment);
314
+ if (!arc) return [];
315
+ const cosine = axis === "x" ? arc.c * arc.rx : arc.s * arc.rx;
316
+ const sine = axis === "x" ? -arc.s * arc.ry : arc.c * arc.ry;
317
+ const first = Math.atan2(sine, cosine);
318
+ const low = Math.min(arc.theta, arc.theta + arc.delta),
319
+ high = Math.max(arc.theta, arc.theta + arc.delta);
320
+ const result: number[] = [];
321
+ for (let k = Math.ceil((low - first) / Math.PI); first + k * Math.PI < high; k++) {
322
+ const t = (first + k * Math.PI - arc.theta) / arc.delta;
323
+ if (t > 0 && t < 1) result.push(t);
324
+ }
325
+ return result;
326
+ }
327
+
328
+ export function pathReverseTravel(
329
+ path: RoutePath,
330
+ axis: "x" | "y" | "both",
331
+ start: RoutePoint,
332
+ end: RoutePoint,
333
+ ): number {
334
+ let reverse = 0,
335
+ from = path.start;
336
+ const axes: readonly ("x" | "y")[] = axis === "both" ? ["x", "y"] : [axis];
337
+ for (const segment of path.segments) {
338
+ const geometry = segmentGeometry(from, segment);
339
+ for (const coordinate of axes) {
340
+ const orientation = Math.sign(end[coordinate] - start[coordinate]);
341
+ const cuts = [0, ...extrema(from, segment, coordinate).sort((a, b) => a - b), 1];
342
+ for (let i = 1; i < cuts.length; i++) {
343
+ const change =
344
+ geometry.point(cuts[i]!)[coordinate] - geometry.point(cuts[i - 1]!)[coordinate];
345
+ reverse += orientation ? Math.max(0, -orientation * change) : Math.abs(change);
346
+ }
347
+ }
348
+ from = segment.to;
349
+ }
350
+ return reverse;
351
+ }
352
+
353
+ function integrateSpeed(derivative: (t: number) => RoutePoint, end = 1): number {
354
+ const speed = (t: number) => {
355
+ const d = derivative(t);
356
+ return Math.hypot(d.x, d.y);
357
+ };
358
+ function integrate(
359
+ a: number,
360
+ b: number,
361
+ fa: number,
362
+ fm: number,
363
+ fb: number,
364
+ estimate: number,
365
+ tolerance: number,
366
+ depth: number,
367
+ ): number {
368
+ const m = (a + b) / 2,
369
+ leftMid = speed((a + m) / 2),
370
+ rightMid = speed((m + b) / 2);
371
+ const left = ((m - a) * (fa + 4 * leftMid + fm)) / 6;
372
+ const right = ((b - m) * (fm + 4 * rightMid + fb)) / 6;
373
+ if (depth === 0 || Math.abs(left + right - estimate) <= 15 * tolerance)
374
+ return left + right + (left + right - estimate) / 15;
375
+ return (
376
+ integrate(a, m, fa, leftMid, fm, left, tolerance / 2, depth - 1) +
377
+ integrate(m, b, fm, rightMid, fb, right, tolerance / 2, depth - 1)
378
+ );
379
+ }
380
+ const fa = speed(0),
381
+ fm = speed(end / 2),
382
+ fb = speed(end),
383
+ estimate = (end * (fa + 4 * fm + fb)) / 6;
384
+ return integrate(0, end, fa, fm, fb, estimate, 1e-8 * Math.max(1, estimate), 20);
385
+ }
386
+
387
+ export function placeFollowingLabel(
388
+ paths: readonly RoutePath[],
389
+ size: { width: number; height: number },
390
+ policy: { fraction?: number; offset?: number },
391
+ ): { bounds: RouteBounds; anchor: RoutePoint; tangent: RoutePoint } {
392
+ if (!paths.length) throw new RangeError("Label placement needs at least one path");
393
+ const intervals = paths
394
+ .flatMap((path) =>
395
+ path.segments.map((segment, i) => {
396
+ const start = i ? path.segments[i - 1]!.to : path.start;
397
+ const geometry = segmentGeometry(start, segment);
398
+ return { start, segment, geometry, length: integrateSpeed(geometry.derivative) };
399
+ }),
400
+ )
401
+ .filter((interval) => interval.length > 0);
402
+ let remaining =
403
+ intervals.reduce((sum, interval) => sum + interval.length, 0) * (policy.fraction ?? 0.5);
404
+ let anchor = paths[0]!.start,
405
+ tangent: RoutePoint = { x: 0, y: 0 };
406
+ for (const [index, interval] of intervals.entries()) {
407
+ if (remaining > interval.length && index < intervals.length - 1) {
408
+ remaining -= interval.length;
409
+ continue;
410
+ }
411
+ let low = 0,
412
+ high = 1;
413
+ for (let i = 0; i < 36 && remaining > 0 && remaining < interval.length; i++) {
414
+ const mid = (low + high) / 2;
415
+ if (integrateSpeed(interval.geometry.derivative, mid) < remaining) low = mid;
416
+ else high = mid;
417
+ }
418
+ const t = remaining <= 0 ? 0 : remaining >= interval.length ? 1 : (low + high) / 2;
419
+ anchor = interval.geometry.point(t);
420
+ tangent = unit(interval.geometry.derivative(t));
421
+ if (!Math.hypot(tangent.x, tangent.y))
422
+ tangent = endpointDirection(interval.start, interval.segment, t < 0.5 ? "start" : "end");
423
+ break;
424
+ }
425
+ const offset = policy.offset ?? 0;
426
+ anchor = { x: anchor.x - tangent.y * offset, y: anchor.y + tangent.x * offset };
427
+ return {
428
+ bounds: { x: anchor.x - size.width / 2, y: anchor.y - size.height / 2, ...size },
429
+ anchor,
430
+ tangent,
431
+ };
432
+ }
433
+
434
+ function segmentSlice(
435
+ start: RoutePoint,
436
+ segment: RouteSegment,
437
+ low: number,
438
+ high: number,
439
+ ): { start: RoutePoint; segment: RouteSegment } {
440
+ const geometry = segmentGeometry(start, segment);
441
+ if (low === 0 && high === 1) return { start, segment };
442
+ if (segment.kind === "cubic") {
443
+ const left = splitBezier(
444
+ { start, control1: segment.control1, control2: segment.control2, end: segment.to },
445
+ high,
446
+ )[0];
447
+ const part = low ? splitBezier(left, low / high)[1] : left;
448
+ return {
449
+ start: part.start,
450
+ segment: { kind: "cubic", control1: part.control1, control2: part.control2, to: part.end },
451
+ };
452
+ }
453
+ if (segment.kind === "quadratic") {
454
+ const leftControl = interpolate(start, segment.control, high);
455
+ const leftEnd = geometry.point(high);
456
+ return {
457
+ start: geometry.point(low),
458
+ segment: {
459
+ kind: "quadratic",
460
+ control: interpolate(leftControl, leftEnd, low / high),
461
+ to: leftEnd,
462
+ },
463
+ };
464
+ }
465
+ if (segment.kind === "arc") {
466
+ const arc = arcCenter(start, segment);
467
+ if (arc)
468
+ return {
469
+ start: geometry.point(low),
470
+ segment: {
471
+ ...segment,
472
+ rx: arc.rx,
473
+ ry: arc.ry,
474
+ largeArc: Math.abs(arc.delta * (high - low)) > Math.PI,
475
+ to: geometry.point(high),
476
+ },
477
+ };
478
+ }
479
+ return { start: geometry.point(low), segment: { kind: "line", to: geometry.point(high) } };
480
+ }
481
+
482
+ export function clipPathOutsideRect(path: RoutePath, rect: RouteBounds): RoutePath[] {
483
+ const paths: RoutePath[] = [];
484
+ let current: { start: RoutePoint; segments: RouteSegment[] } | undefined;
485
+ let from = path.start;
486
+ const inside = (point: RoutePoint) =>
487
+ point.x > rect.x &&
488
+ point.x < rect.x + rect.width &&
489
+ point.y > rect.y &&
490
+ point.y < rect.y + rect.height;
491
+ for (const segment of path.segments) {
492
+ const geometry = segmentGeometry(from, segment);
493
+ const cuts = [0, 1];
494
+ for (const axis of ["x", "y"] as const) {
495
+ const monotone = [0, ...extrema(from, segment, axis).sort((a, b) => a - b), 1];
496
+ for (const border of [rect[axis], rect[axis] + (axis === "x" ? rect.width : rect.height)]) {
497
+ for (let i = 1; i < monotone.length; i++) {
498
+ let low = monotone[i - 1]!,
499
+ high = monotone[i]!;
500
+ const a = geometry.point(low)[axis] - border,
501
+ b = geometry.point(high)[axis] - border;
502
+ if (a === 0) cuts.push(low);
503
+ if (b === 0) cuts.push(high);
504
+ if (a * b >= 0) continue;
505
+ for (let iteration = 0; iteration < 48; iteration++) {
506
+ const mid = (low + high) / 2,
507
+ value = geometry.point(mid)[axis] - border;
508
+ if (a < 0 === value < 0) low = mid;
509
+ else high = mid;
510
+ }
511
+ cuts.push((low + high) / 2);
512
+ }
513
+ }
514
+ }
515
+ const sorted = cuts
516
+ .sort((a, b) => a - b)
517
+ .filter((t, i, values) => !i || t - values[i - 1]! > 1e-12);
518
+ for (let i = 1; i < sorted.length; i++) {
519
+ const low = sorted[i - 1]!,
520
+ high = sorted[i]!;
521
+ if (inside(geometry.point((low + high) / 2))) {
522
+ current = undefined;
523
+ continue;
524
+ }
525
+ const part = segmentSlice(from, segment, low, high);
526
+ if (!current) {
527
+ current = { start: part.start, segments: [] };
528
+ paths.push(current);
529
+ }
530
+ current.segments.push(part.segment);
531
+ }
532
+ from = segment.to;
533
+ }
534
+ if (!path.segments.length && !inside(path.start)) return [path];
535
+ return paths;
536
+ }
@@ -6,6 +6,7 @@ import type { RouteBounds, RoutePoint } from "./types";
6
6
  export interface SearchContext {
7
7
  obstacles(bounds: RouteBounds): readonly RouteBounds[];
8
8
  edgeCost?(a: RoutePoint, b: RoutePoint): number;
9
+ allowsStep?(a: RoutePoint, b: RoutePoint): boolean;
9
10
  guides?(bounds: RouteBounds): readonly RouteBounds[];
10
11
  readonly maxSearchNodes: number;
11
12
  readonly maxGridNodes?: number;
@@ -110,6 +111,7 @@ export function findPath(
110
111
  (style === "polyline" ||
111
112
  aligned ||
112
113
  (style === "octilinear" && Math.abs(start.x - end.x) === Math.abs(start.y - end.y))) &&
114
+ (context.allowsStep?.(start, end) ?? true) &&
113
115
  clear(start, end, context) &&
114
116
  attachmentCost([start, end]) === 0 &&
115
117
  (context.edgeCost?.(start, end) ?? 0) === 0
@@ -120,7 +122,13 @@ export function findPath(
120
122
  { x: start.x, y: end.y },
121
123
  { x: end.x, y: start.y },
122
124
  ]
123
- .filter((bend) => clear(start, bend, context) && clear(bend, end, context))
125
+ .filter(
126
+ (bend) =>
127
+ (context.allowsStep?.(start, bend) ?? true) &&
128
+ (context.allowsStep?.(bend, end) ?? true) &&
129
+ clear(start, bend, context) &&
130
+ clear(bend, end, context),
131
+ )
124
132
  .map((bend) => simplify([start, bend, end]));
125
133
  candidates.sort((a, b) => attachmentCost(a) - attachmentCost(b));
126
134
  if (
@@ -276,7 +284,7 @@ export function findPath(
276
284
  for (const next of neighbors(current.index)) {
277
285
  if (next === current.index || next === current.previous) continue;
278
286
  const b = points[next]!;
279
- if (!visible(current.index, next)) continue;
287
+ if (!(context.allowsStep?.(a, b) ?? true) || !visible(current.index, next)) continue;
280
288
  const before =
281
289
  current.previous < 0 ? terminals.incoming : delta(points[current.previous]!, a);
282
290
  const step = delta(a, b);
@@ -1,4 +1,6 @@
1
1
  import type { EntityRect, Graph, GraphDiff, Point } from "@statelyai/graph";
2
+ import type { BezierEndpoint, BezierGuide } from "./bezier-fit";
3
+ export type { BezierEndpoint, BezierGuide } from "./bezier-fit";
2
4
 
3
5
  export type RoutePoint = Readonly<Point>;
4
6
  export type RouteSegment =
@@ -32,6 +34,8 @@ export interface RouteSection {
32
34
  readonly from: RouteEndpoint;
33
35
  readonly to: RouteEndpoint;
34
36
  readonly path: RoutePath;
37
+ readonly fromSide?: RouteSide;
38
+ readonly toSide?: RouteSide;
35
39
  /** Same ID denotes a shared trunk; render it once. */
36
40
  readonly sharedId?: string;
37
41
  }
@@ -41,13 +45,30 @@ export type RoutingDiagnosticCode =
41
45
  | "ROUTE_CONFLICT"
42
46
  | "ROUTE_BLOCKED"
43
47
  | "SEARCH_BUDGET"
44
- | "CONSTRAINT_VIOLATION";
48
+ | "CONSTRAINT_VIOLATION"
49
+ | "LABEL_OVERLAP";
45
50
  export interface RoutingDiagnostic {
46
51
  readonly code: RoutingDiagnosticCode;
47
52
  readonly edgeId: string;
48
53
  readonly message: string;
54
+ readonly constraint?:
55
+ | "source-tangent"
56
+ | "target-tangent"
57
+ | "label-tangent"
58
+ | "waypoints"
59
+ | "node-clearance"
60
+ | "label-clearance"
61
+ | "monotonicity"
62
+ | "bundle";
63
+ readonly relatedIds?: readonly string[];
64
+ }
65
+ export interface RouteLabelPlacement {
66
+ readonly bounds: RouteBounds;
67
+ readonly anchor: RoutePoint;
68
+ readonly tangent: RoutePoint;
49
69
  }
50
70
  export interface Route {
71
+ readonly label?: RouteLabelPlacement;
51
72
  readonly edgeId: string;
52
73
  readonly sections: readonly RouteSection[];
53
74
  readonly status: "routed" | "fallback";
@@ -75,7 +96,59 @@ export interface RouteAttachment {
75
96
  readonly bounds: RouteBounds;
76
97
  readonly facing?: "inward" | "outward";
77
98
  }
99
+ export interface BezierRoutingOptions {
100
+ readonly avoidObstacles?: boolean;
101
+ readonly attachmentMode?: "floating" | "orthogonal";
102
+ readonly targetClearance?: number;
103
+ }
104
+ export interface BezierEdgeSettings extends BezierRoutingOptions {
105
+ readonly source?: BezierEndpoint;
106
+ readonly target?: Extract<BezierEndpoint, { kind: "floating" }>;
107
+ readonly guides?: { readonly source: BezierGuide; readonly target: BezierGuide };
108
+ readonly obstacles?: readonly RouteBounds[];
109
+ readonly terminalBounds?: readonly RouteBounds[];
110
+ readonly labelTangent?: RoutePoint;
111
+ readonly disabled?: boolean;
112
+ }
113
+ export type RoutingTangent =
114
+ | { readonly kind: "automatic" }
115
+ | { readonly kind: "border-normal" }
116
+ | { readonly kind: "vector"; readonly direction: RoutePoint };
117
+ export type RoutingCurve =
118
+ | { readonly kind: "polyline" }
119
+ | { readonly kind: "bezier"; readonly curvature?: number }
120
+ | { readonly kind: "catmull-rom" }
121
+ | { readonly kind: "rounded-orthogonal"; readonly radius?: number };
122
+ export type RoutingLabel =
123
+ | { readonly kind: "follow"; readonly fraction?: number; readonly offset?: number }
124
+ | { readonly kind: "fixed" }
125
+ | { readonly kind: "through"; readonly tangent?: RoutingTangent }
126
+ | { readonly kind: "gap"; readonly tangent?: RoutingTangent };
127
+ export interface RoutingPolicy {
128
+ readonly curve?: RoutingCurve;
129
+ readonly sourceTangent?: RoutingTangent;
130
+ readonly targetTangent?: RoutingTangent;
131
+ readonly label?: RoutingLabel;
132
+ readonly collisions?: {
133
+ readonly nodes?: "avoid" | "allow";
134
+ readonly labels?: "avoid" | "allow";
135
+ readonly crossingPenalty?: number;
136
+ readonly overlapPenalty?: number;
137
+ };
138
+ readonly monotonic?: {
139
+ readonly axis: "x" | "y" | "both";
140
+ readonly strength: "prefer" | "require";
141
+ };
142
+ readonly stability?: { readonly kind: "recompute" | "preserve-valid" };
143
+ }
78
144
  export interface EdgeRoutingSettings {
145
+ readonly policy?: RoutingPolicy;
146
+ /** Resolve the source from this edge label in the current graph geometry. */
147
+ readonly sourceLabel?: string;
148
+ readonly sourceSides?: readonly RouteSide[];
149
+ readonly targetSides?: readonly RouteSide[];
150
+ readonly labelSides?: readonly RouteSide[];
151
+ readonly bezier?: BezierEdgeSettings;
79
152
  readonly sourceAttachment?: RouteAttachment;
80
153
  readonly targetAttachment?: RouteAttachment;
81
154
  readonly sourceSide?: RouteSide;
@@ -85,6 +158,9 @@ export interface EdgeRoutingSettings {
85
158
  readonly group?: string;
86
159
  }
87
160
  export interface RoutingSettings {
161
+ readonly policy?: RoutingPolicy;
162
+ /** Opt into center-tangent Bézier routing and floating attachments. */
163
+ readonly bezier?: BezierRoutingOptions;
88
164
  /** Native compound geometry is parent-relative; adapters may supply resolved world geometry. */
89
165
  readonly coordinateSpace?: "parent" | "world";
90
166
  readonly clearance?: number;