280 lines
9.1 KiB
JavaScript
280 lines
9.1 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|
|
|
|
})(); |