|
|
|
@ -1,9 +1,192 @@ |
|
|
|
import type { CSSProperties } from 'react'; |
|
|
|
import { useEffect, useRef, type CSSProperties } from 'react'; |
|
|
|
import { getLocalizedPath, getTranslation, type Locale } from '../i18n/utils'; |
|
|
|
import { PLACEHOLDER_IMG } from '../placeholder'; |
|
|
|
import { cssVars } from './css'; |
|
|
|
import { Icon, statsIcons } from '../icons'; |
|
|
|
|
|
|
|
/** |
|
|
|
* 首屏全屏粒子 + 邻近连线 Canvas 背景: |
|
|
|
* - 4 色粒子(白/蓝/紫/极光淡粉)约 70~95 颗(按屏幕面积自适应) |
|
|
|
* - 邻近粒子间画淡蓝连线(dist < 120)形成柔和"网" |
|
|
|
* - 鼠标靠近时粒子轻微吸附,连线高亮 |
|
|
|
* - prefers-reduced-motion 下固定为静态散点,不执行动画 |
|
|
|
*/ |
|
|
|
function HeroParticleCanvas() { |
|
|
|
const ref = useRef<HTMLCanvasElement>(null); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
const canvas = ref.current; |
|
|
|
if (!canvas) return; |
|
|
|
const ctx = canvas.getContext('2d'); |
|
|
|
if (!ctx) return; |
|
|
|
|
|
|
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; |
|
|
|
let raf = 0; |
|
|
|
let dpr = Math.min(window.devicePixelRatio || 1, 2); |
|
|
|
|
|
|
|
type P = { |
|
|
|
x: number; y: number; |
|
|
|
vx: number; vy: number; |
|
|
|
r: number; |
|
|
|
color: string; |
|
|
|
alpha: number; |
|
|
|
alphaSpeed: number; |
|
|
|
}; |
|
|
|
|
|
|
|
const PALETTE = [ |
|
|
|
'255, 255, 255', // 白星
|
|
|
|
'148, 184, 255', // 品牌淡蓝
|
|
|
|
'196, 178, 255', // 薰衣草紫
|
|
|
|
'255, 180, 215', // 极光淡粉
|
|
|
|
]; |
|
|
|
|
|
|
|
let W = 0, H = 0; |
|
|
|
let particles: P[] = []; |
|
|
|
const mouse = { x: -9999, y: -9999, active: false }; |
|
|
|
|
|
|
|
const resize = () => { |
|
|
|
const { clientWidth, clientHeight } = canvas.parentElement || canvas; |
|
|
|
W = clientWidth; |
|
|
|
H = clientHeight; |
|
|
|
dpr = Math.min(window.devicePixelRatio || 1, 2); |
|
|
|
canvas.width = Math.floor(W * dpr); |
|
|
|
canvas.height = Math.floor(H * dpr); |
|
|
|
canvas.style.width = W + 'px'; |
|
|
|
canvas.style.height = H + 'px'; |
|
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); |
|
|
|
|
|
|
|
const area = W * H; |
|
|
|
const count = Math.max(55, Math.min(100, Math.floor(area / 14000))); |
|
|
|
particles = Array.from({ length: count }, () => ({ |
|
|
|
x: Math.random() * W, |
|
|
|
y: Math.random() * H, |
|
|
|
vx: (Math.random() - 0.5) * 0.35, |
|
|
|
vy: (Math.random() - 0.5) * 0.35, |
|
|
|
r: Math.random() * 1.4 + 0.6, |
|
|
|
color: PALETTE[Math.floor(Math.random() * PALETTE.length)], |
|
|
|
alpha: Math.random() * 0.5 + 0.4, |
|
|
|
alphaSpeed: (Math.random() - 0.5) * 0.008, |
|
|
|
})); |
|
|
|
}; |
|
|
|
|
|
|
|
const step = () => { |
|
|
|
ctx.clearRect(0, 0, W, H); |
|
|
|
|
|
|
|
// 1) 粒子
|
|
|
|
for (let i = 0; i < particles.length; i++) { |
|
|
|
const p = particles[i]; |
|
|
|
if (!reduced) { |
|
|
|
// 鼠标吸附(力度很小,避免头晕)
|
|
|
|
if (mouse.active) { |
|
|
|
const dx = mouse.x - p.x, dy = mouse.y - p.y; |
|
|
|
const d2 = dx * dx + dy * dy; |
|
|
|
if (d2 < 140 * 140) { |
|
|
|
const f = (1 - Math.sqrt(d2) / 140) * 0.04; |
|
|
|
p.vx += dx * f * 0.02; |
|
|
|
p.vy += dy * f * 0.02; |
|
|
|
} |
|
|
|
} |
|
|
|
p.x += p.vx; |
|
|
|
p.y += p.vy; |
|
|
|
// 速度阻尼
|
|
|
|
p.vx *= 0.992; p.vy *= 0.992; |
|
|
|
// 边界反弹
|
|
|
|
if (p.x < p.r) { p.x = p.r; p.vx = Math.abs(p.vx); } |
|
|
|
if (p.x > W - p.r) { p.x = W - p.r; p.vx = -Math.abs(p.vx); } |
|
|
|
if (p.y < p.r) { p.y = p.r; p.vy = Math.abs(p.vy); } |
|
|
|
if (p.y > H - p.r) { p.y = H - p.r; p.vy = -Math.abs(p.vy); } |
|
|
|
|
|
|
|
// 闪烁 alpha
|
|
|
|
p.alpha += p.alphaSpeed; |
|
|
|
if (p.alpha > 0.95 || p.alpha < 0.35) p.alphaSpeed = -p.alphaSpeed; |
|
|
|
} |
|
|
|
|
|
|
|
ctx.beginPath(); |
|
|
|
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r * 3); |
|
|
|
g.addColorStop(0, `rgba(${p.color}, ${p.alpha})`); |
|
|
|
g.addColorStop(0.5, `rgba(${p.color}, ${p.alpha * 0.35})`); |
|
|
|
g.addColorStop(1, `rgba(${p.color}, 0)`); |
|
|
|
ctx.fillStyle = g; |
|
|
|
ctx.arc(p.x, p.y, p.r * 3, 0, Math.PI * 2); |
|
|
|
ctx.fill(); |
|
|
|
} |
|
|
|
|
|
|
|
// 2) 邻近连线(仅动画模式执行,静态省开销)
|
|
|
|
if (!reduced) { |
|
|
|
const MAX = 120; |
|
|
|
for (let i = 0; i < particles.length; i++) { |
|
|
|
const a = particles[i]; |
|
|
|
for (let j = i + 1; j < particles.length; j++) { |
|
|
|
const b = particles[j]; |
|
|
|
const dx = a.x - b.x, dy = a.y - b.y; |
|
|
|
const d2 = dx * dx + dy * dy; |
|
|
|
if (d2 < MAX * MAX) { |
|
|
|
const d = Math.sqrt(d2); |
|
|
|
const t = 1 - d / MAX; |
|
|
|
// 鼠标增强:若 a 或 b 靠近鼠标,连线加亮
|
|
|
|
let boost = 0; |
|
|
|
if (mouse.active) { |
|
|
|
const adx = a.x - mouse.x, ady = a.y - mouse.y; |
|
|
|
const bdx = b.x - mouse.x, bdy = b.y - mouse.y; |
|
|
|
const near = Math.min(adx * adx + ady * ady, bdx * bdx + bdy * bdy); |
|
|
|
if (near < 120 * 120) boost = (1 - Math.sqrt(near) / 120) * 0.45; |
|
|
|
} |
|
|
|
const alpha = Math.min(0.55, t * 0.28 + boost); |
|
|
|
ctx.strokeStyle = `rgba(148, 184, 255, ${alpha})`; |
|
|
|
ctx.lineWidth = 0.7; |
|
|
|
ctx.beginPath(); |
|
|
|
ctx.moveTo(a.x, a.y); |
|
|
|
ctx.lineTo(b.x, b.y); |
|
|
|
ctx.stroke(); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
if (!reduced) raf = requestAnimationFrame(step); |
|
|
|
}; |
|
|
|
|
|
|
|
const onMove = (e: PointerEvent) => { |
|
|
|
const rect = canvas.getBoundingClientRect(); |
|
|
|
mouse.x = e.clientX - rect.left; |
|
|
|
mouse.y = e.clientY - rect.top; |
|
|
|
mouse.active = true; |
|
|
|
}; |
|
|
|
const onLeave = () => { mouse.active = false; mouse.x = -9999; mouse.y = -9999; }; |
|
|
|
const onResize = () => { resize(); }; |
|
|
|
|
|
|
|
resize(); |
|
|
|
raf = requestAnimationFrame(step); |
|
|
|
window.addEventListener('resize', onResize); |
|
|
|
window.addEventListener('pointermove', onMove, { passive: true }); |
|
|
|
window.addEventListener('pointerleave', onLeave); |
|
|
|
|
|
|
|
return () => { |
|
|
|
cancelAnimationFrame(raf); |
|
|
|
window.removeEventListener('resize', onResize); |
|
|
|
window.removeEventListener('pointermove', onMove); |
|
|
|
window.removeEventListener('pointerleave', onLeave); |
|
|
|
}; |
|
|
|
}, []); |
|
|
|
|
|
|
|
return ( |
|
|
|
<canvas |
|
|
|
ref={ref} |
|
|
|
aria-hidden="true" |
|
|
|
className="absolute inset-0 w-full h-full pointer-events-none" |
|
|
|
style={{ zIndex: 2 }} |
|
|
|
/> |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
/** 4 颗雾化光晕 orb,慢速漂浮在背景(品牌蓝 / 薰衣草紫 / 极光粉 / 淡绿) */ |
|
|
|
const HERO_GLOW_ORBS = [ |
|
|
|
{ c: 'rgba(61, 90, 254, 0.20)', x: '8%', y: '15%', sz: 380, delay: '0s', anim: 'hero-orb-float-1' }, |
|
|
|
{ c: 'rgba(166, 140, 255, 0.18)', x: '78%', y: '72%', sz: 460, delay: '-6s', anim: 'hero-orb-float-2' }, |
|
|
|
{ c: 'rgba(255, 145, 193, 0.14)', x: '35%', y: '82%', sz: 340, delay: '-11s', anim: 'hero-orb-float-1' }, |
|
|
|
{ c: 'rgba(120, 220, 190, 0.12)', x: '60%', y: '8%', sz: 300, delay: '-3s', anim: 'hero-orb-float-2' }, |
|
|
|
] as const; |
|
|
|
|
|
|
|
export default function Home({ locale }: { locale: Locale }) { |
|
|
|
const t = getTranslation(locale); |
|
|
|
const h = t.home; |
|
|
|
@ -16,21 +199,25 @@ export default function Home({ locale }: { locale: Locale }) { |
|
|
|
|
|
|
|
const news = t.news.items.slice(0, 3); |
|
|
|
|
|
|
|
// 环绕中央玻璃球的轨道粒子:7 圈 · 16 颗发光点,linear 匀速,顺逆交错
|
|
|
|
// 半径 110~275px,明确围绕玻璃球(半径约 100px)外侧公转
|
|
|
|
// 环绕中央玻璃球的轨道粒子:7 圈 · 22 颗发光点(颜色 4 色循环)
|
|
|
|
// 半径 110~275px,围绕玻璃球(半径约 100px)外侧公转,每颗分色
|
|
|
|
const DOT_PALETTE = ['white', 'blue', 'violet', 'pink'] as const; |
|
|
|
const orbits = [ |
|
|
|
{ r: 110, d: '11s', rev: false, dots: [{ sz: 5, a: '20deg' }, { sz: 4, a: '200deg' }] }, |
|
|
|
{ r: 140, d: '14s', rev: true, dots: [{ sz: 5, a: '120deg' }, { sz: 4, a: '300deg' }] }, |
|
|
|
{ r: 170, d: '17s', rev: false, dots: [{ sz: 4, a: '60deg' }, { sz: 4, a: '210deg' }] }, |
|
|
|
{ r: 200, d: '20s', rev: true, dots: [{ sz: 4, a: '0deg' }, { sz: 3, a: '120deg' }, { sz: 3, a: '240deg' }] }, |
|
|
|
{ r: 228, d: '23s', rev: false, dots: [{ sz: 3, a: '90deg' }, { sz: 3, a: '250deg' }] }, |
|
|
|
{ r: 252, d: '26s', rev: true, dots: [{ sz: 3, a: '45deg' }, { sz: 3, a: '190deg' }, { sz: 3, a: '330deg' }] }, |
|
|
|
{ r: 275, d: '29s', rev: false, dots: [{ sz: 3, a: '15deg' }, { sz: 3, a: '160deg' }] }, |
|
|
|
{ r: 110, d: '11s', rev: false, dots: [{ sz: 5, a: '20deg' }, { sz: 4, a: '110deg' }, { sz: 5, a: '200deg' }, { sz: 4, a: '310deg' }] }, |
|
|
|
{ r: 140, d: '14s', rev: true, dots: [{ sz: 5, a: '50deg' }, { sz: 4, a: '170deg' }, { sz: 5, a: '300deg' }] }, |
|
|
|
{ r: 170, d: '17s', rev: false, dots: [{ sz: 4, a: '0deg' }, { sz: 4, a: '95deg' }, { sz: 4, a: '210deg' }, { sz: 4, a: '305deg' }] }, |
|
|
|
{ r: 200, d: '20s', rev: true, dots: [{ sz: 4, a: '40deg' }, { sz: 3, a: '140deg' }, { sz: 3, a: '240deg' }, { sz: 4, a: '330deg' }] }, |
|
|
|
{ r: 228, d: '23s', rev: false, dots: [{ sz: 3, a: '25deg' }, { sz: 3, a: '130deg' }, { sz: 3, a: '250deg' }] }, |
|
|
|
{ r: 252, d: '26s', rev: true, dots: [{ sz: 3, a: '45deg' }, { sz: 3, a: '110deg' }, { sz: 3, a: '190deg' }, { sz: 3, a: '270deg' }, { sz: 3, a: '330deg' }] }, |
|
|
|
{ r: 275, d: '29s', rev: false, dots: [{ sz: 3, a: '15deg' }, { sz: 3, a: '100deg' }, { sz: 3, a: '160deg' }, { sz: 3, a: '240deg' }, { sz: 3, a: '320deg' }] }, |
|
|
|
] as const; |
|
|
|
// 展开总长度,用于 stagger 相位
|
|
|
|
const totalOrbitDots = orbits.reduce((n, o) => n + o.dots.length, 0); |
|
|
|
void totalOrbitDots; |
|
|
|
|
|
|
|
return ( |
|
|
|
<> |
|
|
|
{/* ===== HERO:品牌背景图(玻璃球/水滴抽象,浅蓝+薰衣草,品牌色)+ 动态粒子 ===== */} |
|
|
|
{/* ===== HERO:品牌背景图 + Canvas 粒子网 + 雾化光晕 + 玻璃球轨道粒子 ===== */} |
|
|
|
<section |
|
|
|
className="relative w-full h-[calc(100vh-60px)] min-h-[520px] max-h-[900px] overflow-hidden" |
|
|
|
style={{ |
|
|
|
@ -44,16 +231,39 @@ export default function Home({ locale }: { locale: Locale }) { |
|
|
|
alt="" |
|
|
|
aria-hidden="true" |
|
|
|
className="absolute inset-0 w-full h-full object-cover" |
|
|
|
style={{ zIndex: 0, opacity: 0.92 }} |
|
|
|
loading="eager" |
|
|
|
decoding="async" |
|
|
|
/> |
|
|
|
|
|
|
|
{/* 粒子层:环绕中央玻璃球的轨道粒子(7 圈 / 16 颗,linear 匀速环绕) */} |
|
|
|
<div className="hero-orbit pointer-events-none" aria-hidden="true" style={{ left: '70%', top: '46%' }}> |
|
|
|
{/* 雾化光晕 4 球(慢速漂浮,最底层之上但粒子网之下) */} |
|
|
|
<div aria-hidden="true" className="absolute inset-0 pointer-events-none" style={{ zIndex: 1, overflow: 'hidden' }}> |
|
|
|
{HERO_GLOW_ORBS.map((o, i) => ( |
|
|
|
<span |
|
|
|
key={i} |
|
|
|
className="hero-glow-orb" |
|
|
|
style={cssVars({ |
|
|
|
'--orb-x': o.x, |
|
|
|
'--orb-y': o.y, |
|
|
|
'--orb-sz': o.sz + 'px', |
|
|
|
'--orb-color': o.c, |
|
|
|
animationName: o.anim, |
|
|
|
animationDelay: o.delay, |
|
|
|
})} |
|
|
|
/> |
|
|
|
))} |
|
|
|
</div> |
|
|
|
|
|
|
|
{/* Canvas 全屏粒子 + 邻近连线(白/蓝/紫/粉 4 色) */} |
|
|
|
<HeroParticleCanvas /> |
|
|
|
|
|
|
|
{/* 粒子层:环绕中央玻璃球的轨道粒子(7 圈 / 22 颗,4 色分色,linear 匀速环绕) */} |
|
|
|
<div className="hero-orbit pointer-events-none" aria-hidden="true" style={{ left: '70%', top: '46%', zIndex: 3 }}> |
|
|
|
{orbits.flatMap((o, i) => |
|
|
|
o.dots.map((d, j) => { |
|
|
|
// 全局 dot 序号,用于 stagger 脉冲相位
|
|
|
|
// 全局 dot 序号,用于 stagger 脉冲相位 + 颜色循环
|
|
|
|
const globalIdx = orbits.slice(0, i).reduce((n, x) => n + x.dots.length, 0) + j; |
|
|
|
const palette = DOT_PALETTE[globalIdx % DOT_PALETTE.length]; |
|
|
|
return ( |
|
|
|
<div |
|
|
|
key={`${i}-${j}`} |
|
|
|
@ -66,11 +276,11 @@ export default function Home({ locale }: { locale: Locale }) { |
|
|
|
> |
|
|
|
<div className="hero-orbit-slot" style={{ '--a': d.a } as CSSProperties}> |
|
|
|
<span |
|
|
|
className="hero-orbit-dot" |
|
|
|
className={`hero-orbit-dot dot-${palette}`} |
|
|
|
style={{ |
|
|
|
width: d.sz, |
|
|
|
height: d.sz, |
|
|
|
animationDelay: `${-(globalIdx * 0.4).toFixed(2)}s`, |
|
|
|
animationDelay: `${-(globalIdx * 0.35).toFixed(2)}s`, |
|
|
|
}} |
|
|
|
/> |
|
|
|
</div> |
|
|
|
|