+
{hookStages.map((stage, i) => {
const yPct = ((FIRST_Y + i * SPACING) / VB_H * 100).toFixed(2);
const xPct = (nodeCoords[i].x / VB_W * 100).toFixed(2);
return (
-
-
{stage.name}()
-
{stage.note}
+
+ {stage.name}()
+ {stage.note}
);
@@ -163,73 +175,15 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
);
}
- .hook-grid {
- position: absolute;
- inset: 0;
- pointer-events: none;
- border-radius: calc(var(--radius) * 1.2);
- background-image: radial-gradient(circle, var(--site-grid-dot) 1px, transparent 1px);
- background-size: 18px 18px;
- opacity: 0.18;
- }
-
- /* ── Trail (hidden until JS activates) ── */
- .hook-trail {
- opacity: 0;
- stroke-dasharray: 0.14 0.86;
- stroke-dashoffset: 0.14;
- }
-
- /* ── Desktop / mobile path toggle (unscoped for SVG) ── */
- :global(.hook-paths-mobile) { display: none; }
- @media (max-width: 639px) {
- :global(.hook-paths-desktop) { display: none; }
- :global(.hook-paths-mobile) { display: block; }
- .hook-node { left: 50% !important; }
- }
-
- /* ── Node card — D2 / Mermaid tech-diagram feel ── */
- .hook-card {
- display: flex;
- flex-direction: column;
- gap: 1px;
- padding: 7px 14px;
- background: var(--card);
- white-space: nowrap;
- box-shadow: var(--site-shadow-card);
- text-align: center;
- transition:
- border-color 0.4s ease,
- box-shadow 0.4s ease;
- will-change: box-shadow;
- }
-
- .hook-card-name {
- font-family: var(--font-mono);
- font-size: 0.7rem;
- font-weight: 600;
- line-height: 1.3;
- color: var(--foreground);
- transition: color 0.35s ease;
- }
-
- .hook-card-note {
- font-family: var(--font-mono);
- font-size: 0.55rem;
- line-height: 1.3;
- color: var(--muted-foreground);
- opacity: 0.6;
- transition: opacity 0.35s ease, color 0.35s ease;
- }
-
- /* ── Active node: ping flash ── */
.hook-node.active .hook-card {
border-color: var(--primary);
animation: ring-ping 2s ease-out forwards;
}
+
.hook-node.active .hook-card-name {
color: var(--primary);
}
+
.hook-node.active .hook-card-note {
opacity: 1;
}
@@ -278,12 +232,11 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
const CX_MOBILE = 200;
const CYCLE_DUR = 7;
- 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;
+ const TRAIL_FRAME_MS = 1000 / 60;
type PathSample = {
x: number;
@@ -313,11 +266,10 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
let currentMobile: boolean | null = null;
const nodeEls = Array.from(root.querySelectorAll('[data-hook-node]')) as HTMLElement[];
- const dot = root.querySelector('[data-hook-dot]') as SVGCircleElement | null;
- const dotTrail = root.querySelector('[data-hook-dot-trail]') as SVGCircleElement | null;
+ const dot = root.querySelector('[data-hook-dot]') as HTMLElement | null;
if (!dot || !nodeEls.length) return;
- const mql = window.matchMedia('(max-width: 639px)');
+ const mql = window.matchMedia('(width < 40rem)');
const reduceMotionMql = window.matchMedia('(prefers-reduced-motion: reduce)');
function stopAnimation() {
@@ -347,6 +299,9 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
if (!svgPath || !trail) return;
const totalLen = svgPath.getTotalLength();
+ const viewBox = svgPath.ownerSVGElement?.viewBox.baseVal;
+ const viewBoxWidth = viewBox?.width || 400;
+ const viewBoxHeight = viewBox?.height || 1;
const nodeCoords = nodeEls.map((el) => {
const idx = Number(el.dataset.hookNode);
@@ -386,49 +341,75 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;
const cycleMs = CYCLE_DUR * 1000;
const loopMs = cycleMs + REPEAT_DELAY_MS;
const startedAt = performance.now();
+ let frameId = 0;
+ let dotsVisible = false;
+ let lastTrailUpdate = 0;
+
+ function getSample(v: number) {
+ const samplePosition = Math.min(PATH_SAMPLE_COUNT, Math.max(0, v * PATH_SAMPLE_COUNT));
+ const sampleIndex = Math.floor(samplePosition);
+ const nextIndex = Math.min(PATH_SAMPLE_COUNT, sampleIndex + 1);
+ const mix = samplePosition - sampleIndex;
+ const sample = samples[sampleIndex];
+ const next = samples[nextIndex];
+
+ return {
+ x: sample.x + (next.x - sample.x) * mix,
+ y: sample.y + (next.y - sample.y) * mix,
+ opacity: sample.opacity + (next.opacity - sample.opacity) * mix,
+ nearest: samples[Math.round(samplePosition)].nearest,
+ };
+ }
- function updateFrame() {
- const elapsed = (performance.now() - startedAt) % loopMs;
+ function getTransform(sample: PathSample) {
+ const x = (sample.x / viewBoxWidth * 100).toFixed(4);
+ const y = (sample.y / viewBoxHeight * 100).toFixed(4);
+ return `translate3d(${x}%, ${y}%, 0)`;
+ }
+
+ function setDotsVisible(visible: boolean) {
+ if (visible === dotsVisible) return;
+ dotsVisible = visible;
+ const visibility = visible ? 'visible' : 'hidden';
+ dot!.style.visibility = visibility;
+ }
+
+ function updateFrame(now: number) {
+ const elapsed = (now - startedAt) % loopMs;
if (elapsed > cycleMs) {
- dot!.setAttribute('opacity', '0');
- dotTrail?.setAttribute('opacity', '0');
- trail!.style.opacity = '0';
+ if (dotsVisible) {
+ dot!.style.opacity = '0';
+ trail!.style.opacity = '0';
+ setDotsVisible(false);
+ }
highlightNode(-1);
+ frameId = window.requestAnimationFrame(updateFrame);
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];
+ const sample = getSample(v);
- 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');
- }
+ dot!.style.transform = getTransform(sample);
+ dot!.style.opacity = String(sample.opacity);
+ setDotsVisible(true);
- trail!.style.strokeDashoffset = String(0.14 - v);
- trail!.style.opacity = String(sample.opacity * 0.35);
+ if (now - lastTrailUpdate >= TRAIL_FRAME_MS) {
+ trail!.style.strokeDashoffset = String(0.14 - v);
+ trail!.style.opacity = String(sample.opacity * 0.35);
+ lastTrailUpdate = now;
+ }
highlightNode(sample.nearest);
+ frameId = window.requestAnimationFrame(updateFrame);
}
- updateFrame();
- const intervalId = window.setInterval(updateFrame, HOOK_FRAME_MS);
+ frameId = window.requestAnimationFrame(updateFrame);
stopAnim = () => {
- window.clearInterval(intervalId);
- dot!.setAttribute('opacity', '0');
- dot!.setAttribute('visibility', 'hidden');
- dotTrail?.setAttribute('opacity', '0');
- dotTrail?.setAttribute('visibility', 'hidden');
+ window.cancelAnimationFrame(frameId);
+ dot!.style.opacity = '0';
+ dot!.style.transform = '';
+ setDotsVisible(false);
trail!.style.opacity = '0';
trail!.style.strokeDashoffset = '0.14';
activeIdx = -1;