122 lines
3.7 KiB
React
122 lines
3.7 KiB
React
import React, { useRef, useEffect } from 'react'
|
|
import './BackgroundPattern.css'
|
|
|
|
/**
|
|
* Full-page particle background — subtle floating dots that react to mouse.
|
|
* Replaces the old SVG pattern drift animation.
|
|
*/
|
|
export default function BackgroundPattern() {
|
|
const canvasRef = useRef(null)
|
|
const animRef = useRef(null)
|
|
const mouseRef = useRef({ x: -1000, y: -1000 })
|
|
const particlesRef = useRef([])
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
const ctx = canvas.getContext('2d')
|
|
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent-color').trim() || '#4a9eff'
|
|
|
|
function resize() {
|
|
const dpr = window.devicePixelRatio || 1
|
|
canvas.width = window.innerWidth * dpr
|
|
canvas.height = window.innerHeight * dpr
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
canvas.style.width = window.innerWidth + 'px'
|
|
canvas.style.height = window.innerHeight + 'px'
|
|
initParticles()
|
|
}
|
|
|
|
function initParticles() {
|
|
const w = window.innerWidth
|
|
const h = window.innerHeight
|
|
const count = Math.floor((w * h) / 4000) // ~500 on 1920x1080
|
|
particlesRef.current = Array.from({ length: count }, () => ({
|
|
x: Math.random() * w,
|
|
y: Math.random() * h,
|
|
baseX: 0,
|
|
baseY: 0,
|
|
vx: 0,
|
|
vy: 0,
|
|
radius: 0.8 + Math.random() * 1.2,
|
|
drift: Math.random() * Math.PI * 2,
|
|
driftSpeed: 0.002 + Math.random() * 0.003,
|
|
}))
|
|
particlesRef.current.forEach(p => {
|
|
p.baseX = p.x
|
|
p.baseY = p.y
|
|
})
|
|
}
|
|
|
|
function handleMouseMove(e) {
|
|
mouseRef.current.x = e.clientX
|
|
mouseRef.current.y = e.clientY
|
|
}
|
|
|
|
resize()
|
|
window.addEventListener('resize', resize)
|
|
window.addEventListener('mousemove', handleMouseMove)
|
|
|
|
function animate() {
|
|
const w = window.innerWidth
|
|
const h = window.innerHeight
|
|
ctx.clearRect(0, 0, w, h)
|
|
|
|
const mouse = mouseRef.current
|
|
const repelRadius = 80
|
|
|
|
particlesRef.current.forEach(p => {
|
|
// Subtle autonomous drift
|
|
p.drift += p.driftSpeed
|
|
const driftX = Math.sin(p.drift) * 0.3
|
|
const driftY = Math.cos(p.drift * 0.7) * 0.3
|
|
|
|
// Mouse repulsion
|
|
const dx = p.x - mouse.x
|
|
const dy = p.y - mouse.y
|
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
|
|
if (dist < repelRadius && dist > 0) {
|
|
const force = (repelRadius - dist) / repelRadius
|
|
p.vx += (dx / dist) * force * 1.5
|
|
p.vy += (dy / dist) * force * 1.5
|
|
}
|
|
|
|
// Spring back
|
|
p.vx += (p.baseX - p.x) * 0.02 + driftX * 0.1
|
|
p.vy += (p.baseY - p.y) * 0.02 + driftY * 0.1
|
|
p.vx *= 0.92
|
|
p.vy *= 0.92
|
|
p.x += p.vx
|
|
p.y += p.vy
|
|
|
|
ctx.beginPath()
|
|
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2)
|
|
ctx.fillStyle = accentColor
|
|
ctx.globalAlpha = 0.25
|
|
ctx.fill()
|
|
ctx.globalAlpha = 1
|
|
})
|
|
|
|
if (!reducedMotion && !document.hidden) animRef.current = requestAnimationFrame(animate)
|
|
}
|
|
|
|
function handleVisibilityChange() {
|
|
if (document.hidden) cancelAnimationFrame(animRef.current)
|
|
else if (!reducedMotion) animate()
|
|
}
|
|
|
|
animate()
|
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
return () => {
|
|
cancelAnimationFrame(animRef.current)
|
|
window.removeEventListener('resize', resize)
|
|
window.removeEventListener('mousemove', handleMouseMove)
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
}
|
|
}, [])
|
|
|
|
return <canvas ref={canvasRef} className="bg-particle-canvas" />
|
|
}
|