1$(function () { 2 "use strict"; 3 4 var root = document.documentElement; 5 var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 6 var hasGsap = !!window.gsap && !!window.ScrollTrigger; 7 var animate = hasGsap && !reduced; 8 9 /* The head script claimed .anim optimistically. Honour it only if the 10 animation libraries actually arrived; otherwise fall back to the static 11 frame, which is already the complete, readable page. */ 12 if (animate) { root.classList.add('anim'); } 13 else { root.classList.remove('anim'); } 14 15 if (hasGsap) { gsap.registerPlugin(ScrollTrigger); } 16 17 var $header = $('#siteHeader'); 18 var rafId = null; 19 var lenis = null; 20 21 /* --------------------------------------------------------------- 22 Smooth scroll (Lenis) â skipped for reduced motion 23 --------------------------------------------------------------- */ 24 if (!reduced && window.Lenis) { 25 lenis = new Lenis({ lerp: 0.1, smoothWheel: true, wheelMultiplier: 1 }); 26 lenis.on('scroll', function () {
27 if (hasGsap) ScrollTrigger.update(); 28 }); 29 var raf = function (t) { 30 lenis.raf(t); 31 rafId = requestAnimationFrame(raf); 32 }; 33 rafId = requestAnimationFrame(raf); 34 } 35 36 /* --------------------------------------------------------------- 37 Nav background on scroll 38 --------------------------------------------------------------- */ 39 if (hasGsap) { 40 ScrollTrigger.create({ 41 start: 'top -60', 42 end: 99999, 43 onUpdate: function (self) { 44 $header.toggleClass('is-scrolled', self.scroll() > 60); 45 } 46 }); 47 } else { 48 $(window).on('scroll', function () { 49 $header.toggleClass('is-scrolled', $(window).scrollTop() > 60); 50 }); 51 } 52 53 /* --------------------------------------------------------------- 54 [data-rv] reveal-on-scroll â the composition is the unit, not the element 55 56 Each [data-rv] used to own a ScrollTrigger at 'top 88%'. Stacked siblings 57 therefore crossed the line milliseconds apart and arrived unsynchronised â 58 four separate near-misses instead of one section landing. Siblings are now 59 grouped and driven from a single trigger with a capped stagger, so every 60 entering section reads as one arrival in one language. 61 62 Only the flow sections take part. #hero, #problem and #steps are scrubbed 63 takes with a position:sticky child mapped top-topâbottom-bottom; they do 64 not "enter", they ARE the scroll, and a wrapper transform would fight both 65 the sticky child and the scrub. 66 --------------------------------------------------------------- */ 67 if (animate) { 68 var RV = { y: 28, dur: 0.75, step: 0.075, cap: 0.3 }; 69 /* A group is cut when its members span more than ~70vh, so a tall 70 section's lower rows keep waiting for the reader instead of playing 71 offscreen and being static by the time they are looked at. */ 72 var RV_SPAN = window.innerHeight * 0.7; 73 74 var order = [], byParent = []; 75 $('[data-rv]').each(function () { 76 var i, hit = null; 77 for (i = 0; i < byParent.length; i++) { 78 if (byParent[i].parent === this.parentNode) { hit = byParent[i]; break; } 79 } 80 if (!hit) { hit = { parent: this.parentNode, els: [] }; byParent.push(hit); } 81 hit.els.push(this); 82 }); 83 84 byParent.forEach(function (bucket) { 85 var run = [], top0 = null; 86 bucket.els.forEach(function (el) { 87 var top = el.getBoundingClientRect().top + window.pageYOffset; 88 if (top0 !== null && top - top0 > RV_SPAN) { order.push(run); run = []; top0 = null; } 89 if (top0 === null) { top0 = top; } 90 run.push(el); 91 }); 92 if (run.length) { order.push(run); } 93 }); 94 95 order.forEach(function (els) { 96 gsap.set(els, { opacity: 0, y: RV.y }); 97 ScrollTrigger.create({ 98 trigger: els[0], 99 /* Fire on arrival, not on the first sliver. At 'top 85%' the group 100 began when 15vh of it had appeared, so a 0.75s tween plus stagger 101 finished while the section was still mostly below the fold â the 102 reader met the result, never the motion. 75% was still early. At 103 60% the group's top sits just below centre when it starts, so the 104 move happens where the eye already is, with 60vh of runway left 105 before it reaches the top of the screen. */ 106 start: 'top 60%', 107 once: true, 108 onEnter: function () { 109 gsap.to(els, { 110 opacity: 1, y: 0, 111 duration: RV.dur, 112 ease: 'power3.out', 113 stagger: { amount: Math.min(RV.cap, (els.length - 1) * RV.step) } 114 }); 115 } 116 }); 117 }); 118 119 /* The confidence bars were staged in CSS â .confidence-fill carries 120 transform-origin:left center â but nothing ever drove them. They fill 121 from the card's own arrival, which is what that section's entrance is. 122 The inline width stays the truth, so the static frame is already right. */ 123 var $fills = $('.confidence-fill'); 124 if ($fills.length) { 125 gsap.set($fills, { scaleX: 0 }); 126 ScrollTrigger.create({ 127 trigger: '.confidence-card', 128 /* Must sit behind the card's own arrival at 'top 60%', or the bars 129 fill before the surface holding them exists. */ 130 start: 'top 50%', 131 once: true, 132 onEnter: function () { 133 gsap.to($fills, { 134 scaleX: 1, duration: 0.9, ease: 'power2.out', 135 stagger: { amount: 0.28 }, delay: 0.15 136 }); 137 } 138 }); 139 } 140 } 141 142 /* --------------------------------------------------------------- 143 HERO â one continuous scrubbed take. 144 145 Beats, on a 0-100 scale mapped across the section's scroll budget: 146 00 arrival ......... black, badge in the viewfinder, scanline sweeping 147 06 capture ......... shutter presses, flash fires â "Capture." 148 12 reveal .......... the blackout lifts off the ember gradient 149 24 review .......... camera gives way to the parsed fields 150 30 resolve ......... the four read fields land, one after another 151 40 enrich .......... the two blanks light up and fill â "Enrich."
152 54 assemble ........ fields collapse into one contact card 153 60 orchestrate ..... phone recedes, destinations fly out â "Orchestrate." 154 84 land ............ the headline and the CTA arrive 155 --------------------------------------------------------------- */ 156 var heroTl = null; 157 158 function buildHero() { 159 var pin = document.getElementById('hero-pin'); 160 if (!pin) return; 161 162 /* --- frame zero --- */ 163 gsap.set('#contactCard', { opacity: 0, yPercent: -50, scale: 0.94 }); 164 gsap.set('#leadCard', { opacity: 0, y: 16 }); 165 gsap.set('.rv-txt', { opacity: 0 }); 166 gsap.set('.rv-field.enriched', { '--lit': 0 }); 167 gsap.set('.n1, .n2', { opacity: 0, x: 34, y: 6, scale: 0.9 }); 168 gsap.set('.n3, .n4', { opacity: 0, x: -34, y: 6, scale: 0.9 }); 169 /* No yPercent here. The offset lives in CSS so the static frame gets it too, 170 and GSAP already treats that transform as the baseline for the scale 171 tween â setting yPercent on top of it applied the shift twice. */ 172 gsap.set('#hero-keys .k', { opacity: 0, y: 14 }); 173 174 heroTl = gsap.timeline({ 175 scrollTrigger: { trigger: '#hero', start: 'top top', end: 'bottom bottom', scrub: 0.8 } 176 }); 177 178 /* 00-06 · arrival ------------------------------------------------ */ 179 heroTl.to('#scroll-hint', { opacity: 0, duration: 5, ease: 'power2.in' }, 1); 180 181 /* 06-14 · capture ------------------------------------------------ */ 182 heroTl 183 .to('#scanFrame', { scale: 0.94, duration: 5, ease: 'power2.out' }, 6) 184 .to('#heroScan', { opacity: 0, duration: 3, ease: 'power2.out' }, 6.5) 185 .to('#captureDot', { scale: 0.84, duration: 2, ease: 'power2.in' }, 7) 186 .to('#captureDot', { scale: 1, duration: 3.5, ease: 'back.out(3)' }, 9) 187 .to('#camFlash', { opacity: 0.9, duration: 1, ease: 'none' }, 8.8) 188 .to('#camFlash', { opacity: 0, duration: 4.5, ease: 'power2.out' }, 9.8) 189 .to('#camBadge', { scale: 0.965, duration: 3.5, ease: 'power2.out' }, 9) 190 .to('.sl-scan', { opacity: 0, duration: 2, ease: 'none' }, 8.6) 191 .to('.sl-capture', { opacity: 1, duration: 2.5, ease: 'none' }, 9.4) 192 .to('.k-1', { opacity: 1, y: 0, duration: 7, ease: 'power3.out' }, 13); 193 194 /* 12-26 · the world comes up under the black --------------------- */ 195 heroTl 196 .to('#hero-blackout', { opacity: 0, duration: 15, ease: 'power2.inOut' }, 12) 197 .to('#hero-glow', { opacity: 0.5, scale: 1.2, duration: 22, ease: 'power1.out' }, 12); 198 199 /* 20-30 · viewfinder gives way to the parsed record -------------- */ 200 heroTl 201 .to(['#scanFrame', '#captureDot'], { opacity: 0, duration: 5, ease: 'power2.inOut' }, 20) 202 .to('#camFeed', { opacity: 0, duration: 6, ease: 'power2.inOut' }, 22) 203 .to('.sl-capture', { opacity: 0, duration: 2.5, ease: 'none' }, 24) 204 .to('.sl-enrich', { opacity: 1, duration: 3, ease: 'none' }, 26) 205 .to('#leadCard', { opacity: 1, y: 0, duration: 7, ease: 'power2.out' }, 24); 206 207 /* 30-40 · the four read fields resolve, one at a time ------------ */ 208 ['name', 'email', 'company', 'title'].forEach(function (key, i) { 209 var at = 30 + i * 2.4; 210 heroTl 211 .to('.rv-field[data-rf="' + key + '"] .rv-skel', { opacity: 0, duration: 2.4, ease: 'power2.out' }, at) 212 .to('.rv-field[data-rf="' + key + '"] .rv-txt', { opacity: 1, duration: 2.6, ease: 'power2.out' }, at + 0.7); 213 }); 214 215 /* 40-52 · the two blanks light up and fill ----------------------- */ 216 heroTl.to('.rv-field.enriched', { '--lit': 1, duration: 7, stagger: 2.6, ease: 'power2.out' }, 40); 217 heroTl.to('.k-2', { opacity: 1, y: 0, duration: 7, ease: 'power3.out' }, 44); 218 ['phone', 'site'].forEach(function (key, i) { 219 var at = 41.5 + i * 2.6; 220 heroTl 221 .to('.rv-field[data-rf="' + key + '"] .rv-empty', { opacity: 0, duration: 2, ease: 'power2.out' }, at) 222 .to('.rv-field[data-rf="' + key + '"] .rv-txt', { opacity: 1, duration: 3, ease: 'power2.out' }, at + 1); 223 }); 224 225 /* 54-64 · the record becomes one contact, AT FULL SIZE. 226 The device used to start receding on the same beat the card assembled, 227 so the payoff was born already shrinking and never existed legibly. 228 It now lands whole, and nothing else moves while it does. */ 229 heroTl 230 .to('#leadCard', { opacity: 0, duration: 5, ease: 'power2.inOut' }, 54) 231 .to('.sl-enrich', { opacity: 0, duration: 2.5, ease: 'none' }, 54) 232 .to('.sl-ready', { opacity: 1, duration: 3, ease: 'none' }, 58) 233 .to('#contactCard', { opacity: 1, scale: 1, duration: 10, ease: 'power3.out' }, 54); 234 235 /* 64-72 · the hold. The one beat in the take where the screen is still. */ 236 237 /* 72-98 · the device recedes and the record leaves it. 238 0.88, not the old 0.52: that shrink was sized for chips at 34% from the 239 edges, and those moved to 10% two passes ago. At 0.52 the contact c
239ard's 240 own type fell to ~7px at the exact moment it is the proof. 241 The connectors anchor where the phone comes to rest, so they draw only 242 after it settles â otherwise they hang off nothing. */ 243 heroTl 244 .to('#hero-device', { scale: 0.88, duration: 14, ease: 'power2.inOut' }, 72) 245 /* out of the device, not into empty space â with the connectors gone this 246 motion is the only thing left saying the lead travels somewhere */ 247 .to('.n1, .n2', { opacity: 1, x: 0, y: 0, scale: 1, duration: 9, stagger: 2.2, ease: 'power3.out' }, 78) 248 .to('.n3, .n4', { opacity: 1, x: 0, y: 0, scale: 1, duration: 9, stagger: 2.2, ease: 'power3.out' }, 79.1) 249 .to('.k-3', { opacity: 1, y: 0, duration: 7, ease: 'power3.out' }, 80); 250 251 heroTl.to({}, { duration: 2 }, 98); 252 } 253 254 if (animate) { buildHero(); } 255 256 /* --------------------------------------------------------------- 257 PROBLEM â the pile lands around the headline. 258 Entry offsets and resting tilt are authored inline per card. 259 --------------------------------------------------------------- */ 260 if (animate) { 261 var pItems = $('#problem .p-item').get().map(function (el) { 262 return { 263 el: el, 264 sx: el.style.getPropertyValue('--sx') || '0px', 265 sy: el.style.getPropertyValue('--sy') || '0px', 266 rest: parseFloat(el.style.getPropertyValue('--rest')) || 0, 267 order: parseFloat(el.style.getPropertyValue('--delay')) || 0 268 }; 269 }).sort(function (a, b) { return a.order - b.order; }); 270 271 if (pItems.length) { 272 var probTl = gsap.timeline({ 273 scrollTrigger: { trigger: '#problem', start: 'top top', end: 'bottom bottom', scrub: 0.8 } 274 }); 275 276 probTl.fromTo('.problem-copy', 277 { opacity: 0, y: 34 }, 278 { opacity: 1, y: 0, duration: 18, ease: 'power3.out' }, 0); 279 280 /* --- the statement finds focus, character by character --------------- 281 The block tween above owns opacity. These own blur and rise only, and 282 fire once rather than on scrub: 21 simultaneously blurred layers are
283 cheap as a single 1.4s moment and expensive as a scrubbed one. On 284 concept too â this section is about reading something off a badge. */ 285 (function splitStatement () { 286 var h = document.querySelector('.problem-copy .headline'); 287 if (!h) return; 288 h.setAttribute('aria-label', h.textContent.replace(/\s+/g, ' ').trim()); 289 290 var chars = []; 291 (function walk (node) { 292 Array.prototype.slice.call(node.childNodes).forEach(function (n) { 293 if (n.nodeType === 3) { 294 var frag = document.createDocumentFragment(); 295 n.textContent.split(/(\s+)/).forEach(function (token) { 296 if (!token) return; 297 if (/^\s+$/.test(token)) { 298 var sp = document.createElement('span'); 299 sp.className = 'ch'; 300 sp.setAttribute('aria-hidden', 'true'); 301 sp.textContent = token; 302 frag.appendChild(sp); 303 chars.push(sp); 304 return; 305 } 306 var word = document.createElement('span'); 307 word.className = 'wd'; 308 Array.prototype.forEach.call(token, function (ch) { 309 var c = document.createElement('span'); 310 c.className = 'ch'; 311 c.setAttribute('aria-hidden', 'true'); 312 c.textContent = ch; 313 word.appendChild(c); 314 chars.push(c); 315 }); 316 frag.appendChild(word); 317 }); 318 node.replaceChild(frag, n); 319 } else if (n.nodeType === 1) { 320 walk(n); 321 } 322 }); 323 })(h); 324 325 if (!chars.length) return; 326 gsap.set(chars, { y: 16, filter: 'blur(12px)' }); 327 328 /* Fired from the section timeline rather than a viewport percentage: 329 the reveal must start the instant the block tween above has brought 330 the copy to full opacity, and only the timeline knows when that is. 331 A guard keeps it to once, since a scrub can cross this point again. */ 332 var played = false; 333 probTl.call(function () { 334 if (played) return; 335 played = true; 336 gsap.to(chars, { 337 y: 0, filter: 'blur(0px)', 338 duration: 0.9, ease: 'power4.out', stagger: 0.025, 339 onComplete: function () { gsap.set(chars, { clearProps: 'filter' }); } 340 }); 341 }, null, 17); 342 })(); 343 344 pItems.forEach(function (it, i) { 345 probTl.fromTo(it.el, 346 { x: it.sx, y: it.sy, rotate: 0, opacity: 0 }, 347 { x: 0, y: 0, rotate: it.rest, opacity: 1, duration: 22, ease: 'power3.out' }, 348 8 + i * 5); 349 }); 350 351 probTl.to({}, { duration: 18 }, 100); /* hold the composed pile */ 352 } 353 } 354 355 /* --------------------------------------------------------------- 356 STEPS â pinned crossfade, desktop only. On narrow screens the 357 three beats simply stack (see the CSS media query), so the 358 timeline must not exist there at all. 359 --------------------------------------------------------------- */ 360 var $beats = $('#steps .beat'); 361 var $progs = $('#steps [data-prog]'); 362 363 function setActiveStep(i) { 364 $progs.each(function (k) { $(this).toggleClass('active', k === i); }); 365 } 366 367 if (animate && $beats.length === 3 && gsap.matchMedia) { 368 var mm = gsap.matchMedia(); 369 370 /* ---- desktop: the handoff, split by what each part actually is ------- 371 The numeral is the one continuous element across the three beats, so it 372 rolls in place like a counter. The statement reads vertically, so it 373 leaves upward. The evidence card travels horizontally â out left, in 374 from the right â because it is the payload moving to the next station, 375 and because that is the axis the 01·02·03 indicator already reads on. 376 Animating .beat as one block made all three behave like a slide. */ 377 mm.add('(min-width: 861px)', function () { 378 var parts = []; 379 $beats.each(function () { 380 var $b = $(this); 381 parts.push({ 382 num: $b.find('.step-num').get(), 383 body: $b.find('.step-title, .step-copy').get(), 384 card: $b.find('.cols > *').last().get() 385 }); 386 }); 387 388 [1, 2].forEach(function (i) { 389 gsap.set(parts[i].num, { opacity: 0, y: 44 }); 390 gsap.set(parts[i].body, { opacity: 0, y: 30 }); 391 gsap.set(parts[i].card, { opacity: 0, x: 88 }); 392 }); 393 gsap.set('.steps-track i', { scaleX: 0.333 }); 394 395 var tl = gsap.timeline({ 396 scrollTrigger: { trigger: '#steps', start: 'top top', end: 'bottom bottom', scrub: 0.7 } 397 }); 398
399 [[0, 1, 0.85], [1, 2, 1.95]].forEach(function (h) { 400 var out = parts[h[0]], into = parts[h[1]], at = h[2]; 401 tl.to(out.num, { opacity: 0, y: -44, duration: 0.42, ease: 'power2.in' }, at) 402 .to(out.body, { opacity: 0, y: -30, duration: 0.42, ease: 'power2.in' }, at + 0.04) 403 .to(out.card, { opacity: 0, x: -88, duration: 0.44, ease: 'power2.in' }, at + 0.02) 404 .to(into.num, { opacity: 1, y: 0, duration: 0.50, ease: 'power3.out' }, at + 0.30) 405 .to(into.body, { opacity: 1, y: 0, duration: 0.50, ease: 'power3.out' }, at + 0.34) 406 .to(into.card, { opacity: 1, x: 0, duration: 0.54, ease: 'power3.out' }, at + 0.32) 407 .to('.steps-track i', { scaleX: h[1] === 1 ? 0.666 : 1, duration: 0.6, ease: 'power2.inOut' }, at); 408 }); 409 410 ScrollTrigger.create({ 411 trigger: '#steps', start: 'top top', end: 'bottom bottom', 412 onUpdate: function (self) { 413 var pr = self.progress; 414 setActiveStep(pr < 0.34 ? 0 : (pr < 0.67 ? 1 : 2)); 415 } 416 }); 417 418 return function () { 419 $beats.each(function () { 420 gsap.set($(this).find('.step-num, .step-title, .step-copy, .cols > *').get(), { clearProps: 'all' }); 421 }); 422 }; 423 }); 424 425 /* ---- narrow: the beats stack, so they get the page's own arrival ----- */ 426 mm.add('(max-width: 860px)', function () { 427 $beats.each(function () { 428 var el = this; 429 gsap.set(el, { opacity: 0, y: 26 }); 430 ScrollTrigger.create({ 431 trigger: el, start: 'top 86%', once: true, 432 onEnter: function () { 433 gsap.to(el, { opacity: 1, y: 0, duration: 0.7, ease: 'power3.out' }); 434 } 435 }); 436 }); 437 return function () { gsap.set($beats, { clearProps: 'all' }); }; 438 }); 439 } 440 441 /* --------------------------------------------------------------- 442 PIVOT â the line assembles, then the week runs out 443 444 Scrubbed, not fired: the sentence is about time passing, so the days 445 should advance under the reader's own scrolling rather than play at them. 446 Implemented on GSAP rather than CSS scroll-timelines â the page already 447 has one motion engine that works in every browser, and importing 448 animation-timeline would mean shipping a second system plus its own 449 fallback for Safari and Firefox. 450 --------------------------------------------------------------- */ 451 var pivotWords = (function splitPivot () { 452 var el = document.querySelector('.pivot-line'); 453 if (!el) return []; 454 /* the split shatters the sentence into per-word spans; give assistive tech 455 the whole line back before that happens */ 456 el.setAttribute('aria-label', el.textContent.replace(/\s+/g, ' ').trim()); 457 var out = []; 458 (function walk (node) { 459 Array.prototype.slice.call(node.childNodes).forEach(function (n) { 460 if (n.nodeType === 3) { 461 var frag = document.createDocumentFragment(); 462 n.textContent.split(/(\s+)/).forEach(function (tok) { 463 if (!tok) return; 464 /* whitespace stays a real text node so the line still wraps */ 465 if (/^\s+$/.test(tok)) { frag.appendChild(document.createTextNode(tok)); return; } 466 var clip = document.createElement('span'); 467 clip.className = 'w'; 468 clip.setAttribute('aria-hidden', 'true'); 469 var inner = document.createElement('span'); 470 inner.textContent = tok; 471 clip.appendChild(inner); 472 frag.appendChild(clip); 473 out.push(inner); 474 }); 475 node.replaceChild(frag, n); 476 } else if (n.nodeType === 1) { 477 walk(n); /* recurse so .accent survives the split */ 478 } 479 }); 480 })(el); 481 return out; 482 })(); 483 484 var $pcCells = $('#pivot .pc-cell'); 485 if (animate && pivotWords.length) { 486 /* Each cell rests at its own opacity â the fade down the week is the whole 487 point â so read those values off the CSS before overriding them, and 488 tween each one back to its own. CSS stays the source of truth, which is 489 what keeps the no-JS frame identical to where the scrub lands. */ 490 var cellRest = $pcCells.map(function () { 491 return parseFloat(getComputedStyle(this).opacity) || 1; 492 }).get(); 493 494 gsap.set(pivotWords, { yPercent: 115 }); 495 gsap.set($pcCells, { opacity: 0, y: 18 }); 496 497 var pvTl = gsap.timeline({ 498 scrollTrigger: { trigger: '#pivot', start: 'top top', end: 'bottom bottom', scrub: 0.7 } 499 }); 500 501 /* 04-26 · the line assembles, word after word */ 502 pvTl.to(pivotWords, { yPercent: 0, duration: 7, stagger: 1.4, ease: 'power3.out' }, 4); 503 /* 34-73 · the week lands a day at a time â one per beat, never overlapping, 504 which is what makes it read as days passing rather than a row fading in */ 505 $pcCells.each(function (i) { 506 pvTl.to(this, { 507 opacity: cellRest[i], y: 0, duration: 6, ease: 'power2.out' 508 }, 34 + i * 5.5); 509 }); 510 /* 73-90 · a long beat of nothing. The complete week holds for most of the 511 scene â it is the evidence, and it needs dwell time, not a quick look. */ 512 513 /* 90-100 · the week leaves as one object, not day by day. It arrived in 514 pieces because that is how days pass; it goes whole because what the 515 reader is left holding is the sentence, not the calendar. Exiting 516 faster than it entered, and landing exactly on the end of the scrub, so 517 the last thing that happens in the scene is the handoff to #steps. 518 The per-cell opacities stay where they are and this multiplies over 519 them, so the faded days do not brighten on their way out. */ 520 pvTl.to('.pivot-cal', { opacity: 0, y: -16, duration: 10, ease: 'power2.in' }, 90); 521 } 522 523 /* --------------------------------------------------------------- 524 STAT â the wait counts up once, on arrival. 525 --------------------------------------------------------------- */ 526 var $countNum = $('#countNum'); 527 if (animate && $countNum.length) { 528 var day = { v: 0 }; 529 ScrollTrigger.create({ 530 /* Anchored to the column that shows the number, not to the section. 531 #stat's top crosses any line well before the column does, so the 532 count ran behind a still-hidden element and could finish before the 533 reveal uncovered it â the visitor met a static 90. */ 534 trigger: '.stat-col-wait', start: 'top 52%', once: true, 535 onEnter: function () { 536 /* zero it here, not at setup: if this never fires the markup's real 537 90 stands, instead of the visitor reading a literal wrong number */ 538 $countNum.text('0'); 539 gsap.to(day, { 540 v: 90, duration: 1.6, ease: 'power2.out', delay: 0.25, 541 onUpdate: function () { $countNum.text(Math.round(day.v)); } 542 }); 543 } 544 }); 545 } 546 547 /* --------------------------------------------------------------- 548 3D GLOBE (Three.js) 549 --------------------------------------------------------------- */ 550 (function initGlobe() { 551 var canvas = document.getElementById('globe-canvas'); 552 var fallback = document.getElementById('globe-fallback'); 553 if (!canvas) return; 554 555 if (!window.THREE) { 556 $(canvas).hide(); 557 $(fallback).css('display', 'flex'); 558 return; 559 } 560 561 try { 562 var box = canvas.parentElement; 563 var renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true, alpha: true }); 564 renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); 565 566 var scene = new THREE.Scene(); 567 var cam = new THREE.PerspectiveCamera(45, 1, 0.1, 100); 568 cam.position.z = 3.15; 569 570 // fibonacci-sphere dots 571 var N = 1300, pos = []; 572 var off = 2 / N, inc = Math.PI * (3 - Math.sqrt(5)); 573 for (var i = 0; i < N; i++) { 574 var y = i * off - 1 + off / 2; 575 var r = Math.sqrt(1 - y * y); 576 var phi = i * inc; 577 pos.push(Math.cos(phi) * r, y, Math.sin(phi) * r); 578 } 579 var geo = new THREE.BufferGeometry(); 580 geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); 581 var mat = new THREE.PointsMaterial({ color: 0xFF5A1F, size: 0.022, transparent: true, op
581acity: 0.9 }); 582 var dots = new THREE.Points(geo, mat); 583 scene.add(dots); 584 585 var wire = new THREE.LineSegments( 586 new THREE.WireframeGeometry(new THREE.IcosahedronGeometry(1.001, 2)), 587 new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.055 }) 588 ); 589 scene.add(wire); 590 591 // orbiting node 592 var node = new THREE.Mesh( 593 new THREE.SphereGeometry(0.045, 16, 16), 594 new THREE.MeshBasicMaterial({ color: 0xFF5A1F }) 595 ); 596 var orbit = new THREE.Group(); 597 node.position.set(1.35, 0, 0); 598 orbit.add(node); 599 orbit.rotation.x = 0.5; 600 scene.add(orbit); 601 602 function resize() { 603 var w = box.clientWidth || 400, h = box.clientHeight || 400; 604 renderer.setSize(w, h, false); 605 cam.aspect = w / h; 606 cam.updateProjectionMatrix(); 607 renderer.render(scene, cam); /* keep it correct while paused */ 608 } 609 resize(); 610 $(window).on('resize', resize); 611 612 dots.rotation.y = -0.4; 613 var graf = null, visible = false; 614 615 function frame() { 616 dots.rotation.y += 0.0018; 617 wire.rotation.y = dots.rotation.y; 618 orbit.rotation.y += 0.012; 619 renderer.render(scene, cam); 620 graf = requestAnimationFrame(frame); 621 } 622 function start() { if (graf == null) { graf = requestAnimationFrame(frame); } } 623 function stop() { if (graf != null) { cancelAnimationFrame(graf); graf = null; } } 624 625 renderer.render(scene, cam); /* one frame so it is never blank */ 626 627 /* Only spin while it is actually on screen â a globe rendering behind 628 four viewports of scroll is pure battery. */ 629 if (!reduced) { 630 if (window.IntersectionObserver) { 631 new IntersectionObserver(function (entries) { 632 visible = entries[0].isIntersecting; 633 if (visible) { start(); } else { stop(); } 634 }, { rootMargin: '120px' }).observe(canvas); 635 } else { 636 start(); 637 } 638 $(window).on('pagehide', stop); 639 } 640 641 if (!reduced && hasGsap) { 642 ScrollTrigger.create({ 643 trigger: '#globe', 644 start: 'top bottom', 645 end: 'bottom top', 646 onUpdate: function (self) { 647 var t = (self.progress - 0.5) * 0.6; 648 dots.rotation.x = t; 649 wire.rotation.x = t; 650 } 651 }); 652 } 653 } catch (e) { 654 $(canvas).hide(); 655 $(fallback).css('display', 'flex'); 656 } 657 })(); 658 659 /* Fonts change measured heights, so pinned sections must be re-measured 660 once the webfonts have actually landed. */ 661 if (hasGsap) { 662 ScrollTrigger.refresh(); 663 $(window).on('load', function () { ScrollTrigger.refresh(); }); 664 if (document.fonts && document.fonts.ready) { 665 document.fonts.ready.then(function () { ScrollTrigger.refresh(); }); 666 } 667 } 668 669 /* No beforeunload teardown: it would disqualify the page from the 670 back/forward cache and buy nothing on a document that is going away. */ 671});
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.