Files
TaroTime/app/ui-tarot.js
T

1376 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
const dataService = window.TarotDataService || {};
const {
resolveTarotCardImage,
resolveTarotCardThumbnail,
getTarotCardDisplayName,
getTarotCardSearchAliases,
getDeckOptions,
getActiveDeck
} = window.TarotCardImages || {};
const tarotHouseUi = window.TarotHouseUi || {};
const tarotRelationsUi = window.TarotRelationsUi || {};
const tarotCardDerivations = window.TarotCardDerivations || {};
const tarotDetailUi = window.TarotDetailUi || {};
const tarotRelationDisplay = window.TarotRelationDisplay || {};
const state = {
initialized: false,
cards: [],
filteredCards: [],
searchQuery: "",
selectedCardId: "",
houseFocusMode: false,
houseTopCardsVisible: true,
houseTopInfoModes: {
hebrew: true,
planet: true,
zodiac: true,
trump: true,
path: true,
date: false
},
houseBottomCardsVisible: true,
houseBottomInfoModes: {
zodiac: true,
decan: true,
month: true,
ruler: true,
date: false
},
houseExportInProgress: false,
houseExportFormat: "png",
houseSettingsOpen: false,
magickDataset: null,
referenceData: null,
monthRefsByCardId: new Map(),
courtCardByDecanId: new Map(),
loadingPromise: null
};
let detailNavigator = null;
const TAROT_TRUMP_NUMBER_BY_NAME = {
"the fool": 0,
fool: 0,
"the magus": 1,
magus: 1,
magician: 1,
"the high priestess": 2,
"high priestess": 2,
"the empress": 3,
empress: 3,
"the emperor": 4,
emperor: 4,
"the hierophant": 5,
hierophant: 5,
"the lovers": 6,
lovers: 6,
"the chariot": 7,
chariot: 7,
strength: 8,
lust: 8,
"the hermit": 9,
hermit: 9,
fortune: 10,
"wheel of fortune": 10,
justice: 11,
"the hanged man": 12,
"hanged man": 12,
death: 13,
temperance: 14,
art: 14,
"the devil": 15,
devil: 15,
"the tower": 16,
tower: 16,
"the star": 17,
star: 17,
"the moon": 18,
moon: 18,
"the sun": 19,
sun: 19,
aeon: 20,
judgement: 20,
judgment: 20,
universe: 21,
world: 21,
"the world": 21
};
const MINOR_NUMBER_WORD_BY_VALUE = {
1: "ace",
2: "two",
3: "three",
4: "four",
5: "five",
6: "six",
7: "seven",
8: "eight",
9: "nine",
10: "ten"
};
const HEBREW_LETTER_ALIASES = {
aleph: "alef",
alef: "alef",
heh: "he",
he: "he",
beth: "bet",
bet: "bet",
cheth: "het",
chet: "het",
kaph: "kaf",
kaf: "kaf",
peh: "pe",
tzaddi: "tsadi",
tzadi: "tsadi",
tsadi: "tsadi",
qoph: "qof",
qof: "qof",
taw: "tav",
tau: "tav"
};
const CUBE_MOTHER_CONNECTOR_BY_LETTER = {
alef: { connectorId: "above-below", connectorName: "Above ↔ Below" },
mem: { connectorId: "east-west", connectorName: "East ↔ West" },
shin: { connectorId: "south-north", connectorName: "South ↔ North" }
};
const ELEMENT_NAME_BY_ID = {
water: "Water",
fire: "Fire",
air: "Air",
earth: "Earth"
};
const ELEMENT_HEBREW_LETTER_BY_ID = {
fire: "Yod",
water: "Heh",
air: "Vav",
earth: "Heh"
};
const ELEMENT_HEBREW_CHAR_BY_ID = {
fire: "י",
water: "ה",
air: "ו",
earth: "ה"
};
const HEBREW_LETTER_ID_BY_TETRAGRAMMATON_LETTER = {
yod: "yod",
heh: "he",
vav: "vav"
};
const ACE_ELEMENT_BY_CARD_NAME = {
"ace of cups": "water",
"ace of wands": "fire",
"ace of swords": "air",
"ace of disks": "earth"
};
const COURT_ELEMENT_BY_RANK = {
knight: "fire",
queen: "water",
prince: "air",
princess: "earth"
};
const SUIT_ELEMENT_BY_SUIT = {
wands: "fire",
cups: "water",
swords: "air",
disks: "earth"
};
const MINOR_RANK_NUMBER_BY_NAME = {
ace: 1,
two: 2,
three: 3,
four: 4,
five: 5,
six: 6,
seven: 7,
eight: 8,
nine: 9,
ten: 10
};
const SMALL_CARD_SIGN_BY_MODALITY_AND_SUIT = {
cardinal: {
wands: "aries",
cups: "cancer",
swords: "libra",
disks: "capricorn"
},
fixed: {
wands: "leo",
cups: "scorpio",
swords: "aquarius",
disks: "taurus"
},
mutable: {
wands: "sagittarius",
cups: "pisces",
swords: "gemini",
disks: "virgo"
}
};
const MINOR_PLURAL_BY_RANK = {
ace: "aces",
two: "twos",
three: "threes",
four: "fours",
five: "fives",
six: "sixes",
seven: "sevens",
eight: "eights",
nine: "nines",
ten: "tens"
};
function slugify(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
function cardId(card) {
const suitPart = card.suit ? `-${slugify(card.suit)}` : "";
return `${slugify(card.arcana)}${suitPart}-${slugify(card.name)}`;
}
function getElements() {
return {
tarotSectionEl: document.getElementById("tarot-section"),
tarotHouseSectionEl: document.getElementById("tarot-house-section"),
tarotCardListEl: document.getElementById("tarot-card-list"),
tarotSearchInputEl: document.getElementById("tarot-search-input"),
tarotSearchClearEl: document.getElementById("tarot-search-clear"),
tarotCountEl: document.getElementById("tarot-card-count"),
tarotDetailImageEl: document.getElementById("tarot-detail-image"),
tarotDetailNameEl: document.getElementById("tarot-detail-name"),
tarotDetailTypeEl: document.getElementById("tarot-detail-type"),
tarotDetailPrevEl: document.getElementById("tarot-detail-prev"),
tarotDetailPositionEl: document.getElementById("tarot-detail-position"),
tarotDetailNextEl: document.getElementById("tarot-detail-next"),
tarotDetailSummaryEl: document.getElementById("tarot-detail-summary"),
tarotDetailUprightEl: document.getElementById("tarot-detail-upright"),
tarotDetailReversedEl: document.getElementById("tarot-detail-reversed"),
tarotMetaDeckGalleryCardEl: document.getElementById("tarot-meta-deck-gallery-card"),
tarotDetailDeckGalleryEl: document.getElementById("tarot-detail-deck-gallery"),
tarotMetaMeaningCardEl: document.getElementById("tarot-meta-meaning-card"),
tarotDetailMeaningEl: document.getElementById("tarot-detail-meaning"),
tarotDetailKeywordsEl: document.getElementById("tarot-detail-keywords"),
tarotMetaPlanetCardEl: document.getElementById("tarot-meta-planet-card"),
tarotMetaElementCardEl: document.getElementById("tarot-meta-element-card"),
tarotMetaTetragrammatonCardEl: document.getElementById("tarot-meta-tetragrammaton-card"),
tarotMetaZodiacCardEl: document.getElementById("tarot-meta-zodiac-card"),
tarotMetaCourtDateCardEl: document.getElementById("tarot-meta-courtdate-card"),
tarotMetaHebrewCardEl: document.getElementById("tarot-meta-hebrew-card"),
tarotMetaCubeCardEl: document.getElementById("tarot-meta-cube-card"),
tarotMetaIChingCardEl: document.getElementById("tarot-meta-iching-card"),
tarotMetaCalendarCardEl: document.getElementById("tarot-meta-calendar-card"),
tarotDetailPlanetEl: document.getElementById("tarot-detail-planet"),
tarotDetailElementEl: document.getElementById("tarot-detail-element"),
tarotDetailTetragrammatonEl: document.getElementById("tarot-detail-tetragrammaton"),
tarotDetailZodiacEl: document.getElementById("tarot-detail-zodiac"),
tarotDetailCourtDateEl: document.getElementById("tarot-detail-courtdate"),
tarotDetailHebrewEl: document.getElementById("tarot-detail-hebrew"),
tarotDetailCubeEl: document.getElementById("tarot-detail-cube"),
tarotDetailIChingEl: document.getElementById("tarot-detail-iching"),
tarotDetailCalendarEl: document.getElementById("tarot-detail-calendar"),
tarotKabPathEl: document.getElementById("tarot-kab-path"),
tarotHouseOfCardsEl: document.getElementById("tarot-house-of-cards"),
tarotBrowseViewEl: document.getElementById("tarot-browse-view"),
tarotHouseViewEl: document.getElementById("tarot-house-view"),
tarotHouseSettingsToggleEl: document.getElementById("tarot-house-settings-toggle"),
tarotHouseSettingsPanelEl: document.getElementById("tarot-house-settings-panel"),
tarotHouseTopCardsVisibleEl: document.getElementById("tarot-house-top-cards-visible"),
tarotHouseTopInfoHebrewEl: document.getElementById("tarot-house-top-info-hebrew"),
tarotHouseTopInfoPlanetEl: document.getElementById("tarot-house-top-info-planet"),
tarotHouseTopInfoZodiacEl: document.getElementById("tarot-house-top-info-zodiac"),
tarotHouseTopInfoTrumpEl: document.getElementById("tarot-house-top-info-trump"),
tarotHouseTopInfoPathEl: document.getElementById("tarot-house-top-info-path"),
tarotHouseTopInfoDateEl: document.getElementById("tarot-house-top-info-date"),
tarotHouseBottomCardsVisibleEl: document.getElementById("tarot-house-bottom-cards-visible"),
tarotHouseBottomInfoZodiacEl: document.getElementById("tarot-house-bottom-info-zodiac"),
tarotHouseBottomInfoDecanEl: document.getElementById("tarot-house-bottom-info-decan"),
tarotHouseBottomInfoMonthEl: document.getElementById("tarot-house-bottom-info-month"),
tarotHouseBottomInfoRulerEl: document.getElementById("tarot-house-bottom-info-ruler"),
tarotHouseBottomInfoDateEl: document.getElementById("tarot-house-bottom-info-date"),
tarotHouseExportWebpEl: document.getElementById("tarot-house-export-webp")
};
}
function setHouseBottomInfoCheckboxState(checkbox, enabled) {
if (!checkbox) {
return;
}
checkbox.checked = Boolean(enabled);
checkbox.disabled = Boolean(state.houseExportInProgress);
}
function normalizeRelationId(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
if (typeof tarotRelationDisplay.createTarotRelationDisplay !== "function") {
throw new Error("TarotRelationDisplay.createTarotRelationDisplay is unavailable. Ensure app/ui-tarot-relation-display.js loads before app/ui-tarot.js.");
}
if (typeof tarotCardDerivations.createTarotCardDerivations !== "function") {
throw new Error("TarotCardDerivations.createTarotCardDerivations is unavailable. Ensure app/ui-tarot-card-derivations.js loads before app/ui-tarot.js.");
}
if (typeof tarotDetailUi.createTarotDetailRenderer !== "function") {
throw new Error("TarotDetailUi.createTarotDetailRenderer is unavailable. Ensure app/ui-tarot-detail.js loads before app/ui-tarot.js.");
}
const tarotCardDerivationsUi = tarotCardDerivations.createTarotCardDerivations({
normalizeRelationId,
normalizeTarotCardLookupName,
toTitleCase,
getReferenceData: () => state.referenceData,
ELEMENT_NAME_BY_ID,
ELEMENT_HEBREW_LETTER_BY_ID,
ELEMENT_HEBREW_CHAR_BY_ID,
HEBREW_LETTER_ID_BY_TETRAGRAMMATON_LETTER,
ACE_ELEMENT_BY_CARD_NAME,
COURT_ELEMENT_BY_RANK,
MINOR_RANK_NUMBER_BY_NAME,
SMALL_CARD_SIGN_BY_MODALITY_AND_SUIT,
MINOR_PLURAL_BY_RANK
});
const tarotRelationDisplayUi = tarotRelationDisplay.createTarotRelationDisplay({
normalizeRelationId
});
const tarotDetailRenderer = tarotDetailUi.createTarotDetailRenderer({
getMonthRefsByCardId: () => state.monthRefsByCardId,
getMagickDataset: () => state.magickDataset,
resolveTarotCardImage,
resolveTarotCardThumbnail,
getDeckVariantsForCard,
openDeckVariantLightbox,
getDisplayCardName,
buildTypeLabel,
clearChildren,
normalizeRelationObject,
buildElementRelationsForCard,
buildTetragrammatonRelationsForCard,
buildSmallCardRulershipRelation,
buildSmallCardCourtLinkRelations,
buildCubeRelationsForCard,
buildIChingRelationsForCard,
parseMonthDayToken,
createRelationListItem,
findSephirahForMinorCard
});
function normalizeSearchValue(value) {
return String(value || "").trim().toLowerCase();
}
function normalizeTarotName(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
}
function normalizeTarotCardLookupName(value) {
const text = normalizeTarotName(value)
.replace(/\b(pentacles?|coins?)\b/g, "disks");
const match = text.match(/^(\d{1,2})\s+of\s+(.+)$/i);
if (!match) {
return text;
}
const numeric = Number(match[1]);
const suit = String(match[2] || "").trim();
const rankWord = MINOR_NUMBER_WORD_BY_VALUE[numeric];
if (!rankWord || !suit) {
return text;
}
return `${rankWord} of ${suit}`;
}
function getDisplayCardName(cardOrName, trumpNumber) {
const cardName = typeof cardOrName === "object"
? String(cardOrName?.name || "")
: String(cardOrName || "");
const resolvedTrumpNumber = typeof cardOrName === "object"
? cardOrName?.number
: trumpNumber;
if (typeof getTarotCardDisplayName === "function") {
const display = String(getTarotCardDisplayName(cardName, { trumpNumber: resolvedTrumpNumber }) || "").trim();
if (display) {
return display;
}
}
return cardName.trim();
}
function toTitleCase(value) {
return String(value || "")
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function buildElementRelationsForCard(card, baseElementRelations = []) {
return tarotCardDerivationsUi.buildElementRelationsForCard(card, baseElementRelations);
}
function buildTetragrammatonRelationsForCard(card) {
return tarotCardDerivationsUi.buildTetragrammatonRelationsForCard(card);
}
function buildSmallCardRulershipRelation(card) {
return tarotCardDerivationsUi.buildSmallCardRulershipRelation(card);
}
function buildCourtCardByDecanId(cards) {
if (typeof tarotRelationsUi.buildCourtCardByDecanId !== "function") {
return new Map();
}
return tarotRelationsUi.buildCourtCardByDecanId(cards);
}
function buildSmallCardCourtLinkRelations(card, relations) {
if (typeof tarotRelationsUi.buildSmallCardCourtLinkRelations !== "function") {
return [];
}
return tarotRelationsUi.buildSmallCardCourtLinkRelations(card, relations, state.courtCardByDecanId);
}
function parseMonthDayToken(value) {
if (typeof tarotRelationsUi.parseMonthDayToken !== "function") {
return null;
}
return tarotRelationsUi.parseMonthDayToken(value);
}
function buildMonthReferencesByCard(referenceData, cards) {
if (typeof tarotRelationsUi.buildMonthReferencesByCard !== "function") {
return new Map();
}
return tarotRelationsUi.buildMonthReferencesByCard(referenceData, cards);
}
function relationToSearchText(relation) {
if (!relation) {
return "";
}
if (typeof relation === "string") {
return relation;
}
const relationParts = [
relation.label,
relation.type,
relation.id,
relation.data && typeof relation.data === "object"
? Object.values(relation.data).join(" ")
: ""
];
return relationParts.filter(Boolean).join(" ");
}
function buildCardSearchText(card) {
const displayName = getDisplayCardName(card);
const tarotAliases = typeof getTarotCardSearchAliases === "function"
? getTarotCardSearchAliases(card?.name, { trumpNumber: card?.number })
: [];
const parts = [
card.name,
displayName,
card.arcana,
card.rank,
card.suit,
card.summary,
...tarotAliases,
...(Array.isArray(card.keywords) ? card.keywords : []),
...(Array.isArray(card.relations) ? card.relations.map(relationToSearchText) : [])
];
return normalizeSearchValue(parts.join(" "));
}
function applySearchFilter(elements) {
const query = normalizeSearchValue(state.searchQuery);
state.filteredCards = query
? state.cards.filter((card) => buildCardSearchText(card).includes(query))
: [...state.cards];
if (elements?.tarotSearchClearEl) {
elements.tarotSearchClearEl.disabled = !query;
}
renderList(elements);
if (!state.filteredCards.some((card) => card.id === state.selectedCardId)) {
if (state.filteredCards.length > 0) {
selectCardById(state.filteredCards[0].id, elements);
} else {
syncDetailNavigation(elements);
}
return;
}
updateListSelection(elements);
syncDetailNavigation(elements);
}
function clearChildren(element) {
if (element) {
element.replaceChildren();
}
}
function updateHouseSelection(elements) {
tarotHouseUi.updateSelection?.(elements);
}
function renderHouseOfCards(elements) {
tarotHouseUi.render?.(elements);
}
function syncHouseControls(elements) {
if (elements?.tarotHouseViewEl) {
elements.tarotHouseViewEl.classList.toggle("is-house-focus", Boolean(state.houseFocusMode));
}
if (elements?.tarotHouseSettingsToggleEl) {
elements.tarotHouseSettingsToggleEl.setAttribute("aria-expanded", state.houseSettingsOpen ? "true" : "false");
elements.tarotHouseSettingsToggleEl.textContent = state.houseSettingsOpen ? "Hide Settings" : "Settings";
}
if (elements?.tarotHouseSettingsPanelEl) {
elements.tarotHouseSettingsPanelEl.hidden = !state.houseSettingsOpen;
}
if (elements?.tarotHouseTopCardsVisibleEl) {
elements.tarotHouseTopCardsVisibleEl.checked = Boolean(state.houseTopCardsVisible);
elements.tarotHouseTopCardsVisibleEl.disabled = Boolean(state.houseExportInProgress);
}
setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoHebrewEl, state.houseTopInfoModes.hebrew);
setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoPlanetEl, state.houseTopInfoModes.planet);
setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoZodiacEl, state.houseTopInfoModes.zodiac);
setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoTrumpEl, state.houseTopInfoModes.trump);
setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoPathEl, state.houseTopInfoModes.path);
setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoDateEl, state.houseTopInfoModes.date);
if (elements?.tarotHouseBottomCardsVisibleEl) {
elements.tarotHouseBottomCardsVisibleEl.checked = Boolean(state.houseBottomCardsVisible);
elements.tarotHouseBottomCardsVisibleEl.disabled = Boolean(state.houseExportInProgress);
}
setHouseBottomInfoCheckboxState(elements?.tarotHouseBottomInfoZodiacEl, state.houseBottomInfoModes.zodiac);
setHouseBottomInfoCheckboxState(elements?.tarotHouseBottomInfoDecanEl, state.houseBottomInfoModes.decan);
setHouseBottomInfoCheckboxState(elements?.tarotHouseBottomInfoMonthEl, state.houseBottomInfoModes.month);
setHouseBottomInfoCheckboxState(elements?.tarotHouseBottomInfoRulerEl, state.houseBottomInfoModes.ruler);
setHouseBottomInfoCheckboxState(elements?.tarotHouseBottomInfoDateEl, state.houseBottomInfoModes.date);
if (elements?.tarotHouseExportWebpEl) {
const supportsWebp = tarotHouseUi.isExportFormatSupported?.("webp") === true;
elements.tarotHouseExportWebpEl.disabled = Boolean(state.houseExportInProgress) || !supportsWebp;
elements.tarotHouseExportWebpEl.hidden = !supportsWebp;
elements.tarotHouseExportWebpEl.textContent = state.houseExportInProgress && state.houseExportFormat === "webp"
? "Exporting..."
: "Export WebP";
if (supportsWebp) {
elements.tarotHouseExportWebpEl.title = "Smaller file size, but not guaranteed lossless like PNG.";
}
}
}
function refreshHouseUi() {
if (!state.initialized) {
return;
}
const elements = getElements();
renderHouseOfCards(elements);
syncHouseControls(elements);
}
function setHouseTopCardsVisible(value) {
state.houseTopCardsVisible = Boolean(value);
refreshHouseUi();
}
function setHouseTopInfoMode(mode, value) {
const key = String(mode || "").trim();
if (!key || !Object.prototype.hasOwnProperty.call(state.houseTopInfoModes, key)) {
return;
}
state.houseTopInfoModes[key] = Boolean(value);
refreshHouseUi();
}
function setHouseBottomCardsVisible(value) {
state.houseBottomCardsVisible = Boolean(value);
refreshHouseUi();
}
function setHouseBottomInfoMode(mode, value) {
const key = String(mode || "").trim();
if (!key || !Object.prototype.hasOwnProperty.call(state.houseBottomInfoModes, key)) {
return;
}
state.houseBottomInfoModes[key] = Boolean(value);
refreshHouseUi();
}
async function exportHouseOfCards(elements, format = "png") {
if (state.houseExportInProgress) {
return;
}
state.houseExportInProgress = true;
state.houseExportFormat = format;
syncHouseControls(elements);
try {
await tarotHouseUi.exportImage?.(format);
} catch (error) {
window.alert(error instanceof Error ? error.message : "Unable to export the House of Cards image.");
} finally {
state.houseExportInProgress = false;
state.houseExportFormat = "png";
syncHouseControls(elements);
}
}
function buildTypeLabel(card) {
return tarotCardDerivationsUi.buildTypeLabel(card);
}
function findSephirahForMinorCard(card, kabTree) {
return tarotCardDerivationsUi.findSephirahForMinorCard(card, kabTree);
}
function formatRelation(relation) {
return tarotRelationDisplayUi.formatRelation(relation);
}
function relationKey(relation, index) {
return tarotRelationDisplayUi.relationKey(relation, index);
}
function normalizeRelationObject(relation, index) {
return tarotRelationDisplayUi.normalizeRelationObject(relation, index);
}
function formatRelationDataLines(relation) {
return tarotRelationDisplayUi.formatRelationDataLines(relation);
}
function buildCubeRelationsForCard(card) {
if (typeof tarotRelationsUi.buildCubeRelationsForCard !== "function") {
return [];
}
return tarotRelationsUi.buildCubeRelationsForCard(card, state.magickDataset);
}
function buildIChingRelationsForCard(card) {
if (typeof tarotRelationsUi.buildIChingRelationsForCard !== "function") {
return [];
}
return tarotRelationsUi.buildIChingRelationsForCard(card, state.referenceData);
}
// Returns nav dispatch config for relations that have a corresponding section,
// null for informational-only relations.
function getRelationNavTarget(relation) {
return tarotRelationDisplayUi.getRelationNavTarget(relation);
}
function createRelationListItem(relation) {
return tarotRelationDisplayUi.createRelationListItem(relation);
}
function renderStaticRelationGroup(targetEl, cardEl, relations) {
tarotDetailRenderer.renderStaticRelationGroup(targetEl, cardEl, relations);
}
function renderDetail(card, elements) {
tarotDetailRenderer.renderDetail(card, elements);
}
function updateListSelection(elements) {
if (!elements?.tarotCardListEl) {
return;
}
const buttons = elements.tarotCardListEl.querySelectorAll(".tarot-list-item");
buttons.forEach((button) => {
const isSelected = button.dataset.cardId === state.selectedCardId;
button.classList.toggle("is-selected", isSelected);
button.setAttribute("aria-selected", isSelected ? "true" : "false");
});
}
function getCardSequenceState() {
const total = state.filteredCards.length;
const currentIndex = state.filteredCards.findIndex((card) => card.id === state.selectedCardId);
return {
total,
currentIndex,
previousId: currentIndex > 0 ? state.filteredCards[currentIndex - 1].id : "",
nextId: currentIndex >= 0 && currentIndex < total - 1 ? state.filteredCards[currentIndex + 1].id : ""
};
}
function getDetailNavigator() {
if (detailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") {
return detailNavigator;
}
detailNavigator = window.TarotSequenceNav.createSequenceNavigator({
getElements,
isActive: (elements) => Boolean(elements?.tarotSectionEl && elements.tarotSectionEl.hidden === false),
getSequenceState: getCardSequenceState,
getPrevButton: (elements) => elements?.tarotDetailPrevEl,
getNextButton: (elements) => elements?.tarotDetailNextEl,
getPositionEl: (elements) => elements?.tarotDetailPositionEl,
formatPositionText: ({ total, currentIndex }) => {
if (total > 0 && currentIndex >= 0) {
const suffix = state.searchQuery ? " shown" : "";
return `${currentIndex + 1} of ${total}${suffix}`;
}
return total > 0 ? `${total} cards` : "No cards";
},
selectTarget: (targetId, elements) => {
selectCardById(targetId, elements);
return true;
},
afterSelect: (targetId, elements) => {
scrollCardIntoView(targetId, elements);
}
});
return detailNavigator;
}
function syncDetailNavigation(elements) {
getDetailNavigator()?.sync(elements);
}
function selectAdjacentCard(offset, elements = getElements()) {
return getDetailNavigator()?.step(offset, elements) === true;
}
function bindKeyboardNavigation(elements) {
getDetailNavigator()?.bind(elements);
}
function selectCardById(cardIdToSelect, elements) {
const card = state.cards.find((entry) => entry.id === cardIdToSelect);
if (!card) {
return;
}
state.selectedCardId = card.id;
updateListSelection(elements);
updateHouseSelection(elements);
renderDetail(card, elements);
syncDetailNavigation(elements);
}
function scrollCardIntoView(cardIdToReveal, elements) {
elements?.tarotCardListEl
?.querySelector(`[data-card-id="${cardIdToReveal}"]`)
?.scrollIntoView({ block: "nearest" });
}
function getRegisteredDeckOptionMap() {
const entries = typeof getDeckOptions === "function" ? getDeckOptions() : [];
return new Map(
(Array.isArray(entries) ? entries : [])
.map((entry) => ({
id: String(entry?.id || "").trim(),
label: String(entry?.label || entry?.id || "").trim()
}))
.filter((entry) => entry.id)
.map((entry) => [entry.id, entry])
);
}
function getRegisteredDeckList() {
return Array.from(getRegisteredDeckOptionMap().values());
}
function getDeckVariantsForCard(card) {
if (!card) {
return [];
}
const trumpNumber = Number.isFinite(Number(card?.number)) ? Number(card.number) : undefined;
const activeDeckId = String(getActiveDeck?.() || "").trim().toLowerCase();
return getRegisteredDeckList()
.map((deck) => {
const deckId = String(deck?.id || "").trim().toLowerCase();
if (!deckId) {
return null;
}
const imageOptions = { deckId, trumpNumber };
const src = (typeof resolveTarotCardThumbnail === "function"
? resolveTarotCardThumbnail(card.name, imageOptions)
: "") || (typeof resolveTarotCardImage === "function"
? resolveTarotCardImage(card.name, imageOptions)
: "");
if (!src) {
return null;
}
const displayName = (typeof getTarotCardDisplayName === "function"
? String(getTarotCardDisplayName(card.name, imageOptions) || "").trim()
: "") || getDisplayCardName(card) || card.name;
return {
deckId,
label: String(deck?.label || deckId).trim() || deckId,
src,
displayName,
isActive: deckId === activeDeckId
};
})
.filter(Boolean);
}
function buildDeckLightboxCardRequest(cardIdToResolve, deckIdToResolve = "") {
const card = state.cards.find((entry) => entry.id === cardIdToResolve);
if (!card) {
return null;
}
const resolvedDeckId = String(deckIdToResolve || getActiveDeck?.() || "").trim();
const trumpNumber = Number.isFinite(Number(card?.number)) ? Number(card.number) : undefined;
const deckOptions = resolvedDeckId ? { deckId: resolvedDeckId, trumpNumber } : { trumpNumber };
const src = typeof resolveTarotCardImage === "function"
? resolveTarotCardImage(card.name, deckOptions)
: "";
const previewSrc = typeof resolveTarotCardThumbnail === "function"
? (resolveTarotCardThumbnail(card.name, deckOptions) || src)
: src;
const deckMeta = resolvedDeckId ? getRegisteredDeckOptionMap().get(resolvedDeckId) : null;
const label = (typeof getTarotCardDisplayName === "function"
? getTarotCardDisplayName(card.name, deckOptions)
: "") || getDisplayCardName(card) || card.name || "Tarot card enlarged image";
return {
src,
previewSrc,
altText: label,
label,
cardId: card.id,
deckId: resolvedDeckId,
deckLabel: deckMeta?.label || resolvedDeckId,
compareDetails: tarotDetailRenderer.buildCompareDetails?.(card) || []
};
}
function buildLightboxCardRequestById(cardIdToResolve, deckIdToResolve = "") {
const request = buildDeckLightboxCardRequest(cardIdToResolve, deckIdToResolve || getActiveDeck?.() || "");
if (!request?.src) {
return null;
}
return request;
}
function getDefaultLightboxSequenceIds(cardIdToOpen = "") {
const normalizedCardId = String(cardIdToOpen || "").trim();
const filteredIds = state.filteredCards
.map((card) => String(card?.id || "").trim())
.filter(Boolean);
if (normalizedCardId && filteredIds.includes(normalizedCardId)) {
return filteredIds;
}
return state.cards
.map((card) => String(card?.id || "").trim())
.filter(Boolean);
}
function getDeckVariantSequenceEntries(cardIdToResolve) {
const card = state.cards.find((entry) => entry.id === cardIdToResolve);
if (!card) {
return [];
}
return getDeckVariantsForCard(card)
.map((variant) => {
const deckId = String(variant?.deckId || "").trim().toLowerCase();
if (!deckId) {
return null;
}
return {
sequenceId: deckId,
deckId
};
})
.filter(Boolean);
}
function openCardLightboxById(cardIdToOpen, options = {}) {
const normalizedCardId = String(cardIdToOpen || "").trim();
if (!normalizedCardId) {
return;
}
const requestedDeckId = String(options?.deckId || getActiveDeck?.() || "").trim();
const primaryCardRequest = buildLightboxCardRequestById(normalizedCardId, requestedDeckId);
if (!primaryCardRequest?.src) {
return;
}
const activeDeckId = String(primaryCardRequest.deckId || requestedDeckId || getActiveDeck?.() || "").trim();
const availableCompareDecks = getRegisteredDeckList().filter((deck) => deck.id && deck.id !== activeDeckId);
const requestedSequenceIds = Array.isArray(options?.sequenceIds)
? options.sequenceIds
.map((sequenceId) => String(sequenceId || "").trim())
.filter(Boolean)
: [];
const sequenceIds = requestedSequenceIds.length > 0
? requestedSequenceIds
: getDefaultLightboxSequenceIds(normalizedCardId);
const resolveCardById = typeof options?.resolveCardById === "function"
? options.resolveCardById
: (nextCardId) => buildLightboxCardRequestById(nextCardId, activeDeckId);
const onSelectCardId = typeof options?.onSelectCardId === "function"
? options.onSelectCardId
: (nextCardId) => {
const latestElements = getElements();
selectCardById(nextCardId, latestElements);
scrollCardIntoView(nextCardId, latestElements);
};
window.TarotUiLightbox?.open?.({
src: primaryCardRequest.src,
altText: primaryCardRequest.altText,
label: primaryCardRequest.label,
cardId: primaryCardRequest.cardId,
sequenceId: String(options?.sequenceId || primaryCardRequest.sequenceId || normalizedCardId).trim(),
deckId: primaryCardRequest.deckId || activeDeckId,
deckLabel: primaryCardRequest.deckLabel || "",
compareDetails: primaryCardRequest.compareDetails || [],
allowOverlayCompare: true,
allowDeckCompare: true,
activeDeckId,
activeDeckLabel: primaryCardRequest.deckLabel || "",
availableCompareDecks,
maxCompareDecks: 2,
sequenceIds,
resolveCardById,
resolveDeckCardById: buildDeckLightboxCardRequest,
onSelectCardId
});
}
function openDeckVariantLightbox(cardIdToOpen, deckIdToOpen = "") {
const normalizedCardId = String(cardIdToOpen || "").trim();
if (!normalizedCardId) {
return;
}
const variantEntries = getDeckVariantSequenceEntries(normalizedCardId);
if (variantEntries.length < 1) {
openCardLightboxById(normalizedCardId, { deckId: deckIdToOpen });
return;
}
const requestedDeckId = String(deckIdToOpen || getActiveDeck?.() || "").trim().toLowerCase();
const activeVariant = variantEntries.find((variant) => variant.deckId === requestedDeckId) || variantEntries[0];
if (!activeVariant?.deckId) {
openCardLightboxById(normalizedCardId);
return;
}
const primaryCardRequest = buildLightboxCardRequestById(normalizedCardId, activeVariant.deckId);
if (!primaryCardRequest?.src) {
return;
}
const availableCompareDecks = getRegisteredDeckList().filter((deck) => deck.id && deck.id !== activeVariant.deckId);
window.TarotUiLightbox?.open?.({
...primaryCardRequest,
deckId: activeVariant.deckId,
sequenceId: activeVariant.sequenceId,
activeDeckId: activeVariant.deckId,
activeDeckLabel: primaryCardRequest.deckLabel || "",
availableCompareDecks,
maxCompareDecks: 2,
allowOverlayCompare: true,
allowDeckCompare: true,
sequenceIds: variantEntries.map((variant) => variant.sequenceId),
resolveDeckCardById: buildDeckLightboxCardRequest,
resolveCardById: (sequenceId) => {
const normalizedSequenceId = String(sequenceId || "").trim().toLowerCase();
const matchingVariant = variantEntries.find((variant) => variant.sequenceId === normalizedSequenceId);
if (!matchingVariant?.deckId) {
return null;
}
const request = buildLightboxCardRequestById(normalizedCardId, matchingVariant.deckId);
return request
? {
...request,
sequenceId: matchingVariant.sequenceId,
deckId: matchingVariant.deckId
}
: null;
},
onSelectCardId: () => {
}
});
}
function renderList(elements) {
if (!elements?.tarotCardListEl) {
return;
}
clearChildren(elements.tarotCardListEl);
state.filteredCards.forEach((card) => {
const cardDisplayName = getDisplayCardName(card);
const button = document.createElement("button");
button.type = "button";
button.className = "tarot-list-item";
button.dataset.cardId = card.id;
button.setAttribute("role", "option");
const nameEl = document.createElement("span");
nameEl.className = "tarot-list-name";
nameEl.textContent = cardDisplayName || card.name;
const metaEl = document.createElement("span");
metaEl.className = "tarot-list-meta";
metaEl.textContent = buildTypeLabel(card);
button.append(nameEl, metaEl);
elements.tarotCardListEl.appendChild(button);
});
if (elements.tarotCountEl) {
elements.tarotCountEl.textContent = state.searchQuery
? `${state.filteredCards.length} of ${state.cards.length} cards`
: `${state.cards.length} cards`;
}
}
async function loadCards(referenceData, magickDataset) {
const payload = await dataService.loadTarotCards?.();
const cards = Array.isArray(payload?.cards)
? payload.cards
: (Array.isArray(payload) ? payload : []);
return cards.map((card) => ({
...card,
id: String(card?.id || "").trim() || cardId(card)
}));
}
async function ensureTarotSection(referenceData, magickDataset = null) {
state.referenceData = referenceData || state.referenceData;
if (magickDataset) {
state.magickDataset = magickDataset;
}
tarotHouseUi.init?.({
resolveTarotCardImage,
resolveTarotCardThumbnail,
getActiveDeck,
getDisplayCardName,
clearChildren,
normalizeTarotCardLookupName,
selectCardById,
openCardLightbox: (src, altText, options = {}) => {
const cardId = String(options?.cardId || "").trim();
if (cardId) {
openCardLightboxById(cardId);
return;
}
window.TarotUiLightbox?.open?.({
src,
altText: altText || "Tarot card enlarged image",
label: altText || "Tarot card enlarged image"
});
},
shouldOpenCardLightboxOnSelect: (latestElements) => Boolean(
latestElements?.tarotHouseSectionEl instanceof HTMLElement
&& latestElements.tarotHouseSectionEl.hidden === false
),
isHouseFocusMode: () => state.houseFocusMode,
getCards: () => state.cards,
getSelectedCardId: () => state.selectedCardId,
getHouseTopCardsVisible: () => state.houseTopCardsVisible,
getHouseTopInfoModes: () => ({ ...state.houseTopInfoModes }),
getMagickDataset: () => state.magickDataset,
getHouseBottomCardsVisible: () => state.houseBottomCardsVisible,
getHouseBottomInfoModes: () => ({ ...state.houseBottomInfoModes })
});
const elements = getElements();
if (state.initialized) {
state.monthRefsByCardId = buildMonthReferencesByCard(referenceData, state.cards);
state.courtCardByDecanId = buildCourtCardByDecanId(state.cards);
renderHouseOfCards(elements);
syncHouseControls(elements);
if (state.selectedCardId) {
const selected = state.cards.find((card) => card.id === state.selectedCardId);
if (selected) {
renderDetail(selected, elements);
syncDetailNavigation(elements);
}
}
return;
}
if (!elements.tarotCardListEl || !elements.tarotDetailNameEl) {
return;
}
if (state.loadingPromise) {
await state.loadingPromise;
return;
}
state.loadingPromise = (async () => {
const cards = await loadCards(referenceData, magickDataset);
state.cards = cards;
state.monthRefsByCardId = buildMonthReferencesByCard(referenceData, cards);
state.courtCardByDecanId = buildCourtCardByDecanId(cards);
state.filteredCards = [...cards];
renderList(elements);
renderHouseOfCards(elements);
syncHouseControls(elements);
if (cards.length > 0) {
selectCardById(cards[0].id, elements);
}
elements.tarotCardListEl.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
const button = target instanceof Element
? target.closest(".tarot-list-item")
: null;
if (!(button instanceof HTMLButtonElement)) {
return;
}
const selectedId = button.dataset.cardId;
if (!selectedId) {
return;
}
selectCardById(selectedId, elements);
});
if (elements.tarotSearchInputEl) {
elements.tarotSearchInputEl.addEventListener("input", () => {
state.searchQuery = elements.tarotSearchInputEl.value || "";
applySearchFilter(elements);
});
}
if (elements.tarotSearchClearEl && elements.tarotSearchInputEl) {
elements.tarotSearchClearEl.addEventListener("click", () => {
elements.tarotSearchInputEl.value = "";
state.searchQuery = "";
applySearchFilter(elements);
elements.tarotSearchInputEl.focus();
});
}
bindKeyboardNavigation(elements);
if (elements.tarotHouseTopCardsVisibleEl) {
elements.tarotHouseTopCardsVisibleEl.addEventListener("change", () => {
state.houseTopCardsVisible = Boolean(elements.tarotHouseTopCardsVisibleEl.checked);
renderHouseOfCards(elements);
syncHouseControls(elements);
});
}
if (elements.tarotHouseSettingsToggleEl) {
elements.tarotHouseSettingsToggleEl.addEventListener("click", (event) => {
event.stopPropagation();
state.houseSettingsOpen = !state.houseSettingsOpen;
syncHouseControls(elements);
});
}
if (elements.tarotHouseSettingsPanelEl) {
elements.tarotHouseSettingsPanelEl.addEventListener("click", (event) => {
event.stopPropagation();
});
}
document.addEventListener("click", (event) => {
if (!state.houseSettingsOpen) {
return;
}
const target = event.target;
if (!(target instanceof Node)) {
return;
}
const settingsPanelEl = elements.tarotHouseSettingsPanelEl;
const settingsToggleEl = elements.tarotHouseSettingsToggleEl;
if (settingsPanelEl?.contains(target) || settingsToggleEl?.contains(target)) {
return;
}
state.houseSettingsOpen = false;
syncHouseControls(elements);
});
[
[elements.tarotHouseTopInfoHebrewEl, "hebrew"],
[elements.tarotHouseTopInfoPlanetEl, "planet"],
[elements.tarotHouseTopInfoZodiacEl, "zodiac"],
[elements.tarotHouseTopInfoTrumpEl, "trump"],
[elements.tarotHouseTopInfoPathEl, "path"],
[elements.tarotHouseTopInfoDateEl, "date"]
].forEach(([checkbox, key]) => {
if (!checkbox) {
return;
}
checkbox.addEventListener("change", () => {
state.houseTopInfoModes[key] = Boolean(checkbox.checked);
renderHouseOfCards(elements);
syncHouseControls(elements);
});
});
if (elements.tarotHouseBottomCardsVisibleEl) {
elements.tarotHouseBottomCardsVisibleEl.addEventListener("change", () => {
state.houseBottomCardsVisible = Boolean(elements.tarotHouseBottomCardsVisibleEl.checked);
renderHouseOfCards(elements);
syncHouseControls(elements);
});
}
[
[elements.tarotHouseBottomInfoZodiacEl, "zodiac"],
[elements.tarotHouseBottomInfoDecanEl, "decan"],
[elements.tarotHouseBottomInfoMonthEl, "month"],
[elements.tarotHouseBottomInfoRulerEl, "ruler"],
[elements.tarotHouseBottomInfoDateEl, "date"]
].forEach(([checkbox, key]) => {
if (!checkbox) {
return;
}
checkbox.addEventListener("change", () => {
state.houseBottomInfoModes[key] = Boolean(checkbox.checked);
renderHouseOfCards(elements);
syncHouseControls(elements);
});
});
if (elements.tarotHouseExportWebpEl) {
elements.tarotHouseExportWebpEl.addEventListener("click", () => {
exportHouseOfCards(elements, "webp");
});
}
if (elements.tarotDetailImageEl) {
elements.tarotDetailImageEl.addEventListener("click", () => {
if (elements.tarotDetailImageEl.style.display === "none" || !state.selectedCardId) {
return;
}
openCardLightboxById(state.selectedCardId);
});
}
state.initialized = true;
})();
try {
await state.loadingPromise;
} finally {
state.loadingPromise = null;
}
}
function selectCardByTrump(trumpNumber) {
if (!state.initialized) return;
const el = getElements();
const card = state.cards.find(c => c.arcana === "Major" && c.number === trumpNumber);
if (!card) return;
selectCardById(card.id, el);
scrollCardIntoView(card.id, el);
}
function selectCardByName(name) {
if (!state.initialized) return;
const el = getElements();
const needle = normalizeTarotCardLookupName(name);
const card = state.cards.find((entry) => normalizeTarotCardLookupName(entry.name) === needle);
if (!card) return;
selectCardById(card.id, el);
scrollCardIntoView(card.id, el);
}
window.TarotSectionUi = {
ensureTarotSection,
selectCardByTrump,
selectCardByName,
openCardLightboxById,
getCards: () => state.cards,
getHouseTopCardsVisible: () => state.houseTopCardsVisible,
getHouseTopInfoModes: () => ({ ...state.houseTopInfoModes }),
getHouseBottomCardsVisible: () => state.houseBottomCardsVisible,
getHouseBottomInfoModes: () => ({ ...state.houseBottomInfoModes }),
setHouseTopCardsVisible,
setHouseTopInfoMode,
setHouseBottomCardsVisible,
setHouseBottomInfoMode
};
})();