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.
- package/README.md +66 -4
- package/dist/animation-label.d.ts +8 -0
- package/dist/animation-label.js +47 -0
- package/dist/animation.d.ts +2 -0
- package/dist/animations/charged-orb.d.ts +55 -0
- package/dist/animations/charged-orb.js +229 -0
- package/dist/animations/ghost-train.d.ts +72 -0
- package/dist/animations/ghost-train.js +247 -0
- package/dist/animations/grid-assembly.d.ts +70 -0
- package/dist/animations/grid-assembly.js +282 -0
- package/dist/animations/object-motion.d.ts +19 -7
- package/dist/animations/object-motion.js +38 -32
- package/dist/animations/particles.d.ts +110 -0
- package/dist/animations/particles.js +209 -0
- package/dist/animations/rocket-launch.d.ts +58 -0
- package/dist/animations/rocket-launch.js +375 -0
- package/dist/cjs/animations/charged-orb.cjs +1334 -0
- package/dist/cjs/animations/grid-assembly.cjs +1380 -0
- package/dist/cjs/animations/object-motion.cjs +124 -43
- package/dist/cjs/animations/particles.cjs +1940 -0
- package/dist/cjs/animations/spin.cjs +4 -3
- package/dist/cjs/composite-animation.cjs +58 -0
- package/dist/cjs/engines/little-3d-engine/little-3d-engine.cjs +143 -22
- package/dist/cjs/engines/little-3d-engine/loaders/obj.cjs +30 -1
- package/dist/cjs/engines/little-3d-engine/renderers/canvas2d-textured.cjs +345 -0
- package/dist/cjs/engines/little-3d-engine/renderers/webgl-textured.cjs +528 -0
- package/dist/cjs/engines/little-3d-engine/renderers/webgpu-textured.cjs +712 -0
- package/dist/cjs/prefabs/prefabs.cjs +3916 -0
- package/dist/composite-animation.d.ts +18 -0
- package/dist/composite-animation.js +39 -0
- package/dist/engines/little-3d-engine/little-3d-engine.d.ts +17 -12
- package/dist/engines/little-3d-engine/little-3d-engine.js +13 -8
- package/dist/engines/little-3d-engine/loaders/obj.d.ts +12 -4
- package/dist/engines/little-3d-engine/loaders/obj.js +44 -5
- package/dist/engines/little-3d-engine/renderer.d.ts +7 -1
- package/dist/engines/little-3d-engine/renderer.js +2 -0
- package/dist/engines/little-3d-engine/renderers/canvas2d-textured.d.ts +20 -0
- package/dist/engines/little-3d-engine/renderers/canvas2d-textured.js +135 -0
- package/dist/engines/little-3d-engine/renderers/textured-helpers.d.ts +9 -0
- package/dist/engines/little-3d-engine/renderers/textured-helpers.js +35 -0
- package/dist/engines/little-3d-engine/renderers/webgl-textured.d.ts +43 -0
- package/dist/engines/little-3d-engine/renderers/webgl-textured.js +211 -0
- package/dist/engines/little-3d-engine/renderers/webgpu-textured.d.ts +45 -0
- package/dist/engines/little-3d-engine/renderers/webgpu-textured.js +300 -0
- package/dist/engines/little-3d-engine/renderers/webgpu.d.ts +11 -6
- package/dist/engines/little-3d-engine/shapes/complex/plane.d.ts +3 -0
- package/dist/engines/little-3d-engine/shapes/complex/plane.js +33 -0
- package/dist/engines/little-3d-engine/shapes/{cube.d.ts → primitives/cube.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/{octahedron.d.ts → primitives/octahedron.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/{pyramid.d.ts → primitives/pyramid.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/primitives/quad.d.ts +9 -0
- package/dist/engines/little-3d-engine/shapes/primitives/quad.js +18 -0
- package/dist/engines/little-3d-engine/shapes/{cube-sphere.d.ts → primitives/spheres/cube-sphere.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/{icosphere.d.ts → primitives/spheres/icosphere.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/{icosphere.js → primitives/spheres/icosphere.js} +1 -1
- package/dist/engines/little-3d-engine/shapes/{octa-sphere.d.ts → primitives/spheres/octa-sphere.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/{octa-sphere.js → primitives/spheres/octa-sphere.js} +1 -1
- package/dist/engines/little-3d-engine/shapes/{uv-sphere.d.ts → primitives/spheres/uv-sphere.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/{tetrahedron.d.ts → primitives/tetrahedron.d.ts} +1 -1
- package/dist/engines/little-3d-engine/textures/dynamic/canvas-texture.d.ts +1 -0
- package/dist/engines/little-3d-engine/textures/dynamic/canvas-texture.js +8 -0
- package/dist/engines/little-3d-engine/textures/dynamic/shine.d.ts +2 -0
- package/dist/engines/little-3d-engine/textures/dynamic/shine.js +14 -0
- package/dist/engines/little-3d-engine/textures/dynamic/star.d.ts +14 -0
- package/dist/engines/little-3d-engine/textures/dynamic/star.js +31 -0
- package/dist/engines/little-3d-engine/textures/dynamic/streak.d.ts +5 -0
- package/dist/engines/little-3d-engine/textures/dynamic/streak.js +20 -0
- package/dist/index.d.ts +2 -1
- package/dist/prefabs/charged-orb.d.ts +17 -0
- package/dist/prefabs/charged-orb.js +37 -0
- package/dist/prefabs/crystal-comet.d.ts +4 -0
- package/dist/prefabs/crystal-comet.js +41 -0
- package/dist/prefabs/ghost-train.d.ts +10 -0
- package/dist/prefabs/ghost-train.js +37 -0
- package/dist/prefabs/grid-assembly.d.ts +13 -0
- package/dist/prefabs/grid-assembly.js +15 -0
- package/dist/prefabs/monochrome-streak.d.ts +4 -0
- package/dist/prefabs/monochrome-streak.js +25 -0
- package/dist/prefabs/plane-star-trail.d.ts +4 -0
- package/dist/prefabs/plane-star-trail.js +40 -0
- package/dist/prefabs/prefabs.d.ts +10 -0
- package/dist/prefabs/prefabs.js +9 -0
- package/dist/prefabs/pulsing-starfield.d.ts +4 -0
- package/dist/prefabs/pulsing-starfield.js +27 -0
- package/dist/prefabs/rocket-launch.d.ts +10 -0
- package/dist/prefabs/rocket-launch.js +16 -0
- package/dist/prefabs/spinner.d.ts +7 -0
- package/dist/prefabs/spinner.js +17 -0
- package/dist/prefabs/star-swarm.d.ts +4 -0
- package/dist/prefabs/star-swarm.js +27 -0
- package/dist/prefabs/types.d.ts +46 -0
- package/dist/prefabs/types.js +1 -0
- package/dist/umd/spinner.global.js +2407 -85
- package/dist/umd/spinner.global.min.js +60 -5
- package/package.json +43 -2
- /package/dist/engines/little-3d-engine/shapes/{cube.js → primitives/cube.js} +0 -0
- /package/dist/engines/little-3d-engine/shapes/{octahedron.js → primitives/octahedron.js} +0 -0
- /package/dist/engines/little-3d-engine/shapes/{pyramid.js → primitives/pyramid.js} +0 -0
- /package/dist/engines/little-3d-engine/shapes/{cube-sphere.js → primitives/spheres/cube-sphere.js} +0 -0
- /package/dist/engines/little-3d-engine/shapes/{uv-sphere.js → primitives/spheres/uv-sphere.js} +0 -0
- /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
|
[](https://github.com/runelaang/3d-spinner/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/3d-spinner)
|
|
5
|
+
[](https://bundlephobia.com/package/3d-spinner)
|
|
5
6
|
[](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
|
+
|  |  |  |
|
|
18
|
+
|
|
19
|
+
| `ghostTrain` progress prefab | `ParticlesAnimation` with glow texture |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
|  |  |
|
|
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
|
|
88
|
-
in
|
|
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/
|
|
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
|
+
}
|
package/dist/animation.d.ts
CHANGED
|
@@ -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
|
+
}
|