pack3d / app.js
amilworks's picture
Publish the PACK3D repacking demo
3cc67c8 verified
Raw History Blame Contribute Delete
44.3 kB
// Generated by tools/build_hf_space.py from website/app.js.
// Edit the website component, then regenerate this file.
(() => {
'use strict';
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);
}
})();