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
200 changes: 116 additions & 84 deletions website/src/components/HookIntro.astro
Original file line number Diff line number Diff line change
Expand Up @@ -75,23 +75,6 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
fill="none"
aria-hidden="true"
>
<defs>
<filter id="hook-glow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="hook-glow-sm" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="2.5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>

<!-- Desktop: serpentine S-curve paths -->
<g class="hook-paths-desktop">
<path
Expand Down Expand Up @@ -132,24 +115,9 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
/>
</g>

<!-- Traveling dot (main) -->
<circle
r="5.5"
cx="-50" cy="-50"
fill="var(--primary)"
filter="url(#hook-glow)"
visibility="hidden"
data-hook-dot
/>
<!-- Trailing dot -->
<circle
r="3"
cx="-50" cy="-50"
fill="var(--primary)"
filter="url(#hook-glow-sm)"
visibility="hidden"
data-hook-dot-trail
/>
<!-- Traveling dots avoid animated SVG blur filters; the trail supplies the glow cue. -->
<circle r="5.5" cx="-50" cy="-50" fill="var(--primary)" opacity="0" visibility="hidden" data-hook-dot />
<circle r="3" cx="-50" cy="-50" fill="var(--primary)" opacity="0" visibility="hidden" data-hook-dot-trail />
</svg>

<!-- Node cards -->
Expand Down Expand Up @@ -295,7 +263,7 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
</style>

<script>
import { animate, inView } from 'motion';
import { inView } from 'motion';

type HookFlowWindow = Window & {
__bubHookFlowCleanup?: () => void;
Expand All @@ -313,6 +281,16 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
const TRAIL_LEAD = 0.018;
const FADE_ZONE = 0.06;
const NEAR_DIST_SQ = 36 * 36;
const PATH_SAMPLE_COUNT = 560;
const HOOK_FRAME_MS = 1000 / 30;
const REPEAT_DELAY_MS = 400;

type PathSample = {
x: number;
y: number;
opacity: number;
nearest: number;
};

/** Fade envelope: ramps in/out at path ends, 1.0 in the middle. */
function fade(v: number): number {
Expand All @@ -330,6 +308,7 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
if (!root) return;

let stopAnim: (() => void) | null = null;
let rootInView = false;
let activeIdx = -1;
let currentMobile: boolean | null = null;

Expand All @@ -339,6 +318,18 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
if (!dot || !nodeEls.length) return;

const mql = window.matchMedia('(max-width: 639px)');
const reduceMotionMql = window.matchMedia('(prefers-reduced-motion: reduce)');

function stopAnimation() {
stopAnim?.();
stopAnim = null;
currentMobile = null;
}

function maybeStartAnimation() {
if (!rootInView || document.visibilityState === 'hidden' || reduceMotionMql.matches) return;
setupAnimation(mql.matches);
}

function setupAnimation(isMobile: boolean) {
if (isMobile === currentMobile) return;
Expand All @@ -365,51 +356,75 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
};
});

const samples: PathSample[] = Array.from({ length: PATH_SAMPLE_COUNT + 1 }, (_, sampleIndex) => {
const v = sampleIndex / PATH_SAMPLE_COUNT;
const pt = svgPath.getPointAtLength(v * totalLen);
let nearest = -1;
let bestSq = NEAR_DIST_SQ;

for (let i = 0; i < nodeCoords.length; i++) {
const dx = pt.x - nodeCoords[i].x;
const dy = pt.y - nodeCoords[i].y;
const dSq = dx * dx + dy * dy;
if (dSq < bestSq) { bestSq = dSq; nearest = i; }
}

return {
x: pt.x,
y: pt.y,
opacity: fade(v),
nearest,
};
});

function highlightNode(idx: number) {
if (idx === activeIdx) return;
activeIdx = idx;
nodeEls.forEach((el, i) => el.classList.toggle('active', i === idx));
}

const controls = animate(0, 1, {
duration: CYCLE_DUR,
repeat: Infinity,
repeatDelay: 0.4,
ease: 'linear',
onUpdate(v: number) {
const f = fade(v);

const pt = svgPath!.getPointAtLength(v * totalLen);
dot!.setAttribute('cx', String(pt.x));
dot!.setAttribute('cy', String(pt.y));
dot!.setAttribute('opacity', String(f));
dot!.setAttribute('visibility', 'visible');

if (dotTrail) {
const pt2 = svgPath!.getPointAtLength(Math.max(0, v - TRAIL_LEAD) * totalLen);
dotTrail.setAttribute('cx', String(pt2.x));
dotTrail.setAttribute('cy', String(pt2.y));
dotTrail.setAttribute('opacity', String(f * 0.35));
dotTrail.setAttribute('visibility', 'visible');
}

trail!.style.strokeDashoffset = String(0.14 - v);
trail!.style.opacity = String(f * 0.35);

let nearest = -1;
let bestSq = NEAR_DIST_SQ;
for (let i = 0; i < nodeCoords.length; i++) {
const dx = pt.x - nodeCoords[i].x;
const dy = pt.y - nodeCoords[i].y;
const dSq = dx * dx + dy * dy;
if (dSq < bestSq) { bestSq = dSq; nearest = i; }
}
highlightNode(nearest);
},
});
const cycleMs = CYCLE_DUR * 1000;
const loopMs = cycleMs + REPEAT_DELAY_MS;
const startedAt = performance.now();

function updateFrame() {
const elapsed = (performance.now() - startedAt) % loopMs;
if (elapsed > cycleMs) {
dot!.setAttribute('opacity', '0');
dotTrail?.setAttribute('opacity', '0');
trail!.style.opacity = '0';
highlightNode(-1);
return;
}

const v = elapsed / cycleMs;
const sampleIndex = Math.min(PATH_SAMPLE_COUNT, Math.max(0, Math.round(v * PATH_SAMPLE_COUNT)));
const sample = samples[sampleIndex];

dot!.setAttribute('cx', String(sample.x));
dot!.setAttribute('cy', String(sample.y));
dot!.setAttribute('opacity', String(sample.opacity));
dot!.setAttribute('visibility', 'visible');

if (dotTrail) {
const trailIndex = Math.max(0, sampleIndex - Math.round(TRAIL_LEAD * PATH_SAMPLE_COUNT));
const trailSample = samples[trailIndex];
dotTrail.setAttribute('cx', String(trailSample.x));
dotTrail.setAttribute('cy', String(trailSample.y));
dotTrail.setAttribute('opacity', String(sample.opacity * 0.35));
dotTrail.setAttribute('visibility', 'visible');
}

trail!.style.strokeDashoffset = String(0.14 - v);
trail!.style.opacity = String(sample.opacity * 0.35);
highlightNode(sample.nearest);
}

updateFrame();
const intervalId = window.setInterval(updateFrame, HOOK_FRAME_MS);

stopAnim = () => {
controls.stop();
window.clearInterval(intervalId);
dot!.setAttribute('opacity', '0');
dot!.setAttribute('visibility', 'hidden');
dotTrail?.setAttribute('opacity', '0');
Expand All @@ -423,29 +438,46 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;

const handleMediaChange = () => {
const wasRunning = stopAnim !== null;
stopAnim?.();
stopAnim = null;
currentMobile = null;
if (wasRunning) setupAnimation(mql.matches);
stopAnimation();
if (wasRunning) maybeStartAnimation();
};

const handleMotionPreferenceChange = () => {
if (reduceMotionMql.matches) {
stopAnimation();
} else {
maybeStartAnimation();
}
};

const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
stopAnimation();
} else {
maybeStartAnimation();
}
};

mql.addEventListener('change', handleMediaChange);
reduceMotionMql.addEventListener('change', handleMotionPreferenceChange);
document.addEventListener('visibilitychange', handleVisibilityChange);

const stopInView = inView(root, () => {
setupAnimation(mql.matches);
rootInView = true;
maybeStartAnimation();
return () => {
stopAnim?.();
stopAnim = null;
currentMobile = null;
rootInView = false;
stopAnimation();
};
}, { margin: '-8% 0px' });

hookFlowWindow.__bubHookFlowCleanup = () => {
stopInView();
mql.removeEventListener('change', handleMediaChange);
stopAnim?.();
stopAnim = null;
currentMobile = null;
reduceMotionMql.removeEventListener('change', handleMotionPreferenceChange);
document.removeEventListener('visibilitychange', handleVisibilityChange);
rootInView = false;
stopAnimation();
};
}

Expand Down
Loading
Loading