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>