@xtia/timeline 1.0.10 → 1.0.12

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
@@ -483,6 +483,13 @@ Creates a [`TimelineRange`](#timelinerange-interface) on the Timeline to which t
483
483
 
484
484
  Creates a `TimelinePoint` at an offset from the this point.
485
485
 
486
+ ##### `seek(): void`
487
+
488
+ Seeks the parent Timeline to this point.
489
+
490
+ ##### `seek(duration: number, easer?: Easer): Promise<void>`
491
+
492
+ Smooth-seeks the parent Timeline to this point over a specified duration and resolves the returned Promise on completion.
486
493
 
487
494
 
488
495
 
@@ -593,6 +600,10 @@ blend(from: this, to: this, progress: number): this
593
600
 
594
601
  Creates an emitter that quantises progression emitted by the parent to the nearest of `steps` discrete values.
595
602
 
603
+ ##### `sample<T>(values: ArrayLike<T>): `[`Emitter<T>`](#emittert-interface)
604
+
605
+ Creates an emitter that emits values from an array according to progression.
606
+
596
607
  ##### `threshold(threshold): RangeProgression`
597
608
 
598
609
  Creates an emitter that emits 0 when the parent emits a value below `threshold` and 1 when a parent emission is equal to or greater than `threshold`.
@@ -125,6 +125,20 @@ export declare class RangeProgression extends Emitter<number> {
125
125
  */
126
126
  tween<T extends Tweenable>(from: T, to: T): Emitter<T>;
127
127
  tween<T extends BlendableWith<T, R>, R>(from: T, to: R): Emitter<T>;
128
+ /**
129
+ * Creates a chainable emitter that takes a value from an array according to progression
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * range
134
+ * .sample(["a", "b", "c"])
135
+ * .listen(v => console.log(v));
136
+ * // logs 'b' when a seek lands halfway through range
137
+ * ```
138
+ * @param source array to sample
139
+ * @returns Listenable: emits the sampled values
140
+ */
141
+ sample<T>(source: ArrayLike<T>): Emitter<T>;
128
142
  /**
129
143
  * Creates a chainable progress emitter that quantises progress, as emitted by its parent, to the nearest of `steps` discrete values.
130
144
  *
@@ -1,5 +1,5 @@
1
1
  import { easers } from "./easing";
2
- import { tweenValue } from "./tween";
2
+ import { createTween } from "./tween";
3
3
  import { clamp } from "./utils";
4
4
  export class Emitter {
5
5
  constructor(onListen) {
@@ -138,7 +138,28 @@ export class RangeProgression extends Emitter {
138
138
  })) : h => this.onListen(h));
139
139
  }
140
140
  tween(from, to) {
141
- return new Emitter(handler => this.onListen(progress => handler(tweenValue(from, to, progress))));
141
+ const tween = createTween(from, to);
142
+ return new Emitter(handler => this.onListen(progress => handler(tween(progress))));
143
+ }
144
+ /**
145
+ * Creates a chainable emitter that takes a value from an array according to progression
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * range
150
+ * .sample(["a", "b", "c"])
151
+ * .listen(v => console.log(v));
152
+ * // logs 'b' when a seek lands halfway through range
153
+ * ```
154
+ * @param source array to sample
155
+ * @returns Listenable: emits the sampled values
156
+ */
157
+ sample(source) {
158
+ return new Emitter(handler => this.onListen(progress => {
159
+ const clampedProgress = clamp(progress);
160
+ const index = Math.floor(clampedProgress * (source.length - 1));
161
+ handler(source[index]);
162
+ }));
142
163
  }
143
164
  /**
144
165
  * Creates a chainable progress emitter that quantises progress, as emitted by its parent, to the nearest of `steps` discrete values.
@@ -153,7 +174,7 @@ export class RangeProgression extends Emitter {
153
174
  }
154
175
  return new RangeProgression(handler => this.onListen(progress => {
155
176
  const snapped = Math.round(progress * steps) / steps;
156
- handler(clamp(snapped, 0, 1));
177
+ handler(clamp(snapped));
157
178
  }));
158
179
  }
159
180
  /**
@@ -1,3 +1,4 @@
1
+ import { Easer } from "./easing";
1
2
  import { Emitter, ListenFunc } from "./emitters";
2
3
  import { TimelineRange } from "./range";
3
4
  import { Timeline } from "./timeline";
@@ -35,4 +36,9 @@ export declare class TimelinePoint extends Emitter<PointEvent> {
35
36
  * @returns Listenable: emits a PointEvent when the point is reached or passed by a Timeline seek
36
37
  */
37
38
  delta(timeOffset: number): TimelinePoint;
39
+ /**
40
+ * Seeks the parent Timeline to this point
41
+ */
42
+ seek(): void;
43
+ seek(duration: number, easer?: Easer): Promise<void>;
38
44
  }
package/internal/point.js CHANGED
@@ -38,4 +38,7 @@ export class TimelinePoint extends Emitter {
38
38
  delta(timeOffset) {
39
39
  return this.timeline.point(this.position + timeOffset);
40
40
  }
41
+ seek(duration = 0, easer) {
42
+ return this.timeline.seek(this.position, duration, easer);
43
+ }
41
44
  }
package/internal/range.js CHANGED
@@ -6,7 +6,11 @@ export class TimelineRange extends RangeProgression {
6
6
  constructor(onListen, timeline, startPosition,
7
7
  /** The duration of this range */
8
8
  duration) {
9
- super(onListen);
9
+ super(duration == 0
10
+ ? () => {
11
+ throw new Error("Zero-duration ranges may not be listened");
12
+ }
13
+ : onListen);
10
14
  this.timeline = timeline;
11
15
  this.startPosition = startPosition;
12
16
  this.duration = duration;
@@ -189,7 +189,7 @@ export class Timeline {
189
189
  }
190
190
  if (duration === 0) {
191
191
  this.seekDirect(toPosition);
192
- return;
192
+ return Promise.resolve();
193
193
  }
194
194
  const seeker = new Timeline(true);
195
195
  this.smoothSeeker = seeker;
@@ -1,5 +1,5 @@
1
1
  /** @internal */
2
- export type Tweenable = number | number[] | string | Blendable;
2
+ export type Tweenable = number | number[] | string | string[] | Blendable | Blendable[];
3
3
  /** @internal */
4
4
  export interface Blendable {
5
5
  blend(target: this, progress: number): this;
@@ -7,5 +7,7 @@ export interface Blendable {
7
7
  export interface BlendableWith<T, R> {
8
8
  blend(target: R, progress: number): T;
9
9
  }
10
- /** @internal */
11
- export declare function tweenValue<T extends Tweenable>(from: T, to: T, progress: number): T;
10
+ type TweenFunc<T> = (progress: number) => T;
11
+ export declare function createTween<T extends Tweenable>(from: T, to: T): TweenFunc<T>;
12
+ export declare function createTween<T extends BlendableWith<T, R>, R>(from: T, to: R): TweenFunc<T>;
13
+ export {};
package/internal/tween.js CHANGED
@@ -1,45 +1,82 @@
1
1
  import { clamp } from "./utils";
2
- /** @internal */
3
- export function tweenValue(from, to, progress) {
2
+ const tokenTypes = {
3
+ none: 0,
4
+ number: 1,
5
+ colour: 2,
6
+ };
7
+ export function createTween(from, to) {
8
+ if (from === to)
9
+ return () => from;
4
10
  if (Array.isArray(from)) {
5
- const toArr = to;
6
- if (from.length != toArr.length)
11
+ if (from.length != to.length) {
7
12
  throw new Error("Array size mismatch");
8
- return from.map((v, i) => tweenValue(v, toArr[i], progress));
13
+ }
14
+ const tweens = from.map((f, i) => createTween(f, to[i]));
15
+ return progress => tweens.map(t => t(progress));
9
16
  }
10
- if (typeof from == "string") {
11
- return blendStrings(from, to, progress);
17
+ switch (typeof from) {
18
+ case "number": return progress => blendNumbers(from, to, progress);
19
+ case "object": return progress => from.blend(to, progress);
20
+ case "string": return createStringTween(from, to);
21
+ default: throw new Error("Invalid tweening type");
12
22
  }
13
- if (typeof from == "number") {
14
- return blendNumbers(from, to, progress);
23
+ }
24
+ function createStringTween(from, to) {
25
+ const fromChunks = tokenise(from);
26
+ const toChunks = tokenise(to);
27
+ const tokenCount = fromChunks.filter(c => c.token).length;
28
+ // where length mismatch, use merging
29
+ if (tokenCount !== toChunks.filter(c => c.token).length) {
30
+ return createStringMerge(from, to);
15
31
  }
16
- if (from && typeof from == "object") {
17
- if ("blend" in from) {
18
- const blendableSource = from;
19
- return blendableSource.blend(to, progress);
20
- }
32
+ // where token prefix/type mismatch, use merging
33
+ if (fromChunks.some((chunk, i) => toChunks[i].prefix !== chunk.prefix ||
34
+ toChunks[i].type !== chunk.type)) {
35
+ return createStringMerge(from, to);
21
36
  }
22
- throw new Error("Value not recognised as Tweenable");
37
+ // convert token chunks to individual string tween funcs
38
+ const tweenChunks = fromChunks.map((chunk, i) => {
39
+ const fromToken = chunk.token;
40
+ const toToken = toChunks[i].token;
41
+ const prefix = chunk.prefix;
42
+ if (chunk.type === tokenTypes.none)
43
+ return () => prefix;
44
+ if (chunk.type === tokenTypes.colour) {
45
+ const fromColour = parseColour(fromToken);
46
+ const toColour = parseColour(toToken);
47
+ return progress => prefix + blendColours(fromColour, toColour, progress);
48
+ }
49
+ else {
50
+ const fromNum = parseFloat(fromToken);
51
+ const toNum = parseFloat(toToken);
52
+ return progress => {
53
+ const blendedNum = blendNumbers(fromNum, toNum, progress);
54
+ return prefix + blendedNum.toString();
55
+ };
56
+ }
57
+ });
58
+ if (tweenChunks.length == 1)
59
+ return tweenChunks[0];
60
+ return progress => tweenChunks.map(t => t(progress)).join("");
23
61
  }
24
62
  function blendNumbers(from, to, progress) {
25
63
  return from + progress * (to - from);
26
64
  }
27
- function mergeStrings(from, to, progress) {
28
- const p = Math.min(Math.max(progress, 0), 1);
29
- // Fast‑path: identical strings or one is empty
65
+ function createStringMerge(from, to) {
66
+ // fast‑path: identical strings or one is empty
30
67
  if (from === to)
31
- return from;
68
+ return () => from;
32
69
  if (!from)
33
- return to;
70
+ return () => to;
34
71
  if (!to)
35
- return from;
72
+ return () => from;
36
73
  const split = (s) => {
37
- // Prefer Intl.Segmenter if available (Node ≥ 14, modern browsers)
74
+ // prefer Intl.Segmenter if available (Node 14, modern browsers)
38
75
  if (typeof Intl !== "undefined" && Intl.Segmenter) {
39
76
  const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
40
77
  return Array.from(seg.segment(s), (seg) => seg.segment);
41
78
  }
42
- // Fallback regex (covers surrogate pairs & combining marks)
79
+ // fallback regex (covers surrogate pairs & combining marks)
43
80
  const graphemeRegex = /(\P{Mark}\p{Mark}*|[\uD800-\uDBFF][\uDC00-\uDFFF])/gu;
44
81
  return s.match(graphemeRegex) ?? Array.from(s);
45
82
  };
@@ -54,15 +91,18 @@ function mergeStrings(from, to, progress) {
54
91
  };
55
92
  const fromP = pad(a);
56
93
  const toP = pad(b);
57
- const replaceCount = Math.floor(p * maxLen);
58
- const result = new Array(maxLen);
59
- for (let i = 0; i < maxLen; ++i) {
60
- result[i] = i < replaceCount ? toP[i] : fromP[i];
61
- }
62
- while (result.length && result[result.length - 1] === " ") {
63
- result.pop();
64
- }
65
- return result.join("");
94
+ return (progress) => {
95
+ const clampedProgress = clamp(progress);
96
+ const replaceCount = Math.floor(clampedProgress * maxLen);
97
+ const result = new Array(maxLen);
98
+ for (let i = 0; i < maxLen; ++i) {
99
+ result[i] = i < replaceCount ? toP[i] : fromP[i];
100
+ }
101
+ while (result.length && result[result.length - 1] === " ") {
102
+ result.pop();
103
+ }
104
+ return result.join("");
105
+ };
66
106
  }
67
107
  function parseColour(code) {
68
108
  if (code.length < 2 || !code.startsWith("#"))
@@ -84,68 +124,31 @@ function parseColour(code) {
84
124
  return [...rawHex.matchAll(/../g)].map(hex => parseInt(hex[0], 16));
85
125
  }
86
126
  function blendColours(from, to, bias) {
87
- const fromColour = parseColour(from);
88
- const toColour = parseColour(to);
89
- const blended = fromColour.map((val, i) => clamp(blendNumbers(val, toColour[i], bias), 0, 255));
127
+ const blended = from.map((val, i) => clamp(blendNumbers(val, to[i], bias), 0, 255));
90
128
  return ("#" + blended.map(n => Math.round(n).toString(16).padStart(2, "0")).join("")).replace(/ff$/, "");
91
129
  }
92
130
  const tweenableTokenRegex = /(#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b|[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)/g;
93
- const tokenise = (s) => {
131
+ function tokenise(s) {
94
132
  const chunks = [];
95
133
  let lastIdx = 0;
96
134
  let m;
97
135
  while ((m = tweenableTokenRegex.exec(s))) {
98
136
  const token = m[0];
99
137
  const prefix = s.slice(lastIdx, m.index); // literal before token
100
- chunks.push({ prefix, token });
138
+ const type = getTokenType(token);
139
+ chunks.push({ prefix, token, type });
101
140
  lastIdx = m.index + token.length;
102
141
  }
103
142
  // trailing literal after the last token – stored as a final chunk
104
- // with an empty token (so the consumer can easily append it)
105
143
  const tail = s.slice(lastIdx);
106
144
  if (tail.length) {
107
- chunks.push({ prefix: tail, token: "" });
145
+ chunks.push({ prefix: tail, token: "", type: tokenTypes.none });
108
146
  }
109
147
  return chunks;
110
- };
111
- function blendStrings(from, to, progress) {
112
- if (from === to || progress === 0)
113
- return from;
114
- const fromChunks = tokenise(from);
115
- const toChunks = tokenise(to);
116
- const tokenCount = fromChunks.filter(c => c.token).length;
117
- if (tokenCount !== toChunks.filter(c => c.token).length) {
118
- return mergeStrings(from, to, progress);
119
- }
120
- let result = "";
121
- for (let i = 0, j = 0; i < fromChunks.length && j < toChunks.length;) {
122
- const f = fromChunks[i];
123
- const t = toChunks[j];
124
- // The *prefix* (the text before the token) must be the same.
125
- if (f.prefix !== t.prefix) {
126
- return mergeStrings(from, to, progress);
127
- }
128
- // Append the unchanged prefix.
129
- result += f.prefix;
130
- // If we are at the *trailing* chunk (no token), just break.
131
- if (!f.token && !t.token) {
132
- break;
133
- }
134
- // Blend the token according to its kind.
135
- let blended;
136
- if (f.token.startsWith("#")) {
137
- blended = blendColours(f.token, t.token, progress);
138
- }
139
- else {
140
- const fNum = parseFloat(f.token);
141
- const tNum = parseFloat(t.token);
142
- const blendedNum = blendNumbers(fNum, tNum, progress);
143
- blended = blendedNum.toString();
144
- }
145
- result += blended;
146
- // Advance both pointers.
147
- i++;
148
- j++;
149
- }
150
- return result;
148
+ }
149
+ ;
150
+ function getTokenType(token) {
151
+ if (token.startsWith("#"))
152
+ return tokenTypes.colour;
153
+ return tokenTypes.number;
151
154
  }
@@ -1,4 +1,4 @@
1
1
  /** @internal */
2
- export declare const clamp: (value: number, min: number, max: number) => number;
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;
package/internal/utils.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** @internal */
2
- export const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
2
+ export const clamp = (value, min = 0, max = 1) => Math.min(Math.max(value, min), max);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xtia/timeline",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "repository": {
5
5
  "url": "https://github.com/tiadrop/timeline",
6
6
  "type": "github"