Download app.js from amilworks/pack3d: direct link, hf CLI and curl.
- Browser
- Download file 44.3 kB
-
https://huggingface.co/spaces/amilworks/pack3d/resolve/main/app.js
- Command line
-
hf download hf://spaces/amilworks/pack3d/app.js
-
curl -L -o app.js https://huggingface.co/spaces/amilworks/pack3d/resolve/main/app.js
44.3 kB
| // Generated by tools/build_hf_space.py from website/app.js. | |
| // Edit the website component, then regenerate this file. | |
| (() => { | |
| ; | |
| const D = {"repack":{"seeds":[13,29,41,53,67,83],"ax0":[0.8232041876661003,0.8233935994406542,0.8219183080748164,0.8238790235613092,0.8301391905794541,0.8255466957887013],"ax1":[0.8232724456635336,0.8233549147844315,0.8223302837069276,0.823957710136404,0.8301796226451794,0.8256099889365335],"pk0":[0.8253415346145629,0.8195828790465991,0.825005172441403,0.8185641605406999,0.8264244556427002,0.8223262195204751],"pk1":[0.7731130219375094,0.7535358890270193,0.7723392908771832,0.7799754473112029,0.7801123619700472,0.7906655114804761],"chg_ax":[0.0059668223063151045,0.008325576782226562,0.0075499216715494795,0.006144841512044271,0.0062719980875651045,0.00698089599609375],"chg_pk":[1.1211490631103516,1.2609418233235676,1.41387939453125,1.150795618693034,1.2623087565104167,1.1498578389485676],"gap_training":-0.001806097217432523}}; | |
| const $ = (s, el = document) => el.querySelector(s); | |
| const $$ = (s, el = document) => Array.from(el.querySelectorAll(s)); | |
| const RM = window.matchMedia('(prefers-reduced-motion: reduce)').matches; | |
| // say "tap" where nothing can hover | |
| const HOVERLESS = window.matchMedia('(hover: none)').matches; | |
| const MINUS = '−'; | |
| const tok = (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim(); | |
| const isDark = () => { | |
| const t = document.documentElement.getAttribute('data-theme'); | |
| if (t === 'dark' || t === 'light') return t === 'dark'; | |
| return window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| }; | |
| const sgn = (v, d) => (v < 0 ? MINUS : '+') + Math.abs(v).toFixed(d); | |
| const fix = (v, d) => (v < 0 ? MINUS : '') + Math.abs(v).toFixed(d); | |
| const mean = (a) => a.reduce((s, v) => s + v, 0) / a.length; | |
| const intf = (v) => Math.round(v).toLocaleString('en-US'); | |
| const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); | |
| // easeOut answers the reader, as --ease does; ease is for motion nobody asked for | |
| const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2); | |
| const easeOut = (t) => 1 - Math.pow(1 - t, 5); | |
| function hex2rgb(h) { | |
| h = h.replace('#', ''); | |
| if (h.length === 3) h = h.split('').map((c) => c + c).join(''); | |
| return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255); | |
| } | |
| const toLin = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); | |
| const toGam = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055); | |
| function rgb2lab(rgb) { | |
| const [r, g, b] = rgb.map(toLin); | |
| const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); | |
| const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); | |
| const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); | |
| return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, | |
| 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, | |
| 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s]; | |
| } | |
| function lab2rgb(lab) { | |
| const [L, a, b] = lab; | |
| const l = Math.pow(L + 0.3963377774 * a + 0.2158037573 * b, 3); | |
| const m = Math.pow(L - 0.1055613458 * a - 0.0638541728 * b, 3); | |
| const s = Math.pow(L - 0.0894841775 * a - 1.291485548 * b, 3); | |
| return [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, | |
| -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, | |
| -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s].map((c) => clamp(toGam(clamp(c, 0, 1)), 0, 1)); | |
| } | |
| const lab = (hex) => rgb2lab(hex2rgb(hex)); | |
| const mixLab = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; | |
| const css = (labv) => { const c = lab2rgb(labv); return `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`; }; | |
| function ramp(hexes) { | |
| const L = hexes.map(lab); | |
| return (t) => { | |
| t = clamp(t, 0, 1); | |
| const x = t * (L.length - 1), i = Math.min(L.length - 2, Math.floor(x)); | |
| return mixLab(L[i], L[i + 1], x - i); | |
| }; | |
| } | |
| const RAMPS = { | |
| geo: () => ramp(isDark() ? ['#0C3B32', '#0D6B58', '#18A385', '#62D7BC', '#C6F4E9'] : ['#E2F6F0', '#9DE2D0', '#3DC6A6', '#009078', '#00493A']), | |
| // memory order (--mem-0 to --mem-4): the opening and the end | |
| mem: () => ramp([0, 1, 2, 3, 4].map((i) => tok('--mem-' + i))), | |
| }; | |
| // "r,g,b", for a canvas color with an alpha of its own | |
| const rgbOf = (hex) => hex2rgb(hex).map((c) => Math.round(c * 255)).join(','); | |
| const motion = (() => { | |
| let paused = RM; | |
| try { if (!RM && localStorage.getItem('pack3d-motion') === 'paused') paused = true; } catch (e) { /* storage blocked */ } | |
| const cbs = []; | |
| const label = () => $$('[data-motion]').forEach((b) => { b.textContent = paused ? 'Play motion' : 'Pause motion'; }); | |
| const api = { | |
| get paused() { return paused; }, | |
| set(p) { | |
| p = !!p; | |
| if (p === paused) return; | |
| paused = p; | |
| try { localStorage.setItem('pack3d-motion', p ? 'paused' : 'on'); } catch (e) { /* storage blocked */ } | |
| label(); | |
| cbs.forEach((f) => f(p)); | |
| }, | |
| on(cb) { cbs.push(cb); }, | |
| }; | |
| document.addEventListener('click', (ev) => { if (ev.target.closest('[data-motion]')) api.set(!paused); }); | |
| label(); | |
| return api; | |
| })(); | |
| const themeCbs = []; | |
| function onTheme(cb) { themeCbs.push(cb); } | |
| const themeChanged = () => themeCbs.forEach((f) => { try { f(); } catch (e) { console.error(e); } }); | |
| window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', themeChanged); | |
| const themeWatch = new MutationObserver(themeChanged); | |
| themeWatch.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); | |
| // set the page's theme ('light', 'dark', or null for the system's), every figure repainted before this returns | |
| function setTheme(t) { | |
| const h = document.documentElement; | |
| if (h.getAttribute('data-theme') === t) return; | |
| if (t === null) h.removeAttribute('data-theme'); else h.setAttribute('data-theme', t); | |
| themeWatch.takeRecords(); | |
| themeChanged(); | |
| } | |
| // ====================================================================== device pixel ratio changes | |
| // a window dragged to a screen of another density keeps its CSS size, so no ResizeObserver fires | |
| const dprCbs = []; | |
| function onDpr(cb) { dprCbs.push(cb); } | |
| (function watchDpr() { | |
| const mq = window.matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`); | |
| const f = () => { mq.removeEventListener('change', f); dprCbs.forEach((cb) => cb()); watchDpr(); }; | |
| mq.addEventListener('change', f); | |
| })(); | |
| function initRepackStage() { | |
| const stage = $('#repackStage'), cellsEl = stage && $('.sw-cells', stage); | |
| let canvas = $('#swCanvas'); | |
| if (!stage || !cellsEl || !canvas) return; | |
| const R = D.repack, G = 64, NV = G * G * G; | |
| const rateAx = mean(R.chg_ax), ratePk = mean(R.chg_pk); | |
| const nAx = Math.round(rateAx / 100 * NV), nPk = Math.round(ratePk / 100 * NV); | |
| $('#swRA').textContent = `${rateAx.toFixed(4)}%`; | |
| $('#swRB').textContent = `${ratePk.toFixed(2)}%`; | |
| canvas.setAttribute('aria-label', `Two copies of one cell, ${intf(NV)} positions each, under the same repack. About ${nAx} predictions change under PACK3D, each circled, and about ${intf(nPk)} under packed RoPE. ${HOVERLESS ? 'Swipe sideways' : 'Drag'}, or use the arrow keys, to turn them.`); | |
| const go = $('#swGo'), stateEl = $('#swState'), stateT = $('#swStateT'), stateX = $('#swStateX'), cA = $('#swNA'), cB = $('#swNB'); | |
| const cells = [$('#swA'), $('#swB')]; | |
| let strips = [$('#swStripA'), $('#swStripB')]; | |
| cA.dataset.max = intf(nAx); cB.dataset.max = intf(nPk); // each count is as wide as its final value (the CSS reads it) | |
| // the theme's ink and the changes' colors, read again when the theme changes; a token that is not a hex color keeps | |
| // the light page's | |
| const hexOr = (c, alt) => { const v = hex2rgb(c || ''); return v.every((x) => x >= 0) ? v : alt; }; | |
| let INK = true, INKC = [0.067, 0.067, 0.067], PAPER = [1, 1, 1], SPARK = null; | |
| const colors = () => { | |
| INK = !isDark(); | |
| INKC = hexOr(tok('--ink'), [0.067, 0.067, 0.067]); | |
| PAPER = hexOr(tok('--surface'), [1, 1, 1]); | |
| SPARK = { ax: hexOr(tok('--spark-a'), [0, 0.584, 0.478]), pk: hexOr(tok('--spark-b'), [0.878, 0.31, 0.333]) }; | |
| }; | |
| colors(); | |
| // a new canvas in place of an old one, focus kept, and the old store let go at once (iOS counts a detached canvas | |
| // against its memory until it is collected) | |
| function swap(old) { | |
| const c = old.cloneNode(false), had = document.activeElement === old; | |
| old.replaceWith(c); | |
| old.width = old.height = 0; | |
| if (had) c.focus({ preventScroll: true }); | |
| return c; | |
| } | |
| // ---------------------------------------------------------------- a cell, as a segmented volume (made once) | |
| // It runs in a worker, from its own source, so it uses nothing from outside itself. Square roots, not | |
| // Math.hypot, whose rounding differs between engines: the same seed gives the same cell and sparks everywhere. | |
| function makeScene(G, nAx, nPk) { | |
| const NV = G * G * G; | |
| let seed = 7; | |
| const rnd = () => { seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; | |
| const wob = (x, y, z) => 0.5 * Math.sin(3.1 * x + 1.7) * Math.sin(2.6 * y + 0.4) * Math.sin(2.9 * z + 2.1) + 0.3 * Math.sin(5.3 * x + 1.1 * y + 2.0) * Math.cos(4.1 * z - 0.7 * y); | |
| const cls = new Uint8Array(NV); // 0 outside, 1 cytoplasm, 2 nucleus, 3 organelle | |
| const at = (x, y, z) => (z * G + y) * G + x, P = (v) => (v - G / 2 + 0.5) / (G / 2); | |
| const len = (a, b, c) => Math.sqrt(a * a + b * b + c * c); | |
| // the cell and its nucleus, two wobbly ellipsoids; the wobble moves their surfaces by less than 0.0561 | |
| // and 0.0401, so it is worked out only near them | |
| for (let z = 0; z < G; z++) for (let y = 0; y < G; y++) for (let x = 0; x < G; x++) { | |
| const px = P(x), py = P(y), pz = P(z), d = len(px / 0.93, py / 0.74, pz / 0.66); | |
| if (d >= 1.0561 || (d >= 0.9439 && d + 0.07 * wob(px, py, pz) >= 1)) continue; | |
| const e = len((px - 0.16) / 0.42, (py + 0.04) / 0.35, (pz - 0.02) / 0.36); | |
| cls[at(x, y, z)] = e < 0.9599 || (e < 1.0401 && e + 0.05 * wob(py, pz, px) < 1) ? 2 : 1; | |
| } | |
| for (let m = 0; m < 40; m++) { // mitochondria: short walks through the cytoplasm | |
| let x = 0, y = 0, z = 0, tries = 0; | |
| do { x = 4 + Math.floor(rnd() * (G - 8)); y = 4 + Math.floor(rnd() * (G - 8)); z = 4 + Math.floor(rnd() * (G - 8)); } while (cls[at(x, y, z)] !== 1 && ++tries < 300); | |
| let dx = rnd() - 0.5, dy = rnd() - 0.5, dz = (rnd() - 0.5) * 0.6; | |
| for (let s = 0; s < 14; s++) { | |
| const i = at(Math.round(x), Math.round(y), Math.round(z)); | |
| if (cls[i] !== 1) break; | |
| cls[i] = 3; | |
| dx += (rnd() - 0.5) * 0.5; dy += (rnd() - 0.5) * 0.5; | |
| const l = len(dx, dy, dz) || 1; x += dx / l; y += dy / l; z += (dz / l) * 0.8; | |
| } | |
| } | |
| // the classes counted along each row of G positions in memory, for the memory strips: a strip's pixels then mix | |
| // 4,096 rows on the main thread instead of reading all 262,144 positions | |
| const rows = new Uint8Array(NV / G * 4); | |
| for (let r = 0, i = 0; r < NV / G; r++) for (let x = 0; x < G; x++, i++) rows[r * 4 + cls[i]]++; | |
| // the six neighbours on the flat index (x ± 1, y ± G, z ± G²); past a face of the volume is outside | |
| const GG = G * G, M = G - 1; | |
| const differs = (i, x, y, z, c) => (x < M ? cls[i + 1] !== c : c !== 0) || (x > 0 ? cls[i - 1] !== c : c !== 0) | |
| || (y < M ? cls[i + G] !== c : c !== 0) || (y > 0 ? cls[i - G] !== c : c !== 0) | |
| || (z < M ? cls[i + GG] !== c : c !== 0) || (z > 0 ? cls[i - GG] !== c : c !== 0); | |
| const touches = (i, x, y, z, c) => (x < M && cls[i + 1] === c) || (x > 0 && cls[i - 1] === c) | |
| || (y < M && cls[i + G] === c) || (y > 0 && cls[i - G] === c) | |
| || (z < M && cls[i + GG] === c) || (z > 0 && cls[i - GG] === c); | |
| const memb = [], env = [], edge = [], pos = [], col = [], size = [], kind = []; | |
| const C = { cyto: [0.42, 0.30, 0.86], memb: [0.66, 0.54, 1.0], nuc: [0.08, 0.62, 0.52], env: [0.30, 0.92, 0.76], mito: [1.0, 0.86, 0.78] }; | |
| const KINDS = [[C.memb, 0.36], [C.cyto, 0.03], [C.env, 0.26], [C.nuc, 0.085], [C.mito, 0.4]]; | |
| const put = (x, y, z, kd, s) => { | |
| const j = () => (rnd() - 0.5) * 0.9 / (G / 2); | |
| const [rgb, k] = KINDS[kd]; | |
| pos.push(P(x) + j(), P(y) + j(), P(z) + j()); col.push(rgb[0] * k, rgb[1] * k, rgb[2] * k); size.push(s); kind.push(kd); | |
| }; | |
| for (let z = 0, i = 0; z < G; z++) for (let y = 0; y < G; y++) for (let x = 0; x < G; x++, i++) { | |
| const c = cls[i]; | |
| if (c === 0) { if (differs(i, x, y, z, 0)) edge.push([x, y, z]); continue; } | |
| if (c === 1) { | |
| if (differs(i, x, y, z, 1) && !touches(i, x, y, z, 2) && !touches(i, x, y, z, 3)) { memb.push([x, y, z]); if (rnd() < 0.55) put(x, y, z, 0, 1.5); } | |
| else if (rnd() < 0.08) put(x, y, z, 1, 3.4); | |
| } else if (c === 2) { | |
| if (differs(i, x, y, z, 2)) { env.push([x, y, z]); if (rnd() < 0.6) put(x, y, z, 2, 1.6); } | |
| else if (rnd() < 0.2) put(x, y, z, 3, 2.6); | |
| } else put(x, y, z, 4, 1.4); | |
| } | |
| // the predictions the repack changes, at the paper's rates over all 262,144 positions | |
| const pick = (list, k) => { const a = list.slice(), out = []; for (let i = 0; i < k && a.length; i++) out.push(a.splice(Math.floor(rnd() * a.length), 1)[0]); return out; }; | |
| const sparksPk = pick(memb.concat(env, edge), nPk), sparksAx = pick(env, nAx); | |
| const sparkSet = (list) => { | |
| const p = new Float32Array(list.length * 3), t = new Float32Array(list.length); | |
| list.forEach(([x, y, z], i) => { p.set([P(x), P(y), P(z)], i * 3); t[i] = rnd() * 0.75; }); | |
| return { n: list.length, pos: p, t }; | |
| }; | |
| return { | |
| rows, NP: size.length, pos: new Float32Array(pos), col: new Float32Array(col), size: new Float32Array(size), kind: new Uint8Array(kind), | |
| kinds: KINDS.map(([rgb, k]) => rgb.map((v) => v * k)), pk: sparkSet(sparksPk), ax: sparkSet(sparksAx), | |
| }; | |
| } | |
| // ---------------------------------------------------------------- the camera (shared by both renderers) | |
| const persp = (f, a, nn, fa) => { const t = 1 / Math.tan(f / 2); return [t / a, 0, 0, 0, 0, t, 0, 0, 0, 0, (fa + nn) / (nn - fa), -1, 0, 0, 2 * fa * nn / (nn - fa), 0]; }; | |
| const mul = (a, b) => { const o = new Array(16).fill(0); for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) o[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k]; return o; }; | |
| const viewM = (yaw, pitch, d) => { | |
| const cy = Math.cos(yaw), sy = Math.sin(yaw), cp = Math.cos(pitch), sp = Math.sin(pitch); | |
| return [cy, sp * sy, -cp * sy, 0, 0, cp, sp, 0, sy, -sp * cy, cp * cy, 0, 0, 0, -d, 1]; | |
| }; | |
| // one cell's viewport, in device pixels from the canvas's top left, and its camera | |
| function camera(v, dpr, yaw, pitch) { | |
| const w = v.w * dpr, h = v.h * dpr, dist = 3.45 / Math.min(1, w / h); | |
| return { x: v.x * dpr, y: v.y * dpr, w, h, dist, mvp: mul(persp(0.62, w / h, 0.1, 40), viewM(yaw, pitch, dist)), vox: (2 / G) * (h / 2) / Math.tan(0.31) }; | |
| } | |
| // ---------------------------------------------------------------- WebGL | |
| // u_ink 1: ink on the light page (v_col the hue laid, v_w its weight); 0: light on the dark one (v_col the light). | |
| // u_mode 0 the cell, 1 the changes, 2 their rings, 3 the paper disc under a ring. The mode reaches the fragment | |
| // shader as varyings: a uniform declared in both shaders must match their precisions. | |
| const VS = `attribute vec3 a_pos; attribute vec3 a_col; attribute float a_size; attribute float a_t; | |
| uniform mat4 u_mvp; uniform float u_vox, u_k, u_time, u_mode, u_dist, u_maxpt, u_ink; uniform vec3 u_spark, u_inkc; | |
| varying vec3 v_col; varying float v_ring, v_w, v_ink; | |
| void main() { | |
| vec4 p = u_mvp * vec4(a_pos, 1.0); | |
| gl_Position = p; | |
| float fog = clamp((p.w - u_dist + 0.95) / 1.9, 0.0, 1.0); | |
| float size; | |
| v_ink = u_ink; | |
| if (u_mode < 0.5) { | |
| // the cell in one tone: the light a point gives on the dark page is the ink it lays on the light one | |
| v_w = dot(a_col, vec3(0.2126, 0.7152, 0.0722)) * 1.5 * mix(1.2, 0.5, fog); | |
| v_col = u_ink > 0.5 ? u_inkc : vec3(v_w); | |
| v_ring = 0.0; size = a_size * u_vox / p.w; | |
| } else { | |
| float d = (u_k - a_t) * 7.0; // d * d, not pow(d, 2.0): pow is undefined for d < 0 | |
| float k = clamp((u_k - a_t) * 3.0, 0.0, 1.0); | |
| float flare = 1.0 + 1.6 * exp(-d * d) * step(a_t, u_k); | |
| float tw = 0.85 + 0.15 * sin(u_time * 5.0 + a_t * 40.0); | |
| v_w = k * tw * flare * mix(1.0, 0.6, fog); | |
| v_col = u_ink > 0.5 ? u_spark : u_spark * v_w; | |
| v_ring = u_mode > 2.5 ? 2.0 : u_mode > 1.5 ? 1.0 : 0.0; | |
| size = (u_mode > 2.5 ? 5.8 : u_mode > 1.5 ? 5.2 : (1.5 + 1.1 * (flare - 1.0))) * u_vox / p.w * k; | |
| } | |
| gl_PointSize = min(size, u_maxpt); | |
| }`; | |
| const FS = `precision mediump float; varying vec3 v_col; varying float v_ring, v_w, v_ink; | |
| void main() { | |
| float r = length(gl_PointCoord - 0.5) * 2.0; | |
| float a = v_ring > 1.5 ? 1.0 - smoothstep(0.8, 0.98, r) | |
| : v_ring > 0.5 ? smoothstep(0.74, 0.82, r) * (1.0 - smoothstep(0.88, 0.98, r)) * 0.95 : exp(-r * r * 4.0) * step(r, 1.0); | |
| if (v_ink > 0.5) { | |
| float al = clamp(v_w * a, 0.0, 1.0); | |
| // a point: what it takes from the paper, multiplied in; a ring or its disc: laid over, premultiplied | |
| gl_FragColor = v_ring > 0.5 ? vec4(v_col * al, al) : vec4((1.0 - v_col) * al, 1.0); | |
| } else { | |
| vec3 c = v_col * a; | |
| gl_FragColor = vec4(c, max(c.r, max(c.g, c.b))); // valid premultiplied light over the dark page | |
| } | |
| }`; | |
| const GL_ATTRS = { alpha: true, premultipliedAlpha: true, antialias: false, depth: false, stencil: false, preserveDrawingBuffer: false, powerPreference: 'low-power' }; | |
| // a WebGL renderer on canvas cv, or null. setup() can run again on the same context after a restore. | |
| function glRenderer(cv, S) { | |
| let gl = null; | |
| try { gl = cv.getContext('webgl', GL_ATTRS) || cv.getContext('experimental-webgl', GL_ATTRS); } catch (e) { gl = null; } | |
| if (!gl || gl.isContextLost()) return null; | |
| let X = null; | |
| function setup() { | |
| const sh = (t, s) => { const o = gl.createShader(t); gl.shaderSource(o, s); gl.compileShader(o); if (!gl.getShaderParameter(o, gl.COMPILE_STATUS) && !gl.isContextLost()) throw new Error(gl.getShaderInfoLog(o)); return o; }; | |
| const prog = gl.createProgram(); | |
| gl.attachShader(prog, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FS)); gl.linkProgram(prog); | |
| if (!gl.getProgramParameter(prog, gl.LINK_STATUS) && !gl.isContextLost()) throw new Error(gl.getProgramInfoLog(prog)); | |
| gl.useProgram(prog); | |
| const U = {}; ['u_mvp', 'u_vox', 'u_k', 'u_time', 'u_mode', 'u_dist', 'u_spark', 'u_maxpt', 'u_ink', 'u_inkc'].forEach((n) => { U[n] = gl.getUniformLocation(prog, n); }); | |
| const buf = (arr, n, name) => { const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.bufferData(gl.ARRAY_BUFFER, arr, gl.STATIC_DRAW); return { b, n, l: gl.getAttribLocation(prog, name) }; }; | |
| const set = (p, t, n) => [buf(p, 3, 'a_pos'), buf(new Float32Array(n * 3), 3, 'a_col'), buf(new Float32Array(n).fill(1), 1, 'a_size'), buf(t, 1, 'a_t')]; | |
| X = { | |
| U, cell: [buf(S.pos, 3, 'a_pos'), buf(S.col, 3, 'a_col'), buf(S.size, 1, 'a_size'), buf(new Float32Array(S.NP), 1, 'a_t')], | |
| pk: set(S.pk.pos, S.pk.t, S.pk.n), ax: set(S.ax.pos, S.ax.t, S.ax.n), | |
| maxpt: (gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE) || [1, 64])[1], | |
| }; | |
| gl.disable(gl.DEPTH_TEST); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE); gl.clearColor(0, 0, 0, 0); | |
| } | |
| try { setup(); } catch (e) { console.warn('repack stage: WebGL setup failed', e); return null; } | |
| const bind = (bs) => bs.forEach((x) => { if (x.l < 0) return; gl.bindBuffer(gl.ARRAY_BUFFER, x.b); gl.enableVertexAttribArray(x.l); gl.vertexAttribPointer(x.l, x.n, gl.FLOAT, false, 0, 0); }); | |
| return { | |
| lost: () => gl.isContextLost(), | |
| dispose() { const x = gl.getExtension('WEBGL_lose_context'); if (x && !gl.isContextLost()) x.loseContext(); }, // free the GPU now, not at GC | |
| restore() { try { setup(); return true; } catch (e) { console.warn('repack stage: WebGL restore failed', e); return false; } }, | |
| draw(f) { | |
| const { U } = X; | |
| gl.viewport(0, 0, cv.width, cv.height); | |
| // ink: the sheet's paper, opaque, that each point multiplies (its alpha kept at one); light: nothing, added to | |
| if (INK) gl.clearColor(PAPER[0], PAPER[1], PAPER[2], 1); else gl.clearColor(0, 0, 0, 0); | |
| gl.clear(gl.COLOR_BUFFER_BIT); | |
| const mult = () => (INK ? gl.blendFuncSeparate(gl.ZERO, gl.ONE_MINUS_SRC_COLOR, gl.ZERO, gl.ONE) : gl.blendFunc(gl.ONE, gl.ONE)); | |
| const over = () => (INK ? gl.blendFuncSeparate(gl.ONE, gl.ONE_MINUS_SRC_ALPHA, gl.ZERO, gl.ONE) : gl.blendFunc(gl.ONE, gl.ONE)); | |
| gl.uniform1f(U.u_ink, INK ? 1 : 0); gl.uniform3f(U.u_inkc, ...INKC); | |
| f.views.forEach((v, side) => { | |
| const c = camera(v, f.dpr, f.yaw, f.pitch); | |
| if (c.w < 2 || c.h < 2) return; | |
| gl.viewport(c.x, cv.height - c.y - c.h, c.w, c.h); | |
| gl.uniformMatrix4fv(U.u_mvp, false, c.mvp); | |
| gl.uniform1f(U.u_dist, c.dist); gl.uniform1f(U.u_vox, c.vox); gl.uniform1f(U.u_maxpt, X.maxpt); | |
| gl.uniform1f(U.u_time, (f.now / 1000) % 1256.6370614359173); // a whole number of twinkles, so the wrap is seamless | |
| mult(); gl.uniform1f(U.u_mode, 0); bind(X.cell); gl.drawArrays(gl.POINTS, 0, S.NP); | |
| gl.uniform1f(U.u_k, f.kSpark); | |
| if (side === 1) { gl.uniform3f(U.u_spark, ...SPARK.pk); gl.uniform1f(U.u_mode, 1); bind(X.pk); gl.drawArrays(gl.POINTS, 0, S.pk.n); } | |
| else { | |
| bind(X.ax); | |
| // circled: one in 14,000 is too small to see. On paper each stands on a disc of the paper, so it reads | |
| if (INK) { over(); gl.uniform3f(U.u_spark, ...PAPER); gl.uniform1f(U.u_mode, 3); gl.drawArrays(gl.POINTS, 0, S.ax.n); } | |
| mult(); gl.uniform3f(U.u_spark, ...SPARK.ax); gl.uniform1f(U.u_mode, 1); gl.drawArrays(gl.POINTS, 0, S.ax.n); | |
| over(); gl.uniform1f(U.u_mode, 2); gl.drawArrays(gl.POINTS, 0, S.ax.n); | |
| } | |
| }); | |
| }, | |
| }; | |
| } | |
| // ---------------------------------------------------------------- Canvas 2D: the same points, for a reader without WebGL | |
| // drawn only when something changes, so the figure at rest costs nothing | |
| function ctx2dRenderer(cv, S) { | |
| const g = cv.getContext('2d'); | |
| if (!g) return null; | |
| const SPR = 32; | |
| // the fragment shader, at 32 x 32: light (rgb holding its strength), premultiplied by hand, or ink (rgb the hue, w | |
| // the weight). shape: a soft dot, a ring (true) or a disc ('disc') | |
| const bake = (rgb, ring, w) => { | |
| const c = document.createElement('canvas'); c.width = c.height = SPR; | |
| const x = c.getContext('2d'), img = x.createImageData(SPR, SPR); | |
| for (let j = 0; j < SPR; j++) for (let i = 0; i < SPR; i++) { | |
| const r = Math.hypot((i + 0.5) / SPR - 0.5, (j + 0.5) / SPR - 0.5) * 2; | |
| const ss = (a, b, v) => { const t = clamp((v - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); }; | |
| const a = ring === 'disc' ? 1 - ss(0.8, 0.98, r) : ring ? ss(0.74, 0.82, r) * (1 - ss(0.88, 0.98, r)) * 0.95 : (r <= 1 ? Math.exp(-r * r * 4) : 0); | |
| if (w !== undefined) { | |
| const al = Math.min(1, w * a); | |
| if (al > 0) img.data.set([rgb[0] * 255, rgb[1] * 255, rgb[2] * 255, al * 255], (j * SPR + i) * 4); | |
| continue; | |
| } | |
| const p = rgb.map((v) => Math.min(1, v * a)), al = Math.max(...p); | |
| if (al > 0) img.data.set([p[0] / al * 255, p[1] / al * 255, p[2] / al * 255, al * 255], (j * SPR + i) * 4); | |
| } | |
| x.putImageData(img, 0, 0); return c; | |
| }; | |
| // the cell in one tone, as the shader has it | |
| const tone = (rgb) => (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) * 1.5 * 1.2; | |
| let cellSpr = null, pkSpr = null, axSpr = null, ringSpr = null, discSpr = null; | |
| const project = (m, x, y, z) => { const w = m[3] * x + m[7] * y + m[11] * z + m[15]; return [(m[0] * x + m[4] * y + m[8] * z + m[12]) / w, (m[1] * x + m[5] * y + m[9] * z + m[13]) / w, w]; }; | |
| function dots(c, pos, n, each) { | |
| for (let i = 0; i < n; i++) { | |
| const [nx, ny, w] = project(c.mvp, pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]); | |
| if (w <= 0) continue; | |
| each(i, c.x + (nx * 0.5 + 0.5) * c.w, c.y + (0.5 - ny * 0.5) * c.h, w, clamp((w - c.dist + 0.95) / 1.9, 0, 1)); | |
| } | |
| } | |
| // a layer kept until the camera or the layout changes; a drag draws every third point, the release all of them | |
| const layer = document.createElement('canvas'), lg = layer.getContext('2d'); | |
| let key = ''; | |
| cv.addEventListener('contextrestored', () => { key = ''; kick(); }); // a 2D context given back blank | |
| function sprites() { // again when the theme changes | |
| cellSpr = S.kinds.map((rgb) => { const w = tone(rgb); return INK ? bake(INKC, false, w) : bake([w, w, w]); }); | |
| pkSpr = INK ? bake(SPARK.pk, false, 1) : bake(SPARK.pk); | |
| axSpr = INK ? bake(SPARK.ax, false, 1) : bake(SPARK.ax); | |
| ringSpr = INK ? bake(SPARK.ax, true, 1) : bake(SPARK.ax, true); | |
| discSpr = INK ? bake(PAPER, 'disc', 1) : null; | |
| key = ''; | |
| } | |
| sprites(); | |
| function cellsInto(f, stride) { | |
| if (layer.width !== cv.width || layer.height !== cv.height) { layer.width = cv.width; layer.height = cv.height; } | |
| lg.setTransform(1, 0, 0, 1, 0, 0); lg.globalCompositeOperation = 'source-over'; lg.globalAlpha = 1; | |
| lg.clearRect(0, 0, layer.width, layer.height); | |
| if (INK) { lg.fillStyle = `rgb(${PAPER.map((v) => Math.round(v * 255))})`; lg.fillRect(0, 0, layer.width, layer.height); } | |
| lg.globalCompositeOperation = INK ? 'multiply' : 'lighter'; | |
| f.views.forEach((v) => { | |
| const c = camera(v, f.dpr, f.yaw, f.pitch); | |
| if (c.w < 2 || c.h < 2) return; | |
| lg.save(); lg.beginPath(); lg.rect(c.x, c.y, c.w, c.h); lg.clip(); | |
| for (let i = 0; i < S.NP; i += stride) { | |
| const [nx, ny, w] = project(c.mvp, S.pos[i * 3], S.pos[i * 3 + 1], S.pos[i * 3 + 2]); | |
| if (w <= 0) continue; | |
| const x = c.x + (nx * 0.5 + 0.5) * c.w, y = c.y + (0.5 - ny * 0.5) * c.h, fog = clamp((w - c.dist + 0.95) / 1.9, 0, 1); | |
| const s = Math.max(1, S.size[i] * c.vox / w); | |
| lg.globalAlpha = Math.min(1, (1.2 + (0.5 - 1.2) * fog) / 1.2 * (stride > 1 ? 1.8 : 1)); | |
| lg.drawImage(cellSpr[S.kind[i]], x - s / 2, y - s / 2, s, s); | |
| } | |
| lg.restore(); | |
| }); | |
| lg.globalCompositeOperation = 'source-over'; lg.globalAlpha = 1; | |
| } | |
| return { | |
| still: true, lost: () => false, restore: () => true, dispose() { layer.width = layer.height = 0; }, recolor: sprites, | |
| draw(f) { | |
| const stride = f.drag ? 3 : 1; | |
| const k = [f.yaw, f.pitch, f.dpr, cv.width, cv.height, stride, f.lv].join(); | |
| if (k !== key) { key = k; cellsInto(f, stride); } | |
| g.setTransform(1, 0, 0, 1, 0, 0); | |
| g.globalCompositeOperation = 'source-over'; g.globalAlpha = 1; | |
| g.clearRect(0, 0, cv.width, cv.height); | |
| g.drawImage(layer, 0, 0); | |
| if (f.kSpark <= 0) return; | |
| const add = INK ? 'multiply' : 'lighter', lay = INK ? 'source-over' : 'lighter'; | |
| f.views.forEach((v, side) => { | |
| const c = camera(v, f.dpr, f.yaw, f.pitch); | |
| if (c.w < 2 || c.h < 2) return; | |
| const set = side ? S.pk : S.ax, spr = side ? pkSpr : axSpr; | |
| dots(c, set.pos, set.n, (i, x, y, w, fog) => { | |
| const kk = clamp((f.kSpark - set.t[i]) * 3, 0, 1); | |
| if (kk <= 0) return; | |
| const s = Math.max(1, 1.5 * c.vox / w * kk), r = 5.2 * c.vox / w * kk; | |
| g.globalAlpha = Math.min(1, kk * (1 + (0.6 - 1) * fog)); | |
| if (!side && discSpr) { const d = r * 5.8 / 5.2; g.globalCompositeOperation = lay; g.drawImage(discSpr, x - d / 2, y - d / 2, d, d); } | |
| g.globalCompositeOperation = add; g.drawImage(spr, x - s / 2, y - s / 2, s, s); | |
| if (!side) { g.globalCompositeOperation = lay; g.drawImage(ringSpr, x - r / 2, y - r / 2, r, r); } | |
| }); | |
| }); | |
| g.globalCompositeOperation = 'source-over'; g.globalAlpha = 1; | |
| }, | |
| }; | |
| } | |
| // ---------------------------------------------------------------- the story: the training layout, the repack, the changes | |
| // Each phase eases from where the figure stands, so a press midway turns it there; a press eases out, faster. | |
| const D_ = { repack: 1700, restore: 1100, fade: 500 }, BY_HAND = { repack: 1000, restore: 700 }; | |
| const STATE = { | |
| before: 'Training layout: raster order', | |
| repack: 'Repacking: the same 262,144 tokens, moved in memory in 16 groups', | |
| after: 'Repacked. The same cell, the same two models, a new memory order', | |
| restore: 'Back to the training layout', | |
| }; | |
| const HEARD = `. ${intf(nAx)} predictions changed under PACK3D, ${intf(nPk)} under packed RoPE.`; // read out after "Repacked"; on screen the counts say it | |
| let phase = 'before', t0 = 0, from = { s: 0, k: 0, c: 0 }, cur = from, played = false, curve = ease, span = D_.repack; | |
| let S = null, rd = null, raf = 0, last = 0, shown = [0, 0], stripsAt = -1; | |
| let yaw = 0.6, pitch = 0.42, drag = null, dpr = 1, views = [], lv = 0, onScreen = false, wellInView = false, autoT = 0; | |
| const write = (el, t) => { if (el && el.textContent !== t) el.textContent = t; }; | |
| // the markup holds the two longest states unseen; written from STATE, so they cannot drift | |
| $$('.sw-hold', stateEl).forEach((el, i) => write(el, [STATE.repack, STATE.after][i])); | |
| function setPhase(p, now = performance.now(), byHand = false) { | |
| from = cur; // where the figure stands now | |
| phase = RM && p === 'repack' ? 'after' : RM && p === 'restore' ? 'before' : p; | |
| t0 = now; | |
| if (phase === 'repack' || phase === 'restore') { curve = byHand ? easeOut : ease; span = (byHand ? BY_HAND : D_)[phase]; } | |
| stateEl.classList.toggle('hot', phase === 'after'); | |
| write(stateT, STATE[phase]); | |
| write(stateX, phase === 'after' ? HEARD : ''); | |
| write(go, phase === 'after' || phase === 'repack' ? 'Restore the training layout' : 'Repack the memory'); | |
| kick(); | |
| } | |
| // where the figure stands at a time: s how far the memory is repacked, k the sparks, c the counts | |
| function standAt(now) { | |
| const e = Math.max(0, now - t0), fade = Math.max(0, 1 - e / D_.fade); | |
| if (phase === 'repack') return { s: from.s + (1 - from.s) * curve(Math.min(1, e / span)), k: from.k * fade, c: from.c * fade }; | |
| if (phase === 'after') return { s: 1, k: RM ? 1.4 : Math.min(1.4, e / 900), c: RM ? 1 : Math.min(1, e / 1100) }; | |
| if (phase === 'restore') return { s: from.s * (1 - curve(Math.min(1, e / span))), k: from.k * fade, c: from.c * fade }; | |
| return { s: 0, k: 0, c: 0 }; | |
| } | |
| // the status line speaks only for the reader: playing by itself turns its voice off, a press turns it on | |
| const voice = (on) => stateEl.setAttribute('aria-live', on ? 'polite' : 'off'); | |
| go.addEventListener('click', () => { played = true; voice(true); setPhase(phase === 'after' || phase === 'repack' ? 'restore' : 'repack', performance.now(), true); }); | |
| function kick() { if (!raf && !(rd && rd.lost())) raf = requestAnimationFrame(frame); } // no renderer at all: the counts and strips still run | |
| function frame(now) { | |
| raf = 0; | |
| if (rd && rd.lost()) return; | |
| const dt = last ? Math.min(0.05, (now - last) / 1000) : 0; last = now; | |
| if (phase === 'repack' && now - t0 > span) setPhase('after', now); | |
| else if (phase === 'restore' && now - t0 > span) setPhase('before', now); | |
| cur = standAt(now); | |
| const still = !rd || rd.still || motion.paused; // paused, reduced or drawn in 2D: the cells turn only by hand | |
| if (!still && !drag) yaw += dt * 0.12; | |
| paint(now); | |
| const moving = phase === 'repack' || phase === 'restore' || (phase === 'after' && now - t0 < 1500); | |
| if (onScreen && (!still || moving || drag)) kick(); | |
| } | |
| // counts written only when they change, strips only once the repack has moved; it reads no layout | |
| function paint(now) { | |
| const nA = Math.round(cur.c * nAx), nB = Math.round(cur.c * nPk); | |
| if (nA !== shown[0]) { shown[0] = nA; cA.textContent = intf(nA); } | |
| if (nB !== shown[1]) { shown[1] = nB; cB.textContent = intf(nB); } | |
| if (rd) rd.draw({ views, dpr, yaw, pitch, kSpark: cur.k, now, drag: !!drag, lv }); | |
| if (cur.s !== stripsAt) drawStrips(cur.s); | |
| } | |
| // ---------------------------------------------------------------- the memory strips: all positions in raster order | |
| const SEG = 16, perm = [9, 3, 14, 0, 11, 6, 1, 13, 4, 15, 7, 2, 12, 5, 10, 8]; | |
| // each class's tone along memory, from the memory ramp: outside nearly the sheet, then the cytoplasm, the organelles | |
| // and, deepest, the nucleus. Read again when the theme changes. | |
| let SCOL = null; | |
| const stripColors = () => { | |
| const m = RAMPS.mem(), rgb = (l) => lab2rgb(l).map((c) => Math.round(c * 255)); | |
| SCOL = [rgb(mixLab(lab(tok('--surface') || '#FFFFFF'), m(0), 0.35)), rgb(m(0.12)), rgb(m(1)), rgb(m(0.55))]; | |
| }; | |
| stripColors(); | |
| let stripSrc = null, sg = []; | |
| function stripImage(w) { // each pixel the mean color of the positions it covers | |
| if (stripSrc) stripSrc.width = 0; | |
| const can = document.createElement('canvas'); can.width = w; can.height = 1; | |
| const c2 = can.getContext('2d'); if (!c2) return null; | |
| // from the worker's counts per row of 64 positions; a row split by a pixel's edge counts by the part it covers | |
| const img = c2.createImageData(w, 1), n = new Float64Array(4), rows = S.rows, nr = rows.length / 4; | |
| for (let px = 0; px < w; px++) { | |
| const a = px / w * nr, b = (px + 1) / w * nr; | |
| n.fill(0); | |
| for (let r = Math.floor(a); r < b; r++) { const f = Math.min(b, r + 1) - Math.max(a, r); for (let c = 0; c < 4; c++) n[c] += rows[r * 4 + c] * f; } | |
| const k = n[0] + n[1] + n[2] + n[3] || 1; | |
| for (let ch = 0; ch < 3; ch++) img.data[px * 4 + ch] = (n[0] * SCOL[0][ch] + n[1] * SCOL[1][ch] + n[2] * SCOL[2][ch] + n[3] * SCOL[3][ch]) / k; | |
| img.data[px * 4 + 3] = 255; | |
| } | |
| c2.putImageData(img, 0, 0); return can; | |
| } | |
| function drawStrips(s) { | |
| stripsAt = s; | |
| if (!stripSrc) return; | |
| strips.forEach((cv, i) => { | |
| const g2 = sg[i]; if (!g2) return; | |
| const w = cv.width, h = cv.height, sw = w / SEG; | |
| g2.clearRect(0, 0, w, h); g2.imageSmoothingEnabled = false; | |
| for (let k = 0; k < SEG; k++) g2.drawImage(stripSrc, k * stripSrc.width / SEG, 0, stripSrc.width / SEG, 1, (k + (perm.indexOf(k) - k) * s) * sw, 0, sw + 0.5, h); | |
| }); | |
| } | |
| // measured when sizes change, never in a frame; drawn at once, since a resized canvas is blank | |
| function layout() { | |
| const cr = cellsEl.getBoundingClientRect(), W = cr.width, H = cr.height; | |
| dpr = Math.min(2, window.devicePixelRatio || 1, Math.sqrt(3e6 / Math.max(1, W * H))); // at most 3 Mpx | |
| const bw = Math.round(W * dpr), bh = Math.round(H * dpr); | |
| if (canvas.width !== bw || canvas.height !== bh) { canvas.width = bw; canvas.height = bh; } | |
| views = cells.map((el) => { const r = el.getBoundingClientRect(); return { x: r.left - cr.left, y: r.top - cr.top, w: r.width, h: r.height }; }); | |
| lv++; | |
| const sd = Math.min(2, window.devicePixelRatio || 1); | |
| strips.forEach((s) => { const w = Math.round(s.clientWidth * sd), h = Math.round(s.clientHeight * sd); if (s.width !== w || s.height !== h) { s.width = w; s.height = h; } }); | |
| const sw = Math.max(64, strips[0].width); | |
| if (S && (!stripSrc || stripSrc.width !== sw)) stripSrc = stripImage(sw); // only when the width changes | |
| stripsAt = -1; | |
| if (!(rd && rd.lost())) paint(performance.now()); | |
| kick(); | |
| } | |
| // ---------------------------------------------------------------- the renderer: WebGL, else Canvas 2D, else words | |
| let fails = 0, born = 0, lostT = 0; | |
| function useGL() { // always on a new canvas | |
| if (fails >= 3) return false; // three contexts refused, or lost young: Canvas 2D from here on | |
| const old = rd; | |
| canvas = swap(canvas); | |
| if (old) old.dispose(); // after the swap, so its 'lost' event is ignored | |
| rd = glRenderer(canvas, S); | |
| if (!rd) { fails++; return false; } | |
| born = performance.now(); watch(canvas); stage.classList.remove('no-canvas'); | |
| return true; | |
| } | |
| function use2D() { | |
| const old = rd; | |
| canvas = swap(canvas); | |
| if (old) old.dispose(); | |
| rd = ctx2dRenderer(canvas, S); | |
| stage.classList.toggle('no-canvas', !rd); // no canvas at all: the words in the cells' place; the counts, strips and button still run | |
| return !!rd; | |
| } | |
| function start() { // WebGL now, again a frame later, then Canvas 2D | |
| if (useGL()) return; // the observer's first call lays it out | |
| requestAnimationFrame(() => { if (!useGL()) use2D(); layout(); }); | |
| } | |
| function watch(cv) { | |
| cv.addEventListener('webglcontextlost', (e) => { | |
| e.preventDefault(); // ask the browser to give it back | |
| if (cv !== canvas) return; // a canvas already replaced | |
| cancelAnimationFrame(raf); raf = 0; | |
| fails = performance.now() - born < 5000 ? fails + 1 : 0; | |
| if (!document.hidden) recoverSoon(); | |
| }); | |
| cv.addEventListener('webglcontextrestored', () => { | |
| if (cv !== canvas) return; | |
| clearTimeout(lostT); | |
| born = performance.now(); | |
| if (rd.restore()) layout(); else replace(); | |
| }); | |
| } | |
| // a context lost and not given back (iOS often never gives one back): a new canvas and a new context | |
| function replace() { | |
| clearTimeout(lostT); | |
| if (!useGL()) use2D(); | |
| layout(); | |
| } | |
| // a loss in view may be given back in a moment (a GPU process restarting); one found on return seldom is | |
| function recoverSoon(ms = 1500) { clearTimeout(lostT); lostT = setTimeout(() => { if (rd && rd.lost()) replace(); }, ms); } | |
| const onReturn = () => { if (!document.hidden && rd && rd.lost()) recoverSoon(300); }; | |
| function build() { | |
| // the strips' contexts, made once; a strip that is refused one is given a new canvas, then put away | |
| sg = strips.map((s, i) => s.getContext('2d') || (strips[i] = swap(s)).getContext('2d')); | |
| strips.forEach((s, i) => { if (!sg[i]) s.parentElement.hidden = true; }); | |
| strips.forEach((s) => s.addEventListener('contextrestored', () => { stripsAt = -1; kick(); })); | |
| start(); | |
| // one layout per frame, starting a frame later, so it stays out of the task that took the volume in | |
| const ro = new ResizeObserver(() => layout()); | |
| requestAnimationFrame(() => requestAnimationFrame(() => [cellsEl, ...cells, ...strips.map((s) => s.parentElement)].forEach((el) => ro.observe(el)))); | |
| onDpr(layout); | |
| document.addEventListener('visibilitychange', onReturn); | |
| window.addEventListener('pageshow', onReturn); | |
| autoplay(); | |
| } | |
| // ---------------------------------------------------------------- the volume: made in a worker, once, when the reader gets near | |
| function makeSoon() { | |
| let w = null, url = '', over = false, late = 0; | |
| const done = (s) => { | |
| if (over) return; | |
| over = true; clearTimeout(late); | |
| if (w) w.terminate(); | |
| if (url) URL.revokeObjectURL(url); | |
| if (s) { S = s; build(); } else setTimeout(() => { S = makeScene(G, nAx, nPk); build(); }, 0); // no worker: here, once | |
| }; | |
| try { | |
| const src = `'use strict';const makeScene=${makeScene};self.onmessage=(e)=>{const s=makeScene(...e.data);self.postMessage(s,[s.rows,s.pos,s.col,s.size,s.kind,s.pk.pos,s.pk.t,s.ax.pos,s.ax.t].map((a)=>a.buffer));};`; | |
| url = URL.createObjectURL(new Blob([src], { type: 'text/javascript' })); | |
| w = new Worker(url); | |
| w.onmessage = (e) => done(e.data); | |
| w.onerror = w.onmessageerror = (e) => { e.preventDefault(); done(null); }; | |
| late = setTimeout(() => done(null), 8000); // a worker that never answers | |
| w.postMessage([G, nAx, nPk]); | |
| } catch (e) { done(null); } | |
| } | |
| new IntersectionObserver((es, io) => { if (es.some((e) => e.isIntersecting)) { io.disconnect(); makeSoon(); } }, { rootMargin: '1500px 0px' }).observe(stage); | |
| // ---------------------------------------------------------------- turning the cells by hand | |
| // on the cells' box, so a new canvas needs no listeners of its own | |
| const turned = () => { played = true; motion.set(true); }; | |
| cellsEl.addEventListener('pointerdown', (e) => { | |
| if (e.target !== canvas || e.button !== 0) return; // a right click opens the menu; it does not turn | |
| if (drag) { dropDrag(); return; } // a second finger: a pinch, which zooms the page | |
| drag = { x: e.clientX, y: e.clientY, yaw, pitch, id: e.pointerId, moved: false, finger: e.pointerType !== 'mouse' }; | |
| try { canvas.setPointerCapture(e.pointerId); } catch (err) { /* the pointer is already gone */ } | |
| canvas.classList.add('grabbing'); | |
| }); | |
| cellsEl.addEventListener('pointermove', (e) => { | |
| if (!drag || e.pointerId !== drag.id) return; | |
| if (e.pointerType === 'mouse' && !(e.buttons & 1)) { endDrag(); return; } // the button came up where we could not hear it | |
| const dx = e.clientX - drag.x, dy = e.clientY - drag.y, ax = Math.abs(dx), ay = Math.abs(dy); | |
| if (!drag.moved && (drag.finger ? ax < 8 || ax < ay : ax < 4 && ay < 4)) return; // a finger turns only sideways | |
| drag.moved = true; | |
| yaw = drag.yaw + dx * 0.008; pitch = clamp(drag.pitch + dy * 0.006, -0.4, 1.3); kick(); | |
| }); | |
| const endDrag = () => { if (!drag) return; if (drag.moved) turned(); drag = null; canvas.classList.remove('grabbing'); kick(); }; | |
| const dropDrag = () => { if (!drag) return; ({ yaw, pitch } = drag); drag = null; canvas.classList.remove('grabbing'); kick(); }; | |
| cellsEl.addEventListener('pointerup', endDrag); | |
| cellsEl.addEventListener('lostpointercapture', endDrag); | |
| cellsEl.addEventListener('pointercancel', dropDrag); | |
| cellsEl.addEventListener('keydown', (e) => { | |
| const d = e.target === canvas && !e.altKey && !e.ctrlKey && !e.metaKey && { ArrowLeft: [-0.2, 0], ArrowRight: [0.2, 0], ArrowUp: [0, -0.1], ArrowDown: [0, 0.1] }[e.key]; | |
| if (!d) return; | |
| e.preventDefault(); | |
| turned(); | |
| yaw += d[0]; pitch = clamp(pitch + d[1], -0.4, 1.3); kick(); | |
| }); | |
| // ---------------------------------------------------------------- playing by itself | |
| function autoplay() { | |
| if (played || autoT || motion.paused || !wellInView || !S) return; | |
| autoT = setTimeout(() => { autoT = 0; if (!played && phase === 'before' && !motion.paused && wellInView) { played = true; voice(false); setPhase('repack'); } }, 700); | |
| } | |
| new IntersectionObserver((es) => { | |
| const e = es[es.length - 1]; | |
| onScreen = e.isIntersecting; wellInView = onScreen && e.intersectionRatio > 0.45; | |
| if (onScreen) { last = 0; kick(); autoplay(); } | |
| }, { threshold: [0, 0.45, 0.7] }).observe(stage); | |
| motion.on((paused) => { last = 0; kick(); if (!paused) autoplay(); }); | |
| // focus in the window stops its autoplay, so the button's name never changes under focus | |
| stage.addEventListener('focusin', () => { if (!played) { played = true; clearTimeout(autoT); autoT = 0; } }); | |
| onTheme(() => { | |
| colors(); stripColors(); | |
| if (S && stripSrc) stripSrc = stripImage(stripSrc.width); | |
| stripsAt = -1; | |
| if (rd && rd.recolor) rd.recolor(); | |
| if (!(rd && rd.lost())) paint(performance.now()); | |
| kick(); | |
| }); | |
| setPhase(RM ? 'after' : 'before'); // reduced motion: a still of the result, which still answers the button | |
| if (RM) played = true; | |
| stateEl.setAttribute('role', 'status'); // from here on a change of state is read out; the first, above, is not | |
| } | |
| initRepackStage(); | |
| for (const key of ['ax0', 'ax1', 'pk0', 'pk1']) { | |
| document.getElementById(key).textContent = mean(D.repack[key]).toFixed(4); | |
| } | |
| })(); | |