← back to Scarlet Riverboat Masquerade
snapshot before restart: preserve in-flight work
a5a2c5a70fd282e55bf9187762f038b3705d2920 · 2026-09-04 22:38:51 -0700 · Steve
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gbmuMHgCLDEPM4TpfAr4G
Files touched
M index.htmlM tests/live-happy.cjs
Diff
commit a5a2c5a70fd282e55bf9187762f038b3705d2920
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Sep 4 22:38:51 2026 -0700
snapshot before restart: preserve in-flight work
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gbmuMHgCLDEPM4TpfAr4G
---
index.html | 429 +++++++++++++++++++++++++++++++++++++++++++++------
tests/live-happy.cjs | 41 +++--
2 files changed, 409 insertions(+), 61 deletions(-)
diff --git a/index.html b/index.html
index 768f6b6..6fba8f1 100644
--- a/index.html
+++ b/index.html
@@ -103,14 +103,26 @@
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:.05em;
- background:rgba(40,12,16,.7);color:var(--ink);border:1px solid var(--panel-edge);
- border-radius:10px;padding:9px 13px;cursor:pointer;transition:background .2s,color .2s;
- display:inline-flex;align-items:center;gap:7px;white-space:nowrap;
+ 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{background:rgba(70,20,26,.9);color:var(--gold)}
- .btn[aria-pressed="true"]{background:var(--gold);color:#2a0a0e;border-color:var(--gold);font-weight:600}
- .toggle{display:flex;flex-direction:column;gap:6px}
+ .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}
@@ -175,8 +187,6 @@
.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}
- .mr-yr.err{opacity:.55;border-style:dashed}
- .mr-yr.err .yc{color:var(--ink-dim)}
@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}
@@ -239,6 +249,10 @@
}
@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>
@@ -268,9 +282,9 @@
</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="tablist" aria-label="Choose a music source"></div>
+ <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="listbox" aria-label="Browse shows by year"></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>
@@ -403,6 +417,18 @@ const CHAPTER_SECONDS = 55; // dwell before the boat drifts to the next chapter
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 = [
@@ -932,6 +958,32 @@ function drawBoatOverhead(accent,lan,glow){
}
// ---------- 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;
@@ -947,17 +999,35 @@ function drawAnimals(){
const t=S.t;
const scaleA = S.cam==='deck'?1.15:1;
+ const hop=_beatHop();
- fox(W*0.16, baseY-6, 1*scaleA, t, accent);
- heron(W*0.40, baseY-4, 1*scaleA, t, accent);
- rabbit(W*0.63, baseY-4, 1*scaleA, t, accent);
- bearAndFireflies(W*0.85, baseY-2, 1.1*scaleA, t, accent);
- drawBearCubs(t, scaleA, baseY);
- drawSongbirds(t, scaleA, baseY);
+ // 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;
@@ -1009,7 +1079,7 @@ function rabbit(x,y,s,t,accent){
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 + ((t*22)% (S.W*0.14)) - S.W*0.05, y+hopY);
+ 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();
@@ -1095,8 +1165,12 @@ 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 x = (N===1) ? W*0.16 : margin + span*(i/(N-1));
+ 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]);
}
@@ -1131,10 +1205,15 @@ function songbird(x,y,s,t,ph){
}
}
function drawSongbirds(t,scaleA,baseY){
- const W=S.W; // kept clear of the center control dock
- songbird(W*0.075, baseY-9, 1.0*scaleA, t, 0.0);
- songbird(W*0.235, baseY-7, 0.9*scaleA, t, 2.1);
- songbird(W*0.93, baseY-11, 1.05*scaleA, t, 4.2);
+ 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 ----------
@@ -1237,11 +1316,14 @@ function drawPtero(dt){
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
@@ -1465,13 +1547,26 @@ requestAnimationFrame(frame);
(() => {
"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:'gd72-08-27.sbd.orf.3328.sbeok.shnf', date:'August 27, 1972', title:'Veneta', sub:'Oregon · field, heat, and open sky'},
- {id:'gd74-06-23.sbd.cribbs.16780.sbeok.shnf', date:'June 23, 1974', title:'Jai-Alai Fronton', sub:'Miami · Wall of Sound era'},
- {id:'gd77-05-08.sbd.hicks.4982.sbeok.shnf', date:'May 8, 1977', title:'Cornell', sub:'Ithaca · Barton Hall'},
- {id:'gd90-03-29.aud-fob.set2.unknown.1317.sbeok.shnf',date:'March 29, 1990', title:'Nassau', sub:'Uniondale · late-era expansion'}
+ {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 = 3; // Cornell '77
+const DEFAULT_SHOW = 7; // Cornell '77
const $ = id => document.getElementById(id);
const audio = $('mrAudio'), room = $('musicroom'), sel = $('mrShow'), live = $('live');
@@ -1684,7 +1779,18 @@ function setPlay(on){
// 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{
@@ -1698,18 +1804,47 @@ function ensureGraph(){
}
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);
- let sum=0, n=Math.max(1,Math.floor(_freq.length/6)); // low-end (bass) energy
+ 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
+ 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)
+ 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);
}
@@ -1726,7 +1861,9 @@ if(reactBtn) reactBtn.addEventListener('click',()=>{
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));
+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',()=>{
@@ -1807,7 +1944,7 @@ function mrApplyHeat(src){
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, srcId=src.id;
+ 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;
@@ -1815,13 +1952,12 @@ function buildMap(){
mapEl.innerHTML='';
YEARS.forEach(y=>{
const b=document.createElement('button'); b.type='button'; b.className='mr-yr loading';
- b.dataset.y=String(y); b.setAttribute('role','option'); b.title=y+' — counting…';
+ 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);
@@ -1829,7 +1965,7 @@ function buildMap(){
// 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:'+srcId+' AND year:'+y)+'&rows=0&output=json', sig, breaker)
+ 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);
@@ -1850,11 +1986,11 @@ function buildMap(){
if(srcEl){
srcEl.innerHTML=Object.entries(window.MR_SOURCES).map(([k,s])=>
- `<button type="button" data-src="${k}" class="${k===window.MR_SRC?'on':''}" role="tab" aria-selected="${k===window.MR_SRC}"><b>${s.label}</b><span class="yr">${s.years}</span></button>`).join('');
+ `<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-selected',String(on)); });
+ [...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:'');
});
@@ -1889,8 +2025,55 @@ mrToggle.addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.pre
// 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).
-// cue the default night (no autoplay — the source policy is user-initiated playback)
-loadShow(DEFAULT_SHOW);
+// ---- 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)?$/,'');
@@ -1912,28 +2095,172 @@ if(_p.endsWith('/room') || /dead\.agentabrams/i.test(location.hostname)){
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}));
- group.add(core,wire);
+ 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); });
- function loop(){ requestAnimationFrame(loop);
- const s=window.SRM||{}, t=performance.now()/1000;
- const pulse=s.pulse||0, level=(s.react?(s.level||0):0);
- const idle=0.5+0.5*Math.sin(t*1.2); // gentle breathing when no music
+
+ // ---- 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;
- group.rotation.y+=0.008+energy*0.02; group.rotation.x=Math.sin(t*0.3)*0.18;
- group.scale.setScalar(1+energy*0.35);
- core.material.emissiveIntensity=0.5+energy*1.6;
- const hue=(28 + Math.sin(t*0.2)*16 + level*40)%360; // warm ember range, shifts with the music
+
+ // 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);
}
- loop();
+ requestAnimationFrame(loop);
})();
</script>
</body>
diff --git a/tests/live-happy.cjs b/tests/live-happy.cjs
index 1203de4..aa14e34 100644
--- a/tests/live-happy.cjs
+++ b/tests/live-happy.cjs
@@ -34,23 +34,44 @@ function serve() {
try {
await page.goto(base, { waitUntil: 'load', timeout: 60000 });
- await page.waitForFunction(() => document.querySelectorAll('.mr-yr').length > 0, { timeout: 30000 });
+ await page.waitForFunction(() => document.querySelectorAll('.mr-yr').length > 0, undefined, { timeout: 30000 });
const tiles = await page.$$eval('.mr-yr', els => els.length);
pass(tiles === 31, `GD source renders 31 year tiles (got ${tiles})`);
- // wait for real counts to land (allow generous time — capped pool + real network)
- await page.waitForFunction(() => {
- const el = document.querySelector('.mr-yr[data-y="1994"] .yc');
- const t = el ? (el.textContent || '').trim() : '';
- return /^[0-9][0-9,]*$/.test(t) && parseInt(t.replace(/,/g, ''), 10) > 0;
- }, { timeout: 60000 });
+ // Phase 1 DEGRADES GRACEFULLY: when archive.org is flaky the batch circuit-breaker
+ // short-circuits the remaining year-count fetches (those tiles show "—", never a
+ // false 0) and every good count is cached. A real visitor reloads and the cached-good
+ // tiles paint instantly while the still-missing years re-fetch with a fresh breaker.
+ // This harness exercises exactly that recovery — reload a bounded number of times
+ // (the srm.counts.v1 cache persists across reload in this same context) until the
+ // tour history is well-covered — rather than assuming one breaker-limited pass fills
+ // all 31 against a nonprofit archive that is intermittently slow.
+ const settleOne = async () => {
+ // resolve as soon as no tile is still "loading" (breaker short-circuits fast) or
+ // the whole-map error state appears; bounded so a hung archive can't stall forever.
+ await page.waitForFunction(() => {
+ if (document.querySelector('.mr-map-err')) return true;
+ const t = [...document.querySelectorAll('.mr-yr')];
+ return t.length > 0 && t.every(el => !el.classList.contains('loading'));
+ }, undefined, { timeout: 45000 }).catch(() => {});
+ };
+ const populatedNow = () => page.$$eval('.mr-yr .yc', els => els.filter(e => /^[0-9][0-9,]*$/.test((e.textContent||'').trim()) && +(e.textContent.replace(/,/g,'')) > 0).length);
+ let populated = 0;
+ for (let attempt = 1; attempt <= 4; attempt++) {
+ await settleOne();
+ populated = await populatedNow();
+ if (populated >= 28) break;
+ if (attempt < 4) {
+ await page.reload({ waitUntil: 'load', timeout: 60000 });
+ await page.waitForFunction(() => document.querySelectorAll('.mr-yr').length > 0, undefined, { timeout: 30000 });
+ }
+ }
const y1994 = await page.$eval('.mr-yr[data-y="1994"] .yc', e => (e.textContent || '').trim());
const n1994 = parseInt(y1994.replace(/,/g, ''), 10);
pass(n1994 >= 400 && n1994 <= 520, `1994 shows a real count ~= 460 (got ${y1994})`);
- // count how many of the 31 tiles populated with a real number
- const populated = await page.$$eval('.mr-yr .yc', els => els.filter(e => /^[0-9][0-9,]*$/.test((e.textContent||'').trim()) && +(e.textContent.replace(/,/g,'')) > 0).length);
+ // how many of the 31 tiles populated with a real number (across cache-backed reloads)
pass(populated >= 28, `most GD tiles populated with real counts (${populated}/31)`);
const zero = await page.$$eval('.mr-yr .yc', els => els.some(e => (e.textContent || '').trim() === '0'));
pass(!zero, `no GD tile shows a false "0"`);
@@ -64,7 +85,7 @@ function serve() {
await page.waitForFunction(() => {
const tiles = document.querySelectorAll('.mr-yr');
return tiles.length >= 8 && [...tiles].some(t => /^[0-9][0-9,]*$/.test((t.querySelector('.yc')?.textContent||'').trim()));
- }, { timeout: 60000 });
+ }, undefined, { timeout: 60000 });
const dcTiles = await page.$$eval('.mr-yr', els => els.length);
pass(dcTiles >= 8 && dcTiles <= 9, `D&C source loads (2015-2023 → ${dcTiles} tiles)`);
← ab81288 riverboat: Phase 1 archive.org reliability — cap year-count
·
back to Scarlet Riverboat Masquerade
·
(newest)