craft-library 0.4.0 → 0.6.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.
@@ -0,0 +1,496 @@
1
+ // Panorama · Sara Lobo Library
2
+ // A hero for moving pictures. A clip opens full-bleed under letterbox bars while the title tracks in; then the clip shrinks
3
+ // into one screen of a dome of monitors that surrounds you, and the other screens arrive in a wave around it. The pointer
4
+ // turns your head. The dome is black and white: colour lives only in a soft circle around the pointer, and the screen under
5
+ // it lights the dark with its own colours while focus brackets lock on and name it.
6
+ //
7
+ // import { mountPanorama } from './panorama.js';
8
+ // const hero = mountPanorama(document.querySelector('#hero'), { // fills the element (give it a size)
9
+ // title: 'Lorem ipsum',
10
+ // text: 'Dolor sit amet,', textItalic: 'consectetur adipiscing.',
11
+ // video: { src: 'reel.mp4', poster: 'poster.webp', title: 'Wild Grass', year: 2026 },
12
+ // stills: [{ src: 'still-01.webp', title: 'The Coast Road', year: 2025 }, ...], // up to 16, cropped to 16:10
13
+ // });
14
+ // hero.replay(); hero.destroy();
15
+ //
16
+ // WebGL, no dependencies. The pictures and the clip come from the same site as the page, or from one that allows CORS.
17
+
18
+ const DEFAULTS = {
19
+ title: 'Lorem ipsum',
20
+ text: 'Dolor sit amet,',
21
+ textItalic: 'consectetur adipiscing.', // set in the display face, after text
22
+ video: null, // { src, poster, title, year }: the opening clip, then the screen in the middle of the dome
23
+ stills: [], // [{ src, title, year }]: the other screens, repeated around the dome
24
+ labels: true, // "CAM 07" on each screen
25
+ intro: true, // the opening plays the first time the dome comes on screen; false starts it settled
26
+ cursor: true, // the dot-and-ring pointer (only for a mouse or trackpad)
27
+ fonts: true, // load Cormorant Garamond, Geist and Geist Mono from Google Fonts
28
+ };
29
+
30
+ const FONTS = 'https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;1,300&family=Geist:wght@400;500&family=Geist+Mono:wght@400;500&display=swap';
31
+ const HOLD = 3.3, MORPH_V = 1.7, MORPH = 1.45; // s: the clip alone, the clip to its screen, each other screen in
32
+ const AR = 1.6; // every screen is 16:10
33
+ const D2R = Math.PI / 180, R2D = 180 / Math.PI;
34
+
35
+ const CSS = `
36
+ .cup { --cup-ink: #0b0a09; --cup-paper: #ece7de; --cup-soft: cubic-bezier(.22, .61, .21, 1);
37
+ --cup-display: "Cormorant Garamond", "Cormorant", Garamond, "Times New Roman", serif;
38
+ --cup-ui: "Geist", -apple-system, "Helvetica Neue", Arial, sans-serif; --cup-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
39
+ position: relative; overflow: hidden; background: var(--cup-ink); color: var(--cup-paper); container-type: size; touch-action: pan-y;
40
+ -webkit-font-smoothing: antialiased; -webkit-user-select: none; user-select: none; }
41
+ .cup.cup-cur, .cup.cup-cur * { cursor: none; }
42
+ .cup-gl { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
43
+ .cup-src { position: absolute; left: 0; top: 0; width: 2px; height: 2px; opacity: 0; pointer-events: none; }
44
+ .cup-scrim { position: absolute; inset: 0; pointer-events: none; opacity: 0; transition: opacity 1.2s var(--cup-soft);
45
+ background: radial-gradient(34% 28% at 50% 50%, rgba(11, 10, 9, .9), rgba(11, 10, 9, .45) 60%, rgba(11, 10, 9, 0) 100%); }
46
+ .cup.is-settled .cup-scrim { opacity: 1; }
47
+
48
+ /* the title tracks in like an opening credit: the letters drift together from a wide spacing and out of a blur, centre
49
+ first; then a thin anamorphic streak crosses it and each letter catches the light as it passes */
50
+ .cup-title { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); text-align: center; white-space: nowrap; pointer-events: none; will-change: transform; }
51
+ /* padding-bottom keeps the descenders (the p of "ipsum") clear of the line below */
52
+ .cup-name { position: relative; margin: 0; padding-bottom: .15em; font-family: var(--cup-display); font-weight: 300; font-size: clamp(44px, 10.5cqw, 168px); line-height: .9; letter-spacing: -.01em; }
53
+ .cup-name span { display: inline-block; opacity: 0; transform: translateX(calc(var(--o) * .42em)); filter: blur(14px);
54
+ transition: opacity 1.6s var(--cup-soft) var(--d), transform 2.8s cubic-bezier(.16, 1, .3, 1) var(--d), filter 2.2s var(--cup-soft) var(--d); }
55
+ .cup-title.is-in .cup-name span { opacity: 1; transform: none; filter: blur(0); animation: cup-glint 1.5s ease-in-out var(--g) both; }
56
+ @keyframes cup-glint { 0%, 100% { text-shadow: 0 0 0 rgba(255, 238, 210, 0); } 45% { text-shadow: 0 0 22px rgba(255, 238, 210, .6), 0 0 2px rgba(255, 250, 240, .85); } }
57
+ .cup-flare { position: absolute; left: -20%; right: -20%; top: 54%; height: 1px; pointer-events: none; opacity: 0;
58
+ background: linear-gradient(90deg, transparent, rgba(255, 240, 215, .9) 50%, transparent); box-shadow: 0 0 14px 2px rgba(255, 226, 180, .3); }
59
+ .cup-title.is-in .cup-flare { animation: cup-flare 2.4s cubic-bezier(.45, 0, .2, 1) 1.5s both; }
60
+ @keyframes cup-flare { 0% { opacity: 0; transform: translateX(-55%) scaleX(.35); } 25% { opacity: 1; } 75% { opacity: .85; } 100% { opacity: 0; transform: translateX(55%) scaleX(.35); } }
61
+ .cup-text { margin: 18px 0 0; font: 400 clamp(13px, 1.15cqw, 16px)/1.4 var(--cup-ui); letter-spacing: .01em; opacity: .82; }
62
+ .cup-text em { font-family: var(--cup-display); font-size: 1.32em; }
63
+ /* each word rises from its own mask: a clip-path, not overflow, so every word keeps its own baseline (both faces sit on
64
+ one line), and wider than the word so the italic's leaning strokes stay whole */
65
+ .cup-text .w { display: inline-block; clip-path: inset(0 -.25em); }
66
+ .cup-text .w > span { display: inline-block; transform: translateY(110%); transition: transform 1.4s cubic-bezier(.16, 1, .3, 1) var(--d); }
67
+ .cup-title.is-in .cup-text .w > span { transform: none; }
68
+ .cup-title.is-reset, .cup-title.is-reset * { transition: none !important; animation: none !important; }
69
+
70
+ .cup-bars i { position: absolute; left: 0; right: 0; height: var(--cup-bar, 0px); background: #000; transition: transform 1.3s var(--cup-soft); }
71
+ .cup-bars i:first-child { top: 0; } .cup-bars i:last-child { bottom: 0; }
72
+ .cup.is-morph .cup-bars i:first-child { transform: translateY(-101%); } .cup.is-morph .cup-bars i:last-child { transform: translateY(101%); }
73
+
74
+ /* focus brackets on the screen under the pointer, and its name */
75
+ .cup-aim { position: absolute; left: 0; top: 0; pointer-events: none; opacity: 0; will-change: transform, width, height; }
76
+ .cup-aim > i { position: absolute; width: 16px; height: 16px; border: 0 solid var(--cup-paper); }
77
+ .cup-aim > i:nth-child(1) { left: -7px; top: -7px; border-width: 1px 0 0 1px; }
78
+ .cup-aim > i:nth-child(2) { right: -7px; top: -7px; border-width: 1px 1px 0 0; }
79
+ .cup-aim > i:nth-child(3) { left: -7px; bottom: -7px; border-width: 0 0 1px 1px; }
80
+ .cup-aim > i:nth-child(4) { right: -7px; bottom: -7px; border-width: 0 1px 1px 0; }
81
+ .cup-tag { position: absolute; left: -7px; top: calc(100% + 18px); white-space: nowrap; }
82
+ .cup-tag em { display: block; font: italic 300 28px/1 var(--cup-display); text-shadow: 0 1px 12px rgba(0, 0, 0, .6); }
83
+ .cup-tag small { display: block; margin-top: 8px; font: 10.5px/1 var(--cup-mono); letter-spacing: .1em; opacity: .6; }
84
+ .cup-tag.is-new em, .cup-tag.is-new small { animation: cup-tag .8s var(--cup-soft) both; }
85
+ .cup-tag.is-new small { animation-delay: .1s; }
86
+ @keyframes cup-tag { from { opacity: 0; transform: translateY(8px); filter: blur(5px); } }
87
+
88
+ /* the pointer: a dot, and a ring that trails it and stretches with speed */
89
+ .cup-cursor { position: absolute; left: 0; top: 0; pointer-events: none; opacity: 0; transition: opacity .35s var(--cup-soft); }
90
+ .cup-cursor.is-on { opacity: 1; }
91
+ .cup-cursor i { position: absolute; left: 0; top: 0; border-radius: 50%; will-change: transform; }
92
+ .cup-dot { width: 6px; height: 6px; margin: -3px 0 0 -3px; background: var(--cup-paper); }
93
+ .cup-ring { width: 38px; height: 38px; margin: -19px 0 0 -19px; border: 1px solid rgba(236, 231, 222, .5); }
94
+
95
+ @media (prefers-reduced-motion: reduce) {
96
+ .cup-name span, .cup-text .w > span, .cup-bars i, .cup-scrim { transition: none; }
97
+ .cup-title.is-in .cup-name span, .cup-title.is-in .cup-flare { animation: none; }
98
+ }`;
99
+
100
+ const VS = `
101
+ attribute vec3 aPos; attribute vec2 aUv; attribute vec4 aRect; attribute vec4 aMisc; attribute vec2 aPan; attribute vec2 aLab; attribute float aGlow;
102
+ uniform vec2 uHalf; uniform float uP;
103
+ varying vec2 vUv; varying vec4 vRect; varying vec4 vMisc; varying vec2 vPan; varying vec2 vLab; varying float vGlow;
104
+ void main() {
105
+ vUv = aUv; vRect = aRect; vMisc = aMisc; vPan = aPan; vLab = aLab; vGlow = aGlow;
106
+ gl_Position = vec4(aPos.x / uHalf.x, -aPos.y / uHalf.y, 0.0, (uP - aPos.z) / uP); // CSS-style perspective, divided on the GPU
107
+ }`;
108
+
109
+ const FS = `
110
+ #extension GL_OES_standard_derivatives : enable
111
+ precision mediump float;
112
+ varying vec2 vUv; varying vec4 vRect; varying vec4 vMisc; varying vec2 vPan; varying vec2 vLab; varying float vGlow;
113
+ uniform sampler2D uAtlas; uniform sampler2D uVideo; uniform sampler2D uLabels; uniform float uLab;
114
+ uniform highp vec2 uView; uniform highp float uDpr; uniform highp vec4 uColor;
115
+ vec3 grade(vec3 c) { // black and white, colour in a soft circle at the pointer
116
+ if (uColor.w <= 0.0) return c;
117
+ highp vec2 sp = vec2(gl_FragCoord.x / uDpr - uView.x, uView.y - gl_FragCoord.y / uDpr);
118
+ float k = 1.0 - smoothstep(uColor.z * 0.4, uColor.z, length(sp - uColor.xy));
119
+ vec3 g = vec3(dot(c, vec3(0.299, 0.587, 0.114))) * vec3(1.02, 1.0, 0.96);
120
+ return mix(c, mix(g * 0.92, c * (1.0 + 0.08 * k), k), uColor.w);
121
+ }
122
+ void main() {
123
+ vec2 uv = clamp((vUv - 0.5) / 1.16 + 0.5 + vPan, 0.002, 0.998); // the picture a little larger, panned
124
+ vec2 t = vRect.xy + uv * vRect.zw;
125
+ vec3 col = vMisc.z > 0.5 ? texture2D(uVideo, t).rgb : texture2D(uAtlas, t).rgb;
126
+ vec2 p = (vUv - 0.5) * vec2(1.6, 1.0);
127
+ float kb = vMisc.w, r = 0.035 * kb;
128
+ vec2 q = abs(p) - vec2(0.8, 0.5) + r;
129
+ float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; // a rounded screen
130
+ col *= mix(0.5, 1.0, smoothstep(0.0, 0.14 * kb, -d)); // its dark inner edge
131
+ vec2 lp = (vUv - vec2(0.07, 0.836)) / vec2(0.42, 0.084);
132
+ if (uLab > 0.0 && lp.x > 0.0 && lp.x < 1.0 && lp.y > 0.0 && lp.y < 1.0) {
133
+ float a = texture2D(uLabels, vLab + lp * vec2(0.25, 0.03125)).a;
134
+ col = mix(col, vec3(0.925, 0.906, 0.871), a * 0.8 * uLab);
135
+ }
136
+ col *= 1.0 - vMisc.y;
137
+ col = grade(col);
138
+ float aa = fwidth(d);
139
+ float alpha = (1.0 - smoothstep(-aa, aa, d)) * vMisc.x;
140
+ vec4 outc = vec4(col * alpha, alpha);
141
+ if (vGlow > 0.001) { // the screen's own light on the dark around it
142
+ vec2 cu = clamp(vUv, 0.0, 1.0) * 0.86 + 0.07;
143
+ vec3 gc = vMisc.z > 0.5 ? texture2D(uVideo, vRect.xy + cu * vRect.zw).rgb : texture2D(uAtlas, vRect.xy + cu * vRect.zw, 5.0).rgb;
144
+ float ga = vGlow * 0.85 * exp(-max(d, 0.0) / (0.28 * kb)) * vMisc.x * (1.0 - alpha);
145
+ outc += vec4(grade(gc) * 1.6 * ga, ga);
146
+ }
147
+ gl_FragColor = outc;
148
+ }`;
149
+
150
+ const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
151
+ const lerp = (a, b, u) => a + (b - a) * u;
152
+ const ease = (u) => (u < .5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2); // in-out, both ends at rest
153
+ const sstep = (a, b, v) => { const u = clamp((v - a) / (b - a)); return u * u * (3 - 2 * u); };
154
+ const damp = (cur, to, rate, dt) => cur + (to - cur) * (1 - Math.exp(-rate * dt));
155
+ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
156
+ // rotate a point first about X (b), then about Y (a): the order of "rotateY(a) rotateX(b)" in CSS
157
+ function rot(x, y, z, a, b) {
158
+ const y1 = y * Math.cos(b) - z * Math.sin(b), z1 = y * Math.sin(b) + z * Math.cos(b);
159
+ return [x * Math.cos(a) + z1 * Math.sin(a), y1, -x * Math.sin(a) + z1 * Math.cos(a)];
160
+ }
161
+
162
+ let styled = false;
163
+ function injectStyle(fonts) {
164
+ if (styled) return; styled = true;
165
+ if (fonts && !document.querySelector(`link[href="${FONTS}"]`)) {
166
+ const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = FONTS; document.head.append(l);
167
+ }
168
+ const s = document.createElement('style'); s.textContent = CSS; document.head.append(s);
169
+ }
170
+
171
+ function markup(o) {
172
+ const ch = [...String(o.title)], mid = (ch.length - 1) / 2;
173
+ const letters = ch.map((c, i) => `<span aria-hidden="true" style="--o:${(i - mid).toFixed(1)};--d:${(Math.abs(i - mid) * .07).toFixed(2)}s;--g:${(1.5 + i / Math.max(1, ch.length - 1) * 1.2).toFixed(2)}s">${c === ' ' ? '&nbsp;' : esc(c)}</span>`).join('');
174
+ let k = 0;
175
+ const words = (s) => String(s || '').split(/\s+/).filter(Boolean).map((w) => `<span class="w"><span style="--d:${(1.7 + k++ * .07).toFixed(2)}s">${esc(w)}</span></span>`).join(' ');
176
+ const text = words(o.text) + (o.textItalic ? ` <em>${words(o.textItalic)}</em>` : '');
177
+ return `<canvas class="cup-gl" aria-hidden="true"></canvas>
178
+ <div class="cup-scrim"></div>
179
+ <div class="cup-title"><div class="cup-name" role="heading" aria-level="1" aria-label="${esc(o.title)}">${letters}<i class="cup-flare" aria-hidden="true"></i></div><p class="cup-text">${text}</p></div>
180
+ <div class="cup-bars" aria-hidden="true"><i></i><i></i></div>
181
+ <div class="cup-aim" aria-hidden="true"><i></i><i></i><i></i><i></i><div class="cup-tag"></div></div>
182
+ <div class="cup-cursor" aria-hidden="true"><i class="cup-ring"></i><i class="cup-dot"></i></div>`;
183
+ }
184
+
185
+ export function mountPanorama(root, options = {}) {
186
+ const o = { ...DEFAULTS, ...options };
187
+ injectStyle(o.fonts);
188
+ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
189
+ const fine = matchMedia('(pointer: fine)').matches;
190
+ root.classList.add('cup');
191
+ if (o.cursor && fine) root.classList.add('cup-cur');
192
+ root.innerHTML = markup(o);
193
+ const $ = (s) => root.querySelector(s);
194
+ const canvas = $('.cup-gl'), titleEl = $('.cup-title'), aim = $('.cup-aim'), tag = $('.cup-tag');
195
+ const cursor = $('.cup-cursor'), dot = $('.cup-dot'), ring = $('.cup-ring');
196
+
197
+ const stills = (o.stills || []).slice(0, 16);
198
+ let video = null;
199
+ if (o.video && o.video.src) {
200
+ video = document.createElement('video');
201
+ video.className = 'cup-src'; video.crossOrigin = 'anonymous';
202
+ Object.assign(video, { src: o.video.src, muted: true, loop: true, playsInline: true, preload: 'auto' });
203
+ if (o.video.poster) video.poster = o.video.poster;
204
+ video.setAttribute('muted', ''); video.setAttribute('playsinline', ''); video.setAttribute('aria-hidden', 'true');
205
+ root.append(video);
206
+ }
207
+ // screen 0 is the clip (or the first still); the rest repeat the stills, neighbours in the list kept apart
208
+ const S = Math.max(1, stills.length);
209
+ const filmFor = (i) => (i === 0 && video ? o.video : stills[(i * 7 + Math.floor(i / S) * 4) % S] || {});
210
+ const stillIndex = (i) => (i === 0 && video ? -1 : (i * 7 + Math.floor(i / S) * 4) % S);
211
+
212
+ // ---------- WebGL: every screen is one textured quad ----------
213
+ const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: true, antialias: true });
214
+ if (!gl || !gl.getExtension('OES_standard_derivatives')) {
215
+ // no WebGL: the opening picture and the title, still
216
+ const pic = (o.video && o.video.poster) || (stills[0] && stills[0].src);
217
+ if (pic) Object.assign(root.style, { backgroundImage: `url("${pic}")`, backgroundSize: 'cover', backgroundPosition: 'center' });
218
+ titleEl.classList.add('is-in');
219
+ return { replay() {}, destroy() { root.innerHTML = ''; root.classList.remove('cup', 'cup-cur'); } };
220
+ }
221
+ const compile = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)); return s; };
222
+ const prog = gl.createProgram();
223
+ gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS)); gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FS));
224
+ gl.linkProgram(prog); gl.useProgram(prog);
225
+ const MAX = 100, F = 18; // screens at most, floats per vertex
226
+ const data = new Float32Array(MAX * 4 * F);
227
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
228
+ const idx = new Uint16Array(MAX * 6);
229
+ for (let i = 0; i < MAX; i++) idx.set([i * 4, i * 4 + 1, i * 4 + 2, i * 4, i * 4 + 2, i * 4 + 3], i * 6);
230
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, idx, gl.STATIC_DRAW);
231
+ [['aPos', 3, 0], ['aUv', 2, 3], ['aRect', 4, 5], ['aMisc', 4, 9], ['aPan', 2, 13], ['aLab', 2, 15], ['aGlow', 1, 17]].forEach(([name, size, off]) => {
232
+ const loc = gl.getAttribLocation(prog, name); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, size, gl.FLOAT, false, F * 4, off * 4);
233
+ });
234
+ const U = (n) => gl.getUniformLocation(prog, n);
235
+ const uHalf = U('uHalf'), uP = U('uP'), uLab = U('uLab'), uView = U('uView'), uDpr = U('uDpr'), uColor = U('uColor');
236
+ gl.uniform1i(U('uAtlas'), 0); gl.uniform1i(U('uVideo'), 1); gl.uniform1i(U('uLabels'), 2);
237
+ gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
238
+
239
+ const texture = (unit, mip) => {
240
+ const t = gl.createTexture(); gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, t);
241
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
242
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mip ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
243
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([21, 19, 17, 255]));
244
+ return { t, unit, mip };
245
+ };
246
+ const upload = (tx, src) => {
247
+ try {
248
+ gl.activeTexture(gl.TEXTURE0 + tx.unit); gl.bindTexture(gl.TEXTURE_2D, tx.t);
249
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src);
250
+ if (tx.mip) gl.generateMipmap(gl.TEXTURE_2D);
251
+ } catch (e) { console.warn('Panorama: a picture could not be read (another site without CORS?)', e); }
252
+ };
253
+ const tAtlas = texture(0, true), tVideo = texture(1, false), tLab = texture(2, true);
254
+
255
+ // the stills in one 2048 × 1024 sheet: 400 × 250 each, cropped to 16:10, with a gutter so the mipmaps don't bleed
256
+ const sheet = document.createElement('canvas'); sheet.width = 2048; sheet.height = 1024;
257
+ const sctx = sheet.getContext('2d'); sctx.fillStyle = '#151311'; sctx.fillRect(0, 0, 2048, 1024);
258
+ let pending = stills.length;
259
+ stills.forEach((f, j) => {
260
+ const im = new Image(); im.crossOrigin = 'anonymous'; im.decoding = 'async';
261
+ im.onload = im.onerror = () => {
262
+ if (im.naturalWidth) {
263
+ const iw = im.naturalWidth, ih = im.naturalHeight, sw = Math.min(iw, ih * AR), sh = sw / AR;
264
+ sctx.drawImage(im, (iw - sw) / 2, (ih - sh) / 2, sw, sh, (j % 4) * 512, Math.floor(j / 4) * 256, 400, 250);
265
+ }
266
+ if (--pending === 0) upload(tAtlas, sheet);
267
+ };
268
+ im.src = f.src;
269
+ });
270
+ const cellRect = (j) => [(j % 4) * .25, Math.floor(j / 4) * .25, 400 / 2048, 250 / 1024];
271
+ let videoRect = [.05, 0, .9, 1], lastVT = -1;
272
+ if (video) {
273
+ if (o.video.poster) { const p = new Image(); p.crossOrigin = 'anonymous'; p.onload = () => upload(tVideo, p); p.src = o.video.poster; }
274
+ video.addEventListener('loadedmetadata', () => {
275
+ const a = video.videoWidth / video.videoHeight;
276
+ videoRect = a > AR ? [(1 - AR / a) / 2, 0, AR / a, 1] : [0, (1 - a / AR) / 2, 1, a / AR];
277
+ });
278
+ }
279
+ let labelsReady = false;
280
+ function makeLabels() {
281
+ const c = document.createElement('canvas'); c.width = c.height = 1024;
282
+ const x = c.getContext('2d'); x.fillStyle = '#fff'; x.font = '500 19px "Geist Mono", ui-monospace, Menlo, monospace'; x.textBaseline = 'middle';
283
+ try { x.letterSpacing = '1.5px'; } catch (e) {}
284
+ for (let i = 0; i < 128; i++) {
285
+ const ox = (i % 4) * 256, oy = Math.floor(i / 4) * 32;
286
+ x.globalAlpha = .7; x.beginPath(); x.arc(ox + 9, oy + 16, 4, 0, 7); x.fill();
287
+ x.globalAlpha = 1; x.fillText('CAM ' + String(i + 1).padStart(2, '0'), ox + 22, oy + 17);
288
+ }
289
+ upload(tLab, c); labelsReady = true;
290
+ }
291
+
292
+ // ---------- the dome ----------
293
+ let W = 0, H = 0, P = 600, R = 0, tw = 0, cells = [], n = 0;
294
+ let g = new Float32Array(MAX), hv = new Float32Array(MAX);
295
+ function layout() {
296
+ W = root.clientWidth; H = root.clientHeight; if (!W || !H) return;
297
+ P = Math.max(W * .42, 340); R = Math.max(W, H * 1.3) * .95;
298
+ const C = W < 720 ? 7 : 11, Rw = 7, da = (W < 720 ? 18 : 15) * D2R, db = 13 * D2R;
299
+ cells = [];
300
+ for (let r = 0; r < Rw; r++) for (let c = 0; c < C; c++) cells.push([(c - (C - 1) / 2) * da, (r - (Rw - 1) / 2) * db]);
301
+ cells.sort((p, q) => Math.hypot(p[0], p[1]) - Math.hypot(q[0], q[1])); // the clip takes the middle screen
302
+ n = Math.min(MAX, cells.length); tw = R * da * .84;
303
+ const dpr = Math.min(devicePixelRatio || 1, 2);
304
+ canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
305
+ root.style.setProperty('--cup-bar', (W > H ? Math.max(0, (H - W / 2.39) / 2) : 0) + 'px');
306
+ }
307
+
308
+ // the pointer, relative to the dome's element
309
+ let px = 0, py = 0, inside = false, mx = 0, my = 0;
310
+ const onMove = (e) => {
311
+ const b = root.getBoundingClientRect();
312
+ px = e.clientX - b.left; py = e.clientY - b.top; inside = true;
313
+ mx = clamp(px / W * 2 - 1, -1, 1); my = clamp(py / H * 2 - 1, -1, 1);
314
+ };
315
+ const onLeave = () => { inside = false; mx = my = 0; };
316
+ root.addEventListener('pointermove', onMove);
317
+ root.addEventListener('pointerdown', onMove);
318
+ root.addEventListener('pointerleave', onLeave);
319
+ root.addEventListener('pointercancel', onLeave);
320
+
321
+ // ---------- time ----------
322
+ let t0 = 0, last = 0, raf = 0, visible = false, started = false, ready = false, settledAt = 0;
323
+ let headH = 0, headP = 0, clock = 0, fx = 0, fy = 0, on = 0, mono = 0, labOn = 0, hover = -1;
324
+ const AIM = { x: 0, y: 0, w: 0, h: 0, o: 0, idx: -1 }, CUR = { x: 0, y: 0, s: 1, d: 1, seen: false };
325
+
326
+ function begin() {
327
+ started = true;
328
+ titleEl.classList.add('is-reset'); titleEl.classList.remove('is-in'); void titleEl.offsetWidth; titleEl.classList.remove('is-reset');
329
+ root.classList.remove('is-morph', 'is-settled');
330
+ settledAt = 0; mono = 0; labOn = 0; hover = -1; g.fill(0); hv.fill(0);
331
+ if (video) { try { video.currentTime = 0; video.play().catch(() => {}); } catch (e) {} }
332
+ const skip = reduce || !o.intro;
333
+ t0 = performance.now() / 1000 - (skip ? HOLD + 4 : 0);
334
+ if (skip) { titleEl.classList.add('is-in'); root.classList.add('is-morph', 'is-settled'); settledAt = 1; }
335
+ else setTimeout(() => titleEl.classList.add('is-in'), 450);
336
+ }
337
+
338
+ function pose(i, dt) {
339
+ const [a, b] = cells[i];
340
+ let x = Math.sin(a) * Math.cos(b), y = Math.sin(b), z = -Math.cos(a) * Math.cos(b);
341
+ [x, y, z] = rot(x, y, z, headH, 0); [x, y, z] = rot(x, y, z, 0, -headP);
342
+ const fwd = -z, Xs = x / Math.max(.05, fwd) * P, Ys = y / Math.max(.05, fwd) * P;
343
+ const h = (hv[i] = damp(hv[i], hover === i ? 1 : 0, 6, dt));
344
+ const near = (g[i] = damp(g[i], Math.exp(-((Math.hypot(Xs - fx, Ys - fy) / 240) ** 2)) * on, 4, dt));
345
+ const r = R * (1 - .07 * near - .12 * h);
346
+ return { x: x * r, y: y * r, z: P + z * r, rx: Math.asin(clamp(y, -1, 1)) * R2D, ry: Math.atan2(-x, -z) * R2D,
347
+ w: tw * (1 + .08 * h), o: sstep(.3, .52, fwd), sh: clamp((1 - fwd) * 1.6) * .55 * (1 - .6 * near) * (1 - h),
348
+ ix: -Xs / W * 6, iy: -Ys / H * 6, glow: h };
349
+ }
350
+ const mix = (a, b, u) => { const r = {}; for (const k in b) r[k] = lerp(a[k] ?? b[k], b[k], u); return r; };
351
+ const CORNERS = [[-.5, -.5, 0, 0], [.5, -.5, 1, 0], [.5, .5, 1, 1], [-.5, .5, 0, 1]];
352
+
353
+ function item(i, q, kb) {
354
+ const w = q.w, h = w / AR, cx = Math.cos(q.rx * D2R), sx = Math.sin(q.rx * D2R), cy = Math.cos(q.ry * D2R), sy = Math.sin(q.ry * D2R);
355
+ const pts = [];
356
+ for (const [lx, ly] of CORNERS) { // rotateX, then rotateY, then translate
357
+ const X = lx * w, Y = ly * h * cx, Z = ly * h * sx;
358
+ pts.push(X * cy + Z * sy + q.x, Y + q.y, -X * sy + Z * cy + q.z);
359
+ }
360
+ // a glowing screen gets a larger quad to hold its light; the hit test keeps the screen itself
361
+ const m = q.glow > .001 ? .75 : 0, hp = pts.slice();
362
+ if (m) for (let k = 0; k < 3; k++) { const c = (pts[k] + pts[3 + k] + pts[6 + k] + pts[9 + k]) / 4; for (let v = 0; v < 4; v++) pts[v * 3 + k] = c + (pts[v * 3 + k] - c) * (1 + 2 * m); }
363
+ const si = stillIndex(i);
364
+ return { i, z: q.z, pts, hp, m, rect: si < 0 ? videoRect : cellRect(si), misc: [q.o, q.sh, si < 0 ? 1 : 0, kb], pan: [-q.ix / 116, -q.iy / 116], lab: [(i % 4) * .25, Math.floor(i / 4) / 32], glow: q.glow };
365
+ }
366
+
367
+ function draw(list) {
368
+ gl.viewport(0, 0, canvas.width, canvas.height); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
369
+ if (!labelsReady && o.labels) makeLabels();
370
+ if (video && video.readyState >= 2 && video.currentTime !== lastVT) { lastVT = video.currentTime; upload(tVideo, video); }
371
+ list.sort((a, b) => a.z - b.z); // back to front
372
+ let k = 0;
373
+ for (const it of list) for (let v = 0; v < 4; v++) {
374
+ const e = 1 + 2 * it.m;
375
+ data.set([it.pts[v * 3], it.pts[v * 3 + 1], it.pts[v * 3 + 2], .5 + (CORNERS[v][2] - .5) * e, .5 + (CORNERS[v][3] - .5) * e,
376
+ ...it.rect, ...it.misc, ...it.pan, ...it.lab, it.glow], k); k += F;
377
+ }
378
+ gl.bufferData(gl.ARRAY_BUFFER, data.subarray(0, k), gl.DYNAMIC_DRAW);
379
+ gl.uniform2f(uHalf, W / 2, H / 2); gl.uniform1f(uP, P); gl.uniform1f(uLab, o.labels ? labOn : 0);
380
+ gl.uniform2f(uView, W / 2, H / 2); gl.uniform1f(uDpr, canvas.width / W);
381
+ gl.uniform4f(uColor, fx, fy, Math.min(W, H) * .3 * (.35 + .65 * on), mono);
382
+ [tAtlas, tVideo, tLab].forEach((tx) => { gl.activeTexture(gl.TEXTURE0 + tx.unit); gl.bindTexture(gl.TEXTURE_2D, tx.t); });
383
+ gl.drawElements(gl.TRIANGLES, list.length * 6, gl.UNSIGNED_SHORT, 0);
384
+ }
385
+
386
+ const project = (it) => {
387
+ const s = [];
388
+ for (let v = 0; v < 4; v++) { const d = P - it.hp[v * 3 + 2]; if (d <= 1) return null; s.push(it.hp[v * 3] * P / d, it.hp[v * 3 + 1] * P / d); }
389
+ return s;
390
+ };
391
+ function hit(list, x, y) { // the front-most screen under the pointer
392
+ for (let j = list.length - 1; j >= 0; j--) {
393
+ const it = list[j]; if (it.misc[0] < .3) continue;
394
+ const s = project(it); if (!s) continue;
395
+ let pos = 0, neg = 0;
396
+ for (let v = 0; v < 4; v++) { const ax = s[v * 2], ay = s[v * 2 + 1], bx = s[(v + 1) % 4 * 2], by = s[(v + 1) % 4 * 2 + 1]; if ((bx - ax) * (y - ay) - (by - ay) * (x - ax) > 0) pos++; else neg++; }
397
+ if (!pos || !neg) return it;
398
+ }
399
+ return null;
400
+ }
401
+ function aimAt(it, dt) {
402
+ let r = null;
403
+ if (it) { const s = project(it); if (s) { const xs = [s[0], s[2], s[4], s[6]].map((v) => v + W / 2), ys = [s[1], s[3], s[5], s[7]].map((v) => v + H / 2); r = { x: Math.min(...xs), y: Math.min(...ys), w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) }; } }
404
+ const i = it && r ? it.i : -1;
405
+ if (i !== AIM.idx) {
406
+ if (i >= 0) {
407
+ const f = filmFor(i);
408
+ tag.innerHTML = `<em>${esc(f.title || '')}</em><small>${o.labels ? `CAM ${String(i + 1).padStart(2, '0')}` : ''}${o.labels && f.year ? ' · ' : ''}${esc(f.year || '')}</small>`;
409
+ tag.classList.remove('is-new'); void tag.offsetWidth; tag.classList.add('is-new');
410
+ if (AIM.o < .05) Object.assign(AIM, { x: r.x - r.w * .1, y: r.y - r.h * .1, w: r.w * 1.2, h: r.h * 1.2 }); // lock on from a little wider
411
+ }
412
+ AIM.idx = i;
413
+ }
414
+ if (r) { AIM.x = damp(AIM.x, r.x, 11, dt); AIM.y = damp(AIM.y, r.y, 11, dt); AIM.w = damp(AIM.w, r.w, 11, dt); AIM.h = damp(AIM.h, r.h, 11, dt); }
415
+ AIM.o = damp(AIM.o, r ? 1 : 0, r ? 9 : 5, dt);
416
+ aim.style.opacity = AIM.o.toFixed(3);
417
+ aim.style.transform = `translate(${AIM.x.toFixed(1)}px,${AIM.y.toFixed(1)}px)`;
418
+ aim.style.width = AIM.w.toFixed(1) + 'px'; aim.style.height = AIM.h.toFixed(1) + 'px';
419
+ }
420
+ function cursorStep(dt) {
421
+ const show = o.cursor && fine && inside;
422
+ cursor.classList.toggle('is-on', show);
423
+ if (!show) { CUR.seen = false; return; }
424
+ if (!CUR.seen) { CUR.x = px; CUR.y = py; CUR.seen = true; }
425
+ CUR.x = damp(CUR.x, px, 15, dt); CUR.y = damp(CUR.y, py, 15, dt);
426
+ const vx = px - CUR.x, vy = py - CUR.y, sp = Math.min(.4, Math.hypot(vx, vy) / 110);
427
+ CUR.s = damp(CUR.s, hover >= 0 ? .42 : 1, 10, dt); CUR.d = damp(CUR.d, hover >= 0 ? 1.35 : 1, 10, dt);
428
+ dot.style.transform = `translate(${px.toFixed(1)}px,${py.toFixed(1)}px) scale(${CUR.d.toFixed(3)})`;
429
+ ring.style.transform = `translate(${CUR.x.toFixed(1)}px,${CUR.y.toFixed(1)}px) rotate(${Math.atan2(vy, vx).toFixed(3)}rad) scale(${(CUR.s * (1 + sp)).toFixed(3)},${(CUR.s * (1 - sp * .45)).toFixed(3)})`;
430
+ }
431
+
432
+ const list = [];
433
+ function frame(nowMs) {
434
+ raf = 0;
435
+ if (!visible) return;
436
+ const now = nowMs / 1000, dt = Math.min(.05, now - (last || now)); last = now;
437
+ if (!started && ready) begin();
438
+ if (!started || !n) { raf = requestAnimationFrame(frame); return; }
439
+ const t = now - t0;
440
+ clock += dt;
441
+ headH = damp(headH, mx * .42 + (reduce ? 0 : Math.sin(clock * .13) * .05), 3.2, dt); headP = damp(headP, my * .26, 3.2, dt);
442
+ const live = inside && settledAt;
443
+ if (live) { fx = damp(fx, px - W / 2, 9, dt); fy = damp(fy, py - H / 2, 9, dt); }
444
+ on = damp(on, live ? 1 : 0, 2.4, dt);
445
+ mono = damp(mono, settledAt ? 1 : 0, 1.4, dt);
446
+ labOn = damp(labOn, settledAt ? 1 : 0, 2.5, dt);
447
+ if (t > HOLD - .05) root.classList.add('is-morph');
448
+ if (t > HOLD + MORPH_V + .4 && !settledAt) { settledAt = now; root.classList.add('is-settled'); }
449
+
450
+ list.length = 0;
451
+ const diag = Math.hypot(W, H);
452
+ let p0 = null, u0 = 1;
453
+ for (let i = 0; i < n; i++) {
454
+ const p = pose(i, dt);
455
+ let q;
456
+ if (i === 0) {
457
+ p0 = p; u0 = ease(clamp((t - HOLD) / MORPH_V));
458
+ q = mix({ x: 0, y: 0, z: 0, rx: 0, ry: 0, w: Math.max(W, H * AR), o: 1, sh: 0, ix: 0, iy: 0, glow: 0 }, p, u0);
459
+ } else {
460
+ // the rest arrive in a wave that starts at the clip's screen
461
+ const u = ease(clamp((t - HOLD - .3 - Math.hypot(p.x - p0.x, p.y - p0.y) / diag * 1.1) / MORPH));
462
+ q = u >= 1 ? p : mix({ ...p, z: p.z - 700, o: 0 }, p, u);
463
+ }
464
+ if (q.o > .002) list.push(item(i, q, i === 0 ? lerp(200 / (Math.max(W, H * AR) / AR), 1, u0) : 1));
465
+ }
466
+ draw(list);
467
+ const it = inside && settledAt ? hit(list, px - W / 2, py - H / 2) : null;
468
+ hover = it ? it.i : -1;
469
+ aimAt(it, dt);
470
+ cursorStep(dt);
471
+ titleEl.style.transform = `translate(-50%, -50%) scale(${lerp(1, .9, ease(clamp((t - HOLD - .15) / 1.7))).toFixed(4)})`;
472
+ raf = requestAnimationFrame(frame);
473
+ }
474
+ const wake = () => { if (!raf && visible) { last = 0; raf = requestAnimationFrame(frame); } };
475
+
476
+ // runs only while on screen; the opening waits for the fonts and for the clip to be playable (or 2.2 s)
477
+ const io = new IntersectionObserver((es) => { visible = es[0].isIntersecting; wake(); });
478
+ io.observe(root);
479
+ const ro = new ResizeObserver(() => { layout(); wake(); });
480
+ ro.observe(root);
481
+ layout();
482
+ Promise.all([document.fonts ? document.fonts.ready : null, video ? new Promise((r) => { video.addEventListener('canplay', r, { once: true }); setTimeout(r, 2200); }) : null])
483
+ .then(() => { ready = true; wake(); });
484
+
485
+ return {
486
+ replay() { if (ready) { begin(); wake(); } },
487
+ destroy() {
488
+ cancelAnimationFrame(raf); raf = 0; visible = false; io.disconnect(); ro.disconnect();
489
+ root.removeEventListener('pointermove', onMove); root.removeEventListener('pointerdown', onMove);
490
+ root.removeEventListener('pointerleave', onLeave); root.removeEventListener('pointercancel', onLeave);
491
+ if (video) { video.pause(); video.removeAttribute('src'); video.load(); }
492
+ const lose = gl.getExtension('WEBGL_lose_context'); if (lose) lose.loseContext();
493
+ root.innerHTML = ''; root.classList.remove('cup', 'cup-cur', 'is-morph', 'is-settled'); root.style.removeProperty('--cup-bar');
494
+ },
495
+ };
496
+ }
package/dist/piggy.cdn.js CHANGED
@@ -1,4 +1,4 @@
1
- // Piggy bank · Craft Library, by Sara Lobo
1
+ // Piggy bank · Sara Lobo Library
2
2
  // A congratulations moment in 3D: a frosted glass piggy bank hops in from the right with a full turn, lands on its hind
3
3
  // feet, nearly tips over and clacks back down, rocking like the rigid glass it is. Coins pop into the air above it and
4
4
  // drop in through the slot; each one knocks the glass, and its eyes close a little more until they curve, happy.
package/dist/piggy.js CHANGED
@@ -1,4 +1,4 @@
1
- // Piggy bank · Craft Library, by Sara Lobo
1
+ // Piggy bank · Sara Lobo Library
2
2
  // A congratulations moment in 3D: a frosted glass piggy bank hops in from the right with a full turn, lands on its hind
3
3
  // feet, nearly tips over and clacks back down, rocking like the rigid glass it is. Coins pop into the air above it and
4
4
  // drop in through the slot; each one knocks the glass, and its eyes close a little more until they curve, happy.
@@ -0,0 +1,2 @@
1
+ // where the map pin comes from: for bundlers, the package's pin.js (it imports the `three` dependency)
2
+ export const loadPin = () => import('./pin.js');
@@ -0,0 +1,30 @@
1
+ // Map pin: the mount around the library's pin.js. pin.js, and three.js with it, loads only when a map pin is mounted,
2
+ // so the other pieces stay light.
3
+ import { loadPin } from './pin-load.js';
4
+
5
+ // host: the element the map fills (give it a size; it is made for about 560 × 560). o: { labels: { title, confirm,
6
+ // confirmed }, label (the map's accessible name), autoplay (false: the pin waits for replay()), fonts (false: don't load
7
+ // DM Sans), onChange(address), onConfirm(address) }; address: { address, neighbourhood, place }
8
+ export function mountMapPin(host, o = {}) {
9
+ let pin = null, gone = false, wantReplay = false;
10
+ const opts = { labels: o.labels, label: o.label, manual: o.manual };
11
+ if (o.autoplay === false) opts.autoplay = false;
12
+ if (o.fonts === false) opts.fonts = false;
13
+ const change = (e) => { if (o.onChange) o.onChange(e.detail); };
14
+ const confirm = (e) => { if (o.onConfirm) o.onConfirm(e.detail); };
15
+ host.addEventListener('pinchange', change); host.addEventListener('pinconfirm', confirm);
16
+ const ready = loadPin().then((m) => {
17
+ if (gone) return null;
18
+ pin = m.mountPin(host, opts);
19
+ if (wantReplay) pin.replay();
20
+ return pin.ready.then(() => pin);
21
+ });
22
+ return {
23
+ ready,
24
+ replay: () => { if (pin) pin.replay(); else wantReplay = true; },
25
+ confirm: () => { if (pin) pin.confirm(); },
26
+ tick: (dt, now) => { if (pin) pin.tick(dt, now); },
27
+ get address() { return pin ? pin.address : null; },
28
+ destroy() { gone = true; host.removeEventListener('pinchange', change); host.removeEventListener('pinconfirm', confirm); if (pin) pin.destroy(); pin = null; }
29
+ };
30
+ }