'use client'; import { useEffect, useRef, useCallback } from 'react'; import { useReducedMotion } from '@/hooks/use-reduced-motion'; interface InkDataMorphProps { className?: string; } interface TrailPoint { x: number; y: number; } interface Particle { x: number; y: number; vx: number; vy: number; radius: number; initialRadius: number; dataRadius: number; opacity: number; toneIndex: number; rotation: number; scaleX: number; scaleY: number; isSplash: boolean; phase: 'spreading' | 'settling' | 'morphing' | 'complete' | 'fading'; spreadTime: number; maxSpreadTime: number; settleTime: number; morphProgress: number; targetX: number; targetY: number; delay: number; age: number; trail: TrailPoint[]; seed1: number; seed2: number; wobbleFactor: number; prevVx: number; prevVy: number; inkLayerCount: number; } interface ResponsiveConfig { particleCount: number; centers: { x: number; y: number }[]; centerDistribution: number[]; targetXRange: [number, number]; targetYRange: [number, number]; connectionDistance: number; glowScale: number; gradientLayers: number; maxTrail: number; splashRatio: number; bgOrbScale: number; inkLayers: number; inkRadiusScale: number; inkStringThreshold: number; showFeibai: boolean; paperTextureSize: number; wobbleDetail: number; } const TONES = [ { hex: '#1C1C1C', rgb: '28, 28, 28', weight: 0.15 }, { hex: '#4A4A4A', rgb: '74, 74, 74', weight: 0.25 }, { hex: '#8C8C8C', rgb: '140, 140, 140', weight: 0.30 }, { hex: '#C41E3A', rgb: '196, 30, 58', weight: 0.10 }, { hex: '#E8707A', rgb: '232, 112, 122', weight: 0.20 }, ] as const; const ACCENT_TONES = [3, 4]; function pickToneIndex(isSplash: boolean): number { if (isSplash) { return Math.random() < 0.4 ? 0 : 1; } const r = Math.random(); let cumulative = 0; for (let i = 0; i < TONES.length; i++) { const tone = TONES[i]; if (!tone) { continue; } cumulative += tone.weight; if (r < cumulative) { return i; } } return 2; } function getResponsiveConfig(W: number): ResponsiveConfig { if (W < 768) { return { particleCount: 70, centers: [{ x: 0.65, y: 0.3 }], centerDistribution: [1], targetXRange: [0.55, 0.9], targetYRange: [0.1, 0.5], connectionDistance: 60, glowScale: 0.5, gradientLayers: 2, maxTrail: 2, splashRatio: 0, bgOrbScale: 0.45, inkLayers: 3, inkRadiusScale: 1.2, inkStringThreshold: 0.35, showFeibai: false, paperTextureSize: 128, wobbleDetail: 16, }; } if (W < 1024) { return { particleCount: 120, centers: [ { x: 0.68, y: 0.3 }, { x: 0.3, y: 0.6 }, ], centerDistribution: [0.65, 0.35], targetXRange: [0.5, 0.9], targetYRange: [0.12, 0.6], connectionDistance: 80, glowScale: 0.75, gradientLayers: 2, maxTrail: 4, splashRatio: 0.05, bgOrbScale: 0.7, inkLayers: 3, inkRadiusScale: 1.1, inkStringThreshold: 0.4, showFeibai: true, paperTextureSize: 128, wobbleDetail: 20, }; } return { particleCount: 180, centers: [ { x: 0.72, y: 0.3 }, { x: 0.25, y: 0.65 }, ], centerDistribution: [0.67, 0.33], targetXRange: [0.5, 0.9], targetYRange: [0.12, 0.65], connectionDistance: 100, glowScale: 1.0, gradientLayers: 3, maxTrail: 6, splashRatio: 0.1, bgOrbScale: 1.0, inkLayers: 4, inkRadiusScale: 1.0, inkStringThreshold: 0.5, showFeibai: true, paperTextureSize: 256, wobbleDetail: 24, }; } function easeInOutCubic(t: number): number { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; } function easeOutCubic(t: number): number { return 1 - Math.pow(1 - t, 3); } function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; } function updateParticle(p: Particle, maxTrail: number): void { p.age++; if (p.age < p.delay) { return; } if (p.phase === 'spreading') { if (maxTrail > 0) { p.trail.push({ x: p.x, y: p.y }); if (p.trail.length > maxTrail) { p.trail.shift(); } } p.x += p.vx; p.y += p.vy; p.vx *= 0.98; p.vy *= 0.98; p.spreadTime++; const spreadRatio = p.spreadTime / p.maxSpreadTime; if (spreadRatio < 0.3) { p.radius = p.initialRadius * (1 + spreadRatio * 2); } else { p.radius = p.initialRadius * lerp(1.6, 1, easeOutCubic((spreadRatio - 0.3) / 0.7)); } if (p.spreadTime >= p.maxSpreadTime) { if (p.isSplash) { p.phase = 'fading'; } else { p.phase = 'settling'; } } } else if (p.phase === 'settling') { p.settleTime++; p.opacity = Math.max(0.2, p.opacity - 0.002); p.x += (Math.random() - 0.5) * 0.15; p.y += (Math.random() - 0.5) * 0.15; if (p.settleTime > 60) { p.phase = 'morphing'; } } else if (p.phase === 'morphing') { p.morphProgress = Math.min(1, p.morphProgress + 0.006); const t = easeInOutCubic(p.morphProgress); p.x = lerp(p.x, p.targetX, t * 0.025); p.y = lerp(p.y, p.targetY, t * 0.025); p.radius = lerp(p.radius, p.dataRadius, t * 0.02); p.opacity = lerp(p.opacity, 0.35, t * 0.008); if (p.morphProgress >= 1) { p.phase = 'complete'; } } else if (p.phase === 'complete') { p.opacity = lerp(p.opacity, 0.35, 0.01); } else if (p.phase === 'fading') { p.opacity -= 0.01; p.radius *= 0.98; if (p.opacity <= 0) { p.opacity = 0; p.phase = 'complete'; } } } function drawTrail( ctx: CanvasRenderingContext2D, p: Particle, rgb: string, ): void { if (p.trail.length < 2) { return; } ctx.save(); ctx.lineCap = 'round'; for (let i = 1; i < p.trail.length; i++) { const prev = p.trail[i - 1]; const curr = p.trail[i]; if (!prev || !curr) { continue; } const trailAlpha = (i / p.trail.length) * p.opacity * 0.3; const trailWidth = (i / p.trail.length) * p.radius * 0.8; ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(curr.x, curr.y); ctx.strokeStyle = `rgba(${rgb}, ${trailAlpha})`; ctx.lineWidth = Math.max(0.3, trailWidth); ctx.stroke(); } ctx.restore(); } function drawInkDot( ctx: CanvasRenderingContext2D, x: number, y: number, r: number, rotation: number, scaleX: number, scaleY: number, rgb: string, opacity: number, ): void { ctx.save(); ctx.translate(x, y); ctx.rotate(rotation); ctx.beginPath(); ctx.ellipse(0, 0, Math.max(0.5, r * scaleX), Math.max(0.5, r * scaleY), 0, 0, Math.PI * 2); ctx.fillStyle = `rgba(${rgb}, ${opacity})`; ctx.fill(); ctx.restore(); } function drawParticle( ctx: CanvasRenderingContext2D, p: Particle, time: number, config: ResponsiveConfig, ): void { if (p.age < p.delay || p.opacity <= 0) { return; } const tone = TONES[p.toneIndex]; if (!tone) { return; } const rgb = tone.rgb; const r = Math.max(0.5, p.radius); const gs = config.glowScale; if (p.phase === 'spreading') { drawTrail(ctx, p, rgb); if (config.gradientLayers >= 3) { const grad3 = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 5 * gs); grad3.addColorStop(0, `rgba(${rgb}, ${p.opacity * 0.06})`); grad3.addColorStop(0.5, `rgba(${rgb}, ${p.opacity * 0.02})`); grad3.addColorStop(1, 'transparent'); ctx.fillStyle = grad3; ctx.fillRect(p.x - r * 5 * gs, p.y - r * 5 * gs, r * 10 * gs, r * 10 * gs); } const grad2R = r * 2.8 * gs; const grad2 = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, grad2R); grad2.addColorStop(0, `rgba(${rgb}, ${p.opacity * 0.2})`); grad2.addColorStop(0.6, `rgba(${rgb}, ${p.opacity * 0.06})`); grad2.addColorStop(1, 'transparent'); ctx.fillStyle = grad2; ctx.fillRect(p.x - grad2R, p.y - grad2R, grad2R * 2, grad2R * 2); const grad1 = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r); grad1.addColorStop(0, `rgba(${rgb}, ${p.opacity * 0.9})`); grad1.addColorStop(0.7, `rgba(${rgb}, ${p.opacity * 0.4})`); grad1.addColorStop(1, 'transparent'); ctx.fillStyle = grad1; ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fill(); drawInkDot(ctx, p.x, p.y, r * 0.6, p.rotation, p.scaleX, p.scaleY, rgb, p.opacity * 0.7); } else if (p.phase === 'settling') { const grad2R = r * 2.2 * gs; const grad2 = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, grad2R); grad2.addColorStop(0, `rgba(${rgb}, ${p.opacity * 0.15})`); grad2.addColorStop(0.5, `rgba(${rgb}, ${p.opacity * 0.05})`); grad2.addColorStop(1, 'transparent'); ctx.fillStyle = grad2; ctx.fillRect(p.x - grad2R, p.y - grad2R, grad2R * 2, grad2R * 2); const grad1 = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r); grad1.addColorStop(0, `rgba(${rgb}, ${p.opacity * 0.85})`); grad1.addColorStop(0.6, `rgba(${rgb}, ${p.opacity * 0.3})`); grad1.addColorStop(1, 'transparent'); ctx.fillStyle = grad1; ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fill(); drawInkDot(ctx, p.x, p.y, r * 0.5, p.rotation, p.scaleX, p.scaleY, rgb, p.opacity * 0.5); } else if (p.phase === 'fading') { const grad1 = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 1.5); grad1.addColorStop(0, `rgba(${rgb}, ${p.opacity * 0.6})`); grad1.addColorStop(1, 'transparent'); ctx.fillStyle = grad1; ctx.beginPath(); ctx.arc(p.x, p.y, r * 1.5, 0, Math.PI * 2); ctx.fill(); } else { const pulse = 1 + Math.sin(time * 0.002 + p.targetX * 0.01) * 0.15; const glowR = r * 3 * pulse * gs; const grad2 = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowR); grad2.addColorStop(0, `rgba(${rgb}, ${p.opacity * 0.12})`); grad2.addColorStop(0.4, `rgba(${rgb}, ${p.opacity * 0.04})`); grad2.addColorStop(1, 'transparent'); ctx.fillStyle = grad2; ctx.fillRect(p.x - glowR, p.y - glowR, glowR * 2, glowR * 2); drawInkDot(ctx, p.x, p.y, r, p.rotation, p.scaleX, p.scaleY, rgb, p.opacity); } } function drawConnections( ctx: CanvasRenderingContext2D, particles: Particle[], connectionDistance: number, ): void { const morphed = particles.filter( p => p.phase === 'morphing' && p.morphProgress > 0.3 ); ctx.save(); ctx.lineCap = 'round'; for (let i = 0; i < morphed.length; i++) { const a = morphed[i]; if (!a) { continue; } const toneA = TONES[a.toneIndex]; if (!toneA) { continue; } for (let j = i + 1; j < morphed.length; j++) { const b = morphed[j]; if (!b) { continue; } const toneB = TONES[b.toneIndex]; if (!toneB) { continue; } const dx = a.x - b.x; const dy = a.y - b.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < connectionDistance && dist > 0) { const progress = Math.min(a.morphProgress, b.morphProgress); const distRatio = 1 - dist / connectionDistance; const alpha = distRatio * 0.12 * progress; const lineWidth = 0.3 + distRatio * 0.7; const useAccent = ACCENT_TONES.includes(a.toneIndex) || ACCENT_TONES.includes(b.toneIndex); const connRgb = useAccent ? TONES[3].rgb : TONES[0].rgb; const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2; const offset = dist * 0.1; const cpx = mx + (dy / dist) * offset; const cpy = my - (dx / dist) * offset; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.quadraticCurveTo(cpx, cpy, b.x, b.y); ctx.strokeStyle = `rgba(${connRgb}, ${alpha})`; ctx.lineWidth = lineWidth; ctx.stroke(); } } } ctx.restore(); } function drawBgOrbs( ctx: CanvasRenderingContext2D, W: number, H: number, orbScale: number, ): void { const orbs = [ { x: W * 0.72, y: H * 0.28, r: 350 * orbScale, rgb: TONES[3].rgb, alpha: 0.04 }, { x: W * 0.28, y: H * 0.68, r: 280 * orbScale, rgb: TONES[0].rgb, alpha: 0.03 }, { x: W * 0.5, y: H * 0.45, r: 400 * orbScale, rgb: TONES[4].rgb, alpha: 0.015 }, { x: W * 0.85, y: H * 0.7, r: 200 * orbScale, rgb: TONES[1].rgb, 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(0.6, `rgba(${o.rgb}, ${o.alpha * 0.3})`); 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, config: ResponsiveConfig, ): Particle { const angle = Math.random() * Math.PI * 2; const dist = Math.random() * 8; const isSplash = Math.random() < config.splashRatio; const speed = isSplash ? 1.5 + Math.random() * 3 : 0.4 + Math.random() * 1.8; const toneIndex = pickToneIndex(isSplash); const isHeavy = toneIndex === 0 || toneIndex === 3; const initialRadius = isSplash ? 0.5 + Math.random() * 1.5 : 2 + Math.random() * 5; const [txMin, txMax] = config.targetXRange; const [tyMin, tyMax] = config.targetYRange; return { x: cx + Math.cos(angle) * dist, y: cy + Math.sin(angle) * dist, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, radius: initialRadius, initialRadius, dataRadius: isSplash ? 0 : 1.2 + Math.random() * 2.5, opacity: isSplash ? 0.6 + Math.random() * 0.3 : 0.5 + Math.random() * 0.4, toneIndex, rotation: Math.random() * Math.PI * 2, scaleX: isHeavy ? 0.8 + Math.random() * 0.2 : 0.5 + Math.random() * 0.4, scaleY: isHeavy ? 0.8 + Math.random() * 0.2 : 0.5 + Math.random() * 0.4, isSplash, phase: 'spreading', spreadTime: 0, maxSpreadTime: isSplash ? 30 + Math.random() * 40 : 70 + Math.random() * 90, settleTime: 0, morphProgress: 0, targetX: W * (txMin + Math.random() * (txMax - txMin)), targetY: H * (tyMin + Math.random() * (tyMax - tyMin)), delay, age: 0, trail: [], seed1: Math.random() * Math.PI * 2, seed2: Math.random() * Math.PI * 2, wobbleFactor: 0.3, prevVx: 0, prevVy: 0, inkLayerCount: config.inkLayers, }; } function initParticlesArray(W: number, H: number, config: ResponsiveConfig): Particle[] { const particles: Particle[] = []; let remaining = config.particleCount; for (let c = 0; c < config.centers.length; c++) { const centerDef = config.centers[c]; if (!centerDef) { continue; } const cx = W * centerDef.x; const cy = H * centerDef.y; const dist = config.centerDistribution[c] ?? 0; const count = c === config.centers.length - 1 ? remaining : Math.floor(config.particleCount * dist); remaining -= count; const baseDelay = c * 25; for (let i = 0; i < count; i++) { particles.push( createParticle(cx, cy, W, H, baseDelay + Math.random() * 20, config) ); } } return particles; } export function InkDataMorph({ className = '', }: InkDataMorphProps) { const canvasRef = useRef(null); const particlesRef = useRef([]); const animationRef = useRef(undefined); const startTimeRef = useRef(0); const configRef = useRef(null); const shouldReduceMotion = useReducedMotion(); const drawStaticFinal = useCallback(() => { const canvas = canvasRef.current; if (!canvas) { return; } const ctx = canvas.getContext('2d'); if (!ctx) { return; } const config = configRef.current; if (!config) { return; } const dpr = canvas.width / (canvas.clientWidth || 1); const W = canvas.width / dpr; const H = canvas.height / dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#FAFAF5'; ctx.fillRect(0, 0, W, H); drawBgOrbs(ctx, W, H, config.bgOrbScale); const finalParticles = particlesRef.current .filter(p => !p.isSplash) .map(p => ({ ...p, x: p.targetX, y: p.targetY, radius: p.dataRadius, opacity: 0.35, phase: 'complete' as const, trail: [], })); for (const p of finalParticles) { drawParticle(ctx, p, 0, config); } drawConnections(ctx, finalParticles, config.connectionDistance); }, []); useEffect(() => { const canvas = canvasRef.current; if (!canvas) { return; } const parent = canvas.parentElement; if (!parent) { return; } const initCanvas = () => { if (animationRef.current !== undefined) { cancelAnimationFrame(animationRef.current); animationRef.current = undefined; } const W = parent.clientWidth; const H = parent.clientHeight; const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = W * dpr; canvas.height = H * dpr; const config = getResponsiveConfig(W); configRef.current = config; particlesRef.current = initParticlesArray(W, H, config); if (shouldReduceMotion) { drawStaticFinal(); return; } startTimeRef.current = performance.now(); const animate = () => { const ctx = canvas.getContext('2d'); if (!ctx) { return; } const time = performance.now() - startTimeRef.current; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#FAFAF5'; ctx.fillRect(0, 0, W, H); drawBgOrbs(ctx, W, H, config.bgOrbScale); const particles = particlesRef.current; let allComplete = true; for (const p of particles) { updateParticle(p, config.maxTrail); drawParticle(ctx, p, time, config); if (p.phase !== 'complete') { allComplete = false; } } drawConnections(ctx, particles, config.connectionDistance); if (allComplete) { animationRef.current = undefined; return; } animationRef.current = requestAnimationFrame(animate); }; animationRef.current = requestAnimationFrame(animate); }; initCanvas(); let resizeTimer: ReturnType | undefined; const handleResize = () => { if (resizeTimer !== undefined) { clearTimeout(resizeTimer); } resizeTimer = setTimeout(initCanvas, 300); }; window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); if (resizeTimer !== undefined) { clearTimeout(resizeTimer); } if (animationRef.current !== undefined) { cancelAnimationFrame(animationRef.current); } }; }, [shouldReduceMotion, drawStaticFinal]); return (