'use strict'; document.addEventListener('DOMContentLoaded', () => { const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // ─── Particles ─────────────────────────────────────────── if (!prefersReducedMotion) { const canvas = document.getElementById('particles-canvas'); const ctx = canvas.getContext('2d'); let particles = []; let animId; let mouseX = -1000; let mouseY = -1000; function resizeCanvas() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; } function createParticles(count) { particles = []; for (let i = 0; i < count; i++) { particles.push({ x: Math.random() * canvas.width, y: Math.random() * canvas.height, vx: (Math.random() - 0.5) * 0.8, vy: (Math.random() - 0.5) * 0.8, size: Math.random() * 2.5 + 0.5, opacity: Math.random() * 0.5 + 0.2, }); } } function drawParticles() { ctx.clearRect(0, 0, canvas.width, canvas.height); for (const p of particles) { p.x += p.vx; p.y += p.vy; if (p.x < 0 || p.x > canvas.width) p.vx *= -1; if (p.y < 0 || p.y > canvas.height) p.vy *= -1; const dx = p.x - mouseX; const dy = p.y - mouseY; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < 120) { p.vx += dx * 0.0005; p.vy += dy * 0.0005; const maxSpeed = 2; const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy); if (speed > maxSpeed) { p.vx = (p.vx / speed) * maxSpeed; p.vy = (p.vy / speed) * maxSpeed; } } ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fillStyle = `rgba(124, 58, 237, ${p.opacity})`; ctx.fill(); } // draw connections for (let i = 0; i < particles.length; i++) { for (let j = i + 1; j < particles.length; j++) { const dx = particles[i].x - particles[j].x; const dy = particles[i].y - particles[j].y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < 150) { ctx.beginPath(); ctx.moveTo(particles[i].x, particles[i].y); ctx.lineTo(particles[j].x, particles[j].y); ctx.strokeStyle = `rgba(6, 182, 212, ${0.12 * (1 - dist / 150)})`; ctx.lineWidth = 0.8; ctx.stroke(); } } } animId = requestAnimationFrame(drawParticles); } function initParticles() { resizeCanvas(); const count = Math.min(Math.floor((canvas.width * canvas.height) / 12000), 120); createParticles(count); drawParticles(); } window.addEventListener('resize', () => { resizeCanvas(); createParticles(Math.min(Math.floor((canvas.width * canvas.height) / 12000), 120)); }); document.addEventListener('mousemove', (e) => { mouseX = e.clientX; mouseY = e.clientY; }); document.addEventListener('touchmove', (e) => { const touch = e.touches[0]; mouseX = touch.clientX; mouseY = touch.clientY; }); initParticles(); } // ─── Typewriter ───────────────────────────────────────── const typewriterEl = document.querySelector('.typewriter'); if (typewriterEl) { const text = typewriterEl.dataset.text || ''; const speed = 60; let charIndex = 0; let isDeleting = false; const cursor = document.createElement('span'); cursor.className = 'cursor'; cursor.setAttribute('aria-hidden', 'true'); typewriterEl.appendChild(cursor); // clear initial content typewriterEl.childNodes.forEach((node, i) => { if (node.nodeType === Node.TEXT_NODE) node.textContent = ''; }); function typeStep() { if (!isDeleting) { if (charIndex < text.length) { typewriterEl.textContent = text.slice(0, charIndex + 1); typewriterEl.appendChild(cursor); charIndex++; setTimeout(typeStep, speed); } else { if (!prefersReducedMotion) { setTimeout(() => { isDeleting = true; typeStep(); }, 3000); } } } else { if (charIndex > 0) { charIndex--; typewriterEl.textContent = text.slice(0, charIndex); typewriterEl.appendChild(cursor); setTimeout(typeStep, speed * 0.6); } else { isDeleting = false; setTimeout(typeStep, 1000); } } } typeStep(); } // ─── Navbar scroll effect ─────────────────────────────── const navbar = document.querySelector('.navbar'); let lastScroll = 0; function handleNavScroll() { const currentScroll = window.scrollY; if (currentScroll > 50) { navbar.classList.add('scrolled'); } else { navbar.classList.remove('scrolled'); } lastScroll = currentScroll; } window.addEventListener('scroll', handleNavScroll, { passive: true }); // ─── Hamburger menu ───────────────────────────────────── const hamburger = document.querySelector('.hamburger'); const navLinks = document.querySelector('.nav-links'); function toggleMenu() { const isOpen = navLinks.classList.toggle('open'); hamburger.classList.toggle('active'); hamburger.setAttribute('aria-expanded', isOpen); document.body.style.overflow = isOpen ? 'hidden' : ''; } if (hamburger) { hamburger.addEventListener('click', toggleMenu); } // close menu on link click document.querySelectorAll('.nav-links a').forEach(link => { link.addEventListener('click', () => { if (navLinks.classList.contains('open')) { toggleMenu(); } }); }); // close menu on escape document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && navLinks.classList.contains('open')) { toggleMenu(); } }); // ─── Active nav link ──────────────────────────────────── const sections = document.querySelectorAll('section[id]'); const navAnchors = document.querySelectorAll('.nav-links a'); function updateActiveLink() { let current = ''; sections.forEach(section => { const top = section.offsetTop - 120; if (window.scrollY >= top) { current = section.getAttribute('id'); } }); navAnchors.forEach(a => { a.classList.toggle('active', a.getAttribute('href') === `#${current}`); }); } window.addEventListener('scroll', updateActiveLink, { passive: true }); // ─── Scroll Reveal (IntersectionObserver) ─────────────── if (!prefersReducedMotion) { const revealObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('visible'); } }); }, { threshold: 0.15, rootMargin: '0px 0px -50px 0px' }); document.querySelectorAll('.reveal').forEach(el => revealObserver.observe(el)); } else { document.querySelectorAll('.reveal').forEach(el => el.classList.add('visible')); } // ─── Counter Animation ────────────────────────────────── const counters = document.querySelectorAll('.stat-number'); function animateCounter(el) { const target = parseInt(el.dataset.target, 10); const suffix = el.dataset.suffix || ''; const duration = 2000; const startTime = performance.now(); function update(currentTime) { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); const eased = 1 - Math.pow(1 - progress, 3); const current = Math.floor(eased * target); el.textContent = current + suffix; if (progress < 1) { requestAnimationFrame(update); } else { el.textContent = target + suffix; } } requestAnimationFrame(update); } if (!prefersReducedMotion) { const counterObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const counter = entry.target; animateCounter(counter); counterObserver.unobserve(counter); } }); }, { threshold: 0.5 }); counters.forEach(c => counterObserver.observe(c)); } else { counters.forEach(c => { c.textContent = c.dataset.target + (c.dataset.suffix || ''); }); } // ─── Service cards staggered reveal ───────────────────── if (!prefersReducedMotion) { const cardObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const delay = parseInt(entry.target.dataset.delay || '0', 10); setTimeout(() => { entry.target.classList.add('visible'); }, delay); cardObserver.unobserve(entry.target); } }); }, { threshold: 0.2 }); document.querySelectorAll('.service-card.reveal, .stat-item.reveal, .proyecto-card.reveal').forEach( el => cardObserver.observe(el) ); } // ─── Smooth scroll for anchor links (fallback) ────────── document.querySelectorAll('a[href^="#"]').forEach(anchor => { anchor.addEventListener('click', (e) => { const targetId = anchor.getAttribute('href'); if (targetId === '#') return; const target = document.querySelector(targetId); if (target) { e.preventDefault(); target.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }); }); });