3d-spinner 0.9.2 → 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.
Files changed (101) hide show
  1. package/README.md +66 -4
  2. package/dist/animation-label.d.ts +8 -0
  3. package/dist/animation-label.js +47 -0
  4. package/dist/animation.d.ts +2 -0
  5. package/dist/animations/charged-orb.d.ts +55 -0
  6. package/dist/animations/charged-orb.js +229 -0
  7. package/dist/animations/ghost-train.d.ts +72 -0
  8. package/dist/animations/ghost-train.js +247 -0
  9. package/dist/animations/grid-assembly.d.ts +70 -0
  10. package/dist/animations/grid-assembly.js +282 -0
  11. package/dist/animations/object-motion.d.ts +19 -7
  12. package/dist/animations/object-motion.js +38 -32
  13. package/dist/animations/particles.d.ts +110 -0
  14. package/dist/animations/particles.js +209 -0
  15. package/dist/animations/rocket-launch.d.ts +58 -0
  16. package/dist/animations/rocket-launch.js +375 -0
  17. package/dist/cjs/animations/charged-orb.cjs +1334 -0
  18. package/dist/cjs/animations/grid-assembly.cjs +1380 -0
  19. package/dist/cjs/animations/object-motion.cjs +124 -43
  20. package/dist/cjs/animations/particles.cjs +1940 -0
  21. package/dist/cjs/animations/spin.cjs +4 -3
  22. package/dist/cjs/composite-animation.cjs +58 -0
  23. package/dist/cjs/engines/little-3d-engine/little-3d-engine.cjs +143 -22
  24. package/dist/cjs/engines/little-3d-engine/loaders/obj.cjs +30 -1
  25. package/dist/cjs/engines/little-3d-engine/renderers/canvas2d-textured.cjs +345 -0
  26. package/dist/cjs/engines/little-3d-engine/renderers/webgl-textured.cjs +528 -0
  27. package/dist/cjs/engines/little-3d-engine/renderers/webgpu-textured.cjs +712 -0
  28. package/dist/cjs/prefabs/prefabs.cjs +3916 -0
  29. package/dist/composite-animation.d.ts +18 -0
  30. package/dist/composite-animation.js +39 -0
  31. package/dist/engines/little-3d-engine/little-3d-engine.d.ts +17 -12
  32. package/dist/engines/little-3d-engine/little-3d-engine.js +13 -8
  33. package/dist/engines/little-3d-engine/loaders/obj.d.ts +12 -4
  34. package/dist/engines/little-3d-engine/loaders/obj.js +44 -5
  35. package/dist/engines/little-3d-engine/renderer.d.ts +7 -1
  36. package/dist/engines/little-3d-engine/renderer.js +2 -0
  37. package/dist/engines/little-3d-engine/renderers/canvas2d-textured.d.ts +20 -0
  38. package/dist/engines/little-3d-engine/renderers/canvas2d-textured.js +135 -0
  39. package/dist/engines/little-3d-engine/renderers/textured-helpers.d.ts +9 -0
  40. package/dist/engines/little-3d-engine/renderers/textured-helpers.js +35 -0
  41. package/dist/engines/little-3d-engine/renderers/webgl-textured.d.ts +43 -0
  42. package/dist/engines/little-3d-engine/renderers/webgl-textured.js +211 -0
  43. package/dist/engines/little-3d-engine/renderers/webgpu-textured.d.ts +45 -0
  44. package/dist/engines/little-3d-engine/renderers/webgpu-textured.js +300 -0
  45. package/dist/engines/little-3d-engine/renderers/webgpu.d.ts +11 -6
  46. package/dist/engines/little-3d-engine/shapes/complex/plane.d.ts +3 -0
  47. package/dist/engines/little-3d-engine/shapes/complex/plane.js +33 -0
  48. package/dist/engines/little-3d-engine/shapes/{cube.d.ts → primitives/cube.d.ts} +1 -1
  49. package/dist/engines/little-3d-engine/shapes/{octahedron.d.ts → primitives/octahedron.d.ts} +1 -1
  50. package/dist/engines/little-3d-engine/shapes/{pyramid.d.ts → primitives/pyramid.d.ts} +1 -1
  51. package/dist/engines/little-3d-engine/shapes/primitives/quad.d.ts +9 -0
  52. package/dist/engines/little-3d-engine/shapes/primitives/quad.js +18 -0
  53. package/dist/engines/little-3d-engine/shapes/{cube-sphere.d.ts → primitives/spheres/cube-sphere.d.ts} +1 -1
  54. package/dist/engines/little-3d-engine/shapes/{icosphere.d.ts → primitives/spheres/icosphere.d.ts} +1 -1
  55. package/dist/engines/little-3d-engine/shapes/{icosphere.js → primitives/spheres/icosphere.js} +1 -1
  56. package/dist/engines/little-3d-engine/shapes/{octa-sphere.d.ts → primitives/spheres/octa-sphere.d.ts} +1 -1
  57. package/dist/engines/little-3d-engine/shapes/{octa-sphere.js → primitives/spheres/octa-sphere.js} +1 -1
  58. package/dist/engines/little-3d-engine/shapes/{uv-sphere.d.ts → primitives/spheres/uv-sphere.d.ts} +1 -1
  59. package/dist/engines/little-3d-engine/shapes/{tetrahedron.d.ts → primitives/tetrahedron.d.ts} +1 -1
  60. package/dist/engines/little-3d-engine/textures/dynamic/canvas-texture.d.ts +1 -0
  61. package/dist/engines/little-3d-engine/textures/dynamic/canvas-texture.js +8 -0
  62. package/dist/engines/little-3d-engine/textures/dynamic/shine.d.ts +2 -0
  63. package/dist/engines/little-3d-engine/textures/dynamic/shine.js +14 -0
  64. package/dist/engines/little-3d-engine/textures/dynamic/star.d.ts +14 -0
  65. package/dist/engines/little-3d-engine/textures/dynamic/star.js +31 -0
  66. package/dist/engines/little-3d-engine/textures/dynamic/streak.d.ts +5 -0
  67. package/dist/engines/little-3d-engine/textures/dynamic/streak.js +20 -0
  68. package/dist/index.d.ts +2 -1
  69. package/dist/prefabs/charged-orb.d.ts +17 -0
  70. package/dist/prefabs/charged-orb.js +37 -0
  71. package/dist/prefabs/crystal-comet.d.ts +4 -0
  72. package/dist/prefabs/crystal-comet.js +41 -0
  73. package/dist/prefabs/ghost-train.d.ts +10 -0
  74. package/dist/prefabs/ghost-train.js +37 -0
  75. package/dist/prefabs/grid-assembly.d.ts +13 -0
  76. package/dist/prefabs/grid-assembly.js +15 -0
  77. package/dist/prefabs/monochrome-streak.d.ts +4 -0
  78. package/dist/prefabs/monochrome-streak.js +25 -0
  79. package/dist/prefabs/plane-star-trail.d.ts +4 -0
  80. package/dist/prefabs/plane-star-trail.js +40 -0
  81. package/dist/prefabs/prefabs.d.ts +10 -0
  82. package/dist/prefabs/prefabs.js +9 -0
  83. package/dist/prefabs/pulsing-starfield.d.ts +4 -0
  84. package/dist/prefabs/pulsing-starfield.js +27 -0
  85. package/dist/prefabs/rocket-launch.d.ts +10 -0
  86. package/dist/prefabs/rocket-launch.js +16 -0
  87. package/dist/prefabs/spinner.d.ts +7 -0
  88. package/dist/prefabs/spinner.js +17 -0
  89. package/dist/prefabs/star-swarm.d.ts +4 -0
  90. package/dist/prefabs/star-swarm.js +27 -0
  91. package/dist/prefabs/types.d.ts +46 -0
  92. package/dist/prefabs/types.js +1 -0
  93. package/dist/umd/spinner.global.js +2407 -85
  94. package/dist/umd/spinner.global.min.js +60 -5
  95. package/package.json +43 -2
  96. /package/dist/engines/little-3d-engine/shapes/{cube.js → primitives/cube.js} +0 -0
  97. /package/dist/engines/little-3d-engine/shapes/{octahedron.js → primitives/octahedron.js} +0 -0
  98. /package/dist/engines/little-3d-engine/shapes/{pyramid.js → primitives/pyramid.js} +0 -0
  99. /package/dist/engines/little-3d-engine/shapes/{cube-sphere.js → primitives/spheres/cube-sphere.js} +0 -0
  100. /package/dist/engines/little-3d-engine/shapes/{uv-sphere.js → primitives/spheres/uv-sphere.js} +0 -0
  101. /package/dist/engines/little-3d-engine/shapes/{tetrahedron.js → primitives/tetrahedron.js} +0 -0
package/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![tests](https://img.shields.io/github/actions/workflow/status/runelaang/3d-spinner/ci.yml?label=tests&logo=github)](https://github.com/runelaang/3d-spinner/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/3d-spinner?logo=npm)](https://www.npmjs.com/package/3d-spinner)
5
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/3d-spinner)](https://bundlephobia.com/package/3d-spinner)
5
6
  [![license](https://img.shields.io/github/license/runelaang/3d-spinner)](LICENSE)
6
7
 
7
8
  A zero-dependency 3D spinner, loader, and progress indicator for the browser. It renders to a
@@ -9,6 +10,16 @@ canvas and ships primarily as ES modules split across separate import paths, so
9
10
  only the animation, motion path, and rendering backend they actually use - nothing else is pulled
10
11
  in. CommonJS and a browser-global build are also published; see [Module formats](#module-formats).
11
12
 
13
+ ## Screenshots
14
+
15
+ | `starSwarm` with custom HTML | `pulsingStarfield` | `chargedOrb` at 74% |
16
+ | --- | --- | --- |
17
+ | ![starSwarm prefab with a custom HTML label](screenshots/shot1.png) | ![pulsingStarfield prefab with a Preparing launch label](screenshots/shot2.png) | ![chargedOrb progress prefab at 74 percent](screenshots/shot3.png) |
18
+
19
+ | `ghostTrain` progress prefab | `ParticlesAnimation` with glow texture |
20
+ | --- | --- |
21
+ | ![ghostTrain progress prefab on a tilted track](screenshots/shot4.png) | ![particle loader with a Just a sec label](screenshots/shot5.png) |
22
+
12
23
  ## Install
13
24
 
14
25
  ```sh
@@ -64,7 +75,7 @@ new SpinAnimation({ shape: tetrahedron(), color: "#3b82f6" });
64
75
  ```
65
76
 
66
77
  Shapes exported from `3d-spinner/engines/little-3d-engine` include `cube`, `tetrahedron`,
67
- `octahedron`, `pyramid`, and several spheres (`uvSphere`, `icosphere`, `octaSphere`,
78
+ `octahedron`, `pyramid`, `quad`, and several spheres (`uvSphere`, `icosphere`, `octaSphere`,
68
79
  `cubeSphere`).
69
80
 
70
81
  ## How it fits together
@@ -84,8 +95,8 @@ others:
84
95
  - **Motion controller** (for `ObjectMotionAnimation`) is a small, separate object that decides
85
96
  *how* a thing moves - a circle, square, figure-8, or wander. Swap the controller to change the
86
97
  path without changing the animation.
87
- - **Transition** (for `ObjectMotionAnimation`) is the optional intro/outro effect - grow or shrink
88
- in place, or fly in and out along the path.
98
+ - **Transition** (for `ObjectMotionAnimation`) is the intro/outro effect - by default the object
99
+ flies in and out along the path; grow or shrink in place is available as an option.
89
100
  - **The 3D engine** is the renderer underneath: a small, dependency-free engine that draws shapes
90
101
  and meshes to a canvas, with swappable Canvas 2D, WebGL, and WebGPU backends.
91
102
 
@@ -97,12 +108,63 @@ Each animation is imported from its own subpath, so you only pull in the one you
97
108
  | --- | --- | --- |
98
109
  | `3d-spinner/animations/spin` | `SpinAnimation` | A spinning 3D shape, a cube by default. |
99
110
  | `3d-spinner/animations/object-motion` | `ObjectMotionAnimation` | A mesh that follows a motion path, with an intro/outro you choose. |
111
+ | `3d-spinner/animations/particles` | `ParticlesAnimation` | A stream of camera-facing billboard particles: a burst, a fountain, snow, confetti. |
112
+
113
+ ## Prefabs
114
+
115
+ Prefabs provide complete indeterminate spinner options, including layered animation. They need
116
+ no configuration and accept an optional override object.
117
+
118
+ ```js
119
+ import { createSpinner } from "3d-spinner";
120
+ import { planeStarTrail } from "3d-spinner/prefabs";
121
+
122
+ const spinner = createSpinner(document.getElementById("app"), planeStarTrail());
123
+ ```
124
+
125
+ Common overrides include `backend`, `label`, `fadeLabel`, and `periodMs`. Labels fade with the
126
+ intro and outro by default; set `fadeLabel: false` to keep one fully visible. Motion prefabs also accept
127
+ `object` and `particles` option objects. A label can be text or any `HTMLElement`.
128
+
129
+ ```js
130
+ const message = document.createElement("div");
131
+ message.innerHTML = "<strong>Preparing preview</strong>";
132
+
133
+ createSpinner(document.getElementById("app"), planeStarTrail({
134
+ label: message,
135
+ particles: { rate: 48 },
136
+ }));
137
+ ```
100
138
 
101
139
  `ObjectMotionAnimation` takes a motion controller from `3d-spinner/motion` (`circleMotion`,
102
140
  `squareMotion`, `figureEightMotion`, `wanderMotion`) and optional entrance/exit transitions from
103
141
  `3d-spinner/motion/transitions` (`grow`, `shrink`, `enterFromObjectDirection`,
104
142
  `leaveInObjectDirection`) - for example a figure-8 path with a fly-in and fly-out.
105
143
 
144
+ `ParticlesAnimation` emits fading billboard quads from the center. The emission options shape the
145
+ effect: `direction` and `spread` aim it, `gravity` bends it, and `rate`, `lifeMs`, `speed`,
146
+ `size`, `spin`, and `colors` style it. The stream is deterministic for a given `seed`. Emission
147
+ starts on enter and stops on exit; the live particles fading out is the outro.
148
+
149
+ A `texture` option (an image URL or a drawable element such as a canvas) puts an image on every
150
+ particle, tinted by the particle color, with the image's alpha shaping the particle. Textures
151
+ render through a backend-specific textured renderer fetched on demand for Canvas 2D, WebGL, or
152
+ WebGPU. Canvas 2D texture mapping is limited to planar four-vertex billboards.
153
+
154
+ ```js
155
+ import { createSpinner } from "3d-spinner";
156
+ import { ParticlesAnimation } from "3d-spinner/animations/particles";
157
+
158
+ const spinner = createSpinner(document.getElementById("app"), {
159
+ type: "indeterminate",
160
+ animation: new ParticlesAnimation({
161
+ direction: { x: 0, y: 1, z: 0 },
162
+ gravity: { x: 0, y: -1.6, z: 0 },
163
+ speed: 1.5,
164
+ }),
165
+ });
166
+ ```
167
+
106
168
  ## API
107
169
 
108
170
  ### `createSpinner(target, options)`
@@ -210,7 +272,7 @@ bundles the whole public API onto one `window.Spinner3D` object:
210
272
  npm install
211
273
  npm run build # compile src/ to dist/ (ESM + type declarations, CJS, and a browser-global build)
212
274
  npm test # build, then run the unit tests
213
- npm run dev # serve this folder; open /examples/index.html
275
+ npm run dev # serve this folder; open /examples/ or /examples/prefabs.html
214
276
  ```
215
277
 
216
278
  ## License
@@ -0,0 +1,8 @@
1
+ import type { AnimationLabel } from "./animation.js";
2
+ export interface MountedAnimationLabel {
3
+ readonly container: HTMLDivElement;
4
+ setText(value: string): void;
5
+ setOpacity(value: number): void;
6
+ }
7
+ export declare function animationLabelOpacity(now: number, enterAt: number, introDurationMs: number, exitAt: number, outroDurationMs: number): number;
8
+ export declare function mountAnimationLabel(target: HTMLElement, content: AnimationLabel | undefined): MountedAnimationLabel;
@@ -0,0 +1,47 @@
1
+ const LABEL_STYLE = [
2
+ "position:absolute",
3
+ "inset:0",
4
+ "display:flex",
5
+ "align-items:center",
6
+ "justify-content:center",
7
+ "pointer-events:none",
8
+ "font:700 1.6rem/1 system-ui,sans-serif",
9
+ "letter-spacing:0.02em",
10
+ "color:rgba(255,255,255,0.9)",
11
+ "text-shadow:0 1px 10px rgba(0,0,0,0.6)",
12
+ "z-index:1",
13
+ ].join(";");
14
+ export function animationLabelOpacity(now, enterAt, introDurationMs, exitAt, outroDurationMs) {
15
+ if (enterAt === Infinity)
16
+ return 0;
17
+ const intro = introDurationMs <= 0 ? 1 : Math.max(0, Math.min(1, (now - enterAt) / introDurationMs));
18
+ const outro = exitAt === Infinity
19
+ ? 1
20
+ : outroDurationMs <= 0
21
+ ? 0
22
+ : Math.max(0, Math.min(1, 1 - (now - exitAt) / outroDurationMs));
23
+ return Math.min(intro, outro);
24
+ }
25
+ export function mountAnimationLabel(target, content) {
26
+ var _a;
27
+ const container = document.createElement("div");
28
+ container.style.cssText = LABEL_STYLE;
29
+ container.setAttribute("role", "status");
30
+ if (typeof content === "string")
31
+ container.textContent = content;
32
+ else if (content) {
33
+ (_a = content.style).pointerEvents || (_a.pointerEvents = "auto");
34
+ container.appendChild(content);
35
+ }
36
+ target.appendChild(container);
37
+ return {
38
+ container,
39
+ setText(value) {
40
+ if (typeof content !== "object")
41
+ container.textContent = value;
42
+ },
43
+ setOpacity(value) {
44
+ container.style.opacity = String(value);
45
+ },
46
+ };
47
+ }
@@ -10,6 +10,8 @@ export interface AnimationFrame {
10
10
  /** True when the spinner is indeterminate (progress is a synthetic loop). */
11
11
  readonly indeterminate: boolean;
12
12
  }
13
+ /** Text or custom HTML displayed over an animation. */
14
+ export type AnimationLabel = string | HTMLElement;
13
15
  /**
14
16
  * A reusable visual: a cube spin, a flying plane, a progress bar. The animation
15
17
  * owns its intro/loop/outro visuals but does not decide *when* to play them -
@@ -0,0 +1,55 @@
1
+ import type { AnimationFrame, SpinnerAnimation } from "../animation.js";
2
+ import type { MotionController } from "../motion/controller.js";
3
+ import { type Backend } from "../engines/little-3d-engine/little-3d-engine.js";
4
+ export interface ChargedOrbOptions {
5
+ /** Milliseconds for one satellite revolution around the center orb. Default `6000`. */
6
+ orbitPeriodMs?: number;
7
+ /** Rendering backend. Default `"canvas2d"`. */
8
+ backend?: Backend;
9
+ }
10
+ /**
11
+ * A progress story around a charging orb: the translucent-looking center orb
12
+ * pops straight to full size, then every 10% of progress a mini orb pops out
13
+ * of it and joins an evenly spread ring of satellites (the tenth at 100%). At
14
+ * completion the satellites take one extra lap around the center, dive back
15
+ * into the big orb one after another, and the orb pops away. Satellite motion
16
+ * is time-blended per orb, so progress jumps in either direction stay smooth.
17
+ * {@link satelliteEmitter} exposes the satellites to a particle layer, one
18
+ * stream per orb.
19
+ */
20
+ export declare class ChargedOrbAnimation implements SpinnerAnimation {
21
+ private engine?;
22
+ private center?;
23
+ private readonly minis;
24
+ private readonly blends;
25
+ private readonly offsets;
26
+ private readonly orbitPeriodMs;
27
+ private readonly backend?;
28
+ private enterAt;
29
+ private exitAt;
30
+ private allOutAt;
31
+ private lastNow;
32
+ private finished;
33
+ constructor(options?: ChargedOrbOptions);
34
+ mount(target: HTMLElement): void;
35
+ enter(now: number): void;
36
+ exit(now: number): void;
37
+ isFinished(): boolean;
38
+ /** Milliseconds after {@link exit} during which the satellites are still flying. */
39
+ get outroEmitMs(): number;
40
+ /**
41
+ * A {@link MotionController} that cycles across the live satellites, one
42
+ * spawn slot per orb, so a particle layer emits one stream per satellite.
43
+ * `spawnGapMs` must match the particle layer's emission gap (`1000 / rate`).
44
+ */
45
+ satelliteEmitter(spawnGapMs: number): MotionController;
46
+ render(now: number, frame: AnimationFrame): void;
47
+ destroy(): void;
48
+ private updateBlends;
49
+ private updateSpread;
50
+ private slotAngle;
51
+ private baseAngleAt;
52
+ private reenterStart;
53
+ private miniSample;
54
+ private centerScale;
55
+ }
@@ -0,0 +1,229 @@
1
+ import { Little3dEngine, icosphere, } from "../engines/little-3d-engine/little-3d-engine.js";
2
+ import { easeInCubic, easeInOutCubic, easeInQuad, easeOutBack, easeOutCubic, easeOutQuad, } from "../engines/little-tween-engine/core/tweens.js";
3
+ const MINIS = 10;
4
+ const CAMERA_Z = 3;
5
+ const CENTER_SCALE = 0.76;
6
+ const MINI_SCALE = 0.36;
7
+ const MINI_TRANSPARENCY = { mode: "two-sided", frontOpacity: 0.68, backOpacity: 0.87 };
8
+ const ORBIT_RADIUS = 1.2;
9
+ const TILT = 0.8;
10
+ const TWO_PI = Math.PI * 2;
11
+ const CENTER_POP_MS = 500;
12
+ const LAUNCH_MS = 550;
13
+ const EXIT_HURRY = 2.5;
14
+ const SPREAD_TAU_MS = 250;
15
+ const EXTRA_PAUSE_MS = 250;
16
+ const EXTRA_SPIN_MS = 1300;
17
+ const REENTER_MS = 600;
18
+ const REENTER_STAGGER_MS = 45;
19
+ const CENTER_POP_OUT_AT = REENTER_MS + (MINIS - 1) * REENTER_STAGGER_MS + 150;
20
+ const CENTER_POP_OUT_MS = 420;
21
+ const PARKED = { x: 0, y: 0, z: 50 };
22
+ const CENTER_COLORS = ["#67e8f9", "#22d3ee", "#0ea5e9", "#38bdf8", "#7dd3fc"];
23
+ const MINI_COLORS = [
24
+ ["#e0f2fe", "#bae6fd", "#7dd3fc"],
25
+ ["#c7d2fe", "#a5b4fc", "#818cf8"],
26
+ ["#a5f3fc", "#67e8f9", "#22d3ee"],
27
+ ];
28
+ function clamp01(value) {
29
+ return Math.max(0, Math.min(1, value));
30
+ }
31
+ /**
32
+ * A progress story around a charging orb: the translucent-looking center orb
33
+ * pops straight to full size, then every 10% of progress a mini orb pops out
34
+ * of it and joins an evenly spread ring of satellites (the tenth at 100%). At
35
+ * completion the satellites take one extra lap around the center, dive back
36
+ * into the big orb one after another, and the orb pops away. Satellite motion
37
+ * is time-blended per orb, so progress jumps in either direction stay smooth.
38
+ * {@link satelliteEmitter} exposes the satellites to a particle layer, one
39
+ * stream per orb.
40
+ */
41
+ export class ChargedOrbAnimation {
42
+ constructor(options = {}) {
43
+ this.minis = [];
44
+ this.blends = new Array(MINIS).fill(0);
45
+ this.offsets = new Array(MINIS).fill(0);
46
+ this.enterAt = Infinity;
47
+ this.exitAt = Infinity;
48
+ this.allOutAt = Infinity;
49
+ this.lastNow = 0;
50
+ this.finished = false;
51
+ this.orbitPeriodMs = options.orbitPeriodMs ?? 6000;
52
+ this.backend = options.backend;
53
+ }
54
+ mount(target) {
55
+ if (!target.style.position)
56
+ target.style.position = "relative";
57
+ const engine = new Little3dEngine({
58
+ backend: this.backend,
59
+ camera: { position: { x: 0, y: 0, z: CAMERA_Z } },
60
+ });
61
+ this.center = engine.add(icosphere(1, 2, CENTER_COLORS), { scale: 0 });
62
+ for (let i = 0; i < MINIS; i++) {
63
+ const mesh = icosphere(1, 1, MINI_COLORS[i % MINI_COLORS.length]);
64
+ this.minis.push(engine.add(mesh, { scale: 0, transparency: { ...MINI_TRANSPARENCY } }));
65
+ }
66
+ this.engine = engine;
67
+ engine.mount(target).catch((error) => {
68
+ target.textContent = error instanceof Error ? error.message : String(error);
69
+ });
70
+ }
71
+ enter(now) {
72
+ if (this.enterAt === Infinity)
73
+ this.enterAt = now;
74
+ }
75
+ exit(now) {
76
+ if (this.exitAt === Infinity)
77
+ this.exitAt = now;
78
+ }
79
+ isFinished() {
80
+ return this.finished;
81
+ }
82
+ /** Milliseconds after {@link exit} during which the satellites are still flying. */
83
+ get outroEmitMs() {
84
+ return EXTRA_PAUSE_MS + EXTRA_SPIN_MS + CENTER_POP_OUT_AT;
85
+ }
86
+ /**
87
+ * A {@link MotionController} that cycles across the live satellites, one
88
+ * spawn slot per orb, so a particle layer emits one stream per satellite.
89
+ * `spawnGapMs` must match the particle layer's emission gap (`1000 / rate`).
90
+ */
91
+ satelliteEmitter(spawnGapMs) {
92
+ return {
93
+ positionAt: (t) => {
94
+ const live = [];
95
+ for (let i = 0; i < MINIS; i++) {
96
+ const sample = this.miniSample(i, t);
97
+ if (sample)
98
+ live.push(sample.position);
99
+ }
100
+ if (live.length === 0)
101
+ return PARKED;
102
+ const slot = Math.abs(Math.floor(t / spawnGapMs)) % live.length;
103
+ return live[slot];
104
+ },
105
+ };
106
+ }
107
+ render(now, frame) {
108
+ if (!this.engine || !this.center)
109
+ return;
110
+ if (this.enterAt === Infinity) {
111
+ this.center.transform.scale = 0;
112
+ for (const mini of this.minis)
113
+ mini.transform.scale = 0;
114
+ this.engine.render();
115
+ return;
116
+ }
117
+ const dt = this.lastNow === 0 ? 16 : Math.min(50, now - this.lastNow);
118
+ this.lastNow = now;
119
+ this.updateBlends(dt, frame.progress, now);
120
+ this.updateSpread(dt);
121
+ const t = now - this.enterAt;
122
+ this.center.transform.scale = this.centerScale(now, t);
123
+ this.center.transform.rotation.x = t * 0.0002;
124
+ this.center.transform.rotation.y = t * 0.0005;
125
+ for (let i = 0; i < MINIS; i++) {
126
+ const transform = this.minis[i].transform;
127
+ const sample = this.miniSample(i, now);
128
+ if (!sample) {
129
+ transform.scale = 0;
130
+ continue;
131
+ }
132
+ transform.position.x = sample.position.x;
133
+ transform.position.y = sample.position.y;
134
+ transform.position.z = sample.position.z;
135
+ transform.scale = sample.scale;
136
+ transform.rotation.y = t * 0.0012;
137
+ }
138
+ this.engine.render();
139
+ }
140
+ destroy() {
141
+ this.engine?.destroy();
142
+ this.engine = undefined;
143
+ this.center = undefined;
144
+ this.minis.length = 0;
145
+ }
146
+ updateBlends(dt, progress, now) {
147
+ const exiting = this.exitAt !== Infinity;
148
+ const want = exiting ? MINIS : Math.min(MINIS, Math.floor(progress * MINIS + 1e-9));
149
+ const rate = (dt / LAUNCH_MS) * (exiting ? EXIT_HURRY : 1);
150
+ for (let i = 0; i < MINIS; i++) {
151
+ const target = i < want ? 1 : 0;
152
+ const blend = this.blends[i];
153
+ if (target > blend && (i === 0 || this.blends[i - 1] >= 0.6)) {
154
+ this.blends[i] = Math.min(1, blend + rate);
155
+ if (blend === 0)
156
+ this.offsets[i] = this.slotAngle(i);
157
+ }
158
+ else if (target < blend && (i === MINIS - 1 || this.blends[i + 1] <= 0.4)) {
159
+ this.blends[i] = Math.max(0, blend - rate);
160
+ }
161
+ }
162
+ if (exiting && this.allOutAt === Infinity && this.blends.every((blend) => blend >= 1)) {
163
+ this.allOutAt = now;
164
+ }
165
+ }
166
+ updateSpread(dt) {
167
+ const ease = 1 - Math.exp(-dt / SPREAD_TAU_MS);
168
+ for (let i = 0; i < MINIS; i++) {
169
+ if (this.blends[i] <= 0)
170
+ continue;
171
+ this.offsets[i] += (this.slotAngle(i) - this.offsets[i]) * ease;
172
+ }
173
+ }
174
+ slotAngle(index) {
175
+ const launched = Math.max(1, this.blends.filter((blend) => blend > 0).length);
176
+ return (TWO_PI * Math.min(index, launched - 1)) / launched;
177
+ }
178
+ baseAngleAt(t) {
179
+ let angle = (-TWO_PI * (t - this.enterAt)) / this.orbitPeriodMs;
180
+ if (this.allOutAt !== Infinity) {
181
+ const u = clamp01((t - this.allOutAt - EXTRA_PAUSE_MS) / EXTRA_SPIN_MS);
182
+ angle -= TWO_PI * easeInOutCubic(u);
183
+ }
184
+ return angle;
185
+ }
186
+ reenterStart() {
187
+ return this.allOutAt + EXTRA_PAUSE_MS + EXTRA_SPIN_MS;
188
+ }
189
+ miniSample(index, t) {
190
+ const blend = this.blends[index];
191
+ if (blend <= 0)
192
+ return undefined;
193
+ let radial = easeOutCubic(blend);
194
+ let scale = MINI_SCALE * easeOutBack(blend);
195
+ if (this.allOutAt !== Infinity) {
196
+ const start = this.reenterStart() + index * REENTER_STAGGER_MS;
197
+ const pull = easeInCubic(clamp01((t - start) / REENTER_MS));
198
+ if (pull >= 1)
199
+ return undefined;
200
+ radial *= 1 - pull;
201
+ scale *= 1 - pull;
202
+ }
203
+ const angle = this.baseAngleAt(t) + this.offsets[index];
204
+ const flat = Math.sin(angle) * ORBIT_RADIUS * radial;
205
+ return {
206
+ position: {
207
+ x: Math.cos(angle) * ORBIT_RADIUS * radial,
208
+ y: flat * Math.cos(TILT),
209
+ z: flat * Math.sin(TILT),
210
+ },
211
+ scale,
212
+ };
213
+ }
214
+ centerScale(now, t) {
215
+ if (this.allOutAt !== Infinity) {
216
+ const w = clamp01((now - this.reenterStart() - CENTER_POP_OUT_AT) / CENTER_POP_OUT_MS);
217
+ if (w >= 1) {
218
+ this.finished = true;
219
+ return 0;
220
+ }
221
+ if (w > 0) {
222
+ return CENTER_SCALE * (w < 0.35
223
+ ? 1 + 0.18 * easeOutQuad(w / 0.35)
224
+ : 1.18 * (1 - easeInQuad((w - 0.35) / 0.65)));
225
+ }
226
+ }
227
+ return CENTER_SCALE * easeOutBack(clamp01(t / CENTER_POP_MS));
228
+ }
229
+ }
@@ -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
+ }