← Mike Higgins Home
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>