804 lines
48 KiB
HTML
804 lines
48 KiB
HTML
<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 > 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>
|