packages/core/src/features/viz/lib/hover-queries.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
1 import { resolveSignalValue } from "@pibbl/core";
2 import { finiteCoordinate, pointAccessor, pointData } from "./point-data.js";
3 import type {
4 ClosestPointHit,
5 ClosestPointOptions,
6 HoverQuery,
7 HoverSeries,
8 HoverSeriesMap,
9 } from "./hover-types.js";
10 /**
11 * Creates a data descriptor without observing or copying its dataset.
12 *
13 * @param options - Data, accessors, and scales describing a hoverable series. See
14 * {@link HoverSeries} .
15 * @returns The series descriptor with its datum type preserved. See {@link HoverSeries}.
16 *
17 * @see {@link HoverSeries}
18 */
19 export function defineSeries<D>(options: HoverSeries<D>): HoverSeries<D> {
20 return Object.freeze({ ...options });
21 }
22 function finite(value: unknown, name: string): asserts value is number {
23 if (!finiteCoordinate(value)) throw new RangeError(`${name} must be finite.`);
24 }
25 function limit(value: number | undefined, name: string): void {
26 if (value === undefined) return;
27 finite(value, name);
28 if (value < 0) throw new RangeError(`${name} must be nonnegative.`);
29 }
30 /**
31 * Creates an ordinary query function for the closest mapped recorded point.
32 *
33 * @param options - Series and distance policy for the hover query. See {@link ClosestPointOptions}
34 * .
35 * @returns A query that selects the closest eligible point, or yields undefined when none
36 * qualifies. See {@link HoverQuery} , {@link ClosestPointHit} .
37 *
38 * @see {@link ClosestPointOptions}
39 * @see {@link HoverQuery}
40 * @see {@link ClosestPointHit}
41 * @see {@link HoverSeriesMap}
42 */
43 export function closestPoint<S extends HoverSeriesMap>(
44 options: ClosestPointOptions<S>,
45 ): HoverQuery<ClosestPointHit<S>> {
46 return (context) => {
47 limit(options.maxDistance, "closestPoint maxDistance");
48 const position = resolveSignalValue(options.position);
49 if (position === null) return undefined;
50 finite(position.x, "closestPoint position.x");
51 finite(position.y, "closestPoint position.y");
52 const xs = context.scale(options.xScale),
53 ys = context.scale(options.yScale);
54 let best: ClosestPointHit<S> | undefined,
55 distance = Infinity;
56 for (const key of Object.keys(options.series)) {
57 const series = options.series[key];
58 const data = pointData(resolveSignalValue(series.data), "closestPoint");
59 const x = pointAccessor(series.x),
60 y = pointAccessor(series.y);
61 for (let index = 0; index < data.length; index++) {
62 const datum = data[index];
63 if (series.defined && !series.defined(datum, index, data)) continue;
64 const xv = x(datum, index, data),
65 yv = y(datum, index, data);
66 if (!finiteCoordinate(xv) || !finiteCoordinate(yv)) continue;
67 const anchor = { x: xs.map(xv), y: ys.map(yv) };
68 const next = Math.hypot(anchor.x - position.x, anchor.y - position.y);
69 finite(next, "closestPoint distance");
70 if (
71 next < distance &&
72 (options.maxDistance === undefined || next <= options.maxDistance)
73 ) {
74 distance = next;
75 best = {
76 series: key,
77 datum,
78 index,
79 x: xv,
80 y: yv,
81 anchor,
82 distance,
83 } as ClosestPointHit<S>;
84 }
85 }
86 }
87 return best;
88 };
89 }
90
91 import type {
92 ClosestXOptions,
93 ClosestXHit,
94 HoverInterpolation,
95 InterpolateXOptions,
96 HoverSample,
97 } from "./hover-types.js";
98 /**
99 * Selects a recorded observation by domain-x distance, independently of pointer y.
100 *
101 * @param options - Series and horizontal-distance policy. See {@link ClosestXOptions}.
102 * @returns A query selecting the nearest horizontal match, or undefined when none qualifies. See
103 * {@link HoverQuery} , {@link ClosestXHit} .
104 *
105 * @see {@link ClosestXOptions}
106 * @see {@link HoverQuery}
107 * @see {@link ClosestXHit}
108 */
109 export function closestX<D>(
110 options: ClosestXOptions<D>,
111 ): HoverQuery<ClosestXHit<D>> {
112 return (context) => {
113 limit(options.maxDelta, "closestX maxDelta");
114 const query = resolveSignalValue(options.x);
115 if (query === null) return undefined;
116 finite(query, "closestX x");
117 const xs = context.scale(options.xScale),
118 ys = context.scale(options.yScale);
119 const series = options.series,
120 data = pointData(resolveSignalValue(series.data), "closestX");
121 const x = pointAccessor(series.x),
122 y = pointAccessor(series.y);
123 let best: ClosestXHit<D> | undefined,
124 delta = Infinity;
125 for (let index = 0; index < data.length; index++) {
126 const datum = data[index];
127 if (series.defined && !series.defined(datum, index, data)) continue;
128 const xv = x(datum, index, data),
129 yv = y(datum, index, data);
130 if (!finiteCoordinate(xv) || !finiteCoordinate(yv)) continue;
131 const distance = Math.abs(query - xv);
132 finite(distance, "closestX delta");
133 if (
134 distance < delta &&
135 (options.maxDelta === undefined || distance <= options.maxDelta)
136 ) {
137 delta = distance;
138 best = {
139 datum,
140 index,
141 x: xv,
142 y: yv,
143 anchor: { x: xs.map(xv), y: ys.map(yv) },
144 delta,
145 };
146 }
147 }
148 return best;
149 };
150 }
151 /**
152 * Returns a plain data-space linear interpolation function.
153 *
154 * @returns A linear interpolator for values bracketing a sample position. See
155 * {@link HoverInterpolation} .
156 *
157 * @see {@link HoverInterpolation}
158 */
159 export function linearInterpolation(): HoverInterpolation {
160 return (before, after, fraction) => {
161 finite(before, "linearInterpolation before");
162 finite(after, "linearInterpolation after");
163 finite(fraction, "linearInterpolation fraction");
164 const result = before * (1 - fraction) + after * fraction;
165 finite(result, "linearInterpolation result");
166 return result;
167 };
168 }
169 /**
170 * Samples sorted recorded observations without bridging explicit data gaps.
171 *
172 * @param options - Series, horizontal sampling, and interpolation policy. See
173 * {@link InterpolateXOptions} .
174 * @returns A query producing an interpolated sample, or undefined when sampling is unavailable.
175 * See {@link HoverQuery} , {@link HoverSample} .
176 *
177 * @see {@link InterpolateXOptions}
178 * @see {@link HoverQuery}
179 * @see {@link HoverSample}
180 */
181 export function interpolateX<D>(
182 options: InterpolateXOptions<D>,
183 ): HoverQuery<HoverSample<D>> {
184 return (context) => {
185 limit(options.maxGap, "interpolateX maxGap");
186 const policy = options.duplicateX ?? "error";
187 if (!["error", "first", "last"].includes(policy))
188 throw new TypeError(
189 "interpolateX duplicateX must be error, first, or last.",
190 );
191 if (typeof options.interpolation !== "function")
192 throw new TypeError("interpolateX requires an interpolation function.");
193 const query = resolveSignalValue(options.x);
194 if (query === null) return undefined;
195 finite(query, "interpolateX x");
196 const xs = context.scale(options.xScale),
197 ys = context.scale(options.yScale);
198 const series = options.series,
199 data = pointData(resolveSignalValue(series.data), "interpolateX");
200 const x = pointAccessor(series.x),
201 y = pointAccessor(series.y);
202 type Row = { datum: D; index: number; x: number; y: number };
203 const segments: Row[][] = [];
204 let segment: Row[] = [],
205 previousX = -Infinity;
206 const breakSegment = () => {
207 if (segment.length) segments.push(segment);
208 segment = [];
209 };
210 // Validate the complete source before answering even an exact query.
211 for (let index = 0; index < data.length; index++) {
212 const datum = data[index];
213 if (series.defined && !series.defined(datum, index, data)) {
214 breakSegment();
215 continue;
216 }
217 const xv = x(datum, index, data),
218 yv = y(datum, index, data);
219 if (finiteCoordinate(xv)) {
220 if (xv < previousX)
221 throw new RangeError(
222 "interpolateX requires nondecreasing source x order.",
223 );
224 previousX = xv;
225 }
226 if (!finiteCoordinate(xv) || !finiteCoordinate(yv)) {
227 breakSegment();
228 continue;
229 }
230 const row = { datum, index, x: xv, y: yv },
231 previous = segment.at(-1);
232 if (previous?.x === xv) {
233 if (policy === "error")
234 throw new RangeError(
235 "interpolateX duplicate x requires an explicit first/last policy.",
236 );
237 if (policy === "last") segment[segment.length - 1] = row;
238 } else segment.push(row);
239 }
240 breakSegment();
241 for (const rows of segments) {
242 for (let i = 0; i < rows.length; i++) {
243 const before = rows[i];
244 if (before.x === query)
245 return {
246 kind: "exact",
247 ...before,
248 anchor: { x: xs.map(query), y: ys.map(before.y) },
249 };
250 const after = rows[i + 1];
251 if (!after || query <= before.x || query >= after.x) continue;
252 const span = after.x - before.x,
253 offset = query - before.x;
254 finite(span, "interpolateX interval");
255 finite(offset, "interpolateX offset");
256 if (options.maxGap !== undefined && span > options.maxGap)
257 return undefined;
258 const fraction = offset / span,
259 value = options.interpolation(before.y, after.y, fraction);
260 finite(value, "interpolateX interpolator result");
261 return {
262 kind: "interpolated",
263 x: query,
264 y: value,
265 before: before.datum,
266 after: after.datum,
267 beforeIndex: before.index,
268 afterIndex: after.index,
269 fraction,
270 anchor: { x: xs.map(query), y: ys.map(value) },
271 };
272 }
273 }
274 return undefined;
275 };
276 }
277
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.