limpieza de codigo

This commit is contained in:
2026-10-07 11:37:16 -03:00
parent f818a01003
commit 6ee037ec20
13 changed files with 618 additions and 1198 deletions
+165
View File
@@ -17,6 +17,7 @@ async function initApp() {
initInsightsFeed();
initContactForm();
initCareersForm();
initSearchPage();
}
/* ==========================================================================
@@ -1177,3 +1178,167 @@ function initHeroVideoSpeed() {
video.playbackRate = targetSpeed;
});
}
/* ==========================================================================
12. BÚSQUEDA EN EL SITIO — Página buscar.html
========================================================================== */
const SEARCH_INDEX_URL = 'data/search-index.json';
async function initSearchPage() {
const form = document.getElementById('search-page-form');
const input = document.getElementById('search-page-input');
const results = document.getElementById('search-results');
const status = document.getElementById('search-status');
const suggestions = document.getElementById('search-suggestions');
// Si no estamos en buscar.html, salir silenciosamente
if (!form || !input || !results) return;
let index = [];
// Cargar índice
try {
const res = await fetch(SEARCH_INDEX_URL, { cache: 'no-store' });
if (res.ok) {
index = await res.json();
} else {
console.warn('No se pudo cargar search-index.json:', res.status);
}
} catch (e) {
console.warn('Error cargando search-index.json:', e);
}
// Normalizar texto (sin acentos, minúsculas)
const normalize = (str) =>
(str || '')
.toString()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
// Escapar HTML
const escapeHtml = (str) =>
(str || '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[c]));
// Scoring por relevancia
function scoreItem(item, terms) {
const title = normalize(item.titulo);
const desc = normalize(item.descripcion);
const kw = normalize(item.keywords);
let score = 0;
terms.forEach(term => {
if (!term) return;
if (title.includes(term)) score += 10;
if (kw.includes(term)) score += 6;
if (desc.includes(term)) score += 3;
});
// Bonus si la frase completa aparece en el título
const fullPhrase = terms.join(' ');
if (fullPhrase && title.includes(fullPhrase)) score += 5;
return score;
}
// Resaltar coincidencias
function highlight(text, terms) {
let safe = escapeHtml(text);
terms.forEach(term => {
if (term.length < 2) return;
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escaped})`, 'gi');
safe = safe.replace(regex, '<mark>$1</mark>');
});
return safe;
}
// Render de resultados
function render(term) {
results.innerHTML = '';
const cleanTerm = (term || '').trim();
if (!cleanTerm) {
if (status) status.textContent = '';
if (suggestions) suggestions.hidden = false;
return;
}
if (suggestions) suggestions.hidden = true;
const terms = normalize(cleanTerm).split(/\s+/).filter(Boolean);
const scored = index
.map(item => ({ item, score: scoreItem(item, terms) }))
.filter(r => r.score > 0)
.sort((a, b) => b.score - a.score);
if (scored.length === 0) {
if (status) status.textContent = '';
results.innerHTML = `
<div class="search-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="8" y1="11" x2="14" y2="11"></line>
</svg>
<p>No encontramos resultados para <strong>${escapeHtml(cleanTerm)}</strong>.</p>
<p class="search-hint">
Probá con otros términos o <a href="contacto.html">contactanos directamente</a>.
</p>
</div>
`;
return;
}
if (status) {
status.textContent = `${scored.length} resultado${scored.length !== 1 ? 's' : ''} para "${cleanTerm}"`;
}
results.innerHTML = scored.map(({ item }) => `
<article class="search-result-item">
<div class="result-category">${escapeHtml(item.categoria)}</div>
<h3><a href="${escapeHtml(item.url)}">${highlight(item.titulo, terms)}</a></h3>
<p>${highlight(item.descripcion, terms)}</p>
</article>
`).join('');
}
// Leer ?q= de la URL al cargar
const params = new URLSearchParams(window.location.search);
const initialQuery = params.get('q') || '';
if (initialQuery) {
input.value = initialQuery;
render(initialQuery);
} else {
if (suggestions) suggestions.hidden = false;
// Foco automático al llegar sin query
setTimeout(() => input.focus(), 100);
}
// Submit del form
form.addEventListener('submit', (e) => {
e.preventDefault();
const q = input.value.trim();
const newUrl = q
? `?q=${encodeURIComponent(q)}`
: window.location.pathname;
window.history.replaceState({}, '', newUrl);
render(q);
});
// Búsqueda en vivo con debounce
let debounceTimer;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
render(input.value);
}, 220);
});
}
-302
View File
@@ -1,302 +0,0 @@
/**
* Plexus 3D Hero - Red de Nodos Conectados para Hero
* Integración con Three.js y soporte dinámico para Modo Claro / Oscuro vía CSS Variables.
*/
(function () {
// Asegurarse de que el DOM esté listo
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPlexus);
} else {
initPlexus();
}
function initPlexus() {
// 1. Validar dependencia de Three.js
if (typeof THREE === 'undefined') {
console.warn('[Plexus 3D] Three.js no está cargado. Asegúrate de incluir Three.js antes de este script.');
return;
}
const container = document.getElementById('hero-3d-container');
if (!container) {
// Si el contenedor no existe en esta página, no ejecutar nada
return;
}
// =========================================================================
// CONFIGURACIÓN GENERAL
// =========================================================================
const CONFIG = {
nodeCount: 100, // Cantidad de nodos (puntos)
radius: 120, // Radio general de la esfera/nube 3D
maxDistance: 60, // Distancia máxima para conectar dos nodos con línea
nodeSize: 6, // Tamaño del punto en píxeles
driftSpeed: 0.0005, // Velocidad de flotación orgánica propia
driftAmplitude: 12, // Rango máximo de oscilación (mantiene la forma)
mouseFactor: 0.35, // Grado de rotación en respuesta al cursor
lerpSpeed: 0.05, // Suavidad de la inercia del movimiento (0.01 - 0.1)
// Variables CSS que el script vigilará en tu :root / .dark
cssVars: {
nodeColor: '--ide-primary',
lineColor: '--ide-primary'
},
// Colores de respaldo en caso de que las variables no existan en el CSS
fallbacks: {
nodeColor: '#0a2540',
lineColor: '#008080'
},
lineOpacity: 0.35 // Opacidad de las líneas
};
// =========================================================================
// LECTURA REACTIVA DE VARIABLES CSS
// =========================================================================
function getCSSVariable(varName, fallback) {
const val = getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim();
return val || fallback;
}
// =========================================================================
// CONFIGURACIÓN THREE.JS (Escena, Cámara y Renderer)
// =========================================================================
const scene = new THREE.Scene();
const width = container.clientWidth || 400;
const height = container.clientHeight || 450;
const camera = new THREE.PerspectiveCamera(50, width / height, 1, 1000);
camera.position.z = 320;
const renderer = new THREE.WebGLRenderer({
alpha: true, // Fondo 100% transparente para integrarse con el Hero
antialias: true, // Suavizado de bordes
powerPreference: 'high-performance'
});
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
container.appendChild(renderer.domElement);
// Grupo principal para aplicar rotación y control de inercia
const cloudGroup = new THREE.Group();
scene.add(cloudGroup);
// =========================================================================
// GENERACIÓN DE NODOS (Distribución Esférica Uniforme)
// =========================================================================
const nodes = [];
const nodePositions = new Float32Array(CONFIG.nodeCount * 3);
for (let i = 0; i < CONFIG.nodeCount; i++) {
// Distribución equidistante en esfera (Espiral de Fibonacci 3D)
const phi = Math.acos(-1 + (2 * i) / CONFIG.nodeCount);
const theta = Math.sqrt(CONFIG.nodeCount * Math.PI) * phi;
// Variación radial controlada para darle volumen tridimensional
const r = CONFIG.radius * (0.65 + 0.35 * Math.random());
const baseX = r * Math.cos(theta) * Math.sin(phi);
const baseY = r * Math.sin(theta) * Math.sin(phi);
const baseZ = r * Math.cos(phi);
nodes.push({
base: new THREE.Vector3(baseX, baseY, baseZ),
current: new THREE.Vector3(baseX, baseY, baseZ),
speedOffset: Math.random() * Math.PI * 2,
axisFactors: [
0.8 + Math.random() * 0.4,
0.8 + Math.random() * 0.4,
0.8 + Math.random() * 0.4
]
});
nodePositions[i * 3] = baseX;
nodePositions[i * 3 + 1] = baseY;
nodePositions[i * 3 + 2] = baseZ;
}
// Geometría y textura circular suave para los nodos
const pointsGeometry = new THREE.BufferGeometry();
pointsGeometry.setAttribute('position', new THREE.BufferAttribute(nodePositions, 3));
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(32, 32, 28, 0, Math.PI * 2);
ctx.fill();
const pointTexture = new THREE.CanvasTexture(canvas);
const pointsMaterial = new THREE.PointsMaterial({
size: CONFIG.nodeSize,
map: pointTexture,
transparent: true,
alphaTest: 0.05
});
const pointCloud = new THREE.Points(pointsGeometry, pointsMaterial);
cloudGroup.add(pointCloud);
// =========================================================================
// LÍNEAS DE CONEXIÓN DINÁMICAS
// =========================================================================
const maxPossibleLines = (CONFIG.nodeCount * (CONFIG.nodeCount - 1)) / 2;
const linePositions = new Float32Array(maxPossibleLines * 6);
const linesGeometry = new THREE.BufferGeometry();
linesGeometry.setAttribute(
'position',
new THREE.BufferAttribute(linePositions, 3).setUsage(THREE.DynamicDrawUsage)
);
const linesMaterial = new THREE.LineBasicMaterial({
transparent: true,
opacity: CONFIG.lineOpacity
});
const lines = new THREE.LineSegments(linesGeometry, linesMaterial);
cloudGroup.add(lines);
// =========================================================================
// GESTIÓN DE COLORES Y OBSERVADORES DE TEMA (DARK / LIGHT MODE)
// =========================================================================
function updateThemeColors() {
const nodeColorStr = getCSSVariable(CONFIG.cssVars.nodeColor, CONFIG.fallbacks.nodeColor);
const lineColorStr = getCSSVariable(CONFIG.cssVars.lineColor, CONFIG.fallbacks.lineColor);
pointsMaterial.color.set(nodeColorStr);
linesMaterial.color.set(lineColorStr);
}
// Aplicar colores al arrancar
updateThemeColors();
// 1. Observar cambios en atributos de <html> y <body> (classes, data-theme, etc.)
const observer = new MutationObserver(() => {
updateThemeColors();
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme', 'data-mode', 'style']
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class', 'data-theme', 'data-mode', 'style']
});
// 2. Escuchar cambios de preferencia de color del sistema operativo
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateThemeColors);
}
// Exponer función de refresco manual por si tu toggle dispara eventos personalizados
window.refreshPlexusTheme = updateThemeColors;
// =========================================================================
// INTERACCIÓN CON EL MOUSE (PARALAJE E INCLINACIÓN 3D)
// =========================================================================
let targetRotationX = 0;
let targetRotationY = 0;
window.addEventListener('mousemove', (e) => {
const rect = container.getBoundingClientRect();
// Cálculo de posición normalizada respecto al centro de la ventana o hero
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const normX = (e.clientX - centerX) / (window.innerWidth / 2);
const normY = (e.clientY - centerY) / (window.innerHeight / 2);
targetRotationY = normX * CONFIG.mouseFactor;
targetRotationX = -normY * CONFIG.mouseFactor;
}, { passive: true });
// =========================================================================
// BUCLE DE ANIMACIÓN
// =========================================================================
let time = 0;
function animate() {
requestAnimationFrame(animate);
time += CONFIG.driftSpeed * 1000;
// 1. Oscilación armónica de los nodos (flotación sin desarme)
const currentPosArray = pointsGeometry.attributes.position.array;
for (let i = 0; i < CONFIG.nodeCount; i++) {
const node = nodes[i];
const offset = time * 0.0015 + node.speedOffset;
node.current.x = node.base.x + Math.sin(offset * node.axisFactors[0]) * CONFIG.driftAmplitude;
node.current.y = node.base.y + Math.cos(offset * node.axisFactors[1]) * CONFIG.driftAmplitude;
node.current.z = node.base.z + Math.sin(offset * node.axisFactors[2]) * CONFIG.driftAmplitude;
currentPosArray[i * 3] = node.current.x;
currentPosArray[i * 3 + 1] = node.current.y;
currentPosArray[i * 3 + 2] = node.current.z;
}
pointsGeometry.attributes.position.needsUpdate = true;
// 2. Conectar nodos próximos
let lineIndex = 0;
const lineArray = linesGeometry.attributes.position.array;
for (let i = 0; i < CONFIG.nodeCount; i++) {
for (let j = i + 1; j < CONFIG.nodeCount; j++) {
const dist = nodes[i].current.distanceTo(nodes[j].current);
if (dist < CONFIG.maxDistance) {
lineArray[lineIndex++] = nodes[i].current.x;
lineArray[lineIndex++] = nodes[i].current.y;
lineArray[lineIndex++] = nodes[i].current.z;
lineArray[lineIndex++] = nodes[j].current.x;
lineArray[lineIndex++] = nodes[j].current.y;
lineArray[lineIndex++] = nodes[j].current.z;
}
}
}
linesGeometry.setDrawRange(0, lineIndex / 3);
linesGeometry.attributes.position.needsUpdate = true;
// 3. Rotación base continua + interpolación suave hacia el mouse (Lerp)
cloudGroup.rotation.y += 0.0012;
cloudGroup.rotation.y += (targetRotationY - cloudGroup.rotation.y) * CONFIG.lerpSpeed;
cloudGroup.rotation.x += (targetRotationX - cloudGroup.rotation.x) * CONFIG.lerpSpeed;
renderer.render(scene, camera);
}
animate();
// =========================================================================
// RESPONSIVE RESIZING (Evita deformaciones y recortes)
// =========================================================================
function handleResize() {
if (!container) return;
const newWidth = container.clientWidth;
const newHeight = container.clientHeight;
if (newWidth === 0 || newHeight === 0) return;
camera.aspect = newWidth / newHeight;
camera.updateProjectionMatrix();
renderer.setSize(newWidth, newHeight);
}
// ResizeObserver para cambios precisos en el contenedor del Hero
if (window.ResizeObserver) {
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(container);
} else {
window.addEventListener('resize', handleResize);
}
}
})();
-164
View File
@@ -1,164 +0,0 @@
/**
* Ripple Waves Background - Versión con Distorsión de Ruido Topográfico
* Reemplaza o actualiza tu archivo ripple-bg.js
*/
(() => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initRipple);
} else {
initRipple();
}
function initRipple() {
const canvas = document.getElementById('ripple-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const parent = canvas.parentElement;
// =========================================================================
// PARÁMETROS CONFIGURABLES
// =========================================================================
const CONFIG = {
spacing: 36, // Separación entre anillos
speed: 0.5, // Velocidad de expansión hacia afuera
lineWidth: 2, // Grosor de las líneas
maxAlpha: 0.42, // Opacidad máxima
centerOffsetX: 0.5, // Centro horizontal (50%)
centerOffsetY: 0.2, // Centro vertical (alineado con el h2)
// --- PARÁMETROS DE RUIDO / DISTORSIÓN ---
distortionStrength: 14, // Qué tanto se deforma el círculo (en px)
distortionFrequency: 3, // Cantidad de ondulaciones en el perímetro
evolutionSpeed: 0.00001, // Velocidad con la que muta la forma de las ondas
segmentsPerRing: 25, // Calidad/resolución del contorno (70 a 100)
cssVar: '--ripple-color', // Variable de tu CSS
fallbackColor: 'rgba(24, 123, 141, 0.4)'
};
let width = 0;
let height = 0;
let dpr = 1;
let maxRadius = 0;
let offset = 0;
let time = 0;
let currentColor = CONFIG.fallbackColor;
// --- Leer variable de color desde CSS ---
function updateThemeColor() {
const color = getComputedStyle(document.documentElement)
.getPropertyValue(CONFIG.cssVar)
.trim();
currentColor = color || CONFIG.fallbackColor;
}
updateThemeColor();
const observer = new MutationObserver(updateThemeColor);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] });
observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'data-theme'] });
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateThemeColor);
}
// --- Adaptación de resolución Retina / HiDPI ---
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
width = parent.clientWidth;
height = parent.clientHeight;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
const cx = width * CONFIG.centerOffsetX;
const cy = height * CONFIG.centerOffsetY;
const dx = Math.max(cx, width - cx);
const dy = Math.max(cy, height - cy);
maxRadius = Math.sqrt(dx * dx + dy * dy) + CONFIG.spacing * 2;
}
resize();
if (window.ResizeObserver) {
new ResizeObserver(resize).observe(parent);
} else {
window.addEventListener('resize', resize);
}
// --- Función de Pseudo-Ruido Armónico ---
// Combina ondas senoidales a distintas frecuencias para simular turbulencia/ruido suave
function getNoiseDeformation(angle, baseRadius, t) {
const f = CONFIG.distortionFrequency;
const w1 = Math.sin(angle * f + t + baseRadius * 0.015);
const w2 = Math.cos(angle * (f + 2) - t * 0.8 + baseRadius * 0.02);
const w3 = Math.sin(angle * 2 + t * 0.4);
// Crece ligeramente a medida que el anillo viaja hacia afuera
const growthFactor = 0.5 + (baseRadius / maxRadius) * 0.5;
return (w1 * 0.55 + w2 * 0.35 + w3 * 0.1) * CONFIG.distortionStrength * growthFactor;
}
// --- Loop de Animación ---
function render() {
ctx.clearRect(0, 0, width, height);
const cx = width * CONFIG.centerOffsetX;
const cy = height * CONFIG.centerOffsetY;
offset = (offset + CONFIG.speed) % CONFIG.spacing;
time += CONFIG.evolutionSpeed * 1000;
ctx.save();
ctx.strokeStyle = currentColor;
ctx.lineWidth = CONFIG.lineWidth;
const angleStep = (Math.PI * 2) / CONFIG.segmentsPerRing;
// Dibujar cada anillo deformado
for (let r = offset; r < maxRadius; r += CONFIG.spacing) {
if (r <= 2) continue;
const progress = r / maxRadius;
// Desvanecimiento suave en centro y bordes
let alpha = 1;
if (progress < 0.12) {
alpha = progress / 0.12;
} else {
alpha = Math.cos(((progress - 0.12) / 0.88) * (Math.PI / 2));
}
ctx.globalAlpha = Math.max(0, Math.min(1, alpha * CONFIG.maxAlpha));
ctx.beginPath();
// Trazar el contorno deformado punto a punto
for (let i = 0; i <= CONFIG.segmentsPerRing; i++) {
const angle = i * angleStep;
const deformation = getNoiseDeformation(angle, r, time);
const currentRadius = Math.max(0, r + deformation);
const px = cx + Math.cos(angle) * currentRadius;
const py = cy + Math.sin(angle) * currentRadius;
if (i === 0) {
ctx.moveTo(px, py);
} else {
ctx.lineTo(px, py);
}
}
ctx.closePath();
ctx.stroke();
}
ctx.restore();
requestAnimationFrame(render);
}
requestAnimationFrame(render);
}
})();
-402
View File
@@ -1,402 +0,0 @@
/**
* 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 <html> (cambios de data-theme, class, style)
this._themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'class', 'style']
});
// Observar <body> 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();
}