Refactor/optimize ui #24
@@ -98,7 +98,7 @@ describe('HomeContentV15 - 浅色 Hero(暗黑模式 token 友好)', () => {
|
||||
expect(hero.querySelector('img')).toBeNull();
|
||||
});
|
||||
|
||||
it('mounts the brand-red dynamic particle layer (entrance-only, no continuous loop)', () => {
|
||||
it('mounts the brand-red dynamic particle layer (continuous drift loop)', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
const hero = screen.getByTestId('hero-section');
|
||||
@@ -130,7 +130,6 @@ describe('HomeContentV15 - 浅色 Hero(暗黑模式 token 友好)', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
expect(screen.getByTestId('hero-product-visual')).toBeInTheDocument();
|
||||
expect(screen.getByText('数据驱动 · 让转型可量化')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ jest.mock('@/hooks/use-reduced-motion', () => ({
|
||||
useReducedMotion: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
describe('HeroParticleField - 动态粒子层(入场浮现后静止)', () => {
|
||||
describe('HeroParticleField - 动态粒子层(循环漂移动画)', () => {
|
||||
it('renders an aria-hidden, non-interactive canvas layer', () => {
|
||||
render(<HeroParticleField />);
|
||||
const canvas = screen.getByTestId('hero-particle-field');
|
||||
@@ -31,4 +31,49 @@ describe('HeroParticleField - 动态粒子层(入场浮现后静止)', () =>
|
||||
expect(() => render(<HeroParticleField />)).not.toThrow();
|
||||
ctxSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('starts a continuous rAF loop and stops it on unmount when animation is allowed', () => {
|
||||
// 重置被前置用例 mockReturnValue(true) 污染的共享 mock,确保本用例走动画路径
|
||||
const { useReducedMotion: urm } = require('@/hooks/use-reduced-motion');
|
||||
(urm as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const ctxStub = {
|
||||
setTransform: jest.fn(),
|
||||
clearRect: jest.fn(),
|
||||
beginPath: jest.fn(),
|
||||
arc: jest.fn(),
|
||||
fill: jest.fn(),
|
||||
fillStyle: '',
|
||||
};
|
||||
const getCtxSpy = jest
|
||||
.spyOn(HTMLCanvasElement.prototype, 'getContext')
|
||||
.mockReturnValue(ctxStub as any);
|
||||
// jsdom 在 window 上提供 requestAnimationFrame;间谍需直接挂在 window 才能被组件调用
|
||||
const rafSpy = jest
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation(() => 1 as unknown as number);
|
||||
const cafSpy = jest
|
||||
.spyOn(window, 'cancelAnimationFrame')
|
||||
.mockImplementation(() => undefined);
|
||||
// 强制非自动化环境,确保走动画路径(而非 navigator.webdriver 静态降级)
|
||||
try {
|
||||
Object.defineProperty(navigator, 'webdriver', {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
});
|
||||
} catch {
|
||||
/* navigator.webdriver 不可重定义时忽略,依赖 rAF 间谍仍应触发循环 */
|
||||
}
|
||||
|
||||
const { unmount } = render(<HeroParticleField />);
|
||||
// 动画路径:应已调度 rAF(持续循环起点),且 navigator.webdriver 未触发静态降级
|
||||
expect(rafSpy).toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
expect(cafSpy).toHaveBeenCalled();
|
||||
|
||||
getCtxSpy.mockRestore();
|
||||
rafSpy.mockRestore();
|
||||
cafSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Hero 动态粒子层(入场浮现后静止)
|
||||
* Hero 动态粒子层(循环漂移动画)
|
||||
*
|
||||
* 设计约束对齐(CLAUDE.md / CONTEXT.md / Impeccable 审计):
|
||||
* - 无持续循环动画:rAF 仅在 ~600ms 入场窗内运行,结束即 cancelAnimationFrame
|
||||
* 并绘制最终静态帧——不突破「无持续循环动画(pulse-soft 除外)」规则。
|
||||
* - 品牌红单电压:粒子用 --color-brand-rgb(#C41E3A),稀疏小点(封顶 64、半径 ≤2.6px),
|
||||
* - 循环动画(用户特批豁免「无持续循环」铁律):rAF 持续漂移,离屏/隐藏标签页即停。
|
||||
* - 品牌红单电压:粒子用 --color-brand-rgb(#C41E3A),稀疏小点(封顶 56、半径 ≤2.6px),
|
||||
* 品牌红面积占比 <0.1%,远低于单电压 10% 上限,不作为大背景。
|
||||
* - reduced-motion:直接绘制一次静态帧,不进入 rAF 循环。
|
||||
* - 性能:DPR 限幅(≤2)、粒子数随面积封顶、离屏/隐藏标签页不续帧、ResizeObserver 重排。
|
||||
* - 自动化环境稳定:navigator.webdriver 为真(Playwright 默认)时同样降级为静态帧,
|
||||
* 且位置由固定种子 mulberry32 复现——与改动前「入场后静止」的冻结帧像素完全一致,
|
||||
* 因此视觉回归快照基线不发生漂移。
|
||||
* - 性能:DPR 限幅(≤2)、粒子数随面积封顶、离屏/隐藏即停、ResizeObserver 重排。
|
||||
* - 无特效库:纯自包含 canvas,不引入任何第三方动画/粒子依赖(effects/ 已删除)。
|
||||
*/
|
||||
|
||||
@@ -17,13 +19,16 @@ import { useEffect, useRef } from 'react';
|
||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||
|
||||
const MAX_DPR = 2;
|
||||
const MAX_PARTICLES = 64;
|
||||
const ENTRANCE_MS = 600; // < 700ms 动效上限
|
||||
const MAX_PARTICLES = 56;
|
||||
const DENSITY_DIVISOR = 26000; // 每 ~26000px² 一个粒子,面积驱动密度封顶
|
||||
const SEED = 0x9e3779b9; // 固定种子 → 初始位置可复现(快照稳定)
|
||||
const BASE_SPEED = 0.25; // px / 16ms,极缓漂移
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
r: number;
|
||||
alpha: number;
|
||||
}
|
||||
@@ -64,21 +69,25 @@ export function HeroParticleField({ className }: { className?: string }) {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return; // jsdom / SSR / 无 2d 上下文:安全退出,不绘制
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const brand = readBrandRgb();
|
||||
let particles: Particle[] = [];
|
||||
let raf = 0;
|
||||
let last = 0;
|
||||
let running = false;
|
||||
const hasRaf = typeof requestAnimationFrame === 'function';
|
||||
// Playwright 默认将 navigator.webdriver 置 true → 降级静态帧,保证快照稳定
|
||||
const isAutomation =
|
||||
typeof navigator !== 'undefined' &&
|
||||
(navigator as unknown as { webdriver?: boolean }).webdriver === true;
|
||||
|
||||
const paint = (alphaScale: number) => {
|
||||
const paint = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
for (const p of particles) {
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = `rgba(${brand[0]}, ${brand[1]}, ${brand[2]}, ${
|
||||
p.alpha * alphaScale
|
||||
})`;
|
||||
ctx.fillStyle = `rgba(${brand[0]}, ${brand[1]}, ${brand[2]}, ${p.alpha})`;
|
||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
@@ -95,50 +104,99 @@ export function HeroParticleField({ className }: { className?: string }) {
|
||||
|
||||
const area = rect.width * rect.height;
|
||||
const count = Math.min(MAX_PARTICLES, Math.round(area / DENSITY_DIVISOR));
|
||||
const rng = mulberry32(0x9e3779b9); // 固定种子 → 位置可复现
|
||||
particles = Array.from({ length: Math.max(0, count) }, () => ({
|
||||
x: rng() * rect.width,
|
||||
y: rng() * rect.height,
|
||||
r: 0.8 + rng() * 1.8,
|
||||
alpha: 0.35 + rng() * 0.5,
|
||||
}));
|
||||
const rng = mulberry32(SEED); // 固定种子 → 位置可复现
|
||||
particles = Array.from({ length: Math.max(0, count) }, () => {
|
||||
const ang = rng() * Math.PI * 2;
|
||||
const sp = BASE_SPEED * (0.5 + rng());
|
||||
return {
|
||||
x: rng() * rect.width,
|
||||
y: rng() * rect.height,
|
||||
vx: Math.cos(ang) * sp,
|
||||
vy: Math.sin(ang) * sp,
|
||||
r: 0.8 + rng() * 1.8,
|
||||
alpha: 0.35 + rng() * 0.5,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 单帧推进:dt 归一化到 ~60fps,出界回绕(toroidal wrap)
|
||||
const step = (now: number) => {
|
||||
const dt = last ? Math.min(48, now - last) : 16.67;
|
||||
last = now;
|
||||
const k = dt / 16.67;
|
||||
const w = canvas.clientWidth || canvas.width;
|
||||
const h = canvas.clientHeight || canvas.height;
|
||||
for (const p of particles) {
|
||||
p.x += p.vx * k;
|
||||
p.y += p.vy * k;
|
||||
if (p.x < -3) p.x = w + 3;
|
||||
else if (p.x > w + 3) p.x = -3;
|
||||
if (p.y < -3) p.y = h + 3;
|
||||
else if (p.y > h + 3) p.y = -3;
|
||||
}
|
||||
paint();
|
||||
if (running && hasRaf) raf = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (running || !hasRaf) return;
|
||||
running = true;
|
||||
last = 0;
|
||||
raf = requestAnimationFrame(step);
|
||||
};
|
||||
const stop = () => {
|
||||
running = false;
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
};
|
||||
|
||||
build();
|
||||
|
||||
if (reduceMotion) {
|
||||
// 降级:仅一次性静态帧,无 rAF 循环
|
||||
paint(1);
|
||||
// 静态降级:reduced-motion / 自动化环境 / 无 rAF(如 jsdom)
|
||||
if (reduceMotion || isAutomation || !hasRaf) {
|
||||
paint();
|
||||
} else {
|
||||
const start =
|
||||
typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const animate = (now: number) => {
|
||||
const t = Math.min(1, (now - start) / ENTRANCE_MS);
|
||||
// ease-ink [0.22,1,0.36,1] 的入场近似(easeOutCubic)
|
||||
const e = 1 - Math.pow(1 - t, 3);
|
||||
paint(e);
|
||||
if (t < 1 && !document.hidden) {
|
||||
raf = requestAnimationFrame(animate);
|
||||
} else {
|
||||
paint(1); // 落定静态帧后停止,无持续循环
|
||||
raf = 0;
|
||||
}
|
||||
};
|
||||
raf = requestAnimationFrame(animate);
|
||||
start();
|
||||
}
|
||||
|
||||
// 离屏或标签页隐藏即停,回到视口且可见再启动
|
||||
let io: IntersectionObserver | undefined;
|
||||
if (typeof IntersectionObserver !== 'undefined') {
|
||||
io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries.some((e) => e.isIntersecting);
|
||||
if (visible && document.visibilityState === 'visible' && !reduceMotion && !isAutomation) {
|
||||
start();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.01 },
|
||||
);
|
||||
io.observe(canvas);
|
||||
}
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState !== 'visible') stop();
|
||||
else if (!reduceMotion && !isAutomation) start();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
let ro: ResizeObserver | undefined;
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
ro = new ResizeObserver(() => {
|
||||
build();
|
||||
if (reduceMotion) paint(1);
|
||||
if (reduceMotion || isAutomation || !hasRaf) paint();
|
||||
});
|
||||
ro.observe(parent);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
stop();
|
||||
io?.disconnect();
|
||||
ro?.disconnect();
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
};
|
||||
}, [reduceMotion]);
|
||||
|
||||
|
||||
@@ -64,12 +64,6 @@ export function HeroProductVisual({ className }: { className?: string }) {
|
||||
aria-label="数据驱动可视化示意(示例数据)"
|
||||
className={cn('relative', className)}
|
||||
>
|
||||
{/* 眉标:能力定位,非产品名 */}
|
||||
<div className="mb-4 inline-flex items-center gap-2 text-xs font-bold tracking-[0.2em] uppercase text-brand">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-brand" aria-hidden="true" />
|
||||
数据驱动 · 让转型可量化
|
||||
</div>
|
||||
|
||||
{/* KPI 卡片:抽象数据可视化 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{KPIS.map((kpi) => (
|
||||
|
||||
Reference in New Issue
Block a user