diff --git a/src/app/_components/Animated/AnimatedBackGroundContainer.tsx b/src/app/_components/Animated/AnimatedBackGroundContainer.tsx index 76eb714..6a954ea 100644 --- a/src/app/_components/Animated/AnimatedBackGroundContainer.tsx +++ b/src/app/_components/Animated/AnimatedBackGroundContainer.tsx @@ -1,16 +1,10 @@ "use client"; -import React, { useRef, useEffect, useCallback, useState } from "react"; -import { useGSAP } from "@gsap/react"; -import gsap from "gsap"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { useTheme } from "next-themes"; -/* ───────────────────────────────────────────── - * Config — grayscale palettes - * ───────────────────────────────────────────── */ const PALETTES = { dark: { - base: "#0a0a0a", particles: [ "rgba(255,255,255,0.70)", "rgba(255,255,255,0.45)", @@ -18,9 +12,9 @@ const PALETTES = { "rgba(200,200,200,0.35)", "rgba(255,255,255,0.22)", ], + grainOpacity: 0.05, }, light: { - base: "#f5f5f5", particles: [ "rgba(0,0,0,0.55)", "rgba(0,0,0,0.35)", @@ -28,22 +22,10 @@ const PALETTES = { "rgba(80,80,80,0.25)", "rgba(0,0,0,0.18)", ], + grainOpacity: 0.03, }, } as const; -/* ───────────────────────────────────────────── - * Helpers - * ───────────────────────────────────────────── */ -const isMobileDevice = (): boolean => { - if (typeof window === "undefined") return false; - return window.matchMedia("(pointer: coarse)").matches || window.innerWidth < 768; -}; - -const rand = (min: number, max: number) => Math.random() * (max - min) + min; - -/* ───────────────────────────────────────────── - * Particle - * ───────────────────────────────────────────── */ interface Particle { angle: number; radius: number; @@ -55,33 +37,43 @@ interface Particle { wobblePhase: number; } -const spawnParticle = (): Particle => ({ - angle: rand(0, Math.PI * 2), - radius: rand(30, 240), - speed: rand(0.003, 0.002) * (Math.random() > 0.5 ? 1 : -1), - size: rand(1.2, 4), - colorIndex: Math.floor(rand(0, 5)), - wobbleAmp: rand(6, 30), - wobbleSpeed: rand(0.008, 0.035), - wobblePhase: rand(0, Math.PI * 2), -}); - -/* ───────────────────────────────────────────── - * Component - * ───────────────────────────────────────────── */ interface AnimatedBackgroundContainerProps { children: React.ReactNode; className?: string; - /** Number of orbiting particles. Default 60 */ particleCount?: number; - /** Max orbit radius in px — controls how far particles spread from the cursor. Default 240 */ orbitRadius?: number; - /** How quickly particles catch up to cursor (0–1). Default 0.06 */ followSpeed?: number; - /** Speed multiplier for mobile random anchor drift. Default 1 */ mobileSpeed?: number; } +const DEFAULT_PARTICLE_COLORS: readonly string[] = PALETTES.dark.particles; +const PARTICLE_COLOR_COUNT = DEFAULT_PARTICLE_COLORS.length; + +const rand = (min: number, max: number) => Math.random() * (max - min) + min; + +const isMobileDevice = () => { + if (typeof window === "undefined") { + return false; + } + + return window.matchMedia("(pointer: coarse)").matches || window.innerWidth < 768; +}; + +const createParticle = (orbitRadius: number): Particle => { + const minRadius = Math.max(10, orbitRadius * 0.12); + + return { + angle: rand(0, Math.PI * 2), + radius: rand(minRadius, orbitRadius), + speed: rand(0.002, 0.003) * (Math.random() > 0.5 ? 1 : -1), + size: rand(1.2, 4), + colorIndex: Math.floor(rand(0, PARTICLE_COLOR_COUNT)), + wobbleAmp: rand(orbitRadius * 0.025, orbitRadius * 0.12), + wobbleSpeed: rand(0.008, 0.035), + wobblePhase: rand(0, Math.PI * 2), + }; +}; + export default function AnimatedBackgroundContainer({ children, className = "", @@ -92,172 +84,225 @@ export default function AnimatedBackgroundContainer({ }: AnimatedBackgroundContainerProps) { const canvasRef = useRef(null); const containerRef = useRef(null); - const mousePos = useRef({ x: 0, y: 0 }); - const smoothMouse = useRef({ x: 0, y: 0 }); - const mobileAnchor = useRef({ x: 0, y: 0 }); - const mobileTarget = useRef({ x: 0, y: 0 }); - const isMobile = useRef(false); - const particles = useRef([]); - const frame = useRef(0); + const frameRef = useRef(0); + const animationFrameRef = useRef(null); + const isMobileRef = useRef(false); + const particlesRef = useRef([]); + const mousePosRef = useRef({ x: 0, y: 0 }); + const smoothMouseRef = useRef({ x: 0, y: 0 }); + const mobileAnchorRef = useRef({ x: 0, y: 0 }); + const mobileTargetRef = useRef({ x: 0, y: 0 }); + const followSpeedRef = useRef(followSpeed); + const mobileSpeedRef = useRef(mobileSpeed); + const particleColorsRef = useRef(DEFAULT_PARTICLE_COLORS); const [mounted, setMounted] = useState(false); const { resolvedTheme } = useTheme(); - let isDark = resolvedTheme === "dark"; - if (resolvedTheme == undefined) { - isDark = true; - } + const isDark = resolvedTheme === undefined || resolvedTheme === "dark"; const palette = isDark ? PALETTES.dark : PALETTES.light; - /* Spawn particles */ useEffect(() => { - const minR = Math.max(10, orbitRadius * 0.12); - particles.current = Array.from({ length: particleCount }, () => ({ - ...spawnParticle(), - radius: rand(minR, orbitRadius), - wobbleAmp: rand(orbitRadius * 0.025, orbitRadius * 0.12), - })); + particleColorsRef.current = palette.particles; + }, [palette]); + + useEffect(() => { + followSpeedRef.current = followSpeed; + }, [followSpeed]); + + useEffect(() => { + mobileSpeedRef.current = mobileSpeed; + }, [mobileSpeed]); + + useEffect(() => { + particlesRef.current = Array.from({ length: particleCount }, () => + createParticle(orbitRadius), + ); }, [particleCount, orbitRadius]); - /* Detect mobile & seed positions */ + const seedPositions = useCallback(() => { + const container = containerRef.current; + if (!container) { + return; + } + + const centerX = container.clientWidth / 2; + const centerY = container.clientHeight / 2; + + mousePosRef.current = { x: centerX, y: centerY }; + smoothMouseRef.current = { x: centerX, y: centerY }; + mobileAnchorRef.current = { x: centerX, y: centerY }; + mobileTargetRef.current = { + x: rand(centerX * 0.4, centerX * 1.6), + y: rand(centerY * 0.4, centerY * 1.6), + }; + }, []); + + const resizeCanvas = useCallback(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) { + return; + } + + const width = container.clientWidth; + const height = container.clientHeight; + const dpr = window.devicePixelRatio || 1; + + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.scale(dpr, dpr); + + if (!mounted) { + seedPositions(); + } + }, [mounted, seedPositions]); + useEffect(() => { setMounted(true); - isMobile.current = isMobileDevice(); - if (containerRef.current) { - const cx = containerRef.current.clientWidth / 2; - const cy = containerRef.current.clientHeight / 2; - mousePos.current = { x: cx, y: cy }; - smoothMouse.current = { x: cx, y: cy }; - mobileAnchor.current = { x: cx, y: cy }; - mobileTarget.current = { - x: rand(cx * 0.4, cx * 1.6), - y: rand(cy * 0.4, cy * 1.6), - }; + isMobileRef.current = isMobileDevice(); + resizeCanvas(); + + const handleResize = () => { + isMobileRef.current = isMobileDevice(); + resizeCanvas(); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [resizeCanvas]); + + useEffect(() => { + if (!mounted) { + return; } - }, []); - /* Resize canvas to match container */ - useEffect(() => { - const resize = () => { - const canvas = canvasRef.current; - const container = containerRef.current; - if (!canvas || !container) return; - const dpr = window.devicePixelRatio || 1; - const w = container.clientWidth; - const h = container.clientHeight; - 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) ctx.scale(dpr, dpr); - }; - resize(); - window.addEventListener("resize", resize); - return () => window.removeEventListener("resize", resize); - }, []); + seedPositions(); + }, [mounted, seedPositions]); - /* Mouse tracking (desktop only) */ - const handleMouseMove = useCallback((e: MouseEvent) => { - if (!containerRef.current || isMobile.current) return; - const rect = containerRef.current.getBoundingClientRect(); - mousePos.current = { - x: e.clientX - rect.left, - y: e.clientY - rect.top, + const handleMouseMove = useCallback((event: MouseEvent) => { + const container = containerRef.current; + if (!container || isMobileRef.current) { + return; + } + + const rect = container.getBoundingClientRect(); + mousePosRef.current = { + x: event.clientX - rect.left, + y: event.clientY - rect.top, }; }, []); useEffect(() => { - const el = containerRef.current; - if (!el) return; - el.addEventListener("mousemove", handleMouseMove, { passive: true }); - return () => el.removeEventListener("mousemove", handleMouseMove); + const container = containerRef.current; + if (!container) { + return; + } + + container.addEventListener("mousemove", handleMouseMove, { passive: true }); + return () => container.removeEventListener("mousemove", handleMouseMove); }, [handleMouseMove]); - /* ── GSAP ticker — draw loop ── */ - useGSAP( - () => { - if (!mounted) return; - const canvas = canvasRef.current; - const container = containerRef.current; - if (!canvas || !container) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; + useEffect(() => { + if (!mounted) { + return; + } - const tick = () => { - const w = container.clientWidth; - const h = container.clientHeight; - frame.current++; + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) { + return; + } - /* Anchor: smooth-follow cursor or drift on mobile */ - if (isMobile.current) { - mobileAnchor.current.x += - (mobileTarget.current.x - mobileAnchor.current.x) * 0.008 * mobileSpeed; - mobileAnchor.current.y += - (mobileTarget.current.y - mobileAnchor.current.y) * 0.008 * mobileSpeed; + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } - const dx = mobileTarget.current.x - mobileAnchor.current.x; - const dy = mobileTarget.current.y - mobileAnchor.current.y; - if (Math.sqrt(dx * dx + dy * dy) < 30) { - mobileTarget.current = { - x: rand(w * 0.15, w * 0.85), - y: rand(h * 0.15, h * 0.85), - }; - } - smoothMouse.current.x = mobileAnchor.current.x; - smoothMouse.current.y = mobileAnchor.current.y; - } else { - smoothMouse.current.x += - (mousePos.current.x - smoothMouse.current.x) * followSpeed; - smoothMouse.current.y += - (mousePos.current.y - smoothMouse.current.y) * followSpeed; + const draw = () => { + const width = container.clientWidth; + const height = container.clientHeight; + frameRef.current += 1; + + if (isMobileRef.current) { + const mobileLerp = 0.008 * mobileSpeedRef.current; + mobileAnchorRef.current.x += + (mobileTargetRef.current.x - mobileAnchorRef.current.x) * mobileLerp; + mobileAnchorRef.current.y += + (mobileTargetRef.current.y - mobileAnchorRef.current.y) * mobileLerp; + + const dx = mobileTargetRef.current.x - mobileAnchorRef.current.x; + const dy = mobileTargetRef.current.y - mobileAnchorRef.current.y; + if (Math.hypot(dx, dy) < 30) { + mobileTargetRef.current = { + x: rand(width * 0.15, width * 0.85), + y: rand(height * 0.15, height * 0.85), + }; } - const cx = smoothMouse.current.x; - const cy = smoothMouse.current.y; + smoothMouseRef.current = { ...mobileAnchorRef.current }; + } else { + const desktopLerp = followSpeedRef.current; + smoothMouseRef.current.x += + (mousePosRef.current.x - smoothMouseRef.current.x) * desktopLerp; + smoothMouseRef.current.y += + (mousePosRef.current.y - smoothMouseRef.current.y) * desktopLerp; + } - /* Clear frame */ - ctx.clearRect(0, 0, w, h); + const centerX = smoothMouseRef.current.x; + const centerY = smoothMouseRef.current.y; + const colors = particleColorsRef.current; - /* Draw each particle */ - particles.current.forEach((p) => { - p.angle += p.speed; + ctx.clearRect(0, 0, width, height); - const wobble = - Math.sin(frame.current * p.wobbleSpeed + p.wobblePhase) * p.wobbleAmp; - const r = p.radius + wobble; + for (const particle of particlesRef.current) { + particle.angle += particle.speed; - const x = cx + Math.cos(p.angle) * r; - const y = cy + Math.sin(p.angle) * r; + const wobble = + Math.sin(frameRef.current * particle.wobbleSpeed + particle.wobblePhase) * + particle.wobbleAmp; + const radius = particle.radius + wobble; + const x = centerX + Math.cos(particle.angle) * radius; + const y = centerY + Math.sin(particle.angle) * radius; - /* Soft fade near viewport edges */ - const edgeFade = Math.max( - 0, - Math.min(x / 80, (w - x) / 80, y / 80, (h - y) / 80, 1), - ); - if (edgeFade <= 0) return; + const edgeFade = Math.max( + 0, + Math.min(x / 80, (width - x) / 80, y / 80, (height - y) / 80, 1), + ); - ctx.globalAlpha = edgeFade; - ctx.fillStyle = palette.particles[p.colorIndex]; - ctx.beginPath(); - ctx.arc(x, y, p.size, 0, Math.PI * 2); - ctx.fill(); - }); + if (edgeFade <= 0) { + continue; + } - ctx.globalAlpha = 1; - }; + ctx.globalAlpha = edgeFade; + ctx.fillStyle = colors[particle.colorIndex] ?? colors[0] ?? "#ffffff"; + ctx.beginPath(); + ctx.arc(x, y, particle.size, 0, Math.PI * 2); + ctx.fill(); + } - gsap.ticker.add(tick); - return () => { - gsap.ticker.remove(tick); - }; - }, - { - scope: containerRef, - dependencies: [mounted, isDark, followSpeed, mobileSpeed, orbitRadius, palette], - }, - ); + ctx.globalAlpha = 1; + animationFrameRef.current = window.requestAnimationFrame(draw); + }; + + animationFrameRef.current = window.requestAnimationFrame(draw); + + return () => { + if (animationFrameRef.current !== null) { + window.cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + }; + }, [mounted]); - /* ── Render ── */ return (
- {/* Grain texture */}
- Project I've Been Working on + Projects I've Been Working on
{projects.map((project, i) => (