197 lines
5.9 KiB
TypeScript
197 lines
5.9 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef } from 'react';
|
|
|
|
interface Particle {
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
radius: number;
|
|
alpha: number;
|
|
type: 'lost' | 'converted' | 'regular';
|
|
pulseSpeed: number;
|
|
pulseTime: number;
|
|
}
|
|
|
|
export default function ProblemParticles() {
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
let animationFrameId = 0;
|
|
let cssWidth = canvas.offsetWidth;
|
|
let cssHeight = canvas.offsetHeight;
|
|
|
|
const particles: Particle[] = [];
|
|
const storeNode = { x: cssWidth / 2, y: cssHeight / 2, radius: 18 };
|
|
|
|
const setCanvasSize = () => {
|
|
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
|
cssWidth = canvas.offsetWidth;
|
|
cssHeight = canvas.offsetHeight;
|
|
canvas.width = Math.round(cssWidth * dpr);
|
|
canvas.height = Math.round(cssHeight * dpr);
|
|
// Map drawing coordinates to CSS pixels
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
storeNode.x = cssWidth / 2;
|
|
storeNode.y = cssHeight / 2;
|
|
};
|
|
|
|
const createParticle = (isInitial = false): Particle => {
|
|
const typeRand = Math.random();
|
|
const type = typeRand < 0.2 ? 'lost' : typeRand < 0.45 ? 'converted' : 'regular';
|
|
|
|
let x: number, y: number;
|
|
if (isInitial) {
|
|
x = Math.random() * cssWidth;
|
|
y = Math.random() * cssHeight;
|
|
} else {
|
|
// Spawn from edges
|
|
if (Math.random() < 0.5) {
|
|
x = Math.random() < 0.5 ? 0 : cssWidth;
|
|
y = Math.random() * cssHeight;
|
|
} else {
|
|
x = Math.random() * cssWidth;
|
|
y = Math.random() < 0.5 ? 0 : cssHeight;
|
|
}
|
|
}
|
|
|
|
// Vector pointing towards the store node (center)
|
|
const angle = Math.atan2(storeNode.y - y, storeNode.x - x);
|
|
const speed = 0.4 + Math.random() * 0.8;
|
|
|
|
return {
|
|
x,
|
|
y,
|
|
vx: Math.cos(angle) * speed + (Math.random() - 0.5) * 0.2,
|
|
vy: Math.sin(angle) * speed + (Math.random() - 0.5) * 0.2,
|
|
radius: 2 + Math.random() * 4,
|
|
alpha: 0.1 + Math.random() * 0.7,
|
|
type,
|
|
pulseSpeed: 0.05 + Math.random() * 0.05,
|
|
pulseTime: Math.random() * Math.PI,
|
|
};
|
|
};
|
|
|
|
// Initialize
|
|
setCanvasSize();
|
|
for (let i = 0; i < 45; i++) particles.push(createParticle(true));
|
|
|
|
const handleResize = () => {
|
|
if (!canvas) return;
|
|
setCanvasSize();
|
|
};
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
|
|
const draw = () => {
|
|
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
|
|
|
// Background glow
|
|
const bgGlow = ctx.createRadialGradient(
|
|
storeNode.x,
|
|
storeNode.y,
|
|
10,
|
|
storeNode.x,
|
|
storeNode.y,
|
|
cssWidth * 0.4
|
|
);
|
|
bgGlow.addColorStop(0, 'rgba(0, 102, 255, 0.08)');
|
|
bgGlow.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
|
ctx.fillStyle = bgGlow;
|
|
ctx.fillRect(0, 0, cssWidth, cssHeight);
|
|
|
|
// Store hub
|
|
ctx.shadowBlur = 30;
|
|
ctx.shadowColor = '#0066ff';
|
|
ctx.fillStyle = 'rgba(0, 102, 255, 0.9)';
|
|
ctx.beginPath();
|
|
ctx.arc(storeNode.x, storeNode.y, storeNode.radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.arc(storeNode.x, storeNode.y, storeNode.radius - 6, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
ctx.shadowBlur = 0;
|
|
|
|
// Particles
|
|
particles.forEach((p, idx) => {
|
|
p.x += p.vx;
|
|
p.y += p.vy;
|
|
p.pulseTime += p.pulseSpeed;
|
|
|
|
let alpha = p.alpha;
|
|
let radius = p.radius;
|
|
|
|
if (p.type === 'converted') {
|
|
alpha = Math.max(0.4, p.alpha + Math.sin(p.pulseTime) * 0.3);
|
|
radius = p.radius + Math.sin(p.pulseTime) * 1.5;
|
|
} else if (p.type === 'lost') {
|
|
const dist = Math.hypot(storeNode.x - p.x, storeNode.y - p.y);
|
|
if (dist < 150) p.alpha -= 0.008;
|
|
}
|
|
|
|
// Connections between particles
|
|
for (let j = idx + 1; j < particles.length; j++) {
|
|
const p2 = particles[j];
|
|
const dist = Math.hypot(p2.x - p.x, p2.y - p.y);
|
|
if (dist < 80 && p.alpha > 0 && p2.alpha > 0) {
|
|
const lineAlpha = (1 - dist / 80) * 0.18 * Math.min(alpha, p2.alpha);
|
|
ctx.strokeStyle = `rgba(0, 102, 255, ${lineAlpha})`;
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(p.x, p.y);
|
|
ctx.lineTo(p2.x, p2.y);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
// Connection to store hub
|
|
const distToStore = Math.hypot(storeNode.x - p.x, storeNode.y - p.y);
|
|
if (distToStore < 120 && p.alpha > 0) {
|
|
const lineAlpha = (1 - distToStore / 120) * 0.25 * p.alpha;
|
|
ctx.strokeStyle = p.type === 'converted' ? `rgba(0, 220, 255, ${lineAlpha})` : `rgba(0, 102, 255, ${lineAlpha})`;
|
|
ctx.beginPath();
|
|
ctx.moveTo(p.x, p.y);
|
|
ctx.lineTo(storeNode.x, storeNode.y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Respawn check
|
|
if (distToStore < storeNode.radius || p.x < 0 || p.x > cssWidth || p.y < 0 || p.y > cssHeight || p.alpha <= 0) {
|
|
particles[idx] = createParticle();
|
|
} else {
|
|
ctx.fillStyle = p.type === 'converted' ? `rgba(0, 220, 255, ${alpha})` : p.type === 'lost' ? `rgba(255, 68, 68, ${alpha})` : `rgba(255, 255, 255, ${alpha})`;
|
|
if (p.type === 'converted') {
|
|
ctx.shadowBlur = 10;
|
|
ctx.shadowColor = '#00dcff';
|
|
}
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
});
|
|
|
|
animationFrameId = requestAnimationFrame(draw);
|
|
};
|
|
|
|
draw();
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', handleResize);
|
|
cancelAnimationFrame(animationFrameId);
|
|
};
|
|
}, []);
|
|
|
|
return <canvas ref={canvasRef} className="w-full h-full absolute inset-0" />;
|
|
}
|