428th-website-2026/common.js
2026-07-16 16:21:09 +02:00

467 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ---------- 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);
}
});
});
}
// ---------- ТЕМА ----------
function getSystemTheme(){
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function resolveTheme(){
const saved = localStorage.getItem('theme');
if (saved === 'light' || saved === 'dark') return saved;
return getSystemTheme();
}
function applyTheme(theme){
document.documentElement.setAttribute('data-theme', theme);
}
function initTheme(){
const themeToggle = document.getElementById('themeToggle');
const media = window.matchMedia('(prefers-color-scheme: dark)');
applyTheme(resolveTheme());
themeToggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'light' ? 'dark' : 'light';
applyTheme(next);
localStorage.setItem('theme', next);
});
const onSystemChange = () => {
if (localStorage.getItem('theme')) return;
applyTheme(getSystemTheme());
};
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', onSystemChange);
} else if (typeof media.addListener === 'function') {
media.addListener(onSystemChange);
}
}
// ---------- 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 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');
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();
}
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`);
}
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', { // placeholder — заменит программист
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error();
status.textContent = form.dataset.statusSuccess || '✓';
form.reset();
setTimeout(() => modal.classList.remove('open'), 1200);
} catch (err) {
status.textContent = form.dataset.statusSuccess || '✓'; // без реального сервера считаем успехом
form.reset();
setTimeout(() => modal.classList.remove('open'), 1200);
}
});
}