packages/core/src/lib/animation/program.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
1 import { getSignalNode, isSignal } from '../signals/graph.js';
2 import type { WritableSignal } from '../signals/types.js';
3 import { getDefinitionRecord } from './definition.js';
4 import type {
5 PibblAnimationDefinition,
6 PibblAnimationProgram,
7 PibblAnimationRepeatDirection,
8 PibblAnimationRepeatOptions,
9 } from './types.js';
10
11 export type ProgramDuration =
12 | { readonly kind: 'finite'; readonly milliseconds: number }
13 | { readonly kind: 'infinite' }
14 | { readonly kind: 'unresolved-stepper' };
15
16 export interface WriterSpan {
17 readonly output: WritableSignal<unknown>;
18 readonly start: number;
19 readonly end: number;
20 readonly path: readonly number[];
21 }
22
23 interface WriterRepeatFrame {
24 readonly period: number;
25 readonly iterations: number;
26 }
27
28 /** @internal Activation-resolved symbolic writer schedule. */
29 export interface ResolvedWriterSchedule {
30 readonly output: WritableSignal<unknown>;
31 readonly start: number;
32 readonly end: number;
33 readonly path: readonly number[];
34 readonly repeats: readonly WriterRepeatFrame[];
35 }
36
37 interface WriterSchedule {
38 readonly span: WriterSpan;
39 readonly repeats: readonly WriterRepeatFrame[];
40 }
41
42 interface BaseProgramRecord {
43 readonly kind: 'track' | 'sequence' | 'parallel' | 'wait' | 'marker' | 'repeat';
44 readonly children: readonly PibblAnimationProgram[];
45 readonly duration: ProgramDuration;
46 readonly hasReachableWork: boolean;
47 readonly outputs: readonly WritableSignal<unknown>[];
48 readonly writerSpans: readonly WriterSpan[];
49 readonly writerSchedules: readonly WriterSchedule[];
50 }
51
52 export interface TrackProgramRecord extends BaseProgramRecord {
53 readonly kind: 'track';
54 readonly output: WritableSignal<unknown>;
55 readonly definition: PibblAnimationDefinition<unknown>;
56 }
57
58 export interface SequenceProgramRecord extends BaseProgramRecord {
59 readonly kind: 'sequence';
60 }
61
62 export interface ParallelProgramRecord extends BaseProgramRecord {
63 readonly kind: 'parallel';
64 }
65
66 export interface WaitProgramRecord extends BaseProgramRecord {
67 readonly kind: 'wait';
68 }
69
70 export interface MarkerProgramRecord extends BaseProgramRecord {
71 readonly kind: 'marker';
72 readonly name: string;
73 }
74
75 export interface RepeatProgramRecord extends BaseProgramRecord {
76 readonly kind: 'repeat';
77 readonly iterations: number;
78 readonly direction: PibblAnimationRepeatDirection;
79 }
80
81 export type ProgramRecord =
82 | TrackProgramRecord
83 | SequenceProgramRecord
84 | ParallelProgramRecord
85 | WaitProgramRecord
86 | MarkerProgramRecord
87 | RepeatProgramRecord;
88
89 const programRecords = new WeakMap<PibblAnimationProgram, ProgramRecord>();
90 const exactSeekableDurations = new WeakMap<PibblAnimationProgram, number | undefined>();
91 const activationDependentWriterOutputs = new WeakMap<
92 PibblAnimationProgram,
93 ReadonlySet<WritableSignal<unknown>>
94 >();
95
96 const finite = (milliseconds: number): ProgramDuration => Object.freeze({
97 kind: 'finite' as const,
98 milliseconds,
99 });
100 const infinite: ProgramDuration = Object.freeze({ kind: 'infinite' as const });
101 const unresolved: ProgramDuration = Object.freeze({ kind: 'unresolved-stepper' as const });
102
103 function freezePath(path: readonly number[]): readonly number[] {
104 return Object.freeze([...path]);
105 }
106
107 function freezeSpan(
108 output: WritableSignal<unknown>,
109 start: number,
110 end: number,
111 path: readonly number[],
112 ): WriterSpan {
113 return Object.freeze({ output, start, end, path: freezePath(path) });
114 }
115
116 function freezeSchedule(
117 span: WriterSpan,
118 repeats: readonly WriterRepeatFrame[] = [],
119 ): WriterSchedule {
120 return Object.freeze({
121 span,
122 repeats: Object.freeze(repeats.map(repeat => Object.freeze({ ...repeat }))),
123 });
124 }
125
126 function finiteWriterSpans(schedules: readonly WriterSchedule[]): readonly WriterSpan[] {
127 return Object.freeze(schedules.map(schedule => schedule.span));
128 }
129
130 function outputsFor(schedules: readonly WriterSchedule[]): readonly WritableSignal<unknown>[] {
131 const outputs: WritableSignal<unknown>[] = [];
132 for (const schedule of schedules) {
133 if (!outputs.includes(schedule.span.output)) outputs.push(schedule.span.output);
134 }
135 return Object.freeze(outputs);
136 }
137
138 function createProgram<T extends ProgramRecord>(record: T): PibblAnimationProgram {
139 const program = Object.freeze({}) as PibblAnimationProgram;
140 programRecords.set(program, Object.freeze(record) as ProgramRecord);
141 return program;
142 }
143
144 function programLabel(path: readonly number[]): string {
145 return `[${path.join(', ')}]`;
146 }
147
148 function definitionDuration(definition: PibblAnimationDefinition<unknown>): ProgramDuration {
149 const record = getDefinitionRecord(definition);
150 if (record.kind !== 'stepper') return finite(record.duration);
151 return record.done === undefined ? infinite : unresolved;
152 }
153
154 function assertWritableSignal<T>(output: WritableSignal<T>): WritableSignal<unknown> {
155 if (!isSignal(output) || typeof output.set !== 'function' || typeof output.update !== 'function') {
156 throw new TypeError('Expected a writable signal created by signal().');
157 }
158 return output as WritableSignal<unknown>;
159 }
160
161 function prefixedSchedules(
162 schedules: readonly WriterSchedule[],
163 childIndex: number,
164 offset: number,
165 ): readonly WriterSchedule[] {
166 return Object.freeze(schedules.map(schedule => freezeSchedule(
167 freezeSpan(
168 schedule.span.output,
169 schedule.span.start + offset,
170 schedule.span.end + offset,
171 [childIndex, ...schedule.span.path],
172 ),
173 schedule.repeats,
174 )));
175 }
176
177 function normalizedRepeats(
178 repeats: readonly WriterRepeatFrame[],
179 ): readonly WriterRepeatFrame[] {
180 const normalized: WriterRepeatFrame[] = [];
181 for (const repeat of repeats) {
182 if (repeat.iterations === 1) continue;
183 const previous = normalized.at(-1);
184 const combinedIterations = previous === undefined ? Number.NaN :
185 previous.iterations * repeat.iterations;
186 if (previous !== undefined &&
187 repeat.period === previous.period * previous.iterations &&
188 Number.isFinite(combinedIterations) && Number.isInteger(combinedIterations)) {
189 normalized[normalized.length - 1] = { period: previous.period, iterations: combinedIterations };
190 } else {
191 normalized.push({ period: repeat.period, iterations: repeat.iterations });
192 }
193 }
194 return Object.freeze(normalized.map(repeat => Object.freeze(repeat)));
195 }
196
197 function withRepeat(
198 schedules: readonly WriterSchedule[],
199 period: number,
200 iterations: number,
201 ): readonly WriterSchedule[] {
202 return Object.freeze(schedules.map(schedule => freezeSchedule(schedule.span, normalizedRepeats([
203 ...schedule.repeats,
204 { period, iterations },
205 ]))));
206 }
207
208 interface Dyadic {
209 readonly exponent: number;
210 readonly numerator: bigint;
211 }
212
213 interface ExactWriterSpan {
214 readonly start: bigint;
215 readonly end: bigint;
216 }
217
218 interface ExactWriterRepeatFrame {
219 readonly period: bigint;
220 readonly iterations: number;
221 }
222
223 interface ExactWriterSchedule {
224 readonly span: ExactWriterSpan;
225 readonly repeats: readonly ExactWriterRepeatFrame[];
226 }
227
228 interface ExactArithmeticSchedule {
229 readonly span: ExactWriterSpan;
230 readonly period: bigint;
231 readonly iterations: number;
232 }
233
234 interface OverlapProofBudget {
235 remainingAlignments: number;
236 }
237
238 /**
239 * Caps exact recursive alignment descents during one writer-pair proof. This
240 * is an availability boundary, not an acceptance shortcut: exhaustion rejects
241 * the composition instead of claiming that the schedules are disjoint.
242 */
243 const maximumNestedAlignmentDescents = 512;
244
245 class OverlapProofBudgetExceeded extends Error {}
246
247 const numberBits = new DataView(new ArrayBuffer(8));
248
249 function dyadic(value: number): Dyadic {
250 numberBits.setFloat64(0, value, false);
251 const high = numberBits.getUint32(0, false);
252 const low = numberBits.getUint32(4, false);
253 const fraction = BigInt(high & 0x000f_ffff) << 32n | BigInt(low);
254 const exponentBits = (high >>> 20) & 0x7ff;
255 if (exponentBits === 0 && fraction === 0n) return { numerator: 0n, exponent: 0 };
256 if (exponentBits === 0) return { numerator: fraction, exponent: -1074 };
257 return {
258 numerator: (1n << 52n) | fraction,
259 exponent: exponentBits - 1023 - 52,
260 };
261 }
262
263 function exactScheduleValues(schedule: WriterSchedule): readonly number[] {
264 return [
265 schedule.span.start,
266 schedule.span.end,
267 ...schedule.repeats.map(repeat => repeat.period),
268 ];
269 }
270
271 function commonDyadicExponent(
272 first: WriterSchedule,
273 second: WriterSchedule,
274 ): number {
275 const values = [...exactScheduleValues(first), ...exactScheduleValues(second)]
276 .map(dyadic)
277 .filter(value => value.numerator !== 0n);
278 return values.length === 0 ? 0 : Math.min(...values.map(value => value.exponent));
279 }
280
281 function scaleDyadic(value: number, exponent: number): bigint {
282 const exact = dyadic(value);
283 return exact.numerator << BigInt(exact.exponent - exponent);
284 }
285
286 function exactSchedule(schedule: WriterSchedule, exponent: number): ExactWriterSchedule {
287 return {
288 span: {
289 start: scaleDyadic(schedule.span.start, exponent),
290 end: scaleDyadic(schedule.span.end, exponent),
291 },
292 repeats: schedule.repeats.map(repeat => ({
293 period: scaleDyadic(repeat.period, exponent),
294 iterations: repeat.iterations,
295 })),
296 };
297 }
298
299 function floorDivide(value: bigint, divisor: bigint): bigint {
300 if (value >= 0n) return value / divisor;
301 return -((-value + divisor - 1n) / divisor);
302 }
303
304 function ceilDivide(value: bigint, divisor: bigint): bigint {
305 return -floorDivide(-value, divisor);
306 }
307
308 function clamp(value: bigint, minimum: bigint, maximum: bigint): bigint {
309 return value < minimum ? minimum : value > maximum ? maximum : value;
310 }
311
312 function greatestCommonDivisor(first: bigint, second: bigint): bigint {
313 let left = first;
314 let right = second;
315 while (right !== 0n) {
316 const remainder = left % right;
317 left = right;
318 right = remainder;
319 }
320 return left;
321 }
322
323 /** Returns sum(floor((multiplier * i + offset) / divisor), i = 0..count). */
324 function floorSum(
325 count: bigint,
326 divisor: bigint,
327 multiplier: bigint,
328 offset: bigint,
329 ): bigint {
330 let currentCount = count;
331 let currentDivisor = divisor;
332 let currentMultiplier = multiplier;
333 let currentOffset = offset;
334 let result = 0n;
335 while (true) {
336 if (currentMultiplier >= currentDivisor) {
337 result += (currentCount - 1n) * currentCount * (currentMultiplier / currentDivisor) / 2n;
338 currentMultiplier %= currentDivisor;
339 }
340 if (currentOffset >= currentDivisor) {
341 result += currentCount * (currentOffset / currentDivisor);
342 currentOffset %= currentDivisor;
343 }
344 const maximum = currentMultiplier * currentCount + currentOffset;
345 if (maximum < currentDivisor) return result;
346 currentCount = maximum / currentDivisor;
347 currentOffset = maximum % currentDivisor;
348 const previousDivisor = currentDivisor;
349 currentDivisor = currentMultiplier;
350 currentMultiplier = previousDivisor;
351 }
352 }
353
354 /** Counts bounded nonnegative pairs where a*i + b*j is at most limit. */
355 function boundedLinearCount(
356 firstPeriod: bigint,
357 firstIterations: bigint,
358 secondPeriod: bigint,
359 secondIterations: bigint,
360 limit: bigint,
361 ): bigint {
362 if (limit < 0n) return 0n;
363 const nonemptyFirst = clamp(floorDivide(limit, firstPeriod) + 1n, 0n, firstIterations);
364 if (nonemptyFirst === 0n) return 0n;
365 const completeFirst = clamp(
366 floorDivide(limit - secondPeriod * (secondIterations - 1n), firstPeriod) + 1n,
367 0n,
368 nonemptyFirst,
369 );
370 const partialCount = nonemptyFirst - completeFirst;
371 if (partialCount === 0n) return completeFirst * secondIterations;
372 const reverseOffset = limit - firstPeriod * (nonemptyFirst - 1n);
373 return completeFirst * secondIterations + partialCount + floorSum(
374 partialCount,
375 secondPeriod,
376 firstPeriod,
377 reverseOffset,
378 );
379 }
380
381 function boundedIndexRangeExists(
382 first: bigint,
383 last: bigint,
384 iterations: number,
385 ): boolean {
386 if (last < 0n) return false;
387 if (iterations === Number.POSITIVE_INFINITY) return first <= last;
388 const maximum = BigInt(iterations) - 1n;
389 return first <= maximum && clamp(first, 0n, maximum) <= clamp(last, 0n, maximum);
390 }
391
392 /**
393 * Tests lower <= secondPeriod*j - firstPeriod*i <= upper without visiting
394 * either index domain. Infinite domains are first clipped to the finite band;
395 * the remaining bounded rectangle is counted with Euclidean floor sums.
396 */
397 function boundedDifferenceExists(
398 firstPeriod: bigint,
399 firstIterations: number,
400 secondPeriod: bigint,
401 secondIterations: number,
402 lower: bigint,
403 upper: bigint,
404 ): boolean {
405 if (lower > upper) return false;
406 if (firstPeriod === 0n && secondPeriod === 0n) return lower <= 0n && 0n <= upper;
407 if (firstPeriod === 0n) {
408 return boundedIndexRangeExists(
409 ceilDivide(lower, secondPeriod),
410 floorDivide(upper, secondPeriod),
411 secondIterations,
412 );
413 }
414 if (secondPeriod === 0n) {
415 return boundedIndexRangeExists(
416 ceilDivide(-upper, firstPeriod),
417 floorDivide(-lower, firstPeriod),
418 firstIterations,
419 );
420 }
421 if (firstIterations === Number.POSITIVE_INFINITY &&
422 secondIterations === Number.POSITIVE_INFINITY) {
423 const increment = greatestCommonDivisor(firstPeriod, secondPeriod);
424 return ceilDivide(lower, increment) * increment <= upper;
425 }
426
427 const boundedFirstIterations = firstIterations === Number.POSITIVE_INFINITY ?
428 floorDivide(
429 secondPeriod * (BigInt(secondIterations) - 1n) - lower,
430 firstPeriod,
431 ) + 1n : BigInt(firstIterations);
432 const boundedSecondIterations = secondIterations === Number.POSITIVE_INFINITY ?
433 floorDivide(
434 upper + firstPeriod * (BigInt(firstIterations) - 1n),
435 secondPeriod,
436 ) + 1n : BigInt(secondIterations);
437 if (boundedFirstIterations <= 0n || boundedSecondIterations <= 0n) return false;
438
439 const translatedUpper = upper + firstPeriod * (boundedFirstIterations - 1n);
440 const translatedBelowLower = lower - 1n +
441 firstPeriod * (boundedFirstIterations - 1n);
442 const atMostUpper = boundedLinearCount(
443 firstPeriod,
444 boundedFirstIterations,
445 secondPeriod,
446 boundedSecondIterations,
447 translatedUpper,
448 );
449 const belowLower = boundedLinearCount(
450 firstPeriod,
451 boundedFirstIterations,
452 secondPeriod,
453 boundedSecondIterations,
454 translatedBelowLower,
455 );
456 return atMostUpper > belowLower;
457 }
458
459 function leastBoundedDifference(
460 firstPeriod: bigint,
461 firstIterations: number,
462 secondPeriod: bigint,
463 secondIterations: number,
464 lower: bigint,
465 upper: bigint,
466 ): bigint | undefined {
467 if (!boundedDifferenceExists(
468 firstPeriod,
469 firstIterations,
470 secondPeriod,
471 secondIterations,
472 lower,
473 upper,
474 )) return undefined;
475 let start = lower;
476 let end = upper;
477 while (start < end) {
478 const middle = floorDivide(start + end, 2n);
479 if (boundedDifferenceExists(
480 firstPeriod,
481 firstIterations,
482 secondPeriod,
483 secondIterations,
484 lower,
485 middle,
486 )) {
487 end = middle;
488 } else {
489 start = middle + 1n;
490 }
491 }
492 return start;
493 }
494
495 function oneRepeatSchedule(
496 schedule: ExactWriterSchedule,
497 ): ExactArithmeticSchedule | undefined {
498 if (schedule.repeats.length === 0) {
499 return { span: schedule.span, period: 0n, iterations: 1 };
500 }
501 if (schedule.repeats.length !== 1) return undefined;
502 const repeat = schedule.repeats[0]!;
503 return { span: schedule.span, period: repeat.period, iterations: repeat.iterations };
504 }
505
506 function fixedAgainstPeriodic(
507 fixed: ExactWriterSpan,
508 periodic: ExactArithmeticSchedule,
509 ): boolean {
510 return boundedIndexRangeExists(
511 ceilDivide(fixed.start - periodic.span.end, periodic.period),
512 floorDivide(fixed.end - periodic.span.start, periodic.period),
513 periodic.iterations,
514 );
515 }
516
517 function arithmeticSchedulesOverlap(
518 first: ExactArithmeticSchedule,
519 second: ExactArithmeticSchedule,
520 ): boolean {
521 if (first.period === 0n && second.period === 0n) {
522 return first.span.start <= second.span.end && second.span.start <= first.span.end;
523 }
524 if (first.period === 0n) return fixedAgainstPeriodic(first.span, second);
525 if (second.period === 0n) return fixedAgainstPeriodic(second.span, first);
526 return boundedDifferenceExists(
527 first.period,
528 first.iterations,
529 second.period,
530 second.iterations,
531 first.span.start - second.span.end,
532 first.span.end - second.span.start,
533 );
534 }
535
536 function scheduleRange(schedule: WriterSchedule): { readonly start: number; readonly end: number } {
537 const offset = schedule.repeats.reduce((total, repeat) =>
538 total + (repeat.iterations - 1) * repeat.period,
539 0);
540 return Object.freeze({ start: schedule.span.start, end: schedule.span.end + offset });
541 }
542
543 function exactScheduleRange(
544 schedule: ExactWriterSchedule,
545 ): { readonly start: bigint; readonly end: bigint } {
546 let end = schedule.span.end;
547 for (const repeat of schedule.repeats) {
548 if (repeat.iterations === Number.POSITIVE_INFINITY) {
549 throw new Error('An infinite repeat frame cannot be nested inside another finite frame.');
550 }
551 end += (BigInt(repeat.iterations) - 1n) * repeat.period;
552 }
553 return { start: schedule.span.start, end };
554 }
555
556 function withoutOuterRepeat(schedule: ExactWriterSchedule): ExactWriterSchedule {
557 return { span: schedule.span, repeats: schedule.repeats.slice(0, -1) };
558 }
559
560 function shiftedSchedule(
561 schedule: ExactWriterSchedule,
562 offset: bigint,
563 ): ExactWriterSchedule {
564 return {
565 span: {
566 start: schedule.span.start + offset,
567 end: schedule.span.end + offset,
568 },
569 repeats: schedule.repeats,
570 };
571 }
572
573 function symbolicOuterRepeatOverlap(
574 first: ExactWriterSchedule,
575 second: ExactWriterSchedule,
576 budget: OverlapProofBudget,
577 ): boolean {
578 const firstOuter = first.repeats.at(-1)!;
579 const secondOuter = second.repeats.at(-1)!;
580 const firstInner = withoutOuterRepeat(first);
581 const secondInner = withoutOuterRepeat(second);
582 const firstRange = exactScheduleRange(firstInner);
583 const secondRange = exactScheduleRange(secondInner);
584 const lower = firstRange.start - secondRange.end;
585 const upper = firstRange.end - secondRange.start;
586 let difference = leastBoundedDifference(
587 firstOuter.period,
588 firstOuter.iterations,
589 secondOuter.period,
590 secondOuter.iterations,
591 lower,
592 upper,
593 );
594 while (difference !== undefined) {
595 if (budget.remainingAlignments === 0) throw new OverlapProofBudgetExceeded();
596 budget.remainingAlignments--;
597 if (exactSchedulesOverlap(
598 firstInner,
599 shiftedSchedule(secondInner, difference),
600 budget,
601 )) return true;
602 if (difference === upper) return false;
603 difference = leastBoundedDifference(
604 firstOuter.period,
605 firstOuter.iterations,
606 secondOuter.period,
607 secondOuter.iterations,
608 difference + 1n,
609 upper,
610 );
611 }
612 return false;
613 }
614
615 function nextScheduleTime(
616 schedule: ExactWriterSchedule,
617 target: bigint,
618 ): bigint | undefined {
619 const outer = schedule.repeats.at(-1);
620 if (outer === undefined) {
621 if (target > schedule.span.end) return undefined;
622 return target > schedule.span.start ? target : schedule.span.start;
623 }
624 const inner = withoutOuterRepeat(schedule);
625 const innerRange = exactScheduleRange(inner);
626 let iteration = ceilDivide(target - innerRange.end, outer.period);
627 if (iteration < 0n) iteration = 0n;
628 const maximum = outer.iterations === Number.POSITIVE_INFINITY ? undefined :
629 BigInt(outer.iterations) - 1n;
630 if (maximum !== undefined && iteration > maximum) return undefined;
631 const offset = iteration * outer.period;
632 const innerTime = nextScheduleTime(inner, target - offset);
633 if (innerTime !== undefined) return innerTime + offset;
634 iteration += 1n;
635 if (maximum !== undefined && iteration > maximum) return undefined;
636 return innerRange.start + iteration * outer.period;
637 }
638
639 function repeatAgainstStaticOverlap(
640 repeated: ExactWriterSchedule,
641 staticSchedule: ExactWriterSchedule,
642 ): boolean {
643 const first = nextScheduleTime(repeated, staticSchedule.span.start);
644 return first !== undefined && first <= staticSchedule.span.end;
645 }
646
647 function exactSchedulesOverlap(
648 first: ExactWriterSchedule,
649 second: ExactWriterSchedule,
650 budget: OverlapProofBudget,
651 ): boolean {
652 const firstArithmetic = oneRepeatSchedule(first);
653 const secondArithmetic = oneRepeatSchedule(second);
654 if (firstArithmetic !== undefined && secondArithmetic !== undefined) {
655 return arithmeticSchedulesOverlap(firstArithmetic, secondArithmetic);
656 }
657 const firstOuter = first.repeats.at(-1);
658 const secondOuter = second.repeats.at(-1);
659 if (firstOuter !== undefined && secondOuter !== undefined) {
660 return symbolicOuterRepeatOverlap(first, second, budget);
661 }
662 if (firstOuter !== undefined) return repeatAgainstStaticOverlap(first, second);
663 if (secondOuter !== undefined) return repeatAgainstStaticOverlap(second, first);
664 return false;
665 }
666
667 function schedulesOverlap(first: WriterSchedule, second: WriterSchedule): boolean {
668 if (!Number.isFinite(first.span.end) || !Number.isFinite(second.span.end)) {
669 const firstRange = scheduleRange(first);
670 const secondRange = scheduleRange(second);
671 return firstRange.start <= secondRange.end && secondRange.start <= firstRange.end;
672 }
673 const exponent = commonDyadicExponent(first, second);
674 try {
675 return exactSchedulesOverlap(
676 exactSchedule(first, exponent),
677 exactSchedule(second, exponent),
678 { remainingAlignments: maximumNestedAlignmentDescents },
679 );
680 } catch (error) {
681 if (!(error instanceof OverlapProofBudgetExceeded)) throw error;
682 const name = getSignalNode(first.span.output).debugName ?? '<unnamed signal>';
683 throw new RangeError(
684 `Animation program cannot prove nested same-output writer schedules at paths ` +
685 `${programLabel(first.span.path)} and ${programLabel(second.span.path)} are disjoint ` +
686 `for signal "${name}" within its structural validation budget. Refactor the nested ` +
687 `repeats into sequential programs or use nonoverlapping output signals.`,
688 { cause: error },
689 );
690 }
691 }
692
693 function throwWriterConflict(first: WriterSchedule, second: WriterSchedule): never {
694 const name = getSignalNode(first.span.output).debugName ?? '<unnamed signal>';
695 throw new RangeError(
696 `Animation program writers at paths ${programLabel(first.span.path)} and ` +
697 `${programLabel(second.span.path)} overlap for signal "${name}".`,
698 );
699 }
700
701 function validateParallelChildren(children: readonly ProgramRecord[]): void {
702 for (let firstIndex = 0; firstIndex < children.length; firstIndex++) {
703 for (let secondIndex = firstIndex + 1; secondIndex < children.length; secondIndex++) {
704 const firstSchedules = prefixedSchedules(children[firstIndex]!.writerSchedules, firstIndex, 0);
705 const secondSchedules = prefixedSchedules(children[secondIndex]!.writerSchedules, secondIndex, 0);
706 for (const first of firstSchedules) {
707 for (const second of secondSchedules) {
708 if (first.span.output === second.span.output && schedulesOverlap(first, second)) {
709 throwWriterConflict(first, second);
710 }
711 }
712 }
713 }
714 }
715 }
716
717 function validateRepeatSchedules(
718 schedules: readonly WriterSchedule[],
719 period: number,
720 iterations: number,
721 ): void {
722 if (iterations < 2) return;
723 for (const first of schedules) {
724 const firstRange = scheduleRange(first);
725 for (const second of schedules) {
726 if (first.span.output !== second.span.output) continue;
727 const secondRange = scheduleRange(second);
728 // Iterations are sequential: touching endpoints have source-order precedence.
729 // Zero-duration repeated writers retain the existing ambiguity guard.
730 if (firstRange.end > period + secondRange.start ||
731 (period === 0 && firstRange.end === period + secondRange.start)) {
732 throwWriterConflict(first, second);
733 }
734 }
735 }
736 }
737
738 function sequenceDuration(records: readonly ProgramRecord[]): ProgramDuration {
739 let milliseconds = 0;
740 for (let index = 0; index < records.length; index++) {
741 const duration = records[index]!.duration;
742 if (duration.kind === 'finite') {
743 milliseconds += duration.milliseconds;
744 if (!Number.isFinite(milliseconds)) {
745 throw new RangeError('Pibbl animation program duration must be finite and representable.');
746 }
747 continue;
748 }
749 if (duration.kind === 'infinite') {
750 if (records.slice(index + 1).some(record => record.hasReachableWork)) {
751 throw new RangeError('A statically infinite animation program cannot precede reachable sequence content.');
752 }
753 return infinite;
754 }
755 return unresolved;
756 }
757 return finite(milliseconds);
758 }
759
760 function parallelDuration(records: readonly ProgramRecord[]): ProgramDuration {
761 if (records.some(record => record.duration.kind === 'infinite')) return infinite;
762 if (records.some(record => record.duration.kind === 'unresolved-stepper')) return unresolved;
763 return finite(Math.max(0, ...records.map(record =>
764 (record.duration as Extract<ProgramDuration, { kind: 'finite' }>).milliseconds,
765 )));
766 }
767
768 /**
769 * Binds one animation definition to one writable signal.
770 *
771 * @param output - Writable signal that receives sampled values. See {@link WritableSignal}.
772 * @param definition - Animation definition to sample into the signal. See
773 * {@link PibblAnimationDefinition} .
774 * @returns A program that animates the supplied output signal. See {@link PibblAnimationProgram}.
775 *
776 * @see {@link WritableSignal}
777 * @see {@link PibblAnimationDefinition}
778 * @see {@link PibblAnimationProgram}
779 */
780 export function drive<T>(
781 output: WritableSignal<T>,
782 definition: PibblAnimationDefinition<T>,
783 ): PibblAnimationProgram {
784 const writableOutput = assertWritableSignal(output);
785 const normalizedDefinition = definition as PibblAnimationDefinition<unknown>;
786 const duration = definitionDuration(normalizedDefinition);
787 const end = duration.kind === 'finite' ? duration.milliseconds : Number.POSITIVE_INFINITY;
788 const schedule = freezeSchedule(freezeSpan(writableOutput, 0, end, []));
789 return createProgram({
790 kind: 'track',
791 children: Object.freeze([]),
792 duration,
793 hasReachableWork: true,
794 output: writableOutput,
795 definition: normalizedDefinition,
796 outputs: Object.freeze([writableOutput]),
797 writerSchedules: Object.freeze([schedule]),
798 writerSpans: finiteWriterSpans([schedule]),
799 });
800 }
801
802 /**
803 * Activates children in source order.
804 *
805 * @param children - Programs in execution order. See {@link PibblAnimationProgram}.
806 * @returns A program that runs each child after its predecessor completes. See
807 * {@link PibblAnimationProgram} .
808 *
809 * @see {@link PibblAnimationProgram}
810 */
811 export function sequence(...children: readonly PibblAnimationProgram[]): PibblAnimationProgram {
812 const records = children.map(getProgramRecord);
813 const duration = sequenceDuration(records);
814 let offset = 0;
815 const schedules: WriterSchedule[] = [];
816 for (let index = 0; index < records.length; index++) {
817 const record = records[index]!;
818 schedules.push(...prefixedSchedules(record.writerSchedules, index, offset));
819 if (record.duration.kind === 'finite') offset += record.duration.milliseconds;
820 }
821 const frozenSchedules = Object.freeze(schedules);
822 return createProgram({
823 kind: 'sequence',
824 children: Object.freeze([...children]),
825 duration,
826 hasReachableWork: records.some(record => record.hasReachableWork),
827 outputs: outputsFor(frozenSchedules),
828 writerSchedules: frozenSchedules,
829 writerSpans: finiteWriterSpans(frozenSchedules),
830 });
831 }
832
833 /**
834 * Activates all children from one shared local origin.
835 *
836 * @param children - Programs to begin together. See {@link PibblAnimationProgram}.
837 * @returns A program whose children run concurrently. See {@link PibblAnimationProgram}.
838 *
839 * @see {@link PibblAnimationProgram}
840 */
841 export function parallel(...children: readonly PibblAnimationProgram[]): PibblAnimationProgram {
842 const records = children.map(getProgramRecord);
843 validateParallelChildren(records);
844 const schedules = Object.freeze(records.flatMap((record, index) =>
845 prefixedSchedules(record.writerSchedules, index, 0),
846 ));
847 return createProgram({
848 kind: 'parallel',
849 children: Object.freeze([...children]),
850 duration: parallelDuration(records),
851 hasReachableWork: records.some(record => record.hasReachableWork),
852 outputs: outputsFor(schedules),
853 writerSchedules: schedules,
854 writerSpans: finiteWriterSpans(schedules),
855 });
856 }
857
858 /**
859 * Adds finite nonnegative local time without writing an output.
860 *
861 * @param duration - Delay in milliseconds.
862 * @returns A program that advances time without writing a value. See {@link PibblAnimationProgram}.
863 *
864 * @see {@link PibblAnimationProgram}
865 */
866 export function wait(duration: number): PibblAnimationProgram {
867 if (!Number.isFinite(duration) || duration < 0) {
868 throw new RangeError('Pibbl animation wait duration must be finite and nonnegative.');
869 }
870 return createProgram({
871 kind: 'wait',
872 children: Object.freeze([]),
873 duration: finite(duration),
874 hasReachableWork: duration > 0,
875 outputs: Object.freeze([]),
876 writerSchedules: Object.freeze([]),
877 writerSpans: Object.freeze([]),
878 });
879 }
880
881 /**
882 * Queues one named playback milestone without invoking user code.
883 *
884 * @param name - Name delivered when playback reaches this milestone.
885 * @returns A zero-duration marker program. See {@link PibblAnimationProgram}.
886 *
887 * @see {@link PibblAnimationProgram}
888 */
889 export function marker(name: string): PibblAnimationProgram {
890 if (typeof name !== 'string' || name.length === 0) {
891 throw new TypeError('Pibbl animation marker name must be nonempty.');
892 }
893 return createProgram({
894 kind: 'marker',
895 name,
896 children: Object.freeze([]),
897 duration: finite(0),
898 hasReachableWork: true,
899 outputs: Object.freeze([]),
900 writerSchedules: Object.freeze([]),
901 writerSpans: Object.freeze([]),
902 });
903 }
904
905 /**
906 * Repeats one child a total number of iterations.
907 *
908 * @param child - Program to repeat. See {@link PibblAnimationProgram}.
909 * @param options - Repeat count and repetition policy. See {@link PibblAnimationRepeatOptions}.
910 * @returns A program representing the requested repetitions. See {@link PibblAnimationProgram}.
911 *
912 * @see {@link PibblAnimationProgram}
913 * @see {@link PibblAnimationRepeatOptions}
914 */
915 export function repeat(
916 child: PibblAnimationProgram,
917 options: Readonly<PibblAnimationRepeatOptions>,
918 ): PibblAnimationProgram {
919 const childRecord = getProgramRecord(child);
920 const iterations = options?.iterations;
921 if (iterations !== Number.POSITIVE_INFINITY &&
922 (!Number.isInteger(iterations) || (iterations as number) < 1)) {
923 throw new RangeError('Pibbl animation repeat iterations must be an integer >= 1 or positive infinity.');
924 }
925 const direction = options?.direction ?? 'normal';
926 if (direction !== 'normal' && direction !== 'reverse' &&
927 direction !== 'alternate' && direction !== 'alternate-reverse') {
928 throw new TypeError('Pibbl animation repeat direction must be normal, reverse, alternate, or alternate-reverse.');
929 }
930 const duration = iterations === Number.POSITIVE_INFINITY ? infinite :
931 childRecord.duration.kind === 'infinite' ? infinite :
932 childRecord.duration.kind === 'unresolved-stepper' ? unresolved :
933 finiteProduct(childRecord.duration.milliseconds, iterations as number);
934 const childSchedules = prefixedSchedules(childRecord.writerSchedules, 0, 0);
935 if (childRecord.duration.kind === 'finite') {
936 validateRepeatSchedules(
937 childSchedules,
938 childRecord.duration.milliseconds,
939 iterations as number,
940 );
941 }
942 const schedules = childRecord.duration.kind === 'finite' ? withRepeat(
943 childSchedules,
944 childRecord.duration.milliseconds,
945 iterations as number,
946 ) : childSchedules;
947 return createProgram({
948 kind: 'repeat',
949 children: Object.freeze([child]),
950 duration,
951 hasReachableWork: childRecord.hasReachableWork,
952 iterations: iterations as number,
953 direction,
954 outputs: outputsFor(schedules),
955 writerSchedules: schedules,
956 writerSpans: finiteWriterSpans(schedules),
957 });
958 }
959
960 function finiteProduct(milliseconds: number, iterations: number): ProgramDuration {
961 const result = milliseconds * iterations;
962 if (!Number.isFinite(result)) {
963 throw new RangeError('Pibbl animation program duration must be finite and representable.');
964 }
965 return finite(result);
966 }
967
968 /** @internal Returns the private immutable record associated with a program. */
969 export function getProgramRecord(program: PibblAnimationProgram): ProgramRecord {
970 const record = programRecords.get(program);
971 if (record === undefined) throw new TypeError('Expected a Pibbl animation program.');
972 return record;
973 }
974
975 /**
976 * @internal Returns a construction-time exact seekable duration when no track
977 * needs activation data to resolve it. Positive infinity is exact; `undefined`
978 * means activation or Task 5 state is required.
979 */
980 export function getExactSeekableProgramDuration(
981 program: PibblAnimationProgram,
982 ): number | undefined {
983 if (exactSeekableDurations.has(program)) return exactSeekableDurations.get(program);
984 const record = getProgramRecord(program);
985 let duration: number | undefined;
986 if (record.kind === 'track') {
987 const definition = getDefinitionRecord(record.definition);
988 duration = definition.kind === 'stepper' ||
989 definition.kind === 'spring' && !definition.hasFrom ?
990 undefined : definition.duration;
991 } else if (record.kind === 'wait') {
992 duration = record.duration.kind === 'finite' ? record.duration.milliseconds : undefined;
993 } else if (record.kind === 'marker') {
994 duration = 0;
995 } else {
996 const childDurations = record.children.map(getExactSeekableProgramDuration);
997 if (childDurations.some(childDuration => childDuration === undefined)) {
998 duration = undefined;
999 } else if (record.kind === 'sequence') {
1000 duration = (childDurations as number[]).reduce<number>((total, childDuration) =>
1001 total + childDuration, 0);
1002 } else if (record.kind === 'parallel') {
1003 duration = Math.max(0, ...(childDurations as number[]));
1004 } else {
1005 const childDuration = childDurations[0] as number;
1006 duration = record.iterations === Number.POSITIVE_INFINITY ?
1007 Number.POSITIVE_INFINITY : childDuration * record.iterations;
1008 }
1009 }
1010 exactSeekableDurations.set(program, duration);
1011 return duration;
1012 }
1013
1014 function resolvedSchedule(schedule: ResolvedWriterSchedule): WriterSchedule {
1015 return freezeSchedule(
1016 freezeSpan(schedule.output, schedule.start, schedule.end, schedule.path),
1017 schedule.repeats,
1018 );
1019 }
1020
1021 /** @internal Uses Task 3's bounded exact symbolic proof for resolved schedules. */
1022 export function resolvedWriterSchedulesOverlap(
1023 first: ResolvedWriterSchedule,
1024 second: ResolvedWriterSchedule,
1025 ): boolean {
1026 return schedulesOverlap(resolvedSchedule(first), resolvedSchedule(second));
1027 }
1028
1029 /** @internal Adds one normalized symbolic repeat frame without expansion. */
1030 export function repeatResolvedWriterSchedules(
1031 schedules: readonly ResolvedWriterSchedule[],
1032 period: number,
1033 iterations: number,
1034 ): readonly ResolvedWriterSchedule[] {
1035 return Object.freeze(withRepeat(
1036 schedules.map(resolvedSchedule),
1037 period,
1038 iterations,
1039 ).map(schedule => Object.freeze({
1040 output: schedule.span.output,
1041 start: schedule.span.start,
1042 end: schedule.span.end,
1043 path: freezePath(schedule.span.path),
1044 repeats: schedule.repeats,
1045 })));
1046 }
1047
1048 function dependentWriterOutputs(
1049 program: PibblAnimationProgram,
1050 ): ReadonlySet<WritableSignal<unknown>> {
1051 const cached = activationDependentWriterOutputs.get(program);
1052 if (cached !== undefined) return cached;
1053 const record = getProgramRecord(program);
1054 const dependent = new Set<WritableSignal<unknown>>();
1055 if (record.kind === 'track') {
1056 const definition = getDefinitionRecord(record.definition);
1057 if (definition.kind === 'spring' && !definition.hasFrom) {
1058 dependent.add(record.output);
1059 }
1060 } else if (record.kind === 'sequence') {
1061 let prefixDurationDependsOnActivation = false;
1062 for (const child of record.children) {
1063 for (const output of dependentWriterOutputs(child)) dependent.add(output);
1064 if (prefixDurationDependsOnActivation) {
1065 for (const output of getProgramRecord(child).outputs) dependent.add(output);
1066 }
1067 if (getExactSeekableProgramDuration(child) === undefined) {
1068 prefixDurationDependsOnActivation = true;
1069 }
1070 }
1071 } else {
1072 for (const child of record.children) {
1073 for (const output of dependentWriterOutputs(child)) dependent.add(output);
1074 }
1075 if (record.kind === 'repeat' &&
1076 getExactSeekableProgramDuration(record.children[0]!) === undefined &&
1077 (record.iterations > 1 || record.direction !== 'normal')) {
1078 for (const output of record.outputs) dependent.add(output);
1079 }
1080 }
1081 activationDependentWriterOutputs.set(program, dependent);
1082 return dependent;
1083 }
1084
1085 function requiresActivationWriterSafetyAt(program: PibblAnimationProgram): boolean {
1086 const record = getProgramRecord(program);
1087 if (record.kind === 'parallel') {
1088 for (let firstIndex = 0; firstIndex < record.children.length; firstIndex++) {
1089 for (let secondIndex = firstIndex + 1;
1090 secondIndex < record.children.length;
1091 secondIndex++) {
1092 const first = record.children[firstIndex]!;
1093 const second = record.children[secondIndex]!;
1094 const secondOutputs = getProgramRecord(second).outputs;
1095 const firstDependent = dependentWriterOutputs(first);
1096 const secondDependent = dependentWriterOutputs(second);
1097 if (getProgramRecord(first).outputs.some(candidate =>
1098 secondOutputs.includes(candidate) &&
1099 (firstDependent.has(candidate) || secondDependent.has(candidate)),
1100 )) return true;
1101 }
1102 }
1103 }
1104 if (record.kind === 'repeat' && record.iterations > 1 &&
1105 getExactSeekableProgramDuration(record.children[0]!) === undefined &&
1106 record.outputs.length > 0) {
1107 return true;
1108 }
1109 return record.children.some(requiresActivationWriterSafetyAt);
1110 }
1111
1112 /**
1113 * @internal Reports whether activation-resolved duration can invalidate a
1114 * construction-time parallel or repeat writer proof. The evaluator performs
1115 * the exact resolved proof against its private activation snapshot.
1116 */
1117 export function requiresProgramActivationWriterSafety(
1118 program: PibblAnimationProgram,
1119 ): boolean {
1120 return requiresActivationWriterSafetyAt(program);
1121 }
1122
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.