@vectoriox/iox-ui 5.75.0 → 5.75.3

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.
@@ -117,7 +117,7 @@ export const FRAMES_SHOWING_ATTRIBUTE = 'data-iox-frames-showing';
117
117
  * the subject showing twice, and then settling. Landing at least this fast keeps the end of the move a move.
118
118
  */
119
119
  export const FILM_LANDING_SPEED = 10;
120
- /** How fast a landing scroll is assumed to slow (per second): where it will stop is its speed over this. */
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
121
  const FILM_LANDING_DECAY = 5;
122
122
  /** How quickly the film catches up with a scroll that turned back while it was landing ahead of it (per second). */
123
123
  const FILM_CATCH_RATE = 25;
@@ -171,15 +171,17 @@ export function filmAt(wanted: number): FilmMotion {
171
171
 
172
172
  /**
173
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 past where the scroll will stop, and stands there: it
175
- * never crawls through a dissolve, never stops between two pictures, and never steps back unless the scroll does.
174
+ * pictures a second) it runs on at that speed to the whole picture nearest where the scroll will stop, and stands there:
175
+ * it never crawls through a dissolve, never stops between two pictures, and never steps back over a picture it reached
176
+ * unless the scroll turns. `aim` is where the scroll is heading, when the driver knows it (its undamped target); without it, it is guessed.
176
177
  */
177
- export function stepFilm(motion: FilmMotion, wanted: number, seconds: number, last: number): FilmMotion {
178
+ export function stepFilm(motion: FilmMotion, wanted: number, seconds: number, last: number, aim?: number): FilmMotion {
178
179
  const sameFrame = seconds < FILM_SAME_FRAME_SECONDS;
179
180
  const step = Math.min(FILM_MAX_STEP_SECONDS, seconds);
180
181
  const moved = wanted - motion.wanted;
181
182
  const speed = sameFrame ? motion.speed : motion.speed + (moved / step - motion.speed) * (1 - Math.exp(-FILM_SPEED_RATE * step));
182
- const direction: 1 | -1 = Math.abs(moved) > FILM_EPSILON ? (moved > 0 ? 1 : -1) : motion.direction;
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;
183
185
  // Moving: with the scroll, but never back over a picture a landing already reached unless the scroll turned.
184
186
  if (Math.abs(speed) >= FILM_LANDING_SPEED) {
185
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)));
@@ -187,19 +189,20 @@ export function stepFilm(motion: FilmMotion, wanted: number, seconds: number, la
187
189
  }
188
190
  if (sameFrame) return { ...motion, wanted, direction };
189
191
  const clamp = (value: number): number => Math.min(last, Math.max(0, value));
190
- const stop = wanted + speed / FILM_LANDING_DECAY;
191
- const reached = direction > 0 ? Math.ceil(motion.position - FILM_EPSILON) : Math.floor(motion.position + FILM_EPSILON);
192
- const goal = clamp(direction > 0 ? Math.max(Math.ceil(stop - FILM_EPSILON), reached) : Math.min(Math.floor(stop + FILM_EPSILON), reached));
192
+ const stop = aim ?? wanted + speed / FILM_LANDING_DECAY;
193
+ // The picture nearest the stop, unless reaching it would show a step back: then the next whole one on the way.
194
+ const nearest = Math.round(stop);
195
+ const back = (motion.position - nearest) * direction > 1 / FRAME_BLEND_STEPS;
196
+ const goal = clamp(back ? (direction > 0 ? Math.ceil(motion.position) : Math.floor(motion.position)) : nearest);
193
197
  const behind = (wanted - motion.position) * direction;
194
198
  const reach = Math.max(FILM_LANDING_SPEED * step, behind > 0 ? behind * (1 - Math.exp(-FILM_CATCH_RATE * step)) : 0);
195
199
  const position = direction > 0 ? Math.min(goal, motion.position + reach) : Math.max(goal, motion.position - reach);
196
200
  return { position, wanted, speed, direction };
197
201
  }
198
202
 
199
- /** Whether a film has landed: still, on the whole picture at or just past where the scroll stands. */
203
+ /** Whether a film has landed: still, on a whole picture beside where the scroll stands. */
200
204
  export function filmLanded(motion: FilmMotion): boolean {
201
- const past = (motion.position - motion.wanted) * motion.direction;
202
- return Math.abs(motion.speed) < 0.01 && Number.isInteger(motion.position) && past > -FILM_EPSILON && past < 1;
205
+ return Math.abs(motion.speed) < 0.01 && Number.isInteger(motion.position) && Math.abs(motion.position - motion.wanted) < 1;
203
206
  }
204
207
 
205
208
  /** The picture at `played` % of a set of `count`. */
@@ -318,6 +321,7 @@ export interface FramePlayerOptions {
318
321
  */
319
322
  export class FramePlayer {
320
323
  private played: number | null = null;
324
+ private aim: number | undefined;
321
325
  private motion: FilmMotion | null = null;
322
326
  private steppedAt = 0;
323
327
  private landingFrame: number | null = null;
@@ -355,24 +359,28 @@ export class FramePlayer {
355
359
  this.observer.observe(options.near ?? el);
356
360
  }
357
361
 
358
- /** Stand at `played` %. */
359
- seek(played: number): void {
360
- if (played === this.played) return;
362
+ /**
363
+ * Stand at `played` %, on the way to `aim` % when the caller knows where the scroll is going. `now` is the caller's
364
+ * frame time: the player keeps time by frames, so a frame asked twice moves once however long the page took.
365
+ */
366
+ seek(played: number, aim?: number, now?: number): void {
367
+ if (played === this.played && aim === this.aim) return;
361
368
  this.played = played;
362
- this.step();
369
+ this.aim = aim;
370
+ this.step(now ?? this.host.performance?.now() ?? 0);
363
371
  }
364
372
 
365
373
  /** One step toward the moment asked for, drawn now; a landing goes on by itself until it stands on a whole picture. */
366
- private step(): void {
374
+ private step(now: number): void {
367
375
  if (this.destroyed || this.played === null) return;
368
376
  const wanted = framePosition(this.played, this.info.count);
369
- const now = this.host.performance?.now() ?? Date.now();
370
- this.motion = this.motion ? stepFilm(this.motion, wanted, (now - this.steppedAt) / 1000, this.info.count - 1) : filmAt(wanted);
377
+ const aim = this.aim === undefined ? undefined : framePosition(this.aim, this.info.count);
378
+ this.motion = this.motion ? stepFilm(this.motion, wanted, (now - this.steppedAt) / 1000, this.info.count - 1, aim) : filmAt(wanted);
371
379
  this.steppedAt = now;
372
380
  this.draw();
373
- if (!filmLanded(this.motion) && this.landingFrame === null) this.landingFrame = this.host.requestAnimationFrame(() => {
381
+ if (!filmLanded(this.motion) && this.landingFrame === null) this.landingFrame = this.host.requestAnimationFrame(time => {
374
382
  this.landingFrame = null;
375
- this.step();
383
+ this.step(time);
376
384
  });
377
385
  }
378
386
 
@@ -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. */