@statelyai/layout 0.4.3 → 0.5.1

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 (57) 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-Cj-lWiK1.cjs → elkjs-Bh_E59N_.cjs} +3 -3
  11. package/dist/{elkjs-B5kN_zS6.mjs → elkjs-Dk9yOk9U.mjs} +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-CUwft7on.mjs} +488 -91
  25. package/dist/{layered-BJu8A21s.cjs → layered-QzYtokgN.cjs} +488 -91
  26. package/dist/{layout-engine-BFP1TZbF.cjs → layout-engine-CQ5FcBPC.cjs} +1 -1
  27. package/dist/{layout-engine-DnlmGxHL.mjs → layout-engine-DvDkMQM3.mjs} +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 +186 -42
  41. package/src/layered/loop-envelopes.ts +96 -26
  42. package/src/layered/north-south-ports.ts +17 -4
  43. package/src/layered/self-loop-ports.ts +158 -0
  44. package/src/layered/strategies.ts +183 -32
  45. package/src/routing/algorithms.ts +1436 -148
  46. package/src/routing/bezier-fit.ts +605 -0
  47. package/src/routing/bezier-visibility.ts +107 -0
  48. package/src/routing/bezier.ts +596 -0
  49. package/src/routing/engine.ts +65 -13
  50. package/src/routing/index.ts +14 -0
  51. package/src/routing/model.ts +185 -1
  52. package/src/routing/path.ts +1 -1
  53. package/src/routing/policy-geometry.ts +536 -0
  54. package/src/routing/search.ts +10 -2
  55. package/src/routing/types.ts +77 -1
  56. package/dist/routing-CR8flv8b.mjs +0 -2780
  57. package/dist/routing-QIdGGFBe.cjs +0 -2996
@@ -1,4 +1,5 @@
1
1
  import type { GraphDiff } from "@statelyai/graph";
2
+ import { expandLabelFamilies, labelFamily, routeBezierEdge } from "./bezier";
2
3
  import { conflictCost, reservations, reservationBounds, outsideTerminal } from "./coordination";
3
4
  import { segmentBounds } from "./search";
4
5
  import { routeBounds, routeEdge, type Metrics, type Batch } from "./algorithms";
@@ -6,7 +7,8 @@ import {
6
7
  edgeGeometry,
7
8
  freeze,
8
9
  groupKey,
9
- labelRect,
10
+ obstacleLabelRect,
11
+ effectivePolicy,
10
12
  members,
11
13
  nodeGeometry,
12
14
  settings,
@@ -85,7 +87,7 @@ function initialState(graph: RoutingGraph, config: RoutingSettings, style: Route
85
87
  state = { ...state, obstacles: state.obstacles.set(`n:${id}`, worldRect(n, state)) };
86
88
  for (const [id, e] of edges) {
87
89
  state = edgeMembership(state, e, style, true);
88
- state = { ...state, obstacles: state.obstacles.set(`e:${id}`, labelRect(e, state)) };
90
+ state = { ...state, obstacles: state.obstacles.set(`e:${id}`, obstacleLabelRect(e, state)) };
89
91
  }
90
92
  return state;
91
93
  }
@@ -111,12 +113,30 @@ function calculate(
111
113
  plans: new Map(),
112
114
  };
113
115
  const pending = new Set(affected);
116
+ expandLabelFamilies(pending, input);
117
+ if (previous) expandLabelFamilies(pending, previous);
114
118
  const processed = new Set<string>();
119
+ const familyDepth = new Map<string, number>();
120
+ const familyRoots = new Map<string, string>();
121
+ for (const [id, config] of Object.entries(state.settings.edges)) {
122
+ if (config.sourceLabel === undefined) continue;
123
+ const family = labelFamily(id, input);
124
+ for (let index = 0; index < family.length; index++) {
125
+ const member = family[index]!;
126
+ familyRoots.set(member, family.at(-1)!);
127
+ familyDepth.set(member, family.length - index);
128
+ }
129
+ }
115
130
  const priorities = new Map<string, string>();
116
131
  const priority = (id: string) => {
117
132
  let result = priorities.get(id);
118
133
  if (result !== undefined) return result;
119
134
  const edge = state.edges.get(id) ?? previous?.edges.get(id);
135
+ const familyRoot = familyRoots.get(id);
136
+ if (familyRoot !== undefined) {
137
+ priorities.set(id, familyRoot);
138
+ return familyRoot;
139
+ }
120
140
  const key = edge && groupKey(edge, style, state.settings);
121
141
  result = key
122
142
  ? ((state.edges.has(id) ? state.groups : previous?.groups)?.get(key)?.keys().next().value ??
@@ -125,12 +145,17 @@ function calculate(
125
145
  priorities.set(id, result);
126
146
  return result;
127
147
  };
128
- const later = (candidate: string, id: string) =>
129
- priority(candidate) > priority(id) || (priority(candidate) === priority(id) && candidate > id);
148
+ const compare = (a: string, b: string) =>
149
+ (priority(a) < priority(b) ? -1 : priority(a) > priority(b) ? 1 : 0) ||
150
+ (familyDepth.get(a) ?? 1) - (familyDepth.get(b) ?? 1) ||
151
+ (a < b ? -1 : a > b ? 1 : 0);
152
+ const later = (candidate: string, id: string) => compare(candidate, id) > 0;
130
153
  const invalidate = (id: string, bounds: ReturnType<typeof routeBounds>) => {
131
154
  for (const candidate of routeIndex.query(inflate(bounds, state.settings.edgeSpacing))) {
132
155
  if (!later(candidate, id) || processed.has(candidate)) continue;
133
156
  pending.add(candidate);
157
+ expandLabelFamilies(pending, input);
158
+ for (const done of processed) pending.delete(done);
134
159
  const edge = state.edges.get(candidate);
135
160
  const key = edge && groupKey(edge, style, state.settings);
136
161
  if (key)
@@ -160,11 +185,7 @@ function calculate(
160
185
  }
161
186
  const segmentCache = new Map<string, ReturnType<typeof reservations>>();
162
187
  while (pending.size) {
163
- const id = [...pending].sort(
164
- (a, b) =>
165
- (priority(a) < priority(b) ? -1 : priority(a) > priority(b) ? 1 : 0) ||
166
- (a < b ? -1 : a > b ? 1 : 0),
167
- )[0]!;
188
+ const id = [...pending].sort(compare)[0]!;
168
189
  pending.delete(id);
169
190
  processed.add(id);
170
191
  const edge = state.edges.get(id),
@@ -200,7 +221,14 @@ function calculate(
200
221
  const node = state.nodes.get(id),
201
222
  bounds = node && worldRect(node, state);
202
223
  return bounds
203
- ? [inflate(bounds, state.settings.clearance + state.settings.edgeSpacing * 2 + 1)]
224
+ ? [
225
+ inflate(
226
+ bounds,
227
+ state.settings.clearance +
228
+ (style === "organic" ? 0 : state.settings.edgeSpacing * 2) +
229
+ 1,
230
+ ),
231
+ ]
204
232
  : [];
205
233
  });
206
234
  let segments = segmentCache.get(otherId);
@@ -224,9 +252,11 @@ function calculate(
224
252
  }
225
253
  return result;
226
254
  };
255
+ batch.policy = effectivePolicy(state.settings, id);
256
+ const costSettings = { ...state.settings, ...batch.policy?.collisions };
227
257
  batch.edgeCost = (a, b) =>
228
258
  query(segmentBounds(a, b)).reduce(
229
- (cost, other) => cost + conflictCost(a, b, other, state.settings),
259
+ (cost, other) => cost + conflictCost(a, b, other, costSettings),
230
260
  0,
231
261
  );
232
262
  batch.conflicts = (a, b) =>
@@ -237,7 +267,27 @@ function calculate(
237
267
  );
238
268
  batch.guides = (bounds) =>
239
269
  query(bounds).map((segment) => reservationBounds(segment, state.settings.edgeSpacing));
240
- let route = routeEdge(edge, input, style, counts, batch);
270
+ batch.labelOverlaps = (bounds) => {
271
+ batch.dependencyBounds = batch.dependencyBounds
272
+ ? union(batch.dependencyBounds, bounds)
273
+ : bounds;
274
+ return routeIndex
275
+ .query(bounds)
276
+ .filter((otherId) => {
277
+ const label = routes.get(otherId)?.label;
278
+ return later(id, otherId) && label && intersects(bounds, label.bounds);
279
+ })
280
+ .map((otherId) => `e:${otherId}`);
281
+ };
282
+ batch.routes = routes;
283
+ batch.previous = oldRoute;
284
+ batch.previousSettings = previous?.settings;
285
+ let route =
286
+ style === "bezier" &&
287
+ !batch.policy &&
288
+ !routes.get(state.settings.edges[id]?.sourceLabel ?? "")?.label
289
+ ? routeBezierEdge(edge, input, routes, counts, batch)
290
+ : routeEdge(edge, input, style, counts, batch);
241
291
  if (oldRoute && JSON.stringify(oldRoute) === JSON.stringify(route)) route = oldRoute;
242
292
  const drawnBounds = routeBounds(route);
243
293
  if (route !== oldRoute) {
@@ -390,7 +440,7 @@ function updateState(
390
440
  }
391
441
  for (const id of changedEdges) {
392
442
  const edge = state.edges.get(id);
393
- invalidateObstacle(`e:${id}`, edge && labelRect(edge, state));
443
+ invalidateObstacle(`e:${id}`, edge && obstacleLabelRect(edge, state));
394
444
  affected.add(id);
395
445
  }
396
446
  // Terminal placement depends on current incident edges, including neighbors
@@ -426,6 +476,8 @@ function updateState(
426
476
  if (key !== undefined)
427
477
  for (const peer of version.groups.get(key)?.keys() ?? []) affected.add(peer);
428
478
  }
479
+ expandLabelFamilies(affected, prior);
480
+ expandLabelFamilies(affected, state);
429
481
  return { state, affected };
430
482
  }
431
483
  export function createRoutingStrategy(id: RouteStyle): NativeRoutingStrategy {
@@ -34,9 +34,14 @@ export {
34
34
  getTangentAtLength,
35
35
  pathFromPoints,
36
36
  roundCorners,
37
+ segmentCrossesRect,
37
38
  toSvgPath,
38
39
  } from "./path";
39
40
  export type {
41
+ BezierEndpoint,
42
+ BezierGuide,
43
+ BezierRoutingOptions,
44
+ BezierEdgeSettings,
40
45
  RouteAttachment,
41
46
  EdgeRoutingSettings,
42
47
  NativeRoutingStrategy,
@@ -55,6 +60,11 @@ export type {
55
60
  RoutingGraph,
56
61
  RoutingMetrics,
57
62
  RoutingSettings,
63
+ RoutingPolicy,
64
+ RoutingCurve,
65
+ RoutingTangent,
66
+ RoutingLabel,
67
+ RouteLabelPlacement,
58
68
  RoutingSnapshot,
59
69
  RoutingStrategy,
60
70
  RoutingUpdate,
@@ -67,3 +77,7 @@ export {
67
77
  routeToPolylines,
68
78
  routeToPoints,
69
79
  } from "./adapters";
80
+
81
+ export { trimBezierPathStart } from "./bezier-fit";
82
+
83
+ export { routingPolicies } from "./model";
@@ -1,7 +1,15 @@
1
1
  import type { GraphEdge } from "@statelyai/graph";
2
2
  import { PersistentMap } from "./persistent";
3
3
  import { SpatialIndex } from "./spatial";
4
- import type { Route, RouteBounds, RoutePoint, RoutingSettings, RouteSide } from "./types";
4
+ import type {
5
+ Route,
6
+ RouteBounds,
7
+ RoutePoint,
8
+ RoutingSettings,
9
+ RouteSide,
10
+ RoutingPolicy,
11
+ RoutingTangent,
12
+ } from "./types";
5
13
  export interface NodeGeometry {
6
14
  readonly id: string;
7
15
  readonly parentId?: string | null;
@@ -29,6 +37,8 @@ export interface EdgeGeometry {
29
37
  readonly height?: number;
30
38
  }
31
39
  export interface Settings {
40
+ readonly policy?: RoutingPolicy;
41
+ readonly bezier: RoutingSettings["bezier"];
32
42
  readonly coordinateSpace: "parent" | "world";
33
43
  readonly clearance: number;
34
44
  readonly radius: number;
@@ -42,6 +52,8 @@ export interface Settings {
42
52
  }
43
53
  export function settings(input: RoutingSettings = {}): Settings {
44
54
  const result = {
55
+ policy: normalizePolicy(input.policy),
56
+ bezier: structuredClone(input.bezier),
45
57
  coordinateSpace: input.coordinateSpace ?? "parent",
46
58
  clearance: input.clearance ?? 8,
47
59
  radius: input.radius ?? 10,
@@ -62,6 +74,27 @@ export function settings(input: RoutingSettings = {}): Settings {
62
74
  !Number.isInteger(result.organicIterations)
63
75
  )
64
76
  throw new RangeError("Search and iteration budgets must be integers");
77
+ for (const config of [result.bezier, ...Object.values(result.edges).map((edge) => edge.bezier)]) {
78
+ if (
79
+ config?.targetClearance !== undefined &&
80
+ (!Number.isFinite(config.targetClearance) || config.targetClearance < 0)
81
+ )
82
+ throw new RangeError("Bézier targetClearance must be finite and non-negative");
83
+ }
84
+ for (const edge of Object.values(result.edges)) {
85
+ if (edge.policy) Object.assign(edge, { policy: normalizePolicy(edge.policy) });
86
+ for (const point of edge.waypoints ?? [])
87
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y))
88
+ throw new RangeError("Waypoints must be finite");
89
+ const tangent = edge.bezier?.labelTangent;
90
+ if (
91
+ tangent &&
92
+ (!Number.isFinite(tangent.x) ||
93
+ !Number.isFinite(tangent.y) ||
94
+ !Math.hypot(tangent.x, tangent.y))
95
+ )
96
+ throw new RangeError("Bézier labelTangent must be finite and nonzero");
97
+ }
65
98
  return freeze(result);
66
99
  }
67
100
  export function freeze<T>(value: T): T {
@@ -216,3 +249,154 @@ export function groupKey(edge: EdgeGeometry, style: string, config: Settings): s
216
249
  : `source:${JSON.stringify([edge.sourceId, edge.sourcePort ?? null])}`;
217
250
  return `pair:${JSON.stringify([edge.sourceId, edge.targetId].sort())}`;
218
251
  }
252
+
253
+ function normalizePolicy(input: RoutingPolicy | undefined): RoutingPolicy | undefined {
254
+ if (!input) return;
255
+ const value = structuredClone(input);
256
+ const oneOf = (value: unknown, allowed: readonly string[], name: string) => {
257
+ if (typeof value !== "string" || !allowed.includes(value))
258
+ throw new RangeError(`Invalid ${name}`);
259
+ };
260
+ if (value.curve)
261
+ oneOf(
262
+ value.curve.kind,
263
+ ["polyline", "bezier", "catmull-rom", "rounded-orthogonal"],
264
+ "curve kind",
265
+ );
266
+ if (value.label) oneOf(value.label.kind, ["follow", "fixed", "through", "gap"], "label kind");
267
+ if (value.monotonic) {
268
+ oneOf(value.monotonic.axis, ["x", "y", "both"], "monotonic axis");
269
+ oneOf(value.monotonic.strength, ["prefer", "require"], "monotonic strength");
270
+ }
271
+ if (value.stability)
272
+ oneOf(value.stability.kind, ["recompute", "preserve-valid"], "stability kind");
273
+ for (const name of ["nodes", "labels"] as const)
274
+ if (value.collisions?.[name] !== undefined)
275
+ oneOf(value.collisions[name], ["avoid", "allow"], `${name} collision mode`);
276
+
277
+ const nonnegative = (n: number | undefined, name: string) => {
278
+ if (n !== undefined && (!Number.isFinite(n) || n < 0))
279
+ throw new RangeError(`${name} must be finite and non-negative`);
280
+ };
281
+ function tangent(t: RoutingTangent | undefined): RoutingTangent | undefined {
282
+ if (t) oneOf(t.kind, ["automatic", "border-normal", "vector"], "tangent kind");
283
+ if (t?.kind !== "vector") return t;
284
+ const { x, y } = t.direction,
285
+ length = Math.hypot(x, y);
286
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(length) || !length)
287
+ throw new RangeError("Tangent direction must be finite and nonzero");
288
+ return { kind: "vector", direction: { x: x / length, y: y / length } };
289
+ }
290
+ if (value.curve?.kind === "bezier") nonnegative(value.curve.curvature, "curvature");
291
+ if (value.curve?.kind === "rounded-orthogonal") nonnegative(value.curve.radius, "radius");
292
+ nonnegative(value.collisions?.crossingPenalty, "crossingPenalty");
293
+ nonnegative(value.collisions?.overlapPenalty, "overlapPenalty");
294
+ if (value.label?.kind === "follow") {
295
+ const { fraction = 0.5, offset = 0 } = value.label;
296
+ if (!Number.isFinite(fraction) || fraction < 0 || fraction > 1)
297
+ throw new RangeError("Label fraction must be in [0, 1]");
298
+ if (!Number.isFinite(offset)) throw new RangeError("Label offset must be finite");
299
+ }
300
+ return freeze({
301
+ ...value,
302
+ ...(value.sourceTangent ? { sourceTangent: tangent(value.sourceTangent) } : {}),
303
+ ...(value.targetTangent ? { targetTangent: tangent(value.targetTangent) } : {}),
304
+ ...(value.label?.kind === "through" || value.label?.kind === "gap"
305
+ ? {
306
+ label: {
307
+ ...value.label,
308
+ ...(value.label.tangent ? { tangent: tangent(value.label.tangent) } : {}),
309
+ },
310
+ }
311
+ : {}),
312
+ });
313
+ }
314
+ export function effectivePolicy(config: Settings, edgeId: string): RoutingPolicy | undefined {
315
+ const edge = config.edges[edgeId],
316
+ local = edge?.policy,
317
+ global = config.policy;
318
+ if (!local && !global) return;
319
+ const legacyAttachment = edge?.bezier?.attachmentMode;
320
+ const legacyTangent: RoutingTangent | undefined =
321
+ legacyAttachment === "orthogonal"
322
+ ? { kind: "border-normal" }
323
+ : legacyAttachment === "floating"
324
+ ? { kind: "automatic" }
325
+ : undefined;
326
+ let label = local?.label ?? global?.label;
327
+ const legacyLabel = edge?.bezier?.labelTangent;
328
+ if (!local?.label && legacyLabel && (label?.kind === "through" || label?.kind === "gap")) {
329
+ const length = Math.hypot(legacyLabel.x, legacyLabel.y);
330
+ label = {
331
+ ...label,
332
+ tangent: {
333
+ kind: "vector",
334
+ direction: { x: legacyLabel.x / length, y: legacyLabel.y / length },
335
+ },
336
+ };
337
+ }
338
+ return {
339
+ curve: local?.curve ?? global?.curve,
340
+ sourceTangent: local?.sourceTangent ?? legacyTangent ?? global?.sourceTangent,
341
+ targetTangent: local?.targetTangent ?? legacyTangent ?? global?.targetTangent,
342
+ label,
343
+ collisions: {
344
+ nodes: local?.collisions?.nodes ?? global?.collisions?.nodes ?? "avoid",
345
+ labels: local?.collisions?.labels ?? global?.collisions?.labels ?? "avoid",
346
+ crossingPenalty:
347
+ local?.collisions?.crossingPenalty ??
348
+ global?.collisions?.crossingPenalty ??
349
+ config.crossingPenalty,
350
+ overlapPenalty:
351
+ local?.collisions?.overlapPenalty ??
352
+ global?.collisions?.overlapPenalty ??
353
+ config.overlapPenalty,
354
+ },
355
+ monotonic: local?.monotonic ?? global?.monotonic,
356
+ stability: local?.stability ?? global?.stability,
357
+ };
358
+ }
359
+ export function obstacleLabelRect(
360
+ edge: EdgeGeometry,
361
+ state: Pick<State, "nodes" | "settings">,
362
+ ): RouteBounds | undefined {
363
+ return effectivePolicy(state.settings, edge.id)?.label?.kind === "follow"
364
+ ? undefined
365
+ : labelRect(edge, state);
366
+ }
367
+ const presetCollisions = {
368
+ nodes: "avoid",
369
+ labels: "avoid",
370
+ crossingPenalty: 80,
371
+ overlapPenalty: 8,
372
+ } as const;
373
+ export const routingPolicies: Readonly<{
374
+ smooth: RoutingPolicy;
375
+ diagram: RoutingPolicy;
376
+ authored: RoutingPolicy;
377
+ }> = freeze({
378
+ smooth: {
379
+ curve: { kind: "bezier", curvature: 0.5 },
380
+ sourceTangent: { kind: "automatic" },
381
+ targetTangent: { kind: "automatic" },
382
+ label: { kind: "follow", fraction: 0.5 },
383
+ collisions: presetCollisions,
384
+ stability: { kind: "recompute" },
385
+ },
386
+ diagram: {
387
+ curve: { kind: "rounded-orthogonal", radius: 10 },
388
+ sourceTangent: { kind: "border-normal" },
389
+ targetTangent: { kind: "border-normal" },
390
+ label: { kind: "follow", fraction: 0.5 },
391
+ collisions: presetCollisions,
392
+ stability: { kind: "recompute" },
393
+ },
394
+ authored: {
395
+ curve: { kind: "catmull-rom" },
396
+ sourceTangent: { kind: "automatic" },
397
+ targetTangent: { kind: "automatic" },
398
+ label: { kind: "follow", fraction: 0.5 },
399
+ collisions: presetCollisions,
400
+ stability: { kind: "recompute" },
401
+ },
402
+ });
@@ -22,7 +22,7 @@ function pointLineDistance(p: RoutePoint, a: RoutePoint, b: RoutePoint): number
22
22
  }
23
23
  type Arc = Extract<RouteSegment, { kind: "arc" }>;
24
24
  /** SVG endpoint-to-center conversion, including SVG's radius correction. */
25
- function arcCenter(start: RoutePoint, arc: Arc) {
25
+ export function arcCenter(start: RoutePoint, arc: Arc) {
26
26
  let rx = Math.abs(arc.rx),
27
27
  ry = Math.abs(arc.ry);
28
28
  if (!rx || !ry || distance(start, arc.to) === 0) return undefined;