Files
hesu-JT/js/main.js
T
2026-09-02 18:35:45 +08:00

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();
})();