fecbfd1990
refactor: 优化代码健壮性和类型安全 style: 更新字体样式和全局CSS fix: 修复IntersectionObserver潜在空引用问题 chore: 更新依赖和ESLint配置 build: 更新构建ID和路由配置
229 lines
6.0 KiB
TypeScript
229 lines
6.0 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
import { motion, useMotionValue } from 'framer-motion';
|
|
|
|
interface Particle {
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
size: number;
|
|
opacity: number;
|
|
}
|
|
|
|
interface ParticleGalaxyProps {
|
|
particleCount?: number;
|
|
connectionDistance?: number;
|
|
mouseRadius?: number;
|
|
particleColor?: string;
|
|
lineColor?: string;
|
|
className?: string;
|
|
}
|
|
|
|
export function ParticleGalaxy({
|
|
particleCount = 100,
|
|
connectionDistance = 150,
|
|
mouseRadius = 150,
|
|
particleColor = '196, 30, 58',
|
|
lineColor = '196, 30, 58',
|
|
className = ''
|
|
}: ParticleGalaxyProps) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const particlesRef = useRef<Particle[]>([]);
|
|
const animationRef = useRef<number | undefined>(undefined);
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
|
|
const mouseX = useMotionValue(0);
|
|
const mouseY = useMotionValue(0);
|
|
const mouseInCanvas = useMotionValue(false);
|
|
|
|
const createParticle = useCallback((width: number, height: number): Particle => {
|
|
return {
|
|
x: Math.random() * width,
|
|
y: Math.random() * height,
|
|
vx: (Math.random() - 0.5) * 0.8,
|
|
vy: (Math.random() - 0.5) * 0.8,
|
|
size: Math.random() * 2 + 1,
|
|
opacity: Math.random() * 0.5 + 0.2
|
|
};
|
|
}, []);
|
|
|
|
const initParticles = useCallback((width: number, height: number) => {
|
|
particlesRef.current = Array.from({ length: particleCount }, () =>
|
|
createParticle(width, height)
|
|
);
|
|
}, [particleCount, createParticle]);
|
|
|
|
const drawParticles = useCallback((ctx: CanvasRenderingContext2D, width: number, height: number) => {
|
|
const mx = mouseX.get();
|
|
const my = mouseY.get();
|
|
const inCanvas = mouseInCanvas.get();
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
const particles = particlesRef.current;
|
|
|
|
particles.forEach((particle, i) => {
|
|
let { x, y, vx, vy, size, opacity } = particle;
|
|
|
|
if (inCanvas) {
|
|
const dx = x - mx;
|
|
const dy = y - my;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (distance < mouseRadius) {
|
|
const force = (mouseRadius - distance) / mouseRadius;
|
|
const angle = Math.atan2(dy, dx);
|
|
vx += Math.cos(angle) * force * 0.5;
|
|
vy += Math.sin(angle) * force * 0.5;
|
|
}
|
|
}
|
|
|
|
x += vx;
|
|
y += vy;
|
|
|
|
if (x < 0 || x > width) {vx *= -1;}
|
|
if (y < 0 || y > height) {vy *= -1;}
|
|
|
|
x = Math.max(0, Math.min(width, x));
|
|
y = Math.max(0, Math.min(height, y));
|
|
|
|
vx *= 0.99;
|
|
vy *= 0.99;
|
|
|
|
const speed = Math.sqrt(vx * vx + vy * vy);
|
|
if (speed < 0.1) {
|
|
vx += (Math.random() - 0.5) * 0.1;
|
|
vy += (Math.random() - 0.5) * 0.1;
|
|
}
|
|
|
|
particlesRef.current[i] = { x, y, vx, vy, size, opacity };
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, size, 0, Math.PI * 2);
|
|
ctx.fillStyle = `rgba(${particleColor}, ${opacity})`;
|
|
ctx.fill();
|
|
});
|
|
|
|
for (let i = 0; i < particles.length; i++) {
|
|
for (let j = i + 1; j < particles.length; j++) {
|
|
const p1 = particles[i];
|
|
const p2 = particles[j];
|
|
if (!p1 || !p2) {continue;}
|
|
|
|
const dx = p1.x - p2.x;
|
|
const dy = p1.y - p2.y;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (distance < connectionDistance) {
|
|
const opacity = (1 - distance / connectionDistance) * 0.3;
|
|
ctx.beginPath();
|
|
ctx.moveTo(p1.x, p1.y);
|
|
ctx.lineTo(p2.x, p2.y);
|
|
ctx.strokeStyle = `rgba(${lineColor}, ${opacity})`;
|
|
ctx.lineWidth = 0.5;
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
}
|
|
}, [mouseX, mouseY, mouseInCanvas, mouseRadius, particleColor, lineColor, connectionDistance]);
|
|
|
|
const animate = useCallback(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) {return;}
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) {return;}
|
|
|
|
drawParticles(ctx, canvas.width, canvas.height);
|
|
animationRef.current = requestAnimationFrame(animate);
|
|
}, [drawParticles]);
|
|
|
|
const handleResize = useCallback(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) {return;}
|
|
|
|
const container = canvas.parentElement;
|
|
if (!container) {return;}
|
|
|
|
canvas.width = container.clientWidth;
|
|
canvas.height = container.clientHeight;
|
|
|
|
initParticles(canvas.width, canvas.height);
|
|
}, [initParticles]);
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) {return;}
|
|
|
|
const container = canvas.parentElement;
|
|
if (!container) {return;}
|
|
|
|
canvas.width = container.clientWidth;
|
|
canvas.height = container.clientHeight;
|
|
|
|
initParticles(canvas.width, canvas.height);
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
setIsVisible(entry.isIntersecting);
|
|
});
|
|
},
|
|
{ threshold: 0.1 }
|
|
);
|
|
|
|
observer.observe(canvas);
|
|
|
|
if (isVisible) {
|
|
animate();
|
|
}
|
|
|
|
const handleResizeWithDebounce = () => {
|
|
setTimeout(handleResize, 250);
|
|
};
|
|
|
|
window.addEventListener('resize', handleResizeWithDebounce);
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
window.removeEventListener('resize', handleResizeWithDebounce);
|
|
if (animationRef.current) {
|
|
cancelAnimationFrame(animationRef.current);
|
|
}
|
|
};
|
|
}, [isVisible, animate, initParticles, handleResize]);
|
|
|
|
useEffect(() => {
|
|
if (isVisible) {
|
|
animate();
|
|
} else if (animationRef.current) {
|
|
cancelAnimationFrame(animationRef.current);
|
|
}
|
|
}, [isVisible, animate]);
|
|
|
|
return (
|
|
<motion.div
|
|
className={`absolute inset-0 pointer-events-none ${className}`}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: isVisible ? 1 : 0 }}
|
|
transition={{ duration: 0.5 }}
|
|
>
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="w-full h-full"
|
|
onMouseMove={(e) => {
|
|
mouseX.set(e.clientX);
|
|
mouseY.set(e.clientY);
|
|
mouseInCanvas.set(true);
|
|
}}
|
|
onMouseLeave={() => {
|
|
mouseInCanvas.set(false);
|
|
}}
|
|
/>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
export default ParticleGalaxy; |