"use client"; import { useEffect, useRef, type ReactNode } from "react"; type FireflyDuskProps = { children?: ReactNode; className?: string; /** Number of fireflies. */ count?: number; color?: string; /** Colour of the land and palms against the sky. */ ground?: string; /** How strongly they pull each other into step; 0 never syncs. */ sync?: number; /** 1 is the default pace; 2 is twice as fast. */ speed?: number; }; type Fly = { x: number; y: number; heading: number; pace: number; phase: number; glow: number }; /* Seconds between one firefly's flashes. */ const PERIOD = 1.3; /* A breeze scatters the rhythm this often, so the fall into step keeps happening. */ const BREEZE = 34; const rand = (a: number, b: number) => a + Math.random() * (b - a); function palm(g: CanvasRenderingContext2D, x: number, base: number, height: number, lean: number) { const topX = x + lean; const topY = base - height; g.lineCap = "round"; g.lineWidth = height * 0.03; g.beginPath(); g.moveTo(x, base); g.quadraticCurveTo(x + lean * 0.1, base - height * 0.6, topX, topY); g.stroke(); for (let i = 0; i < 11; i++) { const a = -Math.PI + (i / 10) * Math.PI + rand(-0.08, 0.08); const size = height * rand(0.3, 0.4); const at = (t: number) => [topX + Math.cos(a) * size * t, topY + Math.sin(a) * size * t + size * 0.75 * t * t]; g.lineWidth = height * 0.008; g.beginPath(); g.moveTo(topX, topY); for (let t = 0.1; t <= 1; t += 0.1) g.lineTo(at(t)[0], at(t)[1]); g.stroke(); g.lineWidth = height * 0.004; g.beginPath(); for (let t = 0.08; t < 1; t += 0.035) { const [px, py] = at(t); const drop = size * 0.17 * (1 - t * 0.6); g.moveTo(px, py); g.lineTo(px - drop * 0.3, py + drop); g.moveTo(px, py); g.lineTo(px + drop * 0.3, py + drop); } g.stroke(); } } /* Sky, far hills, palms and grass, painted once: only the fireflies move over it. */ function paintDusk(c: HTMLCanvasElement, w: number, h: number, ground: string) { const g = c.getContext("2d"); if (!g) return; const sky = g.createLinearGradient(0, 0, 0, h * 0.8); sky.addColorStop(0, "#14132f"); sky.addColorStop(0.45, "#3a2452"); sky.addColorStop(0.78, "#9c4a5e"); sky.addColorStop(1, "#f0935a"); g.fillStyle = sky; g.fillRect(0, 0, w, h); const hill = (top: number, swell: number, color: string) => { g.fillStyle = color; g.beginPath(); g.moveTo(0, h); for (let x = 0; x <= w + 8; x += 8) g.lineTo(x, top + Math.sin(x * 0.006 + top) * swell + Math.sin(x * 0.017) * swell * 0.3); g.lineTo(w, h); g.fill(); }; hill(h * 0.76, h * 0.03, "#3b2340"); hill(h * 0.82, h * 0.025, ground); g.strokeStyle = ground; const tall = Math.min(h * 0.72, w * 0.6); for (const [x, size, lean] of [[0.12, 1, 0.09], [0.21, 0.72, -0.07], [0.84, 0.86, -0.1], [0.93, 0.62, 0.05]]) { palm(g, w * x, h * 0.86, tall * size, tall * size * lean); } g.lineWidth = 1.2; g.beginPath(); for (let x = 0; x < w; x += 2.5) { const blade = rand(h * 0.03, h * 0.09); g.moveTo(x, h); g.quadraticCurveTo(x, h - blade * 0.6, x + rand(-8, 8), h - blade); } g.stroke(); g.fillStyle = ground; g.fillRect(0, h * 0.97, w, h * 0.03); } /* FireflyDusk: palms against a last orange sky and fireflies over the field. Each flashes on its own clock and nudges the others, so the swarm falls into step and flashes as one, the way real swarms do. Move the pointer through them to break the rhythm. */ export function FireflyDusk({ children, className = "", count = 70, color = "#d9ff6e", ground = "#0d0a18", sync = 1, speed = 1, }: FireflyDuskProps) { 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 scene = document.createElement("canvas"); const spark = document.createElement("canvas"); spark.width = spark.height = 64; const sg = spark.getContext("2d"); if (sg) { const glow = sg.createRadialGradient(32, 32, 0, 32, 32, 32); glow.addColorStop(0, "#fff"); glow.addColorStop(0.12, color); glow.addColorStop(0.4, `${color}44`); glow.addColorStop(1, `${color}00`); sg.fillStyle = glow; sg.fillRect(0, 0, 64, 64); } let w = 0; let h = 0; let raf = 0; let last = 0; let breeze = 0; let flies: Fly[] = []; let pointer: { x: number; y: number } | null = null; const draw = () => { ctx.globalCompositeOperation = "source-over"; ctx.drawImage(scene, 0, 0, w, h); ctx.globalCompositeOperation = "lighter"; for (const f of flies) { const size = 4 + 26 * f.glow; ctx.globalAlpha = 0.14 + 0.86 * f.glow; ctx.drawImage(spark, f.x - size / 2, f.y - size / 2, size, size); } ctx.globalAlpha = 1; }; const flash = (dt: number) => { const nudge = (sync * 0.3) / Math.max(1, flies.length); for (const f of flies) f.phase += dt / PERIOD; // One flash brings the rest closer to theirs, the nearer to flashing the more so. Those it // tips over flash in the same instant, and from then on they are locked together. const firing = flies.filter((f) => f.phase >= 1); for (let fly = firing.pop(); fly; fly = firing.pop()) { fly.phase = 0; fly.glow = 1; for (const o of flies) { if (o.phase >= 1) continue; o.phase *= 1 + nudge; if (o.phase >= 1) firing.push(o); } } }; const frame = (now: number) => { const dt = last ? Math.min((now - last) / 1000, 0.05) * speed : 0; last = now; breeze += dt; if (breeze > BREEZE) { breeze = 0; for (const f of flies) f.phase = Math.random(); } flash(dt); for (const f of flies) { f.glow *= Math.exp(-dt * 5.5); f.heading += rand(-1.6, 1.6) * dt * 2; // Turn back toward the field near its edges. if (f.y < h * 0.3) f.heading = rand(0.3, 2.8); if (f.y > h * 0.95) f.heading = rand(-2.8, -0.3); f.x = (f.x + Math.cos(f.heading) * f.pace * dt + w) % w; f.y += Math.sin(f.heading) * f.pace * dt; if (pointer) { const dx = f.x - pointer.x; const dy = f.y - pointer.y; const d = Math.hypot(dx, dy); if (d < 90) { f.x += (dx / (d || 1)) * 130 * dt; f.y += (dy / (d || 1)) * 130 * dt; f.phase = Math.random(); } } } 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; for (const c of [canvas, scene]) { c.width = Math.round(w * dpr); c.height = Math.round(h * dpr); c.getContext("2d")?.setTransform(dpr, 0, 0, dpr, 0, 0); } paintDusk(scene, w, h, ground); flies = Array.from({ length: count }, () => ({ x: rand(0, w), y: rand(h * 0.3, h * 0.95), heading: rand(0, Math.PI * 2), pace: rand(10, 24), phase: Math.random(), glow: Math.random() * 0.6, })); draw(); }; const point = (e: PointerEvent) => { const box = canvas.getBoundingClientRect(); pointer = { x: e.clientX - box.left, y: e.clientY - box.top }; }; const leave = () => { pointer = 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); if (!still) { root.addEventListener("pointermove", point); root.addEventListener("pointerleave", leave); } return () => { stop(); ro.disconnect(); io.disconnect(); root.removeEventListener("pointermove", point); root.removeEventListener("pointerleave", leave); }; }, [count, color, ground, sync, speed]); return (
{children}
); }