/* ============================================================ SCRIPTS — Mitko Stojanov (Boxy / Editorial + All Animations) Animations REPLAY when elements re-enter the viewport. ============================================================ */ document.addEventListener('DOMContentLoaded', () => { const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // ── 0. PAGE LOAD FADE ─────────────────────────────────── requestAnimationFrame(() => { document.body.classList.remove('is-loading'); }); // ── 1. SCROLL REVEAL (basic fade-up) — repeats ───────── const revealObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('active'); } else { entry.target.classList.remove('active'); } }); }, { threshold: 0.1, rootMargin: '0px 0px -50px 0px' }); document.querySelectorAll('.reveal').forEach(el => revealObserver.observe(el)); // ── 2. SLIDE-IN LEFT / RIGHT — repeats ────────────────── const slideObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('active'); } else { entry.target.classList.remove('active'); } }); }, { threshold: 0.15, rootMargin: '0px 0px -40px 0px' }); document.querySelectorAll('.reveal-left, .reveal-right').forEach(el => slideObserver.observe(el)); // ── 3. CLIP REVEAL (heading) — repeats ────────────────── const clipObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('active'); } else { entry.target.classList.remove('active'); } }); }, { threshold: 0.5 }); document.querySelectorAll('.clip-reveal').forEach(el => clipObserver.observe(el)); // ── 4. CARD BORDER-DRAW + ICON FADE — repeats ─────────── const cardObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('in-view'); } else { entry.target.classList.remove('in-view'); } }); }, { threshold: 0.2 }); document.querySelectorAll('.card').forEach(el => cardObserver.observe(el)); // ── 5. STAT CELL TOP-SWEEP — repeats ──────────────────── const statObserver = new IntersectionObserver((entries) => { entries.forEach((entry, i) => { if (entry.isIntersecting) { setTimeout(() => { entry.target.classList.add('in-view'); }, i * 150); } else { entry.target.classList.remove('in-view'); } }); }, { threshold: 0.5 }); document.querySelectorAll('.stat-cell').forEach(el => statObserver.observe(el)); // ── 6. SCRAMBLE COUNTER — repeats ─────────────────────── // Track running animation frames so we can cancel on exit const counterAnimations = new WeakMap(); const counterObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { // Cancel any leftover animation first const existingRaf = counterAnimations.get(entry.target); if (existingRaf) cancelAnimationFrame(existingRaf); const target = parseInt(entry.target.getAttribute('data-counter')); if (reducedMotion) { entry.target.textContent = target + '+'; } else { scrambleCounter(entry.target, target); } } else { // Cancel running animation and reset const raf = counterAnimations.get(entry.target); if (raf) { cancelAnimationFrame(raf); counterAnimations.delete(entry.target); } entry.target.textContent = '0'; } }); }, { threshold: 0.5 }); document.querySelectorAll('[data-counter]').forEach(el => counterObserver.observe(el)); function scrambleCounter(element, target) { const suffix = '+'; const targetStr = target.toString(); const digits = targetStr.length; const totalDuration = 1600; const scramblePhase = 700; const startTime = performance.now(); function update(currentTime) { const elapsed = currentTime - startTime; if (elapsed < scramblePhase) { let text = ''; for (let i = 0; i < digits; i++) { text += Math.floor(Math.random() * 10); } element.textContent = text + suffix; counterAnimations.set(element, requestAnimationFrame(update)); } else if (elapsed < totalDuration) { const resolveProgress = (elapsed - scramblePhase) / (totalDuration - scramblePhase); const resolvedCount = Math.ceil(resolveProgress * digits); let text = ''; for (let i = 0; i < digits; i++) { if (i < resolvedCount) { text += targetStr[i]; } else { text += Math.floor(Math.random() * 10); } } element.textContent = text + suffix; counterAnimations.set(element, requestAnimationFrame(update)); } else { element.textContent = target + suffix; counterAnimations.delete(element); } } counterAnimations.set(element, requestAnimationFrame(update)); } // ── 7. TYPEWRITER — repeats ───────────────────────────── // Track active intervals per element to avoid conflicts const typewriterIntervals = new WeakMap(); const typewriterObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { // Don't restart if already typing or typed if (entry.target.classList.contains('typing') || entry.target.classList.contains('typed')) return; if (reducedMotion) { entry.target.classList.add('typed'); entry.target.querySelector('.typewriter-output').textContent = entry.target.getAttribute('data-text'); } else { typewrite(entry.target); } } else { // Clear any running interval for this element const interval = typewriterIntervals.get(entry.target); if (interval) { clearInterval(interval); typewriterIntervals.delete(entry.target); } entry.target.classList.remove('typing', 'typed'); const output = entry.target.querySelector('.typewriter-output'); if (output) output.textContent = ''; } }); }, { threshold: 0.5 }); document.querySelectorAll('.typewriter-wrap').forEach(el => typewriterObserver.observe(el)); function typewrite(wrapper) { const text = wrapper.getAttribute('data-text'); if (!text) return; const output = wrapper.querySelector('.typewriter-output'); if (!output) return; // Kill any existing interval FIRST to prevent double-runs const existing = typewriterIntervals.get(wrapper); if (existing) { clearInterval(existing); typewriterIntervals.delete(wrapper); } output.textContent = ''; wrapper.classList.remove('typed'); wrapper.classList.add('typing'); let i = 0; const interval = setInterval(() => { if (i < text.length) { output.textContent += text[i]; i++; } if (i >= text.length) { clearInterval(interval); typewriterIntervals.delete(wrapper); wrapper.classList.remove('typing'); wrapper.classList.add('typed'); } }, 35); typewriterIntervals.set(wrapper, interval); } // ── 8. TAG CASCADE — repeats ──────────────────────────── const tagObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const tags = entry.target.querySelectorAll('.tag'); if (reducedMotion) { entry.target.classList.add('cascading'); } else { tags.forEach((tag, i) => { tag.style.transitionDelay = `${i * 60}ms`; }); requestAnimationFrame(() => { entry.target.classList.add('cascading'); }); } } else { entry.target.classList.remove('cascading'); } }); }, { threshold: 0.2 }); document.querySelectorAll('.tag-cascade').forEach(el => tagObserver.observe(el)); // ── 9. TEXT HIGHLIGHT SWEEP — repeats ──────────────────── const highlightObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('highlighted'); } else { entry.target.classList.remove('highlighted'); } }); }, { threshold: 0.8 }); document.querySelectorAll('.text-highlight').forEach(el => highlightObserver.observe(el)); // ── 10. NAVBAR SCROLL ─────────────────────────────────── const navbar = document.getElementById('navbar'); window.addEventListener('scroll', () => { navbar.classList.toggle('scrolled', window.scrollY > 50); }, { passive: true }); // ── 11. ACTIVE NAV LINK ───────────────────────────────── const sections = document.querySelectorAll('section[id]'); const navLinks = document.querySelectorAll('.nav-link'); const sectionObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const id = entry.target.getAttribute('id'); navLinks.forEach(link => { link.classList.toggle('active', link.getAttribute('href') === `#${id}`); }); } }); }, { threshold: 0.3, rootMargin: '-80px 0px -50% 0px' }); sections.forEach(section => sectionObserver.observe(section)); // ── 12. MOBILE MENU ───────────────────────────────────── const mobileMenuBtn = document.getElementById('mobile-menu-btn'); const mobileMenu = document.getElementById('mobile-menu'); mobileMenuBtn.addEventListener('click', () => mobileMenu.classList.toggle('hidden')); document.querySelectorAll('.mobile-link').forEach(link => { link.addEventListener('click', () => mobileMenu.classList.add('hidden')); }); // ── 13. SMOOTH SCROLL ─────────────────────────────────── document.querySelectorAll('a[href^="#"]').forEach(anchor => { anchor.addEventListener('click', function (e) { e.preventDefault(); const targetEl = document.querySelector(this.getAttribute('href')); if (targetEl) { const top = targetEl.getBoundingClientRect().top + window.scrollY - navbar.offsetHeight - 24; window.scrollTo({ top, behavior: 'smooth' }); } }); }); // ── 14. SCROLL PROGRESS BAR ───────────────────────────── const progressBar = document.getElementById('scroll-progress'); if (progressBar) { window.addEventListener('scroll', () => { const scrollTop = window.scrollY; const docHeight = document.documentElement.scrollHeight - window.innerHeight; const progress = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0; progressBar.style.width = progress + '%'; }, { passive: true }); } // ── 15. HERO LOAD SEQUENCE ────────────────────────────── // NOTE: .typewriter-wrap is handled exclusively by the typewriterObserver // to prevent double-firing race conditions setTimeout(() => { const heroEls = document.querySelectorAll('#top .reveal, #top .clip-reveal'); heroEls.forEach((el, i) => { setTimeout(() => { if (el.classList.contains('clip-reveal')) { el.classList.add('active'); } else { el.classList.add('active'); } }, i * 150); }); // Line-draw after hero elements const lineEl = document.querySelector('.line-draw'); if (lineEl) { setTimeout(() => lineEl.classList.add('drawn'), heroEls.length * 150 + 200); } }, 400); // ── 16. REDUCED MOTION FALLBACK ───────────────────────── if (reducedMotion) { document.querySelectorAll('.reveal, .reveal-left, .reveal-right, .clip-reveal').forEach(el => { el.style.transition = 'none'; el.classList.add('active'); }); document.querySelectorAll('.card').forEach(el => el.classList.add('in-view')); document.querySelectorAll('.stat-cell').forEach(el => el.classList.add('in-view')); document.querySelectorAll('.tag-cascade').forEach(el => el.classList.add('cascading')); document.querySelectorAll('.text-highlight').forEach(el => el.classList.add('highlighted')); document.querySelectorAll('.typewriter-wrap').forEach(el => { el.classList.add('typed'); const output = el.querySelector('.typewriter-output'); if (output) output.textContent = el.getAttribute('data-text'); }); document.querySelectorAll('.line-draw').forEach(el => { el.style.height = '40px'; el.style.opacity = '1'; }); document.querySelectorAll('.icon-box').forEach(el => { el.style.opacity = '1'; el.style.transform = 'none'; }); if (progressBar) progressBar.style.display = 'none'; } });