Files
novalon-website/src/components/effects/particle-galaxy.tsx
T
张翔 fecbfd1990 feat: 添加预览效果页面并优化交互效果
refactor: 优化代码健壮性和类型安全

style: 更新字体样式和全局CSS

fix: 修复IntersectionObserver潜在空引用问题

chore: 更新依赖和ESLint配置

build: 更新构建ID和路由配置
2026-02-24 10:24:05 +08:00

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;