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