feat(effects): add InkDataMorph component with three-phase animation engine
This commit is contained in:
@@ -0,0 +1,338 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
|
|
||||||
|
interface InkDataMorphProps {
|
||||||
|
particleCount?: number;
|
||||||
|
primaryColor?: string;
|
||||||
|
accentColor?: string;
|
||||||
|
connectionColor?: string;
|
||||||
|
connectionDistance?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
radius: number;
|
||||||
|
initialRadius: number;
|
||||||
|
dataRadius: number;
|
||||||
|
opacity: number;
|
||||||
|
isAccent: boolean;
|
||||||
|
phase: 'spreading' | 'settling' | 'morphing' | 'complete';
|
||||||
|
spreadTime: number;
|
||||||
|
maxSpreadTime: number;
|
||||||
|
settleTime: number;
|
||||||
|
morphProgress: number;
|
||||||
|
targetX: number;
|
||||||
|
targetY: number;
|
||||||
|
delay: number;
|
||||||
|
age: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
particleCount: 150,
|
||||||
|
primaryColor: '#1C1C1C',
|
||||||
|
accentColor: '#C41E3A',
|
||||||
|
connectionColor: '#C41E3A',
|
||||||
|
connectionDistance: 80,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function easeInOutCubic(t: number): number {
|
||||||
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(a: number, b: number, t: number): number {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex: string): string {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return `${r}, ${g}, ${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateParticle(p: Particle): void {
|
||||||
|
p.age++;
|
||||||
|
if (p.age < p.delay) {return;}
|
||||||
|
|
||||||
|
if (p.phase === 'spreading') {
|
||||||
|
p.x += p.vx;
|
||||||
|
p.y += p.vy;
|
||||||
|
p.vx *= 0.98;
|
||||||
|
p.vy *= 0.98;
|
||||||
|
p.spreadTime++;
|
||||||
|
if (p.spreadTime >= p.maxSpreadTime) {
|
||||||
|
p.phase = 'settling';
|
||||||
|
}
|
||||||
|
} else if (p.phase === 'settling') {
|
||||||
|
p.settleTime++;
|
||||||
|
p.opacity = Math.max(0.15, p.opacity - 0.003);
|
||||||
|
if (p.settleTime > 60) {
|
||||||
|
p.phase = 'morphing';
|
||||||
|
}
|
||||||
|
} else if (p.phase === 'morphing') {
|
||||||
|
p.morphProgress = Math.min(1, p.morphProgress + 0.008);
|
||||||
|
const t = easeInOutCubic(p.morphProgress);
|
||||||
|
p.x = lerp(p.x, p.targetX, t * 0.02);
|
||||||
|
p.y = lerp(p.y, p.targetY, t * 0.02);
|
||||||
|
p.radius = lerp(p.radius, p.dataRadius, t * 0.02);
|
||||||
|
p.opacity = lerp(p.opacity, 0.2, t * 0.01);
|
||||||
|
if (p.morphProgress >= 1) {
|
||||||
|
p.phase = 'complete';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawParticle(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
p: Particle,
|
||||||
|
primaryRgb: string,
|
||||||
|
accentRgb: string,
|
||||||
|
): void {
|
||||||
|
if (p.age < p.delay) {return;}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, Math.max(0.5, p.radius), 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = p.isAccent
|
||||||
|
? `rgba(${accentRgb}, ${p.opacity})`
|
||||||
|
: `rgba(${primaryRgb}, ${p.opacity})`;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
if (p.radius > 3 && p.phase === 'spreading') {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.radius * 2.5, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = p.isAccent
|
||||||
|
? `rgba(${accentRgb}, ${p.opacity * 0.1})`
|
||||||
|
: `rgba(${primaryRgb}, ${p.opacity * 0.08})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawConnections(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
particles: Particle[],
|
||||||
|
connectionDistance: number,
|
||||||
|
connectionRgb: string,
|
||||||
|
): void {
|
||||||
|
const morphed = particles.filter(
|
||||||
|
p => p.phase === 'morphing' && p.morphProgress > 0.3
|
||||||
|
);
|
||||||
|
ctx.save();
|
||||||
|
for (let i = 0; i < morphed.length; i++) {
|
||||||
|
const a = morphed[i];
|
||||||
|
if (!a) {continue;}
|
||||||
|
for (let j = i + 1; j < morphed.length; j++) {
|
||||||
|
const b = morphed[j];
|
||||||
|
if (!b) {continue;}
|
||||||
|
const dx = a.x - b.x;
|
||||||
|
const dy = a.y - b.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist < connectionDistance) {
|
||||||
|
const alpha =
|
||||||
|
(1 - dist / connectionDistance) *
|
||||||
|
0.08 *
|
||||||
|
Math.min(a.morphProgress, b.morphProgress);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(a.x, a.y);
|
||||||
|
ctx.lineTo(b.x, b.y);
|
||||||
|
ctx.strokeStyle = `rgba(${connectionRgb}, ${alpha})`;
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBgOrbs(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
W: number,
|
||||||
|
H: number,
|
||||||
|
accentRgb: string,
|
||||||
|
primaryRgb: string,
|
||||||
|
): void {
|
||||||
|
const orbs = [
|
||||||
|
{ x: W * 0.65, y: H * 0.3, r: 300, rgb: accentRgb, alpha: 0.03 },
|
||||||
|
{ x: W * 0.3, y: H * 0.7, r: 250, rgb: primaryRgb, alpha: 0.02 },
|
||||||
|
];
|
||||||
|
for (const o of orbs) {
|
||||||
|
const grad = ctx.createRadialGradient(o.x, o.y, 0, o.x, o.y, o.r);
|
||||||
|
grad.addColorStop(0, `rgba(${o.rgb}, ${o.alpha})`);
|
||||||
|
grad.addColorStop(1, 'transparent');
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.fillRect(o.x - o.r, o.y - o.r, o.r * 2, o.r * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createParticle(
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
W: number,
|
||||||
|
H: number,
|
||||||
|
delay: number,
|
||||||
|
): Particle {
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
const dist = Math.random() * 10;
|
||||||
|
const speed = 0.3 + Math.random() * 1.5;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: cx + Math.cos(angle) * dist,
|
||||||
|
y: cy + Math.sin(angle) * dist,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed,
|
||||||
|
radius: 1.5 + Math.random() * 4,
|
||||||
|
initialRadius: 1.5 + Math.random() * 4,
|
||||||
|
dataRadius: 1 + Math.random() * 2,
|
||||||
|
opacity: 0.4 + Math.random() * 0.4,
|
||||||
|
isAccent: Math.random() > 0.75,
|
||||||
|
phase: 'spreading',
|
||||||
|
spreadTime: 0,
|
||||||
|
maxSpreadTime: 60 + Math.random() * 80,
|
||||||
|
settleTime: 0,
|
||||||
|
morphProgress: 0,
|
||||||
|
targetX: W * (0.55 + Math.random() * 0.35),
|
||||||
|
targetY: H * (0.15 + Math.random() * 0.6),
|
||||||
|
delay,
|
||||||
|
age: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initParticlesArray(W: number, H: number, count: number): Particle[] {
|
||||||
|
const center1 = { x: W * 0.7, y: H * 0.35 };
|
||||||
|
const center2 = { x: W * 0.25, y: H * 0.65 };
|
||||||
|
const count1 = Math.floor(count * 0.67);
|
||||||
|
const count2 = count - count1;
|
||||||
|
|
||||||
|
const particles: Particle[] = [];
|
||||||
|
for (let i = 0; i < count1; i++) {
|
||||||
|
particles.push(createParticle(center1.x, center1.y, W, H, 0));
|
||||||
|
}
|
||||||
|
for (let i = 0; i < count2; i++) {
|
||||||
|
particles.push(createParticle(center2.x, center2.y, W, H, 30));
|
||||||
|
}
|
||||||
|
return particles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InkDataMorph({
|
||||||
|
particleCount = DEFAULTS.particleCount,
|
||||||
|
primaryColor = DEFAULTS.primaryColor,
|
||||||
|
accentColor = DEFAULTS.accentColor,
|
||||||
|
connectionColor = DEFAULTS.connectionColor,
|
||||||
|
connectionDistance = DEFAULTS.connectionDistance,
|
||||||
|
className = '',
|
||||||
|
}: InkDataMorphProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const particlesRef = useRef<Particle[]>([]);
|
||||||
|
const animationRef = useRef<number | undefined>(undefined);
|
||||||
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
const drawStaticFinal = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) {return;}
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {return;}
|
||||||
|
|
||||||
|
const W = canvas.width / 2;
|
||||||
|
const H = canvas.height / 2;
|
||||||
|
const primaryRgb = hexToRgb(primaryColor);
|
||||||
|
const accentRgb = hexToRgb(accentColor);
|
||||||
|
const connectionRgb = hexToRgb(connectionColor);
|
||||||
|
|
||||||
|
ctx.setTransform(2, 0, 0, 2, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
ctx.fillStyle = '#FAFAF5';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
drawBgOrbs(ctx, W, H, accentRgb, primaryRgb);
|
||||||
|
|
||||||
|
const finalParticles = particlesRef.current.map(p => ({
|
||||||
|
...p,
|
||||||
|
x: p.targetX,
|
||||||
|
y: p.targetY,
|
||||||
|
radius: p.dataRadius,
|
||||||
|
opacity: 0.2,
|
||||||
|
phase: 'complete' as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const p of finalParticles) {
|
||||||
|
drawParticle(ctx, p, primaryRgb, accentRgb);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawConnections(ctx, finalParticles, connectionDistance, connectionRgb);
|
||||||
|
}, [primaryColor, accentColor, connectionColor, connectionDistance]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) {return;}
|
||||||
|
|
||||||
|
const parent = canvas.parentElement;
|
||||||
|
if (!parent) {return;}
|
||||||
|
|
||||||
|
const W = parent.clientWidth;
|
||||||
|
const H = parent.clientHeight;
|
||||||
|
canvas.width = W * 2;
|
||||||
|
canvas.height = H * 2;
|
||||||
|
|
||||||
|
particlesRef.current = initParticlesArray(W, H, particleCount);
|
||||||
|
|
||||||
|
if (shouldReduceMotion) {
|
||||||
|
drawStaticFinal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryRgb = hexToRgb(primaryColor);
|
||||||
|
const accentRgb = hexToRgb(accentColor);
|
||||||
|
const connectionRgb = hexToRgb(connectionColor);
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {return;}
|
||||||
|
|
||||||
|
ctx.setTransform(2, 0, 0, 2, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
ctx.fillStyle = '#FAFAF5';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
drawBgOrbs(ctx, W, H, accentRgb, primaryRgb);
|
||||||
|
|
||||||
|
const particles = particlesRef.current;
|
||||||
|
let allComplete = true;
|
||||||
|
|
||||||
|
for (const p of particles) {
|
||||||
|
updateParticle(p);
|
||||||
|
drawParticle(ctx, p, primaryRgb, accentRgb);
|
||||||
|
if (p.phase !== 'complete') {allComplete = false;}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawConnections(ctx, particles, connectionDistance, connectionRgb);
|
||||||
|
|
||||||
|
if (allComplete) {
|
||||||
|
animationRef.current = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationRef.current !== undefined) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [shouldReduceMotion, particleCount, drawStaticFinal, primaryColor, accentColor, connectionColor, connectionDistance]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`absolute inset-0 w-full h-full pointer-events-none ${className}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user