Custom Code Experiment •
Hyperbolic Tiling 1
Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hyperbolic Tiling 1</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: #080810;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: 'Courier New', monospace;
}
#wrap {
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem 1rem 1rem;
gap: 0;
}
#title-label {
font-family: 'Georgia', serif;
font-size: 13px;
letter-spacing: 0.22em;
color: #7a6a4a;
text-transform: uppercase;
margin-bottom: 1rem;
text-align: center;
}
canvas {
cursor: grab;
display: block;
border-radius: 50%;
}
canvas:active { cursor: grabbing; }
#hint {
font-size: 10px;
color: #444;
margin-top: 8px;
text-align: center;
letter-spacing: 0.08em;
}
#controls {
display: flex;
gap: 16px;
margin-top: 1rem;
flex-wrap: wrap;
justify-content: center;
max-width: 600px;
}
.ctrl {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #666;
}
.ctrl label { color: #666; }
.ctrl input[type=range] {
width: 80px;
accent-color: #c8a96e;
cursor: pointer;
}
.ctrl select {
background: #12121e;
color: #aaa;
border: 0.5px solid #333;
border-radius: 3px;
padding: 2px 5px;
font-size: 11px;
font-family: 'Courier New', monospace;
cursor: pointer;
}
.val { color: #c8a96e; min-width: 18px; text-align: right; }
#invalid-msg {
font-size: 11px;
color: #8a3a3a;
margin-top: 6px;
min-height: 16px;
text-align: center;
letter-spacing: 0.05em;
}
</style>
</head>
<body>
<div id="wrap">
<canvas id="c"></canvas>
<div id="hint">drag to navigate hyperbolic space · scroll to adjust spin</div>
<div id="invalid-msg"></div>
<div id="controls">
<div class="ctrl">
<label for="pp">p</label>
<input type="range" id="pp" min="5" max="9" value="5" step="1">
<span class="val" id="ppv">5</span>
</div>
<div class="ctrl">
<label for="qq">q</label>
<input type="range" id="qq" min="4" max="9" value="4" step="1">
<span class="val" id="qqv">4</span>
</div>
<div class="ctrl">
<label for="dep">depth</label>
<input type="range" id="dep" min="2" max="7" value="4" step="1">
<span class="val" id="depv">4</span>
</div>
<div class="ctrl">
<label for="spd">spin</label>
<input type="range" id="spd" min="0" max="20" value="3" step="1">
<span class="val" id="spdv">3</span>
</div>
<div class="ctrl">
<label for="sty">style</label>
<select id="sty">
<option value="fish">Fish</option>
<option value="lizard">Lizard</option>
<option value="bird">Bird</option>
<option value="geo">Geometric</option>
</select>
</div>
<div class="ctrl">
<label for="pal">palette</label>
<select id="pal">
<option value="woodcut">Woodcut</option>
<option value="midnight">Midnight</option>
<option value="gilded">Gilded</option>
<option value="blood">Scarlet</option>
</select>
</div>
</div>
</div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const SZ = Math.min(window.innerWidth - 24, window.innerHeight - 220, 560);
canvas.width = SZ;
canvas.height = SZ;
const R0 = SZ / 2 - 3;
const CX = SZ / 2, CY = SZ / 2;
// ── Complex arithmetic ─────────────────────────────────────────────────────
const C = (re, im = 0) => ({ re, im });
const cadd = (a, b) => C(a.re + b.re, a.im + b.im);
const csub = (a, b) => C(a.re - b.re, a.im - b.im);
const cmul = (a, b) => C(a.re*b.re - a.im*b.im, a.re*b.im + a.im*b.re);
const cdiv = (a, b) => { const d = b.re*b.re + b.im*b.im; return C((a.re*b.re + a.im*b.im)/d, (a.im*b.re - a.re*b.im)/d); };
const cabs2 = a => a.re*a.re + a.im*a.im;
const cabs = a => Math.sqrt(cabs2(a));
const cconj = a => C(a.re, -a.im);
const cneg = a => C(-a.re, -a.im);
const cscale= (a, s) => C(a.re*s, a.im*s);
const carg = a => Math.atan2(a.im, a.re);
// Möbius: shift viewpoint w inside the disk
function mobiusWinv(z, w) {
return cdiv(cadd(z, w), cadd(C(1), cmul(cconj(w), z)));
}
// Geodesic circle for two disk points
function geodesicCircle(a, b) {
const cross = a.re*b.im - a.im*b.re;
if (Math.abs(cross) < 1e-9) return null;
const a2 = cabs2(a), b2 = cabs2(b);
const cx = ((1+b2)*a.re - (1+a2)*b.re) / (2*cross);
const cy = ((1+b2)*a.im - (1+a2)*b.im) / (2*cross);
const rr2 = (cx - a.re)**2 + (cy - a.im)**2;
if (rr2 < 1e-12) return null; // degenerate — treat as straight line
const rr = Math.sqrt(rr2);
if (!isFinite(rr) || rr <= 0) return null;
return { cx, cy, rr };
}
// Inversion (reflect) across geodesic through a,b
function reflectAcrossGeodesic(z, a, b) {
const geo = geodesicCircle(a, b);
if (!geo) {
const th = Math.atan2(b.im - a.im, b.re - a.re);
const c2 = Math.cos(2*th), s2 = Math.sin(2*th);
return C(c2*z.re + s2*z.im, s2*z.re - c2*z.im);
}
const { cx, cy, rr } = geo;
const dz = csub(z, C(cx, cy));
const dz2 = cabs2(dz);
if (dz2 < 1e-14) return z; // z is at the inversion centre — return as-is
return cadd(C(cx, cy), cscale(dz, rr*rr / dz2));
}
// Beardon formula — correct vertex radius for any valid {p,q}
function vertexRadius(p, q) {
if (1/p + 1/q >= 0.5 - 1e-9) return null; // not hyperbolic
const num = Math.cos(Math.PI/p + Math.PI/q);
const den = Math.cos(Math.PI/p - Math.PI/q);
if (den <= 0 || num / den < 0) return null;
return Math.sqrt(num / den);
}
// Fundamental polygon vertices
function buildFundamental(p, q) {
const rv = vertexRadius(p, q);
if (!rv) return null;
const verts = [];
for (let k = 0; k < p; k++) {
const th = 2*Math.PI*k/p + Math.PI/p;
verts.push(C(rv*Math.cos(th), rv*Math.sin(th)));
}
return verts;
}
// ── Palettes ───────────────────────────────────────────────────────────────
const PALS = {
woodcut: {
colors: [
['#1a3020','#2d5c3a','#4a8a5a'],
['#e8d8a0','#c8a84a','#8a6820'],
['#2a1808']
],
bg: '#080c08', rim: 'rgba(180,150,80,0.25)', line: 'rgba(0,0,0,0.55)'
},
midnight: {
colors: [
['#0d1b2e','#1a3a5c','#2a5c90'],
['#5c2010','#903020','#c04030'],
['#0a0d14']
],
bg: '#040810', rim: 'rgba(80,120,200,0.2)', line: 'rgba(0,0,0,0.6)'
},
gilded: {
colors: [
['#18120a','#3a2a10','#6a4a18'],
['#f0e8c8','#d0b060','#a07828'],
['#100c04']
],
bg: '#0c0a04', rim: 'rgba(200,160,60,0.3)', line: 'rgba(0,0,0,0.5)'
},
blood: {
colors: [
['#180808','#3c1010','#6a2020'],
['#e0d8c8','#c0a880','#806040'],
['#0c0404']
],
bg: '#080404', rim: 'rgba(180,60,60,0.25)', line: 'rgba(0,0,0,0.55)'
}
};
// ── State ──────────────────────────────────────────────────────────────────
let p = 5, q = 4, depth = 4, spinSpeed = 3;
let creatureStyle = 'fish', paletteName = 'woodcut';
let globalAngle = 0;
let tiles = [];
let mobiusShift = C(0, 0);
let raf = null;
// ── Tile generation ────────────────────────────────────────────────────────
function tileKey(verts) {
return verts.map(v => `${v.re.toFixed(4)},${v.im.toFixed(4)}`).sort().join('|');
}
function buildTiles() {
const base = buildFundamental(p, q);
if (!base) { tiles = []; return false; }
const map = new Map();
const maxT = Math.min(1800, 300 * depth);
const stack = [{ verts: base, parity: 0, d: 0, fromEdge: -1 }];
let iters = 0;
while (stack.length && iters < maxT * 4) {
iters++;
const { verts, parity, d, fromEdge } = stack.pop();
const cx = verts.reduce((s, v) => s + v.re, 0) / verts.length;
const cy = verts.reduce((s, v) => s + v.im, 0) / verts.length;
if (cx*cx + cy*cy > 0.9998) continue;
const key = tileKey(verts);
if (map.has(key)) continue;
if (map.size >= maxT) continue;
map.set(key, { verts, parity });
if (d >= depth) continue;
const n = verts.length;
for (let e = 0; e < n; e++) {
if (e === fromEdge) continue;
const a = verts[e], b = verts[(e+1)%n];
const newVerts = verts.map(v => reflectAcrossGeodesic(v, a, b));
const ncx = newVerts.reduce((s, v) => s + v.re, 0) / n;
const ncy = newVerts.reduce((s, v) => s + v.im, 0) / n;
if (ncx*ncx + ncy*ncy < 0.9998) {
const oppEdge = (e + Math.floor(n/2)) % n;
stack.push({ verts: newVerts, parity: 1 - parity, d: d+1, fromEdge: oppEdge });
}
}
}
tiles = [...map.values()];
return true;
}
// ── Drawing helpers ────────────────────────────────────────────────────────
function transformVerts(verts) {
return verts.map(v => {
const th = globalAngle;
const c = Math.cos(th), s = Math.sin(th);
const rot = C(v.re*c - v.im*s, v.re*s + v.im*c);
return mobiusWinv(rot, mobiusShift);
});
}
function polyPath(verts) {
ctx.beginPath();
const n = verts.length;
if (!n) return;
ctx.moveTo(CX + verts[0].re * R0, CY + verts[0].im * R0);
for (let i = 0; i < n; i++) {
const a = verts[i], b = verts[(i+1)%n];
const geo = geodesicCircle(a, b);
const arcR = geo ? geo.rr * R0 : 0;
if (!geo || arcR < 0.5) {
// degenerate geodesic — straight line segment
ctx.lineTo(CX + b.re*R0, CY + b.im*R0);
} else {
const { cx, cy, rr } = geo;
const a1 = Math.atan2(a.im - cy, a.re - cx);
const a2 = Math.atan2(b.im - cy, b.re - cx);
const diff = ((a2 - a1) + 2*Math.PI) % (2*Math.PI);
ctx.arc(CX + cx*R0, CY + cy*R0, arcR, a1, a2, diff > Math.PI);
}
}
ctx.closePath();
}
// ── Creature drawers ───────────────────────────────────────────────────────
function tileCenter(verts) {
const n = verts.length;
return { cx: verts.reduce((s,v)=>s+v.re,0)/n, cy: verts.reduce((s,v)=>s+v.im,0)/n };
}
function drawFish(verts, col, parity) {
const n = verts.length;
const { cx, cy } = tileCenter(verts);
const r = cabs(csub(verts[0], C(cx, cy))) * 0.52;
const ang = Math.atan2(verts[0].im - cy, verts[0].re - cx);
const px = CX + cx*R0, py = CY + cy*R0, sc = r*R0;
if (sc < 1) return;
const pal = PALS[paletteName];
const ac = pal.colors[1-parity][1];
ctx.save(); ctx.translate(px, py); ctx.rotate(ang);
// body
ctx.beginPath(); ctx.ellipse(sc*0.08,0,sc*0.9,sc*0.38,0,0,2*Math.PI);
ctx.fillStyle = col; ctx.fill();
// tail lobes
[[1,-1],[1,1]].forEach(([sx,sy]) => {
ctx.beginPath();
ctx.moveTo(-sc*0.82, 0);
ctx.bezierCurveTo(-sc*1.1, sy*sc*0.12, -sc*1.5, sy*sc*0.5, -sc*1.4, sy*sc*0.55);
ctx.bezierCurveTo(-sc*1.2, sy*sc*0.55, -sc*1.3, sy*sc*0.05, -sc*0.82, 0);
ctx.fillStyle = ac; ctx.fill();
});
// dorsal fin
ctx.beginPath();
ctx.moveTo(0, -sc*0.38);
ctx.bezierCurveTo(sc*0.2, -sc*0.7, sc*0.5, -sc*0.65, sc*0.45, -sc*0.38);
ctx.fillStyle = ac; ctx.fill();
// scales
ctx.strokeStyle = 'rgba(0,0,0,0.3)'; ctx.lineWidth = sc*0.04;
for (let s = 0; s < 3; s++) {
ctx.beginPath();
ctx.arc(sc*(0.3-s*0.28), 0, sc*(0.22-s*0.03), Math.PI*0.3, Math.PI*0.85);
ctx.stroke();
}
// eye
ctx.beginPath(); ctx.ellipse(sc*0.55, -sc*0.06, sc*0.13, sc*0.11, 0, 0, 2*Math.PI);
ctx.fillStyle = '#f8f0d0'; ctx.fill();
ctx.beginPath(); ctx.arc(sc*0.57, -sc*0.06, sc*0.07, 0, 2*Math.PI);
ctx.fillStyle = '#080808'; ctx.fill();
ctx.restore();
}
function drawLizard(verts, col, parity) {
const n = verts.length;
const { cx, cy } = tileCenter(verts);
const r = cabs(csub(verts[0], C(cx, cy))) * 0.5;
const ang = Math.atan2(verts[0].im - cy, verts[0].re - cx);
const px = CX + cx*R0, py = CY + cy*R0, sc = r*R0;
if (sc < 1) return;
const pal = PALS[paletteName];
const ac = pal.colors[1-parity][1];
ctx.save(); ctx.translate(px, py); ctx.rotate(ang);
// body
ctx.beginPath(); ctx.ellipse(0, 0, sc*0.85, sc*0.32, 0, 0, 2*Math.PI);
ctx.fillStyle = col; ctx.fill();
// head
ctx.beginPath(); ctx.ellipse(sc*0.95, 0, sc*0.32, sc*0.22, 0, 0, 2*Math.PI);
ctx.fillStyle = col; ctx.fill();
// tail
ctx.beginPath();
ctx.moveTo(-sc*0.85, 0);
ctx.bezierCurveTo(-sc*1.1, sc*0.1, -sc*1.6, sc*0.15, -sc*1.8, 0);
ctx.bezierCurveTo(-sc*1.6, -sc*0.05, -sc*1.1, -sc*0.05, -sc*0.85, 0);
ctx.fillStyle = ac; ctx.fill();
// legs
[[-0.5,-1],[0.2,-1],[-0.5,1],[0.2,1]].forEach(([lx,ly]) => {
ctx.beginPath();
ctx.moveTo(sc*lx, sc*ly*0.32);
ctx.lineTo(sc*(lx-0.15), sc*ly*0.75);
ctx.lineTo(sc*(lx+0.15), sc*ly*0.75);
ctx.fillStyle = ac; ctx.fill();
});
// spine dots
ctx.fillStyle = 'rgba(0,0,0,0.35)';
for (let i = 0; i < 5; i++) {
ctx.beginPath(); ctx.arc(sc*(0.5-i*0.3), 0, sc*0.05, 0, 2*Math.PI); ctx.fill();
}
// eye
ctx.beginPath(); ctx.arc(sc*1.05, -sc*0.07, sc*0.1, 0, 2*Math.PI);
ctx.fillStyle = '#f8f0d0'; ctx.fill();
ctx.beginPath(); ctx.arc(sc*1.07, -sc*0.07, sc*0.06, 0, 2*Math.PI);
ctx.fillStyle = '#080808'; ctx.fill();
ctx.restore();
}
function drawBird(verts, col, parity) {
const n = verts.length;
const { cx, cy } = tileCenter(verts);
const r = cabs(csub(verts[0], C(cx, cy))) * 0.52;
const ang = Math.atan2(verts[0].im - cy, verts[0].re - cx);
const px = CX + cx*R0, py = CY + cy*R0, sc = r*R0;
if (sc < 1) return;
const pal = PALS[paletteName];
const ac = pal.colors[1-parity][1];
ctx.save(); ctx.translate(px, py); ctx.rotate(ang);
// body
ctx.beginPath(); ctx.ellipse(sc*0.1, 0, sc*0.75, sc*0.28, 0, 0, 2*Math.PI);
ctx.fillStyle = col; ctx.fill();
// wings
[[-1],[1]].forEach(([sy]) => {
ctx.beginPath();
ctx.moveTo(sc*0.1, 0);
ctx.bezierCurveTo(-sc*0.1, sy*sc*0.6, sc*0.5, sy*sc*1.1, sc*0.8, sy*sc*0.6);
ctx.bezierCurveTo(sc*0.5, sy*sc*0.45, sc*0.2, sy*sc*0.35, sc*0.1, 0);
ctx.fillStyle = ac; ctx.fill();
});
// tail
[[-0.1],[0.1]].forEach(([sy]) => {
ctx.beginPath();
ctx.moveTo(-sc*0.65, 0);
ctx.bezierCurveTo(-sc*0.9, sy*sc*1.8, -sc*1.3, sy*sc*2.2, -sc*1.4, sy*sc*1.0);
ctx.bezierCurveTo(-sc*1.2, sy*sc*0.0, -sc*0.9, sy*sc*(-0.5), -sc*0.65, 0);
ctx.fillStyle = ac; ctx.fill();
});
// head
ctx.beginPath(); ctx.arc(sc*0.85, 0, sc*0.2, 0, 2*Math.PI);
ctx.fillStyle = col; ctx.fill();
// beak
ctx.beginPath();
ctx.moveTo(sc*1.03, -sc*0.06); ctx.lineTo(sc*1.28, 0); ctx.lineTo(sc*1.03, sc*0.06);
ctx.fillStyle = ac; ctx.fill();
// eye
ctx.beginPath(); ctx.arc(sc*0.9, -sc*0.07, sc*0.07, 0, 2*Math.PI);
ctx.fillStyle = '#f0e8c0'; ctx.fill();
ctx.beginPath(); ctx.arc(sc*0.91, -sc*0.07, sc*0.04, 0, 2*Math.PI);
ctx.fillStyle = '#080808'; ctx.fill();
ctx.restore();
}
function drawGeo(verts, col, parity) {
const n = verts.length;
const { cx, cy } = tileCenter(verts);
const r = cabs(csub(verts[0], C(cx, cy))) * 0.65;
const ang = carg(csub(verts[0], C(cx, cy)));
const px = CX + cx*R0, py = CY + cy*R0, sc = r*R0;
if (sc < 1) return;
const pal = PALS[paletteName];
const ac = pal.colors[1-parity][1];
ctx.save(); ctx.translate(px, py); ctx.rotate(ang);
const pts = n * 2;
ctx.beginPath();
for (let i = 0; i < pts; i++) {
const rr = i%2 === 0 ? sc : sc*0.45;
const th = 2*Math.PI*i/pts;
i === 0 ? ctx.moveTo(rr*Math.cos(th), rr*Math.sin(th))
: ctx.lineTo(rr*Math.cos(th), rr*Math.sin(th));
}
ctx.closePath(); ctx.fillStyle = col; ctx.fill();
ctx.beginPath(); ctx.arc(0, 0, sc*0.28, 0, 2*Math.PI);
ctx.fillStyle = ac; ctx.fill();
ctx.restore();
}
const DRAWERS = { fish: drawFish, lizard: drawLizard, bird: drawBird, geo: drawGeo };
// ── Render ─────────────────────────────────────────────────────────────────
function render() {
const pal = PALS[paletteName];
ctx.clearRect(0, 0, SZ, SZ);
ctx.fillStyle = pal.bg;
ctx.fillRect(0, 0, SZ, SZ);
ctx.save();
ctx.beginPath();
ctx.arc(CX, CY, R0, 0, 2*Math.PI);
ctx.clip();
ctx.fillStyle = pal.bg;
ctx.beginPath(); ctx.arc(CX, CY, R0, 0, 2*Math.PI); ctx.fill();
// fill pass
for (const tile of tiles) {
const verts = transformVerts(tile.verts);
const { cx, cy } = tileCenter(verts);
if (cx*cx + cy*cy > 0.9998) continue;
polyPath(verts);
ctx.fillStyle = pal.colors[tile.parity][0];
ctx.fill();
const drawer = DRAWERS[creatureStyle] || drawFish;
drawer(verts, pal.colors[tile.parity][1], tile.parity);
}
// edge pass
for (const tile of tiles) {
const verts = transformVerts(tile.verts);
const { cx, cy } = tileCenter(verts);
if (cx*cx + cy*cy > 0.9998) continue;
polyPath(verts);
ctx.strokeStyle = pal.line;
ctx.lineWidth = 0.7;
ctx.stroke();
}
// boundary vignette
const grad = ctx.createRadialGradient(CX, CY, R0*0.72, CX, CY, R0);
grad.addColorStop(0, 'rgba(0,0,0,0)');
grad.addColorStop(1, 'rgba(0,0,0,0.88)');
ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(CX, CY, R0, 0, 2*Math.PI); ctx.fill();
ctx.restore();
// rim ring
ctx.beginPath(); ctx.arc(CX, CY, R0, 0, 2*Math.PI);
ctx.strokeStyle = pal.rim;
ctx.lineWidth = 1.5;
ctx.stroke();
}
function loop() {
if (spinSpeed > 0) globalAngle += spinSpeed * 0.00025;
render();
raf = requestAnimationFrame(loop);
}
// ── Build + start ──────────────────────────────────────────────────────────
const invalidMsg = document.getElementById('invalid-msg');
function rebuild() {
const ok = buildTiles();
if (!ok) {
invalidMsg.textContent = `{${p},${q}} is not a valid hyperbolic tiling — need 1/p + 1/q < 1/2`;
} else {
invalidMsg.textContent = '';
}
render();
}
// ── Controls ───────────────────────────────────────────────────────────────
function bindRange(id, key, valId) {
const el = document.getElementById(id);
el.addEventListener('input', () => {
const v = parseInt(el.value);
document.getElementById(valId).textContent = v;
if (key === 'p') p = v;
else if (key === 'q') q = v;
else if (key === 'd') depth = v;
else if (key === 's') { spinSpeed = v; return; }
rebuild();
});
}
bindRange('pp', 'p', 'ppv');
bindRange('qq', 'q', 'qqv');
bindRange('dep', 'd', 'depv');
bindRange('spd', 's', 'spdv');
document.getElementById('pal').addEventListener('change', e => { paletteName = e.target.value; render(); });
document.getElementById('sty').addEventListener('change', e => { creatureStyle = e.target.value; render(); });
// ── Drag navigation (Möbius) ───────────────────────────────────────────────
let dragging = false, dragStart = null, shiftStart = null;
canvas.addEventListener('mousedown', e => {
dragging = true;
const rect = canvas.getBoundingClientRect();
dragStart = { x: e.clientX - rect.left, y: e.clientY - rect.top };
shiftStart = { re: mobiusShift.re, im: mobiusShift.im };
});
canvas.addEventListener('mousemove', e => {
if (!dragging) return;
const rect = canvas.getBoundingClientRect();
const dx = (e.clientX - rect.left - dragStart.x) / R0;
const dy = (e.clientY - rect.top - dragStart.y) / R0;
let ns = C(shiftStart.re - dx, shiftStart.im - dy);
const nm = cabs(ns);
if (nm >= 0.98) ns = cscale(ns, 0.98 / nm);
mobiusShift = ns;
render();
});
canvas.addEventListener('mouseup', () => dragging = false);
canvas.addEventListener('mouseleave', () => dragging = false);
canvas.addEventListener('touchstart', e => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const t = e.touches[0];
dragging = true;
dragStart = { x: t.clientX - rect.left, y: t.clientY - rect.top };
shiftStart = { re: mobiusShift.re, im: mobiusShift.im };
}, { passive: false });
canvas.addEventListener('touchmove', e => {
e.preventDefault();
if (!dragging) return;
const rect = canvas.getBoundingClientRect();
const t = e.touches[0];
const dx = (t.clientX - rect.left - dragStart.x) / R0;
const dy = (t.clientY - rect.top - dragStart.y) / R0;
let ns = C(shiftStart.re - dx, shiftStart.im - dy);
const nm = cabs(ns);
if (nm >= 0.98) ns = cscale(ns, 0.98 / nm);
mobiusShift = ns;
render();
}, { passive: false });
canvas.addEventListener('touchend', () => dragging = false);
// Scroll = spin speed
canvas.addEventListener('wheel', e => {
e.preventDefault();
spinSpeed = Math.max(0, Math.min(20, spinSpeed + (e.deltaY > 0 ? 1 : -1)));
document.getElementById('spd').value = spinSpeed;
document.getElementById('spdv').textContent = spinSpeed;
}, { passive: false });
// ── Go ─────────────────────────────────────────────────────────────────────
rebuild();
loop();
</script>
</body>
</html>