/*
[RFC-0802] GSAP mountain journey animation. Animates a marker along an SVG
route path to a score position and zooms the camera out simultaneously.
Triggered by form submission, not scroll. Respects prefers-reduced-motion.
- Do not introduce app-specific runtime composition or deployment behavior into this reusable package source file.
- Do not use ScrollTrigger — this animation is form-triggered, not scroll-triggered.
- RFC-0802: initial implementation with MotionPathPlugin, form-triggered animation, and reduced-motion fallback.
*/
export interface MountainJourneyAnimationOptions {
sceneSelector: string;
visualSelector: string;
routeSelector: string;
markerSelector: string;
formSelector: string;
errorSelector: string;
workerEndpoint: string;
}
const CAMERA_INITIAL_ZOOM = 3;
const CAMERA_FINAL_ZOOM = 1.0;
const ANIMATION_DURATION = 3;
const DEFAULT_SCORE = 30;
export async function initMountainJourneyAnimation(
options: MountainJourneyAnimationOptions,
): Promise {
const scene = document.querySelector(options.sceneSelector);
if (!scene) return;
const visual = document.querySelector(options.visualSelector);
const route = document.querySelector(options.routeSelector);
const marker = document.querySelector(options.markerSelector);
const form = document.querySelector(options.formSelector);
const errorEl = document.querySelector(options.errorSelector);
if (!visual || !route || !marker || !form || !errorEl) return;
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const startPoint = route.getPointAtLength(0);
marker.setAttribute("cx", String(startPoint.x));
marker.setAttribute("cy", String(startPoint.y));
if (!prefersReducedMotion) {
visual.style.transform = `scale(${CAMERA_INITIAL_ZOOM})`;
}
let activeTl: { kill: () => void } | null = null;
let hasAnimated = false;
const input = form.querySelector('input[name="url"]');
if (input) {
input.addEventListener("input", () => {
if (!hasAnimated) return;
hasAnimated = false;
errorEl.hidden = true;
if (activeTl) {
activeTl.kill();
activeTl = null;
}
if (prefersReducedMotion) {
marker.setAttribute("cx", String(startPoint.x));
marker.setAttribute("cy", String(startPoint.y));
return;
}
void (async () => {
try {
const { gsap } = await import("gsap");
const currentCx = parseFloat(marker.getAttribute("cx") ?? String(startPoint.x));
const currentCy = parseFloat(marker.getAttribute("cy") ?? String(startPoint.y));
const pathLength = route.getTotalLength();
const currentLen = findNearestLength(route, currentCx, currentCy, pathLength);
const progress = { t: currentLen };
const targetLen = 0;
const tl = gsap.timeline();
activeTl = tl;
tl.to(
progress,
{
duration: ANIMATION_DURATION,
ease: "power2.inOut",
t: targetLen,
onUpdate: () => {
const pt = route.getPointAtLength(progress.t);
marker.setAttribute("cx", String(pt.x));
marker.setAttribute("cy", String(pt.y));
},
},
0,
);
tl.to(
visual,
{
duration: ANIMATION_DURATION,
ease: "power2.inOut",
scale: CAMERA_INITIAL_ZOOM,
},
0,
);
} catch (err) {
console.error("[mountain-journey] reset animation failed:", err);
marker.setAttribute("cx", String(startPoint.x));
marker.setAttribute("cy", String(startPoint.y));
}
})();
});
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const formData = new FormData(form);
const rawInput = String(formData.get("url") ?? "").trim();
if (!rawInput) return;
const domain = rawInput.replace(/^https?:\/\//, "").replace(/\/$/, "");
errorEl.hidden = true;
let score: number = DEFAULT_SCORE;
try {
const response = await fetch(options.workerEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: domain }),
});
if (response.ok) {
const data = (await response.json()) as { score: number };
score = Math.max(0, Math.min(100, data.score));
}
} catch {
// CORS or network error — use default score
}
if (prefersReducedMotion) {
placeMarkerAtScore(route, marker, score);
hasAnimated = true;
return;
}
try {
const { gsap } = await import("gsap");
if (activeTl) {
activeTl.kill();
activeTl = null;
}
const pathLength = route.getTotalLength();
const targetLength = (score / 100) * pathLength;
const sp = route.getPointAtLength(0);
marker.setAttribute("cx", String(sp.x));
marker.setAttribute("cy", String(sp.y));
const progress = { t: 0 };
gsap.set(visual, { scale: CAMERA_INITIAL_ZOOM });
const tl = gsap.timeline();
activeTl = tl;
tl.to(
progress,
{
duration: ANIMATION_DURATION,
ease: "power2.inOut",
t: 1,
onUpdate: () => {
const len = progress.t * targetLength;
const pt = route.getPointAtLength(len);
marker.setAttribute("cx", String(pt.x));
marker.setAttribute("cy", String(pt.y));
},
},
0,
);
tl.to(
visual,
{
duration: ANIMATION_DURATION,
ease: "power2.inOut",
scale: CAMERA_FINAL_ZOOM,
},
0,
);
hasAnimated = true;
} catch (err) {
console.error("[mountain-journey] GSAP animation failed:", err);
placeMarkerAtScore(route, marker, score);
hasAnimated = true;
}
});
}
function placeMarkerAtScore(route: SVGPathElement, marker: SVGCircleElement, score: number): void {
const pathLength = route.getTotalLength();
const targetLength = (score / 100) * pathLength;
const point = route.getPointAtLength(targetLength);
marker.setAttribute("cx", String(point.x));
marker.setAttribute("cy", String(point.y));
}
function findNearestLength(
route: SVGPathElement,
cx: number,
cy: number,
pathLength: number,
): number {
let bestLen = 0;
let bestDist = Infinity;
const samples = 100;
for (let i = 0; i <= samples; i++) {
const len = (i / samples) * pathLength;
const pt = route.getPointAtLength(len);
const dist = (pt.x - cx) ** 2 + (pt.y - cy) ** 2;
if (dist < bestDist) {
bestDist = dist;
bestLen = len;
}
}
return bestLen;
}