From a933f661e49b70af3fadee90bc93257f689086be Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 1 Mar 2026 22:36:57 +0300 Subject: [PATCH] =?UTF-8?q?perf:=20optimize=20animated=20backgrounds=20for?= =?UTF-8?q?=20mobile=20=E2=80=94=20reduce=20GPU=20load=20and=20memory=20pr?= =?UTF-8?q?essure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../backgrounds/BackgroundRenderer.tsx | 74 ++++-- .../ui/backgrounds/aurora-background.tsx | 13 +- .../background-beams-collision.tsx | 93 +++++--- .../ui/backgrounds/background-beams.tsx | 9 +- .../ui/backgrounds/background-boxes.tsx | 78 +++---- .../background-gradient-animation.tsx | 103 +++++++-- .../ui/backgrounds/background-lines.tsx | 106 ++++++--- .../ui/backgrounds/background-ripple.tsx | 75 +++++-- .../ui/backgrounds/grid-background.tsx | 4 +- src/components/ui/backgrounds/meteors.tsx | 3 + .../ui/backgrounds/shooting-stars.tsx | 150 ++++++++----- src/components/ui/backgrounds/sparkles.tsx | 160 +++++++------ .../ui/backgrounds/spotlight-bg.tsx | 15 +- src/components/ui/backgrounds/types.ts | 19 +- src/components/ui/backgrounds/vortex.tsx | 210 ++++++++++-------- .../ui/backgrounds/wavy-background.tsx | 112 ++++++---- src/hooks/useAnimationLoop.ts | 140 ++++++++++++ src/vite-env.d.ts | 12 + 18 files changed, 926 insertions(+), 450 deletions(-) create mode 100644 src/hooks/useAnimationLoop.ts diff --git a/src/components/backgrounds/BackgroundRenderer.tsx b/src/components/backgrounds/BackgroundRenderer.tsx index d712f5d..d7e7f93 100644 --- a/src/components/backgrounds/BackgroundRenderer.tsx +++ b/src/components/backgrounds/BackgroundRenderer.tsx @@ -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 = new Set([ + '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; + 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) ?? {}, + 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): Record { 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(
0 ? `blur(${effectiveConfig.blur}px)` : undefined, + filter: effectiveBlur > 0 ? `blur(${effectiveBlur}px)` : undefined, contain: 'strict', backfaceVisibility: 'hidden', }} diff --git a/src/components/ui/backgrounds/aurora-background.tsx b/src/components/ui/backgrounds/aurora-background.tsx index 24cc061..72de75e 100644 --- a/src/components/ui/backgrounds/aurora-background.tsx +++ b/src/components/ui/backgrounds/aurora-background.tsx @@ -1,24 +1,31 @@ import { cn } from '@/lib/utils'; +import { safeBoolean } from './types'; +import { useAnimationPause } from '@/hooks/useAnimationLoop'; interface Props { settings: Record; } +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 (
diff --git a/src/components/ui/backgrounds/background-beams-collision.tsx b/src/components/ui/backgrounds/background-beams-collision.tsx index 64c50da..3a51de6 100644 --- a/src/components/ui/backgrounds/background-beams-collision.tsx +++ b/src/components/ui/backgrounds/background-beams-collision.tsx @@ -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; @@ -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(null); const parentRef = useRef(null); + const paused = useAnimationPause(); return (
- {BEAMS.map((beam) => ( - - ))} + {!paused && + BEAMS.map((beam) => ( + + ))} {/* Bottom collision line */}
; @@ -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 ( -
+
))} diff --git a/src/components/ui/backgrounds/background-boxes.tsx b/src/components/ui/backgrounds/background-boxes.tsx index 62af05e..63def23 100644 --- a/src/components/ui/backgrounds/background-boxes.tsx +++ b/src/components/ui/backgrounds/background-boxes.tsx @@ -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; @@ -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 element. - * - * Previous implementation used 225 DOM
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(null); + const stateRef = useRef(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 ( ; } +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(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 (
- - - - - - - - - -
+ {/* SVG filter only rendered on desktop */} + {!isMobile && ( + + + + + + + + + + )} +
{[ { color: firstColor, anim: 'animate-move-vertical' }, { color: secondColor, anim: 'animate-move-in-circle' }, @@ -104,15 +157,17 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
))} - {interactive && ( + {interactive && !isMobile && (
; } +const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; + +interface LinesState { + ctx: CanvasRenderingContext2D; + noise3D: ReturnType; + nt: number; + w: number; + h: number; + dpr: number; +} + export default function BackgroundLines({ settings }: Props) { const canvasRef = useRef(null); - const animationRef = useRef(0); + const stateRef = useRef(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 ; } diff --git a/src/components/ui/backgrounds/background-ripple.tsx b/src/components/ui/backgrounds/background-ripple.tsx index 228c4da..62d2b3c 100644 --- a/src/components/ui/backgrounds/background-ripple.tsx +++ b/src/components/ui/backgrounds/background-ripple.tsx @@ -1,13 +1,22 @@ import { useEffect, useRef } from 'react'; import { sanitizeColor, clampNumber } from './types'; +import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop'; interface Props { settings: Record; } +interface RippleState { + ctx: CanvasRenderingContext2D; + w: number; + h: number; + dpr: number; + ripples: { phase: number }[]; +} + export default function BackgroundRipple({ settings }: Props) { const canvasRef = useRef(null); - const animationRef = useRef(0); + const stateRef = useRef(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 ; } diff --git a/src/components/ui/backgrounds/grid-background.tsx b/src/components/ui/backgrounds/grid-background.tsx index 327e7af..393f349 100644 --- a/src/components/ui/backgrounds/grid-background.tsx +++ b/src/components/ui/backgrounds/grid-background.tsx @@ -1,11 +1,11 @@ -import { sanitizeColor, clampNumber } from './types'; +import { sanitizeColor, clampNumber, safeSelect } from './types'; interface Props { settings: Record; } 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); diff --git a/src/components/ui/backgrounds/meteors.tsx b/src/components/ui/backgrounds/meteors.tsx index 576496e..9cc0e54 100644 --- a/src/components/ui/backgrounds/meteors.tsx +++ b/src/components/ui/backgrounds/meteors.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import { sanitizeColor, clampNumber } from './types'; +import { useAnimationPause } from '@/hooks/useAnimationLoop'; interface Props { settings: Record; @@ -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`, diff --git a/src/components/ui/backgrounds/shooting-stars.tsx b/src/components/ui/backgrounds/shooting-stars.tsx index a67aaed..cf94944 100644 --- a/src/components/ui/backgrounds/shooting-stars.tsx +++ b/src/components/ui/backgrounds/shooting-stars.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react'; import { sanitizeColor, clampNumber } from './types'; +import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop'; interface Props { settings: Record; @@ -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(null); - const animationRef = useRef(0); + const stateRef = useRef(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 ; } diff --git a/src/components/ui/backgrounds/sparkles.tsx b/src/components/ui/backgrounds/sparkles.tsx index 80357f3..de7f0bc 100644 --- a/src/components/ui/backgrounds/sparkles.tsx +++ b/src/components/ui/backgrounds/sparkles.tsx @@ -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; @@ -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(null); - const animationRef = useRef(0); - const particlesRef = useRef([]); + const stateRef = useRef(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 ; } diff --git a/src/components/ui/backgrounds/spotlight-bg.tsx b/src/components/ui/backgrounds/spotlight-bg.tsx index dd6a5aa..7d68da8 100644 --- a/src/components/ui/backgrounds/spotlight-bg.tsx +++ b/src/components/ui/backgrounds/spotlight-bg.tsx @@ -1,13 +1,24 @@ import { motion } from 'framer-motion'; import { sanitizeColor, clampNumber } from './types'; +import { useAnimationPause } from '@/hooks/useAnimationLoop'; interface Props { settings: Record; } +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
; + } return (
@@ -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)`, }} />
diff --git a/src/components/ui/backgrounds/types.ts b/src/components/ui/backgrounds/types.ts index 4bcabda..d4ddc3b 100644 --- a/src/components/ui/backgrounds/types.ts +++ b/src/components/ui/backgrounds/types.ts @@ -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; diff --git a/src/components/ui/backgrounds/vortex.tsx b/src/components/ui/backgrounds/vortex.tsx index c345e56..f4be70b 100644 --- a/src/components/ui/backgrounds/vortex.tsx +++ b/src/components/ui/backgrounds/vortex.tsx @@ -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; } +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; + particleProps: Float32Array; + tick: number; + center: [number, number]; + w: number; + h: number; + dpr: number; +} + export default function VortexBackground({ settings }: Props) { const canvasRef = useRef(null); - const animationRef = useRef(0); + const stateRef = useRef(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 ; diff --git a/src/components/ui/backgrounds/wavy-background.tsx b/src/components/ui/backgrounds/wavy-background.tsx index 84ef17a..9014bf6 100644 --- a/src/components/ui/backgrounds/wavy-background.tsx +++ b/src/components/ui/backgrounds/wavy-background.tsx @@ -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; } +const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; + +interface WavyState { + ctx: CanvasRenderingContext2D; + noise3D: ReturnType; + nt: number; + w: number; + h: number; + dpr: number; +} + export default function WavyBackground({ settings }: Props) { const canvasRef = useRef(null); - const animationRef = useRef(0); + const stateRef = useRef(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 ; diff --git a/src/hooks/useAnimationLoop.ts b/src/hooks/useAnimationLoop.ts new file mode 100644 index 0000000..5d210f8 --- /dev/null +++ b/src/hooks/useAnimationLoop.ts @@ -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; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index a5e4314..7c33e43 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -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; + }; +}