@vectoriox/iox-ui 5.75.3 → 5.76.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectoriox/iox-ui",
3
- "version": "5.75.3",
3
+ "version": "5.76.0",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -111,23 +111,6 @@ 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
- /**
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;
130
-
131
114
  /** How finely two neighbours mix. Finer than the eye reads, and a scroll that moves less than a step draws nothing. */
132
115
  export const FRAME_BLEND_STEPS = 32;
133
116
 
@@ -152,59 +135,6 @@ export function frameBlendAt(loaded: ReadonlyArray<unknown>, position: number):
152
135
  return nearest < 0 ? null : { from: nearest, to: nearest, mix: 0 };
153
136
  }
154
137
 
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 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.
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
- // 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);
197
- const behind = (wanted - motion.position) * direction;
198
- const reach = Math.max(FILM_LANDING_SPEED * step, behind > 0 ? behind * (1 - Math.exp(-FILM_CATCH_RATE * step)) : 0);
199
- const position = direction > 0 ? Math.min(goal, motion.position + reach) : Math.max(goal, motion.position - reach);
200
- return { position, wanted, speed, direction };
201
- }
202
-
203
- /** Whether a film has landed: still, on a whole picture beside where the scroll stands. */
204
- export function filmLanded(motion: FilmMotion): boolean {
205
- return Math.abs(motion.speed) < 0.01 && Number.isInteger(motion.position) && Math.abs(motion.position - motion.wanted) < 1;
206
- }
207
-
208
138
  /** The picture at `played` % of a set of `count`. */
209
139
  export function frameIndexAt(played: number, count: number): number {
210
140
  if (count <= 1) return 0;
@@ -321,10 +251,6 @@ export interface FramePlayerOptions {
321
251
  */
322
252
  export class FramePlayer {
323
253
  private played: number | null = null;
324
- private aim: number | undefined;
325
- private motion: FilmMotion | null = null;
326
- private steppedAt = 0;
327
- private landingFrame: number | null = null;
328
254
  private readonly set: FrameSet;
329
255
  private readonly info: FrameSetInfo;
330
256
  private readonly images: Array<HTMLImageElement | null>;
@@ -360,33 +286,13 @@ export class FramePlayer {
360
286
  }
361
287
 
362
288
  /**
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.
289
+ * Stand at `played` %, exactly. The film keeps no motion of its own: the scroll's glide, and the slowing at its end,
290
+ * are the caller's, so the film slows with the page and stops when it stops. Between two pictures it blends them.
365
291
  */
366
- seek(played: number, aim?: number, now?: number): void {
367
- if (played === this.played && aim === this.aim) return;
292
+ seek(played: number): void {
293
+ if (played === this.played) return;
368
294
  this.played = played;
369
- this.aim = aim;
370
- this.step(now ?? this.host.performance?.now() ?? 0);
371
- }
372
-
373
- /** One step toward the moment asked for, drawn now; a landing goes on by itself until it stands on a whole picture. */
374
- private step(now: number): void {
375
- if (this.destroyed || this.played === null) return;
376
- const wanted = framePosition(this.played, this.info.count);
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);
379
- this.steppedAt = now;
380
295
  this.draw();
381
- if (!filmLanded(this.motion) && this.landingFrame === null) this.landingFrame = this.host.requestAnimationFrame(time => {
382
- this.landingFrame = null;
383
- this.step(time);
384
- });
385
- }
386
-
387
- private stopLanding(): void {
388
- if (this.landingFrame !== null) this.host.cancelAnimationFrame?.(this.landingFrame);
389
- this.landingFrame = null;
390
296
  }
391
297
 
392
298
  /** Whether a re-render took the canvas away with the markup it sat in. */
@@ -396,7 +302,6 @@ export class FramePlayer {
396
302
 
397
303
  destroy(): void {
398
304
  this.destroyed = true;
399
- this.stopLanding();
400
305
  this.el.removeAttribute(FRAMES_SHOWING_ATTRIBUTE);
401
306
  this.observer?.disconnect();
402
307
  this.observer = null;
@@ -457,8 +362,8 @@ export class FramePlayer {
457
362
  }
458
363
 
459
364
  private draw(): void {
460
- if (!this.motion) return;
461
- const blend = frameBlendAt(this.images, this.motion.position);
365
+ if (this.destroyed || this.played === null) return;
366
+ const blend = frameBlendAt(this.images, framePosition(this.played, this.info.count));
462
367
  const last = this.drawn;
463
368
  if (!blend || (last && last.from === blend.from && last.to === blend.to && last.mix === blend.mix)) return;
464
369
  const image = this.images[blend.from]!;
@@ -466,11 +371,19 @@ export class FramePlayer {
466
371
  const context = this.context!;
467
372
  const { width, height } = this.canvas!;
468
373
  context.clearRect(0, 0, width, height);
469
- context.drawImage(image, 0, 0, width, height);
470
374
  if (blend.mix > 0) {
375
+ // A true mix, (1 − mix)·from + mix·to, even where a see-through film has nothing on one side. Drawing `to` over
376
+ // an opaque `from` kept `from`'s outline whole until the next picture, so the subject's edge jumped a picture at
377
+ // a time instead of moving.
378
+ context.globalCompositeOperation = 'lighter';
379
+ context.globalAlpha = 1 - blend.mix;
380
+ context.drawImage(image, 0, 0, width, height);
471
381
  context.globalAlpha = blend.mix;
472
382
  context.drawImage(this.images[blend.to]!, 0, 0, width, height);
473
383
  context.globalAlpha = 1;
384
+ context.globalCompositeOperation = 'source-over';
385
+ } else {
386
+ context.drawImage(image, 0, 0, width, height);
474
387
  }
475
388
  // Over a transparent film the poster would show through: a second, stiller subject behind the moving one.
476
389
  if (!this.drawn) this.el.setAttribute(FRAMES_SHOWING_ATTRIBUTE, '');
@@ -19,7 +19,8 @@ import { LIGHT_UP_PIECE_CAP, SplitText, splitText } from './split-text';
19
19
  */
20
20
 
21
21
  export const SCENE_DAMPING = 9;
22
- const SETTLED = 0.0005;
22
+ /** Page px from the scroll at which the damping stops and stands exactly on it: a step this small never shows. */
23
+ const SETTLED_PX = 0.25;
23
24
  const MAX_FRAME_SECONDS = 0.1;
24
25
  /** The first frame after a sleep has no previous one to measure from: it counts as one frame, or a scroll would lose it. */
25
26
  const WAKING_FRAME_SECONDS = 1 / 60;
@@ -263,11 +264,12 @@ export class ScrollDriver {
263
264
  for (const { scene, local, target } of reads) {
264
265
  if (this.options.pin && scene.stage) scene.stage.style.setProperty('translate', `0 ${local}px`);
265
266
  const next = scene.fresh ? target : dampToward(scene.progress, target, seconds, this.options.damping);
266
- const settled = Math.abs(next - target) < SETTLED;
267
+ // The step onto the scroll is what is left: taken once it is under SETTLED_PX, so it never shows.
268
+ const settled = scene.fresh || Math.abs(target - scene.progress) * Math.max(1, scene.travel) < SETTLED_PX;
267
269
  scene.progress = settled ? target : next;
268
270
  scene.target = target;
269
271
  scene.fresh = false;
270
- this.write(scene, now);
272
+ this.write(scene);
271
273
  this.options.onFrame?.(scene.root, { target, progress: scene.progress });
272
274
  moving = moving || !settled;
273
275
  }
@@ -283,11 +285,11 @@ export class ScrollDriver {
283
285
  return { scene, local, target: progress };
284
286
  }
285
287
 
286
- private write(scene: Scene, now: number): void {
288
+ private write(scene: Scene): void {
287
289
  for (const layer of scene.layers) {
288
290
  const tracks = scene.phone && layer.phoneTracks.length ? layer.phoneTracks : layer.tracks;
289
291
  this.writeWords(layer, sceneWordsLit(tracks, scene.progress));
290
- this.writePlayed(layer, scene, scenePlayed(tracks, scene.progress), scenePlayed(tracks, scene.target), now);
292
+ this.writePlayed(layer, scene, scenePlayed(tracks, scene.progress));
291
293
  const frame = sceneFrame(tracks, scene.progress, { inlineSign: scene.inlineSign });
292
294
  for (const key of Object.keys(frame) as (keyof SceneFrame)[]) {
293
295
  if (layer.stale || frame[key] !== layer.last[key]) layer.el.style[key] = frame[key] ?? '';
@@ -305,7 +307,7 @@ export class ScrollDriver {
305
307
  * The picture this moment asks for. The attribute sits on the Video's own root, which is the layer on the string path
306
308
  * and inside it on the component path. A layer with no pictures, or no Play track, holds its poster.
307
309
  */
308
- private writePlayed(layer: Layer, scene: Scene, played: number | null, aim: number | null, now: number): void {
310
+ private writePlayed(layer: Layer, scene: Scene, played: number | null): void {
309
311
  const target = played === null ? null : (layer.el.hasAttribute(VideoAttribute.Frames) ? layer.el : layer.el.querySelector<HTMLElement>(`[${VideoAttribute.Frames}]`));
310
312
  const raw = target?.getAttribute(VideoAttribute.Frames) ?? '';
311
313
  const sequence = raw ? normalizeFrameSequence(raw) : null;
@@ -319,8 +321,8 @@ export class ScrollDriver {
319
321
  layer.player = new FramePlayer(target, sequence, { phone: scene.phone, near: scene.root, host: this.host });
320
322
  layer.playerKey = key;
321
323
  }
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);
324
+ // The damped moment, like every other layer: the film slows with the page and stops when it stops.
325
+ layer.player.seek(played!);
324
326
  }
325
327
 
326
328
  /** One opacity per word, and a write only for the few the scroll actually changed. */