← back to Rose City Rooftop Solstice

index.html

1496 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<meta name="description" content="Rose City Rooftop Solstice — an animated urban-fantasy gallery piece: interconnected rooftops become a communal dance garden above a glowing city at sunset." />
<title>Rose City Rooftop Solstice</title>
<style>
  :root{
    --ink:#f6efe4;
    --ink-dim:#c9bfb0;
    --gold:#ffd98a;
    --rose:#ffb1d8;
    --indigo:#2a2350;
    --panel:rgba(18,15,34,.82);
    --panel-edge:rgba(255,214,158,.22);
    --focus:#8fe6ff;
    --serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,"Times New Roman",serif;
    --sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
  }
  *{box-sizing:border-box}
  html,body{height:100%}
  body{
    margin:0;background:#0a0818;color:var(--ink);
    font-family:var(--sans);overflow:hidden;
    -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
  }
  #stage{position:fixed;inset:0}
  canvas{display:block;width:100%;height:100%}

  /* ---- Masthead ---- */
  .masthead{
    position:fixed;left:clamp(14px,3vw,42px);top:clamp(12px,2.4vw,30px);
    z-index:5;max-width:min(60ch,74vw);pointer-events:none;
    text-shadow:0 2px 22px rgba(6,4,20,.65);
  }
  .kicker{
    font:600 clamp(.62rem,1.5vw,.76rem)/1 var(--sans);
    letter-spacing:.42em;text-transform:uppercase;color:var(--gold);
    margin:0 0 .55em;opacity:.9;
  }
  .title{
    font:500 clamp(1.7rem,6vw,3.6rem)/.98 var(--serif);
    letter-spacing:.005em;margin:0;
    background:linear-gradient(96deg,#fff2d8 0%,var(--rose) 58%,#c7b6ff 100%);
    -webkit-background-clip:text;background-clip:text;color:transparent;
  }
  .subtitle{
    font:italic 400 clamp(.82rem,2.1vw,1.06rem)/1.35 var(--serif);
    color:var(--ink-dim);margin:.5em 0 0;max-width:44ch;
  }
  .scene-chip{
    display:inline-flex;gap:.5em;align-items:center;margin-top:.9em;
    pointer-events:none;font:600 .72rem/1 var(--sans);letter-spacing:.14em;
    text-transform:uppercase;color:var(--ink);
    background:var(--panel);border:1px solid var(--panel-edge);
    padding:.5em .8em;border-radius:999px;backdrop-filter:blur(6px);
  }
  .scene-chip .dot{width:.55em;height:.55em;border-radius:50%;background:var(--gold);
    box-shadow:0 0 10px var(--gold)}

  /* ---- Control dock ---- */
  .dock{
    position:fixed;right:clamp(10px,2vw,26px);top:clamp(12px,2.4vw,30px);bottom:auto;
    z-index:6;width:min(320px,86vw);max-height:calc(100dvh - 48px);overflow:auto;
    background:var(--panel);border:1px solid var(--panel-edge);border-radius:16px;
    backdrop-filter:blur(12px) saturate(1.1);
    box-shadow:0 18px 60px rgba(4,2,16,.5);
    padding:14px 14px 16px;
    transition:transform .35s cubic-bezier(.2,.8,.2,1),opacity .3s;
  }
  .dock[hidden]{display:block;transform:translateX(115%);opacity:0;pointer-events:none}
  .dock h2{
    font:600 .68rem/1 var(--sans);letter-spacing:.26em;text-transform:uppercase;
    color:var(--gold);margin:.4em 0 .3em;
  }
  .dock .lede{font:400 .78rem/1.4 var(--sans);color:var(--ink-dim);margin:.1em 0 1em}
  .grp{margin:0 0 14px;padding:0 0 12px;border-bottom:1px solid rgba(255,255,255,.07)}
  .grp:last-of-type{border-bottom:0;padding-bottom:0}
  .row{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:8px 0}
  label{font:500 .82rem/1.2 var(--sans);color:var(--ink)}
  .hint{font:400 .72rem/1.3 var(--sans);color:var(--ink-dim)}

  .seg{display:flex;gap:6px;flex-wrap:wrap}
  .seg button, .btn{
    font:600 .78rem/1 var(--sans);color:var(--ink);
    background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.14);
    padding:.55em .7em;border-radius:9px;cursor:pointer;
    transition:background .18s,border-color .18s,color .18s,transform .05s;
  }
  .seg button:hover,.btn:hover{background:rgba(255,255,255,.13)}
  .seg button[aria-pressed="true"]{
    background:linear-gradient(180deg,rgba(255,217,138,.28),rgba(255,177,216,.16));
    border-color:var(--gold);color:#fff;
  }
  .btn:active{transform:translateY(1px)}
  select{
    font:500 .82rem/1 var(--sans);color:var(--ink);background:rgba(10,8,24,.9);
    border:1px solid rgba(255,255,255,.18);border-radius:9px;padding:.5em .6em;min-width:130px;
  }
  input[type=range]{width:150px;accent-color:var(--rose);height:22px}
  input[type=checkbox]{width:18px;height:18px;accent-color:var(--gold)}

  .actions{display:flex;gap:8px;flex-wrap:wrap}
  .actions .btn{flex:1;text-align:center;min-width:84px}

  /* ---- Focus states (strong, consistent) ---- */
  :focus{outline:none}
  :focus-visible{
    outline:2.5px solid var(--focus);outline-offset:2px;border-radius:8px;
    box-shadow:0 0 0 5px rgba(143,230,255,.22);
  }
  .skip{
    position:fixed;left:8px;top:-60px;z-index:20;background:#0a0818;color:var(--ink);
    padding:.6em .9em;border-radius:8px;border:1px solid var(--panel-edge);
    transition:top .2s;font:600 .8rem var(--sans);text-decoration:none;
  }
  .skip:focus{top:8px}

  /* ---- Toggle + note ---- */
  .toolbar{
    position:fixed;left:clamp(14px,3vw,42px);bottom:clamp(12px,2.4vw,26px);z-index:6;
    display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;max-width:min(70ch,88vw);
  }
  .note{
    font:400 .74rem/1.4 var(--sans);color:var(--ink-dim);
    background:var(--panel);border:1px solid var(--panel-edge);border-radius:12px;
    padding:.6em .85em;max-width:40ch;backdrop-filter:blur(8px);
  }
  .note b{color:var(--ink);font-weight:600}
  .dock-toggle{
    position:fixed;right:clamp(10px,2vw,26px);bottom:clamp(12px,2.4vw,26px);z-index:7;
  }

  /* ---- Legend ---- */
  .legend{display:flex;flex-wrap:wrap;gap:6px}
  .legend .tag{
    font:600 .68rem/1 var(--sans);letter-spacing:.04em;color:var(--ink);
    border:1px solid rgba(255,255,255,.14);border-radius:999px;padding:.42em .6em;
    background:rgba(255,255,255,.05);
  }
  .legend .tag i{width:.55em;height:.55em;border-radius:50%;display:inline-block;margin-right:.45em;vertical-align:middle}

  @media (max-width:720px){
    .subtitle{display:none}
    .dock{top:auto;bottom:64px;right:8px}
    .toolbar{max-width:64vw}
  }
  @media (prefers-reduced-motion:reduce){
    .dock,.scene-chip{transition:none}
  }
</style>
</head>
<body>
  <a class="skip" href="#controls">Skip to controls</a>

  <div id="stage">
    <canvas id="sky" role="img"
      aria-label="An animated urban-fantasy scene at sunset: interconnected city rooftops host a communal dance garden. A ring of figures turns around a glowing rose-shaped light sculpture, couples cross narrow bridges in mirrored steps, freestyle dancers trace curved trails near water towers, and a quiet procession climbs fire escapes. Bears sway, cats leap between ledges, pigeons spiral, dogs weave, and moths gather at lanterns while the light shifts from gold to indigo."></canvas>
  </div>

  <header class="masthead">
    <p class="kicker">A Rooftop Gathering</p>
    <h1 class="title">Rose City Rooftop Solstice</h1>
    <p class="subtitle">At sunset, the rooftops open like a garden — a communal dance above a glowing city, held together by light rather than sound.</p>
    <div class="scene-chip"><span class="dot" id="sceneDot"></span><span id="sceneName">Arrival</span></div>
  </header>

  <!-- ============ CONTROL DOCK ============ -->
  <aside class="dock" id="controls" aria-label="Composition controls">
    <h2>Compose the piece</h2>
    <p class="lede">Every choice below reshapes the gathering. The visual rhythm is authored — nothing here listens to audio.</p>

    <div class="grp" role="group" aria-label="Camera">
      <div class="row"><label id="camLbl">Camera</label></div>
      <div class="seg" role="group" aria-labelledby="camLbl" id="camSeg">
        <button data-cam="skyline" aria-pressed="false">Skyline</button>
        <button data-cam="rooftop" aria-pressed="true">Rooftop</button>
        <button data-cam="birdseye" aria-pressed="false">Bird’s-eye</button>
      </div>
    </div>

    <div class="grp" role="group" aria-label="Scene">
      <div class="row">
        <label for="sceneSel">Scene</label>
        <select id="sceneSel">
          <option value="0">Arrival</option>
          <option value="1">Long Light</option>
          <option value="2">Midnight Bloom</option>
        </select>
      </div>
      <div class="row">
        <label for="autoScene">Drift through scenes</label>
        <input type="checkbox" id="autoScene" checked />
      </div>
      <p class="hint">Each scene changes palette and movement complexity — Arrival is warm and calm, Midnight Bloom is deep and intricate.</p>
    </div>

    <div class="grp" role="group" aria-label="Motion">
      <div class="row">
        <label for="tempo">Visual tempo</label>
        <input type="range" id="tempo" min="0.15" max="2" step="0.05" value="1" aria-describedby="tempoVal" />
      </div>
      <div class="row"><span class="hint" id="tempoVal">1.0×</span></div>
      <div class="row">
        <label for="density">Crowd density</label>
        <input type="range" id="density" min="0.3" max="1.5" step="0.05" value="1" aria-describedby="densVal" />
      </div>
      <div class="row"><span class="hint" id="densVal">Full</span></div>
    </div>

    <div class="grp" role="group" aria-label="Choreography">
      <div class="row">
        <label for="isolate">Isolate group</label>
        <select id="isolate">
          <option value="all">All zones</option>
          <option value="circle">Rose circle</option>
          <option value="bridges">Bridge couples</option>
          <option value="freestyle">Freestyle trails</option>
          <option value="procession">Fire-escape procession</option>
        </select>
      </div>
    </div>

    <div class="grp" role="group" aria-label="Overlays">
      <div class="row"><label for="tgNames">Animal &amp; zone names</label><input type="checkbox" id="tgNames" /></div>
      <div class="row"><label for="tgTrails">Motion trails</label><input type="checkbox" id="tgTrails" checked /></div>
      <div class="row"><label for="tgDiag">Route diagrams</label><input type="checkbox" id="tgDiag" /></div>
    </div>

    <div class="grp" role="group" aria-label="Music from the Internet Archive">
      <h2 style="margin-top:0">Music · from the archive</h2>
      <p class="hint" id="npLine">Press play: live Grateful Dead recordings stream from the Internet Archive, and the whole scene moves to them — dancers step on the beat, the rose pulses with the bass, lanterns and glow breathe with the music.</p>
      <div class="actions">
        <button class="btn" id="mPlay" aria-pressed="false">Play music</button>
        <button class="btn" id="mShuffle">Shuffle show</button>
      </div>
      <div class="row" style="margin-top:8px;gap:8px">
        <button class="btn" id="mPrev" aria-label="Previous track">⏮</button>
        <button class="btn" id="mNext" aria-label="Next track">⏭</button>
        <input type="range" id="mVol" min="0" max="1" step="0.05" value="0.55" aria-label="Music volume" style="flex:1" />
      </div>
      <p class="hint" id="mCredit" style="margin-bottom:0"></p>
    </div>

    <div class="grp" role="group" aria-label="Accessibility &amp; playback">
      <div class="row"><label for="tgReduce">Reduced motion (still composition)</label><input type="checkbox" id="tgReduce" /></div>
      <div class="actions">
        <button class="btn" id="btnPause" aria-pressed="false">Pause</button>
        <button class="btn" id="btnRestart">Restart</button>
      </div>
    </div>

    <div class="grp" role="group" aria-label="Companions legend">
      <h2 style="margin-top:0">Companions</h2>
      <div class="legend" id="legend"></div>
    </div>

    <p class="hint" style="margin-bottom:0">Keys — <b>Space</b> pause · <b>1 2 3</b> cameras · <b>R</b> restart · <b>N</b> names · <b>T</b> trails · <b>D</b> diagrams · <b>M</b> reduced motion · <b>C</b> hide panel.</p>
  </aside>

  <button class="btn dock-toggle" id="dockToggle" aria-expanded="true" aria-controls="controls">Hide panel</button>

  <div class="toolbar">
    <p class="note"><b>An honest note.</b> With the music <b>off</b>, this piece keeps its own time — an <b>authored</b> visual tempo. Press <b>Play music</b> and it becomes <b>live audio-reactive</b>: the scene analyzes the streaming Grateful Dead set (from the <b>Internet Archive</b>) in real time, so the beat, bass and brightness drive the whole gathering. Change the show and the whole scene changes with it. <b>Reduced motion</b> holds a calm, still composition.</p>
  </div>

<script>
"use strict";
(function(){
/* =========================================================================
   Rose City Rooftop Solstice — a self-contained animated gallery piece.
   Canvas 2D. World coordinates (y grows downward). Cameras map world→screen.
   No audio, no network, no external assets. All imagery is original.
   ========================================================================= */

const canvas = document.getElementById('sky');
const ctx = canvas.getContext('2d', { alpha:false });

/* ---------- deterministic PRNG (so the composition is repeatable) ---------- */
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;};}
let rnd = mulberry32(0x50C1CE|0);
const R = (a,b)=>a+(b-a)*rnd();
const RI = (a,b)=>Math.floor(R(a,b+1));
const TAU = Math.PI*2;
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;

/* ---------- color helpers ---------- */
function hx(h){h=h.replace('#','');return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)];}
function mix(a,b,t){return [lerp(a[0],b[0],t),lerp(a[1],b[1],t),lerp(a[2],b[2],t)];}
function rgb(c,a){return a==null?`rgb(${c[0]|0},${c[1]|0},${c[2]|0})`:`rgba(${c[0]|0},${c[1]|0},${c[2]|0},${a})`;}

/* ---------- scenes: palette + complexity ---------- */
const SCENES = [
  { name:'Arrival', complexity:0.55, sunY:0.72, star:0.0,
    skyTop:hx('4a3c7e'), skyMid:hx('c8724e'), skyBot:hx('f5b862'),
    orb:hx('ffe6a8'), roof:hx('4a3d63'), city:hx('2a2350'),
    glow:hx('ffcf8a'), accent:hx('ffe6b0'), lantern:hx('ffb257') },
  { name:'Long Light', complexity:0.85, sunY:0.80, star:0.15,
    skyTop:hx('2f6d86'), skyMid:hx('e0a15a'), skyBot:hx('ffd27a'),
    orb:hx('fff0c0'), roof:hx('3a5566'), city:hx('153043'),
    glow:hx('ffdca0'), accent:hx('bff0e0'), lantern:hx('ffbe5e') },
  { name:'Midnight Bloom', complexity:1.0, sunY:0.30, star:1.0,
    skyTop:hx('0a0e2c'), skyMid:hx('241a52'), skyBot:hx('5a2f7a'),
    orb:hx('dfe6ff'), roof:hx('241f3a'), city:hx('0a0a24'),
    glow:hx('cf8cff'), accent:hx('ffd1f0'), lantern:hx('ffb257') },
];
const SCENE_DUR = 26; // seconds per scene when drifting

/* ---------- global state ---------- */
const S = {
  cam:'rooftop', targetScene:0, autoScene:true, sceneT:0,
  tempo:1, density:1, isolate:'all', paused:false, reduced:false,
  names:false, trails:true, diagrams:false,
  t:0,                 // animation clock (scaled by tempo AND by live audio energy)
  cfrac:SCENES[0].complexity,
  // live audio-reactivity features (all 0 / inert when no music is playing)
  audio:{ level:0, bass:0, mid:0, treble:0, beat:0, active:false, _prev:0, _avg:0, _refr:0 },
};

/* current, continuously-lerped palette + numeric fields */
const P = {
  skyTop:SCENES[0].skyTop.slice(), skyMid:SCENES[0].skyMid.slice(), skyBot:SCENES[0].skyBot.slice(),
  orb:SCENES[0].orb.slice(), roof:SCENES[0].roof.slice(), city:SCENES[0].city.slice(),
  glow:SCENES[0].glow.slice(), accent:SCENES[0].accent.slice(), lantern:SCENES[0].lantern.slice(),
  sunY:SCENES[0].sunY, star:SCENES[0].star,
};

/* ---------- world layout ---------- */
const WORLD = { w:1600 };
// Foreground rooftops (platforms). top = world-y of the roof surface (feet line).
const ROOFS = [
  { id:'westTower',  x:250,  w:150, top:64,  kind:'tower' },
  { id:'proc',       x:585,  w:180, top:118, kind:'flat'  },
  { id:'center',     x:840,  w:290, top:80,  kind:'plaza' },
  { id:'eastTower',  x:1360, w:150, top:52,  kind:'tower' },
];
const roofById = id => ROOFS.find(r=>r.id===id);
// bridges connect center to the towers (narrow crossing platforms)
const BRIDGES = [
  { x0:395, x1:695,  y:150, side:-1 },          // west bridge (westTower→proc/center)
  { x0:985, x1:1285, y:132, side:1 },           // east bridge (center→eastTower)
];
// lanterns sit around the plaza + towers
const LANTERNS = [
  {x:705,y:66}, {x:975,y:66}, {x:840,y:150}, {x:250,y:44}, {x:1360,y:32}, {x:585,y:100},
];

/* ---------- background parallax skyline (procedural, deterministic) ---------- */
function buildSkyline(seed, count, baseTop, spreadTop, wMin, wMax){
  const r = mulberry32(seed);
  const arr=[]; let x=-200;
  while(x < WORLD.w+300){
    const w = wMin + r()*(wMax-wMin);
    const top = baseTop + (r()-0.5)*spreadTop;
    arr.push({x, w, top, lit:r()});
    x += w + r()*30 - 6;
  }
  return arr;
}
const FAR   = buildSkyline(11, 0, 230, 130, 60, 130);
const MID   = buildSkyline(29, 0, 300, 150, 80, 170);

/* ---------- clouds ---------- */
const CLOUDS = Array.from({length:9},()=>({
  x:R(-200,WORLD.w+200), y:R(-40,180), s:R(60,150), v:R(3,9), o:R(.10,.28), d:R(0,1)>.5?1:1,
}));

/* ---------- laundry lines ---------- */
const LAUNDRY = [
  { x0:170,y0:120,x1:330,y1:150, items:6 },
  { x0:900,y0:150,x1:1030,y1:190, items:5 },
  { x0:500,y0:190,x1:665,y1:210, items:6 },
];

/* ============================ ENTITIES ============================ */
const BODY_COLORS = ['#e8756b','#f2b25e','#6fbf9b','#7aa6e6','#c98cd8','#e79ac0','#f0d27a','#79cbd0'];

/* ---- Rose circle: ring of dancers turning around the light sculpture ---- */
const center = roofById('center');
const ROSE = { x:center.x, y:center.top-6, R:96 };
function makeCircle(n){
  const a=[];
  for(let i=0;i<n;i++){
    a.push({ a0:(i/n)*TAU, r:ROSE.R*R(.86,1.06), col:BODY_COLORS[i%BODY_COLORS.length], ph:R(0,TAU), sz:R(.9,1.12) });
  }
  return a;
}
let CIRCLE = makeCircle(20);

/* ---- Bridge couples: mirrored steps crossing narrow bridges ---- */
function makeCouples(){
  const c=[];
  BRIDGES.forEach((b,bi)=>{
    for(let k=0;k<2;k++){
      c.push({ b:bi, off:k*0.5, ph:R(0,TAU), col:BODY_COLORS[(bi*2+k)%BODY_COLORS.length] });
    }
  });
  return c;
}
let COUPLES = makeCouples();

/* ---- Freestyle dancers near water towers: curved trails ---- */
function makeFreestyle(n){
  const a=[]; const anchors=[roofById('westTower'),roofById('eastTower')];
  for(let i=0;i<n;i++){
    const an=anchors[i%2];
    a.push({ cx:an.x+R(-38,38), cy:an.top-R(6,26), rx:R(26,48), ry:R(14,30),
             sp:R(.6,1.15)*(rnd()>.5?1:-1), ph:R(0,TAU), col:BODY_COLORS[(i+3)%BODY_COLORS.length],
             trail:[] });
  }
  return a;
}
let FREE = makeFreestyle(6);

/* ---- Procession: quiet climb up the fire escape, then toward the plaza ---- */
const escBuild = roofById('proc');
// fire escape zig-zag path on the left face of the 'proc' building, then across the roof
function processionPath(){
  const bx = escBuild.x - escBuild.w/2;          // left edge
  const roofY = escBuild.top;
  const cityY = roofY + 210;                       // start low in the city
  const pts=[];
  pts.push([bx-8, cityY]);
  let y=cityY, dir=1;
  const steps=6;
  for(let i=0;i<steps;i++){
    y -= (cityY-roofY)/steps;
    pts.push([bx + (dir>0? 26: -6), y]);
    dir*=-1;
  }
  pts.push([bx+10, roofY]);
  pts.push([escBuild.x+escBuild.w/2-10, roofY-2]);   // walk across roof
  pts.push([center.x-ROSE.R-30, center.top+4]);      // arrive at the circle
  return pts;
}
const PROC_PATH = processionPath();
const PROC_LEN = (()=>{let L=0;for(let i=1;i<PROC_PATH.length;i++){L+=Math.hypot(PROC_PATH[i][0]-PROC_PATH[i-1][0],PROC_PATH[i][1]-PROC_PATH[i-1][1]);}return L;})();
function pathAt(u){ // u in [0,1] along PROC_PATH → [x,y, angle]
  let d=u*PROC_LEN;
  for(let i=1;i<PROC_PATH.length;i++){
    const seg=Math.hypot(PROC_PATH[i][0]-PROC_PATH[i-1][0],PROC_PATH[i][1]-PROC_PATH[i-1][1]);
    if(d<=seg||i===PROC_PATH.length-1){
      const t=seg?d/seg:0;
      const x=lerp(PROC_PATH[i-1][0],PROC_PATH[i][0],t);
      const y=lerp(PROC_PATH[i-1][1],PROC_PATH[i][1],t);
      return [x,y];
    }
    d-=seg;
  }
  return PROC_PATH[PROC_PATH.length-1];
}
function makeProcession(n){
  const a=[]; for(let i=0;i<n;i++) a.push({ u:i/(n+2)*0.9, col:BODY_COLORS[(i+5)%BODY_COLORS.length], ph:R(0,TAU) });
  return a;
}
let PROC = makeProcession(9);

/* ---- Animal companions ---- */
let BEARS=[], CATS=[], DOGS=[], MOTHS=[], PIGEONS=null;
function makeAnimals(){
  BEARS = [
    {x:center.x-150,y:center.top+2,s:1.1,ph:R(0,TAU)},
    {x:center.x+158,y:center.top+2,s:1.0,ph:R(0,TAU)},
    {x:escBuild.x+20,y:escBuild.top+2,s:.95,ph:R(0,TAU)},
  ];
  // cats leap between ledges: each has a start ledge & end ledge and a phase
  const ledges=[[roofById('westTower').x, roofById('westTower').top],
                [escBuild.x-escBuild.w/2+10, escBuild.top],
                [center.x-ROSE.R-40, center.top],
                [center.x+ROSE.R+40, center.top],
                [roofById('eastTower').x, roofById('eastTower').top]];
  CATS=[];
  for(let i=0;i<4;i++){
    const p0=ledges[i%ledges.length], p1=ledges[(i+1)%ledges.length];
    CATS.push({p0,p1,ph:R(0,TAU),sp:R(.4,.7)});
  }
  // dogs weave among the circle (sine path across plaza)
  DOGS=[];
  for(let i=0;i<3;i++) DOGS.push({ph:R(0,TAU),sp:R(.5,.8),amp:R(26,40),col:i});
  // moths cloud around each lantern
  MOTHS=[];
  LANTERNS.forEach((L,li)=>{ for(let k=0;k<6;k++) MOTHS.push({L:li,a:R(0,TAU),r:R(6,20),sp:R(1.4,2.6)*(rnd()>.5?1:-1),ph:R(0,TAU)}); });
  // pigeons: a flock that periodically launches into a rising spiral
  PIGEONS={ home:[escBuild.x+40, escBuild.top-4], birds:Array.from({length:14},(_,i)=>({a:R(0,TAU),r0:R(2,18),ph:R(0,TAU),i})), cycle:R(0,8) };
}
makeAnimals();

/* rebuild population-scaled entities on density change */
function rebuildDensity(){
  const d=S.density;
  CIRCLE = makeCircle(Math.round(20*d));
  FREE   = makeFreestyle(Math.max(2,Math.round(6*d)));
  PROC   = makeProcession(Math.max(3,Math.round(9*d)));
  // couples & animals stay (small, structural)
}

/* ============================ CAMERA ============================ */
const CAM_PRESETS = {
  skyline:  { scale:0.60, cx:820, cy:150, tilt:0.02, name:'Skyline' },
  rooftop:  { scale:1.34, cx:840, cy:60,  tilt:0.12, name:'Rooftop' },
  birdseye: { scale:0.78, cx:840, cy:90,  tilt:0.72, name:'Bird’s-eye' },
};
const cam = { scale:1.08, cx:840, cy:70, tilt:0.10 };
let VW=0, VH=0, DPR=1, screenCx=0, screenCy=0;

function project(wx,wy,depth){
  depth = depth==null?1:depth;
  const s = cam.scale;
  const sx = screenCx + (wx - cam.cx) * s * depth;
  const sy = screenCy + (wy - cam.cy) * s * (1 - 0.5*cam.tilt);
  return [sx, sy];
}
function pscale(depth){ return cam.scale * (depth==null?1:depth); }

/* ============================ RESIZE ============================ */
function resize(){
  DPR = Math.min(window.devicePixelRatio||1, 2);
  VW = window.innerWidth; VH = window.innerHeight;
  canvas.width = Math.round(VW*DPR); canvas.height = Math.round(VH*DPR);
  canvas.style.width=VW+'px'; canvas.style.height=VH+'px';
  ctx.setTransform(DPR,0,0,DPR,0,0);
  screenCx = VW*0.5;
  screenCy = VH*0.62;
}
window.addEventListener('resize', resize);

/* ============================ DRAW: SKY & BACKDROP ============================ */
function drawSky(){
  const g=ctx.createLinearGradient(0,0,0,VH);
  g.addColorStop(0, rgb(P.skyTop));
  g.addColorStop(0.52, rgb(P.skyMid));
  g.addColorStop(1, rgb(P.skyBot));
  ctx.fillStyle=g; ctx.fillRect(0,0,VW,VH);

  // stars (fade in for midnight)
  if(P.star>0.02){
    ctx.save(); ctx.globalAlpha=P.star;
    const sr=mulberry32(7);
    for(let i=0;i<120;i++){
      const x=sr()*VW, y=sr()*VH*0.55, tw=0.4+0.6*Math.abs(Math.sin(S.t*0.6+i));
      ctx.globalAlpha=P.star*tw*0.9;
      ctx.fillStyle= i%9===0? rgb(P.accent):'#fff';
      const s=sr()*1.6+0.3; ctx.fillRect(x,y,s,s);
    }
    ctx.restore();
  }

  // sun / moon — a soft luminous orb near the horizon
  const orbX = screenCx + (890 - cam.cx)*cam.scale*0.4;
  const orbY = VH*P.sunY;
  const orbR = Math.max(48, VH*0.14)*(0.8+0.2*cam.scale);
  const halo=ctx.createRadialGradient(orbX,orbY,orbR*0.2,orbX,orbY,orbR*3.4);
  halo.addColorStop(0, rgb(P.orb,0.9));
  halo.addColorStop(0.18, rgb(P.orb,0.34));
  halo.addColorStop(1, rgb(P.orb,0));
  ctx.fillStyle=halo; ctx.fillRect(0,0,VW,VH);
  ctx.beginPath(); ctx.arc(orbX,orbY,orbR,0,TAU);
  ctx.fillStyle=rgb(P.orb,0.96); ctx.fill();

  // clouds drift (tinted by light)
  const cloudCol = mix(P.skyMid,[255,255,255],0.35);
  CLOUDS.forEach(c=>{
    const [x,y]=project(c.x,c.y,0.5);
    const s=c.s*cam.scale*0.5;
    ctx.save(); ctx.globalAlpha=c.o*(0.7+0.3*(1-P.star));
    ctx.fillStyle=rgb(cloudCol);
    puff(x,y,s);
    ctx.restore();
  });
}
function puff(x,y,s){
  ctx.beginPath();
  ctx.ellipse(x,y,s,s*0.5,0,0,TAU);
  ctx.ellipse(x-s*0.6,y+s*0.12,s*0.55,s*0.34,0,0,TAU);
  ctx.ellipse(x+s*0.6,y+s*0.14,s*0.6,s*0.36,0,0,TAU);
  ctx.fill();
}

function drawSkylineLayer(list, depth, colBase, alpha){
  ctx.save(); ctx.globalAlpha=alpha; ctx.fillStyle=rgb(colBase);
  list.forEach(b=>{
    const [x,y]=project(b.x,b.top,depth);
    const w=b.w*pscale(depth), h=(700)*pscale(depth);
    ctx.fillRect(x,y,w,h);
    // scattered lit windows
    if(b.lit>0.3){
      ctx.save(); ctx.globalAlpha=alpha*clamp(P.star+0.25,0.2,0.9);
      ctx.fillStyle=rgb(P.lantern, 0.7);
      const cols=Math.max(1,(w/12)|0), rows=Math.max(1,(h/16)|0);
      const wr=mulberry32((b.x*7+b.top)|0);
      for(let cc=0;cc<cols;cc++)for(let rr=0;rr<rows;rr++){
        if(wr()>0.86){ ctx.fillRect(x+4+cc*12, y+6+rr*16, 4, 6); }
      }
      ctx.restore();
    }
  });
  ctx.restore();
}

function drawCityGlow(){
  // luminous band where the city meets the rooftops
  const [,y]=project(0, 320, 1);
  const g=ctx.createLinearGradient(0,y-60,0,y+120);
  const gA=0.18*(S.audio.active?(1+0.8*S.audio.level):1);
  g.addColorStop(0, rgb(P.glow,0));
  g.addColorStop(0.5, rgb(P.glow,gA));
  g.addColorStop(1, rgb(P.glow,0));
  ctx.fillStyle=g; ctx.fillRect(0,y-60,VW,180);
}

/* ============================ DRAW: ROOFTOP ARCHITECTURE ============================ */
function drawRoofs(){
  ROOFS.forEach(r=>{
    const half=r.w/2;
    const [lx,ty]=project(r.x-half, r.top);
    const [rx]=project(r.x+half, r.top);
    const w=rx-lx;
    const bottom=VH+40;
    // building body
    const bodyCol = mix(P.roof, P.city, 0.15);
    ctx.fillStyle=rgb(bodyCol);
    ctx.fillRect(lx, ty, w, bottom-ty);
    // reflective window grid with a sweeping specular highlight
    drawWindows(lx,ty,w,bottom-ty, r);
    // roof cap (ellipse) — reads as looking-down when tilted
    if(cam.tilt>0.05){
      const capH = Math.max(4, w*0.16*cam.tilt);
      ctx.beginPath();
      ctx.ellipse(lx+w/2, ty, w/2, capH, 0, 0, TAU);
      ctx.fillStyle=rgb(mix(P.roof,P.accent,0.12));
      ctx.fill();
      // edge light
      ctx.strokeStyle=rgb(P.glow,0.35); ctx.lineWidth=1.5; ctx.stroke();
    } else {
      // parapet edge highlight
      ctx.fillStyle=rgb(mix(P.roof,P.accent,0.22));
      ctx.fillRect(lx, ty-3*cam.scale, w, 3*cam.scale);
    }
    // water tower on tower roofs
    if(r.kind==='tower') drawWaterTower(r);
    if(r.id==='proc') drawFireEscape(r);
  });
  // bridges
  BRIDGES.forEach(b=>{
    const [x0,y0]=project(b.x0,b.y), [x1,y1]=project(b.x1,b.y);
    ctx.strokeStyle=rgb(mix(P.roof,P.accent,0.3)); ctx.lineWidth=Math.max(4,7*cam.scale); ctx.lineCap='round';
    ctx.beginPath(); ctx.moveTo(x0,y0); ctx.lineTo(x1,y1); ctx.stroke();
    // rail lights
    ctx.strokeStyle=rgb(P.glow,0.5); ctx.lineWidth=1.4;
    ctx.beginPath(); ctx.moveTo(x0,y0-6*cam.scale); ctx.lineTo(x1,y1-6*cam.scale); ctx.stroke();
  });
  // laundry lines swaying
  drawLaundry();
  // lanterns (glow)
  LANTERNS.forEach((L,i)=>drawLantern(L,i));
}

function drawWindows(x,y,w,h,r){
  const cols=Math.max(2,(w/(16*cam.scale))|0);
  const rows=Math.max(3,(h/(20))|0);
  const cw=w/cols, ch=Math.min(26, (h/rows));
  const wr=mulberry32((r.x*13+r.top*3)|0);
  const sweep=((S.t*40 + r.x) % (w+220)) - 110; // moving specular
  for(let c=0;c<cols;c++){
    for(let rr=0;rr<rows;rr++){
      const wx=x+c*cw+cw*0.18, wy=y+ch*0.5+rr*ch, ww=cw*0.64, wh=ch*0.6;
      const lit = wr()>0.55;
      // reflective glass base
      const base = lit ? mix(P.glow,[255,255,255],0.15) : mix(P.city,[255,255,255],0.05);
      ctx.fillStyle=rgb(base, lit?0.6:0.4);
      ctx.fillRect(wx,wy,ww,wh);
      // specular sweep highlight
      const dx=(wx-x)-sweep;
      if(dx>-30 && dx<30){
        const a=(1-Math.abs(dx)/30)*0.5;
        ctx.fillStyle=rgb(P.accent,a); ctx.fillRect(wx,wy,ww,wh);
      }
    }
  }
}

function drawWaterTower(r){
  const [cx,ty]=project(r.x, r.top);
  const s=cam.scale;
  const tw=34*s, th=40*s, legH=26*s;
  const bx=cx, by=ty - (cam.tilt>0.05?4:0);
  // legs
  ctx.strokeStyle=rgb(mix(P.roof,P.city,0.2)); ctx.lineWidth=2*s;
  ctx.beginPath();
  ctx.moveTo(bx-tw*0.4, by); ctx.lineTo(bx-tw*0.3, by-legH);
  ctx.moveTo(bx+tw*0.4, by); ctx.lineTo(bx+tw*0.3, by-legH);
  ctx.stroke();
  // tank
  const tankY=by-legH;
  ctx.fillStyle=rgb(mix(P.roof,P.accent,0.16));
  ctx.beginPath();
  ctx.moveTo(bx-tw/2, tankY);
  ctx.lineTo(bx+tw/2, tankY);
  ctx.lineTo(bx+tw/2, tankY-th);
  ctx.lineTo(bx, tankY-th-tw*0.35);
  ctx.lineTo(bx-tw/2, tankY-th);
  ctx.closePath(); ctx.fill();
  ctx.strokeStyle=rgb(P.glow,0.4); ctx.lineWidth=1; ctx.stroke();
}

function drawFireEscape(r){
  const bx=r.x - r.w/2;
  const [x0]=project(bx, r.top);
  const s=cam.scale;
  ctx.strokeStyle=rgb(mix(P.roof,P.accent,0.35),0.85); ctx.lineWidth=1.4;
  // zig-zag rails matching the procession path region
  for(let i=1;i<PROC_PATH.length-2;i++){
    const [ax,ay]=project(PROC_PATH[i-1][0],PROC_PATH[i-1][1]);
    const [bx2,by2]=project(PROC_PATH[i][0],PROC_PATH[i][1]);
    ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx2,by2); ctx.stroke();
    // rungs
    ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(ax-5*s,ay); ctx.stroke();
  }
}

function drawLaundry(){
  LAUNDRY.forEach((L,li)=>{
    const [x0,y0]=project(L.x0,L.y0), [x1,y1]=project(L.x1,L.y1);
    ctx.strokeStyle=rgb(mix(P.roof,P.accent,0.4),0.5); ctx.lineWidth=1;
    ctx.beginPath(); ctx.moveTo(x0,y0); ctx.lineTo(x1,y1); ctx.stroke();
    for(let i=0;i<L.items;i++){
      const t=(i+0.5)/L.items;
      const px=lerp(x0,x1,t), py=lerp(y0,y1,t);
      const sway=Math.sin(S.t*1.4 + i*1.1 + li)*0.28;
      const w=8*cam.scale, h=13*cam.scale;
      ctx.save(); ctx.translate(px,py); ctx.rotate(sway);
      ctx.fillStyle=rgb(BODY_COLORS_RGB[(i+li)%BODY_COLORS_RGB.length], 0.62);
      ctx.fillRect(-w/2,0,w,h);
      ctx.restore();
    }
  });
}
const BODY_COLORS_RGB = BODY_COLORS.map(hx);

function drawLantern(L,i){
  const [x,y]=project(L.x,L.y);
  const s=cam.scale;
  const pulse=0.75+0.25*Math.sin(S.t*1.5+i) + (S.audio.active?(S.audio.bass*0.5+S.audio.beat*0.5):0);
  const rad=14*s*pulse*(0.7+0.6*P.star);
  const g=ctx.createRadialGradient(x,y,1,x,y,rad*2.6);
  g.addColorStop(0, rgb(P.lantern, 0.9));
  g.addColorStop(0.4, rgb(P.lantern, 0.32));
  g.addColorStop(1, rgb(P.lantern, 0));
  ctx.fillStyle=g; ctx.beginPath(); ctx.arc(x,y,rad*2.6,0,TAU); ctx.fill();
  // post
  ctx.strokeStyle=rgb(mix(P.roof,P.city,0.3)); ctx.lineWidth=1.5*s;
  ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x,y+16*s); ctx.stroke();
  ctx.fillStyle=rgb(P.lantern); ctx.beginPath(); ctx.arc(x,y,3.4*s,0,TAU); ctx.fill();
}

/* ============================ FIGURES ============================ */
// generic dancer. feet at (x,y). style: 'dance'|'walk'|'spin'
function figure(x,y,sc,ph,col,style,lean,alpha){
  const s=sc*cam.scale;
  if(s<0.6) { // too small: dot
    ctx.globalAlpha=(alpha==null?1:alpha); ctx.fillStyle=col;
    ctx.beginPath(); ctx.arc(x,y-6*s,2.4*s,0,TAU); ctx.fill(); ctx.globalAlpha=1; return;
  }
  const AA=S.audio;
  const amp = AA.active ? (0.55 + 1.15*AA.level) : 1;      // bigger moves when louder
  const pop = AA.active ? AA.beat*7*s : 0;                 // every dancer lifts on the beat
  const bob = (S.reduced?0:Math.sin(ph)*2*s*amp) + pop;
  const swing = (S.reduced?0.25:Math.sin(ph))*amp;
  lean = (lean||0)*(AA.active?(0.7+0.8*amp*0.6):1);
  ctx.save();
  ctx.globalAlpha=(alpha==null?1:alpha);
  ctx.translate(x, y-bob);
  ctx.rotate(lean);
  ctx.strokeStyle=col; ctx.lineCap='round'; ctx.lineJoin='round';
  ctx.lineWidth=3.4*s;
  const hip=-13*s, sh=-27*s, head=-33*s;
  // legs
  const legSp = style==='walk'? swing*6*s : Math.abs(swing)*4*s;
  ctx.beginPath();
  ctx.moveTo(0,hip); ctx.lineTo(-legSp*0.5, 0);
  ctx.moveTo(0,hip); ctx.lineTo(legSp*0.5, 0);
  ctx.stroke();
  // torso
  ctx.beginPath(); ctx.moveTo(0,hip); ctx.lineTo(0,sh); ctx.stroke();
  // arms
  ctx.beginPath();
  if(style==='dance'||style==='spin'){
    const a=0.7+0.5*swing;
    ctx.moveTo(0,sh); ctx.lineTo(-11*s*a, sh-9*s*a);
    ctx.moveTo(0,sh); ctx.lineTo(11*s*(1.2-a), sh-9*s*(1.2-a));
  } else {
    ctx.moveTo(0,sh); ctx.lineTo(-8*s, sh+8*s*swing);
    ctx.moveTo(0,sh); ctx.lineTo(8*s, sh-8*s*swing);
  }
  ctx.stroke();
  // head
  ctx.fillStyle=col; ctx.beginPath(); ctx.arc(0,head,5*s,0,TAU); ctx.fill();
  ctx.restore();
  ctx.globalAlpha=1;
}

/* ---------- Rose light sculpture ---------- */
function drawRose(){
  const [x,y]=project(ROSE.x, ROSE.y);
  const s=cam.scale;
  const AA=S.audio;
  const bloom = clamp((S.cfrac-0.4)/0.6, 0, 1) * (AA.active?(1+0.35*AA.level):1); // fuller in later scenes + with music
  const rot = S.reduced?0.4:S.t*0.35;
  const layers=5, petalsPer=8;
  const baseR=ROSE.R*s*(0.42+0.28*bloom) * (AA.active?(1 + 0.28*AA.bass + 0.16*AA.beat):1);
  // ground glow
  const g=ctx.createRadialGradient(x,y,2,x,y,baseR*2.6);
  g.addColorStop(0, rgb(P.glow,0.85));
  g.addColorStop(0.5, rgb(P.glow,0.28));
  g.addColorStop(1, rgb(P.glow,0));
  ctx.fillStyle=g; ctx.beginPath(); ctx.arc(x,y,baseR*2.6,0,TAU); ctx.fill();

  ctx.save(); ctx.translate(x,y);
  for(let l=0;l<layers;l++){
    const lr = baseR*(0.35+ l*0.2);
    const alpha = 0.5 - l*0.06;
    const spin = rot*(l%2?-1:1)*(1+l*0.15);
    for(let p=0;p<petalsPer;p++){
      const a = spin + (p/petalsPer)*TAU + l*0.3;
      const px=Math.cos(a)*lr*0.5, py=Math.sin(a)*lr*0.5;
      ctx.save(); ctx.translate(px,py); ctx.rotate(a);
      const petalCol = mix(P.glow, P.accent, l/layers);
      ctx.fillStyle=rgb(petalCol, alpha*(0.7+0.3*bloom));
      ctx.beginPath();
      ctx.ellipse(lr*0.5,0, lr*0.6, lr*0.26, 0, 0, TAU);
      ctx.fill();
      ctx.restore();
    }
  }
  // bright core
  const core=ctx.createRadialGradient(0,0,1,0,0,baseR*0.6);
  core.addColorStop(0,'#ffffff'); core.addColorStop(0.5,rgb(P.accent,0.9)); core.addColorStop(1,rgb(P.glow,0));
  ctx.fillStyle=core; ctx.beginPath(); ctx.arc(0,0,baseR*0.6,0,TAU); ctx.fill();
  ctx.restore();
}

/* ---------- draw the four choreography zones ---------- */
function show(group){ return S.isolate==='all' || S.isolate===group; }
function ghostAlpha(group){ return show(group)?1:0.12; }

function drawCircle(){
  const al=ghostAlpha('circle');
  if(al<=0.12 && S.isolate!=='all'){} // still draw faint
  const rot = S.reduced?0.3:S.t*0.32;
  // sort by depth (back first) using sin of angle for tilt overlap
  const list = CIRCLE.map(d=>{const a=d.a0+rot; return {d,a,depthY:Math.sin(a)};}).sort((p,q)=>p.depthY-q.depthY);
  list.forEach(({d,a})=>{
    const wx=ROSE.x+Math.cos(a)*d.r;
    const tiltY = cam.tilt* Math.sin(a)* d.r*0.5;
    const wy=ROSE.y - 4 + Math.sin(a)*d.r*0.14 + tiltY;
    const [x,y]=project(wx,wy);
    const lean = Math.cos(a)*0.18; // lean into the turn
    figure(x,y,d.sz,S.t*3*S.tempo0+d.ph, d.col,'spin',lean, al);
  });
}

function drawCouples(){
  const al=ghostAlpha('bridges');
  COUPLES.forEach(c=>{
    const b=BRIDGES[c.b];
    const tri = 0.5+0.5*Math.sin(S.t*0.7 + c.off*Math.PI); // 0..1 crossing
    const wx=lerp(b.x0,b.x1, tri);
    const wy=lerp(b.y0!=null?b.y0:b.y, b.y1!=null?b.y1:b.y, tri) - 8;
    // partner mirrored across bridge centre
    const midT = 0.5;
    for(let m=0;m<2;m++){
      const t = m===0? tri : (1-tri);
      const px=lerp(b.x0,b.x1,t);
      const py=b.y-9;
      const [x,y]=project(px,py);
      const lean=(m===0?1:-1)*0.14*Math.sin(S.t*2+c.ph);
      figure(x,y,0.98,S.t*2.4*S.tempo0+c.ph+m*Math.PI, c.col,'dance',lean, al);
    }
  });
}

function drawFreestyle(){
  const al=ghostAlpha('freestyle');
  FREE.forEach(f=>{
    const a=f.ph + S.t*f.sp*1.4;
    const wx=f.cx+Math.cos(a)*f.rx;
    const wy=f.cy+Math.sin(a*1.3)*f.ry - 6;
    const [x,y]=project(wx,wy);
    // trail
    if(S.trails && show('freestyle') && !S.reduced){
      f.trail.push([x,y]); if(f.trail.length>26) f.trail.shift();
      ctx.lineCap='round';
      for(let i=1;i<f.trail.length;i++){
        const t=i/f.trail.length;
        ctx.strokeStyle=rgb(hx(f.col), t*0.5*al);
        ctx.lineWidth=(1+t*3)*cam.scale;
        ctx.beginPath(); ctx.moveTo(f.trail[i-1][0],f.trail[i-1][1]); ctx.lineTo(f.trail[i][0],f.trail[i][1]); ctx.stroke();
      }
    } else if(!S.trails){ f.trail.length=0; }
    const lean=Math.cos(a)*0.3;
    figure(x,y,1.0,a*2.2, f.col,'dance',lean, al);
  });
}

function drawProcession(){
  const al=ghostAlpha('procession');
  const flow = S.reduced?0: (S.t*0.02*S.tempo0);
  PROC.forEach((p,i)=>{
    let u=(p.u + flow) % 1;
    const [wx,wy]=pathAt(u);
    const [x,y]=project(wx,wy);
    figure(x,y,0.92, S.t*3+p.ph, p.col,'walk', 0, al);
  });
}

/* ============================ ANIMALS ============================ */
function drawBears(){
  BEARS.forEach(b=>{
    const [x,y]=project(b.x,b.y); const s=b.s*cam.scale;
    if(s<0.5) return;
    const sway=S.reduced?0:Math.sin(S.t*1.1+b.ph)*0.10*(S.audio.active?(1+1.3*S.audio.bass):1);
    ctx.save(); ctx.translate(x,y); ctx.rotate(sway);
    const col=mix(P.roof,[60,44,38],0.5);
    ctx.fillStyle=rgb(col);
    // body
    ctx.beginPath(); ctx.ellipse(0,-13*s,13*s,16*s,0,0,TAU); ctx.fill();
    // head
    ctx.beginPath(); ctx.arc(0,-32*s,9*s,0,TAU); ctx.fill();
    // ears
    ctx.beginPath(); ctx.arc(-6*s,-39*s,3.4*s,0,TAU); ctx.arc(6*s,-39*s,3.4*s,0,TAU); ctx.fill();
    // legs
    ctx.fillRect(-10*s,-4*s,6*s,6*s); ctx.fillRect(4*s,-4*s,6*s,6*s);
    ctx.restore();
  });
}
function drawDogs(){
  if(!show('circle') && S.isolate!=='all') return;
  DOGS.forEach(d=>{
    const t=S.t*d.sp;
    const wx=ROSE.x + Math.sin(t)*(ROSE.R+40);
    const wy=ROSE.y+8 + Math.cos(t*1.6)*d.amp*0.3;
    const [x,y]=project(wx,wy); const s=cam.scale;
    if(s<0.5) return;
    const col=BODY_COLORS_RGB[(d.col+2)%BODY_COLORS_RGB.length];
    ctx.save(); ctx.translate(x,y);
    const dir=Math.cos(t)>=0?1:-1; ctx.scale(dir,1);
    ctx.fillStyle=rgb(col);
    ctx.beginPath(); ctx.ellipse(0,-6*s,9*s,4.6*s,0,0,TAU); ctx.fill();      // body
    ctx.beginPath(); ctx.arc(8*s,-9*s,3.6*s,0,TAU); ctx.fill();             // head
    ctx.fillRect(9*s,-12*s,4*s,2*s);                                        // snout
    // legs
    const lp=Math.sin(S.t*8*S.tempo0)*2*s;
    ctx.fillRect(-6*s,-2*s,2*s,4*s+lp); ctx.fillRect(4*s,-2*s,2*s,4*s-lp);
    // tail wag
    ctx.strokeStyle=rgb(col); ctx.lineWidth=2*s; ctx.beginPath();
    ctx.moveTo(-8*s,-8*s); ctx.lineTo(-13*s,-11*s+Math.sin(S.t*12)*2*s); ctx.stroke();
    ctx.restore();
  });
}
function drawCats(){
  CATS.forEach(c=>{
    const t=(Math.sin(S.t*c.sp+c.ph)*0.5+0.5); // 0..1 leap progress
    const wx=lerp(c.p0[0],c.p1[0],t);
    const arc=Math.sin(t*Math.PI)*34*(S.audio.active?(1+0.6*S.audio.level):1);
    const wy=lerp(c.p0[1],c.p1[1],t)-arc-4;
    const [x,y]=project(wx,wy); const s=cam.scale;
    if(s<0.5) return;
    const leaping=arc>6;
    const col=mix(P.roof,[30,26,40],0.4);
    ctx.save(); ctx.translate(x,y);
    const dir=(c.p1[0]-c.p0[0])>=0?1:-1; ctx.scale(dir,1);
    ctx.fillStyle=rgb(col);
    if(leaping){
      ctx.beginPath(); ctx.ellipse(0,0,9*s,3.2*s,-0.2,0,TAU); ctx.fill();
    } else {
      ctx.beginPath(); ctx.ellipse(0,-2*s,7*s,3.6*s,0,0,TAU); ctx.fill();
    }
    ctx.beginPath(); ctx.arc(7*s,-3*s,2.8*s,0,TAU); ctx.fill();            // head
    // ears
    ctx.beginPath(); ctx.moveTo(6*s,-5*s); ctx.lineTo(6.5*s,-8*s); ctx.lineTo(8*s,-5*s); ctx.fill();
    // tail
    ctx.strokeStyle=rgb(col); ctx.lineWidth=2*s; ctx.beginPath();
    ctx.moveTo(-7*s,-2*s); ctx.quadraticCurveTo(-13*s,-6*s,-11*s,-11*s); ctx.stroke();
    ctx.restore();
  });
}
function drawPigeons(){
  const t=S.t*0.5*S.tempo0 + PIGEONS.cycle;
  const phase=(t%8)/8;                // 0..1 launch/rise/settle
  let rise = phase<0.7? (phase/0.7): (1-(phase-0.7)/0.3);
  if(S.audio.active) rise = clamp(rise + S.audio.beat*0.35 + S.audio.treble*0.12, 0, 1.2);
  const [hx0,hy0]=PIGEONS.home;
  PIGEONS.birds.forEach(bd=>{
    const spiral = t*2 + bd.a;
    const r = bd.r0 + rise*70 + Math.sin(spiral)*6;
    const wx = hx0 + Math.cos(spiral)*r*0.9;
    const wy = hy0 - rise*150 + Math.sin(spiral)*r*0.3;
    const [x,y]=project(wx,wy); const s=cam.scale;
    const flap=Math.sin(S.t*14 + bd.ph);
    ctx.save(); ctx.translate(x,y);
    ctx.strokeStyle=rgb(mix(P.accent,[240,240,255],0.4), 0.9); ctx.lineWidth=1.6*s; ctx.lineCap='round';
    const wsp=3.4*s*(0.4+0.6*Math.abs(flap));
    ctx.beginPath();
    ctx.moveTo(-wsp, -flap*2*s); ctx.lineTo(0,0); ctx.lineTo(wsp, -flap*2*s);
    ctx.stroke();
    ctx.restore();
  });
}
function drawMoths(){
  MOTHS.forEach(m=>{
    const L=LANTERNS[m.L];
    const a=m.a + S.t*m.sp;
    const jit=3+(S.audio.active?S.audio.treble*9:0);
    const wx=L.x + Math.cos(a)*m.r + Math.sin(S.t*3+m.ph)*jit;
    const wy=L.y + Math.sin(a*1.3)*m.r*0.7 + Math.cos(S.t*3.4+m.ph)*jit;
    const [x,y]=project(wx,wy); const s=cam.scale;
    const flap=Math.sin(S.t*18+m.ph);
    ctx.save(); ctx.translate(x,y);
    ctx.fillStyle=rgb(mix(P.lantern,[255,240,210],0.5), 0.85);
    ctx.beginPath();
    ctx.ellipse(-1.6*s, 0, 1.8*s, 3*s*Math.abs(flap)+0.6, -0.5,0,TAU);
    ctx.ellipse( 1.6*s, 0, 1.8*s, 3*s*Math.abs(flap)+0.6,  0.5,0,TAU);
    ctx.fill();
    ctx.restore();
  });
}

/* ============================ ROUTE DIAGRAMS ============================ */
function dashedArc(x0,y0,x1,y1,bend,col,label){
  const mx=(x0+x1)/2, my=(y0+y1)/2 - bend;
  ctx.save();
  ctx.setLineDash([6,7]); ctx.lineDashOffset=-S.t*20;
  ctx.strokeStyle=col; ctx.lineWidth=1.6;
  ctx.beginPath(); ctx.moveTo(x0,y0); ctx.quadraticCurveTo(mx,my,x1,y1); ctx.stroke();
  ctx.setLineDash([]);
  // node dots
  ctx.fillStyle=col; ctx.beginPath(); ctx.arc(x0,y0,3,0,TAU); ctx.arc(x1,y1,3,0,TAU); ctx.fill();
  if(label){
    ctx.font='600 11px ui-sans-serif,system-ui,sans-serif';
    ctx.fillStyle=rgb(P.accent,0.95);
    ctx.textAlign='center';
    ctx.fillText(label, mx, my-4);
  }
  ctx.restore();
}
function drawDiagrams(){
  const c=rgb(P.accent,0.6);
  // procession route
  ctx.save(); ctx.setLineDash([5,6]); ctx.lineDashOffset=-S.t*20;
  ctx.strokeStyle=rgb(P.accent,0.55); ctx.lineWidth=1.6; ctx.beginPath();
  for(let i=0;i<PROC_PATH.length;i++){const [x,y]=project(PROC_PATH[i][0],PROC_PATH[i][1]); i?ctx.lineTo(x,y):ctx.moveTo(x,y);} ctx.stroke();
  ctx.setLineDash([]); ctx.restore();
  const midP=pathAt(0.5); const [mpx,mpy]=project(midP[0],midP[1]);
  labelTag('Ascent · procession', mpx, mpy-10);
  // bridge crossings
  BRIDGES.forEach((b,i)=>{
    const [x0,y0]=project(b.x0,b.y-12),[x1,y1]=project(b.x1,b.y-12);
    dashedArc(x0,y0,x1,y1,26,c, i===0?'Mirrored crossing':'Mirrored crossing');
  });
  // freestyle loops near towers
  [roofById('westTower'),roofById('eastTower')].forEach(tw=>{
    const [x,y]=project(tw.x, tw.top-16);
    ctx.save(); ctx.setLineDash([5,6]); ctx.lineDashOffset=-S.t*20;
    ctx.strokeStyle=c; ctx.lineWidth=1.6;
    ctx.beginPath(); ctx.ellipse(x,y, 42*cam.scale, 24*cam.scale, 0,0,TAU); ctx.stroke();
    ctx.setLineDash([]); ctx.restore();
    labelTag('Spiral · freestyle', x, y-30*cam.scale);
  });
  // circle
  const [rx,ry]=project(ROSE.x,ROSE.y);
  ctx.save(); ctx.setLineDash([5,6]); ctx.lineDashOffset=S.t*20;
  ctx.strokeStyle=c; ctx.lineWidth=1.6;
  ctx.beginPath(); ctx.ellipse(rx,ry, ROSE.R*cam.scale, ROSE.R*cam.scale*(1-0.5*cam.tilt),0,0,TAU); ctx.stroke();
  ctx.setLineDash([]); ctx.restore();
  labelTag('Turning circle · the rose', rx, ry - (ROSE.R*cam.scale*(1-0.5*cam.tilt))-8);
}
function labelTag(text,x,y){
  ctx.save();
  ctx.font='600 11px ui-sans-serif,system-ui,sans-serif';
  ctx.textAlign='center';
  const w=ctx.measureText(text).width+12;
  ctx.fillStyle='rgba(12,10,26,.72)';
  roundRect(x-w/2,y-13,w,17,8); ctx.fill();
  ctx.fillStyle=rgb(P.accent,0.98);
  ctx.fillText(text,x,y-0.5);
  ctx.restore();
}
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();}

/* ============================ NAME LABELS ============================ */
function drawNames(){
  const items=[
    ['Bears', BEARS[0].x, BEARS[0].y-34],
    ['Cats', CATS[0].p0[0], CATS[0].p0[1]-14],
    ['Pigeons', PIGEONS.home[0]+30, PIGEONS.home[1]-70],
    ['Dogs', ROSE.x, ROSE.y+22],
    ['Moths', LANTERNS[0].x, LANTERNS[0].y-24],
    ['The Rose', ROSE.x, ROSE.y-ROSE.R-4],
    ['Water tower', roofById('eastTower').x, roofById('eastTower').top-46],
  ];
  items.forEach(([t,wx,wy])=>{ const [x,y]=project(wx,wy); labelTag(t,x,y); });
}

/* ============================ VIGNETTE ============================ */
function drawVignette(){
  const g=ctx.createRadialGradient(VW/2,VH*0.5,Math.min(VW,VH)*0.35, VW/2,VH*0.55, Math.max(VW,VH)*0.75);
  g.addColorStop(0,'rgba(0,0,0,0)');
  g.addColorStop(1, 'rgba(6,4,18,0.5)');
  ctx.fillStyle=g; ctx.fillRect(0,0,VW,VH);
}

/* ============================ MASTER RENDER ============================ */
function render(){
  drawSky();
  drawSkylineLayer(FAR, 0.34, mix(P.city,P.skyTop,0.4), 0.7);
  drawSkylineLayer(MID, 0.6,  mix(P.city,P.skyMid,0.22), 0.9);
  drawCityGlow();
  drawRoofs();
  if(S.diagrams) drawDiagrams();
  // depth order: procession (behind), bears, rose+circle+dogs, couples, freestyle, cats, pigeons, moths
  drawProcession();
  drawBears();
  drawRose();
  drawDogs();
  drawCircle();
  drawCouples();
  drawFreestyle();
  drawCats();
  drawPigeons();
  drawMoths();
  drawVignette();
  if(S.names) drawNames();
}

/* ============================ SCENE / PALETTE UPDATE ============================ */
function lerpArrTo(cur,to,k){ for(let i=0;i<3;i++) cur[i]=lerp(cur[i],to[i],k); }
function updatePalette(dt){
  const to=SCENES[S.targetScene];
  const k=clamp(dt*0.6,0,1);
  lerpArrTo(P.skyTop,to.skyTop,k); lerpArrTo(P.skyMid,to.skyMid,k); lerpArrTo(P.skyBot,to.skyBot,k);
  lerpArrTo(P.orb,to.orb,k); lerpArrTo(P.roof,to.roof,k); lerpArrTo(P.city,to.city,k);
  lerpArrTo(P.glow,to.glow,k); lerpArrTo(P.accent,to.accent,k); lerpArrTo(P.lantern,to.lantern,k);
  P.sunY=lerp(P.sunY,to.sunY,k); P.star=lerp(P.star,to.star,k);
  S.cfrac=lerp(S.cfrac,to.complexity,k);
}
function updateCamera(dt){
  const pr=CAM_PRESETS[S.cam];
  const k=clamp(dt*3,0,1);
  // gentle idle drift so parallax reads
  const driftX = S.reduced?0: Math.sin(S.t*0.13)*22*(1.2-cam.tilt);
  cam.scale=lerp(cam.scale,pr.scale,k);
  cam.cx=lerp(cam.cx,pr.cx+driftX,k);
  cam.cy=lerp(cam.cy,pr.cy,k);
  cam.tilt=lerp(cam.tilt,pr.tilt,k);
}

/* ============================ LOOP ============================ */
let last=performance.now();
S.tempo0=1; // smoothed tempo used inside figure phase math
function frame(now){
  const rawdt=Math.min(0.05,(now-last)/1000); last=now;
  sampleAudio();                                   // read the live spectrum every frame
  if(!S.paused && !S.reduced){
    S.tempo0=lerp(S.tempo0,S.tempo,0.1);
    // the master clock speeds up with musical energy → the whole scene moves to the music
    const boost = S.audio.active ? (S.audio.level*1.3 + S.audio.beat*0.25) : 0;
    S.t += rawdt*S.tempo*(1+boost);
    // scene drifting
    if(S.autoScene){
      S.sceneT += rawdt;
      if(S.sceneT>SCENE_DUR){ S.sceneT=0; S.targetScene=(S.targetScene+1)%SCENES.length; syncSceneUI(); }
    }
  }
  updatePalette(S.reduced?0.001:rawdt);
  updateCamera(S.reduced?1:rawdt);
  render();
  if(!S.paused) rafId=requestAnimationFrame(frame);
  else rafId=null;
}
let rafId=null;
function start(){ if(!rafId){ last=performance.now(); rafId=requestAnimationFrame(frame);} }
function stop(){ if(rafId){ cancelAnimationFrame(rafId); rafId=null; } }

/* reduced-motion: draw a single composed still and stop the loop */
function renderStill(){
  stop();
  // compose a calm "Long Light" frame
  updatePalette(1); updateCamera(1);
  render();
}

/* ============================ UI WIRING ============================ */
const $=s=>document.querySelector(s);
const sceneNameEl=$('#sceneName'), sceneDotEl=$('#sceneDot');

function syncSceneUI(){
  $('#sceneSel').value=String(S.targetScene);
  sceneNameEl.textContent=SCENES[S.targetScene].name;
  sceneDotEl.style.background=rgb(SCENES[S.targetScene].glow);
}

// camera segmented control
$('#camSeg').addEventListener('click',e=>{
  const b=e.target.closest('button[data-cam]'); if(!b) return;
  S.cam=b.dataset.cam;
  [...$('#camSeg').children].forEach(x=>x.setAttribute('aria-pressed', String(x===b)));
  if(S.reduced) renderStill();
});
function setCam(name){
  const b=$(`#camSeg button[data-cam="${name}"]`); if(b) b.click();
}

$('#sceneSel').addEventListener('change',e=>{
  S.targetScene=+e.target.value; S.sceneT=0; syncSceneUI();
  if(S.reduced) renderStill();
});
$('#autoScene').addEventListener('change',e=>{ S.autoScene=e.target.checked; });

$('#tempo').addEventListener('input',e=>{ S.tempo=+e.target.value; $('#tempoVal').textContent=S.tempo.toFixed(2).replace(/0$/,'')+'×'; });
$('#density').addEventListener('input',e=>{
  S.density=+e.target.value;
  $('#densVal').textContent = S.density<0.6?'Sparse':S.density<1.05?'Full':'Teeming';
});
$('#density').addEventListener('change',()=>{ rebuildDensity(); if(S.reduced) renderStill(); });

$('#isolate').addEventListener('change',e=>{ S.isolate=e.target.value; if(S.reduced) renderStill(); });
$('#tgNames').addEventListener('change',e=>{ S.names=e.target.checked; if(S.reduced) renderStill(); });
$('#tgTrails').addEventListener('change',e=>{ S.trails=e.target.checked; });
$('#tgDiag').addEventListener('change',e=>{ S.diagrams=e.target.checked; if(S.reduced) renderStill(); });

function setPaused(p){
  S.paused=p;
  const b=$('#btnPause'); b.textContent=p?'Play':'Pause'; b.setAttribute('aria-pressed',String(p));
  if(p) stop(); else if(!S.reduced) start();
}
$('#btnPause').addEventListener('click',()=>setPaused(!S.paused));
$('#btnRestart').addEventListener('click',restart);
function restart(){
  rnd=mulberry32(0x50C1CE|0);
  S.t=0; S.sceneT=0; S.targetScene=0; syncSceneUI();
  rebuildDensity(); makeAnimals();
  FREE.forEach(f=>f.trail.length=0);
  if(S.reduced) renderStill(); else { setPaused(false); }
}

function setReduced(v){
  S.reduced=v; $('#tgReduce').checked=v;
  if(v){ renderStill(); }
  else if(!S.paused){ start(); }
}
$('#tgReduce').addEventListener('change',e=>setReduced(e.target.checked));

// panel show/hide
const dock=$('#controls'), dockToggle=$('#dockToggle');
function setDock(open){
  dock.hidden=!open;
  dockToggle.setAttribute('aria-expanded',String(open));
  dockToggle.textContent=open?'Hide panel':'Controls';
}
dockToggle.addEventListener('click',()=>setDock(dock.hidden));

// legend
(function buildLegend(){
  const items=[['Bears','#7a5a4a'],['Cats','#3a3450'],['Pigeons','#d8d8ff'],['Dogs','#6fbf9b'],['Moths','#ffce8a']];
  $('#legend').innerHTML=items.map(([n,c])=>`<span class="tag"><i style="background:${c}"></i>${n}</span>`).join('');
})();

/* ---------- keyboard shortcuts ---------- */
window.addEventListener('keydown',e=>{
  if(e.target.matches('input,select,textarea')) { if(e.key!=='Escape') return; }
  switch(e.key.toLowerCase()){
    case ' ': e.preventDefault(); setPaused(!S.paused); break;
    case '1': setCam('skyline'); break;
    case '2': setCam('rooftop'); break;
    case '3': setCam('birdseye'); break;
    case 'r': restart(); break;
    case 'n': $('#tgNames').checked=!S.names; S.names=!S.names; if(S.reduced)renderStill(); break;
    case 't': $('#tgTrails').checked=!S.trails; S.trails=!S.trails; break;
    case 'd': $('#tgDiag').checked=!S.diagrams; S.diagrams=!S.diagrams; if(S.reduced)renderStill(); break;
    case 'm': setReduced(!S.reduced); break;
    case 'c': setDock(dock.hidden); break;
  }
});

/* ===================================================================
   Optional LIVE-REACTIVE music — Grateful Dead sets streamed from the
   Internet Archive (archive.org; public keyless API — the band was
   taping-friendly, shared with their blessing). Each track is fetched
   and decoded into an AudioBuffer, then played through a Web Audio
   graph (BufferSource → Analyser → Gain → speakers), so the live
   spectrum drives every element on screen. This is the ONLY part that
   touches the network, and it's audio-only; the visuals stay
   self-contained and react ONLY while a set is playing.
   =================================================================== */
const MUSIC = {
  // curated, verified-real item ids — filenames are always fetched at runtime, never hardcoded
  shows:[
    {id:'gd77-05-08.sbd.hicks.4982.sbeok.shnf',        label:'Barton Hall, Cornell · May 8, 1977'},
    {id:'gd77-05-07.sbd.eaton.wizard.26085.sbeok.shnf',label:'Boston Garden · May 7, 1977'},
    {id:'gd77-05-09.sbd.connor.8304.sbeok.shnf',       label:'Buffalo Memorial Aud. · May 9, 1977'},
    {id:'gd73-06-10.sbd.hollister.174.sbeok.shnf',     label:'RFK Stadium · June 10, 1973'},
    {id:'gd73-02-15.sbd.hall.1580.sbeok.shnf',         label:'Dane County Coliseum · Feb 15, 1973'},
    {id:'gd71-08-06.aud.bertrando.yerys.129.sbeok.shnf',label:'Hollywood Palladium · Aug 6, 1971'},
  ],
  playlist:[], idx:0, showId:'', showLabel:'', loading:false, wantPlay:false,
  ctx:null, analyser:null, gain:null, freq:null,
  srcNode:null, buffers:new Map(), playing:false, _stopping:false, _loadToken:0,
};

const iaItem = id => 'https://archive.org/details/'+id;
const iaFile = (id,name) => 'https://archive.org/download/'+id+'/'+encodeURIComponent(name);
function setNP(t){ const e=document.getElementById('npLine'); if(e) e.textContent=t; }
function setCredit(id){
  const c=document.getElementById('mCredit'); if(!c) return;
  c.innerHTML = id ? 'Source: <a href="'+iaItem(id)+'" target="_blank" rel="noopener noreferrer">archive.org ↗</a> · taping-friendly recording, shared with the band’s blessing.' : '';
}
function updateMusicBtn(playing){
  const b=document.getElementById('mPlay'); if(!b) return;
  b.textContent=playing?'Pause music':'Play music'; b.setAttribute('aria-pressed',String(playing));
}

// Build the Web Audio graph once (on a user gesture). Degrades to non-reactive on failure.
function ensureCtx(){
  if(MUSIC.ctx){ if(MUSIC.ctx.state==='suspended') MUSIC.ctx.resume(); return true; }
  try{
    const AC=window.AudioContext||window.webkitAudioContext;
    MUSIC.ctx=new AC();
    MUSIC.analyser=MUSIC.ctx.createAnalyser();
    MUSIC.analyser.fftSize=1024; MUSIC.analyser.smoothingTimeConstant=0.82;
    MUSIC.gain=MUSIC.ctx.createGain(); MUSIC.gain.gain.value=0.55;
    MUSIC.analyser.connect(MUSIC.gain); MUSIC.gain.connect(MUSIC.ctx.destination); // analyser BEFORE gain → full spectrum at any volume
    MUSIC.freq=new Uint8Array(MUSIC.analyser.frequencyBinCount);
    MUSIC.ctx.resume();
    return true;
  }catch(e){ MUSIC.ctx=null; return false; }
}

// one mp3 per track; prefer the light 64Kbps derivative so loads are fast (~2–3MB / ~2s)
function pickFiles(files){
  const mp3=files.filter(f=>/\.mp3$/i.test(f.name||''));
  const low=mp3.filter(f=>/64Kbps/i.test(String(f.format||'')));
  const vbr=mp3.filter(f=>/VBR/i.test(String(f.format||'')));
  const use=(low.length?low:(vbr.length?vbr:mp3));
  use.sort((a,b)=>(a.name<b.name?-1:a.name>b.name?1:0));
  return use;
}

async function loadShow(show, autoplay, silent){
  MUSIC.wantPlay = MUSIC.wantPlay || !!autoplay;
  const token=++MUSIC._loadToken;
  if(!silent) setNP('Loading '+show.label+' …');
  try{
    const r=await fetch('https://archive.org/metadata/'+show.id);
    const d=await r.json();
    const files=pickFiles(d.files||[]);
    if(!files.length) throw new Error('no mp3');
    if(token!==MUSIC._loadToken) return;                 // superseded by a newer load
    MUSIC.showId=show.id; MUSIC.showLabel=show.label; MUSIC.idx=0;
    MUSIC.playlist=files.map(f=>({url:iaFile(show.id,f.name), title:(f.title||f.name.replace(/\.mp3$/i,'')).replace(/^gd[\d\-]+[a-z]*-?/i,'').trim()||f.name}));
    setCredit(show.id);
    if(MUSIC.wantPlay) startTrack(0);
    else if(!silent) setNP('Ready — '+show.label);
  }catch(e){
    if(!silent) setNP('Couldn’t reach the Internet Archive — check your connection, then try again.');
  }
}

async function getBuffer(url){
  if(MUSIC.buffers.has(url)) return MUSIC.buffers.get(url);
  const r=await fetch(url);                               // CORS on archive.org fileserver is open (ACAO:*)
  const ab=await r.arrayBuffer();
  const buf=await MUSIC.ctx.decodeAudioData(ab);
  MUSIC.buffers.set(url,buf);
  if(MUSIC.buffers.size>4){ const k=MUSIC.buffers.keys().next().value; MUSIC.buffers.delete(k); } // bound memory
  return buf;
}

function stopSource(){
  if(MUSIC.srcNode){
    MUSIC._stopping=true;
    try{ MUSIC.srcNode.onended=null; MUSIC.srcNode.stop(); }catch(e){}
    try{ MUSIC.srcNode.disconnect(); }catch(e){}
    MUSIC.srcNode=null; MUSIC._stopping=false;
  }
}

async function startTrack(i){
  if(!ensureCtx() || !MUSIC.playlist.length) return;
  MUSIC.idx=((i%MUSIC.playlist.length)+MUSIC.playlist.length)%MUSIC.playlist.length;
  const track=MUSIC.playlist[MUSIC.idx];
  const token=++MUSIC._loadToken;
  MUSIC.wantPlay=true;
  setNP('Buffering ♪ '+track.title+' …');
  try{
    const buf=await getBuffer(track.url);
    if(token!==MUSIC._loadToken) return;                 // a newer start superseded us
    stopSource();
    const sn=MUSIC.ctx.createBufferSource();
    sn.buffer=buf; sn.connect(MUSIC.analyser);
    sn.onended=()=>{ if(!MUSIC._stopping && MUSIC.srcNode===sn) startTrack(MUSIC.idx+1); };
    if(MUSIC.ctx.state==='suspended') await MUSIC.ctx.resume();
    sn.start(0);
    MUSIC.srcNode=sn; MUSIC.playing=true;
    updateMusicBtn(true);
    setNP('♪ '+track.title+' — '+MUSIC.showLabel);
    prefetchNext();
  }catch(e){
    if(MUSIC.idx<MUSIC.playlist.length-1) startTrack(MUSIC.idx+1);   // skip a bad track
    else setNP('Couldn’t play this show — try “Shuffle show”.');
  }
}
function prefetchNext(){
  const n=MUSIC.playlist[(MUSIC.idx+1)%MUSIC.playlist.length];
  if(n) getBuffer(n.url).catch(()=>{});
}
function pauseMusic(){ if(MUSIC.ctx && MUSIC.ctx.state==='running'){ MUSIC.ctx.suspend(); MUSIC.playing=false; updateMusicBtn(false); } }
function resumeMusic(){ if(MUSIC.ctx){ MUSIC.ctx.resume(); MUSIC.playing=true; updateMusicBtn(true); } }

// Per-frame: read the live spectrum → bass/mid/treble/level + a beat onset (used by the whole scene).
function sampleAudio(){
  const a=S.audio;
  if(MUSIC.analyser && MUSIC.ctx && MUSIC.ctx.state==='running' && MUSIC.playing){
    MUSIC.analyser.getByteFrequencyData(MUSIC.freq);
    const f=MUSIC.freq, n=f.length;
    const bE=Math.max(2,Math.floor(n*0.06)), mE=Math.floor(n*0.30), tE=Math.floor(n*0.70);
    let bass=0,mid=0,tre=0,all=0;
    for(let i=0;i<bE;i++)  bass+=f[i];
    for(let i=bE;i<mE;i++) mid +=f[i];
    for(let i=mE;i<tE;i++) tre +=f[i];
    for(let i=0;i<tE;i++)  all +=f[i];
    bass/=(bE*255); mid/=((mE-bE)*255); tre/=((tE-mE)*255); all/=(tE*255);
    a.bass=lerp(a.bass,bass,0.4); a.mid=lerp(a.mid,mid,0.4); a.treble=lerp(a.treble,tre,0.4);
    a.level=clamp(lerp(a.level,all*1.7,0.3),0,1);
    const flux=bass-a._prev; a._prev=bass;
    a._avg=lerp(a._avg,bass,0.08);
    if(bass>a._avg*1.32 && flux>0.02 && a._refr<=0){ a.beat=1; a._refr=7; }
    a._refr--; a.beat*=0.80;
    a.active=true;
  } else {
    a.active=false; a.beat*=0.8;
    a.level=lerp(a.level,0,0.12); a.bass=lerp(a.bass,0,0.12); a.mid=lerp(a.mid,0,0.12); a.treble=lerp(a.treble,0,0.12);
  }
}

async function liveShuffle(){
  ensureCtx();
  setNP('Finding a show in the archive …'); MUSIC.wantPlay=true;
  try{
    const q=encodeURIComponent('collection:GratefulDead AND format:(VBR MP3)');
    const r=await fetch('https://archive.org/advancedsearch.php?q='+q+'&fl[]=identifier&fl[]=title&sort[]=downloads+desc&rows=60&output=json');
    const d=await r.json();
    const docs=(d.response&&d.response.docs)||[];
    if(!docs.length) throw new Error('empty');
    const pick=docs[Math.floor(rnd()*docs.length)];
    const label=String(pick.title||'Grateful Dead').replace(/^Grateful Dead Live at\s*/i,'').replace(/\s+on\s+/,' · ');
    loadShow({id:pick.identifier,label}, true);
  }catch(e){
    loadShow(MUSIC.shows[Math.floor(rnd()*MUSIC.shows.length)], true);   // fallback: curated
  }
}

// wiring
document.getElementById('mPlay').addEventListener('click',()=>{
  ensureCtx();
  if(MUSIC.playing){ pauseMusic(); return; }
  if(MUSIC.srcNode && MUSIC.ctx && MUSIC.ctx.state==='suspended'){ resumeMusic(); return; }
  if(MUSIC.playlist.length) startTrack(MUSIC.idx);
  else { MUSIC.wantPlay=true; loadShow(MUSIC.shows[0], true); }
});
document.getElementById('mNext').addEventListener('click',()=>{ if(MUSIC.playlist.length) startTrack(MUSIC.idx+1); });
document.getElementById('mPrev').addEventListener('click',()=>{ if(MUSIC.playlist.length) startTrack(MUSIC.idx-1); });
document.getElementById('mShuffle').addEventListener('click',liveShuffle);
document.getElementById('mVol').addEventListener('input',e=>{ if(MUSIC.gain) MUSIC.gain.gain.value=+e.target.value; });
// silent metadata prefetch so the first Play only needs to fetch the audio
setTimeout(()=>{ if(!MUSIC.playlist.length && !MUSIC.loading) loadShow(MUSIC.shows[0], false, true); }, 1800);

/* ============================ BOOT ============================ */
resize();
syncSceneUI();
$('#densVal').textContent='Full';
// honor OS reduced-motion preference on load
const prefersReduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if(prefersReduce){ setReduced(true); }
else { start(); }

// keep still frame correct if the tab was hidden then shown
document.addEventListener('visibilitychange',()=>{ if(!document.hidden && !S.paused && !S.reduced) start(); });

})();
</script>
</body>
</html>