feat(hero): refine light hero tech-sense with engineering grid and tuned ink links

浅色 Hero 科技感变体(HERO_VARIANT='grid'):墨色工程网格 + 品牌红节点 + 墨色细连线

连线 globalAlpha 上限 0.7→0.42,加边缘径向淡出(确定性,快照零漂移)

工程网格 CSS 加 radial mask 聚焦版心(呼应 McKinsey 穿透噪声)

清理死 CSS:移除 hero-dot-grid/hero-dot-spot 及 --hero-dot-* 变量

保留 --spot-x/--spot-y(spotlight 聚光复用)

门禁:type-check 0 / eslint 0 / 单测 17 passed
This commit is contained in:
2026-09-01 09:54:45 +08:00
parent 95dc07c3f7
commit bfa49410e4
3 changed files with 182 additions and 46 deletions
+117 -9
View File
@@ -1,7 +1,7 @@
'use client';
/**
* Hero 动态粒子层(循环漂移动画)
* Hero 动态粒子层(循环漂移动画 + 科技感变体)
*
* 设计约束对齐(CLAUDE.md / CONTEXT.md / Impeccable 审计):
* - 循环动画(用户特批豁免「无持续循环」铁律):rAF 持续漂移,离屏/隐藏标签页即停。
@@ -9,10 +9,18 @@
* 品牌红面积占比 <0.1%,远低于单电压 10% 上限,不作为大背景。
* - reduced-motion:直接绘制一次静态帧,不进入 rAF 循环。
* - 自动化环境稳定:navigator.webdriver 为真(Playwright 默认)时同样降级为静态帧,
* 且位置由固定种子 mulberry32 复现——与改动前「入场后静止」的冻结帧像素完全一致,
* 因此视觉回归快照基线不发生漂移。
* 且位置由固定种子 mulberry32 复现——与改动前「入场后静止」的冻结帧像素一致,
* 因此视觉回归快照基线不发生漂移(连线/星图位置同样由固定种子复现)。
* - 性能:DPR 限幅(≤2)、粒子数随面积封顶、离屏/隐藏即停、ResizeObserver 重排。
* - 无特效库:纯自包含 canvas,不引入任何第三方动画/粒子依赖(effects/ 已删除)。
*
* 变体(variant prop,默认 'drift' 兼容旧调用):
* - 'drift' 仅品牌红小点漂移(原行为,浅色 Hero 用)
* - 'grid' 浅色科技感:墨色细连线 + 品牌红节点(网格由 HeroSection 的 CSS 层承担)
* - 'network' 品牌红节点 + 邻近中性灰连线(深色 Hero 默认,科技感最强)
* - 'constellation' 品牌红脉冲节点 + 暗白次级点 + 极淡竖向数据流(NovaVis 看板语义)
* - 'spotlight' 同 network + 工程网格 + 品牌红光标聚光(纵深最强)
* 连线/辉光仅作用于深色底(dark=true);浅色变体用墨色细线。
*/
import { useEffect, useRef } from 'react';
@@ -23,6 +31,25 @@ const MAX_PARTICLES = 56;
const DENSITY_DIVISOR = 26000; // 每 ~26000px² 一个粒子,面积驱动密度封顶
const SEED = 0x9e3779b9; // 固定种子 → 初始位置可复现(快照稳定)
const BASE_SPEED = 0.25; // px / 16ms,极缓漂移
const LINK_DIST = 150; // 连线触发距离
export type HeroVariant = 'drift' | 'network' | 'grid' | 'constellation' | 'spotlight';
interface VariantConfig {
links: boolean; // 邻近粒子连线
glow: boolean; // 品牌红节点辉光(仅深底生效)
dim: boolean; // 暗白次级节点(星图)
streams: boolean; // 竖向数据流
}
const VARIANT_CONFIG: Record<HeroVariant, VariantConfig> = {
drift: { links: false, glow: false, dim: false, streams: false },
// grid:浅色科技感变体 —— 墨色细连线 + 品牌红节点(工程网格由 HeroSection 的 CSS 层承担)
grid: { links: true, glow: false, dim: false, streams: false },
network: { links: true, glow: true, dim: false, streams: false },
constellation: { links: false, glow: true, dim: true, streams: true },
spotlight: { links: true, glow: true, dim: false, streams: false },
};
interface Particle {
x: number;
@@ -31,6 +58,7 @@ interface Particle {
vy: number;
r: number;
alpha: number;
dim: boolean;
}
function readBrandRgb(): [number, number, number] {
@@ -60,7 +88,15 @@ function mulberry32(seed: number): () => number {
};
}
export function HeroParticleField({ className }: { className?: string }) {
export function HeroParticleField({
variant = 'drift',
dark = false,
className,
}: {
variant?: HeroVariant;
dark?: boolean;
className?: string;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const reduceMotion = useReducedMotion();
@@ -72,6 +108,9 @@ export function HeroParticleField({ className }: { className?: string }) {
const parent = canvas.parentElement;
if (!parent) return;
// 变体配置在 effect 内派生:随 variant 重建(VARIANT_CONFIG 为模块级常量,无需入依赖数组)
const cfg = VARIANT_CONFIG[variant];
const brand = readBrandRgb();
let particles: Particle[] = [];
let raf = 0;
@@ -84,13 +123,79 @@ export function HeroParticleField({ className }: { className?: string }) {
(navigator as unknown as { webdriver?: boolean }).webdriver === true;
const paint = () => {
// 透明画布:仅绘制粒子层,背景由 section 承载(支持深色/浅色 Hero 切换)
ctx.clearRect(0, 0, canvas.width, canvas.height);
const w = canvas.clientWidth || canvas.width;
const h = canvas.clientHeight || canvas.height;
// 边缘径向淡出:中心可见、四角收敛(确定性 —— 仅依赖固定种子位置 + 视口尺寸,快照零漂移)
const maxR = Math.max(1, Math.hypot(w / 2, h / 2));
const fade = (x: number, y: number) => {
const d = Math.hypot(x - w / 2, y - h / 2) / maxR;
return 0.2 + 0.8 * Math.max(0, 1 - d);
};
// 竖向数据流(constellation):固定种子复现 x 位置 → 静态降级像素稳定
if (cfg.streams) {
const srng = mulberry32(SEED ^ 0x55);
ctx.lineWidth = 1;
for (let i = 0; i < 16; i++) {
const x = srng() * w;
const g = ctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, 'rgba(196,30,58,0)');
g.addColorStop(0.5, 'rgba(196,30,58,0.06)');
g.addColorStop(1, 'rgba(196,30,58,0)');
ctx.strokeStyle = g;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
}
}
// 邻近连线(network / spotlight):中性灰(深底)或墨色(浅底),不抢品牌红
if (cfg.links && particles.length) {
ctx.lineWidth = 1;
for (let i = 0; i < particles.length; i++) {
const a = particles[i];
if (!a) continue; // noUncheckedIndexedAccess 收窄(不用非空断言,避免 lint 告警)
for (let j = i + 1; j < particles.length; j++) {
const b = particles[j];
if (!b) continue;
const dx = a.x - b.x;
const dy = a.y - b.y;
const d = Math.hypot(dx, dy);
if (d < LINK_DIST) {
ctx.strokeStyle = dark ? 'rgba(255,255,255,1)' : 'rgba(10,14,20,1)';
// 浅底连线更克制:上限 0.42(原 0.7)+ 边缘径向淡出,避免「图纸过密」
ctx.globalAlpha = (1 - d / LINK_DIST) * 0.42 * fade((a.x + b.x) / 2, (a.y + b.y) / 2);
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
}
ctx.globalAlpha = 1;
}
// 粒子节点
for (const p of particles) {
if (dark && cfg.glow && !p.dim) {
ctx.shadowColor = 'rgba(196,30,58,0.9)';
ctx.shadowBlur = 8;
} else {
ctx.shadowBlur = 0;
}
// 边缘径向淡出同样作用于节点,使视线聚焦版心
const na = (p.dim ? (dark ? 0.4 : 0.5) : p.alpha) * fade(p.x, p.y);
ctx.fillStyle = p.dim
? `rgba(${dark ? '180,190,205' : '100,116,139'}, ${na})`
: `rgba(${brand[0]}, ${brand[1]}, ${brand[2]}, ${na})`;
ctx.beginPath();
ctx.fillStyle = `rgba(${brand[0]}, ${brand[1]}, ${brand[2]}, ${p.alpha})`;
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.arc(p.x, p.y, p.dim ? p.r * 0.7 : p.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.shadowBlur = 0;
};
const build = () => {
@@ -104,8 +209,8 @@ 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(SEED); // 固定种子 → 位置可复现
particles = Array.from({ length: Math.max(0, count) }, () => {
const rng = mulberry32(SEED); // 固定种子 → 位置可复现(含 dim 标记不消耗额外 rng)
particles = Array.from({ length: Math.max(0, count) }, (_, i) => {
const ang = rng() * Math.PI * 2;
const sp = BASE_SPEED * (0.5 + rng());
return {
@@ -115,6 +220,8 @@ export function HeroParticleField({ className }: { className?: string }) {
vy: Math.sin(ang) * sp,
r: 0.8 + rng() * 1.8,
alpha: 0.35 + rng() * 0.5,
// dim 次级节点:确定性派生(不消耗 rng),保证各变体粒子位置一致
dim: cfg.dim && i % 4 === 1,
};
});
};
@@ -198,12 +305,13 @@ export function HeroParticleField({ className }: { className?: string }) {
ro?.disconnect();
document.removeEventListener('visibilitychange', onVisibility);
};
}, [reduceMotion]);
}, [reduceMotion, variant, dark]);
return (
<canvas
ref={canvasRef}
data-testid="hero-particle-field"
data-variant={variant}
aria-hidden="true"
className={`absolute inset-0 pointer-events-none ${className ?? ''}`}
/>