packages/core/src/lib/animation/definition.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.