const REQUIRED_NODES = [ '.orbit_stage', '.timeline_line', '.timeline_cursor', '.timeline_cursor-blur', '.h1[data-title-variant="primary"]', '.h1[data-title-variant="secondary"]', '.h1_box', '.chapter_evolution', '.pin_wrapper', '.orbit_card:nth-child(4)', ]; const waitForOrbitReady = (timeout = 8000) => new Promise((resolve, reject) => { const deadline = Date.now() + timeout; const hasAllDeps = () => window.gsap && window.ScrollTrigger && window.SplitText && REQUIRED_NODES.every((sel) => document.querySelector(sel)); const tick = () => { if (hasAllDeps()) { resolve(); return; } if (Date.now() > deadline) { reject(new Error('Orbit widgets not ready in time')); return; } requestAnimationFrame(tick); }; tick(); }); const bootOrbit = () => { document.body.style.overflow = 'visible'; if (!window.gsap || !window.ScrollTrigger || !window.SplitText) { document.body.classList.add('orbit-ready'); return; } gsap.registerPlugin(ScrollTrigger, SplitText); const stage = document.querySelector('.orbit_stage'); const timelineLine = document.querySelector('.timeline_line'); const cursor = document.querySelector('.timeline_cursor'); const cursorBlur = document.querySelector('.timeline_cursor-blur'); const pinWrapper = document.querySelector('.pin_wrapper'); const cards = gsap.utils.toArray('.orbit_card'); const titlePrimary = document.querySelector('.h1[data-title-variant="primary"]'); const titleSecondary = document.querySelector('.h1[data-title-variant="secondary"]'); const titleWrapper = document.querySelector('.h1_box'); if ( !stage || !timelineLine || !cursor || !cursorBlur || cards.length !== 4 || !titlePrimary || !titleSecondary || !titleWrapper ) { document.body.classList.add('orbit-ready'); return; } titleSecondary.style.opacity = '0'; titleSecondary.style.willChange = 'opacity, transform'; const initSplitText = () => { const splitPrimary = new SplitText(titlePrimary, { type: 'lines', linesClass: 'title_line' }); const splitSecondary = new SplitText(titleSecondary, { type: 'lines', linesClass: 'title_line' }); return { linesPrimary: splitPrimary.lines, linesSecondary: splitSecondary.lines }; }; let titleLinesPrimary; let titleLinesSecondary; const infoBlocks = cards.map((card) => card.querySelector('.card_info')); const splitters = cards.map((card) => card.querySelector('.splitter_card')); const GAP = 10; const BASE_OFFSET = 140; const EXTRA_Y = [0, 55, 0, 55]; const MOBILE_QUERY = '(max-width: 640px)'; const MOBILE_SETTINGS = { gap: 4, fromWidth: '7rem', fromHeight: '15em', toHeight: '15em', toWidthPercents: [58, 52, 48, 42], }; const MOBILE_SCROLL_END = '+=140%'; const DESKTOP_SCROLL_END = '+=250%'; const LAYOUT_DEBOUNCE_MS = 150; const getIsMobile = () => window.matchMedia(MOBILE_QUERY).matches; const primeElements = (mobile) => { if (!titleLinesPrimary || !titleLinesSecondary) return; gsap.set(infoBlocks, { opacity: 0, y: 16, force3D: !mobile }); gsap.set(splitters, { width: '0%', force3D: false }); gsap.set(cursor, { scaleY: 0, x: timelineLine.offsetLeft, force3D: false }); gsap.set(cursorBlur, { width: 0, x: timelineLine.offsetLeft, force3D: false }); if (mobile) { gsap.set([titlePrimary, titleSecondary], { transformStyle: 'flat', force3D: false }); gsap.set(titleLinesSecondary, { opacity: 0, y: 20, filter: 'blur(8px)', force3D: false, rotateX: 0, yPercent: 0, z: 0, }); } else { gsap.set([titlePrimary, titleSecondary], { transformPerspective: 1400, transformStyle: 'preserve-3d', }); gsap.set(titleLinesSecondary, { opacity: 0, rotateX: 70, yPercent: 50, z: 80, filter: 'blur(12px)', }); } gsap.set(titleWrapper, { transformOrigin: 'center top', force3D: !mobile }); }; const resolveMobileWidthPercent = (index) => { const override = MOBILE_SETTINGS.toWidthPercents; if (Array.isArray(override)) return override[index] ?? override[override.length - 1]; if (typeof override === 'number') return override; return null; }; const buildTargets = (mobile) => { const stageWidth = stage.offsetWidth; const lineWidth = timelineLine.offsetWidth; const lineLeft = timelineLine.offsetLeft; const lineTop = timelineLine.offsetTop; if (!mobile) { const usableWidth = lineWidth - GAP * (cards.length - 1); const slotWidthPx = usableWidth / cards.length; const lineLeftPercent = (lineLeft / stageWidth) * 100; return cards.map((_, index) => { const leftInsidePx = index * (slotWidthPx + GAP); const cardCenterPx = lineLeft + leftInsidePx + slotWidthPx / 2; const leftPercent = (leftInsidePx / stageWidth) * 100; const widthPercent = (slotWidthPx / stageWidth) * 100; const extra = EXTRA_Y[index] || 0; return { top: `${lineTop - (BASE_OFFSET + extra)}px`, left: `calc(${lineLeftPercent}% + ${leftPercent}%)`, width: `${widthPercent}%`, centerPx: cardCenterPx, }; }); } const gapPx = MOBILE_SETTINGS.gap; const widthsPx = cards.map((_, i) => { const percent = resolveMobileWidthPercent(i) ?? 100 / cards.length; return (percent / 100) * stageWidth; }); let cursorPx = lineLeft; return cards.map((_, index) => { const extra = EXTRA_Y[index] || 0; const widthPx = widthsPx[index]; const leftPercent = (cursorPx / stageWidth) * 100; const widthPercent = (widthPx / stageWidth) * 100; const centerPx = cursorPx + widthPx / 2; const target = { top: `${lineTop - (BASE_OFFSET + extra)}px`, left: `${leftPercent}%`, width: `${widthPercent}%`, centerPx, }; cursorPx += widthPx + gapPx; return target; }); }; let targets = []; const leftExitCards = []; const rightExitCards = []; const classifyCardsForExit = () => { leftExitCards.length = 0; rightExitCards.length = 0; const stageCenter = stage.offsetWidth / 2; cards.forEach((card, index) => { if (targets[index].centerPx <= stageCenter) { leftExitCards.push(card); } else { rightExitCards.push(card); } }); }; const parallaxTargets = cards.map((card) => ({ element: card, tx: 0, ty: 0 })); const moveRange = { x: 18, y: 10 }; const applyTranslate3D = (target) => { if (!target) return; target.element.style.translate = `${target.tx}px ${target.ty}px 0`; }; const parallax = (evt) => { const rect = stage.getBoundingClientRect(); const relX = ((evt.clientX - rect.left) / rect.width - 0.5) * 2; const relY = ((evt.clientY - rect.top) / rect.height - 0.5) * 2; parallaxTargets.forEach((target, i) => { const strength = 1 - i * 0.15; const nextX = relX * moveRange.x * strength; const nextY = relY * moveRange.y * strength; gsap.to(target, { tx: nextX, ty: nextY, duration: 0.3, overwrite: true, onUpdate: () => applyTranslate3D(target), }); }); }; const resetParallax = (smooth = true) => { parallaxTargets.forEach((target) => { gsap.to(target, { tx: 0, ty: 0, duration: smooth ? 0.5 : 0, overwrite: true, onUpdate: () => applyTranslate3D(target), }); }); }; const handlePointerLeave = () => resetParallax(true); let parallaxEnabled = false; const toggleParallax = (shouldEnable, mobile) => { if (mobile) return; if (shouldEnable === parallaxEnabled) return; parallaxEnabled = shouldEnable; if (shouldEnable) { stage.addEventListener('pointermove', parallax); stage.addEventListener('pointerleave', handlePointerLeave); } else { stage.removeEventListener('pointermove', parallax); stage.removeEventListener('pointerleave', handlePointerLeave); resetParallax(false); } }; const exitDistance = () => stage.offsetWidth * 0.8; let tl; const finalExitProgress = () => { if (!tl || !tl.labels || typeof tl.labels.finalExit === 'undefined') return 1; return tl.labels.finalExit / tl.duration(); }; const updateParallaxState = (self) => { const pastFinalExit = self.progress >= finalExitProgress(); toggleParallax(!pastFinalExit, false); }; const markReady = () => document.body.classList.add('orbit-ready'); const killTimeline = () => { toggleParallax(false, false); if (tl) { tl.scrollTrigger?.kill(); tl.kill(); tl = null; } }; const setupTimeline = (mobile) => { const savedProgress = !mobile ? (tl?.scrollTrigger?.progress ?? null) : null; killTimeline(); resetParallax(false); if (!titleLinesPrimary || !titleLinesSecondary) return; if (!document.querySelector('.chapter_evolution')) return; primeElements(mobile); targets = buildTargets(mobile); classifyCardsForExit(); const scrollEnd = mobile ? MOBILE_SCROLL_END : DESKTOP_SCROLL_END; tl = gsap.timeline({ scrollTrigger: { trigger: '.chapter_evolution', start: 'top top', end: scrollEnd, scrub: mobile ? 0.3 : true, pin: '.pin_wrapper', invalidateOnRefresh: !mobile, refreshPriority: mobile ? -1 : 0, anticipatePin: mobile ? 0 : 1, pinSpacing: true, }, }); tl.to( '.core_video', { scale: mobile ? 0.75 : 1.25, y: '-4vh', marginTop: 0, ease: 'power1.out', force3D: !mobile, }, 0, ); cards.forEach((card, index) => { const widthTarget = () => targets[index].width; const toVars = { top: () => targets[index].top, left: () => targets[index].left, width: widthTarget, height: mobile ? MOBILE_SETTINGS.toHeight : '96px', scale: 1, rotation: 0, ease: 'power1.inOut', force3D: !mobile, }; tl.to(card, toVars, 0.2); }); tl.to('.timeline_line', { opacity: 1, duration: 0.5, force3D: false }, 0.25); tl.to(cursor, { scaleY: 1, duration: 0.35, ease: 'power2.out', force3D: false }, 0.32); tl.to(cursorBlur, { width: () => timelineLine.offsetWidth, duration: 1, ease: 'none', force3D: false }, 0.35); tl.to(cursor, { x: () => timelineLine.offsetLeft + timelineLine.offsetWidth, duration: 1, ease: 'none', force3D: false }, 0.35); tl.to(infoBlocks, { opacity: 1, y: 0, duration: 0.4, ease: 'power2.out', stagger: 0.05, force3D: !mobile }, 0.35); tl.to(splitters, { width: '100%', duration: 0.45, ease: 'power1.out', stagger: 0.05, force3D: false }, 0.4); tl.addLabel('titleSwap', 0.55); if (mobile) { tl.to( titleLinesPrimary, { y: -20, opacity: 0, filter: 'blur(8px)', duration: 0.35, stagger: 0.04, ease: 'power2.in', force3D: false, }, 'titleSwap', ); tl.addLabel('titleGap', 'titleSwap+=0.3'); tl.call(() => (titleSecondary.style.opacity = '1'), null, 'titleGap-=0.01'); tl.fromTo( titleLinesSecondary, { y: 20, opacity: 0, filter: 'blur(8px)' }, { y: 0, opacity: 1, filter: 'blur(0px)', duration: 0.4, stagger: 0.04, ease: 'power2.out', force3D: false, }, 'titleGap', ); } else { tl.to( titleLinesPrimary, { rotateX: -75, yPercent: -45, z: -90, opacity: 0, filter: 'blur(12px)', duration: 0.45, stagger: 0.06, ease: 'power3.in', }, 'titleSwap', ); tl.addLabel('titleGap', 'titleSwap+=0.4'); tl.call(() => (titleSecondary.style.opacity = '1'), null, 'titleGap-=0.01'); tl.fromTo( titleLinesSecondary, { rotateX: 75, yPercent: 45, z: 90, opacity: 0, filter: 'blur(12px)', }, { rotateX: 0, yPercent: 0, z: 0, opacity: 1, filter: 'blur(0px)', duration: 0.55, stagger: 0.06, ease: 'power3.out', }, 'titleGap', ); } tl.addLabel('finalExit'); tl.to( leftExitCards, { x: () => -exitDistance(), y: '+=40', opacity: 0, duration: 0.7, ease: 'power3.in', force3D: !mobile, }, 'finalExit', ); tl.to( rightExitCards, { x: () => exitDistance(), y: '+=40', opacity: 0, duration: 0.7, ease: 'power3.in', force3D: !mobile, }, 'finalExit', ); tl.to( '.core_video', { scale: 0.35, opacity: 0, filter: 'blur(8px)', duration: 0.8, ease: 'power2.inOut', force3D: !mobile, }, 'finalExit+=0.1', ); tl.to([cursor, cursorBlur], { opacity: 0, duration: 0.35, force3D: false }, 'finalExit'); tl.to( titleWrapper, { scale: 0.8, opacity: 0, filter: 'blur(6px)', y: '-2vh', duration: 0.65, ease: 'power2.in', force3D: !mobile, }, 'finalExit+=0.05', ); const masterTrigger = tl.scrollTrigger; if (masterTrigger) { if (!mobile) { masterTrigger.eventCallback('onUpdate', updateParallaxState); masterTrigger.eventCallback('onLeave', () => toggleParallax(false, false)); masterTrigger.eventCallback('onLeaveBack', () => toggleParallax(true, false)); } if (savedProgress !== null) { requestAnimationFrame(() => { ScrollTrigger.refresh(); const st = tl.scrollTrigger; if (!st) return; const y = st.start + (st.end - st.start) * savedProgress; st.scroll(y); tl.progress(savedProgress); }); } } toggleParallax(true, mobile); }; const initTimeline = (mobile) => { const run = () => { const split = initSplitText(); titleLinesPrimary = split.linesPrimary; titleLinesSecondary = split.linesSecondary; setupTimeline(mobile); markReady(); }; if (mobile) { if ('requestIdleCallback' in window) { requestIdleCallback(run, { timeout: 50 }); } else { requestAnimationFrame(run); } } else { run(); } }; let layoutTimer = null; const rebuildOrbit = () => { if (getIsMobile()) return; setupTimeline(false); ScrollTrigger.refresh(); }; const scheduleRebuild = () => { if (getIsMobile()) return; clearTimeout(layoutTimer); layoutTimer = setTimeout(rebuildOrbit, LAYOUT_DEBOUNCE_MS); }; const handleResize = () => { const mobileNow = getIsMobile(); targets = buildTargets(mobileNow); classifyCardsForExit(); gsap.set(cursor, { x: timelineLine.offsetLeft }); gsap.set(cursorBlur, { x: timelineLine.offsetLeft, width: 0 }); resetParallax(false); if (!mobileNow) { scheduleRebuild(); } }; ScrollTrigger.matchMedia({ [MOBILE_QUERY]: () => { initTimeline(true); return () => killTimeline(); }, '(min-width: 641px)': () => { initTimeline(false); return () => killTimeline(); }, }); window.addEventListener('resize', handleResize); window.addEventListener('orientationchange', handleResize); let lastDpr = window.devicePixelRatio; let dprWatchId = null; const checkDisplayChange = () => { if (getIsMobile()) return; if (window.devicePixelRatio !== lastDpr) { lastDpr = window.devicePixelRatio; } scheduleRebuild(); }; window.addEventListener('focus', checkDisplayChange); window.addEventListener('pageshow', checkDisplayChange); try { window .matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`) .addEventListener('change', checkDisplayChange); } catch (e) {} document.addEventListener('visibilitychange', () => { if (getIsMobile()) return; if (document.visibilityState !== 'visible') { clearInterval(dprWatchId); return; } lastDpr = window.devicePixelRatio; checkDisplayChange(); let n = 0; clearInterval(dprWatchId); dprWatchId = setInterval(() => { n++; if (window.devicePixelRatio !== lastDpr) { lastDpr = window.devicePixelRatio; scheduleRebuild(); } if (n >= 15) clearInterval(dprWatchId); }, 100); }); if (typeof ResizeObserver !== 'undefined') { const layoutObserver = new ResizeObserver(() => { if (!getIsMobile()) scheduleRebuild(); }); layoutObserver.observe(stage); layoutObserver.observe(timelineLine); if (pinWrapper) layoutObserver.observe(pinWrapper); } }; const initOrbit = () => { document.body.style.overflow = 'visible'; waitForOrbitReady() .then(bootOrbit) .catch(() => document.body.classList.add('orbit-ready')); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => setTimeout(initOrbit, 0)); } else { setTimeout(initOrbit, 0); }