← back to Scarlet Riverboat Masquerade
index.html
2268 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Scarlet Riverboat Masquerade — an authored, animated generative gallery of a paddlewheel riverboat drifting through a crimson canyon at dusk. Steer tempo, lanterns, crowd, animals, cameras and chapters.">
<meta name="color-scheme" content="dark">
<title>Scarlet Riverboat Masquerade — An Animated Gallery</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' fill='%237c1522'/><circle cx='8' cy='6' r='3' fill='%23f2b64a'/></svg>">
<style>
:root{
--ink:#f4e7d6;
--ink-dim:#c9a98c;
--gold:#f2b64a;
--crimson:#7c1522;
--panel:rgba(24,8,12,0.72);
--panel-edge:rgba(242,182,74,0.28);
--focus:#ffd27a;
--serif:"Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif;
--sans:"Avenir Next","Segoe UI",system-ui,-apple-system,sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#0a0406;color:var(--ink);font-family:var(--sans);overflow:hidden}
#stage{position:fixed;inset:0;display:block}
canvas{position:absolute;inset:0;width:100%;height:100%}
#orb{z-index:4;pointer-events:none}
/* construction-paper grain — the cutout/South-Park texture over the whole scene */
.grain{position:fixed;inset:0;pointer-events:none;z-index:5;opacity:.10;mix-blend-mode:overlay;
background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 220 220' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
/* ---- Skip link ---- */
.skip{
position:fixed;top:-60px;left:12px;z-index:20;background:var(--gold);color:#2a0a0e;
padding:10px 16px;border-radius:8px;font-weight:600;text-decoration:none;transition:top .2s;
}
.skip:focus{top:12px}
/* ---- Back-link (only shown when hosted as a room of the DEAD listening exhibition) ---- */
.backlink{
position:fixed;top:24px;left:50%;transform:translateX(-50%);z-index:10;display:none;
align-items:center;gap:8px;text-decoration:none;pointer-events:auto;
font-family:var(--serif);font-style:italic;font-size:12.5px;color:var(--ink-dim);
background:var(--panel);border:1px solid var(--panel-edge);border-radius:999px;
padding:6px 15px;backdrop-filter:blur(6px);transition:color .3s,background .3s;
}
.backlink.on{display:inline-flex}
.backlink:hover{color:var(--gold);background:rgba(40,12,16,.86)}
/* ---- Title plate ---- */
.titleplate{
position:fixed;top:22px;left:26px;z-index:6;max-width:min(46vw,520px);
pointer-events:none;text-shadow:0 2px 18px rgba(0,0,0,.7);
}
.titleplate .kicker{
font-family:var(--sans);letter-spacing:.42em;text-transform:uppercase;
font-size:11px;color:var(--gold);opacity:.9;margin:0 0 6px 2px;
}
.titleplate h1{
font-family:var(--serif);font-weight:600;font-style:italic;
font-size:clamp(26px,4.4vw,52px);line-height:.98;margin:0;color:var(--ink);
}
.titleplate .chap{
font-family:var(--serif);font-size:14px;color:var(--ink-dim);margin-top:8px;font-style:italic;
}
/* ---- Chapter rail ---- */
.chapters{
position:fixed;top:64px;right:26px;z-index:6;display:flex;gap:8px;flex-direction:column;align-items:flex-end;
}
.chapters button{
font-family:var(--serif);font-size:13px;font-style:italic;
background:var(--panel);color:var(--ink-dim);border:1px solid transparent;
border-radius:999px;padding:7px 15px;cursor:pointer;backdrop-filter:blur(6px);
transition:color .3s,border-color .3s,background .3s;
display:flex;align-items:center;gap:8px;
}
.chapters button .dot{width:7px;height:7px;border-radius:50%;background:currentColor;opacity:.6}
.chapters button[aria-current="true"]{color:var(--gold);border-color:var(--panel-edge);background:rgba(40,12,16,.86)}
.chapters button:hover{color:var(--ink)}
/* ---- Control dock ---- */
.dock{
position:fixed;left:50%;bottom:20px;transform:translateX(-50%);z-index:7;
display:flex;gap:18px;align-items:stretch;flex-wrap:wrap;justify-content:center;
max-width:min(96vw,1080px);
background:var(--panel);border:1px solid var(--panel-edge);border-radius:16px;
padding:14px 20px;backdrop-filter:blur(9px);box-shadow:0 12px 40px rgba(0,0,0,.5);
}
.group{display:flex;gap:12px;align-items:center}
.group + .group{border-left:1px solid rgba(242,182,74,.16);padding-left:18px}
.field{display:flex;flex-direction:column;gap:5px;min-width:96px}
.field label{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-dim)}
.field .val{font-family:var(--serif);font-style:italic;color:var(--gold);font-size:12px}
input[type=range]{
-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;
background:linear-gradient(90deg,var(--crimson),var(--gold));outline:none;width:100%;
}
input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none;width:16px;height:16px;border-radius:50%;
background:var(--ink);border:2px solid var(--gold);cursor:pointer;box-shadow:0 0 8px rgba(242,182,74,.6);
}
input[type=range]::-moz-range-thumb{
width:16px;height:16px;border-radius:50%;background:var(--ink);border:2px solid var(--gold);cursor:pointer;
}
.btn{
font-family:var(--sans);font-size:12px;letter-spacing:.04em;font-weight:500;
background:linear-gradient(180deg,rgba(58,18,24,.72),rgba(34,10,14,.72));
color:var(--ink);border:1px solid rgba(242,182,74,.22);
border-radius:11px;padding:0 14px;min-height:38px;cursor:pointer;
transition:background .18s,color .18s,border-color .18s,box-shadow .18s,transform .06s;
display:inline-flex;align-items:center;gap:8px;white-space:nowrap;
box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 1px 2px rgba(0,0,0,.35);
}
.btn:hover{border-color:rgba(242,182,74,.5);color:var(--gold);
background:linear-gradient(180deg,rgba(78,24,30,.85),rgba(48,14,18,.85))}
.btn:active{transform:translateY(1px)}
/* active "on" state — a lit lantern glow, not a solid candy fill */
.btn[aria-pressed="true"]{
color:var(--gold);border-color:rgba(242,182,74,.6);font-weight:600;
background:linear-gradient(180deg,rgba(242,182,74,.20),rgba(242,182,74,.07));
box-shadow:inset 0 0 0 1px rgba(242,182,74,.22),inset 0 1px 0 rgba(255,235,190,.16),0 0 14px rgba(242,182,74,.16);
}
.btn[aria-pressed="true"]:hover{background:linear-gradient(180deg,rgba(242,182,74,.28),rgba(242,182,74,.12))}
.toggle{display:flex;flex-direction:column;gap:8px;align-items:stretch}
.toggle .btn{justify-content:flex-start} /* equal-width pills, left-aligned labels */
/* ---- Camera pills ---- */
.cams{position:fixed;left:26px;bottom:20px;z-index:7;display:flex;gap:8px;flex-direction:column}
.cams .btn{justify-content:flex-start;min-width:118px}
/* ---- Museum label ---- */
.label-toggle{position:fixed;right:26px;bottom:20px;z-index:7}
.museum{
position:fixed;right:26px;bottom:66px;z-index:8;width:min(88vw,368px);max-height:74vh;overflow:auto;
background:linear-gradient(180deg,rgba(28,10,14,.96),rgba(16,6,9,.96));
border:1px solid var(--panel-edge);border-radius:14px;padding:20px 22px;
box-shadow:0 18px 50px rgba(0,0,0,.6);transform-origin:bottom right;
transition:opacity .3s,transform .3s;font-size:13px;line-height:1.55;color:var(--ink-dim);
}
.museum[hidden]{display:none}
.museum h2{font-family:var(--serif);font-style:italic;color:var(--gold);font-size:18px;margin:0 0 4px}
.museum .meta{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-dim);margin-bottom:12px;opacity:.8}
.museum p{margin:0 0 10px}
.museum strong{color:var(--ink);font-weight:600}
.museum .keys{margin:14px 0 0;padding:12px 0 0;border-top:1px solid rgba(242,182,74,.16);
font-size:11px;line-height:1.7;letter-spacing:.02em}
.museum .keys b{display:inline-block;min-width:74px;color:var(--gold);font-family:var(--serif);font-style:italic;font-weight:600}
/* ---- Music Room console ---- */
.musicroom{
position:fixed;left:26px;top:158px;z-index:9;width:min(84vw,300px);
background:var(--panel);border:1px solid var(--panel-edge);border-radius:14px;
backdrop-filter:blur(9px);box-shadow:0 12px 40px rgba(0,0,0,.5);overflow:hidden;font-size:12px;
}
.musicroom > header{
display:flex;align-items:center;gap:9px;padding:11px 14px;cursor:pointer;
font-family:var(--serif);font-style:italic;color:var(--gold);font-size:14px;user-select:none;
}
.musicroom .live-dot{width:8px;height:8px;border-radius:50%;background:#5a2128;flex:none}
.musicroom.playing .live-dot{background:var(--gold);animation:pulse 2s infinite}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(242,182,74,.5)}70%{box-shadow:0 0 0 8px rgba(242,182,74,0)}100%{box-shadow:0 0 0 0 rgba(242,182,74,0)}}
.musicroom .chev{margin-left:auto;transition:transform .3s;font-size:11px}
.musicroom[data-open="false"] .chev{transform:rotate(-90deg)}
.musicroom[data-open="false"] .mrbody{display:none}
.mrbody{padding:0 14px 14px}
.mr-search{width:100%;background:rgba(40,12,16,.8);color:var(--ink);border:1px solid var(--panel-edge);border-radius:9px;padding:8px 10px;font-size:12px;margin-bottom:8px}
.mr-search::placeholder{color:var(--ink-dim)}
.mr-year{width:100%;background:rgba(40,12,16,.8);color:var(--ink);border:1px solid var(--panel-edge);border-radius:9px;padding:7px 9px;font-size:12px;margin-bottom:8px;cursor:pointer}
/* source selector — which free Dead archive to browse */
.mr-src{display:flex;gap:6px;margin-bottom:9px}
.mr-src button{flex:1;background:rgba(40,12,16,.7);color:var(--ink-dim);border:1px solid var(--panel-edge);border-radius:9px;padding:8px 8px;cursor:pointer;line-height:1.25;text-align:center;font-family:var(--sans)}
.mr-src button b{display:block;font-size:12px;color:var(--ink);font-weight:600}
.mr-src button .yr{display:block;font-size:9px;color:var(--ink-dim);letter-spacing:.02em;margin-top:1px}
.mr-src button:hover{background:rgba(70,20,26,.85)}
.mr-src button.on{background:linear-gradient(180deg,#ffd97a,var(--gold));border-color:var(--gold)}
.mr-src button.on b,.mr-src button.on .yr{color:#2a0d10}
/* tappable touring-history year map — wrapping tile grid (WCAG-sized targets) */
.mr-map-head{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--ink-dim);margin:2px 0 5px;gap:8px}
#mrMapLabel{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mr-allbtn{flex:none;background:rgba(70,20,26,.6);color:var(--ink);border:1px solid var(--panel-edge);border-radius:8px;font-size:11px;padding:6px 12px;min-height:32px;cursor:pointer}
.mr-allbtn.on{background:var(--gold);color:#2a0d10;border-color:var(--gold)}
.mr-map{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:9px}
.mr-yr{flex:1 1 46px;min-width:46px;min-height:44px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;padding:5px 3px;cursor:pointer;color:var(--ink);border:1px solid var(--panel-edge);border-radius:8px;background:rgba(90,30,20,calc(.12 + var(--heat,0) * .55));transition:transform .1s,box-shadow .15s,background .15s}
.mr-yr:hover{transform:translateY(-1px);box-shadow:0 3px 8px rgba(0,0,0,.35)}
.mr-yr.on{background:linear-gradient(180deg,#ffd97a,var(--gold));border-color:var(--gold);color:#2a0d10;box-shadow:0 0 10px rgba(240,190,90,.6)}
.mr-yr .yl{font-size:12px;font-weight:600;line-height:1;font-variant-numeric:tabular-nums}
.mr-yr .yc{font-size:8.5px;color:var(--ink-dim);letter-spacing:.02em;line-height:1}
.mr-yr.on .yc{color:#6a2c12}
.mr-yr.loading{animation:mrPulse 1s ease-in-out infinite}
@keyframes mrPulse{0%,100%{opacity:.4}50%{opacity:.8}}
.mr-map-err{font-size:11px;color:#e2a08a;padding:6px 4px;font-style:italic}
.mr-results{max-height:150px;overflow:auto;margin-bottom:8px;display:flex;flex-direction:column;gap:2px}
.mr-results:empty{display:none}
.mr-result{display:flex;gap:9px;align-items:baseline;width:100%;text-align:left;background:none;border:0;color:var(--ink-dim);font-family:var(--sans);font-size:11.5px;padding:5px 6px;cursor:pointer;border-radius:6px}
.mr-result:hover{color:var(--ink);background:rgba(70,20,26,.6)}
.mr-result .rd{color:var(--gold);font-variant-numeric:tabular-nums;min-width:74px;flex:none}
.mr-result .rv{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mr-hint{color:var(--ink-dim);font-size:11px;padding:5px 4px;font-style:italic}
.mr-picks{font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-dim);margin:2px 0 5px}
.mr-show{width:100%;background:rgba(40,12,16,.7);color:var(--ink);border:1px solid var(--panel-edge);
border-radius:9px;padding:8px 10px;font-family:var(--serif);font-size:12.5px;cursor:pointer}
.mr-now{margin:12px 0 3px;color:var(--ink);font-family:var(--serif);font-style:italic;font-size:13px;min-height:18px;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mr-sub{color:var(--ink-dim);font-size:10.5px;letter-spacing:.03em;margin-bottom:9px}
.mr-transport{display:flex;align-items:center;gap:8px;margin:8px 0}
.mr-transport .btn{padding:9px 12px;min-height:44px;min-width:44px;justify-content:center}
.mr-transport .play{min-width:54px;justify-content:center;font-size:14px}
.mr-tracks-toggle{margin-left:auto;font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-dim);
background:none;border:0;cursor:pointer;padding:10px 6px;min-height:44px}
.mr-tracks-toggle:hover{color:var(--gold)}
.mr-seek{display:flex;align-items:center;margin:6px 0 2px;padding:9px 0}
.mr-seek input[type=range]{width:100%;height:7px}
.mr-time{font-variant-numeric:tabular-nums;color:var(--ink-dim);font-size:10px;text-align:right}
.mr-tracks{max-height:148px;overflow:auto;margin-top:9px;border-top:1px solid rgba(242,182,74,.16);padding-top:8px;display:none}
.musicroom.show-tracks .mr-tracks{display:block}
.mr-tracks button{display:flex;gap:9px;width:100%;text-align:left;background:none;border:0;color:var(--ink-dim);
font-family:var(--sans);font-size:11.5px;padding:5px 4px;cursor:pointer;border-radius:6px;line-height:1.3}
.mr-tracks button:hover{color:var(--ink);background:rgba(70,20,26,.5)}
.mr-tracks button[aria-current="true"]{color:var(--gold)}
.mr-tracks button .n{color:var(--ink-dim);min-width:20px;font-variant-numeric:tabular-nums;opacity:.7;flex:none}
.mr-foot{margin-top:11px;font-size:10px;line-height:1.55;color:var(--ink-dim)}
.mr-foot a{color:var(--gold);text-decoration:none;border-bottom:1px solid rgba(242,182,74,.4)}
.mr-msg{margin-top:9px;font-size:11px;line-height:1.5;color:var(--ink-dim)}
/* master Controls toggle — panels start collapsed, user opens them */
.chrome-toggle{position:fixed;top:22px;right:26px;z-index:12}
#stage.hide-chrome .dock,
#stage.hide-chrome .cams,
#stage.hide-chrome .chapters,
#stage.hide-chrome .musicroom,
#stage.hide-chrome .label-toggle,
#stage.hide-chrome .museum{display:none!important}
:focus-visible{outline:3px solid var(--focus);outline-offset:2px;border-radius:6px}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
@media (max-width:820px){
.titleplate{max-width:64vw}
.dock{gap:10px;padding:10px 12px}
.group + .group{padding-left:10px}
.field{min-width:74px}
.cams{flex-direction:row;left:12px;bottom:100px}
.cams .btn{min-width:auto;padding:7px 10px}
.label-toggle{bottom:100px;right:12px}
.museum{bottom:148px;right:12px}
.chapters{top:auto;bottom:100px;right:12px;flex-direction:row}
.musicroom{top:auto;bottom:148px;left:12px;right:12px;width:auto}
.musicroom .mr-tracks{max-height:96px}
}
@media (prefers-reduced-motion: reduce){
.museum,.chapters button,.btn,.skip{transition:none}
/* also silence the always-on keyframe loops (live-dot pulse, loading tiles) */
*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important}
.musicroom.playing .live-dot{animation:none}
.mr-yr.loading{animation:none}
}
</style>
</head>
<body>
<a href="#dock" class="skip">Skip to controls</a>
<a href="https://stage.agentabrams.com" class="backlink" id="backlink" aria-label="Back to The Main Stage">← The Main Stage</a>
<main id="stage" class="hide-chrome" aria-label="Scarlet Riverboat Masquerade — an animated visual gallery you can steer with the controls and keyboard">
<h1 class="sr">Scarlet Riverboat Masquerade — an animated generative gallery</h1>
<!-- role removed from <main>: role="application" suppressed normal screen-reader navigation over the native controls + prose -->
<canvas id="sky" aria-hidden="true"></canvas>
<canvas id="scene" aria-hidden="true"></canvas>
<canvas id="orb" aria-hidden="true"></canvas>
<div class="grain" aria-hidden="true"></div>
<div class="titleplate">
<p class="kicker">A Riverboat Listening Room</p>
<h1 aria-hidden="true">Scarlet Riverboat<br>Masquerade</h1>
<p class="chap" id="chapLabel">Chapter I · Departure</p>
</div>
<section class="musicroom" id="musicroom" data-open="true" aria-label="Music room — Grateful Dead Live Music Archive">
<header id="mrToggle" role="button" tabindex="0" aria-expanded="true" aria-controls="mrbody">
<span class="live-dot" aria-hidden="true"></span>
<span>Music Room</span>
<span class="chev" aria-hidden="true">▾</span>
</header>
<div class="mrbody" id="mrbody">
<input class="mr-search" id="mrSearch" type="search" placeholder="🔍 Search the archive — date, city, song…" aria-label="Search the Grateful Dead archive">
<div class="mr-src" id="mrSrc" role="group" aria-label="Choose a music source"></div>
<div class="mr-map-head"><span id="mrMapLabel">Tap a year</span><button type="button" class="mr-allbtn on" id="mrAll">All years</button></div>
<div class="mr-map" id="mrMap" role="group" aria-label="Browse shows by year"></div>
<input type="hidden" id="mrYear" value="">
<div class="mr-results" id="mrResults" role="list"></div>
<div class="mr-picks">Curated nights:</div>
<select class="mr-show" id="mrShow" aria-label="Choose a recording from the Live Music Archive"></select>
<div class="mr-now" id="mrNow">Choose a night, then press play.</div>
<div class="mr-sub" id="mrSub"></div>
<div class="mr-transport">
<button class="btn" id="mrPrev" aria-label="Previous track">⏮</button>
<button class="btn play" id="mrPlay" aria-label="Play" aria-pressed="false">▶</button>
<button class="btn" id="mrNext" aria-label="Next track">⏭</button>
<button class="mr-tracks-toggle" id="mrTracksToggle" aria-expanded="false" aria-controls="mrTracks">Tracks</button>
</div>
<div class="mr-seek"><input type="range" id="mrSeek" min="0" max="1000" value="0" step="1" aria-label="Seek within track"></div>
<div class="mr-time"><span id="mrCur">0:00</span> / <span id="mrDur">0:00</span></div>
<div class="mr-tracks" id="mrTracks" role="list" aria-label="Set list"></div>
<div class="mr-foot" id="mrFoot"></div>
<div class="mr-msg" id="mrMsg" hidden></div>
</div>
<audio id="mrAudio" preload="none"></audio>
</section>
<button class="btn chrome-toggle" id="chromeBtn" aria-pressed="false">☰ Controls</button>
<nav class="chapters" aria-label="Chapters">
<button data-chapter="0" aria-current="true"><span class="dot" aria-hidden="true"></span>I · Departure</button>
<button data-chapter="1"><span class="dot" aria-hidden="true"></span>II · Moon Bend</button>
<button data-chapter="2"><span class="dot" aria-hidden="true"></span>III · Dawn Landing</button>
</nav>
<div class="cams" role="group" aria-label="Camera view">
<button class="btn" data-cam="wide" aria-pressed="true">◈ Wide River</button>
<button class="btn" data-cam="deck" aria-pressed="false">◈ Deck</button>
<button class="btn" data-cam="over" aria-pressed="false">◈ Overhead</button>
</div>
<div class="dock" id="dock" role="group" aria-label="Visual controls">
<div class="group">
<div class="toggle">
<button class="btn" id="playBtn" aria-pressed="true">❚❚ Pause</button>
<button class="btn" id="restartBtn">↻ Restart</button>
</div>
<div class="toggle">
<button class="btn" id="reduceBtn" aria-pressed="false">◐ Reduced Motion</button>
<button class="btn" id="trailBtn" aria-pressed="true">✧ Motion Trails</button>
<button class="btn" id="reactBtn" aria-pressed="false">♫ React to Music</button>
<button class="btn" id="pteroBtn">🦖 Fly Over</button>
<button class="btn" id="venueBtn" aria-pressed="true">🏛 Venue</button>
</div>
</div>
<div class="group">
<div class="field">
<label for="tempo">Visual Tempo <span class="val" id="tempoV">Moderato</span></label>
<input type="range" id="tempo" min="0" max="200" value="100" aria-valuetext="Moderato">
</div>
<div class="field">
<label for="lantern">Lantern Glow <span class="val" id="lanternV">Warm</span></label>
<input type="range" id="lantern" min="0" max="100" value="70" aria-valuetext="Warm">
</div>
<div class="field">
<label for="crowd">Crowd Density <span class="val" id="crowdV">Full</span></label>
<input type="range" id="crowd" min="0" max="100" value="80" aria-valuetext="Full">
</div>
<div class="field">
<label for="animals">Animal Visibility <span class="val" id="animalsV">All</span></label>
<input type="range" id="animals" min="0" max="100" value="100" aria-valuetext="All">
</div>
<div class="field">
<label for="bears">Dancing Bears <span class="val" id="bearsV">3</span></label>
<input type="range" id="bears" min="1" max="10" value="3" step="1" aria-valuetext="3">
</div>
</div>
</div>
<button class="btn label-toggle" id="labelBtn" aria-expanded="false" aria-controls="museum">🏷 Museum Label</button>
<aside class="museum" id="museum" hidden aria-label="Museum label">
<h2>Scarlet Riverboat Masquerade</h2>
<div class="meta">Generative Canvas · Live Music Archive · Authored Visual Rhythm</div>
<p>A fantastical paddlewheel riverboat drifts through a hand-painted crimson canyon while masked revelers fill three decks. This is a <strong>listening room</strong>: press play in the Music Room and a Grateful Dead concert streams from the Internet Archive's <strong>Live Music Archive</strong> while the boat turns around you.</p>
<p><strong>The motion is composed, not measured.</strong> The choreography is an <em>authored visual cadence</em> written into the drawing loop itself — it is deliberately <em>not</em> audio analysis and does not react to the waveform. Set the visual tempo to whatever feels right against the night's playing; the two rhythms — heard and drawn — keep company without one driving the other. <em>(Flip “React to Music” and that changes on purpose: the lanterns, fireflies, and dancers then bloom to a live analysis of the streaming audio.)</em></p>
<p><strong>The recordings are not copied or re-hosted here.</strong> They are community tapes made available through the Grateful Dead collection at the Internet Archive; playback is user-initiated and every set links back to its source item on archive.org.</p>
<p>The <strong>original visual language</strong> reduces each dancer to a weighted silhouette: couples pivot in shared arcs, a linked circle turns about the smokestack, soloists trail scarves, and musicians appear only as abstract shadow-forms — a communal, exploratory spirit rendered without any borrowed logo, emblem, or album art.</p>
<p>On the shore, a cast of creatures each carry a distinct weighted loop — a <strong>trotting fox</strong>, a <strong>slow-stepping heron</strong>, a <strong>squash-and-stretch rabbit</strong>, a <strong>bear conducting fireflies</strong> with two <strong>dancing cubs</strong>, and <strong>songbirds</strong> whose note-bubbles pop to the beat; now and then a <strong>pterodactyl</strong> glides across. The whole cast is drawn as flat, outlined paper-cutout figures over a construction-paper grain. Palette <em>and</em> choreography change across the three chapters as the boat travels from dusk (Departure) through moonrise (Moon Bend) into sunrise (Dawn Landing).</p>
<div class="keys">
<div><b>Space</b> pause / play</div>
<div><b>R</b> restart the journey</div>
<div><b>M</b> reduced motion</div>
<div><b>T</b> motion trails</div>
<div><b>A</b> react to the music</div>
<div><b>P</b> pterodactyl fly-over</div>
<div><b>Click</b> launch a firework</div>
<div><b>1 2 3</b> wide · deck · overhead</div>
<div><b>← →</b> previous / next chapter</div>
<div><b>L</b> this museum label</div>
</div>
</aside>
<p class="sr" aria-live="polite" id="live">Wide river view. Chapter one, Departure.</p>
</main>
<script>
(() => {
"use strict";
const sky = document.getElementById('sky');
const scene = document.getElementById('scene');
const sctx = sky.getContext('2d');
const ctx = scene.getContext('2d');
const live = document.getElementById('live');
// ---------- State ----------
const S = {
t: 0, // authored master clock (visual beats)
playing: true,
reduced: false,
trails: true,
tempo: 1.0, // 0..2
lantern: 0.7,
crowd: 0.8,
animals: 1.0,
bears: 3, // dancing-bear count (1..10)
cam: 'wide', // wide | deck | over
chapter: 0, // 0..2 (target)
chapterMix: 0, // eased 0..2 for palette blend
autoJourney: true, // gentle auto-advance until the visitor picks a chapter
journeyClock: 0, // seconds accrued toward the next auto-advance
W: 0, H: 0, DPR: 1
};
const CHAPTER_SECONDS = 55; // dwell before the boat drifts to the next chapter
// Respect OS reduced-motion at load
if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches){
S.reduced = true;
}
// ...and keep honoring it if the visitor flips the OS setting mid-session
if (window.matchMedia){
const _rmq = matchMedia('(prefers-reduced-motion: reduce)');
const _sync = e => {
S.reduced = e.matches;
const rb = document.getElementById('reduceBtn');
if (rb) rb.setAttribute('aria-pressed', String(S.reduced));
if (S.reduced) ctx.clearRect(0,0,S.W,S.H);
};
if (_rmq.addEventListener) _rmq.addEventListener('change', _sync);
else if (_rmq.addListener) _rmq.addListener(_sync);
}
// ---------- Palettes + per-chapter choreography ----------
const CHAPTERS = [
{ // Departure — dusk crimson/gold, couples lead, easy circle
name:'Departure',
sky:['#2a0a12','#6d1220','#c0432b','#e6893f'],
canyon:['#3a0d16','#7a1a25','#521018'],
river:'#5a1420', riverHi:'#c95a3a',
lantern:'#ffcf6e', dancer:'rgba(20,4,8,0.92)', accent:'#f2b64a',
choreo:{ ringDir:1, ringSpeed:0.45, coupleTurn:1.0, scarf:0.9, ringSway:1.0 }
},
{ // Moon Bend — deep violet/blue night, the linked circle dominates and quickens
name:'Moon Bend',
sky:['#0a0620','#241245','#4a1c52','#7a2b46'],
canyon:['#180a2a','#341a44','#20102e'],
river:'#1a1030', riverHi:'#8a6bd0',
lantern:'#ffe08a', dancer:'rgba(8,4,18,0.92)', accent:'#c9a2ff',
choreo:{ ringDir:1, ringSpeed:0.9, coupleTurn:0.7, scarf:1.15, ringSway:1.4 }
},
{ // Dawn Landing — rose/amber sunrise, soloists + scarves take the floor, circle reverses
name:'Dawn Landing',
sky:['#3a1428','#8a3550','#d97a5a','#f6c88a'],
canyon:['#4a1a2a','#8a3a44','#5c2230'],
river:'#6a2438', riverHi:'#f6b06a',
lantern:'#fff0c0', dancer:'rgba(28,10,14,0.9)', accent:'#ffcf8a',
choreo:{ ringDir:-1, ringSpeed:0.4, coupleTurn:1.25, scarf:1.5, ringSway:0.8 }
}
];
function lerp(a,b,t){return a+(b-a)*t;}
function clamp(v,lo,hi){return Math.max(lo,Math.min(hi,v));}
function hexToRgb(h){h=h.replace('#','');return [parseInt(h.substr(0,2),16),parseInt(h.substr(2,2),16),parseInt(h.substr(4,2),16)];}
function mixHex(h1,h2,t){const a=hexToRgb(h1),b=hexToRgb(h2);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))})`;}
// Darken any 'rgb(r,g,b)' (or hex) color toward black by t (0..1). pal() returns rgb() strings,
// so we must NOT feed those back into hexToRgb — this handles both forms safely.
function shade(color,t){
const m=String(color).match(/\d+/g);
let r,g,b;
if(String(color).charAt(0)==='#'){ const c=hexToRgb(color); r=c[0];g=c[1];b=c[2]; }
else if(m && m.length>=3){ r=+m[0]; g=+m[1]; b=+m[2]; }
else return color;
const k=1-clamp(t,0,1);
const ch=v=>Math.round(clamp(v*k,0,255));
return `rgb(${ch(r)},${ch(g)},${ch(b)})`;
}
function pal(key,i){ // blended palette between integer chapters
const m = S.chapterMix; const lo=Math.floor(m), hi=Math.min(2,lo+1), f=m-lo;
const A=CHAPTERS[lo][key], B=CHAPTERS[hi][key];
if(Array.isArray(A)) return mixHex(A[i],B[i],f);
return mixHex(A,B,f);
}
// Choreography snaps to the nearest chapter (palette blends, steps change crisply)
function CH(){ return CHAPTERS[clamp(Math.round(S.chapterMix),0,2)].choreo; }
// ---- Concert venue backdrops (swap with the streaming show) ----
const VENUES=[
{name:'Fillmore West · San Francisco · 1969', lights:['#ffcf6e','#ff7a4a','#ffd06e']},
{name:'Veneta · Oregon · 1972', lights:['#ffe0a0','#ffbf6a','#fff0c0']},
{name:'Jai-Alai Fronton · Miami · 1974', lights:['#a8c8ff','#ffd0f0','#8fe0ff']},
{name:'Barton Hall · Cornell · 1977', lights:['#ffd0f0','#c9a2ff','#ffe08a']},
{name:'Nassau Coliseum · Uniondale · 1990', lights:['#80e0ff','#ff8ad0','#ffd66e']}
];
let venueOn=true;
// ---- Song-motif layer: original DRAWN animated items keyed to the current track title ----
const MOTIF_MAP=[
[/fire|flame|mountain|burn/, 'flame'],
[/sugar|magnolia|sugaree|begonia|rose|flower|lily|garden|daffodil|scarlet/, 'flower'],
[/rain|snow|cold|storm|winter|hail/, 'snow'],
[/star|moon|dark star|midnight|shine|sunshine|sun/, 'star'],
[/water|river|ripple|ocean|wave|sea|row|creek/, 'water'],
[/wheel|turnin|truck|road|ramble|train|casey|railroad|jones/, 'wheel'],
[/bird|eagle|dove|sparrow|crow|wing/, 'bird']
];
let motifParticles=[], motifClock=0, motifType=null, motifKey=null;
function currentMotifType(){
const tr=((window.SRM&&window.SRM.track)||'').toLowerCase();
if(tr===motifKey) return motifType;
motifKey=tr; motifType='note'; // default: musical notes for every song
for(const m of MOTIF_MAP){ if(m[0].test(tr)){ motifType=m[1]; break; } }
return motifType;
}
function drawMotif(type,s,life,tt,col){
const al=Math.max(0,Math.min(0.9,life));
if(type==='flame'){
const f=Math.sin(tt*12+s)*0.18;
ctx.globalAlpha=al; ctx.fillStyle='#ff8a3a';
ctx.beginPath(); ctx.moveTo(0,s*0.6); ctx.quadraticCurveTo(-s*0.5,0,0,-s*0.75-f*s); ctx.quadraticCurveTo(s*0.5,0,0,s*0.6); ctx.fill();
ctx.fillStyle='#ffd24a'; ctx.beginPath(); ctx.moveTo(0,s*0.4); ctx.quadraticCurveTo(-s*0.25,0,0,-s*0.42-f*s); ctx.quadraticCurveTo(s*0.25,0,0,s*0.4); ctx.fill();
} else if(type==='flower'){
ctx.globalAlpha=al; ctx.rotate(tt*0.6+s); ctx.fillStyle=col||'#ff6aa0';
for(let i=0;i<5;i++){ ctx.rotate(Math.PI*2/5); ctx.beginPath(); ctx.ellipse(0,-s*0.5,s*0.28,s*0.5,0,0,7); ctx.fill(); }
ctx.fillStyle='#ffd24a'; ctx.beginPath(); ctx.arc(0,0,s*0.22,0,7); ctx.fill();
} else if(type==='snow'){
ctx.globalAlpha=al; ctx.strokeStyle='#dff0ff'; ctx.lineWidth=1.6; ctx.lineCap='round'; ctx.rotate(tt*0.8);
for(let i=0;i<6;i++){ ctx.rotate(Math.PI/3); ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(0,-s*0.6); ctx.stroke(); }
} else if(type==='star'){
const tw=0.6+0.4*Math.sin(tt*6+s); ctx.globalAlpha=al*tw; ctx.fillStyle='#ffe89a';
ctx.beginPath(); for(let i=0;i<10;i++){ const a=i*Math.PI/5-Math.PI/2, r=(i%2?s*0.22:s*0.6); ctx.lineTo(Math.cos(a)*r,Math.sin(a)*r); } ctx.closePath(); ctx.fill();
} else if(type==='water'){
ctx.globalAlpha=al; ctx.fillStyle='#7ac0ff';
ctx.beginPath(); ctx.moveTo(0,-s*0.55); ctx.quadraticCurveTo(s*0.5,s*0.2,0,s*0.5); ctx.quadraticCurveTo(-s*0.5,s*0.2,0,-s*0.55); ctx.fill();
} else if(type==='wheel'){
ctx.globalAlpha=al; ctx.rotate(tt*3); ctx.strokeStyle='#f2b64a'; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(0,0,s*0.5,0,7); ctx.stroke();
for(let i=0;i<6;i++){ ctx.rotate(Math.PI/3); ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(0,-s*0.5); ctx.stroke(); }
} else if(type==='bird'){
const flap=Math.sin(tt*8+s)*0.4; ctx.globalAlpha=al; ctx.strokeStyle='#241010'; ctx.lineWidth=2.2; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(-s*0.5,0); ctx.quadraticCurveTo(-s*0.2,-s*0.45-flap*s,0,0); ctx.quadraticCurveTo(s*0.2,-s*0.45-flap*s,s*0.5,0); ctx.stroke();
} else { // musical note
ctx.globalAlpha=al; ctx.fillStyle=col||'#f2b64a';
ctx.beginPath(); ctx.ellipse(-s*0.18,s*0.35,s*0.28,s*0.2,-0.3,0,7); ctx.fill();
ctx.fillRect(s*0.06,-s*0.55,s*0.09,s*0.95);
ctx.beginPath(); ctx.moveTo(s*0.15,-s*0.55); ctx.quadraticCurveTo(s*0.5,-s*0.5,s*0.42,-s*0.18); ctx.lineTo(s*0.15,-s*0.28); ctx.closePath(); ctx.fill();
}
ctx.globalAlpha=1;
}
function drawSongMotifs(dt){
const type=currentMotifType();
if(type && S.playing && !S.reduced){
motifClock+=dt;
if(motifClock>0.55 && motifParticles.length<48){ motifClock=0;
motifParticles.push({x:S.W*(0.06+Math.random()*0.88), y:S.H*0.9, vy:-(18+Math.random()*26), drift:(Math.random()-0.5)*14, life:1, size:16+Math.random()*16, type, col:pal('accent',0)});
}
}
if(!motifParticles.length) return;
for(let i=motifParticles.length-1;i>=0;i--){ const p=motifParticles[i];
p.y+=p.vy*dt; p.x+=p.drift*dt; p.life-=dt*0.16;
if(p.life<=0 || p.y<S.H*0.14){ motifParticles.splice(i,1); continue; }
ctx.save(); ctx.translate(p.x,p.y); drawMotif(p.type,p.size,p.life,S.t,p.col); ctx.restore();
}
}
function drawVenue(){
if(!venueOn) return;
const W=S.W,H=S.H;
const si=(window.SRM&&window.SRM.showIdx);
let vName, vLights;
if(si!=null && si>=0 && si<VENUES.length){ vName=VENUES[si].name; vLights=VENUES[si].lights; }
else { vName=(window.SRM&&window.SRM.venueLabel)||''; vLights=['#ffcf6e','#ff7a4a','#a8c8ff','#c9a2ff']; } // searched show
const V={name:vName, lights:vLights};
// stage-light beams fanning down from the rig, gently sweeping, brightening on the beat
ctx.save(); ctx.globalCompositeOperation='lighter';
for(let i=0;i<4;i++){
const bx=W*(0.16+0.22*i), sweep=Math.sin(S.t*0.5+i*1.3)*W*0.06, tx=bx+sweep;
ctx.globalAlpha=0.045+(S.amp||0)*0.06+(S.pulse||0)*0.05;
ctx.fillStyle=V.lights[i%V.lights.length];
ctx.beginPath(); ctx.moveTo(bx-16,0); ctx.lineTo(bx+16,0); ctx.lineTo(tx+78,H*0.62); ctx.lineTo(tx-78,H*0.62); ctx.closePath(); ctx.fill();
}
ctx.restore(); ctx.globalAlpha=1;
// venue marquee
ctx.save();
const fs=Math.max(13,Math.min(21,W/60));
ctx.font=`italic 600 ${fs}px "Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.shadowColor='rgba(0,0,0,0.65)'; ctx.shadowBlur=14;
ctx.globalAlpha=0.92; ctx.fillStyle=pal('accent',0);
ctx.fillText(V.name, W*0.5, H*0.14);
ctx.restore();
}
// cutout ("South Park") styling: flat fill + a bold dark outline, round joins
const OUTLINE='rgba(8,3,5,0.92)';
function cut(fill,w){ ctx.fillStyle=fill; ctx.strokeStyle=OUTLINE; ctx.lineWidth=w||2.4; ctx.lineJoin='round'; ctx.lineCap='round'; }
function fillOutline(){ ctx.fill(); ctx.stroke(); } // paint the current path as a cutout shape
// ---------- Resize ----------
function resize(){
S.DPR = Math.min(2, window.devicePixelRatio||1);
S.W = window.innerWidth; S.H = window.innerHeight;
[sky,scene].forEach(c=>{
c.width = Math.max(1,Math.round(S.W*S.DPR)); c.height = Math.max(1,Math.round(S.H*S.DPR));
c.getContext('2d').setTransform(S.DPR,0,0,S.DPR,0,0);
});
buildSky();
}
window.addEventListener('resize', resize);
// ---------- Sky (redrawn on chapter change / resize) ----------
let skyDirty = true;
function buildSky(){ skyDirty = true; }
function drawSky(){
const W=S.W,H=S.H;
const g = sctx.createLinearGradient(0,0,0,H*0.72);
g.addColorStop(0, pal('sky',0));
g.addColorStop(0.45, pal('sky',1));
g.addColorStop(0.8, pal('sky',2));
g.addColorStop(1, pal('sky',3));
sctx.clearRect(0,0,W,H);
sctx.fillStyle=g; sctx.fillRect(0,0,W,H*0.74);
// Moon / sun — position drifts with the journey
const cx = lerp(W*0.78, W*0.28, S.chapterMix/2);
const cy = lerp(H*0.18, H*0.30, Math.sin(S.chapterMix/2*Math.PI));
const r = lerp(46, 62, S.chapterMix/2);
const glow = sctx.createRadialGradient(cx,cy,4,cx,cy,r*3.2);
glow.addColorStop(0, pal('lantern',0)); glow.addColorStop(0.15, pal('accent',0));
glow.addColorStop(1,'rgba(0,0,0,0)');
sctx.fillStyle=glow; sctx.beginPath(); sctx.arc(cx,cy,r*3.2,0,7); sctx.fill();
sctx.fillStyle=pal('lantern',0); sctx.beginPath(); sctx.arc(cx,cy,r,0,7); sctx.fill();
// Stars, densest at Moon Bend
const starA = Math.max(0, 1-Math.abs(S.chapterMix-1)) * 0.9;
if(starA>0.02){
sctx.fillStyle=`rgba(255,240,210,${starA})`;
for(let i=0;i<120;i++){
const sx=(i*137.5)%W, sy=((i*89.3)%(H*0.5));
const tw = 0.4+0.6*Math.abs(Math.sin(i*1.7));
sctx.globalAlpha=starA*tw; sctx.fillRect(sx,sy,1.5,1.5);
}
sctx.globalAlpha=1;
}
skyDirty=false;
}
// ---------- Canyon layers (parallax, far -> near) ----------
function canyonSilhouette(baseY, amp, seed, color, offset){
const W=S.W,H=S.H;
ctx.fillStyle=color;
ctx.beginPath();
ctx.moveTo(0,H);
ctx.lineTo(0,baseY);
const step = W/14;
for(let x=0;x<=W+step;x+=step){
const n = Math.sin((x*0.006)+seed) + 0.5*Math.sin((x*0.017)+seed*2.3);
const y = baseY - amp*(0.6+0.4*n) - offset;
ctx.lineTo(x, y);
}
ctx.lineTo(W,H);
ctx.closePath();
ctx.fill();
}
// ---------- Boat transform for the active camera ----------
function boatTransform(){
const W=S.W,H=S.H;
const bob = S.reduced?0:Math.sin(S.t*0.6)*6*(1+(S.amp||0)*1.1);
const sway = S.reduced?0:Math.sin(S.t*0.33)*0.012;
let cx=W*0.5, cy=H*0.60+bob, scale=Math.min(W,H)/620, rot=sway;
if(S.cam==='deck'){ cx=W*0.5; cy=H*0.66+bob; scale=Math.min(W,H)/300; rot=sway*0.6; }
if(S.cam==='over'){ cx=W*0.5; cy=H*0.52; scale=Math.min(W,H)/540; rot=0; }
return {cx,cy,scale,rot,bob};
}
// ---------- Dancers ----------
function drawFigure(x,y,s,phase,type,accent){
const t=S.t, ch=CH();
ctx.save();
ctx.translate(x,y);
ctx.scale(s,s);
let turn=0, lean=0, bob=0, scarf=0, armSwing=0;
const ph = phase;
if(type==='couple'){
turn = Math.sin(t*1.1+ph)*0.9*ch.coupleTurn; // shared pivot, chapter-weighted
lean = Math.sin(t*1.1+ph)*0.12*ch.coupleTurn;
bob = Math.abs(Math.sin(t*1.1+ph))* -3; // rise on the turn
armSwing = 0.7;
} else if(type==='circle'){
turn = Math.sin(t*0.8+ph)*0.35;
bob = Math.abs(Math.sin(t*1.6+ph))*-2.4;
lean = Math.sin(t*0.8+ph)*0.05;
armSwing=0.4;
} else if(type==='solo'){
turn = (t*1.8+ph)%(Math.PI*2); // continuous spin
scarf = 1; bob = Math.abs(Math.sin(t*2.2+ph))*-2;
armSwing=1.0;
} else if(type==='musician'){
bob = Math.sin(t*1.4+ph)*-1.6;
armSwing = 0.5+0.3*Math.sin(t*2+ph);
}
ctx.rotate(lean);
ctx.translate(0,bob*(1+(S.amp||0)*0.7)); // dancers bounce harder on louder passages when reactive
// scarf trail for soloists — length/opacity swell across chapters
if(type==='solo' && scarf){
ctx.save();
ctx.rotate(turn);
ctx.strokeStyle=accent; ctx.globalAlpha=0.5*ch.scarf; ctx.lineWidth=2.2;
ctx.beginPath();
const seg = Math.round(10*ch.scarf);
for(let i=0;i<seg;i++){
const a=i*0.5, rr=8+i*2.4;
const px=Math.cos(a+t*3)* rr, py=-14+Math.sin(a+t*3)*rr*0.4;
i===0?ctx.moveTo(px,py):ctx.lineTo(px,py);
}
ctx.stroke(); ctx.globalAlpha=1; ctx.restore();
}
ctx.rotate(turn*0.28);
const body='rgba(74,30,34,0.98)'; // flat cutout costume tone
// head (with a mask mark) — flat fill + bold outline
cut(body,1.4);
ctx.beginPath(); ctx.arc(0,-22,4.6,0,7); fillOutline();
ctx.fillStyle=accent; ctx.globalAlpha=0.9; ctx.fillRect(-3.4,-24,6.8,2.4); ctx.globalAlpha=1; // mask band
// torso (tapered) — cutout
cut(body,1.4);
ctx.beginPath();
ctx.moveTo(-4,-18); ctx.quadraticCurveTo(0,-6,-3,4);
ctx.lineTo(3,4); ctx.quadraticCurveTo(0,-6,4,-18);
ctx.closePath(); fillOutline();
// arms — swing conveys weight/effort (bold outline limbs)
const sw = Math.sin(t*(type==='musician'?2:1.4)+ph)*armSwing;
ctx.lineWidth=2.8; ctx.strokeStyle=OUTLINE; ctx.lineCap='round';
ctx.beginPath();
ctx.moveTo(-3,-15); ctx.lineTo(-9-sw*4,-10+sw*3);
ctx.moveTo(3,-15); ctx.lineTo(9-sw*4,-10-sw*3);
ctx.stroke();
// legs — bent-knee push (the source of the bob)
const legPush = Math.abs(Math.sin(t*(type==='solo'?2.2:1.1)+ph));
ctx.beginPath();
ctx.moveTo(-2,3); ctx.lineTo(-3-legPush*1.5, 16);
ctx.moveTo(2,3); ctx.lineTo(3+ (1-legPush)*1.5, 16);
ctx.stroke();
// musician's instrument = abstract shadow bar
if(type==='musician'){
ctx.strokeStyle=accent; ctx.globalAlpha=0.5; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(4,-14); ctx.lineTo(14,-18); ctx.stroke();
ctx.globalAlpha=1;
}
ctx.restore();
}
// Little lantern with glow
function lantern(x,y,s,glow,color){
if(glow>0.02){
const g=ctx.createRadialGradient(x,y,1,x,y,26*s*(0.5+glow));
g.addColorStop(0,color); g.addColorStop(0.4,`rgba(255,190,90,${0.35*glow})`);
g.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(x,y,26*s*(0.5+glow),0,7); ctx.fill();
}
ctx.fillStyle=color; ctx.beginPath(); ctx.arc(x,y,2.6*s,0,7); ctx.fill();
}
// ---------- The Riverboat ----------
function drawBoat(){
const {cx,cy,scale,rot}=boatTransform();
const accent=pal('accent',0), lan=pal('lantern',0);
const glow=S.lantern*(1+(S.amp||0)*0.9); // lanterns bloom on the beat when reactive
ctx.save();
ctx.translate(cx,cy); ctx.rotate(rot); ctx.scale(scale,scale);
if(S.cam==='over'){ drawBoatOverhead(accent,lan,glow); ctx.restore(); return; }
// ----- hull -----
cut('rgba(52,16,22,0.98)',3.5);
ctx.beginPath();
ctx.moveTo(-230,40);
ctx.quadraticCurveTo(-260,10,-210,4);
ctx.lineTo(210,4);
ctx.quadraticCurveTo(262,10,232,40);
ctx.quadraticCurveTo(0,78,-230,40);
ctx.closePath(); fillOutline();
// waterline highlight
ctx.strokeStyle=pal('riverHi',0); ctx.globalAlpha=0.5; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(-215,42); ctx.quadraticCurveTo(0,72,215,42); ctx.stroke();
ctx.globalAlpha=1;
// ----- three decks -----
const deckDefs=[
{y:4, w:200, h:-34, n:9}, // lower
{y:-30,w:160, h:-30, n:7}, // middle
{y:-60,w:120, h:-26, n:5} // upper
];
deckDefs.forEach((d,di)=>{
ctx.fillStyle='rgba(72,26,30,0.97)';
ctx.fillRect(-d.w, d.y+d.h, d.w*2, 6);
ctx.strokeStyle=OUTLINE; ctx.lineWidth=1.6; ctx.strokeRect(-d.w, d.y+d.h, d.w*2, 6);
ctx.strokeStyle='rgba(70,24,28,0.9)'; ctx.lineWidth=2;
for(let px=-d.w; px<=d.w; px+=d.w/6){
ctx.beginPath(); ctx.moveTo(px,d.y+d.h); ctx.lineTo(px,d.y+d.h+6); ctx.stroke();
}
const nl = 6;
for(let i=0;i<=nl;i++){
const lx=-d.w + (2*d.w)*(i/nl);
const flick = S.reduced?1:(0.85+0.15*Math.sin(S.t*3+i+di)+(S.pulse||0)*0.7); // lamps throb on the beat
lantern(lx, d.y+d.h-4, 1, glow*flick, lan);
}
});
// ----- smokestacks -----
[-40,40].forEach(sx=>{
ctx.fillStyle='rgba(46,14,20,0.98)'; ctx.strokeStyle=OUTLINE; ctx.lineWidth=2.4;
ctx.fillRect(sx-7,-140,14,86); ctx.strokeRect(sx-7,-140,14,86);
ctx.fillStyle=accent; ctx.globalAlpha=0.7; ctx.fillRect(sx-9,-142,18,6); ctx.globalAlpha=1;
ctx.strokeRect(sx-9,-142,18,6);
});
// smoke drift
if(!S.reduced){
ctx.fillStyle=`rgba(200,140,120,0.10)`;
for(let i=0;i<6;i++){
const t=S.t*0.5+i; const sx=-40+Math.sin(t)*8;
ctx.beginPath(); ctx.arc(sx-Math.sin(t*0.4)*30, -150-i*16-(S.t*4%20), 8+i*2,0,7); ctx.fill();
}
}
// ----- pilot house -----
ctx.fillStyle='rgba(64,22,26,0.98)'; ctx.strokeStyle=OUTLINE; ctx.lineWidth=2.4;
ctx.fillRect(-30,-104,60,30); ctx.strokeRect(-30,-104,60,30);
ctx.fillStyle=accent; ctx.globalAlpha=0.4; ctx.fillRect(-24,-98,48,10); ctx.globalAlpha=1;
// ----- paddlewheel (right side) -----
drawPaddlewheel(232,26,accent);
// ----- populate decks with dancers -----
populateDecks(deckDefs,accent);
ctx.restore();
}
function drawPaddlewheel(x,y,accent){
const spin = S.reduced? 0 : S.t*2.1;
ctx.save(); ctx.translate(x,y);
ctx.strokeStyle='rgba(60,20,24,0.9)'; ctx.lineWidth=3;
ctx.beginPath(); ctx.arc(0,0,34,0,7); ctx.stroke();
ctx.save(); ctx.rotate(spin);
ctx.strokeStyle=accent; ctx.lineWidth=2.4; ctx.globalAlpha=0.85;
for(let i=0;i<8;i++){
ctx.rotate(Math.PI/4);
ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(0,-32); ctx.stroke();
ctx.fillStyle='rgba(50,16,20,0.9)';
ctx.fillRect(-9,-34,18,7);
}
ctx.restore();
ctx.globalAlpha=1;
ctx.fillStyle=accent; ctx.beginPath(); ctx.arc(0,0,4,0,7); ctx.fill();
if(!S.reduced){
ctx.fillStyle='rgba(240,200,170,0.18)';
for(let i=0;i<5;i++){ const a=(S.t*3+i)%1; ctx.beginPath(); ctx.arc(6+a*24,34+Math.sin(i)*3,3-a*2,0,7); ctx.fill();}
}
ctx.restore();
}
function populateDecks(deckDefs, accent){
const density = S.crowd, ch = CH();
// Deck 0 (lower): couples turning beneath lanterns
const d0=deckDefs[0];
const nCouples = Math.round(lerp(0,5,density));
for(let i=0;i<nCouples;i++){
const gx = -d0.w+30 + (2*d0.w-60)*(nCouples<=1?0.5:i/(nCouples-1));
const gap = 9+Math.sin(S.t*1.1+i)*3;
drawFigure(gx-gap, d0.y+d0.h-2, 0.62, i*1.3, 'couple', accent);
drawFigure(gx+gap, d0.y+d0.h-2, 0.62, i*1.3+Math.PI, 'couple', accent);
}
// Deck 1 (middle): linked circle around the smokestack + a soloist
const d1=deckDefs[1];
const ringN = Math.round(lerp(3,8,density));
const ringR = 46, ringSpin = S.reduced?0:S.t*0.5*ch.ringSpeed*ch.ringDir;
// linking ribbon between the circle dancers
ctx.strokeStyle=accent; ctx.globalAlpha=0.28; ctx.lineWidth=1.6;
ctx.beginPath();
for(let i=0;i<=ringN;i++){
const a=(i/ringN)*Math.PI*2+ringSpin;
const rx=Math.cos(a)*ringR, ry=d1.y+d1.h-2+Math.sin(a)*6*ch.ringSway;
i===0?ctx.moveTo(rx,ry):ctx.lineTo(rx,ry);
}
ctx.stroke(); ctx.globalAlpha=1;
for(let i=0;i<ringN;i++){
const a = (i/ringN)*Math.PI*2 + ringSpin;
const rx = Math.cos(a)*ringR, rz=Math.sin(a)*0.42;
const fx = rx, fy = d1.y+d1.h-2 + Math.sin(a)*6*ch.ringSway;
drawFigure(fx, fy, 0.5*(0.8+rz*0.2+0.2), i, 'circle', accent);
}
if(density>0.3) drawFigure(d1.w-30, d1.y+d1.h-2, 0.6, 2.0, 'solo', accent);
// Deck 2 (upper): musicians as abstract silhouettes + a soloist
const d2=deckDefs[2];
const nMus = Math.round(lerp(1,4,density));
for(let i=0;i<nMus;i++){
const gx=-d2.w+26 + (2*d2.w-52)*(i/Math.max(1,nMus));
drawFigure(gx, d2.y+d2.h-2, 0.55, i*2.1, 'musician', accent);
}
if(density>0.5) drawFigure(0, d2.y+d2.h-2, 0.58, 1.1, 'solo', accent);
// dancing bears join the revelers up on deck
if(density>0.3){
bearCub(-d0.w+52, d0.y+d0.h-2, 0.42, S.t, 0.5, BEAR_COLORS[0]);
bearCub(d0.w-52, d0.y+d0.h-2, 0.42, S.t, 2.2, BEAR_COLORS[3]);
bearCub(0, d1.y+d1.h-2, 0.40, S.t, 4.0, BEAR_COLORS[2]);
}
}
// Overhead camera: top-down of the circle around the smokestack
function drawBoatOverhead(accent,lan,glow){
const ch=CH();
ctx.fillStyle='rgba(38,12,16,0.92)';
ctx.beginPath(); ctx.ellipse(0,0,190,110,0,0,7); ctx.fill();
ctx.strokeStyle='rgba(70,24,28,0.8)'; ctx.lineWidth=3;
ctx.beginPath(); ctx.ellipse(0,0,190,110,0,0,7); ctx.stroke();
// bow marker
ctx.fillStyle=accent; ctx.globalAlpha=0.5;
ctx.beginPath(); ctx.moveTo(0,-118); ctx.lineTo(-10,-102); ctx.lineTo(10,-102); ctx.closePath(); ctx.fill();
ctx.globalAlpha=1;
// central smokestack (top)
ctx.fillStyle='rgba(20,6,10,0.98)';
ctx.beginPath(); ctx.arc(0,0,16,0,7); ctx.fill();
ctx.strokeStyle=accent; ctx.globalAlpha=0.6; ctx.beginPath(); ctx.arc(0,0,16,0,7); ctx.stroke(); ctx.globalAlpha=1;
// linked circle rotating around it — the marquee choreography, seen from above
const ringN=Math.round(lerp(4,12,S.crowd)); const R=78; const spin=S.reduced?0:S.t*0.55*ch.ringSpeed*ch.ringDir;
ctx.strokeStyle=accent; ctx.globalAlpha=0.35; ctx.lineWidth=2;
ctx.beginPath();
for(let i=0;i<=ringN;i++){const a=(i/ringN)*Math.PI*2+spin; const x=Math.cos(a)*R,y=Math.sin(a)*R*0.62; i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);}
ctx.closePath(); ctx.stroke(); ctx.globalAlpha=1;
for(let i=0;i<ringN;i++){
const a=(i/ringN)*Math.PI*2+spin;
const x=Math.cos(a)*R, y=Math.sin(a)*R*0.62;
ctx.save(); ctx.translate(x,y);
ctx.fillStyle=pal('dancer',0); ctx.beginPath(); ctx.arc(0,0,5,0,7); ctx.fill();
ctx.fillStyle=accent; ctx.globalAlpha=0.8; ctx.fillRect(-4,-1.5,8,3); ctx.globalAlpha=1;
ctx.restore();
lantern(Math.cos(a)*(R+22),Math.sin(a)*(R+22)*0.62,0.7,glow*0.8,lan);
}
// couples as small paired dots on the outer deck
const nc=Math.round(lerp(0,6,S.crowd));
for(let i=0;i<nc;i++){
const a=(i/Math.max(1,nc))*Math.PI*2 - spin*0.6; const r=150;
const x=Math.cos(a)*r, y=Math.sin(a)*r*0.6;
const g=6+Math.sin(S.t*1.1+i)*3;
ctx.fillStyle=pal('dancer',0);
ctx.beginPath(); ctx.arc(x-g,y,3.4,0,7); ctx.fill();
ctx.beginPath(); ctx.arc(x+g,y,3.4,0,7); ctx.fill();
}
}
// ---------- Shoreline animals ----------
// ---- roaming / tempo helpers: animals travel across the scene and step to the beat ----
// _tempoMul: 1.0 ≈ neutral. Detected music BPM drives it when reacting, else the Visual Tempo slider.
function _tempoMul(){
if(window.SRM && window.SRM.react && window.SRM.bpm>40) return clamp(window.SRM.bpm/110, 0.7, 2.2);
return 0.7 + S.tempo*0.65; // 0.7..2.0 from the slider
}
// _roam: a horizontal position that walks across the screen and wraps off-screen; returns x + facing dir.
// Derived from S.t (which already integrates tempo) so locomotion speeds up/slows with the music.
function _roam(seed, speed){
const dir = (seed%2===0) ? 1 : -1;
const span = S.W*1.30;
const p = (((S.t*speed*0.06*_tempoMul()) + seed*0.31) % 1 + 1) % 1;
const x = dir>0 ? (-S.W*0.15 + p*span) : (S.W*1.15 - p*span);
return {x, dir};
}
// every animal gets a small upward HOP on each detected beat when reacting to the music
function _beatHop(){ return (window.SRM && window.SRM.react) ? (S.pulse||0)*10 : 0; }
// place a creature at its roamed position, facing its travel direction, hopping on the beat
function _place(fn, roam, y, s, accent, hop){
ctx.save();
ctx.translate(roam.x, y - hop);
ctx.scale(roam.dir, 1); // flip to face the way it's walking/flying
fn(0, 0, s, S.t, accent);
ctx.restore();
}
function drawAnimals(){
if(S.animals<=0.02) return;
const W=S.W,H=S.H;
const baseY=H*0.78;
const accent=pal('accent',0);
ctx.save();
ctx.globalAlpha=Math.min(1,S.animals);
// shore band
ctx.fillStyle=pal('canyon',0);
ctx.beginPath(); ctx.moveTo(0,baseY);
for(let x=0;x<=W;x+=40){ctx.lineTo(x,baseY+Math.sin(x*0.02)*6);}
ctx.lineTo(W,H); ctx.lineTo(0,H); ctx.fill();
const t=S.t;
const scaleA = S.cam==='deck'?1.15:1;
const hop=_beatHop();
// walkers now ROAM the shore (each faces its direction of travel, hops on the beat)
_place(fox, _roam(0,1.00), baseY-6, 1*scaleA, accent, hop);
_place(heron, _roam(3,0.55), baseY-4, 1*scaleA, accent, hop*0.6);
_place(rabbit, _roam(4,1.35), baseY-4, 1*scaleA, accent, hop*1.4);
_place(bearAndFireflies, _roam(1,0.42), baseY-2, 1.1*scaleA, accent, hop*0.8);
if(!S.playing && !S.reduced){ drawIdleBackflipBear(scaleA, baseY); } else { drawBearCubs(t, scaleA, baseY); } // idle: a single bear backflips across; playing: the troupe dances + sidesteps to tempo
drawSongbirds(t, scaleA, baseY); // songbirds now FLY around the sky
ctx.restore();
}
function drawIdleBackflipBear(scaleA, baseY){
const W=S.W;
const cross=8.5, gap=1.4, span=cross+gap;
const tt=S.idleT||0; const idx=Math.floor(tt/span); const local=tt%span;
if(local>cross) return;
const prog=local/cross;
const FUR=BEAR_COLORS[idx%BEAR_COLORS.length];
const size=1.55*scaleA, margin=64;
const x=-margin + prog*(W+margin*2);
const flips=3, fp=(prog*flips)%1;
const lift=Math.sin(fp*Math.PI)*54*scaleA;
const spin=-(prog*flips)*Math.PI*2;
ctx.save(); ctx.translate(x,(baseY+8)-lift); ctx.rotate(spin);
bearCub(0,0,size,(S.idleT||0)*0.8,idx*1.3,FUR); ctx.restore();
}
function fox(x,y,s,t,accent){
const gait=Math.sin(t*3.2);
const bob=Math.abs(Math.sin(t*3.2))*-4;
ctx.save(); ctx.translate(x + Math.sin(t*0.4)*30, y+bob); ctx.scale(s,s);
cut('rgba(150,58,42,0.98)');
ctx.beginPath(); ctx.ellipse(0,-10,15,7,0,0,7); fillOutline();
ctx.beginPath(); ctx.moveTo(12,-14); ctx.lineTo(24,-16); ctx.lineTo(13,-8); ctx.closePath(); fillOutline();
ctx.beginPath(); ctx.moveTo(14,-18); ctx.lineTo(17,-24); ctx.lineTo(19,-17);ctx.closePath(); fillOutline();
ctx.strokeStyle=accent; ctx.lineWidth=5; ctx.lineCap='round'; ctx.globalAlpha=0.8;
ctx.beginPath(); ctx.moveTo(-14,-10); ctx.quadraticCurveTo(-26,-14-gait*5,-30,-4-gait*3); ctx.stroke();
ctx.globalAlpha=1;
ctx.strokeStyle='rgba(30,8,12,0.95)'; ctx.lineWidth=2.4;
const lg=gait*4;
ctx.beginPath();
ctx.moveTo(-8,-4); ctx.lineTo(-8-lg,3);
ctx.moveTo(8,-4); ctx.lineTo(8+lg,3);
ctx.moveTo(-4,-4); ctx.lineTo(-4+lg,3);
ctx.moveTo(4,-4); ctx.lineTo(4-lg,3);
ctx.stroke();
ctx.restore();
}
function heron(x,y,s,t,accent){
const step=Math.sin(t*0.9);
const wing=Math.sin(t*1.2)*0.5;
ctx.save(); ctx.translate(x + Math.sin(t*0.25)*18, y); ctx.scale(s,s);
ctx.strokeStyle='rgba(24,8,14,0.95)'; ctx.fillStyle='rgba(24,8,14,0.95)';
ctx.lineWidth=2;
ctx.beginPath();
ctx.moveTo(-2,-18); ctx.lineTo(-4,2+ (step>0?0:step*8));
ctx.moveTo(3,-18); ctx.lineTo(5,2+ (step>0?step*8:0));
ctx.stroke();
cut('rgba(96,44,66,0.98)');
ctx.beginPath(); ctx.ellipse(0,-24,10,7,-0.2,0,7); fillOutline();
ctx.save(); ctx.translate(0,-26); ctx.rotate(wing*0.6);
ctx.beginPath(); ctx.moveTo(0,0); ctx.quadraticCurveTo(-14,-4,-20,4); ctx.quadraticCurveTo(-10,2,0,3); ctx.closePath(); fillOutline();
ctx.restore();
ctx.lineWidth=2.4;
ctx.beginPath(); ctx.moveTo(6,-28);
ctx.quadraticCurveTo(16,-34,12,-46); ctx.stroke();
ctx.beginPath(); ctx.arc(12,-48,3,0,7); ctx.fill();
ctx.strokeStyle=accent; ctx.beginPath(); ctx.moveTo(14,-48); ctx.lineTo(24,-49); ctx.stroke();
ctx.restore();
}
function rabbit(x,y,s,t,accent){
const cyc=(t*1.6)%(Math.PI*2);
const air=Math.max(0,Math.sin(cyc));
const stretch=1+air*0.4, squash=1-Math.max(0,-Math.sin(cyc))*0.35;
const hopY=-air*9; // low, grounded hop — stays on the shore
ctx.save();
ctx.translate(x, y+hopY); // roam is handled by _place(); keep only the vertical hop
ctx.scale(s*(2-stretch)*0.9 + s*0.1, s*stretch*squash);
cut('rgba(120,66,60,0.98)',2);
ctx.beginPath(); ctx.ellipse(0,-8,9,7,0,0,7); fillOutline();
ctx.beginPath(); ctx.arc(8,-12,4.5,0,7); fillOutline();
const ear = air*0.8;
ctx.save(); ctx.translate(8,-15); ctx.rotate(ear);
ctx.beginPath(); ctx.ellipse(0,-6,2,6,0.2,0,7); ctx.fill();
ctx.beginPath(); ctx.ellipse(4,-6,2,6,0.4,0,7); ctx.fill();
ctx.restore();
ctx.fillStyle=accent; ctx.globalAlpha=0.85;
ctx.beginPath(); ctx.arc(-9,-8,3,0,7); ctx.fill(); ctx.globalAlpha=1;
ctx.fillStyle='rgba(34,10,14,0.95)';
ctx.beginPath(); ctx.ellipse(-3,-2+air*2,4,2,air*0.6,0,7); ctx.fill();
ctx.restore();
}
function bearAndFireflies(x,y,s,t,accent){
const wob=Math.sin(t*0.35)*3;
ctx.save(); ctx.translate(x+wob,y); ctx.scale(s,s);
cut('rgba(74,34,34,0.98)',2.8);
ctx.beginPath(); ctx.ellipse(0,-16,18,20,0,0,7); fillOutline();
ctx.beginPath(); ctx.ellipse(-14,-2,10,8,0,0,7); fillOutline();
ctx.beginPath(); ctx.ellipse(14,-2,10,8,0,0,7); fillOutline();
ctx.beginPath(); ctx.arc(0,-36,11,0,7); fillOutline();
ctx.beginPath(); ctx.arc(-9,-45,4,0,7); fillOutline();
ctx.beginPath(); ctx.arc(9,-45,4,0,7); fillOutline();
ctx.strokeStyle='rgba(20,6,10,0.97)'; ctx.lineWidth=6; ctx.lineCap='round';
const ax=Math.sin(t*0.7)*16, ay=-Math.abs(Math.cos(t*0.7))*10;
ctx.beginPath(); ctx.moveTo(6,-24); ctx.quadraticCurveTo(16,-34,18+ax,-40+ay); ctx.stroke();
ctx.strokeStyle=accent; ctx.lineWidth=2; ctx.globalAlpha=0.8;
ctx.beginPath(); ctx.moveTo(18+ax,-40+ay); ctx.lineTo(18+ax+6,-44+ay); ctx.stroke();
ctx.globalAlpha=1;
ctx.restore();
// fireflies respond to the conducting motion (authored, not audio)
const n=14;
for(let i=0;i<n;i++){
const ph=i*0.7;
const orbit=30+ (i%5)*10;
const a=t*0.7*(0.6+ (i%3)*0.2) + ph;
const fx=x + Math.cos(a)*orbit + Math.sin(t*0.7)*14;
const fy=y-40 + Math.sin(a*1.3)*orbit*0.5 - (i%4)*6;
const blink=Math.min(1,(0.35+0.65*Math.abs(Math.sin(t*3+ph)))*(1+(S.amp||0)*1.3));
const g=ctx.createRadialGradient(fx,fy,0,fx,fy,7);
g.addColorStop(0,`rgba(255,230,140,${blink})`); g.addColorStop(1,'rgba(255,200,90,0)');
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(fx,fy,7,0,7); ctx.fill();
}
}
// two bear cubs dancing beside the conductor — bob + rock, bouncing harder on the beat when reactive
// articulated "puppet-style" dancing bear — jointed limbs swing, hips sway, head bobs; bigger on the beat
function bearCub(x,y,s,t,ph,FUR){
const beat=(S.pulse||0), amp=(S.amp||0), e=1+beat*0.9+amp*0.5;
const sway=Math.sin(t*2.8+ph)*0.30*e; // big lively hip sway
const bob=Math.abs(Math.sin(t*5.4+ph))*(4.5+amp*4) + beat*15; // always grooving; big HOP on each beat when reacting
const step=Math.sin(t*2.8+ph); // alternating dance steps
const armL=Math.sin(t*3.6+ph), armR=Math.sin(t*3.6+ph+Math.PI); // hands wave overhead, alternating
FUR = FUR || 'rgba(84,40,38,0.98)';
ctx.save(); ctx.translate(x,y-bob); ctx.scale(s*1.2,s*1.2); ctx.rotate(sway);
function limb(px,py,ang,len,w,foot){ // a jointed, outlined limb
ctx.save(); ctx.translate(px,py); ctx.rotate(ang); ctx.lineCap='round';
ctx.strokeStyle=OUTLINE; ctx.lineWidth=w+2.4; ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(0,len); ctx.stroke();
ctx.strokeStyle=FUR; ctx.lineWidth=w; ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(0,len); ctx.stroke();
if(foot){ cut(FUR,1.8); ctx.beginPath(); ctx.ellipse(0,len,3.2,2,0,0,7); fillOutline(); }
ctx.restore();
}
limb(-3.5,-3, 0.12+step*0.7, 12, 6.5, true); // legs — big alternating dance steps
limb(3.5,-3, 0.12-step*0.7, 12, 6.5, true);
cut(FUR,2.6); ctx.beginPath(); ctx.ellipse(0,-12,8.5,11,0,0,7); fillOutline(); // body
ctx.fillStyle='rgba(255,255,255,0.22)'; ctx.beginPath(); ctx.ellipse(0,-10,4.5,6,0,0,7); ctx.fill(); // belly highlight
limb(-7,-18, 2.3+armL*0.7+beat*0.45, 11, 5, true); // arms overhead, big waves + thrust up on the beat
limb(7,-18, -2.3+armR*0.7-beat*0.45, 11, 5, true);
cut(FUR,2.4); ctx.beginPath(); ctx.arc(0,-30,7,0,7); fillOutline(); // head
ctx.beginPath(); ctx.arc(-6,-36,3,0,7); fillOutline(); // ears
ctx.beginPath(); ctx.arc(6,-36,3,0,7); fillOutline();
cut('rgba(150,96,74,0.98)',1.5); ctx.beginPath(); ctx.ellipse(0,-27,3.6,2.7,0,0,7); fillOutline(); // muzzle
ctx.fillStyle=OUTLINE; ctx.beginPath(); ctx.arc(0,-29,1.1,0,7); ctx.fill(); // nose
ctx.restore();
}
// a user-sized colorful dancing-bear troupe (1..10), auto-distributed + scaled across the shore (our own design, not the GD trademark)
const BEAR_COLORS=['rgba(206,66,116,0.98)','rgba(232,160,52,0.98)','rgba(72,172,150,0.98)','rgba(124,110,224,0.98)','rgba(226,96,60,0.98)','rgba(96,182,96,0.98)','rgba(232,120,182,0.98)','rgba(82,158,224,0.98)','rgba(214,182,60,0.98)','rgba(154,92,204,0.98)'];
function drawBearCubs(t,scaleA,baseY){
const W=S.W, N=Math.max(1,Math.min(10,Math.round(S.bears)));
const margin=W*0.05, span=W-2*margin;
const size=Math.max(0.9, Math.min(2.1, 13/(N+2.5)))*scaleA; // fewer bears bigger, more bears smaller to fit
const mul=_tempoMul();
const groupDrift=Math.sin(t*0.22*mul)*W*0.05; // the whole troupe sways across the shore to tempo
for(let i=0;i<N;i++){
const home = (N===1) ? W*0.16 : margin + span*(i/(N-1));
const step = Math.sin(t*2.0*mul + i*1.3)*(W*0.022+8); // each cub sidesteps/boogies in time
const x = Math.max(margin*0.4, Math.min(W-margin*0.4, home + groupDrift + step));
const yj = ((i*53)%9)-4; // slight stagger so the row isn't a flat line
bearCub(x, baseY+8+yj, size, t, i*1.3, BEAR_COLORS[i%BEAR_COLORS.length]);
}
}
// shoreline songbirds that "sing" the music — note-bubbles pop on the beat (♪♫♬, never lyrics)
const NOTES=['♪','♫','♬'];
function songbird(x,y,s,t,ph){
const bob=Math.sin(t*3+ph)*1.4;
ctx.save(); ctx.translate(x,y+bob); ctx.scale(s,s);
cut('rgba(158,74,56,0.98)',1.8);
ctx.beginPath(); ctx.ellipse(0,0,6,5,0,0,7); fillOutline(); // body
ctx.beginPath(); ctx.arc(4,-5,3,0,7); fillOutline(); // head
ctx.beginPath(); ctx.moveTo(-5,0); ctx.lineTo(-12,-3); ctx.lineTo(-6,3); ctx.closePath(); fillOutline(); // tail
ctx.fillStyle='rgba(242,182,74,0.95)'; // beak
ctx.beginPath(); ctx.moveTo(6,-5); ctx.lineTo(11,-4); ctx.lineTo(6,-3); ctx.closePath(); ctx.fill();
ctx.restore();
// song bubble
const beat=(S.pulse||0), amp=(S.amp||0), reacting=!!(window.SRM&&window.SRM.react);
let show,pop;
if(reacting){ show = amp>0.24||beat>0.15; pop = 0.7+beat*1.0+amp*0.3; }
else { const c=Math.sin(t*1.7+ph); show=c>-0.5; pop=0.82+Math.max(0,c)*0.4; }
if(show){
ctx.save(); ctx.translate(x+10*s, y-17*s); ctx.scale(pop*s*0.95, pop*s*0.95);
cut('rgba(244,231,214,0.96)',1.6);
ctx.beginPath(); ctx.ellipse(0,0,11,8,0,0,7); fillOutline();
ctx.beginPath(); ctx.moveTo(-4,6); ctx.lineTo(-9,12); ctx.lineTo(-1,7); ctx.closePath(); fillOutline(); // tail toward bird
ctx.fillStyle='rgba(30,10,14,0.95)';
ctx.font='bold 12px sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(NOTES[Math.abs((t*2+ph)|0)%3], 0, 0);
ctx.restore();
}
}
function drawSongbirds(t,scaleA,baseY){
const W=S.W, H=S.H, N=5, mul=_tempoMul(); // songbirds now FLY around the sky, facing their heading
for(let i=0;i<N;i++){
const r=_roam(10+i, 0.85+i*0.14);
const lane=0.16+0.30*(0.5+0.5*Math.sin(i*1.7)); // spread across sky lanes
const y=H*lane + Math.sin(t*2.2*mul + i*1.5)*24; // rise/dip flight-path, cadence follows tempo
ctx.save(); ctx.translate(r.x, y); ctx.scale(r.dir,1);
songbird(0, 0, (0.85+0.2*Math.sin(i))*scaleA, t, i*1.3);
ctx.restore();
}
}
// ---------- River foreground with parallax reflections ----------
function drawRiver(){
const W=S.W,H=S.H;
const top=H*0.72;
const g=ctx.createLinearGradient(0,top,0,H);
g.addColorStop(0, pal('river',0));
g.addColorStop(1, shade(pal('river',0),0.5));
ctx.fillStyle=g; ctx.fillRect(0,top,W,H-top);
const hi=pal('riverHi',0);
ctx.strokeStyle=hi;
for(let layer=0;layer<3;layer++){
const alpha=0.16-layer*0.04, speed=(layer+1)*0.5;
ctx.globalAlpha=alpha; ctx.lineWidth=1.5+layer;
for(let i=0;i<12;i++){
const y=top+ (H-top)*((i+ (S.t*speed*0.02*(layer+1))%1)/12);
const amp=(y-top)*0.06;
ctx.beginPath();
for(let x=0;x<=W;x+=24){
const yy=y+Math.sin((x*0.02)+S.t*speed+layer)*amp*(S.reduced?0.3:1);
x===0?ctx.moveTo(x,yy):ctx.lineTo(x,yy);
}
ctx.stroke();
}
}
ctx.globalAlpha=1;
if(S.lantern>0.05 && S.cam!=='over'){
const {cx}=boatTransform();
const rg=ctx.createRadialGradient(cx,top+30,4,cx,top+30,120);
rg.addColorStop(0,`rgba(255,190,90,${Math.min(0.6,0.22*S.lantern*(1+(S.amp||0)))})`); rg.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=rg; ctx.beginPath(); ctx.ellipse(cx,top+40,120,40,0,0,7); ctx.fill();
}
}
// ---------- Beat-triggered fireworks (only when React to Music is on) ----------
let fireworks=[];
function spawnFirework(px,py){
if(fireworks.length>600) return;
const W=S.W,H=S.H;
const x=(px!==undefined)?px:W*(0.30+Math.random()*0.40);
const y=(py!==undefined)?py:H*(0.14+Math.random()*0.20);
const color=pal('accent',0), warm=pal('lantern',0);
const n=20+((Math.random()*12)|0);
for(let i=0;i<n;i++){
const a=(i/n)*Math.PI*2 + Math.random()*0.2, sp=1.4+Math.random()*2.6;
fireworks.push({x,y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,life:1,color: i%3?color:warm});
}
}
function drawFireworks(dt){
if(!fireworks.length) return;
ctx.save(); ctx.globalCompositeOperation='lighter';
for(let i=fireworks.length-1;i>=0;i--){
const p=fireworks[i];
p.x+=p.vx; p.y+=p.vy; p.vy+=0.03; p.vx*=0.98; p.vy*=0.98; p.life-=dt*0.85;
if(p.life<=0){ fireworks.splice(i,1); continue; }
ctx.globalAlpha=Math.max(0,p.life);
ctx.fillStyle=p.color;
ctx.beginPath(); ctx.arc(p.x,p.y,2.2*p.life+0.6,0,7); ctx.fill();
}
ctx.restore(); ctx.globalAlpha=1;
}
let _seenBeat=0;
// ---------- Pterodactyl fly-over (button / key 'p') ----------
let ptero=null;
function launchPtero(){
ptero={ p:0, y:S.H*(0.12+Math.random()*0.14), dir:(Math.random()<0.5?1:-1), scale:1.3+Math.random()*0.7 };
announce('A pterodactyl glides across the canyon.');
}
function drawPtero(dt){
if(!ptero) return;
ptero.p += dt*0.16; // crosses in ~6s
if(ptero.p>=1){ ptero=null; return; }
const W=S.W, span=W+280;
const x = ptero.dir>0 ? -140+ptero.p*span : W+140-ptero.p*span;
const y = ptero.y + Math.sin(ptero.p*Math.PI*2.4)*22; // soaring undulation
const flap = S.reduced?0.5:(Math.sin(S.t*7)*0.5+0.5); // wingbeat
ctx.save(); ctx.translate(x,y); ctx.scale(ptero.dir*ptero.scale, ptero.scale);
ctx.fillStyle='rgba(14,4,8,0.9)';
// wings (flapping) + body
ctx.beginPath();
ctx.moveTo(0,0);
ctx.quadraticCurveTo(-16,-8-flap*22,-44,-3);
ctx.quadraticCurveTo(-18,6,-4,4);
ctx.lineTo(4,4);
ctx.quadraticCurveTo(18,6,44,-3);
ctx.quadraticCurveTo(16,-8-flap*22,0,0);
ctx.closePath(); ctx.fill();
// long beak forward
ctx.beginPath(); ctx.moveTo(3,0); ctx.lineTo(22,-3); ctx.lineTo(4,3); ctx.closePath(); ctx.fill();
// swept-back head crest
ctx.beginPath(); ctx.moveTo(2,-1); ctx.lineTo(-7,-10); ctx.lineTo(5,-2); ctx.closePath(); ctx.fill();
// trailing tail
ctx.strokeStyle='rgba(14,4,8,0.9)'; ctx.lineWidth=1.6; ctx.beginPath(); ctx.moveTo(-2,2); ctx.lineTo(-16,6); ctx.stroke();
ctx.restore();
}
// ---------- Main loop ----------
let last=performance.now();
function frame(now){
const dt=Math.min(0.05,(now-last)/1000); last=now;
S.idleT=(S.idleT||0)+dt;
// live audio energy (0..1) when "React to Music" is on — otherwise motion stays authored
const reacting = !!(window.SRM && window.SRM.react);
S.amp = reacting ? (window.SRM.level||0) : 0;
S.pulse = reacting ? (window.SRM.pulse||0) : 0; // sharp per-beat throb for lamps
// bridge visual state to the separate WebGL orb loop so it can honor reduced motion + pause
if(window.SRM){ window.SRM.reduced = S.reduced; window.SRM.playing = S.playing; }
// fire a burst on each new detected beat
if(reacting && !S.reduced && window.SRM.beat>_seenBeat){ _seenBeat=window.SRM.beat; spawnFirework(); }
else if(!reacting){ _seenBeat=window.SRM?(window.SRM.beat||0):0; } // let fireworks decay naturally
if(S.playing){
const spd = S.reduced ? 0.28 : 1.0;
S.t += dt * S.tempo * spd * 1.4;
// Gentle auto-journey through the three chapters (until the visitor takes over)
if(S.autoJourney && !S.reduced){
S.journeyClock += dt;
if(S.journeyClock >= CHAPTER_SECONDS){
S.journeyClock = 0;
setChapter((S.chapter+1)%3, {auto:true});
}
}
}
// ease chapter mix toward target
const prevMix=S.chapterMix;
S.chapterMix += (S.chapter - S.chapterMix)*Math.min(1,dt*2.2);
if(Math.abs(S.chapter-S.chapterMix)>0.001 || Math.abs(prevMix-S.chapterMix)>0.001) skyDirty=true;
if(skyDirty) drawSky();
const W=S.W,H=S.H;
if(S.trails && !S.reduced && S.playing){
ctx.fillStyle='rgba(6,2,4,0.16)';
ctx.fillRect(0,0,W,H);
} else {
ctx.clearRect(0,0,W,H);
}
// parallax canyon (farthest -> nearest)
canyonSilhouette(H*0.54, 150, 6.1, shade(pal('canyon',0),0.35), Math.sin(S.t*0.03)*3);
canyonSilhouette(H*0.62, 120, 1.2, pal('canyon',0), Math.sin(S.t*0.05)*4);
canyonSilhouette(H*0.70, 90, 4.5, pal('canyon',2), Math.sin(S.t*0.05+2)*3);
drawRiver();
drawAnimals();
drawBoat();
drawVenue();
drawSongMotifs(dt);
drawPtero(dt);
drawFireworks(dt);
requestAnimationFrame(frame);
}
// ---------- UI wiring ----------
const tempoWords=['Adagio','Andante','Moderato','Allegro','Vivace'];
const lanternWords=['Doused','Dim','Warm','Bright','Blazing'];
const crowdWords=['Empty','Sparse','Lively','Full','Teeming'];
const animalWords=['Hidden','Few','Some','Most','All'];
function word(arr,v){return arr[Math.min(arr.length-1,Math.floor(v*arr.length))];}
const tempoEl=document.getElementById('tempo');
const tempoV=document.getElementById('tempoV');
tempoEl.addEventListener('input',e=>{
S.tempo=e.target.value/100; // 0..2
const w=word(tempoWords, Math.min(0.999,S.tempo/2));
tempoV.textContent=w;
e.target.setAttribute('aria-valuetext',w);
});
function wireSlider(id,valId,arr,setter){
const el=document.getElementById(id), v=document.getElementById(valId);
el.addEventListener('input',e=>{
const val=e.target.value/100; setter(val);
const w=word(arr,val); v.textContent=w; e.target.setAttribute('aria-valuetext',w);
});
}
wireSlider('lantern','lanternV',lanternWords,val=>S.lantern=val);
wireSlider('crowd','crowdV',crowdWords,val=>S.crowd=val);
wireSlider('animals','animalsV',animalWords,val=>S.animals=val);
// Dancing Bears count (1..10) — plain integer slider
const bearsEl=document.getElementById('bears');
bearsEl.addEventListener('input',e=>{
S.bears=+e.target.value; const v=String(S.bears);
document.getElementById('bearsV').textContent=v; e.target.setAttribute('aria-valuetext',v);
announce(v+' dancing '+(S.bears==1?'bear':'bears')+'.');
});
// Sync every label to its true computed word on load (no hardcoded mismatch)
['tempo','lantern','crowd','animals'].forEach(id=>{
document.getElementById(id).dispatchEvent(new Event('input'));
});
const playBtn=document.getElementById('playBtn');
playBtn.addEventListener('click',()=>{
S.playing=!S.playing;
playBtn.setAttribute('aria-pressed',S.playing);
playBtn.textContent=S.playing?'❚❚ Pause':'► Play';
announce(S.playing?'Playing.':'Paused.');
});
document.getElementById('restartBtn').addEventListener('click',()=>{
S.t=0; S.playing=true; S.autoJourney=true; S.journeyClock=0;
playBtn.setAttribute('aria-pressed','true'); playBtn.textContent='❚❚ Pause';
setChapter(0,{silent:true});
ctx.clearRect(0,0,S.W,S.H);
announce('Restarted the journey from Departure.');
});
const reduceBtn=document.getElementById('reduceBtn');
reduceBtn.addEventListener('click',()=>{
S.reduced=!S.reduced; reduceBtn.setAttribute('aria-pressed',S.reduced);
if(S.reduced) ctx.clearRect(0,0,S.W,S.H);
announce(S.reduced?'Reduced motion on. Movement gentled.':'Reduced motion off.');
});
if(S.reduced) reduceBtn.setAttribute('aria-pressed','true');
const trailBtn=document.getElementById('trailBtn');
trailBtn.addEventListener('click',()=>{
S.trails=!S.trails; trailBtn.setAttribute('aria-pressed',S.trails);
if(!S.trails) ctx.clearRect(0,0,S.W,S.H);
announce(S.trails?'Motion trails on.':'Motion trails off.');
});
// cameras
document.querySelectorAll('[data-cam]').forEach(b=>{
b.addEventListener('click',()=>{
S.cam=b.dataset.cam;
document.querySelectorAll('[data-cam]').forEach(x=>x.setAttribute('aria-pressed', x===b));
ctx.clearRect(0,0,S.W,S.H);
const names={wide:'Wide river',deck:'Deck',over:'Overhead'};
announce(names[S.cam]+' view.');
});
});
// chapters
const chapLabel=document.getElementById('chapLabel');
const romans=['I','II','III'];
function setChapter(i, opts){
opts=opts||{};
S.chapter=i; skyDirty=true;
if(!opts.auto){ S.autoJourney=false; } // a manual pick takes the wheel from the auto-journey
S.journeyClock=0;
document.querySelectorAll('[data-chapter]').forEach(b=>b.setAttribute('aria-current', String(+b.dataset.chapter===i)));
chapLabel.textContent=`Chapter ${romans[i]} · ${CHAPTERS[i].name}`;
if(!opts.silent) announce(`Chapter ${romans[i]}, ${CHAPTERS[i].name}. Palette and choreography shifting.`);
}
document.querySelectorAll('[data-chapter]').forEach(b=>{
b.addEventListener('click',()=>setChapter(+b.dataset.chapter));
});
// museum label
const labelBtn=document.getElementById('labelBtn');
const museum=document.getElementById('museum');
labelBtn.addEventListener('click',()=>{
const open=museum.hidden;
museum.hidden=!open;
labelBtn.setAttribute('aria-expanded',String(open));
});
function announce(msg){ live.textContent=msg; }
// master Controls toggle — everything starts collapsed; click to reveal / hide the panels
const stageEl=document.getElementById('stage');
const chromeBtn=document.getElementById('chromeBtn');
function toggleChrome(){
const hidden=stageEl.classList.toggle('hide-chrome');
chromeBtn.setAttribute('aria-pressed', String(!hidden));
chromeBtn.textContent = hidden ? '☰ Controls' : '✕ Close';
announce(hidden ? 'Controls hidden.' : 'Controls shown.');
}
chromeBtn.addEventListener('click', toggleChrome);
// Venue backdrop toggle
const venueBtn=document.getElementById('venueBtn');
venueBtn.addEventListener('click',()=>{
venueOn=!venueOn; venueBtn.setAttribute('aria-pressed',String(venueOn));
announce(venueOn?'Venue backdrop on.':'Venue backdrop off.');
});
// keyboard shortcuts
window.addEventListener('keydown',e=>{
const t=e.target;
// While a form field has focus, let it own every key (arrows in the show <select> must not switch chapters)
if(t && t.matches && t.matches('input,select,textarea')) return;
// Let native buttons handle their own Space/Enter activation
if(t && t.matches && t.matches('button,[role="button"]') && (e.key===' '||e.key==='Enter')) return;
switch(e.key.toLowerCase()){
case ' ': e.preventDefault(); playBtn.click(); break;
case 'r': document.getElementById('restartBtn').click(); break;
case 'm': reduceBtn.click(); break;
case 't': trailBtn.click(); break;
case 'a': { const rb=document.getElementById('reactBtn'); if(rb) rb.click(); } break;
case 'p': launchPtero(); break;
case 'c': toggleChrome(); break;
case 'v': venueBtn.click(); break;
case '1': document.querySelector('[data-cam="wide"]').click(); break;
case '2': document.querySelector('[data-cam="deck"]').click(); break;
case '3': document.querySelector('[data-cam="over"]').click(); break;
case 'arrowright': e.preventDefault(); setChapter(Math.min(2,S.chapter+1)); break;
case 'arrowleft': e.preventDefault(); setChapter(Math.max(0,S.chapter-1)); break;
case 'l': labelBtn.click(); break;
}
});
// pterodactyl fly-over button
document.getElementById('pteroBtn').addEventListener('click', launchPtero);
// click / tap anywhere on the scene (not on a control) to launch a firework
document.getElementById('stage').addEventListener('pointerdown', e=>{
if(e.target.closest('button,input,select,a,.dock,.cams,.chapters,.musicroom,.museum,.label-toggle')) return;
const r=scene.getBoundingClientRect();
spawnFirework(e.clientX-r.left, e.clientY-r.top);
spawnFirework(e.clientX-r.left, e.clientY-r.top); // a fuller double burst on a deliberate tap
});
// init
resize();
requestAnimationFrame(frame);
})();
</script>
<script>
/* ---------- Music Room: streams the Grateful Dead Live Music Archive (archive.org) ----------
Reuses the curated show catalog from the dead-agentabrams app. Listen-only:
audio streams straight from the Internet Archive, is user-initiated, and links
back to each source item. The visual scene stays authored — no audio analysis. */
(() => {
"use strict";
const CATALOG = [
{id:"gd69-02-27.sbd.16track.kaplan.6315.sbeok.shnf", date:"February 27, 1969", title:"Fillmore West", sub:"San Francisco · primal psychedelic era"},
{id:"gd70-05-24.sbd.hanno.6481.sbeok.shnf", date:"May 24, 1970", title:"Newcastle-Under-Lyme", sub:"Newcastle · acoustic-and-electric era"},
{id:"gd71-04-14.sbd.lai.6250.sbefail.shnf", date:"April 14, 1971", title:"Christy Mathewson Stadium, Bucknell U.", sub:"Lewisburg · Skull & Roses era"},
{id:"gd72-08-27.sbd.orf.3328.sbeok.shnf", date:"August 27, 1972", title:"Veneta", sub:"Oregon · field, heat, and open sky"},
{id:"gd73-02-15.sbd.hall.1580.sbeok.shnf", date:"February 15, 1973", title:"Dane County Coliseum", sub:"Madison · jazz-loose / Wake of the Flood era"},
{id:"gd74-06-23.sbd.cribbs.16780.sbeok.shnf", date:"June 23, 1974", title:"Jai-Alai Fronton", sub:"Miami · Wall of Sound era"},
{id:"gd76-06-10.sbd.bertha-ashley.23370.sbeok.shnf", date:"June 10, 1976", title:"Boston Music Hall", sub:"Boston · return-from-hiatus era"},
{id:"gd77-05-08.sbd.hicks.4982.sbeok.shnf", date:"May 8, 1977", title:"Cornell", sub:"Ithaca · Barton Hall"},
{id:"gd78-02-01.sbd.parillo.1017.sbeok.shnf", date:"February 1, 1978", title:"Uptown Theater", sub:"Chicago · tight & funky late-’70s era"},
{id:"gd81-03-21.sbd.clugston.6666.sbeok.shnf", date:"March 21, 1981", title:"Rainbow Theatre", sub:"London · European-tour era"},
{id:"gd85-04-27.sbd.jerugim.359.sbeok.shnf", date:"April 27, 1985", title:"Frost Amphitheatre", sub:"Palo Alto · 20th-anniversary / Frost era"},
{id:"gd1987-01-28.sbd.walker-scotton.miller.83286.flac16", date:"January 28, 1987", title:"S.F Civic Auditorium", sub:"San Francisco · In the Dark comeback era"},
{id:"gd89-12-31.sbd.bertha-ashley.18674.sbeok.shnf", date:"December 31, 1989", title:"Oakland-Alameda County Coliseum", sub:"Oakland · late-peak / Built to Last era"},
{id:"gd90-03-29.aud-fob.set2.unknown.1317.sbeok.shnf", date:"March 29, 1990", title:"Nassau", sub:"Uniondale · late-era expansion"},
{id:"gd91-10-31.sbd.gardner.2897.sbeok.shnf", date:"October 31, 1991", title:"Oakland-Alameda County Coliseum", sub:"Oakland · post-Brent / Bruce Hornsby era"},
{id:"gd93-03-24.sbd.keyser.25664.sbeok.flacf", date:"March 24, 1993", title:"Dean Smith Center", sub:"Chapel Hill · Vince era / stadium years"},
{id:"gd94-10-05.sbd.unknown.8030.sbeok.shnf", date:"October 5, 1994", title:"The Spectrum", sub:"Philadelphia · final-years era"},
{id:"gd95-07-09.sbd.7233.sbeok.shnf", date:"July 9, 1995", title:"Soldier Field", sub:"Chicago · the final show"}
];
const DEFAULT_SHOW = 7; // Cornell '77
const $ = id => document.getElementById(id);
const audio = $('mrAudio'), room = $('musicroom'), sel = $('mrShow'), live = $('live');
let showIdx=-1, tracks=[], trackIdx=-1, seeking=false, loadToken=0;
CATALOG.forEach((s,i)=>{ const o=document.createElement('option'); o.value=String(i); o.textContent=`${s.date} — ${s.title}`; sel.appendChild(o); });
const fmt = t => { if(!isFinite(t)||t<0) t=0; const m=Math.floor(t/60); return m+':'+String(Math.floor(t%60)).padStart(2,'0'); };
const announce = m => { if(live) live.textContent=m; };
function setFoot(s){
const short = s.id.split('.')[0];
$('mrFoot').innerHTML =
`Source: <a href="https://archive.org/details/${s.id}" target="_blank" rel="noopener noreferrer">archive.org/details/${short}</a> — `+
`a community recording from the Grateful Dead Live Music Archive, streamed from the Internet Archive. Listen-only; nothing is copied or re-hosted here.`;
}
// Load ANY Grateful Dead archive.org item (curated pick OR a search result)
async function loadShowRef(s, opts){
opts=opts||{};
const myToken=++loadToken; // guard against a stale fetch overwriting a newer selection
try{ audio.pause(); }catch(e){} // stop the current night before loading the next
tracks=[]; trackIdx=-1;
if(window.SRM){ window.SRM.showIdx=(s.venueIdx!=null?s.venueIdx:null); window.SRM.venueLabel=s.venue||[s.title,s.date].filter(Boolean).join(' · '); }
$('mrNow').textContent=`${s.title} — loading set list…`;
$('mrSub').textContent=[s.date,s.sub].filter(Boolean).join(' · ');
$('mrTracks').innerHTML=''; $('mrMsg').hidden=true; $('mrMsg').innerHTML='';
setFoot(s);
try{
const r=await fetch(`https://archive.org/metadata/${s.id}`,{mode:'cors'});
if(!r.ok) throw new Error('metadata '+r.status);
const j=await r.json();
if(myToken!==loadToken) return; // a newer show was selected while we awaited — drop this result
let mp3=(j.files||[]).filter(f=>/vbr mp3/i.test(f.format||''));
if(!mp3.length) mp3=(j.files||[]).filter(f=>/mp3/i.test(f.format||''));
mp3.sort((a,b)=> (a.name>b.name?1:-1));
tracks=mp3.map(f=>({
title:(f.title&&f.title.trim())?f.title.trim():f.name.replace(/\.mp3$/i,''),
url:`https://archive.org/download/${s.id}/${encodeURIComponent(f.name)}`
}));
if(!tracks.length) throw new Error('no audio files');
renderTracks();
trackIdx=0; audio.src=tracks[0].url; // cue the opener, paused (playback stays user-initiated)
if(window.SRM) window.SRM.track=tracks[0].title;
$('mrNow').textContent=`♪ ${tracks[0].title}`;
highlight();
$('mrDur').textContent='0:00'; $('mrCur').textContent='0:00'; $('mrSeek').value='0';
announce(`Loaded ${s.title}. ${tracks.length} tracks from the Live Music Archive.`);
if(opts.play) playTrack(0);
}catch(e){
if(myToken!==loadToken) return; // superseded — let the newer selection own the UI
fallbackEmbed(s);
}
}
async function loadShow(i, opts){ if(sel) sel.value=String(i); return loadShowRef(Object.assign({venueIdx:i}, CATALOG[i]), opts); }
// Search the WHOLE Grateful Dead collection on archive.org — type anything (date, venue, city, song)
let searchTimer=null;
// archive.org is a flaky nonprofit — abort a hung request so the UI fails gracefully
// into the error state instead of pulsing "…" forever (5x sweep 2 fix).
function fetchT(url, ms=9000, extSignal){
const ctl = (typeof AbortController!=='undefined') ? new AbortController() : null;
const t = ctl ? setTimeout(()=>ctl.abort(), ms) : null;
if(ctl && extSignal){ // batch-cancel (e.g. source switch)
if(extSignal.aborted) ctl.abort();
else extSignal.addEventListener('abort', ()=>ctl.abort(), {once:true});
}
return fetch(url, {mode:'cors', signal: ctl?ctl.signal:undefined})
.finally(()=>{ if(t) clearTimeout(t); });
}
// --- Phase 1 reliability: capped pool + polite retry/backoff/breaker + count cache ---
// archive.org serves ONE count query fast but drops ~40% of a 31-way concurrent burst,
// so we throttle, retry timeouts/429s (429 backs off harder), and stop hammering a
// struggling nonprofit once failures pile up.
const MR_POOL_LIMIT=5, MR_RETRY_MAX=2, MR_BREAKER_TRIP=6, MR_COUNT_TIMEOUT=6000;
function mrDelay(ms){ return new Promise(r=>setTimeout(r,ms)); }
function mrJitter(base){ return base+Math.floor(Math.random()*base); }
// Run fn over items with at most `limit` in flight at once. fn must not throw.
function mrPool(items, limit, fn){
const out=new Array(items.length); let next=0;
const worker=async()=>{ while(next<items.length){ const i=next++; out[i]=await fn(items[i], i); } };
const n=Math.min(Math.max(1,limit), items.length||1);
return Promise.all(Array.from({length:n}, worker)).then(()=>out);
}
// Fetch one advancedsearch count with bounded retries. r.ok / status===429 are checked
// BEFORE r.json() so a 429 or HTTP error is never parsed as a success. `breaker` is a
// batch-shared {fails,open}: after MR_BREAKER_TRIP consecutive failures it opens and
// remaining calls short-circuit instead of piling on.
async function mrFetchCount(url, sig, breaker){
for(let attempt=0; ; attempt++){
if(breaker.open) throw new Error('breaker-open');
if(sig&&sig.aborted) throw new Error('aborted');
let outcome, value=null, err=null;
try{
const r=await fetchT(url, MR_COUNT_TIMEOUT, sig); // counts return in ~0.18s — a tight timeout bounds time-to-error under a hang
if(r.status===429){ outcome='retry-429'; err=new Error('http-429'); }
else if(!r.ok){ outcome='fail'; err=new Error('http-'+r.status); }
else{
const j=await r.json();
const n=(j&&j.response&&j.response.numFound);
if(typeof n==='number'){ outcome='ok'; value=n; }
else { outcome='fail'; err=new Error('malformed'); }
}
}catch(e){
if(sig&&sig.aborted) throw e;
outcome='retry-timeout'; err=e; // network drop / abort-timeout
}
if(outcome==='ok'){ breaker.fails=0; return value; }
const canRetry = attempt<MR_RETRY_MAX && (outcome==='retry-timeout'||outcome==='retry-429');
if(canRetry){
await mrDelay(mrJitter(outcome==='retry-429' ? 1500*(attempt+1) : 400*(attempt+1)));
continue; // 429 backs off HARDER than a timeout
}
breaker.fails++;
if(breaker.fails>=MR_BREAKER_TRIP) breaker.open=true;
throw err||new Error('fail');
}
}
// Per-(source|year) count cache in localStorage. HARD RULE: never persist a
// failed/zero/negative count (every real GD/DC year is nonzero, so a cached 0 is a
// poisoned read). Historical years live ~30 days; the current calendar year always
// re-fetches (its count keeps growing).
const MR_CACHE_KEY='srm.counts.v1', MR_CACHE_TTL=30*24*60*60*1000;
function mrCacheLoad(src,y){
try{
const store=JSON.parse(localStorage.getItem(MR_CACHE_KEY)||'{}');
const rec=store[ck(src,y)];
if(!rec || !(rec.n>0)) return null; // missing / poisoned 0/neg → not cached
if(Number(y)===new Date().getFullYear()) return null;
if(Date.now()-rec.t>MR_CACHE_TTL) return null; // stale
return rec.n;
}catch(e){ return null; }
}
function mrCacheSave(src,y,n){
if(!(n>0)) return; // NEVER persist a failed/zero/negative count
if(Number(y)===new Date().getFullYear()) return;
try{
const store=JSON.parse(localStorage.getItem(MR_CACHE_KEY)||'{}');
store[ck(src,y)]={n:n, t:Date.now()};
localStorage.setItem(MR_CACHE_KEY, JSON.stringify(store));
}catch(e){}
}
// Browse the WHOLE Grateful Dead collection — empty = most-popular shows; text and/or a year narrow it.
async function searchArchive(q){
const res=$('mrResults'); if(!res) return;
q=(q||'').trim();
const yr=($('mrYear')&&$('mrYear').value)||'';
const coll='collection:'+((window.MR_SOURCES&&window.MR_SOURCES[window.MR_SRC||'gd'])||{id:'GratefulDead'}).id;
const filters=[]; if(q) filters.push('('+q+')'); if(yr) filters.push('year:'+yr);
const query = filters.length ? coll+' AND '+filters.join(' AND ') : coll;
res.innerHTML='<div class="mr-hint">'+(filters.length?'Searching the archive…':'Loading the most-played shows…')+'</div>';
const sort = 'downloads+desc';
try{
const url='https://archive.org/advancedsearch.php?q='+encodeURIComponent(query)+
'&fl[]=identifier&fl[]=title&fl[]=date&fl[]=venue&fl[]=coverage&fl[]=downloads&sort[]='+sort+'&rows=40&output=json';
const r=await fetchT(url); const j=await r.json();
const docs=(j.response&&j.response.docs)||[]; const total=(j.response&&j.response.numFound)||0;
if(!docs.length){ res.innerHTML='<div class="mr-hint">No shows found. Try a date (1977-05-08), a city, or a song.</div>'; return; }
res.innerHTML=`<div class="mr-hint">${total.toLocaleString()} shows in the archive${filters.length?' matching':''} · showing ${docs.length}</div>`;
docs.forEach(d=>{
const date=(d.date||'').slice(0,10), place=d.venue||d.coverage||d.title||d.identifier;
const b=document.createElement('button'); b.type='button'; b.className='mr-result'; b.setAttribute('role','listitem');
b.innerHTML=`<span class="rd">${date||'—'}</span><span class="rv">${place}</span>`;
b.addEventListener('click',()=>loadShowRef({id:d.identifier, date:date, title:d.venue||d.title||'Grateful Dead', sub:d.coverage||''}, {play:true}));
res.appendChild(b);
});
}catch(e){ res.innerHTML='<div class="mr-hint">Archive browse is unavailable right now — archive.org may be down. Try again in a bit.</div>'; }
}
function fallbackEmbed(s){
$('mrNow').textContent=s.title;
const msg=$('mrMsg'); msg.hidden=false;
const fileNote = location.protocol==='file:' ? ' Opening the file directly (file://) can block cross-site loading — serving it over http fixes it.' : '';
msg.innerHTML=`Couldn't load the set list here.${fileNote} `+
`<button class="btn" id="mrEmbedBtn" style="margin-top:8px">▶ Open the archive.org player</button>`;
$('mrEmbedBtn').onclick=()=>{
msg.innerHTML=`<iframe title="Internet Archive player for ${s.title}" src="https://archive.org/embed/${s.id}" width="100%" height="150" frameborder="0" style="border-radius:8px;border:1px solid var(--panel-edge)"></iframe>`;
room.classList.add('playing');
};
}
function renderTracks(){
const c=$('mrTracks'); c.innerHTML='';
tracks.forEach((t,i)=>{
const b=document.createElement('button'); b.type='button'; b.setAttribute('role','listitem');
b.innerHTML=`<span class="n">${String(i+1).padStart(2,'0')}</span><span>${t.title}</span>`;
b.addEventListener('click',()=>playTrack(i));
c.appendChild(b);
});
}
function highlight(){ [...$('mrTracks').children].forEach((b,i)=>b.setAttribute('aria-current', String(i===trackIdx))); }
function playTrack(i){
if(i<0||i>=tracks.length) return;
if(window.SRM) window.SRM.track=tracks[i].title; // tell the visuals which song is playing
if(i!==trackIdx || !audio.src){ trackIdx=i; audio.src=tracks[i].url; }
audio.play().catch(()=>setPlay(false));
$('mrNow').textContent=`♪ ${tracks[i].title}`;
highlight();
announce(`Now playing ${tracks[i].title}.`);
}
function setPlay(on){
room.classList.toggle('playing',on);
const b=$('mrPlay'); b.textContent=on?'❚❚':'▶';
b.setAttribute('aria-pressed',String(on)); b.setAttribute('aria-label',on?'Pause':'Play');
}
// --- optional audio-reactive bridge: analyzes the LIVE archive.org stream (CORS-open) ---
// Default OFF — visuals stay authored until the visitor flips "React to Music".
audio.crossOrigin='anonymous'; // set before any src load so the CDN stream is analyzable
window.SRM = window.SRM || { level:0, react:false };
// song-analysis fields the orb + animals read: bpm (detected tempo), vocal (0..1 lyric presence),
// change (a counter that ticks on a NOTICEABLE song change), changeKind ('track'|'tempo'|'vocal-in'|'vocal-out')
Object.assign(window.SRM,{ bpm:0, vocal:0, change:window.SRM.change||0, changeKind:'', changeAt:0 });
let _actx, _analyser, _freq, _looping=false;
// beat-interval ring buffer -> BPM, plus running baselines for change detection
let _beatTimes=[], _bpmBase=0, _vocalSm=0, _vocalState=false, _lastTrack='', _lastChangeClock=-999;
function _median(a){ const b=a.slice().sort((x,y)=>x-y); return b.length? b[(b.length>>1)] : 0; }
function _fireChange(kind){
if(_clock-_lastChangeClock < 90) return; // rate-limit: ~1.5s between change events
_lastChangeClock=_clock;
window.SRM.change++; window.SRM.changeKind=kind; window.SRM.changeAt=performance.now();
}
function ensureGraph(){
if(_actx) return true;
try{
_actx = new (window.AudioContext||window.webkitAudioContext)();
const src=_actx.createMediaElementSource(audio); // once per element
_analyser=_actx.createAnalyser(); _analyser.fftSize=256; _analyser.smoothingTimeConstant=0.82;
src.connect(_analyser); _analyser.connect(_actx.destination);
_freq=new Uint8Array(_analyser.frequencyBinCount);
return true;
}catch(e){ return false; }
}
let _avg=0, _clock=0, _lastBeat=-99;
function sampleLevel(){
// stop the rAF loop when it has nothing to analyze; reactBtn / audio 'play' restart it
if(!window.SRM.react || audio.paused){ _looping=false; return; }
if(_analyser){
_analyser.getByteFrequencyData(_freq);
const L=_freq.length; // 128 bins, ~0..22kHz
let sum=0, n=Math.max(1,Math.floor(L/6)); // low-end (bass) energy
for(let i=0;i<n;i++) sum+=_freq[i];
const raw=(sum/n)/255;
window.SRM.level = window.SRM.level*0.6 + raw*0.4; // smoothed 0..1
// vocal-band (~300Hz..3.4kHz -> mid bins) energy, normalised against overall energy:
// a strong, sustained mid presence over the bass floor reads as "lyrics / a voice is in"
let vsum=0, vn=0, tsum=0;
for(let i=3;i<24 && i<L;i++){ vsum+=_freq[i]; vn++; }
for(let i=0;i<L;i++) tsum+=_freq[i];
const vocalRaw = tsum>1 ? Math.min(1,(vsum/Math.max(1,vn))/ (tsum/L) * 0.5) : 0;
_vocalSm = _vocalSm*0.9 + vocalRaw*0.1;
window.SRM.vocal = _vocalSm;
// beat detection: a bass spike well above the running average, rate-limited
_avg = _avg*0.92 + raw*0.08; _clock++;
if(raw > _avg*1.10 && raw > 0.12 && (_clock-_lastBeat) > 5){
const now=performance.now();
if(window.SRM.beat){ const dtb=now-(window.SRM._lastBeatMs||now); if(dtb>240 && dtb<1400){ _beatTimes.push(dtb); if(_beatTimes.length>8) _beatTimes.shift(); } }
window.SRM._lastBeatMs=now;
_lastBeat=_clock; window.SRM.beat=(window.SRM.beat||0)+1; window.SRM.pulse=1;
// BPM from the median beat interval, smoothed
if(_beatTimes.length>=4){ const bpm=60000/_median(_beatTimes); window.SRM.bpm = window.SRM.bpm? window.SRM.bpm*0.8+bpm*0.2 : bpm; }
}
window.SRM.pulse = (window.SRM.pulse||0)*0.86; // decaying throb (spikes to 1 on a beat)
// ---- NOTICEABLE-CHANGE detection (drives the orb's UFO-split & celestial morphs) ----
// 1) a new track started
const tr=window.SRM.track||'';
if(tr && tr!==_lastTrack){ _lastTrack=tr; _bpmBase=0; _beatTimes=[]; _fireChange('track'); }
// 2) a real tempo shift vs the running baseline
if(window.SRM.bpm>40){
if(!_bpmBase) _bpmBase=window.SRM.bpm;
else { if(Math.abs(window.SRM.bpm-_bpmBase)/_bpmBase > 0.18) _fireChange('tempo'); _bpmBase=_bpmBase*0.98+window.SRM.bpm*0.02; }
}
// 3) lyrics-vs-just-music crossover (hysteresis so it doesn't flicker)
if(!_vocalState && _vocalSm>0.42){ _vocalState=true; _fireChange('vocal-in'); }
else if(_vocalState && _vocalSm<0.26){ _vocalState=false; _fireChange('vocal-out'); }
}
requestAnimationFrame(sampleLevel);
}
const reactBtn=$('reactBtn');
if(reactBtn) reactBtn.addEventListener('click',()=>{
const on=!window.SRM.react;
if(on){
if(!ensureGraph()){ announce('Live audio analysis is not available in this browser.'); return; }
if(_actx.state==='suspended') _actx.resume();
if(!_looping){ _looping=true; sampleLevel(); }
}
window.SRM.react=on;
reactBtn.setAttribute('aria-pressed',String(on));
announce(on?'Reacting to the music — lanterns, fireflies, and dancers now move to the live audio.':'Back to authored motion.');
});
audio.addEventListener('play',()=>{ setPlay(true);
if(window.SRM.react && _actx){ if(_actx.state==='suspended') _actx.resume(); if(!_looping){ _looping=true; sampleLevel(); } }
});
audio.addEventListener('pause',()=>setPlay(false));
audio.addEventListener('ended',()=>{ if(trackIdx<tracks.length-1) playTrack(trackIdx+1); else setPlay(false); });
audio.addEventListener('timeupdate',()=>{
const d=audio.duration||0, c=audio.currentTime||0;
$('mrCur').textContent=fmt(c); $('mrDur').textContent=fmt(d);
if(d>0 && !seeking) $('mrSeek').value=String(Math.round(c/d*1000));
});
audio.addEventListener('error',()=>{
const err=audio.error;
if(err && err.code===err.MEDIA_ERR_ABORTED) return; // fired by our own src swaps — not a real failure
if(showIdx<0) return;
if(!tracks.length){ fallbackEmbed(CATALOG[showIdx]); return; }
// a specific track wouldn't stream (404/geo/transient) — move on rather than stall silently
if(trackIdx>=0 && trackIdx<tracks.length-1){ announce('That track was unavailable; skipping ahead.'); playTrack(trackIdx+1); }
else { setPlay(false); const m=$('mrMsg'); m.hidden=false; m.textContent='That track could not be streamed from the archive right now — try another night.'; }
});
$('mrPlay').addEventListener('click',()=>{
if(!tracks.length){ loadShow(showIdx<0?DEFAULT_SHOW:showIdx,{play:true}); return; }
if(trackIdx<0){ playTrack(0); return; }
if(audio.paused) audio.play().catch(()=>{}); else audio.pause();
});
$('mrPrev').addEventListener('click',()=>{ if(tracks.length) playTrack(Math.max(0,trackIdx-1)); });
$('mrNext').addEventListener('click',()=>{ if(tracks.length) playTrack(Math.min(tracks.length-1,trackIdx+1)); });
const seek=$('mrSeek');
seek.addEventListener('input',()=>{ seeking=true; });
seek.addEventListener('change',()=>{ const d=audio.duration||0; if(d>0) audio.currentTime=(+seek.value/1000)*d; seeking=false; });
sel.addEventListener('change',()=>loadShow(+sel.value,{play:true}));
// archive browse wiring — source selector + tappable year-tile grid + debounced search
const searchEl=$('mrSearch'), yearEl=$('mrYear');
const mapEl=$('mrMap'), mapLabel=$('mrMapLabel'), allBtn=$('mrAll'), srcEl=$('mrSrc');
// Two FREE, band-sanctioned Dead archives on archive.org (CORS-open, streamable).
window.MR_SOURCES={
gd:{id:'GratefulDead', label:'Grateful Dead', years:'1965–1995', y0:1965, y1:1995},
dc:{id:'DeadAndCompany', label:'Dead & Company', years:'2015–2023', y0:2015, y1:2023}
};
window.MR_SRC='gd';
const counts={}; // per (source|year) show-count cache
const ck=(s,y)=>s+':'+y;
function setYear(y){
yearEl.value=y?String(y):'';
[...mapEl.children].forEach(c=>c.classList&&c.classList.toggle('on', c.dataset.y===String(y)));
allBtn.classList.toggle('on', !y);
const c=counts[ck(window.MR_SRC,y)];
mapLabel.textContent = y ? `${y}${c!=null?' · '+c.toLocaleString()+' shows':''}` : 'Tap a year';
searchArchive(searchEl?searchEl.value:'');
}
// paint a resolved count onto its tile; leave it non-destructive if we lack a good value
function mrPaintCount(src,y,n){
if(src!==window.MR_SRC) return;
const el=mapEl.querySelector('[data-y="'+y+'"]'); if(!el) return;
el.classList.remove('loading','err');
el.querySelector('.yc').textContent=n.toLocaleString();
el.title=y+' — '+n.toLocaleString()+' shows';
}
// a tile whose live fetch failed with NO good (cached) value: show an error mark, never a false 0
function mrMarkFailed(src,y){
if(src!==window.MR_SRC) return;
const el=mapEl.querySelector('[data-y="'+y+'"]'); if(!el) return;
if(counts[ck(src,y)]!=null) return; // already have a good value → keep it
el.classList.remove('loading'); el.classList.add('err');
el.querySelector('.yc').textContent='—';
el.title=y+' — count unavailable · tap to browse this year';
}
// heat-color every tile from the known counts (sqrt to tame the '77/'89 spikes)
function mrApplyHeat(src){
if(src!==window.MR_SRC) return;
const tiles=[...mapEl.querySelectorAll('.mr-yr')];
const max=Math.max(1,...tiles.map(el=>{const n=counts[ck(src,el.dataset.y)];return n>0?Math.sqrt(n):0;}));
tiles.forEach(el=>{ const n=counts[ck(src,el.dataset.y)]; if(n>0) el.style.setProperty('--heat',(Math.sqrt(n)/max).toFixed(3)); });
}
let buildCtl=null; // cancels the previous batch of year-count fetches on a source switch
function buildMap(){
const src=window.MR_SOURCES[window.MR_SRC], mySrc=window.MR_SRC;
if(buildCtl) buildCtl.abort(); // kill in-flight fetches from the prior build
buildCtl=(typeof AbortController!=='undefined')?new AbortController():null;
const sig=buildCtl?buildCtl.signal:undefined;
const YEARS=[]; for(let y=src.y0;y<=src.y1;y++) YEARS.push(y);
mapEl.innerHTML='';
YEARS.forEach(y=>{
const b=document.createElement('button'); b.type='button'; b.className='mr-yr loading';
b.dataset.y=String(y); b.title=y+' — counting…';
b.innerHTML=`<span class="yl">${y}</span><span class="yc">…</span>`;
b.addEventListener('click',()=>setYear(y));
b.addEventListener('mouseenter',()=>{ const c=counts[ck(window.MR_SRC,y)]; if(c!=null) mapLabel.textContent=`${y} · ${c.toLocaleString()} shows`; });
mapEl.appendChild(b);
});
// 1) paint known-good CACHED counts immediately — kills the "…" ellipsis for repeat visitors
YEARS.forEach(y=>{ const c=mrCacheLoad(mySrc,y); if(c!=null){ counts[ck(mySrc,y)]=c; mrPaintCount(mySrc,y,c); } });
mrApplyHeat(mySrc);
// 2) refresh live via a capped, retrying, circuit-broken pool (max 5 concurrent)
const breaker={fails:0, open:false};
mrPool(YEARS, MR_POOL_LIMIT, y=>
mrFetchCount('https://archive.org/advancedsearch.php?q='+encodeURIComponent('collection:'+src.id+' AND year:'+y)+'&rows=0&output=json', sig, breaker)
.then(n=>{
if(mySrc!==window.MR_SRC) return {y,ok:false};
const nn=Math.max(0,n);
if(nn>0){ counts[ck(mySrc,y)]=nn; mrCacheSave(mySrc,y,nn); mrPaintCount(mySrc,y,nn); mrApplyHeat(mySrc); }
else { mrMarkFailed(mySrc,y); } // genuine 0 for a real year — don't cache, don't fake it
return {y,ok:true};
})
.catch(()=>{ if(mySrc===window.MR_SRC) mrMarkFailed(mySrc,y); return {y,ok:false}; }) // live failure NEVER overwrites a good cached value
).then(()=>{
if(mySrc!==window.MR_SRC) return; // source switched mid-flight
mrApplyHeat(mySrc);
const anyKnown=YEARS.some(y=>counts[ck(mySrc,y)]!=null);
if(!anyKnown){ mapEl.innerHTML='<div class="mr-map-err">Couldn’t load the tour history — archive.org may be unreachable. Search still works.</div>'; return; }
const cur=yearEl.value, c=cur?counts[ck(mySrc,cur)]:null;
if(cur&&c!=null) mapLabel.textContent=`${cur} · ${c.toLocaleString()} shows`;
});
}
if(srcEl){
srcEl.innerHTML=Object.entries(window.MR_SOURCES).map(([k,s])=>
`<button type="button" data-src="${k}" class="${k===window.MR_SRC?'on':''}" aria-pressed="${k===window.MR_SRC}"><b>${s.label}</b><span class="yr">${s.years}</span></button>`).join('');
srcEl.addEventListener('click',e=>{
const btn=e.target.closest('button[data-src]'); if(!btn||btn.dataset.src===window.MR_SRC) return;
window.MR_SRC=btn.dataset.src;
[...srcEl.children].forEach(c=>{ const on=c.dataset.src===window.MR_SRC; c.classList.toggle('on',on); c.setAttribute('aria-pressed',String(on)); });
yearEl.value=''; allBtn.classList.add('on'); mapLabel.textContent='Tap a year';
buildMap(); searchArchive(searchEl?searchEl.value:'');
});
}
if(mapEl && yearEl){
mapEl.addEventListener('mouseleave',()=>{ const on=yearEl.value, c=on?counts[ck(window.MR_SRC,on)]:null; mapLabel.textContent = on?`${on}${c!=null?' · '+c.toLocaleString()+' shows':''}`:'Tap a year'; });
allBtn.addEventListener('click',()=>setYear(0));
buildMap();
}
if(searchEl){
searchEl.addEventListener('input',()=>{ clearTimeout(searchTimer); searchTimer=setTimeout(()=>searchArchive(searchEl.value),450); });
searchEl.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); clearTimeout(searchTimer); searchArchive(searchEl.value); } });
searchArchive(''); // populate the most-played shows on load
}
$('mrTracksToggle').addEventListener('click',()=>{
const on=room.classList.toggle('show-tracks');
$('mrTracksToggle').setAttribute('aria-expanded',String(on));
});
const mrToggle=$('mrToggle');
function toggleRoom(){
const open=room.getAttribute('data-open')!=='false';
room.setAttribute('data-open',String(!open));
mrToggle.setAttribute('aria-expanded',String(!open));
}
mrToggle.addEventListener('click',toggleRoom);
mrToggle.addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); toggleRoom(); } });
// Music Room stays EXPANDED (incl. small screens) so revealing the chrome shows a
// working player in one tap — no hidden second collapse (CTA/screenrecord CRITICAL #1).
// ---- Autostart on load: queue "Me and My Uncle → Bertha → Deal" and play ----
const WISHLIST=['Me and My Uncle','Bertha','Deal'];
function _norm(t){ return (t||'').toLowerCase().replace(/&/g,' and ').replace(/[^a-z0-9 ]+/g,' ').replace(/\bthe\b/g,' ').replace(/\s+/g,' ').trim(); }
function _matches(title,want){ const n=_norm(title), w=_norm(want); if(!n||!w) return false;
return new RegExp('\\b'+w.replace(/[.*+?^${}()|[\]\\]/g,'\\$&').replace(/\s+/g,'\\s+')+'\\b').test(n); }
// fan out to the curated shows in parallel, pool every mp3 track (title + direct url)
async function _poolFromShows(){
const results=await Promise.allSettled(CATALOG.map(s=>
fetch(`https://archive.org/metadata/${s.id}`,{mode:'cors'}).then(r=>r.ok?r.json():null).then(j=>({s,j}))));
const pool=[];
for(const res of results){
if(res.status!=='fulfilled' || !res.value || !res.value.j) continue;
const s=res.value.s, j=res.value.j;
let mp3=(j.files||[]).filter(f=>/vbr mp3/i.test(f.format||'')); if(!mp3.length) mp3=(j.files||[]).filter(f=>/mp3/i.test(f.format||''));
mp3.forEach(f=>pool.push({ title:(f.title&&f.title.trim())?f.title.trim():f.name.replace(/\.mp3$/i,''),
url:`https://archive.org/download/${s.id}/${encodeURIComponent(f.name)}`, show:s }));
}
return pool;
}
// browsers block silent autoplay — try, and if blocked, start on the first tap/keypress
function _tryAutoplay(){
const go=()=>audio.play().then(()=>setPlay(true)).catch(()=>{});
audio.play().then(()=>setPlay(true)).catch(()=>{
const unlock=()=>{ go(); document.removeEventListener('pointerdown',unlock,true); document.removeEventListener('keydown',unlock,true); };
document.addEventListener('pointerdown',unlock,true);
document.addEventListener('keydown',unlock,true);
announce('Tap or press any key to start the music — Me and My Uncle, Bertha, Deal.');
});
}
async function bootMusic(){
$('mrNow').textContent='Cueing the set…';
try{
const pool=await _poolFromShows();
const list=[]; for(const w of WISHLIST){ const hit=pool.find(p=>_matches(p.title,w)); if(hit) list.push(hit); }
if(list.length){
tracks=list; trackIdx=0; showIdx=-1; renderTracks(); highlight();
audio.src=tracks[0].url; if(window.SRM) window.SRM.track=tracks[0].title;
$('mrNow').textContent=`♪ ${tracks[0].title}`;
$('mrSub').textContent='Autostart — '+list.map(t=>t.title).join(' · ');
setFoot(list[0].show);
announce('Autostarting: '+list.map(t=>t.title).join(', ')+'.');
_tryAutoplay();
return;
}
}catch(e){}
await loadShow(DEFAULT_SHOW); // fallback: archive slow/down → cue the default night, still try to start
_tryAutoplay();
}
bootMusic();
// reveal the DEAD back-link only when this room is served as part of the exhibition
const _p=location.pathname.replace(/\/(index\.html)?$/,'');
if(_p.endsWith('/room') || /dead\.agentabrams/i.test(location.hostname)){
const bl=$('backlink'); if(bl) bl.classList.add('on');
}
})();
</script>
<!-- 3D orb (Three.js) floating over the boat, pulsing to the LIVE Dead stream. Classic build so it runs from file://; skips if offline. -->
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js"></script>
<script>
(function(){
if(!window.THREE) return;
const canvas=document.getElementById('orb'); if(!canvas) return;
let W=innerWidth,H=innerHeight;
const renderer=new THREE.WebGLRenderer({canvas,alpha:true,antialias:true});
renderer.setPixelRatio(Math.min(2,devicePixelRatio||1)); renderer.setSize(W,H,false);
const scene=new THREE.Scene();
const camera=new THREE.PerspectiveCamera(50,W/H,0.1,100); camera.position.z=5;
const group=new THREE.Group(); group.position.y=1.45; scene.add(group); // floats up in the sky over the boat
// ---- the core orb (its own sub-group so effects can rotate it WITHOUT tilting the world-down beams) ----
const orb=new THREE.Group(); group.add(orb);
const wire=new THREE.Mesh(new THREE.IcosahedronGeometry(0.95,2), new THREE.MeshBasicMaterial({wireframe:true,color:0xf2b64a,transparent:true,opacity:0.8}));
const core=new THREE.Mesh(new THREE.IcosahedronGeometry(0.76,3), new THREE.MeshStandardMaterial({color:0x2a0810,emissive:0xc9432b,emissiveIntensity:0.7,roughness:0.35,metalness:0.6,transparent:true,opacity:0.85}));
orb.add(core,wire);
// ---- 5 UFO saucers, each casting a downward light beam (searchlight cone, additive) ----
const ufo=new THREE.Group(); ufo.visible=false; group.add(ufo);
const saucers=[];
for(let i=0;i<5;i++){
const u=new THREE.Group();
const body=new THREE.Mesh(new THREE.SphereGeometry(0.24,20,12), new THREE.MeshStandardMaterial({color:0x0d1220,emissive:0x24406e,emissiveIntensity:0.7,roughness:0.25,metalness:0.85}));
body.scale.set(1,0.32,1);
const dome=new THREE.Mesh(new THREE.SphereGeometry(0.13,16,10,0,Math.PI*2,0,Math.PI/2), new THREE.MeshStandardMaterial({color:0xbdf0ff,emissive:0x46c4ff,emissiveIntensity:1.4,roughness:0.15,metalness:0.4,transparent:true,opacity:0.9}));
dome.position.y=0.055;
const rim=new THREE.Mesh(new THREE.TorusGeometry(0.215,0.028,8,26), new THREE.MeshBasicMaterial({color:0x9fe8ff,transparent:true,opacity:0.9}));
rim.rotation.x=Math.PI/2;
// searchlight: cone apex at the saucer (top), wide base sweeping DOWN to the boat
const beam=new THREE.Mesh(new THREE.ConeGeometry(0.42,1.7,22,1,true), new THREE.MeshBasicMaterial({color:0x8fe0ff,transparent:true,opacity:0.14,side:THREE.DoubleSide,depthWrite:false,blending:THREE.AdditiveBlending}));
beam.position.y=-0.9; // hangs below the saucer
u.add(beam,body,rim,dome);
ufo.add(u); saucers.push({g:u,beam,dome,rim,ang:i/5*Math.PI*2,spin:0.4+i*0.12});
}
// ---- celestial patterns: a morphing point-cloud + constellation lines ----
const CN=224;
const cur=new Float32Array(CN*3), targ=new Float32Array(CN*3);
const pgeo=new THREE.BufferGeometry(); pgeo.setAttribute('position',new THREE.BufferAttribute(cur,3));
const stars=new THREE.Points(pgeo,new THREE.PointsMaterial({color:0xfff1cf,size:0.055,transparent:true,opacity:0.95,depthWrite:false,blending:THREE.AdditiveBlending}));
const MAXSEG=110, lpos=new Float32Array(MAXSEG*2*3), lpair=[];
const lgeo=new THREE.BufferGeometry(); lgeo.setAttribute('position',new THREE.BufferAttribute(lpos,3));
const lines=new THREE.LineSegments(lgeo,new THREE.LineBasicMaterial({color:0x9fd0ff,transparent:true,opacity:0.28,depthWrite:false,blending:THREE.AdditiveBlending}));
const celest=new THREE.Group(); celest.visible=false; celest.add(stars,lines); group.add(celest);
// pattern builders write into targ[] and rebuild the constellation line pairs
const PATTERNS=['galaxy','orbits','shell','starburst'];
function buildPattern(name){
for(let i=0;i<CN;i++){
let x=0,y=0,z=0; const f=i/CN;
if(name==='galaxy'){ const arm=i%3, a=f*7.5+arm*2.094, r=0.18+f*1.35; x=Math.cos(a)*r; y=Math.sin(a)*r*0.55; z=Math.sin(i*0.7)*0.14; }
else if(name==='orbits'){ const ring=i%5, a=(i/5)*0.9+ring*1.3, r=0.5+ring*0.22, tilt=ring*0.5; x=Math.cos(a)*r; y=Math.sin(a)*r*Math.cos(tilt); z=Math.sin(a)*r*Math.sin(tilt); }
else if(name==='shell'){ const gr=(1+Math.sqrt(5))/2, th=2*Math.PI*i/gr, ph=Math.acos(1-2*(i+0.5)/CN), r=1.25; x=Math.sin(ph)*Math.cos(th)*r; y=Math.cos(ph)*r; z=Math.sin(ph)*Math.sin(th)*r; }
else { const pt=Math.floor(f*10), sub=(f*10)%1, base=(pt%2?0.45:1.3), a=pt*Math.PI/5; const r=base*(1-sub*0.15); x=Math.cos(a)*r; y=Math.sin(a)*r; z=(Math.random()-0.5)*0.18; } // starburst
targ[i*3]=x; targ[i*3+1]=y; targ[i*3+2]=z;
}
// nearest-neighbour constellation edges (from the target shape)
lpair.length=0;
for(let i=0;i<CN && lpair.length<MAXSEG;i++){
let bj=-1,bd=1e9;
for(let j=i+1;j<CN;j++){ const dx=targ[i*3]-targ[j*3],dy=targ[i*3+1]-targ[j*3+1],dz=targ[i*3+2]-targ[j*3+2],d=dx*dx+dy*dy+dz*dz; if(d<bd){bd=d;bj=j;} }
if(bj>=0 && bd<0.16) lpair.push([i,bj]);
}
}
let patIdx=0; buildPattern(PATTERNS[0]);
for(let i=0;i<CN*3;i++) cur[i]=targ[i]*0.2; // start collapsed toward the core
function cyclePattern(){ patIdx=(patIdx+1)%PATTERNS.length; buildPattern(PATTERNS[patIdx]); }
const l1=new THREE.PointLight(0xff8a4a,1.3,60); l1.position.set(4,3,5); scene.add(l1);
const l2=new THREE.PointLight(0xf2b64a,1.0,60); l2.position.set(-4,-1,4); scene.add(l2);
const l3=new THREE.PointLight(0x7fd0ff,0.0,60); l3.position.set(0,3,4); scene.add(l3); // cool light, rises in UFO/celestial modes
scene.add(new THREE.AmbientLight(0x402028,0.9));
const col=new THREE.Color();
addEventListener('resize',()=>{ W=innerWidth;H=innerHeight; camera.aspect=W/H; camera.updateProjectionMatrix(); renderer.setSize(W,H,false); });
// ---- effect state machine: 'none' (orb), 'ufo', 'celestial', 'bloom' ----
let fx='none', fxT=0, fxDur=7, w=0, seenChange=(window.SRM&&window.SRM.change)||0;
let ai=false, quietT=0;
function enterFx(name,dur){ fx=name; fxT=0; fxDur=dur||7; if(name==='celestial') cyclePattern(); }
// AI VJ picks the next effect from the live audio; when off, a noticeable change still splits the orb
function director(kind,level,vocal,bpm){
if(ai){
if(level>0.55 || bpm>132) enterFx('ufo',6);
else if(vocal<0.24 || kind==='vocal-out') enterFx('celestial',9);
else enterFx(Math.random()<0.55?'ufo':'bloom', 5);
} else {
if(kind==='vocal-out') enterFx('celestial',8); // instrumental stretch -> stars
else enterFx('ufo',7); // any other noticeable change -> 5 UFOs
}
}
// click the orb to hand it to the AI VJ (autonomous, reads the live audio and choreographs itself)
function orbScreen(){ const v=group.position.clone().project(camera); return {x:(v.x*0.5+0.5)*W, y:(-v.y*0.5+0.5)*H, r:Math.max(60,Math.min(W,H)*0.16)}; }
addEventListener('pointerdown', e=>{
const o=orbScreen();
if(Math.hypot(e.clientX-o.x, e.clientY-o.y) < o.r){
ai=!ai; e.stopPropagation();
if(ai){ enterFx('celestial',9); if(window.SRM) window.SRM.aiVJ=true; }
else { fx='none'; if(window.SRM) window.SRM.aiVJ=false; }
const lv=document.getElementById('live'); if(lv) lv.textContent = ai ? 'The orb is taken over by the AI VJ — it choreographs UFOs, celestial patterns and blooms to the music.' : 'AI VJ released. The orb responds to song changes on its own.';
}
}, true);
const easeOut=x=>1-Math.pow(1-x,3);
let last=performance.now();
let _orbStatic=false;
function loop(now){ requestAnimationFrame(loop);
const dt=Math.min(0.05,((now||performance.now())-last)/1000); last=now||performance.now();
const s=window.SRM||{}, t=(now||performance.now())/1000;
// Honor reduced motion (WCAG 2.3.3): draw one calm static frame, then stop spinning + re-rendering.
if(s.reduced){
if(!_orbStatic){ orb.visible=true; ufo.visible=false; celest.visible=false;
orb.rotation.set(Math.sin(t*0.3)*0.18,0,0); orb.scale.setScalar(1);
core.material.emissiveIntensity=0.7; l3.intensity=0; renderer.render(scene,camera); _orbStatic=true; }
return;
}
_orbStatic=false;
const pulse=s.pulse||0, level=(s.react?(s.level||0):0), vocal=(s.react?(s.vocal||0):0), bpm=s.bpm||0;
const idle=0.5+0.5*Math.sin(t*1.2);
const energy=s.react ? (level*0.6+pulse*0.7) : idle*0.12;
// fire effects on a NOTICEABLE song change; the AI VJ also self-triggers on quiet stretches
const chg=(s.change||0);
if(chg>seenChange){ seenChange=chg; director(s.changeKind||'', level, vocal, bpm); }
if(s.react && level<0.16 && vocal<0.18){ quietT+=dt; if(quietT>9 && fx==='none'){ enterFx('celestial', ai?10:7); quietT=0; } } else quietT=0;
// blend weight w: ramp up while the effect holds, ramp back down, then release to the plain orb
const active = fx!=='none' && fxT<fxDur;
if(fx!=='none'){ fxT+=dt; if(fxT>=fxDur+0.7 && w<0.02){ fx='none'; } }
w += ((active?1:0)-w)*Math.min(1,dt*4.5);
const ew=easeOut(Math.max(0,Math.min(1,w)));
// ---- the plain orb (always present; shrinks away as an effect takes hold) ----
orb.visible = w<0.995;
orb.rotation.y+=0.008+energy*0.02; orb.rotation.x=Math.sin(t*0.3)*0.18;
const baseScale=(1+energy*0.35)*(fx==='bloom'? (1+ew*(0.8+pulse*1.4)) : (1-ew));
orb.scale.setScalar(Math.max(0.0001,baseScale));
core.material.emissiveIntensity=0.5+energy*1.6 + (fx==='bloom'?ew*2.5:0);
const hue=(28 + Math.sin(t*0.2)*16 + level*40)%360;
wire.material.color.setHSL(((hue+18)%360)/360,0.85,0.6);
col.setHSL(hue/360,0.85,0.5); core.material.emissive.copy(col);
l3.intensity = ew*1.4;
// ---- UFO formation ----
ufo.visible = (fx==='ufo') && w>0.002;
if(ufo.visible){
const spread=0.35+ew*0.95, hover=1.6; // saucers hover above the boat then beam down
for(let i=0;i<saucers.length;i++){ const S5=saucers[i];
const a=S5.ang + t*0.25;
S5.g.position.set(Math.cos(a)*spread, (i-2)*0.06 + Math.sin(t*1.3+i)*0.08, Math.sin(a)*spread*0.65);
S5.g.rotation.y += (S5.spin+pulse*2.0)*dt*2.2;
S5.g.scale.setScalar(ew);
S5.beam.material.opacity = (0.10+pulse*0.22+level*0.14)*ew; // beams throb on the beat
S5.beam.scale.y = 1+pulse*0.5 + Math.sin(t*3+i)*0.08;
const bh=(200+ i*26 + (bpm||0))%360; S5.dome.material.emissive.setHSL(bh/360,0.9,0.6);
}
}
// ---- celestial point-cloud ----
celest.visible = (fx==='celestial') && w>0.002;
if(celest.visible){
for(let i=0;i<CN*3;i++) cur[i]+=(targ[i]-cur[i])*Math.min(1,dt*2.6);
pgeo.attributes.position.needsUpdate=true;
for(let k=0;k<lpair.length;k++){ const a=lpair[k][0]*3,b=lpair[k][1]*3,o=k*6;
lpos[o]=cur[a];lpos[o+1]=cur[a+1];lpos[o+2]=cur[a+2]; lpos[o+3]=cur[b];lpos[o+4]=cur[b+1];lpos[o+5]=cur[b+2]; }
lgeo.setDrawRange(0,lpair.length*2); lgeo.attributes.position.needsUpdate=true;
celest.rotation.y += (0.12+energy*0.4)*dt; celest.rotation.x=Math.sin(t*0.2)*0.25;
celest.scale.setScalar(ew*(1+pulse*0.12));
stars.material.opacity=0.7+0.3*Math.sin(t*4)+pulse*0.3;
stars.material.color.setHSL(((t*10+level*60)%360)/360,0.5,0.85);
// re-morph to the next celestial pattern on a strong beat, a few seconds in
if(fxT>3 && pulse>0.85 && Math.random()<0.04) cyclePattern();
}
renderer.render(scene,camera);
}
requestAnimationFrame(loop);
})();
</script>
</body>
</html>