Files
pokemon/src/assets/js/holofoil-init.js

280 lines
9.1 KiB
JavaScript
Raw Normal View History

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