@xtia/timeline 1.2.3 → 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
 
@@ -4,7 +4,7 @@ export function animate(duration, looping = false) {
4
4
  const durationMs = typeof duration == "number"
5
5
  ? duration
6
6
  : duration.asMilliseconds;
7
- if (durationMs === Infinity || durationMs < 0)
7
+ if (durationMs === Infinity || durationMs <= 0)
8
8
  throw new RangeError("animate() duration must be positive and finite");
9
9
  let t = 0;
10
10
  if (looping) {
@@ -19,7 +19,7 @@ const createIntervalDriver = (tick) => {
19
19
  };
20
20
  };
21
21
  export const masterDriver = (() => {
22
- const timelines = new Map();
22
+ const subscriptions = new Map();
23
23
  let previousTime = null;
24
24
  let pause = null;
25
25
  const stepAll = (currentTime) => {
@@ -29,7 +29,7 @@ export const masterDriver = (() => {
29
29
  }
30
30
  const delta = currentTime - previousTime;
31
31
  previousTime = currentTime;
32
- timelines.forEach((step, tl) => {
32
+ subscriptions.forEach((step, tl) => {
33
33
  step(delta);
34
34
  });
35
35
  };
@@ -38,14 +38,14 @@ export const masterDriver = (() => {
38
38
  : createIntervalDriver(stepAll);
39
39
  return (stepFn) => {
40
40
  const key = Symbol();
41
- timelines.set(key, stepFn);
42
- if (timelines.size === 1) {
41
+ subscriptions.set(key, stepFn);
42
+ if (subscriptions.size === 1) {
43
43
  previousTime = null;
44
44
  pause = start();
45
45
  }
46
46
  return () => {
47
- timelines.delete(key);
48
- if (timelines.size === 0) {
47
+ subscriptions.delete(key);
48
+ if (subscriptions.size === 0) {
49
49
  pause();
50
50
  }
51
51
  };
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() {
@@ -196,7 +196,7 @@ export declare class Timeline {
196
196
  * @deprecated Legacy API may be absent in a future major version
197
197
  */
198
198
  at(position: number | TimelinePoint, action?: () => void, reverse?: boolean | (() => void)): ChainingInterface;
199
- private createChainingInterface;
199
+ private chain;
200
200
  /**
201
201
  * @deprecated use `timeline.currentTime`
202
202
  */
@@ -394,7 +394,7 @@ export class Timeline {
394
394
  ? durationOrToPoint
395
395
  : (durationOrToPoint.position - startPosition);
396
396
  this.range(startPosition, duration).ease(easer).tween(from, to).apply(apply);
397
- return this.createChainingInterface(startPosition + duration);
397
+ return this.chain(startPosition + duration);
398
398
  }
399
399
  /**
400
400
  * Adds an event at a specific position
@@ -414,7 +414,7 @@ export class Timeline {
414
414
  throw new Error("Invalid call");
415
415
  point.reverseOnly.apply(reverse);
416
416
  }
417
- return this.createChainingInterface(point.position);
417
+ return this.chain(point.position);
418
418
  }
419
419
  if (reverse) {
420
420
  if (reverse === true) {
@@ -427,9 +427,9 @@ export class Timeline {
427
427
  else {
428
428
  point.forwardOnly.apply(action);
429
429
  }
430
- return this.createChainingInterface(point.position);
430
+ return this.chain(point.position);
431
431
  }
432
- createChainingInterface(position) {
432
+ chain(position) {
433
433
  const chain = {
434
434
  thenTween: (duration, apply, from, to, easer) => {
435
435
  return this.tween(position, duration, apply, from, to, easer);
@@ -437,7 +437,7 @@ export class Timeline {
437
437
  then: (action) => this.at(position, action),
438
438
  thenWait: (delay) => {
439
439
  this.point(position + delay);
440
- return this.createChainingInterface(position + delay);
440
+ return this.chain(position + delay);
441
441
  },
442
442
  fork: fn => {
443
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);
package/package.json CHANGED
@@ -1,30 +1,41 @@
1
1
  {
2
2
  "name": "@xtia/timeline",
3
- "version": "1.2.3",
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,6 +0,0 @@
1
- export { Timeline, ChainingInterface } from "./internal/timeline.js";
2
- export { animate } from "./internal/animate.js";
3
- export { TimelinePoint, PointEvent } from "./internal/point.js";
4
- export { TimelineRange } from "./internal/range.js";
5
- export { type Emitter, type RangeProgression, UnsubscribeFunc } from "./internal/emitters.js";
6
- export { easers } from "./internal/easing.js";
package/index.js DELETED
@@ -1,5 +0,0 @@
1
- export { Timeline } from "./internal/timeline.js";
2
- export { animate } from "./internal/animate.js";
3
- export { TimelinePoint } from "./internal/point.js";
4
- export { TimelineRange } from "./internal/range.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
File without changes
File without changes
File without changes
File without changes