feat: landing page inicial para devlab.lat
- 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
This commit is contained in:
311
main.js
Normal file
311
main.js
Normal file
@@ -0,0 +1,311 @@
|
||||
'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' });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user