packages/core/src/features/viz/lib/domain-navigation.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
1 import type { LinearDomain, ResolvedContinuousScale } from "./types.js";
2 /** Explicit hard bounds and minimum mapped extent for continuous-domain navigation. @see {@link panDomain} @see {@link zoomDomain} */
3 export interface DomainNavigationConstraints {
4 /** Finite hard domain endpoints, in either order, valid for the supplied scale. See {@link LinearDomain}. */
5 readonly bounds: LinearDomain;
6 /** Minimum span as a fraction of the mapped hard bounds, greater than zero through one. Defaults to 0.01. */
7 readonly minimumSpanRatio?: number;
8 }
9 /** Named pan settings with explicit navigation limits. @see {@link panDomain} */
10 export interface PanDomainOptions extends DomainNavigationConstraints {
11 /** Finite displacement in range units; positive shifts mapped values toward increasing range coordinates. */
12 readonly rangeDelta: number;
13 }
14 /** Named zoom settings with explicit navigation limits. @see {@link zoomDomain} */
15 export interface ZoomDomainOptions extends DomainNavigationConstraints {
16 /** Finite anchor in the scale's range coordinates, using the same units as its range. */
17 readonly rangeAnchor: number;
18 /** Finite positive magnification; greater than one zooms in. */
19 readonly factor: number;
20 }
21 function finite(value: number, name: string): number {
22 if (typeof value !== "number" || !Number.isFinite(value))
23 throw new RangeError(`${name} must be finite.`);
24 return value;
25 }
26 function navigate(
27 scale: ResolvedContinuousScale,
28 start: number,
29 end: number,
30 constraints: DomainNavigationConstraints,
31 requestedSpan?: number,
32 ): LinearDomain {
33 const [r0, r1] = scale.range;
34 if (r0 === r1 || scale.domain[0] === scale.domain[1])
35 throw new RangeError(
36 "Domain navigation requires noncollapsed domain and range.",
37 );
38 const { bounds, minimumSpanRatio = 0.01 } = constraints;
39 if (!Array.isArray(bounds) || bounds.length !== 2)
40 throw new RangeError("Navigation bounds require two endpoints.");
41 if (scale.type === "utc" && !bounds.every(Number.isSafeInteger))
42 throw new RangeError(
43 "UTC navigation bounds must be safe integer milliseconds.",
44 );
45 const b0 = scale.map(finite(bounds[0], "Lower bound")),
46 b1 = scale.map(finite(bounds[1], "Upper bound"));
47 const lo = Math.min(b0, b1),
48 hi = Math.max(b0, b1),
49 extent = hi - lo;
50 if (!Number.isFinite(extent) || extent <= 0)
51 throw new RangeError(
52 "Navigation bounds must have a finite nonzero mapped extent.",
53 );
54 if (
55 !Number.isFinite(minimumSpanRatio) ||
56 minimumSpanRatio <= 0 ||
57 minimumSpanRatio > 1
58 )
59 throw new RangeError(
60 "minimumSpanRatio must be greater than zero and at most one.",
61 );
62 finite(start, "Mapped start");
63 finite(end, "Mapped end");
64 const requested = requestedSpan ?? Math.abs(end - start);
65 if (!Number.isFinite(requested))
66 throw new RangeError("Navigation span must be finite.");
67 const span = Math.min(extent, Math.max(extent * minimumSpanRatio, requested));
68 const center = start / 2 + end / 2;
69 const low = Math.max(lo, Math.min(hi - span, center - span / 2)),
70 high = low + span;
71 let a = scale.invert(r0 < r1 ? low : high),
72 b = scale.invert(r0 < r1 ? high : low);
73 if (scale.type === "utc") {
74 a = Math.round(a);
75 b = Math.round(b);
76 }
77 finite(a, "Domain start");
78 finite(b, "Domain end");
79 if (a === b)
80 throw new RangeError(
81 "Navigation result collapses at the scale's numeric precision.",
82 );
83 return Object.freeze([a, b] as const);
84 }
85 /**
86 * Moves the visible continuous domain by a displacement expressed in range units.
87 * Positive displacement shifts mapped values toward increasing range coordinates; the returned domain moves oppositely.
88 * @param scale - Current resolved continuous mapping. See {@link ResolvedContinuousScale}.
89 * @param options - Displacement, bounds, and minimum span. See {@link PanDomainOptions}.
90 * @returns A new immutable domain pair. See {@link LinearDomain}.
91 * @throws When inputs, bounds, or the resulting extent are invalid or collapsed.
92 * @see {@link zoomDomain}
93 */
94 export function panDomain(
95 scale: ResolvedContinuousScale,
96 options: PanDomainOptions,
97 ): LinearDomain {
98 const { rangeDelta } = options;
99 finite(rangeDelta, "Pan displacement");
100 return navigate(
101 scale,
102 scale.range[0] - rangeDelta,
103 scale.range[1] - rangeDelta,
104 options,
105 Math.abs(scale.range[1] - scale.range[0]),
106 );
107 }
108 /**
109 * Magnifies a continuous domain around an anchor in range coordinates.
110 * Factor greater than one zooms in; hard bounds and minimum span may limit anchor preservation.
111 * @param scale - Current resolved continuous mapping. See {@link ResolvedContinuousScale}.
112 * @param options - Anchor, magnification, bounds, and minimum span. See {@link ZoomDomainOptions}.
113 * @returns A new immutable domain pair. See {@link LinearDomain}.
114 * @throws When factor, mapping, bounds, or the resulting extent are invalid or collapsed.
115 * @see {@link panDomain}
116 */
117 export function zoomDomain(
118 scale: ResolvedContinuousScale,
119 options: ZoomDomainOptions,
120 ): LinearDomain {
121 const { rangeAnchor, factor } = options;
122 finite(rangeAnchor, "Zoom anchor");
123 finite(factor, "Zoom factor");
124 if (factor <= 0) throw new RangeError("Zoom factor must be positive.");
125 return navigate(
126 scale,
127 rangeAnchor + (scale.range[0] - rangeAnchor) / factor,
128 rangeAnchor + (scale.range[1] - rangeAnchor) / factor,
129 options,
130 );
131 }
132
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.