Skip to content

packages/core/src/features/viz/lib/hover-queries.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

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 built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.