mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
perf: optimize animated backgrounds for mobile — reduce GPU load and memory pressure
- Add useAnimationLoop hook with FPS throttling (30fps mobile/60fps desktop), Page Visibility API pause, and Telegram WebApp activated/deactivated events - Add useAnimationPause hook for CSS/Framer Motion components - Validate animation config with type allowlist, numeric clamping, JSON size guard - Move ctx.setTransform from per-frame to init/resize across all canvas backgrounds - Move ctx.lineCap to init/resize in vortex, replace Float32Array.set with direct writes - Pre-compute shooting star delay instead of per-frame Math.random - Cap DPR to 1.5 on mobile, reduce particle counts, cap blur to 4px - Disable aurora CSS animation on mobile, remove blur on mobile spotlight - Remove mix-blend-hard-light on mobile gradient-animation - Add NaN/Infinity guard to clampNumber, remove dead CSS variables - Add conditional unmount for Framer Motion backgrounds when paused - Add animationPlayState pause for CSS-animated backgrounds
This commit is contained in:
@@ -7,19 +7,57 @@ import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
|
||||
import { backgroundComponents, prefetchBackground } from '@/components/ui/backgrounds/registry';
|
||||
|
||||
const ANIMATION_CACHE_KEY = 'cabinet_animation_config';
|
||||
const MAX_CONFIG_JSON_LENGTH = 10_000;
|
||||
|
||||
const VALID_TYPES: ReadonlySet<string> = new Set<BackgroundType>([
|
||||
'aurora',
|
||||
'sparkles',
|
||||
'vortex',
|
||||
'shooting-stars',
|
||||
'background-beams',
|
||||
'background-beams-collision',
|
||||
'gradient-animation',
|
||||
'wavy',
|
||||
'background-lines',
|
||||
'boxes',
|
||||
'meteors',
|
||||
'grid',
|
||||
'dots',
|
||||
'spotlight',
|
||||
'ripple',
|
||||
'none',
|
||||
]);
|
||||
|
||||
function validateConfig(parsed: unknown): AnimationConfig | null {
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (typeof obj.enabled !== 'boolean') return null;
|
||||
if (typeof obj.type !== 'string' || !VALID_TYPES.has(obj.type)) return null;
|
||||
if (typeof obj.opacity !== 'number') return null;
|
||||
if (typeof obj.blur !== 'number') return null;
|
||||
if (obj.settings != null && (typeof obj.settings !== 'object' || Array.isArray(obj.settings)))
|
||||
return null;
|
||||
return {
|
||||
enabled: obj.enabled,
|
||||
type: obj.type as BackgroundType,
|
||||
opacity: Math.max(0, Math.min(1, obj.opacity)),
|
||||
blur: Math.max(0, Math.min(50, obj.blur)),
|
||||
settings: (obj.settings as Record<string, unknown>) ?? {},
|
||||
reducedOnMobile: typeof obj.reducedOnMobile === 'boolean' ? obj.reducedOnMobile : true,
|
||||
};
|
||||
}
|
||||
|
||||
function getCachedConfig(): AnimationConfig | null {
|
||||
try {
|
||||
const cached = localStorage.getItem(ANIMATION_CACHE_KEY);
|
||||
return cached ? JSON.parse(cached) : null;
|
||||
if (!cached || cached.length > MAX_CONFIG_JSON_LENGTH) return null;
|
||||
return validateConfig(JSON.parse(cached));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Prefetch the background JS chunk immediately based on localStorage cache.
|
||||
// This starts the download before React even renders, so by the time
|
||||
// Suspense needs the component, the chunk is already loaded.
|
||||
const cachedConfig = getCachedConfig();
|
||||
if (cachedConfig?.enabled && cachedConfig.type && cachedConfig.type !== 'none') {
|
||||
prefetchBackground(cachedConfig.type);
|
||||
@@ -34,18 +72,27 @@ function setCachedConfig(config: AnimationConfig) {
|
||||
}
|
||||
|
||||
export function setCachedAnimationConfig(config: AnimationConfig) {
|
||||
setCachedConfig(config);
|
||||
const validated = validateConfig(config);
|
||||
if (validated) setCachedConfig(validated);
|
||||
}
|
||||
|
||||
function reduceMobileSettings(settings: Record<string, unknown>): Record<string, unknown> {
|
||||
const reduced = { ...settings };
|
||||
// 75% reduction (divide by 4) instead of 50% — much less GPU work
|
||||
if (typeof reduced.particleCount === 'number')
|
||||
reduced.particleCount = Math.floor((reduced.particleCount as number) / 2);
|
||||
reduced.particleCount = Math.max(20, Math.floor(reduced.particleCount / 4));
|
||||
if (typeof reduced.particleDensity === 'number')
|
||||
reduced.particleDensity = Math.floor((reduced.particleDensity as number) / 2);
|
||||
reduced.particleDensity = Math.max(50, Math.floor(reduced.particleDensity / 4));
|
||||
if (typeof reduced.number === 'number')
|
||||
reduced.number = Math.floor((reduced.number as number) / 2);
|
||||
reduced.number = Math.max(5, Math.floor(reduced.number / 4));
|
||||
if ('interactive' in reduced) reduced.interactive = false;
|
||||
if (typeof reduced.lineCount === 'number')
|
||||
reduced.lineCount = Math.max(5, Math.floor(reduced.lineCount / 2));
|
||||
if (typeof reduced.rippleCount === 'number')
|
||||
reduced.rippleCount = Math.max(2, Math.floor(reduced.rippleCount / 2));
|
||||
if (typeof reduced.count === 'number') reduced.count = Math.max(3, Math.floor(reduced.count / 2));
|
||||
if (typeof reduced.rows === 'number') reduced.rows = Math.max(4, Math.floor(reduced.rows * 0.6));
|
||||
if (typeof reduced.cols === 'number') reduced.cols = Math.max(4, Math.floor(reduced.cols * 0.6));
|
||||
return reduced;
|
||||
}
|
||||
|
||||
@@ -58,7 +105,9 @@ export function BackgroundRenderer() {
|
||||
const { data: config } = useQuery({
|
||||
queryKey: ['animation-config'],
|
||||
queryFn: async () => {
|
||||
const result = await brandingApi.getAnimationConfig();
|
||||
const raw = await brandingApi.getAnimationConfig();
|
||||
// Validate and clamp API response same as localStorage
|
||||
const result = validateConfig(raw) ?? DEFAULT_ANIMATION_CONFIG;
|
||||
setCachedConfig(result);
|
||||
return result;
|
||||
},
|
||||
@@ -84,17 +133,16 @@ export function BackgroundRenderer() {
|
||||
? reduceMobileSettings(effectiveConfig.settings)
|
||||
: effectiveConfig.settings;
|
||||
|
||||
// Render via portal on document.body with z-index: -1.
|
||||
// This places the animated background BELOW the root stacking context,
|
||||
// preventing Chrome's implicit compositing from promoting every
|
||||
// overlapping element to its own GPU layer (the root cause of flickering).
|
||||
// On mobile, cap blur to 4px max — full blur is extremely GPU-heavy
|
||||
const effectiveBlur = isMobile ? Math.min(effectiveConfig.blur, 4) : effectiveConfig.blur;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0"
|
||||
style={{
|
||||
zIndex: -2,
|
||||
opacity: effectiveConfig.opacity,
|
||||
filter: effectiveConfig.blur > 0 ? `blur(${effectiveConfig.blur}px)` : undefined,
|
||||
filter: effectiveBlur > 0 ? `blur(${effectiveBlur}px)` : undefined,
|
||||
contain: 'strict',
|
||||
backfaceVisibility: 'hidden',
|
||||
}}
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { safeBoolean } from './types';
|
||||
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
export default function AuroraBackground({ settings }: Props) {
|
||||
const showRadialGradient = (settings.showRadialGradient as boolean) ?? true;
|
||||
const showRadialGradient = safeBoolean(settings.showRadialGradient, true);
|
||||
const paused = useAnimationPause();
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute -inset-[10px] animate-aurora opacity-50 blur-[10px] will-change-transform',
|
||||
'pointer-events-none absolute -inset-[10px] opacity-50',
|
||||
!isMobile && 'animate-aurora',
|
||||
showRadialGradient &&
|
||||
'[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,transparent_70%)]',
|
||||
)}
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(100deg, #000 0%, #000 7%, transparent 10%, transparent 12%, #000 16%), repeating-linear-gradient(100deg, #3b82f6 10%, #a5b4fc 15%, #93c5fd 20%, #ddd6fe 25%, #60a5fa 30%)',
|
||||
backgroundSize: '300%, 200%',
|
||||
backgroundSize: isMobile ? '100%, 100%' : '300%, 200%',
|
||||
animationPlayState: paused ? 'paused' : 'running',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
@@ -71,38 +72,60 @@ function CollisionMechanism({
|
||||
const [beamKey, setBeamKey] = useState(0);
|
||||
const [cycleCollisionDetected, setCycleCollisionDetected] = useState(false);
|
||||
|
||||
const checkRef = useRef({ cycleDetected: false });
|
||||
checkRef.current.cycleDetected = cycleCollisionDetected;
|
||||
|
||||
const checkCollision = useCallback(() => {
|
||||
if (checkRef.current.cycleDetected) return;
|
||||
if (!beamRef.current || !containerRef.current || !parentRef.current) return;
|
||||
|
||||
const beamRect = beamRef.current.getBoundingClientRect();
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const parentRect = parentRef.current.getBoundingClientRect();
|
||||
|
||||
if (beamRect.bottom >= containerRect.top) {
|
||||
const relativeX = beamRect.left - parentRect.left + beamRect.width / 2;
|
||||
const relativeY = beamRect.bottom - parentRect.top;
|
||||
|
||||
setCollision({ detected: true, coordinates: { x: relativeX, y: relativeY } });
|
||||
setCycleCollisionDetected(true);
|
||||
}
|
||||
}, [containerRef, parentRef]);
|
||||
|
||||
// Throttled collision detection loop.
|
||||
// Parent unmounts this component when paused, so no visibility handling needed here.
|
||||
useEffect(() => {
|
||||
const checkCollision = () => {
|
||||
if (beamRef.current && containerRef.current && parentRef.current && !cycleCollisionDetected) {
|
||||
const beamRect = beamRef.current.getBoundingClientRect();
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const parentRect = parentRef.current.getBoundingClientRect();
|
||||
let animId = 0;
|
||||
let lastCheck = 0;
|
||||
const CHECK_INTERVAL = 100;
|
||||
|
||||
if (beamRect.bottom >= containerRect.top) {
|
||||
const relativeX = beamRect.left - parentRect.left + beamRect.width / 2;
|
||||
const relativeY = beamRect.bottom - parentRect.top;
|
||||
|
||||
setCollision({ detected: true, coordinates: { x: relativeX, y: relativeY } });
|
||||
setCycleCollisionDetected(true);
|
||||
}
|
||||
const loop = (time: number) => {
|
||||
if (time - lastCheck >= CHECK_INTERVAL) {
|
||||
lastCheck = time;
|
||||
checkCollision();
|
||||
}
|
||||
animId = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
const interval = setInterval(checkCollision, 50);
|
||||
return () => clearInterval(interval);
|
||||
}, [cycleCollisionDetected, containerRef, parentRef]);
|
||||
animId = requestAnimationFrame(loop);
|
||||
|
||||
return () => {
|
||||
if (animId) cancelAnimationFrame(animId);
|
||||
};
|
||||
}, [checkCollision]);
|
||||
|
||||
// Collision reset with proper timeout cleanup
|
||||
useEffect(() => {
|
||||
if (collision.detected && collision.coordinates) {
|
||||
setTimeout(() => {
|
||||
setCollision({ detected: false, coordinates: null });
|
||||
setCycleCollisionDetected(false);
|
||||
}, 2000);
|
||||
setTimeout(() => {
|
||||
setBeamKey((prev) => prev + 1);
|
||||
}, 2000);
|
||||
}
|
||||
}, [collision]);
|
||||
if (!collision.detected || !collision.coordinates) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setCollision({ detected: false, coordinates: null });
|
||||
setCycleCollisionDetected(false);
|
||||
setBeamKey((prev) => prev + 1);
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [collision.detected]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -154,17 +177,19 @@ function CollisionMechanism({
|
||||
export default function BackgroundBeamsCollision({ settings: _settings }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const paused = useAnimationPause();
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="absolute inset-0 overflow-hidden">
|
||||
{BEAMS.map((beam) => (
|
||||
<CollisionMechanism
|
||||
key={`${beam.initialX}-beam`}
|
||||
beamOptions={beam}
|
||||
containerRef={containerRef}
|
||||
parentRef={parentRef}
|
||||
/>
|
||||
))}
|
||||
{!paused &&
|
||||
BEAMS.map((beam) => (
|
||||
<CollisionMechanism
|
||||
key={`${beam.initialX}-beam`}
|
||||
beamOptions={beam}
|
||||
containerRef={containerRef}
|
||||
parentRef={parentRef}
|
||||
/>
|
||||
))}
|
||||
{/* Bottom collision line */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
@@ -99,16 +100,15 @@ function ensureStyles() {
|
||||
}
|
||||
|
||||
export default React.memo(function BackgroundBeams({ settings: _settings }: Props) {
|
||||
const paused = useAnimationPause();
|
||||
|
||||
// Inject CSS once on first render
|
||||
React.useEffect(() => {
|
||||
ensureStyles();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{ contain: 'strict', willChange: 'transform' }}
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden" style={{ contain: 'strict' }}>
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
width="100%"
|
||||
@@ -138,6 +138,7 @@ export default React.memo(function BackgroundBeams({ settings: _settings }: Prop
|
||||
strokeDashoffset="1"
|
||||
style={{
|
||||
animation: `beamDash ${beamParams[paramIndex].duration}s ease-in-out ${beamParams[paramIndex].delay}s infinite`,
|
||||
animationPlayState: paused ? 'paused' : 'running',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useMemo } from 'react';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
@@ -27,23 +28,19 @@ interface CellData {
|
||||
period: number;
|
||||
}
|
||||
|
||||
// Pre-computed transform constants for skewX(-48deg) skewY(14deg) scale(0.675)
|
||||
const SKEW_X_TAN = Math.tan((-48 * Math.PI) / 180);
|
||||
const SKEW_Y_TAN = Math.tan((14 * Math.PI) / 180);
|
||||
const GRID_SCALE = 0.675;
|
||||
|
||||
/**
|
||||
* Animated boxes background rendered on a single <canvas> element.
|
||||
*
|
||||
* Previous implementation used 225 DOM <div> elements with CSS @keyframes.
|
||||
* Chrome's compositor created a separate paint region per div; any hover
|
||||
* state change on page content forced Chrome to re-composite all 225 regions,
|
||||
* causing visible flickering. A single canvas bypasses the DOM style/layout/paint
|
||||
* pipeline entirely — hover interactions on other elements cannot trigger
|
||||
* repaints of canvas content.
|
||||
*/
|
||||
interface BoxesState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export default React.memo(function BackgroundBoxes({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const stateRef = useRef<BoxesState | null>(null);
|
||||
const rows = clampNumber(settings.rows, 4, 30, 15);
|
||||
const cols = clampNumber(settings.cols, 4, 30, 15);
|
||||
const boxColor = sanitizeColor(settings.boxColor, '#818cf8');
|
||||
@@ -61,44 +58,51 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) {
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const dpr = getMobileDpr();
|
||||
const parent = canvas.parentElement;
|
||||
if (!parent) return;
|
||||
const rect = parent.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let animId = 0;
|
||||
stateRef.current = { ctx, w: canvas.width, h: canvas.height };
|
||||
|
||||
const resize = () => {
|
||||
const dpr = devicePixelRatio || 1;
|
||||
const parent = canvas.parentElement;
|
||||
if (!parent) return;
|
||||
const rect = parent.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
const onResize = () => {
|
||||
const r = parent.getBoundingClientRect();
|
||||
canvas.width = r.width * dpr;
|
||||
canvas.height = r.height * dpr;
|
||||
if (stateRef.current) {
|
||||
stateRef.current.w = canvas.width;
|
||||
stateRef.current.h = canvas.height;
|
||||
}
|
||||
};
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
const draw = (now: number) => {
|
||||
useAnimationLoop(
|
||||
(now) => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
const { ctx, w, h } = state;
|
||||
const t = now * 0.001;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.save();
|
||||
|
||||
// Transform origin: center of viewport
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
ctx.translate(cx, cy);
|
||||
|
||||
// Replicate CSS: skewX(-48deg) skewY(14deg) scale(0.675)
|
||||
ctx.transform(1, 0, SKEW_X_TAN, 1, 0, 0);
|
||||
ctx.transform(1, SKEW_Y_TAN, 0, 1, 0, 0);
|
||||
ctx.scale(GRID_SCALE, GRID_SCALE);
|
||||
|
||||
ctx.translate(-cx, -cy);
|
||||
|
||||
// Grid: 300% of viewport, offset by -100% (matches the original CSS layout)
|
||||
const gw = w * 3;
|
||||
const gh = h * 3;
|
||||
const ox = -w;
|
||||
@@ -106,7 +110,6 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) {
|
||||
const cellW = gw / cols;
|
||||
const cellH = gh / rows;
|
||||
|
||||
// Draw colored cells
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const cell = cells[i];
|
||||
const cycleT = ((t + cell.phase) % cell.period) / cell.period;
|
||||
@@ -121,7 +124,6 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) {
|
||||
ctx.fillRect(ox + col * cellW, oy + row * cellH, cellW, cellH);
|
||||
}
|
||||
|
||||
// Draw grid lines as a single batch (much cheaper than 225 individual strokeRects)
|
||||
ctx.strokeStyle = 'rgba(51,65,85,0.5)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
@@ -139,17 +141,9 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) {
|
||||
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
animId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
animId = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [cells, rows, cols]);
|
||||
},
|
||||
[cells, rows, cols],
|
||||
);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sanitizeColor } from './types';
|
||||
import { sanitizeColor, safeBoolean, safeSelect } from './types';
|
||||
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
function hexToRgbString(hex: string): string {
|
||||
hex = hex.replace('#', '');
|
||||
if (hex.length === 3)
|
||||
@@ -22,23 +25,29 @@ function hexToRgbString(hex: string): string {
|
||||
|
||||
export default function BackgroundGradientAnimation({ settings }: Props) {
|
||||
const interactiveRef = useRef<HTMLDivElement>(null);
|
||||
const paused = useAnimationPause();
|
||||
|
||||
const firstColor = hexToRgbString(sanitizeColor(settings.firstColor, '#1271FF'));
|
||||
const secondColor = hexToRgbString(sanitizeColor(settings.secondColor, '#DD4AFF'));
|
||||
const thirdColor = hexToRgbString(sanitizeColor(settings.thirdColor, '#64DCFF'));
|
||||
const fourthColor = hexToRgbString(sanitizeColor(settings.fourthColor, '#C83232'));
|
||||
const fifthColor = hexToRgbString(sanitizeColor(settings.fifthColor, '#B4B432'));
|
||||
const interactive = (settings.interactive as boolean) ?? true;
|
||||
const size = (settings.size as string) ?? '80%';
|
||||
const interactive = safeBoolean(settings.interactive, true);
|
||||
const size = safeSelect(settings.size, ['60%', '80%', '100%'] as const, '80%');
|
||||
|
||||
useEffect(() => {
|
||||
if (!interactive || !interactiveRef.current) return;
|
||||
// Interactive mouse tracking — DESKTOP ONLY
|
||||
if (!interactive || !interactiveRef.current || isMobile) return;
|
||||
|
||||
let curX = 0;
|
||||
let curY = 0;
|
||||
let tgX = 0;
|
||||
let tgY = 0;
|
||||
let animId = 0;
|
||||
let docHidden = document.hidden;
|
||||
let tgInactive = false;
|
||||
|
||||
const isHidden = () => docHidden || tgInactive;
|
||||
|
||||
const move = () => {
|
||||
curX += (tgX - curX) / 20;
|
||||
@@ -49,20 +58,62 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
|
||||
animId = requestAnimationFrame(move);
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (animId) return;
|
||||
animId = requestAnimationFrame(move);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (animId) {
|
||||
cancelAnimationFrame(animId);
|
||||
animId = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouse = (e: MouseEvent) => {
|
||||
tgX = e.clientX;
|
||||
tgY = e.clientY;
|
||||
};
|
||||
|
||||
const onVisibility = () => {
|
||||
docHidden = document.hidden;
|
||||
if (isHidden()) stop();
|
||||
else start();
|
||||
};
|
||||
|
||||
const tg = window.Telegram?.WebApp;
|
||||
const onActivated = () => {
|
||||
tgInactive = false;
|
||||
if (!isHidden()) start();
|
||||
};
|
||||
const onDeactivated = () => {
|
||||
tgInactive = true;
|
||||
stop();
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouse);
|
||||
animId = requestAnimationFrame(move);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
if (tg) {
|
||||
tg.onEvent?.('activated', onActivated);
|
||||
tg.onEvent?.('deactivated', onDeactivated);
|
||||
}
|
||||
if (!isHidden()) start();
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
window.removeEventListener('mousemove', handleMouse);
|
||||
cancelAnimationFrame(animId);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
if (tg) {
|
||||
tg.offEvent?.('activated', onActivated);
|
||||
tg.offEvent?.('deactivated', onDeactivated);
|
||||
}
|
||||
};
|
||||
}, [interactive]);
|
||||
|
||||
// Mobile: use simpler blur (just CSS blur, no SVG filter)
|
||||
// Desktop: keep the goo SVG filter + CSS blur for full effect
|
||||
const filterStyle = isMobile ? 'blur(20px)' : 'url(#blurMe) blur(40px)';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
@@ -74,26 +125,28 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
|
||||
'--fourth-color': fourthColor,
|
||||
'--fifth-color': fifthColor,
|
||||
'--size': size,
|
||||
'--blending': 'hard-light',
|
||||
background: `linear-gradient(40deg, rgb(${firstColor}), rgb(${fifthColor}))`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<svg className="hidden">
|
||||
<defs>
|
||||
<filter id="blurMe">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur" />
|
||||
<feColorMatrix
|
||||
in="blur"
|
||||
mode="matrix"
|
||||
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -8"
|
||||
result="goo"
|
||||
/>
|
||||
<feBlend in="SourceGraphic" in2="goo" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
<div className="absolute inset-0" style={{ filter: 'url(#blurMe) blur(40px)' }}>
|
||||
{/* SVG filter only rendered on desktop */}
|
||||
{!isMobile && (
|
||||
<svg className="hidden">
|
||||
<defs>
|
||||
<filter id="blurMe">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur" />
|
||||
<feColorMatrix
|
||||
in="blur"
|
||||
mode="matrix"
|
||||
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -8"
|
||||
result="goo"
|
||||
/>
|
||||
<feBlend in="SourceGraphic" in2="goo" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
)}
|
||||
<div className="absolute inset-0" style={{ filter: filterStyle }}>
|
||||
{[
|
||||
{ color: firstColor, anim: 'animate-move-vertical' },
|
||||
{ color: secondColor, anim: 'animate-move-in-circle' },
|
||||
@@ -104,15 +157,17 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'absolute left-[calc(50%-var(--size)/2)] top-[calc(50%-var(--size)/2)] h-[var(--size)] w-[var(--size)] rounded-full opacity-100 mix-blend-hard-light',
|
||||
'absolute left-[calc(50%-var(--size)/2)] top-[calc(50%-var(--size)/2)] h-[var(--size)] w-[var(--size)] rounded-full',
|
||||
isMobile ? 'opacity-50' : 'opacity-100 mix-blend-hard-light',
|
||||
blob.anim,
|
||||
)}
|
||||
style={{
|
||||
background: `radial-gradient(circle at center, rgba(${blob.color}, 0.8) 0, rgba(${blob.color}, 0) 50%) no-repeat`,
|
||||
animationPlayState: paused ? 'paused' : 'running',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{interactive && (
|
||||
{interactive && !isMobile && (
|
||||
<div
|
||||
ref={interactiveRef}
|
||||
className="absolute left-[calc(50%-var(--size)/2)] top-[calc(50%-var(--size)/2)] h-[var(--size)] w-[var(--size)] rounded-full opacity-70 mix-blend-hard-light"
|
||||
|
||||
@@ -1,73 +1,105 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createNoise3D } from 'simplex-noise';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
interface LinesState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
noise3D: ReturnType<typeof createNoise3D>;
|
||||
nt: number;
|
||||
w: number;
|
||||
h: number;
|
||||
dpr: number;
|
||||
}
|
||||
|
||||
export default function BackgroundLines({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number>(0);
|
||||
const stateRef = useRef<LinesState | null>(null);
|
||||
|
||||
const lineCount = clampNumber(settings.lineCount, 5, 100, 40);
|
||||
const lineColor = sanitizeColor(settings.lineColor, '#818cf8');
|
||||
const speed = clampNumber(settings.speed, 0.0005, 0.01, 0.002);
|
||||
const strokeWidth = clampNumber(settings.strokeWidth, 0.5, 5, 1);
|
||||
|
||||
const effectiveLineCount = isMobile ? Math.min(lineCount, 20) : lineCount;
|
||||
const step = isMobile ? 8 : 4;
|
||||
|
||||
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 noise3D = createNoise3D();
|
||||
let nt = 0;
|
||||
stateRef.current = { ctx, noise3D: createNoise3D(), nt: 0, w, h, dpr };
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
nt += speed;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const yBase = (i / lineCount) * canvas.height;
|
||||
const opacity = 0.1 + 0.15 * Math.sin((i / lineCount) * Math.PI);
|
||||
useAnimationLoop(() => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = lineColor;
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.lineWidth = strokeWidth;
|
||||
const { ctx, noise3D, w, h } = state;
|
||||
state.nt += speed;
|
||||
|
||||
for (let x = 0; x < canvas.width; x += 4) {
|
||||
const y = yBase + noise3D(x / 600, i * 0.3, nt) * 40;
|
||||
if (x === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
ctx.strokeStyle = lineColor;
|
||||
ctx.lineWidth = strokeWidth;
|
||||
|
||||
for (let i = 0; i < effectiveLineCount; i++) {
|
||||
const yBase = (i / effectiveLineCount) * h;
|
||||
const opacity = 0.1 + 0.15 * Math.sin((i / effectiveLineCount) * Math.PI);
|
||||
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.beginPath();
|
||||
|
||||
for (let x = 0; x < w; x += step) {
|
||||
const y = yBase + noise3D(x / 600, i * 0.3, state.nt) * 40;
|
||||
if (x === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
resize();
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [lineCount, lineColor, speed, strokeWidth]);
|
||||
ctx.globalAlpha = 1;
|
||||
}, [effectiveLineCount, lineColor, speed, strokeWidth]);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface RippleState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
w: number;
|
||||
h: number;
|
||||
dpr: number;
|
||||
ripples: { phase: number }[];
|
||||
}
|
||||
|
||||
export default function BackgroundRipple({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number>(0);
|
||||
const stateRef = useRef<RippleState | null>(null);
|
||||
|
||||
const rippleColor = sanitizeColor(settings.rippleColor, '#818cf8');
|
||||
const rippleCount = clampNumber(settings.rippleCount, 1, 20, 5);
|
||||
@@ -17,24 +26,53 @@ export default function BackgroundRipple({ settings }: Props) {
|
||||
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;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
||||
};
|
||||
|
||||
const ripples = Array.from({ length: rippleCount }, (_, i) => ({
|
||||
phase: (i / rippleCount) * Math.PI * 2,
|
||||
maxRadius: 0,
|
||||
}));
|
||||
|
||||
const animate = (time: number) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
stateRef.current = { ctx, w, h, dpr, ripples };
|
||||
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
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.w = nw;
|
||||
stateRef.current.h = nh;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [rippleCount]);
|
||||
|
||||
useAnimationLoop(
|
||||
(time) => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
const { ctx, w, h, dpr, ripples } = state;
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const maxR = Math.sqrt(cx * cx + cy * cy);
|
||||
|
||||
for (let i = 0; i < ripples.length; i++) {
|
||||
@@ -52,18 +90,9 @@ export default function BackgroundRipple({ settings }: Props) {
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
resize();
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [rippleColor, rippleCount, speed]);
|
||||
},
|
||||
[rippleColor, rippleCount, speed],
|
||||
);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { sanitizeColor, clampNumber, safeSelect } from './types';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export default function GridBackground({ settings }: Props) {
|
||||
const variant = (settings.variant as string) ?? 'grid';
|
||||
const variant = safeSelect(settings.variant, ['grid', 'dots'] as const, 'grid');
|
||||
const gridColor = sanitizeColor(settings.gridColor, 'rgba(255,255,255,0.05)');
|
||||
const gridSize = clampNumber(settings.gridSize, 10, 200, 40);
|
||||
const dotSize = clampNumber(settings.dotSize, 0.5, 10, 1.5);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
@@ -8,6 +9,7 @@ interface Props {
|
||||
export default function Meteors({ settings }: Props) {
|
||||
const count = clampNumber(settings.count, 1, 50, 20);
|
||||
const meteorColor = sanitizeColor(settings.meteorColor, '#ffffff');
|
||||
const paused = useAnimationPause();
|
||||
|
||||
const meteors = useMemo(
|
||||
() =>
|
||||
@@ -31,6 +33,7 @@ export default function Meteors({ settings }: Props) {
|
||||
left: meteor.left,
|
||||
animationDelay: meteor.delay,
|
||||
animationDuration: meteor.duration,
|
||||
animationPlayState: paused ? 'paused' : 'running',
|
||||
width: meteor.size,
|
||||
height: meteor.size,
|
||||
boxShadow: `0 0 0 1px rgba(255,255,255,0.05), 0 0 2px 1px ${meteorColor}20, 0 0 20px 2px ${meteorColor}40`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
@@ -23,9 +24,20 @@ interface BgStar {
|
||||
twinkleSpeed: number | null;
|
||||
}
|
||||
|
||||
interface ShootingState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
shootingStars: Star[];
|
||||
bgStars: BgStar[];
|
||||
lastShootingTime: number;
|
||||
nextShootingDelay: number;
|
||||
w: number;
|
||||
h: number;
|
||||
dpr: number;
|
||||
}
|
||||
|
||||
export default function ShootingStarsBackground({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number>(0);
|
||||
const stateRef = useRef<ShootingState | null>(null);
|
||||
|
||||
const starColor = sanitizeColor(settings.starColor, '#9E00FF');
|
||||
const trailColor = sanitizeColor(settings.trailColor, '#2EB9DF');
|
||||
@@ -37,46 +49,67 @@ export default function ShootingStarsBackground({ settings }: Props) {
|
||||
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;
|
||||
|
||||
let shootingStars: Star[] = [];
|
||||
let bgStars: BgStar[] = [];
|
||||
let lastShootingTime = 0;
|
||||
const count = Math.floor(w * h * starDensity);
|
||||
const bgStars: BgStar[] = Array.from({ length: count }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
radius: Math.random() * 1.2 + 0.3,
|
||||
opacity: Math.random(),
|
||||
twinkleSpeed: Math.random() > 0.3 ? 0.5 + Math.random() * 0.5 : null,
|
||||
}));
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
||||
initBgStars();
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
stateRef.current = {
|
||||
ctx,
|
||||
shootingStars: [],
|
||||
bgStars,
|
||||
lastShootingTime: 0,
|
||||
nextShootingDelay: 4200 + Math.random() * 4500,
|
||||
w,
|
||||
h,
|
||||
dpr,
|
||||
};
|
||||
|
||||
const initBgStars = () => {
|
||||
const count = Math.floor(canvas.width * canvas.height * starDensity);
|
||||
bgStars = Array.from({ length: count }, () => ({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
radius: Math.random() * 1.2 + 0.3,
|
||||
opacity: Math.random(),
|
||||
twinkleSpeed: Math.random() > 0.3 ? 0.5 + Math.random() * 0.5 : null,
|
||||
}));
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const spawnShootingStar = () => {
|
||||
shootingStars.push({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height * 0.5,
|
||||
angle: Math.PI / 4 + (Math.random() - 0.5) * 0.3,
|
||||
scale: 0.5 + Math.random() * 0.5,
|
||||
speed: minSpeed + Math.random() * (maxSpeed - minSpeed),
|
||||
distance: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [starDensity]);
|
||||
|
||||
const animate = (time: number) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
useAnimationLoop(
|
||||
(time) => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
const { ctx, bgStars, w, h } = state;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Background stars
|
||||
for (const s of bgStars) {
|
||||
let opacity = s.opacity;
|
||||
if (s.twinkleSpeed) {
|
||||
@@ -88,14 +121,21 @@ export default function ShootingStarsBackground({ settings }: Props) {
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Spawn shooting stars
|
||||
if (time - lastShootingTime > 4200 + Math.random() * 4500) {
|
||||
spawnShootingStar();
|
||||
lastShootingTime = time;
|
||||
if (time - state.lastShootingTime > state.nextShootingDelay) {
|
||||
state.shootingStars.push({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h * 0.5,
|
||||
angle: Math.PI / 4 + (Math.random() - 0.5) * 0.3,
|
||||
scale: 0.5 + Math.random() * 0.5,
|
||||
speed: minSpeed + Math.random() * (maxSpeed - minSpeed),
|
||||
distance: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
state.lastShootingTime = time;
|
||||
state.nextShootingDelay = 4200 + Math.random() * 4500;
|
||||
}
|
||||
|
||||
// Draw shooting stars
|
||||
shootingStars = shootingStars.filter((star) => {
|
||||
state.shootingStars = state.shootingStars.filter((star) => {
|
||||
star.distance += star.speed;
|
||||
star.opacity = Math.max(0, 1 - star.distance / 500);
|
||||
|
||||
@@ -106,36 +146,28 @@ export default function ShootingStarsBackground({ settings }: Props) {
|
||||
const tailX = star.x + Math.cos(star.angle) * Math.max(0, star.distance - 80);
|
||||
const tailY = star.y + Math.sin(star.angle) * Math.max(0, star.distance - 80);
|
||||
|
||||
const gradient = ctx.createLinearGradient(tailX, tailY, x2, y2);
|
||||
gradient.addColorStop(0, 'transparent');
|
||||
gradient.addColorStop(0.5, trailColor);
|
||||
gradient.addColorStop(1, starColor);
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = gradient;
|
||||
// Draw trail with flat color (avoids per-frame gradient allocation)
|
||||
ctx.lineWidth = star.scale * 2;
|
||||
ctx.globalAlpha = star.opacity;
|
||||
ctx.globalAlpha = star.opacity * 0.4;
|
||||
ctx.strokeStyle = trailColor;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tailX, tailY);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// Draw head dot
|
||||
ctx.globalAlpha = star.opacity;
|
||||
ctx.fillStyle = starColor;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x2, y2, star.scale * 1.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
resize();
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [starColor, trailColor, starDensity, minSpeed, maxSpeed]);
|
||||
},
|
||||
[starColor, trailColor, starDensity, minSpeed, maxSpeed],
|
||||
);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
@@ -16,10 +17,32 @@ interface Particle {
|
||||
opacitySpeed: number;
|
||||
}
|
||||
|
||||
interface SparklesState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
particles: Particle[];
|
||||
rgb: { r: number; g: number; b: number };
|
||||
w: number;
|
||||
h: number;
|
||||
dpr: number;
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string) {
|
||||
hex = hex.replace('#', '');
|
||||
if (hex.length === 3)
|
||||
hex = hex
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('');
|
||||
return {
|
||||
r: parseInt(hex.substring(0, 2), 16),
|
||||
g: parseInt(hex.substring(2, 4), 16),
|
||||
b: parseInt(hex.substring(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
export default function Sparkles({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number>(0);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
const stateRef = useRef<SparklesState | null>(null);
|
||||
|
||||
const particleDensity = clampNumber(settings.particleDensity, 50, 5000, 800);
|
||||
const minSize = clampNumber(settings.minSize, 0.1, 5, 0.4);
|
||||
@@ -27,89 +50,88 @@ export default function Sparkles({ settings }: Props) {
|
||||
const speed = clampNumber(settings.speed, 0.1, 10, 2);
|
||||
const particleColor = sanitizeColor(settings.particleColor, '#FFFFFF');
|
||||
|
||||
const hexToRgb = useCallback((hex: string) => {
|
||||
hex = hex.replace('#', '');
|
||||
if (hex.length === 3)
|
||||
hex = hex
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('');
|
||||
return {
|
||||
r: parseInt(hex.substring(0, 2), 16),
|
||||
g: parseInt(hex.substring(2, 4), 16),
|
||||
b: parseInt(hex.substring(4, 6), 16),
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 resize = () => {
|
||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
||||
initParticles();
|
||||
const area = w * h;
|
||||
const count = Math.floor((area / 1000000) * particleDensity);
|
||||
const particles: Particle[] = Array.from({ length: count }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
size: minSize + Math.random() * (maxSize - minSize),
|
||||
speedX: (Math.random() - 0.5) * speed * 0.2,
|
||||
speedY: (Math.random() - 0.5) * speed * 0.2,
|
||||
opacity: Math.random(),
|
||||
opacityDirection: Math.random() > 0.5 ? 1 : -1,
|
||||
opacitySpeed: 0.005 + Math.random() * 0.01 * speed,
|
||||
}));
|
||||
|
||||
stateRef.current = { ctx, particles, rgb: hexToRgb(particleColor), 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;
|
||||
}
|
||||
};
|
||||
|
||||
const initParticles = () => {
|
||||
const area = canvas.width * canvas.height;
|
||||
const count = Math.floor((area / 1000000) * particleDensity);
|
||||
particlesRef.current = Array.from({ length: count }, () => ({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
size: minSize + Math.random() * (maxSize - minSize),
|
||||
speedX: (Math.random() - 0.5) * speed * 0.2,
|
||||
speedY: (Math.random() - 0.5) * speed * 0.2,
|
||||
opacity: Math.random(),
|
||||
opacityDirection: Math.random() > 0.5 ? 1 : -1,
|
||||
opacitySpeed: 0.005 + Math.random() * 0.01 * speed,
|
||||
}));
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [particleDensity, minSize, maxSize, speed, particleColor]);
|
||||
|
||||
const rgb = hexToRgb(particleColor);
|
||||
useAnimationLoop(() => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
const animate = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const { ctx, particles, rgb, w, h } = state;
|
||||
|
||||
for (const p of particlesRef.current) {
|
||||
p.x += p.speedX;
|
||||
p.y += p.speedY;
|
||||
p.opacity += p.opacityDirection * p.opacitySpeed;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (p.opacity <= 0) {
|
||||
p.opacity = 0;
|
||||
p.opacityDirection = 1;
|
||||
} else if (p.opacity >= 1) {
|
||||
p.opacity = 1;
|
||||
p.opacityDirection = -1;
|
||||
}
|
||||
for (const p of particles) {
|
||||
p.x += p.speedX;
|
||||
p.y += p.speedY;
|
||||
p.opacity += p.opacityDirection * p.opacitySpeed;
|
||||
|
||||
if (p.x < 0) p.x = canvas.width;
|
||||
if (p.x > canvas.width) p.x = 0;
|
||||
if (p.y < 0) p.y = canvas.height;
|
||||
if (p.y > canvas.height) p.y = 0;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${p.opacity})`;
|
||||
ctx.fill();
|
||||
if (p.opacity <= 0) {
|
||||
p.opacity = 0;
|
||||
p.opacityDirection = 1;
|
||||
} else if (p.opacity >= 1) {
|
||||
p.opacity = 1;
|
||||
p.opacityDirection = -1;
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
if (p.x < 0) p.x = w;
|
||||
if (p.x > w) p.x = 0;
|
||||
if (p.y < 0) p.y = h;
|
||||
if (p.y > h) p.y = 0;
|
||||
|
||||
resize();
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [particleDensity, minSize, maxSize, speed, particleColor, hexToRgb]);
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${p.opacity})`;
|
||||
ctx.fill();
|
||||
}
|
||||
}, [particleDensity, minSize, maxSize, speed, particleColor]);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
export default function SpotlightBg({ settings }: Props) {
|
||||
const spotlightColor = sanitizeColor(settings.spotlightColor, '#818cf8');
|
||||
const spotlightSize = clampNumber(settings.spotlightSize, 100, 1000, 400);
|
||||
const paused = useAnimationPause();
|
||||
|
||||
const blur1 = isMobile ? 0 : 60;
|
||||
const blur2 = isMobile ? 0 : 80;
|
||||
|
||||
if (paused) {
|
||||
return <div className="absolute inset-0 overflow-hidden" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
@@ -27,7 +38,7 @@ export default function SpotlightBg({ settings }: Props) {
|
||||
height: spotlightSize,
|
||||
background: `radial-gradient(circle, ${spotlightColor}40 0%, transparent 70%)`,
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(60px)',
|
||||
filter: `blur(${blur1}px)`,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
@@ -46,7 +57,7 @@ export default function SpotlightBg({ settings }: Props) {
|
||||
height: spotlightSize * 0.8,
|
||||
background: `radial-gradient(circle, ${spotlightColor}30 0%, transparent 70%)`,
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(80px)',
|
||||
filter: `blur(${blur2}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -42,17 +42,30 @@ export function sanitizeColor(value: unknown, fallback: string): string {
|
||||
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
|
||||
// Allow rgb/rgba/hsl/hsla with numbers, commas, spaces, dots, %
|
||||
if (/^(rgb|hsl)a?\([0-9,.\s/%]+\)$/.test(trimmed)) return trimmed;
|
||||
// Allow CSS named colors (alphanumeric only)
|
||||
if (/^[a-zA-Z]{3,30}$/.test(trimmed)) return trimmed;
|
||||
// Allow CSS named colors (alphanumeric only, exclude CSS-wide keywords)
|
||||
if (/^[a-zA-Z]{3,30}$/.test(trimmed) && !CSS_WIDE_KEYWORDS.has(trimmed.toLowerCase()))
|
||||
return trimmed;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Clamp a numeric value within bounds */
|
||||
export function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
|
||||
const n = typeof value === 'number' ? value : fallback;
|
||||
const n = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
/** Safely extract a boolean setting with fallback */
|
||||
export function safeBoolean(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
/** Safely extract a string setting from an allowed set of values */
|
||||
export function safeSelect(value: unknown, allowed: readonly string[], fallback: string): string {
|
||||
return typeof value === 'string' && allowed.includes(value) ? value : fallback;
|
||||
}
|
||||
|
||||
const CSS_WIDE_KEYWORDS = new Set(['inherit', 'initial', 'unset', 'revert', 'revert-layer']);
|
||||
|
||||
export interface SettingDefinition {
|
||||
key: string;
|
||||
label: string;
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createNoise3D } from 'simplex-noise';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
const TAU = 2 * Math.PI;
|
||||
const lerp = (n1: number, n2: number, speed: number) => (1 - speed) * n1 + speed * n2;
|
||||
const fadeInOut = (t: number, m: number) => {
|
||||
const hm = 0.5 * m;
|
||||
return Math.abs(((t + hm) % m) - hm) / hm;
|
||||
};
|
||||
const rand = (n: number) => n * Math.random();
|
||||
const randRange = (n: number) => n - rand(2 * n);
|
||||
|
||||
interface VortexState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
noise3D: ReturnType<typeof createNoise3D>;
|
||||
particleProps: Float32Array;
|
||||
tick: number;
|
||||
center: [number, number];
|
||||
w: number;
|
||||
h: number;
|
||||
dpr: number;
|
||||
}
|
||||
|
||||
export default function VortexBackground({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number>(0);
|
||||
const stateRef = useRef<VortexState | null>(null);
|
||||
|
||||
const particleCount = clampNumber(settings.particleCount, 50, 2000, 500);
|
||||
const rangeY = clampNumber(settings.rangeY, 10, 500, 100);
|
||||
@@ -16,128 +39,127 @@ export default function VortexBackground({ settings }: Props) {
|
||||
const rangeSpeed = clampNumber(settings.rangeSpeed, 0.1, 5, 1.5);
|
||||
const backgroundColor = sanitizeColor(settings.backgroundColor, '#000000');
|
||||
|
||||
const particlePropCount = 9;
|
||||
|
||||
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);
|
||||
ctx.lineCap = 'round';
|
||||
|
||||
const noise3D = createNoise3D();
|
||||
const particlePropCount = 9;
|
||||
const particlePropsLength = particleCount * particlePropCount;
|
||||
let particleProps = new Float32Array(particlePropsLength);
|
||||
let tick = 0;
|
||||
let center: [number, number] = [0, 0];
|
||||
const particleProps = new Float32Array(particlePropsLength);
|
||||
const center: [number, number] = [0.5 * w, 0.5 * h];
|
||||
|
||||
const rand = (n: number) => n * Math.random();
|
||||
const randRange = (n: number) => n - rand(2 * n);
|
||||
const TAU = 2 * Math.PI;
|
||||
const fadeInOut = (t: number, m: number) => {
|
||||
const hm = 0.5 * m;
|
||||
return Math.abs(((t + hm) % m) - hm) / hm;
|
||||
};
|
||||
const lerp = (n1: number, n2: number, speed: number) => (1 - speed) * n1 + speed * n2;
|
||||
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
|
||||
particleProps[i] = rand(w);
|
||||
particleProps[i + 1] = center[1] + randRange(rangeY);
|
||||
particleProps[i + 2] = 0;
|
||||
particleProps[i + 3] = 0;
|
||||
particleProps[i + 4] = 0;
|
||||
particleProps[i + 5] = 50 + rand(150);
|
||||
particleProps[i + 6] = rand(rangeSpeed);
|
||||
particleProps[i + 7] = 1 + rand(2);
|
||||
particleProps[i + 8] = baseHue + rand(100);
|
||||
}
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
||||
center = [0.5 * canvas.width, 0.5 * canvas.height];
|
||||
};
|
||||
stateRef.current = { ctx, noise3D, particleProps, tick: 0, center, w, h, dpr };
|
||||
|
||||
const initParticle = (i: number) => {
|
||||
const x = rand(canvas.width);
|
||||
const y = center[1] + randRange(rangeY);
|
||||
const life = 0;
|
||||
const ttl = 50 + rand(150);
|
||||
const speed = rand(rangeSpeed);
|
||||
const radius = 1 + rand(2);
|
||||
const hue = baseHue + rand(100);
|
||||
particleProps.set([x, y, 0, 0, life, ttl, speed, radius, hue], i);
|
||||
};
|
||||
|
||||
const initParticles = () => {
|
||||
tick = 0;
|
||||
particleProps = new Float32Array(particlePropsLength);
|
||||
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
|
||||
initParticle(i);
|
||||
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.ctx.lineCap = 'round';
|
||||
stateRef.current.center = [0.5 * nw, 0.5 * nh];
|
||||
stateRef.current.w = nw;
|
||||
stateRef.current.h = nh;
|
||||
}
|
||||
};
|
||||
|
||||
const drawParticle = (
|
||||
x: number,
|
||||
y: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
life: number,
|
||||
ttl: number,
|
||||
radius: number,
|
||||
hue: number,
|
||||
) => {
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round';
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [particleCount, rangeY, baseHue, rangeSpeed]);
|
||||
|
||||
useAnimationLoop(() => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
const { ctx, noise3D, particleProps, w, h } = state;
|
||||
const particlePropsLength = particleCount * particlePropCount;
|
||||
|
||||
state.tick++;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
|
||||
const x = particleProps[i];
|
||||
const y = particleProps[i + 1];
|
||||
const n = noise3D(x * 0.00125, y * 0.00125, state.tick * 0.0005) * 3 * TAU;
|
||||
const vx = lerp(particleProps[i + 2], Math.cos(n), 0.5);
|
||||
const vy = lerp(particleProps[i + 3], Math.sin(n), 0.5);
|
||||
const life = particleProps[i + 4];
|
||||
const ttl = particleProps[i + 5];
|
||||
const speed = particleProps[i + 6];
|
||||
const x2 = x + vx * speed;
|
||||
const y2 = y + vy * speed;
|
||||
const radius = particleProps[i + 7];
|
||||
const hue = particleProps[i + 8];
|
||||
|
||||
ctx.lineWidth = radius;
|
||||
ctx.strokeStyle = `hsla(${hue},100%,60%,${fadeInOut(life, ttl)})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
tick++;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
particleProps[i] = x2;
|
||||
particleProps[i + 1] = y2;
|
||||
particleProps[i + 2] = vx;
|
||||
particleProps[i + 3] = vy;
|
||||
particleProps[i + 4] = life + 1;
|
||||
|
||||
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
|
||||
const x = particleProps[i];
|
||||
const y = particleProps[i + 1];
|
||||
const n = noise3D(x * 0.00125, y * 0.00125, tick * 0.0005) * 3 * TAU;
|
||||
const vx = lerp(particleProps[i + 2], Math.cos(n), 0.5);
|
||||
const vy = lerp(particleProps[i + 3], Math.sin(n), 0.5);
|
||||
const life = particleProps[i + 4];
|
||||
const ttl = particleProps[i + 5];
|
||||
const speed = particleProps[i + 6];
|
||||
const x2 = x + vx * speed;
|
||||
const y2 = y + vy * speed;
|
||||
const radius = particleProps[i + 7];
|
||||
const hue = particleProps[i + 8];
|
||||
|
||||
drawParticle(x, y, x2, y2, life, ttl, radius, hue);
|
||||
|
||||
particleProps[i] = x2;
|
||||
particleProps[i + 1] = y2;
|
||||
particleProps[i + 2] = vx;
|
||||
particleProps[i + 3] = vy;
|
||||
particleProps[i + 4] = life + 1;
|
||||
|
||||
if (x2 > canvas.width || x2 < 0 || y2 > canvas.height || y2 < 0 || life + 1 > ttl) {
|
||||
initParticle(i);
|
||||
}
|
||||
if (x2 > w || x2 < 0 || y2 > h || y2 < 0 || life + 1 > ttl) {
|
||||
particleProps[i] = rand(w);
|
||||
particleProps[i + 1] = state.center[1] + randRange(rangeY);
|
||||
particleProps[i + 2] = 0;
|
||||
particleProps[i + 3] = 0;
|
||||
particleProps[i + 4] = 0;
|
||||
particleProps[i + 5] = 50 + rand(150);
|
||||
particleProps[i + 6] = rand(rangeSpeed);
|
||||
particleProps[i + 7] = 1 + rand(2);
|
||||
particleProps[i + 8] = baseHue + rand(100);
|
||||
}
|
||||
}
|
||||
|
||||
// Glow effect
|
||||
// Glow effect — DESKTOP ONLY
|
||||
if (!isMobile) {
|
||||
ctx.save();
|
||||
ctx.filter = 'blur(8px) brightness(200%)';
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.drawImage(canvas, 0, 0);
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) ctx.drawImage(canvas, 0, 0, w, h);
|
||||
ctx.restore();
|
||||
|
||||
animationRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
resize();
|
||||
initParticles();
|
||||
animationRef.current = requestAnimationFrame(draw);
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}
|
||||
}, [particleCount, rangeY, baseHue, rangeSpeed, backgroundColor]);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createNoise3D } from 'simplex-noise';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { sanitizeColor, clampNumber, safeSelect } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
interface WavyState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
noise3D: ReturnType<typeof createNoise3D>;
|
||||
nt: number;
|
||||
w: number;
|
||||
h: number;
|
||||
dpr: number;
|
||||
}
|
||||
|
||||
export default function WavyBackground({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number>(0);
|
||||
const stateRef = useRef<WavyState | null>(null);
|
||||
|
||||
const speed = (settings.speed as string) ?? 'fast';
|
||||
const speed = safeSelect(settings.speed, ['slow', 'fast'] as const, 'fast');
|
||||
const waveWidth = clampNumber(settings.waveWidth, 5, 200, 50);
|
||||
const blur = clampNumber(settings.blur, 0, 50, 10);
|
||||
const waveOpacity = clampNumber(settings.waveOpacity, 0.05, 1, 0.5);
|
||||
@@ -21,55 +33,73 @@ export default function WavyBackground({ settings }: Props) {
|
||||
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;
|
||||
|
||||
const noise3D = createNoise3D();
|
||||
let nt = 0;
|
||||
const speedVal = speed === 'slow' ? 0.001 : 0.002;
|
||||
const waveCount = 5;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const effectiveBlur = isMobile ? Math.min(blur, 4) : blur;
|
||||
ctx.filter = effectiveBlur > 0 ? `blur(${effectiveBlur}px)` : 'none';
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
||||
ctx.filter = `blur(${blur}px)`;
|
||||
};
|
||||
stateRef.current = { ctx, noise3D: createNoise3D(), nt: 0, w, h, dpr };
|
||||
|
||||
const drawWave = (_n: number) => {
|
||||
nt += speedVal;
|
||||
for (let i = 0; i < waveCount; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = waveWidth;
|
||||
ctx.strokeStyle = colors[i % colors.length];
|
||||
ctx.globalAlpha = waveOpacity;
|
||||
|
||||
for (let x = 0; x < canvas.width; x += 5) {
|
||||
const y = noise3D(x / 800, 0.3 * i, nt) * 100;
|
||||
ctx.lineTo(x, y + canvas.height * 0.5);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
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);
|
||||
const eb = isMobile ? Math.min(blur, 4) : blur;
|
||||
stateRef.current.ctx.filter = eb > 0 ? `blur(${eb}px)` : 'none';
|
||||
stateRef.current.w = nw;
|
||||
stateRef.current.h = nh;
|
||||
}
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = backgroundFill;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [blur]);
|
||||
|
||||
drawWave(5);
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
const speedVal = speed === 'slow' ? 0.001 : 0.002;
|
||||
const waveCount = isMobile ? 3 : 5;
|
||||
const step = isMobile ? 8 : 5;
|
||||
|
||||
resize();
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
window.addEventListener('resize', resize);
|
||||
useAnimationLoop(() => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
const { ctx, noise3D, w, h } = state;
|
||||
state.nt += speedVal;
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = backgroundFill;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
for (let i = 0; i < waveCount; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = waveWidth;
|
||||
ctx.strokeStyle = colors[i % colors.length];
|
||||
ctx.globalAlpha = waveOpacity;
|
||||
|
||||
for (let x = 0; x < w; x += step) {
|
||||
const y = noise3D(x / 800, 0.3 * i, state.nt) * 100;
|
||||
ctx.lineTo(x, y + h * 0.5);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
}
|
||||
}, [speed, waveWidth, blur, waveOpacity, backgroundFill]);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
|
||||
140
src/hooks/useAnimationLoop.ts
Normal file
140
src/hooks/useAnimationLoop.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
const TARGET_FPS = isMobile ? 30 : 60;
|
||||
const FRAME_INTERVAL = 1000 / TARGET_FPS;
|
||||
|
||||
/**
|
||||
* Shared animation loop hook with built-in performance safeguards:
|
||||
* - FPS throttling: 30 FPS on mobile, 60 FPS on desktop
|
||||
* - Page Visibility API: fully stops RAF when tab/app is hidden
|
||||
* - Telegram Mini App: fully stops RAF when isActive = false
|
||||
* - Auto-cleanup on unmount
|
||||
*/
|
||||
export function useAnimationLoop(
|
||||
callback: (time: number, delta: number) => void,
|
||||
deps: React.DependencyList,
|
||||
) {
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
|
||||
useEffect(() => {
|
||||
let animId = 0;
|
||||
let lastTime = 0;
|
||||
let docHidden = document.hidden;
|
||||
let tgInactive = false;
|
||||
|
||||
const isPaused = () => docHidden || tgInactive;
|
||||
|
||||
const loop = (time: number) => {
|
||||
const delta = time - lastTime;
|
||||
if (delta >= FRAME_INTERVAL) {
|
||||
lastTime = time - (delta % FRAME_INTERVAL);
|
||||
callbackRef.current(time, delta);
|
||||
}
|
||||
animId = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (animId) return; // already running
|
||||
lastTime = performance.now();
|
||||
animId = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (animId) {
|
||||
cancelAnimationFrame(animId);
|
||||
animId = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
docHidden = document.hidden;
|
||||
if (isPaused()) stop();
|
||||
else start();
|
||||
};
|
||||
|
||||
const tg = window.Telegram?.WebApp;
|
||||
const onTgActivated = () => {
|
||||
tgInactive = false;
|
||||
if (!isPaused()) start();
|
||||
};
|
||||
const onTgDeactivated = () => {
|
||||
tgInactive = true;
|
||||
stop();
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
if (tg) {
|
||||
tg.onEvent?.('activated', onTgActivated);
|
||||
tg.onEvent?.('deactivated', onTgDeactivated);
|
||||
}
|
||||
|
||||
// Start the loop (only if page is currently visible)
|
||||
if (!isPaused()) start();
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
if (tg) {
|
||||
tg.offEvent?.('activated', onTgActivated);
|
||||
tg.offEvent?.('deactivated', onTgDeactivated);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether animations should be paused (tab hidden or Telegram deactivated).
|
||||
* Use for CSS/Framer Motion components that can't use useAnimationLoop.
|
||||
*/
|
||||
export function useAnimationPause(): boolean {
|
||||
const [paused, setPaused] = useState(() =>
|
||||
typeof document !== 'undefined' ? document.hidden : false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let docHidden = document.hidden;
|
||||
let tgInactive = false;
|
||||
|
||||
const update = () => setPaused(docHidden || tgInactive);
|
||||
|
||||
const onVisibility = () => {
|
||||
docHidden = document.hidden;
|
||||
update();
|
||||
};
|
||||
|
||||
const tg = window.Telegram?.WebApp;
|
||||
const onActivated = () => {
|
||||
tgInactive = false;
|
||||
update();
|
||||
};
|
||||
const onDeactivated = () => {
|
||||
tgInactive = true;
|
||||
update();
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
if (tg) {
|
||||
tg.onEvent?.('activated', onActivated);
|
||||
tg.onEvent?.('deactivated', onDeactivated);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
if (tg) {
|
||||
tg.offEvent?.('activated', onActivated);
|
||||
tg.offEvent?.('deactivated', onDeactivated);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return paused;
|
||||
}
|
||||
|
||||
/** Returns a reduced DPR for mobile canvas rendering (saves 4x GPU work on retina) */
|
||||
export function getMobileDpr(): number {
|
||||
if (isMobile) return Math.min(devicePixelRatio, 1.5);
|
||||
return devicePixelRatio || 1;
|
||||
}
|
||||
12
src/vite-env.d.ts
vendored
12
src/vite-env.d.ts
vendored
@@ -12,3 +12,15 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
/** Telegram WebApp global — available when running inside Telegram Mini App */
|
||||
interface TelegramWebAppGlobal {
|
||||
onEvent?: (event: string, callback: () => void) => void;
|
||||
offEvent?: (event: string, callback: () => void) => void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
Telegram?: {
|
||||
WebApp?: TelegramWebAppGlobal;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user