@xtia/timeline 1.1.4 → 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
@@ -464,6 +464,26 @@ timeline
464
464
 
465
465
  Returns a [`ChainingInterface`](#chaininginterface-interface) representing the point at which the tween ends.
466
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
+
467
487
  ##### `tween<T>(start, end, apply, from, to, easer?): `[`ChainingInterface`](#chaininginterface-interface)
468
488
 
469
489
  As above, but if the second argument is a [`TimelinePoint`](#timelinepoint-class), it will specify when on the Timeline the tween will *end*.
@@ -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
@@ -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 {};
@@ -90,26 +90,15 @@ export class Emitter {
90
90
  * @returns A new emitter that forwards all values from the parent, invoking `cb` as a side effect.
91
91
  */
92
92
  tap(cb) {
93
- const listeners = [];
94
93
  let parentUnsubscribe = null;
95
- const tappedListen = (handler) => {
96
- listeners.push(handler);
97
- if (listeners.length === 1) {
98
- parentUnsubscribe = this.onListen(value => {
99
- cb(value);
100
- listeners.slice().forEach(fn => fn(value));
101
- });
102
- }
103
- return () => {
104
- const idx = listeners.indexOf(handler);
105
- listeners.splice(idx, 1);
106
- if (listeners.length === 0 && parentUnsubscribe) {
107
- parentUnsubscribe();
108
- parentUnsubscribe = null;
109
- }
110
- };
111
- };
112
- 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);
113
102
  }
114
103
  /**
115
104
  * Immediately passes this emitter to a callback and returns this emitter
@@ -120,11 +109,10 @@ export class Emitter {
120
109
  * ```ts
121
110
  * range
122
111
  * .tween("0%", "100%")
123
- * .fork(branch => {
124
- * branch
112
+ * .fork(branch => branch
125
113
  * .map(s => `Loading: ${s}`)
126
114
  * .apply(s => document.title = s)
127
- * })
115
+ * )
128
116
  * .apply(v => progressBar.style.width = v);
129
117
  * ```
130
118
  * @param cb
@@ -280,3 +268,24 @@ export class RangeProgression extends Emitter {
280
268
  return new RangeProgression(handler => this.onListen(value => handler((value + delta) % 1)));
281
269
  }
282
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
+ }
@@ -4,18 +4,20 @@ 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);
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);
19
21
  protected redirect(listen: ListenFunc<number>): TimelineRange;
20
22
  /**
21
23
  * Creates two ranges by seperating one at a given point
package/internal/range.js CHANGED
@@ -2,23 +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.endPosition = startPosition + duration;
17
- this.end = timeline.point(this.endPosition);
18
- 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;
19
17
  }
20
18
  redirect(listen) {
21
- return new TimelineRange(listen, this.timeline, this.startPosition, this.duration);
19
+ return new TimelineRange(listen, this.timeline, this.start, this.end);
22
20
  }
23
21
  /**
24
22
  * Creates two ranges by seperating one at a given point
@@ -28,6 +26,9 @@ export class TimelineRange extends RangeProgression {
28
26
  * @returns Tuple of two ranges
29
27
  */
30
28
  bisect(position = this.duration / 2) {
29
+ if (position >= this.endPosition) {
30
+ throw new RangeError("Bisection position is beyond end of range");
31
+ }
31
32
  return [
32
33
  this.timeline.range(this.startPosition, position),
33
34
  this.timeline.range(position + this.startPosition, this.duration - position),
@@ -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";
@@ -41,13 +41,15 @@ export declare class Timeline {
41
41
  private smoothSeeker;
42
42
  private seeking;
43
43
  readonly start: TimelinePoint;
44
- 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;
45
49
  private _progression;
46
50
  /**
47
51
  * Listenable: emits a progression value (0..1) when the Timeline's internal
48
52
  * position changes, and when the Timeline's total duration is extended
49
- *
50
- * **Experimental**
51
53
  */
52
54
  get progression(): RangeProgression;
53
55
  constructor();
@@ -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";
@@ -26,16 +26,32 @@ export class Timeline {
26
26
  get end() {
27
27
  return this.point(this._endPosition);
28
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
+ }
29
42
  /**
30
43
  * Listenable: emits a progression value (0..1) when the Timeline's internal
31
44
  * position changes, and when the Timeline's total duration is extended
32
- *
33
- * **Experimental**
34
45
  */
35
46
  get progression() {
36
- if (this._progression === null)
37
- this._progression = new TimelineProgressionEmitter(this.progressionHandlers);
38
- 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;
39
55
  }
40
56
  constructor(autoplay = false, endAction = "pause") {
41
57
  /**
@@ -53,7 +69,7 @@ export class Timeline {
53
69
  this.smoothSeeker = null;
54
70
  this.seeking = false;
55
71
  this.start = this.point(0);
56
- this.progressionHandlers = [];
72
+ this._frameEvents = null;
57
73
  this._progression = null;
58
74
  if (endAction == "loop")
59
75
  endAction = "restart";
@@ -91,32 +107,25 @@ export class Timeline {
91
107
  point(position) {
92
108
  if (position > this._endPosition) {
93
109
  this._endPosition = position;
94
- this.progressionHandlers.slice().forEach(h => h(this._currentTime / position));
110
+ this._progression?.emit(this._currentTime / position);
95
111
  }
96
- const handlers = [];
97
- const data = {
98
- handlers,
99
- position,
100
- };
112
+ const { emit, listen } = createListenable(() => this.points.push(data), () => {
113
+ const idx = this.points.indexOf(data);
114
+ this.points.splice(idx, 1);
115
+ });
101
116
  const addHandler = (handler) => {
102
117
  if (this.seeking)
103
118
  throw new Error("Can't add a listener while seeking");
104
- // 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
105
- if (handlers.length == 0) {
106
- this.points.push(data);
107
- this.currentSortDirection = 0;
119
+ if (position == this._currentTime) {
120
+ emit({
121
+ direction: 1
122
+ });
108
123
  }
109
- handlers.push(handler);
110
- return () => {
111
- const idx = handlers.indexOf(handler);
112
- if (idx === -1)
113
- throw new Error("Internal error: attempting to remove a non-present handler");
114
- handlers.splice(idx, 1);
115
- if (handlers.length == 0) {
116
- const idx = this.points.indexOf(data);
117
- this.points.splice(idx, 1);
118
- }
119
- };
124
+ return listen(handler);
125
+ };
126
+ const data = {
127
+ emit,
128
+ position,
120
129
  };
121
130
  return new TimelinePoint(addHandler, this, position);
122
131
  }
@@ -126,40 +135,30 @@ export class Timeline {
126
135
  : start;
127
136
  const startPosition = startPoint.position;
128
137
  const duration = optionalDuration ?? this._endPosition - startPosition;
129
- // const endPosition = startPosition + duration;
130
- //if (endPosition > this._endPosition) this._endPosition = endPosition;
131
- // ^ leave this to range's point() calls
132
- const handlers = [];
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
+ });
133
143
  const rangeData = {
134
144
  position: startPosition,
135
145
  duration,
136
- handlers,
146
+ emit,
137
147
  };
138
- const addHandler = (handler) => {
139
- if (this.seeking)
140
- throw new Error("Can't add a listener while seeking");
141
- if (handlers.length == 0) {
142
- this.ranges.push(rangeData);
143
- this.currentSortDirection = 0;
144
- }
145
- handlers.push(handler);
146
- // if currentTime is in this range, apply immediately
147
- if (range.contains(this._currentTime)) {
148
- let progress = clamp((this._currentTime - startPosition) / duration, 0, 1);
149
- handler(progress);
148
+ const addHandler = duration == 0
149
+ ? () => {
150
+ throw new Error("Zero-duration ranges may not be listened");
150
151
  }
151
- return () => {
152
- const idx = handlers.indexOf(handler);
153
- if (idx === -1)
154
- throw new Error("Internal error: attempting to remove a non-present handler");
155
- handlers.splice(idx, 1);
156
- if (handlers.length == 0) {
157
- const idx = this.ranges.indexOf(rangeData);
158
- 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);
159
158
  }
159
+ return listen(handler);
160
160
  };
161
- };
162
- const range = new TimelineRange(addHandler, this, startPosition, duration);
161
+ const range = new TimelineRange(addHandler, this, startPoint, endPoint);
163
162
  return range;
164
163
  }
165
164
  getWrappedPosition(n) {
@@ -178,10 +177,10 @@ export class Timeline {
178
177
  const remainder = overflow % segment;
179
178
  return loopStart + remainder;
180
179
  }
181
- seek(to, duration = 0, easer) {
182
- const durationMs = typeof duration == "number"
183
- ? duration
184
- : duration.asMilliseconds;
180
+ seek(to, duration, easer) {
181
+ const durationMs = typeof duration == "object"
182
+ ? duration.asMilliseconds
183
+ : duration;
185
184
  const toPosition = typeof to == "number"
186
185
  ? to
187
186
  : to.position;
@@ -192,21 +191,24 @@ export class Timeline {
192
191
  this.smoothSeeker.pause();
193
192
  // ensure any awaits are resolved for the interrupted seek
194
193
  const interruptPosition = this._currentTime;
195
- this.smoothSeeker.seek(this.smoothSeeker.end);
194
+ this.smoothSeeker.seekDirect(this.smoothSeeker.end.position);
196
195
  this.smoothSeeker = null;
197
196
  // and jump back to where we were interrupted
198
- this.seek(interruptPosition);
197
+ this.seekDirect(interruptPosition);
199
198
  }
200
- if (durationMs === 0) {
199
+ if (!durationMs) {
200
+ const fromTime = this._currentTime;
201
201
  this.seekDirect(toPosition);
202
- return Promise.resolve();
202
+ this._frameEvents?.emit();
203
+ // only add Promise overhead if duration is explicitly 0
204
+ return durationMs === 0 ? Promise.resolve() : undefined;
203
205
  }
204
206
  const seeker = new Timeline(true);
205
207
  this.smoothSeeker = seeker;
206
208
  seeker
207
209
  .range(0, durationMs)
208
210
  .ease(easer)
209
- .tween(this.currentTime, toPosition)
211
+ .tween(this._currentTime, toPosition)
210
212
  .apply(v => this.seekDirect(v));
211
213
  return seeker.end.promise();
212
214
  }
@@ -252,7 +254,7 @@ export class Timeline {
252
254
  pointsBetween.slice().forEach(p => {
253
255
  this.seekRanges(p.position);
254
256
  this._currentTime = p.position;
255
- p.handlers.slice().forEach(h => h(eventData));
257
+ p.emit(eventData);
256
258
  });
257
259
  }
258
260
  seekRanges(to) {
@@ -265,10 +267,10 @@ export class Timeline {
265
267
  const overlaps = fromTime <= rangeEnd && toTime >= range.position;
266
268
  if (overlaps) {
267
269
  let progress = clamp((to - range.position) / range.duration, 0, 1);
268
- range.handlers.slice().forEach(h => h(progress));
270
+ range.emit(progress);
269
271
  }
270
272
  });
271
- this.progressionHandlers.slice().forEach(h => h(toTime / this._endPosition));
273
+ this._progression?.emit(toTime / this._endPosition);
272
274
  }
273
275
  sortEntries(direction) {
274
276
  this.currentSortDirection = direction;
@@ -391,15 +393,8 @@ export class Timeline {
391
393
  }
392
394
  }
393
395
  class TimelineProgressionEmitter extends RangeProgression {
394
- constructor(handlers) {
395
- super((handler) => {
396
- const unique = (n) => handler(n);
397
- handlers.push(unique);
398
- return () => {
399
- const idx = handlers.indexOf(unique);
400
- handlers.splice(idx, 1);
401
- };
402
- });
396
+ constructor(listen) {
397
+ super(listen);
403
398
  }
404
399
  }
405
400
  const sortEvents = (a, b) => {
package/internal/tween.js CHANGED
@@ -19,8 +19,11 @@ export function createTween(from, to) {
19
19
  switch (typeof from) {
20
20
  case "number": return progress => blendNumbers(from, to, progress);
21
21
  case "object": {
22
- if (from instanceof Date)
23
- return progress => new Date(blendNumbers(from.getTime(), to.getTime(), progress));
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
+ }
24
27
  return progress => from.blend(to, progress);
25
28
  }
26
29
  case "string": return createStringTween(from, to);
@@ -30,9 +33,9 @@ export function createTween(from, to) {
30
33
  function createStringTween(from, to) {
31
34
  const fromChunks = tokenise(from);
32
35
  const toChunks = tokenise(to);
33
- const tokenCount = fromChunks.filter(c => c.token).length;
34
- // where length mismatch, use merging
35
- 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) {
36
39
  return createStringMerge(from, to);
37
40
  }
38
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.4",
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"