- Hero con animación de partículas y efecto typewriter - Sección de servicios con animación scroll-reveal - Estadísticas animadas con contadores progresivos - Galería de proyectos con overlay hover - Sección de contacto con enlaces - Footer completo - Diseño responsive mobile-first - Soporte prefers-reduced-motion
312 lines
9.9 KiB
JavaScript
312 lines
9.9 KiB
JavaScript
'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' });
|
|
}
|
|
});
|
|
});
|
|
});
|