← back to Model Arena

data/artifacts/127317180daf/claude-code.html

918 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rose City Rooftop Solstice</title>
<style>
  :root{
    --ink:#f6ecff;
    --ink-dim:#c9bce0;
    --panel:rgba(18,10,38,.72);
    --panel-solid:#160a2e;
    --edge:rgba(255,214,170,.28);
    --gold:#f7c56b;
    --rose:#ff7fae;
    --violet:#b48bff;
    --focus:#7ff0e6;
    --shadow:0 18px 50px rgba(0,0,0,.55);
    font-synthesis:none;
  }
  *{box-sizing:border-box}
  html,body{height:100%;margin:0}
  body{
    background:#0a0618;
    color:var(--ink);
    font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,"Times New Roman",serif;
    overflow:hidden;
    -webkit-font-smoothing:antialiased;
  }
  #stage-wrap{position:fixed;inset:0}
  canvas{display:block;width:100%;height:100%}

  /* Title */
  .masthead{
    position:fixed;top:0;left:0;right:0;padding:18px 22px 40px;
    pointer-events:none;
    background:linear-gradient(180deg,rgba(6,3,16,.55),rgba(6,3,16,0));
    z-index:3;
  }
  .masthead h1{
    margin:0;font-weight:500;letter-spacing:.11em;
    font-size:clamp(19px,3.1vw,34px);
    text-transform:uppercase;
    text-shadow:0 2px 22px rgba(0,0,0,.6);
  }
  .masthead h1 .rose{color:var(--rose)}
  .masthead p{
    margin:.35em 0 0;font-size:clamp(11px,1.5vw,14px);
    color:var(--ink-dim);letter-spacing:.16em;text-transform:uppercase;
    font-family:ui-sans-serif,system-ui,"Helvetica Neue",Arial,sans-serif;
  }
  .scene-flag{
    display:inline-block;margin-top:.5em;padding:.28em .8em;
    border:1px solid var(--edge);border-radius:999px;
    font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;
    letter-spacing:.22em;color:var(--gold);background:rgba(20,10,40,.4);
  }

  /* Controls */
  .controls{
    position:fixed;top:14px;right:14px;z-index:6;
    width:min(320px,90vw);max-height:calc(100vh - 28px);overflow:auto;
    background:var(--panel);backdrop-filter:blur(11px);
    border:1px solid var(--edge);border-radius:16px;
    box-shadow:var(--shadow);
    font-family:ui-sans-serif,system-ui,"Helvetica Neue",Arial,sans-serif;
    scrollbar-width:thin;
  }
  .controls header{
    display:flex;align-items:center;justify-content:space-between;
    padding:12px 14px;position:sticky;top:0;background:var(--panel-solid);
    border-bottom:1px solid var(--edge);z-index:2;border-radius:16px 16px 0 0;
  }
  .controls header h2{margin:0;font-size:13px;letter-spacing:.16em;text-transform:uppercase;color:var(--gold);font-weight:600}
  .toggle-panel{
    background:none;border:1px solid var(--edge);color:var(--ink);
    border-radius:8px;padding:4px 9px;cursor:pointer;font-size:12px;
  }
  .controls .body{padding:6px 14px 16px}
  fieldset{border:none;border-top:1px solid rgba(255,255,255,.08);margin:0;padding:14px 0 4px}
  fieldset:first-of-type{border-top:none}
  legend{
    padding:0;font-size:11px;letter-spacing:.2em;text-transform:uppercase;
    color:var(--violet);font-weight:600;margin-bottom:8px;
  }
  .btnrow{display:flex;flex-wrap:wrap;gap:6px}
  .seg{
    flex:1 1 auto;min-width:64px;text-align:center;
    background:rgba(255,255,255,.05);border:1px solid transparent;
    color:var(--ink);border-radius:9px;padding:8px 6px;cursor:pointer;
    font-size:12px;letter-spacing:.04em;transition:background .18s,border-color .18s;
  }
  .seg:hover{background:rgba(255,255,255,.12)}
  .seg[aria-pressed="true"],.seg.active{
    background:linear-gradient(180deg,rgba(255,127,174,.35),rgba(180,139,255,.28));
    border-color:var(--edge);color:#fff;font-weight:600;
  }
  .slider{margin:10px 0}
  .slider label{display:flex;justify-content:space-between;font-size:12px;color:var(--ink-dim);margin-bottom:4px}
  .slider output{color:var(--gold);font-variant-numeric:tabular-nums}
  input[type="range"]{width:100%;accent-color:var(--rose);height:22px;cursor:pointer}
  .checks{display:grid;grid-template-columns:1fr 1fr;gap:6px 10px}
  .chk{display:flex;align-items:center;gap:7px;font-size:12.5px;color:var(--ink);cursor:pointer;user-select:none}
  .chk input{accent-color:var(--violet);width:15px;height:15px;cursor:pointer}
  .transport{display:flex;gap:6px}
  .transport .seg{font-weight:600}
  .hint{font-size:11px;color:var(--ink-dim);margin:10px 0 0;line-height:1.5}
  kbd{
    font-family:ui-monospace,Menlo,Consolas,monospace;font-size:10.5px;
    background:rgba(255,255,255,.1);border:1px solid var(--edge);
    border-radius:4px;padding:1px 5px;color:var(--ink);
  }
  .note{
    position:fixed;left:14px;bottom:12px;z-index:5;max-width:min(430px,72vw);
    font-family:ui-sans-serif,system-ui,sans-serif;font-size:11.5px;line-height:1.5;
    color:var(--ink-dim);background:rgba(10,5,24,.5);backdrop-filter:blur(6px);
    border:1px solid rgba(255,255,255,.08);border-left:2px solid var(--gold);
    border-radius:0 10px 10px 0;padding:9px 13px;
  }
  .note b{color:var(--gold);font-weight:600}

  /* Focus states */
  a:focus-visible,button:focus-visible,input:focus-visible,.chk:focus-within,[tabindex]:focus-visible{
    outline:3px solid var(--focus);outline-offset:2px;border-radius:8px;
  }
  .skip{position:fixed;left:-999px;top:0;z-index:20;background:var(--panel-solid);color:#fff;padding:10px 16px;border-radius:0 0 10px 0}
  .skip:focus{left:0}

  .controls.collapsed .body{display:none}
  @media (max-width:560px){
    .controls{width:calc(100vw - 20px);left:10px;right:10px}
    .note{max-width:calc(100vw - 28px)}
  }
  @media (prefers-reduced-motion:reduce){
    *{scroll-behavior:auto}
  }
</style>
</head>
<body>
<a class="skip" href="#controls">Skip to controls</a>

<div id="stage-wrap">
  <canvas id="scene" role="img"
    aria-label="An animated urban-fantasy rooftop garden at solstice sunset: dancers spin around a rose-shaped light sculpture, couples cross narrow bridges, freestyle dancers trail curves near water towers, and a quiet procession climbs fire escapes while bears, cats, pigeons, dogs and moths move through the scene."></canvas>
</div>

<header class="masthead" aria-hidden="false">
  <h1>Rose City <span class="rose">Rooftop</span> Solstice</h1>
  <p>An authored dance-garden above the glowing city</p><br>
  <span class="scene-flag" id="sceneFlag">Arrival</span>
</header>

<form class="controls" id="controls" aria-label="Composition controls">
  <header>
    <h2>Direct the Solstice</h2>
    <button type="button" class="toggle-panel" id="panelToggle" aria-expanded="true" aria-controls="ctlBody">Hide</button>
  </header>
  <div class="body" id="ctlBody">

    <fieldset>
      <legend>Camera</legend>
      <div class="btnrow" role="group" aria-label="Camera view">
        <button type="button" class="seg" data-cam="skyline"  aria-pressed="false">Skyline</button>
        <button type="button" class="seg" data-cam="rooftop"  aria-pressed="true">Rooftop</button>
        <button type="button" class="seg" data-cam="birdseye" aria-pressed="false">Bird’s-eye</button>
      </div>
    </fieldset>

    <fieldset>
      <legend>Scene</legend>
      <div class="btnrow" role="group" aria-label="Scene chapter">
        <button type="button" class="seg" data-scene="0" aria-pressed="false">Arrival</button>
        <button type="button" class="seg" data-scene="1" aria-pressed="false">Long Light</button>
        <button type="button" class="seg" data-scene="2" aria-pressed="false">Midnight Bloom</button>
        <button type="button" class="seg active" data-scene="auto" aria-pressed="true">Auto ↻</button>
      </div>
    </fieldset>

    <fieldset>
      <legend>Tempo &amp; Crowd</legend>
      <div class="slider">
        <label for="tempo">Visual tempo <output id="tempoOut">1.0×</output></label>
        <input type="range" id="tempo" min="0" max="2" step="0.05" value="1">
      </div>
      <div class="slider">
        <label for="density">Crowd density <output id="densityOut">70%</output></label>
        <input type="range" id="density" min="0" max="1" step="0.02" value="0.7">
      </div>
    </fieldset>

    <fieldset>
      <legend>Isolate Choreography</legend>
      <div class="checks">
        <label class="chk"><input type="checkbox" data-grp="circle" checked> Rose circle</label>
        <label class="chk"><input type="checkbox" data-grp="couples" checked> Bridge couples</label>
        <label class="chk"><input type="checkbox" data-grp="freestyle" checked> Freestyle</label>
        <label class="chk"><input type="checkbox" data-grp="procession" checked> Procession</label>
        <label class="chk"><input type="checkbox" data-grp="animals" checked> Animals</label>
      </div>
    </fieldset>

    <fieldset>
      <legend>Overlays</legend>
      <div class="checks">
        <label class="chk"><input type="checkbox" id="ovNames"> Animal names</label>
        <label class="chk"><input type="checkbox" id="ovTrails" checked> Motion trails</label>
        <label class="chk"><input type="checkbox" id="ovRoutes"> Route diagrams</label>
        <label class="chk"><input type="checkbox" id="ovReduced"> Reduced motion</label>
      </div>
    </fieldset>

    <fieldset>
      <legend>Transport</legend>
      <div class="transport" role="group" aria-label="Playback">
        <button type="button" class="seg" id="btnPause" aria-pressed="false">⏸ Pause</button>
        <button type="button" class="seg" id="btnRestart">↺ Restart</button>
      </div>
      <p class="hint">
        <kbd>Space</kbd> pause · <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> cameras ·
        <kbd>R</kbd> reduced motion · <kbd>N</kbd> names · <kbd>T</kbd> trails · <kbd>D</kbd> routes.
      </p>
    </fieldset>

  </div>
</form>

<p class="note">
  <b>An honest note.</b> This piece keeps its own solstice time. Its visual rhythm is
  <b>authored in code</b> — every sway, spiral and bloom is choreographed by hand and does
  <b>not</b> listen to any audio. Imagery is wholly original, evoking the generous,
  improvisational spirit of open-air gatherings.
</p>

<script>
(() => {
"use strict";

/* ---------- setup ---------- */
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const STAGE_W = 1600, STAGE_H = 900;
let W = 0, H = 0, DPR = 1, viewScale = 1;

function resize(){
  DPR = Math.min(window.devicePixelRatio||1, 2);
  W = window.innerWidth; H = window.innerHeight;
  canvas.width = Math.floor(W*DPR); canvas.height = Math.floor(H*DPR);
  canvas.style.width = W+'px'; canvas.style.height = H+'px';
  ctx.setTransform(DPR,0,0,DPR,0,0);
  viewScale = Math.max(W/STAGE_W, H/STAGE_H) * 1.02;
  if(state.reduced) render();
}
window.addEventListener('resize', resize);

/* ---------- state ---------- */
const state = {
  tempo:1, density:0.7, paused:false, reduced:false,
  camera:'rooftop',
  sceneMode:'auto', scenePinned:0, scenePhase:0,
  show:{circle:true,couples:true,freestyle:true,procession:true,animals:true},
  names:false, trails:true, routes:false
};

/* ---------- seeded rng ---------- */
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
const rand = mulberry32(20260621);

/* ---------- palettes / scenes ---------- */
const SCENES = [
  { name:'Arrival',        top:'#f7b25a', mid:'#c9527d', low:'#3a2350', glow:'#ffd98a', star:0.0,  cx:0.30 },
  { name:'Long Light',     top:'#ff8f5e', mid:'#b13a80', low:'#2c1e5a', glow:'#ff9ec4', star:0.15, cx:0.70 },
  { name:'Midnight Bloom', top:'#3a2f74', mid:'#6a2c82', low:'#120a2e', glow:'#c39bff', star:1.0,  cx:0.95 }
];
const lerp=(a,b,t)=>a+(b-a)*t;
function hx(c){return [parseInt(c.slice(1,3),16),parseInt(c.slice(3,5),16),parseInt(c.slice(5,7),16)];}
function mix(c1,c2,t){const a=hx(c1),b=hx(c2);return `rgb(${Math.round(lerp(a[0],b[0],t))},${Math.round(lerp(a[1],b[1],t))},${Math.round(lerp(a[2],b[2],t))})`;}
function scenePal(){
  const p = state.scenePhase;
  const i = Math.floor(p)%3, f = p-Math.floor(p), j=(i+1)%3;
  const A=SCENES[i], B=SCENES[j];
  return {
    top:mix(A.top,B.top,f), mid:mix(A.mid,B.mid,f), low:mix(A.low,B.low,f),
    glow:mix(A.glow,B.glow,f), star:lerp(A.star,B.star,f),
    complexity:lerp(A.cx,B.cx,f), idx:i, frac:f, name:SCENES[i].name
  };
}

/* ---------- static world geometry ---------- */
// skyline buildings (parallax)
const buildings = [];
(function(){
  // far layer
  for(let i=0;i<26;i++){
    const w=40+rand()*70; const x=rand()*STAGE_W;
    buildings.push({x, w, h:120+rand()*260, depth:0.25, y:STAGE_H*0.62, hue:rand()});
  }
  // mid layer
  for(let i=0;i<16;i++){
    const w=70+rand()*120; const x=rand()*STAGE_W;
    buildings.push({x, w, h:200+rand()*320, depth:0.55, y:STAGE_H*0.72, hue:rand()});
  }
})();
buildings.sort((a,b)=>a.depth-b.depth);

const clouds = [];
for(let i=0;i<8;i++) clouds.push({x:rand()*STAGE_W, y:80+rand()*260, s:0.6+rand()*1.4, sp:6+rand()*14, d:0.35+rand()*0.4});

// laundry lines between buildings
const laundry=[];
for(let i=0;i<5;i++){
  const x=200+rand()*1100, y=180+rand()*180, len=90+rand()*140;
  const items=3+Math.floor(rand()*4), pcs=[];
  for(let k=0;k<items;k++) pcs.push({t:(k+1)/(items+1), c:['#ff9ec4','#ffe7a8','#9be7e0','#d9c6ff','#ffd0b0'][Math.floor(rand()*5)], w:14+rand()*10, h:20+rand()*16});
  laundry.push({x,y,len,pcs,sag:14+rand()*10});
}

// rooftop platforms
const roofs=[
  {x:150,y:610,w:520,h:300},          // left roof
  {x:640,y:560,w:640,h:340},          // central roof (dance floor)
  {x:1270,y:640,w:300,h:280}          // right roof
];
const ROSE={x:960,y:610};             // rose sculpture center (on central roof)
const towers=[{x:520,y:470},{x:1180,y:430}]; // water towers
const fireEscapeX=1470;               // right building fire escape

/* zone spread by camera (bird's-eye separates zones) */
function camParams(){
  switch(state.camera){
    case 'skyline':  return {zoom:0.66, squash:1.0,  panX:0, panY:-40, spread:0.92, floor:0, figScale:0.86};
    case 'birdseye': return {zoom:0.94, squash:0.74, panX:0, panY:120, spread:1.24, floor:1, figScale:1.0};
    default:         return {zoom:1.0,  squash:1.0,  panX:0, panY:38,  spread:1.0,  floor:0, figScale:1.06};
  }
}
// map a stage point outward from rose center by spread (for bird's-eye separation)
function spr(x,y,cp){ return {x: ROSE.x + (x-ROSE.x)*cp.spread, y: ROSE.y + (y-ROSE.y)*cp.spread}; }

/* ---------- trails buffers ---------- */
const freeTrails=[]; // per freestyle dancer
const MAXTRAIL=26;

/* ---------- clock ---------- */
let clock=0, sceneClock=0, last=performance.now(), rafId=null;

function loop(now){
  const dt=Math.min(0.05,(now-last)/1000); last=now;
  if(!state.paused && !state.reduced){
    clock += dt*state.tempo;
    if(state.sceneMode==='auto'){
      sceneClock += dt*state.tempo;
      state.scenePhase = (sceneClock/22)%3;   // ~22s per chapter
    }
  }
  render();
  rafId=requestAnimationFrame(loop);
}
function startLoop(){ if(!rafId){ last=performance.now(); rafId=requestAnimationFrame(loop);} }
function stopLoop(){ if(rafId){ cancelAnimationFrame(rafId); rafId=null;} }

/* ---------- drawing helpers ---------- */
function roundRect(x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();}

function drawDancer(x,y,s,ph,col,energy){
  const e=state.reduced?0.18:energy;
  const armA=Math.sin(ph)*1.1*e, armB=Math.sin(ph+2.1)*1.1*e;
  const legA=Math.sin(ph+0.6)*0.5*e, legB=Math.sin(ph+3.1)*0.5*e;
  const bob=Math.sin(ph*2)*2.4*e*s;
  const cx=x, cy=y+bob;
  ctx.save();
  ctx.strokeStyle=col; ctx.lineWidth=2.2*s; ctx.lineCap='round'; ctx.fillStyle=col;
  const hip=cy, sh=cy-13*s, hd=sh-8*s;
  // legs
  ctx.beginPath(); ctx.moveTo(cx,hip); ctx.lineTo(cx-6*s+legA*7*s, hip+15*s); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(cx,hip); ctx.lineTo(cx+6*s+legB*7*s, hip+15*s); ctx.stroke();
  // torso
  ctx.beginPath(); ctx.moveTo(cx,hip); ctx.lineTo(cx,sh); ctx.stroke();
  // arms
  ctx.beginPath(); ctx.moveTo(cx,sh+2*s); ctx.lineTo(cx-9*s, sh-6*s-armA*10*s); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(cx,sh+2*s); ctx.lineTo(cx+9*s, sh-6*s-armB*10*s); ctx.stroke();
  // head
  ctx.beginPath(); ctx.arc(cx,hd,4.2*s,0,7); ctx.fill();
  ctx.restore();
}

function label(x,y,text,col){
  ctx.save();
  ctx.font='600 13px ui-sans-serif,system-ui,sans-serif';
  ctx.textAlign='center';
  const w=ctx.measureText(text).width+12;
  ctx.fillStyle='rgba(8,4,20,.6)';
  roundRect(x-w/2,y-16,w,17,8); ctx.fill();
  ctx.fillStyle=col||'#fff';
  ctx.fillText(text,x,y-3);
  ctx.restore();
}

/* ---------- background ---------- */
function drawSky(pal){
  const g=ctx.createLinearGradient(0,0,0,STAGE_H);
  g.addColorStop(0,pal.top); g.addColorStop(0.42,pal.mid); g.addColorStop(1,pal.low);
  ctx.fillStyle=g; ctx.fillRect(-400,-400,STAGE_W+800,STAGE_H+800);
  // sun / solstice orb sinking with scene
  const sunY = lerp(320,540,pal.idx===2?1:pal.frac*0.5+ (pal.idx*0.5));
  const sy = 260 + state.scenePhase*90;
  const gr=ctx.createRadialGradient(1080,sy,10,1080,sy,220);
  gr.addColorStop(0,pal.glow); gr.addColorStop(0.4,'rgba(255,210,150,'+(0.5-pal.star*0.3)+')'); gr.addColorStop(1,'rgba(255,210,150,0)');
  ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(1080,sy,220,0,7); ctx.fill();
  ctx.fillStyle=mix(pal.glow,'#ffffff',0.4); ctx.beginPath(); ctx.arc(1080,sy,34-state.scenePhase*4,0,7); ctx.fill();
  // stars
  if(pal.star>0.02){
    ctx.save(); ctx.fillStyle='#fff';
    const sr=mulberry32(7);
    for(let i=0;i<140;i++){
      const x=sr()*STAGE_W, y=sr()*STAGE_H*0.55;
      const tw=state.reduced?0.6:(0.4+0.6*Math.abs(Math.sin(clock*1.5+i)));
      ctx.globalAlpha=pal.star*tw*0.9; ctx.fillRect(x,y,1.6,1.6);
    }
    ctx.restore();
  }
}

function drawClouds(pal){
  ctx.save();
  for(const c of clouds){
    const dx = state.reduced ? c.x : (c.x + clock*c.sp*c.d)%(STAGE_W+400)-200;
    ctx.globalAlpha=0.16+0.12*(1-pal.star);
    ctx.fillStyle=mix(pal.glow,'#ffffff',0.3);
    for(let k=0;k<4;k++){
      ctx.beginPath();
      ctx.ellipse(dx+k*36*c.s, c.y+Math.sin(k)*8*c.s, 46*c.s-k*4, 20*c.s, 0,0,7);
      ctx.fill();
    }
  }
  ctx.restore();
}

function drawBuildings(pal,cp){
  const drift = state.reduced?0:Math.sin(clock*0.06)*10;
  for(const b of buildings){
    const x=b.x + drift*b.depth;
    const base = mix(pal.low, pal.mid, b.depth*0.5);
    ctx.fillStyle=mix('#0c0722', base, 0.45+b.depth*0.4);
    ctx.fillRect(x, b.y-b.h, b.w, b.h);
    // reflective windows
    const cols=Math.max(2,Math.floor(b.w/16)), rows=Math.floor(b.h/22);
    for(let r=0;r<rows;r++){
      for(let cc=0;cc<cols;cc++){
        const wx=x+6+cc*16, wy=b.y-b.h+10+r*22;
        if(wx>x+b.w-8) continue;
        const idn=(r*7+cc*13+Math.floor(b.x));
        const lit=(Math.sin(idn)*0.5+0.5);
        let a=0.10+lit*0.35*(0.4+pal.star*0.8);
        if(!state.reduced) a*= (0.6+0.4*Math.sin(clock*0.8+idn));
        ctx.fillStyle= mix(pal.glow, pal.top, (cc+r)%2? .2:.7);
        ctx.globalAlpha=Math.max(0,a);
        ctx.fillRect(wx,wy,7,10);
      }
    }
    ctx.globalAlpha=1;
  }
}

function drawLaundry(pal){
  ctx.save();
  ctx.strokeStyle='rgba(255,255,255,.22)'; ctx.lineWidth=1.4;
  for(const L of laundry){
    ctx.beginPath();
    ctx.moveTo(L.x,L.y);
    ctx.quadraticCurveTo(L.x+L.len/2, L.y+L.sag, L.x+L.len, L.y);
    ctx.stroke();
    for(const p of L.pcs){
      const px=L.x+L.len*p.t;
      const py=L.y+L.sag*(1-Math.pow(2*p.t-1,2))*0.9;
      const sway=state.reduced?0:Math.sin(clock*1.4+px*0.05)*3;
      ctx.save(); ctx.translate(px,py); ctx.rotate(sway*0.03);
      ctx.fillStyle=p.c; ctx.globalAlpha=0.85;
      roundRect(-p.w/2,0,p.w,p.h,4); ctx.fill();
      ctx.restore();
    }
  }
  ctx.restore();
}

function drawRoofs(pal,cp){
  for(const r of roofs){
    const top=mix(pal.mid,pal.low,0.45);
    ctx.fillStyle=mix('#150c30',top,0.5);
    roundRect(r.x,r.y,r.w,r.h,10); ctx.fill();
    // rooftop deck highlight
    ctx.fillStyle=mix(pal.glow,pal.mid,0.7); ctx.globalAlpha=0.10+cp.floor*0.12;
    roundRect(r.x+8,r.y+6,r.w-16,26,8); ctx.fill(); ctx.globalAlpha=1;
    // parapet
    ctx.strokeStyle='rgba(255,220,180,.18)'; ctx.lineWidth=2;
    roundRect(r.x,r.y,r.w,r.h,10); ctx.stroke();
  }
  // bird's-eye floor grid on central roof
  if(cp.floor){
    ctx.save(); ctx.strokeStyle='rgba(255,210,170,.10)'; ctx.lineWidth=1;
    const r=roofs[1];
    for(let gx=r.x;gx<=r.x+r.w;gx+=40){ctx.beginPath();ctx.moveTo(gx,r.y);ctx.lineTo(gx,r.y+r.h);ctx.stroke();}
    for(let gy=r.y;gy<=r.y+r.h;gy+=40){ctx.beginPath();ctx.moveTo(r.x,gy);ctx.lineTo(r.x+r.w,gy);ctx.stroke();}
    ctx.restore();
  }
}

/* lanterns + moths */
const lanterns=[{x:720,y:560},{x:1180,y:560},{x:420,y:620},{x:1350,y:640},{x:960,y:470}];
function drawLanterns(pal){
  for(const L of lanterns){
    const p=spr(L.x,L.y,camParams());
    const pulse=state.reduced?1:(0.75+0.25*Math.sin(clock*2+L.x));
    const gr=ctx.createRadialGradient(p.x,p.y,2,p.x,p.y,46);
    gr.addColorStop(0,'rgba(255,224,150,'+(0.9*pulse)+')');
    gr.addColorStop(1,'rgba(255,200,120,0)');
    ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(p.x,p.y,46,0,7); ctx.fill();
    ctx.fillStyle='#ffe6a0'; ctx.beginPath(); ctx.arc(p.x,p.y,5,0,7); ctx.fill();
    ctx.strokeStyle='rgba(255,255,255,.3)';ctx.lineWidth=1;
    ctx.beginPath();ctx.moveTo(p.x,p.y-12);ctx.lineTo(p.x,p.y-30);ctx.stroke();
  }
}
function drawMoths(pal,cp){
  ctx.save();
  for(let li=0;li<lanterns.length;li++){
    const L=lanterns[li]; const c=spr(L.x,L.y,cp);
    const n=3;
    for(let m=0;m<n;m++){
      const t=clock*1.6 + m*2.1 + li;
      const rad=14+8*Math.sin(t*1.3+m);
      const mx=c.x+Math.cos(t+m*2)*rad;
      const my=c.y-8+Math.sin(t*1.4+m*2)*rad*0.7;
      ctx.fillStyle='rgba(255,240,200,.9)';
      const flut=state.reduced?3:(2.5+1.6*Math.abs(Math.sin(t*9)));
      ctx.beginPath();ctx.ellipse(mx-2,my,flut,2.2,0.5,0,7);ctx.fill();
      ctx.beginPath();ctx.ellipse(mx+2,my,flut,2.2,-0.5,0,7);ctx.fill();
      if(li===0&&m===0&&state.names) label(mx,my-8,'moths','#ffe6a0');
    }
  }
  ctx.restore();
}

/* ---------- the rose light sculpture ---------- */
function drawRose(pal,cp){
  const c=spr(ROSE.x,ROSE.y,cp);
  const bloom = pal.idx===2 ? (0.6+0.4*pal.frac) : 0.35+pal.frac*0.35;
  const rot = state.reduced?0:clock*0.35;
  ctx.save(); ctx.translate(c.x,c.y-6); 
  // glow base
  const gr=ctx.createRadialGradient(0,0,4,0,0,120);
  gr.addColorStop(0,'rgba(255,150,190,'+(0.5*bloom)+')');
  gr.addColorStop(0.5,'rgba(200,120,255,'+(0.28*bloom)+')');
  gr.addColorStop(1,'rgba(120,80,200,0)');
  ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(0,0,120,0,7); ctx.fill();
  // layered petals (rose curves)
  const layers=5;
  for(let L=0;L<layers;L++){
    const k = 3 + L;                       // petal count varies per layer
    const R = (18+L*13)*(0.85+0.25*bloom);
    const pulse = state.reduced?1:(0.92+0.08*Math.sin(clock*2 - L));
    ctx.save(); ctx.rotate(rot*(L%2?-1:1)*(0.5+L*0.12));
    ctx.beginPath();
    for(let a=0;a<=Math.PI*2+0.05;a+=0.05){
      const r=R*Math.abs(Math.cos(k*a))*pulse + R*0.28;
      const x=Math.cos(a)*r, y=Math.sin(a)*r;
      a===0?ctx.moveTo(x,y):ctx.lineTo(x,y);
    }
    ctx.closePath();
    ctx.fillStyle = 'rgba('+[255,120+L*20,180-L*10]+','+(0.14+0.06*bloom)+')';
    ctx.strokeStyle='rgba(255,190,225,'+(0.35+0.12*bloom)+')'; ctx.lineWidth=1.4;
    ctx.fill(); ctx.stroke();
    ctx.restore();
  }
  // core
  ctx.fillStyle='#fff3fb'; ctx.beginPath(); ctx.arc(0,0,6*(0.9+bloom*0.4),0,7); ctx.fill();
  ctx.restore();
}

/* ---------- choreography groups ---------- */
function drawCircle(pal,cp){
  const c=spr(ROSE.x,ROSE.y,cp);
  const count=Math.round((7+pal.complexity*7)* (0.4+state.density*0.9));
  const spin=state.reduced?0.6:clock*0.5;
  const R=92*cp.spread;
  for(let i=0;i<count;i++){
    const a=spin + i/count*Math.PI*2;
    const rr=R*(1+0.05*Math.sin(clock*1.5+i));
    const x=c.x+Math.cos(a)*rr, y=c.y-6+Math.sin(a)*rr*0.55*cp.squash;
    const s=(0.9+0.2*Math.sin(a))*cp.figScale;
    const col=mix(pal.glow,'#ffd1e6',(i%2)?0.2:0.7);
    drawDancer(x,y,s,clock*3+i, col, 0.9);
  }
}

function drawCouples(pal,cp){
  // couples crossing narrow bridges in mirrored steps
  const bridges=[
    {a:spr(roofs[0].x+roofs[0].w,660,cp), b:spr(roofs[1].x,650,cp)},
    {a:spr(roofs[1].x+roofs[1].w,660,cp), b:spr(roofs[2].x,690,cp)}
  ];
  for(let bi=0;bi<bridges.length;bi++){
    const B=bridges[bi];
    // bridge plank
    ctx.strokeStyle='rgba(255,220,180,.35)'; ctx.lineWidth=6*cp.figScale;
    ctx.beginPath(); ctx.moveTo(B.a.x,B.a.y); ctx.lineTo(B.b.x,B.b.y); ctx.stroke();
    ctx.strokeStyle='rgba(255,220,180,.15)'; ctx.lineWidth=1;
    for(let g=0;g<=6;g++){const t=g/6;ctx.beginPath();ctx.moveTo(lerp(B.a.x,B.b.x,t),lerp(B.a.y,B.b.y,t)-14);ctx.lineTo(lerp(B.a.x,B.b.x,t),lerp(B.a.y,B.b.y,t));ctx.stroke();}
    const pairs=Math.max(1,Math.round((1+pal.complexity*1.5)*(0.5+state.density)));
    for(let p=0;p<pairs;p++){
      const base=(clock*0.12 + p/pairs + bi*0.3);
      const t=(Math.sin(base*Math.PI*2)*0.5+0.5); // shuttle back & forth mirrored
      const x=lerp(B.a.x,B.b.x,t), y=lerp(B.a.y,B.b.y,t)-8;
      const ph=clock*2.4+p;
      // mirrored partners
      const dx=Math.cos(Math.atan2(B.b.y-B.a.y,B.b.x-B.a.x)+Math.PI/2)*10*cp.figScale;
      const dy=Math.sin(Math.atan2(B.b.y-B.a.y,B.b.x-B.a.x)+Math.PI/2)*6*cp.figScale;
      drawDancer(x+dx,y+dy,0.95*cp.figScale,ph,'#9be7e0',0.7);
      drawDancer(x-dx,y-dy,0.95*cp.figScale,-ph,'#fff0d0',0.7);
    }
  }
}

function drawFreestyle(pal,cp){
  const count=Math.round((3+pal.complexity*5)*(0.4+state.density));
  for(let i=0;i<count;i++){
    while(freeTrails.length<=i) freeTrails.push([]);
    const base=towers[i%towers.length];
    const bp=spr(base.x,base.y+70,cp);
    const t=clock*(0.8+0.2*(i%3));
    const x=bp.x+Math.cos(t*1.3+i)*70*cp.spread + Math.sin(t*0.7+i)*24;
    const y=bp.y+Math.sin(t*1.9+i)*34*cp.squash + Math.cos(t*0.5)*10;
    // trail
    const tr=freeTrails[i];
    if(!state.reduced){ tr.push({x,y}); if(tr.length>MAXTRAIL) tr.shift(); }
    if(state.trails && tr.length>1){
      ctx.save(); ctx.lineCap='round';
      for(let k=1;k<tr.length;k++){
        ctx.globalAlpha=(k/tr.length)*0.5;
        ctx.strokeStyle=mix('#b48bff','#7ff0e6',(i%2)?0.2:0.8);
        ctx.lineWidth=3*(k/tr.length)*cp.figScale;
        ctx.beginPath();ctx.moveTo(tr[k-1].x,tr[k-1].y);ctx.lineTo(tr[k].x,tr[k].y);ctx.stroke();
      }
      ctx.restore();
    }
    drawDancer(x,y,0.95*cp.figScale,t*3+i,mix('#d9c6ff','#9be7e0',(i%2)?0.3:0.7),1);
    // water tower
    const wt=spr(base.x,base.y,cp);
    ctx.save();ctx.fillStyle=mix(pal.low,'#2a1c48',0.4);ctx.strokeStyle='rgba(255,220,180,.25)';ctx.lineWidth=2;
    roundRect(wt.x-24,wt.y-10,48,52,6);ctx.fill();ctx.stroke();
    ctx.beginPath();ctx.moveTo(wt.x-26,wt.y-10);ctx.lineTo(wt.x,wt.y-34);ctx.lineTo(wt.x+26,wt.y-10);ctx.closePath();
    ctx.fillStyle=mix(pal.mid,pal.low,0.4);ctx.fill();ctx.stroke();
    ctx.strokeStyle='rgba(255,220,180,.2)';ctx.beginPath();ctx.moveTo(wt.x-18,wt.y+42);ctx.lineTo(wt.x-14,wt.y+64);ctx.moveTo(wt.x+18,wt.y+42);ctx.lineTo(wt.x+14,wt.y+64);ctx.stroke();
    ctx.restore();
  }
}

function drawProcession(pal,cp){
  // quiet procession climbing fire escapes into the celebration
  const bx=fireEscapeX;
  // fire escape structure
  const top=spr(bx,560,cp), bot=spr(bx,860,cp);
  ctx.save();
  ctx.strokeStyle='rgba(255,220,180,.3)';ctx.lineWidth=2;
  for(let f=0;f<5;f++){
    const y=lerp(bot.y,top.y,f/5);
    ctx.beginPath();ctx.moveTo(bx-34,y);ctx.lineTo(bx+18,y);ctx.stroke();      // platform
    ctx.beginPath();ctx.moveTo(bx-34,y);ctx.lineTo(bx-34,y-((bot.y-top.y)/5));ctx.stroke();
    // zigzag stair
    ctx.beginPath();ctx.moveTo(bx+18,y);ctx.lineTo(bx-34,y-((bot.y-top.y)/5));ctx.stroke();
  }
  ctx.restore();
  const count=Math.round((4+pal.complexity*4)*(0.4+state.density*0.8));
  for(let i=0;i<count;i++){
    const prog=((clock*0.05 + i/count)%1);
    const y=lerp(bot.y,top.y,prog);
    const zig=(Math.floor(prog*5)%2)? 12 : -20;
    const x=bx+zig+Math.sin(prog*30)*6;
    const s=0.8*cp.figScale;
    ctx.globalAlpha=0.55+prog*0.45;
    drawDancer(x,y,s,clock*1.2+i,mix('#ffd98a',pal.glow,0.5),0.35);
    ctx.globalAlpha=1;
  }
  if(state.routes){ label(top.x, top.y-30,'ascending procession','#ffd98a'); }
}

/* ---------- animals ---------- */
function drawBear(x,y,s,ph){
  const sway=state.reduced?0:Math.sin(ph)*0.14;
  const squash=1+Math.abs(Math.sin(ph))*0.05;
  ctx.save();ctx.translate(x,y);ctx.rotate(sway);ctx.scale(1, squash);
  ctx.fillStyle='#5a3b2e';
  roundRect(-16*s,-30*s,32*s,32*s,12*s);ctx.fill();      // body
  ctx.beginPath();ctx.arc(0,-34*s,11*s,0,7);ctx.fill();  // head
  ctx.fillStyle='#4a2f24';
  ctx.beginPath();ctx.arc(-8*s,-42*s,4*s,0,7);ctx.arc(8*s,-42*s,4*s,0,7);ctx.fill(); //ears
  ctx.fillStyle='#c9a06b';ctx.beginPath();ctx.arc(0,-31*s,4*s,0,7);ctx.fill();       //muzzle
  ctx.restore();
}
function drawCat(x,y,s,leap){
  ctx.save();ctx.translate(x,y);
  ctx.fillStyle='#3a3350';
  ctx.beginPath();ctx.ellipse(0,0,12*s,6*s,0,0,7);ctx.fill();  // body
  ctx.beginPath();ctx.arc(9*s,-4*s,4*s,0,7);ctx.fill();        // head
  ctx.beginPath();ctx.moveTo(11*s,-7*s);ctx.lineTo(13*s,-12*s);ctx.lineTo(8*s,-8*s);ctx.fill(); //ear
  ctx.strokeStyle='#3a3350';ctx.lineWidth=2*s;ctx.lineCap='round';
  ctx.beginPath();ctx.moveTo(-11*s,0);ctx.quadraticCurveTo(-20*s,-6*s-leap*4,-16*s,-12*s);ctx.stroke();//tail
  ctx.restore();
}
function drawDog(x,y,s){
  ctx.save();ctx.translate(x,y);
  ctx.fillStyle='#b5793f';
  ctx.beginPath();ctx.ellipse(0,0,13*s,7*s,0,0,7);ctx.fill();
  ctx.beginPath();ctx.arc(11*s,-3*s,5*s,0,7);ctx.fill();
  ctx.beginPath();ctx.moveTo(9*s,-6*s);ctx.lineTo(8*s,-13*s);ctx.lineTo(13*s,-7*s);ctx.fill();//ear
  ctx.strokeStyle='#b5793f';ctx.lineWidth=2*s;ctx.lineCap='round';
  const wag=state.reduced?0:Math.sin(clock*8)*0.4;
  ctx.beginPath();ctx.moveTo(-12*s,-1*s);ctx.lineTo(-19*s,-6*s+wag*8);ctx.stroke();
  ctx.restore();
}

function drawAnimals(pal,cp){
  // bears sway at roof edges
  const bears=[spr(roofs[0].x+70,700,cp), spr(roofs[2].x+220,760,cp)];
  bears.forEach((b,i)=>{ drawBear(b.x,b.y,0.9*cp.figScale, clock*1.1+i); if(state.names) label(b.x,b.y-58,'bear','#c9a06b'); });

  // cats leap between ledges
  const ledges=[spr(roofs[1].x+40,570,cp), spr(roofs[1].x+300,555,cp), spr(roofs[2].x+40,660,cp)];
  for(let i=0;i<2;i++){
    const seg=(clock*0.25+i*0.5)% ledges.length;
    const a=ledges[Math.floor(seg)], b=ledges[(Math.floor(seg)+1)%ledges.length];
    const t=state.reduced?0.5:(seg-Math.floor(seg));
    const x=lerp(a.x,b.x,t), arc=Math.sin(t*Math.PI)*40;
    const y=lerp(a.y,b.y,t)-arc;
    drawCat(x,y,1.0*cp.figScale, state.reduced?0:Math.sin(t*Math.PI));
    if(state.names && i===0) label(x,y-16,'cat','#cfc7e6');
  }

  // dogs weave between circle dancers
  const c=spr(ROSE.x,ROSE.y,cp);
  for(let i=0;i<2;i++){
    const t=clock*0.9+i*3;
    const x=c.x+Math.cos(t*0.6)*150*cp.spread;
    const y=c.y+70 + Math.sin(t*3)*16*cp.squash;
    drawDog(x,y,1.0*cp.figScale);
    if(state.names && i===0) label(x,y-16,'dog','#e6b98a');
  }

  // pigeons rise in coordinated spiral
  const pc=Math.round(10+pal.complexity*8);
  const origin=spr(880,700,cp);
  for(let i=0;i<pc;i++){
    const t=(clock*0.6 + i*0.12);
    const rise=(t%3)/3;                       // 0..1 loop
    const ang=i*0.5 + t*2;
    const rad=(1-rise)*70 + 8;
    const x=origin.x+Math.cos(ang)*rad*cp.spread;
    const y=origin.y-rise*260*cp.squash + Math.sin(ang)*rad*0.4;
    const flap=state.reduced?0.5:Math.abs(Math.sin(clock*10+i));
    ctx.save();ctx.translate(x,y);
    ctx.strokeStyle=mix('#d9d2e8',pal.glow,0.3);ctx.lineWidth=2;ctx.lineCap='round';
    ctx.beginPath();
    ctx.moveTo(-7,0);ctx.quadraticCurveTo(0,-6*flap-2,0,0);ctx.quadraticCurveTo(0,-6*flap-2,7,0);
    ctx.stroke();ctx.restore();
  }
  if(state.names) label(origin.x, origin.y-250*cp.squash-8,'pigeons','#e8e2f2');
}

/* ---------- route diagrams overlay ---------- */
function drawRoutes(pal,cp){
  ctx.save();
  ctx.setLineDash([7,7]); ctx.lineWidth=2;
  ctx.strokeStyle='rgba(127,240,230,.85)';
  // circle route
  const c=spr(ROSE.x,ROSE.y,cp);
  ctx.beginPath();ctx.ellipse(c.x,c.y-6,92*cp.spread,92*cp.spread*0.55*cp.squash,0,0,7);ctx.stroke();
  label(c.x,c.y-92*cp.spread*0.55-6,'rose circle','#7ff0e6');
  // bridges
  ctx.strokeStyle='rgba(155,231,224,.85)';
  const b1a=spr(roofs[0].x+roofs[0].w,660,cp),b1b=spr(roofs[1].x,650,cp);
  const b2a=spr(roofs[1].x+roofs[1].w,660,cp),b2b=spr(roofs[2].x,690,cp);
  ctx.beginPath();ctx.moveTo(b1a.x,b1a.y);ctx.lineTo(b1b.x,b1b.y);ctx.moveTo(b2a.x,b2a.y);ctx.lineTo(b2b.x,b2b.y);ctx.stroke();
  label((b1a.x+b1b.x)/2,(b1a.y+b1b.y)/2-12,'mirrored crossing','#9be7e0');
  // freestyle loops
  ctx.strokeStyle='rgba(180,139,255,.85)';
  towers.forEach(tw=>{const p=spr(tw.x,tw.y+70,cp);ctx.beginPath();ctx.ellipse(p.x,p.y,70*cp.spread,40*cp.squash,0.3,0,7);ctx.stroke();});
  label(spr(towers[1].x,towers[1].y+70,cp).x, spr(towers[1].x,towers[1].y+70,cp).y-44,'freestyle curves','#b48bff');
  ctx.restore();
}

/* ---------- main render ---------- */
function render(){
  const pal=scenePal();
  const cp=camParams();
  document.getElementById('sceneFlag').textContent = pal.name;

  ctx.setTransform(DPR,0,0,DPR,0,0);
  ctx.clearRect(0,0,W,H);

  // camera transform
  ctx.save();
  ctx.translate(W/2, H/2);
  const idle=state.reduced?0:Math.sin(clock*0.08)*8;
  ctx.scale(viewScale*cp.zoom, viewScale*cp.zoom*cp.squash);
  ctx.translate(-STAGE_W/2 + cp.panX + idle, -STAGE_H/2 + cp.panY);

  drawSky(pal);
  drawClouds(pal);
  drawBuildings(pal,cp);
  drawLaundry(pal);
  drawRoofs(pal,cp);

  drawLanterns(pal);
  drawRose(pal,cp);

  if(state.routes) drawRoutes(pal,cp);

  // choreography (respect isolation toggles)
  if(state.show.procession) drawProcession(pal,cp);
  if(state.show.couples)    drawCouples(pal,cp);
  if(state.show.circle)     drawCircle(pal,cp);
  if(state.show.freestyle)  drawFreestyle(pal,cp);
  if(state.show.animals){   drawAnimals(pal,cp); drawMoths(pal,cp); }

  ctx.restore();
}

/* ---------- controls wiring ---------- */
function press(sel,val,attr='data-cam'){
  document.querySelectorAll('['+attr+']').forEach(b=>{
    const on=b.getAttribute(attr)===val;
    b.setAttribute('aria-pressed', on?'true':'false');
    b.classList.toggle('active',on);
  });
}

document.querySelectorAll('[data-cam]').forEach(b=>{
  b.addEventListener('click',()=>{ state.camera=b.dataset.cam; press('','','data-cam'); document.querySelectorAll('[data-cam]').forEach(x=>{const on=x.dataset.cam===state.camera;x.setAttribute('aria-pressed',on);x.classList.toggle('active',on);}); if(state.reduced)render(); });
});
function setCam(c){state.camera=c;document.querySelectorAll('[data-cam]').forEach(x=>{const on=x.dataset.cam===c;x.setAttribute('aria-pressed',on);x.classList.toggle('active',on);});if(state.reduced)render();}

document.querySelectorAll('[data-scene]').forEach(b=>{
  b.addEventListener('click',()=>{
    const v=b.dataset.scene;
    document.querySelectorAll('[data-scene]').forEach(x=>{const on=x===b;x.setAttribute('aria-pressed',on);x.classList.toggle('active',on);});
    if(v==='auto'){ state.sceneMode='auto'; }
    else { state.sceneMode='pinned'; state.scenePhase=parseInt(v,10); }
    if(state.reduced)render();
  });
});

const tempo=document.getElementById('tempo'), tempoOut=document.getElementById('tempoOut');
tempo.addEventListener('input',()=>{state.tempo=parseFloat(tempo.value);tempoOut.textContent=state.tempo.toFixed(1)+'×';});
const density=document.getElementById('density'), densityOut=document.getElementById('densityOut');
density.addEventListener('input',()=>{state.density=parseFloat(density.value);densityOut.textContent=Math.round(state.density*100)+'%';if(state.reduced)render();});

document.querySelectorAll('[data-grp]').forEach(c=>{
  c.addEventListener('change',()=>{state.show[c.dataset.grp]=c.checked;if(state.reduced)render();});
});

const ovNames=document.getElementById('ovNames'),ovTrails=document.getElementById('ovTrails'),
      ovRoutes=document.getElementById('ovRoutes'),ovReduced=document.getElementById('ovReduced');
ovNames.addEventListener('change',()=>{state.names=ovNames.checked;if(state.reduced)render();});
ovTrails.addEventListener('change',()=>{state.trails=ovTrails.checked;if(state.reduced)render();});
ovRoutes.addEventListener('change',()=>{state.routes=ovRoutes.checked;if(state.reduced)render();});
ovReduced.addEventListener('change',()=>setReduced(ovReduced.checked));

const btnPause=document.getElementById('btnPause');
btnPause.addEventListener('click',togglePause);
function togglePause(){
  state.paused=!state.paused;
  btnPause.setAttribute('aria-pressed',state.paused);
  btnPause.textContent=state.paused?'▶ Play':'⏸ Pause';
}
document.getElementById('btnRestart').addEventListener('click',()=>{
  clock=0; sceneClock=0; state.scenePhase= state.sceneMode==='pinned'?state.scenePhase:0;
  freeTrails.length=0;
  if(state.reduced)render();
});

function setReduced(on){
  state.reduced=on; ovReduced.checked=on;
  if(on){ stopLoop(); render(); }
  else { last=performance.now(); startLoop(); }
}

// panel collapse
const panelToggle=document.getElementById('panelToggle'), ctlBody=document.getElementById('ctlBody'), panel=document.getElementById('controls');
panelToggle.addEventListener('click',()=>{
  const collapsed=panel.classList.toggle('collapsed');
  panelToggle.textContent=collapsed?'Show':'Hide';
  panelToggle.setAttribute('aria-expanded',(!collapsed).toString());
});

// keyboard
window.addEventListener('keydown',e=>{
  if(e.target.matches('input,textarea')) return;
  switch(e.key){
    case ' ': e.preventDefault(); togglePause(); break;
    case '1': setCam('skyline'); break;
    case '2': setCam('rooftop'); break;
    case '3': setCam('birdseye'); break;
    case 'r': case 'R': setReduced(!state.reduced); break;
    case 'n': case 'N': ovNames.checked=!ovNames.checked; state.names=ovNames.checked; if(state.reduced)render(); break;
    case 't': case 'T': ovTrails.checked=!ovTrails.checked; state.trails=ovTrails.checked; if(state.reduced)render(); break;
    case 'd': case 'D': ovRoutes.checked=!ovRoutes.checked; state.routes=ovRoutes.checked; if(state.reduced)render(); break;
  }
});

/* ---------- boot ---------- */
resize();
// honor OS reduced-motion preference on first load
if(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches){
  setReduced(true);
} else {
  startLoop();
}
})();
</script>
</body>
</html>