File: /var/www/viitorx.stgviitor.com/wp-content/themes/viitorx/js/front-page-animations.js
/* ─── SMOOTH SCROLL (Lenis) ────────────────────────────── */
const lenis = new Lenis({
duration: 1.08,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
orientation: 'vertical',
gestureOrientation: 'vertical',
smoothWheel: true,
/* Keeps Lenis.scroll in sync with real scroll on touch — required for GSAP ScrollTrigger pin + scrub */
syncTouch: true,
syncTouchLerp: 0.068,
touchInertiaMultiplier: 42,
wheelMultiplier: 1,
touchMultiplier: 1.03,
infinite: false,
/* intl-tel-input / Smart Phone CF7 — country picker is a nested scroll panel; Lenis must not swallow wheel/touch */
prevent(node) {
return (
!!node &&
typeof node.closest === 'function' &&
Boolean(node.closest('.iti__dropdown-content'))
);
},
});
/** Touch-scroll tuning per viewport size.
* Phone (≤640px): near-native 1:1 multiplier + low inertia so short cards don't fly past.
* Tablet (641–1024px): moderate multiplier with slightly reduced inertia.
* Desktop: high-quality smooth wheel with stronger easing. */
(function viitorxLenisTouchTune() {
var mqPhone = window.matchMedia('(max-width: 640px)');
var mqTablet = window.matchMedia('(min-width: 641px) and (max-width: 1024px)');
function apply() {
var o = lenis.options;
if (!o) return;
if (mqPhone.matches) {
/* Phone: 1:1 with finger, minimal inertia — prevents flying past short sticky cards */
o.syncTouchLerp = 0.12;
o.touchInertiaMultiplier = 12;
o.touchMultiplier = 1.0;
o.duration = 0.72;
o.lerp = 0.14;
} else if (mqTablet.matches) {
/* Tablet: slightly faster than native, moderate inertia */
o.syncTouchLerp = 0.09;
o.touchInertiaMultiplier = 22;
o.touchMultiplier = 1.2;
o.duration = 0.88;
o.lerp = 0.12;
} else {
/* Desktop: full smooth wheel */
o.syncTouchLerp = 0.068;
o.touchInertiaMultiplier = 42;
o.touchMultiplier = 1.03;
o.duration = 1.08;
o.lerp = 0.1;
}
if (typeof lenis.resize === 'function') lenis.resize();
}
apply();
[mqPhone, mqTablet].forEach(function(mq) {
if (mq.addEventListener) mq.addEventListener('change', apply);
else if (mq.addListener) mq.addListener(apply);
});
})();
/* ScrollTrigger.scrollerProxy removed: Lenis 1.1.x + ST proxy caused indexOf/undefined errors and frozen scroll.
Official Lenis+GSAP setup: ticker raf + ScrollTrigger.update only (see lenis README). */
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => {
lenis.raf(time * 1000);
});
gsap.ticker.lagSmoothing(0);
gsap.registerPlugin(ScrollTrigger);
ScrollTrigger.config({
ignoreMobileResize: true
});
/**
* Bridge ScrollTrigger ↔ Lenis: ST must read/write the same scroll value Lenis animates.
* Without scrollerProxy, pin/unpin corrects native scroll while Lenis still holds
* animatedScroll → visible jump. (See Lenis README "GSAP ScrollTrigger".)
*/
(function setupLenisScrollTriggerBridge() {
var scrollerEl = document.scrollingElement || document.documentElement;
ScrollTrigger.defaults({
scroller: scrollerEl
});
ScrollTrigger.scrollerProxy(scrollerEl, {
scrollTop: function(value) {
if (arguments.length) {
lenis.scrollTo(value, {
immediate: true,
force: true
});
}
var ls = lenis && lenis.scroll;
if (typeof ls === 'number' && !isNaN(ls)) {
return ls;
}
return (document.scrollingElement || document.documentElement).scrollTop || 0;
},
scrollLeft: function() {
return window.scrollX || scrollerEl.scrollLeft || 0;
},
getBoundingClientRect: function() {
return {
top: 0,
left: 0,
width: window.innerWidth,
height: window.innerHeight
};
}
});
})();
/* Pin spacing changes document flow; Lenis must re-measure after ST refresh. */
ScrollTrigger.addEventListener('refresh', function() {
lenis.resize();
});
requestAnimationFrame(function() {
ScrollTrigger.refresh();
});
/**
* Native hash scrolling breaks with Lenis + ScrollTrigger refresh (stays at top).
* Also: / → /#partners often fires hashchange only (no load). Cross-page loads need load handler.
*/
function viitorxScrollToHash() {
var hash = window.location.hash;
if (!hash || hash === '#') {
return;
}
var raw = hash.slice(1);
var id = raw;
try {
id = decodeURIComponent(raw.replace(/\+/g, ' '));
} catch (e) {
/* keep raw */
}
var target = document.getElementById(id);
if (!target) {
return;
}
var pad = parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop);
var off = Number.isFinite(pad) && pad > 0 ? -pad : -88;
if (typeof lenis !== 'undefined' && typeof lenis.scrollTo === 'function') {
lenis.scrollTo(target, {
offset: off,
lerp: 0.14,
force: true
});
} else {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}
function viitorxRefreshLayoutThenHash() {
ScrollTrigger.refresh();
if (typeof lenis !== 'undefined' && typeof lenis.resize === 'function') {
lenis.resize();
}
requestAnimationFrame(function() {
ScrollTrigger.refresh();
if (typeof lenis !== 'undefined' && typeof lenis.resize === 'function') {
lenis.resize();
}
viitorxScrollToHash();
window.setTimeout(viitorxScrollToHash, 160);
});
}
window.addEventListener('load', viitorxRefreshLayoutThenHash, {
once: true
});
window.addEventListener('hashchange', function() {
viitorxRefreshLayoutThenHash();
});
/* ─── CUSTOM CURSOR (GSAP Polish) ───────────────────────── */
const cursor = document.getElementById('cursor');
const ring = document.getElementById('cursor-ring');
let mouseX = 0,
mouseY = 0;
let ringX = 0,
ringY = 0;
// Use quickSetter for high performance
const xCursorSetter = gsap.quickSetter(cursor, "x", "px");
const yCursorSetter = gsap.quickSetter(cursor, "y", "px");
const xRingSetter = gsap.quickSetter(ring, "x", "px");
const yRingSetter = gsap.quickSetter(ring, "y", "px");
window.addEventListener('mousemove', (e) => {
mouseX = e.clientX;
mouseY = e.clientY;
xCursorSetter(mouseX);
yCursorSetter(mouseY);
});
// Smooth ring movement
gsap.ticker.add(() => {
const dt = 1.0 - Math.pow(1.0 - 0.2, gsap.ticker.deltaRatio());
ringX += (mouseX - ringX) * dt;
ringY += (mouseY - ringY) * dt;
xRingSetter(ringX);
yRingSetter(ringY);
});
// Hover interactions
document.querySelectorAll('a, button, .partner-card, .work-slide, .offerings-stage').forEach(el => {
el.addEventListener('mouseenter', () => {
gsap.to(ring, {
scale: 1.5,
background: 'rgba(200, 153, 106, 0.1)',
borderColor: 'transparent',
duration: 0.3
});
gsap.to(cursor, {
scale: 0.5,
duration: 0.3
});
});
el.addEventListener('mouseleave', () => {
gsap.to(ring, {
scale: 1,
background: 'transparent',
borderColor: 'var(--copper)',
duration: 0.3
});
gsap.to(cursor, {
scale: 1,
duration: 0.3
});
});
});
/* ─── REVIEWS: pin + scrub (all breakpoints) · Lenis resize on refresh ───── */
(function() {
const reviewsSection = document.getElementById('reviews');
const stickyEl = document.getElementById('reviews-sticky');
const track = document.getElementById('reviews-cards-track');
const scattered = track ? track.querySelector('.reviews-scattered') : null;
const cards = track ? track.querySelectorAll('.review-card') : [];
if (!reviewsSection || !stickyEl || !track || !scattered || !cards.length) return;
gsap.registerPlugin(ScrollTrigger);
const PAD = 72;
let travelPx = 520;
function measureTravel() {
var maxBottom = 0;
cards.forEach(function(card) {
var b = card.offsetTop + card.offsetHeight;
if (b > maxBottom) maxBottom = b;
});
scattered.style.height = (maxBottom + PAD) + 'px';
var vh = window.innerHeight;
var raw = maxBottom + PAD - Math.round(vh * 0.9);
var mobileCap = window.innerWidth <= 991 ? Math.round(vh * 0.65) : maxBottom + PAD - 120;
travelPx = Math.max(280, Math.min(Math.round(raw), mobileCap));
return travelPx;
}
const mm = gsap.matchMedia();
function attachReviews() {
measureTravel();
function reviewsPinStartPx() {
var nav = document.getElementById('sticky-nav');
var h = nav && nav.getBoundingClientRect ? Math.ceil(nav.getBoundingClientRect().height) : 0;
if (!Number.isFinite(h) || h < 44) {
h = 88;
}
return Math.min(Math.max(h, 64), 120);
}
var stCfg = {
/* Trigger = pinned viewport — avoids padding offset vs #reviews firing "early" vs Lenis (pin jitter). */
trigger: stickyEl,
start: function() {
return 'top top+=' + reviewsPinStartPx();
},
end: function() {
return '+=' + travelPx;
},
/* Number scrub (not true): smoother linkage with Lenis animated scroll + pin */
scrub: 1,
pin: true,
pinSpacing: true,
anticipatePin: false,
invalidateOnRefresh: true,
fastScrollEnd: false,
onRefresh: function() {
measureTravel();
if (typeof lenis !== 'undefined' && lenis.resize) lenis.resize();
}
};
const tl = gsap.timeline({
scrollTrigger: stCfg
});
tl.to(
track, {
y: function() {
return -travelPx;
},
ease: 'none',
invalidateOnRefresh: true
},
0
);
return function() {
tl.kill();
gsap.set(track, {
clearProps: 'transform'
});
scattered.style.height = '';
};
}
/* transform pin on all breakpoints — position:fixed pin + Lenis often fights scrollerProxy */
mm.add('(min-width: 992px)', function() {
return attachReviews();
});
mm.add('(max-width: 991px)', function() {
return attachReviews();
});
function refreshReviewsST() {
measureTravel();
if (typeof ScrollTrigger !== 'undefined') {
ScrollTrigger.refresh();
}
}
var reviewsResizeTimer;
function onReviewsResize() {
clearTimeout(reviewsResizeTimer);
reviewsResizeTimer = setTimeout(refreshReviewsST, 120);
}
queueMicrotask(refreshReviewsST);
requestAnimationFrame(refreshReviewsST);
gsap.delayedCall(0.18, refreshReviewsST);
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(refreshReviewsST);
}
window.addEventListener('resize', onReviewsResize, {
passive: true
});
window.addEventListener(
'orientationchange',
function() {
gsap.delayedCall(0.12, refreshReviewsST);
}, {
passive: true
}
);
})();
/* ─── HOW THE MAGIC HAPPENS: scrubbed path draw → connectors → alternating step copy ───── */
(function() {
var section = document.getElementById('magic-how');
var pathEl = document.getElementById('magicHowPathStroke');
if (!section || !pathEl || typeof gsap === 'undefined') return;
gsap.registerPlugin(ScrollTrigger);
function magicHowPinStartPx() {
var nav = document.getElementById('sticky-nav');
var h = nav && nav.getBoundingClientRect ? Math.ceil(nav.getBoundingClientRect().height) : 0;
if (!Number.isFinite(h) || h < 44) h = 88;
return Math.min(Math.max(h, 64), 120);
}
function prefersReducedMotion() {
return (
typeof window.matchMedia !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
function lenisResizeSoon() {
if (typeof lenis !== 'undefined' && lenis.resize) lenis.resize();
}
/** Enough scroll runway when not pinning (mobile stack can be taller than the viewport). */
function magicHowUnpinnedScrollPx() {
var vh = window.innerHeight || 800;
return Math.round(Math.max(vh, 560) * 1.6);
}
/** Scroll distance while #magic-how is pinned — must fit full timeline (path + connectors + labels + foot). */
/** Shorter runway = tighter section (less GSAP pin-spacer gap below pinned block); scrub still spans full TL. */
function magicHowPinnedScrollPx() {
var vh = window.innerHeight || 800;
return Math.round(Math.max(vh, 640) * 2.22);
}
/** Force visible final state — DOM only so scrub timelines stay in sync (no tl.progress fight). */
function magicHowForceComplete() {
if (!section) return;
section._magicHowCueDismissed = true;
var pl =
typeof pathEl.getTotalLength === 'function' ? pathEl.getTotalLength() : 1200;
gsap.set(pathEl, {
strokeDasharray: pl,
strokeDashoffset: 0,
opacity: 1,
visibility: 'visible',
});
gsap.set(section.querySelectorAll('.magic-how-cc'), {
autoAlpha: 1
});
section.querySelectorAll('.magic-how-overlay .magic-how-step-label').forEach(function(el) {
var isTop = el.getAttribute('data-enter') === 'top';
gsap.set(el, {
autoAlpha: 1,
xPercent: -50,
yPercent: isTop ? -100 : 0,
transformOrigin: isTop ? '50% 100%' : '50% 0%',
y: 0,
});
});
var scrollCueFc = document.getElementById('magicHowScrollCue');
if (scrollCueFc) {
gsap.set(scrollCueFc, {
autoAlpha: 0
});
}
var footnote = section.querySelector('.magic-how-footnote');
if (footnote) gsap.set(footnote, {
autoAlpha: 1
});
gsap.set(section.querySelectorAll('.magic-how-desktop-node-marker'), {
autoAlpha: 1,
clearProps: 'transform',
});
gsap.set(section.querySelectorAll('.magic-how-mobile-pin'), {
autoAlpha: 1,
});
gsap.set(section.querySelectorAll('.magic-how-mobile-step p'), {
autoAlpha: 1,
clearProps: 'transform',
});
var rf = section.querySelector('#magicHowMobileRailFill');
if (rf) {
rf.style.height =
rf.parentElement && rf.parentElement.offsetHeight ?
Math.max(rf.parentElement.offsetHeight - 12, 64) + 'px' :
rf.style.height || '100%';
gsap.set(rf, {
scaleY: 1,
transformOrigin: 'top center',
autoAlpha: 1
});
}
requestAnimationFrame(lenisResizeSoon);
}
function initDesktopMagic() {
var pinRoot = document.getElementById('magic-how-pin-root') || section;
var connectors = gsap.utils.toArray(section.querySelectorAll('.magic-how-cc'));
var labels = gsap.utils.toArray(section.querySelectorAll('.magic-how-overlay .magic-how-step-label'));
var nodeMarkers = gsap.utils.toArray(section.querySelectorAll('.magic-how-desktop-node-marker'));
var foot = section.querySelector('.magic-how-footnote');
var pl = typeof pathEl.getTotalLength === 'function' ? pathEl.getTotalLength() : 1200;
var stMagicHowCueEarly = null;
section._magicHowCueDismissed = false;
gsap.set(pathEl, {
strokeDasharray: pl,
strokeDashoffset: pl,
visibility: 'visible',
opacity: 1,
});
gsap.set(connectors, {
autoAlpha: 0
});
labels.forEach(function(el) {
var isTop = el.getAttribute('data-enter') === 'top';
var iy = isTop ? -42 : 44;
gsap.set(el, {
autoAlpha: 0,
xPercent: -50,
yPercent: isTop ? -100 : 0,
transformOrigin: isTop ? '50% 100%' : '50% 0%',
y: iy,
});
});
gsap.set(nodeMarkers, {
autoAlpha: 0
});
var scrollCue = document.getElementById('magicHowScrollCue');
if (scrollCue) {
gsap.set(scrollCue, {
autoAlpha: 0
});
}
if (foot) gsap.set(foot, {
autoAlpha: 0
});
function syncMagicHowScrollCueVisibility() {
if (!scrollCue) {
return;
}
if (section._magicHowCueDismissed) {
gsap.set(scrollCue, {
autoAlpha: 0
});
return;
}
var r = section.getBoundingClientRect();
var vh = window.innerHeight || document.documentElement.clientHeight || 800;
var inView = r.top < vh - 4 && r.bottom > 48;
var atl = section._magicHowTl;
var prog = atl && typeof atl.progress === 'function' ? atl.progress() : 0;
/* Hide before footnote / end of scrub so fixed cue doesn't sit on the paragraph */
var show = inView && prog < 0.76;
gsap.to(scrollCue, {
autoAlpha: show ? 1 : 0,
duration: 0.14,
ease: 'power2.out',
overwrite: 'auto',
});
}
var tl = gsap.timeline({
scrollTrigger: {
trigger: pinRoot,
start: function() {
return 'top top+=' + magicHowPinStartPx();
},
end: function() {
return '+=' + magicHowPinnedScrollPx();
},
pin: true,
pinSpacing: true,
anticipatePin: 1,
scrub: 1.35,
fastScrollEnd: false,
invalidateOnRefresh: true,
onRefresh: lenisResizeSoon,
onUpdate: syncMagicHowScrollCueVisibility,
onLeaveBack: function() {
syncMagicHowScrollCueVisibility();
},
onEnterBack: function() {
section._magicHowCueDismissed = false;
syncMagicHowScrollCueVisibility();
},
onLeave: magicHowForceComplete,
},
});
section._magicHowTl = tl;
/* Path length at each node (matches circle centres in SVG) — stroke reveals step-by-step. */
function pathLengthNearestTo(px, py, totalLen) {
var bestT = 0;
var bestD = Infinity;
var n = typeof totalLen === 'number' ? totalLen : pl;
if (typeof pathEl.getPointAtLength !== 'function' || !(n > 8)) return n / 8;
var samples = 320;
for (var s = 0; s <= samples; s++) {
var t = (s / samples) * n;
var p = pathEl.getPointAtLength(t);
var d = (p.x - px) * (p.x - px) + (p.y - py) * (p.y - py);
if (d < bestD) {
bestD = d;
bestT = t;
}
}
return bestT;
}
var nodeCentres = [
[108.9, 247.4],
[343.3, 252],
[574.3, 212.1],
[801.3, 155.9],
[1033.4, 135.4],
[1268.1, 120],
[1484.5, 22.8],
];
var lenAtDot = [];
for (var ni = 0; ni < nodeCentres.length; ni++) {
lenAtDot.push(pathLengthNearestTo(nodeCentres[ni][0], nodeCentres[ni][1], pl));
}
/* Keep sample order monotone along curve (fixes rare jitter if path bends back). */
for (var fix = 1; fix < lenAtDot.length; fix++) {
if (lenAtDot[fix] < lenAtDot[fix - 1]) {
lenAtDot[fix] = lenAtDot[fix - 1] + 16;
}
}
/* Step 0 is visible from section entry — pre-reveal path to first node. */
gsap.set(pathEl, { strokeDashoffset: pl - lenAtDot[0] });
if (nodeMarkers[0]) gsap.set(nodeMarkers[0], { autoAlpha: 1 });
if (connectors[0]) gsap.set(connectors[0], { autoAlpha: 1 });
if (labels[0]) gsap.set(labels[0], { autoAlpha: 1, y: 0 });
var pathRevealTotalDur = 3.45;
var labelFadeDur = 0.48;
var prevAlong = lenAtDot[0];
/*
* Last path segment must include the stroke "tail" in the same tween as dot 7.
* If tail is a separate later tween, scrubbing back runs the tail first and the last
* marker stays visible while the path retracts — looks like a dot "left behind".
*/
for (let step = 1; step < 7; step++) {
var along = step === 6 ? pl : lenAtDot[step];
var chunk = along - prevAlong;
prevAlong = along;
var frac = pl > 8 && isFinite(chunk) && chunk > 0 ? chunk / pl : 1 / 7;
tl.to(pathEl, {
strokeDashoffset: pl - along,
duration: pathRevealTotalDur * frac,
ease: 'none'
});
tl.to(
nodeMarkers[step], {
autoAlpha: 1,
duration: 0.2,
ease: 'power2.out'
},
'>0'
);
tl.to(connectors[step], {
autoAlpha: 1,
duration: 0.1
}, '-=0.04');
tl.to(
labels[step], {
autoAlpha: 1,
y: 0,
duration: labelFadeDur,
ease: 'power2.out',
},
'<0.06'
);
}
if (foot) {
tl.to(foot, {
autoAlpha: 1,
duration: 0.52,
ease: 'power2.out'
}, '>-0.08');
}
stMagicHowCueEarly = ScrollTrigger.create({
trigger: section,
start: 'top bottom',
end: 'bottom top',
onEnter: syncMagicHowScrollCueVisibility,
onLeave: function() {
if (!scrollCue) {
return;
}
gsap.set(scrollCue, {
autoAlpha: 0
});
},
onLeaveBack: function() {
if (!scrollCue) {
return;
}
gsap.set(scrollCue, {
autoAlpha: 0
});
},
});
requestAnimationFrame(function() {
requestAnimationFrame(function() {
if (typeof ScrollTrigger !== 'undefined') {
ScrollTrigger.refresh();
}
syncMagicHowScrollCueVisibility();
});
});
return function cleanup() {
if (stMagicHowCueEarly) {
stMagicHowCueEarly.kill();
stMagicHowCueEarly = null;
}
if (section._magicHowTl === tl) {
section._magicHowTl = null;
}
if (tl.scrollTrigger) tl.scrollTrigger.kill();
tl.kill();
gsap.set(pathEl, {
clearProps: 'strokeDashoffset,strokeDasharray'
});
};
}
function initMobileMagic() {
var railFill = section.querySelector('#magicHowMobileRailFill');
var stack = section.querySelector('.magic-how-mobile-rail');
var pins = gsap.utils.toArray(section.querySelectorAll('.magic-how-mobile-pin'));
var paragraphs = gsap.utils.toArray(section.querySelectorAll('.magic-how-mobile-step p'));
var foot = section.querySelector('.magic-how-footnote');
function measureRail() {
if (!railFill || !stack) return;
var tall = stack.offsetHeight;
gsap.set(railFill, {
height: Math.max(tall - 8, 64)
});
}
measureRail();
/* Pins + copy stay visible — Lenis ↔ ScrollTrigger scrub often desyncs on phones and left
autoAlpha at 0 (visibility:hidden), so scrub-only reveals looked "broken". Rail fill only scrubs. */
gsap.set(railFill, {
scaleY: 0,
transformOrigin: 'top center',
autoAlpha: 1,
});
/* Force visible — prior desktop init or aborted tweens sometimes leave visibility:hidden on touch. */
if (pins.length) gsap.set(pins, {
autoAlpha: 1
});
if (paragraphs.length) gsap.set(paragraphs, {
autoAlpha: 1,
clearProps: 'transform'
});
if (foot) gsap.set(foot, {
autoAlpha: 1
});
var tl = gsap.timeline({
scrollTrigger: {
trigger: section,
start: function() {
return 'top top+=' + magicHowPinStartPx();
},
end: function() {
return '+=' + magicHowUnpinnedScrollPx();
},
scrub: 1,
fastScrollEnd: false,
invalidateOnRefresh: true,
onRefresh: function() {
measureRail();
lenisResizeSoon();
},
onLeave: magicHowForceComplete,
},
});
section._magicHowTl = tl;
tl.to(railFill, {
scaleY: 1,
duration: 1,
ease: 'none',
});
return function cleanup() {
if (section._magicHowTl === tl) {
section._magicHowTl = null;
}
if (tl.scrollTrigger) tl.scrollTrigger.kill();
tl.kill();
gsap.set(railFill, {
clearProps: 'transform,scale,height'
});
};
}
if (prefersReducedMotion()) {
gsap.set(pathEl, {
clearProps: 'strokeDashoffset,strokeDasharray'
});
gsap.set(section.querySelectorAll('.magic-how-cc,.magic-how-footnote'), {
autoAlpha: 1,
});
section.querySelectorAll('.magic-how-overlay .magic-how-step-label').forEach(function(el) {
var isTop = el.getAttribute('data-enter') === 'top';
gsap.set(el, {
autoAlpha: 1,
xPercent: -50,
yPercent: isTop ? -100 : 0,
transformOrigin: isTop ? '50% 100%' : '50% 0%',
y: 0,
});
});
gsap.set(section.querySelectorAll('.magic-how-desktop-node-marker'), {
autoAlpha: 1
});
gsap.set(section.querySelectorAll('.magic-how-mobile-pin,.magic-how-mobile-step p'), {
autoAlpha: 1,
});
var rf = section.querySelector('#magicHowMobileRailFill');
if (rf) gsap.set(rf, {
scaleY: 1
});
return;
}
var mm = gsap.matchMedia();
var dk = null;
var mk = null;
mm.add('(min-width: 1024px)', function() {
dk = initDesktopMagic();
return dk;
});
mm.add('(max-width: 1023px)', function() {
mk = initMobileMagic();
return mk;
});
window.addEventListener(
'resize',
function() {
gsap.delayedCall(0.05, lenisResizeSoon);
}, {
passive: true
}
);
})();
/* ─── OFFERINGS: pin step 1, then each card rises from bottom — text docks under prior row; images stack on top ─ */
(function() {
const wrap = document.getElementById('offerings-stack-wrap');
const stage = document.getElementById('offerings-stage');
const copyCol = document.getElementById('offering-progressive-copy');
const blocks = gsap.utils.toArray('.offering-progressive-block');
const images = gsap.utils.toArray('.offering-visual-layer');
if (!wrap || !stage || !copyCol || blocks.length < 2 || images.length !== blocks.length) return;
gsap.registerPlugin(ScrollTrigger);
gsap.matchMedia().add('(min-width: 1200px)', function() {
const n = blocks.length;
const seg = n - 1;
function getOfferingsDesktopNavPx() {
var navEl = document.getElementById('sticky-nav');
var h = navEl && navEl.getBoundingClientRect ? Math.ceil(navEl.getBoundingClientRect().height) : 88;
if (!Number.isFinite(h) || h < 44) {
h = 88;
}
return Math.min(Math.max(h, 64), 140);
}
/*
* Rows slide from below the slot: yPercent + autoAlpha so nothing shows before that step.
* LEAD_SLOTS: extra timeline time before row 2 starts — maps to scroll after pin so #2 is not partial yet.
*/
const STEP = 1;
const SYNC = 0.64;
const RISE_EASE = 'power2.out';
const RISE_FROM_PERCENT = 132;
const LEAD_SLOTS = 0.22;
const progressive = document.querySelector('.offering-progressive');
const OFFERING_SCROLL_FACTOR = 1.08;
const OFFERING_SCRUB_SMOOTHING = 0.66;
const LEAD_SCROLL_BONUS_PX = function() {
return Math.round(window.innerHeight * 0.09);
};
const tailPadRaw = STEP * seg - (LEAD_SLOTS + (seg - 1) + SYNC);
const blockSeparatorBorder = 'rgba(255, 255, 255, 0.12)';
function readOfferingSpacing() {
const el = progressive || wrap;
const cs = getComputedStyle(el);
const pad = parseFloat(cs.getPropertyValue('--offering-pad-before-rule')) || 32;
const gap = parseFloat(cs.getPropertyValue('--offering-gap-after-block')) || 40;
return {
pad,
gap
};
}
function stripInnerForMeasure(inner) {
if (!inner) return;
gsap.set(inner, {
clearProps: 'transform'
});
inner.style.position = '';
inner.style.left = '';
inner.style.right = '';
inner.style.top = '';
inner.style.bottom = '';
inner.style.width = '';
inner.style.height = '';
inner.style.opacity = '';
}
function measureBlockHeights() {
const sp = readOfferingSpacing();
blocks.slice(1).forEach((block, idx) => {
const inner = block.querySelector('.offering-progressive-block-inner');
const bi = idx + 1;
const isLast = bi === n - 1;
stripInnerForMeasure(inner);
const was = {
height: block.style.height,
minHeight: block.style.minHeight,
overflow: block.style.overflow,
visibility: block.style.visibility,
position: block.style.position,
width: block.style.width,
paddingBottom: block.style.paddingBottom,
marginBottom: block.style.marginBottom,
borderBottomWidth: block.style.borderBottomWidth,
borderBottomStyle: block.style.borderBottomStyle,
borderBottomColor: block.style.borderBottomColor,
};
if (inner) {
gsap.set(inner, {
yPercent: 0,
opacity: 1
});
}
block.style.height = 'auto';
block.style.minHeight = '0';
block.style.overflow = 'visible';
block.style.visibility = 'hidden';
block.style.position = 'absolute';
block.style.left = '0';
block.style.width = copyCol.offsetWidth + 'px';
block.style.paddingBottom = `${sp.pad}px`;
block.style.marginBottom = isLast ? '0px' : `${sp.gap}px`;
if (!isLast) {
block.style.borderBottomWidth = '1px';
block.style.borderBottomStyle = 'solid';
block.style.borderBottomColor = blockSeparatorBorder;
} else {
block.style.borderBottomWidth = '0';
block.style.borderBottomStyle = 'solid';
block.style.borderBottomColor = blockSeparatorBorder;
}
const raw = Math.max(block.offsetHeight, block.scrollHeight);
let h = Math.ceil(raw);
h += Math.max(4, Math.min(14, Math.round(h * 0.02)));
block.dataset.targetHeight = String(h);
block.style.height = was.height;
block.style.minHeight = was.minHeight;
block.style.overflow = was.overflow;
block.style.visibility = was.visibility;
block.style.position = was.position;
block.style.left = '';
block.style.width = was.width;
block.style.paddingBottom = was.paddingBottom;
block.style.marginBottom = was.marginBottom;
block.style.borderBottomWidth = was.borderBottomWidth;
block.style.borderBottomStyle = was.borderBottomStyle;
block.style.borderBottomColor = was.borderBottomColor;
});
}
copyCol.classList.remove('offerings-copy--ready');
copyCol.style.visibility = 'hidden';
measureBlockHeights();
let spacing = readOfferingSpacing();
gsap.set(blocks[0], {
paddingBottom: spacing.pad,
marginBottom: spacing.gap,
borderBottomWidth: 1,
borderBottomStyle: 'solid',
borderBottomColor: blockSeparatorBorder,
});
/**
* Stack rows 2…n absolutely at their final resting tops (below card 1 + gap).
* Full row height from the start + yPercent — same swipe mechanic as visual layers inside the frame.
*/
function layoutOfferingTextStacks() {
spacing = readOfferingSpacing();
const cr = copyCol.getBoundingClientRect();
const mb0 =
parseFloat(window.getComputedStyle(blocks[0]).marginBottom) || 0;
var stackTop =
blocks[0].getBoundingClientRect().bottom -
copyCol.getBoundingClientRect().top +
mb0;
blocks.slice(1).forEach(function(block, idx) {
const innerEl = block.querySelector('.offering-progressive-block-inner');
stripInnerForMeasure(innerEl);
gsap.set(block, {
clearProps: 'transform,height'
});
block.style.position = 'absolute';
block.style.left = '0';
block.style.width = Math.round(cr.width) + 'px';
block.style.margin = '0';
block.style.overflow = 'hidden';
var H = parseFloat(block.dataset.targetHeight) || 120;
var ih = idx + 1;
gsap.set(block, {
top: stackTop,
height: H,
yPercent: RISE_FROM_PERCENT,
autoAlpha: 0,
zIndex: 10 + ih,
force3D: true,
});
stackTop += H;
if (ih < n - 1) {
stackTop += spacing.gap;
}
});
copyCol.style.minHeight = Math.ceil(stackTop) + 'px';
}
layoutOfferingTextStacks();
copyCol.style.visibility = '';
copyCol.classList.add('offerings-copy--ready');
/* Images: full opacity always — motion is yPercent only so overlaps never look semi-transparent (no autoAlpha). */
images.forEach(function(img, i) {
img.style.zIndex = String(10 + i);
gsap.set(img, {
force3D: true
});
gsap.set(img, {
opacity: 1,
yPercent: i === 0 ? 0 : RISE_FROM_PERCENT,
});
});
wrap.style.minHeight = `${Math.round(n * 100 * OFFERING_SCROLL_FACTOR + 9)}vh`;
const tl = gsap.timeline({
scrollTrigger: {
trigger: wrap,
start: function() {
return 'top top+=' + getOfferingsDesktopNavPx();
},
end: function() {
return (
'+=' +
(seg * window.innerHeight * OFFERING_SCROLL_FACTOR + LEAD_SCROLL_BONUS_PX())
);
},
scrub: OFFERING_SCRUB_SMOOTHING,
pin: stage,
pinSpacing: true,
pinType: 'transform',
anticipatePin: 0,
invalidateOnRefresh: true,
id: 'offerings-progressive',
onRefresh: function() {
spacing = readOfferingSpacing();
measureBlockHeights();
gsap.set(blocks[0], {
paddingBottom: spacing.pad,
marginBottom: spacing.gap,
borderBottomWidth: 1,
borderBottomStyle: 'solid',
borderBottomColor: blockSeparatorBorder,
});
layoutOfferingTextStacks();
copyCol.classList.add('offerings-copy--ready');
},
onToggle: function() {
requestAnimationFrame(function() {
lenis.resize();
});
},
},
});
for (let i = 1; i < n; i++) {
const slot = LEAD_SLOTS + (i - 1);
const block = blocks[i];
const nextImg = images[i];
tl.fromTo(
block, {
autoAlpha: 0,
yPercent: RISE_FROM_PERCENT
}, {
autoAlpha: 1,
yPercent: 0,
duration: SYNC,
ease: RISE_EASE,
},
slot
);
tl.fromTo(
nextImg, {
opacity: 1,
yPercent: RISE_FROM_PERCENT
}, {
opacity: 1,
yPercent: 0,
duration: SYNC,
ease: RISE_EASE,
},
slot
);
}
const tailPad = Math.max(0, tailPadRaw);
if (tailPad > 0.008) {
const padObj = {
_noop: 0
};
tl.to(
padObj, {
_noop: 1,
duration: tailPad,
ease: 'none'
},
LEAD_SLOTS + (seg - 1) + SYNC
);
}
requestAnimationFrame(function() {
ScrollTrigger.refresh(true);
});
let resizeOfferings;
const onOfferingsResize = function() {
clearTimeout(resizeOfferings);
resizeOfferings = setTimeout(function() {
measureBlockHeights();
layoutOfferingTextStacks();
ScrollTrigger.refresh();
}, 180);
};
window.addEventListener('resize', onOfferingsResize);
return function offeringsDesktopCleanup() {
clearTimeout(resizeOfferings);
window.removeEventListener('resize', onOfferingsResize);
tl.kill();
};
});
})();
/* ─── OFFERINGS (mobile/tablet): CSS sticky stacking - JavaScript pinning disabled ─ */
(function() {
// Disabled: Now using pure CSS sticky positioning instead of JavaScript pinning
return;
if (typeof ScrollTrigger === 'undefined') return;
gsap.registerPlugin(ScrollTrigger);
const mmOfferingsMobile = gsap.matchMedia();
mmOfferingsMobile.add('(max-width: 1199px)', function() {
const slides = gsap.utils.toArray('.offering-mobile-slide');
if (!slides.length) return;
/**
* Pins must align below fixed #sticky-nav + sticky "Offerings" bar.
* start: 'top top' pins to y=0 → slides sit under the heading; use nav + title height instead.
*/
function getOfferingsMobilePinStartPx() {
var navPx = 88;
var navEl = document.getElementById('sticky-nav');
if (navEl) {
var bh = Math.ceil(navEl.getBoundingClientRect().height);
if (Number.isFinite(bh) && bh > 40) {
navPx = Math.max(bh, 64);
}
} else {
var raw = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue('--nav-bar-outer-height')
);
if (Number.isFinite(raw) && raw > 40) {
navPx = raw;
}
}
const titleEl = document.querySelector('.offerings-mobile-sticky-title');
var th = titleEl ? titleEl.offsetHeight : 52;
if (!Number.isFinite(th) || th < 24) {
th = 52;
}
return Math.round(navPx + th);
}
const nSlides = slides.length;
const triggers = slides.map(function(slide, i) {
return ScrollTrigger.create({
trigger: slide,
start: function() {
return 'top ' + getOfferingsMobilePinStartPx() + 'px';
},
end: function() {
var h = Math.round(window.innerHeight);
/* Last card: shorter pin travel so Work doesn't sit one full viewport away */
if (i === nSlides - 1) {
return '+=' + Math.max(140, Math.round(h * 0.22));
}
return '+=' + h;
},
pin: true,
pinSpacing: false,
id: 'offering-mobile-pin-' + i,
invalidateOnRefresh: true,
anticipatePin: 0,
});
});
requestAnimationFrame(function() {
ScrollTrigger.refresh();
});
return function() {
triggers.forEach(function(t) {
t.kill();
});
};
});
})();
/* ── STICKY NAV + MOBILE DRAWER (Lenis scroll + overlay) ─ */
(function() {
const stickyNav = document.getElementById('sticky-nav');
const navDrawer = document.getElementById('nav-drawer');
const navToggle = document.getElementById('nav-menu-toggle');
const navScrim = document.getElementById('nav-drawer-scrim');
const mqWide = window.matchMedia('(min-width: 901px)');
/** Show bar as soon as the user scrolls (not after hero fraction). Small floor avoids noisy sub-pixel / bounce. */
const STICKY_TRIGGER_PX = 4;
if (!stickyNav) return;
/**
* Measured sticky bar height (works when nav is translated off-screen).
* --anchor-under-nav-offset: bar + cushion for scroll-margin / sticky pins.
* --nav-under-sticky-offset: exact bar bottom (drawer panel top).
*/
function syncStickyNavDependentLayout() {
requestAnimationFrame(() => {
const raw = stickyNav ? stickyNav.getBoundingClientRect().height : 0;
const bar = raw > 0 ? Math.ceil(raw) : 88;
const navPx = Math.max(bar, 64);
/* Flush under bar: sticky "Offerings" + ScrollTrigger pin line */
document.documentElement.style.setProperty('--nav-bar-outer-height', `${navPx}px`);
/* Hash / scroll-padding — small cushion only (not used for sticky title top) */
document.documentElement.style.setProperty('--anchor-under-nav-offset', `${navPx + 8}px`);
if (navDrawer) {
navDrawer.style.setProperty('--nav-under-sticky-offset', `${navPx}px`);
}
if (typeof ScrollTrigger !== 'undefined') {
requestAnimationFrame(function() {
ScrollTrigger.refresh();
});
}
});
}
const updateStickyNavVisibility = () => {
const scrollY = window.scrollY || window.pageYOffset || 0;
const scrolled = scrollY > STICKY_TRIGGER_PX;
const menuOpen = stickyNav.classList.contains('menu-open');
if (scrolled || menuOpen) {
stickyNav.classList.add('visible');
} else {
stickyNav.classList.remove('visible');
}
};
function setNavDrawerOpen(open) {
if (!navDrawer || !navToggle) return;
document.body.classList.toggle('nav-drawer-visible', open);
document.documentElement.classList.toggle('nav-drawer-open', open);
stickyNav.classList.toggle('menu-open', open);
navToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
navToggle.setAttribute('aria-label', open ? 'Close menu' : 'Open menu');
navDrawer.setAttribute('aria-hidden', open ? 'false' : 'true');
if (open) {
navDrawer.removeAttribute('inert');
} else {
navDrawer.setAttribute('inert', '');
}
if (typeof lenis.stop === 'function' && typeof lenis.start === 'function') {
if (open) lenis.stop();
else lenis.start();
}
updateStickyNavVisibility();
if (open) {
syncStickyNavDependentLayout();
}
if (open) {
const first = navDrawer.querySelector('.nav-drawer-list a');
if (first) first.focus({
preventScroll: true
});
} else {
navToggle.focus({
preventScroll: true
});
}
}
function closeNavDrawer() {
if (document.body.classList.contains('nav-drawer-visible')) {
setNavDrawerOpen(false);
}
}
lenis.on('scroll', updateStickyNavVisibility);
updateStickyNavVisibility();
syncStickyNavDependentLayout();
let navOffsetResizeTimer;
window.addEventListener('resize', () => {
clearTimeout(navOffsetResizeTimer);
navOffsetResizeTimer = setTimeout(() => {
syncStickyNavDependentLayout();
if (document.body.classList.contains('nav-drawer-visible')) {
updateStickyNavVisibility();
}
}, 120);
});
window.addEventListener('orientationchange', () => {
syncStickyNavDependentLayout();
});
if (navDrawer && navToggle) {
navToggle.addEventListener('click', () => {
setNavDrawerOpen(!document.body.classList.contains('nav-drawer-visible'));
});
if (navScrim) {
navScrim.addEventListener('click', closeNavDrawer);
}
navDrawer.querySelectorAll('.nav-drawer-list a').forEach((a) => {
a.addEventListener('click', (e) => {
const href = a.getAttribute('href');
if (!href || href.charAt(0) !== '#') {
closeNavDrawer();
return;
}
e.preventDefault();
closeNavDrawer();
const target = document.querySelector(href);
if (target && typeof lenis.scrollTo === 'function') {
const pad = parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop);
const off = Number.isFinite(pad) && pad > 0 ? -pad : -88;
lenis.scrollTo(target, {
offset: off,
lerp: 0.12
});
} else if (target) {
target.scrollIntoView({
behavior: 'smooth'
});
}
});
});
mqWide.addEventListener('change', (ev) => {
if (ev.matches) closeNavDrawer();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && document.body.classList.contains('nav-drawer-visible')) {
e.preventDefault();
closeNavDrawer();
}
});
}
})();
/* ── SCROLL REVEAL ──────────────────────────── */
const revealEls = document.querySelectorAll('.reveal');
const revealObs = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) {
e.target.classList.add('in-view');
}
});
}, {
threshold: 0.12
});
revealEls.forEach(el => revealObs.observe(el));
/* ── INSIGHTS CARDS ANIMATION ──────────────────────────── */
const insightCards = document.querySelectorAll('.insight-card');
const insightsObs = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) {
e.target.classList.add('animate-in');
}
});
}, {
threshold: 0.15,
rootMargin: '0px 0px -50px 0px'
});
insightCards.forEach(card => insightsObs.observe(card));
/* Tablet insights: ArrowLeft/ArrowRight scroll the horizontal strip when focused */
(function() {
var slider = document.querySelector('#insights .insights-slider');
if (!slider || typeof window.matchMedia !== 'function') return;
var mqTablet = window.matchMedia('(min-width: 641px) and (max-width: 1024px)');
slider.addEventListener('keydown', function(ev) {
if (!mqTablet.matches || (ev.key !== 'ArrowLeft' && ev.key !== 'ArrowRight')) return;
var card = slider.querySelector('.insight-card');
var stride = card ? card.getBoundingClientRect().width + 24 : 360;
ev.preventDefault();
slider.scrollBy({
left: (ev.key === 'ArrowRight' ? 1 : -1) * stride,
behavior: 'smooth'
});
});
})();
/* ── WHERE WE BELONG: desktop pinned crossfade (one slot, dissolves 02–04 over 01) ─ */
(function() {
var wrap = document.querySelector('#process .process-wrapper');
var container = document.querySelector('#process .sectors-container.sectors-stack--dissolve');
if (!wrap || !container || typeof gsap === 'undefined' || typeof ScrollTrigger === 'undefined') {
return;
}
var reducedMotion =
typeof window.matchMedia !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) {
return;
}
function processSectorPinStartPx() {
var nav = document.getElementById('sticky-nav');
var navH = nav && nav.getBoundingClientRect ? Math.ceil(nav.getBoundingClientRect().height) : 0;
if (!Number.isFinite(navH) || navH < 44) {
navH = 88;
}
navH = Math.min(Math.max(navH, 64), 120);
/* Line up pin with space below sticky eyebrow so 01/04 stays visible (eyebrow is z-index 24). */
var eyebrow = document.querySelector('#process > h2.process-eyebrow');
var eyeH = 0;
if (eyebrow && eyebrow.getBoundingClientRect) {
eyeH = Math.ceil(eyebrow.getBoundingClientRect().height);
}
if (!Number.isFinite(eyeH) || eyeH < 52) {
eyeH = 76;
}
var gap = 12;
return Math.round(navH + eyeH + gap);
}
function processDissolveEndPx() {
var vh = window.innerHeight || 800;
/* Longer runway = gentler progress through crossfades (pairs with smooth scrub). */
return Math.round(Math.max(vh, 640) * 3.1);
}
var mm = gsap.matchMedia();
mm.add('(min-width: 1200px)', function() {
var slides = gsap.utils.toArray(container.querySelectorAll('.sector-slide'));
if (slides.length < 2) {
return;
}
gsap.set(slides[0], {
autoAlpha: 1
});
gsap.set(slides.slice(1), {
autoAlpha: 0
});
var seg = 0.92;
var ease = 'power1.inOut';
var tl = gsap.timeline({
scrollTrigger: {
trigger: wrap,
start: function() {
return 'top top+=' + processSectorPinStartPx();
},
end: function() {
return '+=' + processDissolveEndPx();
},
pin: true,
pinSpacing: true,
/* Smooth follow with Lenis — avoid tight scrub + fastScrollEnd (feels "snappy" / forced). */
scrub: 1.28,
anticipatePin: 0,
invalidateOnRefresh: true,
fastScrollEnd: false,
onRefresh: function() {
if (typeof lenis !== 'undefined' && typeof lenis.resize === 'function') {
lenis.resize();
}
},
},
});
for (var i = 0; i < slides.length - 1; i++) {
var t = i * seg;
tl.to(
slides[i], {
autoAlpha: 0,
duration: seg,
ease: ease,
},
t
).to(
slides[i + 1], {
autoAlpha: 1,
duration: seg,
ease: ease,
},
t
);
}
return function cleanup() {
if (tl.scrollTrigger) {
tl.scrollTrigger.kill();
}
tl.kill();
gsap.set(slides, {
clearProps: 'opacity,visibility',
});
};
});
})();
/* ── PROCESS SECTION: KPI count-up when a slide crosses into view ─ */
(function() {
var reduced = typeof window.matchMedia !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
function runCounters(slide) {
slide.querySelectorAll('.js-count-stat').forEach(function(item) {
var end = parseFloat(item.getAttribute('data-count-to'));
var suf = item.getAttribute('data-count-suffix') || '';
var el = item.querySelector('.stat-count-display');
if (!el || !Number.isFinite(end)) return;
if (reduced) {
el.textContent = Math.round(end) + suf;
return;
}
var duration = 2600;
var start = performance.now();
function frame(now) {
var u = Math.min(1, (now - start) / duration);
var val = easeOutCubic(u) * end;
el.textContent = Math.round(val) + suf;
if (u < 1) {
requestAnimationFrame(frame);
}
}
requestAnimationFrame(frame);
});
}
/* Trigger counter animation only once for the sticky stats section */
const processStatsSection = document.getElementById('process-stats-sticky');
if (processStatsSection) {
var done = false;
var io = new IntersectionObserver(function(entries) {
entries.forEach(function(e) {
if (done || !e.isIntersecting) return;
if (e.intersectionRatio < 0.05) return;
done = true;
runCounters(processStatsSection);
});
}, {
threshold: [0, 0.05, 0.15, 0.3]
});
io.observe(processStatsSection);
}
})();
/* ── HERO CANVAS — animated glowing wave ───── */
(function() {
const canvas = document.getElementById('hero-canvas');
if (!canvas) return; // Canvas removed in favor of video
const ctx = canvas.getContext('2d');
let W, H, t = 0;
function resize() {
W = canvas.width = canvas.offsetWidth || window.innerWidth;
H = canvas.height = canvas.offsetHeight || window.innerHeight;
}
window.addEventListener('resize', resize);
resize();
function drawWave() {
ctx.clearRect(0, 0, W, H);
// Dark background gradient
const bg = ctx.createLinearGradient(0, 0, 0, H);
bg.addColorStop(0, '#0d0b09');
bg.addColorStop(0.5, '#120e0a');
bg.addColorStop(1, '#0a0806');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
// Radial ambient glow (right center)
const glow = ctx.createRadialGradient(W * 0.62, H * 0.52, 0, W * 0.62, H * 0.52, W * 0.45);
glow.addColorStop(0, 'rgba(170,100,40,0.18)');
glow.addColorStop(0.5, 'rgba(120,65,20,0.08)');
glow.addColorStop(1, 'transparent');
ctx.fillStyle = glow;
ctx.fillRect(0, 0, W, H);
// Draw main wave ribbon
const waveY = H * 0.55;
const amp = H * 0.18;
const speed = 0.0008;
// Multiple layered wave paths for depth
const layers = [{
alpha: 0.06,
width: 120,
offset: 0
},
{
alpha: 0.12,
width: 60,
offset: 0.3
},
{
alpha: 0.22,
width: 28,
offset: 0.6
},
{
alpha: 0.55,
width: 8,
offset: 1.0
},
{
alpha: 0.25,
width: 22,
offset: 1.3
},
{
alpha: 0.10,
width: 50,
offset: 1.6
},
];
layers.forEach(layer => {
ctx.beginPath();
ctx.moveTo(0, H);
const pts = 200;
for (let i = 0; i <= pts; i++) {
const x = (i / pts) * W;
const progress = i / pts;
// Complex wave formula matching the sinuous dune shape
const y = waveY -
Math.sin(progress * Math.PI * 1.8 + t * speed * 800 + layer.offset) * amp * 0.9 +
Math.sin(progress * Math.PI * 0.9 + t * speed * 600) * amp * 0.4 -
Math.cos(progress * Math.PI * 2.5 + t * speed * 700) * amp * 0.15 +
(progress < 0.5 ? -progress * amp * 0.6 : -(1 - progress) * amp * 0.6);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
// Close to bottom
ctx.lineTo(W, H);
ctx.lineTo(0, H);
ctx.closePath();
// Warm copper gradient fill
const waveGrad = ctx.createLinearGradient(0, waveY - amp, 0, waveY + amp * 0.5);
waveGrad.addColorStop(0, `rgba(210,155,80,${layer.alpha * 0.3})`);
waveGrad.addColorStop(0.4, `rgba(195,135,60,${layer.alpha})`);
waveGrad.addColorStop(0.7, `rgba(160,100,35,${layer.alpha * 0.8})`);
waveGrad.addColorStop(1, `rgba(80,45,15,${layer.alpha * 0.2})`);
ctx.strokeStyle = `rgba(220,165,90,${layer.alpha * 1.2})`;
ctx.lineWidth = layer.width;
ctx.stroke();
ctx.fillStyle = waveGrad;
ctx.fill();
});
// Bright highlight ridge
ctx.beginPath();
const ridgePts = 200;
for (let i = 0; i <= ridgePts; i++) {
const x = (i / ridgePts) * W;
const progress = i / ridgePts;
const y = waveY -
Math.sin(progress * Math.PI * 1.8 + t * speed * 800) * amp * 0.9 +
Math.sin(progress * Math.PI * 0.9 + t * speed * 600) * amp * 0.4 -
(progress < 0.5 ? progress * amp * 0.6 : (1 - progress) * amp * 0.6);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
// Bright glowing ridge line
const ridgeGrad = ctx.createLinearGradient(0, 0, W, 0);
ridgeGrad.addColorStop(0, 'rgba(240,200,140,0)');
ridgeGrad.addColorStop(0.25, 'rgba(255,210,150,0.4)');
ridgeGrad.addColorStop(0.5, 'rgba(255,230,180,0.9)');
ridgeGrad.addColorStop(0.75, 'rgba(255,210,150,0.5)');
ridgeGrad.addColorStop(1, 'rgba(240,200,140,0.1)');
ctx.strokeStyle = ridgeGrad;
ctx.lineWidth = 2.5;
ctx.shadowColor = 'rgba(255,210,140,0.8)';
ctx.shadowBlur = 20;
ctx.stroke();
ctx.shadowBlur = 0;
t++;
requestAnimationFrame(drawWave);
}
drawWave();
})();
/* ── 3D OBJECT CANVASES (offerings) ─────────── */
(function() {
// Crystal / shard shape — canvas 1
drawRotatingCrystal('obj-crystal', {
r: 180,
g: 180,
b: 195
});
drawRotatingCrystal('obj-sphere', {
r: 170,
g: 140,
b: 120
});
drawRotatingCrystal('obj-brain', {
r: 150,
g: 150,
b: 165
});
drawRotatingCrystal('obj-hex', {
r: 160,
g: 140,
b: 120
});
drawRotatingCrystal('obj-hex-2', {
r: 180,
g: 160,
b: 140
});
function drawRotatingCrystal(id, tint) {
const canvas = document.getElementById(id);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.width,
H = canvas.height;
let angle = 0;
const speed = 0.004 + Math.random() * 0.003;
// Generate random facets
const facets = [];
const N = 18 + Math.floor(Math.random() * 12);
for (let i = 0; i < N; i++) {
const pts = 3 + Math.floor(Math.random() * 3);
const points = [];
const baseR = 60 + Math.random() * 110;
const baseA = (i / N) * Math.PI * 2;
for (let j = 0; j < pts; j++) {
const a = baseA + (j / pts) * Math.PI * 0.8 - Math.PI * 0.4;
const r = baseR * (0.4 + Math.random() * 0.7);
points.push({
x: Math.cos(a) * r,
y: Math.sin(a) * r * 0.75
});
}
const brightness = 0.4 + Math.random() * 0.6;
const isMetal = Math.random() > 0.4;
facets.push({
points,
brightness,
isMetal,
depth: Math.random()
});
}
function draw() {
ctx.clearRect(0, 0, W, H);
const cx = W / 2,
cy = H / 2;
const cosA = Math.cos(angle),
sinA = Math.sin(angle);
// Sort by depth
const sorted = [...facets].sort((a, b) => a.depth - b.depth);
sorted.forEach(f => {
ctx.beginPath();
f.points.forEach((p, i) => {
const rx2 = p.x * cosA - p.y * sinA * 0.3;
const ry2 = p.x * sinA * 0.3 + p.y * cosA;
if (i === 0) ctx.moveTo(cx + rx2, cy + ry2);
else ctx.lineTo(cx + rx2, cy + ry2);
});
ctx.closePath();
const lum = Math.round(f.brightness * 220);
const r = Math.round(lum * (tint.r / 220));
const g = Math.round(lum * (tint.g / 220));
const b = Math.round(lum * (tint.b / 220));
if (f.isMetal) {
const grad = ctx.createLinearGradient(cx - 80, cy - 80, cx + 80, cy + 80);
grad.addColorStop(0, `rgba(${r+30},${g+30},${b+30},0.95)`);
grad.addColorStop(0.4, `rgba(${r},${g},${b},0.85)`);
grad.addColorStop(1, `rgba(${Math.max(0,r-40)},${Math.max(0,g-40)},${Math.max(0,b-40)},0.9)`);
ctx.fillStyle = grad;
} else {
ctx.fillStyle = `rgba(${r},${g},${b},0.7)`;
}
ctx.fill();
ctx.strokeStyle = `rgba(255,255,255,0.12)`;
ctx.lineWidth = 0.5;
ctx.stroke();
});
// Glow center
const glowR = ctx.createRadialGradient(cx, cy, 0, cx, cy, 180);
glowR.addColorStop(0, `rgba(${tint.r},${tint.g},${tint.b},0.08)`);
glowR.addColorStop(1, 'transparent');
ctx.fillStyle = glowR;
ctx.fillRect(0, 0, W, H);
angle += speed;
requestAnimationFrame(draw);
}
draw();
}
})();