PageSourceSearch

https://www.ecoflix.com/assets/globe.js

js ecoflix.com collected 2026-10-02 06:10:27 UTC 23,461 bytes, 574 lines download raw bytes

1/*
2  3D Globe — Three.js sphere with NGO pins.
3  Reads pin data from .pin elements inside #globeWrap (hidden buttons
4  carry data-name / data-where / data-coords / data-status / data-stat /
5  data-desc / data-ngo / data-img / data-href attributes). Renders an
6  interactive globe, then populates a #globeCard side card on click.
7
8  Usage: in any page that includes a #globeWrap with .pin buttons and
9  a #globeCard, drop this near </body>:
10    <script type="importmap">
11    { "imports": { "three": "https://unpkg.com/[email protected]/build/three.module.js" } }
12    </script>
13    <script type="module" src="/assets/globe.js"></script>
14*/
15import * as THREE from 'three';
16
17(function initGlobe3D(){
18  const wrap = document.getElementById('globeWrap');
19  const canvas = document.getElementById('globe3d');
20  const tooltip = document.getElementById('globe3dTooltip');
21  if (!wrap || !canvas) return;
22  if (!window.WebGLRenderingContext) { wrap.classList.remove('is-3d'); return; }
23
24  // --- Build continent texture ---
25  // Strategy: paint immediate fallback (stylised SVG paths), then async-load real-world land
26  // geometry (world-atlas 110m via CDN) and redraw the texture for recognisable continents.
27  const svg = wrap.querySelector('.globe-svg');
28  const continents = svg ? [...svg.querySelectorAll('path.continent')] : [];
29  const TEX_W = 2048, TEX_H = 1024;
30  const texCanvas = document.createElement('canvas');
31  texCanvas.width = TEX_W; texCanvas.height = TEX_H;
32  const ctx = texCanvas.getContext('2d');
33
34  function paintBase(){
35    // Ocean
36    const og = ctx.createLinearGradient(0, 0, 0, TEX_H);
37    og.addColorStop(0,  '#040a18');
38    og.addColorStop(0.5,'#06121f');
39    og.addColorStop(1,  '#040a18');
40    ctx.fillStyle = og;
41    ctx.fillRect(0, 0, TEX_W, TEX_H);
42    // Ocean dot grid
43    ctx.fillStyle = 'rgba(168,243,65,0.05)';
44    for (let y = 0; y < TEX_H; y += 22){
45      for (let x = 0; x < TEX_W; x += 22){
46        ctx.beginPath(); ctx.arc(x, y, 0.7, 0, Math.PI*2); ctx.fill();
47      }
48    }
49    // Faint latitude reference dashes
50    ctx.strokeStyle = 'rgba(168,243,65,0.06)';
51    ctx.lineWidth = 1;
52    ctx.setLineDash([3,7]);
53    [0.25, 0.5, 0.75].forEach(f => {
54      ctx.beginPath(); ctx.moveTo(0, TEX_H*f); ctx.lineTo(TEX_W, TEX_H*f); ctx.stroke();
55    });
56    ctx.setLineDash([]);
57  }
58
59  function paintFallbackContinents(){
60    // Original stylised SVG paths (1000×500 viewBox → 2048×1024)
61    ctx.save();
62    ctx.scale(TEX_W / 1000, TEX_H / 500);
63    continents.forEach(pathEl => {
64      const d = pathEl.getAttribute('d');
65      if (!d) return;
66      try {
67        const p = new Path2D(d);
68        ctx.fillStyle = 'rgba(168,243,65,0.18)';
69        ctx.fill(p);
70        ctx.strokeStyle = 'rgba(140,235,160,0.65)';
71        ctx.lineWidth = 1.2;
72        ctx.stroke(p);
73      } catch(e){}
74    });
75    ctx.restore();
76  }
77
78  function paintRealContinents(featureCollection){
79    // Real-world land geometry from world-atlas (lon/lat in degrees).
80    // Equirectangular projection: x = (lon+180)/360 * W,  y = (90-lat)/180 * H.
81    ctx.fillStyle = 'rgba(168,243,65,0.20)';
82    ctx.strokeStyle = 'rgba(150,240,170,0.78)';
83    ctx.lineWidth = 1.6;
84    ctx.lineJoin = 'round';
85    for (const feature of featureCollection.features){
86      const geom = feature.geometry;
87      const polys = geom.type === 'MultiPolygon' ? geom.coordinates : [geom.coordinates];
88      for (const polygon of polys){
89        ctx.beginPath();
90        polygon.forEach(ring => {
91          ring.forEach(([lon, lat], i) => {
92            const x = (lon + 180) / 360 * TEX_W;
93            const y = (90 - lat) / 180 * TEX_H;
94            if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
95          });
96          ctx.closePath();
97        });
98        ctx.fill('evenodd');
99        ctx.stroke();
100      }
101    }
102    // Inner dot pattern, clipped to continents via composite
103    ctx.save();
104    ctx.globalCompositeOperation = 'source-atop';
105    ctx.fillStyle = 'rgba(160,250,180,0.18)';
106    const step = 16;
107    for (let y = step/2; y < TEX_H; y += step){
108      for (let x = step/2; x < TEX_W; x += step){
109        ctx.beginPath(); ctx.arc(x, y, 0.95, 0, Math.PI*2); ctx.fill();
110      }
111    }
112    ctx.restore();
113  }
114
115  // Immediate paint with fallback so the globe never appears blank
116  paintBase();
117  paintFallbackContinents();
118
119  const continentTex = new THREE.CanvasTexture(texCanvas);
120  continentTex.colorSpace = THREE.SRGBColorSpace;
121  continentTex.anisotropy = 4;
122  continentTex.needsUpdate = true;
123
124  // Async: replace fallback with real-world land geometry
125  (async () => {
126    try {
127      const [topojson, world] = await Promise.all([
128        import('https://cdn.jsdelivr.net/npm/topojson-client@3/+esm'),
129        fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/land-110m.json').then(r => {
130          if (!r.ok) throw new Error('world-atlas fetch failed: ' + r.status);
131          return r.json();
132        })
133      ]);
134      const land = topojson.feature(world, world.objects.land);
135      ctx.clearRect(0, 0, TEX_W, TEX_H);
136      paintBase();
137      paintRealContinents(land);
138      continentTex.needsUpdate = true;
139    } catch (e){
140      console.warn('[globe] world-atlas unavailable, keeping fallback continents:', e);
141    }
142  })();
143
144  // --- Scene ---
145  let renderer;
146  try {
147    renderer = new THREE.WebGLRenderer({ canvas, alpha:true, antialias:true });
148  } catch (e) {
149    // No WebGL (e.g. hardware accel disabled): fail gracefully, no error storm.
150    wrap.classList.add('globe-no-webgl');
151    console.warn('[globe] WebGL unavailable, skipping 3D globe:', e);
152    return;
153  }
154  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
155  const scene = new THREE.Scene();
156  const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100);
157  camera.position.set(0, 0, 3.05);
158
159  // Group rotated by user interaction / auto-rotate
160  const globeGroup = new THREE.Group();
161  scene.add(globeGroup);
162
163  // Globe body — single sphere textured with the continent canvas
164  const sphereGeo = new THREE.SphereGeometry(1, 96, 96);
165  const sphereMat = new THREE.MeshBasicMaterial({ map: continentTex });
166  const sphere = new THREE.Mesh(sphereGeo, sphereMat);
167  globeGroup.add(sphere);
168
169  // Lat/lon grid overlay
170  const gridMat = new THREE.LineBasicMaterial({ color: 0x5fd06b, transparent:true, opacity:0.09 });
171  const gridGroup = new THREE.Group();
172  [-60,-30,0,30,60].forEach(latDeg => {
173    const latR = latDeg*Math.PI/180;
174    const r = Math.cos(latR), y = Math.sin(latR);
175    const pts = []; const N=128;
176    for (let i=0;i<=N;i++){ const a=(i/N)*Math.PI*2; pts.push(new THREE.Vector3(Math.cos(a)*r*1.004, y*1.004, Math.sin(a)*r*1.004)); }
177    gridGroup.add(new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(pts), gridMat));
178  });
179  for (let lonDeg=0; lonDeg<360; lonDeg+=30){
180    const lonR = lonDeg*Math.PI/180;
181    const pts=[]; const N=96;
182    for (let i=0;i<=N;i++){
183      const lat = -Math.PI/2 + (i/N)*Math.PI;
184      pts.push(new THREE.Vector3(Math.cos(lat)*Math.cos(lonR)*1.004, Math.sin(lat)*1.004, Math.cos(lat)*Math.sin(lonR)*1.004));
185    }
186    gridGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), gridMat));
187  }
188  globeGroup.add(gridGroup);
189
190  // Atmosphere halo
191  const atmGeo = new THREE.SphereGeometry(1.2, 64, 64);
192  const atmMat = new THREE.ShaderMaterial({
193    transparent:true, side:THREE.BackSide, depthWrite:false,
194    blending:THREE.AdditiveBlending,
195    uniforms:{ uColor:{ value:new THREE.Color(0x5fd06b) } },
196    vertexShader:`varying vec3 vN; void main(){ vN=normalize(normalMatrix*normal); gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
197    fragmentShader:`varying vec3 vN; uniform vec3 uColor; void main(){ float i=pow(0.62 - dot(vN, vec3(0,0,1)), 2.2); gl_FragColor=vec4(uColor,1.0)*i*0.85; }`
198  });
199  scene.add(new THREE.Mesh(atmGeo, atmMat));
200
201  // --- Pin data: parse the hidden .pin buttons ---
202  function parseCoords(s){
203    const m = String(s||'').match(/(-?\d+(?:\.\d+)?)\s*°\s*([NS])\s*[·\-\s]+\s*(-?\d+(?:\.\d+)?)\s*°\s*([EW])/i);
204    if (!m) return null;
205    let lat = parseFloat(m[1]); if (m[2].toUpperCase()==='S') lat = -lat;
206    let lon = parseFloat(m[3]); if (m[4].toUpperCase()==='W') lon = -lon;
207    return { lat, lon };
208  }
209  function llToXYZ(lat, lon, r){
210    const phi = lat*Math.PI/180;
211    // Longitude is negated so east/west matches the (correctly drawn) continent
212    // texture — without this, pins are mirrored and land in the wrong ocean.
213    const lam = -lon*Math.PI/180;
214    return new THREE.Vector3(
215      r * Math.cos(phi) * Math.cos(lam),
216      r * Math.sin(phi),
217      r * Math.cos(phi) * Math.sin(lam)
218    );
219  }
220
221  // CanvasTexture mapping: equirectangular maps lon=0 to texture x=0.5.
222  // Three's SphereGeometry default UV maps texture x=0.5 onto +Z direction (lon=90).
223  // To compensate, rotate the sphere -90° around Y so longitude lines up.
224  sphere.rotation.y = -Math.PI / 2;
225  gridGroup.rotation.y = 0; // grid is independent of texture orientation
226  // But pins use llToXYZ which assumes lon=0 → +X. Group sphere+grid together via globeGroup,
227  // but offset sphere rotation only. To align pins with continents, rotate pinGroup by sphere's rotation too.
228
229  const pinGroup = new THREE.Group();
230  pinGroup.rotation.y = -Math.PI / 2; // align with the texture orientation on sphere
231  globeGroup.add(pinGroup);
232
233  // Likewise grid follows pin orientation so longitude lines pass through 0° at front
234  gridGroup.rotation.y = -Math.PI / 2;
235
236  const pinButtons = [...wrap.querySelectorAll('.pin')];
237  const pins = [];
238  // De-cluster: nudge any pin that lands too close to an already-placed one,
239  // spiralling it outward until it has breathing room (keeps the globe usable
240  // where projects sit on top of their partner NGO or near each other).
241  const placedLL = [];
242  function declash(ll){
243    const MIN = 1.7; let lat = ll.lat, lon = ll.lon, k = 0;
244    const near = () => placedLL.some(q => Math.abs(q.lat - lat) < MIN && Math.abs(q.lon - lon) < MIN);
245    while (near() && k < 60){
246      const a = k * 2.39996, rr = MIN * 0.85 * (1 + Math.floor(k / 8));
247      lat = ll.lat + Math.sin(a) * rr;
248      lon = ll.lon + Math.cos(a) * rr / Math.max(0.35, Math.cos(ll.lat * Math.PI / 180));
249      k++;
250    }
251    placedLL.push({ lat, lon }); return { lat, lon };
252  }
253  pinButtons.forEach(btn => {
254    const ll0 = parseCoords(btn.dataset.coords);
255    if (!ll0) return;
256    const ll = declash(ll0);
257    const status = btn.dataset.status || '';
258    // Pin colours: NGO partner = green, Active project = gold,
259    // Completed project = blue, Urgent project = coral.
260    const PIN_COLORS = {
261      'urgent': 0xe26a5b, 'project-urgent': 0xe26a5b, 'featured': 0xf0c25f,
262      'project-active': 0xf0c25f, 'project-completed': 0x5aa9e6,
263      'ngo': 0x5fd06b, 'active': 0x5fd06b,
264    };
265    const isFeatured = status === 'featured';
266    const isUrgent   = (status === 'urgent' || status === 'project-urgent');
267    const color = PIN_COLORS[status] || 0x5fd06b;
268    const label = btn.querySelector('.pin-label')?.textContent?.trim() || btn.dataset.name || '';
269
270    const surface = llToXYZ(ll.lat, ll.lon, 1.0);
271    const tip     = llToXYZ(ll.lat, ll.lon, 1.04);
272    const dotR    = (isFeatured || isUrgent) ? 0.024 : 0.017;
273
274    // Glowing dot
275    const dot = new THREE.Mesh(
276      new THREE.SphereGeometry(dotR, 18, 18),
277      new THREE.MeshBasicMaterial({ color })
278    );
279    dot.position.copy(tip);
280    pinGroup.add(dot);
281
282    // Ring around dot, oriented tangent to sphere
283    const ringInner = dotR*1.6, ringOuter = dotR*2.6;
284    const ring = new THREE.Mesh(
285      new THREE.RingGeometry(ringInner, ringOuter, 32),
286      new THREE.MeshBasicMaterial({ color, transparent:true, opacity:0.35, side:THREE.DoubleSide, depthWrite:false })
287    );
288    ring.position.copy(tip);
289    ring.lookAt(tip.clone().multiplyScalar(2));
290    pinGroup.add(ring);
291
292    // Pillar
293    const pillar = new THREE.Line(
294      new THREE.BufferGeometry().setFromPoints([surface, tip]),
295      new THREE.LineBasicMaterial({ color, transparent:true, opacity:0.55 })
296    );
297    pinGroup.add(pillar);
298
299    pins.push({ dot, ring, pillar, button: btn, label, color, status, tip });
300  });
301
302  // --- Interaction ---
303  const rot = { x: 0.22, y: -1.1 };      // initial tilt + Greenwich-ish facing
304  let isDragging = false;
305  let drag = null;
306  let lastInteract = 0;
307  let hoverPin = null;
308
309  function setHoverPin(p, clientX, clientY){
310    if (hoverPin !== p){
311      if (hoverPin){
312        hoverPin.dot.scale.set(1,1,1);
313        hoverPin.ring.material.opacity = 0.35;
314      }
315      hoverPin = p;
316      if (p){
317        p.dot.scale.set(1.7, 1.7, 1.7);
318        p.ring.material.opacity = 0.8;
319        tooltip.textContent = p.label;
320        tooltip.classList.add('is-visible');
321        canvas.style.cursor = 'pointer';
322      } else {
323        tooltip.classList.remove('is-visible');
324        canvas.style.cursor = isDragging ? 'grabbing' : 'grab';
325      }
326    }
327    if (p && clientX != null){
328      const rect = canvas.getBoundingClientRect();
329      tooltip.style.left = (clientX - rect.left) + 'px';
330      tooltip.style.top  = (clientY - rect.top)  + 'px';
331    }
332  }
333
334  const raycaster = new THREE.Raycaster();
335  raycaster.params.Points = { threshold: 0.02 };
336  const mouseNDC = new THREE.Vector2();
337  const dotMeshes = pins.map(p => p.dot);
338
339  function pickPin(clientX, clientY){
340    const rect = canvas.getBoundingClientRect();
341    mouseNDC.x = ((clientX - rect.left) / rect.width)  *  2 - 1;
342    mouseNDC.y = ((clientY - rect.top)  / rect.height) * -2 + 1;
343    raycaster.setFromCamera(mouseNDC, camera);
344    // Increase pin hit radius via threshold-ish trick: raycast spheres directly
345    const hits = raycaster.intersectObjects(dotMeshes, false);
346    if (!hits.length) return null;
347    const sphereHit = raycaster.intersectObject(sphere, false);
348    const nearest = hits[0];
349    // Reject if globe surface is in front of the pin (i.e. pin is behind globe)
350    if (sphereHit.length && sphereHit[0].distance + 0.005 < nearest.distance) return null;
351    return pins[dotMeshes.indexOf(nearest.object)];
352  }
353
354  canvas.addEventListener('pointerdown', (e) => {
355    isDragging = true;
356    canvas.classList.add('is-dragging');
357    try { canvas.setPointerCapture(e.pointerId); } catch(_){}
358    drag = { x: e.clientX, y: e.clientY, rotX: rot.x, rotY: rot.y, moved: 0 };
359    lastInteract = performance.now();
360  });
361  canvas.addEventListener('pointermove', (e) => {
362    if (isDragging){
363      const dx = e.clientX - drag.x;
364      const dy = e.clientY - drag.y;
365      drag.moved = Math.max(drag.moved, Math.hypot(dx, dy));
366      rot.y = drag.rotY + dx * 0.005;
367      rot.x = drag.rotX + dy * 0.005;
368      rot.x = Math.max(-Math.PI/2.1, Math.min(Math.PI/2.1, rot.x));
369      lastInteract = performance.now();
370      setHoverPin(null);
371    } else {
372      const p = pickPin(e.clientX, e.clientY);
373      setHoverPin(p, e.clientX, e.clientY);
374      // Pause auto-rotate while cursor is over the canvas
375      lastInteract = performance.now();
376    }
377  });
378  canvas.addEventListener('pointerenter', () => { lastInteract = performance.now(); });
379  // --- Right-rail project card population ---
380  const card = document.getElementById('globeCard');
381  const gcDetailEl = card?.querySelector('.gc-detail');
382  const gcCloseBtn = document.getElementById('gcClose');
383  function statusLabel(s){
384    if (s === 'urgent' || s === 'project-urgent') return 'Urgent project';
385    if (s === 'project-active') return 'Active project';
386    if (s === 'project-completed') return 'Completed project';
387    if (s === 'featured') return 'Featured project';
388    return 'NGO partner';
389  }
390  function populateGlobeCard(btn){
391    if (!card || !btn) return;
392    const status = btn.dataset.status || '';
393    card.classList.remove('is-featured','is-urgent');
394    if (status === 'featured') card.classList.add('is-featured');
395    else if (status === 'urgent') card.classList.add('is-urgent');
396    card.classList.add('is-active');
397    if (gcDetailEl) gcDetailEl.hidden = false;
398    const set = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; };
399    set('gcStatus', statusLabel(status));
400    set('gcWhere',  btn.dataset.where  || '—');
401    set('gcCoords', btn.dataset.coords || '—');
402    set('gcName',   btn.dataset.name   || '—');
403    set('gcNgo',    btn.dataset.ngo    || '—');
404    set('gcDesc',   btn.dataset.desc   || '');
405    set('gcStat',   btn.dataset.stat   || '—');
406    // Hide the "On the ground" stat box when there's no tagline (partner mode).
407    const statBox = document.querySelector('#globeCard .gc-stat-box');
408    if (statBox) statBox.style.display = (btn.dataset.stat || '').trim() ? '' : 'none';
409    // NGO image — preload-probe so broken URLs hide cleanly. Skip videos.
410    const gcImg = document.getElementById('gcImg');
411    if (gcImg){
412      const raw = (btn.dataset.img || '').trim();
413      const looksLikeImage = raw && !/\.(mp4|webm|mov|m4v)(\?|$)/i.test(raw);
414      gcImg.style.backgroundImage = '';
415      gcImg.hidden = true;
416      gcImg.dataset.target = raw; // for race-condition guard
417      if (looksLikeImage){
418        const probe = new Image();
419        probe.onload = () => {
420          if (gcImg.dataset.target !== raw) return; // another pin clicked since
421          gcImg.style.backgroundImage = `url("${raw.replace(/"/g, '\\"')}")`;
422          gcImg.hidden = false;
423        };
424        probe.onerror = () => { /* keep hidden */ };
425        probe.src = raw;
426      }
427    }
428    const cta = document.getElementById('gcCta');
429    if (cta && btn.dataset.href) cta.setAttribute('href', btn.dataset.href);
430    // On narrow screens, the card stacks below the globe — scroll it into view
431    if (window.matchMedia('(max-width: 1080px)').matches){
432      card.scrollIntoView({ behavior:'smooth', block:'center' });
433    }
434  }
435  function closeGlobeCard(){
436    if (!card) return;
437    card.classList.remove('is-active','is-featured','is-urgent');
438    if (gcDetailEl) gcDetailEl.hidden = true;
439  }
440  gcCloseBtn?.addEventListener('click', closeGlobeCard);
441
442  function endDrag(e){
443    if (!isDragging) return;
444    isDragging = false;
445    canvas.classList.remove('is-dragging');
446    try { canvas.releasePointerCapture(e.pointerId); } catch(_){}
447    if (drag && drag.moved < 5){
448      const p = pickPin(e.clientX, e.clientY);
449      if (p && p.button) populateGlobeCard(p.button);
450    }
451    drag = null;
452  }
453  canvas.addEventListener('pointerup', endDrag);
454  canvas.addEventListener('pointercancel', endDrag);
455  canvas.addEventListener('pointerleave', (e) => {
456    if (isDragging) endDrag(e);
457    setHoverPin(null);
458  });
459  // Prevent native scroll while dragging vertically on touch
460  canvas.addEventListener('touchstart', (e) => { if (e.cancelable) e.preventDefault(); }, { passive:false });
461
462  // --- Zoom (wheel + pinch) so dense regions like the UK can be inspected ---
463  let baseZ = 3.05;          // camera distance at zoom = 1 (recomputed in resize)
464  let zoom  = 1;             // 1 = full globe, higher = closer in
465  const MIN_ZOOM = 1, MAX_ZOOM = 5;
466  function applyZoom(){ camera.position.z = baseZ / zoom; camera.updateProjectionMatrix(); }
467  function setZoom(z){ zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z)); applyZoom(); lastInteract = performance.now(); }
468
469  // --- Resize ---
470  function resize(){
471    const rect = wrap.getBoundingClientRect();
472    const w = Math.max(1, rect.width);
473    const h = Math.max(1, rect.height);
474    renderer.setSize(w, h, false);
475    camera.aspect = w / h;
476    // For wide aspect (2:1), pull camera back so globe fits vertically with margin
477    baseZ = 3.55 * Math.max(1, (w/h) * 0.55);
478    applyZoom();
479  }
480  resize();
481
482  // Scroll / trackpad to zoom; double-click (or double-tap) to reset to full globe
483  canvas.addEventListener('wheel', (e) => {
484    e.preventDefault();
485    setZoom(zoom * (e.deltaY < 0 ? 1.12 : 1 / 1.12));
486  }, { passive:false });
487  canvas.addEventListener('dblclick', () => setZoom(1));
488  // Two-finger pinch zoom on touch devices
489  let pinchDist = 0;
490  canvas.addEventListener('touchstart', (e) => {
491    if (e.touches.length === 2)
492      pinchDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
493  }, { passive:true });
494  canvas.addEventListener('touchmove', (e) => {
495    if (e.touches.length === 2 && pinchDist){
496      const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
497      setZoom(zoom * (d / pinchDist));
498      pinchDist = d;
499      if (e.cancelable) e.preventDefault();
500    }
501  }, { passive:false });
502  const ro = new ResizeObserver(resize);
503  ro.observe(wrap);
504
505  // --- Animate ---
506  const clock = new THREE.Clock();
507  function tick(){
508    const dt = Math.min(0.1, clock.getDelta());
509    const idle = performance.now() - lastInteract > 1800;
510    if (idle && !isDragging && !hoverPin && zoom <= 1.05){
511      rot.y += 0.06 * dt;
512    }
513    globeGroup.rotation.x = rot.x;
514    globeGroup.rotation.y = rot.y;
515    // Soft pulse on featured/urgent rings
516    const t = clock.elapsedTime;
517    pins.forEach(p => {
518      if (p === hoverPin) return;
519      const urgent = (p.status === 'urgent' || p.status === 'project-urgent');
520      if (urgent){
521        // Emergency beacon: fast, strong pulse on the ring + dot.
522        const beat = (Math.sin(t * 5.2) + 1) / 2;        // 0..1, ~0.8 Hz
523        p.ring.material.opacity = 0.3 + beat * 0.6;
524        const s = 1 + beat * 1.6;
525        p.ring.scale.set(s, s, s);
526        p.dot.scale.set(1 + beat * 0.6, 1 + beat * 0.6, 1 + beat * 0.6);
527      } else {
528        const base = (p.status === 'featured') ? 0.45 : 0.3;
529        p.ring.material.opacity = base + Math.sin(t*1.5 + p.tip.x*4) * 0.12;
530      }
531    });
532    renderer.render(scene, camera);
533    requestAnimationFrame(tick);
534  }
535  tick();
536
537  // --- Page-level focus (NGO / project pages) -----------------------------
538  // If #globeWrap has data-focus-name="…" matching a pin's data-name, rotate
539  // the globe to face that pin, dim the others, mark it as the highlight,
540  // and pre-populate the right-rail card so it lands on the page already
541  // showing this project. Toggle off when the user clicks a different pin.
542  const focusName = wrap.dataset.focusName || '';
543  if (focusName){
544    const focusPin = pins.find(p => p.button?.dataset.name === focusName);
545    if (focusPin){
546      const ll = parseCoords(focusPin.button.dataset.coords);
547      if (ll){
548        // Face the pin: rotation.y = lon (radians), rotation.x = small downward tilt to centre
549        rot.y = ll.lon * Math.PI / 180;
550        rot.x = Math.max(-Math.PI/2.4, Math.min(Math.PI/2.4, -ll.lat * Math.PI / 180 * 0.55));
551        lastInteract = performance.now() + 2_500; // hold position for ~2.5s before auto-rotate
552      }
553      // Highlight: bigger ring + brighter dot
554      focusPin.dot.scale.set(1.6, 1.6, 1.6);
555      focusPin.ring.material.opacity = 0.85;
556      focusPin.dot.material.color.set(focusPin.color);
557      // Dim non-focus pins
558      pins.forEach(p => {
559        if (p === focusPin) return;
560        p.dot.material.opacity = 0.55;
561        p.dot.material.transparent = true;
562        p.ring.material.opacity = 0.18;
563        p.pillar.material.opacity = 0.25;
564      });
565      // Pre-populate the right-rail card
566      if (typeof populateGlobeCard === 'function'){
567        populateGlobeCard(focusPin.button);
568      }
569    }
570  }
571
572  // Mark visible so any existing reveal CSS runs
573  wrap.classList.add('is-visible');
574})();

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.