mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 10:03: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';
|
import { backgroundComponents, prefetchBackground } from '@/components/ui/backgrounds/registry';
|
||||||
|
|
||||||
const ANIMATION_CACHE_KEY = 'cabinet_animation_config';
|
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 {
|
function getCachedConfig(): AnimationConfig | null {
|
||||||
try {
|
try {
|
||||||
const cached = localStorage.getItem(ANIMATION_CACHE_KEY);
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefetch the background JS chunk immediately based on localStorage cache.
|
// 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();
|
const cachedConfig = getCachedConfig();
|
||||||
if (cachedConfig?.enabled && cachedConfig.type && cachedConfig.type !== 'none') {
|
if (cachedConfig?.enabled && cachedConfig.type && cachedConfig.type !== 'none') {
|
||||||
prefetchBackground(cachedConfig.type);
|
prefetchBackground(cachedConfig.type);
|
||||||
@@ -34,18 +72,27 @@ function setCachedConfig(config: AnimationConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setCachedAnimationConfig(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> {
|
function reduceMobileSettings(settings: Record<string, unknown>): Record<string, unknown> {
|
||||||
const reduced = { ...settings };
|
const reduced = { ...settings };
|
||||||
|
// 75% reduction (divide by 4) instead of 50% — much less GPU work
|
||||||
if (typeof reduced.particleCount === 'number')
|
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')
|
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')
|
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 ('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;
|
return reduced;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +105,9 @@ export function BackgroundRenderer() {
|
|||||||
const { data: config } = useQuery({
|
const { data: config } = useQuery({
|
||||||
queryKey: ['animation-config'],
|
queryKey: ['animation-config'],
|
||||||
queryFn: async () => {
|
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);
|
setCachedConfig(result);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
@@ -84,17 +133,16 @@ export function BackgroundRenderer() {
|
|||||||
? reduceMobileSettings(effectiveConfig.settings)
|
? reduceMobileSettings(effectiveConfig.settings)
|
||||||
: effectiveConfig.settings;
|
: effectiveConfig.settings;
|
||||||
|
|
||||||
// Render via portal on document.body with z-index: -1.
|
// On mobile, cap blur to 4px max — full blur is extremely GPU-heavy
|
||||||
// This places the animated background BELOW the root stacking context,
|
const effectiveBlur = isMobile ? Math.min(effectiveConfig.blur, 4) : effectiveConfig.blur;
|
||||||
// preventing Chrome's implicit compositing from promoting every
|
|
||||||
// overlapping element to its own GPU layer (the root cause of flickering).
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none fixed inset-0"
|
className="pointer-events-none fixed inset-0"
|
||||||
style={{
|
style={{
|
||||||
zIndex: -2,
|
zIndex: -2,
|
||||||
opacity: effectiveConfig.opacity,
|
opacity: effectiveConfig.opacity,
|
||||||
filter: effectiveConfig.blur > 0 ? `blur(${effectiveConfig.blur}px)` : undefined,
|
filter: effectiveBlur > 0 ? `blur(${effectiveBlur}px)` : undefined,
|
||||||
contain: 'strict',
|
contain: 'strict',
|
||||||
backfaceVisibility: 'hidden',
|
backfaceVisibility: 'hidden',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { safeBoolean } from './types';
|
||||||
|
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||||
|
|
||||||
export default function AuroraBackground({ settings }: Props) {
|
export default function AuroraBackground({ settings }: Props) {
|
||||||
const showRadialGradient = (settings.showRadialGradient as boolean) ?? true;
|
const showRadialGradient = safeBoolean(settings.showRadialGradient, true);
|
||||||
|
const paused = useAnimationPause();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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 &&
|
showRadialGradient &&
|
||||||
'[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,transparent_70%)]',
|
'[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,transparent_70%)]',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage:
|
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%)',
|
'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>
|
</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 { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
@@ -71,9 +72,13 @@ function CollisionMechanism({
|
|||||||
const [beamKey, setBeamKey] = useState(0);
|
const [beamKey, setBeamKey] = useState(0);
|
||||||
const [cycleCollisionDetected, setCycleCollisionDetected] = useState(false);
|
const [cycleCollisionDetected, setCycleCollisionDetected] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const checkRef = useRef({ cycleDetected: false });
|
||||||
const checkCollision = () => {
|
checkRef.current.cycleDetected = cycleCollisionDetected;
|
||||||
if (beamRef.current && containerRef.current && parentRef.current && !cycleCollisionDetected) {
|
|
||||||
|
const checkCollision = useCallback(() => {
|
||||||
|
if (checkRef.current.cycleDetected) return;
|
||||||
|
if (!beamRef.current || !containerRef.current || !parentRef.current) return;
|
||||||
|
|
||||||
const beamRect = beamRef.current.getBoundingClientRect();
|
const beamRect = beamRef.current.getBoundingClientRect();
|
||||||
const containerRect = containerRef.current.getBoundingClientRect();
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
const parentRect = parentRef.current.getBoundingClientRect();
|
const parentRect = parentRef.current.getBoundingClientRect();
|
||||||
@@ -85,24 +90,42 @@ function CollisionMechanism({
|
|||||||
setCollision({ detected: true, coordinates: { x: relativeX, y: relativeY } });
|
setCollision({ detected: true, coordinates: { x: relativeX, y: relativeY } });
|
||||||
setCycleCollisionDetected(true);
|
setCycleCollisionDetected(true);
|
||||||
}
|
}
|
||||||
|
}, [containerRef, parentRef]);
|
||||||
|
|
||||||
|
// Throttled collision detection loop.
|
||||||
|
// Parent unmounts this component when paused, so no visibility handling needed here.
|
||||||
|
useEffect(() => {
|
||||||
|
let animId = 0;
|
||||||
|
let lastCheck = 0;
|
||||||
|
const CHECK_INTERVAL = 100;
|
||||||
|
|
||||||
|
const loop = (time: number) => {
|
||||||
|
if (time - lastCheck >= CHECK_INTERVAL) {
|
||||||
|
lastCheck = time;
|
||||||
|
checkCollision();
|
||||||
}
|
}
|
||||||
|
animId = requestAnimationFrame(loop);
|
||||||
};
|
};
|
||||||
|
|
||||||
const interval = setInterval(checkCollision, 50);
|
animId = requestAnimationFrame(loop);
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [cycleCollisionDetected, containerRef, parentRef]);
|
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animId) cancelAnimationFrame(animId);
|
||||||
|
};
|
||||||
|
}, [checkCollision]);
|
||||||
|
|
||||||
|
// Collision reset with proper timeout cleanup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (collision.detected && collision.coordinates) {
|
if (!collision.detected || !collision.coordinates) return;
|
||||||
setTimeout(() => {
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
setCollision({ detected: false, coordinates: null });
|
setCollision({ detected: false, coordinates: null });
|
||||||
setCycleCollisionDetected(false);
|
setCycleCollisionDetected(false);
|
||||||
}, 2000);
|
|
||||||
setTimeout(() => {
|
|
||||||
setBeamKey((prev) => prev + 1);
|
setBeamKey((prev) => prev + 1);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
|
||||||
}, [collision]);
|
return () => clearTimeout(timer);
|
||||||
|
}, [collision.detected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -154,10 +177,12 @@ function CollisionMechanism({
|
|||||||
export default function BackgroundBeamsCollision({ settings: _settings }: Props) {
|
export default function BackgroundBeamsCollision({ settings: _settings }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const paused = useAnimationPause();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={parentRef} className="absolute inset-0 overflow-hidden">
|
<div ref={parentRef} className="absolute inset-0 overflow-hidden">
|
||||||
{BEAMS.map((beam) => (
|
{!paused &&
|
||||||
|
BEAMS.map((beam) => (
|
||||||
<CollisionMechanism
|
<CollisionMechanism
|
||||||
key={`${beam.initialX}-beam`}
|
key={`${beam.initialX}-beam`}
|
||||||
beamOptions={beam}
|
beamOptions={beam}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
@@ -99,16 +100,15 @@ function ensureStyles() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default React.memo(function BackgroundBeams({ settings: _settings }: Props) {
|
export default React.memo(function BackgroundBeams({ settings: _settings }: Props) {
|
||||||
|
const paused = useAnimationPause();
|
||||||
|
|
||||||
// Inject CSS once on first render
|
// Inject CSS once on first render
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
ensureStyles();
|
ensureStyles();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="absolute inset-0 overflow-hidden" style={{ contain: 'strict' }}>
|
||||||
className="absolute inset-0 overflow-hidden"
|
|
||||||
style={{ contain: 'strict', willChange: 'transform' }}
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -138,6 +138,7 @@ export default React.memo(function BackgroundBeams({ settings: _settings }: Prop
|
|||||||
strokeDashoffset="1"
|
strokeDashoffset="1"
|
||||||
style={{
|
style={{
|
||||||
animation: `beamDash ${beamParams[paramIndex].duration}s ease-in-out ${beamParams[paramIndex].delay}s infinite`,
|
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 React, { useEffect, useRef, useMemo } from 'react';
|
||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
@@ -27,23 +28,19 @@ interface CellData {
|
|||||||
period: number;
|
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_X_TAN = Math.tan((-48 * Math.PI) / 180);
|
||||||
const SKEW_Y_TAN = Math.tan((14 * Math.PI) / 180);
|
const SKEW_Y_TAN = Math.tan((14 * Math.PI) / 180);
|
||||||
const GRID_SCALE = 0.675;
|
const GRID_SCALE = 0.675;
|
||||||
|
|
||||||
/**
|
interface BoxesState {
|
||||||
* Animated boxes background rendered on a single <canvas> element.
|
ctx: CanvasRenderingContext2D;
|
||||||
*
|
w: number;
|
||||||
* Previous implementation used 225 DOM <div> elements with CSS @keyframes.
|
h: number;
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export default React.memo(function BackgroundBoxes({ settings }: Props) {
|
export default React.memo(function BackgroundBoxes({ settings }: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const stateRef = useRef<BoxesState | null>(null);
|
||||||
const rows = clampNumber(settings.rows, 4, 30, 15);
|
const rows = clampNumber(settings.rows, 4, 30, 15);
|
||||||
const cols = clampNumber(settings.cols, 4, 30, 15);
|
const cols = clampNumber(settings.cols, 4, 30, 15);
|
||||||
const boxColor = sanitizeColor(settings.boxColor, '#818cf8');
|
const boxColor = sanitizeColor(settings.boxColor, '#818cf8');
|
||||||
@@ -61,44 +58,51 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const dpr = getMobileDpr();
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
let animId = 0;
|
|
||||||
|
|
||||||
const resize = () => {
|
|
||||||
const dpr = devicePixelRatio || 1;
|
|
||||||
const parent = canvas.parentElement;
|
const parent = canvas.parentElement;
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
const rect = parent.getBoundingClientRect();
|
const rect = parent.getBoundingClientRect();
|
||||||
canvas.width = rect.width * dpr;
|
canvas.width = rect.width * dpr;
|
||||||
canvas.height = rect.height * dpr;
|
canvas.height = rect.height * dpr;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
stateRef.current = { ctx, w: canvas.width, h: canvas.height };
|
||||||
|
|
||||||
|
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', onResize);
|
||||||
window.addEventListener('resize', resize);
|
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 t = now * 0.001;
|
||||||
const w = canvas.width;
|
|
||||||
const h = canvas.height;
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
ctx.save();
|
ctx.save();
|
||||||
|
|
||||||
// Transform origin: center of viewport
|
|
||||||
const cx = w / 2;
|
const cx = w / 2;
|
||||||
const cy = h / 2;
|
const cy = h / 2;
|
||||||
ctx.translate(cx, cy);
|
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, 0, SKEW_X_TAN, 1, 0, 0);
|
||||||
ctx.transform(1, SKEW_Y_TAN, 0, 1, 0, 0);
|
ctx.transform(1, SKEW_Y_TAN, 0, 1, 0, 0);
|
||||||
ctx.scale(GRID_SCALE, GRID_SCALE);
|
ctx.scale(GRID_SCALE, GRID_SCALE);
|
||||||
|
|
||||||
ctx.translate(-cx, -cy);
|
ctx.translate(-cx, -cy);
|
||||||
|
|
||||||
// Grid: 300% of viewport, offset by -100% (matches the original CSS layout)
|
|
||||||
const gw = w * 3;
|
const gw = w * 3;
|
||||||
const gh = h * 3;
|
const gh = h * 3;
|
||||||
const ox = -w;
|
const ox = -w;
|
||||||
@@ -106,7 +110,6 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) {
|
|||||||
const cellW = gw / cols;
|
const cellW = gw / cols;
|
||||||
const cellH = gh / rows;
|
const cellH = gh / rows;
|
||||||
|
|
||||||
// Draw colored cells
|
|
||||||
for (let i = 0; i < cells.length; i++) {
|
for (let i = 0; i < cells.length; i++) {
|
||||||
const cell = cells[i];
|
const cell = cells[i];
|
||||||
const cycleT = ((t + cell.phase) % cell.period) / cell.period;
|
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);
|
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.strokeStyle = 'rgba(51,65,85,0.5)';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -139,17 +141,9 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) {
|
|||||||
|
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
},
|
||||||
animId = requestAnimationFrame(draw);
|
[cells, rows, cols],
|
||||||
};
|
);
|
||||||
|
|
||||||
animId = requestAnimationFrame(draw);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(animId);
|
|
||||||
window.removeEventListener('resize', resize);
|
|
||||||
};
|
|
||||||
}, [cells, rows, cols]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { sanitizeColor } from './types';
|
import { sanitizeColor, safeBoolean, safeSelect } from './types';
|
||||||
|
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||||
|
|
||||||
function hexToRgbString(hex: string): string {
|
function hexToRgbString(hex: string): string {
|
||||||
hex = hex.replace('#', '');
|
hex = hex.replace('#', '');
|
||||||
if (hex.length === 3)
|
if (hex.length === 3)
|
||||||
@@ -22,23 +25,29 @@ function hexToRgbString(hex: string): string {
|
|||||||
|
|
||||||
export default function BackgroundGradientAnimation({ settings }: Props) {
|
export default function BackgroundGradientAnimation({ settings }: Props) {
|
||||||
const interactiveRef = useRef<HTMLDivElement>(null);
|
const interactiveRef = useRef<HTMLDivElement>(null);
|
||||||
|
const paused = useAnimationPause();
|
||||||
|
|
||||||
const firstColor = hexToRgbString(sanitizeColor(settings.firstColor, '#1271FF'));
|
const firstColor = hexToRgbString(sanitizeColor(settings.firstColor, '#1271FF'));
|
||||||
const secondColor = hexToRgbString(sanitizeColor(settings.secondColor, '#DD4AFF'));
|
const secondColor = hexToRgbString(sanitizeColor(settings.secondColor, '#DD4AFF'));
|
||||||
const thirdColor = hexToRgbString(sanitizeColor(settings.thirdColor, '#64DCFF'));
|
const thirdColor = hexToRgbString(sanitizeColor(settings.thirdColor, '#64DCFF'));
|
||||||
const fourthColor = hexToRgbString(sanitizeColor(settings.fourthColor, '#C83232'));
|
const fourthColor = hexToRgbString(sanitizeColor(settings.fourthColor, '#C83232'));
|
||||||
const fifthColor = hexToRgbString(sanitizeColor(settings.fifthColor, '#B4B432'));
|
const fifthColor = hexToRgbString(sanitizeColor(settings.fifthColor, '#B4B432'));
|
||||||
const interactive = (settings.interactive as boolean) ?? true;
|
const interactive = safeBoolean(settings.interactive, true);
|
||||||
const size = (settings.size as string) ?? '80%';
|
const size = safeSelect(settings.size, ['60%', '80%', '100%'] as const, '80%');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!interactive || !interactiveRef.current) return;
|
// Interactive mouse tracking — DESKTOP ONLY
|
||||||
|
if (!interactive || !interactiveRef.current || isMobile) return;
|
||||||
|
|
||||||
let curX = 0;
|
let curX = 0;
|
||||||
let curY = 0;
|
let curY = 0;
|
||||||
let tgX = 0;
|
let tgX = 0;
|
||||||
let tgY = 0;
|
let tgY = 0;
|
||||||
let animId = 0;
|
let animId = 0;
|
||||||
|
let docHidden = document.hidden;
|
||||||
|
let tgInactive = false;
|
||||||
|
|
||||||
|
const isHidden = () => docHidden || tgInactive;
|
||||||
|
|
||||||
const move = () => {
|
const move = () => {
|
||||||
curX += (tgX - curX) / 20;
|
curX += (tgX - curX) / 20;
|
||||||
@@ -49,20 +58,62 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
|
|||||||
animId = requestAnimationFrame(move);
|
animId = requestAnimationFrame(move);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (animId) return;
|
||||||
|
animId = requestAnimationFrame(move);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (animId) {
|
||||||
|
cancelAnimationFrame(animId);
|
||||||
|
animId = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleMouse = (e: MouseEvent) => {
|
const handleMouse = (e: MouseEvent) => {
|
||||||
tgX = e.clientX;
|
tgX = e.clientX;
|
||||||
tgY = e.clientY;
|
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);
|
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 () => {
|
return () => {
|
||||||
|
stop();
|
||||||
window.removeEventListener('mousemove', handleMouse);
|
window.removeEventListener('mousemove', handleMouse);
|
||||||
cancelAnimationFrame(animId);
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
if (tg) {
|
||||||
|
tg.offEvent?.('activated', onActivated);
|
||||||
|
tg.offEvent?.('deactivated', onDeactivated);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [interactive]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 overflow-hidden"
|
className="absolute inset-0 overflow-hidden"
|
||||||
@@ -74,11 +125,12 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
|
|||||||
'--fourth-color': fourthColor,
|
'--fourth-color': fourthColor,
|
||||||
'--fifth-color': fifthColor,
|
'--fifth-color': fifthColor,
|
||||||
'--size': size,
|
'--size': size,
|
||||||
'--blending': 'hard-light',
|
|
||||||
background: `linear-gradient(40deg, rgb(${firstColor}), rgb(${fifthColor}))`,
|
background: `linear-gradient(40deg, rgb(${firstColor}), rgb(${fifthColor}))`,
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{/* SVG filter only rendered on desktop */}
|
||||||
|
{!isMobile && (
|
||||||
<svg className="hidden">
|
<svg className="hidden">
|
||||||
<defs>
|
<defs>
|
||||||
<filter id="blurMe">
|
<filter id="blurMe">
|
||||||
@@ -93,7 +145,8 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
|
|||||||
</filter>
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
<div className="absolute inset-0" style={{ filter: 'url(#blurMe) blur(40px)' }}>
|
)}
|
||||||
|
<div className="absolute inset-0" style={{ filter: filterStyle }}>
|
||||||
{[
|
{[
|
||||||
{ color: firstColor, anim: 'animate-move-vertical' },
|
{ color: firstColor, anim: 'animate-move-vertical' },
|
||||||
{ color: secondColor, anim: 'animate-move-in-circle' },
|
{ color: secondColor, anim: 'animate-move-in-circle' },
|
||||||
@@ -104,15 +157,17 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
|
|||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={cn(
|
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,
|
blob.anim,
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
background: `radial-gradient(circle at center, rgba(${blob.color}, 0.8) 0, rgba(${blob.color}, 0) 50%) no-repeat`,
|
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
|
<div
|
||||||
ref={interactiveRef}
|
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"
|
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,50 +1,93 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { createNoise3D } from 'simplex-noise';
|
import { createNoise3D } from 'simplex-noise';
|
||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
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) {
|
export default function BackgroundLines({ settings }: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
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 lineCount = clampNumber(settings.lineCount, 5, 100, 40);
|
||||||
const lineColor = sanitizeColor(settings.lineColor, '#818cf8');
|
const lineColor = sanitizeColor(settings.lineColor, '#818cf8');
|
||||||
const speed = clampNumber(settings.speed, 0.0005, 0.01, 0.002);
|
const speed = clampNumber(settings.speed, 0.0005, 0.01, 0.002);
|
||||||
const strokeWidth = clampNumber(settings.strokeWidth, 0.5, 5, 1);
|
const strokeWidth = clampNumber(settings.strokeWidth, 0.5, 5, 1);
|
||||||
|
|
||||||
|
const effectiveLineCount = isMobile ? Math.min(lineCount, 20) : lineCount;
|
||||||
|
const step = isMobile ? 8 : 4;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
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');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
const noise3D = createNoise3D();
|
stateRef.current = { ctx, noise3D: createNoise3D(), nt: 0, w, h, dpr };
|
||||||
let nt = 0;
|
|
||||||
|
|
||||||
const resize = () => {
|
const onResize = () => {
|
||||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
const nw = parent?.offsetWidth ?? window.innerWidth;
|
||||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
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 = () => {
|
window.addEventListener('resize', onResize);
|
||||||
nt += speed;
|
return () => window.removeEventListener('resize', onResize);
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
}, []);
|
||||||
|
|
||||||
for (let i = 0; i < lineCount; i++) {
|
useAnimationLoop(() => {
|
||||||
const yBase = (i / lineCount) * canvas.height;
|
const state = stateRef.current;
|
||||||
const opacity = 0.1 + 0.15 * Math.sin((i / lineCount) * Math.PI);
|
if (!state) return;
|
||||||
|
|
||||||
|
const { ctx, noise3D, w, h } = state;
|
||||||
|
state.nt += speed;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.strokeStyle = lineColor;
|
ctx.strokeStyle = lineColor;
|
||||||
ctx.globalAlpha = opacity;
|
|
||||||
ctx.lineWidth = strokeWidth;
|
ctx.lineWidth = strokeWidth;
|
||||||
|
|
||||||
for (let x = 0; x < canvas.width; x += 4) {
|
for (let i = 0; i < effectiveLineCount; i++) {
|
||||||
const y = yBase + noise3D(x / 600, i * 0.3, nt) * 40;
|
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) {
|
if (x === 0) {
|
||||||
ctx.moveTo(x, y);
|
ctx.moveTo(x, y);
|
||||||
} else {
|
} else {
|
||||||
@@ -56,18 +99,7 @@ export default function BackgroundLines({ settings }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
}, [effectiveLineCount, lineColor, speed, strokeWidth]);
|
||||||
};
|
|
||||||
|
|
||||||
resize();
|
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(animationRef.current);
|
|
||||||
window.removeEventListener('resize', resize);
|
|
||||||
};
|
|
||||||
}, [lineCount, lineColor, speed, strokeWidth]);
|
|
||||||
|
|
||||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RippleState {
|
||||||
|
ctx: CanvasRenderingContext2D;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
dpr: number;
|
||||||
|
ripples: { phase: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
export default function BackgroundRipple({ settings }: Props) {
|
export default function BackgroundRipple({ settings }: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const animationRef = useRef<number>(0);
|
const stateRef = useRef<RippleState | null>(null);
|
||||||
|
|
||||||
const rippleColor = sanitizeColor(settings.rippleColor, '#818cf8');
|
const rippleColor = sanitizeColor(settings.rippleColor, '#818cf8');
|
||||||
const rippleCount = clampNumber(settings.rippleCount, 1, 20, 5);
|
const rippleCount = clampNumber(settings.rippleCount, 1, 20, 5);
|
||||||
@@ -17,24 +26,53 @@ export default function BackgroundRipple({ settings }: Props) {
|
|||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
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');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
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) => ({
|
const ripples = Array.from({ length: rippleCount }, (_, i) => ({
|
||||||
phase: (i / rippleCount) * Math.PI * 2,
|
phase: (i / rippleCount) * Math.PI * 2,
|
||||||
maxRadius: 0,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const animate = (time: number) => {
|
stateRef.current = { ctx, w, h, dpr, ripples };
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
const cx = canvas.width / 2;
|
const onResize = () => {
|
||||||
const cy = canvas.height / 2;
|
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);
|
const maxR = Math.sqrt(cx * cx + cy * cy);
|
||||||
|
|
||||||
for (let i = 0; i < ripples.length; i++) {
|
for (let i = 0; i < ripples.length; i++) {
|
||||||
@@ -52,18 +90,9 @@ export default function BackgroundRipple({ settings }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
},
|
||||||
};
|
[rippleColor, rippleCount, speed],
|
||||||
|
);
|
||||||
resize();
|
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(animationRef.current);
|
|
||||||
window.removeEventListener('resize', resize);
|
|
||||||
};
|
|
||||||
}, [rippleColor, rippleCount, speed]);
|
|
||||||
|
|
||||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
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 {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GridBackground({ settings }: Props) {
|
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 gridColor = sanitizeColor(settings.gridColor, 'rgba(255,255,255,0.05)');
|
||||||
const gridSize = clampNumber(settings.gridSize, 10, 200, 40);
|
const gridSize = clampNumber(settings.gridSize, 10, 200, 40);
|
||||||
const dotSize = clampNumber(settings.dotSize, 0.5, 10, 1.5);
|
const dotSize = clampNumber(settings.dotSize, 0.5, 10, 1.5);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
@@ -8,6 +9,7 @@ interface Props {
|
|||||||
export default function Meteors({ settings }: Props) {
|
export default function Meteors({ settings }: Props) {
|
||||||
const count = clampNumber(settings.count, 1, 50, 20);
|
const count = clampNumber(settings.count, 1, 50, 20);
|
||||||
const meteorColor = sanitizeColor(settings.meteorColor, '#ffffff');
|
const meteorColor = sanitizeColor(settings.meteorColor, '#ffffff');
|
||||||
|
const paused = useAnimationPause();
|
||||||
|
|
||||||
const meteors = useMemo(
|
const meteors = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -31,6 +33,7 @@ export default function Meteors({ settings }: Props) {
|
|||||||
left: meteor.left,
|
left: meteor.left,
|
||||||
animationDelay: meteor.delay,
|
animationDelay: meteor.delay,
|
||||||
animationDuration: meteor.duration,
|
animationDuration: meteor.duration,
|
||||||
|
animationPlayState: paused ? 'paused' : 'running',
|
||||||
width: meteor.size,
|
width: meteor.size,
|
||||||
height: 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`,
|
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 { useEffect, useRef } from 'react';
|
||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
@@ -23,9 +24,20 @@ interface BgStar {
|
|||||||
twinkleSpeed: number | null;
|
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) {
|
export default function ShootingStarsBackground({ settings }: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const animationRef = useRef<number>(0);
|
const stateRef = useRef<ShootingState | null>(null);
|
||||||
|
|
||||||
const starColor = sanitizeColor(settings.starColor, '#9E00FF');
|
const starColor = sanitizeColor(settings.starColor, '#9E00FF');
|
||||||
const trailColor = sanitizeColor(settings.trailColor, '#2EB9DF');
|
const trailColor = sanitizeColor(settings.trailColor, '#2EB9DF');
|
||||||
@@ -37,46 +49,67 @@ export default function ShootingStarsBackground({ settings }: Props) {
|
|||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
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');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
let shootingStars: Star[] = [];
|
const count = Math.floor(w * h * starDensity);
|
||||||
let bgStars: BgStar[] = [];
|
const bgStars: BgStar[] = Array.from({ length: count }, () => ({
|
||||||
let lastShootingTime = 0;
|
x: Math.random() * w,
|
||||||
|
y: Math.random() * h,
|
||||||
const resize = () => {
|
|
||||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
|
||||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
|
||||||
initBgStars();
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
radius: Math.random() * 1.2 + 0.3,
|
||||||
opacity: Math.random(),
|
opacity: Math.random(),
|
||||||
twinkleSpeed: Math.random() > 0.3 ? 0.5 + Math.random() * 0.5 : null,
|
twinkleSpeed: Math.random() > 0.3 ? 0.5 + Math.random() * 0.5 : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
stateRef.current = {
|
||||||
|
ctx,
|
||||||
|
shootingStars: [],
|
||||||
|
bgStars,
|
||||||
|
lastShootingTime: 0,
|
||||||
|
nextShootingDelay: 4200 + Math.random() * 4500,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
dpr,
|
||||||
};
|
};
|
||||||
|
|
||||||
const spawnShootingStar = () => {
|
const onResize = () => {
|
||||||
shootingStars.push({
|
const nw = parent?.offsetWidth ?? window.innerWidth;
|
||||||
x: Math.random() * canvas.width,
|
const nh = parent?.offsetHeight ?? window.innerHeight;
|
||||||
y: Math.random() * canvas.height * 0.5,
|
canvas.width = nw * dpr;
|
||||||
angle: Math.PI / 4 + (Math.random() - 0.5) * 0.3,
|
canvas.height = nh * dpr;
|
||||||
scale: 0.5 + Math.random() * 0.5,
|
canvas.style.width = `${nw}px`;
|
||||||
speed: minSpeed + Math.random() * (maxSpeed - minSpeed),
|
canvas.style.height = `${nh}px`;
|
||||||
distance: 0,
|
if (stateRef.current) {
|
||||||
opacity: 1,
|
stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
});
|
stateRef.current.w = nw;
|
||||||
|
stateRef.current.h = nh;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const animate = (time: number) => {
|
window.addEventListener('resize', onResize);
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
return () => window.removeEventListener('resize', onResize);
|
||||||
|
}, [starDensity]);
|
||||||
|
|
||||||
|
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) {
|
for (const s of bgStars) {
|
||||||
let opacity = s.opacity;
|
let opacity = s.opacity;
|
||||||
if (s.twinkleSpeed) {
|
if (s.twinkleSpeed) {
|
||||||
@@ -88,14 +121,21 @@ export default function ShootingStarsBackground({ settings }: Props) {
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn shooting stars
|
if (time - state.lastShootingTime > state.nextShootingDelay) {
|
||||||
if (time - lastShootingTime > 4200 + Math.random() * 4500) {
|
state.shootingStars.push({
|
||||||
spawnShootingStar();
|
x: Math.random() * w,
|
||||||
lastShootingTime = time;
|
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
|
state.shootingStars = state.shootingStars.filter((star) => {
|
||||||
shootingStars = shootingStars.filter((star) => {
|
|
||||||
star.distance += star.speed;
|
star.distance += star.speed;
|
||||||
star.opacity = Math.max(0, 1 - star.distance / 500);
|
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 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 tailY = star.y + Math.sin(star.angle) * Math.max(0, star.distance - 80);
|
||||||
|
|
||||||
const gradient = ctx.createLinearGradient(tailX, tailY, x2, y2);
|
// Draw trail with flat color (avoids per-frame gradient allocation)
|
||||||
gradient.addColorStop(0, 'transparent');
|
|
||||||
gradient.addColorStop(0.5, trailColor);
|
|
||||||
gradient.addColorStop(1, starColor);
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.strokeStyle = gradient;
|
|
||||||
ctx.lineWidth = star.scale * 2;
|
ctx.lineWidth = star.scale * 2;
|
||||||
ctx.globalAlpha = star.opacity;
|
ctx.globalAlpha = star.opacity * 0.4;
|
||||||
|
ctx.strokeStyle = trailColor;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(tailX, tailY);
|
ctx.moveTo(tailX, tailY);
|
||||||
ctx.lineTo(x2, y2);
|
ctx.lineTo(x2, y2);
|
||||||
ctx.stroke();
|
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;
|
return true;
|
||||||
});
|
});
|
||||||
|
},
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
[starColor, trailColor, starDensity, minSpeed, maxSpeed],
|
||||||
};
|
);
|
||||||
|
|
||||||
resize();
|
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(animationRef.current);
|
|
||||||
window.removeEventListener('resize', resize);
|
|
||||||
};
|
|
||||||
}, [starColor, trailColor, starDensity, minSpeed, maxSpeed]);
|
|
||||||
|
|
||||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
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 { sanitizeColor, clampNumber } from './types';
|
||||||
|
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
@@ -16,18 +17,16 @@ interface Particle {
|
|||||||
opacitySpeed: number;
|
opacitySpeed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sparkles({ settings }: Props) {
|
interface SparklesState {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
ctx: CanvasRenderingContext2D;
|
||||||
const animationRef = useRef<number>(0);
|
particles: Particle[];
|
||||||
const particlesRef = useRef<Particle[]>([]);
|
rgb: { r: number; g: number; b: number };
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
dpr: number;
|
||||||
|
}
|
||||||
|
|
||||||
const particleDensity = clampNumber(settings.particleDensity, 50, 5000, 800);
|
function hexToRgb(hex: string) {
|
||||||
const minSize = clampNumber(settings.minSize, 0.1, 5, 0.4);
|
|
||||||
const maxSize = clampNumber(settings.maxSize, 0.5, 10, 1.4);
|
|
||||||
const speed = clampNumber(settings.speed, 0.1, 10, 2);
|
|
||||||
const particleColor = sanitizeColor(settings.particleColor, '#FFFFFF');
|
|
||||||
|
|
||||||
const hexToRgb = useCallback((hex: string) => {
|
|
||||||
hex = hex.replace('#', '');
|
hex = hex.replace('#', '');
|
||||||
if (hex.length === 3)
|
if (hex.length === 3)
|
||||||
hex = hex
|
hex = hex
|
||||||
@@ -39,27 +38,40 @@ export default function Sparkles({ settings }: Props) {
|
|||||||
g: parseInt(hex.substring(2, 4), 16),
|
g: parseInt(hex.substring(2, 4), 16),
|
||||||
b: parseInt(hex.substring(4, 6), 16),
|
b: parseInt(hex.substring(4, 6), 16),
|
||||||
};
|
};
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
|
export default function Sparkles({ settings }: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const stateRef = useRef<SparklesState | null>(null);
|
||||||
|
|
||||||
|
const particleDensity = clampNumber(settings.particleDensity, 50, 5000, 800);
|
||||||
|
const minSize = clampNumber(settings.minSize, 0.1, 5, 0.4);
|
||||||
|
const maxSize = clampNumber(settings.maxSize, 0.5, 10, 1.4);
|
||||||
|
const speed = clampNumber(settings.speed, 0.1, 10, 2);
|
||||||
|
const particleColor = sanitizeColor(settings.particleColor, '#FFFFFF');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
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');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
const resize = () => {
|
const area = w * h;
|
||||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
|
||||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
|
||||||
initParticles();
|
|
||||||
};
|
|
||||||
|
|
||||||
const initParticles = () => {
|
|
||||||
const area = canvas.width * canvas.height;
|
|
||||||
const count = Math.floor((area / 1000000) * particleDensity);
|
const count = Math.floor((area / 1000000) * particleDensity);
|
||||||
particlesRef.current = Array.from({ length: count }, () => ({
|
const particles: Particle[] = Array.from({ length: count }, () => ({
|
||||||
x: Math.random() * canvas.width,
|
x: Math.random() * w,
|
||||||
y: Math.random() * canvas.height,
|
y: Math.random() * h,
|
||||||
size: minSize + Math.random() * (maxSize - minSize),
|
size: minSize + Math.random() * (maxSize - minSize),
|
||||||
speedX: (Math.random() - 0.5) * speed * 0.2,
|
speedX: (Math.random() - 0.5) * speed * 0.2,
|
||||||
speedY: (Math.random() - 0.5) * speed * 0.2,
|
speedY: (Math.random() - 0.5) * speed * 0.2,
|
||||||
@@ -67,14 +79,36 @@ export default function Sparkles({ settings }: Props) {
|
|||||||
opacityDirection: Math.random() > 0.5 ? 1 : -1,
|
opacityDirection: Math.random() > 0.5 ? 1 : -1,
|
||||||
opacitySpeed: 0.005 + Math.random() * 0.01 * speed,
|
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 rgb = hexToRgb(particleColor);
|
window.addEventListener('resize', onResize);
|
||||||
|
return () => window.removeEventListener('resize', onResize);
|
||||||
|
}, [particleDensity, minSize, maxSize, speed, particleColor]);
|
||||||
|
|
||||||
const animate = () => {
|
useAnimationLoop(() => {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
const state = stateRef.current;
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
for (const p of particlesRef.current) {
|
const { ctx, particles, rgb, w, h } = state;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
for (const p of particles) {
|
||||||
p.x += p.speedX;
|
p.x += p.speedX;
|
||||||
p.y += p.speedY;
|
p.y += p.speedY;
|
||||||
p.opacity += p.opacityDirection * p.opacitySpeed;
|
p.opacity += p.opacityDirection * p.opacitySpeed;
|
||||||
@@ -87,29 +121,17 @@ export default function Sparkles({ settings }: Props) {
|
|||||||
p.opacityDirection = -1;
|
p.opacityDirection = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (p.x < 0) p.x = canvas.width;
|
if (p.x < 0) p.x = w;
|
||||||
if (p.x > canvas.width) p.x = 0;
|
if (p.x > w) p.x = 0;
|
||||||
if (p.y < 0) p.y = canvas.height;
|
if (p.y < 0) p.y = h;
|
||||||
if (p.y > canvas.height) p.y = 0;
|
if (p.y > h) p.y = 0;
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${p.opacity})`;
|
ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${p.opacity})`;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
}, [particleDensity, minSize, maxSize, speed, particleColor]);
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
|
||||||
};
|
|
||||||
|
|
||||||
resize();
|
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(animationRef.current);
|
|
||||||
window.removeEventListener('resize', resize);
|
|
||||||
};
|
|
||||||
}, [particleDensity, minSize, maxSize, speed, particleColor, hexToRgb]);
|
|
||||||
|
|
||||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||||
|
|
||||||
export default function SpotlightBg({ settings }: Props) {
|
export default function SpotlightBg({ settings }: Props) {
|
||||||
const spotlightColor = sanitizeColor(settings.spotlightColor, '#818cf8');
|
const spotlightColor = sanitizeColor(settings.spotlightColor, '#818cf8');
|
||||||
const spotlightSize = clampNumber(settings.spotlightSize, 100, 1000, 400);
|
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 (
|
return (
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
@@ -27,7 +38,7 @@ export default function SpotlightBg({ settings }: Props) {
|
|||||||
height: spotlightSize,
|
height: spotlightSize,
|
||||||
background: `radial-gradient(circle, ${spotlightColor}40 0%, transparent 70%)`,
|
background: `radial-gradient(circle, ${spotlightColor}40 0%, transparent 70%)`,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
filter: 'blur(60px)',
|
filter: `blur(${blur1}px)`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -46,7 +57,7 @@ export default function SpotlightBg({ settings }: Props) {
|
|||||||
height: spotlightSize * 0.8,
|
height: spotlightSize * 0.8,
|
||||||
background: `radial-gradient(circle, ${spotlightColor}30 0%, transparent 70%)`,
|
background: `radial-gradient(circle, ${spotlightColor}30 0%, transparent 70%)`,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
filter: 'blur(80px)',
|
filter: `blur(${blur2}px)`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,17 +42,30 @@ export function sanitizeColor(value: unknown, fallback: string): string {
|
|||||||
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
|
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
|
||||||
// Allow rgb/rgba/hsl/hsla with numbers, commas, spaces, dots, %
|
// Allow rgb/rgba/hsl/hsla with numbers, commas, spaces, dots, %
|
||||||
if (/^(rgb|hsl)a?\([0-9,.\s/%]+\)$/.test(trimmed)) return trimmed;
|
if (/^(rgb|hsl)a?\([0-9,.\s/%]+\)$/.test(trimmed)) return trimmed;
|
||||||
// Allow CSS named colors (alphanumeric only)
|
// Allow CSS named colors (alphanumeric only, exclude CSS-wide keywords)
|
||||||
if (/^[a-zA-Z]{3,30}$/.test(trimmed)) return trimmed;
|
if (/^[a-zA-Z]{3,30}$/.test(trimmed) && !CSS_WIDE_KEYWORDS.has(trimmed.toLowerCase()))
|
||||||
|
return trimmed;
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clamp a numeric value within bounds */
|
/** Clamp a numeric value within bounds */
|
||||||
export function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
|
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));
|
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 {
|
export interface SettingDefinition {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -1,14 +1,37 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { createNoise3D } from 'simplex-noise';
|
import { createNoise3D } from 'simplex-noise';
|
||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
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) {
|
export default function VortexBackground({ settings }: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
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 particleCount = clampNumber(settings.particleCount, 50, 2000, 500);
|
||||||
const rangeY = clampNumber(settings.rangeY, 10, 500, 100);
|
const rangeY = clampNumber(settings.rangeY, 10, 500, 100);
|
||||||
@@ -16,86 +39,82 @@ export default function VortexBackground({ settings }: Props) {
|
|||||||
const rangeSpeed = clampNumber(settings.rangeSpeed, 0.1, 5, 1.5);
|
const rangeSpeed = clampNumber(settings.rangeSpeed, 0.1, 5, 1.5);
|
||||||
const backgroundColor = sanitizeColor(settings.backgroundColor, '#000000');
|
const backgroundColor = sanitizeColor(settings.backgroundColor, '#000000');
|
||||||
|
|
||||||
|
const particlePropCount = 9;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
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');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
|
||||||
const noise3D = createNoise3D();
|
const noise3D = createNoise3D();
|
||||||
const particlePropCount = 9;
|
|
||||||
const particlePropsLength = particleCount * particlePropCount;
|
const particlePropsLength = particleCount * particlePropCount;
|
||||||
let particleProps = new Float32Array(particlePropsLength);
|
const particleProps = new Float32Array(particlePropsLength);
|
||||||
let tick = 0;
|
const center: [number, number] = [0.5 * w, 0.5 * h];
|
||||||
let center: [number, number] = [0, 0];
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
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];
|
|
||||||
};
|
|
||||||
|
|
||||||
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) {
|
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
|
||||||
initParticle(i);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
stateRef.current = { ctx, noise3D, particleProps, tick: 0, center, 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.ctx.lineCap = 'round';
|
||||||
|
stateRef.current.center = [0.5 * nw, 0.5 * nh];
|
||||||
|
stateRef.current.w = nw;
|
||||||
|
stateRef.current.h = nh;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawParticle = (
|
window.addEventListener('resize', onResize);
|
||||||
x: number,
|
return () => window.removeEventListener('resize', onResize);
|
||||||
y: number,
|
}, [particleCount, rangeY, baseHue, rangeSpeed]);
|
||||||
x2: number,
|
|
||||||
y2: number,
|
|
||||||
life: number,
|
|
||||||
ttl: number,
|
|
||||||
radius: number,
|
|
||||||
hue: number,
|
|
||||||
) => {
|
|
||||||
ctx.save();
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
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 = () => {
|
useAnimationLoop(() => {
|
||||||
tick++;
|
const state = stateRef.current;
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
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.fillStyle = backgroundColor;
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
|
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
|
||||||
const x = particleProps[i];
|
const x = particleProps[i];
|
||||||
const y = particleProps[i + 1];
|
const y = particleProps[i + 1];
|
||||||
const n = noise3D(x * 0.00125, y * 0.00125, tick * 0.0005) * 3 * TAU;
|
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 vx = lerp(particleProps[i + 2], Math.cos(n), 0.5);
|
||||||
const vy = lerp(particleProps[i + 3], Math.sin(n), 0.5);
|
const vy = lerp(particleProps[i + 3], Math.sin(n), 0.5);
|
||||||
const life = particleProps[i + 4];
|
const life = particleProps[i + 4];
|
||||||
@@ -106,7 +125,12 @@ export default function VortexBackground({ settings }: Props) {
|
|||||||
const radius = particleProps[i + 7];
|
const radius = particleProps[i + 7];
|
||||||
const hue = particleProps[i + 8];
|
const hue = particleProps[i + 8];
|
||||||
|
|
||||||
drawParticle(x, y, x2, y2, life, ttl, radius, hue);
|
ctx.lineWidth = radius;
|
||||||
|
ctx.strokeStyle = `hsla(${hue},100%,60%,${fadeInOut(life, ttl)})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
ctx.lineTo(x2, y2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
particleProps[i] = x2;
|
particleProps[i] = x2;
|
||||||
particleProps[i + 1] = y2;
|
particleProps[i + 1] = y2;
|
||||||
@@ -114,30 +138,28 @@ export default function VortexBackground({ settings }: Props) {
|
|||||||
particleProps[i + 3] = vy;
|
particleProps[i + 3] = vy;
|
||||||
particleProps[i + 4] = life + 1;
|
particleProps[i + 4] = life + 1;
|
||||||
|
|
||||||
if (x2 > canvas.width || x2 < 0 || y2 > canvas.height || y2 < 0 || life + 1 > ttl) {
|
if (x2 > w || x2 < 0 || y2 > h || y2 < 0 || life + 1 > ttl) {
|
||||||
initParticle(i);
|
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.save();
|
||||||
ctx.filter = 'blur(8px) brightness(200%)';
|
ctx.filter = 'blur(8px) brightness(200%)';
|
||||||
ctx.globalCompositeOperation = 'lighter';
|
ctx.globalCompositeOperation = 'lighter';
|
||||||
ctx.drawImage(canvas, 0, 0);
|
const canvas = canvasRef.current;
|
||||||
|
if (canvas) ctx.drawImage(canvas, 0, 0, w, h);
|
||||||
ctx.restore();
|
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]);
|
}, [particleCount, rangeY, baseHue, rangeSpeed, backgroundColor]);
|
||||||
|
|
||||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { createNoise3D } from 'simplex-noise';
|
import { createNoise3D } from 'simplex-noise';
|
||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber, safeSelect } from './types';
|
||||||
|
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
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) {
|
export default function WavyBackground({ settings }: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
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 waveWidth = clampNumber(settings.waveWidth, 5, 200, 50);
|
||||||
const blur = clampNumber(settings.blur, 0, 50, 10);
|
const blur = clampNumber(settings.blur, 0, 50, 10);
|
||||||
const waveOpacity = clampNumber(settings.waveOpacity, 0.05, 1, 0.5);
|
const waveOpacity = clampNumber(settings.waveOpacity, 0.05, 1, 0.5);
|
||||||
@@ -21,55 +33,73 @@ export default function WavyBackground({ settings }: Props) {
|
|||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
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');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
const noise3D = createNoise3D();
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
let nt = 0;
|
const effectiveBlur = isMobile ? Math.min(blur, 4) : blur;
|
||||||
const speedVal = speed === 'slow' ? 0.001 : 0.002;
|
ctx.filter = effectiveBlur > 0 ? `blur(${effectiveBlur}px)` : 'none';
|
||||||
const waveCount = 5;
|
|
||||||
|
|
||||||
const resize = () => {
|
stateRef.current = { ctx, noise3D: createNoise3D(), nt: 0, w, h, dpr };
|
||||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
|
||||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
const onResize = () => {
|
||||||
ctx.filter = `blur(${blur}px)`;
|
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 drawWave = (_n: number) => {
|
window.addEventListener('resize', onResize);
|
||||||
nt += speedVal;
|
return () => window.removeEventListener('resize', onResize);
|
||||||
|
}, [blur]);
|
||||||
|
|
||||||
|
const speedVal = speed === 'slow' ? 0.001 : 0.002;
|
||||||
|
const waveCount = isMobile ? 3 : 5;
|
||||||
|
const step = isMobile ? 8 : 5;
|
||||||
|
|
||||||
|
useAnimationLoop(() => {
|
||||||
|
const state = stateRef.current;
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
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++) {
|
for (let i = 0; i < waveCount; i++) {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.lineWidth = waveWidth;
|
ctx.lineWidth = waveWidth;
|
||||||
ctx.strokeStyle = colors[i % colors.length];
|
ctx.strokeStyle = colors[i % colors.length];
|
||||||
ctx.globalAlpha = waveOpacity;
|
ctx.globalAlpha = waveOpacity;
|
||||||
|
|
||||||
for (let x = 0; x < canvas.width; x += 5) {
|
for (let x = 0; x < w; x += step) {
|
||||||
const y = noise3D(x / 800, 0.3 * i, nt) * 100;
|
const y = noise3D(x / 800, 0.3 * i, state.nt) * 100;
|
||||||
ctx.lineTo(x, y + canvas.height * 0.5);
|
ctx.lineTo(x, y + h * 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const animate = () => {
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
ctx.fillStyle = backgroundFill;
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
drawWave(5);
|
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
|
||||||
};
|
|
||||||
|
|
||||||
resize();
|
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(animationRef.current);
|
|
||||||
window.removeEventListener('resize', resize);
|
|
||||||
};
|
|
||||||
}, [speed, waveWidth, blur, waveOpacity, backgroundFill]);
|
}, [speed, waveWidth, blur, waveOpacity, backgroundFill]);
|
||||||
|
|
||||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
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 {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv;
|
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