diff --git a/src/components/effects/ink-data-morph.tsx b/src/components/effects/ink-data-morph.tsx new file mode 100644 index 0000000..debbcdd --- /dev/null +++ b/src/components/effects/ink-data-morph.tsx @@ -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(null); + const particlesRef = useRef([]); + const animationRef = useRef(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 ( +