"use client"; import { useEffect, useRef, type ReactNode } from "react"; type MonsoonGlassProps = { children?: ReactNode; className?: string; /** Colours of the city lights behind the glass. */ lights?: string[]; /** Base colour of the night behind the glass. */ background?: string; /** 1 is a steady monsoon; 2 is twice the drops. */ rain?: number; /** 1 is the default pace; 2 is twice as fast. */ speed?: number; }; type Bead = { x: number; y: number; r: number }; type Runner = Bead & { v: number; hold: number; trail: number; bend: number }; const LIGHTS = ["#ffb347", "#ff5e62", "#7ee0ff", "#ffe08a"]; const rand = (a: number, b: number) => a + Math.random() * (b - a); function sheet(w: number, h: number) { const c = document.createElement("canvas"); c.width = Math.max(1, Math.round(w)); c.height = Math.max(1, Math.round(h)); return c; } /* The city: dark blocks with tiny lit windows, then soft street lights over them. The window detail is what fog hides and a wipe gives back. */ function paintCity(c: HTMLCanvasElement, background: string, lights: string[]) { const g = c.getContext("2d"); if (!g) return; const { width: w, height: h } = c; g.fillStyle = background; g.fillRect(0, 0, w, h); for (let x = rand(-30, 0); x < w; ) { const bw = rand(w * 0.07, w * 0.17); const top = h - rand(h * 0.3, h * 0.72); g.fillStyle = "#050a12"; g.fillRect(x, top, bw, h - top); for (let wy = top + 8; wy < h; wy += 9) { for (let wx = x + 5; wx < x + bw - 5; wx += 7) { if (Math.random() > 0.3) continue; g.globalAlpha = rand(0.45, 1); g.fillStyle = lights[Math.floor(Math.random() * lights.length)]; g.fillRect(wx, wy, 3, 4); } } g.globalAlpha = 1; x += bw + rand(0, 10); } g.globalCompositeOperation = "lighter"; for (let i = 0; i < 30; i++) { const x = rand(0, w); const y = rand(h * 0.4, h); const r = rand(10, Math.min(w, h) * 0.1); const glow = g.createRadialGradient(x, y, 0, x, y, r); const color = lights[Math.floor(Math.random() * lights.length)]; glow.addColorStop(0, color); glow.addColorStop(1, `${color}00`); g.globalAlpha = rand(0.25, 0.65); g.fillStyle = glow; g.beginPath(); g.arc(x, y, r, 0, Math.PI * 2); g.fill(); } g.globalAlpha = 1; g.globalCompositeOperation = "source-over"; } /* One water bead: a drop is a tiny lens, so it is lit from below and dark on top. */ function paintBead() { const c = sheet(48, 48); const g = c.getContext("2d"); if (!g) return c; const body = g.createRadialGradient(24, 31, 2, 24, 24, 23); body.addColorStop(0, "rgba(255,255,255,0.6)"); body.addColorStop(0.65, "rgba(220,235,245,0.16)"); body.addColorStop(1, "rgba(0,8,18,0.5)"); g.fillStyle = body; g.beginPath(); g.arc(24, 24, 23, 0, Math.PI * 2); g.fill(); g.fillStyle = "rgba(255,255,255,0.85)"; g.beginPath(); g.arc(17, 15, 3.5, 0, Math.PI * 2); g.fill(); return c; } /* MonsoonGlass: a fogged window at night. Beads gather, grow heavy and run, clearing a trail the fog slowly takes back. Drag the pointer to wipe the glass and see the city sharp. */ export function MonsoonGlass({ children, className = "", lights = LIGHTS, background = "#0a1524", rain = 1, speed = 1, }: MonsoonGlassProps) { const rootRef = useRef(null); const canvasRef = useRef(null); useEffect(() => { const root = rootRef.current; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!root || !canvas || !ctx) return; const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const bead = paintBead(); let w = 0; let h = 0; let raf = 0; let last = 0; let refill = 0; let wipeX: number | null = null; let wipeY = 0; let scene = sheet(1, 1); let frost = sheet(1, 1); let fog = sheet(1, 1); let f = fog.getContext("2d"); let beads: Bead[] = []; let runners: Runner[] = []; const clearFog = (x0: number, y0: number, x1: number, y1: number, width: number) => { if (!f) return; f.lineCap = "round"; f.lineWidth = width; f.beginPath(); f.moveTo(x0, y0); f.lineTo(x1, y1); f.stroke(); }; const step = (dt: number) => { if (!f) return; // The breath on the glass comes back in small coats, not every frame. refill += dt; if (refill > 0.25) { refill = 0; f.globalCompositeOperation = "source-over"; f.globalAlpha = 0.08; f.drawImage(frost, 0, 0, w, h); f.globalAlpha = 1; } f.globalCompositeOperation = "destination-out"; const most = Math.min(800, (w * h * rain) / 1300); for (let n = (most / 5) * dt; n > 0; n--) { if (n < 1 && Math.random() > n) break; beads.push({ x: rand(0, w), y: rand(0, h), r: rand(1, 3.6) }); } if (beads.length > most) beads.splice(0, beads.length - most); if (Math.random() < dt * rain * Math.max(0.7, w / 500)) { const r = rand(2.6, 5.5); runners.push({ x: rand(0, w), y: rand(-20, h * 0.5), r, v: 45 + r * 34, hold: rand(0, 0.5), trail: 0, bend: rand(0, 6) }); } for (const d of runners) { d.hold -= dt; if (d.hold > 0) continue; if (Math.random() < dt * 0.7) d.hold = rand(0.15, 0.6); const fromX = d.x; const fromY = d.y; const move = d.v * dt; d.y += move; d.x += Math.sin(d.y * 0.012 + d.bend) * move * 0.1; clearFog(fromX, fromY, d.x, d.y, d.r * 2.3); // Running drops drink what they touch and leave small ones behind them. beads = beads.filter((b) => { if (Math.hypot(b.x - d.x, b.y - d.y) > d.r + b.r) return true; d.r = Math.min(7, d.r + b.r * 0.03); return false; }); d.trail -= move; if (d.trail < 0) { d.trail = rand(12, 34); beads.push({ x: d.x + rand(-1, 1), y: d.y - d.r * 2, r: rand(0.8, 1.8) }); } } runners = runners.filter((d) => d.y < h + 20); }; const draw = () => { ctx.drawImage(scene, 0, 0, w, h); ctx.drawImage(fog, 0, 0, w, h); for (const b of beads) ctx.drawImage(bead, b.x - b.r, b.y - b.r, b.r * 2, b.r * 2); for (const d of runners) ctx.drawImage(bead, d.x - d.r, d.y - d.r * 1.35, d.r * 2, d.r * 2.7); }; const frame = (now: number) => { const dt = last ? Math.min((now - last) / 1000, 0.05) * speed : 0; last = now; step(dt); draw(); raf = requestAnimationFrame(frame); }; const start = () => { if (still || raf) return; last = 0; raf = requestAnimationFrame(frame); }; const stop = () => { cancelAnimationFrame(raf); raf = 0; }; const resize = () => { const dpr = Math.min(window.devicePixelRatio || 1, 2); w = canvas.clientWidth; h = canvas.clientHeight; if (!w || !h) return; canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); scene = sheet(w, h); paintCity(scene, background, lights); // Shrinking then stretching is a cheap blur that also works where canvas filters do not. const small = sheet(w / 12, h / 12); small.getContext("2d")?.drawImage(scene, 0, 0, small.width, small.height); frost = sheet(w / 2, h / 2); const fr = frost.getContext("2d"); if (fr) { fr.imageSmoothingQuality = "high"; fr.drawImage(small, 0, 0, frost.width, frost.height); fr.fillStyle = "rgba(196,216,232,0.17)"; fr.fillRect(0, 0, frost.width, frost.height); } fog = sheet(w / 2, h / 2); f = fog.getContext("2d"); f?.setTransform(0.5, 0, 0, 0.5, 0, 0); f?.drawImage(frost, 0, 0, w, h); beads = []; runners = []; // Start mid-storm: the window is already streaked the moment it appears. for (let i = 0; i < 180; i++) step(0.04); draw(); }; const wipe = (e: PointerEvent) => { if (!f) return; const box = canvas.getBoundingClientRect(); const x = e.clientX - box.left; const y = e.clientY - box.top; const reach = Math.min(w, h) * 0.05 + 8; f.globalCompositeOperation = "destination-out"; clearFog(wipeX ?? x, wipeX === null ? y : wipeY, x, y, reach * 2); beads = beads.filter((b) => Math.hypot(b.x - x, b.y - y) > reach); wipeX = x; wipeY = y; if (still) draw(); }; const lift = () => { wipeX = null; }; // Redraw on size change; only animate while on screen. const ro = new ResizeObserver(resize); const io = new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop())); ro.observe(canvas); io.observe(canvas); root.addEventListener("pointermove", wipe); root.addEventListener("pointerleave", lift); return () => { stop(); ro.disconnect(); io.disconnect(); root.removeEventListener("pointermove", wipe); root.removeEventListener("pointerleave", lift); }; }, [lights, background, rain, speed]); return (
{children}
); }