/** * SnowballBackground * Círculos huecos flotantes que se fusionan en el mouse aumentando su radio * y se desintegran expulsando todas las partículas. * * Uso: * 1) Automático: agregá la clase .snowball-bg a cualquier contenedor * 2) Manual: new SnowballBackground({ containerId: 'mi-id' }) * * Tema: lee variables CSS del contenedor o :root y reacciona a cambios de tema. */ class SnowballBackground { constructor(options = {}) { // --- Resolver contenedor (acepta elemento, ID o selector) --- if (options.container instanceof HTMLElement) { this.container = options.container; } else if (options.containerId) { this.container = document.getElementById(options.containerId); } else if (options.containerSelector) { this.container = document.querySelector(options.containerSelector); } if (!this.container) { console.warn('SnowballBackground: no se encontró el contenedor.', options); return; } // Evitar doble inicialización en el mismo contenedor if (this.container._snowballInstance) { return; } this.container._snowballInstance = this; // --- Configuración (defaults, se sobreescriben con CSS vars y options) --- this.config = { particleCount: 70, maxCapacity: 20, cooldownSeconds: 2.5, color: '#38bdf8', lineWidth: 1.5, minRadius: 5, maxRadius: 10 }; // Leer variables CSS antes de aplicar options (options tienen prioridad) this.syncThemeColors(); // Aplicar overrides manuales if (options.particleCount) this.config.particleCount = options.particleCount; if (options.maxCapacity) this.config.maxCapacity = options.maxCapacity; if (options.cooldownSeconds) this.config.cooldownSeconds = options.cooldownSeconds; if (options.color) this.config.color = options.color; if (options.lineWidth) this.config.lineWidth = options.lineWidth; if (options.minRadius) this.config.minRadius = options.minRadius; if (options.maxRadius) this.config.maxRadius = options.maxRadius; // --- Estados internos --- this.particles = []; this.absorbedParticles = []; this.disintegrationShards = []; this.mouse = { x: -1000, y: -1000, active: false }; this.isCoolingDown = false; this.cooldownEndTime = 0; this.currentBallRadius = 0; // --- Init --- this.initCanvas(); this.initParticles(); this.bindEvents(); this.observeThemeChanges(); this.loop(); } /* ============================================================= * LECTURA DE VARIABLES CSS * =========================================================== */ readCSSVar(name) { // Primero busca en el contenedor, luego en :root let value = getComputedStyle(this.container).getPropertyValue(name).trim(); if (!value) { value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } return value || null; } syncThemeColors() { const map = { '--snowball-color': { key: 'color', parse: String }, '--snowball-particle-count': { key: 'particleCount', parse: parseInt }, '--snowball-max-capacity': { key: 'maxCapacity', parse: parseInt }, '--snowball-cooldown': { key: 'cooldownSeconds',parse: parseFloat }, '--snowball-line-width': { key: 'lineWidth', parse: parseFloat }, '--snowball-min-radius': { key: 'minRadius', parse: parseFloat }, '--snowball-max-radius': { key: 'maxRadius', parse: parseFloat } }; for (const [cssVar, { key, parse }] of Object.entries(map)) { const raw = this.readCSSVar(cssVar); if (raw !== null) { const parsed = parse(raw, 10); // Solo aplicar si el parseo dio un valor válido if (typeof parsed === 'string' ? parsed : !isNaN(parsed)) { this.config[key] = parsed; } } } } /* ============================================================= * OBSERVADOR DE CAMBIOS DE TEMA * =========================================================== */ observeThemeChanges() { this._themeObserver = new MutationObserver(() => { this.syncThemeColors(); }); // Observar (cambios de data-theme, class, style) this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class', 'style'] }); // Observar también (algunos frameworks ponen el tema ahí) this._themeObserver.observe(document.body, { attributes: true, attributeFilter: ['data-theme', 'class', 'style'] }); } /* ============================================================= * CANVAS * =========================================================== */ initCanvas() { this.canvas = document.createElement('canvas'); this.canvas.className = 'snowball-canvas'; this.canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:0;'; this.ctx = this.canvas.getContext('2d'); // El contenedor debe ser posicionable const pos = getComputedStyle(this.container).position; if (pos === 'static') { this.container.style.position = 'relative'; } this.container.appendChild(this.canvas); this.resize(); } resize() { const rect = this.container.getBoundingClientRect(); this.width = rect.width; this.height = rect.height; const dpr = window.devicePixelRatio || 1; this.canvas.width = this.width * dpr; this.canvas.height = this.height * dpr; this.ctx.scale(dpr, dpr); } /* ============================================================= * PARTÍCULAS * =========================================================== */ initParticles() { this.particles = []; for (let i = 0; i < this.config.particleCount; i++) { const radius = this.randomRange(this.config.minRadius, this.config.maxRadius); this.particles.push({ x: this.randomRange(radius, Math.max(radius + 1, this.width - radius)), y: this.randomRange(radius, Math.max(radius + 1, this.height - radius)), vx: (Math.random() - 0.5) * 1.6, vy: (Math.random() - 0.5) * 1.6, radius, state: 'FREE' }); } } bindEvents() { this._onResize = () => this.resize(); window.addEventListener('resize', this._onResize); this._onMouseMove = (e) => { const rect = this.container.getBoundingClientRect(); this.mouse.x = e.clientX - rect.left; this.mouse.y = e.clientY - rect.top; this.mouse.active = true; }; this.container.addEventListener('mousemove', this._onMouseMove); this._onMouseLeave = () => { this.mouse.active = false; }; this.container.addEventListener('mouseleave', this._onMouseLeave); } randomRange(min, max) { return Math.random() * (max - min) + min; } /* ============================================================= * CONVERSIÓN DE COLOR UNIVERSAL * =========================================================== */ toRgba(color, alpha = 1) { if (!color) return `rgba(0,0,0,${alpha})`; const c = color.trim(); // rgba(...) o rgb(...) if (c.startsWith('rgb')) { const parts = c.match(/[\d.]+/g); if (!parts || parts.length < 3) return `rgba(0,0,0,${alpha})`; const [r, g, b] = parts; const origA = parts[3] !== undefined ? parseFloat(parts[3]) : 1; return `rgba(${r},${g},${b},${(origA * alpha).toFixed(3)})`; } // #hex (3, 4, 6 u 8 dígitos) if (c.startsWith('#')) { let hex = c.slice(1); if (hex.length === 3 || hex.length === 4) { hex = hex.split('').map(ch => ch + ch).join(''); } const n = parseInt(hex.slice(0, 6), 16); const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; let hexA = 1; if (hex.length === 8) hexA = parseInt(hex.slice(6, 8), 16) / 255; return `rgba(${r},${g},${b},${(hexA * alpha).toFixed(3)})`; } // Fallback: dejar que el navegador resuelva (hsl, nombres, etc.) try { const tmp = document.createElement('canvas').getContext('2d'); tmp.fillStyle = c; const resolved = tmp.fillStyle; if (resolved !== c) return this.toRgba(resolved, alpha); } catch (_) {} return `rgba(0,0,0,${alpha})`; } /* ============================================================= * EXPLOSIÓN * =========================================================== */ explode() { const ex = this.mouse.x, ey = this.mouse.y; const br = this.currentBallRadius; const count = this.absorbedParticles.length; // Fragmentos de desintegración const shardCount = 8; const arcStep = (Math.PI * 2) / shardCount; for (let i = 0; i < shardCount; i++) { const angle = i * arcStep; this.disintegrationShards.push({ x: ex, y: ey, radius: br, startAngle: angle + 0.1, endAngle: angle + arcStep - 0.1, vx: Math.cos(angle + arcStep / 2) * 3, vy: Math.sin(angle + arcStep / 2) * 3, alpha: 1 }); } // Dispersar partículas this.absorbedParticles.forEach((p, i) => { const angle = (Math.PI * 2 / count) * i + (Math.random() - 0.5) * 0.3; const force = this.randomRange(10, 18); p.x = ex + Math.cos(angle) * (br * 0.8); p.y = ey + Math.sin(angle) * (br * 0.8); p.vx = Math.cos(angle) * force; p.vy = Math.sin(angle) * force; p.state = 'EXPLODED'; }); this.absorbedParticles = []; this.currentBallRadius = 0; this.isCoolingDown = true; this.cooldownEndTime = performance.now() + this.config.cooldownSeconds * 1000; } /* ============================================================= * UPDATE * =========================================================== */ update() { const now = performance.now(); if (this.isCoolingDown && now >= this.cooldownEndTime) { this.isCoolingDown = false; } let targetRadius = 0; if (this.absorbedParticles.length > 0) { targetRadius = 14 + (this.absorbedParticles.length / this.config.maxCapacity) * 46; } this.currentBallRadius += (targetRadius - this.currentBallRadius) * 0.15; const hitRadius = this.absorbedParticles.length === 0 ? 14 : this.currentBallRadius; this.particles.forEach((p) => { if (p.state === 'FREE') { p.x += p.vx; p.y += p.vy; if (p.x - p.radius <= 0) { p.x = p.radius; p.vx *= -1; } if (p.x + p.radius >= this.width) { p.x = this.width - p.radius; p.vx *= -1; } if (p.y - p.radius <= 0) { p.y = p.radius; p.vy *= -1; } if (p.y + p.radius >= this.height) { p.y = this.height - p.radius; p.vy *= -1; } if (this.mouse.active && !this.isCoolingDown) { const dx = p.x - this.mouse.x, dy = p.y - this.mouse.y; if (Math.sqrt(dx * dx + dy * dy) < hitRadius + p.radius) { p.state = 'ABSORBED'; this.absorbedParticles.push(p); if (this.absorbedParticles.length >= this.config.maxCapacity) { this.explode(); } } } } else if (p.state === 'EXPLODED') { p.x += p.vx; p.y += p.vy; p.vx *= 0.96; p.vy *= 0.96; if (p.x - p.radius <= 0) { p.x = p.radius; p.vx *= -1; } if (p.x + p.radius >= this.width) { p.x = this.width - p.radius; p.vx *= -1; } if (p.y - p.radius <= 0) { p.y = p.radius; p.vy *= -1; } if (p.y + p.radius >= this.height) { p.y = this.height - p.radius; p.vy *= -1; } if (Math.sqrt(p.vx * p.vx + p.vy * p.vy) < 1.6) p.state = 'FREE'; } }); for (let i = this.disintegrationShards.length - 1; i >= 0; i--) { const s = this.disintegrationShards[i]; s.x += s.vx; s.y += s.vy; s.radius += 0.8; s.alpha -= 0.05; if (s.alpha <= 0) this.disintegrationShards.splice(i, 1); } } /* ============================================================= * DRAW (todos los colores pasan por toRgba) * =========================================================== */ draw() { this.ctx.clearRect(0, 0, this.width, this.height); const color = this.config.color; const lw = this.config.lineWidth; // 1. Partículas libres / explotadas this.ctx.lineWidth = lw; this.ctx.strokeStyle = this.toRgba(color, 1); this.particles.forEach((p) => { if (p.state !== 'ABSORBED') { this.ctx.beginPath(); this.ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2); this.ctx.stroke(); } }); // 2. Fragmentos de desintegración this.disintegrationShards.forEach((s) => { this.ctx.save(); this.ctx.lineWidth = lw + 0.5; this.ctx.strokeStyle = this.toRgba(color, s.alpha); this.ctx.beginPath(); this.ctx.arc(s.x, s.y, s.radius, s.startAngle, s.endAngle); this.ctx.stroke(); this.ctx.restore(); }); // 3. Bola en el mouse if (this.mouse.active && !this.isCoolingDown && this.currentBallRadius > 2) { this.ctx.save(); this.ctx.lineWidth = lw + 0.5; this.ctx.strokeStyle = this.toRgba(color, 1); this.ctx.beginPath(); this.ctx.arc(this.mouse.x, this.mouse.y, this.currentBallRadius, 0, Math.PI * 2); this.ctx.stroke(); this.ctx.restore(); } } /* ============================================================= * LOOP * =========================================================== */ loop() { this.update(); this.draw(); requestAnimationFrame(() => this.loop()); } } /* ============================================================== * AUTO-INICIALIZACIÓN * Busca todos los elementos con clase .snowball-bg * ============================================================== */ function initSnowballBackgrounds() { document.querySelectorAll('.snowball-bg').forEach((el) => { if (!el._snowballInstance) { new SnowballBackground({ container: el }); } }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initSnowballBackgrounds); } else { initSnowballBackgrounds(); }