Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="en" className="h-full overflow-hidden">
<html lang="en" className="h-full overflow-hidden" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
Expand Down
2 changes: 1 addition & 1 deletion web/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default function Home() {
<div className="h-full flex flex-col bg-[#0a0a0f] text-white">
<Navbar />
<main className="flex-1 relative overflow-y-auto">
<ParticleBackground particleCount={260} mouseRadius={250} />
<ParticleBackground particleCount={300} mouseRadius={400} />
<div className="relative z-10 max-w-3xl mx-auto px-8 py-24">
<h2 className="text-xs uppercase tracking-wider text-white/30 mb-4">Current activity</h2>
<Link
Expand Down
122 changes: 82 additions & 40 deletions web/src/components/ParticleBackground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,6 @@

import { useEffect, useRef } from 'react'

// 颜色内联(原 UIColors / ParticleColors)
const CREAM = '#fbf0d9'
const STEEL_BLUE = '#669aba'
const PARTICLE_COLORS = ['#be1420', '#669aba', '#fbf0d9', '#ffffff', '#a8d4f0', '#ff9f43', '#ffcc80', '#7ec8e3']

interface Particle {
x: number
y: number
Expand All @@ -19,19 +14,29 @@ interface Particle {

interface ParticleBackgroundProps {
particleCount?: number
connectionDistance?: number
mouseRadius?: number
}

// Astrolabe color palette
const colors = [
'#be1420', // red
'#669aba', // steel blue
'#fbf0d9', // cream
'#ffffff', // white
'#a8d4f0', // light blue
'#ff9f43', // orange
'#ffcc80', // light orange
'#7ec8e3', // sky blue
]

export default function ParticleBackground({
particleCount = 60,
connectionDistance = 120,
mouseRadius = 150,
particleCount = 80,
mouseRadius = 200,
}: ParticleBackgroundProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const mouseRef = useRef({ x: -1000, y: -1000 })
const particlesRef = useRef<Particle[]>([])
const animationRef = useRef<number>(undefined)
const animationRef = useRef<number | null>(null)

useEffect(() => {
const canvas = canvasRef.current
Expand All @@ -53,11 +58,11 @@ export default function ParticleBackground({
particlesRef.current.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.5,
vx: (Math.random() - 0.5) * 0.4,
vy: (Math.random() - 0.5) * 0.4,
size: Math.random() * 1.5 + 0.8,
color: PARTICLE_COLORS[Math.floor(Math.random() * PARTICLE_COLORS.length)],
opacity: Math.random() * 0.4 + 0.6,
color: colors[Math.floor(Math.random() * colors.length)],
opacity: Math.random() * 0.3 + 0.7,
})
}
}
Expand All @@ -69,24 +74,40 @@ export default function ParticleBackground({
const handleMouseLeave = () => {
mouseRef.current = { x: -1000, y: -1000 }
}

// Touch events for mobile
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length > 0) {
const touch = e.touches[0]
mouseRef.current = { x: touch.clientX, y: touch.clientY }
}
}
const handleTouchEnd = () => {
mouseRef.current = { x: -1000, y: -1000 }
}

window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseleave', handleMouseLeave)
window.addEventListener('touchstart', handleTouchMove, { passive: true })
window.addEventListener('touchmove', handleTouchMove, { passive: true })
window.addEventListener('touchend', handleTouchEnd)

const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height)

const particles = particlesRef.current
const mouse = mouseRef.current

const hasConnection: boolean[] = new Array(particles.length).fill(false)
// Use smaller radius on mobile for less dense connections
const isMobile = canvas.width < 768
const effectiveRadius = isMobile ? mouseRadius * 0.7 : mouseRadius

particles.forEach((p, i) => {
// Calculate mouse distance for each particle
const mouseDistances: number[] = []
particles.forEach((p) => {
const mouseDx = p.x - mouse.x
const mouseDy = p.y - mouse.y
const mouseDistance = Math.sqrt(mouseDx * mouseDx + mouseDy * mouseDy)
if (mouseDistance < mouseRadius) {
hasConnection[i] = true
}
mouseDistances.push(Math.sqrt(mouseDx * mouseDx + mouseDy * mouseDy))
})

particles.forEach((p, i) => {
Expand All @@ -99,8 +120,21 @@ export default function ParticleBackground({
p.x = Math.max(0, Math.min(canvas.width, p.x))
p.y = Math.max(0, Math.min(canvas.height, p.y))

const displaySize = hasConnection[i] ? p.size : p.size * 0.5
const displayOpacity = hasConnection[i] ? p.opacity : p.opacity * 0.7
const mouseDistance = mouseDistances[i]

// Smoothly interpolate size and opacity based on distance
// influence: 1 at center, 0 at edge, with smooth falloff
const influence = mouseDistance < effectiveRadius
? Math.pow(1 - mouseDistance / effectiveRadius, 1.5) // smooth cubic falloff
: 0

const minSize = p.size * 0.5
const maxSize = p.size * 1.8
const displaySize = minSize + (maxSize - minSize) * influence

const minOpacity = p.opacity * 0.7
const maxOpacity = p.opacity
const displayOpacity = minOpacity + (maxOpacity - minOpacity) * influence

ctx.beginPath()
ctx.arc(p.x, p.y, displaySize, 0, Math.PI * 2)
Expand All @@ -109,40 +143,45 @@ export default function ParticleBackground({
ctx.fill()
ctx.globalAlpha = 1

const mouseDx = p.x - mouse.x
const mouseDy = p.y - mouse.y
const mouseDistance = Math.sqrt(mouseDx * mouseDx + mouseDy * mouseDy)

if (mouseDistance < mouseRadius) {
const opacity = (1 - mouseDistance / mouseRadius) * 0.4
if (mouseDistance < effectiveRadius) {
// Smooth quadratic falloff for line opacity
const lineOpacity = Math.pow(1 - mouseDistance / effectiveRadius, 2) * (isMobile ? 0.4 : 0.5)
ctx.beginPath()
ctx.moveTo(p.x, p.y)
ctx.lineTo(mouse.x, mouse.y)
ctx.strokeStyle = CREAM
ctx.globalAlpha = opacity
ctx.lineWidth = 1
ctx.strokeStyle = '#fbf0d9'
ctx.globalAlpha = lineOpacity
ctx.lineWidth = 0.5 + lineOpacity * 1.5 // line width also fades
ctx.stroke()
ctx.globalAlpha = 1

particles.forEach((p2, j) => {
if (i >= j) return
const p2MouseDx = p2.x - mouse.x
const p2MouseDy = p2.y - mouse.y
const p2MouseDistance = Math.sqrt(p2MouseDx * p2MouseDx + p2MouseDy * p2MouseDy)
const p2MouseDistance = mouseDistances[j]

if (p2MouseDistance < mouseRadius) {
if (p2MouseDistance < effectiveRadius) {
const dx = p.x - p2.x
const dy = p.y - p2.y
const distance = Math.sqrt(dx * dx + dy * dy)

if (distance < mouseRadius) {
const opacity = (1 - distance / mouseRadius) * 0.25
// On mobile, only draw lines between closer particles
const maxLineDistance = isMobile ? effectiveRadius * 0.6 : effectiveRadius

if (distance < maxLineDistance) {
// Combined influence: both particles' proximity to mouse affects line
const avgInfluence = (
Math.pow(1 - mouseDistance / effectiveRadius, 2) +
Math.pow(1 - p2MouseDistance / effectiveRadius, 2)
) / 2
const distanceFactor = Math.pow(1 - distance / maxLineDistance, 1.5)
const opacity = avgInfluence * distanceFactor * (isMobile ? 0.3 : 0.35)

ctx.beginPath()
ctx.moveTo(p.x, p.y)
ctx.lineTo(p2.x, p2.y)
ctx.strokeStyle = STEEL_BLUE
ctx.strokeStyle = '#669aba'
ctx.globalAlpha = opacity
ctx.lineWidth = 0.8
ctx.lineWidth = 0.3 + opacity * 1.5
ctx.stroke()
ctx.globalAlpha = 1
}
Expand All @@ -159,11 +198,14 @@ export default function ParticleBackground({
window.removeEventListener('resize', resizeCanvas)
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseleave', handleMouseLeave)
window.removeEventListener('touchstart', handleTouchMove)
window.removeEventListener('touchmove', handleTouchMove)
window.removeEventListener('touchend', handleTouchEnd)
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
}
}, [particleCount, connectionDistance, mouseRadius])
}, [particleCount, mouseRadius])

return (
<canvas
Expand Down
Loading