vexy-stax-js 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/LICENSE +201 -0
- package/README.md +77 -0
- package/package.json +57 -0
- package/schema/vexy-stax-scene.schema.json +122 -0
- package/src/element.js +131 -0
- package/src/export.js +143 -0
- package/src/geometry.js +248 -0
- package/src/global.js +16 -0
- package/src/index.js +246 -0
- package/src/scene.js +268 -0
- package/src/scrollspy.js +140 -0
- package/src/stage.js +349 -0
- package/src/transition.js +169 -0
package/src/stage.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// this_file: src/stage.js
|
|
3
|
+
//
|
|
4
|
+
// three.js stage: builds plates (PlaneGeometry + texture, bottom-aligned,
|
|
5
|
+
// centered, MeshBasicMaterial), a floor plane, and a perspective camera.
|
|
6
|
+
// Reduced to the two views (expanded/compact) per SPEC.md §3, consuming
|
|
7
|
+
// geometry.js for camera/spacing/opacity. Adapted from
|
|
8
|
+
// vexy-stax-old/vexy-stax-js (SceneComposition + FloorManager + camera),
|
|
9
|
+
// stripped of the editor UI.
|
|
10
|
+
|
|
11
|
+
import * as THREE from "three";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
compactCamera,
|
|
15
|
+
expandedCamera,
|
|
16
|
+
plateGaps,
|
|
17
|
+
stackDepth,
|
|
18
|
+
MIN_GAP,
|
|
19
|
+
} from "./geometry.js";
|
|
20
|
+
import { resolvedOpacity } from "./scene.js";
|
|
21
|
+
|
|
22
|
+
/** Caption fade factor for a slide+view, given the morph factor t (0=compact,1=expanded). */
|
|
23
|
+
function captionFade(caption, t) {
|
|
24
|
+
if (!caption) return 0;
|
|
25
|
+
switch (caption.show_in) {
|
|
26
|
+
case "both":
|
|
27
|
+
return 1;
|
|
28
|
+
case "none":
|
|
29
|
+
return 0;
|
|
30
|
+
case "compact":
|
|
31
|
+
return 1 - t; // visible in compact, fades out as deck expands
|
|
32
|
+
case "expanded":
|
|
33
|
+
default:
|
|
34
|
+
return t; // visible in expanded, fades in as deck expands
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build a canvas-texture sprite for caption text. Rendered upright (a Sprite
|
|
40
|
+
* always faces the camera) and placed beneath its plate. Text is drawn left→right
|
|
41
|
+
* so it reads correctly (never mirrored).
|
|
42
|
+
*/
|
|
43
|
+
function makeCaptionSprite(text, style) {
|
|
44
|
+
const size = style?.size ?? 28;
|
|
45
|
+
const color = style?.color ?? "#222222";
|
|
46
|
+
const font = style?.font ?? "sans-serif";
|
|
47
|
+
const dpr = 2; // crisper text
|
|
48
|
+
const pxFont = `${size * dpr}px ${font}`;
|
|
49
|
+
const pad = size * dpr * 0.4;
|
|
50
|
+
|
|
51
|
+
const measureCanvas = document.createElement("canvas");
|
|
52
|
+
const mctx = measureCanvas.getContext("2d");
|
|
53
|
+
mctx.font = pxFont;
|
|
54
|
+
const textW = Math.ceil(mctx.measureText(text).width);
|
|
55
|
+
const canvasW = Math.max(1, textW + pad * 2);
|
|
56
|
+
const canvasH = Math.ceil(size * dpr * 1.6);
|
|
57
|
+
|
|
58
|
+
const canvas = document.createElement("canvas");
|
|
59
|
+
canvas.width = canvasW;
|
|
60
|
+
canvas.height = canvasH;
|
|
61
|
+
const ctx = canvas.getContext("2d");
|
|
62
|
+
ctx.font = pxFont;
|
|
63
|
+
ctx.textAlign = "center";
|
|
64
|
+
ctx.textBaseline = "middle";
|
|
65
|
+
ctx.fillStyle = color;
|
|
66
|
+
ctx.fillText(text, canvasW / 2, canvasH / 2);
|
|
67
|
+
|
|
68
|
+
const texture = new THREE.CanvasTexture(canvas);
|
|
69
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
70
|
+
texture.needsUpdate = true;
|
|
71
|
+
const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false });
|
|
72
|
+
const sprite = new THREE.Sprite(material);
|
|
73
|
+
// World size in scene points: scale to the caption pixel size (1px ≈ 1pt) so it
|
|
74
|
+
// is legible relative to the plates. Preserve the canvas aspect ratio.
|
|
75
|
+
const worldH = size * 2.2;
|
|
76
|
+
const worldW = worldH * (canvasW / canvasH);
|
|
77
|
+
sprite.scale.set(worldW, worldH, 1);
|
|
78
|
+
return { sprite, material };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Load a texture from a URL/data-URI, resolving once decoded. */
|
|
82
|
+
function loadTexture(loader, src) {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
loader.load(
|
|
85
|
+
src,
|
|
86
|
+
(texture) => {
|
|
87
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
88
|
+
resolve(texture);
|
|
89
|
+
},
|
|
90
|
+
undefined,
|
|
91
|
+
(err) => reject(new Error(`Failed to load texture: ${src} (${err?.message ?? err})`))
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class Stage {
|
|
97
|
+
/**
|
|
98
|
+
* @param {HTMLElement} container
|
|
99
|
+
* @param {object} scene normalized scene (from loadScene)
|
|
100
|
+
*/
|
|
101
|
+
constructor(container, scene) {
|
|
102
|
+
this.container = container;
|
|
103
|
+
this.scene = scene;
|
|
104
|
+
this.plates = []; // { mesh, width, height, slide, caption? }
|
|
105
|
+
this.captions = []; // { sprite, material, plateIndex }
|
|
106
|
+
this.view = scene.view;
|
|
107
|
+
this._disposed = false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Build renderer, scene graph, camera, floor, and load all plate textures. */
|
|
111
|
+
async init() {
|
|
112
|
+
const width = this.container.clientWidth || this.scene.size.width;
|
|
113
|
+
const height = this.container.clientHeight || this.scene.size.height;
|
|
114
|
+
|
|
115
|
+
this.threeScene = new THREE.Scene();
|
|
116
|
+
this.threeScene.background = new THREE.Color(this.scene.background);
|
|
117
|
+
|
|
118
|
+
this.camera = new THREE.PerspectiveCamera(
|
|
119
|
+
this.scene.camera.fov,
|
|
120
|
+
width / height,
|
|
121
|
+
1,
|
|
122
|
+
1_000_000
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
|
|
126
|
+
this.renderer.setPixelRatio(typeof window !== "undefined" ? window.devicePixelRatio : 1);
|
|
127
|
+
this.renderer.setSize(width, height, false);
|
|
128
|
+
this.container.appendChild(this.renderer.domElement);
|
|
129
|
+
|
|
130
|
+
await this._buildPlates();
|
|
131
|
+
this._buildFloor();
|
|
132
|
+
this._buildCaptions();
|
|
133
|
+
this.setView(this.view);
|
|
134
|
+
|
|
135
|
+
return this;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async _buildPlates() {
|
|
139
|
+
const loader = new THREE.TextureLoader();
|
|
140
|
+
const textures = await Promise.all(this.scene.slides.map((s) => loadTexture(loader, s.src)));
|
|
141
|
+
const reflectivity = this.scene.floor.reflectivity;
|
|
142
|
+
|
|
143
|
+
textures.forEach((texture, i) => {
|
|
144
|
+
const w = texture.image.width;
|
|
145
|
+
const h = texture.image.height;
|
|
146
|
+
const geometry = new THREE.PlaneGeometry(w, h);
|
|
147
|
+
// DoubleSide so the front face reads un-mirrored from +Z and the plate stays
|
|
148
|
+
// visible when the expanded camera orbits behind it.
|
|
149
|
+
const material = new THREE.MeshBasicMaterial({
|
|
150
|
+
map: texture,
|
|
151
|
+
side: THREE.DoubleSide,
|
|
152
|
+
transparent: true,
|
|
153
|
+
opacity: 1,
|
|
154
|
+
});
|
|
155
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
156
|
+
this.threeScene.add(mesh);
|
|
157
|
+
|
|
158
|
+
// Mirror copy below the floor line for the floor reflection (matches the
|
|
159
|
+
// pygfx engine): scaled (1,-1,1), opacity = plate opacity * reflectivity,
|
|
160
|
+
// rendered behind the floor (renderOrder -1).
|
|
161
|
+
let reflection = null;
|
|
162
|
+
if (reflectivity > 0) {
|
|
163
|
+
const reflMat = new THREE.MeshBasicMaterial({
|
|
164
|
+
map: texture,
|
|
165
|
+
side: THREE.DoubleSide,
|
|
166
|
+
transparent: true,
|
|
167
|
+
opacity: reflectivity,
|
|
168
|
+
});
|
|
169
|
+
reflection = new THREE.Mesh(geometry.clone(), reflMat);
|
|
170
|
+
reflection.scale.y = -1;
|
|
171
|
+
reflection.renderOrder = -1;
|
|
172
|
+
this.threeScene.add(reflection);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this.plates.push({ mesh, reflection, width: w, height: h, slide: this.scene.slides[i] });
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Build a caption sprite under each plate that declares one (best-effort). */
|
|
180
|
+
_buildCaptions() {
|
|
181
|
+
if (typeof document === "undefined") return; // no canvas → skip captions
|
|
182
|
+
const defaults = this.scene.caption_defaults ?? null;
|
|
183
|
+
this.plates.forEach((plate, i) => {
|
|
184
|
+
const caption = plate.slide.caption;
|
|
185
|
+
if (!caption) return;
|
|
186
|
+
const style = { ...(defaults ?? {}), ...(caption.style ?? {}) };
|
|
187
|
+
const { sprite, material } = makeCaptionSprite(caption.text, style);
|
|
188
|
+
this.threeScene.add(sprite);
|
|
189
|
+
this.captions.push({ sprite, material, plateIndex: i, caption });
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
_tallestHeight() {
|
|
194
|
+
return this.plates.reduce((m, p) => Math.max(m, p.height), 0);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
_buildFloor() {
|
|
198
|
+
// Match the pygfx engine's floor extents/placement so JS and Python frame the
|
|
199
|
+
// deck identically: width = widest*4, depth = stackDepth + widest*2, centered
|
|
200
|
+
// under the deck at (0, floorY, -depth/2).
|
|
201
|
+
const widest = this.plates.reduce((m, p) => Math.max(m, p.width), 0) || this.scene.size.width;
|
|
202
|
+
const depth = stackDepth(this.scene, "expanded");
|
|
203
|
+
const extentW = widest * 4.0;
|
|
204
|
+
const extentZ = depth > 0 ? depth + widest * 2.0 : widest * 2.0;
|
|
205
|
+
const geometry = new THREE.PlaneGeometry(extentW, extentZ);
|
|
206
|
+
const material = new THREE.MeshBasicMaterial({
|
|
207
|
+
color: new THREE.Color(this.scene.floor.color),
|
|
208
|
+
transparent: true,
|
|
209
|
+
opacity: this.scene.floor.opacity,
|
|
210
|
+
side: THREE.DoubleSide,
|
|
211
|
+
});
|
|
212
|
+
const floor = new THREE.Mesh(geometry, material);
|
|
213
|
+
// Lay flat on the XZ plane at the floor line (bottom of the tallest plate).
|
|
214
|
+
floor.rotation.x = -Math.PI / 2;
|
|
215
|
+
floor.position.set(0, -this._tallestHeight() / 2, -depth / 2.0);
|
|
216
|
+
this.threeScene.add(floor);
|
|
217
|
+
this.floor = floor;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Position plates (Z spacing + Y bottom-align), opacity, and camera for a view.
|
|
222
|
+
* @param {"expanded"|"compact"} view
|
|
223
|
+
*/
|
|
224
|
+
setView(view) {
|
|
225
|
+
this.view = view;
|
|
226
|
+
const gaps = view === "compact" ? this.plates.map(() => MIN_GAP) : plateGaps(this.scene);
|
|
227
|
+
const opacities = this.plates.map((p) => resolvedOpacity(p.slide, view));
|
|
228
|
+
const pose = view === "compact" ? compactCamera(this.scene) : expandedCamera(this.scene);
|
|
229
|
+
const t = view === "compact" ? 0 : 1;
|
|
230
|
+
this._placePlates(gaps, opacities);
|
|
231
|
+
this._placeCaptions(t);
|
|
232
|
+
this._applyPose(pose);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Apply a geometry.js FrameState (camera pose + per-plate gaps + per-slide
|
|
237
|
+
* opacities) for the playable transition / scrollspy. `t` is the eased morph
|
|
238
|
+
* factor (0=compact,1=expanded) used for caption fade.
|
|
239
|
+
* @param {{camera:object, gaps:number[], opacities:number[]}} state
|
|
240
|
+
* @param {number} t
|
|
241
|
+
*/
|
|
242
|
+
applyFrameState(state, t) {
|
|
243
|
+
this._placePlates(state.gaps, state.opacities);
|
|
244
|
+
this._placeCaptions(t);
|
|
245
|
+
this._applyPose(state.camera);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Lay out plates along Z (index 0 farthest), bottom-aligned, with opacities. */
|
|
249
|
+
_placePlates(gaps, opacities) {
|
|
250
|
+
const tallest = this._tallestHeight();
|
|
251
|
+
const bottomY = -tallest / 2;
|
|
252
|
+
const floorY = bottomY;
|
|
253
|
+
const reflectivity = this.scene.floor.reflectivity;
|
|
254
|
+
|
|
255
|
+
// Cumulative Z: index 0 farthest (most negative), last at 0.
|
|
256
|
+
// stack_depth = sum(gaps[1:]); place slide i at z = -(stack_depth - cumGapTo(i)).
|
|
257
|
+
let totalDepth = 0;
|
|
258
|
+
for (let i = 1; i < gaps.length; i++) totalDepth += gaps[i];
|
|
259
|
+
|
|
260
|
+
let cum = 0;
|
|
261
|
+
this.plates.forEach((plate, i) => {
|
|
262
|
+
if (i > 0) cum += gaps[i];
|
|
263
|
+
const z = -(totalDepth - cum);
|
|
264
|
+
const y = bottomY + plate.height / 2;
|
|
265
|
+
plate.mesh.position.set(0, y, z);
|
|
266
|
+
plate.mesh.material.opacity = opacities[i];
|
|
267
|
+
if (plate.reflection) {
|
|
268
|
+
// Mirror the plate (centered at y) across the floor line: a point at y
|
|
269
|
+
// maps to 2*floorY - y; the plate center is y, so the reflection center
|
|
270
|
+
// is 2*floorY - y, with scale.y=-1 flipping the image. Opacity fades with
|
|
271
|
+
// the plate (matches the pygfx engine).
|
|
272
|
+
plate.reflection.position.set(0, 2.0 * floorY - y, z);
|
|
273
|
+
plate.reflection.material.opacity = opacities[i] * reflectivity;
|
|
274
|
+
plate.reflection.visible = opacities[i] * reflectivity > 0.001;
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Position each caption beneath its plate and fade it per the morph factor t. */
|
|
280
|
+
_placeCaptions(t) {
|
|
281
|
+
if (this.captions.length === 0) return;
|
|
282
|
+
const tallest = this._tallestHeight();
|
|
283
|
+
const floorY = -tallest / 2;
|
|
284
|
+
this.captions.forEach(({ sprite, material, plateIndex, caption }) => {
|
|
285
|
+
const plate = this.plates[plateIndex];
|
|
286
|
+
sprite.position.set(plate.mesh.position.x, floorY - sprite.scale.y, plate.mesh.position.z);
|
|
287
|
+
const fade = captionFade(caption, t);
|
|
288
|
+
material.opacity = fade;
|
|
289
|
+
sprite.visible = fade > 0.001;
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Apply a geometry.js pose (position/target/fov/near) to the three camera. */
|
|
294
|
+
_applyPose(pose) {
|
|
295
|
+
this.camera.position.set(pose.position[0], pose.position[1], pose.position[2]);
|
|
296
|
+
this.camera.up.set(0, 1, 0);
|
|
297
|
+
this.camera.lookAt(pose.target[0], pose.target[1], pose.target[2]);
|
|
298
|
+
// three.js PerspectiveCamera.fov is the VERTICAL fov. The shared geometry
|
|
299
|
+
// treats scene.camera.fov as a reference fov consumed identically by the
|
|
300
|
+
// Python pygfx engine, whose projection sets the vertical half-height to
|
|
301
|
+
// tan(vfov/2) = 2*tan(fov/2) / (1 + sceneAspect)
|
|
302
|
+
// (it splits the reference size across width+height by the scene aspect).
|
|
303
|
+
// Convert here so the JS stage frames the deck identically to the Python
|
|
304
|
+
// engines (cross-engine parity, SPEC.md §8) without touching geometry.js.
|
|
305
|
+
const sceneAspect = this.scene.size.width / this.scene.size.height;
|
|
306
|
+
const halfFov = (pose.fov * Math.PI) / 360; // (fov/2) in radians
|
|
307
|
+
const vfov = 2 * Math.atan((2 * Math.tan(halfFov)) / (1 + sceneAspect));
|
|
308
|
+
this.camera.fov = (vfov * 180) / Math.PI;
|
|
309
|
+
this.camera.near = pose.near;
|
|
310
|
+
this.camera.updateProjectionMatrix();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
render() {
|
|
314
|
+
if (this._disposed) return;
|
|
315
|
+
this.renderer.render(this.threeScene, this.camera);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
resize(width, height) {
|
|
319
|
+
if (!width || !height) return;
|
|
320
|
+
this.camera.aspect = width / height;
|
|
321
|
+
this.camera.updateProjectionMatrix();
|
|
322
|
+
this.renderer.setSize(width, height, false);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
dispose() {
|
|
326
|
+
this._disposed = true;
|
|
327
|
+
this.plates.forEach((p) => {
|
|
328
|
+
p.mesh.geometry?.dispose();
|
|
329
|
+
p.mesh.material?.map?.dispose();
|
|
330
|
+
p.mesh.material?.dispose();
|
|
331
|
+
if (p.reflection) {
|
|
332
|
+
p.reflection.geometry?.dispose();
|
|
333
|
+
p.reflection.material?.dispose();
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
this.captions.forEach((c) => {
|
|
337
|
+
c.material?.map?.dispose();
|
|
338
|
+
c.material?.dispose();
|
|
339
|
+
});
|
|
340
|
+
this.floor?.geometry?.dispose();
|
|
341
|
+
this.floor?.material?.dispose();
|
|
342
|
+
this.renderer?.dispose();
|
|
343
|
+
if (this.renderer?.domElement?.parentNode) {
|
|
344
|
+
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
|
|
345
|
+
}
|
|
346
|
+
this.plates = [];
|
|
347
|
+
this.captions = [];
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// this_file: src/transition.js
|
|
3
|
+
//
|
|
4
|
+
// Morph driver (SPEC.md §3, §6.1). Drives the camera pose + per-plate spacing +
|
|
5
|
+
// per-slide opacity (and caption fade) from compact↔expanded endpoints, using
|
|
6
|
+
// the shared easing from geometry.js so the playable animation matches the
|
|
7
|
+
// frame_plan math the engines render.
|
|
8
|
+
//
|
|
9
|
+
// The geometry is provided by frameStateAt() (pure, in geometry.js); this module
|
|
10
|
+
// only sequences the legs over wall-clock time with requestAnimationFrame and
|
|
11
|
+
// applies each FrameState through an `apply` callback. No three.js here, so the
|
|
12
|
+
// timeline logic is testable under node --test.
|
|
13
|
+
|
|
14
|
+
import { frameStateAt, ease } from "./geometry.js";
|
|
15
|
+
|
|
16
|
+
// Each transition is a sequence of legs expressed as morph endpoints
|
|
17
|
+
// (0=compact, 1=expanded). Mirrors _LEGS in geometry.py / framePlan.
|
|
18
|
+
const LEGS = {
|
|
19
|
+
expand: [[0.0, 1.0]],
|
|
20
|
+
collapse: [[1.0, 0.0]],
|
|
21
|
+
expand_collapse: [
|
|
22
|
+
[0.0, 1.0],
|
|
23
|
+
[1.0, 0.0],
|
|
24
|
+
],
|
|
25
|
+
collapse_expand: [
|
|
26
|
+
[1.0, 0.0],
|
|
27
|
+
[0.0, 1.0],
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Endpoint morph factors for a transition kind: [start, end] of its first/last leg. */
|
|
32
|
+
export function transitionEndpoints(kind) {
|
|
33
|
+
const legs = LEGS[kind];
|
|
34
|
+
if (!legs) throw new Error(`Unknown transition kind: ${JSON.stringify(kind)}`);
|
|
35
|
+
return { startMorph: legs[0][0], endMorph: legs[legs.length - 1][1] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build a normalized timeline of segments for a transition, in [0,1] global
|
|
40
|
+
* progress. Each leg contributes a move segment (duration seconds) and a hold
|
|
41
|
+
* segment (wait seconds). Returns segments with their global-progress span and
|
|
42
|
+
* the morph endpoints they interpolate between (move) or hold at (hold).
|
|
43
|
+
*
|
|
44
|
+
* Pure + deterministic so it can be unit-tested without a clock.
|
|
45
|
+
* @param {string} kind transition kind
|
|
46
|
+
* @param {{duration:number, wait:number}} timing
|
|
47
|
+
*/
|
|
48
|
+
export function buildTimeline(kind, { duration, wait }) {
|
|
49
|
+
const legs = LEGS[kind];
|
|
50
|
+
if (!legs) throw new Error(`Unknown transition kind: ${JSON.stringify(kind)}`);
|
|
51
|
+
const segments = [];
|
|
52
|
+
let total = 0;
|
|
53
|
+
for (const [start, end] of legs) {
|
|
54
|
+
if (duration > 0) segments.push({ type: "move", start, end, seconds: duration });
|
|
55
|
+
if (wait > 0) segments.push({ type: "hold", start: end, end, seconds: wait });
|
|
56
|
+
total += duration + wait;
|
|
57
|
+
}
|
|
58
|
+
// Guard: a zero-length timeline (duration=wait=0) still needs one instantaneous
|
|
59
|
+
// segment so we can settle at the final endpoint.
|
|
60
|
+
if (total <= 0) {
|
|
61
|
+
const last = legs[legs.length - 1];
|
|
62
|
+
segments.push({ type: "hold", start: last[1], end: last[1], seconds: 0 });
|
|
63
|
+
total = 0;
|
|
64
|
+
}
|
|
65
|
+
// Assign global-progress spans [from, to] across the whole timeline.
|
|
66
|
+
let acc = 0;
|
|
67
|
+
for (const seg of segments) {
|
|
68
|
+
const from = total > 0 ? acc / total : 0;
|
|
69
|
+
acc += seg.seconds;
|
|
70
|
+
const to = total > 0 ? acc / total : 1;
|
|
71
|
+
seg.from = from;
|
|
72
|
+
seg.to = to;
|
|
73
|
+
}
|
|
74
|
+
return { segments, totalSeconds: total };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve a global progress p in [0,1] to a morph factor (already eased) using a
|
|
79
|
+
* timeline from buildTimeline(). The move segments ease their local progress; the
|
|
80
|
+
* hold segments stay at their endpoint morph. Pure — used by both the rAF player
|
|
81
|
+
* and the scrollspy mapper.
|
|
82
|
+
* @param {{segments:Array}} timeline
|
|
83
|
+
* @param {number} p global progress [0,1]
|
|
84
|
+
* @param {(t:number)=>number} easeFn easing applied within move segments
|
|
85
|
+
*/
|
|
86
|
+
export function morphAtProgress(timeline, p, easeFn) {
|
|
87
|
+
const { segments } = timeline;
|
|
88
|
+
const clamped = Math.max(0, Math.min(1, p));
|
|
89
|
+
for (const seg of segments) {
|
|
90
|
+
if (clamped <= seg.to || seg === segments[segments.length - 1]) {
|
|
91
|
+
if (seg.type === "hold" || seg.to === seg.from) return seg.end;
|
|
92
|
+
const local = (clamped - seg.from) / (seg.to - seg.from);
|
|
93
|
+
const eased = easeFn(Math.max(0, Math.min(1, local)));
|
|
94
|
+
return seg.start + (seg.end - seg.start) * eased;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return segments[segments.length - 1].end;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Play a transition over wall-clock time, calling `apply(frameState)` each frame
|
|
102
|
+
* and `onProgress(p)` with global progress [0,1]. Resolves when complete.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} scene parsed scene
|
|
105
|
+
* @param {(state:object)=>void} apply applies a FrameState to the stage
|
|
106
|
+
* @param {object} [opts]
|
|
107
|
+
* @param {string} [opts.kind] override scene.transition.kind
|
|
108
|
+
* @param {(p:number)=>void} [opts.onProgress]
|
|
109
|
+
* @param {(ms:number)=>void} [opts.render] called after each apply (renders a frame)
|
|
110
|
+
* @param {{now:()=>number, raf:(cb)=>any, caf:(h)=>void}} [opts.clock] injectable for tests
|
|
111
|
+
* @returns {{promise:Promise<void>, cancel:()=>void}}
|
|
112
|
+
*/
|
|
113
|
+
export function playTransition(scene, apply, opts = {}) {
|
|
114
|
+
const tr = scene.transition;
|
|
115
|
+
const kind = opts.kind ?? tr?.kind;
|
|
116
|
+
if (!kind) throw new Error("playTransition: no transition kind (scene.transition is null and no kind given)");
|
|
117
|
+
const duration = tr?.duration ?? 3.0;
|
|
118
|
+
const wait = tr?.wait ?? 0.0;
|
|
119
|
+
const easing = tr?.easing ?? "easeInOutCubic";
|
|
120
|
+
const timeline = buildTimeline(kind, { duration, wait });
|
|
121
|
+
|
|
122
|
+
const now = opts.clock?.now ?? (() => performance.now());
|
|
123
|
+
const raf =
|
|
124
|
+
opts.clock?.raf ??
|
|
125
|
+
(typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame : (cb) => setTimeout(() => cb(now()), 16));
|
|
126
|
+
const caf =
|
|
127
|
+
opts.clock?.caf ??
|
|
128
|
+
(typeof cancelAnimationFrame !== "undefined" ? cancelAnimationFrame : (h) => clearTimeout(h));
|
|
129
|
+
|
|
130
|
+
let handle = null;
|
|
131
|
+
let cancelled = false;
|
|
132
|
+
let resolveFn;
|
|
133
|
+
let rejectFn;
|
|
134
|
+
const promise = new Promise((resolve, reject) => {
|
|
135
|
+
resolveFn = resolve;
|
|
136
|
+
rejectFn = reject;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const totalMs = timeline.totalSeconds * 1000;
|
|
140
|
+
const start = now();
|
|
141
|
+
|
|
142
|
+
const step = () => {
|
|
143
|
+
if (cancelled) {
|
|
144
|
+
rejectFn(new Error("transition cancelled"));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const elapsed = now() - start;
|
|
148
|
+
const p = totalMs > 0 ? Math.min(1, elapsed / totalMs) : 1;
|
|
149
|
+
const t = morphAtProgress(timeline, p, (x) => ease(easing, x));
|
|
150
|
+
apply(frameStateAt(scene, t));
|
|
151
|
+
opts.onProgress?.(p);
|
|
152
|
+
if (p >= 1) {
|
|
153
|
+
resolveFn();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
handle = raf(step);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Kick off on the next frame so listeners can attach.
|
|
160
|
+
handle = raf(step);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
promise,
|
|
164
|
+
cancel() {
|
|
165
|
+
cancelled = true;
|
|
166
|
+
if (handle != null) caf(handle);
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|