From 1d00ca97e52db514083ca0a06ac54d33637b6625 Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:11:55 +1000 Subject: [PATCH] feat(backgrounds): add fireflies background --- src/components/ui/backgrounds/fireflies.tsx | 122 ++++++++++++++++++++ src/components/ui/backgrounds/registry.ts | 38 ++++++ src/components/ui/backgrounds/types.ts | 1 + src/locales/en.json | 2 + src/locales/fa.json | 2 + src/locales/ru.json | 2 + src/locales/zh.json | 2 + src/utils/backgroundConfig.ts | 1 + 8 files changed, 170 insertions(+) create mode 100644 src/components/ui/backgrounds/fireflies.tsx diff --git a/src/components/ui/backgrounds/fireflies.tsx b/src/components/ui/backgrounds/fireflies.tsx new file mode 100644 index 0000000..d6e7010 --- /dev/null +++ b/src/components/ui/backgrounds/fireflies.tsx @@ -0,0 +1,122 @@ +import { useEffect, useRef } from 'react'; +import { sanitizeColor, clampNumber } from './types'; +import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop'; + +interface Props { + settings: Record; +} + +interface Firefly { + x: number; + y: number; + vx: number; + vy: number; + radius: number; + phase: number; + pulseSpeed: number; +} + +interface FirefliesState { + ctx: CanvasRenderingContext2D; + fireflies: Firefly[]; + w: number; + h: number; + dpr: number; +} + +export default function FirefliesBackground({ settings }: Props) { + const canvasRef = useRef(null); + const stateRef = useRef(null); + + const color = sanitizeColor(settings.color, '#ffd166'); + const count = clampNumber(settings.count, 5, 200, 40); + const speed = clampNumber(settings.speed, 0.1, 3, 1); + const size = clampNumber(settings.size, 0.5, 6, 2); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const dpr = getMobileDpr(); + const parent = canvas.parentElement; + const w = parent?.offsetWidth ?? window.innerWidth; + const h = parent?.offsetHeight ?? window.innerHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const fireflies: Firefly[] = Array.from({ length: Math.floor(count) }, () => ({ + x: Math.random() * w, + y: Math.random() * h, + vx: (Math.random() - 0.5) * speed * 0.6, + vy: (Math.random() - 0.5) * speed * 0.6, + radius: size * (0.5 + Math.random() * 0.5), + phase: Math.random() * Math.PI * 2, + pulseSpeed: 0.5 + Math.random() * 1.5, + })); + + stateRef.current = { ctx, fireflies, w, h, dpr }; + + const onResize = () => { + const nw = parent?.offsetWidth ?? window.innerWidth; + const nh = parent?.offsetHeight ?? window.innerHeight; + canvas.width = nw * dpr; + canvas.height = nh * dpr; + canvas.style.width = `${nw}px`; + canvas.style.height = `${nh}px`; + if (stateRef.current) { + stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + stateRef.current.w = nw; + stateRef.current.h = nh; + } + }; + + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [count, speed, size]); + + useAnimationLoop( + (time) => { + const state = stateRef.current; + if (!state) return; + + const { ctx, fireflies, w, h } = state; + + ctx.clearRect(0, 0, w, h); + + for (const f of fireflies) { + f.x += f.vx + Math.sin(time / 2000 + f.phase) * 0.3 * speed; + f.y += f.vy + Math.cos(time / 2400 + f.phase) * 0.2 * speed; + + if (f.x < -10) f.x = w + 10; + if (f.x > w + 10) f.x = -10; + if (f.y < -10) f.y = h + 10; + if (f.y > h + 10) f.y = -10; + + const pulse = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin((time / 1000) * f.pulseSpeed + f.phase)); + + ctx.globalAlpha = pulse * 0.25; + ctx.beginPath(); + ctx.arc(f.x, f.y, f.radius * 3, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + + ctx.globalAlpha = pulse; + ctx.beginPath(); + ctx.arc(f.x, f.y, f.radius, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + } + + ctx.globalAlpha = 1; + }, + [color, count, speed, size], + ); + + return ; +} diff --git a/src/components/ui/backgrounds/registry.ts b/src/components/ui/backgrounds/registry.ts index 8c91a86..184b8a1 100644 --- a/src/components/ui/backgrounds/registry.ts +++ b/src/components/ui/backgrounds/registry.ts @@ -18,6 +18,7 @@ const backgroundImports: Record, () => Promise import('./grid-background'), spotlight: () => import('./spotlight-bg'), ripple: () => import('./background-ripple'), + fireflies: () => import('./fireflies'), }; /** Prefetch the JS chunk for a background type (call early to avoid lazy-load delay) */ @@ -47,6 +48,7 @@ export const backgroundComponents: Record< dots: lazy(() => import('./grid-background')), spotlight: lazy(() => import('./spotlight-bg')), ripple: lazy(() => import('./background-ripple')), + fireflies: lazy(() => import('./fireflies')), }; // Registry of all background definitions with settings for the editor @@ -578,4 +580,40 @@ export const backgroundRegistry: BackgroundDefinition[] = [ }, ], }, + { + type: 'fireflies', + labelKey: 'admin.backgrounds.fireflies', + descriptionKey: 'admin.backgrounds.firefliesDesc', + category: 'canvas', + settings: [ + { key: 'color', label: 'admin.backgrounds.particleColor', type: 'color', default: '#ffd166' }, + { + key: 'count', + label: 'admin.backgrounds.count', + type: 'number', + min: 5, + max: 200, + step: 5, + default: 40, + }, + { + key: 'speed', + label: 'admin.backgrounds.speed', + type: 'number', + min: 0.1, + max: 3, + step: 0.1, + default: 1, + }, + { + key: 'size', + label: 'admin.backgrounds.size', + type: 'number', + min: 0.5, + max: 6, + step: 0.5, + default: 2, + }, + ], + }, ]; diff --git a/src/components/ui/backgrounds/types.ts b/src/components/ui/backgrounds/types.ts index d4ddc3b..8f814c3 100644 --- a/src/components/ui/backgrounds/types.ts +++ b/src/components/ui/backgrounds/types.ts @@ -14,6 +14,7 @@ export type BackgroundType = | 'dots' | 'spotlight' | 'ripple' + | 'fireflies' | 'none'; export interface AnimationConfig { diff --git a/src/locales/en.json b/src/locales/en.json index 06ecce6..66dc887 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1153,6 +1153,8 @@ "noiseDesc": "Noise gradients with motion", "ripple": "Ripple", "rippleDesc": "Ripple wave effect", + "fireflies": "Fireflies", + "firefliesDesc": "Drifting glowing dots with pulsation", "geminiEffect": "Gemini Effect", "geminiEffectDesc": "SVG effect like Google Gemini", "speed": "Speed", diff --git a/src/locales/fa.json b/src/locales/fa.json index db3d58f..c064a2a 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1035,6 +1035,8 @@ "noiseDesc": "گرادیان‌های نویزی با حرکت", "ripple": "موج", "rippleDesc": "اثر موج دایره‌ای", + "fireflies": "کرم‌های شب‌تاب", + "firefliesDesc": "نقاط درخشان شناور با تپش", "geminiEffect": "افکت Gemini", "geminiEffectDesc": "افکت SVG مشابه Google Gemini", "speed": "سرعت", diff --git a/src/locales/ru.json b/src/locales/ru.json index 1b9dde9..97d8a1d 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1183,6 +1183,8 @@ "noiseDesc": "Шумовые градиенты с движением", "ripple": "Ripple", "rippleDesc": "Волновой эффект при клике", + "fireflies": "Fireflies", + "firefliesDesc": "Дрейфующие светящиеся точки с пульсацией", "geminiEffect": "Gemini Effect", "geminiEffectDesc": "SVG-эффект как на Google Gemini", "speed": "Скорость", diff --git a/src/locales/zh.json b/src/locales/zh.json index 9ba385f..d8923f9 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1035,6 +1035,8 @@ "noiseDesc": "带运动的噪声渐变", "ripple": "涟漪", "rippleDesc": "涟漪波纹效果", + "fireflies": "萤火虫", + "firefliesDesc": "漂浮闪烁的发光点", "geminiEffect": "Gemini效果", "geminiEffectDesc": "类似Google Gemini的SVG效果", "speed": "速度", diff --git a/src/utils/backgroundConfig.ts b/src/utils/backgroundConfig.ts index d085be0..73cd48c 100644 --- a/src/utils/backgroundConfig.ts +++ b/src/utils/backgroundConfig.ts @@ -19,6 +19,7 @@ const VALID_TYPES: ReadonlySet = new Set([ 'dots', 'spotlight', 'ripple', + 'fireflies', 'none', ]);