Raiju/js/landing-effects.js
2026-10-25 06:05:42 +03:00

422 lines
16 KiB
JavaScript

/**
* Landing Page Effects
* Parallax, animated counters, header transparency, step connector animation.
* Only active on .page-landing pages. Respects prefers-reduced-motion.
*/
(function () {
'use strict';
// Skip everything if not on the landing page
if (!document.body.classList.contains('page-landing')) return;
// Respect reduced motion preference
var prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// ============================================================
// 1. HEADER TRANSPARENCY
// ============================================================
var header = document.getElementById('siteHeader');
var heroSection = document.querySelector('.landing-hero');
if (header && heroSection) {
// Start transparent
header.classList.add('header-transparent');
var heroObserver = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
header.classList.add('header-transparent');
} else {
header.classList.remove('header-transparent');
}
});
}, {
threshold: 0,
rootMargin: '-80px 0px 0px 0px'
});
heroObserver.observe(heroSection);
}
// ============================================================
// 2. PARALLAX SCROLLING
// ============================================================
if (!prefersReducedMotion) {
var parallaxElements = document.querySelectorAll('[data-parallax]');
if (parallaxElements.length) {
var ticking = false;
function updateParallax() {
var scrollY = window.scrollY;
parallaxElements.forEach(function (el) {
var speed = parseFloat(el.dataset.parallax) || 0.05;
var rect = el.getBoundingClientRect();
var offset = (scrollY - el.offsetTop + window.innerHeight) * speed;
el.style.transform = 'translateY(' + offset + 'px)';
});
ticking = false;
}
window.addEventListener('scroll', function () {
if (!ticking) {
window.requestAnimationFrame(updateParallax);
ticking = true;
}
}, { passive: true });
}
}
// ============================================================
// 3. ANIMATED COUNTERS
// ============================================================
if (!prefersReducedMotion) {
var counterElements = document.querySelectorAll('[data-count-to]');
if (counterElements.length) {
var counterObserver = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
animateCounter(entry.target);
counterObserver.unobserve(entry.target);
}
});
}, {
threshold: 0.5
});
counterElements.forEach(function (el) {
counterObserver.observe(el);
});
}
}
function animateCounter(el) {
var target = el.dataset.countTo;
var prefix = '';
var suffix = '';
var numericTarget = 0;
// Extract numeric value and any prefix/suffix (e.g. "<500KB", "100%", "∞")
var match = target.match(/^([^0-9]*)(\d+)(.*)$/);
if (!match) {
// Non-numeric values like "∞" — just show immediately
return;
}
prefix = match[1];
numericTarget = parseInt(match[2], 10);
suffix = match[3];
var duration = 1500;
var startTime = null;
function easeOutExpo(t) {
return t === 1 ? 1 : 1 - Math.pow(2, -10 * t);
}
function step(timestamp) {
if (!startTime) startTime = timestamp;
var progress = Math.min((timestamp - startTime) / duration, 1);
var easedProgress = easeOutExpo(progress);
var currentValue = Math.round(numericTarget * easedProgress);
el.textContent = prefix + currentValue + suffix;
if (progress < 1) {
requestAnimationFrame(step);
}
}
requestAnimationFrame(step);
}
// ============================================================
// 4. VIBECODING ACCORDION
// ============================================================
var accordionCards = document.querySelectorAll('.vibecoding-card');
if (accordionCards.length) {
accordionCards.forEach(function (card) {
var trigger = card.querySelector('.vibecoding-card-trigger');
if (!trigger) return;
trigger.addEventListener('click', function () {
// Skip accordion toggle in edit mode — all cards are shown expanded
if (document.body.classList.contains('edit-mode-active')) return;
var isActive = card.classList.contains('vibecoding-card-active');
// Close all
accordionCards.forEach(function (c) {
c.classList.remove('vibecoding-card-active');
var t = c.querySelector('.vibecoding-card-trigger');
if (t) t.setAttribute('aria-expanded', 'false');
});
// Open clicked (unless it was already open)
if (!isActive) {
card.classList.add('vibecoding-card-active');
trigger.setAttribute('aria-expanded', 'true');
}
});
});
// Auto-cycle when section is sufficiently visible in the viewport.
// Stops when the section is less than 50% visible to prevent layout
// shifts from off-screen accordion transitions.
var autoInterval = null;
var userInteracted = false;
var sectionVisible = false;
function startAutoCycle() {
if (autoInterval || prefersReducedMotion) return;
autoInterval = setInterval(function () {
if (userInteracted || !sectionVisible) return;
if (document.body.classList.contains('edit-mode-active')) return;
var activeCard = document.querySelector('.vibecoding-card-active');
var cards = Array.from(accordionCards);
var idx = activeCard ? cards.indexOf(activeCard) : -1;
var next = (idx + 1) % cards.length;
cards.forEach(function (c) {
c.classList.remove('vibecoding-card-active');
var t = c.querySelector('.vibecoding-card-trigger');
if (t) t.setAttribute('aria-expanded', 'false');
});
cards[next].classList.add('vibecoding-card-active');
var nextTrigger = cards[next].querySelector('.vibecoding-card-trigger');
if (nextTrigger) nextTrigger.setAttribute('aria-expanded', 'true');
}, 4000);
}
// Observe visibility: start cycling when ≥50% visible, pause when not
var accordionSection = document.querySelector('.landing-vibecoding');
if (accordionSection) {
var accObserver = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
sectionVisible = entry.isIntersecting;
if (sectionVisible && !userInteracted) {
startAutoCycle();
}
});
}, { threshold: 0.5 });
accObserver.observe(accordionSection);
}
// Stop auto-cycle permanently on user interaction
accordionCards.forEach(function (card) {
card.addEventListener('click', function () {
userInteracted = true;
if (autoInterval) {
clearInterval(autoInterval);
autoInterval = null;
}
});
});
}
// ============================================================
// 5. STEPS SEQUENCED ANIMATION
// ============================================================
// Sequence: dot1 pops → text1 fades in → line grows to 50% →
// dot2 pops → text2 fades in → line grows to 100% →
// dot3 pops → text3 fades in
if (!prefersReducedMotion) {
var stepsGrid = document.querySelector('.steps-grid');
var connectorFill = document.querySelector('.steps-connector-fill');
var stepCards = stepsGrid ? stepsGrid.querySelectorAll('.step-card') : [];
if (stepsGrid && stepCards.length) {
// Mark grid for CSS animation states (cards start hidden)
stepsGrid.classList.add('steps-animate');
// Connector fill starts at 0 width, we'll animate via JS
if (connectorFill) {
connectorFill.style.width = '0';
connectorFill.style.transition = 'none';
}
function runStepsSequence() {
var delays = { dot: 500, text: 250, line: 600 };
var t = 0;
var lineStops = [50, 100]; // percentage stops between dots
stepCards.forEach(function (card, i) {
// Pop the dot
setTimeout(function () {
card.classList.add('step-visible');
}, t);
t += delays.dot;
// Fade in title + description
var title = card.querySelector('.step-title');
var desc = card.querySelector('.step-desc');
if (title) {
(function (el, d) {
setTimeout(function () { el.style.animationDelay = '0ms'; }, d);
})(title, t);
}
if (desc) {
(function (el, d) {
setTimeout(function () { el.style.animationDelay = '120ms'; }, d);
})(desc, t);
}
t += delays.text;
// Grow the connector line to the next stop
if (connectorFill && i < stepCards.length - 1) {
(function (pct, d) {
setTimeout(function () {
connectorFill.style.transition =
'width ' + delays.line + 'ms cubic-bezier(0.16, 1, 0.3, 1)';
connectorFill.style.width = pct + '%';
}, d);
})(lineStops[i], t);
t += delays.line;
}
});
}
var stepsObserver = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
// Small delay after reveal fade-in completes
setTimeout(runStepsSequence, 300);
stepsObserver.unobserve(entry.target);
}
});
}, { threshold: 0.3 });
stepsObserver.observe(stepsGrid);
}
}
// ============================================================
// 6. INTERACTIVE FEATURES HALO + CARD GLOW
// ============================================================
var featuresSection = document.querySelector('.landing-features');
var halo = document.getElementById('featuresHalo');
var featureCards = document.querySelectorAll('.feature-card');
if (featuresSection && halo && featureCards.length) {
// Find mascot (home position for halo)
var mascot = featuresSection.querySelector('.features-mascot');
// Spring physics state
var haloX = 0;
var haloY = 0;
var haloVX = 0;
var haloVY = 0;
var targetX = 0;
var targetY = 0;
// Spring constants
var stiffness = 0.04;
var damping = 0.75;
var isOverSection = false;
var isOverCard = false;
var haloActive = false;
// Get home position (mascot center, relative to section)
function getHomePos() {
if (!mascot) return { x: featuresSection.offsetWidth / 2, y: featuresSection.offsetHeight / 2 };
var sRect = featuresSection.getBoundingClientRect();
var mRect = mascot.getBoundingClientRect();
return {
x: mRect.left - sRect.left + mRect.width / 2,
y: mRect.top - sRect.top + mRect.height / 2
};
}
// Initialize position
var home = getHomePos();
haloX = home.x;
haloY = home.y;
targetX = home.x;
targetY = home.y;
halo.style.left = haloX + 'px';
halo.style.top = haloY + 'px';
// Card glow: track mouse position relative to each card
featureCards.forEach(function (card) {
card.addEventListener('mousemove', function (e) {
var rect = card.getBoundingClientRect();
var x = e.clientX - rect.left;
var y = e.clientY - rect.top;
card.style.setProperty('--glow-x', x + 'px');
card.style.setProperty('--glow-y', y + 'px');
});
card.addEventListener('mouseenter', function () {
isOverCard = true;
var sRect = featuresSection.getBoundingClientRect();
var cRect = card.getBoundingClientRect();
targetX = cRect.left - sRect.left + cRect.width / 2;
targetY = cRect.top - sRect.top + cRect.height / 2;
});
card.addEventListener('mouseleave', function () {
isOverCard = false;
var h = getHomePos();
targetX = h.x;
targetY = h.y;
});
});
// Section mouse tracking
featuresSection.addEventListener('mouseenter', function () {
isOverSection = true;
if (!haloActive) {
haloActive = true;
animateHalo();
}
});
featuresSection.addEventListener('mousemove', function (e) {
if (!isOverCard) {
var sRect = featuresSection.getBoundingClientRect();
targetX = e.clientX - sRect.left;
targetY = e.clientY - sRect.top;
}
});
featuresSection.addEventListener('mouseleave', function () {
isOverSection = false;
isOverCard = false;
var h = getHomePos();
targetX = h.x;
targetY = h.y;
});
function animateHalo() {
// Spring force
var dx = targetX - haloX;
var dy = targetY - haloY;
var ax = dx * stiffness;
var ay = dy * stiffness;
haloVX = (haloVX + ax) * damping;
haloVY = (haloVY + ay) * damping;
haloX += haloVX;
haloY += haloVY;
halo.style.left = haloX + 'px';
halo.style.top = haloY + 'px';
// Keep animating if still moving or section is hovered
var speed = Math.abs(haloVX) + Math.abs(haloVY);
var distToTarget = Math.abs(dx) + Math.abs(dy);
if (isOverSection || speed > 0.1 || distToTarget > 1) {
requestAnimationFrame(animateHalo);
} else {
haloActive = false;
}
}
}
})();