@xtia/timeline 1.1.3 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,7 @@ Timeline is a type-safe, seekable, deterministic choreography system that can co
6
6
 
7
7
  * [API Reference](#reference)
8
8
  * [Playground](https://stackblitz.com/edit/timeline-string-tween?file=src%2Fmain.ts)
9
+ * [Intro by HL](https://codepen.io/H-L-the-lessful/full/vELdyvB)
9
10
 
10
11
  ## Basic Use:
11
12
 
@@ -113,13 +114,11 @@ const urlEmitter = filenameEmitter
113
114
 
114
115
  ```
115
116
 
116
- Range objects also provide a `play()` method that instructs the Timeline to play through that particular range:
117
+ Range objects also be passed to `Timeline`'s `play()` method to play through that particular range:
117
118
 
118
119
  ```ts
119
- // play through the first two seconds of the Timeline
120
- await timeline
121
- .range(0, 2000)
122
- .play();
120
+ // play through the first 5 seconds of the Timeline at 1000 units/s
121
+ await timeline.play(firstFiveSeconds);
123
122
  ```
124
123
 
125
124
  Custom easers can be passed to `ease()` as `(progress: number) => number`:
@@ -356,6 +355,27 @@ Despite the massive overhaul, the previous API is present and expanded and upgra
356
355
 
357
356
  ## Reference
358
357
 
358
+ ### Contents
359
+
360
+ #### Functions
361
+
362
+ * [`animate`](#animateduration-function)
363
+
364
+ #### Classes
365
+
366
+ * [`Timeline`](#timeline-class)
367
+ * [`TimelinePoint`](#timelinepoint-class)
368
+ * [`TimelineRange`](#timelinerange-class)
369
+ * [`RangeProgression`](#rangeprogression-class)
370
+ * [`Emitter<T>`](#emittert-class)
371
+
372
+ #### Interfaces
373
+
374
+ * [`PointEvent`](#pointevent-interface)
375
+ * [`ChainingInterface`](#chaininginterface-interface)
376
+
377
+
378
+
359
379
  ### `Timeline` class
360
380
 
361
381
  A self-contained collection of points and ranges that trigger events as the Timeline seeks to and through them.
@@ -374,17 +394,17 @@ Controls the speed at which a Timeline will progress when driven by the `play()`
374
394
 
375
395
  Returns true if the Timeline is actively being driven by the `play()` method (including by autoplay).
376
396
 
377
- ##### `end: `[`TimelinePoint`](#timelinepoint-interface)
397
+ ##### `end: `[`TimelinePoint`](#timelinepoint-class)
378
398
 
379
399
  Returns the **current** final point in the Timeline.
380
400
 
381
- ##### `start: `[`TimelinePoint`](#timelinepoint-interface)
401
+ ##### `start: `[`TimelinePoint`](#timelinepoint-class)
382
402
 
383
403
  Returns a point representing position 0.
384
404
 
385
405
  #### Methods
386
406
 
387
- ##### `point(position): `[`TimelinePoint`](#timelinepoint-interface)
407
+ ##### `point(position): `[`TimelinePoint`](#timelinepoint-class)
388
408
 
389
409
  Returns a point that represents a specific position on the Timeline.
390
410
 
@@ -392,7 +412,7 @@ If `position` is greater than that Timeline's end-position, the end-position wil
392
412
 
393
413
  *Note*, for deterministic consistency, points will be triggered if a forward-moving seek lands exactly on the point's position (or passes it entirely), while a backward-moving seek will trigger points that are passed or moved from.
394
414
 
395
- ##### `range(start, duration): `[`TimelineRange`](#timelinerange-interface)
415
+ ##### `range(start, duration): `[`TimelineRange`](#timelinerange-class)
396
416
 
397
417
  Returns a range that represents a section of the Timeline.
398
418
 
@@ -404,7 +424,9 @@ If `start` is omitted, the range will start at 0 and represent the full **curren
404
424
 
405
425
  ##### `seek(toPosition): void`
406
426
 
407
- Sets the Timeline's internal position (`currentTime`), triggering in chronological order listeners attached to any [`TimelinePoint`](#timelinepoint-interface) or [`TimelineRange`](#timelinerange-interface) that are passed or landed on.
427
+ Sets the Timeline's internal position (`currentTime`), triggering in chronological order listeners attached to any [`TimelinePoint`](#timelinepoint-class) or [`TimelineRange`](#timelinerange-class) that are passed or landed on.
428
+
429
+ `toPosition` may be a number or a [`TimelinePoint`](#timelinepoint-class).
408
430
 
409
431
  ##### `seek(toPosition, duration, easer?): Promise<void>`
410
432
 
@@ -422,9 +444,13 @@ Begins playing through the Timeline, from its current position, at (1000 × `tim
422
444
 
423
445
  Begins playing through the Timeline, from its current position, at (1000 × `timeScale`) units per second, updating `fps` times per second.
424
446
 
447
+ ##### `play(range, easer?): Promise<void>`
448
+
449
+ If a [`TimelineRange`](#timelinerange-class) is passed, the Timeline will play through that range at 1000 units per second, following the rules of a [smooth seek](#seektoposition-duration-easer-promisevoid).
450
+
425
451
  ##### `tween<T>(start, duration, apply, from, to, easer?): `[`ChainingInterface`](#chaininginterface-interface)
426
452
 
427
- Creates a [`TimelineRange`](#timelinerange-interface) and attaches a tweening listener.
453
+ Creates a [`TimelineRange`](#timelinerange-class) and attaches a tweening listener.
428
454
 
429
455
  Equivalent to
430
456
 
@@ -438,24 +464,46 @@ timeline
438
464
 
439
465
  Returns a [`ChainingInterface`](#chaininginterface-interface) representing the point at which the tween ends.
440
466
 
467
+ ##### `apply(handler)`
468
+
469
+ Registers a handler to be invoked on every seek, after points and ranges are applied.
470
+
471
+ This is useful for systems that use Timeline's point and range emissions to manipulate state that is to be applied *at once* to another system.
472
+
473
+ ```ts
474
+ // don't wastefully render the scene for every entity update
475
+ timeline
476
+ .range(0, 1000)
477
+ .tween(10, 30)
478
+ .apply(v => scene.hero.x = v);
479
+ timeline
480
+ .range(500, 1000)
481
+ .tween(15, 50)
482
+ .apply(v => scene.monster.x = v);
483
+ // render when all updates for a frame are done:
484
+ timeline.apply(() => renderScene(scene));
485
+ ```
486
+
441
487
  ##### `tween<T>(start, end, apply, from, to, easer?): `[`ChainingInterface`](#chaininginterface-interface)
442
488
 
443
- As above, but if the second argument is a [`TimelinePoint`](#timelinepoint-interface), it will specify when on the Timeline the tween will *end*.
489
+ As above, but if the second argument is a [`TimelinePoint`](#timelinepoint-class), it will specify when on the Timeline the tween will *end*.
444
490
 
445
491
  ##### `at(position, apply, reverse?): `[`ChainingInterface`](#chaininginterface-interface)
446
492
 
447
- Creates a [`TimelinePoint`](#timelinepoint-interface) and attaches a listener that will trigger when the Timeline seeks past or to that point.
493
+ Creates a [`TimelinePoint`](#timelinepoint-class) and attaches a listener that will trigger when the Timeline seeks past or to that point.
448
494
 
449
495
  If `reverse` is a function, that will be called instead of `apply` when the seek that triggered the event was moving backwards. If `reverse` is `true`, `apply` will be called regardless of which direction the seek moved. If `reverse` is false or omitted, this listener will ignore backward-moving seeks.
450
496
 
451
497
 
452
498
 
453
499
 
454
- ### `TimelinePoint` interface
500
+ ### `TimelinePoint` class
455
501
 
456
502
  Represents a single point on a [`Timeline`](#timeline-class).
457
503
 
458
- ##### Inherits [`Emitter<PointEvent>`](#emittert-interface)
504
+ This class is not meant to be constructed directly; instances are created with [`Timeline.point()`](#pointposition-timelinepoint).
505
+
506
+ ##### Inherits [`Emitter<PointEvent>`](#emittert-class)
459
507
 
460
508
  Listeners will be invoked with a [`PointEvent`](#pointevent-interface) when a seek passes or lands on the point.
461
509
 
@@ -471,11 +519,11 @@ This point's position on the Timeline.
471
519
 
472
520
  ##### `range(duration): TimelineRange`
473
521
 
474
- Creates a [`TimelineRange`](#timelinerange-interface) on the Timeline to which the point belongs, of the specified duration.
522
+ Creates a [`TimelineRange`](#timelinerange-class) on the Timeline to which the point belongs, of the specified duration.
475
523
 
476
524
  ##### `to(endPoint): TimelineRange`
477
525
 
478
- Creates a [`TimelineRange`](#timelinerange-interface) on the Timeline to which the point belongs, ending at the specified point.
526
+ Creates a [`TimelineRange`](#timelinerange-class) on the Timeline to which the point belongs, ending at the specified point.
479
527
 
480
528
  ##### `delta(timeOffset): TimelinePoint`
481
529
 
@@ -520,7 +568,7 @@ point
520
568
 
521
569
  ### `PointEvent` interface
522
570
 
523
- Provides information relevant to [`TimelinePoint`](#timelinepoint-interface) events.
571
+ Provides information relevant to [`TimelinePoint`](#timelinepoint-class) events.
524
572
 
525
573
  #### Properties
526
574
 
@@ -544,21 +592,23 @@ timeline
544
592
 
545
593
 
546
594
 
547
- ### `TimelineRange` interface
595
+ ### `TimelineRange` class
548
596
 
549
597
  Represents a fixed-length, fixed position section of a [`Timeline`](#timeline-class).
550
598
 
551
- ##### Inherits [`RangeProgression`](#rangeprogression-interface)
599
+ This class is not meant to be constructed directly; instances are created with [`Timeline.range()`](#rangestart-duration-timelinerange).
600
+
601
+ ##### Inherits [`RangeProgression`](#rangeprogression-class)
552
602
 
553
603
  Emits a normalised progression (0..1) of the range when the parent Timeline seeks over or into it.
554
604
 
555
605
  #### Properties
556
606
 
557
- ##### `start: `[`TimelinePoint`](#timelinepoint-interface)
607
+ ##### `start: `[`TimelinePoint`](#timelinepoint-class)
558
608
 
559
609
  The point on the Timeline at which this range starts.
560
610
 
561
- ##### `end: `[`TimelinePoint`](#timelinepoint-interface)
611
+ ##### `end: `[`TimelinePoint`](#timelinepoint-class)
562
612
 
563
613
  The point on the Timeline at which this range ends.
564
614
 
@@ -572,7 +622,7 @@ The length of the range.
572
622
 
573
623
  Creates two ranges representing two distinct sections of the parent. `position` is relative to the parent's start.
574
624
 
575
- ##### `spread(count): `[`TimelinePoint`](#timelinepoint-interface)[]
625
+ ##### `spread(count): `[`TimelinePoint`](#timelinepoint-class)[]
576
626
 
577
627
  Creates and returns `count` points spread evenly over the range.
578
628
 
@@ -600,7 +650,7 @@ Creates a new range by offsetting the parent by a given time delta.
600
650
 
601
651
  ##### `contains(point): boolean`
602
652
 
603
- Returns true if the given [`TimelinePoint`](#timelinepoint-interface) sits within this range.
653
+ Returns true if the given [`TimelinePoint`](#timelinepoint-class) sits within this range.
604
654
 
605
655
  ##### `overlaps(range): boolean`
606
656
 
@@ -609,11 +659,13 @@ Returns true if the given range overlaps with this range.
609
659
 
610
660
 
611
661
 
612
- ### `RangeProgression` interface
662
+ ### `RangeProgression` class
663
+
664
+ Represents a step in an immutable [`TimelineRange`](#timelinerange-class) event transformation pipeline.
613
665
 
614
- Represents a step in an immutable [`TimelineRange`](#timelinerange-interface) event transformation pipeline.
666
+ This class is not meant to be constructed directly; instances are created by various transformation methods of [`TimelineRange`](#timelinerange-class).
615
667
 
616
- ##### Inherits [`Emitter<number>`](#emittert-interface)
668
+ ##### Inherits [`Emitter<number>`](#emittert-class)
617
669
 
618
670
  Listeners will be invoked when a seek passes or lands within a range.
619
671
 
@@ -623,7 +675,7 @@ Listeners will be invoked when a seek passes or lands within a range.
623
675
 
624
676
  Creates an emitter that applies an easing function to parent emissions.
625
677
 
626
- ##### `tween<T>(from, to): `[`Emitter<T>`](#emittert-interface)
678
+ ##### `tween<T>(from, to): `[`Emitter<T>`](#emittert-class)
627
679
 
628
680
  Creates an emitter blends two values, biased by progression emitted by the parent.
629
681
 
@@ -637,7 +689,7 @@ blend(from: this, to: this, progress: number): this
637
689
 
638
690
  Creates an emitter that quantises progression emitted by the parent to the nearest of `steps` discrete values.
639
691
 
640
- ##### `sample<T>(values: ArrayLike<T>): `[`Emitter<T>`](#emittert-interface)
692
+ ##### `sample<T>(values: ArrayLike<T>): `[`Emitter<T>`](#emittert-class)
641
693
 
642
694
  Creates an emitter that emits values from an array according to progression.
643
695
 
@@ -705,7 +757,7 @@ range
705
757
 
706
758
 
707
759
 
708
- ### `Emitter<T>` interface
760
+ ### `Emitter<T>` class
709
761
 
710
762
  #### Methods
711
763
 
@@ -713,6 +765,8 @@ range
713
765
 
714
766
  Attaches a handler to the emitter and returns a function that will unsubscribe the handler.
715
767
 
768
+ This class is not meant to be constructed directly; instances are created by transformation methods.
769
+
716
770
  ##### `map<R>(mapFunc: (value: T) => R): Emitter<R>`
717
771
 
718
772
  Creates an emitter that performs an arbitrary transformation.
@@ -753,7 +807,7 @@ range
753
807
 
754
808
  ### `animate(duration)` function
755
809
 
756
- Creates and returns a [`TimelineRange`](#timelinerange-interface) that will automatically play over `duration` milliseconds.
810
+ Creates and returns a [`TimelineRange`](#timelinerange-class) that will automatically play over `duration` milliseconds.
757
811
 
758
812
  ### `ChainingInterface` interface
759
813
 
@@ -770,7 +824,7 @@ timeline
770
824
 
771
825
  #### Properties
772
826
 
773
- ##### `end: `[`TimelinePoint`](#timelinepoint-interface)
827
+ ##### `end: `[`TimelinePoint`](#timelinepoint-class)
774
828
 
775
829
  The point on the Timeline at which the effect of the previous chained call ends.
776
830
 
@@ -14,7 +14,7 @@ export declare class Emitter<T> {
14
14
  * @param listen
15
15
  * @returns {this}
16
16
  */
17
- protected redirect: (listen: ListenFunc<T>) => Emitter<T>;
17
+ protected redirect(listen: ListenFunc<T>): Emitter<T>;
18
18
  /**
19
19
  * Compatibility alias for `apply()` - registers a function to receive emitted values
20
20
  * @param handler
@@ -68,11 +68,10 @@ export declare class Emitter<T> {
68
68
  * ```ts
69
69
  * range
70
70
  * .tween("0%", "100%")
71
- * .fork(branch => {
72
- * branch
71
+ * .fork(branch => branch
73
72
  * .map(s => `Loading: ${s}`)
74
73
  * .apply(s => document.title = s)
75
- * })
74
+ * )
76
75
  * .apply(v => progressBar.style.width = v);
77
76
  * ```
78
77
  * @param cb
@@ -80,7 +79,7 @@ export declare class Emitter<T> {
80
79
  fork(cb: (branch: this) => void): this;
81
80
  }
82
81
  export declare class RangeProgression extends Emitter<number> {
83
- protected redirect: (listen: ListenFunc<number>) => RangeProgression;
82
+ protected redirect(listen: ListenFunc<number>): RangeProgression;
84
83
  /**
85
84
  * Creates a chainable progress emitter that applies an easing function to its parent's emitted values
86
85
  *
@@ -216,4 +215,8 @@ export declare class RangeProgression extends Emitter<number> {
216
215
  */
217
216
  offset(delta: number): RangeProgression;
218
217
  }
218
+ export declare function createListenable<T>(onAddFirst?: () => void, onRemoveLast?: () => void): {
219
+ listen: (fn: (v: T) => void) => UnsubscribeFunc;
220
+ emit: (value: T) => void;
221
+ };
219
222
  export {};
@@ -4,15 +4,17 @@ import { clamp } from "./utils";
4
4
  export class Emitter {
5
5
  constructor(onListen) {
6
6
  this.onListen = onListen;
7
- /**
8
- * Used by tap() to create a clone of an Emitter with a redirected onListen
9
- *
10
- * Should be overridden in all Emitter subclasses
11
- * @see {@link TimelineRange.redirect}
12
- * @param listen
13
- * @returns {this}
14
- */
15
- this.redirect = (listen) => new Emitter(listen);
7
+ }
8
+ /**
9
+ * Used by tap() to create a clone of an Emitter with a redirected onListen
10
+ *
11
+ * Should be overridden in all Emitter subclasses
12
+ * @see {@link TimelineRange.redirect}
13
+ * @param listen
14
+ * @returns {this}
15
+ */
16
+ redirect(listen) {
17
+ return new Emitter(listen);
16
18
  }
17
19
  /**
18
20
  * Compatibility alias for `apply()` - registers a function to receive emitted values
@@ -88,26 +90,15 @@ export class Emitter {
88
90
  * @returns A new emitter that forwards all values from the parent, invoking `cb` as a side effect.
89
91
  */
90
92
  tap(cb) {
91
- const listeners = [];
92
93
  let parentUnsubscribe = null;
93
- const tappedListen = (handler) => {
94
- listeners.push(handler);
95
- if (listeners.length === 1) {
96
- parentUnsubscribe = this.onListen(value => {
97
- cb(value);
98
- listeners.slice().forEach(fn => fn(value));
99
- });
100
- }
101
- return () => {
102
- const idx = listeners.indexOf(handler);
103
- listeners.splice(idx, 1);
104
- if (listeners.length === 0 && parentUnsubscribe) {
105
- parentUnsubscribe();
106
- parentUnsubscribe = null;
107
- }
108
- };
109
- };
110
- return this.redirect(tappedListen);
94
+ const { emit, listen } = createListenable(() => parentUnsubscribe = this.onListen(value => {
95
+ cb(value);
96
+ emit(value);
97
+ }), () => {
98
+ parentUnsubscribe();
99
+ parentUnsubscribe = null;
100
+ });
101
+ return this.redirect(listen);
111
102
  }
112
103
  /**
113
104
  * Immediately passes this emitter to a callback and returns this emitter
@@ -118,11 +109,10 @@ export class Emitter {
118
109
  * ```ts
119
110
  * range
120
111
  * .tween("0%", "100%")
121
- * .fork(branch => {
122
- * branch
112
+ * .fork(branch => branch
123
113
  * .map(s => `Loading: ${s}`)
124
114
  * .apply(s => document.title = s)
125
- * })
115
+ * )
126
116
  * .apply(v => progressBar.style.width = v);
127
117
  * ```
128
118
  * @param cb
@@ -133,9 +123,8 @@ export class Emitter {
133
123
  }
134
124
  }
135
125
  export class RangeProgression extends Emitter {
136
- constructor() {
137
- super(...arguments);
138
- this.redirect = (listen) => new RangeProgression(listen);
126
+ redirect(listen) {
127
+ return new RangeProgression(listen);
139
128
  }
140
129
  ease(easer) {
141
130
  if (!easer)
@@ -279,3 +268,24 @@ export class RangeProgression extends Emitter {
279
268
  return new RangeProgression(handler => this.onListen(value => handler((value + delta) % 1)));
280
269
  }
281
270
  }
271
+ export function createListenable(onAddFirst, onRemoveLast) {
272
+ const handlers = [];
273
+ const addListener = (fn) => {
274
+ const unique = (v) => fn(v);
275
+ handlers.push(unique);
276
+ if (onAddFirst && handlers.length == 1)
277
+ onAddFirst();
278
+ return () => {
279
+ const idx = handlers.indexOf(unique);
280
+ if (idx === -1)
281
+ throw new Error("Handler already unsubscribed");
282
+ handlers.splice(idx, 1);
283
+ if (onRemoveLast && handlers.length == 0)
284
+ onRemoveLast();
285
+ };
286
+ };
287
+ return {
288
+ listen: addListener,
289
+ emit: (value) => handlers.forEach(h => h(value)),
290
+ };
291
+ }
@@ -17,7 +17,7 @@ export declare class TimelinePoint extends Emitter<PointEvent> {
17
17
  * The point's absolute position on the Timeline
18
18
  */
19
19
  position: number);
20
- protected redirect: (listen: ListenFunc<PointEvent>) => TimelinePoint;
20
+ protected redirect(listen: ListenFunc<PointEvent>): TimelinePoint;
21
21
  /**
22
22
  * Creates a range on the Timeline, with a given duration, starting at this point
23
23
  * @param duration
package/internal/point.js CHANGED
@@ -9,7 +9,9 @@ export class TimelinePoint extends Emitter {
9
9
  super(onListen);
10
10
  this.timeline = timeline;
11
11
  this.position = position;
12
- this.redirect = (listen) => new TimelinePoint(listen, this.timeline, this.position);
12
+ }
13
+ redirect(listen) {
14
+ return new TimelinePoint(listen, this.timeline, this.position);
13
15
  }
14
16
  /**
15
17
  * Creates a range on the Timeline, with a given duration, starting at this point
@@ -56,20 +58,15 @@ export class TimelinePoint extends Emitter {
56
58
  return this.filter(-1);
57
59
  }
58
60
  filter(arg) {
59
- if (typeof arg == "number") {
60
- return new Emitter(handler => {
61
- return this.onListen((ev) => {
62
- if (ev.direction === arg)
63
- handler(ev);
64
- });
65
- });
66
- }
67
- return new Emitter(handler => {
68
- return this.onListen((ev) => {
61
+ return new Emitter(typeof arg == "number"
62
+ ? handler => this.onListen(ev => {
63
+ if (ev.direction === arg)
64
+ handler(ev);
65
+ })
66
+ : handler => this.onListen((ev) => {
69
67
  if (arg(ev))
70
68
  handler(ev);
71
- });
72
- });
69
+ }));
73
70
  }
74
71
  /**
75
72
  * Creates a Promise that will be resolved when the Timeline first seeks to/past this point
@@ -4,19 +4,21 @@ import { TimelinePoint } from "./point";
4
4
  import { Timeline } from "./timeline";
5
5
  export declare class TimelineRange extends RangeProgression {
6
6
  private timeline;
7
- private startPosition;
8
- /** The duration of this range */
9
- readonly duration: number;
10
- private endPosition;
11
7
  /** The point on the Timeline at which this range begins */
12
8
  readonly start: TimelinePoint;
13
9
  /** The point on the Timeline at which this range ends */
14
10
  readonly end: TimelinePoint;
15
- /** @internal Manual construction of RangeProgression is outside of the API contract and subject to undocumented change */
16
- constructor(onListen: ListenFunc<number>, timeline: Timeline, startPosition: number,
11
+ private startPosition;
12
+ private endPosition;
17
13
  /** The duration of this range */
18
- duration: number);
19
- protected redirect: (listen: ListenFunc<number>) => TimelineRange;
14
+ readonly duration: number;
15
+ /** @internal Manual construction of RangeProgression is outside of the API contract and subject to undocumented change */
16
+ constructor(onListen: ListenFunc<number>, timeline: Timeline,
17
+ /** The point on the Timeline at which this range begins */
18
+ start: TimelinePoint,
19
+ /** The point on the Timeline at which this range ends */
20
+ end: TimelinePoint);
21
+ protected redirect(listen: ListenFunc<number>): TimelineRange;
20
22
  /**
21
23
  * Creates two ranges by seperating one at a given point
22
24
  * @param position Point of separation, relative to the range's start - if omitted, the range will be separated halfway
@@ -70,7 +72,7 @@ export declare class TimelineRange extends RangeProgression {
70
72
  * @param point The point to check
71
73
  * @returns true if the provided point is within the range
72
74
  */
73
- contains(point: TimelinePoint): boolean;
75
+ contains(point: TimelinePoint | number): boolean;
74
76
  /**
75
77
  * Checks if a range is fully within this range
76
78
  * @param range The range to check
package/internal/range.js CHANGED
@@ -2,21 +2,21 @@ import { RangeProgression } from "./emitters";
2
2
  import { TimelinePoint } from "./point";
3
3
  export class TimelineRange extends RangeProgression {
4
4
  /** @internal Manual construction of RangeProgression is outside of the API contract and subject to undocumented change */
5
- constructor(onListen, timeline, startPosition,
6
- /** The duration of this range */
7
- duration) {
8
- super(duration == 0
9
- ? () => {
10
- throw new Error("Zero-duration ranges may not be listened");
11
- }
12
- : onListen);
5
+ constructor(onListen, timeline,
6
+ /** The point on the Timeline at which this range begins */
7
+ start,
8
+ /** The point on the Timeline at which this range ends */
9
+ end) {
10
+ super(onListen);
13
11
  this.timeline = timeline;
14
- this.startPosition = startPosition;
15
- this.duration = duration;
16
- this.redirect = (listen) => new TimelineRange(listen, this.timeline, this.startPosition, this.duration);
17
- this.endPosition = startPosition + duration;
18
- this.end = timeline.point(this.endPosition);
19
- this.start = timeline.point(startPosition);
12
+ this.start = start;
13
+ this.end = end;
14
+ this.startPosition = start.position;
15
+ this.endPosition = end.position;
16
+ this.duration = this.endPosition - this.startPosition;
17
+ }
18
+ redirect(listen) {
19
+ return new TimelineRange(listen, this.timeline, this.start, this.end);
20
20
  }
21
21
  /**
22
22
  * Creates two ranges by seperating one at a given point
@@ -26,6 +26,9 @@ export class TimelineRange extends RangeProgression {
26
26
  * @returns Tuple of two ranges
27
27
  */
28
28
  bisect(position = this.duration / 2) {
29
+ if (position >= this.endPosition) {
30
+ throw new RangeError("Bisection position is beyond end of range");
31
+ }
29
32
  return [
30
33
  this.timeline.range(this.startPosition, position),
31
34
  this.timeline.range(position + this.startPosition, this.duration - position),
@@ -93,6 +96,9 @@ export class TimelineRange extends RangeProgression {
93
96
  return this.grow((factor - 1) * this.duration, anchor);
94
97
  }
95
98
  contains(target) {
99
+ if (typeof target == "number") {
100
+ return target >= this.startPosition && target < this.endPosition;
101
+ }
96
102
  const [targetStart, targetEnd] = target instanceof TimelinePoint
97
103
  ? [target.position, target.position]
98
104
  : [target.startPosition, target.startPosition + target.duration];
@@ -1,5 +1,5 @@
1
1
  import { Easer, easers } from "./easing";
2
- import { RangeProgression } from "./emitters";
2
+ import { RangeProgression, UnsubscribeFunc } from "./emitters";
3
3
  import { TimelinePoint } from "./point";
4
4
  import { TimelineRange } from "./range";
5
5
  import { Tweenable } from "./tween";
@@ -10,12 +10,16 @@ declare const EndAction: {
10
10
  readonly wrap: 2;
11
11
  readonly restart: 3;
12
12
  };
13
+ type Period = {
14
+ asMilliseconds: number;
15
+ };
13
16
  /**
14
17
  * Creates an autoplaying Timeline and returns a range from it
15
- * @param duration Animation duration, in milliseconds
18
+ * @param durationMs Animation duration, in milliseconds
16
19
  * @returns Object representing a range on a single-use, autoplaying Timeline
17
20
  */
18
- export declare function animate(duration: number): TimelineRange;
21
+ export declare function animate(durationMs: number): TimelineRange;
22
+ export declare function animate(period: Period): TimelineRange;
19
23
  export declare class Timeline {
20
24
  /**
21
25
  * Multiplies the speed at which `play()` progresses through the Timeline
@@ -37,13 +41,15 @@ export declare class Timeline {
37
41
  private smoothSeeker;
38
42
  private seeking;
39
43
  readonly start: TimelinePoint;
40
- private progressionHandlers;
44
+ private _frameEvents;
45
+ /**
46
+ * Registers a handler to be invoked on every seek, after points and ranges are applied
47
+ */
48
+ apply(handler: () => void): UnsubscribeFunc;
41
49
  private _progression;
42
50
  /**
43
51
  * Listenable: emits a progression value (0..1) when the Timeline's internal
44
52
  * position changes, and when the Timeline's total duration is extended
45
- *
46
- * **Experimental**
47
53
  */
48
54
  get progression(): RangeProgression;
49
55
  constructor();
@@ -114,7 +120,8 @@ export declare class Timeline {
114
120
  * @param easer Optional easing function for the smooth-seek process
115
121
  * @returns A promise, resolved when the smooth-seek process finishes
116
122
  */
117
- seek(toPosition: number | TimelinePoint, duration: number, easer?: Easer | keyof typeof easers): Promise<void>;
123
+ seek(toPosition: number | TimelinePoint, durationMs: number, easer?: Easer | keyof typeof easers): Promise<void>;
124
+ seek(toPosition: number | TimelinePoint, duration: Period, easer?: Easer | keyof typeof easers): Promise<void>;
118
125
  private seekDirect;
119
126
  private seekPoints;
120
127
  private seekRanges;
@@ -1,4 +1,4 @@
1
- import { RangeProgression } from "./emitters";
1
+ import { createListenable, RangeProgression } from "./emitters";
2
2
  import { TimelinePoint } from "./point";
3
3
  import { TimelineRange } from "./range";
4
4
  import { clamp } from "./utils";
@@ -9,13 +9,11 @@ const EndAction = {
9
9
  wrap: 2,
10
10
  restart: 3,
11
11
  };
12
- /**
13
- * Creates an autoplaying Timeline and returns a range from it
14
- * @param duration Animation duration, in milliseconds
15
- * @returns Object representing a range on a single-use, autoplaying Timeline
16
- */
17
- export function animate(duration) {
18
- return new Timeline(true).range(0, duration);
12
+ export function animate(durationMs) {
13
+ return new Timeline(true)
14
+ .range(0, typeof durationMs == "number"
15
+ ? durationMs
16
+ : durationMs.asMilliseconds);
19
17
  }
20
18
  export class Timeline {
21
19
  get currentTime() { return this._currentTime; }
@@ -28,16 +26,32 @@ export class Timeline {
28
26
  get end() {
29
27
  return this.point(this._endPosition);
30
28
  }
29
+ /**
30
+ * Registers a handler to be invoked on every seek, after points and ranges are applied
31
+ */
32
+ apply(handler) {
33
+ if (this._frameEvents === null) {
34
+ const { emit, listen } = createListenable();
35
+ this._frameEvents = {
36
+ listen,
37
+ emit,
38
+ };
39
+ }
40
+ return this._frameEvents.listen(handler);
41
+ }
31
42
  /**
32
43
  * Listenable: emits a progression value (0..1) when the Timeline's internal
33
44
  * position changes, and when the Timeline's total duration is extended
34
- *
35
- * **Experimental**
36
45
  */
37
46
  get progression() {
38
- if (this._progression === null)
39
- this._progression = new TimelineProgressionEmitter(this.progressionHandlers);
40
- return this._progression;
47
+ if (this._progression === null) {
48
+ const { emit, listen } = createListenable();
49
+ this._progression = {
50
+ emitter: new TimelineProgressionEmitter(listen),
51
+ emit,
52
+ };
53
+ }
54
+ return this._progression.emitter;
41
55
  }
42
56
  constructor(autoplay = false, endAction = "pause") {
43
57
  /**
@@ -55,7 +69,7 @@ export class Timeline {
55
69
  this.smoothSeeker = null;
56
70
  this.seeking = false;
57
71
  this.start = this.point(0);
58
- this.progressionHandlers = [];
72
+ this._frameEvents = null;
59
73
  this._progression = null;
60
74
  if (endAction == "loop")
61
75
  endAction = "restart";
@@ -93,32 +107,25 @@ export class Timeline {
93
107
  point(position) {
94
108
  if (position > this._endPosition) {
95
109
  this._endPosition = position;
96
- this.progressionHandlers.slice().forEach(h => h(this._currentTime / position));
110
+ this._progression?.emit(this._currentTime / position);
97
111
  }
98
- const handlers = [];
99
- const data = {
100
- handlers,
101
- position,
102
- };
112
+ const { emit, listen } = createListenable(() => this.points.push(data), () => {
113
+ const idx = this.points.indexOf(data);
114
+ this.points.splice(idx, 1);
115
+ });
103
116
  const addHandler = (handler) => {
104
117
  if (this.seeking)
105
118
  throw new Error("Can't add a listener while seeking");
106
- // we're adding and removing points and ranges to the internal registry according to whether any subscriptions are active, to allow obsolete points and ranges to be garbage-collected
107
- if (handlers.length == 0) {
108
- this.points.push(data);
109
- this.currentSortDirection = 0;
119
+ if (position == this._currentTime) {
120
+ emit({
121
+ direction: 1
122
+ });
110
123
  }
111
- handlers.push(handler);
112
- return () => {
113
- const idx = handlers.indexOf(handler);
114
- if (idx === -1)
115
- throw new Error("Internal error: attempting to remove a non-present handler");
116
- handlers.splice(idx, 1);
117
- if (handlers.length == 0) {
118
- const idx = this.points.indexOf(data);
119
- this.points.splice(idx, 1);
120
- }
121
- };
124
+ return listen(handler);
125
+ };
126
+ const data = {
127
+ emit,
128
+ position,
122
129
  };
123
130
  return new TimelinePoint(addHandler, this, position);
124
131
  }
@@ -128,35 +135,31 @@ export class Timeline {
128
135
  : start;
129
136
  const startPosition = startPoint.position;
130
137
  const duration = optionalDuration ?? this._endPosition - startPosition;
131
- // const endPosition = startPosition + duration;
132
- //if (endPosition > this._endPosition) this._endPosition = endPosition;
133
- // ^ leave this to range's point() calls
134
- const handlers = [];
135
- const range = {
138
+ const endPoint = this.point(startPosition + duration);
139
+ const { emit, listen } = createListenable(() => this.ranges.push(rangeData), () => {
140
+ const idx = this.ranges.indexOf(rangeData);
141
+ this.ranges.splice(idx, 1);
142
+ });
143
+ const rangeData = {
136
144
  position: startPosition,
137
145
  duration,
138
- handlers,
146
+ emit,
139
147
  };
140
- const addHandler = (handler) => {
141
- if (this.seeking)
142
- throw new Error("Can't add a listener while seeking");
143
- if (handlers.length == 0) {
144
- this.ranges.push(range);
145
- this.currentSortDirection = 0;
148
+ const addHandler = duration == 0
149
+ ? () => {
150
+ throw new Error("Zero-duration ranges may not be listened");
146
151
  }
147
- handlers.push(handler);
148
- return () => {
149
- const idx = handlers.indexOf(handler);
150
- if (idx === -1)
151
- throw new Error("Internal error: attempting to remove a non-present handler");
152
- handlers.splice(idx, 1);
153
- if (handlers.length == 0) {
154
- const idx = this.ranges.indexOf(range);
155
- this.ranges.splice(idx, 1);
152
+ : (handler) => {
153
+ if (this.seeking)
154
+ throw new Error("Can't add a listener while seeking");
155
+ if (range.contains(this._currentTime)) {
156
+ let progress = clamp((this._currentTime - startPosition) / duration, 0, 1);
157
+ handler(progress);
156
158
  }
159
+ return listen(handler);
157
160
  };
158
- };
159
- return new TimelineRange(addHandler, this, startPosition, duration);
161
+ const range = new TimelineRange(addHandler, this, startPoint, endPoint);
162
+ return range;
160
163
  }
161
164
  getWrappedPosition(n) {
162
165
  if (this.endAction.type !== EndAction.wrap)
@@ -174,7 +177,10 @@ export class Timeline {
174
177
  const remainder = overflow % segment;
175
178
  return loopStart + remainder;
176
179
  }
177
- seek(to, duration = 0, easer) {
180
+ seek(to, duration, easer) {
181
+ const durationMs = typeof duration == "object"
182
+ ? duration.asMilliseconds
183
+ : duration;
178
184
  const toPosition = typeof to == "number"
179
185
  ? to
180
186
  : to.position;
@@ -183,20 +189,26 @@ export class Timeline {
183
189
  }
184
190
  if (this.smoothSeeker !== null) {
185
191
  this.smoothSeeker.pause();
186
- // ensure any awaits are resolved for the previous seek
187
- this.smoothSeeker.seek(this.smoothSeeker.end);
192
+ // ensure any awaits are resolved for the interrupted seek
193
+ const interruptPosition = this._currentTime;
194
+ this.smoothSeeker.seekDirect(this.smoothSeeker.end.position);
188
195
  this.smoothSeeker = null;
196
+ // and jump back to where we were interrupted
197
+ this.seekDirect(interruptPosition);
189
198
  }
190
- if (duration === 0) {
199
+ if (!durationMs) {
200
+ const fromTime = this._currentTime;
191
201
  this.seekDirect(toPosition);
192
- return Promise.resolve();
202
+ this._frameEvents?.emit();
203
+ // only add Promise overhead if duration is explicitly 0
204
+ return durationMs === 0 ? Promise.resolve() : undefined;
193
205
  }
194
206
  const seeker = new Timeline(true);
195
207
  this.smoothSeeker = seeker;
196
208
  seeker
197
- .range(0, duration)
209
+ .range(0, durationMs)
198
210
  .ease(easer)
199
- .tween(this.currentTime, toPosition)
211
+ .tween(this._currentTime, toPosition)
200
212
  .apply(v => this.seekDirect(v));
201
213
  return seeker.end.promise();
202
214
  }
@@ -242,10 +254,12 @@ export class Timeline {
242
254
  pointsBetween.slice().forEach(p => {
243
255
  this.seekRanges(p.position);
244
256
  this._currentTime = p.position;
245
- p.handlers.slice().forEach(h => h(eventData));
257
+ p.emit(eventData);
246
258
  });
247
259
  }
248
260
  seekRanges(to) {
261
+ if (this._currentTime === to)
262
+ return;
249
263
  const fromTime = Math.min(this._currentTime, to);
250
264
  const toTime = Math.max(this._currentTime, to);
251
265
  this.ranges.slice().forEach((range) => {
@@ -253,10 +267,10 @@ export class Timeline {
253
267
  const overlaps = fromTime <= rangeEnd && toTime >= range.position;
254
268
  if (overlaps) {
255
269
  let progress = clamp((to - range.position) / range.duration, 0, 1);
256
- range.handlers.slice().forEach(h => h(progress));
270
+ range.emit(progress);
257
271
  }
258
272
  });
259
- this.progressionHandlers.slice().forEach(h => h(toTime / this._endPosition));
273
+ this._progression?.emit(toTime / this._endPosition);
260
274
  }
261
275
  sortEntries(direction) {
262
276
  this.currentSortDirection = direction;
@@ -289,7 +303,7 @@ export class Timeline {
289
303
  this.currentTime += delta;
290
304
  return;
291
305
  }
292
- // overshot; perform endAction
306
+ // overshot; perform restart/pause endAction
293
307
  if (this.endAction.type == EndAction.restart) {
294
308
  const loopRange = this.endAction.at.to(this._endPosition);
295
309
  const loopLen = loopRange.duration;
@@ -379,15 +393,8 @@ export class Timeline {
379
393
  }
380
394
  }
381
395
  class TimelineProgressionEmitter extends RangeProgression {
382
- constructor(handlers) {
383
- super((handler) => {
384
- const unique = (n) => handler(n);
385
- handlers.push(unique);
386
- return () => {
387
- const idx = handlers.indexOf(unique);
388
- handlers.splice(idx, 1);
389
- };
390
- });
396
+ constructor(listen) {
397
+ super(listen);
391
398
  }
392
399
  }
393
400
  const sortEvents = (a, b) => {
@@ -1,5 +1,5 @@
1
1
  /** @internal */
2
- export type Tweenable = number | number[] | string | string[] | Blendable | Blendable[];
2
+ export type Tweenable = number | number[] | string | string[] | Blendable | Date | Blendable[];
3
3
  /** @internal */
4
4
  export interface Blendable {
5
5
  blend(target: this, progress: number): this;
package/internal/tween.js CHANGED
@@ -18,7 +18,14 @@ export function createTween(from, to) {
18
18
  }
19
19
  switch (typeof from) {
20
20
  case "number": return progress => blendNumbers(from, to, progress);
21
- case "object": return progress => from.blend(to, progress);
21
+ case "object": {
22
+ if (from instanceof Date) {
23
+ const fromStamp = from.getTime();
24
+ const toStamp = to.getTime();
25
+ return progress => new Date(blendNumbers(fromStamp, toStamp, progress));
26
+ }
27
+ return progress => from.blend(to, progress);
28
+ }
22
29
  case "string": return createStringTween(from, to);
23
30
  default: throw new Error("Invalid tweening type");
24
31
  }
@@ -26,9 +33,9 @@ export function createTween(from, to) {
26
33
  function createStringTween(from, to) {
27
34
  const fromChunks = tokenise(from);
28
35
  const toChunks = tokenise(to);
29
- const tokenCount = fromChunks.filter(c => c.token).length;
30
- // where length mismatch, use merging
31
- if (tokenCount !== toChunks.filter(c => c.token).length) {
36
+ const tokenCount = fromChunks.length;
37
+ // where token count mismatch, use merging
38
+ if (tokenCount !== toChunks.length) {
32
39
  return createStringMerge(from, to);
33
40
  }
34
41
  // where token prefix/type mismatch, use merging
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xtia/timeline",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "repository": {
5
5
  "url": "https://github.com/tiadrop/timeline",
6
6
  "type": "github"
@@ -22,7 +22,8 @@
22
22
  },
23
23
  "keywords": [
24
24
  "animation",
25
- "timeline"
25
+ "timeline",
26
+ "choreography"
26
27
  ],
27
28
  "author": "Aleta Lovelace",
28
29
  "license": "MIT"