PageSourceSearch

https://snapdat.ai/assets/js/jquery-animations.js

js snapdat.ai collected 2026-10-05 12:39:07 UTC 29,065 bytes, 671 lines download raw bytes

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.