@xtia/timeline 1.2.2 → 1.3.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 Aleta Lovelace
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -342,7 +342,7 @@ await timeline.seek(timeline.end, 400, "overshootIn");
342
342
 
343
343
  ## Backward-compatibility
344
344
 
345
- Despite the massive overhaul, the previous API is present and expanded and upgrading to 1.0.0 should be frictionless in the vast majority of cases.
345
+ The previous API is present and expanded, but deprecated.
346
346
 
347
347
  #### Breaking changes
348
348
 
@@ -363,6 +363,7 @@ Despite the massive overhaul, the previous API is present and expanded and upgra
363
363
  * `timeline.position` will be replaced with `timeline.currentTime` to be consistent with other seekable concepts.
364
364
  * `"loop"` endAction is now `"restart"` to disambiguate from new looping strategies.
365
365
  * `timeline.step()` is redundant now that `currentTime` is writable; use `timeline.currentTime += delta` instead.
366
+ * The legacy API (`tl.tween()`, `tl.at()`, `tl.position`) will be removed in 2.*
366
367
 
367
368
 
368
369
 
@@ -0,0 +1,15 @@
1
+ import { RangeProgression } from "./emitters.js";
2
+ import { Period } from "./utils.js";
3
+ /**
4
+ * Creates an autoplaying one-shot progression emitter
5
+ * @param durationMs Animation duration, in milliseconds
6
+ * @returns Object representing a range on a single-use, autoplaying Timeline
7
+ */
8
+ export declare function animate(durationMs: number): RangeProgression;
9
+ export declare function animate(period: Period): RangeProgression;
10
+ /**
11
+ * Creates a looping progression emitter that will play while it has active listeners
12
+ * @param duration Animation duration, in milliseconds, or a Period
13
+ * @returns Object representing a range on a looping Timeline
14
+ */
15
+ export declare function animate(duration: number | Period, looping: true): RangeProgression;
package/lib/animate.js ADDED
@@ -0,0 +1,32 @@
1
+ import { masterDriver } from "./driver.js";
2
+ import { createListenable, RangeProgression } from "./emitters.js";
3
+ export function animate(duration, looping = false) {
4
+ const durationMs = typeof duration == "number"
5
+ ? duration
6
+ : duration.asMilliseconds;
7
+ if (durationMs === Infinity || durationMs <= 0)
8
+ throw new RangeError("animate() duration must be positive and finite");
9
+ let t = 0;
10
+ if (looping) {
11
+ const { emit, listen } = createListenable(() => masterDriver(delta => {
12
+ t += delta;
13
+ emit((t / durationMs) % 1);
14
+ }));
15
+ return new RangeProgression(h => {
16
+ h(t);
17
+ return listen(h);
18
+ });
19
+ }
20
+ const { emit, listen } = createListenable();
21
+ const masterUnsub = masterDriver(delta => {
22
+ t = Math.min(durationMs, t + delta);
23
+ emit(t / durationMs);
24
+ if (t === duration) {
25
+ masterUnsub();
26
+ }
27
+ });
28
+ return new RangeProgression(h => {
29
+ h(t);
30
+ return listen(h);
31
+ });
32
+ }
@@ -0,0 +1 @@
1
+ export declare const masterDriver: (stepFn: (n: number) => void) => () => void;
package/lib/driver.js ADDED
@@ -0,0 +1,53 @@
1
+ const default_interval_fps = 60;
2
+ const createRafDriver = (tick) => {
3
+ let rafId = null;
4
+ return () => {
5
+ const frame = (ts) => {
6
+ tick(ts);
7
+ rafId = requestAnimationFrame(frame);
8
+ };
9
+ rafId = requestAnimationFrame(frame);
10
+ return () => cancelAnimationFrame(rafId);
11
+ };
12
+ };
13
+ const createIntervalDriver = (tick) => {
14
+ const timeSource = globalThis.performance || globalThis.Date;
15
+ const tickTime = () => tick(timeSource.now());
16
+ return () => {
17
+ const intervalId = setInterval(tickTime, 1000 / default_interval_fps);
18
+ return () => clearInterval(intervalId);
19
+ };
20
+ };
21
+ export const masterDriver = (() => {
22
+ const subscriptions = new Map();
23
+ let previousTime = null;
24
+ let pause = null;
25
+ const stepAll = (currentTime) => {
26
+ if (previousTime === null) {
27
+ previousTime = currentTime;
28
+ return;
29
+ }
30
+ const delta = currentTime - previousTime;
31
+ previousTime = currentTime;
32
+ subscriptions.forEach((step, tl) => {
33
+ step(delta);
34
+ });
35
+ };
36
+ const start = "requestAnimationFrame" in globalThis
37
+ ? createRafDriver(stepAll)
38
+ : createIntervalDriver(stepAll);
39
+ return (stepFn) => {
40
+ const key = Symbol();
41
+ subscriptions.set(key, stepFn);
42
+ if (subscriptions.size === 1) {
43
+ previousTime = null;
44
+ pause = start();
45
+ }
46
+ return () => {
47
+ subscriptions.delete(key);
48
+ if (subscriptions.size === 0) {
49
+ pause();
50
+ }
51
+ };
52
+ };
53
+ })();
@@ -6,7 +6,7 @@ export type ListenFunc<T> = (handler: Handler<T>) => UnsubscribeFunc;
6
6
  export type UnsubscribeFunc = () => void;
7
7
  export declare class Emitter<T> {
8
8
  protected onListen: ListenFunc<T>;
9
- protected constructor(onListen: ListenFunc<T>);
9
+ constructor(onListen: ListenFunc<T>);
10
10
  protected transform<R = T>(handler: (value: T, emit: (value: R) => void) => void): (fn: (v: R) => void) => UnsubscribeFunc;
11
11
  /**
12
12
  * Compatibility alias for `apply()` - registers a function to receive emitted values
package/lib/index.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { Timeline, ChainingInterface } from "./timeline.js";
2
+ export { animate } from "./animate.js";
3
+ export { TimelinePoint, PointEvent } from "./point.js";
4
+ export { TimelineRange } from "./range.js";
5
+ export { type Emitter, type RangeProgression, UnsubscribeFunc } from "./emitters.js";
6
+ export { easers } from "./easing.js";
package/lib/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { Timeline } from "./timeline.js";
2
+ export { animate } from "./animate.js";
3
+ export { TimelinePoint } from "./point.js";
4
+ export { TimelineRange } from "./range.js";
5
+ export { easers } from "./easing.js";
@@ -18,7 +18,16 @@ type CurveSegment = {
18
18
  speed?: number;
19
19
  ease?: Easer | keyof typeof easers;
20
20
  };
21
- type StaticSegment = LineSegment | CurveSegment;
21
+ type ArcSegment = {
22
+ type: "arc";
23
+ from?: XY;
24
+ to: XY;
25
+ radius?: number;
26
+ direction: "clockwise" | "anticlockwise";
27
+ speed?: number;
28
+ ease?: Easer | keyof typeof easers;
29
+ };
30
+ type StaticSegment = LineSegment | CurveSegment | ArcSegment;
22
31
  type Segment = StaticSegment | CustomSegment;
23
32
  type CustomSegment = {
24
33
  get: SegmentEvaluator;
package/lib/path.js ADDED
@@ -0,0 +1,150 @@
1
+ import { createListenable } from "./emitters.js";
2
+ import { Timeline } from "./timeline.js";
3
+ export function createPathEmitter(input) {
4
+ const { listen, emit } = createListenable();
5
+ const tl = new Timeline();
6
+ const firstItem = input[0];
7
+ let getCurrentPosition;
8
+ let items;
9
+ if (Array.isArray(firstItem)) {
10
+ // first is XY - use it as starting position and exclude it from iteration
11
+ items = input.slice(1);
12
+ getCurrentPosition = () => firstItem;
13
+ }
14
+ else {
15
+ items = input;
16
+ getCurrentPosition = () => [0, 0];
17
+ }
18
+ items.forEach(item => {
19
+ const speed = typeof item === 'object' && !Array.isArray(item) && "speed" in item ? item.speed ?? 1 : 1;
20
+ if (typeof item == "function") {
21
+ const length = estimateLength(item);
22
+ tl.end.range(length / speed)
23
+ .apply(v => emit(item(v)));
24
+ getCurrentPosition = () => item(1);
25
+ }
26
+ else if (Array.isArray(item)) { // XY
27
+ const start = getCurrentPosition();
28
+ const length = distance(start, item);
29
+ tl.end.range(length / speed)
30
+ .tween(start, item)
31
+ .apply(emit);
32
+ getCurrentPosition = () => item;
33
+ }
34
+ else if ("get" in item) { // custom segment
35
+ const length = item.length ?? estimateLength(item.get);
36
+ tl.end.range(length / speed)
37
+ .ease(item.ease)
38
+ .apply(v => emit(item.get(v)));
39
+ getCurrentPosition = () => item.get(1);
40
+ }
41
+ else
42
+ switch (item.type) { // static segment
43
+ case "line": {
44
+ const start = item.from ?? getCurrentPosition();
45
+ const length = distance(start, item.to);
46
+ tl.end.range(length / speed)
47
+ .ease(item.ease)
48
+ .tween(start, item.to)
49
+ .apply(emit);
50
+ getCurrentPosition = () => item.to;
51
+ break;
52
+ }
53
+ case "curve": {
54
+ const start = item.from ?? getCurrentPosition();
55
+ const curve = createCurve(start, item.to, item.control1, item.control2);
56
+ const length = estimateLength(curve);
57
+ tl.end.range(length / speed)
58
+ .ease(item.ease)
59
+ .apply(v => emit(curve(v)));
60
+ getCurrentPosition = () => item.to;
61
+ break;
62
+ }
63
+ case "arc": {
64
+ const start = getCurrentPosition();
65
+ const arc = createArc(start, item.to, item.radius, item.direction);
66
+ const length = estimateLength(arc);
67
+ tl.end.range(length / (item.speed ?? 1))
68
+ .ease(item.ease)
69
+ .apply(v => emit(arc(v)));
70
+ getCurrentPosition = () => item.to;
71
+ break;
72
+ }
73
+ }
74
+ });
75
+ return { listen, seek: t => {
76
+ tl.seek(t * tl.end.position);
77
+ } };
78
+ }
79
+ function createCurve([startX, startY], [endX, endY], [control1x, control1y], [control2x, control2y]) {
80
+ return (t) => {
81
+ const ti = 1 - t;
82
+ const x = ti ** 3 * startX +
83
+ 3 * ti ** 2 * t * control1x +
84
+ 3 * ti * t ** 2 * control2x +
85
+ t ** 3 * endX;
86
+ const y = ti ** 3 * startY +
87
+ 3 * ti ** 2 * t * control1y +
88
+ 3 * ti * t ** 2 * control2y +
89
+ t ** 3 * endY;
90
+ return [x, y];
91
+ };
92
+ }
93
+ function estimateLength(curve, samples = 100) {
94
+ let length = 0;
95
+ let prev = curve(0);
96
+ for (let i = 1; i <= samples; i++) {
97
+ const t = i / samples;
98
+ const current = curve(t);
99
+ length += Math.sqrt((current[0] - prev[0]) ** 2 + (current[1] - prev[1]) ** 2);
100
+ prev = current;
101
+ }
102
+ return length;
103
+ }
104
+ const distance = (a, b) => Math.sqrt((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2);
105
+ function createArc([startX, startY], [endX, endY], radius, direction = "clockwise") {
106
+ const dx = endX - startX;
107
+ const dy = endY - startY;
108
+ const chordLength = Math.sqrt(dx * dx + dy * dy);
109
+ if (chordLength < 0.0001) {
110
+ return _ => [startX, startY];
111
+ }
112
+ const r = radius ?? chordLength / 2;
113
+ const minRadius = chordLength / 2;
114
+ let effectiveRadius = Math.max(r, minRadius);
115
+ const halfChord = chordLength / 2;
116
+ let centreOffset = Math.sqrt(effectiveRadius * effectiveRadius - halfChord * halfChord);
117
+ if (isNaN(centreOffset)) {
118
+ effectiveRadius = minRadius;
119
+ centreOffset = 0;
120
+ }
121
+ const chordMidX = (startX + endX) / 2;
122
+ const chordMidY = (startY + endY) / 2;
123
+ const perpX = -dy / chordLength;
124
+ const perpY = dx / chordLength;
125
+ const sign = direction === "clockwise" ? 1 : -1;
126
+ const centerX = chordMidX + perpX * sign * centreOffset;
127
+ const centerY = chordMidY + perpY * sign * centreOffset;
128
+ const startAngle = Math.atan2(startY - centerY, startX - centerX);
129
+ const endAngle = Math.atan2(endY - centerY, endX - centerX);
130
+ let angleDiff = endAngle - startAngle;
131
+ if (direction === "clockwise") {
132
+ if (angleDiff > 0)
133
+ angleDiff -= Math.PI * 2;
134
+ if (angleDiff > -Math.PI)
135
+ angleDiff -= Math.PI * 2;
136
+ }
137
+ else {
138
+ if (angleDiff < 0)
139
+ angleDiff += Math.PI * 2;
140
+ if (angleDiff < Math.PI)
141
+ angleDiff += Math.PI * 2;
142
+ }
143
+ return (t) => {
144
+ const clampedT = Math.max(0, Math.min(1, t));
145
+ const angle = startAngle + angleDiff * clampedT;
146
+ const x = centerX + effectiveRadius * Math.cos(angle);
147
+ const y = centerY + effectiveRadius * Math.sin(angle);
148
+ return [x, y];
149
+ };
150
+ }
@@ -46,13 +46,13 @@ export declare class TimelinePoint extends Emitter<PointEvent> {
46
46
  */
47
47
  seek(duration: number, easer?: Easer): Promise<void>;
48
48
  /**
49
- * An point emitter that only emits on forward-moving seeks
49
+ * A point emitter that only emits on forward-moving seeks
50
50
  * @returns Listenable: emits forward-seeking point events
51
51
  */
52
52
  get forwardOnly(): Emitter<PointEvent>;
53
53
  private _forwardOnly?;
54
54
  /**
55
- * An point emitter that only emits on backward-moving seeks
55
+ * A point emitter that only emits on backward-moving seeks
56
56
  * @returns Listenable: emits backward-seeking point events
57
57
  */
58
58
  get reverseOnly(): Emitter<PointEvent>;
@@ -41,7 +41,7 @@ export class TimelinePoint extends Emitter {
41
41
  return this.timeline.seek(this.position, duration, easer);
42
42
  }
43
43
  /**
44
- * An point emitter that only emits on forward-moving seeks
44
+ * A point emitter that only emits on forward-moving seeks
45
45
  * @returns Listenable: emits forward-seeking point events
46
46
  */
47
47
  get forwardOnly() {
@@ -50,7 +50,7 @@ export class TimelinePoint extends Emitter {
50
50
  return this._forwardOnly;
51
51
  }
52
52
  /**
53
- * An point emitter that only emits on backward-moving seeks
53
+ * A point emitter that only emits on backward-moving seeks
54
54
  * @returns Listenable: emits backward-seeking point events
55
55
  */
56
56
  get reverseOnly() {
@@ -3,29 +3,13 @@ import { RangeProgression, UnsubscribeFunc } from "./emitters.js";
3
3
  import { TimelinePoint } from "./point.js";
4
4
  import { TimelineRange } from "./range.js";
5
5
  import { Tweenable } from "./tween.js";
6
- import { Widen } from "./utils.js";
6
+ import { Period, Widen } from "./utils.js";
7
7
  declare const EndAction: {
8
8
  readonly pause: 0;
9
9
  readonly continue: 1;
10
10
  readonly wrap: 2;
11
11
  readonly restart: 3;
12
12
  };
13
- type Period = {
14
- asMilliseconds: number;
15
- };
16
- /**
17
- * Creates an autoplaying one-shot progression emitter
18
- * @param durationMs Animation duration, in milliseconds
19
- * @returns Object representing a range on a single-use, autoplaying Timeline
20
- */
21
- export declare function animate(durationMs: number): RangeProgression;
22
- export declare function animate(period: Period): RangeProgression;
23
- /**
24
- * Creates a looping progression emitter that will play while it has active listeners
25
- * @param duration Animation duration, in milliseconds, or a Period
26
- * @returns Object representing a range on a looping Timeline
27
- */
28
- export declare function animate(duration: number | Period, looping: true): RangeProgression;
29
13
  type TimelineOptions = {
30
14
  atEnd?: {
31
15
  wrapAt: number;
@@ -196,6 +180,7 @@ export declare class Timeline {
196
180
  * @param from Value at start of range
197
181
  * @param to Value at end of range
198
182
  * @param easer Optional easing function
183
+ * @deprecated Legacy API may be absent in a future major version
199
184
  */
200
185
  tween<T extends Tweenable>(start: number | TimelinePoint, duration: number, apply: (v: Widen<T>) => void, from: T, to: T, easer?: Easer | keyof typeof easers): ChainingInterface;
201
186
  tween<T extends Tweenable>(start: number | TimelinePoint, end: TimelinePoint, // ease migration for tl.tween(0, tl.end, ...)
@@ -208,14 +193,18 @@ export declare class Timeline {
208
193
  * @param action Handler for forward seeking
209
194
  * @param reverse Handler for backward seeking
210
195
  * @returns A tween/event chaining interface
196
+ * @deprecated Legacy API may be absent in a future major version
211
197
  */
212
198
  at(position: number | TimelinePoint, action?: () => void, reverse?: boolean | (() => void)): ChainingInterface;
213
- private createChainingInterface;
199
+ private chain;
214
200
  /**
215
201
  * @deprecated use `timeline.currentTime`
216
202
  */
217
203
  get position(): number;
218
204
  }
205
+ /**
206
+ * @deprecated Legacy API may be absent in a future major version
207
+ */
219
208
  export interface ChainingInterface {
220
209
  thenTween<T extends Tweenable>(duration: number, apply: (v: Widen<T>) => void, from: T, to: T, easer?: Easer): ChainingInterface;
221
210
  then(action: () => void): ChainingInterface;
@@ -2,89 +2,13 @@ import { createListenable, RangeProgression } from "./emitters.js";
2
2
  import { TimelinePoint } from "./point.js";
3
3
  import { TimelineRange } from "./range.js";
4
4
  import { clamp } from "./utils.js";
5
- const default_interval_fps = 60;
6
- const createRafDriver = (tick) => {
7
- let rafId = null;
8
- return () => {
9
- const frame = (ts) => {
10
- tick(ts);
11
- rafId = requestAnimationFrame(frame);
12
- };
13
- rafId = requestAnimationFrame(frame);
14
- return () => cancelAnimationFrame(rafId);
15
- };
16
- };
17
- const createIntervalDriver = (tick) => {
18
- const timeSource = globalThis.performance || globalThis.Date;
19
- const tickTime = () => tick(timeSource.now());
20
- return () => {
21
- const intervalId = setInterval(tickTime, 1000 / default_interval_fps);
22
- return () => clearInterval(intervalId);
23
- };
24
- };
25
- const masterDriver = (() => {
26
- const timelines = new Map();
27
- let previousTime = null;
28
- let pause = null;
29
- const stepAll = (currentTime) => {
30
- if (previousTime === null) {
31
- previousTime = currentTime;
32
- return;
33
- }
34
- const delta = currentTime - previousTime;
35
- previousTime = currentTime;
36
- timelines.forEach((step, tl) => {
37
- step(delta * tl.timeScale);
38
- });
39
- };
40
- const start = "requestAnimationFrame" in globalThis
41
- ? createRafDriver(stepAll)
42
- : createIntervalDriver(stepAll);
43
- return (timeline, stepFn) => {
44
- timelines.set(timeline, stepFn);
45
- if (timelines.size === 1) {
46
- previousTime = null;
47
- pause = start();
48
- }
49
- return () => {
50
- timelines.delete(timeline);
51
- if (timelines.size === 0) {
52
- pause();
53
- }
54
- };
55
- };
56
- })();
5
+ import { masterDriver } from "./driver.js";
57
6
  const EndAction = {
58
7
  pause: 0,
59
8
  continue: 1,
60
9
  wrap: 2,
61
10
  restart: 3,
62
11
  };
63
- export function animate(duration, looping = false) {
64
- const tl = new Timeline(!looping, looping ? "wrap" : "pause");
65
- const durationMs = typeof duration == "number"
66
- ? duration
67
- : duration.asMilliseconds;
68
- const parentRange = tl.range(0, durationMs);
69
- if (looping) {
70
- let listeners = 0;
71
- const range = new TimelineRange(h => {
72
- if (++listeners == 1) {
73
- tl.play();
74
- }
75
- let unsub = parentRange.apply(h);
76
- return () => {
77
- if (--listeners == 0)
78
- tl.pause();
79
- unsub();
80
- };
81
- }, tl, tl.start, tl.point(durationMs));
82
- return range.ease(); // empty ease() to coax TimelineRange -> RangeProgression
83
- }
84
- else {
85
- return parentRange.ease();
86
- }
87
- }
88
12
  export class Timeline {
89
13
  /**
90
14
  * The current position of this Timeline's 'play head'
@@ -411,7 +335,7 @@ export class Timeline {
411
335
  this.seek(arg.start);
412
336
  return this.seek(arg.end, arg.duration / this.timeScale, easer);
413
337
  }
414
- this._pause = masterDriver(this, n => this.next(n));
338
+ this._pause = masterDriver(n => this.next(n * this.timeScale));
415
339
  }
416
340
  next(delta) {
417
341
  if (this._currentTime + delta <= this._endPosition) {
@@ -470,7 +394,7 @@ export class Timeline {
470
394
  ? durationOrToPoint
471
395
  : (durationOrToPoint.position - startPosition);
472
396
  this.range(startPosition, duration).ease(easer).tween(from, to).apply(apply);
473
- return this.createChainingInterface(startPosition + duration);
397
+ return this.chain(startPosition + duration);
474
398
  }
475
399
  /**
476
400
  * Adds an event at a specific position
@@ -480,6 +404,7 @@ export class Timeline {
480
404
  * @param action Handler for forward seeking
481
405
  * @param reverse Handler for backward seeking
482
406
  * @returns A tween/event chaining interface
407
+ * @deprecated Legacy API may be absent in a future major version
483
408
  */
484
409
  at(position, action, reverse) {
485
410
  const point = typeof position == "number" ? this.point(position) : position;
@@ -489,7 +414,7 @@ export class Timeline {
489
414
  throw new Error("Invalid call");
490
415
  point.reverseOnly.apply(reverse);
491
416
  }
492
- return this.createChainingInterface(point.position);
417
+ return this.chain(point.position);
493
418
  }
494
419
  if (reverse) {
495
420
  if (reverse === true) {
@@ -502,9 +427,9 @@ export class Timeline {
502
427
  else {
503
428
  point.forwardOnly.apply(action);
504
429
  }
505
- return this.createChainingInterface(point.position);
430
+ return this.chain(point.position);
506
431
  }
507
- createChainingInterface(position) {
432
+ chain(position) {
508
433
  const chain = {
509
434
  thenTween: (duration, apply, from, to, easer) => {
510
435
  return this.tween(position, duration, apply, from, to, easer);
@@ -512,7 +437,7 @@ export class Timeline {
512
437
  then: (action) => this.at(position, action),
513
438
  thenWait: (delay) => {
514
439
  this.point(position + delay);
515
- return this.createChainingInterface(position + delay);
440
+ return this.chain(position + delay);
516
441
  },
517
442
  fork: fn => {
518
443
  fn(chain);
@@ -50,6 +50,10 @@ function createStringTween(from, to) {
50
50
  const prefix = chunk.prefix;
51
51
  if (chunk.type === TokenTypes.none)
52
52
  return () => prefix;
53
+ if (fromToken === toToken) {
54
+ const full = prefix + fromToken;
55
+ return () => full;
56
+ }
53
57
  if (chunk.type === TokenTypes.colour) {
54
58
  const fromColour = parseColour(fromToken);
55
59
  const toColour = parseColour(toToken);
@@ -2,3 +2,6 @@
2
2
  export declare const clamp: (value: number, min?: number, max?: number) => number;
3
3
  /** @internal */
4
4
  export type Widen<T> = T extends number ? number : T extends string ? string : T;
5
+ export type Period = {
6
+ asMilliseconds: number;
7
+ };
package/package.json CHANGED
@@ -1,30 +1,41 @@
1
1
  {
2
2
  "name": "@xtia/timeline",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
+ "description": "A general-purpose, environment-agnostic choreography engine",
4
5
  "repository": {
5
6
  "url": "https://github.com/tiadrop/timeline",
6
7
  "type": "github"
7
8
  },
8
- "description": "A general-purpose, environment-agnostic choreography engine",
9
9
  "sideEffects": false,
10
- "types": "./index.d.ts",
11
- "main": "./index.js",
10
+ "files": [
11
+ "lib/"
12
+ ],
13
+ "types": "./lib/index.d.ts",
14
+ "main": "./lib/index.js",
12
15
  "exports": {
13
16
  ".": {
14
- "types": "./index.d.ts",
15
- "default": "./index.js"
17
+ "types": "./lib/index.d.ts",
18
+ "default": "./lib/index.js"
16
19
  },
17
- "./internal/*": null
20
+ "./lib/*": null
18
21
  },
19
22
  "scripts": {
20
- "prepublishOnly": "cp ../README.md .",
21
- "postpublish": "rm README.md"
23
+ "build": "tsc",
24
+ "test": "jest",
25
+ "prepublishOnly": "tsc"
22
26
  },
27
+ "author": "Aleta Lovelace",
23
28
  "keywords": [
24
29
  "animation",
25
30
  "timeline",
26
31
  "choreography"
27
32
  ],
28
- "author": "Aleta Lovelace",
29
- "license": "MIT"
33
+ "license": "MIT",
34
+ "devDependencies": {
35
+ "@types/jest": "^30.0.0",
36
+ "@xtia/mezr": "^0.1.2",
37
+ "jest": "^30.2.0",
38
+ "ts-jest": "^29.4.5",
39
+ "typescript": "^5.9.3"
40
+ }
30
41
  }
package/index.d.ts DELETED
@@ -1,5 +0,0 @@
1
- export { Timeline, animate, ChainingInterface } from "./internal/timeline.js";
2
- export { TimelinePoint, PointEvent } from "./internal/point.js";
3
- export { TimelineRange } from "./internal/range.js";
4
- export { Emitter, RangeProgression, UnsubscribeFunc } from "./internal/emitters.js";
5
- export { easers } from "./internal/easing.js";
package/index.js DELETED
@@ -1,5 +0,0 @@
1
- export { Timeline, animate } from "./internal/timeline.js";
2
- export { TimelinePoint } from "./internal/point.js";
3
- export { TimelineRange } from "./internal/range.js";
4
- export { Emitter, RangeProgression } from "./internal/emitters.js";
5
- export { easers } from "./internal/easing.js";
package/internal/path.js DELETED
@@ -1,85 +0,0 @@
1
- import { createListenable } from "./emitters.js";
2
- import { Timeline } from "./timeline.js";
3
- export function createPathEmitter(input) {
4
- const { listen, emit } = createListenable();
5
- const tl = new Timeline();
6
- let lastXY = [0, 0];
7
- const firstItem = input[0];
8
- let getCurrentPosition;
9
- let items;
10
- if (Array.isArray(firstItem)) {
11
- // first is XY - use it as starting position and exclude it from iteration
12
- items = input.slice(1);
13
- getCurrentPosition = () => firstItem;
14
- }
15
- else {
16
- items = input;
17
- getCurrentPosition = () => [0, 0];
18
- }
19
- items.forEach(item => {
20
- const speed = typeof item === 'object' && !Array.isArray(item) && "speed" in item ? item.speed ?? 1 : 1;
21
- if (typeof item == "function") {
22
- const length = estimateLength(item);
23
- tl.end.range(length / speed).apply(v => lastXY = item(v));
24
- getCurrentPosition = () => item(1);
25
- }
26
- else if (Array.isArray(item)) { // XY
27
- const start = getCurrentPosition();
28
- const length = distance(start, item);
29
- tl.end.range(length / speed).tween(start, item).apply(v => lastXY = v);
30
- getCurrentPosition = () => item;
31
- }
32
- else if ("get" in item) { // custom segment
33
- const length = item.length ?? estimateLength(item.get);
34
- tl.end.range(length / speed).ease(item.ease).apply(v => lastXY = item.get(v));
35
- getCurrentPosition = () => item.get(1);
36
- }
37
- else
38
- switch (item.type) { // static segment
39
- case "line": {
40
- const start = item.from ?? getCurrentPosition();
41
- const length = distance(start, item.to);
42
- tl.end.range(length / speed).ease(item.ease).tween(start, item.to).apply(v => lastXY = v);
43
- getCurrentPosition = () => item.to;
44
- break;
45
- }
46
- case "curve": {
47
- const start = item.from ?? getCurrentPosition();
48
- const curve = createCurve(start, item.to, item.control1, item.control2);
49
- const length = estimateLength(curve);
50
- tl.end.range(length / speed).ease(item.ease).map(curve).apply(v => lastXY = v);
51
- getCurrentPosition = () => item.to;
52
- }
53
- }
54
- });
55
- return { listen, seek: t => {
56
- tl.seek(t * tl.end.position);
57
- emit(lastXY);
58
- } };
59
- }
60
- function createCurve([startX, startY], [endX, endY], [control1x, control1y], [control2x, control2y]) {
61
- return (t) => {
62
- const ti = 1 - t;
63
- const x = ti ** 3 * startX +
64
- 3 * ti ** 2 * t * control1x +
65
- 3 * ti * t ** 2 * control2x +
66
- t ** 3 * endX;
67
- const y = ti ** 3 * startY +
68
- 3 * ti ** 2 * t * control1y +
69
- 3 * ti * t ** 2 * control2y +
70
- t ** 3 * endY;
71
- return [x, y];
72
- };
73
- }
74
- function estimateLength(curve, samples = 100) {
75
- let length = 0;
76
- let prev = curve(0);
77
- for (let i = 1; i <= samples; i++) {
78
- const t = i / samples;
79
- const current = curve(t);
80
- length += Math.sqrt((current[0] - prev[0]) ** 2 + (current[1] - prev[1]) ** 2);
81
- prev = current;
82
- }
83
- return length;
84
- }
85
- const distance = (a, b) => Math.sqrt((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2);
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes