← Mike Higgins Home
Custom Code Experiment •

Hyperbolic Tiling 2

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 2</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="butterfly">Butterfly</option>
          <option value="crab">Crab</option>
          <option value="snake">Snake</option>
          <option value="fox">Fox</option>
          <option value="bat">Bat</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;

    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 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 cscale= (a,s) => C(a.re*s,a.im*s);
    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); };

    function mobiusWinv(z,w) {
      const cw = cconj(w);
      return cdiv(cadd(z,w), cadd(C(1), C(cw.re*z.re - cw.im*z.im, cw.re*z.im + cw.im*z.re)));
    }

    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;
      const rr=Math.sqrt(rr2);
      if (!isFinite(rr)||rr<=0) return null;
      return {cx,cy,rr};
    }

    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;
      return cadd(C(cx,cy), cscale(dz, rr*rr/dz2));
    }

    function vertexRadius(p,q) {
      if (1/p+1/q>=0.5-1e-9) return null;
      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);
    }

    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;
    }

    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)' }
    };

    let p=5,q=4,depth=4,spinSpeed=3;
    let creatureStyle='butterfly', paletteName='woodcut';
    let globalAngle=0, tiles=[], mobiusShift=C(0,0);

    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;
    }

    function transformVerts(verts) {
      return verts.map(v=>{
        const th=globalAngle, 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) {
          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();
    }

    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};
    }

    // ── BUTTERFLY ─────────────────────────────────────────────────────────────
    function drawButterfly(verts,col,parity) {
      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.5) return;
      const ac=PALS[paletteName].colors[1-parity][1];
      ctx.save(); ctx.translate(px,py); ctx.rotate(ang);
      [1,-1].forEach(sy=>{
        ctx.beginPath();
        ctx.moveTo(0,0);
        ctx.bezierCurveTo(sc*0.3,sy*sc*0.2, sc*1.1,sy*sc*0.1, sc*1.2,sy*sc*0.7);
        ctx.bezierCurveTo(sc*1.0,sy*sc*1.1, sc*0.3,sy*sc*1.0, sc*0.1,sy*sc*0.5);
        ctx.closePath(); ctx.fillStyle=col; ctx.fill();
        ctx.beginPath();
        ctx.moveTo(sc*0.15,sy*sc*0.15);
        ctx.bezierCurveTo(sc*0.5,sy*sc*0.1, sc*0.9,sy*sc*0.15, sc*1.0,sy*sc*0.6);
        ctx.strokeStyle='rgba(0,0,0,0.25)'; ctx.lineWidth=sc*0.04; ctx.stroke();
      });
      [1,-1].forEach(sy=>{
        ctx.beginPath();
        ctx.moveTo(0,0);
        ctx.bezierCurveTo(-sc*0.2,sy*sc*0.2, -sc*0.8,sy*sc*0.15, -sc*0.9,sy*sc*0.7);
        ctx.bezierCurveTo(-sc*0.7,sy*sc*1.0, -sc*0.2,sy*sc*0.85, sc*0.05,sy*sc*0.45);
        ctx.closePath(); ctx.fillStyle=ac; ctx.fill();
      });
      ctx.beginPath(); ctx.ellipse(0,0,sc*0.12,sc*0.55,0,0,2*Math.PI);
      ctx.fillStyle='rgba(0,0,0,0.7)'; ctx.fill();
      [1,-1].forEach(sy=>{
        ctx.beginPath();
        ctx.moveTo(0,sy*sc*0.1);
        ctx.quadraticCurveTo(sc*0.3,sy*sc*0.5, sc*0.5,sy*sc*0.65);
        ctx.strokeStyle='rgba(0,0,0,0.6)'; ctx.lineWidth=sc*0.025; ctx.stroke();
        ctx.beginPath(); ctx.arc(sc*0.5,sy*sc*0.65,sc*0.04,0,2*Math.PI);
        ctx.fillStyle='rgba(0,0,0,0.7)'; ctx.fill();
      });
      ctx.restore();
    }

    // ── CRAB ──────────────────────────────────────────────────────────────────
    function drawCrab(verts,col,parity) {
      const {cx,cy}=tileCenter(verts);
      const r=cabs(csub(verts[0],C(cx,cy)))*0.48;
      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.5) return;
      const ac=PALS[paletteName].colors[1-parity][1];
      ctx.save(); ctx.translate(px,py); ctx.rotate(ang);
      [[-0.3,-1],[0.0,-1],[0.3,-1],[-0.3,1],[0.0,1],[0.3,1]].forEach(([lx,ly])=>{
        ctx.beginPath();
        ctx.moveTo(sc*lx, sc*ly*0.25);
        ctx.lineTo(sc*(lx+ly*0.05), sc*ly*0.65);
        ctx.lineTo(sc*(lx+ly*0.2), sc*ly*0.75);
        ctx.strokeStyle=ac; ctx.lineWidth=sc*0.07; ctx.lineCap='round'; ctx.stroke();
      });
      [-1,1].forEach(sy=>{
        ctx.beginPath();
        ctx.moveTo(sc*0.45, sy*sc*0.15); ctx.lineTo(sc*0.9, sy*sc*0.4);
        ctx.strokeStyle=ac; ctx.lineWidth=sc*0.09; ctx.stroke();
        ctx.beginPath(); ctx.arc(sc*0.9, sy*sc*0.4, sc*0.18, 0, 2*Math.PI);
        ctx.fillStyle=col; ctx.fill();
        ctx.beginPath();
        ctx.moveTo(sc*0.9,sy*sc*0.4); ctx.lineTo(sc*1.08,sy*sc*0.28); ctx.lineTo(sc*1.05,sy*sc*0.5);
        ctx.fillStyle=ac; ctx.fill();
      });
      ctx.beginPath(); ctx.ellipse(0,0,sc*0.55,sc*0.35,0,0,2*Math.PI);
      ctx.fillStyle=col; ctx.fill();
      for (let i=0;i<3;i++) {
        ctx.beginPath(); ctx.ellipse(0,0,sc*(0.55-i*0.13),sc*(0.35-i*0.08),0,0,2*Math.PI);
        ctx.strokeStyle='rgba(0,0,0,0.2)'; ctx.lineWidth=sc*0.03; ctx.stroke();
      }
      [-1,1].forEach(sy=>{
        ctx.beginPath(); ctx.moveTo(sy*sc*0.2,-sc*0.35); ctx.lineTo(sy*sc*0.25,-sc*0.52);
        ctx.strokeStyle=ac; ctx.lineWidth=sc*0.05; ctx.stroke();
        ctx.beginPath(); ctx.arc(sy*sc*0.25,-sc*0.52,sc*0.07,0,2*Math.PI);
        ctx.fillStyle='#f8f0d0'; ctx.fill();
        ctx.beginPath(); ctx.arc(sy*sc*0.27,-sc*0.53,sc*0.04,0,2*Math.PI);
        ctx.fillStyle='#080808'; ctx.fill();
      });
      ctx.restore();
    }

    // ── SNAKE ─────────────────────────────────────────────────────────────────
    function drawSnake(verts,col,parity) {
      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.5) return;
      const ac=PALS[paletteName].colors[1-parity][1];
      ctx.save(); ctx.translate(px,py); ctx.rotate(ang);
      ctx.beginPath();
      ctx.moveTo(sc*0.8, 0);
      ctx.bezierCurveTo(sc*0.8, -sc*0.7, -sc*0.2, -sc*0.85, -sc*0.6, -sc*0.35);
      ctx.bezierCurveTo(-sc*1.0, sc*0.1, -sc*0.7, sc*0.7, sc*0.0, sc*0.7);
      ctx.bezierCurveTo(sc*0.5, sc*0.7, sc*0.6, sc*0.2, sc*0.3, sc*0.1);
      ctx.strokeStyle=col; ctx.lineWidth=sc*0.22; ctx.lineCap='round'; ctx.lineJoin='round'; ctx.stroke();
      ctx.strokeStyle=ac; ctx.lineWidth=sc*0.06;
      for (let i=0;i<5;i++) {
        const bx=sc*(0.8-i/5*1.4), by=sc*(i/5*0.5-0.2);
        ctx.beginPath(); ctx.arc(bx,by,sc*0.09, 0.3, Math.PI-0.3); ctx.stroke();
      }
      ctx.beginPath(); ctx.ellipse(sc*0.9,0,sc*0.22,sc*0.14,0,0,2*Math.PI);
      ctx.fillStyle=col; ctx.fill();
      ctx.beginPath();
      ctx.moveTo(sc*1.1,0); ctx.lineTo(sc*1.35,-sc*0.07);
      ctx.moveTo(sc*1.1,0); ctx.lineTo(sc*1.35,sc*0.07);
      ctx.strokeStyle='rgba(180,20,20,0.9)'; ctx.lineWidth=sc*0.03; ctx.stroke();
      ctx.beginPath(); ctx.ellipse(sc*0.95,-sc*0.05,sc*0.06,sc*0.05,0,0,2*Math.PI);
      ctx.fillStyle='#f8f0d0'; ctx.fill();
      ctx.beginPath(); ctx.arc(sc*0.96,-sc*0.05,sc*0.03,0,2*Math.PI);
      ctx.fillStyle='#080808'; ctx.fill();
      ctx.restore();
    }

    // ── FOX ───────────────────────────────────────────────────────────────────
    function drawFox(verts,col,parity) {
      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.5) return;
      const ac=PALS[paletteName].colors[1-parity][1];
      ctx.save(); ctx.translate(px,py); ctx.rotate(ang);
      ctx.beginPath();
      ctx.moveTo(-sc*0.7,0);
      ctx.bezierCurveTo(-sc*1.1,sc*0.15,-sc*1.7,sc*0.55,-sc*1.5,sc*0.0);
      ctx.bezierCurveTo(-sc*1.7,-sc*0.55,-sc*1.1,-sc*0.35,-sc*0.7,0);
      ctx.fillStyle=ac; ctx.fill();
      ctx.beginPath(); ctx.ellipse(-sc*1.5,0,sc*0.2,sc*0.13,0,0,2*Math.PI);
      ctx.fillStyle='rgba(240,230,210,0.85)'; ctx.fill();
      ctx.beginPath(); ctx.ellipse(0,0,sc*0.7,sc*0.28,0,0,2*Math.PI);
      ctx.fillStyle=col; ctx.fill();
      [[-0.3,-1],[-0.3,1],[0.2,-1],[0.2,1]].forEach(([lx,ly])=>{
        ctx.beginPath();
        ctx.moveTo(sc*lx, sc*ly*0.28); ctx.lineTo(sc*lx, sc*ly*0.62); ctx.lineTo(sc*(lx+0.12), sc*ly*0.65);
        ctx.strokeStyle=col; ctx.lineWidth=sc*0.1; ctx.lineCap='round'; ctx.stroke();
      });
      ctx.beginPath(); ctx.ellipse(sc*0.8,0,sc*0.3,sc*0.25,0,0,2*Math.PI);
      ctx.fillStyle=col; ctx.fill();
      ctx.beginPath(); ctx.ellipse(sc*1.08,sc*0.04,sc*0.18,sc*0.11,-0.15,0,2*Math.PI);
      ctx.fillStyle=ac; ctx.fill();
      [-1,1].forEach(sy=>{
        ctx.beginPath();
        ctx.moveTo(sc*0.72,sy*sc*0.22); ctx.lineTo(sc*0.6,sy*sc*0.55); ctx.lineTo(sc*0.9,sy*sc*0.24);
        ctx.closePath(); ctx.fillStyle=col; ctx.fill();
        ctx.beginPath();
        ctx.moveTo(sc*0.73,sy*sc*0.24); ctx.lineTo(sc*0.64,sy*sc*0.47); ctx.lineTo(sc*0.86,sy*sc*0.26);
        ctx.closePath(); ctx.fillStyle=ac; ctx.fill();
      });
      ctx.beginPath(); ctx.ellipse(sc*0.88,-sc*0.07,sc*0.07,sc*0.06,0,0,2*Math.PI);
      ctx.fillStyle='#f8e8c0'; ctx.fill();
      ctx.beginPath(); ctx.arc(sc*0.89,-sc*0.07,sc*0.04,0,2*Math.PI);
      ctx.fillStyle='#080808'; ctx.fill();
      ctx.beginPath(); ctx.arc(sc*1.14,sc*0.04,sc*0.04,0,2*Math.PI);
      ctx.fillStyle='rgba(0,0,0,0.75)'; ctx.fill();
      ctx.restore();
    }

    // ── BAT ───────────────────────────────────────────────────────────────────
    function drawBat(verts,col,parity) {
      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.5) return;
      const ac=PALS[paletteName].colors[1-parity][1];
      ctx.save(); ctx.translate(px,py); ctx.rotate(ang);
      [1,-1].forEach(sy=>{
        ctx.beginPath();
        ctx.moveTo(0,0);
        ctx.bezierCurveTo(sc*0.2,sy*sc*0.2, sc*0.9,sy*sc*0.1, sc*1.3,sy*sc*0.5);
        ctx.bezierCurveTo(sc*1.1,sy*sc*0.9, sc*0.5,sy*sc*1.0, sc*0.1,sy*sc*0.55);
        ctx.bezierCurveTo(sc*0.05,sy*sc*0.4, -sc*0.05,sy*sc*0.2, 0,0);
        ctx.fillStyle=col; ctx.fill();
        [[0.5,0.2,1.1,0.4],[0.35,0.35,0.8,0.85],[0.15,0.42,0.35,0.9]].forEach(([x1,y1,x2,y2])=>{
          ctx.beginPath(); ctx.moveTo(sc*0.05,sy*sc*0.05); ctx.lineTo(sc*x2,sy*sc*y2);
          ctx.strokeStyle='rgba(0,0,0,0.2)'; ctx.lineWidth=sc*0.025; ctx.stroke();
        });
      });
      ctx.beginPath(); ctx.ellipse(sc*0.05,0,sc*0.22,sc*0.18,0,0,2*Math.PI);
      ctx.fillStyle=col; ctx.fill();
      ctx.beginPath(); ctx.arc(sc*0.28,0,sc*0.2,0,2*Math.PI);
      ctx.fillStyle=col; ctx.fill();
      [-1,1].forEach(sy=>{
        ctx.beginPath();
        ctx.moveTo(sc*0.2,sy*sc*0.16); ctx.lineTo(sc*0.15,sy*sc*0.42); ctx.lineTo(sc*0.35,sy*sc*0.18);
        ctx.closePath(); ctx.fillStyle=ac; ctx.fill();
      });
      [-1,1].forEach(sy=>{
        ctx.beginPath(); ctx.arc(sc*0.32,sy*sc*0.07,sc*0.055,0,2*Math.PI);
        ctx.fillStyle='rgba(255,230,180,0.9)'; ctx.fill();
        ctx.beginPath(); ctx.arc(sc*0.33,sy*sc*0.07,sc*0.03,0,2*Math.PI);
        ctx.fillStyle='#080808'; ctx.fill();
      });
      ctx.beginPath();
      ctx.moveTo(sc*0.44,-sc*0.03); ctx.lineTo(sc*0.48,-sc*0.07); ctx.lineTo(sc*0.46,sc*0.01);
      ctx.moveTo(sc*0.46,-sc*0.01); ctx.lineTo(sc*0.5,sc*0.05); ctx.lineTo(sc*0.48,sc*0.04);
      ctx.strokeStyle='rgba(240,230,220,0.9)'; ctx.lineWidth=sc*0.025; ctx.stroke();
      ctx.restore();
    }

    const DRAWERS = { butterfly:drawButterfly, crab:drawCrab, snake:drawSnake, fox:drawFox, bat:drawBat };

    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();
      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();
        (DRAWERS[creatureStyle]||drawButterfly)(verts, pal.colors[tile.parity][1], tile.parity);
      }
      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();
      }
      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();
      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();
      requestAnimationFrame(loop);
    }

    const invalidMsg=document.getElementById('invalid-msg');
    function rebuild() {
      const ok=buildTiles();
      invalidMsg.textContent=ok?'':'{'+p+','+q+'} is not a valid hyperbolic tiling — need 1/p + 1/q < 1/2';
      render();
    }

    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();});

    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(),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(),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);
    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});

    rebuild();
    loop();
  </script>
</body>
</html>