Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FAqjv3n4yABxvJPJHFRhVA
157 lines
13 KiB
HTML
157 lines
13 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<title>The Zeta Flow</title>
|
||
<meta name="description" content="One parabolic Möbius flow, z ↦ z/(tz + 1): t = 0 is the upright Riemann sphere, t = 1 is the zeta picture, t = 2 is the full step. The ball is placed in space at every t and its shadow follows.">
|
||
<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>
|
||
: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}
|
||
.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:300px;max-width:100%}
|
||
.stage{border:1px solid var(--line);border-radius:6px;background:#000;position:relative;overflow:hidden}
|
||
.stage canvas{display:block;width:100%;aspect-ratio:16/10;touch-action:none;cursor:grab}
|
||
.hint{position:absolute;left:10px;top:8px;font-size:11px;color:var(--muted)}
|
||
.read{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:6px 18px;padding:12px 14px;border:1px solid var(--line);border-radius:6px;background:var(--panel);font-variant-numeric:tabular-nums}
|
||
.read div{display:flex;justify-content:space-between;gap:10px} .read span.k{color:var(--muted)} .read .v{color:var(--fg)}
|
||
.notes{display:grid;grid-template-columns:1.3fr 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 b{color:var(--fg);font-weight:500}
|
||
@media (max-width:760px){.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="../strut/">strut</a> · <a href="../dial/">dial</a> · the flow</div>
|
||
<h1>The zeta <i>flow</i></h1>
|
||
</header>
|
||
<p class="lede">One Möbius map, T(z) = z/(z + 1), takes the upright Riemann sphere to the zeta picture. It is a shear with one fixed point, at 0, so it sits in a flow: <b>T<sup>t</sup>(z) = z/(tz + 1)</b>. t = 0 is the sphere, t = 1 is the zeta picture, t = 2 is the full step. Every T<sup>t</sup> is a real ball on the plane: tipped by 2·arctan t, radius 1/(1 + t²), resting at t/(1 + t²). The free pole walks the harmonic ladder 1/t; the equator is a straight line only at t = 1.</p>
|
||
|
||
<div class="bar">
|
||
<span><label class="k">t</label><input type="range" id="t" min="0" max="3" step="0.01" value="1"> <span id="tRead">1.00</span></span>
|
||
<span class="seg"><button data-t="0">0</button><button data-t="0.5">½</button><button data-t="1">1</button><button data-t="2">2</button><button data-t="3">3</button></span>
|
||
<label class="chk"><input type="checkbox" id="shadow" checked> shadow</label>
|
||
<label class="chk"><input type="checkbox" id="ball" checked> ball</label>
|
||
<label class="chk"><input type="checkbox" id="rays"> rays</label>
|
||
<label class="chk"><input type="checkbox" id="trail" checked> ladder 1/t</label>
|
||
</div>
|
||
|
||
<div class="stage"><canvas id="c"></canvas><span class="hint">drag to orbit · scroll to zoom</span></div>
|
||
|
||
<div class="read">
|
||
<div><span class="k">map</span><span class="v" id="r0">z ↦ z/(z+1)</span></div>
|
||
<div><span class="k">tip</span><span class="v" id="r1">90°</span></div>
|
||
<div><span class="k">radius</span><span class="v" id="r2">½</span></div>
|
||
<div><span class="k">rests at</span><span class="v" id="r3">½</span></div>
|
||
<div><span class="k">poles cast to</span><span class="v" id="r4">0 and 1</span></div>
|
||
<div><span class="k">equator casts</span><span class="v" id="r5">the line Re = ½</span></div>
|
||
<div><span class="k">lamp is the old</span><span class="v" id="r6">−1</span></div>
|
||
</div>
|
||
|
||
<section class="notes">
|
||
<div class="card">
|
||
<h3>Zero, half, full</h3>
|
||
<p><b>t = 0.</b> The Riemann sphere as usual: radius 1, resting at 0, axis upright. Latitudes cast circles around 0, longitudes cast rays. Poles at 0 and ∞. Sphere phase.</p>
|
||
<p><b>t = 1.</b> The zeta picture: tipped a quarter turn, half size, resting at ½. Poles at 0 and 1; the equator casts the critical line, the only moment in the flow when it is straight, because the point going to the lamp (the old −1/t) is on the equator exactly when t = 1. Plane phase.</p>
|
||
<p><b>t = 2.</b> The full step: tipped 127° (past flat), radius ⅕, resting at ⅖. Poles at 0 and ½; the equator casts the circle through ⅓ and 1. Sphere phase again, on the far side.</p>
|
||
<p>As t grows the ball keeps tipping toward upside-down, shrinking like 1/t², and sliding back toward 0; the second pole walks 1, ½, ⅓, … into the first. The flow never closes. Compare the <a href="../dial/" style="color:var(--up)">dial</a>, which turns about ½–∞ and does close.</p>
|
||
</div>
|
||
<div class="card">
|
||
<h3>Key</h3>
|
||
<p><span style="color:var(--teal)">●</span> poles and their shadows · <span style="color:var(--up)">—</span> equator and its shadow · <span style="color:#fff">●</span> lamp · <span style="color:var(--down)">●</span> the ball's lowest point (real origin, 1/(t+1))</p>
|
||
<p>Cool latitudes are the hemisphere around the fixed pole 0, warm the hemisphere around the walking pole. "Rays" draws a few lamp rays through the equator so you can see the projection happen. The ladder marks 1, ½, ⅓, … on the plane.</p>
|
||
<p>Plane units: the standard sphere's equator casts the unit circle, so 1 on the plane is the grid line labelled 1.</p>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<script>
|
||
(()=>{
|
||
const $=id=>document.getElementById(id);
|
||
const C=$("c"),g=C.getContext("2d");
|
||
const st={t:1,az:-0.6,el:0.5,zoom:1.7,shadow:true,ball:true,rays:false,trail:true};
|
||
const COOL="#6f7ad8",WARM="#e0a050",GOLD="#f0b44c",TEAL="#4fd1c5",PINK="#e2559a",GREY="#8a8a9a";
|
||
// --- geometry (plane unit = 1; the standard sphere has radius 1/2 so its equator casts the unit circle: lamp at height 1, equator at 1/2)
|
||
const R0=0.5;
|
||
function ballAt(t){const a=1/(1+t*t),b=t/(1+t*t),th=2*Math.atan(t);const rho=a*R0;const c=[2*R0*b,0,rho],L=[2*R0*b,0,2*rho];
|
||
const pt=(phi,tht)=>{const x=Math.sin(phi)*Math.cos(tht),y=Math.sin(phi)*Math.sin(tht),z=-Math.cos(phi),ca=Math.cos(th),sa=Math.sin(th);
|
||
return[c[0]+rho*(x*ca+z*sa),c[1]+rho*y,c[2]+rho*(-x*sa+z*ca)];};
|
||
const sh=p=>{const d=[p[0]-L[0],p[1]-L[1],p[2]-L[2]];const s=-L[2]/Math.min(d[2],-1e-4);return[L[0]+s*d[0],L[1]+s*d[1],0];};
|
||
return{a,b,th,rho,c,L,pt,sh};}
|
||
// --- camera: orbit about a target, simple perspective
|
||
function cam(){const ce=Math.cos(st.el),se=Math.sin(st.el),ca=Math.cos(st.az),sa=Math.sin(st.az);
|
||
const tgt=[0.45,0,0.18];const dist=3.2/st.zoom;const eye=[tgt[0]+dist*ce*ca,tgt[1]+dist*ce*sa,tgt[2]+dist*se];
|
||
const f=norm(sub(tgt,eye)),r=norm(cross(f,[0,0,1])),u=cross(r,f);return{eye,f,r,u};}
|
||
const sub=(a,b)=>[a[0]-b[0],a[1]-b[1],a[2]-b[2]],dot=(a,b)=>a[0]*b[0]+a[1]*b[1]+a[2]*b[2],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]],norm=a=>{const l=Math.hypot(...a);return[a[0]/l,a[1]/l,a[2]/l];};
|
||
let K;function proj(p){const d=sub(p,K.eye);const z=dot(d,K.f);if(z<0.05)return null;const W=C.width,H=C.height,fl=0.9*H;return[W/2+fl*dot(d,K.r)/z,H/2-fl*dot(d,K.u)/z,z];}
|
||
function poly(pts,col,w,dash){g.strokeStyle=col;g.lineWidth=w*devicePixelRatio;if(dash)g.setLineDash(dash.map(x=>x*devicePixelRatio));g.beginPath();let pen=false;
|
||
for(const p of pts){const q=proj(p);if(!q||Math.abs(q[0])>6*C.width||Math.abs(q[1])>6*C.height){pen=false;continue;}if(!pen){g.moveTo(q[0],q[1]);pen=true;}else g.lineTo(q[0],q[1]);}g.stroke();g.setLineDash([]);}
|
||
function dotp(p,col,r){const q=proj(p);if(!q)return;g.fillStyle=col;g.beginPath();g.arc(q[0],q[1],r*devicePixelRatio,0,7);g.fill();}
|
||
function label(p,txt,col,dx=8,dy=-8){const q=proj(p);if(!q)return;g.fillStyle=col;g.font=`${12*devicePixelRatio}px JetBrains Mono`;g.fillText(txt,q[0]+dx*devicePixelRatio,q[1]+dy*devicePixelRatio);}
|
||
function draw(){
|
||
const dpr=devicePixelRatio,W=C.clientWidth*dpr,H=Math.round(W*10/16);C.width=W;C.height=H;K=cam();g.clearRect(0,0,W,H);
|
||
const B=ballAt(st.t),t=st.t;
|
||
// floor grid
|
||
for(let x=-3;x<=4;x+=0.5)poly([[x,-2.5,0],[x,2.5,0]],x===0?"#4a4560":(Number.isInteger(x)?"#2e2a40":"#201d2c"),x===0?1.2:0.8);
|
||
for(let y=-2.5;y<=2.5;y+=0.5)poly([[-3,y,0],[4,y,0]],y===0?"#4a4560":(Number.isInteger(y)?"#2e2a40":"#201d2c"),y===0?1.2:0.8);
|
||
for(const x of[0,1,2,3])label([x,0,0],String(x),"#6a6580",4,16);
|
||
// shadow grid
|
||
const lat=(phi,n=140)=>Array.from({length:n+1},(_,i)=>B.pt(phi,i/n*2*Math.PI)),lon=(th,n=70)=>Array.from({length:n+1},(_,i)=>B.pt(0.02+(Math.PI-0.04)*i/n,th));
|
||
if(st.shadow){for(let k=1;k<=7;k++){const phi=k*Math.PI/8;poly(lat(phi).map(B.sh),phi<Math.PI/2-1e-6?COOL:(phi>Math.PI/2+1e-6?WARM:GOLD),phi===Math.PI/2?2.6:1.1);}
|
||
for(let k=0;k<6;k++)poly(lon(k*Math.PI/6).map(B.sh),GREY,0.9);
|
||
poly(lat(Math.PI/2,220).map(B.sh),GOLD,2.6);}
|
||
// ladder
|
||
if(st.trail){for(let n=1;n<=8;n++)dotp([1/n,0,0],"#5a5570",2.2);}
|
||
// rays
|
||
if(st.rays){for(let k=0;k<10;k++){const p=B.pt(Math.PI/2,k/10*2*Math.PI);poly([B.L,B.sh(p)],"rgba(240,180,76,.45)",0.9,[3,3]);}}
|
||
// ball: depth-sorted polylines, far side dimmed
|
||
if(st.ball){const segs=[];const push=(pts,col,w)=>{for(let i=0;i<pts.length-1;i++){const m=[(pts[i][0]+pts[i+1][0])/2,(pts[i][1]+pts[i+1][1])/2,(pts[i][2]+pts[i+1][2])/2];const d=sub(m,B.c);const facing=dot(d,sub(K.eye,m))>0;segs.push({a:pts[i],b:pts[i+1],col,w,facing,z:dot(sub(m,K.eye),K.f)});}};
|
||
for(let k=1;k<=7;k++){const phi=k*Math.PI/8;push(lat(phi,90),phi<Math.PI/2-1e-6?COOL:(phi>Math.PI/2+1e-6?WARM:GOLD),phi===Math.PI/2?2.8:1.2);}
|
||
for(let k=0;k<6;k++)push(lon(k*Math.PI/6,45),GREY,1.0);
|
||
segs.sort((p,q)=>q.z-p.z);
|
||
for(const s of segs){g.globalAlpha=s.facing?1:0.22;poly([s.a,s.b],s.col,s.w);}g.globalAlpha=1;
|
||
// outline
|
||
const out=[];const n=90;for(let i=0;i<=n;i++){const a=i/n*2*Math.PI;out.push([B.c[0]+B.rho*(K.r[0]*Math.cos(a)+K.u[0]*Math.sin(a)),B.c[1]+B.rho*(K.r[1]*Math.cos(a)+K.u[1]*Math.sin(a)),B.c[2]+B.rho*(K.r[2]*Math.cos(a)+K.u[2]*Math.sin(a))]);}
|
||
poly(out,"#5a5570",1.0);}
|
||
// points
|
||
const P0=B.pt(0,0),P1=B.pt(Math.PI,0);
|
||
dotp(B.sh(P0),TEAL,4);dotp(B.sh(P1),TEAL,4);dotp(P0,TEAL,4);dotp(P1,TEAL,4);
|
||
dotp(B.L,"#fff",4.5);dotp([B.c[0],B.c[1],0],PINK,3.5);
|
||
label(B.L,"∞","#fff");label(B.sh(P0),"0",TEAL,6,18);if(t>0.05)label(B.sh(P1),(t===1?"1":t===2?"½":"1/t = "+(1/t).toFixed(2)),TEAL,6,18);
|
||
// readout
|
||
const f=x=>Number.isInteger(x*1000)?String(x):x.toFixed(3);
|
||
$("r0").textContent=t===0?"z ↦ z":"z ↦ z/("+(t===1?"":f(t))+"z + 1)";$("r1").textContent=(2*Math.atan(t)*180/Math.PI).toFixed(1)+"°";
|
||
$("r2").textContent=t===1?"½":t===2?"⅕":f(1/(1+t*t));$("r3").textContent=t===1?"½":t===2?"⅖":f(t/(1+t*t));
|
||
$("r4").textContent=t===0?"0 and ∞":"0 and "+(t===1?"1":t===2?"½":f(1/t));
|
||
// equator image: the circle through T(1) = 1/(t+1) and T(−1) = 1/(1−t), which is ∞ at t = 1
|
||
$("r5").textContent=t===0?"the unit circle":Math.abs(t-1)<1e-9?"the line Re = ½":"the circle through "+f(1/(1+t))+" and "+(t<1?"−"+f(1/(1-t)):f(1/(t-1)));
|
||
$("r6").textContent=t===0?"∞ (itself)":"−"+(t===1?"1":f(1/t));
|
||
}
|
||
C.addEventListener("pointerdown",e=>{C.setPointerCapture(e.pointerId);C._d=[e.clientX,e.clientY];});
|
||
C.addEventListener("pointermove",e=>{if(!C._d)return;st.az-=(e.clientX-C._d[0])*0.008;st.el=Math.max(0.08,Math.min(1.45,st.el+(e.clientY-C._d[1])*0.006));C._d=[e.clientX,e.clientY];draw();});
|
||
C.addEventListener("pointerup",()=>{C._d=null;});
|
||
C.addEventListener("wheel",e=>{e.preventDefault();st.zoom=Math.max(0.4,Math.min(4,st.zoom*Math.exp(-e.deltaY*0.0012)));draw();},{passive:false});
|
||
$("t").oninput=e=>{st.t=+e.target.value;$("tRead").textContent=st.t.toFixed(2);draw();};
|
||
document.querySelectorAll("[data-t]").forEach(b=>b.onclick=()=>{st.t=+b.dataset.t;$("t").value=st.t;$("tRead").textContent=st.t.toFixed(2);draw();});
|
||
for(const k of["shadow","ball","rays","trail"])$(k).onchange=e=>{st[k]=e.target.checked;draw();};
|
||
addEventListener("resize",draw);draw();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|