From c025698cb43592eded3a15b42f8fed0af752850f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E7=BF=94?= Date: Sun, 3 May 2026 16:50:35 +0800 Subject: [PATCH] docs: add Hero ink-data-morph implementation plan --- .../plans/2026-05-03-hero-ink-data-morph.md | 671 ++++++++++++++++++ 1 file changed, 671 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-03-hero-ink-data-morph.md diff --git a/docs/superpowers/plans/2026-05-03-hero-ink-data-morph.md b/docs/superpowers/plans/2026-05-03-hero-ink-data-morph.md new file mode 100644 index 0000000..e1acd6a --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-hero-ink-data-morph.md @@ -0,0 +1,671 @@ +# 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 + 调整背景色/延迟 | + +--- + +### 任务 1:InkDataMorph 组件 — 粒子数据结构与初始化 + +**文件:** +- 创建:`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(null); + const particlesRef = useRef([]); + const animationRef = useRef(undefined); + const startTimeRef = useRef(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 ( +