vexy-stax-js 3.0.0 → 3.0.1
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 +109 -0
- package/package.json +1 -1
- package/schema/vexy-stax-scene.schema.json +43 -6
- package/src/element.js +3 -0
- package/src/geometry.js +272 -22
- package/src/index.js +45 -4
- package/src/scene.js +41 -6
- package/src/stage.js +358 -78
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,115 @@
|
|
|
4
4
|
|
|
5
5
|
All notable changes to this project are documented here.
|
|
6
6
|
|
|
7
|
+
## [3.0.6] — issue 327
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Caption font fell back to serif (Times New Roman) in the playwright render**
|
|
12
|
+
(327.1): `makeCaptionSprite` now builds the canvas font as
|
|
13
|
+
`"<family>", system-ui, sans-serif`, so an unloaded/unknown family never falls
|
|
14
|
+
back to the canvas default serif. `_ensureCaptionFonts` now awaits the Google
|
|
15
|
+
Fonts `<link>` `onload` BEFORE calling `document.fonts.load`, so REM's
|
|
16
|
+
`@font-face` rules exist when the load is requested (previously a race resolved
|
|
17
|
+
the load against the system fallback, leaving captions in a serif font).
|
|
18
|
+
|
|
19
|
+
## [3.0.5] — issue 326
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **Plate + caption borders OFF by default** (326): `parseEdge` width default
|
|
24
|
+
`0.004 → 0.0` (and schema default). A border (around both the slide plates and
|
|
25
|
+
the caption plates, which share `edge.width`) now draws only when `width > 0`.
|
|
26
|
+
Border color default is unchanged and applies only when a border is enabled.
|
|
27
|
+
|
|
28
|
+
## [3.0.4] — issues 324–325
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **Caption + border colors default `#f2f2f2`, each overridable** (324):
|
|
33
|
+
`scene.edge.color` default `#cccccc → #f2f2f2` (the slide-plate border and, by
|
|
34
|
+
default, the caption-plate fill + border). New `caption_defaults.fill_color` and
|
|
35
|
+
`caption_defaults.border_color` (each defaulting to `scene.edge.color`) make the
|
|
36
|
+
caption plate fill and border independently overridable; `caption_defaults.color`
|
|
37
|
+
still sets the caption text color. New `geometry.captionFillColor()` /
|
|
38
|
+
`captionBorderColor()` helpers; `makeCaptionSprite` now takes separate
|
|
39
|
+
`fillColor` / `borderColor` (each falling back to `edgeColor`) and the stage
|
|
40
|
+
passes the resolved colors. Schema gained the `edge.color` default and
|
|
41
|
+
`captionStyle.fill_color` / `border_color`.
|
|
42
|
+
- **Caption font 1/3 larger by default** (324): `CAPTION_PLATE_HEIGHT_FRAC`
|
|
43
|
+
`0.10 → 0.10·4/3 ≈ 0.1333`, so `CAPTION_DEFAULT_SIZE_FRAC` `0.075 → 0.10` of the
|
|
44
|
+
scene height.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **Blender compact view rendered black / transition translucent** (325):
|
|
49
|
+
fixed in the Python `blender` engine (Cycles `transparent_max_bounces` was
|
|
50
|
+
exhausted by the dense head-on plate stack). No changes to the browser package;
|
|
51
|
+
the JS/three.js engine was already correct (it draws single planes with
|
|
52
|
+
`depthWrite:false` + explicit render orders).
|
|
53
|
+
|
|
54
|
+
## [3.0.3] — issues 321–323
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- **Caption plate touches slide plate** (321/323): `CAPTION_GAP_EM` reduced from
|
|
59
|
+
`2.0` to `0.0` in `geometry.js` so the caption plate right edge aligns exactly
|
|
60
|
+
with the slide plate left edge — no visual gap.
|
|
61
|
+
|
|
62
|
+
## [3.0.2] — issue 320
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
|
|
66
|
+
- **Caption plate fill = border color** (320.7): caption plate background is now
|
|
67
|
+
`edgeColor` (= `scene.edge.color`, default `#cccccc`) instead of white, so the
|
|
68
|
+
caption plate matches the slide-plate border.
|
|
69
|
+
- **Plate/reflection `depthWrite: false`** (320.9): THREE.js plate and reflection
|
|
70
|
+
materials set `depthWrite: false` to eliminate z-fighting flicker at the bottom
|
|
71
|
+
of each slide during Playwright-recorded transitions.
|
|
72
|
+
- **JS outputs use py testdata** (320.5–6): `verify/example.mjs`, harness HTML
|
|
73
|
+
files, and `verify/server.mjs` now read scene + slides from
|
|
74
|
+
`vexy-stax-py/testdata/` instead of the removed `vexy-stax-js/testdata/`.
|
|
75
|
+
- **Testdata removed** (320.6): `vexy-stax-js/testdata/` directory deleted; the
|
|
76
|
+
shared source of truth is `vexy-stax-py/testdata/`.
|
|
77
|
+
|
|
78
|
+
## [3.0.1] — issues 303–318
|
|
79
|
+
|
|
80
|
+
### Added
|
|
81
|
+
|
|
82
|
+
- **Smoked-glass floor + blurry reflections** (303 §1): floor defaults to ~4%
|
|
83
|
+
smoked glass; the mirror reflection texture is Gaussian-blurred (`ctx.filter`)
|
|
84
|
+
so it reads soft, not crisp.
|
|
85
|
+
- **Plate edge border** (305): `Edge` scene model (`width` fraction of plate
|
|
86
|
+
height, `color`), default-on; 4 thin perimeter quads per plate in `stage.js`.
|
|
87
|
+
- **Caption plates** (311, typography revised by 315): captions render as small
|
|
88
|
+
**white opaque bordered plates** (same edge as the slide plates), text centered;
|
|
89
|
+
plate height = 10% of plate height, text = 75% of that (→ 7.5%), width = text +
|
|
90
|
+
0.75em padding each side.
|
|
91
|
+
- **`seek(t)`** on `VexyStax` + `<vexy-stax>`: apply an arbitrary morph factor
|
|
92
|
+
(0 compact → 1 expanded). `scrollspy({map})` accepts a custom progress→morph map.
|
|
93
|
+
`compactCamera`/`expandedCamera` accept an optional viewport aspect (issue 314).
|
|
94
|
+
- **`outputs/scrollable.html`** (304.2 + 314): a **full-width 2:1 white** scene
|
|
95
|
+
(no rounding/box-shadow) between intro and outro copy. Compact = plate centered
|
|
96
|
+
with side padding; it morphs to expanded once **80% of the scene is visible**
|
|
97
|
+
scrolling in, then **latches** expanded (further scrolling does not collapse it).
|
|
98
|
+
|
|
99
|
+
### Fixed
|
|
100
|
+
|
|
101
|
+
- **playable.html scale at HiDPI** (304.1): `renderer.setSize(w, h)` (updateStyle
|
|
102
|
+
default) so the canvas displays at its CSS size, not the 2× backing size.
|
|
103
|
+
- **npm publish version conflict** (318): bumped package version to `3.0.1` since
|
|
104
|
+
`3.0.0` was already published on npm.
|
|
105
|
+
|
|
106
|
+
### Changed
|
|
107
|
+
|
|
108
|
+
- Default `camera.distance` is `"100%"` (compact fit-tight); default caption size
|
|
109
|
+
is 7.5% of plate height (75% of the caption-plate height — issues 311/315, supersede
|
|
110
|
+
308's 5%); `caption_fade.stagger_frames` for frame-based stagger.
|
|
111
|
+
|
|
112
|
+
### Removed
|
|
113
|
+
|
|
114
|
+
- **Floor shadows** (312): the short pale plate shadows on the floor were eliminated.
|
|
115
|
+
|
|
7
116
|
## [Unreleased]
|
|
8
117
|
|
|
9
118
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vexy-stax-js",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Browser renderer for the vexy-stax shared scene format: 3D glass plates in two views, with morphable opacity. Ships as ESM, Web Component, and a classic-script global.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"distance": {
|
|
31
31
|
"description": "Camera distance: absolute points as a number/string, or viewport-fit percentage like \"90%\".",
|
|
32
32
|
"anyOf": [{ "type": "number" }, { "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?%?$" }],
|
|
33
|
-
"default": "
|
|
33
|
+
"default": "100%"
|
|
34
34
|
},
|
|
35
35
|
"angle": { "type": "number", "default": 60, "description": "Azimuth degrees for expanded view." },
|
|
36
36
|
"elevation": { "type": "number", "default": 0, "description": "Degrees above horizon for expanded view." },
|
|
@@ -53,14 +53,33 @@
|
|
|
53
53
|
"type": "object",
|
|
54
54
|
"additionalProperties": false,
|
|
55
55
|
"properties": {
|
|
56
|
-
"color": { "type": "string", "default": "#
|
|
57
|
-
"opacity": { "type": "number", "minimum": 0, "maximum": 1, "default":
|
|
56
|
+
"color": { "type": "string", "default": "#1a1a1a" },
|
|
57
|
+
"opacity": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.04, "description": "Smoked glass — ~4% (issue 303)." },
|
|
58
58
|
"reflectivity": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.5 }
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
|
+
"edge": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"additionalProperties": false,
|
|
64
|
+
"description": "Visible plate border (issue 305), default-on.",
|
|
65
|
+
"properties": {
|
|
66
|
+
"width": { "type": "number", "minimum": 0, "default": 0.0, "description": "Border thickness as a fraction of plate height; 0 = off (issue 326: slide + caption borders off by default)." },
|
|
67
|
+
"color": { "type": "string", "default": "#f2f2f2", "description": "Slide-plate border color; also the default caption plate fill + border (issue 324)." }
|
|
68
|
+
}
|
|
69
|
+
},
|
|
61
70
|
"background": { "type": "string", "default": "#ffffff" },
|
|
62
71
|
"juicy": { "type": "boolean", "default": false, "description": "Python-only per-channel color match." },
|
|
63
72
|
"caption_defaults": { "$ref": "#/$defs/captionStyle" },
|
|
73
|
+
"caption_fade": {
|
|
74
|
+
"type": "object",
|
|
75
|
+
"additionalProperties": false,
|
|
76
|
+
"description": "Caption fade-in timing during a transition (issue 302 §B.4 / 309).",
|
|
77
|
+
"properties": {
|
|
78
|
+
"window": { "type": "number", "exclusiveMinimum": 0, "maximum": 1, "default": 0.9, "description": "Fraction of the morph (from the end) over which captions fade in." },
|
|
79
|
+
"stagger": { "type": "number", "minimum": 0, "exclusiveMaximum": 1, "default": 0.3, "description": "Back→front succession spread as a fraction of the fade window." },
|
|
80
|
+
"stagger_frames": { "type": "integer", "minimum": 0, "description": "Issue 309: back→front per-caption step in transition frames; overrides stagger when set." }
|
|
81
|
+
}
|
|
82
|
+
},
|
|
64
83
|
"slides": {
|
|
65
84
|
"type": "array",
|
|
66
85
|
"minItems": 1,
|
|
@@ -113,9 +132,27 @@
|
|
|
113
132
|
"type": "object",
|
|
114
133
|
"additionalProperties": false,
|
|
115
134
|
"properties": {
|
|
116
|
-
"size": {
|
|
117
|
-
|
|
118
|
-
|
|
135
|
+
"size": {
|
|
136
|
+
"type": "number",
|
|
137
|
+
"exclusiveMinimum": 0,
|
|
138
|
+
"description": "Font size for the caption text (1em) in scene-point units."
|
|
139
|
+
},
|
|
140
|
+
"color": {
|
|
141
|
+
"type": "string",
|
|
142
|
+
"description": "Color for the caption text (e.g., hex string like '#222222')."
|
|
143
|
+
},
|
|
144
|
+
"font": {
|
|
145
|
+
"type": "string",
|
|
146
|
+
"description": "Font family name (e.g., 'sans-serif', 'Arial') or a path to a TrueType/OpenType font file."
|
|
147
|
+
},
|
|
148
|
+
"fill_color": {
|
|
149
|
+
"type": "string",
|
|
150
|
+
"description": "Caption plate FILL color (issue 324); defaults to scene.edge.color when omitted."
|
|
151
|
+
},
|
|
152
|
+
"border_color": {
|
|
153
|
+
"type": "string",
|
|
154
|
+
"description": "Caption plate BORDER color (issue 324); defaults to scene.edge.color when omitted."
|
|
155
|
+
}
|
|
119
156
|
}
|
|
120
157
|
}
|
|
121
158
|
}
|
package/src/element.js
CHANGED
|
@@ -124,6 +124,9 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
124
124
|
scrollspy(opts) {
|
|
125
125
|
return this._stax?.scrollspy(opts);
|
|
126
126
|
}
|
|
127
|
+
seek(t) {
|
|
128
|
+
return this._stax?.seek(t);
|
|
129
|
+
}
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
if (typeof customElements !== "undefined" && !customElements.get("vexy-stax")) {
|
package/src/geometry.js
CHANGED
|
@@ -18,6 +18,96 @@ import { resolvedOpacity } from "./scene.js";
|
|
|
18
18
|
|
|
19
19
|
export const MIN_GAP = 3.0;
|
|
20
20
|
export const FILL = 0.85;
|
|
21
|
+
export const V_FILL = 0.98; // expanded: max fraction of frame height the deck may occupy (no crop)
|
|
22
|
+
|
|
23
|
+
// Caption fade defaults (issue 302 §B.4): captions fade in over the final
|
|
24
|
+
// CAPTION_FADE_WINDOW fraction of the morph, staggered back->front by CAPTION_STAGGER.
|
|
25
|
+
export const CAPTION_FADE_WINDOW = 0.9;
|
|
26
|
+
export const CAPTION_STAGGER = 0.3;
|
|
27
|
+
// Caption layout (issue 302 §B, em-based): captions sit to the LEFT of the plates with
|
|
28
|
+
// their RIGHT edges aligned CAPTION_GAP_EM em (em == caption size) from the plate left
|
|
29
|
+
// edge (0 = touching, issues 321/323), and the text BASELINE CAPTION_BASELINE_EM em above
|
|
30
|
+
// the virtual ground (the floor at the bottom of the plates). The nominal "em" is the
|
|
31
|
+
// caption size in scene points.
|
|
32
|
+
export const CAPTION_GAP_EM = 0.0; // issues 321/323: caption plate right edge touches slide plate left edge
|
|
33
|
+
export const CAPTION_BASELINE_EM = 1.0;
|
|
34
|
+
// Caption plate (issues 311, 315): each caption sits on a small white opaque bordered plate.
|
|
35
|
+
// Plate height = CAPTION_PLATE_HEIGHT_FRAC of the plate height; text 1em =
|
|
36
|
+
// CAPTION_FONT_FRAC_OF_PLATE of the caption-plate height; plate padded CAPTION_PLATE_PAD_EM
|
|
37
|
+
// em on each side of the text. Default caption size = 0.10*0.75 = 0.075 of scene height
|
|
38
|
+
// (issue 315 revised plate height 20%->10% and pad 1.5em->0.75em). Mirrors geometry.py.
|
|
39
|
+
export const CAPTION_PLATE_HEIGHT_FRAC = (0.1 * 4) / 3; // ≈0.1333 (issue 324: font 1/3 larger; was 0.10)
|
|
40
|
+
export const CAPTION_FONT_FRAC_OF_PLATE = 0.75;
|
|
41
|
+
export const CAPTION_PLATE_PAD_EM = 0.75;
|
|
42
|
+
export const CAPTION_DEFAULT_SIZE_FRAC = CAPTION_PLATE_HEIGHT_FRAC * CAPTION_FONT_FRAC_OF_PLATE; // ≈0.10 (issue 324; was 0.075)
|
|
43
|
+
|
|
44
|
+
// Floor reflection (issue 303 §1) — shared so the blurry reflection is consistent across
|
|
45
|
+
// engines. Fraction of the plate-image height (px). (Floor shadows removed per issue 312.)
|
|
46
|
+
export const REFLECTION_BLUR_FRAC = 0.02; // Gaussian blur radius of the mirror reflection
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Plate border thickness in scene points (issue 305): edge.width × plate height. Mirrors
|
|
50
|
+
* plate_edge_width in geometry.py. Caption plates (issue 311) reuse this same border.
|
|
51
|
+
*/
|
|
52
|
+
export function plateEdgeWidth(scene) {
|
|
53
|
+
return scene.size.height * scene.edge.width;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Nominal caption text size in scene points (1em). Resolves caption_defaults.size when set,
|
|
58
|
+
* else CAPTION_DEFAULT_SIZE_FRAC of scene height (issue 311). Mirrors caption_size.
|
|
59
|
+
*/
|
|
60
|
+
export function captionSize(scene) {
|
|
61
|
+
const cd = scene.caption_defaults;
|
|
62
|
+
if (cd && cd.size !== null && cd.size !== undefined) return Number(cd.size);
|
|
63
|
+
return Math.max(8.0, scene.size.height * CAPTION_DEFAULT_SIZE_FRAC);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Caption plate FILL color (issue 324): caption_defaults.fill_color else scene.edge.color. */
|
|
67
|
+
export function captionFillColor(scene) {
|
|
68
|
+
const cd = scene.caption_defaults;
|
|
69
|
+
return cd && cd.fill_color ? cd.fill_color : scene.edge.color;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Caption plate BORDER color (issue 324): caption_defaults.border_color else scene.edge.color. */
|
|
73
|
+
export function captionBorderColor(scene) {
|
|
74
|
+
const cd = scene.caption_defaults;
|
|
75
|
+
return cd && cd.border_color ? cd.border_color : scene.edge.color;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Height of a caption plate in scene points (issue 311): caption_size / 0.75 so the text
|
|
80
|
+
* 1em stays 75% of the plate height. Mirrors caption_plate_height in geometry.py.
|
|
81
|
+
*/
|
|
82
|
+
export function captionPlateHeight(scene) {
|
|
83
|
+
return captionSize(scene) / CAPTION_FONT_FRAC_OF_PLATE;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* World Y of a caption plate's vertical center (issue 311): the plate sits on the virtual
|
|
88
|
+
* ground (Y = -height/2), so its center is half its height above it. Mirrors geometry.py.
|
|
89
|
+
*/
|
|
90
|
+
export function captionPlateCenterY(scene) {
|
|
91
|
+
return -(scene.size.height / 2.0) + captionPlateHeight(scene) / 2.0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* World X where every caption's RIGHT edge aligns — CAPTION_GAP_EM em left of the plate
|
|
96
|
+
* left edge (-width/2). All plates share scene.size width centered at X=0. Mirrors
|
|
97
|
+
* caption_anchor_x in geometry.py.
|
|
98
|
+
*/
|
|
99
|
+
export function captionAnchorX(scene) {
|
|
100
|
+
return -(scene.size.width / 2.0 + CAPTION_GAP_EM * captionSize(scene));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* World Y of the caption text BASELINE — CAPTION_BASELINE_EM em above the virtual ground
|
|
105
|
+
* (the floor at the bottom of the plates, Y = -height/2). Mirrors caption_baseline_y in
|
|
106
|
+
* geometry.py.
|
|
107
|
+
*/
|
|
108
|
+
export function captionBaselineY(scene) {
|
|
109
|
+
return -(scene.size.height / 2.0) + CAPTION_BASELINE_EM * captionSize(scene);
|
|
110
|
+
}
|
|
21
111
|
|
|
22
112
|
/** Per-slide gap (points), falling back to camera.gap when unset (null). */
|
|
23
113
|
export function plateGaps(scene) {
|
|
@@ -70,15 +160,19 @@ function parseDistance(distance, viewportWidth) {
|
|
|
70
160
|
}
|
|
71
161
|
|
|
72
162
|
/**
|
|
73
|
-
* Angled hero camera framing the expanded *deck* (SPEC.md §3).
|
|
74
|
-
*
|
|
75
|
-
*
|
|
163
|
+
* Angled hero camera framing the expanded *deck* (SPEC.md §3, issue 302 §2). The
|
|
164
|
+
* distance is chosen — and the camera horizontally re-centered (panned) — by a
|
|
165
|
+
* deterministic bisection so that, in the projected image, the left margin (frame
|
|
166
|
+
* edge → leftmost plate) and the right margin (rightmost plate → frame edge) each
|
|
167
|
+
* equal the projected inter-plate gap (mean adjacent plate-center horizontal stagger).
|
|
168
|
+
* A vertical-fit floor keeps the deck from cropping top/bottom. Plate size = scene.size
|
|
169
|
+
* so JS and Python agree exactly (same iterations/brackets → identical numbers).
|
|
76
170
|
* Returns { position:[x,y,z], target:[x,y,z], fov, near }.
|
|
77
171
|
*/
|
|
78
|
-
export function expandedCamera(scene) {
|
|
172
|
+
export function expandedCamera(scene, viewportAspect) {
|
|
79
173
|
const cam = scene.camera;
|
|
80
174
|
const depth = stackDepth(scene, "expanded");
|
|
81
|
-
const
|
|
175
|
+
const baseTarget = [0.0, 0.0, -depth / 2.0];
|
|
82
176
|
|
|
83
177
|
// Direction target -> camera (azimuth swings toward -X, elevation lifts +Y).
|
|
84
178
|
const az = (cam.angle * Math.PI) / 180.0;
|
|
@@ -95,28 +189,107 @@ export function expandedCamera(scene) {
|
|
|
95
189
|
right = Math.abs(dot(look, upWorld)) < 0.999 ? normalize(right) : [1.0, 0.0, 0.0];
|
|
96
190
|
const up = normalize(cross(right, look));
|
|
97
191
|
|
|
192
|
+
const hfov = (cam.fov * Math.PI) / 180.0;
|
|
193
|
+
const aspect = viewportAspect || scene.size.width / scene.size.height;
|
|
194
|
+
const vfov = 2.0 * Math.atan(Math.tan(hfov / 2.0) / aspect);
|
|
195
|
+
const th = Math.tan(hfov / 2.0);
|
|
196
|
+
const tv = Math.tan(vfov / 2.0);
|
|
197
|
+
|
|
98
198
|
const halfWPlate = scene.size.width / 2.0;
|
|
99
199
|
const halfHPlate = scene.size.height / 2.0;
|
|
100
200
|
const zPositions = stackPositions(plateGaps(scene));
|
|
101
|
-
|
|
102
|
-
|
|
201
|
+
|
|
202
|
+
// Precompute each corner's (right, up, look) offsets relative to baseTarget so
|
|
203
|
+
// projecting at a candidate (distance D, horizontal pan) is cheap and exact.
|
|
204
|
+
// ndc_x = (cr - pan)/((cl + D)*th). Mirrors geometry.py expanded_camera.
|
|
205
|
+
const corners = []; // [cr, cu, cl]
|
|
206
|
+
const centers = []; // [cr, cl] of each plate center
|
|
103
207
|
for (const z of zPositions) {
|
|
208
|
+
const relC = sub([0.0, 0.0, z], baseTarget);
|
|
209
|
+
centers.push([dot(relC, right), dot(relC, look)]);
|
|
104
210
|
for (const sx of [-halfWPlate, halfWPlate]) {
|
|
105
211
|
for (const sy of [-halfHPlate, halfHPlate]) {
|
|
106
|
-
const rel = sub([sx, sy, z],
|
|
107
|
-
|
|
108
|
-
halfH = Math.max(halfH, Math.abs(dot(rel, up)));
|
|
212
|
+
const rel = sub([sx, sy, z], baseTarget);
|
|
213
|
+
corners.push([dot(rel, right), dot(rel, up), dot(rel, look)]);
|
|
109
214
|
}
|
|
110
215
|
}
|
|
111
216
|
}
|
|
112
217
|
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
218
|
+
const span = (D, pan) => {
|
|
219
|
+
let a = Infinity;
|
|
220
|
+
let b = -Infinity;
|
|
221
|
+
let ymax = 0.0;
|
|
222
|
+
for (const [cr, cu, cl] of corners) {
|
|
223
|
+
const zv = cl + D;
|
|
224
|
+
const nx = (cr - pan) / (zv * th);
|
|
225
|
+
a = Math.min(a, nx);
|
|
226
|
+
b = Math.max(b, nx);
|
|
227
|
+
ymax = Math.max(ymax, Math.abs(cu / (zv * tv)));
|
|
228
|
+
}
|
|
229
|
+
return [a, b, ymax];
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const gapOf = (D, pan) => {
|
|
233
|
+
const xs = centers.map(([cr, cl]) => (cr - pan) / ((cl + D) * th));
|
|
234
|
+
if (xs.length < 2) return 0.0;
|
|
235
|
+
let s = 0.0;
|
|
236
|
+
for (let i = 0; i < xs.length - 1; i++) s += Math.abs(xs[i + 1] - xs[i]);
|
|
237
|
+
return s / (xs.length - 1);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const recenter = (D) => {
|
|
241
|
+
let lo = -halfWPlate * 8.0;
|
|
242
|
+
let hi = halfWPlate * 8.0;
|
|
243
|
+
for (let i = 0; i < 64; i++) {
|
|
244
|
+
const pan = 0.5 * (lo + hi);
|
|
245
|
+
const [a, b] = span(D, pan);
|
|
246
|
+
if (a + b > 0.0) lo = pan;
|
|
247
|
+
else hi = pan;
|
|
248
|
+
}
|
|
249
|
+
return 0.5 * (lo + hi);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Bracket scale: the legacy bounding-box fit distance.
|
|
253
|
+
let halfW = 0.0;
|
|
254
|
+
let halfH = 0.0;
|
|
255
|
+
for (const [cr, cu] of corners) {
|
|
256
|
+
halfW = Math.max(halfW, Math.abs(cr));
|
|
257
|
+
halfH = Math.max(halfH, Math.abs(cu));
|
|
258
|
+
}
|
|
259
|
+
const d0 = Math.max(halfW / (FILL * th), halfH / (FILL * tv));
|
|
260
|
+
|
|
261
|
+
// margin grows with distance, gap shrinks => (margin - gap) increasing; bisect.
|
|
262
|
+
let lo = d0 * 0.1;
|
|
263
|
+
let hi = d0 * 20.0;
|
|
264
|
+
let distance = d0;
|
|
265
|
+
for (let i = 0; i < 80; i++) {
|
|
266
|
+
distance = 0.5 * (lo + hi);
|
|
267
|
+
const pan = recenter(distance);
|
|
268
|
+
const [a, b] = span(distance, pan);
|
|
269
|
+
const margin = 0.5 * (a + 1.0 + (1.0 - b));
|
|
270
|
+
if (margin - gapOf(distance, pan) > 0.0) hi = distance;
|
|
271
|
+
else lo = distance;
|
|
272
|
+
}
|
|
273
|
+
distance = 0.5 * (lo + hi);
|
|
274
|
+
|
|
275
|
+
// Vertical-fit floor: never let the gap pull the camera so close the deck crops.
|
|
276
|
+
let vlo = d0 * 0.05;
|
|
277
|
+
let vhi = d0 * 40.0;
|
|
278
|
+
for (let i = 0; i < 80; i++) {
|
|
279
|
+
const dv = 0.5 * (vlo + vhi);
|
|
280
|
+
const [, , ymax] = span(dv, 0.0); // vertical extent is independent of pan
|
|
281
|
+
if (ymax > V_FILL) vlo = dv;
|
|
282
|
+
else vhi = dv;
|
|
283
|
+
}
|
|
284
|
+
distance = Math.max(distance, 0.5 * (vlo + vhi));
|
|
285
|
+
|
|
286
|
+
const pan = recenter(distance);
|
|
119
287
|
const near = Math.max(1.0, distance * 0.005);
|
|
288
|
+
const target = [
|
|
289
|
+
baseTarget[0] + right[0] * pan,
|
|
290
|
+
baseTarget[1] + right[1] * pan,
|
|
291
|
+
baseTarget[2] + right[2] * pan,
|
|
292
|
+
];
|
|
120
293
|
const position = [
|
|
121
294
|
target[0] + toCam[0] * distance,
|
|
122
295
|
target[1] + toCam[1] * distance,
|
|
@@ -130,11 +303,39 @@ export function expandedCamera(scene) {
|
|
|
130
303
|
* width or absolute points; near plane scales with distance.
|
|
131
304
|
* Returns { position:[x,y,z], target:[x,y,z], fov, near }.
|
|
132
305
|
*/
|
|
133
|
-
export function compactCamera(scene) {
|
|
306
|
+
export function compactCamera(scene, viewportAspect) {
|
|
134
307
|
const cam = scene.camera;
|
|
135
308
|
const depth = stackDepth(scene, "compact");
|
|
136
309
|
const target = [0.0, 0.0, -depth / 2.0];
|
|
137
|
-
|
|
310
|
+
|
|
311
|
+
let isPercent = false;
|
|
312
|
+
let pctVal = 90.0;
|
|
313
|
+
if (typeof cam.distance === "string") {
|
|
314
|
+
const text = cam.distance.trim();
|
|
315
|
+
if (text.endsWith("%")) {
|
|
316
|
+
isPercent = true;
|
|
317
|
+
const parsed = parseFloat(text.slice(0, -1));
|
|
318
|
+
if (!isNaN(parsed)) pctVal = parsed;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let distance;
|
|
323
|
+
if (isPercent) {
|
|
324
|
+
// Dual-axis crop-free fit (SPEC.md §3, issue 302 §1): fit the frontmost plate
|
|
325
|
+
// (scene.size) so the limiting axis touches P% and the other axis only ever has
|
|
326
|
+
// extra padding (never a crop). distance = max(d_w, d_h). Mirrors geometry.py.
|
|
327
|
+
const hfov = (cam.fov * Math.PI) / 180.0;
|
|
328
|
+
const aspect = viewportAspect || scene.size.width / scene.size.height;
|
|
329
|
+
const vfov = 2.0 * Math.atan(Math.tan(hfov / 2.0) / aspect);
|
|
330
|
+
const frac = pctVal / 100.0;
|
|
331
|
+
const dW = scene.size.width / (2.0 * Math.tan(hfov / 2.0) * frac);
|
|
332
|
+
const dH = scene.size.height / (2.0 * Math.tan(vfov / 2.0) * frac);
|
|
333
|
+
const distToZ0 = Math.max(dW, dH);
|
|
334
|
+
distance = distToZ0 + depth / 2.0;
|
|
335
|
+
} else {
|
|
336
|
+
distance = parseDistance(cam.distance, scene.size.width);
|
|
337
|
+
}
|
|
338
|
+
|
|
138
339
|
const near = Math.max(1.0, distance * 0.005);
|
|
139
340
|
const position = [target[0], target[1], target[2] + distance];
|
|
140
341
|
return { position, target, fov: cam.fov, near };
|
|
@@ -169,6 +370,47 @@ export function interpolateOpacity(slide, tExpanded) {
|
|
|
169
370
|
return Math.max(0.0, Math.min(1.0, value));
|
|
170
371
|
}
|
|
171
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Per-slide caption opacity at morph factor tExpanded (0=compact, 1=expanded).
|
|
375
|
+
* Honors caption.show_in and the staggered fade (issue 302 §B.4): an `expanded`
|
|
376
|
+
* caption stays invisible until the final `window` fraction of the morph, then fades
|
|
377
|
+
* in — staggered back (index 0) → front so the frontmost reaches full opacity exactly
|
|
378
|
+
* at t=1 (full opacity ONLY in expanded). `both`→1, `none`→0, `compact`→fades out.
|
|
379
|
+
* Slides without a caption → 0. Mirrors caption_opacities in geometry.py.
|
|
380
|
+
*/
|
|
381
|
+
export function captionOpacities(scene, tExpanded) {
|
|
382
|
+
const t = Math.max(0.0, Math.min(1.0, tExpanded));
|
|
383
|
+
const cf = scene.caption_fade;
|
|
384
|
+
const window = cf ? cf.window : CAPTION_FADE_WINDOW;
|
|
385
|
+
const stagger = cf ? cf.stagger : CAPTION_STAGGER;
|
|
386
|
+
const n = scene.slides.length;
|
|
387
|
+
const denom = n > 1 ? n - 1 : 1;
|
|
388
|
+
|
|
389
|
+
// Total back->front spread (fraction of the morph). Default: `stagger` of the window.
|
|
390
|
+
// Issue 309: if stagger_frames is set + a transition exists, the per-caption step is
|
|
391
|
+
// that many frames of one leg; spread = (n-1) steps, capped so the frontmost finishes
|
|
392
|
+
// fading at t=1 (ramp stays positive).
|
|
393
|
+
let spread = stagger * window;
|
|
394
|
+
if (cf && cf.stagger_frames !== null && cf.stagger_frames !== undefined && scene.transition) {
|
|
395
|
+
const legFrames = Math.round(scene.transition.duration * scene.transition.fps);
|
|
396
|
+
if (legFrames > 0) {
|
|
397
|
+
const stepT = cf.stagger_frames / legFrames;
|
|
398
|
+
spread = Math.min((n - 1) * stepT, window * 0.95);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const ramp = Math.max(1e-6, window - spread);
|
|
402
|
+
|
|
403
|
+
return scene.slides.map((slide, i) => {
|
|
404
|
+
const cap = slide.caption;
|
|
405
|
+
if (!cap || cap.show_in === "none") return 0.0;
|
|
406
|
+
if (cap.show_in === "both") return 1.0;
|
|
407
|
+
if (cap.show_in === "compact") return 1.0 - t;
|
|
408
|
+
// expanded: staggered window fade-in, backmost (i=0) first, frontmost last.
|
|
409
|
+
const startI = 1.0 - window + (i / denom) * spread;
|
|
410
|
+
return Math.max(0.0, Math.min(1.0, (t - startI) / ramp));
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
172
414
|
function lerp3(a, b, t) {
|
|
173
415
|
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
|
174
416
|
}
|
|
@@ -187,7 +429,12 @@ function frameState(scene, compact, expanded, t) {
|
|
|
187
429
|
const expandedGaps = plateGaps(scene);
|
|
188
430
|
const gaps = expandedGaps.map((g) => MIN_GAP + (g - MIN_GAP) * t);
|
|
189
431
|
const opacities = scene.slides.map((s) => interpolateOpacity(s, t));
|
|
190
|
-
return {
|
|
432
|
+
return {
|
|
433
|
+
camera: poseAt(compact, expanded, t),
|
|
434
|
+
gaps,
|
|
435
|
+
opacities,
|
|
436
|
+
captionOpacities: captionOpacities(scene, t),
|
|
437
|
+
};
|
|
191
438
|
}
|
|
192
439
|
|
|
193
440
|
/**
|
|
@@ -198,9 +445,12 @@ function frameState(scene, compact, expanded, t) {
|
|
|
198
445
|
* @param {object} scene parsed scene
|
|
199
446
|
* @param {number} t eased morph factor
|
|
200
447
|
*/
|
|
201
|
-
export function frameStateAt(scene, t) {
|
|
202
|
-
|
|
203
|
-
|
|
448
|
+
export function frameStateAt(scene, t, viewportAspect) {
|
|
449
|
+
// viewportAspect (the live element's container aspect) defaults to the scene aspect, so
|
|
450
|
+
// the rendered/aspect-locked paths are unchanged; the scrollable passes its 2:1 container
|
|
451
|
+
// aspect so compact fits with side padding and expanded fills vertically (issue 314).
|
|
452
|
+
const compact = compactCamera(scene, viewportAspect);
|
|
453
|
+
const expanded = expandedCamera(scene, viewportAspect);
|
|
204
454
|
return frameState(scene, compact, expanded, Math.max(0, Math.min(1, t)));
|
|
205
455
|
}
|
|
206
456
|
|
package/src/index.js
CHANGED
|
@@ -49,8 +49,30 @@ export class VexyStax {
|
|
|
49
49
|
this.container = container;
|
|
50
50
|
this.scene = scene;
|
|
51
51
|
this.stage = new Stage(container, scene);
|
|
52
|
+
this._ro = null; // ResizeObserver for post-layout resize
|
|
52
53
|
this._ready = this.stage.init().then(() => {
|
|
53
54
|
this.stage.render();
|
|
55
|
+
// After mount, observe the container for its first actual layout dimensions.
|
|
56
|
+
// When the container uses height:auto + aspect-ratio, clientHeight is 0 at
|
|
57
|
+
// init time; the ResizeObserver fires once the CSS engine resolves the size,
|
|
58
|
+
// ensuring the canvas fits the container rather than the raw scene pixel size.
|
|
59
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
60
|
+
let observed = false;
|
|
61
|
+
this._ro = new ResizeObserver((entries) => {
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const w = entry.contentRect?.width;
|
|
64
|
+
const h = entry.contentRect?.height;
|
|
65
|
+
if (w > 0 && h > 0) {
|
|
66
|
+
this.stage.resize(w, h);
|
|
67
|
+
this.stage.render();
|
|
68
|
+
// After the first real resize we can stop if the element has a
|
|
69
|
+
// fixed CSS size; keep observing for responsive containers.
|
|
70
|
+
observed = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
this._ro.observe(container);
|
|
75
|
+
}
|
|
54
76
|
return this;
|
|
55
77
|
});
|
|
56
78
|
}
|
|
@@ -68,6 +90,18 @@ export class VexyStax {
|
|
|
68
90
|
return this;
|
|
69
91
|
}
|
|
70
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Apply an arbitrary morph factor `t` (0 = compact, 1 = expanded) and render. The
|
|
95
|
+
* low-level scrub primitive behind scroll-driven stories (e.g. the scrollable demo
|
|
96
|
+
* computes its own tent mapping and calls seek each scroll frame). Clamped to [0,1].
|
|
97
|
+
*/
|
|
98
|
+
seek(t) {
|
|
99
|
+
const tt = Math.max(0, Math.min(1, Number(t) || 0));
|
|
100
|
+
this.stage.applyFrameState(frameStateAt(this.scene, tt, this.stage.camera.aspect), tt);
|
|
101
|
+
this.stage.render();
|
|
102
|
+
return this;
|
|
103
|
+
}
|
|
104
|
+
|
|
71
105
|
/** Resize the renderer/camera to the container (or explicit size). */
|
|
72
106
|
resize(width, height) {
|
|
73
107
|
const w = width ?? this.container.clientWidth;
|
|
@@ -127,7 +161,7 @@ export class VexyStax {
|
|
|
127
161
|
}
|
|
128
162
|
// Snap to the starting endpoint so the first frame is correct.
|
|
129
163
|
const { startMorph } = transitionEndpoints(resolvedKind);
|
|
130
|
-
this.stage.applyFrameState(frameStateAt(this.scene, startMorph), startMorph);
|
|
164
|
+
this.stage.applyFrameState(frameStateAt(this.scene, startMorph, this.stage.camera.aspect), startMorph);
|
|
131
165
|
this.stage.render();
|
|
132
166
|
|
|
133
167
|
this._cancelTransition?.();
|
|
@@ -213,9 +247,14 @@ export class VexyStax {
|
|
|
213
247
|
const wait = this.scene.transition?.wait ?? 0.0;
|
|
214
248
|
|
|
215
249
|
const apply = (p) => {
|
|
216
|
-
// Map
|
|
217
|
-
|
|
218
|
-
|
|
250
|
+
// Map scroll progress [0,1] -> morph t. By default through the transition
|
|
251
|
+
// timeline; a custom `opts.map` (p -> t) enables non-linear scroll stories such
|
|
252
|
+
// as the scrollable demo's tent (compact at the edges, expanded when centered).
|
|
253
|
+
const t =
|
|
254
|
+
typeof opts.map === "function"
|
|
255
|
+
? Math.max(0, Math.min(1, opts.map(p)))
|
|
256
|
+
: this._scrollMorph(kind, p, easing, duration, wait);
|
|
257
|
+
this.stage.applyFrameState(frameStateAt(this.scene, t, this.stage.camera.aspect), t);
|
|
219
258
|
this.stage.render();
|
|
220
259
|
};
|
|
221
260
|
|
|
@@ -241,6 +280,8 @@ export class VexyStax {
|
|
|
241
280
|
destroy() {
|
|
242
281
|
this._cancelTransition?.();
|
|
243
282
|
this._scrollspy?.disconnect?.();
|
|
283
|
+
this._ro?.disconnect?.();
|
|
284
|
+
this._ro = null;
|
|
244
285
|
this.stage?.dispose();
|
|
245
286
|
}
|
|
246
287
|
}
|