152 lines
5.1 KiB
JavaScript
152 lines
5.1 KiB
JavaScript
const MAX_GAZE_SVG_UNITS = 34;
|
|
const FULL_GAZE_DISTANCE_PX = 320;
|
|
const GAZE_EASING = 0.2;
|
|
const GAZE_EPSILON = 0.08;
|
|
|
|
/**
|
|
* Convert a viewport pointer position into a bounded SVG-space gaze offset.
|
|
* The response ramps up near the logo, then caps so the globe stays in the eye.
|
|
*
|
|
* @param {number} clientX - Pointer x coordinate in CSS pixels.
|
|
* @param {number} clientY - Pointer y coordinate in CSS pixels.
|
|
* @param {{left:number, top:number, width:number, height:number}} rect - Logo bounds.
|
|
* @param {number} [maxOffset=MAX_GAZE_SVG_UNITS] - Maximum SVG-space translation.
|
|
* @returns {{x:number, y:number}}
|
|
*/
|
|
export function calculateLogoGaze(clientX, clientY, rect, maxOffset = MAX_GAZE_SVG_UNITS) {
|
|
const values = [clientX, clientY, rect?.left, rect?.top, rect?.width, rect?.height, maxOffset];
|
|
if (!values.every(Number.isFinite) || rect.width <= 0 || rect.height <= 0 || maxOffset < 0) {
|
|
return { x: 0, y: 0 };
|
|
}
|
|
|
|
const dx = clientX - (rect.left + rect.width / 2);
|
|
const dy = clientY - (rect.top + rect.height / 2);
|
|
const distance = Math.hypot(dx, dy);
|
|
if (distance === 0) return { x: 0, y: 0 };
|
|
|
|
const strength = Math.min(distance / FULL_GAZE_DISTANCE_PX, 1);
|
|
const scale = maxOffset * strength / distance;
|
|
return { x: dx * scale, y: dy * scale };
|
|
}
|
|
|
|
/**
|
|
* Make every same-origin logo object marked with `data-logo-gaze` follow the
|
|
* pointer. Only the globe and its latitude/longitude cage move; the eye shell
|
|
* remains fixed. Returns a cleanup callback.
|
|
*
|
|
* @param {Document|Element} [root=document] - DOM root to search.
|
|
* @returns {() => void}
|
|
*/
|
|
export function initLogoGaze(root = document) {
|
|
if (typeof window === 'undefined' || !root?.querySelectorAll) return () => {};
|
|
|
|
const logos = [...root.querySelectorAll('[data-logo-gaze]')];
|
|
if (!logos.length) return () => {};
|
|
|
|
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
const states = logos.map((element) => ({
|
|
element,
|
|
parts: [],
|
|
currentX: 0,
|
|
currentY: 0,
|
|
targetX: 0,
|
|
targetY: 0,
|
|
}));
|
|
let animationFrame = 0;
|
|
let disposed = false;
|
|
|
|
const applyTransform = (state) => {
|
|
const transform = `translate(${state.currentX.toFixed(2)} ${state.currentY.toFixed(2)})`;
|
|
for (const part of state.parts) part.setAttribute('transform', transform);
|
|
};
|
|
|
|
const animate = () => {
|
|
animationFrame = 0;
|
|
let keepAnimating = false;
|
|
|
|
for (const state of states) {
|
|
const deltaX = state.targetX - state.currentX;
|
|
const deltaY = state.targetY - state.currentY;
|
|
|
|
if (Math.abs(deltaX) <= GAZE_EPSILON && Math.abs(deltaY) <= GAZE_EPSILON) {
|
|
state.currentX = state.targetX;
|
|
state.currentY = state.targetY;
|
|
} else {
|
|
state.currentX += deltaX * GAZE_EASING;
|
|
state.currentY += deltaY * GAZE_EASING;
|
|
keepAnimating = true;
|
|
}
|
|
applyTransform(state);
|
|
}
|
|
|
|
if (keepAnimating) animationFrame = window.requestAnimationFrame(animate);
|
|
};
|
|
|
|
const scheduleAnimation = () => {
|
|
if (!animationFrame) animationFrame = window.requestAnimationFrame(animate);
|
|
};
|
|
|
|
const loadInlineLogos = async () => {
|
|
try {
|
|
const source = logos[0].dataset.logoSrc || '/logo.svg';
|
|
const response = await window.fetch(source);
|
|
if (!response.ok) return;
|
|
const markup = await response.text();
|
|
if (disposed) return;
|
|
|
|
const svgDocument = new DOMParser().parseFromString(markup, 'image/svg+xml');
|
|
if (svgDocument.querySelector('parsererror')) return;
|
|
const sourceSvg = svgDocument.documentElement;
|
|
|
|
for (const state of states) {
|
|
const svg = sourceSvg.cloneNode(true);
|
|
svg.removeAttribute('role');
|
|
svg.removeAttribute('aria-labelledby');
|
|
svg.setAttribute('aria-hidden', 'true');
|
|
svg.setAttribute('focusable', 'false');
|
|
svg.querySelector('title')?.remove();
|
|
state.element.replaceChildren(svg);
|
|
state.parts = [
|
|
svg.querySelector('#globe'),
|
|
svg.querySelector('#globe_cage'),
|
|
].filter(Boolean);
|
|
applyTransform(state);
|
|
}
|
|
} catch {
|
|
// Keep the already-rendered <img> fallback if the inline asset cannot load.
|
|
}
|
|
};
|
|
|
|
if (!reducedMotion) loadInlineLogos();
|
|
|
|
const onPointerMove = (event) => {
|
|
if (reducedMotion || event.pointerType === 'touch') return;
|
|
for (const state of states) {
|
|
const gaze = calculateLogoGaze(event.clientX, event.clientY, state.element.getBoundingClientRect());
|
|
state.targetX = gaze.x;
|
|
state.targetY = gaze.y;
|
|
}
|
|
scheduleAnimation();
|
|
};
|
|
|
|
const resetGaze = () => {
|
|
for (const state of states) {
|
|
state.targetX = 0;
|
|
state.targetY = 0;
|
|
}
|
|
scheduleAnimation();
|
|
};
|
|
|
|
window.addEventListener('pointermove', onPointerMove, { passive: true });
|
|
window.addEventListener('blur', resetGaze);
|
|
document.documentElement.addEventListener('pointerleave', resetGaze);
|
|
|
|
return () => {
|
|
disposed = true;
|
|
window.removeEventListener('pointermove', onPointerMove);
|
|
window.removeEventListener('blur', resetGaze);
|
|
document.documentElement.removeEventListener('pointerleave', resetGaze);
|
|
if (animationFrame) window.cancelAnimationFrame(animationFrame);
|
|
};
|
|
}
|