/* RainCanvas — canvas-based rain with subtle ground splashes. * * Sits between the backdrop image and the vignette overlay. * Respects prefers-reduced-motion (renders nothing). * * Props: * density — drops per 1000px of viewport width (default 60) * angle — degrees from vertical, positive = wind to the right (default 14) * speed — multiplier on fall speed (default 1) * color — drop color (default soft blue-white) */ function RainCanvas({ density = 60, angle = 14, speed = 1, color = "rgba(200, 215, 235, 0.55)", } = {}) { const canvasRef = React.useRef(null); const rafRef = React.useRef(0); const stateRef = React.useRef({ drops: [], splashes: [], w: 0, h: 0, dpr: 1 }); React.useEffect(() => { const reduced = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduced) return; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); const s = stateRef.current; const rad = (angle * Math.PI) / 180; const sin = Math.sin(rad); const cos = Math.cos(rad); function resize() { const dpr = Math.min(window.devicePixelRatio || 1, 2); const w = canvas.clientWidth; const h = canvas.clientHeight; canvas.width = Math.floor(w * dpr); canvas.height = Math.floor(h * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); s.w = w; s.h = h; s.dpr = dpr; seed(); } function seed() { const count = Math.max(40, Math.floor((s.w / 1000) * density * 4)); s.drops = new Array(count).fill(0).map(() => makeDrop(true)); } function makeDrop(initial) { // length & speed correlate so far drops look slower/thinner const z = Math.random(); // depth 0..1 const length = 10 + z * 22; // 10–32px const baseSpeed = 7 + z * 11; // 7–18 px/frame at 60fps return { x: Math.random() * (s.w + Math.abs(sin) * s.h) - Math.abs(sin) * s.h * 0.5, y: initial ? Math.random() * s.h : -20 - Math.random() * 40, len: length, vy: baseSpeed * speed, alpha: 0.25 + z * 0.55, z, }; } function spawnSplash(x, y) { s.splashes.push({ x, y, r: 0, maxR: 3 + Math.random() * 3, life: 0, ttl: 22 + Math.random() * 10, }); } let last = performance.now(); function tick(now) { const dt = Math.min(2, (now - last) / 16.67); // normalize to ~60fps frames last = now; ctx.clearRect(0, 0, s.w, s.h); // --- drops --- ctx.lineCap = "round"; for (let i = 0; i < s.drops.length; i++) { const d = s.drops[i]; const vx = d.vy * sin * 0.8; const vy = d.vy * cos; d.x += vx * dt; d.y += vy * dt; // tail endpoints const x2 = d.x - sin * d.len; const y2 = d.y - cos * d.len; ctx.strokeStyle = color.replace( /rgba\(([^)]+),\s*[\d.]+\)/, `rgba($1, ${d.alpha.toFixed(3)})` ); ctx.lineWidth = 0.6 + d.z * 0.9; ctx.beginPath(); ctx.moveTo(d.x, d.y); ctx.lineTo(x2, y2); ctx.stroke(); if (d.y > s.h) { // splash on "ground" — only some of the time, biased toward closer drops if (Math.random() < 0.35 + d.z * 0.4) { spawnSplash(d.x, s.h - 1); } Object.assign(d, makeDrop(false)); } else if (d.x > s.w + 40 || d.x < -40) { Object.assign(d, makeDrop(false)); } } // --- splashes --- for (let i = s.splashes.length - 1; i >= 0; i--) { const sp = s.splashes[i]; sp.life += dt; sp.r = (sp.life / sp.ttl) * sp.maxR; const a = Math.max(0, 1 - sp.life / sp.ttl) * 0.5; ctx.strokeStyle = `rgba(210, 220, 235, ${a.toFixed(3)})`; ctx.lineWidth = 1; // half-ellipse to suggest puddle ring ctx.beginPath(); ctx.ellipse(sp.x, sp.y, sp.r, sp.r * 0.35, 0, Math.PI, 2 * Math.PI); ctx.stroke(); if (sp.life >= sp.ttl) s.splashes.splice(i, 1); } rafRef.current = requestAnimationFrame(tick); } resize(); rafRef.current = requestAnimationFrame(tick); window.addEventListener("resize", resize); return () => { cancelAnimationFrame(rafRef.current); window.removeEventListener("resize", resize); }; }, [density, angle, speed, color]); return (