packages/core/src/features/viz/inspection/queries.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
1 import type {
2 NearestRecordedPointHit,
3 NearestRecordedXHit,
4 NumericDomainInterpolation,
5 NumericDuplicateResolver,
6 InspectionCandidate,
7 NumericRenderedFilter,
8 NumericRenderedObservation,
9 NumericRenderedSeries,
10 RecordedXSample,
11 RenderedSeriesPoint,
12 } from "./query-types.js";
13
14 function finite(value: unknown, name: string): asserts value is number {
15 if (typeof value !== "number" || !Number.isFinite(value))
16 throw new RangeError(`${name} must be finite.`);
17 }
18
19 function limit(value: number | undefined, name: string): void {
20 if (value === undefined) return;
21 finite(value, name);
22 if (value < 0) throw new RangeError(`${name} must be nonnegative.`);
23 }
24
25 function point(value: Readonly<RenderedSeriesPoint>, name: string): void {
26 finite(value.x, `${name}.x`);
27 finite(value.y, `${name}.y`);
28 }
29
30 function candidate<D>(
31 rendered: NumericRenderedSeries<D>,
32 observation: NumericRenderedObservation<D>,
33 ): InspectionCandidate<D> {
34 return Object.freeze({
35 ...observation,
36 anchorInsideClips: () =>
37 rendered.anchorInsideClips(observation.anchorLocal),
38 });
39 }
40
41 function accepts<D>(
42 rendered: NumericRenderedSeries<D>,
43 observation: NumericRenderedObservation<D>,
44 filter: NumericRenderedFilter<D> | undefined,
45 ): boolean {
46 return filter === undefined || filter(candidate(rendered, observation));
47 }
48
49 function anchored<D>(
50 rendered: NumericRenderedSeries<D>,
51 observation: NumericRenderedObservation<D>,
52 ): NumericRenderedObservation<D> {
53 return { ...observation, ...rendered.anchorAtDomain(observation) };
54 }
55
56 /**
57 * Retains the first source observation at a duplicate domain coordinate.
58 *
59 * @param first - First selected observation at this domain coordinate.
60 * @param _next - Later observation at the same domain coordinate.
61 * @returns The earliest source observation.
62 * @see {@link NumericDuplicateResolver}
63 */
64 export function firstSourceObservation<D>(
65 first: NumericRenderedObservation<D>,
66 _next: NumericRenderedObservation<D>,
67 ): NumericRenderedObservation<D> {
68 return first;
69 }
70
71 /**
72 * Replaces a source observation with the next one at a duplicate domain coordinate.
73 *
74 * @param _first - Earlier selected observation at this domain coordinate.
75 * @param next - Later observation at the same domain coordinate.
76 * @returns The latest source observation.
77 * @see {@link NumericDuplicateResolver}
78 */
79 export function lastSourceObservation<D>(
80 _first: NumericRenderedObservation<D>,
81 next: NumericRenderedObservation<D>,
82 ): NumericRenderedObservation<D> {
83 return next;
84 }
85
86 /**
87 * Returns whether a filter candidate's anchor lies inside the committed clip stack.
88 *
89 * Pass this function as a query filter when visibility at the anchor is required.
90 * Queries otherwise examine all eligible rendered observations.
91 *
92 * @param candidate - Candidate whose committed local anchor should be clip-tested.
93 * @returns Whether the candidate anchor lies within every committed clip.
94 * @see {@link NumericRenderedFilter}
95 */
96 export function anchorInsideClips<D>(
97 candidate: InspectionCandidate<D>,
98 ): boolean {
99 return candidate.anchorInsideClips();
100 }
101
102 /** Options for {@link nearestRecordedX}. */
103 export interface NearestRecordedXOptions<D> {
104 /** Numeric horizontal domain value to inspect, or undefined for no request. */
105 readonly domainX: number | undefined;
106 /** Maximum accepted absolute horizontal-domain delta. */
107 readonly maxDomainDelta?: number;
108 /** Further restricts committed observations for this request. */
109 readonly filter?: NumericRenderedFilter<D>;
110 }
111
112 /**
113 * Finds the recorded observation nearest to a numeric horizontal domain value in O(n) time.
114 *
115 * Ties retain source order. Distance is measured in domain units, while both
116 * returned anchors retain their explicitly named local and root-logical units.
117 *
118 * @param rendered - Committed rendered-series generation to inspect synchronously.
119 * @param options - Domain position, optional limit, and observation filter.
120 * @returns The selected recorded observation, or undefined when none qualifies.
121 * @see {@link NearestRecordedXOptions}
122 */
123 export function nearestRecordedX<D>(
124 rendered: NumericRenderedSeries<D>,
125 options: NearestRecordedXOptions<D>,
126 ): NearestRecordedXHit<D> | undefined {
127 rendered.assertLive();
128 const query = options.domainX;
129 if (query === undefined) return undefined;
130 finite(query, "nearestRecordedX domainX");
131 limit(options.maxDomainDelta, "nearestRecordedX maxDomainDelta");
132 let best: NearestRecordedXHit<D> | undefined;
133 let delta = Infinity;
134 for (const observation of rendered.observations) {
135 if (!accepts(rendered, observation, options.filter)) continue;
136 const next = Math.abs(observation.domainX - query);
137 finite(next, "nearestRecordedX domain delta");
138 if (
139 next < delta &&
140 (options.maxDomainDelta === undefined || next <= options.maxDomainDelta)
141 ) {
142 delta = next;
143 best = Object.freeze({
144 ...anchored(rendered, observation),
145 domainDelta: next,
146 });
147 }
148 }
149 return best;
150 }
151
152 /** Options for {@link nearestRecordedPoint}. */
153 export interface NearestRecordedPointOptions<D> {
154 /** Root-logical point to inspect, or undefined for no request. */
155 readonly rootPoint: Readonly<RenderedSeriesPoint> | undefined;
156 /** Maximum accepted Euclidean root-logical distance. */
157 readonly maxRootDistance?: number;
158 /** Further restricts committed observations for this request. */
159 readonly filter?: NumericRenderedFilter<D>;
160 }
161
162 /**
163 * Finds the recorded observation nearest to a root-logical point in O(n) time.
164 *
165 * Ties retain source order. This query measures distance in root-logical units
166 * so it remains meaningful under nonuniform series transforms.
167 *
168 * @param rendered - Committed rendered-series generation to inspect synchronously.
169 * @param options - Root position, optional limit, and observation filter.
170 * @returns The selected recorded observation, or undefined when none qualifies.
171 * @see {@link NearestRecordedPointOptions}
172 */
173 export function nearestRecordedPoint<D>(
174 rendered: NumericRenderedSeries<D>,
175 options: NearestRecordedPointOptions<D>,
176 ): NearestRecordedPointHit<D> | undefined {
177 rendered.assertLive();
178 const query = options.rootPoint;
179 if (query === undefined) return undefined;
180 point(query, "nearestRecordedPoint rootPoint");
181 limit(options.maxRootDistance, "nearestRecordedPoint maxRootDistance");
182 let best: NearestRecordedPointHit<D> | undefined;
183 let distance = Infinity;
184 for (const observation of rendered.observations) {
185 if (!accepts(rendered, observation, options.filter)) continue;
186 const next = Math.hypot(
187 observation.anchorRoot.x - query.x,
188 observation.anchorRoot.y - query.y,
189 );
190 finite(next, "nearestRecordedPoint root distance");
191 if (
192 next < distance &&
193 (options.maxRootDistance === undefined || next <= options.maxRootDistance)
194 ) {
195 distance = next;
196 best = Object.freeze({
197 ...anchored(rendered, observation),
198 rootDistance: next,
199 });
200 }
201 }
202 return best;
203 }
204
205 /** Options for {@link sampleRecordedX}. */
206 export interface SampleRecordedXOptions<D> {
207 /** Numeric horizontal domain value to sample, or undefined for no request. */
208 readonly domainX: number | undefined;
209 /** Computes the estimated numeric domain value between two recorded values. */
210 readonly interpolation: NumericDomainInterpolation;
211 /** Explicitly chooses one observation from each duplicate-domain group. */
212 readonly resolveDuplicate?: NumericDuplicateResolver<D>;
213 /** Maximum domain interval that may be interpolated across. */
214 readonly maxDomainGap?: number;
215 /** Further restricts committed observations for this request. */
216 readonly filter?: NumericRenderedFilter<D>;
217 }
218
219 function resolvedRows<D>(
220 rendered: NumericRenderedSeries<D>,
221 filter: NumericRenderedFilter<D> | undefined,
222 resolver: NumericDuplicateResolver<D> | undefined,
223 ): readonly (readonly NumericRenderedObservation<D>[])[] {
224 let validatedRun: number | undefined;
225 let validatedX = -Infinity;
226 for (const observation of rendered.observations) {
227 finite(observation.domainX, "sampleRecordedX observation.domainX");
228 finite(observation.domainY, "sampleRecordedX observation.domainY");
229 if (validatedRun === observation.run && observation.domainX < validatedX)
230 throw new RangeError(
231 "sampleRecordedX requires nondecreasing domainX within each rendered run.",
232 );
233 validatedRun = observation.run;
234 validatedX = observation.domainX;
235 }
236 const runs: NumericRenderedObservation<D>[][] = [];
237 let current: NumericRenderedObservation<D>[] = [];
238 let previousRun: number | undefined;
239 const finish = () => {
240 if (current.length) runs.push(current);
241 current = [];
242 previousRun = undefined;
243 };
244 for (const observation of rendered.observations) {
245 if (previousRun !== undefined && observation.run !== previousRun) finish();
246 previousRun = observation.run;
247 if (!accepts(rendered, observation, filter)) {
248 finish();
249 continue;
250 }
251 current.push(observation);
252 }
253 finish();
254 return runs.map((run) => {
255 const resolved: NumericRenderedObservation<D>[] = [];
256 for (let start = 0; start < run.length;) {
257 let end = start + 1;
258 while (end < run.length && run[end]!.domainX === run[start]!.domainX)
259 end++;
260 let selected = run[start]!;
261 for (let duplicate = start + 1; duplicate < end; duplicate++) {
262 const next = run[duplicate]!;
263 if (resolver === undefined)
264 throw new RangeError(
265 "sampleRecordedX duplicate domainX requires resolveDuplicate.",
266 );
267 const resolved = resolver(selected, next);
268 if (resolved !== selected && resolved !== next)
269 throw new TypeError(
270 "sampleRecordedX resolveDuplicate must return its first or next observation.",
271 );
272 selected = resolved;
273 }
274 resolved.push(selected);
275 start = end;
276 }
277 return resolved;
278 });
279 }
280
281 /**
282 * Returns an exact recorded observation or a caller-defined interpolation between records.
283 *
284 * It never bridges separate rendered runs, filtered observations, or an interval larger
285 * than `maxDomainGap`. Duplicate x values reject by default and require an explicit
286 * `resolveDuplicate` function. Estimated results retain their two source observations and
287 * map explicit local/root anchors through the committed generation. This is O(n) in captured
288 * observations and the result is borrowed until the rendered generation is replaced or disposed.
289 *
290 * @param rendered - Committed rendered-series generation to inspect synchronously.
291 * @param options - Domain position, interpolation, duplicate policy, and filter.
292 * @returns An exact record, an explicitly interpolated estimate, or undefined.
293 * @see {@link SampleRecordedXOptions}
294 */
295 export function sampleRecordedX<D>(
296 rendered: NumericRenderedSeries<D>,
297 options: SampleRecordedXOptions<D>,
298 ): RecordedXSample<D> | undefined {
299 rendered.assertLive();
300 const query = options.domainX;
301 if (query === undefined) return undefined;
302 finite(query, "sampleRecordedX domainX");
303 limit(options.maxDomainGap, "sampleRecordedX maxDomainGap");
304 if (typeof options.interpolation !== "function")
305 throw new TypeError("sampleRecordedX interpolation must be a function.");
306 if (
307 options.resolveDuplicate !== undefined &&
308 typeof options.resolveDuplicate !== "function"
309 )
310 throw new TypeError("sampleRecordedX resolveDuplicate must be a function.");
311 for (const run of resolvedRows(
312 rendered,
313 options.filter,
314 options.resolveDuplicate,
315 )) {
316 for (let index = 0; index < run.length; index++) {
317 const before = run[index]!;
318 if (before.domainX === query)
319 return Object.freeze({ ...anchored(rendered, before), kind: "exact" });
320 const after = run[index + 1];
321 if (!after || query <= before.domainX || query >= after.domainX) continue;
322 const gap = after.domainX - before.domainX;
323 finite(gap, "sampleRecordedX domain gap");
324 if (options.maxDomainGap !== undefined && gap > options.maxDomainGap)
325 return undefined;
326 const fraction = (query - before.domainX) / gap;
327 const domainY = options.interpolation(
328 before.domainY,
329 after.domainY,
330 fraction,
331 );
332 finite(domainY, "sampleRecordedX interpolation result");
333 return Object.freeze({
334 kind: "interpolated",
335 domainX: query,
336 domainY,
337 ...rendered.anchorAtDomain({ domainX: query, domainY }),
338 before,
339 after,
340 fraction,
341 });
342 }
343 }
344 return undefined;
345 }
346
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision dd39ce2. ALPHA — NOT FOR PRODUCTION USE.