Files
novalon-website/docs/superpowers/plans/2026-05-03-hero-ink-data-morph.md

672 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Hero 数字水墨动画实现计划
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
**目标:** 为 Hero 区域实现"数字水墨"Canvas 2D 粒子动画,传达"传统智慧 → 数字化转型"的品牌叙事。
**架构:** 新建 `InkDataMorph` Canvas 2D 粒子组件,三阶段状态机驱动(扩散 → 沉淀 → 数据化),集成到 `hero-section-v2.tsx` 中作为背景层,文字内容层在 Canvas 之上。
**技术栈:** React 18 + Canvas 2D API + requestAnimationFrame + Jest
**规格文档:** `docs/superpowers/specs/2026-05-03-hero-ink-data-morph-design.md`
---
## 文件结构
| 操作 | 文件 | 职责 |
|------|------|------|
| 新建 | `src/components/effects/ink-data-morph.tsx` | 数字水墨粒子引擎组件 |
| 新建 | `src/components/effects/ink-data-morph.test.tsx` | 组件单元测试 |
| 修改 | `src/components/effects/index.ts` | 导出新组件 |
| 修改 | `src/components/sections/hero-section-v2.tsx` | 集成 InkDataMorph + 调整背景色/延迟 |
---
### 任务 1InkDataMorph 组件 — 粒子数据结构与初始化
**文件:**
- 创建:`src/components/effects/ink-data-morph.tsx`
- [ ] **步骤 1:创建组件骨架与粒子类型定义**
创建 `src/components/effects/ink-data-morph.tsx`,包含完整的类型定义和组件骨架:
```tsx
'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 easeOutQuart(t: number): number {
return 1 - Math.pow(1 - t, 4);
}
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}`;
}
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 startTimeRef = useRef<number>(0);
const shouldReduceMotion = useReducedMotion();
const createParticle = useCallback((
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,
};
}, []);
const initParticles = useCallback((W: number, H: number) => {
const center1 = { x: W * 0.7, y: H * 0.35 };
const center2 = { x: W * 0.25, y: H * 0.65 };
const count1 = Math.floor(particleCount * 0.67);
const count2 = particleCount - 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));
}
particlesRef.current = particles;
}, [particleCount, createParticle]);
return (
<canvas
ref={canvasRef}
className={`absolute inset-0 w-full h-full pointer-events-none ${className}`}
aria-hidden="true"
/>
);
}
```
- [ ] **步骤 2:验证文件无语法错误**
运行:`npx tsc --noEmit src/components/effects/ink-data-morph.tsx`
预期:无错误输出
- [ ] **步骤 3Commit**
```bash
git add src/components/effects/ink-data-morph.tsx
git commit -m "feat(effects): add InkDataMorph component skeleton with particle types"
```
---
### 任务 2InkDataMorph 组件 — 三阶段动画引擎
**文件:**
- 修改:`src/components/effects/ink-data-morph.tsx`
- [ ] **步骤 1:在组件内添加 updateParticle、drawParticle、drawConnections、animate 函数**
`InkDataMorph` 组件的 `return` 语句之前,添加以下函数:
```tsx
const updateParticle = useCallback((p: Particle) => {
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';
}
}
}, []);
const drawParticle = useCallback((
ctx: CanvasRenderingContext2D,
p: Particle,
primaryRgb: string,
accentRgb: string,
) => {
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();
}
}, []);
const drawConnections = useCallback((
ctx: CanvasRenderingContext2D,
particles: Particle[],
connectionRgb: string,
) => {
const morphed = particles.filter(
p => p.phase === 'morphing' && p.morphProgress > 0.3
);
ctx.save();
for (let i = 0; i < morphed.length; i++) {
for (let j = i + 1; j < morphed.length; j++) {
const dx = morphed[i].x - morphed[j].x;
const dy = morphed[i].y - morphed[j].y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < connectionDistance) {
const alpha =
(1 - dist / connectionDistance) *
0.08 *
Math.min(morphed[i].morphProgress, morphed[j].morphProgress);
ctx.beginPath();
ctx.moveTo(morphed[i].x, morphed[i].y);
ctx.lineTo(morphed[j].x, morphed[j].y);
ctx.strokeStyle = `rgba(${connectionRgb}, ${alpha})`;
ctx.lineWidth = 0.5;
ctx.stroke();
}
}
}
ctx.restore();
}, [connectionDistance]);
const drawBgOrbs = useCallback((
ctx: CanvasRenderingContext2D,
W: number,
H: number,
accentRgb: string,
primaryRgb: string,
) => {
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);
}
}, []);
```
- [ ] **步骤 2:添加 animate 函数和 useEffect**
`drawBgOrbs` 之后、`return` 之前添加:
```tsx
const animate = 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 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, connectionRgb);
if (allComplete) {
animationRef.current = undefined;
return;
}
animationRef.current = requestAnimationFrame(animate);
}, [primaryColor, accentColor, connectionColor, updateParticle, drawParticle, drawConnections, drawBgOrbs]);
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 particles = particlesRef.current;
for (const p of particles) {
p.x = p.targetX;
p.y = p.targetY;
p.radius = p.dataRadius;
p.opacity = 0.2;
p.phase = 'complete';
drawParticle(ctx, p, primaryRgb, accentRgb);
}
drawConnections(ctx, particles, connectionRgb);
}, [primaryColor, accentColor, connectionColor, drawParticle, drawConnections, drawBgOrbs]);
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;
initParticles(W, H);
if (shouldReduceMotion) {
drawStaticFinal();
return;
}
startTimeRef.current = performance.now();
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current !== undefined) {
cancelAnimationFrame(animationRef.current);
}
};
}, [shouldReduceMotion, initParticles, animate, drawStaticFinal]);
```
- [ ] **步骤 3:验证 TypeScript 编译**
运行:`npx tsc --noEmit src/components/effects/ink-data-morph.tsx`
预期:无错误
- [ ] **步骤 4Commit**
```bash
git add src/components/effects/ink-data-morph.tsx
git commit -m "feat(effects): add three-phase animation engine to InkDataMorph"
```
---
### 任务 3InkDataMorph 单元测试
**文件:**
- 创建:`src/components/effects/ink-data-morph.test.tsx`
- [ ] **步骤 1:编写测试**
创建 `src/components/effects/ink-data-morph.test.tsx`
```tsx
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import { InkDataMorph } from './ink-data-morph';
jest.mock('@/hooks/use-reduced-motion', () => ({
useReducedMotion: () => false,
}));
describe('InkDataMorph', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render a canvas element', () => {
const { container } = render(
<div style={{ width: 800, height: 600, position: 'relative' }}>
<InkDataMorph />
</div>
);
const canvas = container.querySelector('canvas');
expect(canvas).toBeInTheDocument();
});
it('should have aria-hidden="true"', () => {
const { container } = render(
<div style={{ width: 800, height: 600, position: 'relative' }}>
<InkDataMorph />
</div>
);
const canvas = container.querySelector('canvas');
expect(canvas).toHaveAttribute('aria-hidden', 'true');
});
it('should have pointer-events-none class', () => {
const { container } = render(
<div style={{ width: 800, height: 600, position: 'relative' }}>
<InkDataMorph />
</div>
);
const canvas = container.querySelector('canvas');
expect(canvas).toHaveClass('pointer-events-none');
});
it('should apply custom className', () => {
const { container } = render(
<div style={{ width: 800, height: 600, position: 'relative' }}>
<InkDataMorph className="custom-class" />
</div>
);
const canvas = container.querySelector('canvas');
expect(canvas).toHaveClass('custom-class');
});
it('should accept custom props without crashing', () => {
const { container } = render(
<div style={{ width: 800, height: 600, position: 'relative' }}>
<InkDataMorph
particleCount={100}
primaryColor="#333333"
accentColor="#FF0000"
connectionColor="#FF0000"
connectionDistance={100}
/>
</div>
);
const canvas = container.querySelector('canvas');
expect(canvas).toBeInTheDocument();
});
it('should not start requestAnimationFrame when reduced motion is preferred', () => {
jest.resetModules();
jest.doMock('@/hooks/use-reduced-motion', () => ({
useReducedMotion: () => true,
}));
const rafSpy = jest.spyOn(window, 'requestAnimationFrame');
const { InkDataMorph: ReducedInkDataMorph } = require('./ink-data-morph');
render(
<div style={{ width: 800, height: 600, position: 'relative' }}>
<ReducedInkDataMorph />
</div>
);
expect(rafSpy).not.toHaveBeenCalled();
rafSpy.mockRestore();
jest.dontMock('@/hooks/use-reduced-motion');
});
it('should cleanup requestAnimationFrame on unmount', () => {
const cafSpy = jest.spyOn(window, 'cancelAnimationFrame');
const { unmount } = render(
<div style={{ width: 800, height: 600, position: 'relative' }}>
<InkDataMorph />
</div>
);
unmount();
expect(cafSpy).toHaveBeenCalled();
cafSpy.mockRestore();
});
});
```
- [ ] **步骤 2:运行测试**
运行:`npx jest src/components/effects/ink-data-morph.test.tsx --no-coverage`
预期:所有测试通过
- [ ] **步骤 3Commit**
```bash
git add src/components/effects/ink-data-morph.test.tsx
git commit -m "test(effects): add InkDataMorph unit tests"
```
---
### 任务 4:导出 InkDataMorph 组件
**文件:**
- 修改:`src/components/effects/index.ts`
- [ ] **步骤 1:添加导出**
`src/components/effects/index.ts` 末尾添加一行:
```ts
export { InkDataMorph } from './ink-data-morph';
```
- [ ] **步骤 2:验证导出**
运行:`npx tsc --noEmit`
预期:无错误
- [ ] **步骤 3Commit**
```bash
git add src/components/effects/index.ts
git commit -m "feat(effects): export InkDataMorph from index"
```
---
### 任务 5:集成 InkDataMorph 到 Hero Section
**文件:**
- 修改:`src/components/sections/hero-section-v2.tsx`
- [ ] **步骤 1:添加 dynamic import 并修改 Hero 组件**
`hero-section-v2.tsx` 做以下修改:
1. 在文件顶部 import 区域添加 dynamic import(在 `import { useReducedMotion }` 之后):
```tsx
import dynamic from 'next/dynamic';
const InkDataMorph = dynamic(
() => import('@/components/effects/ink-data-morph').then(mod => ({ default: mod.InkDataMorph })),
{ ssr: false }
);
```
2.`<section>``className``bg-white` 改为 `bg-[#FAFAF5]`
```tsx
className="relative min-h-screen flex flex-col justify-center overflow-hidden bg-[#FAFAF5]"
```
3.`<section>` 开始标签之后、`<div className="container-wide` 之前,添加 Canvas 层:
```tsx
<InkDataMorph />
```
4. 修改各 motion 元素的 delay,对齐 Canvas 动画时序:
- 品牌标签 delay`0``1.6`
- 标题 delay`0.1``1.9`
- 副标题 delay`0.2``2.2`
- 描述 delay`0.3``2.5`
- 按钮组 delay`0.4``2.8`
具体修改:
- 品牌标签 `transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}``transition={{ duration: 0.5, delay: 1.6, ease: [0.16, 1, 0.3, 1] }}`
- 标题 `transition={{ duration: 0.5, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}``transition={{ duration: 0.5, delay: 1.9, ease: [0.16, 1, 0.3, 1] }}`
- 副标题 `transition={{ duration: 0.5, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}``transition={{ duration: 0.5, delay: 2.2, ease: [0.16, 1, 0.3, 1] }}`
- 描述 `transition={{ duration: 0.5, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}``transition={{ duration: 0.5, delay: 2.5, ease: [0.16, 1, 0.3, 1] }}`
- 按钮组 `transition={{ duration: 0.5, delay: 0.4, ease: [0.16, 1, 0.3, 1] }}``transition={{ duration: 0.5, delay: 2.8, ease: [0.16, 1, 0.3, 1] }}`
- [ ] **步骤 2:验证 TypeScript 编译**
运行:`npx tsc --noEmit`
预期:无错误
- [ ] **步骤 3Commit**
```bash
git add src/components/sections/hero-section-v2.tsx
git commit -m "feat(hero): integrate InkDataMorph animation with adjusted timing"
```
---
### 任务 6:视觉验证与最终检查
**文件:** 无文件修改
- [ ] **步骤 1:启动开发服务器并验证**
运行:`npm run dev`
验证清单:
1. 打开首页,观察 Hero 区域三阶段动画
2. 确认背景色为宣纸暖白(#FAFAF5
3. 确认文字在 Canvas 粒子之上
4. 确认动画完成后无持续 CPU 开销(DevTools Performance 面板)
5. 切换到移动端视口(375px),确认动画流畅
6. 在 DevTools 中模拟 `prefers-reduced-motion: reduce`,确认直接显示静态数据网络
- [ ] **步骤 2:运行完整测试套件**
运行:`npx jest src/components/effects/ink-data-morph.test.tsx --no-coverage`
预期:所有测试通过
- [ ] **步骤 3:运行 lint**
运行:`npx next lint --file src/components/effects/ink-data-morph.tsx --file src/components/sections/hero-section-v2.tsx`
预期:无错误
- [ ] **步骤 4:最终 Commit(如有 lint 修复)**
```bash
git add -A
git commit -m "chore: lint fixes for ink-data-morph integration"
```