cambios de textos

This commit is contained in:
2026-09-26 10:56:43 -03:00
parent c317f6efed
commit 1e2595275f
20 changed files with 637 additions and 93 deletions
+402
View File
@@ -0,0 +1,402 @@
/**
* 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();
}