@xtia/timeline 0.2.4 → 1.0.0

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.
Files changed (4) hide show
  1. package/README.md +291 -0
  2. package/index.d.ts +189 -150
  3. package/index.js +543 -228
  4. package/package.json +19 -13
package/index.js CHANGED
@@ -1,255 +1,587 @@
1
1
  "use strict";
2
- // PRE-PRODUCTION / ALPHA 0.2.4
3
- // Copyright, 2024, Aleta Lovelace
4
- // Licensed under CC BY-SA 4.0 <http://creativecommons.org/licenses/by-sa/4.0/>
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.dynamicEasers = exports.easers = exports.Timeline = void 0;
7
- const blendNumbers = (from, to, progress) => from + progress * (to - from);
8
- const applyTween = (tween, position) => {
9
- let progress = (position - tween.time) / tween.duration;
10
- const startValue = Number(typeof tween.startValue == "function" ? tween.startValue() : tween.startValue);
11
- const endValue = Number(typeof tween.endValue == "function" ? tween.endValue() : tween.endValue);
12
- if (progress <= 0) {
13
- tween.apply(startValue);
3
+ exports.easers = exports.Timeline = void 0;
4
+ exports.animate = animate;
5
+ const default_fps = 60;
6
+ const EndAction = {
7
+ pause: 0,
8
+ continue: 1,
9
+ wrap: 2,
10
+ restart: 3,
11
+ };
12
+ /**
13
+ * Creates an autoplaying Timeline and returns a range from it
14
+ * @param duration
15
+ * @returns Object representing a range on a single-use, autoplaying Timeline
16
+ */
17
+ function animate(duration) {
18
+ return new Timeline(true).range(0, duration);
19
+ }
20
+ class Timeline {
21
+ get currentTime() { return this._currentTime; }
22
+ set currentTime(v) {
23
+ this.seek(v);
14
24
  }
15
- else if (progress >= 1) {
16
- tween.apply(endValue);
25
+ get isPlaying() {
26
+ return this.interval !== null;
17
27
  }
18
- else {
19
- if (tween.ease)
20
- progress = tween.ease(progress);
21
- tween.apply(blendNumbers(startValue, endValue, progress));
28
+ get end() {
29
+ return this.point(this._endPosition);
22
30
  }
23
- };
24
- const sortEvents = (a, b) => {
25
- return a.time - b.time;
26
- };
27
- const sortTweens = (a, b) => {
28
- return (a.time + a.duration) - (b.time + b.duration);
29
- };
30
- const sortReverse = (a, b) => {
31
- if (a.time == b.time)
32
- return 1;
33
- return b.time - a.time;
34
- };
35
- const copyObject = (source, properties) => Object.fromEntries(properties.map(p => [p, source[p]]));
36
- class Timeline {
37
- tweens = [];
38
- events = [];
39
- _position = 0;
40
- _end = 0;
41
- seeking = false;
42
- playInterval;
43
- currentSortDirection = 0;
44
- smoothSeekTimeline;
45
- pauseAtEnd;
46
- /**
47
- * @property Sets a time at which to rewind back to 0. If `true`, looping will occur after the final tween or event.
48
- */
49
- loop = false;
50
- /**
51
- * @property Time delta multiplier to change speed. Affects `step()` and `play()`
52
- */
53
- timeScale = 1;
54
- constructor(autoPlay = false, atEnd = "pause") {
55
- if (atEnd == "pause") {
56
- this.pauseAtEnd = true;
31
+ constructor(autoplay = false, endAction = "pause") {
32
+ /**
33
+ * Multiplies the speed at which `play()` progresses through the Timeline
34
+ *
35
+ * A value of 2 would double progression speed while .25 would slow it to a quarter
36
+ */
37
+ this.timeScale = 1;
38
+ this._currentTime = 0;
39
+ this._endPosition = 0;
40
+ this.interval = null;
41
+ this.points = [];
42
+ this.ranges = [];
43
+ this.currentSortDirection = 0;
44
+ this.smoothSeeker = null;
45
+ this.seeking = false;
46
+ this.start = this.point(0);
47
+ this.positionHandlers = [];
48
+ if (endAction == "loop")
49
+ endAction = "restart";
50
+ if (autoplay !== false) {
51
+ this.play(typeof autoplay == "number" ? autoplay : default_fps);
57
52
  }
58
- else if (atEnd == "loop") {
59
- this.loop = true;
53
+ if (typeof endAction == "object"
54
+ && "restartAt" in endAction) {
55
+ this.endAction = {
56
+ type: EndAction.restart,
57
+ at: this.point(endAction.restartAt),
58
+ };
60
59
  }
61
- if (autoPlay !== false)
62
- this.play(autoPlay === true ? 60 : autoPlay);
60
+ else if (typeof endAction == "object"
61
+ && "wrapAt" in endAction) {
62
+ this.endAction = {
63
+ type: EndAction.wrap,
64
+ at: this.point(endAction.wrapAt),
65
+ };
66
+ }
67
+ else
68
+ this.endAction = {
69
+ type: EndAction[endAction],
70
+ at: this.point(0),
71
+ };
63
72
  }
64
- get position() { return this._position; }
65
73
  /**
66
- * @property The final position at which an event or tween exists on this Timeline
74
+ * Defines a single point on the Timeline
75
+ *
76
+ * @param position
77
+ * @returns A point on the Timeline as specified
78
+ *
79
+ * Listenable: this point will emit a PointEvent whenever a `seek()` reaches or passes it
67
80
  */
68
- get end() { return this._end; }
69
- tween(startOrTween, duration, apply, from = 0, to = 1, ease) {
70
- const tweenRecord = typeof startOrTween == "object" ? copyObject(startOrTween, ["apply", "duration", "ease", "endValue", "time", "startValue"]) : {
71
- time: startOrTween,
72
- duration,
73
- startValue: from,
74
- endValue: to,
75
- apply,
76
- ease,
81
+ point(position) {
82
+ if (position > this._endPosition)
83
+ this._endPosition = position;
84
+ const handlers = [];
85
+ const data = {
86
+ handlers,
87
+ position,
77
88
  };
78
- this.tweens.push(tweenRecord);
79
- const endTime = tweenRecord.time + tweenRecord.duration;
80
- if (endTime > this._end)
81
- this._end = endTime;
82
- this.currentSortDirection = 0;
83
- this.seek(this.position);
84
- return this.createChainInterface(endTime);
89
+ return createEmitter(handler => {
90
+ if (this.seeking)
91
+ throw new Error("Can't add a listener while seeking");
92
+ // 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
93
+ if (handlers.length == 0) {
94
+ this.points.push(data);
95
+ this.currentSortDirection = 0;
96
+ }
97
+ handlers.push(handler);
98
+ return () => {
99
+ const idx = handlers.indexOf(handler);
100
+ if (idx === -1)
101
+ throw new Error("Internal error: attempting to remove a non-present handler");
102
+ handlers.splice(idx, 1);
103
+ if (handlers.length == 0) {
104
+ const idx = this.points.indexOf(data);
105
+ this.points.splice(idx, 1);
106
+ }
107
+ };
108
+ }, {
109
+ delta: t => this.point(position + t),
110
+ range: duration => this.range(position, duration),
111
+ to: target => {
112
+ const targetPosition = typeof target == "number"
113
+ ? target
114
+ : target.position;
115
+ return this.range(position, targetPosition - position);
116
+ },
117
+ position,
118
+ });
85
119
  }
86
- at(time, apply = false, revert = false) {
87
- if (time > this._end)
88
- this._end = time;
89
- if (apply || revert) {
90
- this.events.push({ apply, revert: revert === true ? apply : revert, time });
91
- this.currentSortDirection = 0;
92
- if (time <= this._position && apply)
93
- apply();
94
- }
95
- return this.createChainInterface(time);
120
+ range(start = 0, optionalDuration) {
121
+ const startPoint = typeof start == "number"
122
+ ? this.point(start)
123
+ : start;
124
+ const startPosition = startPoint.position;
125
+ const duration = optionalDuration ?? this._endPosition - startPosition;
126
+ if ((startPosition + duration) > this._endPosition)
127
+ this._endPosition = startPosition + duration;
128
+ const handlers = [];
129
+ const range = {
130
+ position: startPosition,
131
+ duration,
132
+ handlers,
133
+ };
134
+ const addHandler = (handler) => {
135
+ if (this.seeking)
136
+ throw new Error("Can't add a listener while seeking");
137
+ if (handlers.length == 0) {
138
+ this.ranges.push(range);
139
+ this.currentSortDirection = 0;
140
+ }
141
+ handlers.push(handler);
142
+ return () => {
143
+ const idx = handlers.indexOf(handler);
144
+ if (idx === -1)
145
+ throw new Error("Internal error: attempting to remove a non-present handler");
146
+ handlers.splice(idx, 1);
147
+ if (handlers.length == 0) {
148
+ const idx = this.ranges.indexOf(range);
149
+ this.ranges.splice(idx, 1);
150
+ }
151
+ };
152
+ };
153
+ return createProgressEmitter(addHandler, {
154
+ duration,
155
+ start: this.point(startPosition),
156
+ end: this.point(startPosition + duration),
157
+ bisect: (position = duration / 2) => {
158
+ return [
159
+ this.range(startPosition, position),
160
+ this.range(startPosition + position, duration - position),
161
+ ];
162
+ },
163
+ spread: (count) => {
164
+ const delta = duration / (count + 1);
165
+ return [
166
+ ...Array(count).fill(0).map((_, idx) => this.point(idx * delta + startPosition + delta))
167
+ ];
168
+ },
169
+ play: (easer) => {
170
+ this.pause();
171
+ this.currentTime = startPosition;
172
+ return this.seek(startPosition + duration, duration, easer);
173
+ },
174
+ });
96
175
  }
97
- in(time, apply, revert = false) {
98
- // todo, subtract lastFrameTime difference if playing? [/ done?]
99
- // todo, test this
100
- if (this.isPlaying)
101
- time -= (new Date().getTime() - this.lastFrameTime) * this.timeScale;
102
- return this.at(time + this._position, apply, revert);
176
+ getWrappedPosition(n) {
177
+ if (this.endAction.type !== EndAction.wrap)
178
+ return n;
179
+ const wrapAt = this.endAction.at?.position ?? 0;
180
+ if (wrapAt == 0)
181
+ return n % this._endPosition;
182
+ if (n <= this._endPosition)
183
+ return n;
184
+ const loopStart = wrapAt;
185
+ const segment = this._endPosition - loopStart;
186
+ if (segment <= 0)
187
+ return Math.min(n, this._endPosition);
188
+ const overflow = n - this._endPosition;
189
+ const remainder = overflow % segment;
190
+ return loopStart + remainder;
103
191
  }
104
- seek(time, smooth = false) {
105
- if (this.seeking)
106
- throw new Error("Seeking is not allowed within timeline events");
107
- const interrupting = this.smoothSeekTimeline;
108
- if (interrupting) {
109
- interrupting.pause();
110
- this.smoothSeekTimeline = undefined;
192
+ seek(to, duration = 0, easer) {
193
+ const toPosition = typeof to == "number"
194
+ ? to
195
+ : to.position;
196
+ if (this.seeking) {
197
+ throw new Error("Can't seek while seeking");
198
+ }
199
+ if (this.smoothSeeker !== null) {
200
+ this.smoothSeeker.pause();
201
+ // ensure any awaits are resolved for the previous seek?
202
+ this.smoothSeeker.seek(this.smoothSeeker.end);
203
+ this.smoothSeeker = null;
111
204
  }
112
- if (smooth === false)
113
- return this._seek(time);
114
- if (smooth === true)
115
- smooth = 400;
116
- this.smoothSeekTimeline = new Timeline(true);
117
- this.smoothSeekTimeline.tween(0, smooth, v => this._seek(v), this._position, time, interrupting ? exports.easers.easeOut : exports.easers.easeInOut);
205
+ if (duration === 0) {
206
+ this.seekDirect(toPosition);
207
+ return;
208
+ }
209
+ const seeker = new Timeline(true);
210
+ this.smoothSeeker = seeker;
211
+ seeker.range(0, duration).ease(easer).tween(this.currentTime, toPosition).listen(v => this.seekDirect(v));
212
+ return new Promise(r => seeker.end.listen(() => r()));
118
213
  }
119
- _seek(time) {
120
- if (time === this._position)
214
+ seekDirect(toPosition) {
215
+ const fromPosition = this._currentTime;
216
+ if (toPosition === fromPosition)
121
217
  return;
122
- const fromPosition = this._position;
123
- const reversing = time < fromPosition;
124
- const requiredSortDirection = reversing ? -1 : 1;
125
- if (this.currentSortDirection != requiredSortDirection) {
126
- this.events.sort(reversing ? sortReverse : sortEvents);
127
- this.tweens.sort(reversing ? sortReverse : sortTweens);
128
- this.currentSortDirection = requiredSortDirection;
218
+ const loopingTo = this.getWrappedPosition(toPosition);
219
+ const loopingFrom = this.getWrappedPosition(fromPosition);
220
+ let virtualFrom = loopingFrom;
221
+ let virtualTo = loopingTo;
222
+ const direction = toPosition > fromPosition ? 1 : -1;
223
+ if (direction !== this.currentSortDirection)
224
+ this.sortEntries(direction);
225
+ if (direction === 1 && loopingTo < loopingFrom) {
226
+ virtualFrom = loopingFrom - this._endPosition;
129
227
  }
130
- this.events.forEach(ev => {
131
- const eventOverlaps = reversing
132
- ? (ev.time <= fromPosition) && (ev.time > time)
133
- : (ev.time > fromPosition) && (ev.time <= time);
134
- if (!eventOverlaps)
135
- return;
136
- // timeline.position and tween states reflect the event's exact position during its handler
137
- this.seekTweens(ev.time);
138
- this._position = ev.time;
139
- if (reversing) {
140
- if (ev.revert)
141
- ev.revert(); // ev.revert?.() produced a 'false not callable' error :/
142
- }
143
- else {
144
- if (ev.apply)
145
- ev.apply();
228
+ else if (direction === -1 && loopingTo > loopingFrom) {
229
+ virtualFrom = loopingFrom + this._endPosition;
230
+ }
231
+ this.seeking = true;
232
+ this._currentTime = virtualFrom;
233
+ try {
234
+ this.seekPoints(virtualTo);
235
+ this.seekRanges(virtualTo);
236
+ }
237
+ catch (e) {
238
+ this.pause();
239
+ throw e;
240
+ }
241
+ this._currentTime = toPosition;
242
+ this.positionHandlers.forEach(h => h(toPosition));
243
+ this.seeking = false;
244
+ }
245
+ seekPoints(to) {
246
+ const from = this._currentTime;
247
+ const direction = to > from ? 1 : -1;
248
+ const pointsBetween = this.points.filter(direction > 0
249
+ ? p => p.position > from && p.position <= to
250
+ : p => p.position <= from && p.position > to);
251
+ pointsBetween.forEach(p => {
252
+ this.seekRanges(p.position);
253
+ this._currentTime = p.position;
254
+ const eventData = {
255
+ direction
256
+ };
257
+ p.handlers.forEach(h => h(eventData));
258
+ });
259
+ }
260
+ seekRanges(to) {
261
+ const fromTime = this._currentTime;
262
+ this.ranges.forEach((range) => {
263
+ const { duration, position } = range;
264
+ const end = position + duration;
265
+ // filter ranges that overlap seeked range
266
+ if (Math.min(position, end) <= Math.max(to, fromTime)
267
+ && Math.min(to, fromTime) <= Math.max(position, end)) {
268
+ let progress = clamp((to - range.position) / range.duration, 0, 1);
269
+ range.handlers.forEach(h => h(progress));
146
270
  }
147
271
  });
148
- this.seekTweens(time);
149
- this._position = time;
150
- this.seeking = false;
151
272
  }
152
- /**
153
- * Moves the timeline forwards (or backwards given negative delta) by a specified amount, applying intersecting tweens and events
154
- * Honours `loop` ending action
155
- * @param delta Amount of time to move forward by. This will be affected by the `timeScale` property
156
- */
157
- step(delta = 1, smooth = false) {
158
- this.lastFrameTime = new Date().getTime();
159
- const newPosition = this._position + delta * this.timeScale;
160
- const loopAt = this.loop === true ? this._end : this.loop;
161
- if (loopAt === false || newPosition < loopAt) {
162
- this.seek(newPosition, smooth);
163
- return;
164
- }
165
- const loopOvershoot = newPosition - loopAt;
166
- this.seek(loopAt);
167
- this.seek(0);
168
- this.step(loopOvershoot);
273
+ sortEntries(direction) {
274
+ this.currentSortDirection = direction;
275
+ this.points.sort(direction == 1
276
+ ? sortEvents
277
+ : sortReverse);
278
+ this.ranges.sort(direction == 1
279
+ ? sortTweens
280
+ : sortReverse);
169
281
  }
170
- lastFrameTime = new Date().getTime();
171
- /**
172
- * Begins moving forward in time from current `position` at a rate of 1000 x `timeScale` units per second
173
- * If the timeline is already playing, it will stop and resume at the specified `fps`
174
- * Honours `loop` and `pause` ending actions
175
- * @param fps Frames per second
176
- */
177
- play(fps = 60) {
178
- if (this.playInterval !== null)
282
+ play(fps = default_fps) {
283
+ if (this.interval !== null)
179
284
  this.pause();
180
- this.lastFrameTime = new Date().getTime();
181
- this.playInterval = setInterval(() => {
182
- this.step(new Date().getTime() - this.lastFrameTime);
183
- if (this.pauseAtEnd && this._position >= this._end) {
285
+ let previousTime = Date.now();
286
+ this.interval = setInterval(() => {
287
+ const newTime = Date.now();
288
+ const elapsed = newTime - previousTime;
289
+ previousTime = newTime;
290
+ let delta = elapsed * this.timeScale;
291
+ if (this._currentTime + delta <= this._endPosition) {
292
+ this.currentTime += delta;
293
+ return;
294
+ }
295
+ // overshot; perform endAction
296
+ if (this.endAction.type == EndAction.restart) {
297
+ const loopRange = this.endAction.at.to(this._endPosition);
298
+ const loopLen = loopRange.duration;
299
+ if (loopLen <= 0) {
300
+ const target = Math.min(this._currentTime + delta, this._endPosition);
301
+ this.seek(target);
302
+ return;
303
+ }
304
+ while (delta > 0) {
305
+ const distanceToEnd = this._endPosition - this._currentTime;
306
+ if (delta < distanceToEnd) {
307
+ this.seek(this._currentTime + delta);
308
+ return;
309
+ }
310
+ this.seek(this._endPosition);
311
+ delta -= distanceToEnd;
312
+ this.seek(this.endAction.at);
313
+ }
314
+ return;
315
+ }
316
+ if (this.endAction.type == EndAction.pause) {
317
+ this.seek(this._endPosition);
184
318
  this.pause();
185
- this._position = this._end;
319
+ return;
186
320
  }
321
+ this.currentTime += delta;
187
322
  }, 1000 / fps);
188
323
  }
189
- /**
190
- * Pauses automatic progression started via `play()`
191
- */
192
324
  pause() {
193
- if (this.playInterval !== null) {
194
- clearInterval(this.playInterval);
195
- this.playInterval = null;
196
- }
325
+ if (this.interval === null)
326
+ return;
327
+ clearInterval(this.interval);
328
+ this.interval = null;
197
329
  }
198
- get isPlaying() {
199
- return this.playInterval !== null;
330
+ step(delta = 1) {
331
+ this.currentTime += delta * this.timeScale;
200
332
  }
201
- /**
202
- * Creates a Timeline as a tween
203
- * * Transposes the inner Timeline's `0` -> `innerDuration` to the parent Timeline's `startTime` -> `outerDuration`
204
- * * Experimental - this functionality may change in future versions
205
- * @param startTime Position on the parent Timeline at which the inner Timeline will start seeking
206
- * @param outerDuration Length of time occupied on the parent Timeline by the inner Timeline
207
- * @param innerDuration Duration of the inner Timeline to transpose to `startTime` -> `outerDuration`
208
- * @param ease Function that takes progression (generally between 0 and 1) and returns modified progression
209
- * @returns
210
- */
211
- createInnerTimeline(startTime, outerDuration, innerDuration = 1, ease) {
212
- const innerTimeline = new Timeline(false);
213
- this.tween(startTime, outerDuration, (v) => innerTimeline.seek(v), 0, innerDuration, ease);
214
- return innerTimeline;
333
+ tween(start, durationOrToPoint, apply, from, to, easer) {
334
+ const startPosition = typeof start == "number"
335
+ ? start
336
+ : start.position;
337
+ const duration = typeof durationOrToPoint == "number"
338
+ ? durationOrToPoint
339
+ : (durationOrToPoint.position - startPosition);
340
+ this.range(startPosition, duration).ease(easer).tween(from, to).listen(apply);
341
+ return this.createChainingInterface(startPosition + duration);
215
342
  }
216
- createChainInterface(timeOffset) {
343
+ at(position, action, reverse) {
344
+ const point = typeof position == "number" ? this.point(position) : position;
345
+ if (reverse === true)
346
+ reverse = action;
347
+ if (action)
348
+ point.listen(reverse
349
+ ? (event => event.direction < 0 ? reverse() : action)
350
+ : action);
351
+ return this.createChainingInterface(point.position);
352
+ }
353
+ createChainingInterface(position) {
217
354
  return {
218
- then: (apply, revert) => {
219
- return this.at(timeOffset, apply, revert);
220
- },
221
- thenTween: (duration, apply, from = 0, to = 0, ease) => {
222
- return this.tween(timeOffset, duration, apply, from, to, ease);
355
+ thenTween: (duration, apply, from = 0, to = 1, easer) => {
356
+ return this.tween(position, duration, apply, from, to, easer);
223
357
  },
224
- thenWait: (duration) => {
225
- return this.at(timeOffset + duration);
358
+ then: (action) => this.at(position, action),
359
+ thenWait: (delay) => {
360
+ this.point(position + delay);
361
+ return this.createChainingInterface(position + delay);
226
362
  },
363
+ end: this.point(position),
227
364
  };
228
365
  }
229
- seekTweens(toTime) {
230
- const fromTime = this._position;
231
- this.tweens.forEach((tween) => {
232
- const { duration, time: start } = tween;
233
- const end = start + duration;
234
- // filter tweens that overlap seeked range
235
- if (Math.min(start, end) <= Math.max(toTime, fromTime)
236
- && Math.min(toTime, fromTime) <= Math.max(start, end)) {
237
- applyTween(tween, toTime);
238
- }
239
- });
240
- }
241
366
  /**
242
- * Initialises and starts a Timeline using chained calls to specify tweens and events
243
- * @param looping
244
- * @returns
367
+ * @deprecated use `timeline.currentTime`
245
368
  */
246
- static start(looping = false) {
247
- const tl = new Timeline(true, looping ? "loop" : "pause");
248
- tl.loop = looping;
249
- return tl.at(0);
369
+ get position() {
370
+ return this._currentTime;
250
371
  }
251
372
  }
252
373
  exports.Timeline = Timeline;
374
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
375
+ const sortEvents = (a, b) => {
376
+ return a.position - b.position;
377
+ };
378
+ const sortTweens = (a, b) => {
379
+ return (a.position + a.duration) - (b.position + b.duration);
380
+ };
381
+ const sortReverse = (a, b) => {
382
+ if (a.position == b.position)
383
+ return 1;
384
+ return b.position - a.position;
385
+ };
386
+ function tweenValue(from, to, progress) {
387
+ if (Array.isArray(from)) {
388
+ const toArr = to;
389
+ if (from.length != toArr.length)
390
+ throw new Error("Array size mismatch");
391
+ return from.map((v, i) => tweenValue(v, toArr[i], progress));
392
+ }
393
+ if (typeof from == "string") {
394
+ return blendStrings(from, to, progress);
395
+ }
396
+ if (typeof from == "number") {
397
+ return blendNumbers(from, to, progress);
398
+ }
399
+ if (from && typeof from == "object") {
400
+ if ("blend" in from) {
401
+ const blendableSource = from;
402
+ return blendableSource.blend(to, progress);
403
+ }
404
+ }
405
+ throw new Error("Value not recognised as Tweenable");
406
+ }
407
+ function blendNumbers(from, to, progress) {
408
+ return from + progress * (to - from);
409
+ }
410
+ function mergeStrings(from, to, progress) {
411
+ const p = Math.min(Math.max(progress, 0), 1);
412
+ // Fast‑path: identical strings or one is empty
413
+ if (from === to)
414
+ return from;
415
+ if (!from)
416
+ return to;
417
+ if (!to)
418
+ return from;
419
+ const split = (s) => {
420
+ // Prefer Intl.Segmenter if available (Node ≥ 14, modern browsers)
421
+ if (typeof Intl !== "undefined" && Intl.Segmenter) {
422
+ const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
423
+ return Array.from(seg.segment(s), (seg) => seg.segment);
424
+ }
425
+ // Fallback regex (covers surrogate pairs & combining marks)
426
+ const graphemeRegex = /(\P{Mark}\p{Mark}*|[\uD800-\uDBFF][\uDC00-\uDFFF])/gu;
427
+ return s.match(graphemeRegex) ?? Array.from(s);
428
+ };
429
+ const a = split(from);
430
+ const b = split(to);
431
+ const maxLen = Math.max(a.length, b.length);
432
+ const pad = (arr) => {
433
+ const diff = maxLen - arr.length;
434
+ if (diff <= 0)
435
+ return arr;
436
+ return arr.concat(Array(diff).fill(" "));
437
+ };
438
+ const fromP = pad(a);
439
+ const toP = pad(b);
440
+ const replaceCount = Math.floor(p * maxLen);
441
+ const result = new Array(maxLen);
442
+ for (let i = 0; i < maxLen; ++i) {
443
+ result[i] = i < replaceCount ? toP[i] : fromP[i];
444
+ }
445
+ while (result.length && result[result.length - 1] === " ") {
446
+ result.pop();
447
+ }
448
+ return result.join("");
449
+ }
450
+ function parseColour(code) {
451
+ if (code.length < 2 || !code.startsWith("#"))
452
+ throw new Error("Invalid colour");
453
+ let rawHex = code.substring(1);
454
+ if (rawHex.length == 1)
455
+ rawHex = rawHex + rawHex + rawHex;
456
+ if (rawHex.length == 2) {
457
+ const white = rawHex[0];
458
+ const alpha = rawHex[1];
459
+ rawHex = white + white + white + alpha;
460
+ }
461
+ if (rawHex.length == 3)
462
+ rawHex += "f";
463
+ if (rawHex.length == 4)
464
+ rawHex = rawHex.replace(/./g, c => c + c);
465
+ if (rawHex.length == 6)
466
+ rawHex += "ff";
467
+ return [...rawHex.matchAll(/../g)].map(hex => parseInt(hex[0], 16));
468
+ }
469
+ function blendColours(from, to, bias) {
470
+ const fromColour = parseColour(from);
471
+ const toColour = parseColour(to);
472
+ const blended = fromColour.map((val, i) => clamp(blendNumbers(val, toColour[i], bias), 0, 255));
473
+ return ("#" + blended.map(n => Math.round(n).toString(16).padStart(2, "0")).join("")).replace(/ff$/, "");
474
+ }
475
+ const tweenableTokenRegex = /(#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b|[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)/g;
476
+ function blendStrings(from, to, progress) {
477
+ if (from === to || progress === 0)
478
+ return from;
479
+ const tokenise = (s) => {
480
+ const chunks = [];
481
+ let lastIdx = 0;
482
+ let m;
483
+ while ((m = tweenableTokenRegex.exec(s))) {
484
+ const token = m[0];
485
+ const prefix = s.slice(lastIdx, m.index); // literal before token
486
+ chunks.push({ prefix, token });
487
+ lastIdx = m.index + token.length;
488
+ }
489
+ // trailing literal after the last token – stored as a final chunk
490
+ // with an empty token (so the consumer can easily append it)
491
+ const tail = s.slice(lastIdx);
492
+ if (tail.length) {
493
+ chunks.push({ prefix: tail, token: "" });
494
+ }
495
+ return chunks;
496
+ };
497
+ const fromChunks = tokenise(from);
498
+ const toChunks = tokenise(to);
499
+ const tokenCount = fromChunks.filter(c => c.token).length;
500
+ if (tokenCount !== toChunks.filter(c => c.token).length) {
501
+ return mergeStrings(from, to, progress);
502
+ }
503
+ let result = "";
504
+ for (let i = 0, j = 0; i < fromChunks.length && j < toChunks.length;) {
505
+ const f = fromChunks[i];
506
+ const t = toChunks[j];
507
+ // The *prefix* (the text before the token) must be the same.
508
+ if (f.prefix !== t.prefix) {
509
+ return mergeStrings(from, to, progress);
510
+ }
511
+ // Append the unchanged prefix.
512
+ result += f.prefix;
513
+ // If we are at the *trailing* chunk (no token), just break.
514
+ if (!f.token && !t.token) {
515
+ break;
516
+ }
517
+ // Blend the token according to its kind.
518
+ let blended;
519
+ if (f.token.startsWith("#")) {
520
+ blended = blendColours(f.token, t.token, progress);
521
+ }
522
+ else {
523
+ const fNum = parseFloat(f.token);
524
+ const tNum = parseFloat(t.token);
525
+ const blendedNum = blendNumbers(fNum, tNum, progress);
526
+ blended = blendedNum.toString();
527
+ }
528
+ result += blended;
529
+ // Advance both pointers.
530
+ i++;
531
+ j++;
532
+ }
533
+ return result;
534
+ }
535
+ ;
536
+ function createEmitter(onListen, api) {
537
+ const emitter = Object.create(api ?? {}, {
538
+ listen: {
539
+ value: (handler) => {
540
+ const uniqueHandler = (value) => {
541
+ handler(value);
542
+ };
543
+ return onListen(uniqueHandler);
544
+ },
545
+ },
546
+ map: {
547
+ value: (mapFunc) => {
548
+ return createEmitter(handler => {
549
+ const pipedHandler = (value) => {
550
+ handler(mapFunc(value));
551
+ };
552
+ return onListen(pipedHandler);
553
+ });
554
+ }
555
+ },
556
+ });
557
+ return emitter;
558
+ }
559
+ function createProgressEmitter(onListen, api) {
560
+ return createEmitter(onListen, Object.create(api, {
561
+ ease: {
562
+ value: (easer) => {
563
+ const easerFunc = typeof easer == "string"
564
+ ? exports.easers[easer]
565
+ : easer;
566
+ return createProgressEmitter(easer ? handler => {
567
+ const pipedHandler = (value) => {
568
+ handler(easerFunc(value));
569
+ };
570
+ return onListen(pipedHandler);
571
+ } : onListen, {});
572
+ }
573
+ },
574
+ tween: {
575
+ value: (from, to) => createEmitter(handler => {
576
+ const tweenedHandler = (progress) => {
577
+ const value = tweenValue(from, to, progress);
578
+ handler(value);
579
+ };
580
+ return onListen(tweenedHandler);
581
+ })
582
+ }
583
+ }));
584
+ }
253
585
  const overshoot = 1.70158;
254
586
  exports.easers = {
255
587
  linear: (x) => x,
@@ -297,20 +629,3 @@ exports.easers = {
297
629
  },
298
630
  pingpong: (x) => x < .5 ? x * 2 : 1 - (x - .5) * 2,
299
631
  };
300
- exports.dynamicEasers = {
301
- average: (easers) => (x) => {
302
- return easers.reduce((n, easer) => n + easer(x), 0) / easers.length;
303
- },
304
- blend: (fromEaser, toEaser, amount) => (x) => {
305
- return blendNumbers(fromEaser(x), toEaser(x), amount);
306
- },
307
- sine: (freq = 2) => (x) => Math.sin(x * freq * Math.PI * 2),
308
- multiply: (easer, mul) => (x) => {
309
- for (let i = 0; i < mul; i++)
310
- x = easer(x);
311
- return x;
312
- },
313
- combine: (easers) => (x) => {
314
- return easers.reduce((n, easer) => easer(n), x);
315
- },
316
- };