Files
site/zeta/index.html
T

804 lines
48 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<title>Zeta Ball</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=STIX+Two+Text:ital,wght@0,400;0,600;1,400;1,600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<style>
/* Layout: an observatory bench. The ball on the left, its shadow on the s-plane on the right,
the turning controls underneath like a set of dials. One dark world, chosen on purpose:
domain colouring reads best on black. */
:root {
--bg: #0c0e11;
--panel: #13161b;
--line: #262b33;
--fg: #ece7dc;
--muted: #9aa1ab;
--gold: #e8b84a;
--orange: #f08a3c;
--teal: #4fc3b8;
--lamp: #fff3a0;
--chalk: #f4f4f2;
--focus: #8fb8ff;
--f-math: "STIX Two Text", "STIX Two Math", Cambria, "Times New Roman", serif;
--f-ui: "IBM Plex Sans", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--f-data: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
color-scheme: dark;
}
html, body { background: var(--bg); color: var(--fg); }
body { font-family: var(--f-ui); font-size: 15px; line-height: 1.5; }
.wrap { max-width: 1180px; margin: 0 auto; padding-inline: 20px; padding-block: 22px 40px; display: grid; gap: 18px; }
header { display: grid; gap: 6px; }
h1 { font-family: var(--f-math); font-weight: 600; font-size: clamp(30px, 4.4vw, 46px); line-height: 1.05; margin: 0; letter-spacing: 0.01em; text-wrap: balance; }
h1 em { font-style: italic; color: var(--gold); }
.lede { margin: 0; color: var(--muted); max-width: 70ch; }
.lede b { color: var(--fg); font-weight: 500; }
.stage { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.pane { display: grid; gap: 6px; min-width: 0; }
.pane-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.pane-title { font-family: var(--f-math); font-size: 19px; }
.pane-note { font-size: 12.5px; color: var(--muted); }
.view { position: relative; aspect-ratio: 1 / 1; width: 100%; max-width: 100%; background: #07080a; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; touch-action: none; }
.view canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
#ballView { cursor: grab; }
#ballView.dragging { cursor: grabbing; }
#floorView { cursor: move; }
.busy { position: absolute; right: 10px; top: 8px; font: 500 11px/1 var(--f-data); color: var(--muted); letter-spacing: 0.06em; text-transform: uppercase; opacity: 0; transition: opacity .2s; }
.busy.on { opacity: 1; }
.bench { display: grid; grid-template-columns: 1.25fr 1fr; gap: 14px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 14px; display: grid; gap: 12px; min-width: 0; }
.label { font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.turns { display: grid; grid-template-columns: auto auto auto; gap: 8px 10px; align-items: center; justify-content: start; }
.axis-name { font-family: var(--f-math); font-size: 16px; white-space: nowrap; }
.axis-name small { font-family: var(--f-ui); font-size: 12px; color: var(--muted); margin-left: 6px; }
button { font: 500 13.5px/1 var(--f-ui); color: var(--fg); background: #1b1f26; border: 1px solid #323843; border-radius: 5px; padding: 8px 11px; cursor: pointer; }
button:hover { background: #222832; border-color: #444c58; }
button:focus-visible, input:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
button[aria-pressed="true"] { background: #2a2416; border-color: var(--gold); color: var(--gold); }
.seg { display: inline-flex; border: 1px solid #323843; border-radius: 5px; overflow: hidden; }
.seg button { border: 0; border-radius: 0; font-family: var(--f-math); font-size: 17px; padding: 6px 14px; }
.seg button + button { border-left: 1px solid #323843; }
.checks { display: flex; gap: 14px; flex-wrap: wrap; }
.checks label { display: inline-flex; gap: 6px; align-items: center; font-size: 13.5px; cursor: pointer; }
input[type="range"] { width: 180px; max-width: 100%; accent-color: var(--gold); }
.read { font-family: var(--f-data); font-size: 13.5px; font-variant-numeric: tabular-nums; display: grid; gap: 6px; }
.read div { display: flex; gap: 10px; flex-wrap: wrap; }
.read span.k { color: var(--muted); min-width: 9.5em; font-family: var(--f-ui); }
.read .q { color: var(--gold); }
.morph { display: grid; gap: 10px; }
.morph .view.wide { aspect-ratio: 16 / 9; cursor: default; }
.morph-head { display: grid; gap: 4px; }
.morph-head p { margin: 0; color: var(--muted); max-width: 78ch; }
.stops { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
#blend { width: min(420px, 100%); }
.notes { display: grid; grid-template-columns: 1.25fr 1fr; gap: 14px; }
.notes ol { margin: 0; padding-left: 1.3em; display: grid; gap: 7px; max-width: 72ch; }
.notes li::marker { color: var(--gold); font-family: var(--f-data); }
.math { font-family: var(--f-math); font-size: 1.06em; }
.legend { display: grid; gap: 7px; font-size: 13.5px; }
.legend div { display: flex; gap: 9px; align-items: center; }
.sw { width: 26px; height: 0; border-top: 3px solid; flex: none; }
.dot { width: 9px; height: 9px; border-radius: 50%; border: 1px solid #000; flex: none; }
.fine { font-size: 12.5px; color: var(--muted); }
@media (max-width: 860px) {
.stage, .bench, .notes { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
.busy { transition: none; }
}
</style>
<div class="wrap">
<header>
<h1>Zeta Ball <em>ζ</em></h1>
<p class="lede">The zeta function painted on a ball of diameter <b>½</b> that rests on the plane at <b>s = ½</b>, with a lamp at its top. Its poles sit over <b>0</b> and <b>1</b>, and its upright great circle casts the critical line. Drag the ball to turn it. The shadow panel shows what the lamp casts on the s-plane, computed live.</p>
</header>
<section class="stage" aria-label="Ball and shadow">
<div class="pane">
<div class="pane-head"><span class="pane-title">the ball</span><span class="pane-note">drag to turn · lamp at the top</span></div>
<div class="view" id="ballView">
<canvas id="ballBase"></canvas><canvas id="ballOver"></canvas>
<span class="busy" id="ballBusy">refining</span>
</div>
</div>
<div class="pane">
<div class="pane-head"><span class="pane-title">its shadow on the s-plane</span><span class="pane-note" id="floorNote">drag to pan · scroll to zoom</span></div>
<div class="view" id="floorView">
<canvas id="floorBase"></canvas><canvas id="floorOver"></canvas>
<span class="busy" id="floorBusy">refining</span>
</div>
</div>
</section>
<section class="bench">
<div class="card">
<div class="row" style="justify-content: space-between;">
<span class="label">Painted with</span>
<div class="seg" role="group" aria-label="Function">
<button id="fnZeta" aria-pressed="true">ζ(s)</button>
<button id="fnXi" aria-pressed="false">ξ(s)</button>
</div>
</div>
<div class="label">Quarter turns about the ball's own axes</div>
<div class="turns">
<span class="axis-name">½ ↔ ∞ <small>upright axis</small></span>
<button data-axis="Z" data-dir="-1" aria-label="Turn a quarter back about the axis through one half and infinity">−¼ turn</button>
<button data-axis="Z" data-dir="1" aria-label="Turn a quarter about the axis through one half and infinity">+¼ turn</button>
<span class="axis-name">0 ↔ 1 <small>through the poles</small></span>
<button data-axis="X" data-dir="-1" aria-label="Turn a quarter back about the axis through 0 and 1">−¼ turn</button>
<button data-axis="X" data-dir="1" aria-label="Turn a quarter about the axis through 0 and 1">+¼ turn</button>
<span class="axis-name">½ ± i/2 <small>front to back</small></span>
<button data-axis="Y" data-dir="-1" aria-label="Turn a quarter back about the axis through one half plus or minus i over 2">−¼ turn</button>
<button data-axis="Y" data-dir="1" aria-label="Turn a quarter about the axis through one half plus or minus i over 2">+¼ turn</button>
</div>
<div class="row">
<button id="reset">Reset the ball</button>
<div class="checks">
<label><input type="checkbox" id="ovGrid" checked> ball's grid</label>
<label><input type="checkbox" id="ovCircles" checked> circles</label>
<label><input type="checkbox" id="ovZeros" checked> zeros</label>
<label><input type="checkbox" id="ovLabels" checked> labels</label>
</div>
</div>
</div>
<div class="card">
<div class="label">Shadow view</div>
<div class="row">
<label for="zoom" class="fine">width</label>
<input type="range" id="zoom" min="-3.4" max="4.6" step="0.01" value="1.1">
<span class="read" id="zoomRead">±3</span>
</div>
<div class="row">
<button id="centreHalf">Centre on ½</button>
<button id="toInfinity">Zoom to where ∞ landed</button>
</div>
<div class="read" aria-live="polite">
<div><span class="k">rotation q</span><span class="q" id="qRead">1</span></div>
<div><span class="k">under the lamp</span><span id="topRead">s = ∞</span></div>
<div><span class="k">touching the floor</span><span id="botRead">s = ½</span></div>
</div>
</div>
</section>
<section class="card morph" aria-label="From the plane phase to zeta">
<div class="morph-head">
<span class="pane-title">From the plane phase to ζ</span>
<p>3b1b's grid for the analytically continued <span class="math">ζ</span>: the half-plane Re s &gt; 1 in warm colours, its mirror image in cool ones. Slide from the sphere in its plane phase (foci at −1 and 1, as in the quaternion explorable) through <span class="math">x ↦ (x + 1)/2</span> to this ball's own grid (foci 0 and 1), then on to <span class="math">ζ</span> itself. The middle stop is <span class="math">1/(1 − 2¹⁻ˢ)</span>, and <span class="math">ζ(s) = η(s)/(1 − 2¹⁻ˢ)</span>, so ζ is the ball's grid bent by η.</p>
</div>
<div class="view wide" id="morphView"><canvas id="morphCanvas"></canvas><span class="busy" id="morphBusy">computing ζ</span></div>
<div class="stops">
<input type="range" id="blend" min="0" max="2" step="0.001" value="2" aria-label="Blend from plane phase to zeta">
<button id="stopA">Plane phase (±1)</button>
<button id="stopB">Ball's grid (0, ½, 1)</button>
<button id="stopC">ζ</button>
</div>
</section>
<section class="notes">
<div class="card">
<div class="label">Things to try</div>
<ol>
<li>Paint with <span class="math">ξ</span>, widen the shadow view to about ±40 so the zeros show, and give the <span class="math">½ ↔ ∞</span> axis a quarter turn. The critical line and the real axis trade places. They are the only two lines through ½ where <span class="math">ξ</span> is real: on one it swings like cos and holds the zeros, on the other it grows like cosh.</li>
<li>Turn it once more. <span class="math">ξ</span> looks exactly as it started, because <span class="math">ξ(s) = ξ(1 − s)</span>. With <span class="math">ζ</span> the colours change.</li>
<li>Turn about <span class="math">0 ↔ 1</span>. The point at infinity, where every zero piles up, comes down to <span class="math">½ − i/2</span> and then onto the touching point. Press “Zoom to where ∞ landed” to see the zeros lined up into it.</li>
<li>Turn about <span class="math">½ ± i/2</span>. The critical line closes into a circle around the pole, which now sits at the touching point.</li>
<li>Four quarter turns about any axis bring the ball home, but <span class="math">q</span> reads −1. Four more make it +1.</li>
</ol>
</div>
<div class="card">
<div class="label">Key</div>
<div class="legend">
<div><span class="sw" style="border-color: var(--gold)"></span>critical line, Re s = ½ (i-circle)</div>
<div><span class="sw" style="border-color: var(--chalk)"></span>real axis (k-circle)</div>
<div><span class="sw" style="border-color: var(--teal)"></span>waist |s − ½| = ½, through 0 and 1 (j-circle)</div>
<div><span class="dot" style="background: var(--chalk)"></span>nontrivial zeros, the first 79 above and below</div>
<div><span class="dot" style="background: var(--teal)"></span>trivial zeros −2, −4, −6, … (zeta only; ξ has none)</div>
<div><span class="dot" style="background: var(--lamp)"></span>∞, the point under the lamp at the start</div>
</div>
<p class="fine" style="margin: 0;">Colour is the angle of the value, brightness its size (dark small, bright large). Grey marks points too far out to compute here. The ball's own coordinate <span class="math">z = (s − 1)/s</span> puts its six marked points at <span class="math">z = 0, ∞, ±1, ±i</span>.</p>
</div>
</section>
</div>
<script type="text/plain" id="worker-src">
const TAU = 2 * Math.PI, LN2 = Math.LN2, LNPI = Math.log(Math.PI), HALF_LN_TAU = 0.5 * Math.log(TAU);
const BC = [1/12, -1/720, 1/30240, -1/1209600, 1/47900160, -691/1307674368000, 1/74724249600,
-3617/10670622842880000, 43867/5109094217170944000, -174611/802857662698291200000,
854513/138/1124000727777607680000, -236364091/2730/620448401733239439360000,
8553103/6/403291461126605635584000000, -23749461029/870/304888344611713860501504000000];
const LOGN = new Float64Array(410);
for (let n = 1; n < 410; n++) LOGN[n] = Math.log(n);
function zetaEM(sr, si, out) { // zeta(s) for Re s >= 1/2, Euler-Maclaurin
const N = Math.min(400, Math.max(12, Math.ceil(Math.hypot(sr, si) / Math.PI) + 10));
let re = 0, im = 0;
for (let n = 1; n < N; n++) {
const l = LOGN[n], m = Math.exp(-sr * l), a = -si * l;
re += m * Math.cos(a); im += m * Math.sin(a);
}
const lnN = LOGN[N], m0 = Math.exp(-sr * lnN), a0 = -si * lnN;
const nr = m0 * Math.cos(a0), ni = m0 * Math.sin(a0); // N^-s
re += nr / 2; im += ni / 2;
const ar = N * nr, ai = N * ni, br = sr - 1, bi = si, d = br * br + bi * bi;
re += (ar * br + ai * bi) / d; im += (ai * br - ar * bi) / d; // N^(1-s)/(s-1)
let pr = sr, pi = si, sc = 1 / N;
for (let k = 1; k <= BC.length; k++) {
const tr = pr * nr - pi * ni, ti = pr * ni + pi * nr, c = BC[k - 1] * sc;
re += c * tr; im += c * ti;
if (Math.abs(c) * Math.hypot(tr, ti) < 1e-16 * Math.hypot(re, im)) break;
const xr = sr + 2 * k - 1, yr = sr + 2 * k, qr = xr * yr - si * si, qi = si * (xr + yr);
const npr = pr * qr - pi * qi; pi = pr * qi + pi * qr; pr = npr;
sc /= N * N;
}
out[0] = re; out[1] = im;
}
function lnGamma(zr, zi, out) { // Re z > 0
let accR = 0, accI = 0;
while (zr < 9) { accR += 0.5 * Math.log(zr * zr + zi * zi); accI += Math.atan2(zi, zr); zr += 1; }
const lr = 0.5 * Math.log(zr * zr + zi * zi), li = Math.atan2(zi, zr);
let r = (zr - 0.5) * lr - zi * li - zr + HALF_LN_TAU;
let i = (zr - 0.5) * li + zi * lr - zi;
const d = zr * zr + zi * zi, ir = zr / d, ii = -zi / d, i2r = ir * ir - ii * ii, i2i = 2 * ir * ii;
let pr = ir, pi = ii;
const cs = [1/12, -1/360, 1/1260, -1/1680];
for (let j = 0; j < 4; j++) { r += cs[j] * pr; i += cs[j] * pi; const t = pr * i2r - pi * i2i; pi = pr * i2i + pi * i2r; pr = t; }
out[0] = r - accR; out[1] = i - accI;
}
function logSin(x, y, out) {
if (y > 15) { out[0] = -LN2 + y; out[1] = Math.PI / 2 - x; return; }
if (y < -15) { out[0] = -LN2 - y; out[1] = x - Math.PI / 2; return; }
const re = Math.sin(x) * Math.cosh(y), im = Math.cos(x) * Math.sinh(y);
out[0] = 0.5 * Math.log(re * re + im * im); out[1] = Math.atan2(im, re);
}
const T1 = [0, 0], T2 = [0, 0], T3 = [0, 0];
function logZeta(sr, si, out) {
if (sr >= 0.5) {
zetaEM(sr, si, T1);
out[0] = 0.5 * Math.log(T1[0] * T1[0] + T1[1] * T1[1]); out[1] = Math.atan2(T1[1], T1[0]); return;
}
logSin(Math.PI * sr / 2, Math.PI * si / 2, T1);
lnGamma(1 - sr, -si, T2);
zetaEM(1 - sr, -si, T3);
out[0] = sr * LN2 + (sr - 1) * LNPI + T1[0] + T2[0] + 0.5 * Math.log(T3[0] * T3[0] + T3[1] * T3[1]);
out[1] = si * (LN2 + LNPI) + T1[1] + T2[1] + Math.atan2(T3[1], T3[0]);
}
const T4 = [0, 0], T5 = [0, 0];
function logXi(sr, si, out) {
if (sr < 0.5) { sr = 1 - sr; si = -si; } // xi(s) = xi(1 - s)
const pr = 0.5 * (sr * (sr - 1) - si * si), pi = 0.5 * si * (2 * sr - 1);
lnGamma(sr / 2, si / 2, T4);
logZeta(sr, si, T5);
out[0] = 0.5 * Math.log(pr * pr + pi * pi) - sr / 2 * LNPI + T4[0] + T5[0];
out[1] = Math.atan2(pi, pr) - si / 2 * LNPI + T4[1] + T5[1];
}
function hsv(h, s, v, buf, o) {
const i = Math.floor(h * 6), f = h * 6 - i, p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s);
let r, g, b;
switch (((i % 6) + 6) % 6) { case 0: r = v; g = t; b = p; break; case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break; case 3: r = p; g = q; b = v; break; case 4: r = t; g = p; b = v; break;
default: r = v; g = p; b = q; }
buf[o] = r * 255; buf[o + 1] = g * 255; buf[o + 2] = b * 255; buf[o + 3] = 255;
}
const L = [0, 0];
function paint(sr, si, fn, buf, o, shade) { // colour of f(s) into buf
if (!isFinite(sr) || Math.hypot(sr, si) > 1200) { buf[o] = buf[o + 1] = buf[o + 2] = 44 * shade; buf[o + 3] = 255; return; }
if (fn === 1) logXi(sr, si, L); else logZeta(sr, si, L);
const scale = fn === 1 ? 4 : 1;
if (!isFinite(L[0])) { const v = L[0] < 0 ? 0 : 255; buf[o] = buf[o + 1] = buf[o + 2] = v; buf[o + 3] = 255; return; }
let h = (L[1] / TAU) % 1; if (h < 0) h += 1;
const v = (0.22 + 0.62 * (0.5 + Math.atan(0.7 * L[0] / scale) / Math.PI)) * shade;
hsv(h, 0.6, v, buf, o);
}
// world: unit sphere, bottom (0,0,-1) over s = 1/2, lamp at (0,0,1); w = 2s - 1 = (x + iy)/(1 - z)
function material(R, x, y, z) { // R^T applied: world point -> painted point
return [R[0] * x + R[3] * y + R[6] * z, R[1] * x + R[4] * y + R[7] * z, R[2] * x + R[5] * y + R[8] * z];
}
onmessage = (e) => {
if (e.data.job === "grid") { // zeta at many points, for the morph panel
const pts = e.data.pts, out = new Float64Array(pts.length);
for (let i = 0; i < pts.length; i += 2) {
logZeta(pts[i], pts[i + 1], L);
const m = Math.exp(L[0]);
out[i] = m * Math.cos(L[1]); out[i + 1] = m * Math.sin(L[1]);
}
postMessage({ id: e.data.id, job: "grid", out }, [out.buffer]);
return;
}
const m = e.data, n = m.n, buf = new Uint8ClampedArray(n * n * 4), R = m.R;
if (m.job === "floor") {
const x0 = m.cx - m.hw, y1 = m.cy + m.hw, step = 2 * m.hw / n;
for (let j = 0; j < n; j++) {
const si = y1 - (j + 0.5) * step;
for (let i = 0; i < n; i++) {
const sr = x0 + (i + 0.5) * step;
const wr = 2 * sr - 1, wi = 2 * si, a = wr * wr + wi * wi;
const P = material(R, 2 * wr / (a + 1), 2 * wi / (a + 1), (a - 1) / (a + 1));
const dz = 1 - P[2];
const o = (j * n + i) * 4;
if (dz < 1e-12) { paint(NaN, 0, m.fn, buf, o, 1); continue; }
paint((P[0] / dz + 1) / 2, P[1] / dz / 2, m.fn, buf, o, 1);
}
}
} else {
const e1 = m.e1, e2 = m.e2, c = m.c, Ld = m.light;
for (let j = 0; j < n; j++) {
const b = 1 - (j + 0.5) * 2 / n;
for (let i = 0; i < n; i++) {
const a = (i + 0.5) * 2 / n - 1, rr = a * a + b * b, o = (j * n + i) * 4;
if (rr > 1) { buf[o + 3] = 0; continue; }
const h = Math.sqrt(1 - rr);
const X = a * e1[0] + b * e2[0] + h * c[0], Y = a * e1[1] + b * e2[1] + h * c[1], Z = a * e1[2] + b * e2[2] + h * c[2];
const lam = Math.max(0, X * Ld[0] + Y * Ld[1] + Z * Ld[2]);
const shade = 0.72 + 0.34 * lam;
const P = material(R, X, Y, Z), dz = 1 - P[2];
if (dz < 1e-12) { paint(NaN, 0, m.fn, buf, o, shade); continue; }
paint((P[0] / dz + 1) / 2, P[1] / dz / 2, m.fn, buf, o, Math.min(1.08, shade));
if (rr > 0.96) { const f = (1 - rr) / 0.04; buf[o + 3] = 255 * Math.min(1, f * 1.4); }
}
}
}
postMessage({ id: m.id, job: m.job, n, buf }, [buf.buffer]);
};
</script>
<script>
(() => {
"use strict";
const ZEROS = [14.134725, 21.022040, 25.010858, 30.424876, 32.935062, 37.586178, 40.918719, 43.327073, 48.005151, 49.773832, 52.970321, 56.446248, 59.347044, 60.831779, 65.112544, 67.079811, 69.546402, 72.067158, 75.704691, 77.144840, 79.337375, 82.910381, 84.735493, 87.425275, 88.809111, 92.491899, 94.651344, 95.870634, 98.831194, 101.317851, 103.725538, 105.446623, 107.168611, 111.029536, 111.874659, 114.320221, 116.226680, 118.790783, 121.370125, 122.946829, 124.256819, 127.516684, 129.578704, 131.087689, 133.497737, 134.756510, 138.116042, 139.736209, 141.123707, 143.111846, 146.000982, 147.422765, 150.053520, 150.925258, 153.024694, 156.112909, 157.597592, 158.849988, 161.188964, 163.030710, 165.537069, 167.184440, 169.094515, 169.911976, 173.411537, 174.754192, 176.441434, 178.377408, 179.916484, 182.207078, 184.874469, 185.598783, 187.228922, 189.416159, 192.026657, 193.079730, 195.265397, 196.876483, 198.015315];
const css = getComputedStyle(document.documentElement);
const col = (n) => css.getPropertyValue(n).trim();
const C = { gold: col("--gold"), orange: col("--orange"), teal: col("--teal"), lamp: col("--lamp"), chalk: col("--chalk"), muted: col("--muted") };
// ---------- quaternions (w, x, y, z); rotation R maps painted point -> world point ----------
const qmul = (a, b) => [a[0] * b[0] - a[1] * b[1] - a[2] * b[2] - a[3] * b[3],
a[0] * b[1] + a[1] * b[0] + a[2] * b[3] - a[3] * b[2],
a[0] * b[2] - a[1] * b[3] + a[2] * b[0] + a[3] * b[1],
a[0] * b[3] + a[1] * b[2] - a[2] * b[1] + a[3] * b[0]];
const qaxis = (ax, ang) => { const s = Math.sin(ang / 2), n = Math.hypot(ax[0], ax[1], ax[2]) || 1; return [Math.cos(ang / 2), s * ax[0] / n, s * ax[1] / n, s * ax[2] / n]; };
const qnorm = (q) => { const n = Math.hypot(q[0], q[1], q[2], q[3]); return q.map((v) => v / n); };
const qmat = (q) => { const [w, x, y, z] = q; return [
1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y),
2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x),
2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)]; };
const apply = (R, p) => [R[0] * p[0] + R[1] * p[1] + R[2] * p[2], R[3] * p[0] + R[4] * p[1] + R[5] * p[2], R[6] * p[0] + R[7] * p[1] + R[8] * p[2]];
const applyT = (R, p) => [R[0] * p[0] + R[3] * p[1] + R[6] * p[2], R[1] * p[0] + R[4] * p[1] + R[7] * p[2], R[2] * p[0] + R[5] * p[1] + R[8] * p[2]];
function slerp(a, b, t) {
let d = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
if (d > 0.9995) return qnorm(a.map((v, i) => v + t * (b[i] - v)));
const th = Math.acos(Math.max(-1, Math.min(1, d))), s = Math.sin(th);
const ka = Math.sin((1 - t) * th) / s, kb = Math.sin(t * th) / s;
return a.map((v, i) => ka * v + kb * b[i]);
}
// ---------- sphere <-> s-plane (unit sphere; bottom over s = 1/2; lamp at the top) ----------
const sToP = (sr, si) => { const wr = 2 * sr - 1, wi = 2 * si, a = wr * wr + wi * wi; return [2 * wr / (a + 1), 2 * wi / (a + 1), (a - 1) / (a + 1)]; };
const pToS = (p) => { const d = 1 - p[2]; if (d < 1e-12) return null; return [(p[0] / d + 1) / 2, p[1] / d / 2]; };
const AXES = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
// ---------- state ----------
const st = { q: [1, 0, 0, 0], fn: 0, cx: 0.5, cy: 0, hw: 3, ov: { grid: true, circles: true, zeros: true, labels: true }, anim: null, moving: false };
const $ = (id) => document.getElementById(id);
const ballView = $("ballView"), floorView = $("floorView");
const cv = { ballBase: $("ballBase"), ballOver: $("ballOver"), floorBase: $("floorBase"), floorOver: $("floorOver") };
// camera for the ball view (orthographic)
const el = 20 * Math.PI / 180, az = -58 * Math.PI / 180;
const CAM = [Math.cos(el) * Math.cos(az), Math.cos(el) * Math.sin(az), Math.sin(el)];
const fwd = CAM.map((v) => -v), up0 = [0, 0, 1];
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const norm3 = (v) => { const n = Math.hypot(v[0], v[1], v[2]); return v.map((x) => x / n); };
const E1 = norm3(cross(fwd, up0)), E2 = norm3(cross(E1, fwd));
const LIGHT = norm3([-0.3, -0.7, 0.75]);
const BALL_R = 0.36; // ball radius as a fraction of the ball canvas width
// ---------- workers ----------
const src = $("worker-src").textContent;
const url = URL.createObjectURL(new Blob([src], { type: "text/javascript" }));
const mk = () => new Worker(url);
const W = { floor: mk(), ball: mk() };
const latest = { floor: 0, ball: 0 }, shown = { floor: 0, ball: 0 };
const inflight = { floor: false, ball: false }, wanted = { floor: null, ball: null };
const off = { floor: document.createElement("canvas"), ball: document.createElement("canvas") };
let seq = 1;
for (const k of ["floor", "ball"]) {
W[k].onmessage = (e) => {
const m = e.data;
if (m.id < shown[k]) return;
shown[k] = m.id;
inflight[k] = false;
if (wanted[k] !== null) { const hi = wanted[k]; wanted[k] = null; post(k, hi); }
const o = off[k]; o.width = m.n; o.height = m.n;
o.getContext("2d").putImageData(new ImageData(m.buf, m.n, m.n), 0, 0);
drawBase(k);
$(k + "Busy").classList.toggle("on", m.id < latest[k]);
};
}
function request(k, hi) {
if (inflight[k]) { wanted[k] = wanted[k] === null ? hi : (wanted[k] || hi); $(k + "Busy").classList.add("on"); return; }
post(k, hi);
}
function post(k, hi) {
const R = qmat(st.q), id = seq++;
inflight[k] = true;
latest[k] = id;
if (k === "floor") W.floor.postMessage({ id, job: "floor", R, cx: st.cx, cy: st.cy, hw: st.hw, n: hi ? 380 : 128, fn: st.fn });
else W.ball.postMessage({ id, job: "ball", R, e1: E1, e2: E2, c: CAM, light: LIGHT, n: hi ? 320 : 120, fn: st.fn });
$(k + "Busy").classList.add("on");
}
let hiTimer = 0;
function rerender(which = ["floor", "ball"]) {
for (const k of which) request(k, false);
clearTimeout(hiTimer);
hiTimer = setTimeout(() => { if (!st.anim && !st.moving) for (const k of which) request(k, true); }, 160);
drawOver();
readouts();
}
// ---------- canvas sizing ----------
const DPR = () => Math.min(2, window.devicePixelRatio || 1);
function size(c) { const r = c.getBoundingClientRect(), d = DPR(); const w = Math.max(1, Math.round(r.width * d)); if (c.width !== w) { c.width = w; c.height = w; } return w; }
function drawBase(k) {
if (k === "floor") {
const c = cv.floorBase, w = size(c), g = c.getContext("2d");
g.imageSmoothingEnabled = true; g.clearRect(0, 0, w, w);
if (off.floor.width > 1) g.drawImage(off.floor, 0, 0, w, w);
} else {
const c = cv.ballBase, w = size(c), g = c.getContext("2d");
g.clearRect(0, 0, w, w);
drawFloorGrid(g, w);
if (off.ball.width > 1) { const r = BALL_R * w, cx = w / 2, cy = w * 0.47; g.imageSmoothingEnabled = true; g.drawImage(off.ball, cx - r, cy - r, 2 * r, 2 * r); }
}
}
// ball-view projection of a world point
function bproj(p, w) { const r = BALL_R * w; return [w / 2 + r * (p[0] * E1[0] + p[1] * E1[1] + p[2] * E1[2]), w * 0.47 - r * (p[0] * E2[0] + p[1] * E2[1] + p[2] * E2[2]), p[0] * CAM[0] + p[1] * CAM[1] + p[2] * CAM[2]]; }
function drawFloorGrid(g, w) {
g.lineWidth = 1 * DPR();
const L = 4.2;
for (let i = -4; i <= 4; i++) {
for (const [a, b] of [[[i, -L, -1], [i, L, -1]], [[-L, i, -1], [L, i, -1]]]) {
const A = bproj(a, w), B = bproj(b, w);
g.strokeStyle = "rgba(236,231,220,0.10)"; g.beginPath(); g.moveTo(A[0], A[1]); g.lineTo(B[0], B[1]); g.stroke();
}
}
// the floor's own real axis and critical line (fixed in the room)
const line = (a, b, c, wd) => { const A = bproj(a, w), B = bproj(b, w); g.strokeStyle = c; g.lineWidth = wd * DPR(); g.beginPath(); g.moveTo(A[0], A[1]); g.lineTo(B[0], B[1]); g.stroke(); };
line([-L, 0, -1], [L, 0, -1], "rgba(244,244,242,0.45)", 1.2);
line([0, -L, -1], [0, L, -1], "rgba(232,184,74,0.55)", 1.4);
}
// ---------- overlays ----------
const MAT_CIRCLES = [
{ name: "k", color: () => C.chalk, f: (t) => [Math.cos(t), 0, Math.sin(t)], w: 1.4 },
{ name: "i", color: () => C.gold, f: (t) => [0, Math.cos(t), Math.sin(t)], w: 2.2 },
{ name: "j", color: () => C.teal, f: (t) => [Math.cos(t), Math.sin(t), 0], w: 1.6 },
];
// the ball's own grid: latitudes about the 0-1 axis (|z| = 2^k) and meridians through 0 and 1
const GRID = [];
for (let k = -3; k <= 3.001; k += 0.5) {
if (Math.abs(k) < 1e-9) continue; // that one is the critical line
const r2 = Math.pow(4, k), a = (1 - r2) / (1 + r2), rho = Math.sqrt(Math.max(0, 1 - a * a));
GRID.push({ f: (t) => [a, rho * Math.cos(t), rho * Math.sin(t)], color: "rgba(159,208,255,", w: 0.9 });
}
for (let j = 1; j < 12; j++) {
if (j === 6) continue; // that one is the real axis
const b = j * Math.PI / 12, cb = Math.cos(b), sb = Math.sin(b);
GRID.push({ f: (t) => [Math.cos(t), Math.sin(t) * cb, Math.sin(t) * sb], color: "rgba(255,183,208,", w: 0.8 });
}
const POINTS = [
{ p: [0, 0, -1], lab: "½", color: () => C.gold, shape: "diamond" },
{ p: [0, 0, 1], lab: "∞", color: () => C.lamp, shape: "star" },
{ p: [1, 0, 0], lab: "1", color: () => C.orange, shape: "dot" },
{ p: [-1, 0, 0], lab: "0", color: () => C.chalk, shape: "dot" },
{ p: [0, 1, 0], lab: "½+i/2", color: () => C.teal, shape: "dot" },
{ p: [0, -1, 0], lab: "½−i/2", color: () => C.teal, shape: "dot" },
];
const ZPTS = [];
for (const g of ZEROS) { ZPTS.push(sToP(0.5, g)); ZPTS.push(sToP(0.5, -g)); }
const TPTS = [];
for (let n = 1; n <= 80; n++) TPTS.push(sToP(-2 * n, 0));
function marker(g, x, y, shape, color, d) {
g.fillStyle = color; g.strokeStyle = "rgba(0,0,0,0.85)"; g.lineWidth = 1.2 * d;
g.beginPath();
if (shape === "diamond") { const r = 6 * d; g.moveTo(x, y - r); g.lineTo(x + r, y); g.lineTo(x, y + r); g.lineTo(x - r, y); g.closePath(); }
else if (shape === "star") { const r1 = 8 * d, r2 = 3.4 * d; for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + i * Math.PI / 5, r = i % 2 ? r2 : r1; i ? g.lineTo(x + r * Math.cos(a), y + r * Math.sin(a)) : g.moveTo(x + r * Math.cos(a), y + r * Math.sin(a)); } g.closePath(); }
else g.arc(x, y, 4.6 * d, 0, 2 * Math.PI);
g.fill(); g.stroke();
}
function label(g, x, y, text, color, d) {
g.font = `600 ${13 * d}px ${css.getPropertyValue("--f-math")}`;
g.lineWidth = 3.2 * d; g.strokeStyle = "rgba(7,8,10,0.9)"; g.strokeText(text, x + 8 * d, y - 7 * d);
g.fillStyle = color; g.fillText(text, x + 8 * d, y - 7 * d);
}
function drawOver() {
const R = qmat(st.q), d = DPR();
// ---- floor overlay ----
{
const c = cv.floorOver, w = size(c), g = c.getContext("2d");
g.clearRect(0, 0, w, w);
const x0 = st.cx - st.hw, y1 = st.cy + st.hw, sc = w / (2 * st.hw);
const toPx = (s) => [(s[0] - x0) * sc, (y1 - s[1]) * sc];
// axes of the floor (fixed): faint
g.strokeStyle = "rgba(255,255,255,0.18)"; g.lineWidth = 1 * d;
let p = toPx([0, 0]); g.beginPath(); g.moveTo(0, p[1]); g.lineTo(w, p[1]); g.moveTo(p[0], 0); g.lineTo(p[0], w); g.stroke();
ticks(g, w, x0, y1, sc, d);
if (st.ov.grid) {
for (const gr of GRID) {
g.strokeStyle = gr.color + "0.62)"; g.lineWidth = gr.w * d;
g.beginPath();
let prev = null; const N = 900;
for (let i = 0; i <= N; i++) {
const s = pToS(apply(R, gr.f(2 * Math.PI * i / N)));
if (!s) { prev = null; continue; }
const P = toPx(s);
if (prev && Math.hypot(P[0] - prev[0], P[1] - prev[1]) < w * 0.5 && Math.abs(P[0]) < 20 * w && Math.abs(P[1]) < 20 * w) g.lineTo(P[0], P[1]); else g.moveTo(P[0], P[1]);
prev = P;
}
g.stroke();
}
}
if (st.ov.circles) {
for (const ci of MAT_CIRCLES) {
g.strokeStyle = ci.color(); g.lineWidth = ci.w * d; g.globalAlpha = 0.95;
g.beginPath();
let prev = null; const N = 1400;
for (let i = 0; i <= N; i++) {
const Q = apply(R, ci.f(2 * Math.PI * i / N)), s = pToS(Q);
if (!s) { prev = null; continue; }
const P = toPx(s);
if (prev && Math.hypot(P[0] - prev[0], P[1] - prev[1]) < w * 0.6 && Math.abs(P[0]) < 20 * w && Math.abs(P[1]) < 20 * w) g.lineTo(P[0], P[1]); else g.moveTo(P[0], P[1]);
prev = P;
}
g.stroke(); g.globalAlpha = 1;
}
}
if (st.ov.zeros) {
const dots = (arr, color, r) => { g.fillStyle = color; g.strokeStyle = "rgba(0,0,0,0.8)"; g.lineWidth = 0.8 * d;
for (const P0 of arr) { const s = pToS(apply(R, P0)); if (!s) continue; const P = toPx(s); if (P[0] < -10 || P[1] < -10 || P[0] > w + 10 || P[1] > w + 10) continue; g.beginPath(); g.arc(P[0], P[1], r * d, 0, 2 * Math.PI); g.fill(); g.stroke(); } };
if (st.fn === 0) dots(TPTS, C.teal, 2.6); // trivial zeros belong to zeta, not xi
dots(ZPTS, C.chalk, 2.6);
}
for (const pt of POINTS) {
const s = pToS(apply(R, pt.p)); if (!s) continue;
const P = toPx(s); if (P[0] < -20 || P[1] < -20 || P[0] > w + 20 || P[1] > w + 20) continue;
marker(g, P[0], P[1], pt.shape, pt.color(), d);
if (st.ov.labels) label(g, P[0], P[1], pt.lab, pt.color(), d);
}
}
// ---- ball overlay ----
{
const c = cv.ballOver, w = size(c), g = c.getContext("2d");
g.clearRect(0, 0, w, w);
if (st.ov.grid) {
for (const gr of GRID) {
const N = 240; let prev = null;
for (let i = 0; i <= N; i++) {
const P = bproj(apply(R, gr.f(2 * Math.PI * i / N)), w);
if (prev && (P[2] > 0 || prev[2] > 0)) {
const front = P[2] > 0 && prev[2] > 0;
g.strokeStyle = gr.color + (front ? "0.85)" : "0.18)"); g.lineWidth = gr.w * (front ? 1.1 : 0.8) * d;
g.beginPath(); g.moveTo(prev[0], prev[1]); g.lineTo(P[0], P[1]); g.stroke();
}
prev = P;
}
}
}
if (st.ov.circles) {
for (const ci of MAT_CIRCLES) {
const N = 360; let prev = null, prevFront = null;
for (let i = 0; i <= N; i++) {
const P = bproj(apply(R, ci.f(2 * Math.PI * i / N)), w), front = P[2] > 0;
if (prev) {
g.strokeStyle = ci.color(); g.globalAlpha = front && prevFront ? 0.95 : 0.22; g.lineWidth = (front ? ci.w + 0.6 : ci.w * 0.8) * d;
g.beginPath(); g.moveTo(prev[0], prev[1]); g.lineTo(P[0], P[1]); g.stroke();
}
prev = P; prevFront = front;
}
g.globalAlpha = 1;
}
}
if (st.ov.zeros) {
g.fillStyle = C.chalk;
for (const P0 of ZPTS) { const P = bproj(apply(R, P0), w); if (P[2] <= 0) continue; g.beginPath(); g.arc(P[0], P[1], 1.6 * d, 0, 2 * Math.PI); g.fill(); }
}
for (const pt of POINTS) {
const P = bproj(apply(R, pt.p), w); if (P[2] <= 0.02) continue;
marker(g, P[0], P[1], pt.shape, pt.color(), d * 0.9);
const T0 = bproj([0, 0, 1], w), nearLamp = Math.hypot(P[0] - T0[0], P[1] - T0[1]) < 14 * d;
if (st.ov.labels) label(g, P[0] + (nearLamp ? 10 * d : 0), P[1] + (nearLamp ? 12 * d : 0), pt.lab, pt.color(), d * 0.95);
}
// the lamp: fixed at the top of the room, sitting on the ball's top point
const T = bproj([0, 0, 1], w);
const grd = g.createRadialGradient(T[0], T[1], 0, T[0], T[1], 18 * d);
grd.addColorStop(0, "rgba(255,243,160,0.95)"); grd.addColorStop(1, "rgba(255,243,160,0)");
g.fillStyle = grd; g.beginPath(); g.arc(T[0], T[1], 18 * d, 0, 2 * Math.PI); g.fill();
g.font = `500 ${11.5 * d}px ${css.getPropertyValue("--f-ui")}`; g.fillStyle = C.lamp; g.textAlign = "right"; g.fillText("lamp", T[0] - 12 * d, T[1] - 10 * d); g.textAlign = "left";
}
}
const num = (v, dec) => (v < 0 ? "−" : "") + Math.abs(v).toFixed(dec);
function niceStep(span) { const raw = span / 5, p = Math.pow(10, Math.floor(Math.log10(raw))), m = raw / p; return (m < 1.5 ? 1 : m < 3.5 ? 2 : m < 7.5 ? 5 : 10) * p; }
function ticks(g, w, x0, y1, sc, d) {
const st_ = niceStep(2 * st.hw), dec = Math.max(0, -Math.floor(Math.log10(st_) + 1e-9));
g.font = `${10.5 * d}px ${css.getPropertyValue("--f-data")}`; g.fillStyle = "rgba(236,231,220,0.55)";
for (let v = Math.ceil(x0 / st_) * st_; v <= x0 + 2 * st.hw; v += st_) { const X = (v - x0) * sc; g.fillRect(X, w - 6 * d, 1 * d, 6 * d); g.fillText(num(v, dec), X + 3 * d, w - 8 * d); }
for (let v = Math.ceil((y1 - 2 * st.hw) / st_) * st_; v <= y1; v += st_) { const Y = (y1 - v) * sc; g.fillRect(0, Y, 6 * d, 1 * d); if (Math.abs(v) > 1e-12 && Y < w - 22 * d && Y > 14 * d) g.fillText((Math.abs(Math.abs(v) - 1) < 1e-12 ? (v < 0 ? "−" : "") : num(v, dec)) + "i", 8 * d, Y - 3 * d); }
}
// ---------- readouts ----------
const fmt = (x) => { const a = Math.abs(x); return a < 5e-4 ? "0" : (Math.abs(a - 0.5) < 5e-4 ? (x < 0 ? "−½" : "½") : (x < 0 ? "−" : "") + (a >= 1000 ? a.toExponential(2) : a.toFixed(3).replace(/\.?0+$/, ""))); };
function sText(s) {
if (!s) return "s = ∞";
const [r, i] = s; if (Math.hypot(r, i) > 1e6) return "s = ∞";
const re = fmt(r), im = Math.abs(i) < 5e-4 ? "" : (i < 0 ? " − " : " + ") + (Math.abs(Math.abs(i) - 0.5) < 5e-4 ? "i/2" : fmt(Math.abs(i)) + "i");
return "s = " + (re === "0" && im ? (i < 0 ? "−" : "") + im.slice(3) : re + im);
}
function qText(q) {
const parts = [[q[0], ""], [q[1], "i"], [q[2], "j"], [q[3], "k"]].filter(([v]) => Math.abs(v) > 5e-4);
if (!parts.length) return "0";
return parts.map(([v, u], i) => { const a = Math.abs(v), s = (Math.abs(a - Math.SQRT1_2) < 5e-4 ? "√½" : Math.abs(a - 1) < 5e-4 && u ? "" : (Math.abs(a - 0.5) < 5e-4 ? "½" : a.toFixed(3).replace(/\.?0+$/, ""))) + u;
return (i ? (v < 0 ? " − " : " + ") : (v < 0 ? "−" : "")) + (s || "1"); }).join("");
}
function readouts() {
const R = qmat(st.q);
$("qRead").textContent = "q = " + qText(st.q);
$("topRead").textContent = sText(pToS(applyT(R, [0, 0, 1])));
$("botRead").textContent = sText(pToS(applyT(R, [0, 0, -1])));
$("zoomRead").textContent = "±" + (st.hw >= 10 ? st.hw.toFixed(0) : st.hw >= 1 ? st.hw.toFixed(1) : st.hw.toPrecision(2));
}
// ---------- interaction ----------
let drag = null;
ballView.addEventListener("pointerdown", (e) => { if (st.anim) return; drag = { x: e.clientX, y: e.clientY }; ballView.setPointerCapture(e.pointerId); ballView.classList.add("dragging"); st.moving = true; });
ballView.addEventListener("pointermove", (e) => {
if (!drag) return;
const dx = e.clientX - drag.x, dy = e.clientY - drag.y; drag = { x: e.clientX, y: e.clientY };
const r = BALL_R * ballView.getBoundingClientRect().width, ang = Math.hypot(dx, dy) / r;
if (ang < 1e-6) return;
const axis = [dy * E1[0] + dx * E2[0], dy * E1[1] + dx * E2[1], dy * E1[2] + dx * E2[2]];
st.q = qnorm(qmul(qaxis(axis, ang), st.q));
rerender();
});
const endDrag = () => { if (!drag) return; drag = null; ballView.classList.remove("dragging"); st.moving = false; rerender(); };
ballView.addEventListener("pointerup", endDrag); ballView.addEventListener("pointercancel", endDrag);
let pan = null;
floorView.addEventListener("pointerdown", (e) => { pan = { x: e.clientX, y: e.clientY }; floorView.setPointerCapture(e.pointerId); st.moving = true; });
floorView.addEventListener("pointermove", (e) => {
if (!pan) return;
const w = floorView.getBoundingClientRect().width, k = 2 * st.hw / w;
st.cx -= (e.clientX - pan.x) * k; st.cy += (e.clientY - pan.y) * k; pan = { x: e.clientX, y: e.clientY };
rerender(["floor"]);
});
const endPan = () => { if (!pan) return; pan = null; st.moving = false; rerender(["floor"]); };
floorView.addEventListener("pointerup", endPan); floorView.addEventListener("pointercancel", endPan);
floorView.addEventListener("wheel", (e) => {
e.preventDefault();
const r = floorView.getBoundingClientRect(), fx = (e.clientX - r.left) / r.width, fy = (e.clientY - r.top) / r.height;
const sx = st.cx - st.hw + fx * 2 * st.hw, sy = st.cy + st.hw - fy * 2 * st.hw;
const k = Math.exp(Math.max(-0.5, Math.min(0.5, e.deltaY * 0.0015)));
st.hw = Math.min(100, Math.max(0.02, st.hw * k));
st.cx = sx - (fx - 0.5) * 2 * st.hw; st.cy = sy + (fy - 0.5) * 2 * st.hw;
$("zoom").value = Math.log(st.hw); rerender(["floor"]);
}, { passive: false });
$("zoom").addEventListener("input", (e) => { st.hw = Math.exp(+e.target.value); rerender(["floor"]); });
$("centreHalf").addEventListener("click", () => { st.cx = 0.5; st.cy = 0; rerender(["floor"]); });
$("toInfinity").addEventListener("click", () => {
const s = pToS(apply(qmat(st.q), [0, 0, 1]));
if (!s || Math.hypot(s[0], s[1]) > 50) { $("floorNote").textContent = "∞ is under the lamp right now: turn the ball first"; return; }
st.cx = s[0]; st.cy = s[1]; st.hw = 0.06; $("zoom").value = Math.log(st.hw); $("floorNote").textContent = "drag to pan · scroll to zoom"; rerender(["floor"]);
});
function setFn(f) { st.fn = f; $("fnZeta").setAttribute("aria-pressed", f === 0); $("fnXi").setAttribute("aria-pressed", f === 1); rerender(); }
$("fnZeta").addEventListener("click", () => setFn(0));
$("fnXi").addEventListener("click", () => setFn(1));
for (const id of ["Grid", "Circles", "Zeros", "Labels"]) $("ov" + id).addEventListener("change", (e) => { st.ov[id.toLowerCase()] = e.target.checked; drawOver(); });
const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function turnTo(target, ms) {
const from = st.q.slice(), t0 = performance.now();
st.anim = true;
const step = (now) => {
let t = Math.min(1, (now - t0) / ms); const e = t * t * (3 - 2 * t);
st.q = slerp(from, target, e);
if (t < 1) { rerender(); requestAnimationFrame(step); } else { st.q = target; st.anim = null; rerender(); }
};
requestAnimationFrame(step);
}
document.querySelectorAll("button[data-axis]").forEach((b) => b.addEventListener("click", () => {
if (st.anim) return;
const ax = AXES[b.dataset.axis], dir = +b.dataset.dir;
turnTo(qmul(st.q, qaxis(ax, dir * Math.PI / 2)), reduce ? 1 : 1100);
}));
$("reset").addEventListener("click", () => { if (st.anim) return; st.q = [1, 0, 0, 0]; rerender(); });
new ResizeObserver(() => { drawBase("floor"); drawBase("ball"); drawOver(); }).observe(ballView);
// ---------- morph panel: plane phase (foci ±1) -> ball's grid (foci 0, 1) -> zeta ----------
const MV = $("morphView"), MC = $("morphCanvas");
const morph = { lines: [], u: 2, ready: false };
(function buildGrid() {
const n = 420, xs = [], ys = [];
for (let k = 1; k <= 24; k++) xs.push(1 + k / 8);
for (let x = 5; x <= 9; x++) xs.push(x);
for (let k = -16; k <= 16; k++) if (k) ys.push(k / 8);
ys.push(-4, -3, 3, 4);
const add = (pts, warm, idx, of) => morph.lines.push({ s: pts, warm, t: of > 1 ? idx / (of - 1) : 0 });
xs.forEach((x, i) => { for (const mir of [false, true]) { const pts = []; for (let j = 0; j < n; j++) { const y = -4 + 8 * j / (n - 1); pts.push(mir ? 2 - x : x, y); } add(pts, !mir, i, xs.length); } });
ys.forEach((y, i) => { for (const mir of [false, true]) { const pts = []; for (let j = 0; j < n; j++) { const x = 1 + 8 * j / (n - 1); pts.push(mir ? 2 - x : x, y); } add(pts, !mir, i, ys.length); } });
const all = new Float64Array(morph.lines.reduce((m, L) => m + L.s.length, 0));
let o = 0; for (const L of morph.lines) { all.set(L.s, o); L.off = o; o += L.s.length; }
// stop B: 1/(1 - 2^(1-s)) = the ball's own grid; stop A: the same with foci at -1 and 1 (x -> 2x - 1)
const B = new Float64Array(all.length), A = new Float64Array(all.length);
for (let i = 0; i < all.length; i += 2) {
const sr = all[i], si = all[i + 1], m = Math.exp((1 - sr) * Math.LN2), ang = -si * Math.LN2;
const dr = 1 - m * Math.cos(ang), di = -m * Math.sin(ang), dd = dr * dr + di * di;
B[i] = dr / dd; B[i + 1] = -di / dd;
A[i] = 2 * B[i] - 1; A[i + 1] = 2 * B[i + 1];
}
morph.A = A; morph.B = B; morph.all = all;
const wk = new Worker(url);
wk.onmessage = (e) => { morph.C = e.data.out; morph.ready = true; $("morphBusy").classList.remove("on"); drawMorph(); wk.terminate(); };
$("morphBusy").classList.add("on");
wk.postMessage({ id: 1, job: "grid", pts: all });
})();
const WARMS = ["#ffe36b", "#ffb05a", "#ff7f9a", "#f0a0c8"], COOLS = ["#c8d2ff", "#8a9cff", "#9a7cff", "#e0e0e0"];
const ramp = (cs, t) => { const x = Math.max(0, Math.min(0.999, t)) * (cs.length - 1), i = Math.floor(x), f = x - i;
const h = (c) => [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)];
const p = h(cs[i]), q = h(cs[i + 1]); return `rgb(${p.map((v, k) => Math.round(v + f * (q[k] - v))).join(",")})`; };
function drawMorph() {
const r = MC.getBoundingClientRect(), d = DPR(), W = Math.max(1, Math.round(r.width * d)), H = Math.max(1, Math.round(r.height * d));
if (MC.width !== W || MC.height !== H) { MC.width = W; MC.height = H; }
const g = MC.getContext("2d");
g.fillStyle = "#06070a"; g.fillRect(0, 0, W, H);
const x0 = -6.6, x1 = 7.6, yc = 0, sc = W / (x1 - x0), y1 = yc + H / sc / 2;
const P = (x, y) => [(x - x0) * sc, (y1 - y) * sc];
// axes and ticks
g.strokeStyle = "rgba(255,255,255,0.22)"; g.lineWidth = 1 * d;
let o = P(0, 0); g.beginPath(); g.moveTo(0, o[1]); g.lineTo(W, o[1]); g.moveTo(o[0], 0); g.lineTo(o[0], H); g.stroke();
g.font = `${10.5 * d}px ${css.getPropertyValue("--f-data")}`; g.fillStyle = "rgba(236,231,220,0.5)";
for (let x = -6; x <= 7; x++) { const q = P(x, 0); g.fillRect(q[0], q[1] - 3 * d, 1 * d, 6 * d); if (x) g.fillText(num(x, 0), q[0] - 4 * d, q[1] + 15 * d); }
const u = morph.u, A = morph.A, B = morph.B, C = morph.ready ? morph.C : morph.B;
const t = u <= 1 ? u : u - 1, X = u <= 1 ? A : B, Y = u <= 1 ? B : C;
for (const L of morph.lines) {
g.strokeStyle = L.warm ? ramp(WARMS, L.t) : ramp(COOLS, L.t);
g.globalAlpha = L.warm ? 0.85 : 0.7; g.lineWidth = (L.warm ? 0.9 : 0.75) * d;
g.beginPath();
let prev = null;
for (let i = L.off; i < L.off + L.s.length; i += 2) {
const xr = X[i] + t * (Y[i] - X[i]), xi = X[i + 1] + t * (Y[i + 1] - X[i + 1]);
if (!isFinite(xr) || !isFinite(xi) || Math.abs(xr) > 60 || Math.abs(xi) > 60) { prev = null; continue; }
const q = P(xr, xi);
if (prev && Math.hypot(q[0] - prev[0], q[1] - prev[1]) < 0.25 * W) g.lineTo(q[0], q[1]); else g.moveTo(q[0], q[1]);
prev = q;
}
g.stroke();
}
g.globalAlpha = 1;
// the three marked points slide with the blend: -1, 0, 1 -> 0, 1/2, 1
const k = Math.min(1, u);
const marks = [[-1 + k, "−1 → 0", "#8a9cff"], [k / 2, "0 → ½", "#f4f4f2"], [1, "1 stays", GOLD()]];
g.font = `600 ${12 * d}px ${css.getPropertyValue("--f-math")}`;
marks.forEach(([x, lab, c], i) => { const q = P(x, 0); marker(g, q[0], q[1], "dot", c, d * 0.9);
if (u < 1.98) { g.textAlign = "center"; g.lineWidth = 3 * d; g.strokeStyle = "rgba(6,7,10,0.9)"; const yy = q[1] + (i === 1 ? -14 : 24) * d; g.strokeText(lab, q[0], yy); g.fillStyle = c; g.fillText(lab, q[0], yy); g.textAlign = "left"; } });
g.font = `600 ${12.5 * d}px ${css.getPropertyValue("--f-math")}`;
const name = u < 0.02 ? "the sphere in its plane phase: foci −1 and 1" : u < 0.98 ? "squishing by x ↦ (x + 1)/2" : u < 1.02 ? "the zeta ball's own grid: 1/(1 − 2¹⁻ˢ), foci 0 and 1" : u < 1.98 ? "bending by η" : "ζ(s) = η(s)/(1 − 2¹⁻ˢ)";
g.fillStyle = "#ece7dc"; g.fillText(name, 12 * d, 22 * d);
}
const GOLD = () => C.gold;
$("blend").addEventListener("input", (e) => { morph.u = +e.target.value; drawMorph(); });
function glide(to) {
const from = morph.u, t0 = performance.now(), ms = reduce ? 1 : 1600 * Math.abs(to - from) + 1;
const step = (now) => { const t = Math.min(1, (now - t0) / ms), e = t * t * (3 - 2 * t); morph.u = from + (to - from) * e; $("blend").value = morph.u; drawMorph(); if (t < 1) requestAnimationFrame(step); };
requestAnimationFrame(step);
}
$("stopA").addEventListener("click", () => glide(0));
$("stopB").addEventListener("click", () => glide(1));
$("stopC").addEventListener("click", () => glide(2));
new ResizeObserver(() => drawMorph()).observe(MV);
window.__zb = st;
st.hw = Math.exp(1.1);
rerender();
})();
</script>