/** * holofoil-init.js * ----------------------------------------------------------------------------- * Instruments .image-grow and .card-image-wrap with the holofoil effect system. * * GRID (.image-grow) * Effect is invisible at rest. On hover, pointer tracking drives the shine * and glare layers. The card lift/scale comes from main.scss as before. * * MODAL (.card-image-wrap) * Effect sweeps autonomously once per minute via CSS animation. * Pointer tracking takes over while the user hovers the image. * * DEFAULT FALLBACK * If data-default="true" is set (onerror in the Astro markup), no effect * is applied -- even if the attribute appears after stamp() has run. * ----------------------------------------------------------------------------- */ (function HolofoilSystem() { 'use strict'; // -- Constants -------------------------------------------------------------- const SHIMMER_SEL = [ '.image-grow[data-rarity]', '.image-grow[data-variant="Holofoil"]', '.image-grow[data-variant="1st Edition Holofoil"]', '.image-grow[data-variant="Unlimited Holofoil"]', '.image-grow[data-variant="Reverse Holofoil"]', '.card-image-wrap[data-rarity]', '.card-image-wrap[data-variant="Holofoil"]', '.card-image-wrap[data-variant="1st Edition Holofoil"]', '.card-image-wrap[data-variant="Unlimited Holofoil"]', '.card-image-wrap[data-variant="Reverse Holofoil"]', ].join(','); const ALL_WRAPPERS_SEL = '.image-grow, .card-image-wrap'; // Foil variant visual randomisation const FOIL_ANGLE_MIN = -65, FOIL_ANGLE_MAX = -25; const FOIL_BRITE_MIN = 0.18, FOIL_BRITE_MAX = 0.32; const FOIL_SAT_MIN = 0.40, FOIL_SAT_MAX = 0.75; const SKIP_RARITIES = new Set(['common', 'uncommon', '']); // -- Helpers ---------------------------------------------------------------- const rand = (min, max) => parseFloat((Math.random() * (max - min) + min).toFixed(2)); const clamp01 = n => Math.max(0, Math.min(1, n)); function pointerVars(x, y, rect) { const fromLeft = clamp01((x - rect.left) / rect.width); const fromTop = clamp01((y - rect.top) / rect.height); const fromCenter = clamp01(Math.sqrt((fromLeft - 0.5) ** 2 + (fromTop - 0.5) ** 2) * 2); return { px: fromLeft * 100, py: fromTop * 100, fromLeft, fromTop, fromCenter, bgX: 50 + (fromLeft - 0.5) * 30, bgY: 50 + (fromTop - 0.5) * 30, }; } function applyPointerVars(el, v) { el.style.setProperty('--pointer-x', v.px.toFixed(1) + '%'); el.style.setProperty('--pointer-y', v.py.toFixed(1) + '%'); el.style.setProperty('--pointer-from-left', v.fromLeft.toFixed(3)); el.style.setProperty('--pointer-from-top', v.fromTop.toFixed(3)); el.style.setProperty('--pointer-from-center', v.fromCenter.toFixed(3)); el.style.setProperty('--background-x', v.bgX.toFixed(1) + '%'); el.style.setProperty('--background-y', v.bgY.toFixed(1) + '%'); } const isHoloVariant = v => ['Holofoil', 'Reverse Holofoil', '1st Edition Holofoil', 'Unlimited Holofoil'].includes(v); const isModalWrapper = el => el.classList.contains('card-image-wrap'); const isDefault = el => el.dataset.default === 'true'; // -- Child injection -------------------------------------------------------- function injectChildren(el) { if (el.querySelector('.holo-shine')) return; const shine = document.createElement('div'); shine.className = 'holo-shine'; const glare = document.createElement('div'); glare.className = 'holo-glare'; el.appendChild(shine); el.appendChild(glare); } // -- Default image guard ---------------------------------------------------- /** * Watch for the onerror handler in the Astro markup setting data-default="true" * after stamp() has already run. Hide the effect children immediately when seen. */ function watchForDefault(el) { if (isDefault(el)) return; var observer = new MutationObserver(function() { if (isDefault(el)) { var shine = el.querySelector('.holo-shine'); var glare = el.querySelector('.holo-glare'); if (shine) shine.style.display = 'none'; if (glare) glare.style.display = 'none'; observer.disconnect(); } }); observer.observe(el, { attributes: true, attributeFilter: ['data-default'] }); } // -- Stamp ------------------------------------------------------------------ function stamp(el) { if (el.dataset.holoInit) return; // Skip if already a default fallback image if (isDefault(el)) { el.dataset.holoInit = 'skip'; return; } const rarity = (el.dataset.rarity || '').toLowerCase(); const variant = el.dataset.variant || ''; const hasHoloRarity = rarity && !SKIP_RARITIES.has(rarity); const hasHoloVariant = isHoloVariant(variant); if (!hasHoloRarity && !hasHoloVariant) { el.dataset.holoInit = 'skip'; return; } injectChildren(el); // Per-card foil visual randomisation (angle/brightness/saturation) if (hasHoloVariant) { el.style.setProperty('--foil-angle', Math.round(rand(FOIL_ANGLE_MIN, FOIL_ANGLE_MAX)) + 'deg'); el.style.setProperty('--foil-brightness', rand(FOIL_BRITE_MIN, FOIL_BRITE_MAX).toFixed(2)); el.style.setProperty('--foil-saturation', rand(FOIL_SAT_MIN, FOIL_SAT_MAX ).toFixed(2)); } // Modal-only: set a stable delay offset for the autonomous CSS animation if (isModalWrapper(el)) { el.classList.add('holo-modal-mode'); el.style.setProperty('--shimmer-delay', rand(-8, 0) + 's'); } watchForDefault(el); el.dataset.holoInit = '1'; } function stampAll(root) { (root || document).querySelectorAll(ALL_WRAPPERS_SEL).forEach(stamp); } // -- Pointer tracking ------------------------------------------------------- const pointerState = new WeakMap(); function onPointerEnter(e) { const el = e.currentTarget; if (el.dataset.holoInit !== '1' || isDefault(el)) return; el.dataset.holoActive = '1'; if (!pointerState.has(el)) pointerState.set(el, { rafId: null }); } function onPointerMove(e) { const el = e.currentTarget; if (el.dataset.holoInit !== '1') return; const state = pointerState.get(el); if (!state) return; if (state.rafId) cancelAnimationFrame(state.rafId); state.rafId = requestAnimationFrame(function() { const rect = el.getBoundingClientRect(); applyPointerVars(el, pointerVars(e.clientX, e.clientY, rect)); state.rafId = null; }); } function onPointerLeave(e) { const el = e.currentTarget; if (el.dataset.holoInit !== '1') return; const state = pointerState.get(el); if (state && state.rafId) { cancelAnimationFrame(state.rafId); state.rafId = null; } delete el.dataset.holoActive; if (isModalWrapper(el)) { // Let the CSS animation resume driving --card-opacity el.style.removeProperty('--card-opacity'); } } function attachListeners(el) { if (el.dataset.holoListeners) return; el.addEventListener('pointerenter', onPointerEnter, { passive: true }); el.addEventListener('pointermove', onPointerMove, { passive: true }); el.addEventListener('pointerleave', onPointerLeave, { passive: true }); el.dataset.holoListeners = '1'; } function attachAllListeners(root) { (root || document).querySelectorAll(SHIMMER_SEL).forEach(function(el) { stamp(el); if (el.dataset.holoInit === '1') attachListeners(el); }); } // -- MutationObserver: react to HTMX / infinite scroll ---------------------- function observeGrid() { var grid = document.getElementById('cardGrid'); if (!grid) return; new MutationObserver(function(mutations) { for (var i = 0; i < mutations.length; i++) { var nodes = mutations[i].addedNodes; for (var j = 0; j < nodes.length; j++) { var node = nodes[j]; if (node.nodeType !== 1) continue; if (node.matches && node.matches(ALL_WRAPPERS_SEL)) { stamp(node); if (node.dataset.holoInit === '1') attachListeners(node); } if (node.querySelectorAll) { node.querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) { stamp(el); if (el.dataset.holoInit === '1') attachListeners(el); }); } } } }).observe(grid, { childList: true, subtree: true }); } function observeModal() { var modal = document.getElementById('cardModal'); if (!modal) return; new MutationObserver(function() { modal.querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) { stamp(el); if (el.dataset.holoInit === '1') attachListeners(el); }); }).observe(modal, { childList: true, subtree: true }); } // -- Bootstrap -------------------------------------------------------------- function init() { stampAll(); attachAllListeners(); observeGrid(); observeModal(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();