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.