Files
tront.noname.company/portfolio.js

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);
}
})();