@@ -75,23 +75,6 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
7575 fill =" none"
7676 aria-hidden =" true"
7777 >
78- <defs >
79- <filter id =" hook-glow" x =" -100%" y =" -100%" width =" 300%" height =" 300%" >
80- <feGaussianBlur stdDeviation =" 5" result =" blur" />
81- <feMerge >
82- <feMergeNode in =" blur" />
83- <feMergeNode in =" SourceGraphic" />
84- </feMerge >
85- </filter >
86- <filter id =" hook-glow-sm" x =" -100%" y =" -100%" width =" 300%" height =" 300%" >
87- <feGaussianBlur stdDeviation =" 2.5" result =" blur" />
88- <feMerge >
89- <feMergeNode in =" blur" />
90- <feMergeNode in =" SourceGraphic" />
91- </feMerge >
92- </filter >
93- </defs >
94-
9578 <!-- Desktop: serpentine S-curve paths -->
9679 <g class =" hook-paths-desktop" >
9780 <path
@@ -132,24 +115,9 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
132115 />
133116 </g >
134117
135- <!-- Traveling dot (main) -->
136- <circle
137- r =" 5.5"
138- cx =" -50" cy =" -50"
139- fill =" var(--primary)"
140- filter =" url(#hook-glow)"
141- visibility =" hidden"
142- data-hook-dot
143- />
144- <!-- Trailing dot -->
145- <circle
146- r =" 3"
147- cx =" -50" cy =" -50"
148- fill =" var(--primary)"
149- filter =" url(#hook-glow-sm)"
150- visibility =" hidden"
151- data-hook-dot-trail
152- />
118+ <!-- Traveling dots avoid animated SVG blur filters; the trail supplies the glow cue. -->
119+ <circle r =" 5.5" cx =" -50" cy =" -50" fill =" var(--primary)" opacity =" 0" visibility =" hidden" data-hook-dot />
120+ <circle r =" 3" cx =" -50" cy =" -50" fill =" var(--primary)" opacity =" 0" visibility =" hidden" data-hook-dot-trail />
153121 </svg >
154122
155123 <!-- Node cards -->
@@ -295,7 +263,7 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
295263</style >
296264
297265<script >
298- import { animate, inView } from 'motion';
266+ import { inView } from 'motion';
299267
300268 type HookFlowWindow = Window & {
301269 __bubHookFlowCleanup?: () => void;
@@ -313,6 +281,16 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
313281 const TRAIL_LEAD = 0.018;
314282 const FADE_ZONE = 0.06;
315283 const NEAR_DIST_SQ = 36 * 36;
284+ const PATH_SAMPLE_COUNT = 560;
285+ const HOOK_FRAME_MS = 1000 / 30;
286+ const REPEAT_DELAY_MS = 400;
287+
288+ type PathSample = {
289+ x: number;
290+ y: number;
291+ opacity: number;
292+ nearest: number;
293+ };
316294
317295 /** Fade envelope: ramps in/out at path ends, 1.0 in the middle. */
318296 function fade(v: number): number {
@@ -330,6 +308,7 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
330308 if (!root) return;
331309
332310 let stopAnim: (() => void) | null = null;
311+ let rootInView = false;
333312 let activeIdx = -1;
334313 let currentMobile: boolean | null = null;
335314
@@ -339,6 +318,18 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
339318 if (!dot || !nodeEls.length) return;
340319
341320 const mql = window.matchMedia('(max-width: 639px)');
321+ const reduceMotionMql = window.matchMedia('(prefers-reduced-motion: reduce)');
322+
323+ function stopAnimation() {
324+ stopAnim?.();
325+ stopAnim = null;
326+ currentMobile = null;
327+ }
328+
329+ function maybeStartAnimation() {
330+ if (!rootInView || document.visibilityState === 'hidden' || reduceMotionMql.matches) return;
331+ setupAnimation(mql.matches);
332+ }
342333
343334 function setupAnimation(isMobile: boolean) {
344335 if (isMobile === currentMobile) return;
@@ -365,51 +356,75 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
365356 };
366357 });
367358
359+ const samples: PathSample[] = Array.from({ length: PATH_SAMPLE_COUNT + 1 }, (_, sampleIndex) => {
360+ const v = sampleIndex / PATH_SAMPLE_COUNT;
361+ const pt = svgPath.getPointAtLength(v * totalLen);
362+ let nearest = -1;
363+ let bestSq = NEAR_DIST_SQ;
364+
365+ for (let i = 0; i < nodeCoords.length; i++) {
366+ const dx = pt.x - nodeCoords[i].x;
367+ const dy = pt.y - nodeCoords[i].y;
368+ const dSq = dx * dx + dy * dy;
369+ if (dSq < bestSq) { bestSq = dSq; nearest = i; }
370+ }
371+
372+ return {
373+ x: pt.x,
374+ y: pt.y,
375+ opacity: fade(v),
376+ nearest,
377+ };
378+ });
379+
368380 function highlightNode(idx: number) {
369381 if (idx === activeIdx) return;
370382 activeIdx = idx;
371383 nodeEls.forEach((el, i) => el.classList.toggle('active', i === idx));
372384 }
373385
374- const controls = animate(0, 1, {
375- duration: CYCLE_DUR,
376- repeat: Infinity,
377- repeatDelay: 0.4,
378- ease: 'linear',
379- onUpdate(v: number) {
380- const f = fade(v);
381-
382- const pt = svgPath!.getPointAtLength(v * totalLen);
383- dot!.setAttribute('cx', String(pt.x));
384- dot!.setAttribute('cy', String(pt.y));
385- dot!.setAttribute('opacity', String(f));
386- dot!.setAttribute('visibility', 'visible');
387-
388- if (dotTrail) {
389- const pt2 = svgPath!.getPointAtLength(Math.max(0, v - TRAIL_LEAD) * totalLen);
390- dotTrail.setAttribute('cx', String(pt2.x));
391- dotTrail.setAttribute('cy', String(pt2.y));
392- dotTrail.setAttribute('opacity', String(f * 0.35));
393- dotTrail.setAttribute('visibility', 'visible');
394- }
395-
396- trail!.style.strokeDashoffset = String(0.14 - v);
397- trail!.style.opacity = String(f * 0.35);
398-
399- let nearest = -1;
400- let bestSq = NEAR_DIST_SQ;
401- for (let i = 0; i < nodeCoords.length; i++) {
402- const dx = pt.x - nodeCoords[i].x;
403- const dy = pt.y - nodeCoords[i].y;
404- const dSq = dx * dx + dy * dy;
405- if (dSq < bestSq) { bestSq = dSq; nearest = i; }
406- }
407- highlightNode(nearest);
408- },
409- });
386+ const cycleMs = CYCLE_DUR * 1000;
387+ const loopMs = cycleMs + REPEAT_DELAY_MS;
388+ const startedAt = performance.now();
389+
390+ function updateFrame() {
391+ const elapsed = (performance.now() - startedAt) % loopMs;
392+ if (elapsed > cycleMs) {
393+ dot!.setAttribute('opacity', '0');
394+ dotTrail?.setAttribute('opacity', '0');
395+ trail!.style.opacity = '0';
396+ highlightNode(-1);
397+ return;
398+ }
399+
400+ const v = elapsed / cycleMs;
401+ const sampleIndex = Math.min(PATH_SAMPLE_COUNT, Math.max(0, Math.round(v * PATH_SAMPLE_COUNT)));
402+ const sample = samples[sampleIndex];
403+
404+ dot!.setAttribute('cx', String(sample.x));
405+ dot!.setAttribute('cy', String(sample.y));
406+ dot!.setAttribute('opacity', String(sample.opacity));
407+ dot!.setAttribute('visibility', 'visible');
408+
409+ if (dotTrail) {
410+ const trailIndex = Math.max(0, sampleIndex - Math.round(TRAIL_LEAD * PATH_SAMPLE_COUNT));
411+ const trailSample = samples[trailIndex];
412+ dotTrail.setAttribute('cx', String(trailSample.x));
413+ dotTrail.setAttribute('cy', String(trailSample.y));
414+ dotTrail.setAttribute('opacity', String(sample.opacity * 0.35));
415+ dotTrail.setAttribute('visibility', 'visible');
416+ }
417+
418+ trail!.style.strokeDashoffset = String(0.14 - v);
419+ trail!.style.opacity = String(sample.opacity * 0.35);
420+ highlightNode(sample.nearest);
421+ }
422+
423+ updateFrame();
424+ const intervalId = window.setInterval(updateFrame, HOOK_FRAME_MS);
410425
411426 stopAnim = () => {
412- controls.stop( );
427+ window.clearInterval(intervalId );
413428 dot!.setAttribute('opacity', '0');
414429 dot!.setAttribute('visibility', 'hidden');
415430 dotTrail?.setAttribute('opacity', '0');
@@ -423,29 +438,46 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
423438
424439 const handleMediaChange = () => {
425440 const wasRunning = stopAnim !== null;
426- stopAnim?.();
427- stopAnim = null;
428- currentMobile = null;
429- if (wasRunning) setupAnimation(mql.matches);
441+ stopAnimation();
442+ if (wasRunning) maybeStartAnimation();
443+ };
444+
445+ const handleMotionPreferenceChange = () => {
446+ if (reduceMotionMql.matches) {
447+ stopAnimation();
448+ } else {
449+ maybeStartAnimation();
450+ }
451+ };
452+
453+ const handleVisibilityChange = () => {
454+ if (document.visibilityState === 'hidden') {
455+ stopAnimation();
456+ } else {
457+ maybeStartAnimation();
458+ }
430459 };
431460
432461 mql.addEventListener('change', handleMediaChange);
462+ reduceMotionMql.addEventListener('change', handleMotionPreferenceChange);
463+ document.addEventListener('visibilitychange', handleVisibilityChange);
433464
434465 const stopInView = inView(root, () => {
435- setupAnimation(mql.matches);
466+ rootInView = true;
467+ maybeStartAnimation();
436468 return () => {
437- stopAnim?.();
438- stopAnim = null;
439- currentMobile = null;
469+ rootInView = false;
470+ stopAnimation();
440471 };
441472 }, { margin: '-8% 0px' });
442473
443474 hookFlowWindow.__bubHookFlowCleanup = () => {
444475 stopInView();
445476 mql.removeEventListener('change', handleMediaChange);
446- stopAnim?.();
447- stopAnim = null;
448- currentMobile = null;
477+ reduceMotionMql.removeEventListener('change', handleMotionPreferenceChange);
478+ document.removeEventListener('visibilitychange', handleVisibilityChange);
479+ rootInView = false;
480+ stopAnimation();
449481 };
450482 }
451483
0 commit comments