195 lines
8.7 KiB
JavaScript
195 lines
8.7 KiB
JavaScript
/* Shared, dependency-free motion for the portfolio index and case-study pages. */
|
|
(() => {
|
|
'use strict';
|
|
const $ = (selector, root = document) => root.querySelector(selector);
|
|
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
const finePointer = window.matchMedia('(hover: hover) and (pointer: fine)');
|
|
const isCase = document.body.classList.contains('portfolio-page');
|
|
document.documentElement.classList.add('portfolio-ready');
|
|
|
|
// A tiny tilt belongs to each artwork, without affecting its readable title.
|
|
const scenes = $$('.project-scene');
|
|
const visibleScenes = new Set();
|
|
const pendingPointers = new Map();
|
|
let pointerFrame = 0;
|
|
function flushPointers() {
|
|
pendingPointers.forEach((point, scene) => {
|
|
scene.style.setProperty('--tilt-x', `${point.x}deg`);
|
|
scene.style.setProperty('--tilt-y', `${point.y}deg`);
|
|
});
|
|
pendingPointers.clear();
|
|
pointerFrame = 0;
|
|
}
|
|
scenes.forEach(scene => {
|
|
scene.addEventListener('pointermove', event => {
|
|
if (reduced.matches || !finePointer.matches || event.pointerType === 'touch') return;
|
|
const bounds = scene.getBoundingClientRect();
|
|
pendingPointers.set(scene, {
|
|
x: ((event.clientY - bounds.top) / bounds.height - .5) * -3.6,
|
|
y: ((event.clientX - bounds.left) / bounds.width - .5) * 4.8
|
|
});
|
|
if (!pointerFrame) pointerFrame = requestAnimationFrame(flushPointers);
|
|
});
|
|
scene.addEventListener('pointerleave', () => {
|
|
pendingPointers.delete(scene);
|
|
scene.style.setProperty('--tilt-x', '0deg');
|
|
scene.style.setProperty('--tilt-y', '0deg');
|
|
});
|
|
});
|
|
if ('IntersectionObserver' in window) {
|
|
const observer = new IntersectionObserver(entries => {
|
|
entries.forEach(entry => entry.isIntersecting ? visibleScenes.add(entry.target) : visibleScenes.delete(entry.target));
|
|
requestUpdate();
|
|
}, { rootMargin: '100px' });
|
|
scenes.forEach(scene => observer.observe(scene));
|
|
}
|
|
|
|
// Reveal the case study in short, staggered sequences; content stays readable without JS.
|
|
$$('.case-features .case-reveal').forEach((element, index) => element.style.setProperty('--case-delay', `${index * 65}ms`));
|
|
if ('IntersectionObserver' in window) {
|
|
const reveals = new IntersectionObserver(entries => {
|
|
entries.forEach(entry => {
|
|
if (!entry.isIntersecting) return;
|
|
entry.target.classList.add('is-visible');
|
|
reveals.unobserve(entry.target);
|
|
});
|
|
}, { threshold: .08, rootMargin: '0px 0px -25px 0px' });
|
|
$$('.case-reveal').forEach(element => reveals.observe(element));
|
|
} else {
|
|
$$('.case-reveal').forEach(element => element.classList.add('is-visible'));
|
|
}
|
|
|
|
const progress = $('.case-progress');
|
|
const readingProgress = $('.case-reading-progress');
|
|
const caseLinks = $$('.case-nav a');
|
|
const caseSections = caseLinks.map(link => $(link.getAttribute('href')));
|
|
let updatePending = false;
|
|
function updateView() {
|
|
if (!reduced.matches) {
|
|
visibleScenes.forEach(scene => {
|
|
const bounds = scene.getBoundingClientRect();
|
|
const relative = (bounds.top + bounds.height / 2 - window.innerHeight / 2) / window.innerHeight;
|
|
scene.style.setProperty('--scene-scroll', `${Math.max(-22, Math.min(22, relative * -22))}px`);
|
|
});
|
|
}
|
|
if (isCase) {
|
|
const length = document.documentElement.scrollHeight - window.innerHeight;
|
|
const portion = length > 0 ? Math.min(1, Math.max(0, window.scrollY / length)) : 0;
|
|
progress.style.transform = `scaleX(${portion})`;
|
|
readingProgress.textContent = `${String(Math.round(portion * 100)).padStart(2, '0')}%`;
|
|
let active = 0;
|
|
caseSections.forEach((section, index) => { if (section.getBoundingClientRect().top <= 145) active = index; });
|
|
caseLinks.forEach((link, index) => {
|
|
if (index === active) link.setAttribute('aria-current', 'location');
|
|
else link.removeAttribute('aria-current');
|
|
});
|
|
}
|
|
updatePending = false;
|
|
}
|
|
function requestUpdate() {
|
|
if (!updatePending) { updatePending = true; requestAnimationFrame(updateView); }
|
|
}
|
|
window.addEventListener('scroll', requestUpdate, { passive: true });
|
|
window.addEventListener('resize', requestUpdate, { passive: true });
|
|
reduced.addEventListener('change', () => {
|
|
scenes.forEach(scene => {
|
|
scene.style.removeProperty('--scene-scroll');
|
|
scene.style.removeProperty('--tilt-x');
|
|
scene.style.removeProperty('--tilt-y');
|
|
});
|
|
requestUpdate();
|
|
});
|
|
requestUpdate();
|
|
|
|
// Preserve the selected index filter across a case-study round trip.
|
|
if (!isCase) {
|
|
const filterButtons = $$('.filter');
|
|
filterButtons.forEach(button => button.addEventListener('click', () => {
|
|
try { sessionStorage.setItem('tront-portfolio-filter', button.dataset.filter); } catch { /* Storage is optional. */ }
|
|
}));
|
|
const returning = performance.getEntriesByType('navigation')[0]?.type === 'back_forward' || /\/projects\/[^/]+\.html$/.test(new URL(document.referrer || location.href).pathname);
|
|
if (returning) {
|
|
try {
|
|
const selected = sessionStorage.getItem('tront-portfolio-filter');
|
|
const target = filterButtons.find(button => button.dataset.filter === selected);
|
|
target?.click();
|
|
} catch { /* Normal navigation still works without storage. */ }
|
|
}
|
|
// A direct project anchor should always point to a visible card.
|
|
if (location.hash.startsWith('#project-')) {
|
|
const card = document.getElementById(location.hash.slice(1));
|
|
if (card?.hidden) filterButtons.find(button => button.dataset.filter === 'all')?.click();
|
|
}
|
|
}
|
|
|
|
const backLink = $('[data-back-to-work]');
|
|
backLink?.addEventListener('click', event => {
|
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;
|
|
try {
|
|
const referrer = new URL(document.referrer);
|
|
const home = new URL('../index.html', location.href);
|
|
const isHome = referrer.origin === home.origin && (referrer.pathname === home.pathname || referrer.pathname === home.pathname.replace('index.html', ''));
|
|
if (isHome && history.length > 1) { event.preventDefault(); history.back(); }
|
|
} catch { /* A direct visit uses the ordinary index link. */ }
|
|
});
|
|
|
|
// Accessible galleries with real source screenshots and interruptible transitions.
|
|
const tabs = $$('.screen-tabs [role="tab"]');
|
|
const gallery = $('.screen-gallery');
|
|
const screens = $$('.case-screen');
|
|
let galleryAnimation;
|
|
let screenAnimation;
|
|
function finishGallery() {
|
|
if (galleryAnimation) {
|
|
galleryAnimation.onfinish = null;
|
|
galleryAnimation.cancel();
|
|
galleryAnimation = null;
|
|
}
|
|
screenAnimation?.cancel();
|
|
gallery?.style.removeProperty('height');
|
|
gallery?.style.removeProperty('overflow');
|
|
}
|
|
function showScreen(index, focus = false) {
|
|
if (!tabs[index]) return;
|
|
if (tabs[index].getAttribute('aria-selected') === 'true') { if (focus) tabs[index].focus(); return; }
|
|
const start = gallery.getBoundingClientRect().height;
|
|
finishGallery();
|
|
tabs.forEach((tab, i) => {
|
|
tab.setAttribute('aria-selected', String(i === index));
|
|
tab.tabIndex = i === index ? 0 : -1;
|
|
screens[i].hidden = i !== index;
|
|
screens[i].classList.toggle('is-current', i === index);
|
|
});
|
|
const next = screens[index];
|
|
const img = $('img', next);
|
|
img.loading = 'eager';
|
|
const end = next.getBoundingClientRect().height;
|
|
if (!reduced.matches && gallery.animate) {
|
|
gallery.style.height = `${start}px`;
|
|
gallery.style.overflow = 'hidden';
|
|
galleryAnimation = gallery.animate([{ height: `${start}px` }, { height: `${end}px` }], { duration: 480, easing: 'cubic-bezier(.22,1,.36,1)' });
|
|
screenAnimation = next.animate([{ opacity: 0, transform: 'translateY(12px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 380, easing: 'cubic-bezier(.22,1,.36,1)' });
|
|
galleryAnimation.onfinish = finishGallery;
|
|
}
|
|
if (focus) tabs[index].focus();
|
|
requestUpdate();
|
|
}
|
|
tabs.forEach((tab, index) => {
|
|
tab.addEventListener('click', () => showScreen(index));
|
|
tab.addEventListener('pointerenter', () => { $('img', screens[index]).loading = 'eager'; }, { once: true });
|
|
tab.addEventListener('keydown', event => {
|
|
let target;
|
|
if (event.key === 'ArrowRight') target = (index + 1) % tabs.length;
|
|
if (event.key === 'ArrowLeft') target = (index + tabs.length - 1) % tabs.length;
|
|
if (event.key === 'Home') target = 0;
|
|
if (event.key === 'End') target = tabs.length - 1;
|
|
if (target !== undefined) { event.preventDefault(); showScreen(target, true); }
|
|
});
|
|
});
|
|
if (gallery) {
|
|
window.addEventListener('resize', finishGallery, { passive: true });
|
|
reduced.addEventListener('change', finishGallery);
|
|
}
|
|
})();
|