portfolio/static/js/main.js

149 lines
4.6 KiB
JavaScript

document.addEventListener("DOMContentLoaded", () => {
const setupKangarooEye = () => {
const heroGraphic = document.querySelector("main > section > svg");
const eye = heroGraphic?.querySelector("#eye");
const pupil = heroGraphic?.querySelector("#pupil");
if (!heroGraphic || !eye || !pupil) {
return;
}
const limits = {
left: 4,
right: 2,
vertical: 4,
};
let frame = null;
let pointer = null;
const render = () => {
frame = null;
if (!pointer) {
pupil.setAttribute("transform", "translate(0 0)");
return;
}
const rect = heroGraphic.getBoundingClientRect();
const eyeRect = eye.getBoundingClientRect();
if (!rect.width || !rect.height || !eyeRect.width || !eyeRect.height) {
return;
}
const eyeCenterX = eyeRect.left + (eyeRect.width / 2);
const eyeCenterY = eyeRect.top + (eyeRect.height / 2);
const normalizedX = (pointer.x - eyeCenterX) / (rect.width / 2);
const normalizedY = (pointer.y - eyeCenterY) / (rect.height / 2);
const clampedX = Math.max(-1, Math.min(1, normalizedX));
const clampedY = Math.max(-1, Math.min(1, normalizedY));
const moveX = clampedX < 0 ? clampedX * limits.left : clampedX * limits.right;
const moveY = clampedY * limits.vertical;
pupil.setAttribute("transform", `translate(${moveX.toFixed(2)} ${moveY.toFixed(2)})`);
};
const requestRender = () => {
if (frame !== null) {
return;
}
frame = window.requestAnimationFrame(render);
};
window.addEventListener("mousemove", (event) => {
pointer = { x: event.clientX, y: event.clientY };
requestRender();
}, { passive: true });
window.addEventListener("mouseleave", () => {
pointer = null;
requestRender();
});
requestRender();
};
const setupToc = () => {
const toc = document.querySelector("article > nav#toc");
const content = document.querySelector("article > div#content");
const list = toc?.querySelector("ol");
if (!toc || !content || !list) {
return;
}
const links = Array.from(toc.querySelectorAll('a[href^="#"]'));
const sections = Array.from(content.querySelectorAll("section[id]"));
let activeId = null;
if (!links.length || !sections.length) {
return;
}
const linksById = new Map(
links.map((link) => [decodeURIComponent(link.getAttribute("href").slice(1)), link]),
);
const scrollActiveItemIntoView = (link, behavior = "auto") => {
const item = link?.closest("li");
if (!item) {
return;
}
const hasHorizontalOverflow = list.scrollWidth > list.clientWidth;
if (!hasHorizontalOverflow) {
return;
}
const itemLeft = item.offsetLeft;
const itemCenter = itemLeft + (item.offsetWidth / 2);
const maxScrollLeft = list.scrollWidth - list.clientWidth;
const targetLeft = Math.max(0, Math.min(maxScrollLeft, itemCenter - (list.clientWidth / 2)));
list.scrollTo({
behavior,
left: targetLeft,
});
};
const setActive = (id, behavior = "auto") => {
const activeLink = linksById.get(id);
links.forEach((link) => {
link.closest("li")?.classList.toggle("active", activeLink === link);
});
if (activeLink && activeId !== id) {
activeId = id;
scrollActiveItemIntoView(activeLink, behavior);
}
};
const updateActive = (behavior = "auto") => {
const threshold = window.innerHeight * 0.25;
let current = sections[0];
sections.forEach((section) => {
if (section.getBoundingClientRect().top <= threshold) {
current = section;
}
});
if (current?.id) {
setActive(current.id, behavior);
}
};
updateActive();
window.addEventListener("scroll", () => updateActive("smooth"), { passive: true });
window.addEventListener("resize", updateActive);
};
setupKangarooEye();
setupToc();
});