From 119ce83b56b650d72d31a2b2a8ec7c85b03fb4d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E7=BF=94?= Date: Sun, 3 May 2026 19:37:14 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=89=A9=E5=B1=95=20Particle=20?= =?UTF-8?q?=E5=92=8C=20ResponsiveConfig=20=E6=95=B0=E6=8D=AE=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=A2=A8=E9=9F=B5=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/effects/ink-data-morph.tsx | 655 +++++++++++++++++----- 1 file changed, 511 insertions(+), 144 deletions(-) diff --git a/src/components/effects/ink-data-morph.tsx b/src/components/effects/ink-data-morph.tsx index debbcdd..2679c80 100644 --- a/src/components/effects/ink-data-morph.tsx +++ b/src/components/effects/ink-data-morph.tsx @@ -4,14 +4,14 @@ 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 TrailPoint { + x: number; + y: number; +} + interface Particle { x: number; y: number; @@ -21,8 +21,12 @@ interface Particle { initialRadius: number; dataRadius: number; opacity: number; - isAccent: boolean; - phase: 'spreading' | 'settling' | 'morphing' | 'complete'; + toneIndex: number; + rotation: number; + scaleX: number; + scaleY: number; + isSplash: boolean; + phase: 'spreading' | 'settling' | 'morphing' | 'complete' | 'fading'; spreadTime: number; maxSpreadTime: number; settleTime: number; @@ -31,85 +35,347 @@ interface Particle { targetY: number; delay: number; age: number; + trail: TrailPoint[]; + seed1: number; + seed2: number; + wobbleFactor: number; + prevVx: number; + prevVy: number; + inkLayerCount: number; } -const DEFAULTS = { - particleCount: 150, - primaryColor: '#1C1C1C', - accentColor: '#C41E3A', - connectionColor: '#C41E3A', - connectionDistance: 80, -} as const; +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 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 { +function updateParticle(p: Particle, maxTrail: number): void { p.age++; - if (p.age < p.delay) {return;} + 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) { - p.phase = 'settling'; + if (p.isSplash) { + p.phase = 'fading'; + } else { + p.phase = 'settling'; + } } } else if (p.phase === 'settling') { p.settleTime++; - p.opacity = Math.max(0.15, p.opacity - 0.003); + 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.008); + p.morphProgress = Math.min(1, p.morphProgress + 0.006); 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.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.2, t * 0.01); + 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, - primaryRgb: string, - accentRgb: string, + time: number, + config: ResponsiveConfig, ): void { - if (p.age < p.delay) {return;} + if (p.age < p.delay || p.opacity <= 0) { + 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(); + 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.radius > 3 && p.phase === 'spreading') { + 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, 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.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); } } @@ -117,31 +383,53 @@ 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(); + ctx.lineCap = 'round'; for (let i = 0; i < morphed.length; i++) { const a = morphed[i]; - if (!a) {continue;} + 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;} + 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) { - const alpha = - (1 - dist / connectionDistance) * - 0.08 * - Math.min(a.morphProgress, b.morphProgress); + 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.lineTo(b.x, b.y); - ctx.strokeStyle = `rgba(${connectionRgb}, ${alpha})`; - ctx.lineWidth = 0.5; + ctx.quadraticCurveTo(cpx, cpy, b.x, b.y); + ctx.strokeStyle = `rgba(${connRgb}, ${alpha})`; + ctx.lineWidth = lineWidth; ctx.stroke(); } } @@ -153,16 +441,18 @@ function drawBgOrbs( ctx: CanvasRenderingContext2D, W: number, H: number, - accentRgb: string, - primaryRgb: string, + orbScale: number, ): 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 }, + { 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); @@ -175,158 +465,235 @@ function createParticle( W: number, H: number, delay: number, + config: ResponsiveConfig, ): Particle { const angle = Math.random() * Math.PI * 2; - const dist = Math.random() * 10; - const speed = 0.3 + Math.random() * 1.5; + 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: 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, + 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: 60 + Math.random() * 80, + maxSpreadTime: isSplash ? 30 + Math.random() * 40 : 70 + Math.random() * 90, settleTime: 0, morphProgress: 0, - targetX: W * (0.55 + Math.random() * 0.35), - targetY: H * (0.15 + Math.random() * 0.6), + 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, 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; - +function initParticlesArray(W: number, H: number, config: ResponsiveConfig): Particle[] { 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)); + 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({ - 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 startTimeRef = useRef(0); + const configRef = useRef(null); const shouldReduceMotion = useReducedMotion(); const drawStaticFinal = useCallback(() => { const canvas = canvasRef.current; - if (!canvas) {return;} + if (!canvas) { + return; + } const ctx = canvas.getContext('2d'); - if (!ctx) {return;} + if (!ctx) { + return; + } + const config = configRef.current; + if (!config) { + return; + } - const W = canvas.width / 2; - const H = canvas.height / 2; - const primaryRgb = hexToRgb(primaryColor); - const accentRgb = hexToRgb(accentColor); - const connectionRgb = hexToRgb(connectionColor); + const dpr = canvas.width / (canvas.clientWidth || 1); + const W = canvas.width / dpr; + const H = canvas.height / dpr; - ctx.setTransform(2, 0, 0, 2, 0, 0); + 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, accentRgb, primaryRgb); + drawBgOrbs(ctx, W, H, config.bgOrbScale); - const finalParticles = particlesRef.current.map(p => ({ - ...p, - x: p.targetX, - y: p.targetY, - radius: p.dataRadius, - opacity: 0.2, - phase: 'complete' as const, - })); + 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, primaryRgb, accentRgb); + drawParticle(ctx, p, 0, config); } - drawConnections(ctx, finalParticles, connectionDistance, connectionRgb); - }, [primaryColor, accentColor, connectionColor, connectionDistance]); + drawConnections(ctx, finalParticles, config.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(); + if (!canvas) { return; } - const primaryRgb = hexToRgb(primaryColor); - const accentRgb = hexToRgb(accentColor); - const connectionRgb = hexToRgb(connectionColor); + const parent = canvas.parentElement; + if (!parent) { + return; + } - 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;} + const initCanvas = () => { + if (animationRef.current !== undefined) { + cancelAnimationFrame(animationRef.current); + animationRef.current = undefined; } - drawConnections(ctx, particles, connectionDistance, connectionRgb); + const W = parent.clientWidth; + const H = parent.clientHeight; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = W * dpr; + canvas.height = H * dpr; - if (allComplete) { - animationRef.current = undefined; + 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); }; - 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, particleCount, drawStaticFinal, primaryColor, accentColor, connectionColor, connectionDistance]); + }, [shouldReduceMotion, drawStaticFinal]); return (