3d-spinner 0.9.2 → 0.9.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.
Files changed (97) 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/grid-assembly.d.ts +67 -0
  8. package/dist/animations/grid-assembly.js +268 -0
  9. package/dist/animations/object-motion.d.ts +19 -7
  10. package/dist/animations/object-motion.js +38 -32
  11. package/dist/animations/particles.d.ts +110 -0
  12. package/dist/animations/particles.js +209 -0
  13. package/dist/cjs/animations/charged-orb.cjs +1334 -0
  14. package/dist/cjs/animations/grid-assembly.cjs +1498 -0
  15. package/dist/cjs/animations/object-motion.cjs +124 -43
  16. package/dist/cjs/animations/particles.cjs +1940 -0
  17. package/dist/cjs/animations/spin.cjs +4 -3
  18. package/dist/cjs/composite-animation.cjs +58 -0
  19. package/dist/cjs/engines/little-3d-engine/little-3d-engine.cjs +131 -22
  20. package/dist/cjs/engines/little-3d-engine/loaders/obj.cjs +30 -1
  21. package/dist/cjs/engines/little-3d-engine/renderers/canvas2d-textured.cjs +345 -0
  22. package/dist/cjs/engines/little-3d-engine/renderers/webgl-textured.cjs +528 -0
  23. package/dist/cjs/engines/little-3d-engine/renderers/webgpu-textured.cjs +712 -0
  24. package/dist/cjs/prefabs/prefabs.cjs +3450 -0
  25. package/dist/composite-animation.d.ts +18 -0
  26. package/dist/composite-animation.js +39 -0
  27. package/dist/engines/little-3d-engine/little-3d-engine.d.ts +17 -12
  28. package/dist/engines/little-3d-engine/little-3d-engine.js +13 -8
  29. package/dist/engines/little-3d-engine/loaders/obj.d.ts +12 -4
  30. package/dist/engines/little-3d-engine/loaders/obj.js +44 -5
  31. package/dist/engines/little-3d-engine/renderer.d.ts +7 -1
  32. package/dist/engines/little-3d-engine/renderer.js +2 -0
  33. package/dist/engines/little-3d-engine/renderers/canvas2d-textured.d.ts +20 -0
  34. package/dist/engines/little-3d-engine/renderers/canvas2d-textured.js +135 -0
  35. package/dist/engines/little-3d-engine/renderers/textured-helpers.d.ts +9 -0
  36. package/dist/engines/little-3d-engine/renderers/textured-helpers.js +35 -0
  37. package/dist/engines/little-3d-engine/renderers/webgl-textured.d.ts +43 -0
  38. package/dist/engines/little-3d-engine/renderers/webgl-textured.js +211 -0
  39. package/dist/engines/little-3d-engine/renderers/webgpu-textured.d.ts +45 -0
  40. package/dist/engines/little-3d-engine/renderers/webgpu-textured.js +300 -0
  41. package/dist/engines/little-3d-engine/renderers/webgpu.d.ts +11 -6
  42. package/dist/engines/little-3d-engine/shapes/complex/plane.d.ts +3 -0
  43. package/dist/engines/little-3d-engine/shapes/complex/plane.js +33 -0
  44. package/dist/engines/little-3d-engine/shapes/{cube.d.ts → primitives/cube.d.ts} +1 -1
  45. package/dist/engines/little-3d-engine/shapes/{octahedron.d.ts → primitives/octahedron.d.ts} +1 -1
  46. package/dist/engines/little-3d-engine/shapes/{pyramid.d.ts → primitives/pyramid.d.ts} +1 -1
  47. package/dist/engines/little-3d-engine/shapes/primitives/quad.d.ts +9 -0
  48. package/dist/engines/little-3d-engine/shapes/primitives/quad.js +18 -0
  49. package/dist/engines/little-3d-engine/shapes/{cube-sphere.d.ts → primitives/spheres/cube-sphere.d.ts} +1 -1
  50. package/dist/engines/little-3d-engine/shapes/{icosphere.d.ts → primitives/spheres/icosphere.d.ts} +1 -1
  51. package/dist/engines/little-3d-engine/shapes/{icosphere.js → primitives/spheres/icosphere.js} +1 -1
  52. package/dist/engines/little-3d-engine/shapes/{octa-sphere.d.ts → primitives/spheres/octa-sphere.d.ts} +1 -1
  53. package/dist/engines/little-3d-engine/shapes/{octa-sphere.js → primitives/spheres/octa-sphere.js} +1 -1
  54. package/dist/engines/little-3d-engine/shapes/{uv-sphere.d.ts → primitives/spheres/uv-sphere.d.ts} +1 -1
  55. package/dist/engines/little-3d-engine/shapes/{tetrahedron.d.ts → primitives/tetrahedron.d.ts} +1 -1
  56. package/dist/engines/little-3d-engine/textures/dynamic/canvas-texture.d.ts +1 -0
  57. package/dist/engines/little-3d-engine/textures/dynamic/canvas-texture.js +8 -0
  58. package/dist/engines/little-3d-engine/textures/dynamic/shine.d.ts +2 -0
  59. package/dist/engines/little-3d-engine/textures/dynamic/shine.js +14 -0
  60. package/dist/engines/little-3d-engine/textures/dynamic/star.d.ts +2 -0
  61. package/dist/engines/little-3d-engine/textures/dynamic/star.js +16 -0
  62. package/dist/engines/little-3d-engine/textures/dynamic/streak.d.ts +5 -0
  63. package/dist/engines/little-3d-engine/textures/dynamic/streak.js +20 -0
  64. package/dist/index.d.ts +2 -1
  65. package/dist/prefabs/charged-orb.d.ts +17 -0
  66. package/dist/prefabs/charged-orb.js +37 -0
  67. package/dist/prefabs/crystal-comet.d.ts +4 -0
  68. package/dist/prefabs/crystal-comet.js +41 -0
  69. package/dist/prefabs/ghost-train.d.ts +9 -0
  70. package/dist/prefabs/ghost-train.js +44 -0
  71. package/dist/prefabs/grid-assembly.d.ts +13 -0
  72. package/dist/prefabs/grid-assembly.js +15 -0
  73. package/dist/prefabs/monochrome-streak.d.ts +4 -0
  74. package/dist/prefabs/monochrome-streak.js +25 -0
  75. package/dist/prefabs/plane-star-trail.d.ts +4 -0
  76. package/dist/prefabs/plane-star-trail.js +40 -0
  77. package/dist/prefabs/prefabs.d.ts +10 -0
  78. package/dist/prefabs/prefabs.js +9 -0
  79. package/dist/prefabs/pulsing-starfield.d.ts +4 -0
  80. package/dist/prefabs/pulsing-starfield.js +27 -0
  81. package/dist/prefabs/rocket-launch.d.ts +8 -0
  82. package/dist/prefabs/rocket-launch.js +59 -0
  83. package/dist/prefabs/spinner.d.ts +7 -0
  84. package/dist/prefabs/spinner.js +17 -0
  85. package/dist/prefabs/star-swarm.d.ts +4 -0
  86. package/dist/prefabs/star-swarm.js +27 -0
  87. package/dist/prefabs/types.d.ts +46 -0
  88. package/dist/prefabs/types.js +1 -0
  89. package/dist/umd/spinner.global.js +3055 -1251
  90. package/dist/umd/spinner.global.min.js +60 -5
  91. package/package.json +42 -1
  92. /package/dist/engines/little-3d-engine/shapes/{cube.js → primitives/cube.js} +0 -0
  93. /package/dist/engines/little-3d-engine/shapes/{octahedron.js → primitives/octahedron.js} +0 -0
  94. /package/dist/engines/little-3d-engine/shapes/{pyramid.js → primitives/pyramid.js} +0 -0
  95. /package/dist/engines/little-3d-engine/shapes/{cube-sphere.js → primitives/spheres/cube-sphere.js} +0 -0
  96. /package/dist/engines/little-3d-engine/shapes/{uv-sphere.js → primitives/spheres/uv-sphere.js} +0 -0
  97. /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,67 @@
1
+ import type { AnimationFrame, AnimationLabel, SpinnerAnimation } from "../animation.js";
2
+ import { type Backend, type Mesh } from "../engines/little-3d-engine/little-3d-engine.js";
3
+ export interface GridAssemblyOptions {
4
+ /** Meshes (or factories) cycled across the 25 shapes. Defaults to a mixed primitive set. */
5
+ meshes?: Array<Mesh | (() => Mesh)>;
6
+ /** Uniform shape size in scene units. Default `0.34`. */
7
+ size?: number;
8
+ /** Gap between neighboring grid cells in scene units. Default `0.12`. */
9
+ gap?: number;
10
+ /** Milliseconds for one full orbit revolution. Default `9000`. */
11
+ orbitPeriodMs?: number;
12
+ /** Milliseconds one shape takes to travel between the orbit and its grid cell. Default `800`. */
13
+ dockMs?: number;
14
+ /** Rendering backend. Default `"canvas2d"`. */
15
+ backend?: Backend;
16
+ /** Overlay label shown in indeterminate mode (progress mode shows a percentage). */
17
+ label?: AnimationLabel;
18
+ /** Fade the label with the story's beginning and end. Default `true`. */
19
+ fadeLabel?: boolean;
20
+ }
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.
29
+ */
30
+ export declare class GridAssemblyAnimation implements SpinnerAnimation {
31
+ private engine?;
32
+ private label?;
33
+ private observer?;
34
+ private readonly handles;
35
+ private readonly blends;
36
+ private readonly dockedAt;
37
+ private readonly tumbleX;
38
+ private readonly tumbleY;
39
+ private readonly fades;
40
+ private readonly slots;
41
+ private readonly meshes;
42
+ private readonly size;
43
+ private readonly orbitPeriodMs;
44
+ private readonly dockMs;
45
+ private readonly backend?;
46
+ private readonly labelContent?;
47
+ private readonly fadeLabel;
48
+ private aspect;
49
+ private enterAt;
50
+ private exitAt;
51
+ private allDockedAt;
52
+ private collapseAt;
53
+ private captured?;
54
+ private lastNow;
55
+ private popFading;
56
+ private finished;
57
+ constructor(options?: GridAssemblyOptions);
58
+ mount(target: HTMLElement): void;
59
+ enter(now: number): void;
60
+ exit(now: number): void;
61
+ isFinished(): boolean;
62
+ render(now: number, frame: AnimationFrame): void;
63
+ destroy(): void;
64
+ private updateBlends;
65
+ private renderStory;
66
+ private renderCollapse;
67
+ }