homelab/services/burka_wed/script.js
2026-06-01 21:42:59 +03:00

713 lines
27 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.

/* =============================================
WEDDING SITE — АЛЕКСАНДР & ЮЛИЯ
============================================= */
// ---- Обратный отсчёт ----
const WEDDING_DATE = new Date('2026-08-22T16:00:00+03:00');
function updateCountdown() {
const now = new Date();
const diff = WEDDING_DATE - now;
if (diff <= 0) {
document.getElementById('countdown').innerHTML =
'<p style="font-size:1.5rem;font-weight:700;color:var(--purple)">🎉 Сегодня наша свадьба!</p>';
return;
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
document.getElementById('cd-days').textContent = String(days);
document.getElementById('cd-hours').textContent = String(hours).padStart(2, '0');
document.getElementById('cd-minutes').textContent = String(minutes).padStart(2, '0');
document.getElementById('cd-seconds').textContent = String(seconds).padStart(2, '0');
}
updateCountdown();
setInterval(updateCountdown, 1000);
// ---- Конфетти ----
const CONFETTI_COLORS = ['#FFD166', '#06D6A0', '#9B5DE5', '#F15BB5', '#00BBF9', '#FF6B6B'];
const CONFETTI_SHAPES = ['border-radius:2px', 'border-radius:50%', 'clip-path:polygon(50% 0%,0% 100%,100% 100%)'];
// Фоновый дождь конфетти на герое
(function spawnConfetti() {
const container = document.getElementById('confetti');
for (let i = 0; i < 60; i++) {
const el = document.createElement('div');
el.className = 'confetti-piece';
const size = Math.random() * 8 + 6;
el.style.cssText = `
left: ${Math.random() * 100}%;
width: ${size}px;
height: ${size}px;
background: ${CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)]};
${CONFETTI_SHAPES[Math.floor(Math.random() * CONFETTI_SHAPES.length)]};
animation-duration: ${Math.random() * 6 + 5}s;
animation-delay: ${Math.random() * 8}s;
`;
container.appendChild(el);
}
})();
// Взрыв конфетти из заданной точки
function burstConfetti(x, y, count = 40) {
for (let i = 0; i < count; i++) {
const el = document.createElement('div');
el.className = 'confetti-burst';
const size = Math.random() * 9 + 5;
const angle = Math.random() * 360;
const dist = Math.random() * 160 + 60;
const color = CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)];
const shape = CONFETTI_SHAPES[Math.floor(Math.random() * CONFETTI_SHAPES.length)];
const dur = Math.random() * 600 + 700;
el.style.cssText = `
position: fixed;
left: ${x}px;
top: ${y}px;
width: ${size}px;
height: ${size}px;
background: ${color};
${shape};
pointer-events: none;
z-index: 9999;
transform: translate(-50%, -50%);
animation: burst ${dur}ms ease-out forwards;
--dx: ${Math.cos(angle * Math.PI / 180) * dist}px;
--dy: ${Math.sin(angle * Math.PI / 180) * dist}px;
`;
document.body.appendChild(el);
setTimeout(() => el.remove(), dur + 50);
}
}
// Взрыв при клике в любом месте страницы
document.addEventListener('click', (e) => {
burstConfetti(e.clientX, e.clientY, 35);
});
// ===== ДИСКО ШАР =====
(function initDiscoBall() {
const BALL_R = 45; // радиус шара (в 3 раза больше прежнего)
const ROPE_LEN = 18; // верёвка от низа шапки до верха шара
const WIRE_MAX = 26; // максимальная длина провода прожектора
const TILE_S = 7; // размер зеркального тайла
const TILE_G = 1; // зазор между тайлами
const LERP_SPD = 0.10; // скорость выезда/уезда прожектора (0..1)
const DISC_COLORS = ['#ff6b9d','#ffd166','#06d6a0','#00bbf9','#9b5de5','#ffffff','#ff9f43','#a8e6cf'];
const cvs = document.createElement('canvas');
cvs.id = 'disco-canvas';
cvs.style.cssText = 'position:fixed;top:0;left:0;pointer-events:none;z-index:998;';
document.body.appendChild(cvs);
const ctx = cvs.getContext('2d');
let CW = window.innerWidth, CH = 280;
cvs.width = CW; cvs.height = CH;
let rot = 0;
let glints = []; // разлетающиеся блики
let ballPos = null;
// Для каждой нав-ссылки храним прогресс выезда (0 = спрятан, 1 = полностью выехал)
// и флаг — стреляли ли мы уже бликами при этом появлении
const projState = new Map(); // link → { progress, target, glintFired }
function refreshPos() {
const logo = document.querySelector('.nav-logo');
const nav = document.getElementById('navbar');
if (!logo || !nav) return;
const lr = logo.getBoundingClientRect();
const nh = nav.getBoundingClientRect().height;
ballPos = {
x: lr.left + lr.width / 2,
ropeTopY: nh,
ballY: nh + ROPE_LEN + BALL_R,
navH: nh,
};
}
window.addEventListener('resize', () => { CW = cvs.width = window.innerWidth; refreshPos(); });
refreshPos();
// Инициализируем состояние для каждой ссылки
document.querySelectorAll('.nav-links a').forEach(link => {
projState.set(link, { progress: 0, target: 0, glintFired: false });
link.addEventListener('mouseenter', e => {
const s = projState.get(e.currentTarget);
if (s) { s.target = 1; }
});
link.addEventListener('mouseleave', e => {
const s = projState.get(e.currentTarget);
if (s) { s.target = 0; s.glintFired = false; }
});
});
// ---- корпус прожектора Fresnel в локальных координатах (линза смотрит вправо) ----
function drawFresnelBody() {
const BW = 46, BH = 32, FW = 20, LR = 11;
const FLAP_L = 22, FLAP_T = 5.5;
ctx.shadowColor = 'rgba(0,0,0,.7)';
ctx.shadowBlur = 14;
// ── Шторки-барндоры (рисуем ДО корпуса — они «выходят» из него) ─
// Верхняя шторка
ctx.save();
ctx.translate(BW / 2 - FW * 0.5, -BH / 2);
ctx.rotate(-0.52);
ctx.fillStyle = '#161616';
ctx.strokeStyle = '#353535';
ctx.lineWidth = 0.7;
ctx.beginPath();
ctx.rect(-3, -FLAP_T / 2, FLAP_L + 3, FLAP_T);
ctx.fill(); ctx.stroke();
// Шарнир
ctx.fillStyle = '#444'; ctx.strokeStyle = '#666'; ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.arc(0, 0, 2.5, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
// Рёбра жёсткости
for (let i = 6; i < FLAP_L; i += 7) {
ctx.strokeStyle = '#0e0e0e'; ctx.lineWidth = 0.6;
ctx.beginPath(); ctx.moveTo(i, -FLAP_T / 2 + 1); ctx.lineTo(i, FLAP_T / 2 - 1); ctx.stroke();
}
ctx.restore();
// Нижняя шторка
ctx.save();
ctx.translate(BW / 2 - FW * 0.5, BH / 2);
ctx.rotate(0.52);
ctx.fillStyle = '#161616';
ctx.strokeStyle = '#353535'; ctx.lineWidth = 0.7;
ctx.beginPath(); ctx.rect(-3, -FLAP_T / 2, FLAP_L + 3, FLAP_T);
ctx.fill(); ctx.stroke();
ctx.fillStyle = '#444'; ctx.strokeStyle = '#666'; ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.arc(0, 0, 2.5, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
for (let i = 6; i < FLAP_L; i += 7) {
ctx.strokeStyle = '#0e0e0e'; ctx.lineWidth = 0.6;
ctx.beginPath(); ctx.moveTo(i, -FLAP_T / 2 + 1); ctx.lineTo(i, FLAP_T / 2 - 1); ctx.stroke();
}
ctx.restore();
// Боковая шторка (правая, видна при боковом ракурсе)
ctx.save();
ctx.translate(BW / 2, 0);
ctx.rotate(-0.14);
ctx.fillStyle = '#1a1a1a'; ctx.strokeStyle = '#2e2e2e'; ctx.lineWidth = 0.7;
ctx.beginPath(); ctx.rect(0, -BH / 2 - 1, FLAP_L * 0.55, BH + 2);
ctx.fill(); ctx.stroke();
ctx.restore();
// ── Основной корпус ───────────────────────────────────────────
// Внешний кант (тень)
ctx.fillStyle = '#070707';
ctx.fillRect(-BW / 2 - 1.5, -BH / 2 - 1.5, BW + 3, BH + 3);
// Корпус с градиентом
const bg = ctx.createLinearGradient(-BW / 2, -BH / 2, -BW / 2, BH / 2);
bg.addColorStop(0, '#2f2f2f');
bg.addColorStop(0.42, '#1d1d1d');
bg.addColorStop(1, '#111');
ctx.fillStyle = bg;
ctx.fillRect(-BW / 2, -BH / 2, BW, BH);
// Металлическая фаска сверху
ctx.fillStyle = 'rgba(255,255,255,.08)';
ctx.fillRect(-BW / 2, -BH / 2, BW, 2.5);
// Тень снизу
ctx.fillStyle = 'rgba(0,0,0,.18)';
ctx.fillRect(-BW / 2, BH / 2 - 2.5, BW, 2.5);
// Задняя затемнённая секция (радиатор охлаждения)
ctx.fillStyle = 'rgba(0,0,0,.28)';
ctx.fillRect(-BW / 2, -BH / 2, BW * 0.22, BH);
// Рёбра радиатора
ctx.strokeStyle = '#0c0c0c'; ctx.lineWidth = 1.2;
for (let i = 0; i < 3; i++) {
const sx = -BW / 2 + 4 + i * 4.5;
ctx.beginPath(); ctx.moveTo(sx, -BH / 2 + 5); ctx.lineTo(sx, BH / 2 - 5); ctx.stroke();
}
// Вентиляционные прорези (средняя часть корпуса)
ctx.strokeStyle = '#0a0a0a'; ctx.lineWidth = 1.8;
for (let i = 0; i < 4; i++) {
const sx = -BW / 2 + 17 + i * 6;
if (sx > BW / 2 - FW - 5) break;
ctx.beginPath(); ctx.moveTo(sx, -BH / 2 + 6); ctx.lineTo(sx, BH / 2 - 6); ctx.stroke();
}
// Логотип / шильдик на корпусе
ctx.fillStyle = 'rgba(255,255,255,.04)';
ctx.fillRect(-BW / 2 + 18, BH / 2 - 9, 14, 5);
// Ручка/рукоятка сверху корпуса
ctx.fillStyle = '#252525'; ctx.strokeStyle = '#3e3e3e'; ctx.lineWidth = 0.9;
ctx.beginPath(); ctx.rect(-10, -BH / 2 - 10, 20, 10); ctx.fill(); ctx.stroke();
// Насечки на ручке
ctx.strokeStyle = '#4a4a4a'; ctx.lineWidth = 0.55;
for (let i = -7; i <= 7; i += 3.5) {
ctx.beginPath(); ctx.moveTo(i, -BH / 2 - 9); ctx.lineTo(i, -BH / 2 - 2); ctx.stroke();
}
// ── Передняя секция (корпус линзы + рамка шторок) ─────────────
ctx.fillStyle = '#0d0d0d';
ctx.fillRect(BW / 2 - FW, -BH / 2, FW, BH);
// Квадратный фланец под шторки
ctx.strokeStyle = '#2c2c2c'; ctx.lineWidth = 2;
ctx.strokeRect(BW / 2 - FW + 2, -BH / 2 + 2, FW - 4, BH - 4);
// Болты фланца
[
[BW / 2 - FW + 5, -BH / 2 + 5], [BW / 2 - FW + 5, BH / 2 - 5],
[BW / 2 - 4, -BH / 2 + 5], [BW / 2 - 4, BH / 2 - 5],
].forEach(([bx2, by2]) => {
ctx.beginPath(); ctx.arc(bx2, by2, 1.8, 0, Math.PI * 2);
ctx.fillStyle = '#404040'; ctx.fill();
ctx.strokeStyle = '#666'; ctx.lineWidth = 0.5; ctx.stroke();
// Крест болта
ctx.strokeStyle = '#555'; ctx.lineWidth = 0.4;
ctx.beginPath(); ctx.moveTo(bx2 - 1.2, by2); ctx.lineTo(bx2 + 1.2, by2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(bx2, by2 - 1.2); ctx.lineTo(bx2, by2 + 1.2); ctx.stroke();
});
// ── Линза Fresnel ─────────────────────────────────────────────
const lx = BW / 2 - FW / 2, ly = 0;
// Чёрная оправа линзы
ctx.beginPath(); ctx.arc(lx, ly, LR + 3.5, 0, Math.PI * 2);
ctx.fillStyle = '#060606'; ctx.fill();
ctx.strokeStyle = '#424242'; ctx.lineWidth = 1.3; ctx.stroke();
// Внутренняя тень оправы
ctx.beginPath(); ctx.arc(lx, ly, LR + 2, 0, Math.PI * 2);
ctx.strokeStyle = '#1a1a1a'; ctx.lineWidth = 1; ctx.stroke();
// Концентрические кольца Fresnel (физическая текстура линзы)
for (let ri = LR - 0.5; ri > 1.5; ri -= 2.3) {
ctx.beginPath(); ctx.arc(lx, ly, ri, 0, Math.PI * 2);
const alpha = 0.05 + (LR - ri) * 0.05;
ctx.strokeStyle = `rgba(255,200,60,${alpha})`;
ctx.lineWidth = 1;
ctx.stroke();
}
// Свечение линзы — тёплый вольфрамовый цвет
const lg = ctx.createRadialGradient(lx - 2.5, ly - 2.5, 0, lx, ly, LR + 3.5);
lg.addColorStop(0, 'rgba(255,255,230,1)');
lg.addColorStop(0.22, 'rgba(255,230,140,.97)');
lg.addColorStop(0.55, 'rgba(255,150,20,.78)');
lg.addColorStop(0.82, 'rgba(210,60,0,.32)');
lg.addColorStop(1, 'rgba(150,30,0,0)');
ctx.beginPath(); ctx.arc(lx, ly, LR + 3.5, 0, Math.PI * 2);
ctx.fillStyle = lg; ctx.fill();
// Зеркальный блик на линзе
ctx.beginPath(); ctx.arc(lx - 4, ly - 4, 3, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,.75)'; ctx.fill();
ctx.beginPath(); ctx.arc(lx - 3, ly - 3, 1, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,.95)'; ctx.fill();
// ── Вилка-держатель (yoke / U-кронштейн) ─────────────────────
ctx.shadowBlur = 0;
ctx.strokeStyle = '#3c3c3c'; ctx.lineWidth = 3.5;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(-BW / 4 + 2, -BH / 2 - 2);
ctx.lineTo(-BW / 4 + 2, -BH / 2 - 15);
ctx.lineTo( BW / 4 - 2, -BH / 2 - 15);
ctx.lineTo( BW / 4 - 2, -BH / 2 - 2);
ctx.stroke();
// Скоба кронштейна соединяется горизонтально с креплением
ctx.strokeStyle = '#4a4a4a'; ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(-BW / 4 + 2, -BH / 2 - 15);
ctx.lineTo( BW / 4 - 2, -BH / 2 - 15);
ctx.stroke();
// Шарнирный болт (точка поворота прожектора в кронштейне)
ctx.beginPath(); ctx.arc(0, -BH / 2 - 15, 4.5, 0, Math.PI * 2);
ctx.fillStyle = '#5a5a5a'; ctx.fill();
ctx.strokeStyle = '#888'; ctx.lineWidth = 0.9; ctx.stroke();
ctx.strokeStyle = '#777'; ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.moveTo(-2.5, -BH / 2 - 15); ctx.lineTo(2.5, -BH / 2 - 15); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, -BH / 2 - 17.5); ctx.lineTo(0, -BH / 2 - 12.5); ctx.stroke();
ctx.shadowBlur = 0;
ctx.lineCap = 'butt';
}
// ---- рисуем прожектор с учётом прогресса (0..1) ----
function drawSpotlight(link, progress) {
if (progress < 0.01) return;
const r = link.getBoundingClientRect();
const px = r.left + r.width / 2;
const wireY0 = ballPos.navH;
const wireLen = WIRE_MAX * progress;
const projX = px;
const projY = wireY0 + wireLen;
const bx = ballPos.x, by = ballPos.ballY;
// Угол от центра прожектора к диско-шару
const angle = Math.atan2(by - projY, bx - projX);
// Позиция линзы в мировых координатах
const BW = 46, FW = 20;
const lensLocalX = BW / 2 - FW / 2;
const lensWX = projX + Math.cos(angle) * lensLocalX;
const lensWY = projY + Math.sin(angle) * lensLocalX;
ctx.save();
ctx.globalAlpha = Math.pow(progress, 0.65);
// ── Конус света (рисуем ДО корпуса, чтобы корпус был поверх) ──
const dx = bx - lensWX, dy = by - lensWY;
const beamLen = Math.sqrt(dx * dx + dy * dy);
const beamAng = Math.atan2(dy, dx);
const ha = 0.20;
const beam = ctx.createLinearGradient(lensWX, lensWY, bx, by);
beam.addColorStop(0, 'rgba(255,245,180,.60)');
beam.addColorStop(0.5, 'rgba(255,230,120,.15)');
beam.addColorStop(1, 'rgba(255,200,50,.02)');
ctx.beginPath();
ctx.moveTo(lensWX, lensWY);
ctx.lineTo(lensWX + Math.cos(beamAng - ha) * beamLen, lensWY + Math.sin(beamAng - ha) * beamLen);
ctx.lineTo(lensWX + Math.cos(beamAng + ha) * beamLen, lensWY + Math.sin(beamAng + ha) * beamLen);
ctx.closePath();
ctx.fillStyle = beam;
ctx.fill();
// ── Провод (прямо вниз, к центру корпуса) ────────────────────
ctx.strokeStyle = 'rgba(45,45,45,.92)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(px, wireY0);
ctx.lineTo(projX, projY);
ctx.stroke();
// ── Корпус прожектора ─────────────────────────────────────────
ctx.translate(projX, projY);
ctx.rotate(angle);
drawFresnelBody();
ctx.restore();
}
// ---- рисуем шар ----
function drawBall() {
const cx = ballPos.x, cy = ballPos.ballY;
// Верёвка
ctx.save();
ctx.strokeStyle = 'rgba(110,85,60,.9)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx, ballPos.ropeTopY);
ctx.lineTo(cx, cy - BALL_R);
ctx.stroke();
ctx.restore();
// Клип по кругу
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, BALL_R, 0, Math.PI * 2);
ctx.clip();
// Металлическая основа
const base = ctx.createRadialGradient(cx - BALL_R * 0.28, cy - BALL_R * 0.3, 0, cx, cy, BALL_R);
base.addColorStop(0, '#e8e8e8');
base.addColorStop(0.35,'#999');
base.addColorStop(1, '#0e0e0e');
ctx.fillStyle = base;
ctx.fillRect(cx - BALL_R, cy - BALL_R, BALL_R * 2, BALL_R * 2);
// Суммарный прогресс всех прожекторов (0 = ни одного, 1+ = полностью активны)
let totalProj = 0;
projState.forEach(s => { totalProj += s.progress; });
const colorMix = Math.min(1, totalProj); // 0 = только белый, 1 = полные цвета
// Вспомогательная функция: смешать hex-цвет с белым по коэффициенту t (0=белый, 1=цвет)
function mixWithWhite(hex, t) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const ri = Math.round(r + (255 - r) * (1 - t));
const gi = Math.round(g + (255 - g) * (1 - t));
const bi = Math.round(b + (255 - b) * (1 - t));
return `rgb(${ri},${gi},${bi})`;
}
// Зеркальные тайлы
const step = TILE_S + TILE_G;
for (let ty = -BALL_R; ty < BALL_R; ty += step) {
for (let tx = -BALL_R; tx < BALL_R; tx += step) {
if (tx * tx + ty * ty >= BALL_R * BALL_R) continue;
const angle = Math.atan2(ty, tx);
const dist = Math.sqrt(tx * tx + ty * ty) / BALL_R;
const ci = (
(Math.floor((angle + rot * 2.5) / (Math.PI / 4)) % DISC_COLORS.length + DISC_COLORS.length)
% DISC_COLORS.length + Math.floor(dist * 3)
) % DISC_COLORS.length;
const bright = 0.18 + 0.82 * Math.max(0, Math.cos(angle - rot * 1.5));
const boost = Math.min(0.35, totalProj * 0.25);
// Без прожектора — серебристо-белые тайлы; с прожектором — цветные
const tileColor = colorMix < 0.01
? `hsl(0,0%,${Math.round(55 + bright * 45)}%)`
: mixWithWhite(DISC_COLORS[ci], colorMix);
ctx.fillStyle = tileColor;
ctx.globalAlpha = Math.min(1, bright + boost) * 0.90;
ctx.fillRect(cx + tx, cy + ty, TILE_S, TILE_S);
}
}
ctx.globalAlpha = 1;
// Глянцевый блик (объём)
const shine = ctx.createRadialGradient(cx - BALL_R * 0.32, cy - BALL_R * 0.38, 0, cx, cy, BALL_R);
shine.addColorStop(0, 'rgba(255,255,255,.72)');
shine.addColorStop(0.25, 'rgba(255,255,255,.07)');
shine.addColorStop(1, 'rgba(0,0,0,.55)');
ctx.fillStyle = shine;
ctx.fillRect(cx - BALL_R, cy - BALL_R, BALL_R * 2, BALL_R * 2);
ctx.restore();
// Крепёжный колпачок
ctx.save();
ctx.fillStyle = '#bbb';
ctx.beginPath();
ctx.arc(cx, cy - BALL_R + 3, 5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
// ---- орбитальные блики (масштаб зависит от суммы прожекторов) ----
function drawOrbitGlints() {
let totalProj = 0;
projState.forEach(s => { totalProj += s.progress; });
if (totalProj < 0.05) return;
const cx = ballPos.x, cy = ballPos.ballY;
const count = 12;
for (let i = 0; i < count; i++) {
const a = rot * 4 + i * ((Math.PI * 2) / count);
const r = BALL_R + 6 + 4 * Math.sin(rot * 5 + i * 1.1);
const sx = cx + Math.cos(a) * r;
const sy = cy + Math.sin(a) * r * 0.5;
const al = totalProj * (0.3 + 0.7 * Math.abs(Math.sin(rot * 9 + i * 1.5)));
ctx.beginPath();
ctx.arc(sx, sy, 2.2, 0, Math.PI * 2);
ctx.fillStyle = DISC_COLORS[i % DISC_COLORS.length];
ctx.globalAlpha = Math.min(1, al);
ctx.fill();
}
ctx.globalAlpha = 1;
}
// ---- основной цикл ----
function loop() {
ctx.clearRect(0, 0, CW, CH);
rot += 0.007;
if (!ballPos) { refreshPos(); requestAnimationFrame(loop); return; }
// Обновляем прогресс каждого прожектора (плавный выезд/заезд)
projState.forEach((s, link) => {
s.progress += (s.target - s.progress) * LERP_SPD;
// Стреляем бликами когда прожектор почти полностью выехал
if (s.progress > 0.85 && !s.glintFired) {
s.glintFired = true;
for (let i = 0; i < 16; i++) {
const a = Math.random() * Math.PI * 2;
glints.push({
x: ballPos.x, y: ballPos.ballY,
vx: Math.cos(a) * (Math.random() * 5 + 1.5),
vy: Math.sin(a) * (Math.random() * 5 + 1.5) - 1.5,
life: 1, decay: 0.022 + Math.random() * 0.025,
color: DISC_COLORS[Math.floor(Math.random() * DISC_COLORS.length)],
r: 1.5 + Math.random() * 2.5,
});
}
}
// Рисуем прожектор с текущим прогрессом
drawSpotlight(link, s.progress);
});
// Шар на верёвке
drawBall();
// Орбитальные блики (перед шаром)
drawOrbitGlints();
// Разлетающиеся блики
glints = glints.filter(g => g.life > 0);
glints.forEach(g => {
ctx.beginPath();
ctx.arc(g.x, g.y, g.r, 0, Math.PI * 2);
ctx.fillStyle = g.color;
ctx.globalAlpha = g.life;
ctx.fill();
ctx.globalAlpha = 1;
g.x += g.vx;
g.y += g.vy;
g.vy += 0.09;
g.life -= g.decay;
});
requestAnimationFrame(loop);
}
loop();
})();
// ---- Навигация: бургер-меню ----
const burger = document.getElementById('burger');
const navLinks = document.getElementById('nav-links');
burger.addEventListener('click', () => {
navLinks.classList.toggle('open');
});
// Закрыть меню при клике на ссылку
navLinks.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => navLinks.classList.remove('open'));
});
// ---- Навигация: тень при прокрутке ----
const navbar = document.getElementById('navbar');
window.addEventListener('scroll', () => {
navbar.classList.toggle('scrolled', window.scrollY > 20);
});
// ---- FAQ: аккордеон ----
document.querySelectorAll('.faq-question').forEach(btn => {
btn.addEventListener('click', () => {
const expanded = btn.getAttribute('aria-expanded') === 'true';
const answer = btn.nextElementSibling;
// Закрыть все остальные
document.querySelectorAll('.faq-question').forEach(b => {
b.setAttribute('aria-expanded', 'false');
b.nextElementSibling.classList.remove('open');
});
// Переключить текущий
if (!expanded) {
btn.setAttribute('aria-expanded', 'true');
answer.classList.add('open');
}
});
});
// ---- Яндекс.Карта: проверка загрузки iframe ----
const ymapFrame = document.getElementById('ymap-frame');
const mapFallback = document.getElementById('map-fallback');
// Подставляем реальный iframe с картой через Yandex Maps short link
// Если embed не поддерживается — показываем кнопку
if (ymapFrame) {
mapFallback.style.display = 'none';
}
// ---- Галерея: лайтбокс ----
const lightbox = document.getElementById('lightbox');
const lightboxImg = lightbox?.querySelector('.lightbox-img');
const galleryItems = Array.from(document.querySelectorAll('.gallery-item'));
let lightboxIndex = 0;
function openLightbox(index) {
if (!lightbox || !lightboxImg || !galleryItems.length) return;
lightboxIndex = index;
const img = galleryItems[lightboxIndex].querySelector('img');
lightboxImg.src = img.src;
lightboxImg.alt = img.alt;
lightbox.hidden = false;
lightbox.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
}
function closeLightbox() {
if (!lightbox) return;
lightbox.hidden = true;
lightbox.setAttribute('aria-hidden', 'true');
lightboxImg.src = '';
document.body.style.overflow = '';
}
function showLightboxStep(delta) {
if (!galleryItems.length) return;
lightboxIndex = (lightboxIndex + delta + galleryItems.length) % galleryItems.length;
const img = galleryItems[lightboxIndex].querySelector('img');
lightboxImg.src = img.src;
lightboxImg.alt = img.alt;
}
galleryItems.forEach((item, index) => {
item.addEventListener('click', () => openLightbox(index));
});
lightbox?.querySelector('.lightbox-close')?.addEventListener('click', closeLightbox);
lightbox?.querySelector('.lightbox-prev')?.addEventListener('click', () => showLightboxStep(-1));
lightbox?.querySelector('.lightbox-next')?.addEventListener('click', () => showLightboxStep(1));
lightbox?.addEventListener('click', (e) => {
if (e.target === lightbox) closeLightbox();
});
document.addEventListener('keydown', (e) => {
if (lightbox?.hidden) return;
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') showLightboxStep(-1);
if (e.key === 'ArrowRight') showLightboxStep(1);
});
// ---- Плавное появление секций при прокрутке ----
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 }
);
document.querySelectorAll('.timeline-card, .faq-item, .gallery-item, .coordinator-card, .stat-card, .stats-two-col').forEach(el => {
el.style.opacity = '0';
el.style.transform = 'translateY(24px)';
el.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
observer.observe(el);
});
document.addEventListener('animationend', () => {}, { once: true });
// Добавляем класс visible через CSS
const style = document.createElement('style');
style.textContent = '.visible { opacity: 1 !important; transform: none !important; }';
document.head.appendChild(style);