/* ──────────────────────────────────────────────────────────────── Particle ocean – oleaje infinito en perspectiva 3/4 Horizonte visible. Camara deriva aleatoriamente. Grid de lineas horizontales + verticales entre particulas. Canvas: #bg-snakes (id retenido). ──────────────────────────────────────────────────────────────── */ (() => { const canvas = document.getElementById('bg-snakes'); if (!canvas) return; const ctx = canvas.getContext('2d'); const cssVar = (name, def) => { const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return v || def; }; const brandColor = cssVar('--brand-primary', '#0a6ebd'); const brandDark = cssVar('--brand-dark', '#006698'); const bgColor = cssVar('--background', '#ffffff'); function parseHex(hex) { const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return m ? [parseInt(m[1],16), parseInt(m[2],16), parseInt(m[3],16)] : null; } const [CR, CG, CB] = parseHex(brandColor) || [10, 110, 189]; const [LR, LG, LB] = parseHex(brandDark) || [0, 102, 152]; /* ── Tunables ── */ const ROWS = 25; const DOT_BASE = 0.85; const VP_Y_FRAC = 0.25; // horizonte a 1/4 desde el top const DEPTH_POW = 1.4; const PERSP_MIN = 0.06; const WAVE_AMP_Y = 40; const WAVE_AMP_X = 14; const WAVE_SPEED = 1.4; const DRIFT_SPEED = 15; const TURN_ACCEL = 0.18; const TURN_DAMP = 0.985; const SPREAD = 10; const HORIZON_ALPHA = 0.15; const LINE_ALPHA = 0.07; // opacidad de las lineas del grid let W = 0, H = 0, DPR = 1, cols = 250, activeRows = ROWS; let vpx = 0, vpy = 0; let time = 0; /* ── Camera ── */ let camX = 0, camZ = 0; let heading = Math.random() * Math.PI * 2; let turnRate = 0; function getDPR() { return Math.min(1.5, Math.max(1, window.devicePixelRatio || 1)); } function resize() { DPR = getDPR(); W = window.innerWidth | 0; H = window.innerHeight | 0; canvas.width = (W * DPR) | 0; canvas.height = (H * DPR) | 0; canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; ctx.setTransform(DPR, 0, 0, DPR, 0, 0); vpy = H * VP_Y_FRAC; vpx = W * 1.10; cols = Math.max(30, Math.min(120, (W / 20) | 0)); activeRows = W < 600 ? 40 : W < 900 ? 55 : ROWS; } let rTO = null; window.addEventListener('resize', () => { clearTimeout(rTO); rTO = setTimeout(resize, 100); }, { passive: true }); resize(); /* ── Ocean wave bidimensional ── */ const _a = new Float64Array(5); let _lastDY = 0; function oceanWaveXY(wx, wz, t) { _a[0] = wx * 0.05 + wz * 0.03 + t * 2.0; _a[1] = wx * 0.03 - wz * 0.06 + t * 1.5; _a[2] = wx * 0.08 + wz * 0.015 + t * 2.5; _a[3] = wx * 0.02 + wz * 0.08 - t * 1.8; _a[4] = wx * 0.13 - wz * 0.05 + t * 3.2; _lastDY = Math.sin(_a[0]) * 0.32 + Math.sin(_a[1]) * 0.24 + Math.sin(_a[2]) * 0.16 + Math.sin(_a[3]) * 0.20 + Math.sin(_a[4]) * 0.08; return ( Math.cos(_a[0]) * 0.28 + Math.cos(_a[1]) * 0.22 + Math.cos(_a[2]) * 0.14 + Math.cos(_a[3]) * 0.18 ); } /* ── Reduced motion ── */ const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; let last = performance.now(), rafId = null; let visible = document.visibilityState === 'visible'; /* ── Buffers para posiciones (flat: c, fx, fy por punto) ── */ const rowPts = []; // rowPts[r] = Float32Array-like flat [c,fx,fy,...] const rowAlpha = []; const rowDotSz = []; const lastColFx = new Float32Array(300); const lastColFy = new Float32Array(300); /* ── Render loop ── */ function frame(now) { const dt = Math.min(0.05, (now - last) / 1000); last = now; if (!reduced) { time += dt * WAVE_SPEED; turnRate += (Math.random() - 0.5) * 2 * TURN_ACCEL * dt; turnRate *= TURN_DAMP; turnRate = Math.max(-1.2, Math.min(1.2, turnRate)); heading += turnRate * dt; camX += Math.sin(heading) * DRIFT_SPEED * dt; camZ += Math.cos(heading) * DRIFT_SPEED * dt; } ctx.fillStyle = bgColor; ctx.fillRect(0, 0, W, H); /* ══════ PASO 1: calcular posiciones ══════ */ const bottomY = H * 1.08; const spanH = bottomY - vpy; const baseLeft = -W * (SPREAD - 1) * 0.5; const baseWidth = W * SPREAD; rowPts.length = activeRows; rowAlpha.length = activeRows; rowDotSz.length = activeRows; for (let r = 0; r < activeRows; r++) { const rt = r / (activeRows - 1); const depthT = Math.pow(rt, DEPTH_POW); const rowY = vpy + depthT * spanH; const progress = depthT; const scale = Math.max(PERSP_MIN, progress); const worldZ = 50 / Math.max(progress, 0.015); rowAlpha[r] = 0.10 + scale * 0.78; rowDotSz[r] = Math.max(0.5, DOT_BASE * scale * 2); const pts = []; for (let c = 0; c < cols; c++) { const bottomX = baseLeft + (c / (cols - 1)) * baseWidth; const px = vpx + (bottomX - vpx) * progress; if (px < -20 || px > W + 20) continue; const worldX = (px - vpx) / Math.max(progress, 0.015) * 0.5; const waveDX = oceanWaveXY(worldX + camX, worldZ + camZ, time); const fx = px + waveDX * WAVE_AMP_X * scale; const fy = rowY - _lastDY * WAVE_AMP_Y * scale; if (fy < vpy - 3 || fy > H + 20) continue; pts.push(c, fx, fy); } rowPts[r] = pts; } /* ══════ PASO 2: lineas del grid (detras de particulas) ══════ */ const lineStyle = 'rgba(' + LR + ',' + LG + ',' + LB + ',' + LINE_ALPHA + ')'; ctx.strokeStyle = lineStyle; ctx.lineWidth = 0.5; // Lineas horizontales (conectan particulas consecutivas en la misma fila) ctx.beginPath(); for (let r = 0; r < activeRows; r++) { const p = rowPts[r]; for (let i = 3; i < p.length; i += 3) { ctx.moveTo(p[i - 2], p[i - 1]); ctx.lineTo(p[i + 1], p[i + 2]); } } ctx.stroke(); // Lineas verticales (conectan misma columna entre filas adyacentes) for (let i = 0; i < cols; i++) { lastColFx[i] = NaN; lastColFy[i] = NaN; } ctx.beginPath(); for (let r = 0; r < activeRows; r++) { const p = rowPts[r]; for (let i = 0; i < p.length; i += 3) { const c = p[i]; const fx = p[i + 1]; const fy = p[i + 2]; if (lastColFx[c] === lastColFx[c]) { // !isNaN ctx.moveTo(lastColFx[c], lastColFy[c]); ctx.lineTo(fx, fy); } lastColFx[c] = fx; lastColFy[c] = fy; } } ctx.stroke(); /* ══════ PASO 3: particulas (encima de las lineas) ══════ */ for (let r = 0; r < activeRows; r++) { const p = rowPts[r]; const ds = rowDotSz[r]; ctx.fillStyle = 'rgba(' + CR + ',' + CG + ',' + CB + ',' + rowAlpha[r] + ')'; ctx.beginPath(); for (let i = 0; i < p.length; i += 3) { const fx = p[i + 1]; const fy = p[i + 2]; ctx.moveTo(fx + ds, fy); ctx.arc(fx, fy, ds, 0, 6.2832); } ctx.fill(); } rafId = requestAnimationFrame(frame); } function start() { if (!rafId && visible) { last = performance.now(); rafId = requestAnimationFrame(frame); } } function stop() { if (rafId) { cancelAnimationFrame(rafId); rafId = null; } } document.addEventListener('visibilitychange', () => { visible = document.visibilityState === 'visible'; visible ? start() : stop(); }); start(); })(); /* ── Password visibility toggle (sin cambios) ── */ document.addEventListener('DOMContentLoaded', () => { const toggle = document.getElementById('passwordToggle'); const input = document.getElementById('password'); if (!toggle || !input) return; const icon = toggle.querySelector('i'); toggle.style.cursor = 'pointer'; toggle.addEventListener('click', () => { const showing = input.type === 'text'; input.type = showing ? 'password' : 'text'; icon.classList.toggle('fa-eye', showing); icon.classList.toggle('fa-eye-slash', !showing); }); });