|
| 1 | +<!DOCTYPE html> |
| 2 | +<html lang="en"> |
| 3 | +<head> |
| 4 | +<meta charset="utf-8"> |
| 5 | +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| 6 | +<title>Earth Monitor</title> |
| 7 | +<style> |
| 8 | +*{box-sizing:border-box;margin:0;padding:0} |
| 9 | +body{background:#000;color:#7cff7c;font-family:monospace;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:16px} |
| 10 | +canvas{display:block;border-radius:50%;background:#000} |
| 11 | +.controls{margin-top:14px;display:flex;gap:10px;align-items:center} |
| 12 | +button{background:#001400;border:1px solid #00ff00;color:#7cff7c;padding:8px 14px;cursor:pointer;font-family:monospace;font-size:13px} |
| 13 | +button:hover{background:#002200} |
| 14 | +.seek-bar{width:220px;height:8px;background:#001a00;border:1px solid #00aa44;border-radius:3px;cursor:pointer;overflow:hidden} |
| 15 | +.seek-fill{height:100%;width:0%;background:linear-gradient(90deg,#00ff44,#7cff7c)} |
| 16 | +.subs{margin-top:12px;max-width:600px;min-height:2.5em;opacity:.85;text-align:center;font-size:13px;line-height:1.5} |
| 17 | +.footer{margin-top:10px;font-size:.75em;opacity:.6} |
| 18 | +.footer a{color:#7cff7c;text-decoration:none} |
| 19 | +</style> |
| 20 | +</head> |
| 21 | +<body> |
| 22 | + |
| 23 | +<canvas id="c"></canvas> |
| 24 | + |
| 25 | +<div class="controls"> |
| 26 | + <button id="play">▶ Play</button> |
| 27 | + <div class="seek-bar" id="seek"><div class="seek-fill" id="seekFill"></div></div> |
| 28 | +</div> |
| 29 | + |
| 30 | +<div id="subs" class="subs"></div> |
| 31 | + |
| 32 | +<div class="footer"> |
| 33 | + <a href="https://standardgalactic.github.io/spherepop/entropy_of_austerity.pdf" target="_blank">entropy_of_austerity.pdf</a> |
| 34 | +</div> |
| 35 | + |
| 36 | +<audio id="audio" crossorigin="anonymous"> |
| 37 | + <source src="The_physics_of_the_austerity_trap.mp3"> |
| 38 | + <track kind="subtitles" src="The_physics_of_the_austerity_trap.vtt" default> |
| 39 | +</audio> |
| 40 | + |
| 41 | +<script> |
| 42 | +const canvas = document.getElementById('c') |
| 43 | +const g = canvas.getContext('2d') |
| 44 | +const audio = document.getElementById('audio') |
| 45 | +const playBtn = document.getElementById('play') |
| 46 | +const subsEl = document.getElementById('subs') |
| 47 | +const seek = document.getElementById('seek') |
| 48 | +const seekFill = document.getElementById('seekFill') |
| 49 | + |
| 50 | +const SIZE = Math.min(460, window.innerWidth - 32) |
| 51 | +canvas.width = SIZE |
| 52 | +canvas.height = SIZE |
| 53 | +const R = SIZE * 0.37 |
| 54 | +const cx = SIZE / 2 |
| 55 | +const cy = SIZE / 2 |
| 56 | + |
| 57 | +/* ── offscreen cloud buffer ── */ |
| 58 | +const cloudCanvas = document.createElement('canvas') |
| 59 | +cloudCanvas.width = SIZE |
| 60 | +cloudCanvas.height = SIZE |
| 61 | +const cg = cloudCanvas.getContext('2d') |
| 62 | + |
| 63 | +/* ── seek bar ── */ |
| 64 | +seek.onclick = e => { |
| 65 | + if(!audio.duration) return |
| 66 | + const r = seek.getBoundingClientRect() |
| 67 | + audio.currentTime = ((e.clientX - r.left) / r.width) * audio.duration |
| 68 | +} |
| 69 | + |
| 70 | +/* ── audio init ── */ |
| 71 | +let actx, analyser, timeBuf |
| 72 | +let smoothEnergy = 0 |
| 73 | +let bandEnergy = [0,0,0,0] |
| 74 | + |
| 75 | +function initAudio(){ |
| 76 | + if(actx) return |
| 77 | + actx = new (window.AudioContext||window.webkitAudioContext)() |
| 78 | + analyser = actx.createAnalyser() |
| 79 | + analyser.fftSize = 1024 |
| 80 | + analyser.smoothingTimeConstant = 0.85 |
| 81 | + const src = actx.createMediaElementSource(audio) |
| 82 | + src.connect(analyser) |
| 83 | + analyser.connect(actx.destination) |
| 84 | + timeBuf = new Uint8Array(analyser.fftSize) |
| 85 | +} |
| 86 | + |
| 87 | +playBtn.onclick = () => { |
| 88 | + initAudio() |
| 89 | + if(audio.paused){ audio.play(); playBtn.textContent='⏸ Pause' } |
| 90 | + else { audio.pause(); playBtn.textContent='▶ Play' } |
| 91 | +} |
| 92 | + |
| 93 | +/* ── mobile-safe subtitles ── */ |
| 94 | +function setupSubtitles(){ |
| 95 | + const track = audio.textTracks && audio.textTracks[0] |
| 96 | + if(!track){ subsEl.textContent = ''; return } |
| 97 | + |
| 98 | + track.mode = 'hidden' |
| 99 | + |
| 100 | + function update(){ |
| 101 | + const cues = track.activeCues |
| 102 | + subsEl.textContent = (cues && cues.length) ? cues[0].text : '' |
| 103 | + } |
| 104 | + |
| 105 | + audio.addEventListener('timeupdate', update) |
| 106 | + audio.addEventListener('seeked', update) |
| 107 | + audio.addEventListener('play', update) |
| 108 | + |
| 109 | + // iOS needs a moment before track.mode sticks |
| 110 | + setTimeout(() => { try{ track.mode = 'hidden' }catch(e){} }, 500) |
| 111 | + |
| 112 | + // polling fallback — critical for mobile where cues load late |
| 113 | + let tries = 0 |
| 114 | + const interval = setInterval(() => { |
| 115 | + tries++ |
| 116 | + if(track.cues && track.cues.length){ update(); clearInterval(interval) } |
| 117 | + if(tries > 100) clearInterval(interval) |
| 118 | + }, 100) |
| 119 | +} |
| 120 | + |
| 121 | +audio.addEventListener('loadedmetadata', setupSubtitles) |
| 122 | +audio.addEventListener('loadeddata', setupSubtitles) |
| 123 | + |
| 124 | +/* ── energy ── */ |
| 125 | +function updateEnergy(){ |
| 126 | + if(audio.duration) seekFill.style.width = (audio.currentTime / audio.duration * 100) + '%' |
| 127 | + if(!analyser) return |
| 128 | + |
| 129 | + analyser.getByteTimeDomainData(timeBuf) |
| 130 | + let e = 0 |
| 131 | + for(let i=0;i<timeBuf.length;i++){ const d=timeBuf[i]-128; e+=d*d } |
| 132 | + e /= timeBuf.length |
| 133 | + smoothEnergy += (Math.min(e/3000,1) - smoothEnergy) * 0.05 |
| 134 | + |
| 135 | + const freq = new Uint8Array(analyser.frequencyBinCount) |
| 136 | + analyser.getByteFrequencyData(freq) |
| 137 | + const band = Math.floor(freq.length/4) |
| 138 | + for(let b=0;b<4;b++){ |
| 139 | + let s=0; for(let i=b*band;i<(b+1)*band;i++) s+=freq[i] |
| 140 | + bandEnergy[b] += (s/band/255 - bandEnergy[b]) * 0.07 |
| 141 | + } |
| 142 | +} |
| 143 | + |
| 144 | +/* ── projection ── */ |
| 145 | +const DEG = Math.PI/180 |
| 146 | +let yaw = 0 |
| 147 | + |
| 148 | +function project(lat, lon){ |
| 149 | + const la=lat*DEG, lo=lon*DEG |
| 150 | + let x=Math.cos(la)*Math.cos(lo) |
| 151 | + let y=Math.sin(la) |
| 152 | + let z=Math.cos(la)*Math.sin(lo) |
| 153 | + const c=Math.cos(yaw), s=Math.sin(yaw) |
| 154 | + const rx=x*c+z*s |
| 155 | + const rz=-x*s+z*c |
| 156 | + return { x:cx+rx*R, y:cy-y*R, visible:rz>-0.05, depth:(rz+1)/2 } |
| 157 | +} |
| 158 | + |
| 159 | +/* ── continents ── */ |
| 160 | +const continents = [ |
| 161 | + [[60,-140],[65,-120],[60,-90],[55,-60],[50,-55],[45,-60],[35,-75],[25,-80],[20,-87],[15,-90],[10,-85],[8,-77],[15,-75],[22,-100],[30,-115],[45,-125],[55,-130],[60,-140]], |
| 162 | + [[10,-75],[8,-62],[5,-52],[0,-50],[-5,-35],[-10,-37],[-20,-40],[-33,-52],[-45,-65],[-55,-68],[-40,-62],[-25,-48],[-15,-38],[-5,-35],[0,-48],[5,-60],[10,-75]], |
| 163 | + [[70,30],[65,25],[60,20],[55,5],[50,0],[45,0],[44,10],[38,15],[37,22],[40,28],[45,35],[50,30],[55,20],[60,25],[65,28],[70,30]], |
| 164 | + [[37,10],[30,5],[15,-15],[5,-15],[-5,-5],[-15,12],[-25,15],[-35,20],[-34,26],[-28,32],[-15,36],[0,42],[10,44],[15,42],[22,37],[30,32],[35,25],[37,10]], |
| 165 | + [[70,30],[68,60],[65,90],[60,120],[55,130],[45,135],[35,120],[25,120],[20,110],[10,100],[5,100],[10,77],[22,70],[28,50],[35,36],[40,38],[45,40],[50,50],[55,60],[60,60],[65,60],[70,30]], |
| 166 | + [[-15,130],[-17,140],[-20,148],[-30,153],[-38,146],[-38,140],[-35,118],[-32,115],[-22,114],[-17,122],[-15,130]], |
| 167 | + [[60,-44],[65,-52],[70,-54],[76,-65],[83,-45],[82,-25],[78,-18],[72,-22],[66,-36],[60,-44]] |
| 168 | +] |
| 169 | + |
| 170 | +function drawContinents(){ |
| 171 | + g.save() |
| 172 | + g.strokeStyle='rgba(110,255,155,0.85)' |
| 173 | + g.lineWidth=1.2 |
| 174 | + for(const poly of continents){ |
| 175 | + g.beginPath() |
| 176 | + let started=false |
| 177 | + for(const [la,lo] of poly){ |
| 178 | + const p=project(la,lo) |
| 179 | + if(!p.visible){ started=false; continue } |
| 180 | + started ? g.lineTo(p.x,p.y) : g.moveTo(p.x,p.y) |
| 181 | + started=true |
| 182 | + } |
| 183 | + if(started) g.closePath() |
| 184 | + g.stroke() |
| 185 | + g.fillStyle='rgba(0,255,100,0.05)' |
| 186 | + g.fill() |
| 187 | + } |
| 188 | + g.restore() |
| 189 | +} |
| 190 | + |
| 191 | +/* ── grid ── */ |
| 192 | +function drawGrid(){ |
| 193 | + g.save() |
| 194 | + g.strokeStyle='rgba(0,255,136,0.09)' |
| 195 | + g.lineWidth=0.8 |
| 196 | + for(let lat=-60;lat<=60;lat+=30){ |
| 197 | + g.beginPath(); let first=true |
| 198 | + for(let lon=-180;lon<=180;lon+=4){ |
| 199 | + const p=project(lat,lon) |
| 200 | + if(!p.visible){ first=true; continue } |
| 201 | + first ? g.moveTo(p.x,p.y) : g.lineTo(p.x,p.y) |
| 202 | + first=false |
| 203 | + } |
| 204 | + g.stroke() |
| 205 | + } |
| 206 | + for(let lon=-150;lon<=180;lon+=30){ |
| 207 | + g.beginPath(); let first=true |
| 208 | + for(let lat=-88;lat<=88;lat+=3){ |
| 209 | + const p=project(lat,lon) |
| 210 | + if(!p.visible){ first=true; continue } |
| 211 | + first ? g.moveTo(p.x,p.y) : g.lineTo(p.x,p.y) |
| 212 | + first=false |
| 213 | + } |
| 214 | + g.stroke() |
| 215 | + } |
| 216 | + g.restore() |
| 217 | +} |
| 218 | + |
| 219 | +/* ── clouds ── */ |
| 220 | +const clouds = [] |
| 221 | +for(let i=0;i<5;i++) clouds.push({ lat:-15+Math.random()*30, baseLon:Math.random()*360-180, lon:0, baseSize:22+Math.random()*20, speed:0.008+Math.random()*0.008, phase:Math.random()*Math.PI*2, band:0 }) |
| 222 | +for(let i=0;i<4;i++) clouds.push({ lat:40+Math.random()*20, baseLon:Math.random()*360-180, lon:0, baseSize:18+Math.random()*16, speed:0.010+Math.random()*0.010, phase:Math.random()*Math.PI*2, band:1 }) |
| 223 | +for(let i=0;i<4;i++) clouds.push({ lat:-40-Math.random()*20, baseLon:Math.random()*360-180, lon:0, baseSize:18+Math.random()*16, speed:0.010+Math.random()*0.010, phase:Math.random()*Math.PI*2, band:2 }) |
| 224 | +for(let i=0;i<3;i++){ |
| 225 | + clouds.push({ lat:65+Math.random()*15, baseLon:Math.random()*360-180, lon:0, baseSize:14+Math.random()*12, speed:0.006+Math.random()*0.006, phase:Math.random()*Math.PI*2, band:3 }) |
| 226 | + clouds.push({ lat:-65-Math.random()*15, baseLon:Math.random()*360-180, lon:0, baseSize:14+Math.random()*12, speed:0.006+Math.random()*0.006, phase:Math.random()*Math.PI*2, band:3 }) |
| 227 | +} |
| 228 | + |
| 229 | +function drawClouds(t){ |
| 230 | + cg.clearRect(0,0,SIZE,SIZE) |
| 231 | + cg.globalCompositeOperation='lighter' |
| 232 | + |
| 233 | + for(const c of clouds){ |
| 234 | + c.lon = c.baseLon + t*c.speed |
| 235 | + if(c.lon>180) c.lon-=360 |
| 236 | + |
| 237 | + const p=project(c.lat, c.lon) |
| 238 | + if(!p.visible) continue |
| 239 | + |
| 240 | + const depthScale=0.45+p.depth*0.55 |
| 241 | + const pulse=1+bandEnergy[c.band]*1.6+smoothEnergy*0.5 |
| 242 | + const r=c.baseSize*depthScale*pulse |
| 243 | + const alpha=0.04+smoothEnergy*0.18 |
| 244 | + |
| 245 | + const grad=cg.createRadialGradient(p.x,p.y,0,p.x,p.y,r) |
| 246 | + grad.addColorStop(0, `rgba(140,255,160,${alpha})`) |
| 247 | + grad.addColorStop(0.5,`rgba(120,220,140,${(alpha*0.6).toFixed(3)})`) |
| 248 | + grad.addColorStop(1, 'rgba(0,0,0,0)') |
| 249 | + cg.fillStyle=grad |
| 250 | + cg.beginPath() |
| 251 | + cg.arc(p.x,p.y,r,0,Math.PI*2) |
| 252 | + cg.fill() |
| 253 | + } |
| 254 | + |
| 255 | + cg.globalCompositeOperation='source-over' |
| 256 | + cg.filter='blur(16px)' |
| 257 | + cg.drawImage(cloudCanvas,0,0) |
| 258 | + cg.filter='none' |
| 259 | + |
| 260 | + g.save() |
| 261 | + g.beginPath() |
| 262 | + g.arc(cx,cy,R,0,Math.PI*2) |
| 263 | + g.clip() |
| 264 | + g.globalAlpha=0.9 |
| 265 | + g.drawImage(cloudCanvas,0,0) |
| 266 | + g.restore() |
| 267 | +} |
| 268 | + |
| 269 | +/* ── sphere ── */ |
| 270 | +function drawSphere(){ |
| 271 | + const bg=g.createRadialGradient(cx-R*0.2,cy-R*0.2,R*0.1,cx,cy,R) |
| 272 | + bg.addColorStop(0,'rgba(10,40,20,0.5)') |
| 273 | + bg.addColorStop(0.7,'rgba(2,12,5,0.95)') |
| 274 | + bg.addColorStop(1,'rgba(0,0,0,0.98)') |
| 275 | + g.beginPath(); g.arc(cx,cy,R,0,Math.PI*2) |
| 276 | + g.fillStyle=bg; g.fill() |
| 277 | + g.strokeStyle='rgba(80,255,130,0.32)' |
| 278 | + g.lineWidth=1.2; g.stroke() |
| 279 | + |
| 280 | + const glow=g.createRadialGradient(cx,cy,0,cx,cy,R) |
| 281 | + glow.addColorStop(0,`rgba(0,255,120,${0.02+smoothEnergy*0.07})`) |
| 282 | + glow.addColorStop(1,'rgba(0,0,0,0)') |
| 283 | + g.beginPath(); g.arc(cx,cy,R,0,Math.PI*2) |
| 284 | + g.fillStyle=glow; g.fill() |
| 285 | + |
| 286 | + for(let i=0;i<3;i++){ |
| 287 | + g.beginPath() |
| 288 | + g.arc(cx,cy,R*(1.012+i*0.012),0,Math.PI*2) |
| 289 | + g.strokeStyle=`rgba(100,255,160,${0.04+smoothEnergy*0.05})` |
| 290 | + g.lineWidth=1; g.stroke() |
| 291 | + } |
| 292 | +} |
| 293 | + |
| 294 | +/* ── stars ── */ |
| 295 | +function drawStars(t){ |
| 296 | + const n=Math.floor(SIZE*SIZE/4200) |
| 297 | + g.save() |
| 298 | + for(let i=0;i<n;i++){ |
| 299 | + const hx=Math.sin(i*91.7+1.1)*43758.54 |
| 300 | + const hy=Math.sin(i*57.3+2.9+t*0.00003)*21781.13 |
| 301 | + const x=(hx-Math.floor(hx))*SIZE |
| 302 | + const y=(hy-Math.floor(hy))*SIZE |
| 303 | + const z=0.4+((Math.sin(i*12.9+0.7)*9182.33%1+1)%1)*0.6 |
| 304 | + g.fillStyle=`rgba(130,255,170,${(0.14+z*0.3).toFixed(2)})` |
| 305 | + g.fillRect(x,y,z>0.85?2:1,z>0.85?2:1) |
| 306 | + } |
| 307 | + g.restore() |
| 308 | +} |
| 309 | + |
| 310 | +/* ── render loop ── */ |
| 311 | +let t=0 |
| 312 | +function draw(){ |
| 313 | + updateEnergy() |
| 314 | + t++ |
| 315 | + yaw+=0.0012 |
| 316 | + |
| 317 | + g.fillStyle='rgba(0,0,0,0.18)' |
| 318 | + g.fillRect(0,0,SIZE,SIZE) |
| 319 | + |
| 320 | + drawStars(t) |
| 321 | + drawSphere() |
| 322 | + drawGrid() |
| 323 | + drawContinents() |
| 324 | + drawClouds(t) |
| 325 | + |
| 326 | + requestAnimationFrame(draw) |
| 327 | +} |
| 328 | +draw() |
| 329 | +</script> |
| 330 | +</body> |
| 331 | +</html> |
0 commit comments