← back to Aurora Drift
index.html
419 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="Aurora Drift — an animated generative gallery of northern lights drifting over a frozen lake at night: aurora curtains, a still reflection, a lone cabin, and falling snow.">
<meta name="color-scheme" content="dark">
<title>Aurora Drift — A Northern Night</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='%2305081a'/><path d='M2 6 Q8 2 14 6' fill='none' stroke='%232fff9a' stroke-width='1.4'/><path d='M2 9 Q8 5 14 9' fill='none' stroke='%232fe6ff' stroke-width='1.2'/></svg>">
<style>
:root{
--ink:#e6f0f4; --dim:#9fb6c4; --aur:#2fe6ff; --aur2:#2fff9a; --gold:#ffcf6e;
--panel:rgba(8,16,26,0.72); --panel-edge:rgba(70,150,190,0.3); --focus:#8fe0ff;
--serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;
--sans:"Avenir Next","Segoe UI",system-ui,-apple-system,sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#03060f;color:var(--ink);font-family:var(--sans);overflow:hidden}
#stage{position:fixed;inset:0}
canvas{position:absolute;inset:0;width:100%;height:100%}
#orb{z-index:4;pointer-events:none}
.grain{position:fixed;inset:0;pointer-events:none;z-index:5;opacity:.06;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{position:fixed;top:-60px;left:12px;z-index:20;background:var(--aur);color:#04121a;padding:10px 16px;border-radius:8px;font-weight:600;text-decoration:none;transition:top .2s}
.skip:focus{top:12px}
.chrome-toggle{position:fixed;top:20px;left:20px;z-index:12;font-size:18px;padding:8px 13px;line-height:1}
.stagelink{position:fixed;top:22px;left:50%;transform:translateX(-50%);z-index:12;font-family:var(--serif);font-style:italic;font-size:12.5px;color:var(--dim);text-decoration:none;background:var(--panel);border:1px solid var(--panel-edge);border-radius:999px;padding:6px 15px;backdrop-filter:blur(6px);transition:color .3s}
.stagelink:hover{color:var(--aur2)}
@media(max-width:820px){ .stagelink{display:none} }
#stage.hide-chrome .dock,#stage.hide-chrome .cams,#stage.hide-chrome .label-toggle,#stage.hide-chrome .museum{display:none!important}
.titleplate{position:fixed;top:74px;left:26px;z-index:6;max-width:min(52vw,560px);pointer-events:none;text-shadow:0 2px 18px rgba(0,0,0,.75)}
.titleplate .kicker{letter-spacing:.42em;text-transform:uppercase;font-size:11px;color:var(--aur2);opacity:.95;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}
.titleplate .sub{font-family:var(--serif);font-size:14px;color:var(--dim);margin-top:8px;font-style:italic}
.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(70,150,190,.2);padding-left:18px}
.field{display:flex;flex-direction:column;gap:5px;min-width:92px}
.field label{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim)}
.field .val{font-family:var(--serif);font-style:italic;color:var(--aur2);font-size:12px}
input[type=range]{-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:linear-gradient(90deg,var(--aur2),var(--aur));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(--aur);cursor:pointer;box-shadow:0 0 8px rgba(47,230,255,.6)}
input[type=range]::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--ink);border:2px solid var(--aur);cursor:pointer}
.btn{font-family:var(--sans);font-size:12px;letter-spacing:.05em;background:rgba(14,28,44,.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}
.btn:hover{background:rgba(24,50,80,.9);color:var(--aur2)}
.btn[aria-pressed="true"]{background:var(--aur2);color:#04121a;border-color:var(--aur2);font-weight:600}
.toggle{display:flex;flex-direction:column;gap:6px}
.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}
.dock-sel{background:rgba(14,28,44,.7);color:var(--ink);border:1px solid var(--panel-edge);border-radius:8px;padding:6px 8px;font-size:12px;font-family:var(--serif);cursor:pointer}
.label-toggle{position:fixed;right:26px;bottom:20px;z-index:7}
.museum{position:fixed;right:26px;bottom:66px;z-index:8;width:min(88vw,360px);max-height:74vh;overflow:auto;
background:linear-gradient(180deg,rgba(10,20,32,.96),rgba(5,10,18,.96));border:1px solid var(--panel-edge);border-radius:14px;padding:20px 22px;box-shadow:0 18px 50px rgba(0,0,0,.6);font-size:13px;line-height:1.55;color:var(--dim)}
.museum[hidden]{display:none}
.museum h2{font-family:var(--serif);font-style:italic;color:var(--aur2);font-size:18px;margin:0 0 4px}
.museum .meta{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--dim);margin-bottom:12px;opacity:.8}
.museum p{margin:0 0 10px}.museum strong{color:var(--ink)}
.museum .keys{margin:14px 0 0;padding:12px 0 0;border-top:1px solid rgba(70,150,190,.2);font-size:11px;line-height:1.7}
.museum .keys b{display:inline-block;min-width:74px;color:var(--aur2);font-family:var(--serif);font-style:italic}
: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}.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}.dock{gap:10px;padding:10px 12px}.group + .group{padding-left:10px}.field{min-width:74px}}
</style>
</head>
<body>
<a href="#dock" class="skip">Skip to controls</a>
<main id="stage" class="hide-chrome" role="application" aria-label="Aurora Drift — an animated northern-lights gallery you can steer">
<h1 class="sr">Aurora Drift — an animated generative gallery of northern lights</h1>
<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>
<button class="btn chrome-toggle" id="chromeBtn" aria-pressed="false" aria-label="Menu — show or hide controls">☰</button>
<a class="stagelink" href="https://stage.agentabrams.com" aria-label="Back to The Main Stage">← The Main Stage</a>
<div class="titleplate">
<p class="kicker">A Northern Night · An Animated Gallery</p>
<h1 aria-hidden="true">Aurora<br>Drift</h1>
<p class="sub">A frozen lake · under the lights</p>
</div>
<div class="cams" role="group" aria-label="Camera view">
<button class="btn" data-cam="wide" aria-pressed="true">◈ The Lake</button>
<button class="btn" data-cam="sky" aria-pressed="false">◈ Skyward</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="reduceBtn" aria-pressed="false">◐ Reduced Motion</button>
</div>
<div class="toggle">
<button class="btn" id="trailBtn" aria-pressed="true">✧ Motion Trails</button>
<button class="btn" id="reflectBtn" aria-pressed="true">◇ Reflection</button>
<button class="btn" id="soundBtn" aria-pressed="false">🔊 Sound</button>
</div>
</div>
<div class="group">
<div class="field"><label for="mood">Soundscape <span class="val" id="moodV">Solar Wind</span></label><select id="mood" class="dock-sel"><option value="solar">Solar Wind</option><option value="glacier">Glacier</option><option value="boreal">Boreal Green</option><option value="violet">Violet Veil</option></select></div>
<div class="field"><label for="vol">Volume <span class="val" id="volV">55%</span></label><input type="range" id="vol" min="0" max="100" value="55"></div>
<div class="field"><label for="tempo">Drift <span class="val" id="tempoV">Gentle</span></label><input type="range" id="tempo" min="0" max="200" value="100"></div>
<div class="field"><label for="aurora">Aurora <span class="val" id="auroraV">Vivid</span></label><input type="range" id="aurora" min="0" max="100" value="78"></div>
<div class="field"><label for="snow">Snowfall <span class="val" id="snowV">Light</span></label><input type="range" id="snow" min="0" max="100" value="45"></div>
<div class="field"><label for="stars">Stars <span class="val" id="starsV">Many</span></label><input type="range" id="stars" min="0" max="100" value="70"></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>Aurora Drift</h2>
<div class="meta">Generative Canvas · Authored Visual Rhythm · Single File</div>
<p>Northern lights drift over a still, frozen lake. Curtains of green and violet fold and unfold across the night; a lone cabin glows on the far shore; snow falls and the whole sky returns, mirrored, in the ice.</p>
<p><strong>The motion is composed, not measured</strong> — the aurora folds on an <em>authored cadence</em> written into the drawing loop, never an analysis of sound. There is no audio; the rhythm is the slow breathing of light on ice.</p>
<p>An original northern night — no borrowed photographs, logos, or art. The third room of The Main Stage, a cool counterpoint to the crimson river and the burning playa.</p>
<div class="keys">
<div><b>Space</b> pause / play</div>
<div><b>M</b> reduced motion</div>
<div><b>T</b> motion trails</div>
<div><b>R</b> reflection</div>
<div><b>C</b> show / hide controls</div>
<div><b>1 2</b> the lake · skyward</div>
<div><b>Click</b> a shooting star</div>
<div><b>L</b> this museum label</div>
</div>
</aside>
<p class="sr" aria-live="polite" id="live">A frozen lake under the northern lights.</p>
</main>
<script>
(() => {
"use strict";
const sky=document.getElementById('sky'), scene=document.getElementById('scene');
const sctx=sky.getContext('2d'), ctx=scene.getContext('2d');
const live=document.getElementById('live');
const S={ t:0, playing:true, reduced:false, trails:true, reflect:true,
tempo:1.0, aurora:0.78, snow:0.45, stars:0.70, cam:'wide',
sound:false, volume:0.55, mood:'solar', W:0,H:0,DPR:1 };
try{ window.__auroraS=S; }catch(e){}
// soundscape moods — each retunes the drone AND swaps the aurora palette
const MOODS={
solar: { name:'Solar Wind', cols:[[47,255,154],[47,230,255],[255,120,200],[255,210,110]], root:98 },
glacier:{ name:'Glacier', cols:[[120,220,255],[47,230,255],[160,200,255],[90,255,230]], root:110 },
boreal: { name:'Boreal Green', cols:[[47,255,154],[120,255,200],[47,230,255],[90,255,140]], root:82 },
violet: { name:'Violet Veil', cols:[[140,120,255],[200,120,255],[120,160,255],[170,110,230]], root:65 }
};
// ---- ambient synth pad (original, no audio files) ----
let actx=null, master=null, padOsc=[], padGain=null, padLfo=null, padTrem=null, analyser=null, freqData=null;
function ensureAudio(){ if(actx) return true; try{ actx=new (window.AudioContext||window.webkitAudioContext)(); master=actx.createGain(); master.gain.value=S.volume;
analyser=actx.createAnalyser(); analyser.fftSize=64; analyser.smoothingTimeConstant=0.8; freqData=new Uint8Array(analyser.frequencyBinCount);
master.connect(analyser); master.connect(actx.destination); return true; }catch(e){ return false; } }
function stopPad(){ padOsc.forEach(o=>{try{o.stop();}catch(e){}}); padOsc=[]; [padLfo,padTrem].forEach(o=>{if(o){try{o.stop();}catch(e){}}}); padLfo=padTrem=null; padGain=null; }
function startPad(){
if(!actx) return; stopPad();
const root=MOODS[S.mood].root, t=actx.currentTime;
const filt=actx.createBiquadFilter(); filt.type='lowpass'; filt.frequency.value=520; filt.Q.value=5;
const pg=actx.createGain(); pg.gain.value=0.0001; pg.connect(filt); filt.connect(master);
padLfo=actx.createOscillator(); const lg=actx.createGain(); padLfo.frequency.value=0.06; lg.gain.value=260; padLfo.connect(lg); lg.connect(filt.frequency); padLfo.start();
[1,1.5,2,3].forEach((mult,i)=>{ const o=actx.createOscillator(); o.type=i>2?'triangle':'sine'; o.frequency.value=root*mult*(1+(Math.random()-0.5)*0.004); o.connect(pg); o.start(); padOsc.push(o); });
pg.gain.linearRampToValueAtTime(0.14, t+2.5); // slow swell in
padGain=pg;
// gentle rhythmic tremolo so the lights have a beat to follow (~54 bpm pulse)
padTrem=actx.createOscillator(); const tg=actx.createGain(); padTrem.frequency.value=0.9; tg.gain.value=0.05; padTrem.connect(tg); tg.connect(pg.gain); padTrem.start();
}
if(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) S.reduced=true;
const announce=m=>{ live.textContent=m; };
function clamp(v,a,b){return Math.max(a,Math.min(b,v));}
let starsArr=[];
function resize(){
S.DPR=Math.min(2,devicePixelRatio||1); S.W=innerWidth; S.H=innerHeight;
[sky,scene].forEach(c=>{ c.width=Math.round(S.W*S.DPR); c.height=Math.round(S.H*S.DPR); c.getContext('2d').setTransform(S.DPR,0,0,S.DPR,0,0); });
starsArr=[]; const n=Math.round(S.W*S.H/3400);
for(let i=0;i<n;i++) starsArr.push({x:Math.random()*S.W, y:Math.random()*S.H*0.58, r:Math.random()*1.3+0.2, tw:Math.random()*6.28});
skyDirty=true;
}
addEventListener('resize',resize);
const HORIZON=()=>S.H*0.62;
let skyDirty=true;
function drawSky(){
const W=S.W,H=S.H;
const g=sctx.createLinearGradient(0,0,0,H);
g.addColorStop(0,'#04070f'); g.addColorStop(0.4,'#081a2a'); g.addColorStop(0.62,'#0c2436'); g.addColorStop(0.63,'#061420'); g.addColorStop(1,'#030a12');
sctx.clearRect(0,0,W,H); sctx.fillStyle=g; sctx.fillRect(0,0,W,H);
// stars
const sa=S.stars;
for(const s of starsArr){ const tw=(0.4+0.6*Math.abs(Math.sin(s.tw)))*sa; sctx.globalAlpha=tw; sctx.fillStyle='#eaf2ff'; sctx.beginPath(); sctx.arc(s.x,s.y,s.r,0,7); sctx.fill(); }
sctx.globalAlpha=1;
// moon
const mx=W*0.16,my=H*0.2,mr=30;
const gl=sctx.createRadialGradient(mx,my,3,mx,my,mr*3); gl.addColorStop(0,'rgba(220,235,255,0.45)'); gl.addColorStop(1,'rgba(0,0,0,0)');
sctx.fillStyle=gl; sctx.beginPath(); sctx.arc(mx,my,mr*3,0,7); sctx.fill();
sctx.fillStyle='#e6eeff'; sctx.beginPath(); sctx.arc(mx,my,mr,0,7); sctx.fill();
skyDirty=false;
}
// ---- aurora curtains ----
let AUR_COLS=MOODS[S.mood].cols; // palette follows the selected soundscape
function auroraPath(g, yTop, band, seed, camScale){
const W=S.W; const pts=[];
const step=W/22;
for(let x=-step;x<=W+step;x+=step){
const wob=Math.sin(x*0.004+seed+S.t*0.25)*40 + Math.sin(x*0.011+seed*2+S.t*0.15)*22;
pts.push([x, yTop+wob]);
}
return pts;
}
function drawAuroraInto(c, flip){
if(S.aurora<=0.02) return;
const W=S.W,H=S.H, hz=HORIZON(), intensity=S.aurora*(0.72+(S.pulse||0)*0.55); // aurora brightens to the beat
c.save(); c.globalCompositeOperation='lighter';
const bands=4;
for(let b=0;b<bands;b++){
const col=AUR_COLS[b%AUR_COLS.length];
const yTop=H*(0.16+b*0.055);
const height=H*(0.30-b*0.02);
const seed=b*2.1;
const pts=auroraPath(c,yTop,0,seed);
// draw vertical gradient ribbon following the wavy top
const grad=c.createLinearGradient(0,yTop-20,0,yTop+height);
grad.addColorStop(0,`rgba(${col[0]},${col[1]},${col[2]},0)`);
grad.addColorStop(0.25,`rgba(${col[0]},${col[1]},${col[2]},${0.16*intensity})`);
grad.addColorStop(1,`rgba(${col[0]},${col[1]},${col[2]},0)`);
c.fillStyle=grad;
c.beginPath(); c.moveTo(pts[0][0], pts[0][1]);
for(const p of pts) c.lineTo(p[0], p[1]);
for(let i=pts.length-1;i>=0;i--) c.lineTo(pts[i][0], pts[i][1]+height);
c.closePath(); c.fill();
// bright lower rim
c.strokeStyle=`rgba(${col[0]},${col[1]},${col[2]},${0.35*intensity})`; c.lineWidth=2;
c.beginPath(); c.moveTo(pts[0][0], pts[0][1]+height*0.62);
for(const p of pts) c.lineTo(p[0], p[1]+height*0.62); c.stroke();
// faint vertical rays
c.globalAlpha=0.12*intensity;
for(let i=1;i<pts.length;i+=2){ c.strokeStyle=`rgb(${col[0]},${col[1]},${col[2]})`; c.lineWidth=6; c.beginPath(); c.moveTo(pts[i][0],pts[i][1]); c.lineTo(pts[i][0],pts[i][1]+height); c.stroke(); }
c.globalAlpha=1;
}
c.restore();
}
// ---- treeline + cabin ----
function drawTreeline(){
const W=S.W,H=S.H, hz=HORIZON();
ctx.fillStyle='#02090e';
ctx.beginPath(); ctx.moveTo(0,hz);
for(let x=0;x<=W;x+=18){ const h=10+((x*7)%22); ctx.lineTo(x, hz-h*0.5); ctx.lineTo(x+9, hz); }
ctx.lineTo(W,hz); ctx.closePath(); ctx.fill();
// a few pointier pines
ctx.fillStyle='#03101a';
for(let i=0;i<14;i++){ const x=(i*97+40)%W, ph=18+((i*13)%16); ctx.beginPath(); ctx.moveTo(x,hz-ph); ctx.lineTo(x-6,hz); ctx.lineTo(x+6,hz); ctx.closePath(); ctx.fill(); }
}
function drawCabin(){
const W=S.W,H=S.H, hz=HORIZON(), x=W*0.7, y=hz-2;
ctx.fillStyle='#0a1520';
ctx.fillRect(x,y-22,40,22);
ctx.beginPath(); ctx.moveTo(x-4,y-22); ctx.lineTo(x+20,y-36); ctx.lineTo(x+44,y-22); ctx.closePath(); ctx.fill();
// warm window
const wx=x+15, wy=y-14, glow=(0.6+0.4*Math.sin(S.t*2))*(0.8+(S.pulse||0)*0.7); // cabin window pulses to the beat
const g=ctx.createRadialGradient(wx,wy,1,wx,wy,20); g.addColorStop(0,`rgba(255,207,110,${0.5*glow})`); g.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(wx,wy,20,0,7); ctx.fill();
ctx.fillStyle=`rgba(255,207,110,${0.9*glow})`; ctx.fillRect(wx-4,wy-4,10,10);
// chimney smoke
if(!S.reduced){ ctx.fillStyle='rgba(200,210,220,0.10)'; for(let i=0;i<5;i++){ const t=S.t*0.4+i; ctx.beginPath(); ctx.arc(x+34+Math.sin(t)*6, y-40-i*10-(S.t*5%18), 4+i, 0,7); ctx.fill(); } }
}
// ---- frozen lake (reflection) ----
function drawLake(){
const W=S.W,H=S.H, hz=HORIZON();
const g=ctx.createLinearGradient(0,hz,0,H); g.addColorStop(0,'#061622'); g.addColorStop(1,'#020a12');
ctx.fillStyle=g; ctx.fillRect(0,hz,W,H-hz);
if(S.reflect && S.aurora>0.02){
// mirror the aurora into the ice, faded + slightly rippled
ctx.save(); ctx.beginPath(); ctx.rect(0,hz,W,H-hz); ctx.clip();
ctx.globalAlpha=0.4; ctx.translate(0,hz*2); ctx.scale(1,-1);
drawAuroraInto(ctx,true);
ctx.restore();
// horizontal ice ripples over the reflection
ctx.strokeStyle='rgba(120,200,230,0.06)';
for(let i=0;i<10;i++){ const y=hz+ (H-hz)*(i/10); ctx.beginPath(); for(let x=0;x<=W;x+=20) ctx.lineTo(x, y+Math.sin(x*0.03+S.t+i)*1.5); ctx.stroke(); }
}
// moon reflection streak
const mx=W*0.16;
const rg=ctx.createLinearGradient(mx,hz,mx,H); rg.addColorStop(0,'rgba(220,235,255,0.14)'); rg.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=rg; ctx.fillRect(mx-14,hz,28,H-hz);
}
// ---- snow ----
let snow=[];
function drawSnow(){
const W=S.W,H=S.H, want=Math.round(S.snow*160);
if(!S.reduced && S.playing) while(snow.length<want) snow.push({x:Math.random()*W,y:Math.random()*H,vy:12+Math.random()*30,vx:(Math.random()-0.5)*10,r:0.7+Math.random()*2,a:0.3+Math.random()*0.5});
ctx.save(); ctx.fillStyle='#eaf4ff';
for(let i=snow.length-1;i>=0;i--){ const p=snow[i]; if(!S.reduced&&S.playing){ p.y+=p.vy*0.016*S.tempo; p.x+=p.vx*0.016; if(p.y>H){p.y=-4;p.x=Math.random()*W;} }
if(i>=want){ snow.splice(i,1); continue; } ctx.globalAlpha=p.a; ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,7); ctx.fill(); }
ctx.restore(); ctx.globalAlpha=1;
}
// ---- shooting stars (click) ----
let shooters=[];
function shoot(x,y){ shooters.push({x,y,vx:220+Math.random()*160,vy:60+Math.random()*80,life:1}); }
function drawShooters(dt){ if(!shooters.length) return; ctx.save(); ctx.globalCompositeOperation='lighter'; ctx.lineCap='round';
for(let i=shooters.length-1;i>=0;i--){ const s=shooters[i]; const px=s.x,py=s.y; s.x+=s.vx*dt; s.y+=s.vy*dt; s.life-=dt*0.7;
if(s.life<=0){ shooters.splice(i,1); continue; }
const g=ctx.createLinearGradient(px,py,s.x,s.y); g.addColorStop(0,'rgba(255,255,255,0)'); g.addColorStop(1,`rgba(200,240,255,${s.life})`);
ctx.strokeStyle=g; ctx.lineWidth=2.4; ctx.beginPath(); ctx.moveTo(px,py); ctx.lineTo(s.x,s.y); ctx.stroke();
ctx.fillStyle=`rgba(255,255,255,${s.life})`; ctx.beginPath(); ctx.arc(s.x,s.y,2,0,7); ctx.fill();
}
ctx.restore(); }
let last=performance.now();
function frame(now){
const dt=Math.min(0.05,(now-last)/1000); last=now;
if(S.playing){ S.t += dt*S.tempo*(S.reduced?0.3:1); }
// beat pulse: from the live audio when sound is on, else a soft authored breathing
if(analyser && S.sound){ analyser.getByteFrequencyData(freqData); let sum=0,n=Math.max(1,freqData.length>>1); for(let i=0;i<n;i++) sum+=freqData[i]; const raw=(sum/n)/255; S.pulse=(S.pulse||0)*0.7+raw*0.3; }
else { S.pulse=0.30+0.30*Math.sin(S.t*1.4); }
if(skyDirty) drawSky();
const W=S.W,H=S.H;
if(S.trails && !S.reduced && S.playing){ ctx.fillStyle='rgba(3,8,16,0.16)'; ctx.fillRect(0,0,W,H); } else ctx.clearRect(0,0,W,H);
drawAuroraInto(ctx,false);
drawTreeline();
drawCabin();
drawLake();
drawSnow();
drawShooters(dt);
requestAnimationFrame(frame);
}
// ---- UI ----
const tempoWords=['Still','Slow','Gentle','Flowing','Rushing'], auroraWords=['Faint','Soft','Vivid','Bright','Blazing'], snowWords=['Clear','Flurries','Light','Steady','Heavy'], starWords=['Few','Some','Many','Dense','Brilliant'];
function word(a,v){return a[Math.min(a.length-1,Math.floor(v*a.length))];}
function slider(id,valId,arr,set){ const el=document.getElementById(id), v=document.getElementById(valId); el.addEventListener('input',e=>{ const val=+e.target.value/100; set(val); v.textContent=word(arr,val); }); }
document.getElementById('tempo').addEventListener('input',e=>{ S.tempo=+e.target.value/100*2; document.getElementById('tempoV').textContent=word(tempoWords,Math.min(0.999,S.tempo/2)); });
slider('aurora','auroraV',auroraWords,v=>S.aurora=v);
slider('snow','snowV',snowWords,v=>S.snow=v);
slider('stars','starsV',starWords,v=>{S.stars=v;skyDirty=true;});
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.'); });
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.':'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.'); });
const reflectBtn=document.getElementById('reflectBtn');
reflectBtn.addEventListener('click',()=>{ S.reflect=!S.reflect; reflectBtn.setAttribute('aria-pressed',S.reflect); announce(S.reflect?'Reflection on.':'Reflection off.'); });
// sound + soundscape selection
const soundBtn=document.getElementById('soundBtn');
soundBtn.addEventListener('click',()=>{
if(!S.sound){ if(!ensureAudio()){ announce('Audio unavailable in this browser.'); return; } if(actx.state==='suspended') actx.resume(); startPad(); }
else { stopPad(); }
S.sound=!S.sound; soundBtn.setAttribute('aria-pressed',S.sound);
announce(S.sound?'Sound on — the drone drifts in.':'Sound off.');
});
const moodEl=document.getElementById('mood');
moodEl.addEventListener('change',()=>{
S.mood=moodEl.value; AUR_COLS=MOODS[S.mood].cols; skyDirty=true;
document.getElementById('moodV').textContent=MOODS[S.mood].name;
if(S.sound) startPad(); // retune the drone
announce(MOODS[S.mood].name+' soundscape.');
});
document.getElementById('vol').addEventListener('input',e=>{ S.volume=+e.target.value/100; if(master) master.gain.value=S.volume; document.getElementById('volV').textContent=e.target.value+'%'; });
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)); announce(b.dataset.cam==='sky'?'Skyward view.':'The lake view.'); }); });
const labelBtn=document.getElementById('labelBtn'), museum=document.getElementById('museum');
labelBtn.addEventListener('click',()=>{ const open=museum.hidden; museum.hidden=!open; labelBtn.setAttribute('aria-expanded',String(open)); });
const stageEl=document.getElementById('stage'), chromeBtn=document.getElementById('chromeBtn');
function toggleChrome(){ const hidden=stageEl.classList.toggle('hide-chrome'); chromeBtn.setAttribute('aria-pressed',String(!hidden)); chromeBtn.textContent=hidden?'☰':'✕'; announce(hidden?'Controls hidden.':'Controls shown.'); }
chromeBtn.addEventListener('click',toggleChrome);
stageEl.addEventListener('pointerdown',e=>{ if(e.target.closest('button,input,select,a,.dock,.cams,.museum,.label-toggle')) return; const r=scene.getBoundingClientRect(); shoot(e.clientX-r.left, e.clientY-r.top); });
addEventListener('keydown',e=>{ if(e.target.matches('input,button')&&(e.key===' '||e.key==='Enter')) return;
switch(e.key.toLowerCase()){ case ' ':e.preventDefault();playBtn.click();break; case 'm':reduceBtn.click();break; case 't':trailBtn.click();break; case 'r':reflectBtn.click();break; case 's':soundBtn.click();break; case 'c':toggleChrome();break; case 'l':labelBtn.click();break; case '1':document.querySelector('[data-cam="wide"]').click();break; case '2':document.querySelector('[data-cam="sky"]').click();break; }
});
resize(); requestAnimationFrame(frame);
})();
</script>
<!-- slow icy 3D orb — the exhibition's through-line. 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.set(1.6,1.35,0); scene.add(group);
const wire=new THREE.Mesh(new THREE.IcosahedronGeometry(0.7,2), new THREE.MeshBasicMaterial({wireframe:true,color:0x9fe8ff,transparent:true,opacity:0.7}));
const core=new THREE.Mesh(new THREE.IcosahedronGeometry(0.55,3), new THREE.MeshStandardMaterial({color:0x0a2230,emissive:0x2fe6ff,emissiveIntensity:0.5,roughness:0.3,metalness:0.7,transparent:true,opacity:0.8}));
group.add(core,wire);
scene.add(new THREE.PointLight(0x2fff9a,1.1,60).translateX(4).translateY(3).translateZ(5));
scene.add(new THREE.PointLight(0x9f6aff,0.9,60).translateX(-4).translateY(-1).translateZ(4));
scene.add(new THREE.AmbientLight(0x203040,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.__auroraS||{}, t=performance.now()/1000, paused=(s.playing===false)||s.reduced, pulse=s.pulse||0;
if(!paused) group.rotation.y+=0.005; group.rotation.x=Math.sin(t*0.2)*0.15;
group.scale.setScalar(1+0.06*Math.sin(t*1.1)+pulse*0.22);
core.material.emissiveIntensity=0.4+pulse*1.3;
const hue=(160+Math.sin(t*0.25)*40)/360; col.setHSL(hue,0.7,0.55); core.material.emissive.copy(col); wire.material.color.setHSL((hue+0.08)%1,0.7,0.7);
renderer.render(scene,camera);
}
loop();
})();
</script>
</body>
</html>