1+ "use client" ;
2+
3+ import { useEffect , useRef } from "react" ;
4+ import { createTimer } from "animejs" ;
5+
6+ const PARTICLE_COUNT = 300 ;
7+
8+ interface Particle {
9+ x : number ;
10+ y : number ;
11+ z : number ;
12+ }
13+
14+ function generateParticles ( count : number ) : Particle [ ] {
15+ const particles : Particle [ ] = [ ] ;
16+ for ( let i = 0 ; i < count ; i ++ ) {
17+ const r = Math . random ( ) * 2 ;
18+ const theta = Math . random ( ) * Math . PI * 2 ;
19+ const phi = Math . acos ( 2 * Math . random ( ) - 1 ) ;
20+ particles . push ( {
21+ x : r * Math . sin ( phi ) * Math . cos ( theta ) ,
22+ y : r * Math . sin ( phi ) * Math . sin ( theta ) ,
23+ z : r * Math . cos ( phi ) ,
24+ } ) ;
25+ }
26+ return particles ;
27+ }
28+
29+ const PARTICLES = generateParticles ( PARTICLE_COUNT ) ;
30+
31+
32+ /**
33+ * Renders the animated particle sphere background for the hero section.
34+ * Uses a canvas-based animation and runs as a decorative visual layer.
35+ */
36+ export default function Canvas ( ) {
37+ const canvasRef = useRef < HTMLCanvasElement > ( null ) ;
38+ const rotationRef = useRef ( { x : 0 , y : 0 } ) ;
39+
40+ useEffect ( ( ) => {
41+ const canvas = canvasRef . current ;
42+ if ( ! canvas ) return ;
43+
44+ const ctx = canvas . getContext ( "2d" ) ;
45+ if ( ! ctx ) return ;
46+
47+ const resize = ( ) => {
48+ canvas . width = canvas . offsetWidth * Math . min ( window . devicePixelRatio , 1.5 ) ;
49+ canvas . height = canvas . offsetHeight * Math . min ( window . devicePixelRatio , 1.5 ) ;
50+ } ;
51+ resize ( ) ;
52+ const ro = new ResizeObserver ( resize ) ;
53+ ro . observe ( canvas ) ;
54+
55+ const timer = createTimer ( {
56+ loop : true ,
57+ onUpdate ( self ) {
58+ const t = self . currentTime / 1000 ; // seconds
59+ rotationRef . current . x = t * 0.03 ;
60+ rotationRef . current . y = t * 0.05 ;
61+
62+ const w = canvas . width ;
63+ const h = canvas . height ;
64+ const cx = w / 2 ;
65+ const cy = h / 2 ;
66+ const scale = Math . min ( w , h ) * 0.35 ;
67+ const fov = 3 ; // perspective divisor
68+
69+ ctx . clearRect ( 0 , 0 , w , h ) ;
70+
71+ const rx = rotationRef . current . x ;
72+ const ry = rotationRef . current . y ;
73+ const cosX = Math . cos ( rx ) , sinX = Math . sin ( rx ) ;
74+ const cosY = Math . cos ( ry ) , sinY = Math . sin ( ry ) ;
75+
76+ for ( const p of PARTICLES ) {
77+ // Rotate Y then X
78+ const x1 = p . x * cosY - p . z * sinY ;
79+ const z1 = p . x * sinY + p . z * cosY ;
80+ const y2 = p . y * cosX - z1 * sinX ;
81+ const z2 = p . y * sinX + z1 * cosX ;
82+
83+ // Perspective projection
84+ const perspective = fov / ( fov + z2 ) ;
85+ const sx = cx + x1 * scale * perspective ;
86+ const sy = cy + y2 * scale * perspective ;
87+ const r = Math . max ( 0.5 , 1.5 * perspective ) ;
88+ const alpha = 0.15 + 0.35 * perspective ;
89+
90+ ctx . beginPath ( ) ;
91+ ctx . arc ( sx , sy , r , 0 , Math . PI * 2 ) ;
92+ ctx . fillStyle = `rgba(196, 146, 42, ${ alpha } )` ;
93+ ctx . fill ( ) ;
94+ }
95+ } ,
96+ } ) ;
97+
98+ return ( ) => {
99+ timer . cancel ( ) ;
100+ ro . disconnect ( ) ;
101+ } ;
102+ } , [ ] ) ;
103+
104+ return (
105+ < div aria-hidden = "true" className = "absolute inset-0 -z-10" >
106+ < canvas ref = { canvasRef } className = "w-full h-full" />
107+ </ div >
108+ ) ;
109+ }
0 commit comments