Skip to content

packages/core/src/lib/animation/definition.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 { withSignalWriteForbidden } from '../signals/graph.js';
2 import { easing } from './easing.js';
3 import {
4   createSpringRecord,
5   resolveSpringStart,
6   sampleSpring,
7   type SpringDefinitionRecord,
8 } from './spring.js';
9 import type {
10   PibblAnimationDefinition,
11   PibblAnimationTarget,
12   PibblAnimationOptions,
13   PibblAnimationSampleInput,
14   PibblEasing,
15   PibblInterpolator,
16   PibblKeyframesOptions,
17   PibblSpringOptions,
18   PibblStepperOptions,
19   PibblTweenOptions,
20 } from './types.js';
21 
22 declare const seekableDefinitionActivationBrand: unique symbol;
23 
24 interface BaseDefinitionRecord {
25   readonly duration: number;
26   readonly debugName: string | undefined;
27 }
28 
29 interface TweenDefinitionRecord<T> extends BaseDefinitionRecord {
30   readonly kind: 'tween';
31   readonly hasFrom: boolean;
32   readonly from: T | undefined;
33   readonly to: T;
34   readonly easing: PibblEasing;
35   readonly interpolate: PibblInterpolator<T> | undefined;
36 }
37 
38 interface KeyframesDefinitionRecord<T> extends BaseDefinitionRecord {
39   readonly kind: 'keyframes';
40   readonly values: readonly T[];
41   readonly offsets: readonly number[];
42   readonly easings: readonly PibblEasing[];
43   readonly interpolate: PibblInterpolator<T> | undefined;
44 }
45 
46 interface CustomDefinitionRecord<T> extends BaseDefinitionRecord {
47   readonly kind: 'custom';
48   readonly sample: (input: PibblAnimationSampleInput<T>) => T;
49 }
50 
51 export interface StepperDefinitionRecord<T> extends BaseDefinitionRecord {
52   readonly kind: 'stepper';
53   readonly stepMilliseconds: number;
54   readonly maxElapsedMilliseconds: number;
55   readonly initial: (currentValue: T) => unknown;
56   readonly step: (state: unknown, deltaMilliseconds: number) => unknown;
57   readonly read: (state: unknown) => T;
58   readonly done: ((state: unknown) => boolean) | undefined;
59 }
60 
61 export type DefinitionRecord<T> =
62   | TweenDefinitionRecord<T>
63   | KeyframesDefinitionRecord<T>
64   | CustomDefinitionRecord<T>
65   | StepperDefinitionRecord<T>
66   | SpringDefinitionRecord;
67 
68 /** @internal Opaque activation-time state for one seekable track instance. */
69 export interface SeekableDefinitionActivation<T> {
70   readonly [seekableDefinitionActivationBrand]: T;
71   readonly duration: number;
72   readonly initialValue: T;
73 }
74 
75 /** @internal Callback-free knowledge about a seekable definition boundary. */
76 export type SeekableDefinitionBoundaryValue<T> =
77   | { readonly kind: 'known'; readonly value: T }
78   | { readonly kind: 'unknown' };
79 
80 interface SeekableDefinitionActivationRecord<T> {
81   readonly definition: PibblAnimationDefinition<T>;
82   readonly definitionRecord: Exclude<DefinitionRecord<T>, StepperDefinitionRecord<T>>;
83   readonly initialValue: T;
84 }
85 
86 const definitionRecords = new WeakMap<
87   PibblAnimationDefinition<unknown>,
88   DefinitionRecord<unknown>
89 >();
90 // Binding is carried by the definition, so the hook does not select algorithms.
91 const targetBindings = new WeakMap<object, {
92   readonly unbound: boolean;
93   readonly bind: (target: PibblAnimationTarget<any>) => PibblAnimationDefinition<any>;
94 }>();
95 
96 function targetDefinition<T>(
97   definition: PibblAnimationDefinition<T>,
98   unbound: boolean,
99   bind: (target: PibblAnimationTarget<T>) => PibblAnimationDefinition<T>,
100 ): PibblAnimationDefinition<T> {
101   targetBindings.set(definition, { unbound, bind });
102   return definition;
103 }
104 
105 /** @internal Resolves hook-owned endpoints using the definition's implementation. */
106 export function bindAnimationTarget<T>(
107   definition: PibblAnimationDefinition<T>,
108   target: PibblAnimationTarget<T>,
109 ): PibblAnimationDefinition<T> {
110   const binding = targetBindings.get(definition);
111   if (binding) return binding.bind(target);
112   getDefinitionRecord(definition);
113   return definition;
114 }
115 
116 const seekableActivationRecords = new WeakMap<
117   SeekableDefinitionActivation<unknown>,
118   SeekableDefinitionActivationRecord<unknown>
119 >();
120 let keyframeDefinitions = 0;
121 let keyframeStorageArrayAllocations = 0;
122 let keyframeSamples = 0;
123 let keyframeSampleArrayAllocations = 0;
124 let keyframeSampleDepth = 0;
125 
126 export interface AnimationDefinitionPerformanceSnapshot {
127   readonly keyframeDefinitions: number;
128   readonly keyframeStorageArrayAllocations: number;
129   readonly keyframeSamples: number;
130   readonly keyframeSampleArrayAllocations: number;
131 }
132 
133 /** @internal Deterministic structural counters for normalized keyframe sampling. */
134 export function animationDefinitionPerformanceSnapshot():
135 AnimationDefinitionPerformanceSnapshot {
136   return Object.freeze({
137     keyframeDefinitions,
138     keyframeStorageArrayAllocations,
139     keyframeSamples,
140     keyframeSampleArrayAllocations,
141   });
142 }
143 
144 function definitionLabel(debugName: string | undefined): string {
145   return debugName === undefined ? 'Pibbl animation definition' :
146     `Pibbl animation definition "${debugName}"`;
147 }
148 
149 function assertDuration(duration: number, debugName: string | undefined): void {
150   if (!Number.isFinite(duration) || duration < 0) {
151     throw new RangeError(`${definitionLabel(debugName)} duration must be finite and nonnegative.`);
152   }
153 }
154 
155 function assertFiniteNumber(value: number, message: string): void {
156   if (!Number.isFinite(value)) throw new RangeError(message);
157 }
158 
159 const MUTATING_MAP_METHODS = new Set<PropertyKey>(['set', 'delete', 'clear']);
160 const MUTATING_SET_METHODS = new Set<PropertyKey>(['add', 'delete', 'clear']);
161 const MUTATING_DATE_METHODS = new Set<PropertyKey>([
162   'setDate', 'setFullYear', 'setHours', 'setMilliseconds', 'setMinutes',
163   'setMonth', 'setSeconds', 'setTime', 'setUTCDate', 'setUTCFullYear',
164   'setUTCHours', 'setUTCMilliseconds', 'setUTCMinutes', 'setUTCMonth',
165   'setUTCSeconds', 'setYear',
166 ]);
167 
168 function immutableBuiltin<T extends object>(
169   target: T,
170   mutatingMethods: ReadonlySet<PropertyKey>,
171 ): T {
172   return new Proxy(target, {
173     get(current, key) {
174       if (mutatingMethods.has(key)) {
175         return (): never => {
176           throw new TypeError('Cannot mutate an animation definition snapshot.');
177         };
178       }
179       const member = Reflect.get(current, key, current);
180       return typeof member === 'function' && key !== 'constructor' ?
181         member.bind(current) : member;
182     },
183     set(): false {
184       return false;
185     },
186     defineProperty(): false {
187       return false;
188     },
189     deleteProperty(): false {
190       return false;
191     },
192   });
193 }
194 
195 function rememberIdentity<T extends object>(
196   value: T,
197   seen: WeakMap<object, unknown>,
198 ): T {
199   seen.set(value, value);
200   return value;
201 }
202 
203 function hasOwnProperties(value: object, allowed: ReadonlySet<PropertyKey>): boolean {
204   return Reflect.ownKeys(value).some(key => !allowed.has(key));
205 }
206 
207 function hasOnlyDataProperties(value: object, allowed: ReadonlySet<PropertyKey>): boolean {
208   return Reflect.ownKeys(value).every(key => {
209     if (allowed.has(key)) return true;
210     const descriptor = Object.getOwnPropertyDescriptor(value, key);
211     return descriptor !== undefined && 'value' in descriptor;
212   });
213 }
214 
215 const ARRAY_OWN_KEYS = new Set<PropertyKey>(['length']);
216 const REGEXP_OWN_KEYS = new Set<PropertyKey>(['lastIndex']);
217 const snapshotsRequiringIsolation = new WeakSet<object>();
218 
219 function requiresIsolation(value: unknown): value is object {
220   return (typeof value === 'object' && value !== null || typeof value === 'function') &&
221     snapshotsRequiringIsolation.has(value as object);
222 }
223 
224 function propagateIsolation(snapshot: object, value: unknown): void {
225   if (requiresIsolation(value)) snapshotsRequiringIsolation.add(snapshot);
226 }
227 
228 function isArrayIndex(key: PropertyKey): boolean {
229   return typeof key === 'string' && /^(?:0|[1-9]\d*)$/u.test(key);
230 }
231 
232 function cloneTypedArray(
233   value: ArrayBufferView,
234   buffer?: ArrayBuffer,
235 ): ArrayBufferView {
236   if (value instanceof DataView) {
237     if (buffer !== undefined) {
238       return new DataView(buffer, value.byteOffset, value.byteLength);
239     }
240     const bytes = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
241     return new DataView(bytes);
242   }
243   const TypedArray = value.constructor as unknown as {
244     new (source: ArrayBufferView): ArrayBufferView;
245     new (buffer: ArrayBuffer, byteOffset: number, length: number): ArrayBufferView;
246   };
247   return buffer === undefined ? new TypedArray(value) :
248     new TypedArray(
249       buffer,
250       value.byteOffset,
251       (value as ArrayBufferView & { readonly length: number }).length,
252     );
253 }
254 
255 /**
256  * Definitions retain values after their caller has moved on. The context is
257  * shared across one definition so aliases and cycles remain aliases and cycles
258  * in its immutable snapshot. Only categories with stable, inspectable cloning
259  * semantics are copied. Reconstructable values that cannot themselves be
260  * frozen are marked so only the containing paths are rematerialized before a
261  * callback or terminal return. Functions, classes, accessors, and opaque host
262  * values (notably WeakMap, WeakSet, Promise, and host handles) remain usable
263  * identity values because cloning them can manufacture an invalid receiver or
264  * change their behavior; an absolute snapshot guarantee for those values
265  * requires an explicit public clone contract that Pibbl does not currently
266  * provide.
267  */
268 function snapshotAnimationValue<T>(
269   value: T,
270   seen: WeakMap<object, unknown>,
271   isolateStoredValue = false,
272 ): T {
273   if (value === null || typeof value === 'function' || typeof value !== 'object') {
274     return value;
275   }
276   const existing = seen.get(value);
277   if (existing !== undefined) return existing as T;
278   if (isolateStoredValue && !requiresIsolation(value)) {
279     seen.set(value, value);
280     return value;
281   }
282 
283   if (value instanceof Date) {
284     if (hasOwnProperties(value, new Set())) return rememberIdentity(value, seen) as T;
285     const snapshot = immutableBuiltin(new Date(value.getTime()), MUTATING_DATE_METHODS);
286     seen.set(value, snapshot);
287     return snapshot as T;
288   }
289   if (value instanceof Map) {
290     if (hasOwnProperties(value, new Set())) return rememberIdentity(value, seen) as T;
291     const target = new Map();
292     const snapshot = immutableBuiltin(target, MUTATING_MAP_METHODS);
293     seen.set(value, snapshot);
294     for (const [key, entry] of value) {
295       const keySnapshot = snapshotAnimationValue(key, seen, isolateStoredValue);
296       const entrySnapshot = snapshotAnimationValue(entry, seen, isolateStoredValue);
297       target.set(keySnapshot, entrySnapshot);
298       propagateIsolation(snapshot, keySnapshot);
299       propagateIsolation(snapshot, entrySnapshot);
300     }
301     return snapshot as T;
302   }
303   if (value instanceof Set) {
304     if (hasOwnProperties(value, new Set())) return rememberIdentity(value, seen) as T;
305     const target = new Set();
306     const snapshot = immutableBuiltin(target, MUTATING_SET_METHODS);
307     seen.set(value, snapshot);
308     for (const entry of value) {
309       const entrySnapshot = snapshotAnimationValue(entry, seen, isolateStoredValue);
310       target.add(entrySnapshot);
311       propagateIsolation(snapshot, entrySnapshot);
312     }
313     return snapshot as T;
314   }
315   if (Array.isArray(value)) {
316     const ownKeys = Reflect.ownKeys(value);
317     if (Object.getPrototypeOf(value) !== Array.prototype ||
318       ownKeys.some(key => !ARRAY_OWN_KEYS.has(key) && !isArrayIndex(key)) ||
319       !hasOnlyDataProperties(value, ARRAY_OWN_KEYS)) {
320       return rememberIdentity(value, seen) as T;
321     }
322     const snapshot: unknown[] = new Array(value.length);
323     if (keyframeSampleDepth > 0) keyframeSampleArrayAllocations++;
324     seen.set(value, snapshot);
325     for (const key of ownKeys) {
326       if (ARRAY_OWN_KEYS.has(key)) continue;
327       const descriptor = Object.getOwnPropertyDescriptor(value, key);
328       if (descriptor === undefined || !('value' in descriptor)) continue;
329       const itemSnapshot = snapshotAnimationValue(
330         descriptor.value,
331         seen,
332         isolateStoredValue,
333       );
334       Object.defineProperty(snapshot, key, {
335         configurable: false,
336         enumerable: descriptor.enumerable,
337         writable: false,
338         value: itemSnapshot,
339       });
340       propagateIsolation(snapshot, itemSnapshot);
341     }
342     return Object.freeze(snapshot) as T;
343   }
344 
345   if (value instanceof RegExp) {
346     if (hasOwnProperties(value, REGEXP_OWN_KEYS)) return rememberIdentity(value, seen) as T;
347     const snapshot = new RegExp(value.source, value.flags);
348     snapshot.lastIndex = value.lastIndex;
349     seen.set(value, snapshot);
350     snapshotsRequiringIsolation.add(snapshot);
351     return snapshot as T;
352   }
353 
354   if (ArrayBuffer.isView(value)) {
355     const ownKeys = Reflect.ownKeys(value);
356     if (ownKeys.some(key => !isArrayIndex(key))) return rememberIdentity(value, seen) as T;
357     const buffer = value.buffer instanceof ArrayBuffer ?
358       snapshotAnimationValue(value.buffer, seen, isolateStoredValue) : undefined;
359     const snapshot = cloneTypedArray(value, buffer);
360     seen.set(value, snapshot);
361     snapshotsRequiringIsolation.add(snapshot);
362     return snapshot as T;
363   }
364 
365   if (value instanceof ArrayBuffer) {
366     const snapshot = value.slice(0);
367     seen.set(value, snapshot);
368     snapshotsRequiringIsolation.add(snapshot);
369     return snapshot as T;
370   }
371 
372   const prototype = Object.getPrototypeOf(value);
373   if (prototype !== Object.prototype && prototype !== null ||
374     !hasOnlyDataProperties(value, new Set())) {
375     return rememberIdentity(value, seen) as T;
376   }
377   const snapshot = Object.create(prototype) as Record<PropertyKey, unknown>;
378   seen.set(value, snapshot);
379   for (const key of Reflect.ownKeys(value)) {
380     const descriptor = Object.getOwnPropertyDescriptor(value, key);
381     if (descriptor === undefined) continue;
382     const propertySnapshot = snapshotAnimationValue(
383       descriptor.value,
384       seen,
385       isolateStoredValue,
386     );
387     Object.defineProperty(snapshot, key, {
388       configurable: false,
389       enumerable: descriptor.enumerable,
390       writable: false,
391       value: propertySnapshot,
392     });
393     propagateIsolation(snapshot, propertySnapshot);
394   }
395   return Object.freeze(snapshot) as T;
396 }
397 
398 function isolateStoredAnimationValue<T>(
399   value: T,
400   seen: WeakMap<object, unknown>,
401 ): T {
402   return snapshotAnimationValue(value, seen, true);
403 }
404 
405 /** @internal Captures a committed output value for a playback activation. */
406 export function snapshotAnimationActivationValue<T>(
407   value: T,
408   seen: WeakMap<object, unknown> = new WeakMap(),
409 ): T {
410   return snapshotAnimationValue(value, seen);
411 }
412 
413 function validateSampledValue<T>(sampled: T, debugName: string | undefined): T {
414   if (typeof sampled === 'number' && !Number.isFinite(sampled)) {
415     throw new RangeError(`${definitionLabel(debugName)} sampled value must be finite.`);
416   }
417   return sampled;
418 }
419 
420 function createDefinition<T>(record: DefinitionRecord<T>): PibblAnimationDefinition<T> {
421   const definition = Object.freeze({}) as PibblAnimationDefinition<T>;
422   definitionRecords.set(
423     definition as PibblAnimationDefinition<unknown>,
424     Object.freeze(record) as DefinitionRecord<unknown>,
425   );
426   return definition;
427 }
428 
429 function hasOwn(object: object, key: PropertyKey): boolean {
430   return Object.prototype.hasOwnProperty.call(object, key);
431 }
432 
433 function callback<T>(
434   debugName: string | undefined,
435   callback: () => T,
436 ): T {
437   return withSignalWriteForbidden(
438     `${definitionLabel(debugName)} callbacks cannot write to signals.`,
439     callback,
440   );
441 }
442 
443 function sampledProgress(duration: number, localTime: number): {
444   readonly localTime: number;
445   readonly progress: number;
446 } {
447   assertFiniteNumber(localTime, 'Pibbl animation sample localTime must be finite.');
448   if (duration === 0) return Object.freeze({ localTime: 0, progress: 1 });
449   const normalizedLocalTime = Math.min(duration, Math.max(0, localTime));
450   return Object.freeze({
451     localTime: normalizedLocalTime,
452     progress: normalizedLocalTime / duration,
453   });
454 }
455 
456 function interpolate<T>(
457   from: T,
458   to: T,
459   progress: number,
460   custom: PibblInterpolator<T> | undefined,
461   debugName: string | undefined,
462 ): T {
463   if (custom === undefined && (typeof from !== 'number' || typeof to !== 'number')) {
464     throw new TypeError(`${definitionLabel(debugName)} requires an explicit interpolator for nonnumeric values.`);
465   }
466   let sampled: T;
467   if (custom === undefined) {
468     sampled = ((from as unknown as number) +
469       ((to as unknown as number) - (from as unknown as number)) * progress) as T;
470   } else {
471     const isolated = new WeakMap<object, unknown>();
472     const isolatedFrom = isolateStoredAnimationValue(from, isolated);
473     const isolatedTo = isolateStoredAnimationValue(to, isolated);
474     sampled = callback(debugName, () => custom(isolatedFrom, isolatedTo, progress));
475   }
476   return validateSampledValue(sampled, debugName);
477 }
478 
479 function easedProgress(
480   ease: PibblEasing,
481   progress: number,
482   debugName: string | undefined,
483 ): number {
484   const eased = callback(debugName, () => ease(progress));
485   if (!Number.isFinite(eased)) {
486     throw new RangeError(`${definitionLabel(debugName)} easing result must be finite.`);
487   }
488   return eased;
489 }
490 
491 function normalizeOffsets(
492   values: readonly unknown[],
493   offsets: readonly number[] | undefined,
494   debugName: string | undefined,
495 ): readonly number[] {
496   if (offsets === undefined) {
497     return Object.freeze(values.map((_, index) => index / (values.length - 1)));
498   }
499   if (offsets.length !== values.length) {
500     throw new RangeError(`${definitionLabel(debugName)} keyframe offsets must match the values length.`);
501   }
502   const snapshot = [...offsets];
503   for (const offset of snapshot) {
504     if (!Number.isFinite(offset) || offset < 0 || offset > 1) {
505       throw new RangeError(`${definitionLabel(debugName)} keyframe offsets must be finite values within [0, 1].`);
506     }
507   }
508   if (snapshot[0] !== 0) {
509     throw new RangeError(`${definitionLabel(debugName)} keyframe offsets must start at 0.`);
510   }
511   if (snapshot.at(-1) !== 1) {
512     throw new RangeError(`${definitionLabel(debugName)} keyframe offsets must end at 1.`);
513   }
514   for (let index = 1; index < snapshot.length; index++) {
515     if (snapshot[index] < snapshot[index - 1]) {
516       throw new RangeError(`${definitionLabel(debugName)} keyframe offsets must be nondecreasing.`);
517     }
518   }
519   return Object.freeze(snapshot);
520 }
521 
522 function normalizeEasings(
523   easingOption: PibblEasing | readonly PibblEasing[] | undefined,
524   segmentCount: number,
525   debugName: string | undefined,
526 ): readonly PibblEasing[] {
527   if (Array.isArray(easingOption)) {
528     if (easingOption.length !== segmentCount) {
529       throw new RangeError(`${definitionLabel(debugName)} keyframe easing list must have one entry per segment.`);
530     }
531     if (easingOption.some(ease => typeof ease !== 'function')) {
532       throw new TypeError(`${definitionLabel(debugName)} keyframe easings must be functions.`);
533     }
534     return Object.freeze([...easingOption]);
535   }
536   const ease = easingOption ?? easing.linear;
537   if (typeof ease !== 'function') {
538     throw new TypeError(`${definitionLabel(debugName)} easing must be a function.`);
539   }
540   return Object.freeze(Array.from({ length: segmentCount }, () => ease));
541 }
542 
543 /**
544  * Returns an opaque immutable tween definition.
545  *
546  * @param options - Start and end values, duration, easing, and interpolation settings. See
547  * {@link PibblTweenOptions} .
548  * @returns An immutable tween definition. See {@link PibblAnimationDefinition}.
549  *
550  * @see {@link PibblTweenOptions}
551  * @see {@link PibblAnimationDefinition}
552  */
553 export function tween<T = number>(options: Readonly<PibblTweenOptions<T>>): PibblAnimationDefinition<T> {
554   assertDuration(options.duration, options.debugName);
555   if (typeof options.easing !== 'undefined' && typeof options.easing !== 'function') {
556     throw new TypeError(`${definitionLabel(options.debugName)} easing must be a function.`);
557   }
558   if (typeof options.interpolate !== 'undefined' && typeof options.interpolate !== 'function') {
559     throw new TypeError(`${definitionLabel(options.debugName)} interpolator must be a function.`);
560   }
561   const hasFrom = hasOwn(options, 'from');
562   const snapshots = new WeakMap<object, unknown>();
563   const record: TweenDefinitionRecord<T> = {
564     kind: 'tween',
565     duration: options.duration,
566     debugName: options.debugName,
567     hasFrom,
568     from: hasFrom ? snapshotAnimationValue(options.from as T, snapshots) : undefined,
569     to: snapshotAnimationValue(options.to as T, snapshots),
570     easing: options.easing ?? easing.linear,
571     interpolate: options.interpolate,
572   };
573   return targetDefinition(createDefinition(record), !hasOwn(options, 'to'), ({ from, to }) =>
574     tween({ from, to, duration: record.duration, easing: record.easing,
575       interpolate: record.interpolate, debugName: record.debugName }));
576 }
577 
578 /**
579  * Returns an opaque immutable keyframe definition.
580  *
581  * @param options - Keyframe values, timing, easing, and interpolation settings. See
582  * {@link PibblKeyframesOptions} .
583  * @returns An immutable animation definition sampling the keyframes. See
584  * {@link PibblAnimationDefinition} .
585  *
586  * @see {@link PibblKeyframesOptions}
587  * @see {@link PibblAnimationDefinition}
588  */
589 export function keyframes<T>(
590   options: Readonly<PibblKeyframesOptions<T>>,
591 ): PibblAnimationDefinition<T> {
592   assertDuration(options.duration, options.debugName);
593   if (options.values.length < 2) {
594     throw new RangeError(`${definitionLabel(options.debugName)} keyframes require at least two values.`);
595   }
596   if (typeof options.interpolate !== 'undefined' && typeof options.interpolate !== 'function') {
597     throw new TypeError(`${definitionLabel(options.debugName)} interpolator must be a function.`);
598   }
599   const snapshots = new WeakMap<object, unknown>();
600   const values = Object.freeze(options.values.map(value => snapshotAnimationValue(value, snapshots)));
601   const offsets = normalizeOffsets(values, options.offsets, options.debugName);
602   const easings = normalizeEasings(options.easing, values.length - 1, options.debugName);
603   keyframeDefinitions++;
604   keyframeStorageArrayAllocations += 3;
605   return createDefinition({
606     kind: 'keyframes',
607     duration: options.duration,
608     debugName: options.debugName,
609     values,
610     offsets,
611     easings,
612     interpolate: options.interpolate,
613   });
614 }
615 
616 /**
617  * Returns an opaque immutable analytic one-dimensional spring definition.
618  *
619  * @param options - Target, initial conditions, and spring parameters. See {@link PibblSpringOptions}
620  * .
621  * @returns An analytic numeric spring definition. See {@link PibblAnimationDefinition}.
622  *
623  * @see {@link PibblSpringOptions}
624  * @see {@link PibblAnimationDefinition}
625  */
626 export function spring(
627   options: Readonly<PibblSpringOptions> = {},
628 ): PibblAnimationDefinition<number> {
629   const unbound = !hasOwn(options, 'to');
630   const record = createSpringRecord({ ...options, to: unbound ? 0 : options.to as number });
631   return targetDefinition(createDefinition<number>(record), unbound, ({ from, to, velocity }) =>
632     spring({ ...record, from, to, initialVelocity: velocity ?? record.initialVelocity }));
633 }
634 
635 /**
636  * Returns an opaque immutable definition from a custom seekable sampler.
637  *
638  * @param options - Duration and pure sampling callback for the animation. See
639  * {@link PibblAnimationOptions} .
640  * @returns An immutable, seekable animation definition. See {@link PibblAnimationDefinition}.
641  *
642  * @see {@link PibblAnimationOptions}
643  * @see {@link PibblAnimationDefinition}
644  */
645 export function defineAnimation<T>(
646   options: Readonly<PibblAnimationOptions<T>>,
647 ): PibblAnimationDefinition<T> {
648   assertDuration(options.duration, options.debugName);
649   if (typeof options.sample !== 'function') {
650     throw new TypeError(`${definitionLabel(options.debugName)} sample must be a function.`);
651   }
652   return createDefinition({
653     kind: 'custom',
654     duration: options.duration,
655     debugName: options.debugName,
656     sample: options.sample,
657   });
658 }
659 
660 /**
661  * Returns an opaque immutable stepper definition. Steppers are evaluated by
662  * the playback engine, not by the seekable sampler.
663  *
664  * @param options - Initial state, fixed-step integration, value reader, and completion predicate.
665  * See {@link PibblStepperOptions} .
666  * @returns A stateful animation definition advanced by fixed simulation steps. See
667  * {@link PibblAnimationDefinition} .
668  *
669  * @see {@link PibblStepperOptions}
670  * @see {@link PibblAnimationDefinition}
671  */
672 export function stepper<State, T>(
673   options: Readonly<PibblStepperOptions<State, T>>,
674 ): PibblAnimationDefinition<T> {
675   if (typeof options.initial !== 'function') {
676     throw new TypeError(`${definitionLabel(options.debugName)} stepper initial must be a function.`);
677   }
678   if (typeof options.step !== 'function') {
679     throw new TypeError(`${definitionLabel(options.debugName)} stepper step must be a function.`);
680   }
681   if (typeof options.read !== 'function') {
682     throw new TypeError(`${definitionLabel(options.debugName)} stepper read must be a function.`);
683   }
684   if (typeof options.done !== 'undefined' && typeof options.done !== 'function') {
685     throw new TypeError(`${definitionLabel(options.debugName)} stepper done must be a function.`);
686   }
687   if (!Number.isFinite(options.stepMilliseconds) || options.stepMilliseconds <= 0) {
688     throw new RangeError(`${definitionLabel(options.debugName)} stepper stepMilliseconds must be finite and positive.`);
689   }
690   const maxElapsedMilliseconds = options.maxElapsedMilliseconds ?? 100;
691   if (!Number.isFinite(maxElapsedMilliseconds) || maxElapsedMilliseconds <= 0) {
692     throw new RangeError(`${definitionLabel(options.debugName)} stepper maxElapsedMilliseconds must be finite and positive.`);
693   }
694   return createDefinition({
695     kind: 'stepper',
696     duration: Number.POSITIVE_INFINITY,
697     debugName: options.debugName,
698     stepMilliseconds: options.stepMilliseconds,
699     maxElapsedMilliseconds,
700     initial: options.initial as (currentValue: T) => unknown,
701     step: options.step as (state: unknown, deltaMilliseconds: number) => unknown,
702     read: options.read as (state: unknown) => T,
703     done: options.done as ((state: unknown) => boolean) | undefined,
704   });
705 }
706 
707 /** @internal Returns the private immutable record associated with a definition. */
708 export function getDefinitionRecord<T>(
709   definition: PibblAnimationDefinition<T>,
710 ): DefinitionRecord<T> {
711   if (targetBindings.get(definition)?.unbound) {
712     throw new TypeError('Animation definition needs a target: supply to for drive(), or pass it to useAnimatedValue().');
713   }
714   const record = definitionRecords.get(definition as PibblAnimationDefinition<unknown>);
715   if (record === undefined) throw new TypeError('Expected a Pibbl animation definition.');
716   return record as DefinitionRecord<T>;
717 }
718 
719 /** @internal Samples a non-stepper definition from its activation snapshot. */
720 export function sampleSeekableDefinition<T>(
721   definition: PibblAnimationDefinition<T>,
722   input: Readonly<Pick<PibblAnimationSampleInput<T>, 'initialValue' | 'localTime'>>,
723 ): T {
724   const record = getDefinitionRecord(definition);
725   if (record.kind === 'stepper') {
726     throw new TypeError(
727       `${definitionLabel(record.debugName)} is stateful and requires the stateful program evaluator.`,
728     );
729   }
730   if (record.kind === 'spring') {
731     const resolved = resolveSpringStart(record, input.initialValue as number);
732     const timing = sampledProgress(resolved.duration, input.localTime);
733     return sampleSpring(resolved, timing.localTime / 1000).position as T;
734   }
735   const timing = sampledProgress(record.duration, input.localTime);
736   if (record.kind === 'custom') {
737     const sampled = callback(record.debugName, () => record.sample(Object.freeze({
738       initialValue: input.initialValue,
739       localTime: timing.localTime,
740       progress: timing.progress,
741     })));
742     return validateSampledValue(sampled, record.debugName);
743   }
744   if (record.kind === 'tween') {
745     const from = record.hasFrom ? record.from as T : input.initialValue;
746     return interpolate(
747       from,
748       record.to,
749       easedProgress(record.easing, timing.progress, record.debugName),
750       record.interpolate,
751       record.debugName,
752     );
753   }
754 
755   keyframeSamples++;
756   keyframeSampleDepth++;
757   try {
758     return sampleKeyframe(record, timing);
759   } finally {
760     keyframeSampleDepth--;
761   }
762 }
763 
764 function sampleKeyframe<T>(
765   record: KeyframesDefinitionRecord<T>,
766   timing: { readonly progress: number },
767 ): T {
768   let segment = 0;
769   for (let index = 1; index < record.offsets.length; index++) {
770     if (record.offsets[index] <= timing.progress) segment = index;
771     else break;
772   }
773   if (segment === record.values.length - 1) {
774     return validateSampledValue(
775       isolateStoredAnimationValue(record.values[segment], new WeakMap()),
776       record.debugName,
777     );
778   }
779   const start = record.offsets[segment];
780   const end = record.offsets[segment + 1];
781   return interpolate(
782     record.values[segment],
783     record.values[segment + 1],
784     easedProgress(record.easings[segment], (timing.progress - start) / (end - start), record.debugName),
785     record.interpolate,
786     record.debugName,
787   );
788 }
789 
790 /**
791  * @internal Resolves activation-dependent definition data exactly once for a
792  * track instance. Stateful definitions use a separate playback-owned record.
793  */
794 export function activateSeekableDefinition<T>(
795   definition: PibblAnimationDefinition<T>,
796   initialValue: T,
797   seen: WeakMap<object, unknown> = new WeakMap(),
798 ): SeekableDefinitionActivation<T> {
799   const definitionRecord = getDefinitionRecord(definition);
800   if (definitionRecord.kind === 'stepper') {
801     throw new TypeError(
802       'Stateful animation definitions require the stateful program evaluator.',
803     );
804   }
805   const snapshot = snapshotAnimationActivationValue(initialValue, seen);
806   const resolved = definitionRecord.kind === 'spring' ?
807     resolveSpringStart(definitionRecord, snapshot as number) : definitionRecord;
808   const activation = Object.freeze({
809     duration: resolved.duration,
810     initialValue: snapshot,
811   }) as SeekableDefinitionActivation<T>;
812   seekableActivationRecords.set(
813     activation as SeekableDefinitionActivation<unknown>,
814     Object.freeze({
815       definition,
816       definitionRecord: resolved,
817       initialValue: snapshot,
818     }) as SeekableDefinitionActivationRecord<unknown>,
819   );
820   return activation;
821 }
822 
823 /** @internal Samples a definition from one retained activation record. */
824 export function sampleActivatedSeekableDefinition<T>(
825   activation: SeekableDefinitionActivation<T>,
826   localTime: number,
827 ): T {
828   const activationRecord = seekableActivationRecords.get(
829     activation as SeekableDefinitionActivation<unknown>,
830   ) as SeekableDefinitionActivationRecord<T> | undefined;
831   if (activationRecord === undefined) {
832     throw new TypeError('Expected a seekable Pibbl animation definition activation.');
833   }
834   const record = activationRecord.definitionRecord;
835   if (record.kind === 'spring') {
836     const timing = sampledProgress(record.duration, localTime);
837     return sampleSpring(record, timing.localTime / 1000).position as T;
838   }
839   return sampleSeekableDefinition(activationRecord.definition, {
840     initialValue: activationRecord.initialValue,
841     localTime,
842   });
843 }
844 
845 /**
846  * @internal Returns a boundary value only when the normalized definition makes
847  * it exact without invoking user easing, interpolation, or sampling callbacks.
848  */
849 export function getSeekableDefinitionBoundaryValue<T>(
850   activation: SeekableDefinitionActivation<T>,
851   boundary: 'start' | 'end',
852 ): SeekableDefinitionBoundaryValue<T> {
853   const activationRecord = seekableActivationRecords.get(
854     activation as SeekableDefinitionActivation<unknown>,
855   ) as SeekableDefinitionActivationRecord<T> | undefined;
856   if (activationRecord === undefined) {
857     throw new TypeError('Expected a seekable Pibbl animation definition activation.');
858   }
859   const record = activationRecord.definitionRecord;
860   if (record.kind === 'spring') {
861     return Object.freeze({
862       kind: 'known' as const,
863       value: (boundary === 'start' ? record.from : record.to) as T,
864     });
865   }
866   if (record.kind === 'keyframes') {
867     if (boundary === 'end') {
868       return Object.freeze({ kind: 'known' as const, value: record.values.at(-1)! });
869     }
870     if (record.interpolate === undefined && record.easings[0] === easing.linear) {
871       return Object.freeze({ kind: 'known' as const, value: record.values[0]! });
872     }
873     return Object.freeze({ kind: 'unknown' as const });
874   }
875   if (record.kind === 'tween' && record.interpolate === undefined &&
876     record.easing === easing.linear) {
877     return Object.freeze({
878       kind: 'known' as const,
879       value: boundary === 'start' ?
880         (record.hasFrom ? record.from as T : activationRecord.initialValue) : record.to,
881     });
882   }
883   return Object.freeze({ kind: 'unknown' as const });
884 }
885 
886 /**
887  * @internal Returns a callback-free boundary that does not depend on a track's
888  * activation value. This lets discovery recover known terminal state after an
889  * earlier opaque custom value.
890  */
891 export function getStaticSeekableDefinitionBoundaryValue<T>(
892   definition: PibblAnimationDefinition<T>,
893   boundary: 'start' | 'end',
894 ): SeekableDefinitionBoundaryValue<T> {
895   const record = getDefinitionRecord(definition);
896   if (record.kind === 'spring' && record.hasFrom) {
897     return Object.freeze({
898       kind: 'known' as const,
899       value: (boundary === 'start' ? record.from : record.to) as T,
900     });
901   }
902   if (record.kind === 'keyframes') {
903     if (boundary === 'end') {
904       return Object.freeze({ kind: 'known' as const, value: record.values.at(-1)! });
905     }
906     if (record.interpolate === undefined && record.easings[0] === easing.linear) {
907       return Object.freeze({ kind: 'known' as const, value: record.values[0]! });
908     }
909   }
910   if (record.kind === 'tween' && record.interpolate === undefined &&
911     record.easing === easing.linear) {
912     if (boundary === 'end') {
913       return Object.freeze({ kind: 'known' as const, value: record.to });
914     }
915     if (record.hasFrom) {
916       return Object.freeze({ kind: 'known' as const, value: record.from as T });
917     }
918   }
919   return Object.freeze({ kind: 'unknown' as const });
920 }
921 

Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.