@statelyai/layout 0.2.0 → 0.3.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 (67) hide show
  1. package/README.md +71 -2
  2. package/dist/elkjs/index.cjs +3 -2
  3. package/dist/elkjs/index.d.cts +11 -2
  4. package/dist/elkjs/index.d.mts +11 -2
  5. package/dist/elkjs/index.mjs +3 -3
  6. package/dist/elkjs/main.cjs +2 -2
  7. package/dist/elkjs/main.d.cts +2 -2
  8. package/dist/elkjs/main.d.mts +2 -2
  9. package/dist/elkjs/main.mjs +2 -2
  10. package/dist/elkjs/worker-api.cjs +1 -1
  11. package/dist/elkjs/worker-api.d.cts +2 -2
  12. package/dist/elkjs/worker-api.d.mts +2 -2
  13. package/dist/elkjs/worker-api.mjs +1 -1
  14. package/dist/elkjs/worker.cjs +2 -2
  15. package/dist/elkjs/worker.mjs +2 -2
  16. package/dist/{elkjs-CFlfkEBz.mjs → elkjs-DaHMISnQ.mjs} +152 -3
  17. package/dist/{elkjs-BVnDwnBQ.cjs → elkjs-dMghiiWW.cjs} +157 -2
  18. package/dist/index-BEA_pzeT.d.cts +72 -0
  19. package/dist/{index-BSH2bloA.d.mts → index-D9dhxrRl.d.cts} +35 -3
  20. package/dist/index-DNTLRsDl.d.mts +72 -0
  21. package/dist/{index-CG712jzV.d.cts → index-DNixtE3h.d.mts} +35 -3
  22. package/dist/index.cjs +36 -9
  23. package/dist/index.d.cts +4 -3
  24. package/dist/index.d.mts +4 -3
  25. package/dist/index.mjs +4 -3
  26. package/dist/layered/index.cjs +1 -1
  27. package/dist/layered/index.d.cts +3 -2
  28. package/dist/layered/index.d.mts +3 -2
  29. package/dist/layered/index.mjs +1 -1
  30. package/dist/{layered-Cf9Rn8l7.cjs → layered-B2buJqMf.cjs} +417 -469
  31. package/dist/{layered-Bz2nWmVp.mjs → layered-DGFTeQSl.mjs} +391 -431
  32. package/dist/{layout-engine-WbC0YjjT.mjs → layout-engine-Cs5tatA6.mjs} +1 -1
  33. package/dist/{layout-engine-BNex9jxg.cjs → layout-engine-Dn1PiFwP.cjs} +1 -1
  34. package/dist/routing/index.cjs +28 -0
  35. package/dist/routing/index.d.cts +3 -0
  36. package/dist/routing/index.d.mts +3 -0
  37. package/dist/routing/index.mjs +3 -0
  38. package/dist/routing-A2hrK9MP.mjs +2396 -0
  39. package/dist/routing-ujEP7Qh6.cjs +2606 -0
  40. package/dist/types-CT2akdK0.d.mts +133 -0
  41. package/dist/types-uxPlGRof.d.cts +133 -0
  42. package/dist/{worker-client-DAT_z2OR.d.mts → worker-client-BwGGRD5y.d.mts} +1 -1
  43. package/dist/{worker-client-DVhklF6A.d.cts → worker-client-D4zNyPEI.d.cts} +1 -1
  44. package/package.json +7 -2
  45. package/src/authoring/coordinates.ts +10 -2
  46. package/src/elkjs/index.ts +5 -3
  47. package/src/elkjs/routes.ts +147 -0
  48. package/src/index.ts +6 -0
  49. package/src/layered/compound.ts +417 -0
  50. package/src/layered/index.ts +27 -102
  51. package/src/layered/types.ts +21 -0
  52. package/src/routing/adapters.ts +111 -0
  53. package/src/routing/algorithms.ts +676 -0
  54. package/src/routing/coordination.ts +86 -0
  55. package/src/routing/engine.ts +473 -0
  56. package/src/routing/index.ts +68 -0
  57. package/src/routing/layout-cache.ts +65 -0
  58. package/src/routing/model.ts +218 -0
  59. package/src/routing/path.ts +324 -0
  60. package/src/routing/persistent.ts +117 -0
  61. package/src/routing/search.ts +276 -0
  62. package/src/routing/spatial.ts +78 -0
  63. package/src/routing/types.ts +146 -0
  64. /package/dist/{public-types-B8rAALJq.d.mts → public-types-9NCPoqbp.d.mts} +0 -0
  65. /package/dist/{public-types-CVB0hXGk.d.cts → public-types-BcRZoJE3.d.cts} +0 -0
  66. /package/dist/{worker-client-DkwS4sHx.mjs → worker-client-BNjwT1AJ.mjs} +0 -0
  67. /package/dist/{worker-client-D3HMK33r.cjs → worker-client-D4oEvhAl.cjs} +0 -0
@@ -0,0 +1,218 @@
1
+ import type { GraphEdge } from "@statelyai/graph";
2
+ import { PersistentMap } from "./persistent";
3
+ import { SpatialIndex } from "./spatial";
4
+ import type { Route, RouteBounds, RoutePoint, RoutingSettings, RouteSide } from "./types";
5
+ export interface NodeGeometry {
6
+ readonly id: string;
7
+ readonly parentId?: string | null;
8
+ readonly x?: number;
9
+ readonly y?: number;
10
+ readonly width?: number;
11
+ readonly height?: number;
12
+ readonly ports?: readonly {
13
+ readonly name: string;
14
+ readonly x?: number;
15
+ readonly y?: number;
16
+ readonly width?: number;
17
+ readonly height?: number;
18
+ }[];
19
+ }
20
+ export interface EdgeGeometry {
21
+ readonly id: string;
22
+ readonly sourceId: string;
23
+ readonly targetId: string;
24
+ readonly sourcePort?: string;
25
+ readonly targetPort?: string;
26
+ readonly x?: number;
27
+ readonly y?: number;
28
+ readonly width?: number;
29
+ readonly height?: number;
30
+ }
31
+ export interface Settings {
32
+ readonly coordinateSpace: "parent" | "world";
33
+ readonly clearance: number;
34
+ readonly radius: number;
35
+ readonly edgeSpacing: number;
36
+ readonly bendPenalty: number;
37
+ readonly crossingPenalty: number;
38
+ readonly overlapPenalty: number;
39
+ readonly maxSearchNodes: number;
40
+ readonly organicIterations: number;
41
+ readonly edges: NonNullable<RoutingSettings["edges"]>;
42
+ }
43
+ export function settings(input: RoutingSettings = {}): Settings {
44
+ const result = {
45
+ coordinateSpace: input.coordinateSpace ?? "parent",
46
+ clearance: input.clearance ?? 8,
47
+ radius: input.radius ?? 10,
48
+ edgeSpacing: input.edgeSpacing ?? 12,
49
+ bendPenalty: input.bendPenalty ?? 10,
50
+ crossingPenalty: input.crossingPenalty ?? 80,
51
+ overlapPenalty: input.overlapPenalty ?? 8,
52
+ maxSearchNodes: input.maxSearchNodes ?? 4000,
53
+ organicIterations: input.organicIterations ?? 12,
54
+ edges: structuredClone(input.edges ?? {}),
55
+ };
56
+ for (const [key, value] of Object.entries(result))
57
+ if (typeof value === "number" && (!Number.isFinite(value) || value < 0))
58
+ throw new RangeError(`${key} must be finite and non-negative`);
59
+ if (
60
+ !Number.isInteger(result.maxSearchNodes) ||
61
+ result.maxSearchNodes < 1 ||
62
+ !Number.isInteger(result.organicIterations)
63
+ )
64
+ throw new RangeError("Search and iteration budgets must be integers");
65
+ return freeze(result);
66
+ }
67
+ export function freeze<T>(value: T): T {
68
+ if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
69
+ for (const child of Object.values(value)) freeze(child);
70
+ Object.freeze(value);
71
+ }
72
+ return value;
73
+ }
74
+ export function nodeGeometry(n: NodeGeometry): NodeGeometry {
75
+ return freeze({
76
+ id: n.id,
77
+ parentId: n.parentId,
78
+ x: n.x,
79
+ y: n.y,
80
+ width: n.width,
81
+ height: n.height,
82
+ ports: n.ports?.map((p) => ({
83
+ name: p.name,
84
+ x: p.x,
85
+ y: p.y,
86
+ width: p.width,
87
+ height: p.height,
88
+ })),
89
+ });
90
+ }
91
+ export function edgeGeometry(e: Partial<GraphEdge<unknown>> & { id: string }): EdgeGeometry {
92
+ return freeze({
93
+ id: e.id,
94
+ sourceId: e.sourceId ?? "",
95
+ targetId: e.targetId ?? "",
96
+ sourcePort: e.sourcePort,
97
+ targetPort: e.targetPort,
98
+ x: e.x,
99
+ y: e.y,
100
+ width: e.width,
101
+ height: e.height,
102
+ });
103
+ }
104
+ export function rect(value: {
105
+ readonly x?: number;
106
+ readonly y?: number;
107
+ readonly width?: number;
108
+ readonly height?: number;
109
+ }): RouteBounds | undefined {
110
+ const { x, y, width, height } = value;
111
+ return [x, y, width, height].every((n) => typeof n === "number" && Number.isFinite(n)) &&
112
+ width! >= 0 &&
113
+ height! >= 0
114
+ ? { x: x!, y: y!, width: width!, height: height! }
115
+ : undefined;
116
+ }
117
+ export interface State {
118
+ readonly graphId: string;
119
+ readonly direction: string | undefined;
120
+ readonly settings: Settings;
121
+ readonly nodes: PersistentMap<NodeGeometry>;
122
+ readonly edges: PersistentMap<EdgeGeometry>;
123
+ readonly children: PersistentMap<PersistentMap<true>>;
124
+ readonly incident: PersistentMap<PersistentMap<true>>;
125
+ readonly groups: PersistentMap<PersistentMap<true>>;
126
+ readonly obstacles: SpatialIndex;
127
+ readonly routeIndex: SpatialIndex;
128
+ readonly routes: PersistentMap<Route>;
129
+ }
130
+ export function members(
131
+ index: PersistentMap<PersistentMap<true>>,
132
+ key: string,
133
+ id: string,
134
+ add: boolean,
135
+ ): PersistentMap<PersistentMap<true>> {
136
+ const current = index.get(key) ?? new PersistentMap<true>(),
137
+ next = add ? current.set(id, true) : current.delete(id);
138
+ return next.size ? index.set(key, next) : index.delete(key);
139
+ }
140
+ export function ancestors(id: string, state: Pick<State, "nodes">): string[] {
141
+ const result: string[] = [],
142
+ seen = new Set([id]);
143
+ let parent = state.nodes.get(id)?.parentId;
144
+ while (parent != null) {
145
+ if (seen.has(parent)) throw new Error(`Cyclic routing hierarchy at ${parent}`);
146
+ result.push(parent);
147
+ seen.add(parent);
148
+ parent = state.nodes.get(parent)?.parentId;
149
+ }
150
+ return result;
151
+ }
152
+ export function worldRect(
153
+ node: NodeGeometry,
154
+ state: Pick<State, "nodes" | "settings">,
155
+ ): RouteBounds | undefined {
156
+ const bounds = rect(node);
157
+ if (!bounds) return;
158
+ if (state.settings.coordinateSpace === "world") return bounds;
159
+ let x = bounds.x,
160
+ y = bounds.y;
161
+ for (const id of ancestors(node.id, state)) {
162
+ const parent = state.nodes.get(id),
163
+ p = parent && rect(parent);
164
+ if (!p) return;
165
+ x += p.x;
166
+ y += p.y;
167
+ }
168
+ return { ...bounds, x, y };
169
+ }
170
+ export function labelRect(
171
+ edge: EdgeGeometry,
172
+ state: Pick<State, "nodes" | "settings">,
173
+ ): RouteBounds | undefined {
174
+ const bounds = rect(edge);
175
+ if (!bounds || bounds.width <= 0 || bounds.height <= 0) return;
176
+ const source = state.nodes.get(edge.sourceId),
177
+ target = state.nodes.get(edge.targetId);
178
+ if (
179
+ state.settings.coordinateSpace === "world" ||
180
+ !source ||
181
+ source.parentId == null ||
182
+ source.parentId !== target?.parentId
183
+ )
184
+ return bounds;
185
+ const parent = state.nodes.get(source.parentId),
186
+ p = parent && worldRect(parent, state);
187
+ return p ? { ...bounds, x: bounds.x + p.x, y: bounds.y + p.y } : undefined;
188
+ }
189
+ export const center = (r: RouteBounds): RoutePoint => ({
190
+ x: r.x + r.width / 2,
191
+ y: r.y + r.height / 2,
192
+ });
193
+ export function sideToward(a: RoutePoint, b: RoutePoint): RouteSide {
194
+ return Math.abs(a.x - b.x) >= Math.abs(a.y - b.y)
195
+ ? b.x >= a.x
196
+ ? "right"
197
+ : "left"
198
+ : b.y >= a.y
199
+ ? "bottom"
200
+ : "top";
201
+ }
202
+ export const vector = (side: RouteSide): RoutePoint => ({
203
+ x: side === "right" ? 1 : side === "left" ? -1 : 0,
204
+ y: side === "bottom" ? 1 : side === "top" ? -1 : 0,
205
+ });
206
+ export function anchor(bounds: RouteBounds, side: RouteSide): RoutePoint {
207
+ const c = center(bounds),
208
+ v = vector(side);
209
+ return { x: c.x + (v.x * bounds.width) / 2, y: c.y + (v.y * bounds.height) / 2 };
210
+ }
211
+ export function groupKey(edge: EdgeGeometry, style: string, config: Settings): string | undefined {
212
+ const group = config.edges[edge.id]?.group;
213
+ if (style === "bus" || style === "bundle" || style === "fan")
214
+ return group !== undefined
215
+ ? `group:${group}`
216
+ : `source:${JSON.stringify([edge.sourceId, edge.sourcePort ?? null])}`;
217
+ return `pair:${JSON.stringify([edge.sourceId, edge.targetId].sort())}`;
218
+ }
@@ -0,0 +1,324 @@
1
+ import { crossesRect } from "../authoring/routing";
2
+ import type { RouteBounds, RoutePath, RoutePoint, RouteSegment } from "./types";
3
+
4
+ export const distance = (a: RoutePoint, b: RoutePoint): number => Math.hypot(a.x - b.x, a.y - b.y);
5
+ const lerp = (a: RoutePoint, b: RoutePoint, t: number): RoutePoint => ({
6
+ x: a.x + (b.x - a.x) * t,
7
+ y: a.y + (b.y - a.y) * t,
8
+ });
9
+ export function pathFromPoints(points: readonly RoutePoint[]): RoutePath {
10
+ if (!points.length) throw new RangeError("A path needs at least one point");
11
+ return {
12
+ start: { ...points[0]! },
13
+ segments: points.slice(1).map((to) => ({ kind: "line", to: { ...to } })),
14
+ };
15
+ }
16
+ function pointLineDistance(p: RoutePoint, a: RoutePoint, b: RoutePoint): number {
17
+ const squared = (b.x - a.x) ** 2 + (b.y - a.y) ** 2;
18
+ const t = squared
19
+ ? Math.max(0, Math.min(1, ((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / squared))
20
+ : 0;
21
+ return distance(p, lerp(a, b, t));
22
+ }
23
+ type Arc = Extract<RouteSegment, { kind: "arc" }>;
24
+ /** SVG endpoint-to-center conversion, including SVG's radius correction. */
25
+ function arcCenter(start: RoutePoint, arc: Arc) {
26
+ let rx = Math.abs(arc.rx),
27
+ ry = Math.abs(arc.ry);
28
+ if (!rx || !ry || distance(start, arc.to) === 0) return undefined;
29
+ const phi = (arc.rotation * Math.PI) / 180,
30
+ c = Math.cos(phi),
31
+ s = Math.sin(phi);
32
+ const dx = (start.x - arc.to.x) / 2,
33
+ dy = (start.y - arc.to.y) / 2;
34
+ const x = c * dx + s * dy,
35
+ y = -s * dx + c * dy;
36
+ const scale = Math.sqrt(Math.max(1, (x * x) / (rx * rx) + (y * y) / (ry * ry)));
37
+ rx *= scale;
38
+ ry *= scale;
39
+ const factor =
40
+ (arc.largeArc === arc.sweep ? -1 : 1) *
41
+ Math.sqrt(
42
+ Math.max(
43
+ 0,
44
+ (rx * rx * ry * ry - rx * rx * y * y - ry * ry * x * x) /
45
+ (rx * rx * y * y + ry * ry * x * x),
46
+ ),
47
+ );
48
+ const cx = (factor * rx * y) / ry,
49
+ cy = (-factor * ry * x) / rx;
50
+ const center = {
51
+ x: c * cx - s * cy + (start.x + arc.to.x) / 2,
52
+ y: s * cx + c * cy + (start.y + arc.to.y) / 2,
53
+ };
54
+ const theta = Math.atan2((y - cy) / ry, (x - cx) / rx);
55
+ let delta = Math.atan2((-y - cy) / ry, (-x - cx) / rx) - theta;
56
+ if (arc.sweep && delta < 0) delta += 2 * Math.PI;
57
+ if (!arc.sweep && delta > 0) delta -= 2 * Math.PI;
58
+ return { rx, ry, c, s, center, theta, delta };
59
+ }
60
+ export interface FlattenPathOptions {
61
+ readonly tolerance?: number;
62
+ readonly maxSegments?: number;
63
+ }
64
+ /** Adaptive, bounded-error polyline approximation. Throws rather than silently exceeding tolerance. */
65
+ export function flattenPath(path: RoutePath, options: FlattenPathOptions = {}): RoutePoint[] {
66
+ const tolerance = options.tolerance ?? 0.5,
67
+ max = options.maxSegments ?? 65536;
68
+ if (!(tolerance > 0 && Number.isFinite(tolerance)) || !Number.isInteger(max) || max < 1)
69
+ throw new RangeError("Invalid flattening tolerance or segment budget");
70
+ const points: RoutePoint[] = [{ ...path.start }];
71
+ const add = (point: RoutePoint) => {
72
+ if (points.length > max) throw new RangeError("Path flattening exceeded maxSegments");
73
+ points.push({ ...point });
74
+ };
75
+ function bezier(controls: readonly RoutePoint[], depth = 0) {
76
+ const first = controls[0]!,
77
+ last = controls.at(-1)!;
78
+ if (controls.slice(1, -1).every((p) => pointLineDistance(p, first, last) <= tolerance)) {
79
+ add(last);
80
+ return;
81
+ }
82
+ if (depth >= 32) throw new RangeError("Cannot flatten path at requested tolerance");
83
+ const left = [first],
84
+ right = [last];
85
+ let row = [...controls];
86
+ while (row.length > 1) {
87
+ row = row.slice(1).map((p, i) => lerp(row[i]!, p, 0.5));
88
+ left.push(row[0]!);
89
+ right.unshift(row.at(-1)!);
90
+ }
91
+ bezier(left, depth + 1);
92
+ bezier(right, depth + 1);
93
+ }
94
+ let from = path.start;
95
+ for (const segment of path.segments) {
96
+ if (segment.kind === "line") add(segment.to);
97
+ else if (segment.kind === "quadratic") bezier([from, segment.control, segment.to]);
98
+ else if (segment.kind === "cubic")
99
+ bezier([from, segment.control1, segment.control2, segment.to]);
100
+ else {
101
+ const arc = arcCenter(from, segment);
102
+ if (!arc) add(segment.to);
103
+ else {
104
+ const step = Math.min(
105
+ Math.PI / 2,
106
+ 2 * Math.acos(Math.max(-1, 1 - tolerance / Math.max(arc.rx, arc.ry))),
107
+ );
108
+ const count = Math.max(1, Math.ceil(Math.abs(arc.delta) / step));
109
+ if (!Number.isFinite(count) || points.length + count > max + 1)
110
+ throw new RangeError("Path flattening exceeded maxSegments");
111
+ for (let i = 1; i < count; i++) {
112
+ const t = arc.theta + (arc.delta * i) / count;
113
+ add({
114
+ x: arc.center.x + arc.c * arc.rx * Math.cos(t) - arc.s * arc.ry * Math.sin(t),
115
+ y: arc.center.y + arc.s * arc.rx * Math.cos(t) + arc.c * arc.ry * Math.sin(t),
116
+ });
117
+ }
118
+ add(segment.to);
119
+ }
120
+ }
121
+ from = segment.to;
122
+ }
123
+ return points;
124
+ }
125
+ /** Conservative bounds: control hulls and full ellipse envelopes contain every curve. */
126
+ export function getPathBounds(path: RoutePath): RouteBounds {
127
+ const points: RoutePoint[] = [path.start];
128
+ let from = path.start;
129
+ for (const segment of path.segments) {
130
+ points.push(segment.to);
131
+ if (segment.kind === "quadratic") points.push(segment.control);
132
+ if (segment.kind === "cubic") points.push(segment.control1, segment.control2);
133
+ if (segment.kind === "arc") {
134
+ const arc = arcCenter(from, segment);
135
+ if (arc) {
136
+ const dx = Math.hypot(arc.rx * arc.c, arc.ry * arc.s),
137
+ dy = Math.hypot(arc.rx * arc.s, arc.ry * arc.c);
138
+ points.push(
139
+ { x: arc.center.x - dx, y: arc.center.y - dy },
140
+ { x: arc.center.x + dx, y: arc.center.y + dy },
141
+ );
142
+ }
143
+ }
144
+ from = segment.to;
145
+ }
146
+ const xs = points.map((p) => p.x),
147
+ ys = points.map((p) => p.y);
148
+ const x = Math.min(...xs),
149
+ y = Math.min(...ys);
150
+ return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
151
+ }
152
+ export function getPathLength(path: RoutePath, options?: FlattenPathOptions): number {
153
+ const points = flattenPath(path, options);
154
+ return points.reduce((sum, p, i) => sum + (i ? distance(points[i - 1]!, p) : 0), 0);
155
+ }
156
+ function locate(path: RoutePath, length: number, options?: FlattenPathOptions) {
157
+ const points = flattenPath(path, options);
158
+ let remaining = Math.max(0, length);
159
+ for (let i = 1; i < points.length; i++) {
160
+ const a = points[i - 1]!,
161
+ b = points[i]!,
162
+ size = distance(a, b);
163
+ if (size && (remaining <= size || i === points.length - 1))
164
+ return { a, b, t: Math.min(1, remaining / size) };
165
+ remaining -= size;
166
+ }
167
+ return { a: points.at(-1)!, b: points.at(-1)!, t: 0 };
168
+ }
169
+ export function getPointAtLength(
170
+ path: RoutePath,
171
+ length: number,
172
+ options?: FlattenPathOptions,
173
+ ): RoutePoint {
174
+ const { a, b, t } = locate(path, length, options);
175
+ return lerp(a, b, t);
176
+ }
177
+ export function getTangentAtLength(
178
+ path: RoutePath,
179
+ length: number,
180
+ options?: FlattenPathOptions,
181
+ ): RoutePoint {
182
+ const { a, b } = locate(path, length, options),
183
+ d = distance(a, b);
184
+ return d ? { x: (b.x - a.x) / d, y: (b.y - a.y) / d } : { x: 0, y: 0 };
185
+ }
186
+ /** Round line-to-line corners only; existing curves and path endpoints are retained. */
187
+ export function roundCorners(path: RoutePath, options: { readonly radius: number }): RoutePath {
188
+ const radius = options.radius;
189
+ if (!Number.isFinite(radius) || radius < 0)
190
+ throw new RangeError("radius must be non-negative and finite");
191
+ if (!radius) return path;
192
+ const result: RouteSegment[] = [];
193
+ let previous = path.start;
194
+ for (let i = 0; i < path.segments.length; i++) {
195
+ const segment = path.segments[i]!,
196
+ next = path.segments[i + 1];
197
+ const corner = segment.to;
198
+ if (segment.kind === "line" && next?.kind === "line") {
199
+ const l1 = distance(previous, corner),
200
+ l2 = distance(corner, next.to);
201
+ if (l1 > 0 && l2 > 0) {
202
+ const u = { x: (corner.x - previous.x) / l1, y: (corner.y - previous.y) / l1 },
203
+ v = { x: (next.to.x - corner.x) / l2, y: (next.to.y - corner.y) / l2 };
204
+ const angle = Math.acos(Math.max(-1, Math.min(1, u.x * v.x + u.y * v.y)));
205
+ if (angle > 1e-8 && angle < Math.PI - 1e-8) {
206
+ const tangent = Math.tan(angle / 2),
207
+ trim = Math.min(radius * tangent, l1 / 2, l2 / 2),
208
+ r = trim / tangent;
209
+ result.push(
210
+ { kind: "line", to: { x: corner.x - u.x * trim, y: corner.y - u.y * trim } },
211
+ {
212
+ kind: "arc",
213
+ rx: r,
214
+ ry: r,
215
+ rotation: 0,
216
+ largeArc: false,
217
+ sweep: u.x * v.y - u.y * v.x > 0,
218
+ to: { x: corner.x + v.x * trim, y: corner.y + v.y * trim },
219
+ },
220
+ );
221
+ previous = corner;
222
+ continue;
223
+ }
224
+ }
225
+ }
226
+ result.push(segment);
227
+ previous = corner;
228
+ }
229
+ return { start: path.start, segments: result };
230
+ }
231
+ export function toSvgPath(
232
+ path: RoutePath,
233
+ options: { readonly radius?: number; readonly precision?: number } = {},
234
+ ): string {
235
+ const shaped = options.radius ? roundCorners(path, { radius: options.radius }) : path;
236
+ const precision = options.precision ?? 6;
237
+ if (!Number.isInteger(precision) || precision < 0 || precision > 15)
238
+ throw new RangeError("precision must be an integer from 0 to 15");
239
+ const n = (value: number) => {
240
+ if (!Number.isFinite(value)) throw new RangeError("Path coordinates must be finite");
241
+ return String(Number(value.toFixed(precision)));
242
+ };
243
+ const p = (point: RoutePoint) => `${n(point.x)} ${n(point.y)}`;
244
+ return [
245
+ `M ${p(shaped.start)}`,
246
+ ...shaped.segments.map((s) => {
247
+ switch (s.kind) {
248
+ case "line":
249
+ return `L ${p(s.to)}`;
250
+ case "quadratic":
251
+ return `Q ${p(s.control)} ${p(s.to)}`;
252
+ case "cubic":
253
+ return `C ${p(s.control1)} ${p(s.control2)} ${p(s.to)}`;
254
+ case "arc":
255
+ return `A ${n(Math.abs(s.rx))} ${n(Math.abs(s.ry))} ${n(s.rotation)} ${Number(s.largeArc)} ${Number(s.sweep)} ${p(s.to)}`;
256
+ }
257
+ }),
258
+ ].join(" ");
259
+ }
260
+
261
+ /** Test curve geometry, subdividing conservative bounds only where they overlap. */
262
+ export function segmentCrossesRect(
263
+ start: RoutePoint,
264
+ segment: RouteSegment,
265
+ rect: RouteBounds,
266
+ ): boolean {
267
+ const overlaps = (points: readonly RoutePoint[]) => {
268
+ const xs = points.map((p) => p.x),
269
+ ys = points.map((p) => p.y);
270
+ return (
271
+ Math.min(...xs) < rect.x + rect.width &&
272
+ Math.max(...xs) > rect.x &&
273
+ Math.min(...ys) < rect.y + rect.height &&
274
+ Math.max(...ys) > rect.y
275
+ );
276
+ };
277
+ const inside = (p: RoutePoint) =>
278
+ p.x > rect.x && p.x < rect.x + rect.width && p.y > rect.y && p.y < rect.y + rect.height;
279
+ function bezier(controls: readonly RoutePoint[], depth: number): boolean {
280
+ if (!overlaps(controls)) return false;
281
+ if (inside(controls[0]!) || inside(controls.at(-1)!)) return true;
282
+ if (depth === 32) return true; // Unresolved subpixel contact stays conservative.
283
+ const left = [controls[0]!],
284
+ right = [controls.at(-1)!];
285
+ let row = [...controls];
286
+ while (row.length > 1) {
287
+ row = row.slice(1).map((p, i) => lerp(row[i]!, p, 0.5));
288
+ left.push(row[0]!);
289
+ right.unshift(row.at(-1)!);
290
+ }
291
+ return bezier(left, depth + 1) || bezier(right, depth + 1);
292
+ }
293
+ if (segment.kind === "line") return crossesRect(start, segment.to, rect);
294
+ if (segment.kind === "quadratic") return bezier([start, segment.control, segment.to], 0);
295
+ if (segment.kind === "cubic")
296
+ return bezier([start, segment.control1, segment.control2, segment.to], 0);
297
+ const arc = arcCenter(start, segment);
298
+ if (!arc) return crossesRect(start, segment.to, rect);
299
+ const point = (t: number): RoutePoint => ({
300
+ x: arc.center.x + arc.c * arc.rx * Math.cos(t) - arc.s * arc.ry * Math.sin(t),
301
+ y: arc.center.y + arc.s * arc.rx * Math.cos(t) + arc.c * arc.ry * Math.sin(t),
302
+ });
303
+ function interval(a: number, b: number, depth: number): boolean {
304
+ const points = [point(a), point(b)];
305
+ // Exact extrema of each rotated ellipse coordinate within this subarc.
306
+ for (const base of [
307
+ Math.atan2(-arc!.s * arc!.ry, arc!.c * arc!.rx),
308
+ Math.atan2(arc!.c * arc!.ry, arc!.s * arc!.rx),
309
+ ]) {
310
+ for (let k = Math.ceil((a - base) / Math.PI); base + k * Math.PI < b; k++)
311
+ points.push(point(base + k * Math.PI));
312
+ }
313
+ if (!overlaps(points)) return false;
314
+ if (inside(points[0]!) || inside(points[1]!)) return true;
315
+ if (depth === 32) return true;
316
+ const middle = (a + b) / 2;
317
+ return interval(a, middle, depth + 1) || interval(middle, b, depth + 1);
318
+ }
319
+ return interval(
320
+ Math.min(arc.theta, arc.theta + arc.delta),
321
+ Math.max(arc.theta, arc.theta + arc.delta),
322
+ 0,
323
+ );
324
+ }
@@ -0,0 +1,117 @@
1
+ /** Path-copying AVL map. Updating k entries costs O(k log n), never a whole Map copy. */
2
+ interface Node<V> {
3
+ readonly key: string;
4
+ readonly value: V;
5
+ readonly left?: Node<V>;
6
+ readonly right?: Node<V>;
7
+ readonly height: number;
8
+ readonly size: number;
9
+ }
10
+ const height = <V>(n?: Node<V>) => n?.height ?? 0;
11
+ const size = <V>(n?: Node<V>) => n?.size ?? 0;
12
+ function node<V>(key: string, value: V, left?: Node<V>, right?: Node<V>): Node<V> {
13
+ return {
14
+ key,
15
+ value,
16
+ left,
17
+ right,
18
+ height: 1 + Math.max(height(left), height(right)),
19
+ size: 1 + size(left) + size(right),
20
+ };
21
+ }
22
+ function balance<V>(n: Node<V>): Node<V> {
23
+ if (height(n.left) - height(n.right) > 1) {
24
+ let l = n.left!;
25
+ if (height(l.right) > height(l.left)) {
26
+ const r = l.right!;
27
+ l = node(r.key, r.value, node(l.key, l.value, l.left, r.left), r.right);
28
+ }
29
+ return node(l.key, l.value, l.left, node(n.key, n.value, l.right, n.right));
30
+ }
31
+ if (height(n.right) - height(n.left) > 1) {
32
+ let r = n.right!;
33
+ if (height(r.left) > height(r.right)) {
34
+ const l = r.left!;
35
+ r = node(l.key, l.value, l.left, node(r.key, r.value, l.right, r.right));
36
+ }
37
+ return node(r.key, r.value, node(n.key, n.value, n.left, r.left), r.right);
38
+ }
39
+ return n;
40
+ }
41
+ function put<V>(n: Node<V> | undefined, key: string, value: V): Node<V> {
42
+ if (!n) return node(key, value);
43
+ if (key === n.key) return Object.is(value, n.value) ? n : node(key, value, n.left, n.right);
44
+ return balance(
45
+ key < n.key
46
+ ? node(n.key, n.value, put(n.left, key, value), n.right)
47
+ : node(n.key, n.value, n.left, put(n.right, key, value)),
48
+ );
49
+ }
50
+ function remove<V>(n: Node<V> | undefined, key: string): Node<V> | undefined {
51
+ if (!n) return n;
52
+ if (key < n.key) return balance(node(n.key, n.value, remove(n.left, key), n.right));
53
+ if (key > n.key) return balance(node(n.key, n.value, n.left, remove(n.right, key)));
54
+ if (!n.left) return n.right;
55
+ if (!n.right) return n.left;
56
+ let successor = n.right;
57
+ while (successor.left) successor = successor.left;
58
+ return balance(node(successor.key, successor.value, n.left, remove(n.right, successor.key)));
59
+ }
60
+ function* entries<V>(n?: Node<V>): Generator<[string, V]> {
61
+ if (!n) return;
62
+ yield* entries(n.left);
63
+ yield [n.key, n.value];
64
+ yield* entries(n.right);
65
+ }
66
+ export class PersistentMap<V> implements ReadonlyMap<string, V> {
67
+ readonly #root?: Node<V>;
68
+ constructor(root?: Node<V>) {
69
+ this.#root = root;
70
+ Object.freeze(this);
71
+ }
72
+ get size() {
73
+ return size(this.#root);
74
+ }
75
+ get [Symbol.toStringTag]() {
76
+ return "PersistentMap";
77
+ }
78
+ get(key: string): V | undefined {
79
+ let n = this.#root;
80
+ while (n) {
81
+ if (key === n.key) return n.value;
82
+ n = key < n.key ? n.left : n.right;
83
+ }
84
+ }
85
+ has(key: string) {
86
+ let n = this.#root;
87
+ while (n) {
88
+ if (key === n.key) return true;
89
+ n = key < n.key ? n.left : n.right;
90
+ }
91
+ return false;
92
+ }
93
+ set(key: string, value: V): PersistentMap<V> {
94
+ return new PersistentMap(put(this.#root, key, value));
95
+ }
96
+ delete(key: string): PersistentMap<V> {
97
+ return this.has(key) ? new PersistentMap(remove(this.#root, key)) : this;
98
+ }
99
+ *entries(): MapIterator<[string, V]> {
100
+ yield* entries(this.#root);
101
+ }
102
+ *keys(): MapIterator<string> {
103
+ for (const [key] of this) yield key;
104
+ }
105
+ *values(): MapIterator<V> {
106
+ for (const [, value] of this) yield value;
107
+ }
108
+ [Symbol.iterator]() {
109
+ return this.entries();
110
+ }
111
+ forEach(
112
+ callback: (value: V, key: string, map: ReadonlyMap<string, V>) => void,
113
+ thisArg?: unknown,
114
+ ) {
115
+ for (const [key, value] of this) callback.call(thisArg, value, key, this);
116
+ }
117
+ }