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:
@@ -5,7 +5,7 @@ import { motion } from 'framer-motion';
|
|||||||
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { StaticLink } from '@/components/ui/static-link';
|
import { StaticLink } from '@/components/ui/static-link';
|
||||||
import { HeroParticleField } from '@/components/sections/hero-particle-field';
|
import { HeroParticleField, type HeroVariant } from '@/components/sections/hero-particle-field';
|
||||||
import { ArrowRight, Lightbulb, Database, Layers, Code, Puzzle, Server, Quote, Calendar } from 'lucide-react';
|
import { ArrowRight, Lightbulb, Database, Layers, Code, Puzzle, Server, Quote, Calendar } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { EASE_OUT } from '@/components/ui/page-decoration';
|
import { EASE_OUT } from '@/components/ui/page-decoration';
|
||||||
@@ -136,12 +136,23 @@ function StyledTitle({ lines }: { lines: (string | undefined)[] }) {
|
|||||||
|
|
||||||
// ===== 品牌 CTA 按钮已抽离为可复用 UI 组件:@/components/ui/cta-button =====
|
// ===== 品牌 CTA 按钮已抽离为可复用 UI 组件:@/components/ui/cta-button =====
|
||||||
|
|
||||||
// ===== Hero 区:浅色 editorial 画布 + 品牌红单电压 =====
|
// ===== Hero 区:浅色画布 + 精密工程网格科技感 + 品牌红单电压 =====
|
||||||
// 设计方向(2026-08-31 重设计,参考 Accenture Radical Relevance):
|
// 设计方向(2026-08-31 重设计,参考 Accenture Radical Relevance;2026-09-01 科技感升级):
|
||||||
// - 声明先行:H1 → 主张 → CTA,一屏只负责「一句话说清你是谁 + 一个行动」
|
// - 声明先行:H1 → 主张 → CTA,一屏只负责「一句话说清你是谁 + 一个行动」
|
||||||
// - 数据指标下沉为独立成果带(HeroStatsBand),由滚动触发(Glance 层),不再挤在 hero 内
|
// - 数据指标下沉为独立成果带(HeroStatsBand),由滚动触发(Glance 层),不再挤在 hero 内
|
||||||
// - 单一电压:hero 内只有品牌红一个彩色,其余全走中性 token
|
// - 单一电压:hero 内只有品牌红一个彩色;连线走墨色(浅底)/中性灰(深底),不抢品牌红
|
||||||
// - 字重收敛:大标题 700(原 900),对齐 Accenture「字重 9→3」的克制
|
// - 字重收敛:大标题 700(原 900),对齐 Accenture「字重 9→3」的克制
|
||||||
|
// - 科技感引擎:HeroParticleField 变体(固定种子、reduced-motion/自动化降级 → 快照零漂移)
|
||||||
|
//
|
||||||
|
// ===== HERO_VARIANT 观感开关(默认 'grid':浅色底 + 工程网格科技感)=====
|
||||||
|
// 定调:Hero 保持浅色背景(与全站浅色咨询风一致),科技感由「精密工程网格 + 动态粒子 + 墨色细连线」承担,
|
||||||
|
// 不做深色 Hero。深色变体保留待选(改这一行即生效,无需改其它代码):
|
||||||
|
// 'grid' 浅色工程网格 + 品牌红漂移节点 + 墨色细连线 ← 默认落地
|
||||||
|
// 'network' 深色节点网络(品牌红节点 + 中性灰连线)
|
||||||
|
// 'constellation' 深色数据星图(品牌红脉冲节点 + 暗白次级点 + 竖向数据流)
|
||||||
|
// 'spotlight' 深色聚光 + 粒子 + 工程网格(纵深最强,复用 L2 光标聚光)
|
||||||
|
const HERO_VARIANT: HeroVariant = 'grid';
|
||||||
|
|
||||||
function HeroSection({ heroData }: {
|
function HeroSection({ heroData }: {
|
||||||
heroData?: Record<string, any> | null;
|
heroData?: Record<string, any> | null;
|
||||||
}) {
|
}) {
|
||||||
@@ -150,6 +161,12 @@ function HeroSection({ heroData }: {
|
|||||||
const siteConfig = useSiteConfig();
|
const siteConfig = useSiteConfig();
|
||||||
const reduceMotion = useReducedMotion();
|
const reduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
// 科技感变体开关(见文件顶部 HERO_VARIANT 注释):深色变体统一走深色画布 + 主题感知文字
|
||||||
|
const dark = HERO_VARIANT !== 'grid';
|
||||||
|
const showGrid = HERO_VARIANT === 'constellation' || HERO_VARIANT === 'spotlight'; // 深色工程网格
|
||||||
|
const showLightGrid = HERO_VARIANT === 'grid'; // 浅色工程网格(墨色 hairline,精密质感)
|
||||||
|
const showCursorGlow = HERO_VARIANT === 'spotlight';
|
||||||
|
|
||||||
const heading = heroData?.heading || heroData?.headingBottom || '让每一家企业都拥有数据驱动的决策能力';
|
const heading = heroData?.heading || heroData?.headingBottom || '让每一家企业都拥有数据驱动的决策能力';
|
||||||
const subheading = heroData?.subheading || heroData?.description || '';
|
const subheading = heroData?.subheading || heroData?.description || '';
|
||||||
const ctaLabel = heroData?.ctaLabel || '预约咨询';
|
const ctaLabel = heroData?.ctaLabel || '预约咨询';
|
||||||
@@ -161,7 +178,7 @@ function HeroSection({ heroData }: {
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative min-h-[90vh] flex items-center overflow-hidden bg-bg-primary"
|
className={`relative min-h-[90vh] flex items-center overflow-hidden ${dark ? 'bg-dark-bg text-white' : 'bg-bg-primary'}`}
|
||||||
data-testid="hero-section"
|
data-testid="hero-section"
|
||||||
onPointerMove={(e) => {
|
onPointerMove={(e) => {
|
||||||
if (!spotRef.current || !containerRef.current) return;
|
if (!spotRef.current || !containerRef.current) return;
|
||||||
@@ -170,21 +187,25 @@ function HeroSection({ heroData }: {
|
|||||||
spotRef.current.style.setProperty('--spot-y', `${e.clientY - rect.top}px`);
|
spotRef.current.style.setProperty('--spot-y', `${e.clientY - rect.top}px`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* L1 静态点阵:token 化(颜色/alpha 随主题反色),深底自动切中性浅灰 */}
|
{/* L1 工程网格:浅底墨色 hairline(grid)/ 深底白色 hairline(constellation、spotlight) */}
|
||||||
<div className="absolute inset-0 pointer-events-none hero-dot-grid" aria-hidden="true" />
|
{showLightGrid && (
|
||||||
{/* L2 光标聚光(Skim 层):mask 跟随鼠标,仅写 2 个 CSS 变量,无 canvas、无逐帧 JS;
|
<div className="absolute inset-0 pointer-events-none hero-grid-overlay--light" aria-hidden="true" />
|
||||||
reduced-motion 下不渲染,避免动态 */}
|
|
||||||
{!reduceMotion && (
|
|
||||||
<div ref={spotRef} className="absolute inset-0 pointer-events-none hero-dot-spot opacity-60" aria-hidden="true" />
|
|
||||||
)}
|
)}
|
||||||
{/* L3 动态粒子(品牌红点缀):rAF 持续循环漂移;
|
{showGrid && (
|
||||||
reduced-motion / 自动化环境下降级为一次性静态帧 */}
|
<div className="absolute inset-0 pointer-events-none hero-grid-overlay--dark" aria-hidden="true" />
|
||||||
<HeroParticleField />
|
)}
|
||||||
|
{/* L2 品牌红光标聚光(仅 spotlight 变体 + 非 reduced-motion):跟随鼠标,仅写 2 个 CSS 变量,无 canvas、无逐帧 JS */}
|
||||||
|
{showCursorGlow && !reduceMotion && (
|
||||||
|
<div ref={spotRef} className="absolute inset-0 pointer-events-none hero-spot-glow opacity-70" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{/* L3 动态粒子(品牌红点缀 + 科技感变体):rAF 持续循环漂移;
|
||||||
|
reduced-motion / 自动化环境下降级为一次性静态帧(连线/星图位置由固定种子复现 → 快照零漂移) */}
|
||||||
|
<HeroParticleField variant={HERO_VARIANT} dark={dark} />
|
||||||
|
|
||||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-16 sm:py-20 md:py-24 lg:py-24">
|
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-16 sm:py-20 md:py-24 lg:py-24">
|
||||||
<div className="grid items-center gap-16">
|
<div className="grid items-center gap-16">
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
{/* 品牌口号 Badge:浅色画布上的品牌红信号(hero 不重复渲染 Logo,避免与导航栏重复) */}
|
{/* 品牌口号 Badge:画布上的品牌红信号(深底走 brand-light 保证对比度;hero 不重复渲染 Logo,避免与导航栏重复) */}
|
||||||
{slogan && (
|
{slogan && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@@ -192,18 +213,18 @@ function HeroSection({ heroData }: {
|
|||||||
transition={{ duration: 0.35, delay: 0.03, ease: EASE_OUT }}
|
transition={{ duration: 0.35, delay: 0.03, ease: EASE_OUT }}
|
||||||
className="mb-6"
|
className="mb-6"
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-brand/5 text-brand text-sm font-medium border border-brand/15">
|
<span className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border ${dark ? 'bg-brand/10 text-brand-light border-brand/30' : 'bg-brand/5 text-brand border-brand/15'}`}>
|
||||||
{slogan}
|
{slogan}
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 主标题:大胆、直接(浅色画布上的墨色宣言) */}
|
{/* 主标题:大胆、直接(深底白色 / 浅底墨色,随 HERO_VARIANT 切换) */}
|
||||||
<motion.h1
|
<motion.h1
|
||||||
initial={{ opacity: 0, y: 40 }}
|
initial={{ opacity: 0, y: 40 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.45, delay: 0.06, ease: EASE_OUT }}
|
transition={{ duration: 0.45, delay: 0.06, ease: EASE_OUT }}
|
||||||
className="text-4xl sm:text-5xl md:text-6xl lg:text-6xl 2xl:text-7xl font-bold text-ink leading-[1.05] tracking-tight mb-7 sm:mb-8"
|
className={`text-4xl sm:text-5xl md:text-6xl lg:text-6xl 2xl:text-7xl font-bold leading-[1.05] tracking-tight mb-7 sm:mb-8 ${dark ? 'text-white' : 'text-ink'}`}
|
||||||
>
|
>
|
||||||
{heading}
|
{heading}
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
@@ -214,7 +235,7 @@ function HeroSection({ heroData }: {
|
|||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.35, delay: 0.16, ease: EASE_OUT }}
|
transition={{ duration: 0.35, delay: 0.16, ease: EASE_OUT }}
|
||||||
className="text-lg sm:text-xl text-text-secondary max-w-2xl leading-relaxed mb-10"
|
className={`text-lg sm:text-xl max-w-2xl leading-relaxed mb-10 ${dark ? 'text-white/70' : 'text-text-secondary'}`}
|
||||||
>
|
>
|
||||||
{subheading}
|
{subheading}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
@@ -232,7 +253,7 @@ function HeroSection({ heroData }: {
|
|||||||
<a
|
<a
|
||||||
href={secondaryCtaHref}
|
href={secondaryCtaHref}
|
||||||
data-testid="hero-secondary-link"
|
data-testid="hero-secondary-link"
|
||||||
className="group inline-flex items-center gap-2 text-sm font-medium text-text-secondary hover:text-ink transition-colors duration-200 py-4"
|
className={`group inline-flex items-center gap-2 text-sm font-medium transition-colors duration-200 py-4 ${dark ? 'text-white/70 hover:text-white' : 'text-text-secondary hover:text-ink'}`}
|
||||||
>
|
>
|
||||||
{secondaryCtaLabel}
|
{secondaryCtaLabel}
|
||||||
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||||
|
|||||||
+24
-17
@@ -790,29 +790,36 @@ html[data-theme='dark'] body {
|
|||||||
background-size: 24px 24px;
|
background-size: 24px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Hero 背景点阵:L1 静态 + L2 光标聚光 ===
|
/* === Hero 工程网格 + 品牌红光标聚光(L1 静态网格 + L2 聚光)===
|
||||||
颜色 / 透明度走 CSS 变量,暗黑模式在 html[data-theme='dark'] 覆盖,零 JS 自动反色。
|
L1 工程网格(hero-grid-overlay--light/dark):墨色 hairline 44px 网格 + 径向遮罩聚焦版心,零 JS 自动反色。
|
||||||
实测:浅色 brand 红 @0.12 → 1.22:1;深底中性浅灰 @0.08 → 1.20:1(可感知不抢戏)。
|
L2 聚光层(hero-spot-glow)用 2 个 CSS 变量(--spot-x/--spot-y)跟随光标,无 canvas、无逐帧 JS。
|
||||||
原 hero 内联点阵为 rgba(196,30,58,0.4)+opacity 0.03,实测仅 1.05:1,等于不可见。
|
旧点阵类 hero-dot-grid/hero-dot-spot 已被工程网格取代,2026-09-01 清理。 */
|
||||||
L2 聚光层用 mask + 2 个 CSS 变量(--spot-x/--spot-y),无 canvas、无逐帧 JS。 */
|
|
||||||
:root {
|
:root {
|
||||||
--hero-dot-color: rgba(var(--color-brand-rgb), 0.12);
|
|
||||||
--hero-dot-size: 24px;
|
|
||||||
--spot-x: 50%;
|
--spot-x: 50%;
|
||||||
--spot-y: 50%;
|
--spot-y: 50%;
|
||||||
}
|
}
|
||||||
html[data-theme='dark'] {
|
|
||||||
--hero-dot-color: rgba(var(--color-text-muted-rgb), 0.08);
|
/* Hero 科技感工程网格层(浅色/深色 Hero 共用,跟随 HERO_VARIANT 切换) */
|
||||||
|
.hero-grid-overlay--dark {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
|
||||||
|
background-size: 44px 44px;
|
||||||
|
-webkit-mask-image: radial-gradient(circle at 50% 50%, #000 35%, transparent 82%);
|
||||||
|
mask-image: radial-gradient(circle at 50% 50%, #000 35%, transparent 82%);
|
||||||
}
|
}
|
||||||
.hero-dot-grid {
|
.hero-grid-overlay--light {
|
||||||
background-image: radial-gradient(circle at 1px 1px, var(--hero-dot-color) 1px, transparent 1px);
|
background-image:
|
||||||
background-size: var(--hero-dot-size) var(--hero-dot-size);
|
linear-gradient(rgba(10, 14, 20, 0.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(10, 14, 20, 0.05) 1px, transparent 1px);
|
||||||
|
background-size: 44px 44px;
|
||||||
|
-webkit-mask-image: radial-gradient(circle at 50% 50%, #000 35%, transparent 82%);
|
||||||
|
mask-image: radial-gradient(circle at 50% 50%, #000 35%, transparent 82%);
|
||||||
}
|
}
|
||||||
.hero-dot-spot {
|
/* 品牌红光标聚光(spotlight 变体):跟随 --spot-x/--spot-y,无 canvas、无逐帧 JS */
|
||||||
background-image: radial-gradient(circle at 1px 1px, var(--hero-dot-color) 1px, transparent 1px);
|
.hero-spot-glow {
|
||||||
background-size: var(--hero-dot-size) var(--hero-dot-size);
|
background: radial-gradient(440px circle at var(--spot-x) var(--spot-y),
|
||||||
-webkit-mask-image: radial-gradient(280px circle at var(--spot-x) var(--spot-y), #000 0%, transparent 72%);
|
rgba(var(--color-brand-rgb), 0.16), transparent 60%);
|
||||||
mask-image: radial-gradient(280px circle at var(--spot-x) var(--spot-y), #000 0%, transparent 72%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-grain {
|
.bg-grain {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hero 动态粒子层(循环漂移动画)
|
* Hero 动态粒子层(循环漂移动画 + 科技感变体)
|
||||||
*
|
*
|
||||||
* 设计约束对齐(CLAUDE.md / CONTEXT.md / Impeccable 审计):
|
* 设计约束对齐(CLAUDE.md / CONTEXT.md / Impeccable 审计):
|
||||||
* - 循环动画(用户特批豁免「无持续循环」铁律):rAF 持续漂移,离屏/隐藏标签页即停。
|
* - 循环动画(用户特批豁免「无持续循环」铁律):rAF 持续漂移,离屏/隐藏标签页即停。
|
||||||
@@ -9,10 +9,18 @@
|
|||||||
* 品牌红面积占比 <0.1%,远低于单电压 10% 上限,不作为大背景。
|
* 品牌红面积占比 <0.1%,远低于单电压 10% 上限,不作为大背景。
|
||||||
* - reduced-motion:直接绘制一次静态帧,不进入 rAF 循环。
|
* - reduced-motion:直接绘制一次静态帧,不进入 rAF 循环。
|
||||||
* - 自动化环境稳定:navigator.webdriver 为真(Playwright 默认)时同样降级为静态帧,
|
* - 自动化环境稳定:navigator.webdriver 为真(Playwright 默认)时同样降级为静态帧,
|
||||||
* 且位置由固定种子 mulberry32 复现——与改动前「入场后静止」的冻结帧像素完全一致,
|
* 且位置由固定种子 mulberry32 复现——与改动前「入场后静止」的冻结帧像素一致,
|
||||||
* 因此视觉回归快照基线不发生漂移。
|
* 因此视觉回归快照基线不发生漂移(连线/星图位置同样由固定种子复现)。
|
||||||
* - 性能:DPR 限幅(≤2)、粒子数随面积封顶、离屏/隐藏即停、ResizeObserver 重排。
|
* - 性能:DPR 限幅(≤2)、粒子数随面积封顶、离屏/隐藏即停、ResizeObserver 重排。
|
||||||
* - 无特效库:纯自包含 canvas,不引入任何第三方动画/粒子依赖(effects/ 已删除)。
|
* - 无特效库:纯自包含 canvas,不引入任何第三方动画/粒子依赖(effects/ 已删除)。
|
||||||
|
*
|
||||||
|
* 变体(variant prop,默认 'drift' 兼容旧调用):
|
||||||
|
* - 'drift' 仅品牌红小点漂移(原行为,浅色 Hero 用)
|
||||||
|
* - 'grid' 浅色科技感:墨色细连线 + 品牌红节点(网格由 HeroSection 的 CSS 层承担)
|
||||||
|
* - 'network' 品牌红节点 + 邻近中性灰连线(深色 Hero 默认,科技感最强)
|
||||||
|
* - 'constellation' 品牌红脉冲节点 + 暗白次级点 + 极淡竖向数据流(NovaVis 看板语义)
|
||||||
|
* - 'spotlight' 同 network + 工程网格 + 品牌红光标聚光(纵深最强)
|
||||||
|
* 连线/辉光仅作用于深色底(dark=true);浅色变体用墨色细线。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
@@ -23,6 +31,25 @@ const MAX_PARTICLES = 56;
|
|||||||
const DENSITY_DIVISOR = 26000; // 每 ~26000px² 一个粒子,面积驱动密度封顶
|
const DENSITY_DIVISOR = 26000; // 每 ~26000px² 一个粒子,面积驱动密度封顶
|
||||||
const SEED = 0x9e3779b9; // 固定种子 → 初始位置可复现(快照稳定)
|
const SEED = 0x9e3779b9; // 固定种子 → 初始位置可复现(快照稳定)
|
||||||
const BASE_SPEED = 0.25; // px / 16ms,极缓漂移
|
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 {
|
interface Particle {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -31,6 +58,7 @@ interface Particle {
|
|||||||
vy: number;
|
vy: number;
|
||||||
r: number;
|
r: number;
|
||||||
alpha: number;
|
alpha: number;
|
||||||
|
dim: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readBrandRgb(): [number, number, number] {
|
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 canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const reduceMotion = useReducedMotion();
|
const reduceMotion = useReducedMotion();
|
||||||
|
|
||||||
@@ -72,6 +108,9 @@ export function HeroParticleField({ className }: { className?: string }) {
|
|||||||
const parent = canvas.parentElement;
|
const parent = canvas.parentElement;
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
|
|
||||||
|
// 变体配置在 effect 内派生:随 variant 重建(VARIANT_CONFIG 为模块级常量,无需入依赖数组)
|
||||||
|
const cfg = VARIANT_CONFIG[variant];
|
||||||
|
|
||||||
const brand = readBrandRgb();
|
const brand = readBrandRgb();
|
||||||
let particles: Particle[] = [];
|
let particles: Particle[] = [];
|
||||||
let raf = 0;
|
let raf = 0;
|
||||||
@@ -84,13 +123,79 @@ export function HeroParticleField({ className }: { className?: string }) {
|
|||||||
(navigator as unknown as { webdriver?: boolean }).webdriver === true;
|
(navigator as unknown as { webdriver?: boolean }).webdriver === true;
|
||||||
|
|
||||||
const paint = () => {
|
const paint = () => {
|
||||||
|
// 透明画布:仅绘制粒子层,背景由 section 承载(支持深色/浅色 Hero 切换)
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
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) {
|
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.beginPath();
|
||||||
ctx.fillStyle = `rgba(${brand[0]}, ${brand[1]}, ${brand[2]}, ${p.alpha})`;
|
ctx.arc(p.x, p.y, p.dim ? p.r * 0.7 : p.r, 0, Math.PI * 2);
|
||||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const build = () => {
|
const build = () => {
|
||||||
@@ -104,8 +209,8 @@ export function HeroParticleField({ className }: { className?: string }) {
|
|||||||
|
|
||||||
const area = rect.width * rect.height;
|
const area = rect.width * rect.height;
|
||||||
const count = Math.min(MAX_PARTICLES, Math.round(area / DENSITY_DIVISOR));
|
const count = Math.min(MAX_PARTICLES, Math.round(area / DENSITY_DIVISOR));
|
||||||
const rng = mulberry32(SEED); // 固定种子 → 位置可复现
|
const rng = mulberry32(SEED); // 固定种子 → 位置可复现(含 dim 标记不消耗额外 rng)
|
||||||
particles = Array.from({ length: Math.max(0, count) }, () => {
|
particles = Array.from({ length: Math.max(0, count) }, (_, i) => {
|
||||||
const ang = rng() * Math.PI * 2;
|
const ang = rng() * Math.PI * 2;
|
||||||
const sp = BASE_SPEED * (0.5 + rng());
|
const sp = BASE_SPEED * (0.5 + rng());
|
||||||
return {
|
return {
|
||||||
@@ -115,6 +220,8 @@ export function HeroParticleField({ className }: { className?: string }) {
|
|||||||
vy: Math.sin(ang) * sp,
|
vy: Math.sin(ang) * sp,
|
||||||
r: 0.8 + rng() * 1.8,
|
r: 0.8 + rng() * 1.8,
|
||||||
alpha: 0.35 + rng() * 0.5,
|
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();
|
ro?.disconnect();
|
||||||
document.removeEventListener('visibilitychange', onVisibility);
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
};
|
};
|
||||||
}, [reduceMotion]);
|
}, [reduceMotion, variant, dark]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
data-testid="hero-particle-field"
|
data-testid="hero-particle-field"
|
||||||
|
data-variant={variant}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`absolute inset-0 pointer-events-none ${className ?? ''}`}
|
className={`absolute inset-0 pointer-events-none ${className ?? ''}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user