428th-website-2026/common.js
Artemii Peretiachenko f6d611aec1 Keep the hero logo on screen while it scales down on scroll.
The sticky stage was scrolling away, so the title clipped under the header and left an empty gap above the next section.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 19:26:37 +02:00

502 lines
17 KiB
JavaScript

// ---------- SHARED UTILITIES ----------
function readCssPx(name, fallback, el = document.documentElement){
const value = getComputedStyle(el).getPropertyValue(name).trim();
const parsed = parseFloat(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// ---------- ЯЗЫК (переключатель = ссылки на статические URL) ----------
function initLangSwitch(){
const root = document.getElementById('langSwitch');
if (!root) return;
root.querySelectorAll('a[data-set-lang]').forEach(link => {
link.addEventListener('click', () => {
const lang = link.getAttribute('data-set-lang');
if (lang === 'ru' || lang === 'en' || lang === 'ua') {
localStorage.setItem('lang', lang);
}
});
});
}
// ---------- NAV MAGNET (как ghost / EclipseButton) ----------
function initNavMagnet(){
const canHover = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
if (prefersReducedMotion || !canHover) return;
document.querySelectorAll('.main-nav a:not(.nav-cta)').forEach(link => {
let raf = 0;
let tx = 0;
let ty = 0;
let ts = 1;
let cx = 0;
let cy = 0;
let cs = 1;
function tick(){
cx += (tx - cx) * 0.18;
cy += (ty - cy) * 0.18;
cs += (ts - cs) * 0.18;
link.style.transform = `translate3d(${cx}px, ${cy}px, 0) scale(${cs})`;
const done = Math.abs(tx - cx) + Math.abs(ty - cy) + Math.abs(ts - cs) < 0.04;
raf = done ? 0 : requestAnimationFrame(tick);
}
function schedule(){
if (!raf) raf = requestAnimationFrame(tick);
}
link.addEventListener('mousemove', (e) => {
const rect = link.getBoundingClientRect();
tx = (e.clientX - rect.left - rect.width / 2) * 0.15;
ty = (e.clientY - rect.top - rect.height / 2) * 0.15;
ts = 1.02;
schedule();
});
link.addEventListener('mouseleave', () => {
tx = 0;
ty = 0;
ts = 1;
schedule();
});
});
}
// ---------- HERO LOGO SCROLL ANIMATION ----------
function initHeroScroll(config){
const {
section,
stage,
brand,
brandInner,
mobileAnimClass,
scrollDrivenClass,
brandScaleAnimation,
syncFit,
brandGapFromSection = false,
clampMobileScale = null,
clearSectionVars = null,
setMobileScaleVar = false,
subtractRunwayOnMobile = true
} = config;
const siteHeader = document.querySelector('.site-header');
const mainNav = document.querySelector('.main-nav');
let heroPin = null;
if (section && !section.parentElement?.classList.contains('hero-scroll')){
const heroScroll = document.createElement('div');
heroScroll.className = 'hero-scroll';
section.parentElement.insertBefore(heroScroll, section);
heroScroll.appendChild(section);
}
if (section?.parentElement?.classList.contains('hero-scroll')){
heroPin = section.parentElement.querySelector(':scope > .hero-pin');
if (!heroPin){
heroPin = document.createElement('div');
heroPin.className = 'hero-pin';
heroPin.setAttribute('aria-hidden', 'true');
section.parentElement.appendChild(heroPin);
}
}
let cachedBaseHeight = 0;
let cachedBrandGap = 32;
let cachedMaxScale = 1.9;
let cachedMaxBrandHeight = 0;
let cachedMinBrandHeight = 0;
let cachedNavHeight = 0;
let heroRunway = 0;
let heroStartY = 0;
let heroResizeTimer = 0;
let heroIsMobile = false;
let heroUseScrollTimeline = false;
let lastHeroScrollStep = -1;
let postHeroLift = null;
const heroRoot = document.documentElement;
function clearHeroAnimationVars(){
heroRoot.style.removeProperty('--hero-scale');
heroRoot.style.removeProperty('--hero-brand-height');
heroRoot.style.removeProperty('--hero-stage-height');
heroRoot.style.removeProperty('--hero-brand-max-h');
heroRoot.style.removeProperty('--hero-brand-min-h');
heroRoot.style.removeProperty('--hero-nav-shift');
heroRoot.style.removeProperty('--hero-nav-shift-max');
heroRoot.style.removeProperty('--hero-scroll-start');
heroRoot.style.removeProperty('--hero-scroll-end');
heroRoot.style.removeProperty('--hero-pin-space');
if (brandInner){
brandInner.style.removeProperty('animation-range');
brandInner.style.removeProperty('animation-timeline');
brandInner.style.removeProperty('animation');
}
if (mainNav){
mainNav.style.removeProperty('animation-range');
mainNav.style.removeProperty('animation-timeline');
mainNav.style.removeProperty('animation');
mainNav.style.removeProperty('transform');
}
if (postHeroLift){
postHeroLift.style.removeProperty('animation-range');
postHeroLift.style.removeProperty('animation-timeline');
postHeroLift.style.removeProperty('animation');
postHeroLift.style.removeProperty('transform');
delete postHeroLift.dataset.heroLiftExited;
}
if (clearSectionVars) clearSectionVars(section);
}
function ensurePostHeroLift(){
if (postHeroLift?.isConnected) return postHeroLift;
postHeroLift = section?.parentElement?.querySelector(':scope > .post-hero-lift') || null;
return postHeroLift;
}
function applyHeroScrollAnimations(range){
brandInner.style.animation = `${brandScaleAnimation} ease-out both`;
brandInner.style.animationTimeline = 'scroll(root block)';
brandInner.style.animationRange = range;
mainNav.style.animation = 'hero-nav-lift ease-out both';
mainNav.style.animationTimeline = 'scroll(root block)';
mainNav.style.animationRange = range;
ensurePostHeroLift();
if (postHeroLift){
postHeroLift.style.animation = 'hero-content-lift ease-out both';
postHeroLift.style.animationTimeline = 'scroll(root block)';
postHeroLift.style.animationRange = range;
postHeroLift.style.removeProperty('transform');
delete postHeroLift.dataset.heroLiftExited;
}
}
function setHeroMobileMode(enabled){
if (!section) return;
section.classList.toggle(mobileAnimClass, enabled);
section.classList.toggle(scrollDrivenClass, enabled && heroUseScrollTimeline);
if (enabled) ensurePostHeroLift();
}
function getHeroScrollRunwayMultiplier(){
return readCssPx('--hero-scroll-runway-multiplier', 1);
}
function getHeroEffectiveRunway(){
return heroRunway * getHeroScrollRunwayMultiplier();
}
function getHeroAnimationRange(){
return `${heroStartY}px ${heroStartY + getHeroEffectiveRunway()}px`;
}
function easeOutCubic(t){
return 1 - (1 - t) ** 3;
}
function getHeroScrollProgress(){
return Math.min(Math.max(window.scrollY - heroStartY, 0), getHeroEffectiveRunway());
}
function updateHeroContentLift(){
if (!heroIsMobile || prefersReducedMotion || !section){
if (postHeroLift) postHeroLift.style.removeProperty('transform');
return;
}
ensurePostHeroLift();
if (!postHeroLift) return;
if (heroUseScrollTimeline){
const exited = section.getBoundingClientRect().bottom <= 0;
if (exited){
if (postHeroLift.dataset.heroLiftExited !== '1'){
postHeroLift.style.animation = 'none';
postHeroLift.style.transform = 'translate3d(0, 0, 0)';
postHeroLift.dataset.heroLiftExited = '1';
}
return;
}
if (postHeroLift.dataset.heroLiftExited === '1'){
delete postHeroLift.dataset.heroLiftExited;
const range = getHeroAnimationRange();
postHeroLift.style.animation = 'hero-content-lift ease-out both';
postHeroLift.style.animationTimeline = 'scroll(root block)';
postHeroLift.style.animationRange = range;
postHeroLift.style.removeProperty('transform');
}
return;
}
if (section.getBoundingClientRect().bottom <= 0){
postHeroLift.style.transform = 'translate3d(0, 0, 0)';
return;
}
const navShift = getComputedStyle(heroRoot).getPropertyValue('--hero-nav-shift').trim() || '0px';
postHeroLift.style.transform = `translate3d(0, ${navShift}, 0)`;
}
function isHeroMobileViewport(){
return window.innerWidth < 600;
}
function getHeroMaxScale(){
const mobile = window.innerWidth < 600;
const scope = section || document.documentElement;
return readCssPx(mobile ? '--hero-max-scale-mobile' : '--hero-max-scale', mobile ? 1.6 : 2.2, scope);
}
function syncSiteHeaderHeight(){
if (!siteHeader) return;
const height = siteHeader.getBoundingClientRect().height;
document.documentElement.style.setProperty('--site-header-height', `${Math.ceil(height)}px`);
}
function getHeroBrandHeight(scale){
return cachedBaseHeight * scale + cachedBrandGap;
}
function getHeroStickyTop(){
return readCssPx('--site-header-height', 57) + readCssPx('--hero-stage-offset', 80);
}
function measureHero(){
syncSiteHeaderHeight();
heroIsMobile = isHeroMobileViewport();
lastHeroScrollStep = -1;
if (!brandInner || !brand || !section || !stage || prefersReducedMotion){
heroUseScrollTimeline = false;
setHeroMobileMode(false);
clearHeroAnimationVars();
syncFit();
return;
}
const gapScope = brandGapFromSection ? section : document.documentElement;
cachedBrandGap = readCssPx('--hero-brand-gap', 32, gapScope);
cachedMaxScale = getHeroMaxScale();
clearHeroAnimationVars();
brandInner.style.removeProperty('transform');
syncFit();
cachedBaseHeight = brandInner.offsetHeight;
if (heroIsMobile){
if (clampMobileScale){
cachedMaxScale = clampMobileScale(cachedMaxScale, brand, brandInner, section);
}
cachedMaxBrandHeight = getHeroBrandHeight(cachedMaxScale);
cachedMinBrandHeight = getHeroBrandHeight(1);
} else {
cachedMaxBrandHeight = getHeroBrandHeight(1);
cachedMinBrandHeight = getHeroBrandHeight(1 / cachedMaxScale);
}
cachedNavHeight = mainNav ? mainNav.offsetHeight : 0;
heroRunway = Math.max(cachedMaxBrandHeight - cachedMinBrandHeight, 0);
heroStartY = stage.getBoundingClientRect().top + window.scrollY - getHeroStickyTop();
heroUseScrollTimeline = heroIsMobile
&& typeof CSS !== 'undefined'
&& CSS.supports('animation-timeline', 'scroll()');
if (heroIsMobile){
setHeroMobileMode(true);
if (setMobileScaleVar){
section.style.setProperty('--hero-max-scale-mobile', cachedMaxScale.toFixed(4));
}
heroRoot.style.setProperty('--hero-brand-max-h', `${cachedMaxBrandHeight}px`);
heroRoot.style.setProperty('--hero-brand-min-h', `${cachedMinBrandHeight}px`);
heroRoot.style.setProperty('--hero-brand-height', `${cachedMaxBrandHeight}px`);
heroRoot.style.setProperty('--hero-stage-height', `${cachedMaxBrandHeight + cachedNavHeight}px`);
heroRoot.style.setProperty('--hero-nav-shift-max', `${-(cachedMaxBrandHeight - cachedMinBrandHeight)}px`);
if (heroUseScrollTimeline){
applyHeroScrollAnimations(getHeroAnimationRange());
}
} else {
setHeroMobileMode(false);
}
updateHeroBrand();
updateHeroContentLift();
syncHeroPinSpace();
}
function syncHeroPinSpace(){
if (!section || heroIsMobile || prefersReducedMotion || heroRunway <= 0){
heroRoot.style.removeProperty('--hero-pin-space');
return;
}
const runway = getHeroEffectiveRunway();
const progress = runway > 0 ? Math.min(getHeroScrollProgress() / runway, 1) : 1;
const multiplier = getHeroScrollRunwayMultiplier();
const spacer = heroRunway * Math.max(multiplier - 1, 0) * (1 - progress);
heroRoot.style.setProperty('--hero-pin-space', `${spacer}px`);
}
function updateHeroBrand(){
if (!brandInner || !brand || !stage || prefersReducedMotion || cachedBaseHeight <= 0) return;
if (heroUseScrollTimeline) return;
const scrolled = getHeroScrollProgress();
const effectiveRunway = getHeroEffectiveRunway();
const linearProgress = effectiveRunway > 0 ? scrolled / effectiveRunway : 1;
const progress = easeOutCubic(Math.min(linearProgress, 1));
if (heroIsMobile){
const scale = cachedMaxScale - progress * (cachedMaxScale - 1);
const brandHeight = cachedBaseHeight * scale + cachedBrandGap;
const navShift = cachedMaxBrandHeight - brandHeight;
heroRoot.style.setProperty('--hero-scale', scale.toFixed(4));
heroRoot.style.setProperty('--hero-nav-shift', `${-navShift}px`);
updateHeroContentLift();
return;
}
if (scrolled === lastHeroScrollStep) return;
lastHeroScrollStep = scrolled;
const brandHeight = cachedMaxBrandHeight - progress * (cachedMaxBrandHeight - cachedMinBrandHeight);
const scale = (brandHeight - cachedBrandGap) / cachedBaseHeight;
heroRoot.style.setProperty('--hero-scale', scale.toFixed(4));
heroRoot.style.setProperty('--hero-brand-height', `${brandHeight}px`);
heroRoot.style.setProperty('--hero-stage-height', `${brandHeight + cachedNavHeight}px`);
syncHeroPinSpace();
}
let heroScrollRaf = 0;
let heroLastScrollY = -1;
function runHeroScrollFrame(){
heroScrollRaf = 0;
const scrollY = window.scrollY;
if (heroIsMobile) updateHeroContentLift();
if (!heroUseScrollTimeline) updateHeroBrand();
if (scrollY !== heroLastScrollY){
heroLastScrollY = scrollY;
scheduleHeroScrollFrame();
}
}
function scheduleHeroScrollFrame(){
if (heroScrollRaf) return;
heroScrollRaf = requestAnimationFrame(runHeroScrollFrame);
}
function onHeroScroll(){
scheduleHeroScrollFrame();
}
function initHeroAnimation(){
measureHero();
}
function scheduleHeroMeasure(){
clearTimeout(heroResizeTimer);
heroResizeTimer = setTimeout(initHeroAnimation, 120);
}
function getNavScrollOffset(){
return readCssPx('--site-header-height', 57) + 24;
}
function getSectionScrollInset(target){
const paddingTop = parseFloat(getComputedStyle(target).paddingTop);
return Number.isFinite(paddingTop) ? paddingTop : 0;
}
function scrollToSection(target){
const offset = getNavScrollOffset();
let top = target.getBoundingClientRect().top + window.scrollY - offset + getSectionScrollInset(target);
const belowHero = section
&& (section.compareDocumentPosition(target) & Node.DOCUMENT_POSITION_FOLLOWING);
const canSubtractRunway = subtractRunwayOnMobile || !heroIsMobile;
if (belowHero && heroRunway > 0 && canSubtractRunway && window.scrollY < heroStartY + heroRunway){
top -= heroRunway;
}
window.scrollTo({
top: Math.max(top, 0),
behavior: prefersReducedMotion ? 'auto' : 'smooth'
});
}
window.addEventListener('scroll', onHeroScroll, { passive: true });
window.addEventListener('resize', scheduleHeroMeasure);
window.addEventListener('load', initHeroAnimation);
if (brandInner && window.ResizeObserver){
const heroResizeObserver = new ResizeObserver(scheduleHeroMeasure);
heroResizeObserver.observe(brandInner);
}
if (siteHeader && window.ResizeObserver){
const headerResizeObserver = new ResizeObserver(scheduleHeroMeasure);
headerResizeObserver.observe(siteHeader);
}
initHeroAnimation();
document.querySelectorAll('.main-nav a[href^="#"]').forEach(link => {
link.addEventListener('click', e => {
const target = document.querySelector(link.getAttribute('href'));
if (!target) return;
e.preventDefault();
scrollToSection(target);
});
});
}
// ---------- REVEAL ON SCROLL ----------
function initReveal(){
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting){
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.15 });
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
}
// ---------- МОДАЛКА + ФОРМА ----------
function initContactForm(){
const modal = document.getElementById('contactModal');
document.getElementById('openContactForm').addEventListener('click', () => modal.classList.add('open'));
document.getElementById('closeContactForm').addEventListener('click', () => modal.classList.remove('open'));
modal.addEventListener('click', e => { if (e.target === modal) modal.classList.remove('open'); });
const form = document.getElementById('contactForm');
const status = document.getElementById('formStatus');
form.addEventListener('submit', async (e) => {
e.preventDefault();
status.textContent = form.dataset.statusLoading || '...';
const data = Object.fromEntries(new FormData(form).entries());
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error('request_failed');
status.textContent = form.dataset.statusSuccess || '✓';
form.reset();
setTimeout(() => modal.classList.remove('open'), 1200);
} catch (err) {
status.textContent = form.dataset.statusError || 'Error';
}
});
}