@vectoriox/iox-ui 5.74.7 → 5.75.2

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.
@@ -61,6 +61,12 @@ declare function frameSetFor(sequence: FrameSequence, phone: boolean): {
61
61
  declare function framePosition(played: number, count: number): number;
62
62
  /** On a Video whose pictures are drawing: the poster has done its job and steps aside. */
63
63
  declare const FRAMES_SHOWING_ATTRIBUTE = "data-iox-frames-showing";
64
+ /**
65
+ * Below this many pictures a second the film stops following the scroll exactly and lands on its own. A landing scroll
66
+ * slows exponentially, so the last picture or two would take most of a second as one slow dissolve between two places:
67
+ * the subject showing twice, and then settling. Landing at least this fast keeps the end of the move a move.
68
+ */
69
+ declare const FILM_LANDING_SPEED = 10;
64
70
  /** How finely two neighbours mix. Finer than the eye reads, and a scroll that moves less than a step draws nothing. */
65
71
  declare const FRAME_BLEND_STEPS = 32;
66
72
  /** What to draw: picture `from`, with `to` over it at `mix` when the moment falls between two that have arrived. */
@@ -75,6 +81,28 @@ interface FrameBlend {
75
81
  * A neighbour still loading never blends: the nearest arrived picture stands in, and null means none has.
76
82
  */
77
83
  declare function frameBlendAt(loaded: ReadonlyArray<unknown>, position: number): FrameBlend | null;
84
+ /** Where the film stands and how the scroll under it moves: what one landing step reads and writes. */
85
+ interface FilmMotion {
86
+ /** The moment drawn, in pictures (2.5 = half way from the third to the fourth). */
87
+ position: number;
88
+ /** The moment the scroll asks for. */
89
+ wanted: number;
90
+ /** The scroll's speed, in pictures a second, smoothed. */
91
+ speed: number;
92
+ /** The way the scroll last moved: 1 onward, -1 back. */
93
+ direction: 1 | -1;
94
+ }
95
+ /** Where a film stands before its first step: on the moment asked for, still. */
96
+ declare function filmAt(wanted: number): FilmMotion;
97
+ /**
98
+ * One step of the film. Moving fast, it stands exactly where the scroll asks. Landing (slower than FILM_LANDING_SPEED
99
+ * pictures a second) it runs on at that speed to the whole picture at or past where the scroll will stop, and stands
100
+ * there: it never crawls through a dissolve, never stops between two pictures, and never steps back unless the scroll
101
+ * does. `aim` is where the scroll is heading, when the driver knows it (its undamped target); without it, it is guessed.
102
+ */
103
+ declare function stepFilm(motion: FilmMotion, wanted: number, seconds: number, last: number, aim?: number): FilmMotion;
104
+ /** Whether a film has landed: still, on the whole picture at or just past where the scroll stands. */
105
+ declare function filmLanded(motion: FilmMotion): boolean;
78
106
  /** The picture at `played` % of a set of `count`. */
79
107
  declare function frameIndexAt(played: number, count: number): number;
80
108
  /**
@@ -130,8 +158,10 @@ declare class FramePlayer {
130
158
  private readonly el;
131
159
  private readonly sequence;
132
160
  private played;
133
- private restTimer;
134
- private restFrame;
161
+ private aim;
162
+ private motion;
163
+ private steppedAt;
164
+ private landingFrame;
135
165
  private readonly set;
136
166
  private readonly info;
137
167
  private readonly images;
@@ -140,7 +170,6 @@ declare class FramePlayer {
140
170
  private observer;
141
171
  private canvas;
142
172
  private context;
143
- private position;
144
173
  private drawn;
145
174
  private nextToLoad;
146
175
  private loading;
@@ -148,11 +177,14 @@ declare class FramePlayer {
148
177
  private destroyed;
149
178
  private readonly pending;
150
179
  constructor(el: HTMLElement, sequence: FrameSequence, options?: FramePlayerOptions);
151
- /** Stand at `played` %. */
152
- seek(played: number): void;
153
- /** A stopped scroll must not stand on two pictures at once: a moving subject would show twice. It settles on the nearer. */
154
- private rest;
155
- private stopResting;
180
+ /**
181
+ * Stand at `played` %, on the way to `aim` % when the caller knows where the scroll is going. `now` is the caller's
182
+ * frame time: the player keeps time by frames, so a frame asked twice moves once however long the page took.
183
+ */
184
+ seek(played: number, aim?: number, now?: number): void;
185
+ /** One step toward the moment asked for, drawn now; a landing goes on by itself until it stands on a whole picture. */
186
+ private step;
187
+ private stopLanding;
156
188
  /** Whether a re-render took the canvas away with the markup it sat in. */
157
189
  get detached(): boolean;
158
190
  destroy(): void;
@@ -445,5 +477,5 @@ declare function attachVideos(container: ParentNode, options?: VideoRuntimeOptio
445
477
  /** The runtime already running at `el`, if any. */
446
478
  declare function videoRuntimeOf(el: Element | null | undefined): VideoRuntime | null;
447
479
 
448
- export { CONSENT_GRANT_EVENT, CONSENT_PREFERENCES_EVENT, FRAMES_PHONE_MAX_WIDTH, FRAMES_SHOWING_ATTRIBUTE, FRAME_BLEND_STEPS, FRAME_SEQUENCE_ID, FrameExtension, FramePlayer, FrameSet, FramesClass, MAX_FRAMES, MAX_FRAME_BYTES, PHONE_LONG_SIDE, VIDEO_EMBED_ORIGINS, VIDEO_NODE_TYPE, VIDEO_PROVIDER_COMPANY, VIDEO_PROVIDER_LABEL, VIDEO_WORDS, VideoAttribute, VideoClass, VideoIcon, VideoKind, VideoMode, VideoRuntime, VideoSource, VideoTrait, WIDE_LONG_SIDE, attachVideoAt, attachVideos, documentConsentTransport, fittedFrameSize, frameBlendAt, frameFileName, frameIndexAt, frameLoadOrder, framePosition, frameSetFor, frameSummary, frameUrl, isThirdPartyPoster, nearestLoadedFrame, normalizeFrameSequence, parseVideoUrl, phoneFrameCount, phoneFrameSrcset, planVideo, readConsentRecord, sampledFrameIndexes, serializeVideoAttributes, sortFrameFiles, videoConsentCategory, videoEmbedUrl, videoFrameCount, videoInnerHtml, videoPlayLabel, videoPosterFileName, videoProp, videoRefOf, videoRootAttributes, videoRuntimeOf, videoSourceOf, videoThumbnailRequest, videoWords };
449
- export type { ConsentGrantDetail, ConsentTransport, FrameBlend, FramePlayerOptions, FrameSequence, FrameSetInfo, FrameSizes, VideoMarkupOptions, VideoNodeLike, VideoPlan, VideoRef, VideoRuntimeOptions, VideoThumbnailRequest, VideoWords };
480
+ export { CONSENT_GRANT_EVENT, CONSENT_PREFERENCES_EVENT, FILM_LANDING_SPEED, FRAMES_PHONE_MAX_WIDTH, FRAMES_SHOWING_ATTRIBUTE, FRAME_BLEND_STEPS, FRAME_SEQUENCE_ID, FrameExtension, FramePlayer, FrameSet, FramesClass, MAX_FRAMES, MAX_FRAME_BYTES, PHONE_LONG_SIDE, VIDEO_EMBED_ORIGINS, VIDEO_NODE_TYPE, VIDEO_PROVIDER_COMPANY, VIDEO_PROVIDER_LABEL, VIDEO_WORDS, VideoAttribute, VideoClass, VideoIcon, VideoKind, VideoMode, VideoRuntime, VideoSource, VideoTrait, WIDE_LONG_SIDE, attachVideoAt, attachVideos, documentConsentTransport, filmAt, filmLanded, fittedFrameSize, frameBlendAt, frameFileName, frameIndexAt, frameLoadOrder, framePosition, frameSetFor, frameSummary, frameUrl, isThirdPartyPoster, nearestLoadedFrame, normalizeFrameSequence, parseVideoUrl, phoneFrameCount, phoneFrameSrcset, planVideo, readConsentRecord, sampledFrameIndexes, serializeVideoAttributes, sortFrameFiles, stepFilm, videoConsentCategory, videoEmbedUrl, videoFrameCount, videoInnerHtml, videoPlayLabel, videoPosterFileName, videoProp, videoRefOf, videoRootAttributes, videoRuntimeOf, videoSourceOf, videoThumbnailRequest, videoWords };
481
+ export type { ConsentGrantDetail, ConsentTransport, FilmMotion, FrameBlend, FramePlayerOptions, FrameSequence, FrameSetInfo, FrameSizes, VideoMarkupOptions, VideoNodeLike, VideoPlan, VideoRef, VideoRuntimeOptions, VideoThumbnailRequest, VideoWords };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectoriox/iox-ui",
3
- "version": "5.74.7",
3
+ "version": "5.75.2",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -111,9 +111,22 @@ export function framePosition(played: number, count: number): number {
111
111
  /** On a Video whose pictures are drawing: the poster has done its job and steps aside. */
112
112
  export const FRAMES_SHOWING_ATTRIBUTE = 'data-iox-frames-showing';
113
113
 
114
- /** How long a stopped scroll waits before settling on a whole picture, and how fast it gets there per frame. */
115
- const REST_AFTER_MS = 140;
116
- const REST_RATE = 0.25;
114
+ /**
115
+ * Below this many pictures a second the film stops following the scroll exactly and lands on its own. A landing scroll
116
+ * slows exponentially, so the last picture or two would take most of a second as one slow dissolve between two places:
117
+ * the subject showing twice, and then settling. Landing at least this fast keeps the end of the move a move.
118
+ */
119
+ export const FILM_LANDING_SPEED = 10;
120
+ /** With no word from the driver on where the scroll is going, how fast a landing scroll is assumed to slow (per second). */
121
+ const FILM_LANDING_DECAY = 5;
122
+ /** How quickly the film catches up with a scroll that turned back while it was landing ahead of it (per second). */
123
+ const FILM_CATCH_RATE = 25;
124
+ /** How quickly the film's reading of the scroll's speed follows it (per second). */
125
+ const FILM_SPEED_RATE = 30;
126
+ /** A step this short is the same frame asked twice: it moves nothing and reads no speed. */
127
+ const FILM_SAME_FRAME_SECONDS = 0.002;
128
+ const FILM_MAX_STEP_SECONDS = 0.1;
129
+ const FILM_EPSILON = 1e-6;
117
130
 
118
131
  /** How finely two neighbours mix. Finer than the eye reads, and a scroll that moves less than a step draws nothing. */
119
132
  export const FRAME_BLEND_STEPS = 32;
@@ -139,6 +152,58 @@ export function frameBlendAt(loaded: ReadonlyArray<unknown>, position: number):
139
152
  return nearest < 0 ? null : { from: nearest, to: nearest, mix: 0 };
140
153
  }
141
154
 
155
+ /** Where the film stands and how the scroll under it moves: what one landing step reads and writes. */
156
+ export interface FilmMotion {
157
+ /** The moment drawn, in pictures (2.5 = half way from the third to the fourth). */
158
+ position: number;
159
+ /** The moment the scroll asks for. */
160
+ wanted: number;
161
+ /** The scroll's speed, in pictures a second, smoothed. */
162
+ speed: number;
163
+ /** The way the scroll last moved: 1 onward, -1 back. */
164
+ direction: 1 | -1;
165
+ }
166
+
167
+ /** Where a film stands before its first step: on the moment asked for, still. */
168
+ export function filmAt(wanted: number): FilmMotion {
169
+ return { position: wanted, wanted, speed: 0, direction: 1 };
170
+ }
171
+
172
+ /**
173
+ * One step of the film. Moving fast, it stands exactly where the scroll asks. Landing (slower than FILM_LANDING_SPEED
174
+ * pictures a second) it runs on at that speed to the whole picture at or past where the scroll will stop, and stands
175
+ * there: it never crawls through a dissolve, never stops between two pictures, and never steps back unless the scroll
176
+ * does. `aim` is where the scroll is heading, when the driver knows it (its undamped target); without it, it is guessed.
177
+ */
178
+ export function stepFilm(motion: FilmMotion, wanted: number, seconds: number, last: number, aim?: number): FilmMotion {
179
+ const sameFrame = seconds < FILM_SAME_FRAME_SECONDS;
180
+ const step = Math.min(FILM_MAX_STEP_SECONDS, seconds);
181
+ const moved = wanted - motion.wanted;
182
+ const speed = sameFrame ? motion.speed : motion.speed + (moved / step - motion.speed) * (1 - Math.exp(-FILM_SPEED_RATE * step));
183
+ const heading = aim === undefined ? moved : aim - wanted;
184
+ const direction: 1 | -1 = Math.abs(heading) > FILM_EPSILON ? (heading > 0 ? 1 : -1) : Math.abs(moved) > FILM_EPSILON ? (moved > 0 ? 1 : -1) : motion.direction;
185
+ // Moving: with the scroll, but never back over a picture a landing already reached unless the scroll turned.
186
+ if (Math.abs(speed) >= FILM_LANDING_SPEED) {
187
+ const position = direction > 0 ? Math.max(wanted, Math.min(motion.position, Math.ceil(wanted))) : Math.min(wanted, Math.max(motion.position, Math.floor(wanted)));
188
+ return { position, wanted, speed, direction };
189
+ }
190
+ if (sameFrame) return { ...motion, wanted, direction };
191
+ const clamp = (value: number): number => Math.min(last, Math.max(0, value));
192
+ const stop = aim ?? wanted + speed / FILM_LANDING_DECAY;
193
+ const reached = direction > 0 ? Math.ceil(motion.position - FILM_EPSILON) : Math.floor(motion.position + FILM_EPSILON);
194
+ const goal = clamp(direction > 0 ? Math.max(Math.ceil(stop - FILM_EPSILON), reached) : Math.min(Math.floor(stop + FILM_EPSILON), reached));
195
+ const behind = (wanted - motion.position) * direction;
196
+ const reach = Math.max(FILM_LANDING_SPEED * step, behind > 0 ? behind * (1 - Math.exp(-FILM_CATCH_RATE * step)) : 0);
197
+ const position = direction > 0 ? Math.min(goal, motion.position + reach) : Math.max(goal, motion.position - reach);
198
+ return { position, wanted, speed, direction };
199
+ }
200
+
201
+ /** Whether a film has landed: still, on the whole picture at or just past where the scroll stands. */
202
+ export function filmLanded(motion: FilmMotion): boolean {
203
+ const past = (motion.position - motion.wanted) * motion.direction;
204
+ return Math.abs(motion.speed) < 0.01 && Number.isInteger(motion.position) && past > -FILM_EPSILON && past < 1;
205
+ }
206
+
142
207
  /** The picture at `played` % of a set of `count`. */
143
208
  export function frameIndexAt(played: number, count: number): number {
144
209
  if (count <= 1) return 0;
@@ -255,8 +320,10 @@ export interface FramePlayerOptions {
255
320
  */
256
321
  export class FramePlayer {
257
322
  private played: number | null = null;
258
- private restTimer: number | null = null;
259
- private restFrame: number | null = null;
323
+ private aim: number | undefined;
324
+ private motion: FilmMotion | null = null;
325
+ private steppedAt = 0;
326
+ private landingFrame: number | null = null;
260
327
  private readonly set: FrameSet;
261
328
  private readonly info: FrameSetInfo;
262
329
  private readonly images: Array<HTMLImageElement | null>;
@@ -265,7 +332,6 @@ export class FramePlayer {
265
332
  private observer: IntersectionObserver | null = null;
266
333
  private canvas: HTMLCanvasElement | null = null;
267
334
  private context: CanvasRenderingContext2D | null = null;
268
- private position = 0;
269
335
  private drawn: FrameBlend | null = null;
270
336
  private nextToLoad = 0;
271
337
  private loading = 0;
@@ -292,36 +358,34 @@ export class FramePlayer {
292
358
  this.observer.observe(options.near ?? el);
293
359
  }
294
360
 
295
- /** Stand at `played` %. */
296
- seek(played: number): void {
297
- if (played === this.played) return;
361
+ /**
362
+ * Stand at `played` %, on the way to `aim` % when the caller knows where the scroll is going. `now` is the caller's
363
+ * frame time: the player keeps time by frames, so a frame asked twice moves once however long the page took.
364
+ */
365
+ seek(played: number, aim?: number, now?: number): void {
366
+ if (played === this.played && aim === this.aim) return;
298
367
  this.played = played;
299
- this.stopResting();
300
- this.position = framePosition(played, this.info.count);
301
- this.draw();
302
- this.restTimer = this.host.setTimeout?.(() => this.rest(), REST_AFTER_MS) ?? null;
368
+ this.aim = aim;
369
+ this.step(now ?? this.host.performance?.now() ?? 0);
303
370
  }
304
371
 
305
- /** A stopped scroll must not stand on two pictures at once: a moving subject would show twice. It settles on the nearer. */
306
- private rest(): void {
307
- this.restTimer = null;
308
- const target = Math.round(this.position);
309
- const step = (): void => {
310
- this.restFrame = null;
311
- if (this.destroyed) return;
312
- const left = target - this.position;
313
- this.position = Math.abs(left) < 1 / FRAME_BLEND_STEPS ? target : this.position + left * REST_RATE;
314
- this.draw();
315
- if (this.position !== target) this.restFrame = this.host.requestAnimationFrame(step);
316
- };
317
- step();
372
+ /** One step toward the moment asked for, drawn now; a landing goes on by itself until it stands on a whole picture. */
373
+ private step(now: number): void {
374
+ if (this.destroyed || this.played === null) return;
375
+ const wanted = framePosition(this.played, this.info.count);
376
+ const aim = this.aim === undefined ? undefined : framePosition(this.aim, this.info.count);
377
+ this.motion = this.motion ? stepFilm(this.motion, wanted, (now - this.steppedAt) / 1000, this.info.count - 1, aim) : filmAt(wanted);
378
+ this.steppedAt = now;
379
+ this.draw();
380
+ if (!filmLanded(this.motion) && this.landingFrame === null) this.landingFrame = this.host.requestAnimationFrame(time => {
381
+ this.landingFrame = null;
382
+ this.step(time);
383
+ });
318
384
  }
319
385
 
320
- private stopResting(): void {
321
- if (this.restTimer !== null) this.host.clearTimeout?.(this.restTimer);
322
- if (this.restFrame !== null) this.host.cancelAnimationFrame?.(this.restFrame);
323
- this.restTimer = null;
324
- this.restFrame = null;
386
+ private stopLanding(): void {
387
+ if (this.landingFrame !== null) this.host.cancelAnimationFrame?.(this.landingFrame);
388
+ this.landingFrame = null;
325
389
  }
326
390
 
327
391
  /** Whether a re-render took the canvas away with the markup it sat in. */
@@ -331,7 +395,7 @@ export class FramePlayer {
331
395
 
332
396
  destroy(): void {
333
397
  this.destroyed = true;
334
- this.stopResting();
398
+ this.stopLanding();
335
399
  this.el.removeAttribute(FRAMES_SHOWING_ATTRIBUTE);
336
400
  this.observer?.disconnect();
337
401
  this.observer = null;
@@ -392,7 +456,8 @@ export class FramePlayer {
392
456
  }
393
457
 
394
458
  private draw(): void {
395
- const blend = frameBlendAt(this.images, this.position);
459
+ if (!this.motion) return;
460
+ const blend = frameBlendAt(this.images, this.motion.position);
396
461
  const last = this.drawn;
397
462
  if (!blend || (last && last.from === blend.from && last.to === blend.to && last.mix === blend.mix)) return;
398
463
  const image = this.images[blend.from]!;
@@ -267,7 +267,7 @@ export class ScrollDriver {
267
267
  scene.progress = settled ? target : next;
268
268
  scene.target = target;
269
269
  scene.fresh = false;
270
- this.write(scene);
270
+ this.write(scene, now);
271
271
  this.options.onFrame?.(scene.root, { target, progress: scene.progress });
272
272
  moving = moving || !settled;
273
273
  }
@@ -283,11 +283,11 @@ export class ScrollDriver {
283
283
  return { scene, local, target: progress };
284
284
  }
285
285
 
286
- private write(scene: Scene): void {
286
+ private write(scene: Scene, now: number): void {
287
287
  for (const layer of scene.layers) {
288
288
  const tracks = scene.phone && layer.phoneTracks.length ? layer.phoneTracks : layer.tracks;
289
289
  this.writeWords(layer, sceneWordsLit(tracks, scene.progress));
290
- this.writePlayed(layer, scene, scenePlayed(tracks, scene.progress));
290
+ this.writePlayed(layer, scene, scenePlayed(tracks, scene.progress), scenePlayed(tracks, scene.target), now);
291
291
  const frame = sceneFrame(tracks, scene.progress, { inlineSign: scene.inlineSign });
292
292
  for (const key of Object.keys(frame) as (keyof SceneFrame)[]) {
293
293
  if (layer.stale || frame[key] !== layer.last[key]) layer.el.style[key] = frame[key] ?? '';
@@ -305,7 +305,7 @@ export class ScrollDriver {
305
305
  * The picture this moment asks for. The attribute sits on the Video's own root, which is the layer on the string path
306
306
  * and inside it on the component path. A layer with no pictures, or no Play track, holds its poster.
307
307
  */
308
- private writePlayed(layer: Layer, scene: Scene, played: number | null): void {
308
+ private writePlayed(layer: Layer, scene: Scene, played: number | null, aim: number | null, now: number): void {
309
309
  const target = played === null ? null : (layer.el.hasAttribute(VideoAttribute.Frames) ? layer.el : layer.el.querySelector<HTMLElement>(`[${VideoAttribute.Frames}]`));
310
310
  const raw = target?.getAttribute(VideoAttribute.Frames) ?? '';
311
311
  const sequence = raw ? normalizeFrameSequence(raw) : null;
@@ -319,7 +319,8 @@ export class ScrollDriver {
319
319
  layer.player = new FramePlayer(target, sequence, { phone: scene.phone, near: scene.root, host: this.host });
320
320
  layer.playerKey = key;
321
321
  }
322
- layer.player.seek(played!);
322
+ // Where the damping is heading, so a landing film stops on the picture the scroll stops on, not one past it.
323
+ layer.player.seek(played!, aim ?? undefined, now);
323
324
  }
324
325
 
325
326
  /** One opacity per word, and a write only for the few the scroll actually changed. */