3d-spinner 0.9.3 → 0.9.4

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.
@@ -0,0 +1,72 @@
1
+ import type { AnimationFrame, AnimationLabel, SpinnerAnimation } from "../animation.js";
2
+ import { type Backend } from "../engines/little-3d-engine/little-3d-engine.js";
3
+ import type { MotionController } from "../motion/controller.js";
4
+ export interface GhostTrainOptions {
5
+ /** How the convoy moves. Default a tilted square track. */
6
+ motion?: MotionController;
7
+ /** Uniform car size in scene units. Default `0.3`. */
8
+ size?: number;
9
+ /** Rendering backend. Default `"canvas2d"`. */
10
+ backend?: Backend;
11
+ /** Overlay label; progress mode shows a percentage. */
12
+ label?: AnimationLabel;
13
+ /** Fade the label with the story's beginning and end. Default `true`. */
14
+ fadeLabel?: boolean;
15
+ }
16
+ /**
17
+ * A progress story: a translucent train of ice cubes runs laps around a tilted
18
+ * square track, cars turning smoothly through the corners as if seen from above.
19
+ * Every 2% of progress attaches one more car, popping it into existence at the
20
+ * tail. At 100% the lead car keeps going in its current direction of travel and
21
+ * every following car funnels through that same exit point along the exact same
22
+ * path, the convoy accelerating clear of the view within four seconds. Car
23
+ * count follows the
24
+ * reported progress, so scrubbing in either direction stays smooth.
25
+ * {@link trailEmitter} exposes the lead car to a particle layer for the star trail.
26
+ */
27
+ export declare class GhostTrainAnimation implements SpinnerAnimation {
28
+ private engine?;
29
+ private label?;
30
+ private observer?;
31
+ private readonly cars;
32
+ private readonly appear;
33
+ private readonly headings;
34
+ private readonly motion;
35
+ private readonly size;
36
+ private readonly backend?;
37
+ private readonly labelContent?;
38
+ private readonly fadeLabel;
39
+ private aspect;
40
+ private enterAt;
41
+ private outroAt;
42
+ private carsAtOutro;
43
+ private exitPathTime;
44
+ private exitPoint;
45
+ private exitDir;
46
+ private exitSpeed;
47
+ private lastNow;
48
+ private finished;
49
+ constructor(options?: GhostTrainOptions);
50
+ mount(target: HTMLElement): void;
51
+ enter(now: number): void;
52
+ exit(now: number): void;
53
+ isFinished(): boolean;
54
+ /** Milliseconds the lead car keeps moving into the outro; feed a trail layer's `outroMs`. */
55
+ get outroDurationMs(): number;
56
+ /**
57
+ * A {@link MotionController} following the lead car's actual position, through
58
+ * laps and the accelerating escape. Feed it to a particle layer's `emitter`
59
+ * so the star trail stays behind the train.
60
+ */
61
+ trailEmitter(): MotionController;
62
+ render(now: number, frame: AnimationFrame): void;
63
+ destroy(): void;
64
+ /** Extra path-time every car has accelerated forward by, `now` ms into the outro. */
65
+ private warp;
66
+ /**
67
+ * The single trajectory every car rides, sampled at path-time `p`: the track
68
+ * up to the exit switch point, then a straight escape outward. Because the
69
+ * switch point and direction are shared, all cars follow the exact same path.
70
+ */
71
+ private pathPosition;
72
+ }
@@ -0,0 +1,247 @@
1
+ import { animationLabelOpacity, mountAnimationLabel, } from "../animation-label.js";
2
+ import { Little3dEngine, cube, cross, dot, normalize, subtract, } from "../engines/little-3d-engine/little-3d-engine.js";
3
+ import { squareMotion } from "../motion/square.js";
4
+ import { easeOutBack } from "../engines/little-tween-engine/core/tweens.js";
5
+ const MAX_CARS = 50; // one car per 2% of progress
6
+ const CAMERA_Z = 3;
7
+ const FOV = (55 * Math.PI) / 180;
8
+ const HALF_HEIGHT = Math.tan(FOV / 2) * CAMERA_Z;
9
+ const RUN_GAP_MS = 130; // spacing between cars in path-time; 49 gaps stay under one lap
10
+ const POP_MS = 320; // a new car pops into existence over this long
11
+ const SAMPLE_MS = 8; // heading sample step
12
+ const TURN_RATE = (0.4 * Math.PI) / 180; // max heading change per millisecond (smooth cornering)
13
+ // Blast-off: the whole convoy funnels through the lead car's exit point and
14
+ // follows the exact same escape path. A time-warp accelerates every car's
15
+ // path-time after exit so all of them clear the view within MAX_OUTRO_MS.
16
+ const MAX_OUTRO_MS = 4000;
17
+ const WARP_ACCEL = 1000; // extra path-milliseconds accrued, = 0.5 * WARP_ACCEL * seconds^2
18
+ const TRAIL_OUTRO_MS = 1200; // how long the lead keeps shedding stars into the outro
19
+ const TRANSPARENCY = { mode: "two-sided", opacity: 0.275 };
20
+ const CAR_COLORS = ["#bae6fd", "#7dd3fc", "#38bdf8", "#0ea5e9", "#a5f3fc", "#e0f2fe"];
21
+ const WORLD_UP = { x: 0, y: 1, z: 0 };
22
+ function clamp01(value) {
23
+ return Math.max(0, Math.min(1, value));
24
+ }
25
+ /** Orientation (engine Euler) that points the car's local +X along `forward`, kept upright. */
26
+ function orientationFor(forward) {
27
+ const fwd = normalize(forward);
28
+ let right = cross(fwd, WORLD_UP);
29
+ if (Math.hypot(right.x, right.y, right.z) < 1e-4)
30
+ right = { x: 0, y: 0, z: 1 };
31
+ right = normalize(right);
32
+ const up = cross(right, fwd);
33
+ const w = normalize(cross(fwd, up));
34
+ return {
35
+ x: Math.atan2(cross(w, fwd).z, w.z),
36
+ y: Math.asin(Math.max(-1, Math.min(1, -fwd.z))),
37
+ z: Math.atan2(fwd.y, fwd.x),
38
+ };
39
+ }
40
+ /** Rotate unit vector `from` toward `to` by at most `maxRad`, for a rate-limited turn. */
41
+ function rotateToward(from, to, maxRad) {
42
+ const a = normalize(from);
43
+ const b = normalize(to);
44
+ const d = Math.max(-1, Math.min(1, dot(a, b)));
45
+ const angle = Math.acos(d);
46
+ if (angle <= maxRad || angle < 1e-4)
47
+ return b;
48
+ const sin = Math.sin(angle);
49
+ if (sin < 1e-4)
50
+ return b; // (near-)opposite: snap rather than spin unpredictably
51
+ const t = maxRad / angle;
52
+ const w1 = Math.sin((1 - t) * angle) / sin;
53
+ const w2 = Math.sin(t * angle) / sin;
54
+ return normalize({ x: a.x * w1 + b.x * w2, y: a.y * w1 + b.y * w2, z: a.z * w1 + b.z * w2 });
55
+ }
56
+ /**
57
+ * A progress story: a translucent train of ice cubes runs laps around a tilted
58
+ * square track, cars turning smoothly through the corners as if seen from above.
59
+ * Every 2% of progress attaches one more car, popping it into existence at the
60
+ * tail. At 100% the lead car keeps going in its current direction of travel and
61
+ * every following car funnels through that same exit point along the exact same
62
+ * path, the convoy accelerating clear of the view within four seconds. Car
63
+ * count follows the
64
+ * reported progress, so scrubbing in either direction stays smooth.
65
+ * {@link trailEmitter} exposes the lead car to a particle layer for the star trail.
66
+ */
67
+ export class GhostTrainAnimation {
68
+ constructor(options = {}) {
69
+ this.cars = [];
70
+ this.appear = new Array(MAX_CARS).fill(0);
71
+ this.headings = new Array(MAX_CARS).fill(undefined);
72
+ this.aspect = 16 / 9;
73
+ this.enterAt = Infinity;
74
+ this.outroAt = Infinity;
75
+ this.carsAtOutro = 0;
76
+ this.exitPathTime = 0; // lead car's path-time at blast-off (the escape switch point)
77
+ this.exitPoint = { x: 0, y: 0, z: 0 };
78
+ this.exitDir = { x: 1, y: 0, z: 0 }; // shared escape direction, outward from the track
79
+ this.exitSpeed = 0.001; // path-units per path-millisecond at the switch (keeps speed continuous)
80
+ this.lastNow = 0;
81
+ this.finished = false;
82
+ this.motion = options.motion ?? squareMotion({ size: 1.7, periodMs: 6800, tilt: 0.5 });
83
+ this.size = options.size ?? 0.15;
84
+ this.backend = options.backend;
85
+ this.labelContent = options.label;
86
+ this.fadeLabel = options.fadeLabel ?? true;
87
+ }
88
+ mount(target) {
89
+ if (!target.style.position)
90
+ target.style.position = "relative";
91
+ const engine = new Little3dEngine({
92
+ backend: this.backend,
93
+ camera: { position: { x: 0, y: 0, z: CAMERA_Z }, fov: FOV },
94
+ });
95
+ const mesh = cube(1, CAR_COLORS);
96
+ for (let i = 0; i < MAX_CARS; i++) {
97
+ this.cars.push(engine.add(mesh, { scale: 0, transparency: { ...TRANSPARENCY } }));
98
+ }
99
+ this.engine = engine;
100
+ engine.mount(target).catch((error) => {
101
+ target.textContent = error instanceof Error ? error.message : String(error);
102
+ });
103
+ const measure = () => {
104
+ if (target.clientWidth > 0 && target.clientHeight > 0) {
105
+ this.aspect = target.clientWidth / target.clientHeight;
106
+ }
107
+ };
108
+ measure();
109
+ this.observer = new ResizeObserver(measure);
110
+ this.observer.observe(target);
111
+ this.label = mountAnimationLabel(target, this.labelContent);
112
+ if (this.fadeLabel)
113
+ this.label.setOpacity(0);
114
+ }
115
+ enter(now) {
116
+ if (this.enterAt === Infinity)
117
+ this.enterAt = now;
118
+ }
119
+ exit(now) {
120
+ if (this.outroAt !== Infinity || this.enterAt === Infinity)
121
+ return;
122
+ this.outroAt = now;
123
+ this.carsAtOutro = this.appear.filter((a) => a > 0.5).length;
124
+ this.exitPathTime = now - this.enterAt;
125
+ const from = this.motion.positionAt(this.exitPathTime);
126
+ const velocity = subtract(this.motion.positionAt(this.exitPathTime + 1), this.motion.positionAt(this.exitPathTime - 1));
127
+ const speed = Math.hypot(velocity.x, velocity.y, velocity.z);
128
+ this.exitPoint = from;
129
+ if (speed > 1e-6)
130
+ this.exitSpeed = speed / 2;
131
+ // The lead car keeps going in its current direction of travel; every car
132
+ // funnels through the exit point and follows that same straight path out.
133
+ this.exitDir = speed > 1e-6 ? normalize(velocity) : { x: 1, y: 0, z: 0 };
134
+ }
135
+ isFinished() {
136
+ return this.finished;
137
+ }
138
+ /** Milliseconds the lead car keeps moving into the outro; feed a trail layer's `outroMs`. */
139
+ get outroDurationMs() {
140
+ return TRAIL_OUTRO_MS;
141
+ }
142
+ /**
143
+ * A {@link MotionController} following the lead car's actual position, through
144
+ * laps and the accelerating escape. Feed it to a particle layer's `emitter`
145
+ * so the star trail stays behind the train.
146
+ */
147
+ trailEmitter() {
148
+ return {
149
+ positionAt: (t) => this.enterAt === Infinity
150
+ ? this.motion.positionAt(t)
151
+ : this.pathPosition(t - this.enterAt + this.warp(t)),
152
+ };
153
+ }
154
+ render(now, frame) {
155
+ if (!this.engine || !this.label)
156
+ return;
157
+ for (const car of this.cars)
158
+ car.transform.scale = 0;
159
+ if (this.enterAt === Infinity) {
160
+ this.engine.render();
161
+ return;
162
+ }
163
+ const dt = this.lastNow === 0 ? 16 : Math.min(50, now - this.lastNow);
164
+ this.lastNow = now;
165
+ // One car per 2%; round so the fiftieth attaches ~99% and has a beat to pop
166
+ // before the 100% blast-off (mirrors the rocket-launch count).
167
+ const want = this.outroAt !== Infinity
168
+ ? this.carsAtOutro
169
+ : Math.min(MAX_CARS, Math.round(frame.progress * MAX_CARS));
170
+ const halfWidth = HALF_HEIGHT * this.aspect;
171
+ const warp = this.warp(now);
172
+ let anyOnScreen = false;
173
+ for (let k = 0; k < MAX_CARS; k++) {
174
+ const target = k < want ? 1 : 0;
175
+ this.appear[k] = clamp01(this.appear[k] + Math.sign(target - this.appear[k]) * (dt / POP_MS));
176
+ if (this.appear[k] <= 0) {
177
+ this.headings[k] = undefined;
178
+ continue;
179
+ }
180
+ const p = now - this.enterAt - k * RUN_GAP_MS + warp;
181
+ const position = this.pathPosition(p);
182
+ if (Math.abs(position.x) > halfWidth + this.size || Math.abs(position.y) > HALF_HEIGHT + this.size) {
183
+ continue; // off-screen: leave it hidden
184
+ }
185
+ const ahead = subtract(this.pathPosition(p + SAMPLE_MS), position);
186
+ const targetDir = Math.hypot(ahead.x, ahead.y, ahead.z) > 1e-5
187
+ ? ahead
188
+ : this.headings[k] ?? { x: 1, y: 0, z: 0 };
189
+ this.headings[k] = this.headings[k]
190
+ ? rotateToward(this.headings[k], targetDir, TURN_RATE * dt)
191
+ : normalize(targetDir);
192
+ const orientation = orientationFor(this.headings[k]);
193
+ const transform = this.cars[k].transform;
194
+ transform.position.x = position.x;
195
+ transform.position.y = position.y;
196
+ transform.position.z = position.z;
197
+ transform.rotation.x = orientation.x;
198
+ transform.rotation.y = orientation.y;
199
+ transform.rotation.z = orientation.z;
200
+ transform.scale = this.size * easeOutBack(this.appear[k]);
201
+ anyOnScreen = true;
202
+ }
203
+ this.label.setText(frame.indeterminate
204
+ ? (typeof this.labelContent === "string" ? this.labelContent : "")
205
+ : `${Math.round(frame.progress * 100)}%`);
206
+ if (this.fadeLabel) {
207
+ this.label.setOpacity(animationLabelOpacity(now, this.enterAt, POP_MS, this.outroAt, TRAIL_OUTRO_MS));
208
+ }
209
+ if (this.outroAt !== Infinity && now > this.outroAt + 300 &&
210
+ (!anyOnScreen || now >= this.outroAt + MAX_OUTRO_MS)) {
211
+ this.finished = true;
212
+ }
213
+ this.engine.render();
214
+ }
215
+ destroy() {
216
+ this.observer?.disconnect();
217
+ this.observer = undefined;
218
+ this.label?.container.remove();
219
+ this.label = undefined;
220
+ this.engine?.destroy();
221
+ this.engine = undefined;
222
+ this.cars.length = 0;
223
+ }
224
+ /** Extra path-time every car has accelerated forward by, `now` ms into the outro. */
225
+ warp(now) {
226
+ if (this.outroAt === Infinity)
227
+ return 0;
228
+ const seconds = (now - this.outroAt) / 1000;
229
+ return 0.5 * WARP_ACCEL * seconds * seconds;
230
+ }
231
+ /**
232
+ * The single trajectory every car rides, sampled at path-time `p`: the track
233
+ * up to the exit switch point, then a straight escape outward. Because the
234
+ * switch point and direction are shared, all cars follow the exact same path.
235
+ */
236
+ pathPosition(p) {
237
+ if (this.outroAt === Infinity || p <= this.exitPathTime) {
238
+ return this.motion.positionAt(p);
239
+ }
240
+ const distance = this.exitSpeed * (p - this.exitPathTime);
241
+ return {
242
+ x: this.exitPoint.x + this.exitDir.x * distance,
243
+ y: this.exitPoint.y + this.exitDir.y * distance,
244
+ z: this.exitPoint.z + this.exitDir.z * distance,
245
+ };
246
+ }
247
+ }
@@ -19,13 +19,14 @@ export interface GridAssemblyOptions {
19
19
  fadeLabel?: boolean;
20
20
  }
21
21
  /**
22
- * A progress story in three acts: 25 shapes fly in and circle just inside the
23
- * view edge; as progress climbs they leave the orbit one by one and dock into
24
- * a 5x5 grid at the center (docked shapes idle with a staggered spin every two
25
- * seconds); at completion the finished grid holds for a second, then every
26
- * shape dives into the center while shrinking away and vanishes with a small
27
- * pop. Docking is driven by time-based blends toward per-shape targets, so a
28
- * progress jump in either direction stays smooth.
22
+ * A progress story in three acts: 25 pastel dark-blue cubes fly in and circle
23
+ * just inside the view edge, completing the full ring before any of them move;
24
+ * once the circle is complete they leave the orbit one by one as progress climbs
25
+ * and dock into a 5x5 grid at the center (docked cubes idle with a staggered
26
+ * spin every two seconds); at completion the finished grid holds for a second,
27
+ * then the cubes dive into the center a little staggered, shrinking away and
28
+ * vanishing with a small pop. Docking is driven by time-based blends toward
29
+ * per-shape targets, so a progress jump in either direction stays smooth.
29
30
  */
30
31
  export declare class GridAssemblyAnimation implements SpinnerAnimation {
31
32
  private engine?;
@@ -36,6 +37,9 @@ export declare class GridAssemblyAnimation implements SpinnerAnimation {
36
37
  private readonly dockedAt;
37
38
  private readonly tumbleX;
38
39
  private readonly tumbleY;
40
+ private readonly collapseDelay;
41
+ private readonly popStarted;
42
+ private maxCollapseDelay;
39
43
  private readonly fades;
40
44
  private readonly slots;
41
45
  private readonly meshes;
@@ -52,7 +56,6 @@ export declare class GridAssemblyAnimation implements SpinnerAnimation {
52
56
  private collapseAt;
53
57
  private captured?;
54
58
  private lastNow;
55
- private popFading;
56
59
  private finished;
57
60
  constructor(options?: GridAssemblyOptions);
58
61
  mount(target: HTMLElement): void;
@@ -1,5 +1,5 @@
1
1
  import { animationLabelOpacity, mountAnimationLabel, } from "../animation-label.js";
2
- import { Little3dEngine, cube, tetrahedron, octahedron, pyramid, icosphere, } from "../engines/little-3d-engine/little-3d-engine.js";
2
+ import { Little3dEngine, cube, } from "../engines/little-3d-engine/little-3d-engine.js";
3
3
  import { easeInCubic, easeInOutCubic, easeOutCubic, } from "../engines/little-tween-engine/core/tweens.js";
4
4
  const GRID = 5;
5
5
  const COUNT = GRID * GRID;
@@ -8,6 +8,8 @@ const FOV = (55 * Math.PI) / 180;
8
8
  const TWO_PI = Math.PI * 2;
9
9
  const INTRO_MS = 900;
10
10
  const INTRO_STAGGER_MS = 60;
11
+ // The orbit ring is fully formed once the last-staggered shape finishes flying in.
12
+ const INTRO_DONE_MS = (COUNT - 1) * INTRO_STAGGER_MS + INTRO_MS;
11
13
  const GATE_DOCK = 0.35;
12
14
  const GATE_UNDOCK = 0.65;
13
15
  const EXIT_HURRY = 2.5;
@@ -16,15 +18,12 @@ const SPIN_MS = 380;
16
18
  const SPIN_STAGGER_MS = 80;
17
19
  const HOLD_MS = 1000;
18
20
  const COLLAPSE_MS = 700;
21
+ const COLLAPSE_SPREAD_MS = 500; // shapes leave a little staggered, not all at once
19
22
  const POP_MS = 170;
20
23
  const LABEL_FADE_MS = 600;
21
- const DEFAULT_MESHES = [
22
- () => cube(1, ["#60a5fa", "#3b82f6", "#2563eb", "#38bdf8", "#0ea5e9", "#1d4ed8"]),
23
- () => tetrahedron(1, ["#f472b6", "#ec4899", "#db2777", "#f9a8d4"]),
24
- () => octahedron(1, ["#34d399", "#10b981", "#059669", "#6ee7b7", "#a7f3d0", "#047857", "#4ade80", "#065f46"]),
25
- () => pyramid(1, ["#fbbf24", "#f59e0b", "#d97706", "#fde68a", "#fcd34d"]),
26
- () => icosphere(1, 1, ["#a78bfa", "#8b5cf6", "#7c3aed", "#c4b5fd"]),
27
- ];
24
+ // Every shape is the same pastel dark-blue cube; the six face tints give it depth.
25
+ const CUBE_COLORS = ["#8397c6", "#7186b8", "#6176a8", "#93a6cf", "#556a9c", "#7a8fc0"];
26
+ const DEFAULT_MESHES = [() => cube(1, CUBE_COLORS)];
28
27
  function clamp01(value) {
29
28
  return Math.max(0, Math.min(1, value));
30
29
  }
@@ -48,13 +47,14 @@ function hash01(index, salt) {
48
47
  return (h >>> 0) / 4294967296;
49
48
  }
50
49
  /**
51
- * A progress story in three acts: 25 shapes fly in and circle just inside the
52
- * view edge; as progress climbs they leave the orbit one by one and dock into
53
- * a 5x5 grid at the center (docked shapes idle with a staggered spin every two
54
- * seconds); at completion the finished grid holds for a second, then every
55
- * shape dives into the center while shrinking away and vanishes with a small
56
- * pop. Docking is driven by time-based blends toward per-shape targets, so a
57
- * progress jump in either direction stays smooth.
50
+ * A progress story in three acts: 25 pastel dark-blue cubes fly in and circle
51
+ * just inside the view edge, completing the full ring before any of them move;
52
+ * once the circle is complete they leave the orbit one by one as progress climbs
53
+ * and dock into a 5x5 grid at the center (docked cubes idle with a staggered
54
+ * spin every two seconds); at completion the finished grid holds for a second,
55
+ * then the cubes dive into the center a little staggered, shrinking away and
56
+ * vanishing with a small pop. Docking is driven by time-based blends toward
57
+ * per-shape targets, so a progress jump in either direction stays smooth.
58
58
  */
59
59
  export class GridAssemblyAnimation {
60
60
  constructor(options = {}) {
@@ -63,6 +63,9 @@ export class GridAssemblyAnimation {
63
63
  this.dockedAt = new Array(COUNT).fill(Infinity);
64
64
  this.tumbleX = [];
65
65
  this.tumbleY = [];
66
+ this.collapseDelay = [];
67
+ this.popStarted = new Array(COUNT).fill(false);
68
+ this.maxCollapseDelay = 0;
66
69
  this.fades = [];
67
70
  this.slots = [];
68
71
  this.aspect = 16 / 9;
@@ -71,7 +74,6 @@ export class GridAssemblyAnimation {
71
74
  this.allDockedAt = Infinity;
72
75
  this.collapseAt = Infinity;
73
76
  this.lastNow = 0;
74
- this.popFading = false;
75
77
  this.finished = false;
76
78
  const sources = options.meshes && options.meshes.length > 0 ? options.meshes : DEFAULT_MESHES;
77
79
  this.meshes = sources.map(resolveMesh);
@@ -88,7 +90,9 @@ export class GridAssemblyAnimation {
88
90
  this.slots.push({ x: (col - 2) * spacing, y: (2 - row) * spacing, z: 0 });
89
91
  this.tumbleX.push(TWO_PI * hash01(i, 2) - Math.PI);
90
92
  this.tumbleY.push(TWO_PI * hash01(i, 4) - Math.PI);
93
+ this.collapseDelay.push(hash01(i, 7) * COLLAPSE_SPREAD_MS);
91
94
  }
95
+ this.maxCollapseDelay = Math.max(...this.collapseDelay);
92
96
  }
93
97
  mount(target) {
94
98
  if (!target.style.position)
@@ -153,7 +157,7 @@ export class GridAssemblyAnimation {
153
157
  if (this.fadeLabel) {
154
158
  this.label.setOpacity(animationLabelOpacity(now, this.enterAt, LABEL_FADE_MS, this.collapseAt, COLLAPSE_MS));
155
159
  }
156
- if (this.collapseAt !== Infinity && now >= this.collapseAt + COLLAPSE_MS + POP_MS) {
160
+ if (this.collapseAt !== Infinity && now >= this.collapseAt + this.maxCollapseDelay + COLLAPSE_MS + POP_MS) {
157
161
  this.finished = true;
158
162
  }
159
163
  this.engine.render();
@@ -170,7 +174,12 @@ export class GridAssemblyAnimation {
170
174
  }
171
175
  updateBlends(dt, progress, now) {
172
176
  const exiting = this.exitAt !== Infinity;
173
- const want = exiting ? COUNT : Math.min(COUNT, Math.floor(progress * COUNT + 1e-9));
177
+ // Hold every shape on the orbit until the ring is fully formed: no shape
178
+ // starts diving to its grid cell before the circle is complete.
179
+ const ringComplete = now - this.enterAt >= INTRO_DONE_MS;
180
+ const want = !ringComplete
181
+ ? 0
182
+ : exiting ? COUNT : Math.min(COUNT, Math.floor(progress * COUNT + 1e-9));
174
183
  const rate = (dt / this.dockMs) * (exiting ? EXIT_HURRY : 1);
175
184
  for (let i = 0; i < COUNT; i++) {
176
185
  const target = i < want ? 1 : 0;
@@ -241,27 +250,32 @@ export class GridAssemblyAnimation {
241
250
  if (!this.captured) {
242
251
  this.captured = this.handles.map((handle) => ({ ...handle.transform.position }));
243
252
  }
244
- const u = clamp01((now - this.collapseAt) / COLLAPSE_MS);
245
- const pull = easeInCubic(u);
253
+ // Each shape dives and pops on its own small delay, so the grid empties a
254
+ // little staggered instead of all at once.
246
255
  for (let i = 0; i < COUNT; i++) {
247
256
  const transform = this.handles[i].transform;
248
257
  const from = this.captured[i];
258
+ const local = now - this.collapseAt - this.collapseDelay[i];
259
+ if (local <= 0) {
260
+ transform.position.x = from.x;
261
+ transform.position.y = from.y;
262
+ transform.position.z = from.z;
263
+ transform.scale = this.size;
264
+ continue;
265
+ }
266
+ const pull = easeInCubic(clamp01(local / COLLAPSE_MS));
249
267
  transform.position.x = from.x * (1 - pull);
250
268
  transform.position.y = from.y * (1 - pull);
251
269
  transform.position.z = from.z * (1 - pull);
252
270
  transform.scale = this.size * (1 - 0.99 * pull);
253
- }
254
- if (u >= 1) {
255
- if (!this.popFading) {
256
- this.popFading = true;
257
- for (let i = 0; i < COUNT; i++)
271
+ if (local >= COLLAPSE_MS) {
272
+ if (!this.popStarted[i]) {
273
+ this.popStarted[i] = true;
258
274
  this.handles[i].transparency = this.fades[i];
259
- }
260
- const v = clamp01((now - this.collapseAt - COLLAPSE_MS) / POP_MS);
261
- for (let i = 0; i < COUNT; i++) {
275
+ }
276
+ const v = clamp01((local - COLLAPSE_MS) / POP_MS);
262
277
  this.fades[i].opacity = 1 - v;
263
- this.handles[i].transform.scale =
264
- v >= 1 ? 0 : this.size * 0.01 * (1 + 1.6 * Math.sin(Math.PI * v));
278
+ transform.scale = v >= 1 ? 0 : this.size * 0.01 * (1 + 1.6 * Math.sin(Math.PI * v));
265
279
  }
266
280
  }
267
281
  }
@@ -0,0 +1,58 @@
1
+ import type { AnimationFrame, AnimationLabel, SpinnerAnimation } from "../animation.js";
2
+ import { type Backend } from "../engines/little-3d-engine/little-3d-engine.js";
3
+ export interface RocketLaunchOptions {
4
+ /** Rendering backend. Default `"canvas2d"`. */
5
+ backend?: Backend;
6
+ /** Overlay label; progress mode shows a percentage. */
7
+ label?: AnimationLabel;
8
+ /** Fade the label with the story's beginning and launch. Default `true`. */
9
+ fadeLabel?: boolean;
10
+ }
11
+ /**
12
+ * A progress story told by a launch pad. Every 5% of progress a small rocket
13
+ * slides in cartoon-style from the right and lines up left-to-right under the
14
+ * progress text, idling with a thin wisp of smoke. At 100% the whole row blasts
15
+ * off in a loose stagger on columns of fire; partway up three of them suddenly
16
+ * veer 30-50 degrees and streak away. Rocket count follows the reported
17
+ * progress, so scrubbing in either direction stays smooth.
18
+ */
19
+ export declare class RocketLaunchAnimation implements SpinnerAnimation {
20
+ private engine?;
21
+ private label?;
22
+ private observer?;
23
+ private readonly backend?;
24
+ private readonly labelContent?;
25
+ private readonly fadeLabel;
26
+ private readonly rockets;
27
+ private readonly smoke;
28
+ private readonly fire;
29
+ private readonly smokeFades;
30
+ private readonly fireFades;
31
+ private readonly blends;
32
+ private readonly groundedAt;
33
+ private readonly turnS;
34
+ private readonly turnDir;
35
+ private readonly turnRoll;
36
+ private readonly stagger;
37
+ private aspect;
38
+ private enterAt;
39
+ private exitAt;
40
+ private launchedAt;
41
+ private lastNow;
42
+ private finished;
43
+ constructor(options?: RocketLaunchOptions);
44
+ mount(target: HTMLElement): void;
45
+ enter(now: number): void;
46
+ exit(now: number): void;
47
+ isFinished(): boolean;
48
+ render(now: number, frame: AnimationFrame): void;
49
+ destroy(): void;
50
+ private updateBlends;
51
+ /** Along-track distance climbed `la` ms after this rocket's own blast-off. */
52
+ private ascentDistance;
53
+ /** Rocket center, nose direction, and roll `la` ms into its climb. */
54
+ private ascentPose;
55
+ private renderAscent;
56
+ private emitFire;
57
+ private emitSmoke;
58
+ }