← back to Aurora Drift
Aurora Drift: single-file animated northern-lights gallery (aurora curtains, frozen-lake reflection, cabin, snow, icy 3D orb) — Room 03 for The Main Stage; deploy config ready
f76741e33fd74e09ed5dc4dbaca3bf75561b760b · 2026-09-02 07:23:01 -0700 · Steve Abrams
Files touched
A .gitignoreA README.mdA deploy/aurora.agentabrams.com.nginxA deploy/aurora.agentabrams.com.ssl.nginxA deploy/deploy.shA index.html
Diff
commit f76741e33fd74e09ed5dc4dbaca3bf75561b760b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 2 07:23:01 2026 -0700
Aurora Drift: single-file animated northern-lights gallery (aurora curtains, frozen-lake reflection, cabin, snow, icy 3D orb) — Room 03 for The Main Stage; deploy config ready
---
.gitignore | 5 +
README.md | 16 ++
deploy/aurora.agentabrams.com.nginx | 8 +
deploy/aurora.agentabrams.com.ssl.nginx | 19 ++
deploy/deploy.sh | 33 +++
index.html | 366 ++++++++++++++++++++++++++++++++
6 files changed, 447 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f8dc3e8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+.env*
+*.log
+.DS_Store
+.playwright-mcp/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..cfd57b1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,16 @@
+# Aurora Drift — A Northern Night
+
+A single-file animated generative gallery: northern lights drift over a frozen
+lake at night. Aurora curtains fold across the sky, a lone cabin glows on the
+far shore, snow falls, and the whole sky returns mirrored in the ice.
+
+Room 03 of The Main Stage — a cool counterpoint to the Scarlet Riverboat
+Masquerade and Playa Ignition.
+
+## Run
+Open `index.html` in any modern browser. No build, no dependencies (Three.js
+loaded from CDN for the icy orb; degrades gracefully offline).
+
+## Steer it
+Drift · Aurora · Snowfall · Stars sliders · Reflection + Trails toggles ·
+cameras (The Lake / Skyward) · click for a shooting star · ☰ controls · `C` toggle.
diff --git a/deploy/aurora.agentabrams.com.nginx b/deploy/aurora.agentabrams.com.nginx
new file mode 100644
index 0000000..f40c9d2
--- /dev/null
+++ b/deploy/aurora.agentabrams.com.nginx
@@ -0,0 +1,8 @@
+server {
+ listen 45.61.58.125:80;
+ server_name aurora.agentabrams.com;
+ root /var/www/aurora.agentabrams.com;
+ index index.html;
+ location /.well-known/acme-challenge/ { root /var/www/aurora.agentabrams.com; }
+ location / { try_files $uri $uri/ =404; }
+}
diff --git a/deploy/aurora.agentabrams.com.ssl.nginx b/deploy/aurora.agentabrams.com.ssl.nginx
new file mode 100644
index 0000000..1b0f16c
--- /dev/null
+++ b/deploy/aurora.agentabrams.com.ssl.nginx
@@ -0,0 +1,19 @@
+server {
+ listen 45.61.58.125:80;
+ server_name aurora.agentabrams.com;
+ location /.well-known/acme-challenge/ { root /var/www/aurora.agentabrams.com; }
+ location / { return 301 https://$host$request_uri; }
+}
+server {
+ listen 45.61.58.125:443 ssl;
+ server_name aurora.agentabrams.com;
+ root /var/www/aurora.agentabrams.com;
+ index index.html;
+ ssl_certificate /etc/letsencrypt/live/aurora.agentabrams.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/aurora.agentabrams.com/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+ add_header X-Content-Type-Options nosniff always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ location / { try_files $uri $uri/ =404; }
+}
diff --git a/deploy/deploy.sh b/deploy/deploy.sh
new file mode 100755
index 0000000..07c541b
--- /dev/null
+++ b/deploy/deploy.sh
@@ -0,0 +1,33 @@
+#!/bin/bash
+set -euo pipefail
+KAMATERA=root@45.61.58.125
+SITE=aurora.agentabrams.com
+HERE="$(cd "$(dirname "$0")/.." && pwd)"
+
+export $(grep -E '^CLOUDFLARE_API_TOKEN=' ~/Projects/secrets-manager/.env)
+ZONE=$(curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" "https://api.cloudflare.com/client/v4/zones?name=agentabrams.com" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
+EXISTS=$(curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records?name=$SITE" | python3 -c "import sys,json; print(len(json.load(sys.stdin)['result']))")
+if [ "$EXISTS" = "0" ]; then
+ curl -s -X POST -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H 'Content-Type: application/json' "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records" -d '{"type":"A","name":"aurora","content":"45.61.58.125","ttl":300,"proxied":false}' | python3 -c "import sys,json; r=json.load(sys.stdin); assert r['success'],r['errors']; print('DNS record created')"
+else
+ echo 'DNS record already exists'
+fi
+
+rsync -az --delete --exclude .git --exclude deploy --exclude README.md --exclude verification "$HERE/" "$KAMATERA:/var/www/$SITE/"
+CERT="/etc/letsencrypt/live/$SITE/fullchain.pem"
+if ssh "$KAMATERA" "test -f $CERT"; then
+ scp -q "$HERE/deploy/$SITE.ssl.nginx" "$KAMATERA:/etc/nginx/sites-available/$SITE"
+ ssh "$KAMATERA" "ln -sf /etc/nginx/sites-available/$SITE /etc/nginx/sites-enabled/$SITE && nginx -t && systemctl reload nginx"
+else
+ scp -q "$HERE/deploy/$SITE.nginx" "$KAMATERA:/etc/nginx/sites-available/$SITE"
+ ssh "$KAMATERA" "ln -sf /etc/nginx/sites-available/$SITE /etc/nginx/sites-enabled/$SITE && nginx -t && systemctl reload nginx"
+ for _ in $(seq 1 30); do [ -n "$(dig +short "$SITE" @1.1.1.1)" ] && break; sleep 10; done
+ ssh "$KAMATERA" "certbot certonly --webroot -w /var/www/$SITE -d $SITE --non-interactive --agree-tos -m steve@designerwallcoverings.com"
+ scp -q "$HERE/deploy/$SITE.ssl.nginx" "$KAMATERA:/etc/nginx/sites-available/$SITE"
+ ssh "$KAMATERA" "nginx -t && systemctl reload nginx"
+fi
+sleep 2
+code=$(curl -s -o /dev/null -w '%{http_code}' "https://$SITE/")
+echo "https://$SITE -> HTTP $code"
+[ "$code" = '200' ] && echo 'DEPLOY OK' || exit 1
+
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..cf4f353
--- /dev/null
+++ b/index.html
@@ -0,0 +1,366 @@
+<!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}
+ .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>
+ </div>
+ </div>
+ <div class="group">
+ <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', W:0,H:0,DPR:1 };
+try{ window.__auroraS=S; }catch(e){}
+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 ----
+const AUR_COLS=[[47,255,154],[47,230,255],[140,120,255],[120,255,200]];
+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;
+ 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.7+0.3*Math.sin(S.t*2);
+ 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); }
+ 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.'); });
+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 '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;
+ 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));
+ 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>
(oldest)
·
back to Aurora Drift
·
Add sound + selection controller to Aurora: original ambient 1c90e32 →