Browse Source

feat(Home): 首屏粒子系统全面升级:全屏Canvas粒子网+邻近连线+鼠标吸附+4色雾化光晕球+玻璃球轨道22颗4色长拖尾彗星

master
doublex001 3 months ago
parent
commit
7b6dbb884f
  1. 242
      src/components/Home.tsx
  2. 154
      src/styles/global.css

242
src/components/Home.tsx

@ -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 { getLocalizedPath, getTranslation, type Locale } from '../i18n/utils';
import { PLACEHOLDER_IMG } from '../placeholder'; import { PLACEHOLDER_IMG } from '../placeholder';
import { cssVars } from './css'; import { cssVars } from './css';
import { Icon, statsIcons } from '../icons'; 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 }) { export default function Home({ locale }: { locale: Locale }) {
const t = getTranslation(locale); const t = getTranslation(locale);
const h = t.home; const h = t.home;
@ -16,21 +199,25 @@ export default function Home({ locale }: { locale: Locale }) {
const news = t.news.items.slice(0, 3); const news = t.news.items.slice(0, 3);
// 环绕中央玻璃球的轨道粒子:7 圈 · 16 颗发光点,linear 匀速,顺逆交错 // 环绕中央玻璃球的轨道粒子:7 圈 · 22 颗发光点(颜色 4 色循环)
// 半径 110~275px,明确围绕玻璃球(半径约 100px)外侧公转 // 半径 110~275px,围绕玻璃球(半径约 100px)外侧公转,每颗分色
const DOT_PALETTE = ['white', 'blue', 'violet', 'pink'] as const;
const orbits = [ const orbits = [
{ r: 110, d: '11s', rev: false, dots: [{ sz: 5, a: '20deg' }, { sz: 4, a: '200deg' }] }, { 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: '120deg' }, { sz: 4, a: '300deg' }] }, { 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: '60deg' }, { sz: 4, a: '210deg' }] }, { 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: '0deg' }, { sz: 3, a: '120deg' }, { sz: 3, a: '240deg' }] }, { 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: '90deg' }, { sz: 3, a: '250deg' }] }, { 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: '190deg' }, { sz: 3, a: '330deg' }] }, { 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: '160deg' }] }, { 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; ] as const;
// 展开总长度,用于 stagger 相位
const totalOrbitDots = orbits.reduce((n, o) => n + o.dots.length, 0);
void totalOrbitDots;
return ( return (
<> <>
{/* ===== HERO:品牌背景图(玻璃球/水滴抽象,浅蓝+薰衣草,品牌色)+ 动态粒子 ===== */} {/* ===== HERO:品牌背景图 + Canvas 粒子网 + 雾化光晕 + 玻璃球轨道粒子 ===== */}
<section <section
className="relative w-full h-[calc(100vh-60px)] min-h-[520px] max-h-[900px] overflow-hidden" className="relative w-full h-[calc(100vh-60px)] min-h-[520px] max-h-[900px] overflow-hidden"
style={{ style={{
@ -44,16 +231,39 @@ export default function Home({ locale }: { locale: Locale }) {
alt="" alt=""
aria-hidden="true" aria-hidden="true"
className="absolute inset-0 w-full h-full object-cover" className="absolute inset-0 w-full h-full object-cover"
style={{ zIndex: 0, opacity: 0.92 }}
loading="eager" loading="eager"
decoding="async" decoding="async"
/> />
{/* 粒子层:环绕中央玻璃球的轨道粒子(7 圈 / 16 颗,linear 匀速环绕) */} {/* 雾化光晕 4 球(慢速漂浮,最底层之上但粒子网之下) */}
<div className="hero-orbit pointer-events-none" aria-hidden="true" style={{ left: '70%', top: '46%' }}> <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) => {orbits.flatMap((o, i) =>
o.dots.map((d, j) => { 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 globalIdx = orbits.slice(0, i).reduce((n, x) => n + x.dots.length, 0) + j;
const palette = DOT_PALETTE[globalIdx % DOT_PALETTE.length];
return ( return (
<div <div
key={`${i}-${j}`} 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}> <div className="hero-orbit-slot" style={{ '--a': d.a } as CSSProperties}>
<span <span
className="hero-orbit-dot" className={`hero-orbit-dot dot-${palette}`}
style={{ style={{
width: d.sz, width: d.sz,
height: d.sz, height: d.sz,
animationDelay: `${-(globalIdx * 0.4).toFixed(2)}s`, animationDelay: `${-(globalIdx * 0.35).toFixed(2)}s`,
}} }}
/> />
</div> </div>

154
src/styles/global.css

@ -1787,15 +1787,163 @@ footer a {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* 轻微呼吸:相位由 inline animationDelay 注入(每颗错开 0.4s),避免齐刷刷 */ /* 轻微呼吸:相位由 inline animationDelay 注入(每颗错开 0.35s),避免齐刷刷 */
@keyframes heroOrbitPulse { @keyframes heroOrbitPulse {
0%, 100% { opacity: 0.6; transform: translate(-50%, -50%) scale(0.85); } 0%, 100% { opacity: 0.6; transform: translate(-50%, -50%) scale(0.85); }
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.15); } 50% { opacity: 1; transform: translate(-50%, -50%) scale(1.25); }
}
/* ===== 轨道粒子 4 色变体(白/蓝/紫/粉)+ 加长彗星拖尾 ===== */
.hero-orbit-dot.dot-white {
background: radial-gradient(
circle,
rgba(255, 255, 255, 0.98) 0%,
rgba(255, 255, 255, 0.6) 30%,
rgba(220, 230, 255, 0.35) 60%,
rgba(220, 230, 255, 0) 82%
);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.55), 0 0 18px rgba(210, 220, 255, 0.28);
}
.hero-orbit-dot.dot-white::before {
width: 44px;
background: linear-gradient(
to left,
rgba(255, 255, 255, 0.88) 0%,
rgba(200, 215, 255, 0.45) 40%,
rgba(200, 215, 255, 0) 100%
);
}
.hero-ring.is-rev .hero-orbit-dot.dot-white::before {
background: linear-gradient(
to right,
rgba(255, 255, 255, 0.88) 0%,
rgba(200, 215, 255, 0.45) 40%,
rgba(200, 215, 255, 0) 100%
);
}
.hero-orbit-dot.dot-blue {
background: radial-gradient(
circle,
rgba(210, 225, 255, 0.98) 0%,
rgba(120, 160, 255, 0.62) 32%,
rgba(70, 110, 255, 0.30) 62%,
rgba(70, 110, 255, 0) 84%
);
box-shadow: 0 0 8px rgba(120, 160, 255, 0.55), 0 0 20px rgba(61, 90, 254, 0.32);
}
.hero-orbit-dot.dot-blue::before {
width: 52px;
background: linear-gradient(
to left,
rgba(170, 198, 255, 0.9) 0%,
rgba(85, 125, 255, 0.45) 42%,
rgba(85, 125, 255, 0) 100%
);
}
.hero-ring.is-rev .hero-orbit-dot.dot-blue::before {
background: linear-gradient(
to right,
rgba(170, 198, 255, 0.9) 0%,
rgba(85, 125, 255, 0.45) 42%,
rgba(85, 125, 255, 0) 100%
);
}
.hero-orbit-dot.dot-violet {
background: radial-gradient(
circle,
rgba(232, 220, 255, 0.98) 0%,
rgba(185, 155, 255, 0.60) 32%,
rgba(150, 110, 255, 0.28) 62%,
rgba(150, 110, 255, 0) 84%
);
box-shadow: 0 0 8px rgba(185, 155, 255, 0.55), 0 0 20px rgba(150, 110, 255, 0.30);
}
.hero-orbit-dot.dot-violet::before {
width: 48px;
background: linear-gradient(
to left,
rgba(220, 205, 255, 0.9) 0%,
rgba(160, 130, 255, 0.44) 42%,
rgba(160, 130, 255, 0) 100%
);
}
.hero-ring.is-rev .hero-orbit-dot.dot-violet::before {
background: linear-gradient(
to right,
rgba(220, 205, 255, 0.9) 0%,
rgba(160, 130, 255, 0.44) 42%,
rgba(160, 130, 255, 0) 100%
);
}
.hero-orbit-dot.dot-pink {
background: radial-gradient(
circle,
rgba(255, 230, 240, 0.98) 0%,
rgba(255, 170, 205, 0.58) 32%,
rgba(255, 125, 180, 0.26) 62%,
rgba(255, 125, 180, 0) 84%
);
box-shadow: 0 0 8px rgba(255, 170, 205, 0.52), 0 0 20px rgba(255, 125, 180, 0.28);
}
.hero-orbit-dot.dot-pink::before {
width: 44px;
background: linear-gradient(
to left,
rgba(255, 215, 230, 0.9) 0%,
rgba(255, 145, 193, 0.42) 42%,
rgba(255, 145, 193, 0) 100%
);
}
.hero-ring.is-rev .hero-orbit-dot.dot-pink::before {
background: linear-gradient(
to right,
rgba(255, 215, 230, 0.9) 0%,
rgba(255, 145, 193, 0.42) 42%,
rgba(255, 145, 193, 0) 100%
);
}
/* ===== 雾化光晕 orb:超大 blur 圆,慢速漂浮 ===== */
.hero-glow-orb {
position: absolute;
left: var(--orb-x);
top: var(--orb-y);
width: var(--orb-sz);
height: var(--orb-sz);
border-radius: 9999px;
background: radial-gradient(circle, var(--orb-color) 0%, transparent 72%);
filter: blur(28px);
opacity: 0.85;
animation-duration: 18s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
will-change: transform;
pointer-events: none;
transform: translate(-50%, -50%);
}
@keyframes hero-orb-float-1 {
0%, 100% { transform: translate(-50%, -50%) translate(0, 0) scale(1); }
33% { transform: translate(-50%, -50%) translate(40px, -30px) scale(1.08); }
66% { transform: translate(-50%, -50%) translate(-30px, 25px) scale(0.94); }
}
@keyframes hero-orb-float-2 {
0%, 100% { transform: translate(-50%, -50%) translate(0, 0) scale(1); }
40% { transform: translate(-50%, -50%) translate(-36px, 30px) scale(1.05); }
75% { transform: translate(-50%, -50%) translate(28px, -28px) scale(0.97); }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.hero-ring { animation: none; } .hero-ring { animation: none; }
.hero-orbit-dot { animation: none; opacity: 0.8; } .hero-orbit-dot { animation: none; opacity: 0.82; }
.hero-glow-orb { animation: none; }
}
@media (max-width: 640px) {
.hero-glow-orb { filter: blur(18px); opacity: 0.7; }
} }
/* ================================================================ /* ================================================================

Loading…
Cancel
Save