feat(backgrounds): add snowfall background

This commit is contained in:
Boris Kovalskii
2026-06-11 10:13:39 +10:00
parent 1d00ca97e5
commit e3dbc4e663
9 changed files with 168 additions and 0 deletions

View File

@@ -20,6 +20,8 @@ function reduceMobileSettings(settings: Record<string, unknown>): Record<string,
reduced.particleCount = Math.max(20, Math.floor(reduced.particleCount / 4)); reduced.particleCount = Math.max(20, Math.floor(reduced.particleCount / 4));
if (typeof reduced.particleDensity === 'number') if (typeof reduced.particleDensity === 'number')
reduced.particleDensity = Math.max(50, Math.floor(reduced.particleDensity / 4)); reduced.particleDensity = Math.max(50, Math.floor(reduced.particleDensity / 4));
if (typeof reduced.density === 'number')
reduced.density = Math.max(10, Math.floor(reduced.density / 2));
if (typeof reduced.number === 'number') if (typeof reduced.number === 'number')
reduced.number = Math.max(5, Math.floor(reduced.number / 4)); reduced.number = Math.max(5, Math.floor(reduced.number / 4));
if ('interactive' in reduced) reduced.interactive = false; if ('interactive' in reduced) reduced.interactive = false;

View File

@@ -19,6 +19,7 @@ const backgroundImports: Record<Exclude<BackgroundType, 'none'>, () => Promise<u
spotlight: () => import('./spotlight-bg'), spotlight: () => import('./spotlight-bg'),
ripple: () => import('./background-ripple'), ripple: () => import('./background-ripple'),
fireflies: () => import('./fireflies'), fireflies: () => import('./fireflies'),
snowfall: () => import('./snowfall'),
}; };
/** Prefetch the JS chunk for a background type (call early to avoid lazy-load delay) */ /** Prefetch the JS chunk for a background type (call early to avoid lazy-load delay) */
@@ -49,6 +50,7 @@ export const backgroundComponents: Record<
spotlight: lazy(() => import('./spotlight-bg')), spotlight: lazy(() => import('./spotlight-bg')),
ripple: lazy(() => import('./background-ripple')), ripple: lazy(() => import('./background-ripple')),
fireflies: lazy(() => import('./fireflies')), fireflies: lazy(() => import('./fireflies')),
snowfall: lazy(() => import('./snowfall')),
}; };
// Registry of all background definitions with settings for the editor // Registry of all background definitions with settings for the editor
@@ -616,4 +618,40 @@ export const backgroundRegistry: BackgroundDefinition[] = [
}, },
], ],
}, },
{
type: 'snowfall',
labelKey: 'admin.backgrounds.snowfall',
descriptionKey: 'admin.backgrounds.snowfallDesc',
category: 'canvas',
settings: [
{ key: 'color', label: 'admin.backgrounds.particleColor', type: 'color', default: '#ffffff' },
{
key: 'density',
label: 'admin.backgrounds.density',
type: 'number',
min: 20,
max: 400,
step: 10,
default: 150,
},
{
key: 'speed',
label: 'admin.backgrounds.speed',
type: 'number',
min: 0.1,
max: 3,
step: 0.1,
default: 1,
},
{
key: 'wind',
label: 'admin.backgrounds.wind',
type: 'number',
min: -3,
max: 3,
step: 0.5,
default: 0.5,
},
],
},
]; ];

View File

@@ -0,0 +1,114 @@
import { useEffect, useRef } from 'react';
import { sanitizeColor, clampNumber } from './types';
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
}
interface Flake {
x: number;
y: number;
radius: number;
fallSpeed: number;
phase: number;
opacity: number;
}
interface SnowfallState {
ctx: CanvasRenderingContext2D;
flakes: Flake[];
w: number;
h: number;
dpr: number;
}
export default function SnowfallBackground({ settings }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const stateRef = useRef<SnowfallState | null>(null);
const color = sanitizeColor(settings.color, '#ffffff');
const density = clampNumber(settings.density, 20, 400, 150);
const speed = clampNumber(settings.speed, 0.1, 3, 1);
const wind = clampNumber(settings.wind, -3, 3, 0.5);
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 flakes: Flake[] = Array.from({ length: Math.floor(density) }, () => ({
x: Math.random() * w,
y: Math.random() * h,
radius: 0.8 + Math.random() * 2.2,
fallSpeed: 0.4 + Math.random() * 1.2,
phase: Math.random() * Math.PI * 2,
opacity: 0.3 + Math.random() * 0.7,
}));
stateRef.current = { ctx, flakes, 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);
}, [density]);
useAnimationLoop(
(time) => {
const state = stateRef.current;
if (!state) return;
const { ctx, flakes, w, h } = state;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = color;
for (const f of flakes) {
f.y += f.fallSpeed * speed;
f.x += wind * f.fallSpeed * 0.6 + Math.sin(time / 1800 + f.phase) * 0.4;
if (f.y > h + 5) {
f.y = -5;
f.x = Math.random() * w;
}
if (f.x > w + 5) f.x = -5;
if (f.x < -5) f.x = w + 5;
ctx.globalAlpha = f.opacity;
ctx.beginPath();
ctx.arc(f.x, f.y, f.radius, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
},
[color, density, speed, wind],
);
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -15,6 +15,7 @@ export type BackgroundType =
| 'spotlight' | 'spotlight'
| 'ripple' | 'ripple'
| 'fireflies' | 'fireflies'
| 'snowfall'
| 'none'; | 'none';
export interface AnimationConfig { export interface AnimationConfig {

View File

@@ -1155,6 +1155,8 @@
"rippleDesc": "Ripple wave effect", "rippleDesc": "Ripple wave effect",
"fireflies": "Fireflies", "fireflies": "Fireflies",
"firefliesDesc": "Drifting glowing dots with pulsation", "firefliesDesc": "Drifting glowing dots with pulsation",
"snowfall": "Snowfall",
"snowfallDesc": "Falling snow with wind drift",
"geminiEffect": "Gemini Effect", "geminiEffect": "Gemini Effect",
"geminiEffectDesc": "SVG effect like Google Gemini", "geminiEffectDesc": "SVG effect like Google Gemini",
"speed": "Speed", "speed": "Speed",
@@ -1176,6 +1178,7 @@
"explosionColor": "Explosion Color", "explosionColor": "Explosion Color",
"multicolor": "Multicolor", "multicolor": "Multicolor",
"lineColor": "Line Color", "lineColor": "Line Color",
"wind": "Wind",
"bgColor": "Background Color", "bgColor": "Background Color",
"fillColor": "Fill Color", "fillColor": "Fill Color",
"color1": "Color 1", "color1": "Color 1",

View File

@@ -1037,6 +1037,8 @@
"rippleDesc": "اثر موج دایره‌ای", "rippleDesc": "اثر موج دایره‌ای",
"fireflies": "کرم‌های شب‌تاب", "fireflies": "کرم‌های شب‌تاب",
"firefliesDesc": "نقاط درخشان شناور با تپش", "firefliesDesc": "نقاط درخشان شناور با تپش",
"snowfall": "بارش برف",
"snowfallDesc": "برف در حال بارش با وزش باد",
"geminiEffect": "افکت Gemini", "geminiEffect": "افکت Gemini",
"geminiEffectDesc": "افکت SVG مشابه Google Gemini", "geminiEffectDesc": "افکت SVG مشابه Google Gemini",
"speed": "سرعت", "speed": "سرعت",
@@ -1058,6 +1060,7 @@
"explosionColor": "رنگ انفجار", "explosionColor": "رنگ انفجار",
"multicolor": "حالت چندرنگ", "multicolor": "حالت چندرنگ",
"lineColor": "رنگ خطوط", "lineColor": "رنگ خطوط",
"wind": "باد",
"bgColor": "رنگ پس‌زمینه", "bgColor": "رنگ پس‌زمینه",
"fillColor": "رنگ پر کردن", "fillColor": "رنگ پر کردن",
"color1": "رنگ ۱", "color1": "رنگ ۱",

View File

@@ -1185,6 +1185,8 @@
"rippleDesc": "Волновой эффект при клике", "rippleDesc": "Волновой эффект при клике",
"fireflies": "Fireflies", "fireflies": "Fireflies",
"firefliesDesc": "Дрейфующие светящиеся точки с пульсацией", "firefliesDesc": "Дрейфующие светящиеся точки с пульсацией",
"snowfall": "Snowfall",
"snowfallDesc": "Падающий снег с ветром",
"geminiEffect": "Gemini Effect", "geminiEffect": "Gemini Effect",
"geminiEffectDesc": "SVG-эффект как на Google Gemini", "geminiEffectDesc": "SVG-эффект как на Google Gemini",
"speed": "Скорость", "speed": "Скорость",
@@ -1206,6 +1208,7 @@
"explosionColor": "Цвет взрыва", "explosionColor": "Цвет взрыва",
"multicolor": "Разноцветный режим", "multicolor": "Разноцветный режим",
"lineColor": "Цвет линий", "lineColor": "Цвет линий",
"wind": "Ветер",
"bgColor": "Цвет фона", "bgColor": "Цвет фона",
"fillColor": "Цвет заливки", "fillColor": "Цвет заливки",
"color1": "Цвет 1", "color1": "Цвет 1",

View File

@@ -1037,6 +1037,8 @@
"rippleDesc": "涟漪波纹效果", "rippleDesc": "涟漪波纹效果",
"fireflies": "萤火虫", "fireflies": "萤火虫",
"firefliesDesc": "漂浮闪烁的发光点", "firefliesDesc": "漂浮闪烁的发光点",
"snowfall": "降雪",
"snowfallDesc": "随风飘落的雪花",
"geminiEffect": "Gemini效果", "geminiEffect": "Gemini效果",
"geminiEffectDesc": "类似Google Gemini的SVG效果", "geminiEffectDesc": "类似Google Gemini的SVG效果",
"speed": "速度", "speed": "速度",
@@ -1058,6 +1060,7 @@
"explosionColor": "爆炸颜色", "explosionColor": "爆炸颜色",
"multicolor": "多彩模式", "multicolor": "多彩模式",
"lineColor": "线条颜色", "lineColor": "线条颜色",
"wind": "风力",
"bgColor": "背景颜色", "bgColor": "背景颜色",
"fillColor": "填充颜色", "fillColor": "填充颜色",
"color1": "颜色1", "color1": "颜色1",

View File

@@ -20,6 +20,7 @@ const VALID_TYPES: ReadonlySet<string> = new Set<BackgroundType>([
'spotlight', 'spotlight',
'ripple', 'ripple',
'fireflies', 'fireflies',
'snowfall',
'none', 'none',
]); ]);