518 lines
20 KiB
JavaScript
518 lines
20 KiB
JavaScript
/* ============================================================
|
|
HESU — homepage interactions (vanilla JS, no dependencies)
|
|
============================================================ */
|
|
(function () {
|
|
'use strict';
|
|
|
|
/* ---------- header scrolled state ---------- */
|
|
var header = document.getElementById('siteHeader');
|
|
function onScroll() {
|
|
if (!header) return;
|
|
if (window.scrollY > 40) header.classList.add('scrolled');
|
|
else header.classList.remove('scrolled');
|
|
}
|
|
window.addEventListener('scroll', onScroll, { passive: true });
|
|
onScroll();
|
|
|
|
/* ---------- mobile drawer ---------- */
|
|
var toggle = document.getElementById('menuToggle');
|
|
var nav = document.getElementById('mainNav');
|
|
|
|
// backdrop
|
|
var backdrop = document.createElement('div');
|
|
backdrop.className = 'nav-backdrop';
|
|
document.body.appendChild(backdrop);
|
|
|
|
function closeNav() {
|
|
if (!nav || !toggle) return;
|
|
nav.classList.remove('open');
|
|
toggle.classList.remove('open');
|
|
toggle.setAttribute('aria-expanded', 'false');
|
|
backdrop.classList.remove('show');
|
|
document.body.style.overflow = '';
|
|
}
|
|
function openNav() {
|
|
nav.classList.add('open');
|
|
toggle.classList.add('open');
|
|
toggle.setAttribute('aria-expanded', 'true');
|
|
backdrop.classList.add('show');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
if (toggle && nav) {
|
|
toggle.addEventListener('click', function () {
|
|
nav.classList.contains('open') ? closeNav() : openNav();
|
|
});
|
|
}
|
|
backdrop.addEventListener('click', closeNav);
|
|
// close on link click (mobile)
|
|
if (nav) {
|
|
nav.querySelectorAll('a').forEach(function (a) {
|
|
a.addEventListener('click', function () {
|
|
if (window.innerWidth <= 860) {
|
|
// allow mega parent toggle to work first on mobile
|
|
if (a.parentElement.classList.contains('has-mega')) return;
|
|
closeNav();
|
|
}
|
|
});
|
|
});
|
|
|
|
/* ---------- mobile dropdown collapsibles ---------- */
|
|
nav.querySelectorAll('.nav-item.has-mega > a').forEach(function (trigger) {
|
|
trigger.addEventListener('click', function (e) {
|
|
if (window.innerWidth > 860) return;
|
|
e.preventDefault();
|
|
var li = trigger.parentElement;
|
|
// close other open items
|
|
nav.querySelectorAll('.nav-item.has-mega.open').forEach(function (other) {
|
|
if (other !== li) other.classList.remove('open');
|
|
});
|
|
li.classList.toggle('open');
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ---------- Full-screen mega menus (desktop hover; mobile uses drawer collapsible) ---------- */
|
|
(function () {
|
|
var items = Array.prototype.slice.call(document.querySelectorAll('.nav-item.has-mega'));
|
|
if (!items.length) return;
|
|
var openTimer, closeTimer;
|
|
|
|
function closeAll() {
|
|
document.querySelectorAll('.mega.open').forEach(function (m) { m.classList.remove('open'); });
|
|
items.forEach(function (it) { it.classList.remove('mega-on'); });
|
|
}
|
|
function open(mega, item) {
|
|
closeAll();
|
|
mega.classList.add('open');
|
|
item.classList.add('mega-on');
|
|
}
|
|
|
|
items.forEach(function (item) {
|
|
var mega = item.querySelector('.mega');
|
|
if (!mega) return;
|
|
item.addEventListener('mouseenter', function () {
|
|
if (window.innerWidth <= 860) return;
|
|
clearTimeout(closeTimer);
|
|
openTimer = setTimeout(function () { open(mega, item); }, 120);
|
|
});
|
|
item.addEventListener('mouseleave', function () {
|
|
if (window.innerWidth <= 860) return;
|
|
clearTimeout(openTimer);
|
|
closeTimer = setTimeout(closeAll, 200);
|
|
});
|
|
mega.querySelectorAll('.mega-card').forEach(function (c) {
|
|
c.addEventListener('click', function () { closeAll(); });
|
|
});
|
|
});
|
|
|
|
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeAll(); });
|
|
})();
|
|
|
|
/* ---------- partner carousel ---------- */
|
|
var carousel = document.getElementById('partnerCarousel');
|
|
var dotsWrap = document.getElementById('partnerDots');
|
|
if (carousel && dotsWrap) {
|
|
var slides = Array.prototype.slice.call(carousel.querySelectorAll('.slide'));
|
|
var idx = 0;
|
|
var timer = null;
|
|
|
|
slides.forEach(function (_, i) {
|
|
var b = document.createElement('button');
|
|
b.setAttribute('role', 'tab');
|
|
b.setAttribute('aria-label', 'Slide ' + (i + 1));
|
|
b.addEventListener('click', function () { goTo(i, true); });
|
|
dotsWrap.appendChild(b);
|
|
});
|
|
var dots = Array.prototype.slice.call(dotsWrap.children);
|
|
|
|
function render() {
|
|
slides.forEach(function (s, i) { s.classList.toggle('is-active', i === idx); });
|
|
dots.forEach(function (d, i) { d.classList.toggle('active', i === idx); });
|
|
}
|
|
function goTo(i, user) {
|
|
idx = (i + slides.length) % slides.length;
|
|
render();
|
|
if (user) restart();
|
|
}
|
|
function next() { goTo(idx + 1); }
|
|
function stop() { clearInterval(timer); timer = null; }
|
|
function start() { stop(); timer = setInterval(next, 5000); }
|
|
function restart() { start(); }
|
|
|
|
render();
|
|
start();
|
|
carousel.addEventListener('mouseenter', stop);
|
|
carousel.addEventListener('mouseleave', start);
|
|
}
|
|
|
|
/* ---------- scroll reveal ---------- */
|
|
var reveals = document.querySelectorAll('[data-reveal], .reveal'); /* unified reveal — page scripts removed */
|
|
if ('IntersectionObserver' in window) {
|
|
var io = new IntersectionObserver(function (entries) {
|
|
entries.forEach(function (en) {
|
|
/* reveal-repeat: replay the stagger every time the element enters the
|
|
viewport (reset while fully out) — used by the bento business cells */
|
|
if (en.target.classList.contains('reveal-repeat')) {
|
|
en.target.classList.toggle('in', en.isIntersecting);
|
|
return;
|
|
}
|
|
if (en.isIntersecting) {
|
|
en.target.classList.add('in');
|
|
io.unobserve(en.target);
|
|
}
|
|
});
|
|
}, { threshold: 0.12 });
|
|
reveals.forEach(function (el) { io.observe(el); });
|
|
} else {
|
|
reveals.forEach(function (el) { el.classList.add('in'); });
|
|
}
|
|
|
|
/* ---------- stats count-up ---------- */
|
|
function animateCount(el) {
|
|
var target = parseInt(el.getAttribute('data-count'), 10);
|
|
if (isNaN(target)) return;
|
|
var plus = el.querySelector('.plus');
|
|
var dur = 1400, start = null;
|
|
function step(ts) {
|
|
if (start === null) start = ts;
|
|
var p = Math.min((ts - start) / dur, 1);
|
|
var eased = 1 - Math.pow(1 - p, 3); // easeOutCubic
|
|
var val = Math.floor(eased * target);
|
|
el.firstChild.nodeValue = val.toLocaleString();
|
|
if (p < 1) requestAnimationFrame(step);
|
|
else el.firstChild.nodeValue = target.toLocaleString();
|
|
}
|
|
requestAnimationFrame(step);
|
|
}
|
|
var stats = document.querySelectorAll('.stat-num');
|
|
if ('IntersectionObserver' in window) {
|
|
var so = new IntersectionObserver(function (entries) {
|
|
entries.forEach(function (en) {
|
|
if (en.isIntersecting) {
|
|
animateCount(en.target);
|
|
so.unobserve(en.target);
|
|
}
|
|
});
|
|
}, { threshold: 0.4 });
|
|
stats.forEach(function (el) { so.observe(el); });
|
|
}
|
|
|
|
/* ---------- Network peek carousel — drag to slide, seamless infinite loop (same drag system on desktop & mobile) ---------- */
|
|
(function () {
|
|
var stage = document.querySelector('.network-stage');
|
|
if (!stage) return;
|
|
var strip = stage.querySelector('.network-strip');
|
|
var track = stage.querySelector('.network-track');
|
|
if (!track) return;
|
|
var realCards = Array.prototype.slice.call(track.querySelectorAll('.network-card'));
|
|
var n = realCards.length;
|
|
if (!n) return;
|
|
|
|
var GAP = 18, VISIBLE = 3.6, PAIRED = true; // desktop defaults; mobile re-tunes in applyMode
|
|
var pos = n; // continuous position (left card of centered pair)
|
|
var cardW = 0, cardStep = 1, stripW = 0, centerBase = 0;
|
|
var cloned = false, enabled = false;
|
|
|
|
// build [clone][real][clone] so both edges always have cards (no blank when looping)
|
|
function setClones(on) {
|
|
if (on && !cloned) {
|
|
var pre = document.createDocumentFragment();
|
|
var post = document.createDocumentFragment();
|
|
realCards.forEach(function (c) {
|
|
var a = c.cloneNode(true); a.setAttribute('data-clone', ''); pre.appendChild(a);
|
|
var b = c.cloneNode(true); b.setAttribute('data-clone', ''); post.appendChild(b);
|
|
});
|
|
track.insertBefore(pre, track.firstChild);
|
|
track.appendChild(post);
|
|
cloned = true;
|
|
} else if (!on && cloned) {
|
|
Array.prototype.slice.call(track.querySelectorAll('.network-card[data-clone]')).forEach(function (c) { c.remove(); });
|
|
cloned = false;
|
|
}
|
|
}
|
|
|
|
function measure() {
|
|
stripW = strip.getBoundingClientRect().width;
|
|
cardW = stripW / VISIBLE;
|
|
cardStep = cardW + GAP;
|
|
centerBase = PAIRED ? (stripW - 2 * cardW - GAP) / 2 : (stripW - cardW) / 2;
|
|
var all = track.querySelectorAll('.network-card');
|
|
for (var i = 0; i < all.length; i++) all[i].style.flexBasis = cardW + 'px';
|
|
}
|
|
function transformFor(p) { return 'translateX(' + (centerBase - p * cardStep) + 'px)'; }
|
|
function render(animate) {
|
|
track.style.transition = animate ? '' : 'none';
|
|
track.style.transform = transformFor(pos);
|
|
}
|
|
function normalize() { pos = n + (((pos - n) % n) + n) % n; } // wrap into real band [n, 2n)
|
|
|
|
// after a slide settles, silently fold position back into the real band (seamless)
|
|
track.addEventListener('transitionend', function (e) {
|
|
if (enabled && e.propertyName === 'transform') { normalize(); render(false); }
|
|
});
|
|
|
|
// drag to slide (pointer = mouse + touch)
|
|
var dragging = false, startX = 0, startP = 0, delta = 0, moved = false;
|
|
function onDown(e) {
|
|
if (!enabled) return;
|
|
dragging = true; moved = false; delta = 0;
|
|
startX = e.clientX; startP = pos;
|
|
track.classList.add('dragging');
|
|
}
|
|
function onMove(e) {
|
|
if (!dragging) return;
|
|
delta = e.clientX - startX;
|
|
if (Math.abs(delta) > 6) moved = true;
|
|
track.style.transform = transformFor(startP - delta / cardStep);
|
|
}
|
|
function onUp() {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
track.classList.remove('dragging');
|
|
pos = Math.round(startP - delta / cardStep); // snap to nearest card
|
|
render(true); // animate; transitionend folds it back
|
|
}
|
|
strip.addEventListener('pointerdown', onDown);
|
|
window.addEventListener('pointermove', onMove);
|
|
window.addEventListener('pointerup', onUp);
|
|
window.addEventListener('pointercancel', onUp);
|
|
// suppress link navigation right after a real drag
|
|
track.addEventListener('click', function (e) { if (moved) { e.preventDefault(); e.stopPropagation(); } }, true);
|
|
|
|
function applyMode() {
|
|
var mobile = window.matchMedia('(max-width: 860px)').matches;
|
|
PAIRED = !mobile; // desktop centers a card pair; mobile centers one card
|
|
GAP = mobile ? 12 : 18;
|
|
VISIBLE = mobile ? 1.32 : 3.6; // mobile: centered card + neighbour peeks
|
|
enabled = true;
|
|
setClones(true);
|
|
measure();
|
|
if (!track.dataset.init) { pos = n; track.dataset.init = '1'; }
|
|
if (pos < 1 || pos >= 2 * n) pos = n;
|
|
render(false);
|
|
}
|
|
var rT;
|
|
window.addEventListener('resize', function () { clearTimeout(rT); rT = setTimeout(applyMode, 150); });
|
|
applyMode();
|
|
})();
|
|
|
|
/* ---------- presence map — bloom all, then rotating-window highlights ----------
|
|
Entering the viewport blooms ALL locations at once; the cycle then runs the
|
|
sliding window straight from its steady state: every tick the next location
|
|
replays its full entrance while the oldest visible one exits (wrapping around,
|
|
the last location returns exactly as the first one leaves). Four stay visible.
|
|
Leaving the viewport gracefully exits everything; coming back blooms again. */
|
|
(function () {
|
|
var overlay = document.querySelector('.map-overlay');
|
|
if (!overlay) return;
|
|
var groups = Array.prototype.slice.call(overlay.querySelectorAll('.location'));
|
|
if (!groups.length) return;
|
|
|
|
function show(i) {
|
|
groups.forEach(function (g, j) { g.classList.toggle('active', j === i); });
|
|
}
|
|
|
|
// drop the DUBAI active baked into the HTML (no-JS keeps it as a static fallback)
|
|
show(-1);
|
|
|
|
var tick = 0, timer = null, run = 0;
|
|
// stride-2 visit order (0,2,4,1,3) so the point entering and the point
|
|
// exiting each step are far apart on the map instead of neighbours
|
|
var order = [];
|
|
var stride = groups.length % 2 === 1 ? 2 : (groups.length > 3 ? 3 : 1);
|
|
for (var i = 0; i < groups.length; i++) order.push((i * stride) % groups.length);
|
|
// deactivate with the reverse (.out) choreography — class is lifted when it ends
|
|
function exit(el) {
|
|
el.classList.remove('active');
|
|
if (el.classList.contains('out')) return; // already on its way out
|
|
el.classList.add('out');
|
|
setTimeout(function () { el.classList.remove('out'); }, 650);
|
|
}
|
|
function step() {
|
|
var on = order[tick % order.length];
|
|
var off = order[(tick + 1) % order.length];
|
|
groups.forEach(function (g, i) {
|
|
if (i === on) {
|
|
// remove → flush → re-add so the entrance sequence replays
|
|
g.classList.remove('active');
|
|
g.getBoundingClientRect();
|
|
g.classList.remove('out');
|
|
g.classList.add('active');
|
|
} else if (i === off) {
|
|
exit(g);
|
|
}
|
|
});
|
|
tick++;
|
|
}
|
|
function start() {
|
|
if (timer) return;
|
|
var myRun = ++run;
|
|
// let one frame of the invisible base state paint first, then bloom everything
|
|
requestAnimationFrame(function () {
|
|
requestAnimationFrame(function () {
|
|
if (run !== myRun) return;
|
|
tick = 0;
|
|
groups.forEach(function (g) { g.classList.remove('out'); g.classList.add('active'); });
|
|
timer = setInterval(step, 2000);
|
|
});
|
|
});
|
|
timer = 1; // mark as starting (replaced by the real interval above)
|
|
}
|
|
function stop() {
|
|
if (timer) { clearInterval(timer); timer = null; }
|
|
run++; // invalidate pending callbacks
|
|
tick = 0;
|
|
groups.forEach(function (g) { if (g.classList.contains('active')) exit(g); });
|
|
}
|
|
|
|
var wrap = document.querySelector('.map-wrap');
|
|
|
|
// Paint the page's exact background onto the wrap: same image, stretched
|
|
// to .page-content's full size, offset by the wrap's position inside it —
|
|
// a pixel-perfect local copy. With .map-wrap { isolation: isolate } the
|
|
// blended img mixes against THIS instead of the page backdrop, so Chrome
|
|
// never has to capture a cross-element backdrop when the map scrolls into
|
|
// view (that capture lags a frame and flashes the map's intrinsic colors).
|
|
var pc = document.querySelector('.page-content');
|
|
if (wrap && pc) {
|
|
var syncBackdrop = function () {
|
|
var wr = wrap.getBoundingClientRect();
|
|
var pr = pc.getBoundingClientRect();
|
|
wrap.style.backgroundImage = 'url(image/home-bgs.png)';
|
|
wrap.style.backgroundSize = pr.width + 'px ' + pc.offsetHeight + 'px';
|
|
wrap.style.backgroundPosition = (pr.left - wr.left) + 'px ' + (pr.top - wr.top) + 'px';
|
|
wrap.style.backgroundRepeat = 'no-repeat';
|
|
};
|
|
syncBackdrop();
|
|
var rT2 = null;
|
|
window.addEventListener('resize', function () {
|
|
clearTimeout(rT2); rT2 = setTimeout(syncBackdrop, 150);
|
|
});
|
|
}
|
|
|
|
// Fade the map image in only after the page has fully loaded: the image
|
|
// color-dodges against the (locally copied) page background image, and
|
|
// over an unloaded near-white backdrop the map washes out — showing it
|
|
// early would flash a colorless map before the glow pops in.
|
|
var mapImg = wrap ? wrap.querySelector('.map-bg') : null;
|
|
if (mapImg) {
|
|
// Reveal needs BOTH: the lazy image decoded (it paints a frame or two
|
|
// before its mix-blend-mode composite kicks in) AND the page fully
|
|
// loaded (color-dodge over an unloaded white backdrop washes out).
|
|
// The fade is JS-driven because CSS animations/transitions lose to the
|
|
// !important reveal rule in the cascade — this way the un-blended
|
|
// first frames stay at near-zero opacity no matter what.
|
|
var imgLoaded = mapImg.complete && mapImg.naturalWidth > 0;
|
|
var pageLoaded = document.readyState === 'complete';
|
|
var tryReveal = function () {
|
|
if (!imgLoaded || !pageLoaded) return;
|
|
requestAnimationFrame(function () {
|
|
requestAnimationFrame(function () {
|
|
var t0 = null;
|
|
var step = function (t) {
|
|
if (t0 === null) t0 = t;
|
|
var p = Math.min((t - t0) / 700, 1);
|
|
mapImg.style.opacity = p.toFixed(3);
|
|
if (p < 1) requestAnimationFrame(step);
|
|
else mapImg.classList.add('ready'); // !important locks the final state
|
|
};
|
|
requestAnimationFrame(step);
|
|
});
|
|
});
|
|
};
|
|
if (!imgLoaded) mapImg.addEventListener('load', function () { imgLoaded = true; tryReveal(); }, { once: true });
|
|
if (!pageLoaded) window.addEventListener('load', function () { pageLoaded = true; tryReveal(); }, { once: true });
|
|
tryReveal();
|
|
}
|
|
|
|
if ('IntersectionObserver' in window && wrap) {
|
|
var mo = new IntersectionObserver(function (entries) {
|
|
entries[0].isIntersecting ? start() : stop();
|
|
}, { threshold: 0.12 });
|
|
mo.observe(wrap);
|
|
} else {
|
|
start();
|
|
}
|
|
})();
|
|
|
|
/* ---------- ae page map — bloom all, then rotating-window highlights ----------
|
|
Same logic as the homepage map: enter blooms everything, then the sliding
|
|
window runs from its steady state (next marker replays in, oldest exits). */
|
|
(function () {
|
|
var overlay = document.querySelector('.ae-map__overlay');
|
|
if (!overlay) return;
|
|
var markers = Array.prototype.slice.call(overlay.querySelectorAll('.marker'));
|
|
if (!markers.length) return;
|
|
|
|
function show(i) {
|
|
markers.forEach(function (m, j) { m.classList.toggle('active', j === i); });
|
|
}
|
|
|
|
// drop the Belarus active baked into the HTML (no-JS keeps it as a static fallback)
|
|
show(-1);
|
|
|
|
var tick = 0, timer = null, run = 0;
|
|
// stride-2 visit order so the marker entering and the marker exiting each
|
|
// step are far apart on the map instead of neighbours
|
|
var order = [];
|
|
var stride = markers.length % 2 === 1 ? 2 : (markers.length > 3 ? 3 : 1);
|
|
for (var i = 0; i < markers.length; i++) order.push((i * stride) % markers.length);
|
|
// deactivate with the reverse (.out) choreography — class is lifted when it ends
|
|
function exit(el) {
|
|
el.classList.remove('active');
|
|
if (el.classList.contains('out')) return; // already on its way out
|
|
el.classList.add('out');
|
|
setTimeout(function () { el.classList.remove('out'); }, 650);
|
|
}
|
|
function step() {
|
|
var on = order[tick % order.length];
|
|
var off = order[(tick + 1) % order.length];
|
|
markers.forEach(function (m, i) {
|
|
if (i === on) {
|
|
// remove → flush → re-add so the entrance sequence replays
|
|
m.classList.remove('active');
|
|
m.getBoundingClientRect();
|
|
m.classList.remove('out');
|
|
m.classList.add('active');
|
|
} else if (i === off) {
|
|
exit(m);
|
|
}
|
|
});
|
|
tick++;
|
|
}
|
|
function start() {
|
|
if (timer) return;
|
|
var myRun = ++run;
|
|
requestAnimationFrame(function () {
|
|
requestAnimationFrame(function () {
|
|
if (run !== myRun) return;
|
|
tick = 0;
|
|
markers.forEach(function (m) { m.classList.remove('out'); m.classList.add('active'); });
|
|
timer = setInterval(step, 1800);
|
|
});
|
|
});
|
|
timer = 1;
|
|
}
|
|
function stop() {
|
|
if (timer) { clearInterval(timer); timer = null; }
|
|
run++; // invalidate pending callbacks
|
|
tick = 0;
|
|
markers.forEach(function (m) { if (m.classList.contains('active')) exit(m); });
|
|
}
|
|
|
|
var wrap = overlay.closest('.ae-map');
|
|
if ('IntersectionObserver' in window && wrap) {
|
|
var mo = new IntersectionObserver(function (entries) {
|
|
entries[0].isIntersecting ? start() : stop();
|
|
}, { threshold: 0.12 });
|
|
mo.observe(wrap);
|
|
} else {
|
|
start();
|
|
}
|
|
})();
|
|
|
|
/* ---------- footer year ---------- */
|
|
var y = document.getElementById('year');
|
|
if (y) y.textContent = new Date().getFullYear();
|
|
})();
|