Files
site/turns/index.html
T
teahubxyzandClaude Fable 5.1 52c9574b4f turns: one sphere, four turns — ζ painted on the Riemann sphere, turned about its three struts
The base case: the sphere stands with 0 at the bottom and ∞ at the top, 0, 1, ∞, −1 a quarter turn apart. Three sliders = the three struts (tip about ±i, slide about 0–∞, dial about ±1); four stop buttons for the tipping turn, whose shadows are the charts s/(1−s), 2s−1, 1−1/s, 1/(1−2s). ζ/ξ/χ as a texture on the sphere (worker), the ball and its shadow side by side, beads for the six frame points and the zeros.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv3n4yABxvJPJHFRhVA
2026-10-28 06:14:11 +00:00

338 lines
24 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.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>One Sphere, Four Turns</title>
<meta name="description" content="ζ painted on the Riemann sphere, turned about its three struts: a quarter turn about ±i is the zeta orientation, a half turn is Li's chart. Four real points a quarter turn apart: 0, 1, ∞, −1.">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,500;1,500&family=JetBrains+Mono:wght@400;500&display=swap">
<style>
/* Same dark look as the other explorers. A ball on the left, its shadow (the chart) on the right, three sliders for the three struts. */
:root{--bg:#0b0a12;--panel:#15131f;--line:#2a2638;--fg:#e9e4f2;--muted:#9a93ad;--up:#f0b44c;--down:#e2559a;--teal:#4fd1c5;
--display:"Cormorant Garamond",Georgia,serif;--mono:"JetBrains Mono",ui-monospace,monospace;color-scheme:dark}
html,body{margin:0}
body{background:var(--bg);color:var(--fg);font-family:var(--mono);font-size:13.5px;line-height:1.6;
padding:calc(28px + env(safe-area-inset-top,0px)) max(16px,env(safe-area-inset-right,0px)) calc(48px + env(safe-area-inset-bottom,0px)) max(16px,env(safe-area-inset-left,0px))}
main{max-width:1040px;margin:0 auto;display:flex;flex-direction:column;gap:18px}
.eyebrow{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
.eyebrow a{color:var(--muted)}
h1{font-family:var(--display);font-weight:500;font-size:clamp(30px,5.5vw,46px);line-height:1.05;margin:.15em 0 0;text-wrap:balance}
h1 i{color:var(--down)}
.lede{color:var(--muted);max-width:72ch;margin:0}
.lede b{color:var(--fg);font-weight:500}
.bar{display:flex;flex-wrap:wrap;gap:10px 18px;align-items:center;padding:12px 14px;border:1px solid var(--line);border-radius:6px;background:var(--panel)}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:5px;overflow:hidden}
.seg button{background:transparent;color:var(--fg);border:0;padding:6px 11px;font:inherit;cursor:pointer}
.seg button+button{border-left:1px solid var(--line)}
.seg button[aria-pressed="true"]{background:var(--up);color:#1a1408}
label.k{color:var(--muted);font-size:11.5px;letter-spacing:.06em;text-transform:uppercase;margin-right:6px}
.chk{display:inline-flex;gap:6px;align-items:center;cursor:pointer}
input[type=range]{accent-color:var(--up);width:130px;vertical-align:middle}
.sl{display:inline-flex;align-items:center;gap:6px}
.sl b{font-weight:500;color:var(--fg);min-width:3.2em;display:inline-block}
.row{display:grid;grid-template-columns:1fr 1fr;gap:14px;align-items:start}
.pane{border:1px solid var(--line);border-radius:6px;background:var(--panel);overflow:hidden}
.pane h2{font-family:var(--display);font-weight:500;font-size:20px;margin:0;padding:8px 12px 0}
.pane p.sub{margin:0;padding:0 12px 8px;color:var(--muted);font-size:12px;min-height:1.6em}
.view{position:relative;aspect-ratio:1;margin:0 12px 12px;background:#000;overflow:hidden;border-radius:6px}
.view.lens{border-radius:50%;box-shadow:0 0 0 1px var(--line)}
.view canvas{position:absolute;inset:0;width:100%;height:100%}
.busy{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);font-size:12px;color:var(--up);background:rgba(11,10,18,.75);padding:6px 10px;border-radius:4px;opacity:0;transition:opacity .2s;pointer-events:none}
.busy.on{opacity:1}
.read{padding:0 12px 10px;color:var(--muted);font-size:12px;min-height:1.6em}
.read b{color:var(--fg);font-weight:500}
.notes{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.card{border:1px solid var(--line);border-radius:6px;background:var(--panel);padding:14px 16px;color:var(--muted);font-size:12.5px}
.card h3{font-family:var(--display);font-weight:500;font-size:19px;color:var(--fg);margin:0 0 6px}
.card p{margin:0 0 8px}
.card p:last-child{margin-bottom:0}
.card b{color:var(--fg);font-weight:500}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;vertical-align:middle}
.line{display:inline-block;width:14px;height:2px;margin-right:5px;vertical-align:middle}
@media (max-width:760px){.row,.notes{grid-template-columns:1fr}}
</style>
</head>
<body>
<main>
<header>
<div class="eyebrow">maths · <a href="../">two poles</a> · <a href="../zeta/">zeta ball</a> · <a href="../flow/">flow</a> · <a href="../dial/">dial</a> · <a href="../lamp/">lamp</a> · four turns</div>
<h1>One sphere, <i>four turns</i></h1>
</header>
<p class="lede">ζ painted on the Riemann sphere, and nothing else: the sphere stands with 0 at the bottom, ∞ at the top, and the four real points <b>0, 1, ∞, −1</b> a quarter turn apart on the real circle — 1 is half way to ∞. The three struts are the three axes you can turn it about. A quarter turn about the ±i strut tips it on its side: 0 and ∞ land on the unit circle together, and the shadow is zeta's picture. Another quarter turn and ∞ is at the bottom: Li's chart. Turning about the 0–∞ strut slides the touch point along the unit circle, through 1, i, −1, −i. Turning about the ±1 strut is the dial whose half turn is s ↦ 1 − s. The ball is the thing; every chart is its shadow from the top.</p>
<div class="bar" role="group" aria-label="controls">
<span><label class="k">paint</label>
<span class="seg"><button data-fn="0" aria-pressed="true">ζ</button><button data-fn="1" aria-pressed="false">ξ</button><button data-fn="2" aria-pressed="false">χ</button></span></span>
<span><label class="k">stops</label>
<span class="seg"><button data-stop="0">0</button><button data-stop="90">¼</button><button data-stop="180">½</button><button data-stop="270">¾</button></span></span>
<span class="sl"><label class="k">tip · ±i</label><input type="range" id="a" min="0" max="360" step="1" value="0"><b id="aR">0°</b></span>
<span class="sl"><label class="k">slide · 0–∞</label><input type="range" id="b" min="0" max="360" step="1" value="0"><b id="bR">0°</b></span>
<span class="sl"><label class="k">dial · ±1</label><input type="range" id="g" min="0" max="360" step="1" value="0"><b id="gR">0°</b></span>
<span class="sl"><label class="k">chart window</label><input type="range" id="zoom" min="1" max="6" step="0.1" value="2.4"><b id="zR">±2.4</b></span>
<label class="chk"><input type="checkbox" id="frame" checked> struts</label>
<label class="chk"><input type="checkbox" id="beads" checked> beads &amp; zeros</label>
<label class="chk"><input type="checkbox" id="labels" checked> labels</label>
</div>
<div class="row">
<section class="pane">
<h2>The ball</h2>
<p class="sub" id="subL">seen from the i side; the lamp is at the top, the touch point at the bottom</p>
<div class="view lens" id="vL"><canvas id="pL"></canvas><canvas id="oL"></canvas><span class="busy" id="busy">painting ζ on the sphere…</span></div>
</section>
<section class="pane">
<h2 id="titleR">Its shadow: the chart</h2>
<p class="sub" id="subR">stop 0 · w = s / (1 − s): ζ's 0 at the centre, its pole at the lamp</p>
<div class="view" id="vR"><canvas id="pR"></canvas><canvas id="oR"></canvas></div>
</section>
</div>
<p class="read" id="read">hover the chart for the point's two names</p>
<section class="notes">
<div class="card">
<h3>The four stops are four charts</h3>
<p>The quarter turn about the ±i strut is w ↦ (w − 1)/(w + 1): it fixes ±i and cycles the real circle 0 → −1 → ∞ → 1 → 0. Read off the shadow at each stop: <b>0</b>: w = s/(1 − s), ζ's 0 at the centre, its pole at the lamp, the critical line the unit circle. <b>¼</b>: w = 2s − 1, the zeta orientation — 0 and ∞ of the sphere on the unit circle at ∓1 (the scale-and-shift puts them on 0 and 1), the critical line standing up through the touch point. <b>½</b>: w = 1 − 1/s, Li's chart, ζ's pole at the centre and ζ's 0 at the lamp. <b>¾</b>: w = 1/(1 − 2s). Then home.</p>
</div>
<div class="card">
<h3>Three struts, three turns</h3>
<p>Tip about ±i (the stops above). Slide about 0–∞: the point touching the plane runs round the unit circle, 1 → i → −1 → −i. Dial about ±1: its half turn is s ↦ 1 − s, the functional equation. The three half turns are s ↦ (1 − s)/(1 − 2s), s ↦ s/(2s − 1) and s ↦ 1 − s; each is the product of the other two. On the critical line they read t ↦ 1/4t, t ↦ −1/4t, t ↦ −t.</p>
</div>
<div class="card">
<h3>1 is half way to ∞</h3>
<p>On the sphere the arc from 0 to 1 equals the arc from 1 to ∞: a quarter turn each. The beads on the rim show it. In ζ's names the four real points are 0, ½, 1 and ∞ — the two poles of its picture and its two units — so the point of the s-plane called ∞ is the sphere's −1, a quarter turn from either pole, no farther than ½ is.</p>
</div>
<div class="card">
<h3>Where the zeros are</h3>
<p>In the sphere's own frame every nontrivial zero sits on the unit circle, and all of them crowd the point −1: the first within 4° of it, the fortieth within half a degree. The trivial zeros sit on the real circle and crowd −1 from the other side, at −2/3, −4/5, −6/7, … So the one place on the ball where ζ is busy is the bead opposite 1. The lamp page is a magnifying glass held over that bead.</p>
</div>
</section>
</main>
<script type="text/plain" id="wsrc">
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];
function hurwitz(sr,si,a,out){
const N=Math.min(560,Math.max(14,Math.ceil(Math.hypot(sr,si)/Math.PI)+12));
let re=0,im=0;
for(let n=0;n<N;n++){const l=Math.log(n+a),m=Math.exp(-sr*l),t=-si*l;re+=m*Math.cos(t);im+=m*Math.sin(t);}
const lN=Math.log(N+a),m0=Math.exp(-sr*lN),t0=-si*lN,nr=m0*Math.cos(t0),ni=m0*Math.sin(t0);
re+=nr/2;im+=ni/2;
const ar=(N+a)*nr,ai=(N+a)*ni,br=sr-1,bi=si,d=br*br+bi*bi;
re+=(ar*br+ai*bi)/d;im+=(ai*br-ar*bi)/d;
let pr=sr,pi=si,sc=1/(N+a);
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+a)*(N+a);
}
out[0]=re;out[1]=im;
}
function zetaEM(sr,si,out){hurwitz(sr,si,1,out);}
function lnGamma(zr,zi,out){
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,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],T4=[0,0],T5=[0,0];
function logChiLeft(sr,si,out){
logSin(Math.PI*sr/2,Math.PI*si/2,T1);lnGamma(1-sr,-si,T2);
out[0]=sr*LN2+(sr-1)*LNPI+T1[0]+T2[0];
out[1]=si*(LN2+LNPI)+T1[1]+T2[1];
}
function logChi(sr,si,out){
if(sr<=0.5){logChiLeft(sr,si,out);return;}
logChiLeft(1-sr,-si,out);out[0]=-out[0];out[1]=-out[1];
}
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;}
logChiLeft(sr,si,T3);zetaEM(1-sr,-si,T1);
out[0]=T3[0]+0.5*Math.log(T1[0]*T1[0]+T1[1]*T1[1]);
out[1]=T3[1]+Math.atan2(T1[1],T1[0]);
}
function logXi(sr,si,out){
if(sr<0.5){sr=1-sr;si=-si;}
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){
if(!isFinite(sr)||!isFinite(si)||Math.hypot(sr,si)>1700){buf[o]=buf[o+1]=buf[o+2]=40;buf[o+3]=255;return;}
if(fn===1)logXi(sr,si,L);else if(fn===2)logChi(sr,si,L);else logZeta(sr,si,L);
const scale=fn===1?4:fn===2?2: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.14+0.74*(0.5+Math.atan(0.7*L[0]/scale)/Math.PI);hsv(h,0.62,v,buf,o);
}
// texture of the sphere in its own frame: 0 at the south pole, ∞ at the north, 1 at +X, i at +Y.
// stereographic from the north pole: z0 = (X+iY)/(1-Z); ζ's variable: s = z0/(1+z0) = (X+iY)/((1-Z)+X+iY)
onmessage=(e)=>{
const m=e.data,NX=m.nx,NY=m.ny,buf=new Uint8ClampedArray(NX*NY*4);
for(let j=0;j<NY;j++){
const th=(j+0.5)/NY*Math.PI,st=Math.sin(th),Z=Math.cos(th);
for(let i=0;i<NX;i++){
const ph=(i+0.5)/NX*TAU-Math.PI,X=st*Math.cos(ph),Y=st*Math.sin(ph);
const dr=(1-Z)+X,di=Y,d=dr*dr+di*di;
let sr,si;
if(d<1e-18){sr=Infinity;si=0;}else{sr=(X*dr+Y*di)/d;si=(Y*dr-X*di)/d;}
paint(sr,si,m.fn,buf,(j*NX+i)*4);
}
if(j%32===0)postMessage({id:m.id,progress:j/NY});
}
postMessage({id:m.id,nx:NX,ny:NY,buf},[buf.buffer]);
};
</script>
<script>
(()=>{
const $=id=>document.getElementById(id);
const ZEROS=[14.1347251,21.0220396,25.0108576,30.4248761,32.9350616,37.5861782,40.918719,43.3270733,48.0051509,49.7738325,52.9703215,56.4462477,59.347044,60.8317785,65.112544,67.0798105,69.5464017,72.0671577,75.7046907,77.1448401,79.337375,82.9103809,84.735493,87.4252746,88.8091112,92.4918993,94.651344,95.8706342,98.8311942,101.317851,103.725538,105.4466231,107.1686112,111.0295355,111.8746592,114.3202209,116.2266803,118.7907829,121.370125,122.9468293,124.2568186,127.5166839,129.5787042,131.0876885,133.4977372,134.7565098,138.1160421,139.736209,141.1237074,143.1118458,146.0009825,147.4227653,150.0535204,150.9252576,153.0246938,156.1129093,157.5975918,158.8499882,161.1889641,163.0307097,165.5370692,167.18444,169.0945154,169.9119765,173.4115365,174.7541915,176.4414343,178.3774078,179.916484,182.2070785,184.8744678,185.5987837,187.2289226,189.4161587,192.0266564,193.0797266,195.2653967,196.8764818,198.0153097,201.2647519,202.4935945,204.1896718,205.3946972,207.9062589,209.5765097,211.6908626,213.3479194,214.5470448,216.1695385,219.0675963,220.7149188,221.4307056,224.0070003,224.9833247,227.4214443,229.3374133,231.2501887,231.9872353,233.6934042,236.5242297,237.7698205,239.5554776,241.0491578,242.8232719,244.0708985,247.1369901,248.1019901,249.5736896,251.0149478,253.0699867,255.3062565,256.3807137,258.6104395,259.874407,260.8050845,263.5738939,265.5578518,266.6149738,267.9219151,269.970449];
const st={fn:0,a:0,b:0,g:0,zoom:2.4,frame:true,beads:true,labels:true};
const NX=1024,NY=512;
let TEX=null,texFn=-1,jobId=0;
const W=new Worker(URL.createObjectURL(new Blob([$("wsrc").textContent],{type:"text/javascript"})));
function paintTexture(){const id=++jobId;$("busy").classList.add("on");$("busy").textContent=`painting ${["ζ","ξ","χ"][st.fn]} on the sphere…`;W.postMessage({id,fn:st.fn,nx:NX,ny:NY});}
W.onmessage=(e)=>{const m=e.data;if(m.id!==jobId)return;
if(m.progress!==undefined){$("busy").textContent=`painting ${["ζ","ξ","χ"][st.fn]} on the sphere… ${Math.round(m.progress*100)}%`;return;}
TEX=m.buf;texFn=st.fn;$("busy").classList.remove("on");render();};
// rotation: world = Ry(a) Rz(b) Rx(g) body ; we need body = Rx(-g) Rz(-b) Ry(-a) world
function mats(){
const a=st.a*Math.PI/180,b=st.b*Math.PI/180,g=st.g*Math.PI/180;
const ca=Math.cos(a),sa=Math.sin(a),cb=Math.cos(b),sb=Math.sin(b),cg=Math.cos(g),sg=Math.sin(g);
const Ry=[[ca,0,sa],[0,1,0],[-sa,0,ca]],Rz=[[cb,-sb,0],[sb,cb,0],[0,0,1]],Rx=[[1,0,0],[0,cg,-sg],[0,sg,cg]];
const mul=(A,B)=>A.map((r,i)=>[0,1,2].map(j=>r[0]*B[0][j]+r[1]*B[1][j]+r[2]*B[2][j]));
const R=mul(Ry,mul(Rz,Rx)),Ri=R[0].map((_,j)=>R.map(r=>r[j])); // inverse = transpose
return{R,Ri};
}
const ap=(M,v)=>[M[0][0]*v[0]+M[0][1]*v[1]+M[0][2]*v[2],M[1][0]*v[0]+M[1][1]*v[1]+M[1][2]*v[2],M[2][0]*v[0]+M[2][1]*v[1]+M[2][2]*v[2]];
// texture lookup, bilinear, for a body point
function look(X,Y,Z,out,o,shade){
const th=Math.acos(Math.max(-1,Math.min(1,Z))),ph=Math.atan2(Y,X);
let u=(ph+Math.PI)/(2*Math.PI)*NX-0.5,v=th/Math.PI*NY-0.5;
if(u<0)u+=NX;const u0=Math.floor(u),v0=Math.max(0,Math.min(NY-2,Math.floor(v))),fu=u-u0,fv=Math.max(0,Math.min(1,v-v0));
const u1=(u0+1)%NX,i00=(v0*NX+u0)*4,i01=(v0*NX+u1)*4,i10=((v0+1)*NX+u0)*4,i11=((v0+1)*NX+u1)*4;
for(let c=0;c<3;c++){const t=TEX[i00+c]*(1-fu)*(1-fv)+TEX[i01+c]*fu*(1-fv)+TEX[i10+c]*(1-fu)*fv+TEX[i11+c]*fu*fv;out[o+c]=t*shade;}
out[o+3]=255;
}
function size(c){const S=Math.round(c.clientWidth*devicePixelRatio);if(c.width!==S){c.width=c.height=S;}return S;}
let imgL=null,imgR=null;
function renderBall(Ri){
const c=$("pL"),S=size(c),g=c.getContext("2d");const N=Math.min(S,560);c.width=c.height=N;
if(!imgL||imgL.width!==N)imgL=g.createImageData(N,N);const d=imgL.data;
for(let j=0;j<N;j++)for(let i=0;i<N;i++){
const o=(j*N+i)*4,x=(i+0.5)/N*2-1,y=1-(j+0.5)/N*2,r2=x*x+y*y;
if(r2>1){d[o]=d[o+1]=d[o+2]=0;d[o+3]=0;continue;}
const Yw=Math.sqrt(1-r2); // camera on the +Y side looking in: screen x = X, screen y = Z
const b=ap(Ri,[x,Yw,y]);
look(b[0],b[1],b[2],d,o,0.55+0.45*Math.max(0,(0.25*x+0.3*y+0.9*Yw)/1.0));
}
g.putImageData(imgL,0,0);
}
function renderChart(Ri){
const c=$("pR"),S=size(c),g=c.getContext("2d");const N=Math.min(S,560);c.width=c.height=N;
if(!imgR||imgR.width!==N)imgR=g.createImageData(N,N);const d=imgR.data,h=st.zoom;
for(let j=0;j<N;j++)for(let i=0;i<N;i++){
const o=(j*N+i)*4,x=((i+0.5)/N*2-1)*h,y=(1-(j+0.5)/N*2)*h,q=x*x+y*y;
const Xw=2*x/(q+1),Yw=2*y/(q+1),Zw=(q-1)/(q+1); // inverse stereographic from the world's top
const b=ap(Ri,[Xw,Yw,Zw]);
look(b[0],b[1],b[2],d,o,1);
}
g.putImageData(imgR,0,0);
}
// overlays: struts (three great circles), beads, zeros, labels
const BODY={"0":[0,0,-1],"∞":[0,0,1],"1":[1,0,0],"−1":[-1,0,0],"i":[0,1,0],"−i":[0,-1,0]};
const ZNAME={"0":"s = 0","∞":"s = 1","1":"s = ½","−1":"s = ∞","i":"s = ½ + i/2","−i":"s = ½ − i/2"};
const BCOL={"0":"#4fd1c5","∞":"#4fd1c5","1":"#ffffff","−1":"#f0b44c","i":"#9a93ad","−i":"#9a93ad"};
function circlePts(kind){const P=[];for(let k=0;k<=240;k++){const t=k/240*2*Math.PI;
P.push(kind==="real"?[Math.cos(t),0,Math.sin(t)]:kind==="unit"?[Math.cos(t),Math.sin(t),0]:[0,Math.cos(t),Math.sin(t)]);}return P;}
const CIRC={real:circlePts("real"),unit:circlePts("unit"),imag:circlePts("imag")};
const CCOL={real:"rgba(230,230,230,.75)",unit:"rgba(240,180,76,.9)",imag:"rgba(154,147,173,.7)"};
function ballXY(w,S){return[(w[0]+1)/2*S,(1-w[2])/2*S,w[1]];} // screen x = X, y = Z, depth = Y
function chartXY(w,S){const den=1-w[2];if(den<1e-6)return null;const x=w[0]/den,y=w[1]/den;const h=st.zoom;return[(x/h+1)/2*S,(1-y/h)/2*S,Math.hypot(x,y)];}
function overlays(R){
const dpr=devicePixelRatio;
for(const side of["L","R"]){
const c=$("o"+side),S=size(c),g=c.getContext("2d");g.clearRect(0,0,S,S);
const proj=side==="L"?(w=>ballXY(w,S)):(w=>chartXY(w,S));
if(st.frame){
for(const k of["real","unit","imag"]){
g.strokeStyle=CCOL[k];g.lineWidth=(k==="unit"?1.6:1.2)*dpr;
let pen=false;g.beginPath();
for(const p of CIRC[k]){const w=ap(R,p),q=proj(w);
if(!q||(side==="R"&&q[2]>40)){pen=false;continue;}
if(side==="L"&&q[2]<0){ // hidden side of the ball: draw faint
if(pen){g.stroke();pen=false;}continue;}
if(!pen){g.moveTo(q[0],q[1]);pen=true;}else g.lineTo(q[0],q[1]);}
g.stroke();
if(side==="L"){g.strokeStyle=CCOL[k].replace(/[\d.]+\)$/,".22)");g.setLineDash([3*dpr,4*dpr]);let pen2=false;g.beginPath();
for(const p of CIRC[k]){const w=ap(R,p),q=proj(w);if(q[2]>=0){if(pen2){g.stroke();pen2=false;}continue;}if(!pen2){g.moveTo(q[0],q[1]);pen2=true;}else g.lineTo(q[0],q[1]);}
g.stroke();g.setLineDash([]);}
}
}
if(st.beads){
// zeros on the unit circle of the sphere's own frame, thinned by screen distance
g.lineWidth=1.1*dpr;let last=null;
for(const t of ZEROS){const th=2*Math.atan(2*t);
for(const sg of[1,-1]){const w=ap(R,[Math.cos(th),sg*Math.sin(th),0]),q=proj(w);if(!q)continue;
if(side==="L"&&q[2]<0)continue;if(side==="R"&&q[2]>40)continue;
if(last&&Math.hypot(q[0]-last[0],q[1]-last[1])<5*dpr){continue;}
g.strokeStyle="#fff";g.beginPath();g.arc(q[0],q[1],2.4*dpr,0,7);g.stroke();if(sg===1)last=q;}
}
last=null;
for(let n=1;n<=80;n++){const z0=-2*n/(2*n+1),q0=z0*z0,X=2*z0/(q0+1),Z=(q0-1)/(q0+1);const w=ap(R,[X,0,Z]),q=proj(w);if(!q)continue;
if(side==="L"&&q[2]<0)continue;if(side==="R"&&q[2]>40)continue;
if(last&&Math.hypot(q[0]-last[0],q[1]-last[1])<5*dpr)continue;last=q;
g.strokeStyle="#4fd1c5";g.beginPath();g.arc(q[0],q[1],2.1*dpr,0,7);g.stroke();}
for(const k in BODY){const w=ap(R,BODY[k]),q=proj(w);if(!q||(side==="R"&&q[2]>40))continue;
const hidden=side==="L"&&q[2]<0;
g.fillStyle=BCOL[k];g.globalAlpha=hidden?0.35:1;g.beginPath();g.arc(q[0],q[1],(k==="0"||k==="∞"?4.2:3.6)*dpr,0,7);g.fill();g.globalAlpha=1;
if(st.labels&&!hidden){g.fillStyle="#e9e4f2";g.font=`${12*dpr}px JetBrains Mono`;g.textAlign="left";
const lx=Math.min(q[0]+7*dpr,S-110*dpr),ly=Math.max(14*dpr,Math.min(S-6*dpr,q[1]-6*dpr));
g.fillText(k,lx,ly);g.fillStyle="#9a93ad";g.font=`${10*dpr}px JetBrains Mono`;g.fillText(ZNAME[k],lx,ly+12*dpr);}
}
}
}
}
function stopText(){
const z=(st.b%360===0&&st.g%360===0)?{0:"stop 0 · w = s / (1 − s): ζ's 0 at the centre, its pole at the lamp; the critical line is the unit circle",90:"stop ¼ · w = 2s − 1: the zeta orientation — 0 and ∞ of the sphere at ∓1, the critical line through the touch point",180:"stop ½ · w = 1 − 1/s: Li's chart — ζ's pole at the centre, ζ's 0 at the lamp",270:"stop ¾ · w = 1/(1 − 2s): the lamp page's chart, ζ's ∞ at the centre"}[st.a%360]:null;
$("subR").textContent=z||`tip ${st.a}°, slide ${st.b}°, dial ${st.g}° · the shadow of the turned ball`;
}
function render(){if(!TEX)return;const{R,Ri}=mats();renderBall(Ri);renderChart(Ri);overlays(R);stopText();}
let raf=0;function queue(){if(raf)return;raf=requestAnimationFrame(()=>{raf=0;render();});}
for(const k of["a","b","g"]){$(k).oninput=e=>{st[k]=+e.target.value;$(k+"R").textContent=st[k]+"°";queue();};}
$("zoom").oninput=e=>{st.zoom=+e.target.value;$("zR").textContent="±"+st.zoom.toFixed(1);queue();};
for(const k of["frame","beads","labels"])$(k).onchange=e=>{st[k]=e.target.checked;queue();};
document.querySelectorAll("[data-fn]").forEach(b=>b.onclick=()=>{st.fn=+b.dataset.fn;document.querySelectorAll("[data-fn]").forEach(x=>x.setAttribute("aria-pressed",x===b));paintTexture();});
let anim=0;
document.querySelectorAll("[data-stop]").forEach(b=>b.onclick=()=>{const target=+b.dataset.stop;let from=st.a;let delta=((target-from)%360+540)%360-180;const t0=performance.now();cancelAnimationFrame(anim);
const step=now=>{const u=Math.min(1,(now-t0)/900),s=u<.5?2*u*u:1-Math.pow(-2*u+2,2)/2;st.a=((from+delta*s)%360+360)%360;$("a").value=Math.round(st.a);$("aR").textContent=Math.round(st.a)+"°";render();if(u<1)anim=requestAnimationFrame(step);else{st.a=target%360;$("a").value=st.a;$("aR").textContent=st.a+"°";render();}};
anim=requestAnimationFrame(step);});
$("vR").onmousemove=e=>{const r=$("vR").getBoundingClientRect(),h=st.zoom;const x=((e.clientX-r.left)/r.width*2-1)*h,y=(1-(e.clientY-r.top)/r.height*2)*h,q=x*x+y*y;
const{Ri}=mats();const b=ap(Ri,[2*x/(q+1),2*y/(q+1),(q-1)/(q+1)]);
const dr=(1-b[2])+b[0],di=b[1],d=dr*dr+di*di;const sr=(b[0]*dr+b[1]*di)/d,si=(b[1]*dr-b[0]*di)/d;
const z0r=b[0]/(1-b[2]),z0i=b[1]/(1-b[2]);
const f=v=>Math.abs(v)>1e4?"∞":(Math.abs(v)<1e-4?v.toExponential(1):v.toFixed(3));
$("read").innerHTML=`sphere's own chart <b>${f(z0r)} ${z0i<0?"−":"+"} ${f(Math.abs(z0i))}i</b> &nbsp;·&nbsp; ζ's variable s = <b>${f(sr)} ${si<0?"−":"+"} ${f(Math.abs(si))}i</b>`;};
$("vR").onmouseleave=()=>{$("read").textContent="hover the chart for the point's two names";};
addEventListener("resize",queue);
paintTexture();
})();
</script>
</body>
</html>