Files
site/flow/index.html
T

157 lines
13 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>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>