@@ -108,7 +108,7 @@ export default function HeroBackground(): React.ReactElement {
108108 // 1 in 4 connections carries a visible data packet.
109109 const packetConns = conns . filter ( ( _ , i ) => i % 4 === 0 ) ;
110110
111- // ── Step 4: Geometric intersection nodes ─────────────── ───────────────
111+ // ── Step 4: Geometric intersection nodes (capped at 25) ───────────────
112112 const intersectionNodes : Vec2 [ ] = [ ] ;
113113 outer: for ( let i = 0 ; i < conns . length ; i ++ ) {
114114 for ( let j = i + 1 ; j < conns . length ; j ++ ) {
@@ -117,27 +117,47 @@ export default function HeroBackground(): React.ReactElement {
117117 if ( rawNodes . some ( n => ( n . x - pt . x ) ** 2 + ( n . y - pt . y ) ** 2 < 144 ) ) continue ;
118118 if ( intersectionNodes . some ( n => ( n . x - pt . x ) ** 2 + ( n . y - pt . y ) ** 2 < 64 ) ) continue ;
119119 intersectionNodes . push ( pt ) ;
120- if ( intersectionNodes . length >= 200 ) break outer;
120+ if ( intersectionNodes . length >= 25 ) break outer;
121121 }
122122 }
123123
124124 return { nodes, connections : conns , packetConns, intersectionNodes} ;
125125 } , [ ] ) ;
126126
127- // ── Global mouse listener ───────────────── ────────────────────────────────
127+ // ── Global mouse listener (rAF-throttled) ────────────────────────────────
128128 useEffect ( ( ) => {
129129 const el = containerRef . current ;
130130 if ( ! el ) return ;
131+ let rafId : number | null = null ;
131132 const onMove = ( e : MouseEvent ) => {
132- const r = el . getBoundingClientRect ( ) ;
133- const x = e . clientX - r . left ;
134- const y = e . clientY - r . top ;
135- const inside = x >= 0 && x <= r . width && y >= 0 && y <= r . height ;
136- el . style . setProperty ( '--mx' , inside ? `${ x } px` : '-400px' ) ;
137- el . style . setProperty ( '--my' , inside ? `${ y } px` : '-400px' ) ;
133+ if ( rafId ) return ;
134+ rafId = requestAnimationFrame ( ( ) => {
135+ rafId = null ;
136+ const r = el . getBoundingClientRect ( ) ;
137+ const x = e . clientX - r . left ;
138+ const y = e . clientY - r . top ;
139+ const inside = x >= 0 && x <= r . width && y >= 0 && y <= r . height ;
140+ el . style . setProperty ( '--mx' , inside ? `${ x } px` : '-400px' ) ;
141+ el . style . setProperty ( '--my' , inside ? `${ y } px` : '-400px' ) ;
142+ } ) ;
138143 } ;
139144 document . addEventListener ( 'mousemove' , onMove , { passive : true } ) ;
140- return ( ) => document . removeEventListener ( 'mousemove' , onMove ) ;
145+ return ( ) => {
146+ document . removeEventListener ( 'mousemove' , onMove ) ;
147+ if ( rafId ) cancelAnimationFrame ( rafId ) ;
148+ } ;
149+ } , [ ] ) ;
150+
151+ // ── IntersectionObserver — pause animations when off-screen ──────────────
152+ useEffect ( ( ) => {
153+ const el = containerRef . current ;
154+ if ( ! el ) return ;
155+ const obs = new IntersectionObserver (
156+ ( [ entry ] ) => el . classList . toggle ( 'paused' , ! entry . isIntersecting ) ,
157+ { threshold : 0 } ,
158+ ) ;
159+ obs . observe ( el ) ;
160+ return ( ) => obs . disconnect ( ) ;
141161 } , [ ] ) ;
142162
143163 return (
@@ -156,31 +176,22 @@ export default function HeroBackground(): React.ReactElement {
156176 aria-hidden = "true"
157177 >
158178 { /* ── LAYER 1: BLUEPRINT ──────────────────────────────────────────── */ }
179+ { /* Filters removed — nodes are rendered at group opacity 0.15 and */ }
180+ { /* are invisible at that opacity; no filter defs needed. */ }
159181 < svg
160182 width = "100%" height = "100%"
161183 viewBox = { `0 0 ${ W } ${ H } ` }
162184 preserveAspectRatio = "xMidYMid slice"
163185 style = { { position : 'absolute' , inset : 0 , display : 'block' , pointerEvents : 'none' } }
164186 >
165- < defs >
166- < filter id = "bp-hub" x = "-150%" y = "-150%" width = "400%" height = "400%" >
167- < feGaussianBlur in = "SourceGraphic" stdDeviation = "3" result = "b" />
168- < feMerge > < feMergeNode in = "b" /> < feMergeNode in = "SourceGraphic" /> </ feMerge >
169- </ filter >
170- < filter id = "bp-node" x = "-80%" y = "-80%" width = "260%" height = "260%" >
171- < feGaussianBlur in = "SourceGraphic" stdDeviation = "1.2" result = "b" />
172- < feMerge > < feMergeNode in = "b" /> < feMergeNode in = "SourceGraphic" /> </ feMerge >
173- </ filter >
174- </ defs >
175187 < g opacity = "0.15" >
176188 { connections . map ( c => (
177189 < line key = { c . id } x1 = { c . from . x } y1 = { c . from . y } x2 = { c . to . x } y2 = { c . to . y }
178190 stroke = { CYAN } strokeWidth = "0.4" />
179191 ) ) }
180192 { nodes . map ( n => (
181193 < circle key = { n . id } cx = { n . x } cy = { n . y } r = { n . size }
182- fill = { n . isHub ? CYAN : INDIGO }
183- filter = { n . isHub ? 'url(#bp-hub)' : 'url(#bp-node)' } />
194+ fill = { n . isHub ? CYAN : INDIGO } />
184195 ) ) }
185196 { intersectionNodes . map ( ( n , i ) => (
186197 < circle key = { `ix-${ i } ` } cx = { n . x } cy = { n . y } r = "0.9" fill = { CYAN } />
@@ -228,41 +239,51 @@ export default function HeroBackground(): React.ReactElement {
228239 </ circle >
229240 ) ) }
230241
242+ { /* Packet arrival rings — CSS animation replaces SMIL <animate r> */ }
231243 { packetConns . map ( c => {
232- const arrivalBegin = `${ c . pktBegin + c . pktDur } s` ;
233- const period = `${ c . pktDur } s` ;
244+ // Arrival fires after one full transit from the staggered start.
245+ // pktBegin is negative (e.g. -1.2s) meaning the packet is mid-flight
246+ // at t=0. The ring should fire at t = pktDur + pktBegin (which can be
247+ // negative — CSS handles negative delays correctly as an offset into
248+ // the animation cycle).
249+ const delay = `${ c . pktDur + c . pktBegin } s` ;
234250 return (
235251 < circle key = { `ring-${ c . id } ` } cx = { c . to . x } cy = { c . to . y }
236- r = "2" fill = "none" stroke = { GREEN } strokeWidth = "1.2" >
237- < animate attributeName = "r" values = "2;18;18" keyTimes = "0;0.7;1"
238- dur = { period } begin = { arrivalBegin } repeatCount = "indefinite"
239- calcMode = "spline" keySplines = "0.1 0.8 0.2 1; 0 0 0 0" />
240- < animate attributeName = "stroke-opacity" values = "0.8;0;0" keyTimes = "0;0.7;1"
241- dur = { period } begin = { arrivalBegin } repeatCount = "indefinite"
242- calcMode = "spline" keySplines = "0.15 0.8 0.2 1; 0 0 0 0" />
243- </ circle >
252+ r = "18" fill = "none" stroke = { GREEN } strokeWidth = "1.2"
253+ style = { {
254+ transformBox : 'fill-box' ,
255+ transformOrigin : 'center' ,
256+ animation : `pkt-ring ${ c . pktDur } s ${ delay } infinite` ,
257+ } } />
244258 ) ;
245259 } ) }
246260
247- { nodes . map ( n => (
248- < circle key = { n . id } cx = { n . x } cy = { n . y } r = { n . size }
249- fill = { n . isHub ? CYAN : INDIGO }
250- filter = { n . isHub ? 'url(#rv-hub)' : 'url(#rv-node)' } />
251- ) ) }
261+ { /* Hub nodes — filter applied to group, one paint per group */ }
262+ < g filter = "url(#rv-hub)" >
263+ { nodes . filter ( n => n . isHub ) . map ( n => (
264+ < circle key = { n . id } cx = { n . x } cy = { n . y } r = { n . size } fill = { CYAN } />
265+ ) ) }
266+ </ g >
267+
268+ { /* Regular nodes — filter applied to group */ }
269+ < g filter = "url(#rv-node)" >
270+ { nodes . filter ( n => ! n . isHub ) . map ( n => (
271+ < circle key = { n . id } cx = { n . x } cy = { n . y } r = { n . size } fill = { INDIGO } />
272+ ) ) }
273+ </ g >
252274
275+ { /* Intersection node pulses — CSS animation replaces SMIL <animate r> */ }
253276 { intersectionNodes . map ( ( n , i ) => {
254- const pulseBegin = `${ ( i * 0.41 ) % 3 } s` ;
277+ const delay = `${ ( i * 0.41 ) % 3 } s` ;
255278 return (
256279 < React . Fragment key = { `ix-${ i } ` } >
257280 < circle cx = { n . x } cy = { n . y } r = "0.9" fill = { CYAN } opacity = "0.7" />
258- < circle cx = { n . x } cy = { n . y } r = "0.9" fill = "none" stroke = { GREEN } strokeWidth = "0.8" >
259- < animate attributeName = "r" values = "0.9;14;14" keyTimes = "0;0.7;1"
260- dur = "3s" begin = { pulseBegin } repeatCount = "indefinite"
261- calcMode = "spline" keySplines = "0.1 0.8 0.2 1; 0 0 0 0" />
262- < animate attributeName = "stroke-opacity" values = "0.6;0;0" keyTimes = "0;0.7;1"
263- dur = "3s" begin = { pulseBegin } repeatCount = "indefinite"
264- calcMode = "spline" keySplines = "0.15 0.8 0.2 1; 0 0 0 0" />
265- </ circle >
281+ < circle cx = { n . x } cy = { n . y } r = "14" fill = "none" stroke = { GREEN } strokeWidth = "0.8"
282+ style = { {
283+ transformBox : 'fill-box' ,
284+ transformOrigin : 'center' ,
285+ animation : `ix-pulse 3s ${ delay } infinite` ,
286+ } } />
266287 </ React . Fragment >
267288 ) ;
268289 } ) }
0 commit comments