PageSourceSearch

https://basecamp.com/assets/js/modules/activity_map_simulation.js

js basecamp.com collected 2026-09-24 06:36:55 UTC 14,631 bytes, 443 lines download raw bytes

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.