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

19 KiB
Raw Permalink Blame History

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,包含完整的类型定义和组件骨架:

'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
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 语句之前,添加以下函数:

  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 之前添加:

  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
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

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
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 末尾添加一行:

export { InkDataMorph } from './ink-data-morph';
  • 步骤 2:验证导出

运行:npx tsc --noEmit 预期:无错误

  • 步骤 3Commit
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 } 之后):
import dynamic from 'next/dynamic';

const InkDataMorph = dynamic(
  () => import('@/components/effects/ink-data-morph').then(mod => ({ default: mod.InkDataMorph })),
  { ssr: false }
);
  1. <section>classNamebg-white 改为 bg-[#FAFAF5]
className="relative min-h-screen flex flex-col justify-center overflow-hidden bg-[#FAFAF5]"
  1. <section> 开始标签之后、<div className="container-wide 之前,添加 Canvas 层:
      <InkDataMorph />
  1. 修改各 motion 元素的 delay,对齐 Canvas 动画时序:
  • 品牌标签 delay01.6
  • 标题 delay0.11.9
  • 副标题 delay0.22.2
  • 描述 delay0.32.5
  • 按钮组 delay0.42.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
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 修复)
git add -A
git commit -m "chore: lint fixes for ink-data-morph integration"