refactor: 扩展 Particle 和 ResponsiveConfig 数据模型,新增墨韵增强字段
This commit is contained in:
@@ -4,14 +4,14 @@ import { useEffect, useRef, useCallback } from 'react';
|
|||||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
|
|
||||||
interface InkDataMorphProps {
|
interface InkDataMorphProps {
|
||||||
particleCount?: number;
|
|
||||||
primaryColor?: string;
|
|
||||||
accentColor?: string;
|
|
||||||
connectionColor?: string;
|
|
||||||
connectionDistance?: number;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TrailPoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface Particle {
|
interface Particle {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -21,8 +21,12 @@ interface Particle {
|
|||||||
initialRadius: number;
|
initialRadius: number;
|
||||||
dataRadius: number;
|
dataRadius: number;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
isAccent: boolean;
|
toneIndex: number;
|
||||||
phase: 'spreading' | 'settling' | 'morphing' | 'complete';
|
rotation: number;
|
||||||
|
scaleX: number;
|
||||||
|
scaleY: number;
|
||||||
|
isSplash: boolean;
|
||||||
|
phase: 'spreading' | 'settling' | 'morphing' | 'complete' | 'fading';
|
||||||
spreadTime: number;
|
spreadTime: number;
|
||||||
maxSpreadTime: number;
|
maxSpreadTime: number;
|
||||||
settleTime: number;
|
settleTime: number;
|
||||||
@@ -31,85 +35,347 @@ interface Particle {
|
|||||||
targetY: number;
|
targetY: number;
|
||||||
delay: number;
|
delay: number;
|
||||||
age: number;
|
age: number;
|
||||||
|
trail: TrailPoint[];
|
||||||
|
seed1: number;
|
||||||
|
seed2: number;
|
||||||
|
wobbleFactor: number;
|
||||||
|
prevVx: number;
|
||||||
|
prevVy: number;
|
||||||
|
inkLayerCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS = {
|
interface ResponsiveConfig {
|
||||||
particleCount: 150,
|
particleCount: number;
|
||||||
primaryColor: '#1C1C1C',
|
centers: { x: number; y: number }[];
|
||||||
accentColor: '#C41E3A',
|
centerDistribution: number[];
|
||||||
connectionColor: '#C41E3A',
|
targetXRange: [number, number];
|
||||||
connectionDistance: 80,
|
targetYRange: [number, number];
|
||||||
} as const;
|
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 {
|
function easeInOutCubic(t: number): number {
|
||||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
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 {
|
function lerp(a: number, b: number, t: number): number {
|
||||||
return a + (b - a) * t;
|
return a + (b - a) * t;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hexToRgb(hex: string): string {
|
function updateParticle(p: Particle, maxTrail: number): void {
|
||||||
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++;
|
p.age++;
|
||||||
if (p.age < p.delay) {return;}
|
if (p.age < p.delay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (p.phase === 'spreading') {
|
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.x += p.vx;
|
||||||
p.y += p.vy;
|
p.y += p.vy;
|
||||||
p.vx *= 0.98;
|
p.vx *= 0.98;
|
||||||
p.vy *= 0.98;
|
p.vy *= 0.98;
|
||||||
p.spreadTime++;
|
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.spreadTime >= p.maxSpreadTime) {
|
||||||
p.phase = 'settling';
|
if (p.isSplash) {
|
||||||
|
p.phase = 'fading';
|
||||||
|
} else {
|
||||||
|
p.phase = 'settling';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (p.phase === 'settling') {
|
} else if (p.phase === 'settling') {
|
||||||
p.settleTime++;
|
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) {
|
if (p.settleTime > 60) {
|
||||||
p.phase = 'morphing';
|
p.phase = 'morphing';
|
||||||
}
|
}
|
||||||
} else if (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);
|
const t = easeInOutCubic(p.morphProgress);
|
||||||
p.x = lerp(p.x, p.targetX, t * 0.02);
|
p.x = lerp(p.x, p.targetX, t * 0.025);
|
||||||
p.y = lerp(p.y, p.targetY, t * 0.02);
|
p.y = lerp(p.y, p.targetY, t * 0.025);
|
||||||
p.radius = lerp(p.radius, p.dataRadius, t * 0.02);
|
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) {
|
if (p.morphProgress >= 1) {
|
||||||
p.phase = 'complete';
|
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(
|
function drawParticle(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
p: Particle,
|
p: Particle,
|
||||||
primaryRgb: string,
|
time: number,
|
||||||
accentRgb: string,
|
config: ResponsiveConfig,
|
||||||
): void {
|
): void {
|
||||||
if (p.age < p.delay) {return;}
|
if (p.age < p.delay || p.opacity <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.beginPath();
|
const tone = TONES[p.toneIndex];
|
||||||
ctx.arc(p.x, p.y, Math.max(0.5, p.radius), 0, Math.PI * 2);
|
if (!tone) {
|
||||||
ctx.fillStyle = p.isAccent
|
return;
|
||||||
? `rgba(${accentRgb}, ${p.opacity})`
|
}
|
||||||
: `rgba(${primaryRgb}, ${p.opacity})`;
|
const rgb = tone.rgb;
|
||||||
ctx.fill();
|
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.beginPath();
|
||||||
ctx.arc(p.x, p.y, p.radius * 2.5, 0, Math.PI * 2);
|
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = p.isAccent
|
|
||||||
? `rgba(${accentRgb}, ${p.opacity * 0.1})`
|
|
||||||
: `rgba(${primaryRgb}, ${p.opacity * 0.08})`;
|
|
||||||
ctx.fill();
|
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,
|
ctx: CanvasRenderingContext2D,
|
||||||
particles: Particle[],
|
particles: Particle[],
|
||||||
connectionDistance: number,
|
connectionDistance: number,
|
||||||
connectionRgb: string,
|
|
||||||
): void {
|
): void {
|
||||||
const morphed = particles.filter(
|
const morphed = particles.filter(
|
||||||
p => p.phase === 'morphing' && p.morphProgress > 0.3
|
p => p.phase === 'morphing' && p.morphProgress > 0.3
|
||||||
);
|
);
|
||||||
ctx.save();
|
ctx.save();
|
||||||
|
ctx.lineCap = 'round';
|
||||||
for (let i = 0; i < morphed.length; i++) {
|
for (let i = 0; i < morphed.length; i++) {
|
||||||
const a = morphed[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++) {
|
for (let j = i + 1; j < morphed.length; j++) {
|
||||||
const b = morphed[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 dx = a.x - b.x;
|
||||||
const dy = a.y - b.y;
|
const dy = a.y - b.y;
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
if (dist < connectionDistance) {
|
if (dist < connectionDistance && dist > 0) {
|
||||||
const alpha =
|
const progress = Math.min(a.morphProgress, b.morphProgress);
|
||||||
(1 - dist / connectionDistance) *
|
const distRatio = 1 - dist / connectionDistance;
|
||||||
0.08 *
|
const alpha = distRatio * 0.12 * progress;
|
||||||
Math.min(a.morphProgress, b.morphProgress);
|
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.beginPath();
|
||||||
ctx.moveTo(a.x, a.y);
|
ctx.moveTo(a.x, a.y);
|
||||||
ctx.lineTo(b.x, b.y);
|
ctx.quadraticCurveTo(cpx, cpy, b.x, b.y);
|
||||||
ctx.strokeStyle = `rgba(${connectionRgb}, ${alpha})`;
|
ctx.strokeStyle = `rgba(${connRgb}, ${alpha})`;
|
||||||
ctx.lineWidth = 0.5;
|
ctx.lineWidth = lineWidth;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,16 +441,18 @@ function drawBgOrbs(
|
|||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
W: number,
|
W: number,
|
||||||
H: number,
|
H: number,
|
||||||
accentRgb: string,
|
orbScale: number,
|
||||||
primaryRgb: string,
|
|
||||||
): void {
|
): void {
|
||||||
const orbs = [
|
const orbs = [
|
||||||
{ x: W * 0.65, y: H * 0.3, r: 300, rgb: accentRgb, alpha: 0.03 },
|
{ x: W * 0.72, y: H * 0.28, r: 350 * orbScale, rgb: TONES[3].rgb, alpha: 0.04 },
|
||||||
{ x: W * 0.3, y: H * 0.7, r: 250, rgb: primaryRgb, alpha: 0.02 },
|
{ 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) {
|
for (const o of orbs) {
|
||||||
const grad = ctx.createRadialGradient(o.x, o.y, 0, o.x, o.y, o.r);
|
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, `rgba(${o.rgb}, ${o.alpha})`);
|
||||||
|
grad.addColorStop(0.6, `rgba(${o.rgb}, ${o.alpha * 0.3})`);
|
||||||
grad.addColorStop(1, 'transparent');
|
grad.addColorStop(1, 'transparent');
|
||||||
ctx.fillStyle = grad;
|
ctx.fillStyle = grad;
|
||||||
ctx.fillRect(o.x - o.r, o.y - o.r, o.r * 2, o.r * 2);
|
ctx.fillRect(o.x - o.r, o.y - o.r, o.r * 2, o.r * 2);
|
||||||
@@ -175,158 +465,235 @@ function createParticle(
|
|||||||
W: number,
|
W: number,
|
||||||
H: number,
|
H: number,
|
||||||
delay: number,
|
delay: number,
|
||||||
|
config: ResponsiveConfig,
|
||||||
): Particle {
|
): Particle {
|
||||||
const angle = Math.random() * Math.PI * 2;
|
const angle = Math.random() * Math.PI * 2;
|
||||||
const dist = Math.random() * 10;
|
const dist = Math.random() * 8;
|
||||||
const speed = 0.3 + Math.random() * 1.5;
|
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 {
|
return {
|
||||||
x: cx + Math.cos(angle) * dist,
|
x: cx + Math.cos(angle) * dist,
|
||||||
y: cy + Math.sin(angle) * dist,
|
y: cy + Math.sin(angle) * dist,
|
||||||
vx: Math.cos(angle) * speed,
|
vx: Math.cos(angle) * speed,
|
||||||
vy: Math.sin(angle) * speed,
|
vy: Math.sin(angle) * speed,
|
||||||
radius: 1.5 + Math.random() * 4,
|
radius: initialRadius,
|
||||||
initialRadius: 1.5 + Math.random() * 4,
|
initialRadius,
|
||||||
dataRadius: 1 + Math.random() * 2,
|
dataRadius: isSplash ? 0 : 1.2 + Math.random() * 2.5,
|
||||||
opacity: 0.4 + Math.random() * 0.4,
|
opacity: isSplash ? 0.6 + Math.random() * 0.3 : 0.5 + Math.random() * 0.4,
|
||||||
isAccent: Math.random() > 0.75,
|
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',
|
phase: 'spreading',
|
||||||
spreadTime: 0,
|
spreadTime: 0,
|
||||||
maxSpreadTime: 60 + Math.random() * 80,
|
maxSpreadTime: isSplash ? 30 + Math.random() * 40 : 70 + Math.random() * 90,
|
||||||
settleTime: 0,
|
settleTime: 0,
|
||||||
morphProgress: 0,
|
morphProgress: 0,
|
||||||
targetX: W * (0.55 + Math.random() * 0.35),
|
targetX: W * (txMin + Math.random() * (txMax - txMin)),
|
||||||
targetY: H * (0.15 + Math.random() * 0.6),
|
targetY: H * (tyMin + Math.random() * (tyMax - tyMin)),
|
||||||
delay,
|
delay,
|
||||||
age: 0,
|
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[] {
|
function initParticlesArray(W: number, H: number, config: ResponsiveConfig): 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[] = [];
|
const particles: Particle[] = [];
|
||||||
for (let i = 0; i < count1; i++) {
|
let remaining = config.particleCount;
|
||||||
particles.push(createParticle(center1.x, center1.y, W, H, 0));
|
|
||||||
}
|
for (let c = 0; c < config.centers.length; c++) {
|
||||||
for (let i = 0; i < count2; i++) {
|
const centerDef = config.centers[c];
|
||||||
particles.push(createParticle(center2.x, center2.y, W, H, 30));
|
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;
|
return particles;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InkDataMorph({
|
export function InkDataMorph({
|
||||||
particleCount = DEFAULTS.particleCount,
|
|
||||||
primaryColor = DEFAULTS.primaryColor,
|
|
||||||
accentColor = DEFAULTS.accentColor,
|
|
||||||
connectionColor = DEFAULTS.connectionColor,
|
|
||||||
connectionDistance = DEFAULTS.connectionDistance,
|
|
||||||
className = '',
|
className = '',
|
||||||
}: InkDataMorphProps) {
|
}: InkDataMorphProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const particlesRef = useRef<Particle[]>([]);
|
const particlesRef = useRef<Particle[]>([]);
|
||||||
const animationRef = useRef<number | undefined>(undefined);
|
const animationRef = useRef<number | undefined>(undefined);
|
||||||
|
const startTimeRef = useRef<number>(0);
|
||||||
|
const configRef = useRef<ResponsiveConfig | null>(null);
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
const drawStaticFinal = useCallback(() => {
|
const drawStaticFinal = useCallback(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) {return;}
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) {return;}
|
if (!ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const config = configRef.current;
|
||||||
|
if (!config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const W = canvas.width / 2;
|
const dpr = canvas.width / (canvas.clientWidth || 1);
|
||||||
const H = canvas.height / 2;
|
const W = canvas.width / dpr;
|
||||||
const primaryRgb = hexToRgb(primaryColor);
|
const H = canvas.height / dpr;
|
||||||
const accentRgb = hexToRgb(accentColor);
|
|
||||||
const connectionRgb = hexToRgb(connectionColor);
|
|
||||||
|
|
||||||
ctx.setTransform(2, 0, 0, 2, 0, 0);
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
ctx.clearRect(0, 0, W, H);
|
ctx.clearRect(0, 0, W, H);
|
||||||
ctx.fillStyle = '#FAFAF5';
|
ctx.fillStyle = '#FAFAF5';
|
||||||
ctx.fillRect(0, 0, W, H);
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
drawBgOrbs(ctx, W, H, accentRgb, primaryRgb);
|
drawBgOrbs(ctx, W, H, config.bgOrbScale);
|
||||||
|
|
||||||
const finalParticles = particlesRef.current.map(p => ({
|
const finalParticles = particlesRef.current
|
||||||
...p,
|
.filter(p => !p.isSplash)
|
||||||
x: p.targetX,
|
.map(p => ({
|
||||||
y: p.targetY,
|
...p,
|
||||||
radius: p.dataRadius,
|
x: p.targetX,
|
||||||
opacity: 0.2,
|
y: p.targetY,
|
||||||
phase: 'complete' as const,
|
radius: p.dataRadius,
|
||||||
}));
|
opacity: 0.35,
|
||||||
|
phase: 'complete' as const,
|
||||||
|
trail: [],
|
||||||
|
}));
|
||||||
|
|
||||||
for (const p of finalParticles) {
|
for (const p of finalParticles) {
|
||||||
drawParticle(ctx, p, primaryRgb, accentRgb);
|
drawParticle(ctx, p, 0, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
drawConnections(ctx, finalParticles, connectionDistance, connectionRgb);
|
drawConnections(ctx, finalParticles, config.connectionDistance);
|
||||||
}, [primaryColor, accentColor, connectionColor, connectionDistance]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) {return;}
|
if (!canvas) {
|
||||||
|
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const primaryRgb = hexToRgb(primaryColor);
|
const parent = canvas.parentElement;
|
||||||
const accentRgb = hexToRgb(accentColor);
|
if (!parent) {
|
||||||
const connectionRgb = hexToRgb(connectionColor);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const animate = () => {
|
const initCanvas = () => {
|
||||||
const ctx = canvas.getContext('2d');
|
if (animationRef.current !== undefined) {
|
||||||
if (!ctx) {return;}
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
animationRef.current = undefined;
|
||||||
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);
|
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) {
|
const config = getResponsiveConfig(W);
|
||||||
animationRef.current = undefined;
|
configRef.current = config;
|
||||||
|
particlesRef.current = initParticlesArray(W, H, config);
|
||||||
|
|
||||||
|
if (shouldReduceMotion) {
|
||||||
|
drawStaticFinal();
|
||||||
return;
|
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);
|
||||||
};
|
};
|
||||||
|
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
initCanvas();
|
||||||
|
|
||||||
|
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const handleResize = () => {
|
||||||
|
if (resizeTimer !== undefined) {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
}
|
||||||
|
resizeTimer = setTimeout(initCanvas, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
if (resizeTimer !== undefined) {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
}
|
||||||
if (animationRef.current !== undefined) {
|
if (animationRef.current !== undefined) {
|
||||||
cancelAnimationFrame(animationRef.current);
|
cancelAnimationFrame(animationRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [shouldReduceMotion, particleCount, drawStaticFinal, primaryColor, accentColor, connectionColor, connectionDistance]);
|
}, [shouldReduceMotion, drawStaticFinal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
|
|||||||
Reference in New Issue
Block a user