1// Live world activity map â projection, day/night solar math, and the particle 2// simulation that makes the map feel alive. Rendering is decoupled from the data: 3// snapshots set slow-changing *targets*; this runs a continuous animation toward 4// them, so the map stays alive between (and even without) data refreshes. 5// 6// Used by controllers/activity_map_controller.js, which owns the DOM, fetching, 7// and visibility handling. Base continents come from modules/world_land.js. 8 9import { WORLD_LAND } from './world_land.js'; 10 11// --- Projection (equirectangular / plate carree) --------------------------- 12// Normalised coordinates: x in [0,1] for lon -180..180, y in [0,1] for the 13// visible latitude window below. We crop only the empty band south of Antarctica 14// (whose land data ends ~-80) so it sits flush with the bottom of the canvas. 15// The frame's aspect-ratio in activity-map.css MUST equal 360 / LAT_SPAN. 16const LAT_NORTH = 90; 17const LAT_SOUTH = -82; 18const LAT_SPAN = LAT_NORTH - LAT_SOUTH; // 172 â aspect-ratio 360/172 â 2.093 19 20export function projectNorm(lon, lat) { 21 return { x: (lon + 180) / 360, y: (LAT_NORTH - lat) / LAT_SPAN }; 22} 23 24// --- Day / night ------------------------------------------------------------ 25// Subsolar point: the lat/lon where the sun is directly overhead right now. 26 27export function subsolarPoint(date) { 28 const dayMs = 24 * 60 * 60 * 1000; 29 const startOfYear = Date.UTC(date.getUTCFullYear(), 0, 0); 30 const dayOfYear = (date - startOfYear) / dayMs; 31 32 // Solar declination (deg) â simple, accurate to ~1 degree. 33 const decl = 23.44 * Math.sin((2 * Math.PI * (dayOfYear - 81)) / 365); 34 35 // Subsolar longitude tracks UTC time of day. 36 const utcHours = 37 date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600; 38 let lon = -15 * (utcHours - 12); 39 lon = ((lon + 540) % 360) - 180; // wrap to -180..180 40 41 return { lat: decl, lon }; 42} 43 44const DEG = Math.PI / 180; 45 46// Illumination at a lon/lat: 1 = full day, 0 = full night, with a soft 47// terminator. `sunVec` is the subsolar point's precomputed unit vector. Takes 48// lon/lat (not normalised coords) so it's independent of the projection window. 49export function dayFactor(lon, lat, sunVec) { 50 const cl = Math.cos(lat * DEG); 51 const px = cl * Math.cos(lon * DEG); 52 const py = Math.sin(lat * DEG); 53 const pz = cl * Math.sin(lon * DEG); 54 const cos = px * sunVec.x + py * sunVec.y + pz * sunVec.z; // -1..1 55 // Smooth the terminator over a few degrees of cosine either side of 0. 56 const t = (cos + 0.12) / 0.24; 57 return t <= 0 ? 0 : t >= 1 ? 1 : t * t * (3 - 2 * t); 58} 59 60function sunVector(sub) { 61 const cl = Math.cos(sub.lat * DEG); 62 return { 63 x: cl * Math.cos(sub.lon * DEG), 64 y: Math.sin(sub.lat * DEG), 65 z: cl * Math.sin(sub.lon * DEG), 66 }; 67} 68 69// --- Helpers ---------------------------------------------------------------- 70 71const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); 72const cellKey = (c) => `${c.lat.toFixed(3)},${c.lon.toFixed(3)}`; 73 74// Deterministic-ish jitter seed per particle so each one animates on its own 75// phase (no global flicker), without needing to store extra randomness. 76function hashPhase(str) { 77 let h = 2166136261; 78 for (let i = 0; i < str.length; i++) { 79 h ^= str.charCodeAt(i); 80 h = Math.imul(h, 16777619); 81 } 82 return ((h >>> 0) % 10000) / 10000; // 0..1 83} 84 85// --- The map ---------------------------------------------------------------- 86 87export class WorldActivityMap { 88 constructor(canvas, options = {}) { 89 this.canvas = canvas; 90 this.ctx = canvas.getContext('2d'); 91 this.reducedMotion = !!options.reducedMotion; 92 93 // Colors are read from CSS custom properties on the canvas so the map themes 94 // with the rest of the site. Solid colors only â alpha is applied at draw time. 95 const styles = getComputedStyle(canvas); 96 this.colors = { 97 dot: (styles.getPropertyValue('--activity-dot') || '#1f7aec').trim(), 98 land: (styles.getPropertyValue('--activity-land') || '#8aa0b8').trim(), 99 night: (styles.getPropertyValue('--activity-night') || '#0b1f33').trim(), 100 }; 101 102 // Radius mapping from a cell's active-user count `n`. 103 this.minR = options.minRadius ?? 1.1; 104 this.maxR = options.maxRadius ?? 7; 105 this.radiusScale = options.radiusScale ?? 1.6; // px per log10(n) 106 107 // Activity dots render at half size on narrow phones (30em is the 108 // framework's phone breakpoint). Refreshed in resize() as the viewport 109 // crosses the breakpoint; applied at draw time. 110 this.mobileQuery = window.matchMedia('(max-width: 30em)'); 111 this.dotScale = 1; 112 113 this.particles = new Map(); // key -> particle 114 this.ripples = []; 115 this.totalActive = 0; 116 this.generatedAt = null; 117 118 this.running = false;
119 this.lastFrame = 0; 120 this.lastRippleAt = 0; 121 this.nightMinute = -1; 122 123 // Offscreen layers (rebuilt on resize / time change, not every frame). 124 this.baseLayer = document.createElement('canvas'); 125 this.nightLayer = document.createElement('canvas'); 126 127 // Low-res illumination buffer for a soft, correctly-curved terminator. 128 this.illumW = 128; 129 this.illumH = 64; 130 131 this.resize(); 132 } 133 134 // --- Sizing --------------------------------------------------------------- 135 136 resize() { 137 const rect = this.canvas.getBoundingClientRect(); 138 const dpr = Math.min(window.devicePixelRatio || 1, 2); 139 this.cssW = Math.max(1, rect.width); 140 this.cssH = Math.max(1, rect.height || rect.width / 2); 141 this.dpr = dpr; 142 143 for (const c of [this.canvas, this.baseLayer]) { 144 c.width = Math.round(this.cssW * dpr); 145 c.height = Math.round(this.cssH * dpr); 146 } 147 this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); 148 149 this.dotScale = this.mobileQuery.matches ? 0.5 : 1; 150 151 this.renderBaseLayer(); 152 this.nightMinute = -1; // force night layer rebuild 153 } 154 155 // Static dotted continents, drawn once per size into an offscreen layer. 156 renderBaseLayer() { 157 const { gw, gh, points } = WORLD_LAND; 158 const ctx = this.baseLayer.getContext('2d'); 159 ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); 160 ctx.clearRect(0, 0, this.cssW, this.cssH); 161 ctx.fillStyle = this.colors.land; 162 ctx.globalAlpha = 0.62; 163 164 const r = clamp(Math.min(this.cssW / gw, this.cssH / gh) * 0.36, 0.6, 2.4); 165 for (let i = 0; i < points.length; i += 2) { 166 const lat = 90 - (points[i + 1] / gh) * 180; 167 const ny = (LAT_NORTH - lat) / LAT_SPAN; 168 if (ny > 1) continue; // below the visible crop (Antarctica / deep south) 169 const x = (points[i] / gw) * this.cssW; 170 const y = ny * this.cssH; 171 ctx.beginPath(); 172 ctx.arc(x, y, r, 0, Math.PI * 2); 173 ctx.fill(); 174 } 175 ctx.globalAlpha = 1; 176 } 177 178 // Soft day/night wash, rebuilt at most once per minute. 179 renderNightLayer(date) { 180 const minute = Math.floor(date.getTime() / 60000); 181 if (minute === this.nightMinute) return; 182 this.nightMinute = minute; 183 184 const W = this.illumW; 185 const H = this.illumH; 186 const buf = document.createElement('canvas'); 187 buf.width = W; 188 buf.height = H; 189 const bctx = buf.getContext('2d'); 190 const img = bctx.createImageData(W, H); 191 const sun = sunVector(subsolarPoint(date)); 192 193 // Night color channels. 194 const c = this.colors.night; 195 const rgb = parseColorToRgb(c) || { r: 11, g: 31, b: 51 }; 196 197 for (let y = 0; y < H; y++) { 198 const lat = LAT_NORTH - ((y + 0.5) / H) * LAT_SPAN; 199 for (let x = 0; x < W; x++) { 200 const lon = ((x + 0.5) / W) * 360 - 180; 201 const day = dayFactor(lon, lat, sun); 202 const night = 1 - day; 203 const a = Math.round(night * 140); // max night darkness (0..255) 204 const o = (y * W + x) * 4; 205 img.data[o] = rgb.r; 206 img.data[o + 1] = rgb.g; 207 img.data[o + 2] = rgb.b; 208 img.data[o + 3] = a; 209 } 210 } 211 bctx.putImageData(img, 0, 0); 212 213 // Scale up smoothly into a full-size layer for a feathered terminator. 214 this.nightLayer.width = Math.round(this.cssW * this.dpr); 215 this.nightLayer.height = Math.round(this.cssH * this.dpr); 216 const nctx = this.nightLayer.getContext('2d'); 217 nctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); 218 nctx.clearRect(0, 0, this.cssW, this.cssH); 219 nctx.imageSmoothingEnabled = true; 220 nctx.drawImage(buf, 0, 0, W, H, 0, 0, this.cssW, this.cssH); 221 } 222 223 // --- Data ----------------------------------------------------------------- 224 225 setSnapshot(snapshot) { 226 if (!snapshot || !Array.isArray(snapshot.cells)) return; 227 this.totalActive = snapshot.total_active ?? null; 228 this.generatedAt = snapshot.generated_at ?? null; 229 230 const seen = new Set(); 231 for (const cell of snapshot.cells) { 232 if (typeof cell.lat !== 'number' || typeof cell.lon !== 'number') continue; 233 const key = cellKey(cell); 234 seen.add(key); 235 const { x, y } = projectNorm(cell.lon, cell.lat); 236 const targetR = clamp(
237 this.minR + Math.log10(Math.max(1, cell.n || 1)) * this.radiusScale, 238 this.minR, 239 this.maxR 240 ); 241 242 let p = this.particles.get(key); 243 if (p) { 244 p.targetR = targetR; 245 p.n = cell.n; 246 p.targetAlpha = 1; 247 p.dying = false; 248 } else { 249 // New place: spawn from nothing so additions fade/grow in. 250 const ph = hashPhase(key); 251 this.particles.set(key, { 252 key, 253 nx: x, 254 ny: y, 255 n: cell.n, 256 r: 0, 257 targetR, 258 alpha: 0, 259 targetAlpha: 1, 260 dying: false, 261 phase: ph * Math.PI * 2, 262 pulseRate: 0.8 + ph * 1.6, // rad/s, desynchronised per place 263 pulseAmp: 0.12 + ph * 0.12, 264 jitterPhase: hashPhase(key + 'j') * Math.PI * 2, 265 }); 266 } 267 } 268 269 // Places no longer present fade out, then get removed. 270 for (const [key, p] of this.particles) { 271 if (!seen.has(key)) { 272 p.dying = true; 273 p.targetAlpha = 0; 274 p.targetR = 0; 275 } 276 } 277 } 278 279 // --- Animation loop ------------------------------------------------------- 280 281 start() { 282 if (this.running) return; 283 this.running = true; 284 this.lastFrame = 0; 285 this._tick = (t) => { 286 if (!this.running) return; 287 this.frame(t); 288 this._raf = requestAnimationFrame(this._tick); 289 }; 290 this._raf = requestAnimationFrame(this._tick); 291 } 292 293 stop() { 294 this.running = false; 295 if (this._raf) cancelAnimationFrame(this._raf); 296 } 297 298 frame(timeMs) { 299 const now = timeMs || 0; 300 let dt = this.lastFrame ? (now - this.lastFrame) / 1000 : 0; 301 this.lastFrame = now; 302 dt = clamp(dt, 0, 0.05); // guard against tab-resume jumps 303 const tSec = now / 1000; 304 305 const ctx = this.ctx; 306 ctx.clearRect(0, 0, this.cssW, this.cssH); 307 308 // Layer 1: continents. 309 ctx.drawImage(this.baseLayer, 0, 0, this.cssW, this.cssH); 310 311 // Layer 2: day/night wash. 312 this.renderNightLayer(new Date()); 313 ctx.drawImage(this.nightLayer, 0, 0, this.cssW, this.cssH); 314 315 // Layer 3: activity. 316 this.drawParticles(ctx, tSec, dt); 317 this.drawRipples(ctx, dt, now); 318 } 319 320 drawParticles(ctx, tSec, dt) { 321 const ease = this.reducedMotion ? 1 : clamp(dt * 3.5, 0, 1); 322 ctx.fillStyle = this.colors.dot; 323 324 for (const [key, p] of this.particles) { 325 // Ease radius/alpha toward targets (smooth snapshot transitions). 326 p.r += (p.targetR - p.r) * ease; 327 p.alpha += (p.targetAlpha - p.alpha) * ease; 328 if (p.dying && p.alpha < 0.02) { 329 this.particles.delete(key); 330 continue; 331 } 332 333 let r = p.r * this.dotScale; 334 let a = p.alpha; 335 let x = p.nx * this.cssW; 336 let y = p.ny * this.cssH; 337 338 if (!this.reducedMotion) { 339 const pulse = Math.sin(tSec * p.pulseRate + p.phase); 340 r *= 1 + p.pulseAmp * pulse; 341 a *= 0.75 + 0.25 * (0.5 + 0.5 * pulse); 342 const j = 0.6; 343 x += Math.sin(tSec * 0.6 + p.jitterPhase) * j; 344 y += Math.cos(tSec * 0.5 + p.jitterPhase) * j; 345 } 346 if (r <= 0.05 || a <= 0.01) continue; 347 348 // Soft halo + bright core, cheap (two arcs, no per-dot gradient). 349 ctx.globalAlpha = a * 0.28; 350 ctx.beginPath(); 351 ctx.arc(x, y, r * 2.1, 0, Math.PI * 2); 352 ctx.fill(); 353 354 ctx.globalAlpha = a; 355 ctx.beginPath(); 356 ctx.arc(x, y, r, 0, Math.PI * 2); 357 ctx.fill(); 358 } 359 ctx.globalAlpha = 1; 360 } 361 362 // Occasional expanding rings on busy places â reads as "new activity now". 363 drawRipples(ctx, dt, nowMs) { 364 if (!this.reducedMotion) { 365 // Spawn rate scales gently with how many places are active. 366 const interval = clamp(900 - this.particles.size * 1.5, 220, 900); 367 if (nowMs - this.lastRippleAt > interval) { 368 this.lastRippleAt = nowMs; 369 this.spawnRipple(); 370 } 371 } 372 373 ctx.strokeStyle = this.colors.dot; 374 for (let i = this.ripples.length - 1; i >= 0; i--) { 375 const rp = this.ripples[i]; 376 rp.age += dt; 377 const k = rp.age / rp.life; 378 if (k >= 1) { 379 this.ripples.splice(i, 1); 380 continue; 381 } 382 ctx.globalAlpha = (1 - k) * 0.5; 383 ctx.lineWidth = 1.2; 384 ctx.beginPath(); 385 ctx.arc( 386 rp.nx * this.cssW, 387 rp.ny * this.cssH, 388 (rp.baseR + k * rp.maxR) * this.dotScale, 389 0, 390 Math.PI * 2 391 ); 392 ctx.stroke(); 393 } 394 ctx.globalAlpha = 1; 395 } 396 397 spawnRipple() { 398 // Weight selection toward busier places. 399 const live = []; 400 let total = 0; 401 for (const p of this.particles.values()) { 402 if (p.dying || p.alpha < 0.5) continue; 403 const w = Math.max(1, p.n || 1); 404 total += w; 405 live.push({ p, acc: total }); 406 } 407 if (!live.length) return; 408 const pick = Math.random() * total; 409 const chosen = live.find((e) => e.acc >= pick) || live[live.length - 1]; 410 const p = chosen.p; 411 this.ripples.push({
412 nx: p.nx, 413 ny: p.ny, 414 age: 0, 415 life: 1.6, 416 baseR: p.r, 417 maxR: 14, 418 }); 419 } 420} 421 422// Parse "#rrggbb" / "rgb(...)" to channels for the night ImageData. Returns null 423// for color spaces we can't read here (e.g. oklch); callers fall back to a default. 424function parseColorToRgb(str) { 425 if (!str) return null; 426 str = str.trim(); 427 if (str[0] === '#') { 428 let h = str.slice(1); 429 if (h.length === 3) h = h.split('').map((c) => c + c).join(''); 430 if (h.length !== 6) return null; 431 return { 432 r: parseInt(h.slice(0, 2), 16), 433 g: parseInt(h.slice(2, 4), 16), 434 b: parseInt(h.slice(4, 6), 16), 435 }; 436 } 437 const m = str.match(/rgba?\(([^)]+)\)/); 438 if (m) { 439 const parts = m[1].split(/[ ,/]+/).map(Number); 440 return { r: parts[0], g: parts[1], b: parts[2] }; 441 } 442 return null; 443}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.