1/* ============================================================ 2 Header navigation â single source of truth. 3 Every page ships with a fallback flat <nav class="nav">â¦</nav> 4 for no-JS users. This block re-renders that nav with grouped 5 dropdowns (About / Projects / Give) plus two flat links 6 (Watch, News). Currently-active route is marked 7 aria-current="page". Dropdowns open on click + keyboard, close 8 on outside click / Escape. 9 ============================================================ */ 10/* Header navigation now lives in /assets/nav.js (loaded on every page). */ 11 12 13/* ============================================================ 14 Header utility link â small "NGO sign-in â" prepended to the 15 .header-cta cluster on every page. Discreet, muted, sits to 16 the left of the Channel / Support our work buttons so it's 17 discoverable for partner NGOs without dominating the header. 18 Also surfaces inside the mobile drawer so it's reachable on 19 touch devices where .header-cta is hidden. 20 ============================================================ */ 21(function ngoSignInLink(){ 22 const NGO_LABEL = 'NGO sign-in'; 23 const NGO_HREF = '/ngo-portal/'; 24 25 // Desktop / tablet â slot it into the header-cta cluster. 26 const cta = document.querySelector('header.header .header-cta'); 27 if (cta && !cta.querySelector('.header-ngo-link')){ 28 const a = document.createElement('a'); 29 a.href = NGO_HREF; 30 a.className = 'header-ngo-link'; 31 a.setAttribute('aria-label', 'NGO partner sign-in'); 32 a.innerHTML = NGO_LABEL + ' <span class="hnl-arr" aria-hidden="true">â</span>'; 33 cta.insertBefore(a, cta.firstChild); 34 } 35
36 // Mobile drawer â append as a secondary action so partners can 37 // reach the portal from the burger menu too. 38 const drawer = document.querySelector('.drawer'); 39 if (drawer && !drawer.querySelector('.drawer-ngo-link')){ 40 const a = document.createElement('a'); 41 a.href = NGO_HREF; 42 a.className = 'drawer-ngo-link'; 43 a.innerHTML = '<span>NGO partner sign-in</span><span aria-hidden="true">â</span>'; 44 drawer.appendChild(a); 45 } 46})(); 47 48/* ============================================================ 49 Cartograph auto-enricher. 50 Many sub-pages ship a `<svg>` inside `.bg-cartograph` with 51 only one or two decorative paths â looks thin compared to the 52 homepage's richer setups. This IIFE finds those sparse SVGs 53 and injects additional layers (a secondary trail, a compass 54 rose, animal tracks, coordinate markers, ruler ticks) so 55 every page has the same cartographic depth. 56 57 It varies the injection by section index so adjacent sections 58 on the same page don't look identical, and skips SVGs that 59 already have rich content (3+ children of relevant types). 60 ============================================================ */ 61(function enrichCartographs(){ 62 const NS = 'http://www.w3.org/2000/svg'; 63 const ready = cb => (document.readyState === 'loading') 64 ? document.addEventListener('DOMContentLoaded', cb, { once:true }) 65 : cb(); 66 67 // Five mini-templates that get rotated through sections. Each 68 // is an array of SVG elements built lazily so we can vary 69 // transforms / coords per instance. 70 function buildVariant(i, w, h){ 71 const els = []; 72 const make = (tag, attrs) => { 73 const el = document.createElementNS(NS, tag); 74 Object.entries(attrs).forEach(([k,v]) => el.setAttribute(k, String(v))); 75 return el; 76 }; 77 const v = i % 5; 78 79 // Latitude lines â always 2-3 to anchor the horizon. 80 const latPositions = [h*0.22, h*0.58, h*0.85].slice(0, v === 4 ? 3 : 2); 81 latPositions.forEach(y => { 82 els.push(make('line', { 83 class:'bg-lat', x1:-50, y1:y, x2:w+50, y2:y, 84 'stroke-dasharray': v % 2 === 0 ? '2 18' : '2 12' 85 })); 86 }); 87 88 // Primary trail â varied shape per index. 89 const trails = [ 90 `M-50,${h*0.42} C${w*0.25},${h*0.30} ${w*0.42},${h*0.62} ${w*0.62},${h*0.42} C${w*0.82},${h*0.22} ${w},${h*0.55} ${w+60},${h*0.40}`, 91 `M-50,${h*0.65} C${w*0.20},${h*0.50} ${w*0.45},${h*0.75} ${w*0.70},${h*0.55} C${w*0.88},${h*0.40} ${w*1.05},${h*0.65} ${w+60},${h*0.55}`, 92 `M-50,${h*0.35} C${w*0.18},${h*0.55} ${w*0.40},${h*0.25} ${w*0.65},${h*0.45} C${w*0.85},${h*0.65} ${w*1.05},${h*0.32} ${w+60},${h*0.45}`, 93 `M-50,${h*0.78} C${w*0.30},${h*0.45} ${w*0.55},${h*0.70} ${w*0.80},${h*0.40} C${w*0.95},${h*0.25} ${w*1.05},${h*0.50} ${w+60},${h*0.38}`, 94 `M-50,${h*0.50} C${w*0.22},${h*0.62} ${w*0.48},${h*0.32} ${w*0.72},${h*0.55} C${w*0.90},${h*0.72} ${w*1.05},${h*0.42} ${w+60},${h*0.50}`, 95 ]; 96 els.push(make('path', { class:'bg-trail', d: trails[v] })); 97 98 // Soft secondary trail for layered depth. 99 els.push(make('path', { 100 class:'bg-trail-soft', 101 d:`M-50,${h*0.20 + v*20} C${w*0.30},${h*0.42} ${w*0.55},${h*0.18} ${w*0.78},${h*0.32} C${w*0.95},${h*0.42} ${w*1.05},${h*0.22} ${w+60},${h*0.28}` 102 })); 103 104 // Animal tracks along the primary trail (small dots). 105 const trackY = h * (0.40 + (v - 2) * 0.06); 106 for (let k = 0; k < 6; k++){ 107 els.push(make('circle', { 108 class:'bg-track', 109 cx: w*0.18 + k * w*0.10, 110 cy: trackY + Math.sin(k + v) * 14, 111 r: 2.8 + (k % 2) * 0.4 112 })); 113 } 114 115 // Compass rose in a corner (alternates corners). 116 const corners = [ 117 { x: w*0.88, y: h*0.18, label:'N' }, 118 { x: w*0.10, y: h*0.82, label:'S' }, 119 { x: w*0.90, y: h*0.78, label:'E' }, 120 { x: w*0.12, y: h*0.22, label:'W' }, 121 { x: w*0.86, y: h*0.20, label:'N' }, 122 ]; 123 const c = corners[v]; 124 const compass = make('g', { class:'bg-compass', transform:`translate(${c.x},${c.y})` }); 125 compass.appendChild(make('circle', { class:'bg-compass-ring', r:42 })); 126 compass.appendChild(make('circle', { class:'bg-compass-ring', r:30 })); 127 compass.appendChild(make('line', { class:'bg-compass-tick', x1:0, y1:-46, x2:0, y2:46 })); 128 compass.appendChild(make('line', { class:'bg-compass-tick', x1:-46, y1:0, x2:46, y2:0 })); 129 compass.appendChild(make('polygon',{ class:'bg-compass-arrow', points:'0,-42 5,0 0,42 -5,0' })); 130 compass.appendChild(make('polygon',{ class:'bg-compass-arrow', points:'-42,0 0,5 42,0 0,-5' })); 131 const label = make('text', { class:'bg-compass-label', x:0, y:-52, 'text-anchor':'middle' }); 132 label.textContent = c.label; 133 compass.appendChild(label); 134 els.push(compass); 135
136 // Two coordinate markers (cross-bearings) scattered. 137 const markers = [ 138 { x: w*0.32, y: h*0.30, r: 14 }, 139 { x: w*0.68, y: h*0.72, r: 10 }, 140 ]; 141 markers.forEach((m, idx) => { 142 const g = make('g', { class:'bg-marker', transform:`translate(${m.x},${m.y})` }); 143 g.appendChild(make('circle', { r: m.r })); 144 g.appendChild(make('circle', { r: 3, class:'bg-marker-dot' })); 145 if (idx === 0){ 146 g.appendChild(make('line', { class:'bg-compass-tick', x1:-m.r-8, y1:0, x2:-m.r, y2:0 })); 147 g.appendChild(make('line', { class:'bg-compass-tick', x1: m.r, y1:0, x2: m.r+8, y2:0 })); 148 } 149 els.push(g); 150 }); 151 152 // Coordinate stamp text â gives a "log entry" feel. 153 const stampLabels = ['02 · CROSSING','03 · WAYPOINT','04 · CACHE','05 · MARKER','06 · TRAIL']; 154 const stamp = make('text', { 155 class:'bg-coord-text', 156 x: w*0.08, y: h*0.92 157 }); 158 stamp.textContent = stampLabels[v]; 159 els.push(stamp); 160 161 return els; 162 } 163 164 ready(() => { 165 let idx = 0; 166 document.querySelectorAll('.bg-cartograph > svg').forEach(svg => { 167 // Only enrich SVGs that look sparse. 168 const children = svg.children.length; 169 if (children >= 5) return; 170 171 // Parse viewBox to size the new content correctly. 172 const vb = (svg.getAttribute('viewBox') || '0 0 1600 900').split(/\s+/).map(Number); 173 const w = vb[2] || 1600; 174 const h = vb[3] || 900; 175 176 const newEls = buildVariant(idx++, w, h); 177 178 // Insert before any existing children so they sit on top. 179 const first = svg.firstChild; 180 newEls.forEach(el => svg.insertBefore(el, first)); 181 182 svg.setAttribute('data-enriched','1'); 183 }); 184 }); 185})(); 186 187/* ====== Year ====== */ 188(function setYear(){ 189 const yr = document.getElementById('yr'); 190 if (yr) yr.textContent = new Date().getFullYear(); 191})(); 192 193/* ====== Ambient mouse-follow green glow ====== */ 194(() => { 195 const glow = document.getElementById('ambientGlow'); 196 if (!glow) return; 197 const finePointer = window.matchMedia('(hover:hover) and (pointer:fine)').matches; 198 const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 199 if (!finePointer || reduceMotion) return; 200 201 let tx = window.innerWidth / 2, ty = window.innerHeight / 3; 202 let gx = tx, gy = ty; 203 let running = false; 204 205 document.addEventListener('mousemove', (e) => { 206 tx = e.clientX; ty = e.clientY; 207 if (!glow.classList.contains('is-on')) glow.classList.add('is-on'); 208 if (!running) { running = true; requestAnimationFrame(frame); } 209 }); 210 211 function frame(){ 212 // slow easing for an ambient drift, not a snappy cursor 213 gx += (tx - gx) * 0.05; 214 gy += (ty - gy) * 0.05; 215 glow.style.transform = `translate(${gx}px, ${gy}px) translate(-50%, -50%)`; 216 if (Math.abs(tx - gx) > 0.5 || Math.abs(ty - gy) > 0.5) { 217 requestAnimationFrame(frame); 218 } else { 219 running = false; 220 } 221 } 222})(); 223 224/* ====== Header scroll state + parallax CSS variable for cartograph art ====== */ 225const header = document.getElementById('header'); 226const docEl = document.documentElement; 227const onScroll = () => { 228 header.classList.toggle('scrolled', window.scrollY > 24); 229 // Expose scroll position to CSS so the .bg-cartograph illustrations 230 // parallax up slightly as the user scrolls (subtle depth-of-field). 231 docEl.style.setProperty('--scroll-y', String(window.scrollY)); 232}; 233onScroll(); 234window.addEventListener('scroll', onScroll, { passive:true }); 235 236/* ====== Mobile drawer ====== */ 237const menuBtn = document.getElementById('menuBtn'); 238const drawer = document.getElementById('drawer'); 239function setDrawer(open){ 240 menuBtn.classList.toggle('open', open); 241 drawer.classList.toggle('open', open); 242 drawer.setAttribute('aria-hidden', String(!open)); 243 menuBtn.setAttribute('aria-expanded', String(open)); 244 menuBtn.setAttribute('aria-label', open ? 'Close menu' : 'Open menu'); 245 document.body.style.overflow = open ? 'hidden' : ''; 246} 247menuBtn?.addEventListener('click', () => setDrawer(!menuBtn.classList.contains('open'))); 248drawer?.querySelectorAll('a').forEach(a => a.addEventListener('click', () => setDrawer(false))); 249document.addEventListener('keydown', (e) => { 250 if (e.key === 'Escape' && menuBtn.classList.contains('open')) setDrawer(false); 251}); 252 253/* ====== Hero (single-viewport static, no scroll-scrub) ====== */ 254const heroCard = document.getElementById('heroCard'); 255const scrollCue = document.getElementById('scrollCue'); 256const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 257 258if (heroCard) heroCard.style.setProperty('--card-copy-op', 1); 259if (scrollCue) scrollCue.style.setProperty('--cue-op', 1); 260 261// Honour reduced-motion: pause every autoplay video, leave the poster in view. 262if (reducedMotion) { 263 document.querySelectorAll('video[autoplay]').forEach(v => { 264 v.removeAttribute('autoplay'); 265 v.pause(); 266 }); 267} 268 269/* ====== IntersectionObserver reveals ====== */ 270const io = new IntersectionObserver((entries) => {
271 entries.forEach(e => { 272 if (e.isIntersecting) { 273 e.target.classList.add('in'); 274 io.unobserve(e.target); 275 } 276 }); 277}, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' }); 278 279document.querySelectorAll('.reveal, .reveal-stagger').forEach(el => io.observe(el)); 280 281/* Section card-lift trigger â adds .is-revealed to each .section-lift 282 when its top edge enters the viewport, animating the rise + scale */ 283const liftIO = new IntersectionObserver((entries) => { 284 entries.forEach(e => { 285 if (e.isIntersecting) { 286 e.target.classList.add('is-revealed'); 287 liftIO.unobserve(e.target); 288 } 289 }); 290}, { threshold: 0.04, rootMargin: '0px 0px -2% 0px' }); 291document.querySelectorAll('.section-lift').forEach(el => liftIO.observe(el)); 292 293/* ====== Hero single clip â kick off playback, no carousel rotation ====== */ 294(() => { 295 const carousel = document.getElementById('heroCarousel'); 296 if (!carousel) return; 297 const video = carousel.querySelector('.hcv'); 298 if (!video) return; 299 video.play().catch(() => {/* autoplay policy may block â fine, will play on first interaction */}); 300})(); 301 302/* Project cards now render as a static grid â horizontal-scroll driver removed. */ 303 304/* ====== Word-by-word reveals on big cinematic headlines ====== */ 305(() => { 306 function wrapWords(el){ 307 const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null); 308 const nodes = []; 309 while (walker.nextNode()) nodes.push(walker.currentNode); 310 nodes.forEach(node => { 311 if (!node.textContent.trim()) return; 312 const parts = node.textContent.split(/(\s+)/); 313 const frag = document.createDocumentFragment(); 314 parts.forEach(p => { 315 if (/\S/.test(p)) { 316 const wrap = document.createElement('span'); 317 wrap.className = 'word'; 318 const inner = document.createElement('span'); 319 inner.className = 'word-inner'; 320 inner.textContent = p; 321 wrap.appendChild(inner); 322 frag.appendChild(wrap); 323 } else if (p.length) { 324 frag.appendChild(document.createTextNode(p)); 325 } 326 }); 327 node.parentNode.replaceChild(frag, node); 328 }); 329 } 330 document.querySelectorAll('.reveal-words').forEach(wrapWords); 331 332 if (reducedMotion) { 333 document.querySelectorAll('.reveal-words').forEach(el => el.classList.add('in')); 334 } else { 335 const wIO = new IntersectionObserver((entries) => { 336 entries.forEach(e => { 337 if (e.isIntersecting) { 338 e.target.classList.add('in'); 339 wIO.unobserve(e.target); 340 } 341 }); 342 }, { threshold: 0.3 }); 343 document.querySelectorAll('.reveal-words').forEach(el => wIO.observe(el)); 344 } 345})(); 346 347/* ====== Timeline + pillar icon reveals (per-item, once only) ====== */ 348const tlIO = new IntersectionObserver((entries) => { 349 entries.forEach(e => { 350 if (e.isIntersecting) { 351 e.target.classList.add('in'); 352 tlIO.unobserve(e.target); 353 } 354 }); 355}, { threshold: 0.25, rootMargin: '0px 0px -10% 0px' }); 356document.querySelectorAll('.tl-item, .pillar, .proj-card').forEach(el => tlIO.observe(el)); 357 358/* ====== World map popups ====== */ 359(() => { 360 const wrap = document.querySelector('.globe-wrap'); 361 const popup = document.getElementById('mapPopup'); 362 if (!wrap || !popup) return; 363 const mpImg = document.getElementById('mpImg'); 364 const mpStatus = document.getElementById('mpStatus'); 365 const mpWhere = document.getElementById('mpWhere'); 366 const mpCoords = document.getElementById('mpCoords'); 367 const mpName = document.getElementById('mpName'); 368 const mpNgo = document.getElementById('mpNgo'); 369 const mpDesc = document.getElementById('mpDesc'); 370 const mpStat = document.getElementById('mpStat'); 371 const mpCta = document.getElementById('mpCta'); 372 const mpClose = document.getElementById('mpClose'); 373 374 function close(){ 375 popup.classList.remove('is-open'); 376 popup.setAttribute('aria-hidden', 'true'); 377 wrap.classList.remove('popup-open'); 378 } 379 function openFor(pin){ 380 const d = pin.dataset; 381 // Populate 382 mpImg.style.backgroundImage = d.img ? `url('${d.img}')` : 'none'; 383 mpImg.style.display = d.img ? 'block' : 'none'; 384 mpStatus.textContent = d.status === 'urgent' ? 'Urgent' : d.status === 'featured' ? 'Featured' : 'Active'; 385 mpWhere.textContent = d.where || 'â'; 386 mpCoords.textContent = d.coords || ''; 387 mpName.textContent = d.name || 'â'; 388 mpNgo.textContent = d.ngo || ''; 389 mpDesc.textContent = d.desc || ''; 390 mpStat.textContent = d.stat || ''; 391 mpCta.setAttribute('href', d.href || '#'); 392 popup.classList.remove('is-urgent', 'is-featured'); 393 if (d.status === 'urgent') popup.classList.add('is-urgent'); 394 if (d.status === 'featured') popup.classList.add('is-featured'); 395 396 // Centre the popup in the middle of the map (size/position via CSS class) 397 const wrapRect = wrap.getBoundingClientRect(); 398 const popupW = Math.min(380, wrapRect.width - 32); 399 popup.style.width = popupW + 'px'; 400 popup.style.left = '50%'; 401 popup.style.top = '50%'; 402 // Clear any inline transform so CSS .is-open transition takes over 403 popup.style.transform = ''; 404 wrap.classList.add('popup-open'); 405 406 popup.classList.add('is-open'); 407 popup.setAttribute('aria-hidden', 'false'); 408 } 409 410 wrap.querySelectorAll('.pin').forEach(pin => { 411 pin.addEventListener('click', (e) => { 412 e.stopPropagation(); 413 openFor(pin); 414 }); 415 }); 416 mpClose.addEventListener('click', close); 417 document.addEventListener('click', (e) => { 418 if (!popup.classList.contains('is-open')) return; 419 if (popup.contains(e.target)) return; 420 if (e.target.closest('.pin')) return; 421 close(); 422 }); 423 document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); }); 424})(); 425 426/* ====== Number counters ====== */ 427const easeOut = t => 1 - Math.pow(1 - t, 3); 428function animateCount(el) { 429 const to = parseInt(el.dataset.to, 10); 430 // Guard: only animate elements that opted in with a valid numeric data-to. 431 // Without this, plain .count elements (which carry their own markup) get 432 // overwritten with "NaN" and lose their inner label. 433 if (!Number.isFinite(to)) return; 434 const prefix = el.dataset.prefix || ''; 435 const suffix = el.dataset.suffix || ''; 436 const dur = 1800; 437 const start = performance.now(); 438 const tick = (now) => { 439 const t = Math.min(1, (now - start) / dur); 440 const v = Math.floor(easeOut(t) * to);
441 el.textContent = prefix + v.toLocaleString() + suffix; 442 if (t < 1) requestAnimationFrame(tick); 443 else el.textContent = prefix + to.toLocaleString() + suffix; 444 }; 445 requestAnimationFrame(tick); 446} 447const countIO = new IntersectionObserver((entries) => { 448 entries.forEach(e => { 449 if (e.isIntersecting) { 450 animateCount(e.target); 451 countIO.unobserve(e.target); 452 } 453 }); 454}, { threshold: 0.4 }); 455document.querySelectorAll('.count[data-to]').forEach(el => countIO.observe(el)); 456 457/* ====== Pillars pinned-side slide swap ====== */ 458const pillars = [...document.querySelectorAll('.pillar')]; 459const slides = [...document.querySelectorAll('.pillars-media .slide')]; 460const badgeText = document.getElementById('pillarBadgeText'); 461const pillarLabels = [ 462 'Pillar 01 · The Channel', 463 'Pillar 02 · NGO Pages', 464 'Pillar 03 · The Audience', 465 'Pillar 04 · Direct Action' 466]; 467const pillarIO = new IntersectionObserver((entries) => { 468 entries.forEach(e => { 469 if (e.isIntersecting) { 470 const idx = parseInt(e.target.dataset.i, 10); 471 slides.forEach(s => s.classList.toggle('active', parseInt(s.dataset.i,10) === idx)); 472 badgeText.textContent = pillarLabels[idx]; 473 } 474 }); 475}, { threshold: 0.5, rootMargin: '-25% 0px -25% 0px' }); 476pillars.forEach(p => pillarIO.observe(p)); 477 478/* ====== Project card spotlight + tilt ====== */ 479document.querySelectorAll('.proj-card.js-tilt').forEach(card => { 480 card.addEventListener('mousemove', (e) => { 481 const r = card.getBoundingClientRect(); 482 const x = ((e.clientX - r.left) / r.width) * 100; 483 const y = ((e.clientY - r.top) / r.height) * 100; 484 card.style.setProperty('--mx', x + '%'); 485 card.style.setProperty('--my', y + '%'); 486 const tiltX = (50 - y) * 0.05; 487 const tiltY = (x - 50) * 0.05; 488 card.style.transform = `perspective(1000px) rotateX(${tiltX}deg) rotateY(${tiltY}deg) translateZ(0)`; 489 }); 490 card.addEventListener('mouseleave', () => { 491 card.style.transform = ''; 492 card.style.setProperty('--mx', '50%'); 493 card.style.setProperty('--my', '50%'); 494 }); 495}); 496 497/* ====== Biome scrollytelling cross-fade ====== */ 498(() => { 499 const section = document.querySelector('.biomes'); 500 if (!section) return; 501 const layers = [...section.querySelectorAll('.biome-layer')]; 502 const texts = [...section.querySelectorAll('.biome-text')]; 503 const chips = [...section.querySelectorAll('.bchip')]; 504 const n = layers.length; 505 let currentIdx = -1; 506 507 function setActive(idx){ 508 if (idx === currentIdx) return; 509 currentIdx = idx; 510 layers.forEach((l, i) => { 511 const active = i === idx; 512 l.classList.toggle('is-active', active); 513 if (active) l.play().catch(() => {}); 514 else l.pause(); 515 }); 516 texts.forEach((t, i) => t.classList.toggle('is-active', i === idx)); 517 chips.forEach((c, i) => c.classList.toggle('is-active', i === idx)); 518 } 519 520 function onScroll(){ 521 const r = section.getBoundingClientRect(); 522 const total = section.offsetHeight - window.innerHeight; 523 const progress = Math.max(0, Math.min(1, -r.top / total)); 524 // Map progress to biome index. Each biome owns 1/n of the scroll. 525 const idx = Math.min(n - 1, Math.floor(progress * n)); 526 setActive(idx); 527 } 528 529 if (reducedMotion) { 530 setActive(0); 531 } else { 532 onScroll(); 533 window.addEventListener('scroll', onScroll, { passive:true }); 534 window.addEventListener('resize', onScroll); 535 } 536})(); 537 538/* ====== Pause off-screen videos (saves CPU / bandwidth) + custom 539 playback rates ============================================ 540 Any video with `data-rate="<n>"` plays at that rate. The rate 541 setup runs UNGATED by reducedMotion (rate is unrelated to motion 542 preference) and attaches a `ratechange` listener that re-asserts 543 our target whenever the rate drifts â handles browsers that 544 reset to 1x after pause/resume, autoplay-from-attribute fires 545 before our handlers attach, etc. 546 ============================================================ */ 547function applyVideoRate(v){ 548 const r = parseFloat(v.dataset.rate || ''); 549 if (!isFinite(r) || r <= 0) return; 550 // Guard against re-entry: when WE set playbackRate, the assignment 551 // triggers a `ratechange` event which would call this again. The 552 // flag breaks that loop. 553 if (v._ecoflixSettingRate) return; 554 if (Math.abs(v.playbackRate - r) > 0.001){ 555 try { 556 v._ecoflixSettingRate = true; 557 v.playbackRate = r; 558 } catch(_){} 559 setTimeout(() => { v._ecoflixSettingRate = false; }, 0); 560 } 561} 562// Wire rate-control to EVERY tagged video, regardless of 563// reducedMotion / IntersectionObserver support. This block runs 564// at script parse time AND on DOMContentLoaded so it catches both 565// early autoplay and any videos injected later. 566function wireVideoRate(v){ 567 if (v._ecoflixRateWired) return; 568 v._ecoflixRateWired = true; 569 applyVideoRate(v); 570 ['play', 'playing', 'loadedmetadata', 'loadeddata', 571 'canplay', 'seeked', 'ratechange'].forEach(ev => { 572 v.addEventListener(ev, () => applyVideoRate(v)); 573 }); 574 // Belt-and-braces: re-assert at 150ms, 500ms, 1500ms in case some 575 // upstream code (a library, the browser itself) resets the rate 576 // after first play. 577 setTimeout(() => applyVideoRate(v), 150); 578 setTimeout(() => applyVideoRate(v), 500); 579 setTimeout(() => applyVideoRate(v), 1500); 580} 581function wireAllVideoRates(){ 582 document.querySelectorAll('video[data-rate]').forEach(wireVideoRate); 583} 584wireAllVideoRates(); 585if (document.readyState === 'loading'){ 586 document.addEventListener('DOMContentLoaded', wireAllVideoRates, { once:true }); 587} 588 589// Off-screen pause/play â kept inside the reducedMotion + IO guard 590// because that's a perf optimisation (motion-related). The rate 591// system above runs independently of this. 592if (!reducedMotion && 'IntersectionObserver' in window) { 593 const videoIO = new IntersectionObserver((entries) => {
594 entries.forEach(e => { 595 const v = e.target; 596 if (e.isIntersecting) { 597 applyVideoRate(v); 598 v.play().catch(() => {/* autoplay policy may block â ignore */}); 599 } else { 600 v.pause(); 601 } 602 }); 603 }, { threshold: 0.1 }); 604 document.querySelectorAll('.btile video, .hero-video, .pvideo, .wvideo, .fq-video, .img-video, .trailer-video, .hgb-video, .cinema-band-video').forEach(v => { 605 videoIO.observe(v); 606 }); 607} 608 609/* FAQ accordion is now native <details>/<summary> â no JS needed. */ 610 611/* ====== World map activation ====== 612 Adds .is-visible to the globe when it enters the viewport (triggers 613 the staggered draw-in for continents, arcs and pins). Then runs an 614 ambient donation-ping loop: every ~1.6s a green expanding ring 615 appears at a random pin location, like a real-time donation feed. */ 616(function activateGlobe(){ 617 const wrap = document.getElementById('globeWrap'); 618 if (!wrap) return; 619 620 let activated = false; 621 const io = new IntersectionObserver((entries) => { 622 entries.forEach(e => { 623 if (e.isIntersecting && !activated){ 624 activated = true; 625 wrap.classList.add('is-visible'); 626 // Wait for pins to land, then start the ambient pings 627 setTimeout(startPings, 2800); 628 } 629 }); 630 }, { threshold: 0.25 }); 631 io.observe(wrap); 632 633 function startPings(){ 634 if (window.matchMedia('(prefers-reduced-motion:reduce)').matches) return; 635 const pins = Array.from(wrap.querySelectorAll('.pin')); 636 if (!pins.length) return; 637 let lastIdx = -1; 638 639 function spawn(){ 640 // Don't spawn while the popup is open â the page is in focus mode 641 if (wrap.classList.contains('popup-open')) { 642 setTimeout(spawn, 1800); 643 return; 644 } 645 // Pick a pin distinct from the last one 646 let idx; 647 do { 648 idx = Math.floor(Math.random() * pins.length); 649 } while (idx === lastIdx && pins.length > 1); 650 lastIdx = idx; 651 652 const pin = pins[idx]; 653 const ping = document.createElement('div'); 654 ping.className = 'donation-ping'; 655 // Featured/urgent pins get a hotter ring to match their dot 656 if (pin.classList.contains('is-urgent')){ 657 ping.style.borderColor = 'rgba(226,106,91,.85)'; 658 ping.style.boxShadow = '0 0 14px rgba(226,106,91,.55)'; 659 } else if (pin.classList.contains('is-featured')){ 660 ping.style.borderColor = 'rgba(240,194,95,.85)'; 661 ping.style.boxShadow = '0 0 14px rgba(240,194,95,.55)'; 662 } 663 ping.style.left = pin.style.left; 664 ping.style.top = pin.style.top; 665 wrap.appendChild(ping); 666 setTimeout(() => ping.remove(), 1900); 667 668 // Stagger 1.2â2.4s between pings for natural rhythm 669 const next = 1200 + Math.random() * 1200; 670 setTimeout(spawn, next); 671 } 672 spawn(); 673 } 674})(); 675 676/* ====== Smooth anchor (header offset) ====== */ 677document.querySelectorAll('a[href^="#"]').forEach(a => { 678 a.addEventListener('click', (e) => { 679 const id = a.getAttribute('href'); 680 if (id.length > 1) { 681 const target = document.querySelector(id); 682 if (target) { 683 e.preventDefault(); 684 const top = target.getBoundingClientRect().top + window.scrollY - 70; 685 window.scrollTo({ top, behavior: 'smooth' }); 686 } 687 } 688 }); 689}); 690 691/* ===== Geolocate: find the nearest project ===== */ 692(function(){ 693 const strip = document.getElementById('nearMe'); 694 if (!strip) return; 695 const btn = document.getElementById('nearMeBtn'); 696 const btnLabel = strip.querySelector('.nm-btn-label'); 697 const userLatEl = strip.querySelector('.nm-user-lat'); 698 const userLonEl = strip.querySelector('.nm-user-lon'); 699 const nearestNameEl = strip.querySelector('.nm-nearest-name'); 700 const nearestDistEl = strip.querySelector('.nm-dist'); 701 const headlineEl = strip.querySelector('.nm-headline'); 702 const idleHeadline = headlineEl.textContent; 703 704 function setState(s){ strip.setAttribute('data-state', s); } 705 706 function fmtCoord(v, posLabel, negLabel){ 707 const dir = v >= 0 ? posLabel : negLabel; 708 return Math.abs(v).toFixed(2) + '°' + dir; 709 } 710 function haversine(lat1, lon1, lat2, lon2){ 711 const R = 6371; // km 712 const toRad = x => x * Math.PI / 180; 713 const dLat = toRad(lat2 - lat1); 714 const dLon = toRad(lon2 - lon1); 715 const a = Math.sin(dLat/2)**2 + 716 Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * 717 Math.sin(dLon/2)**2; 718 return 2 * R * Math.asin(Math.sqrt(a)); 719 } 720 721 function findNearest(userLat, userLon){ 722 const cards = Array.from(document.querySelectorAll('.proj-card[data-lat][data-lon]')); 723 let best = null; 724 cards.forEach(c => { 725 const lat = parseFloat(c.dataset.lat); 726 const lon = parseFloat(c.dataset.lon); 727 if (Number.isNaN(lat) || Number.isNaN(lon)) return; 728 const d = haversine(userLat, userLon, lat, lon); 729 if (!best || d < best.dist) best = { card: c, dist: d, name: c.dataset.name || 'Project' }; 730 }); 731 return best; 732 } 733 734 function applyResult(userLat, userLon){ 735 userLatEl.textContent = fmtCoord(userLat, 'N', 'S'); 736 userLonEl.textContent = fmtCoord(userLon, 'E', 'W'); 737 const nearest = findNearest(userLat, userLon); 738 if (nearest){
739 nearestNameEl.textContent = nearest.name; 740 const km = nearest.dist; 741 const niceDist = km < 1 742 ? '< 1 km away' 743 : km < 10 744 ? km.toFixed(1) + ' km away' 745 : Math.round(km).toLocaleString() + ' km away'; 746 nearestDistEl.textContent = niceDist; 747 // mark the card 748 document.querySelectorAll('.proj-card.is-nearest').forEach(el => el.classList.remove('is-nearest')); 749 nearest.card.classList.add('is-nearest'); 750 headlineEl.textContent = 'Your nearest project'; 751 } 752 setState('granted'); 753 } 754 755 function handleError(err){ 756 setState('error'); 757 btnLabel.textContent = 'Try again'; 758 headlineEl.textContent = idleHeadline; 759 const status = strip.querySelector('.nearme-status'); 760 if (status){ 761 if (err && err.code === 1) status.textContent = 'Location blocked. You can re-enable it in your browser settings.'; 762 else if (err && err.code === 3) status.textContent = 'Took too long â try again?'; 763 else status.textContent = 'Couldnât pin your location. Try again?'; 764 } 765 } 766 767 btn.addEventListener('click', () => { 768 if (!('geolocation' in navigator)){ 769 handleError({ code: 0 }); 770 return; 771 } 772 setState('loading'); 773 btnLabel.textContent = 'Locatingâ¦'; 774 navigator.geolocation.getCurrentPosition( 775 pos => applyResult(pos.coords.latitude, pos.coords.longitude), 776 err => handleError(err), 777 { enableHighAccuracy:false, timeout:10000, maximumAge:300000 } 778 ); 779 }); 780})(); 781 782/* ================= CLIENT DEMO MODULES ================= */ 783 784/* === 1. Live giving feed (in-page section) === */ 785(function(){ 786 const list = document.getElementById('lfList'); 787 const countEl = document.getElementById('lfCount'); 788 if (!list) return; 789 790 // Pool of names + cities + projects. Each tick picks one combo + a plausible amount. 791 const names = ['Sarah','Marcus','Aisha','Tom','Lena','Ravi','Camille','James','Mei','Ben','Olivia','Jonas','Priya','Lucas','Nia','Felix','Yuki','Hannah','Diego','Amara','Ines','Oscar','Sofia','Theo']; 792 const cities = ['Berlin','Brooklyn','London','Melbourne','Stockholm','Bangalore','Paris','San Francisco','Singapore','Dublin','Lisbon','Oslo','Cape Town','Toronto','Montréal','Madrid','Edinburgh','Auckland','Buenos Aires','ReykjavÃk','Nairobi','Bristol']; 793 const projects = ['Wolf Connection','The Last Stand','Elephant Nature Park','Going Wild','Free Billy','Wildlife on the Edge']; 794 const amounts = [10,15,20,25,35,40,50,50,75,100,100,150,200,250,500,1000]; 795 796 const MAX_ROWS = 6; 797 const TICK_MS = 4200; // new donation lands roughly every 4s 798 let counter = 47; // matches initial "47 donations this hour" 799 let secondsSinceFirst = 0; // for "when" labels 800 801 function fmtWhen(seconds){ 802 if (seconds < 10) return 'just now'; 803 if (seconds < 60) return seconds + 's ago'; 804 const m = Math.floor(seconds / 60); 805 if (m < 60) return m + 'm ago'; 806 const h = Math.floor(m / 60); 807 return h + 'h ago'; 808 } 809 810 function pickActivity(){ 811 const n = names[Math.floor(Math.random() * names.length)]; 812 const c = cities[Math.floor(Math.random() * cities.length)]; 813 const p = projects[Math.floor(Math.random() * projects.length)]; 814 const a = amounts[Math.floor(Math.random() * amounts.length)]; 815 return { who: n + ' · ' + c, amt: a, project: p }; 816 } 817 818 function buildRow(a, whenLabel){ 819 const li = document.createElement('li'); 820 li.className = 'lf-row is-new'; 821 li.innerHTML = ` 822 <span class="lf-mini-dot" aria-hidden="true"></span> 823 <div class="lf-body"> 824 <span class="lf-line"><b>${a.who}</b> just gave <span class="lf-amt">$${a.amt.toLocaleString()}</span> to <b>${a.project}</b></span> 825 <span class="lf-meta"><span class="lf-when">${whenLabel}</span></span> 826 </div> 827 `; 828 return li; 829 } 830 831 // Re-label all rows' "when" relative to their age 832 function rerollTimes(){ 833 const rows = list.querySelectorAll('.lf-row'); 834 rows.forEach((row, idx) => { 835 const whenEl = row.querySelector('.lf-when'); 836 if (!whenEl) return; 837 const age = parseInt(row.dataset.age || '0', 10); 838 whenEl.textContent = fmtWhen(age); 839 }); 840 } 841 842 // Age every visible row by `delta` seconds 843 function ageRows(delta){ 844 list.querySelectorAll('.lf-row').forEach(row => { 845 const age = parseInt(row.dataset.age || '0', 10) + delta; 846 row.dataset.age = age; 847 }); 848 } 849 850 function addRow(){ 851 const a = pickActivity(); 852 const li = buildRow(a, 'just now'); 853 li.dataset.age = '0'; 854 list.insertBefore(li, list.firstChild); 855 // remove .is-new after animation so subsequent rerolls don't re-trigger 856 setTimeout(() => li.classList.remove('is-new'), 700); 857 858 // trim list â only remove rows that haven't already been marked for removal 859 const overflow = list.querySelectorAll('.lf-row:not(.is-leaving)'); 860 for (let k = MAX_ROWS; k < overflow.length; k++){ 861 const old = overflow[k]; 862 old.classList.add('is-leaving'); 863 setTimeout(() => old.remove(), 480); 864 } 865 866 counter++; 867 countEl.textContent = counter; 868 } 869 870 // Seed with 5 historical rows 871 function seed(){ 872 const seedAges = [12, 38, 75, 142, 240]; // seconds ago
873 seedAges.forEach((age, idx) => { 874 const a = pickActivity(); 875 const li = buildRow(a, fmtWhen(age)); 876 li.classList.remove('is-new'); 877 li.dataset.age = age; 878 list.appendChild(li); 879 }); 880 } 881 882 seed(); 883 884 // Tick: age rows + maybe add new 885 setInterval(() => { 886 ageRows(1); 887 rerollTimes(); 888 }, 1000); 889 890 setInterval(addRow, TICK_MS); 891})(); 892 893/* === 2. Impact calculator === */ 894(function(){ 895 const slider = document.getElementById('calcSlider'); 896 if (!slider) return; 897 const valEl = document.getElementById('calcVal'); 898 const headlineEl = document.getElementById('calcHeadline'); 899 const subEl = document.getElementById('calcSub'); 900 const iconEl = document.getElementById('calcImpactIcon'); 901 const impactWrap = document.getElementById('calcImpact'); 902 const ctaAmtEl = document.getElementById('calcCtaAmt'); 903 const ctaEl = document.getElementById('calcCta'); 904 const presets = document.querySelectorAll('#calcPresets .calc-preset'); 905 906 // Tiers: each defines a threshold (amount) and the impact copy + icon 907 const tiers = [ 908 { up:9, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M4 8c4 0 4 6 8 6s4-6 8-6"/></svg>', 909 h:'A ranger meal in Kenya', 910 s:'A hot meal at the end of a 12-hour patrol. Small but it keeps people in the field.' }, 911 { up:24, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="4"/><path d="M3 21c0-5 4-9 9-9s9 4 9 9"/></svg>', 912 h:'Two days of school workshops', 913 s:'Curriculum and teacher packs for a class learning what conservation actually does.' }, 914 { up:74, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l8-8 8 8M6 10v10h12V10"/><path d="M9 20v-6h6v6"/></svg>', 915 h:'A day of drone surveillance over Sumatra', 916 s:'Forest Watch Indonesia maps illegal logging from the air daily â this funds one flight.' }, 917 { up:199, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18M5 21V8l7-5 7 5v13M9 21v-6h6v6"/></svg>', 918 h:'A week of sanctuary care for one elephant', 919 s:'Food, vet checks, mahout salary. Save Elephant Foundation runs the largest sanctuary in Thailand.' }, 920 { up:499, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 21c0-3 2.5-6 6-6s6 3 6 6"/><path d="M14 21c0-2 2-4 4-4s4 2 4 4"/></svg>', 921 h:'A herder fence in Nepal', 922 s:'Solar-powered livestock fences stop snow leopards being killed in retaliation by herders.' }, 923 { up:999, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2c-3 5-3 9 0 12s3 6 0 8"/><path d="M5 11c2 1 3 3 3 5M19 11c-2 1-3 3-3 5"/></svg>', 924 h:'A month of ranger pay, kit and fuel', 925 s:'Boots, salary, vehicle, radio. A full ranger paid in the field for 30 days.' }, 926 { up:Infinity, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L3 7v6c0 5 4 9 9 9s9-4 9-9V7l-9-5z"/><path d="M9 12l2 2 4-4"/></svg>', 927 h:'An acre of old-growth forest protected', 928 s:'Legal challenge, ranger patrol, drone mapping. The full stack to defend a chunk of forest.' }, 929 ]; 930 931 function pick(v){ 932 for (const t of tiers){ if (v <= t.up) return t; } 933 return tiers[tiers.length-1]; 934 } 935 function update(v, fromPreset){ 936 const n = parseInt(v,10); 937 valEl.textContent = n.toLocaleString(); 938 ctaAmtEl.textContent = '$' + n.toLocaleString(); 939 const t = pick(n); 940 headlineEl.textContent = t.h; 941 subEl.textContent = t.s; 942 iconEl.innerHTML = t.icon; 943 // slider fill % 944 const pct = ((n - parseInt(slider.min,10)) / (parseInt(slider.max,10) - parseInt(slider.min,10))) * 100; 945 slider.style.setProperty('--p', pct.toFixed(1) + '%'); 946 // bump animation 947 impactWrap.classList.remove('bump'); void impactWrap.offsetWidth; impactWrap.classList.add('bump'); 948 // preset active state
949 presets.forEach(b => b.classList.toggle('active', parseInt(b.dataset.v,10) === n)); 950 } 951 slider.addEventListener('input', e => update(e.target.value)); 952 presets.forEach(b => b.addEventListener('click', () => { slider.value = b.dataset.v; update(b.dataset.v, true); })); 953 update(slider.value); 954})(); 955 956/* === 3. Species-lost counter === */ 957(function(){ 958 const el = document.getElementById('speciesNum'); 959 if (!el) return; 960 // Wilson estimate: ~27,000 species lost per year. As of mid-May (~day 133), 961 // we'd be at ~27,000 * (133/365) â 9,830. Bump to a punchier baseline + live tick. 962 // Tick: 27,000/year = 1 every ~19 min â 0.0009 per sec. 963 // We tick the counter visibly by ~1 every 18-20 seconds for visual life. 964 const start = Date.now(); 965 const baseline = 14237; 966 let visibleCount = baseline; 967 function tick(){ 968 visibleCount += 1; 969 el.textContent = visibleCount.toLocaleString(); 970 } 971 // Trigger when section becomes visible 972 const io = new IntersectionObserver(entries => { 973 entries.forEach(e => { 974 if (e.isIntersecting){ 975 // count-up reveal: animate baseline up over 1.4s 976 const target = baseline; 977 const dur = 1400; const t0 = performance.now(); 978 function step(now){ 979 const k = Math.min(1, (now - t0) / dur); 980 const eased = 1 - Math.pow(1 - k, 3); 981 el.textContent = Math.round(target * eased).toLocaleString(); 982 if (k < 1) requestAnimationFrame(step); 983 else { 984 // live tick every ~19s 985 setInterval(tick, 19000); 986 } 987 } 988 requestAnimationFrame(step); 989 io.unobserve(e.target); 990 } 991 }); 992 }, { threshold: .3 }); 993 io.observe(el); 994})(); 995 996/* === 5. Press strip reveal (no extra JS needed; reveal handled by IO) === */ 997
998/* === 6. Hero sound toggle === */ 999(function(){ 1000 const btn = document.getElementById('heroSoundBtn'); 1001 if (!btn) return; 1002 const vid = document.querySelector('.hero-carousel .hcv'); 1003 if (!vid) return; 1004 btn.addEventListener('click', () => { 1005 const on = btn.getAttribute('data-on') === 'true'; 1006 if (on){ 1007 vid.muted = true; 1008 btn.setAttribute('data-on','false'); 1009 btn.setAttribute('aria-label','Turn sound on'); 1010 } else { 1011 vid.muted = false; 1012 // some browsers require volume + a fresh play call after unmute 1013 try { vid.volume = 0.6; vid.play(); } catch(e){} 1014 btn.setAttribute('data-on','true'); 1015 btn.setAttribute('aria-label','Turn sound off'); 1016 } 1017 }); 1018})(); 1019 1020/* === 7. Donate-button leaf burst === */ 1021(function(){ 1022 document.querySelectorAll('.btn-primary').forEach(btn => { 1023 if (btn.querySelector('.leafburst')) return; 1024 const wrap = document.createElement('span'); 1025 wrap.className = 'leafburst'; 1026 wrap.setAttribute('aria-hidden','true'); 1027 const N = 8; 1028 for (let k = 0; k < N; k++){ 1029 const leaf = document.createElement('span'); 1030 leaf.className = 'leaf-p'; 1031 const angle = (Math.PI * 2 * k) / N + (Math.random() * 0.6); 1032 const dist = 50 + Math.random() * 38; 1033 const dx = Math.cos(angle) * dist; 1034 const dy = Math.sin(angle) * dist - 30; 1035 leaf.style.setProperty('--ldx', dx.toFixed(1) + 'px'); 1036 leaf.style.setProperty('--ldy', dy.toFixed(1) + 'px'); 1037 leaf.style.setProperty('--lr', (Math.random()*120 - 60).toFixed(0) + 'deg'); 1038 leaf.style.animationDelay = (k * 0.04).toFixed(2) + 's'; 1039 wrap.appendChild(leaf); 1040 } 1041 btn.appendChild(wrap); 1042 }); 1043})(); 1044 1045/* === 8. Picks for tonight â purely declarative, no JS needed === */ 1046 1047/* === 9. Cursor compass on dark sections === */ 1048(function(){ 1049 if (matchMedia('(pointer:coarse)').matches) return; // skip touch devices 1050 const cc = document.createElement('div'); 1051 cc.className = 'cursor-compass'; 1052 cc.innerHTML = '<span class="cc-n">N</span><span class="cc-needle"></span>'; 1053 document.body.appendChild(cc); 1054 const needle = cc.querySelector('.cc-needle'); 1055 1056 let mx = 0, my = 0, rx = 0, ry = 0; 1057 let active = false; 1058 let lastMove = 0; 1059 document.addEventListener('mousemove', e => { 1060 mx = e.clientX; my = e.clientY; 1061 lastMove = Date.now(); 1062 // Determine if we're over a dark section. Use elementFromPoint and walk up 1063 const node = document.elementFromPoint(mx, my); 1064 if (!node) return; 1065 const dark = node.closest('.broll-section, .proof, .convert, .hp-promise, .anchor, .watch-change, .founder-quote, .hscroll-stage, footer'); 1066 const hover = node.closest('a, button, [role="button"], input, textarea, select'); 1067 active = !!dark && !hover; 1068 cc.classList.toggle('show', active); 1069 }); 1070 function raf(){ 1071 rx += (mx - rx) * 0.22; 1072 ry += (my - ry) * 0.22; 1073 cc.style.transform = `translate(${rx}px, ${ry}px) translate(-50%,-50%)`; 1074 // rotate needle based on cursor velocity direction (subtle) 1075 const angle = (Math.sin(Date.now() / 1800) * 18); // slow drift like real compass 1076 needle.style.transform = `rotate(${angle.toFixed(2)}deg)`; 1077 requestAnimationFrame(raf); 1078 } 1079 requestAnimationFrame(raf); 1080})(); 1081 1082/* === 10. Live viewers count === REMOVED. 1083 The simulated "people on this page right now" counter was taken down 1084 sitewide (owner decision, 2026-06) â its markup is gone and this driver 1085 is intentionally disabled. */ 1086 1087/* ================= UX ENHANCEMENT MODULES ================= */ 1088 1089/* === Back-to-top + sticky donate bar + chapter nav (scroll-driven) === */ 1090(function(){ 1091 const bttop = document.getElementById('backToTop'); 1092 const sdb = document.getElementById('stickyDonate'); 1093 const chapNav = document.getElementById('chapNav'); 1094 const chapDots = chapNav ? Array.from(chapNav.querySelectorAll('.chap-dot')) : []; 1095 1096 function onScroll(){ 1097 const y = window.scrollY; 1098 const vh = window.innerHeight; 1099 const docH = document.documentElement.scrollHeight; 1100 1101 // Back-to-top 1102 if (bttop) bttop.classList.toggle('show', y > vh * 0.8); 1103 1104 // Sticky donate bar â show after hero 1105 if (sdb){ 1106 const past = y > vh * 0.65; 1107 const nearFoot = (y + vh) > (docH - 200); 1108 sdb.classList.toggle('show', past && !nearFoot); 1109 sdb.classList.toggle('atfoot', nearFoot); 1110 } 1111 1112 // Chapter nav â show after intro 1113 if (chapNav) chapNav.classList.toggle('show', y > vh * 0.4); 1114 1115 // Highlight current chapter
1116 chapDots.forEach(dot => { 1117 const target = document.querySelector(dot.dataset.target); 1118 if (!target) return; 1119 const r = target.getBoundingClientRect(); 1120 const isInView = r.top < vh * 0.4 && r.bottom > vh * 0.2; 1121 dot.classList.toggle('is-active', isInView); 1122 }); 1123 } 1124 1125 if (bttop) bttop.addEventListener('click', () => window.scrollTo({ top:0, behavior:'smooth' })); 1126 1127 if (chapNav){ 1128 chapDots.forEach(dot => { 1129 dot.addEventListener('click', () => { 1130 const target = document.querySelector(dot.dataset.target); 1131 if (!target) return; 1132 const top = target.getBoundingClientRect().top + window.scrollY - 60; 1133 window.scrollTo({ top, behavior:'smooth' }); 1134 }); 1135 }); 1136 } 1137 1138 window.addEventListener('scroll', onScroll, { passive:true }); 1139 onScroll(); 1140})(); 1141 1142/* === Sticky donate-bar buttons === */ 1143(function(){ 1144 const calcBtn = document.getElementById('sdbCalcBtn'); 1145 const donateBtn = document.getElementById('sdbDonateBtn'); 1146 if (calcBtn){ 1147 calcBtn.addEventListener('click', () => { 1148 const calc = document.getElementById('impactCalc'); 1149 if (!calc) return; 1150 const top = calc.getBoundingClientRect().top + window.scrollY - 80; 1151 window.scrollTo({ top, behavior:'smooth' }); 1152 }); 1153 } 1154 if (donateBtn){ 1155 donateBtn.addEventListener('click', () => { 1156 // Open donation preview modal pre-filled to $50 1157 window.dispatchEvent(new CustomEvent('open-donate-modal', { detail:{ amount:50 } })); 1158 }); 1159 } 1160})(); 1161 1162/* === Cookie / privacy bar === */ 1163(function(){ 1164 const bar = document.getElementById('cookBar'); 1165 if (!bar) return; 1166 const KEY = 'ecoflix.cookies'; 1167 if (localStorage.getItem(KEY)) return; // already chosen 1168 setTimeout(() => bar.classList.add('show'), 1800); 1169 function close(choice){ 1170 bar.classList.remove('show'); 1171 localStorage.setItem(KEY, choice); 1172 setTimeout(() => bar.remove(), 500); 1173 } 1174 document.getElementById('cbAccept').addEventListener('click', () => close('accepted')); 1175 document.getElementById('cbDecline').addEventListener('click', () => close('declined')); 1176})(); 1177 1178/* === Image shimmer === */ 1179(function(){ 1180 document.querySelectorAll('.proj-card .img').forEach(img => { 1181 // Try to detect when bg image is loaded 1182 const url = img.style.backgroundImage.match(/url\(['"]?([^'"]+)['"]?\)/); 1183 if (!url) { img.classList.add('img-loaded'); return; } 1184 const tmp = new Image(); 1185 tmp.onload = () => img.classList.add('img-loaded'); 1186 tmp.onerror = () => img.classList.add('img-loaded'); 1187 tmp.src = url[1]; 1188 }); 1189})(); 1190 1191/* === Save projects (heart) + filter + view toggle + recall ribbon === */ 1192(function(){ 1193 const KEY = 'ecoflix.savedProjects'; 1194 function load(){ 1195 try { return JSON.parse(localStorage.getItem(KEY) || '[]'); } 1196 catch(e){ return []; } 1197 } 1198 function save(list){ localStorage.setItem(KEY, JSON.stringify(list)); } 1199 1200 let saved = load(); 1201 const cards = Array.from(document.querySelectorAll('.proj-card[data-id]')); 1202 if (!cards.length) return; 1203 1204 // Inject heart button into each card (top-right; respects existing coord-stamp) 1205 cards.forEach(card => { 1206 const id = card.dataset.id; 1207 const btn = document.createElement('button'); 1208 btn.className = 'save-btn'; 1209 btn.type = 'button'; 1210 btn.setAttribute('aria-label', 'Save project'); 1211 btn.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-7-4.5-9.5-9C1 9 3 5 7 5c2 0 4 1 5 3 1-2 3-3 5-3 4 0 6 4 4.5 7-2.5 4.5-9.5 9-9.5 9z"/></svg>'; 1212 if (saved.includes(id)) btn.classList.add('is-saved'); 1213 btn.addEventListener('click', (e) => { 1214 e.preventDefault(); e.stopPropagation(); 1215 saved = load(); 1216 if (saved.includes(id)){ 1217 saved = saved.filter(s => s !== id); 1218 btn.classList.remove('is-saved'); 1219 btn.setAttribute('aria-label', 'Save project'); 1220 } else { 1221 saved.push(id); 1222 btn.classList.add('is-saved'); 1223 btn.setAttribute('aria-label', 'Unsave project'); 1224 } 1225 save(saved); 1226 updateSavedCount(); 1227 refilter(); 1228 }); 1229 // Coord-stamp lives at bottom-right; heart at top-right. No collision. 1230 card.appendChild(btn); 1231 }); 1232 1233 // Saved count chip + recall ribbon 1234 const savedCountEl = document.getElementById('savedCount'); 1235 const ribbon = document.getElementById('savedRibbon'); 1236 function updateSavedCount(){ 1237 if (savedCountEl) savedCountEl.textContent = '(' + saved.length + ')'; 1238 if (ribbon){ 1239 if (saved.length > 0){ 1240 ribbon.hidden = false;
1241 ribbon.querySelector('.sr-count').textContent = saved.length; 1242 requestAnimationFrame(() => ribbon.classList.add('show')); 1243 } else { 1244 ribbon.classList.remove('show'); 1245 setTimeout(() => { ribbon.hidden = true; }, 400); 1246 } 1247 } 1248 } 1249 updateSavedCount(); 1250 1251 document.getElementById('srClear')?.addEventListener('click', () => { 1252 saved = []; 1253 save(saved); 1254 document.querySelectorAll('.save-btn.is-saved').forEach(b => b.classList.remove('is-saved')); 1255 updateSavedCount(); 1256 refilter(); 1257 }); 1258 1259 // Filter chips 1260 const chips = document.querySelectorAll('.proj-chip'); 1261 let activeFilter = 'all'; 1262 let ecoSet = null; // project ids for the active ?ecosystem= deep-link, else null 1263 function refilter(){ 1264 cards.forEach(card => { 1265 let show = true; 1266 if (activeFilter === 'urgent'){ 1267 show = card.classList.contains('is-urgent'); 1268 } else if (activeFilter === 'saved'){ 1269 show = saved.includes(card.dataset.id); 1270 } else if (activeFilter === 'featured'){ 1271 show = card.dataset.featured === 'true'; 1272 } else if (activeFilter === 'completed'){ 1273 show = card.dataset.status === 'completed'; 1274 } else if (activeFilter !== 'all'){ 1275 show = card.dataset.region === activeFilter; 1276 } 1277 if (ecoSet) show = show && ecoSet.has(card.dataset.id); 1278 card.classList.toggle('is-filtered-out', !show); 1279 }); 1280 } 1281 function clearEco(){ ecoSet = null; document.getElementById('ecoChip')?.remove(); } 1282 chips.forEach(chip => { 1283 chip.addEventListener('click', () => { 1284 clearEco(); // a built-in chip supersedes the ecosystem deep-link 1285 chips.forEach(c => c.classList.remove('active')); 1286 chip.classList.add('active');
1287 activeFilter = chip.dataset.filter; 1288 refilter(); 1289 }); 1290 }); 1291 1292 // Ecosystem deep-link (?ecosystem=oceans). The homepage "Find the wild you 1293 // care about" tiles land here; map the ecosystem to its projects via the 1294 // curated /assets/ecosystems.json and filter the carousel to match, with a 1295 // dismissible chip so it's clear what's showing and how to clear it. Async, 1296 // so nudge panels.js to re-collect the visible cards once it applies. 1297 const ecoParam = new URLSearchParams(location.search).get('ecosystem'); 1298 if (ecoParam){ 1299 fetch('/assets/ecosystems.json').then(r => r.json()).then(map => { 1300 const ids = map && map[ecoParam]; 1301 if (!Array.isArray(ids) || !ids.length) return; // unknown/empty â leave everything showing 1302 ecoSet = new Set(ids); 1303 const label = ecoParam.charAt(0).toUpperCase() + ecoParam.slice(1); 1304 const chip = document.createElement('button'); 1305 chip.id = 'ecoChip'; 1306 chip.type = 'button'; 1307 chip.className = 'proj-chip active'; 1308 chip.innerHTML = label + ' <span class="pc-count">(' + ids.length + ')</span> <span aria-hidden="true" style="margin-left:3px">×</span>'; 1309 chip.setAttribute('aria-label', label + ' ecosystem â clear filter'); 1310 chip.addEventListener('click', () => { 1311 clearEco(); 1312 const all = document.querySelector('.proj-chip[data-filter="all"]'); 1313 chips.forEach(c => c.classList.remove('active')); 1314 if (all) all.classList.add('active'); 1315 activeFilter = 'all'; 1316 refilter(); 1317 document.getElementById('hscrollTrack')?.dispatchEvent(new Event('pcs:refilter')); 1318 }); 1319 chips.forEach(c => c.classList.remove('active')); // no built-in chip active while an ecosystem is 1320 const row = (chips[0] && chips[0].parentElement) || document.body; 1321 row.insertBefore(chip, chips[0] || null); 1322 refilter(); 1323 document.getElementById('hscrollTrack')?.dispatchEvent(new Event('pcs:refilter')); 1324 }).catch(() => {}); 1325 } 1326 1327 // View toggle 1328 const track = document.getElementById('hscrollTrack'); 1329 document.querySelectorAll('.view-toggle button').forEach(b => { 1330 b.addEventListener('click', () => { 1331 document.querySelectorAll('.view-toggle button').forEach(x => x.classList.remove('active')); 1332 b.classList.add('active'); 1333 if (track){ 1334 track.classList.toggle('view-list', b.dataset.view === 'list'); 1335 } 1336 }); 1337 }); 1338})(); 1339 1340/* === Press detail expander === */ 1341(function(){ 1342 const grid = document.getElementById('pressGrid'); 1343 const detail = document.getElementById('pressDetail'); 1344 if (!grid || !detail) return; 1345 let activeEl = null; 1346 grid.querySelectorAll('span').forEach(span => { 1347 span.setAttribute('role','button'); 1348 span.setAttribute('tabindex','0'); 1349 function open(){ 1350 if (activeEl === span){ 1351 detail.classList.remove('show'); 1352 setTimeout(() => { detail.hidden = true; }, 250); 1353 activeEl = null; return; 1354 } 1355 activeEl = span; 1356 detail.innerHTML = ` 1357 <div class="pd-source">${span.dataset.src || 'Press'}</div> 1358 <blockquote>“${span.dataset.q}”</blockquote> 1359 <div class="pd-meta">${span.dataset.meta || ''}</div> 1360 `; 1361 detail.hidden = false; 1362 requestAnimationFrame(() => detail.classList.add('show')); 1363 } 1364 span.addEventListener('click', open); 1365 span.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); open(); } }); 1366 }); 1367})(); 1368 1369/* === Compare bars â animate on reveal === */ 1370(function(){ 1371 const compare = document.querySelector('.compare'); 1372 if (!compare) return; 1373 const io = new IntersectionObserver(entries => { 1374 entries.forEach(e => { 1375 if (e.isIntersecting){ 1376 compare.querySelectorAll('.cc-fill').forEach(f => { 1377 const target = f.parentElement.parentElement.classList.contains('is-us') ? '100%' : '61%'; 1378 // force the transition to play by going from 0 to target 1379 f.style.width = '0'; 1380 requestAnimationFrame(() => { setTimeout(() => f.style.width = target, 120); }); 1381 }); 1382 io.unobserve(compare); 1383 } 1384 }); 1385 }, { threshold: .3 }); 1386 io.observe(compare); 1387})(); 1388 1389/* === Newsletter signup === */ 1390(function(){ 1391 const form = document.getElementById('nlForm'); 1392 if (!form) return; 1393 const wrap = document.getElementById('newsletter'); 1394 const input = document.getElementById('nlEmail'); 1395 form.addEventListener('submit', (e) => { 1396 e.preventDefault(); 1397 wrap.classList.remove('is-error'); 1398 const v = (input.value || '').trim(); 1399 const ok = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v); 1400 if (!ok){ wrap.classList.add('is-error'); input.focus(); return; } 1401 wrap.classList.add('is-success'); 1402 try { localStorage.setItem('ecoflix.newsletter', v); } catch(e){} 1403 // Send to the CMS subscriber list (+ welcome sequence). Fire-and-forget. 1404 fetch('/api/email/subscribe', { 1405 method:'POST', headers:{'Content-Type':'application/json'}, 1406 body: JSON.stringify({ email: v, source: 'newsletter' }) 1407 }).catch(function(){}); 1408 }); 1409})(); 1410 1411/* === Donation preview modal === */ 1412(function(){ 1413 const modal = document.getElementById('donateModal'); 1414 if (!modal) return; 1415 const close = document.getElementById('dpmClose'); 1416 const amtEl = document.getElementById('dpmAmt'); 1417 const headlineEl = document.getElementById('dpmHeadline'); 1418 const subEl = document.getElementById('dpmSub'); 1419 const iconEl = document.getElementById('dpmIcon'); 1420 const cta = document.getElementById('dpmCta'); 1421 const giftCk = document.getElementById('dpmGiftCk'); 1422 const giftFields = document.getElementById('dpmGiftFields'); 1423 1424 const tiers = [ 1425 { up:9, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M4 8c4 0 4 6 8 6s4-6 8-6"/></svg>', h:'A ranger meal in Kenya', s:'A hot meal at the end of a 12-hour patrol.' }, 1426 { up:24, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="4"/><path d="M3 21c0-5 4-9 9-9s9 4 9 9"/></svg>', h:'Two days of school workshops', s:'Curriculum and teacher packs for a class.' }, 1427 { up:74, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l8-8 8 8M6 10v10h12V10"/><path d="M9 21v-6h6v6"/></svg>', h:'A day of drone surveillance over Sumatra', s:'Forest Watch Indonesia maps illegal logging from the air daily â this funds one flight.' }, 1428 { up:199, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18M5 21V8l7-5 7 5v13"/></svg>
1428', h:'A week of sanctuary care for one elephant', s:'Food, vet checks, mahout salary. Save Elephant Foundation in Thailand.' }, 1429 { up:499, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 21c0-3 2.5-6 6-6s6 3 6 6"/></svg>', h:'A herder fence in Nepal', s:'Stops snow leopards being killed in retaliation by herders.' }, 1430 { up:999, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2c-3 5-3 9 0 12s3 6 0 8"/></svg>', h:'A month of ranger pay, kit and fuel', s:'A full ranger paid in the field for 30 days.' }, 1431 { up:Infinity, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L3 7v6c0 5 4 9 9 9s9-4 9-9V7l-9-5z"/><path d="M9 12l2 2 4-4"/></svg>', h:'An acre of old-growth forest protected', s:'Legal challenge, ranger patrol, drone mapping.' }, 1432 ]; 1433 1434 function setAmount(n){ 1435 amtEl.textContent = n.toLocaleString(); 1436 const t = tiers.find(x => n <= x.up) || tiers[tiers.length-1]; 1437 headlineEl.textContent = t.h; 1438 subEl.textContent = t.s; 1439 iconEl.innerHTML = t.icon; 1440 } 1441 1442 function open(amount){ 1443 setAmount(amount || 50); 1444 modal.classList.add('show'); 1445 modal.setAttribute('aria-hidden','false'); 1446 document.body.style.overflow = 'hidden'; 1447 } 1448 function closeModal(){ 1449 modal.classList.remove('show'); 1450 modal.setAttribute('aria-hidden','true'); 1451 document.body.style.overflow = ''; 1452 } 1453 1454 close.addEventListener('click', closeModal); 1455 modal.addEventListener('click', e => { if (e.target === modal) closeModal(); }); 1456 document.addEventListener('keydown', e => { if (e.key === 'Escape' && modal.classList.contains('show')) closeModal(); }); 1457 giftCk.addEventListener('change', () => giftFields.classList.toggle('show', giftCk.checked)); 1458 1459 // Legacy: the open-donate-modal event used to pre-flight donors in a 1460 // preview modal before sending them off-site to Givebutter. We now 1461 // have a full Stripe checkout at /donate/, so this handler instead 1462 // routes straight there, passing the amount through as ?amount=. The 1463 // donate page's picker reads that and seeds the selection. 1464 window.addEventListener('open-donate-modal', e => { 1465 const amount = (e.detail && e.detail.amount) || 50; 1466 window.location.href = '/donate/?amount=' + encodeURIComponent(amount) + '#pick'; 1467 }); 1468 1469 // The "intercept .btn-primary â open modal" pattern has been removed. 1470 // Every Donate button now navigates directly to /donate/ where the 1471 // Stripe Payment Element + Apple Pay / Google Pay / Link Quick Donate 1472 // lane live. No more modal pre-flight. 1473})(); 1474 1475/* ============================================================ 1476 POLISH PASS 1477 Site-wide micro-experiences. Each block is a self-contained 1478 IIFE that's safe to remove independently. Reduced-motion 1479 preferences are honoured throughout. 1480 ============================================================ */ 1481(function siteWidePolish(){ 1482 const ready = cb => (document.readyState === 'loading') 1483 ? document.addEventListener('DOMContentLoaded', cb, { once:true }) 1484 : cb(); 1485 const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; 1486 const finePointer = matchMedia('(hover:hover) and (pointer:fine)').matches; 1487 1488 /* ââ Scroll progress bar (top of viewport) âââââââââââââââ */ 1489 (function scrollProgress(){ 1490 const bar = document.createElement('div'); 1491 bar.id = 'scrollProgress'; 1492 document.documentElement.appendChild(bar); 1493 let raf = 0; 1494 function update(){ 1495 const doc = document.documentElement; 1496 const max = (doc.scrollHeight - window.innerHeight) || 1; 1497 const p = Math.min(1, Math.max(0, window.scrollY / max)); 1498 bar.style.width = (p * 100).toFixed(2) + '%'; 1499 } 1500 window.addEventListener('scroll', () => { 1501 if (raf) return; 1502 raf = requestAnimationFrame(() => { raf = 0; update(); }); 1503 }, { passive:true }); 1504 window.addEventListener('resize', update); 1505 update(); 1506 })(); 1507 1508 /* ââ Image blur-up loader ââââââââââââââââââââââââââââââââ */ 1509 ready(() => { 1510 document.querySelectorAll('img[loading="lazy"]').forEach(img => {
1511 if (img.complete && img.naturalWidth) { 1512 img.classList.add('img-ready'); 1513 return; 1514 } 1515 img.addEventListener('load', () => img.classList.add('img-ready'), { once:true }); 1516 img.addEventListener('error', () => img.classList.add('img-ready'), { once:true }); 1517 }); 1518 // Catch images added later (NGO portal preview, dynamic carousels). 1519 new MutationObserver(records => { 1520 records.forEach(r => r.addedNodes.forEach(n => { 1521 if (!(n instanceof Element)) return; 1522 n.querySelectorAll?.('img[loading="lazy"]').forEach(img => { 1523 if (img.complete && img.naturalWidth) img.classList.add('img-ready'); 1524 else img.addEventListener('load', () => img.classList.add('img-ready'), { once:true }); 1525 }); 1526 })); 1527 }).observe(document.body, { childList:true, subtree:true }); 1528 }); 1529 1530 /* ââ Number counter animation (in-view) ââââââââââââââââââ */ 1531 ready(() => { 1532 if (reduceMotion) return; 1533 // Selectors that contain headline numbers across the site. 1534 // Each match should have its terminal numeric text â we extract it, 1535 // animate from 0 to that value, and preserve any suffix/prefix. 1536 const sel = [ 1537 '.hnum', // homepage hero stats (.h-num bold text) 1538 '.calc-amount #calcVal', // donation slider readout 1539 '.sc-number', // species counter 1540 '.footer-stats b', // footer mini-stats 1541 '.stat-pill b', // various stat strips 1542 '.fs-num', // misc num pills 1543 '.np-stat b', // ngo portal dashboard stats 1544 ].join(','); 1545 const seen = new WeakSet(); 1546 function parseNumber(text){ 1547 // Pull the first number (with K/M suffix + commas + decimals). 1548 const m = text.match(/(\d[\d,]*\.?\d*)\s*([KkMm]?)([+%]?)/); 1549 if (!m) return null; 1550 const raw = parseFloat(m[1].replace(/,/g, '')); 1551 const suffix = m[2] ? m[2].toUpperCase() : ''; 1552 const sign = m[3] || ''; 1553 const mult = suffix === 'K' ? 1e3 : suffix === 'M' ? 1e6 : 1; 1554 return { value: raw * mult, suffix, sign, raw, originalSuffix:m[2] }; 1555 } 1556 function format(n, parsed){ 1557 let v = n; 1558 if (parsed.suffix === 'M') return (v/1e6).toFixed(1).replace(/\.0$/,'') + parsed.originalSuffix + parsed.sign; 1559 if (parsed.suffix === 'K') return (v/1e3).toFixed(1).replace(/\.0$/,'') + parsed.originalSuffix + parsed.sign; 1560 return Math.round(v).toLocaleString() + parsed.sign; 1561 } 1562 function animate(el){ 1563 if (seen.has(el)) return; 1564 seen.add(el); 1565 const original = el.textContent.trim(); 1566 const parsed = parseNumber(original); 1567 if (!parsed || parsed.value < 4) return; 1568 // Preserve any wrapping <em>/<span> by working on the leading text node. 1569 const targetText = original; 1570 const dollarPrefix = /^\$/.test(targetText) ? '$' : ''; 1571 const duration = Math.min(1400, 400 + parsed.value * 0.5); 1572 const startTime = performance.now(); 1573 function tick(now){ 1574 const t = Math.min(1, (now - startTime) / duration); 1575 const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic 1576 const current = parsed.value * eased; 1577 el.textContent = dollarPrefix + format(current, parsed); 1578 if (t < 1) requestAnimationFrame(tick); 1579 else el.textContent = targetText; // restore exact original so any styling pseudo-content stays right 1580 } 1581 requestAnimationFrame(tick); 1582 } 1583 const io = new IntersectionObserver(entries => { 1584 entries.forEach(e => { if (e.isIntersecting) animate(e.target); }); 1585 }, { threshold: 0.4 }); 1586 document.querySelectorAll(sel).forEach(el => io.observe(el)); 1587 }); 1588 1589 /* ââ Magnetic primary CTAs âââââââââââââââââââââââââââââââ */ 1590 (function magneticButtons(){ 1591 if (!finePointer || reduceMotion) return; 1592 function bind(btn){ 1593 const r = 18; // px of pull radius 1594 let raf = 0; 1595 let target = { x:0, y:0 }; 1596 function apply(){ 1597 raf = 0; 1598 btn.style.transform = `translate(${target.x.toFixed(2)}px, ${target.y.toFixed(2)}px)`; 1599 } 1600 btn.addEventListener('pointermove', e => { 1601 const b = btn.getBoundingClientRect(); 1602 const dx = (e.clientX - (b.left + b.width/2)) / (b.width/2); 1603 const dy = (e.clientY - (b.top + b.height/2)) / (b.height/2); 1604 target.x = Math.max(-1, Math.min(1, dx)) * r; 1605 target.y = Math.max(-1, Math.min(1, dy)) * (r * 0.6); 1606 if (!raf) raf = requestAnimationFrame(apply); 1607 }); 1608 btn.addEventListener('pointerleave', () => { 1609 target.x = 0; target.y = 0; 1610 if (!raf) raf = requestAnimationFrame(apply); 1611 }); 1612 } 1613 ready(() => { 1614 document.querySelectorAll('a.btn-primary, button.btn-primary').forEach(bind); 1615 }); 1616 })(); 1617 1618 /* ââ Coord-stamp ripple ââââââââââââââââââââââââââââââââââ */ 1619 ready(() => { 1620 const sel = '.coord-stamp, .pd-tag, .ngo-coord, .fc-eyebrow, .ebngo-coord, .ebp-count, .nf-coord, .redir-coord'; 1621 document.addEventListener('click', e => { 1622 const el = e.target.closest(sel); 1623 if (!el) return; 1624 const r = el.getBoundingClientRect(); 1625 el.style.setProperty('--rx', (e.clientX - r.left) + 'px'); 1626 el.style.setProperty('--ry', (e.clientY - r.top) + 'px'); 1627 el.classList.remove('ripple-go'); 1628 void el.offsetWidth; 1629 el.classList.add('ripple-go'); 1630 setTimeout(() => el.classList.remove('ripple-go'), 850); 1631 }, { passive:true }); 1632 }); 1633 1634 /* ââ Logo easter egg (7 clicks within 3s) ââââââââââââââââ */ 1635 ready(() => { 1636 const brand = document.querySelector('header.header a.brand'); 1637 if (!brand) return; 1638 let taps = 0; 1639 let lastTap = 0; 1640 brand.addEventListener('click', e => { 1641 const now = performance.now(); 1642 if (now - lastTap > 3000) taps = 0; 1643 lastTap = now; 1644 taps++; 1645 if (taps >= 7){ 1646 taps = 0; 1647 e.preventDefault(); 1648 showToast('â Hello, careful reader'); 1649 } 1650 }); 1651 function showToast(text){ 1652 const t = document.createElement('div'); 1653 t.className = 'ee-toast'; 1654 t.textContent = text; 1655 document.body.appendChild(t); 1656 requestAnimationFrame(() => t.classList.add('show')); 1657 setTimeout(() => { 1658 t.classList.remove('show'); 1659 setTimeout(() => t.remove(), 400); 1660 }, 3800); 1661 } 1662 }); 1663 1664 /* ââ Save-for-later bookmark (project pages) âââââââââââââ */ 1665 ready(() => { 1666 const m = location.pathname.match(/^\/projects\/([^/]+)\/?$/); 1667 if (!m || m[1] === 'index.html' || m[1] === '') return; 1668 const slug = m[1]; 1669 if (slug === 'projects') return; 1670 1671 // Try to derive the project name from the page title or hero h1. 1672 const titleEl = document.querySelector('main h1, .pd-hero h1, h1'); 1673 const projectName = (titleEl?.textContent.trim() || slug).slice(0, 80); 1674 1675 const KEY = 'ecoflix:saved-projects'; 1676 function read(){
1677 try{ return JSON.parse(localStorage.getItem(KEY) || '[]'); } 1678 catch{ return []; } 1679 } 1680 function write(list){ 1681 try{ localStorage.setItem(KEY, JSON.stringify(list)); }catch{} 1682 } 1683 function isSaved(){ 1684 return read().some(p => p.slug === slug); 1685 } 1686 function toggle(){ 1687 const list = read(); 1688 const idx = list.findIndex(p => p.slug === slug); 1689 if (idx >= 0) list.splice(idx, 1); 1690 else list.unshift({ slug, name: projectName, savedAt: Date.now() }); 1691 write(list); 1692 return idx < 0; // true if newly saved 1693 } 1694 1695 const btn = document.createElement('button'); 1696 btn.type = 'button'; 1697 btn.className = 'bk-fab'; 1698 btn.setAttribute('aria-label', 'Save this project for later'); 1699 btn.innerHTML = ` 1700 <span class="bk-pulse" aria-hidden="true"></span> 1701 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> 1702 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/> 1703 </svg> 1704 <span class="bk-tip">Save for later</span> 1705 `; 1706 if (isSaved()) btn.classList.add('bk-on'); 1707 btn.addEventListener('click', () => { 1708 const justSaved = toggle(); 1709 btn.classList.toggle('bk-on', justSaved); 1710 if (justSaved){ 1711 btn.classList.add('bk-just-saved'); 1712 setTimeout(() => btn.classList.remove('bk-just-saved'), 1000); 1713 // Update tooltip 1714 btn.querySelector('.bk-tip').textContent = 'Saved'; 1715 } else { 1716 btn.querySelector('.bk-tip').textContent = 'Removed'; 1717 } 1718 setTimeout(() => { 1719 btn.querySelector('.bk-tip').textContent = isSaved() ? 'Saved · click to remove' : 'Save for later'; 1720 }, 1500); 1721 }); 1722 document.body.appendChild(btn); 1723 }); 1724 1725 /* Saved-count badge â appears next to the "Projects" link 1726 in the main nav across every page when the visitor has any 1727 saved projects. Subtle, only when there's a real number. */ 1728 ready(() => { 1729 const KEY = 'ecoflix:saved-projects'; 1730 let list; 1731 try { list = JSON.parse(localStorage.getItem(KEY) || '[]'); } catch { list = []; } 1732 if (!list.length) return; 1733 // Update if it changes elsewhere on the page. 1734 function paint(){ 1735 const count = (function(){ 1736 try{ return JSON.parse(localStorage.getItem(KEY) || '[]').length; } 1737 catch{ return 0; } 1738 })(); 1739 // Look through every nav menu link for "All projects" or "Projects". 1740 document.querySelectorAll('.nav-menu a, .nav a, .drawer-nav a').forEach(a => { 1741 if (!/projects/i.test(a.textContent.trim())) return; 1742 if (a.querySelector('.saved-count-badge')) { 1743 const b = a.querySelector('.saved-count-badge'); 1744 if (count > 0) b.textContent = count; 1745 else b.remove(); 1746 return; 1747 } 1748 if (count > 0){ 1749 const badge = document.createElement('span'); 1750 badge.className = 'saved-count-badge'; 1751 badge.textContent = count; 1752 badge.setAttribute('aria-label', count + ' saved'); 1753 a.appendChild(badge); 1754 } 1755 }); 1756 } 1757 paint(); 1758 window.addEventListener('storage', e => { if (e.key === KEY) paint(); }); 1759 }); 1760 1761 /* ââ Command palette (âK / Ctrl+K) âââââââââââââââââââââââ */ 1762 (function commandPalette(){ 1763 const ENTRIES = [ 1764 // About 1765 { g:'About', label:'Mission', href:'/mission/', sub:'Why Ecoflix exists', gl:'M' }, 1766 { g:'About', label:'How it works', href:'/how-it-works/', sub:'Sofa to sanctuary', gl:'H' }, 1767 { g:'About', label:'How we help', href:'/how-we-help/', sub:'The three levers', gl:'H' }, 1768 { g:'About', label:'Our story', href:'/history/', sub:'2019 â today', gl:'S' }, 1769 { g:'About', label:'Team', href:'/team/', sub:'Executive · Board · Advisors',gl:'T' }, 1770 { g:'About', label:'Connect', href:'/connect/', sub:'Social channels', gl:'C' }, 1771 { g:'About', label:'FAQ', href:'/faq/', sub:'26 questions answered', gl:'?' }, 1772 // Projects 1773 { g:'Projects', label:'All projects', href:'/projects/', sub:'Featured Originals carousel', gl:'P' }, 1774 { g:'Projects', label:'NGO partners', href:'/ngo-partners/', sub:'64 partners · the globe', gl:'N' }, 1775 { g:'Projects', label:'Impact report', href:'/impact-report/', sub:'Where the money landed', gl:'I' }, 1776 { g:'Projects', label:"A Free Life for Thailand's Elephants", href:'/projects/a-free-life-for-thailands-rescued-elephants/', sub:'Save Elephant Foundation · Thailand', gl:'EL' }, 1777 { g:'Projects', label:'Saving the Cross River Gorilla', href:'/projects/saving-the-cross-river-gorilla/', sub:'African Conservation Foundation · Cameroon', gl:'CR' }, 1778 { g:'Projects', label:"NA's First Whale Sanctuary", href:'/projects/building-north-americas-first-whale-sanctuary/', sub:'The Whale Sanctuary Project · Canada', gl:'WS' }, 1779 { g:'Projects', label:"Frontline Support for Africa", href:'/projects/frontline-support-for-africas-wildlife/', sub:'Over and Above Africa', gl:'OA' }, 1780 { g:'Projects', label:"Freedom for India's Bears & Elephants", href:'/projects/freedom-for-indias-bears-and-elephants/', sub:'Wildlife SOS
1780· India', gl:'WS' }, 1781 { g:'Projects', label:'A Forest Home for Rescued Wolves', href:'/projects/a-forest-home-for-rescued-wolves/', sub:'Wolf Connection · California, USA', gl:'WC' }, 1782 // Give 1783 { g:'Give', label:'Donate', href:'/donate/', sub:'Pick an amount · 100% goes through', gl:'$' }, 1784 { g:'Give', label:'Circle of Giving', href:'/circle-of-giving/', sub:'Monthly · Field / Sanctuary / Founders', gl:'O' }, 1785 { g:'Give', label:'Gift memberships', href:'/gift-memberships/', sub:'1 · 5 · 10 · 50 · 100', gl:'G' }, 1786 { g:'Give', label:'Corporate partners',href:'/partners/', sub:'Founding · Programme · Ally', gl:'B' }, 1787 { g:'Give', label:'For teachers', href:'/teachers/', sub:'Free classroom access', gl:'T' }, 1788 // Watch 1789 { g:'Watch', label:'The channel', href:'/watch/', sub:'Staff picks · this weekend', gl:'W' }, 1790 { g:'Watch', label:'Podcasts', href:'/podcasts/', sub:'Long-form audio from the field', gl:'P' }, 1791 { g:'Watch', label:'Open watch.ecoflix.com', href:'https://watch.ecoflix.com/', sub:'Go to the channel directly', gl:'â' }, 1792 // Business / BD 1793 { g:'Business', label:'Distribution & ads',href:'/distribution/', sub:'Carry the channel · advertise · syndicate', gl:'D' }, 1794 // News 1795 { g:'News', label:'News', href:'/news/', sub:'Live news from our partners', gl:'N' }, 1796 // Utility 1797 { g:'Utility', label:'Contact', href:'/contact/', sub:'Press · partnerships · general', gl:'@' }, 1798 { g:'Utility', label:'Privacy', href:'/privacy/', sub:'GDPR · CCPA', gl:'§' }, 1799 { g:'Utility', label:'Terms', href:'/terms/', sub:'Of use', gl:'§' }, 1800 { g:'Utility', label:'Cookies', href:'/cookies/', sub:'Preferences', gl:'§' }, 1801 // NGO portal 1802 { g:'NGO', label:'NGO portal sign-in',href:'/ngo-portal/', sub:'Partner login', gl:'⤴' }, 1803 ]; 1804 1805 let backdrop, input, list, items = [], current = 0, lastFocus = null; 1806 1807 function build(){ 1808 backdrop = document.createElement('div'); 1809 backdrop.className = 'cp-backdrop'; 1810 backdrop.setAttribute('role','dialog'); 1811 backdrop.setAttribute('aria-modal','true'); 1812 backdrop.setAttribute('aria-label','Go to'); 1813 backdrop.innerHTML = ` 1814 <div class="cp-panel" role="document"> 1815 <div class="cp-search"> 1816 <svg class="cp-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M21 21l-5-5"/></svg> 1817 <input id="cpInput" type="search" placeholder="Go to⦠(try 'donate', 'team', 'wolves')" autocomplete="off" spellcheck="false" /> 1818 <span class="cp-search-kbd">esc</span> 1819 </div> 1820 <ul class="cp-list" id="cpList" role="listbox"></ul> 1821 <div class="cp-foot"> 1822 <span><span class="kbd">â</span><span class="kbd">â</span> navigate</span> 1823 <span><span class="kbd">âµ</span> open · <span class="kbd">esc</span> close</span> 1824 </div> 1825 </div> 1826 `; 1827 document.body.appendChild(backdrop); 1828 input = backdrop.querySelector('#cpInput'); 1829 list = backdrop.querySelector('#cpList'); 1830 1831 input.addEventListener('input', () => render(input.value.trim())); 1832 input.addEventListener('keydown', onKeydown); 1833 backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); }); 1834 list.addEventListener('click', e => { 1835 const it = e.target.closest('.cp-item'); 1836 if (it){ navigate(it.dataset.href); } 1837 }); 1838 } 1839 1840 function score(query, label, sub){ 1841 // Simple fuzzy: case-insensitive substring + token boost. 1842 const q = query.toLowerCase(); 1843 const l = label.toLowerCase(); 1844 const s = (sub || '').toLowerCase(); 1845 if (!q) return 1; // show all 1846 if (l.startsWith(q)) return 100; 1847 if (l.includes(q)) return 70; 1848 if (s.includes(q)) return 40; 1849 // Letter-by-letter loose match 1850 let qi = 0; 1851 for (let i = 0; i < l.length && qi < q.length; i++){ 1852 if (l[i] === q[qi]) qi++; 1853 } 1854 return qi === q.length ? 15 : 0; 1855 } 1856 1857 function render(query){ 1858 const ranked = ENTRIES 1859 .map(e => ({ e, sc: score(query, e.label, e.sub) })) 1860 .filter(x => x.sc > 0) 1861 .sort((a,b) => b.sc - a.sc); 1862 1863 list.innerHTML = ''; 1864 if (!ranked.length){ 1865 list.innerHTML = '<li class="cp-empty">Nothing matches â try a different word</li>'; 1866 items = []; current = 0; 1867 return; 1868 } 1869 let currentGroup = null; 1870 ranked.forEach(({ e }, idx) => { 1871 if (!query && e.g !== currentGroup){ 1872 currentGroup = e.g; 1873 const g = document.createElement('li'); 1874 g.className = 'cp-group'; 1875 g.textContent = e.g; 1876 list.appendChild(g); 1877 } 1878 const li = document.createElement('li'); 1879 li.className = 'cp-item' + (idx === 0 ? ' is-active' : ''); 1880 li.dataset.href = e.href; 1881 li.setAttribute('role','option'); 1882 li.innerHTML = `
1883 <span class="cp-glyph">${e.gl || '·'}</span> 1884 <span class="cp-text"><b>${e.label}</b><span>${e.sub || ''}</span></span> 1885 <span class="cp-arrow">â</span> 1886 `; 1887 list.appendChild(li); 1888 }); 1889 items = Array.from(list.querySelectorAll('.cp-item')); 1890 current = 0; 1891 } 1892 1893 function setActive(i){ 1894 if (!items.length) return; 1895 items[current]?.classList.remove('is-active'); 1896 current = (i + items.length) % items.length; 1897 const next = items[current]; 1898 next?.classList.add('is-active'); 1899 next?.scrollIntoView({ block:'nearest' }); 1900 } 1901 1902 function onKeydown(e){ 1903 if (e.key === 'Escape') { e.preventDefault(); close(); } 1904 else if (e.key === 'ArrowDown'){ e.preventDefault(); setActive(current + 1); } 1905 else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(current - 1); } 1906 else if (e.key === 'Enter') { e.preventDefault(); if (items[current]) navigate(items[current].dataset.href); } 1907 } 1908 1909 function navigate(href){ 1910 if (!href) return; 1911 if (/^https?:/.test(href)) window.open(href, '_blank', 'noopener'); 1912 else window.location.href = href; 1913 close(); 1914 } 1915 1916 function open(){ 1917 if (!backdrop) build(); 1918 lastFocus = document.activeElement; 1919 backdrop.classList.add('is-open'); 1920 input.value = ''; 1921 render(''); 1922 // Defer to next frame so focus lands after the animation start. 1923 requestAnimationFrame(() => input.focus()); 1924 document.documentElement.style.overflow = 'hidden'; 1925 } 1926 function close(){ 1927 if (!backdrop) return; 1928 backdrop.classList.remove('is-open'); 1929 document.documentElement.style.overflow = ''; 1930 lastFocus?.focus?.(); 1931 } 1932 1933 window.addEventListener('keydown', e => { 1934 // âK or Ctrl+K opens the palette from anywhere. 1935 const isShortcut = (e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey); 1936 if (isShortcut){ 1937 e.preventDefault(); 1938 if (backdrop && backdrop.classList.contains('is-open')) close(); 1939 else open(); 1940 } 1941 // "/" key opens the palette when not focused in an input. 1942 if (e.key === '/' && !/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || '')){ 1943 e.preventDefault(); 1944 open(); 1945 } 1946 }); 1947 1948 // Public hook so other UI (e.g. a future header search icon) can launch it. 1949 window.openCommandPalette = open; 1950 })(); 1951 1952 /* ââ Cartograph trail stroke-draw ââââââââââââââââââââââââââ 1953 Every `.bg-cartograph .bg-trail` path on every page gets a 1954 stroke-draw entrance: the path measures itself, stores the 1955 length as a CSS variable, starts invisible (offset = length), 1956 and animates to offset=0 (fully drawn) once it enters the 1957 viewport. Brings the cartograph signature to life across the 1958 site without touching any per-page markup. */ 1959 ready(() => { 1960 if (reduceMotion) return; 1961 const trails = document.querySelectorAll('.bg-cartograph .bg-trail'); 1962 if (!trails.length) return; 1963 // Measure first. Skip 0-length paths defensively. 1964 trails.forEach(t => { 1965 let len = 0; 1966 try { len = t.getTotalLength(); } catch {} 1967 if (!len || !isFinite(len)) return; 1968 t.style.setProperty('--tl', len.toFixed(0)); 1969 t.classList.add('tr-pending'); 1970 }); 1971 const io = new IntersectionObserver(entries => { 1972 entries.forEach(e => { 1973 if (!e.isIntersecting) return; 1974 // Stagger entry so adjacent trails don't draw at the same instant. 1975 const t = e.target; 1976 const delay = 80 + Math.random() * 220; 1977 setTimeout(() => t.classList.add('tr-drawn'), delay); 1978 io.unobserve(t); 1979 }); 1980 }, { threshold: 0.18 }); 1981 trails.forEach(t => io.observe(t)); 1982 }); 1983 1984})(); 1985 1986/* ============================================================ 1987 Vercel Web Analytics â privacy-friendly page views & visitors. 1988 Loaded on every page that ships site.js. Multi-page site, so each 1989 navigation is a full page load and the collector auto-fires one 1990 pageview per view. Pages without site.js carry the tag inline. 1991 ============================================================ */ 1992(function () { 1993 if (window.__ecoflixVA) return; 1994 window.__ecoflixVA = true; 1995 window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); }; 1996 var s = document.createElement('script'); 1997 s.defer = true;
1998 s.src = '/_vercel/insights/script.js'; 1999 (document.head || document.documentElement).appendChild(s); 2000})();
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.