Skip to content

Commit 81afe5c

Browse files
committed
reduce cpu/gpu strain of the hero image
1 parent 60424f6 commit 81afe5c

2 files changed

Lines changed: 87 additions & 46 deletions

File tree

packages/test-site/src/components/HeroBackground.tsx

Lines changed: 67 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -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
})}

packages/test-site/src/custom/custom.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,26 @@ input.error {
131131
* This file only handles layout, the legibility overlay, and text styling.
132132
*/
133133

134+
/* GPU-composited keyframes for SVG ring/pulse animations.
135+
* r is set to max statically; transform: scale() starts near zero so the
136+
* visual matches the original SMIL r 0→max behaviour. */
137+
@keyframes ix-pulse {
138+
0% { transform: scale(0.064); opacity: 0.6; }
139+
70% { transform: scale(1); opacity: 0; }
140+
100% { transform: scale(1); opacity: 0; }
141+
}
142+
143+
@keyframes pkt-ring {
144+
0% { transform: scale(0.111); opacity: 0.8; }
145+
70% { transform: scale(1); opacity: 0; }
146+
100% { transform: scale(1); opacity: 0; }
147+
}
148+
149+
/* Pause all CSS animations when the hero is off-screen (set by IntersectionObserver). */
150+
.paused * {
151+
animation-play-state: paused !important;
152+
}
153+
134154
/* Stage: full-width hero.
135155
* z-index: 4 keeps the hero ON TOP of the feature section (z-index: 3).
136156
* ::after is a gradient overlay that fades the hero bottom to #0f172a —

0 commit comments

Comments
 (0)