Files
TaroTime/app/ui-tarot.js
2026-03-07 05:17:50 -08:00

1370 lines
42 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 { resolveTarotCardImage, getTarotCardDisplayName, getTarotCardSearchAliases } = window.TarotCardImages || {};
const tarotHouseUi = window.TarotHouseUi || {};
const tarotRelationsUi = window.TarotRelationsUi || {};
const state = {
initialized: false,
cards: [],
filteredCards: [],
searchQuery: "",
selectedCardId: "",
magickDataset: null,
referenceData: null,
monthRefsByCardId: new Map(),
courtCardByDecanId: new Map()
};
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"
}
};
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 {
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"),
tarotDetailSummaryEl: document.getElementById("tarot-detail-summary"),
tarotDetailUprightEl: document.getElementById("tarot-detail-upright"),
tarotDetailReversedEl: document.getElementById("tarot-detail-reversed"),
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"),
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"),
tarotDetailCalendarEl: document.getElementById("tarot-detail-calendar"),
tarotKabPathEl: document.getElementById("tarot-kab-path"),
tarotHouseOfCardsEl: document.getElementById("tarot-house-of-cards")
};
}
function normalizeRelationId(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
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 resolveElementIdForCard(card) {
if (!card) {
return "";
}
const cardLookupName = normalizeTarotCardLookupName(card.name);
const rankKey = String(card.rank || "").trim().toLowerCase();
return ACE_ELEMENT_BY_CARD_NAME[cardLookupName] || COURT_ELEMENT_BY_RANK[rankKey] || "";
}
function createElementRelation(card, elementId, sourceKind, sourceLabel) {
if (!card || !elementId) {
return null;
}
const elementName = ELEMENT_NAME_BY_ID[elementId] || toTitleCase(elementId);
const hebrewLetter = ELEMENT_HEBREW_LETTER_BY_ID[elementId] || "";
const hebrewChar = ELEMENT_HEBREW_CHAR_BY_ID[elementId] || "";
const relationLabel = `${elementName}${hebrewChar ? ` (${hebrewChar})` : (hebrewLetter ? ` (${hebrewLetter})` : "")} · ${sourceLabel}`;
return {
type: "element",
id: elementId,
label: relationLabel,
data: {
elementId,
name: elementName,
tarotCard: card.name,
hebrewLetter,
hebrewChar,
sourceKind,
sourceLabel,
rank: card.rank || "",
suit: card.suit || ""
},
__key: `element|${elementId}|${sourceKind}|${normalizeRelationId(sourceLabel)}|${card.id || normalizeTarotCardLookupName(card.name)}`
};
}
function buildElementRelationsForCard(card, baseElementRelations = []) {
if (!card) {
return [];
}
if (card.arcana === "Major") {
return Array.isArray(baseElementRelations) ? [...baseElementRelations] : [];
}
const relations = [];
const suitKey = String(card.suit || "").trim().toLowerCase();
const suitElementId = SUIT_ELEMENT_BY_SUIT[suitKey] || "";
if (suitElementId) {
const suitRelation = createElementRelation(card, suitElementId, "suit", `Suit: ${card.suit}`);
if (suitRelation) {
relations.push(suitRelation);
}
}
const rankKey = String(card.rank || "").trim().toLowerCase();
const courtElementId = COURT_ELEMENT_BY_RANK[rankKey] || "";
if (courtElementId) {
const courtRelation = createElementRelation(card, courtElementId, "court", `Court: ${card.rank}`);
if (courtRelation) {
relations.push(courtRelation);
}
}
return relations;
}
function buildTetragrammatonRelationsForCard(card) {
if (!card) {
return [];
}
const elementId = resolveElementIdForCard(card);
if (!elementId) {
return [];
}
const letter = ELEMENT_HEBREW_LETTER_BY_ID[elementId] || "";
if (!letter) {
return [];
}
const elementName = ELEMENT_NAME_BY_ID[elementId] || elementId;
const letterKey = String(letter || "").trim().toLowerCase();
const hebrewLetterId = HEBREW_LETTER_ID_BY_TETRAGRAMMATON_LETTER[letterKey] || "";
return [{
type: "tetragrammaton",
id: `${letterKey}-${elementId}`,
label: `${letter} · ${elementName}`,
data: {
letter,
elementId,
elementName,
hebrewLetterId
},
__key: `tetragrammaton|${letterKey}|${elementId}|${card.id || normalizeTarotCardLookupName(card.name)}`
}];
}
function getSmallCardModality(rankNumber) {
const numeric = Number(rankNumber);
if (!Number.isFinite(numeric) || numeric < 2 || numeric > 10) {
return "";
}
if (numeric <= 4) {
return "cardinal";
}
if (numeric <= 7) {
return "fixed";
}
return "mutable";
}
function buildSmallCardRulershipRelation(card) {
if (!card || card.arcana !== "Minor") {
return null;
}
const rankKey = String(card.rank || "").trim().toLowerCase();
const rankNumber = MINOR_RANK_NUMBER_BY_NAME[rankKey];
const modality = getSmallCardModality(rankNumber);
if (!modality) {
return null;
}
const suitKey = String(card.suit || "").trim().toLowerCase();
const signId = SMALL_CARD_SIGN_BY_MODALITY_AND_SUIT[modality]?.[suitKey] || "";
if (!signId) {
return null;
}
const sign = (Array.isArray(state.referenceData?.signs) ? state.referenceData.signs : [])
.find((entry) => String(entry?.id || "").trim().toLowerCase() === signId);
const signName = String(sign?.name || toTitleCase(signId));
const signSymbol = String(sign?.symbol || "").trim();
const modalityName = toTitleCase(modality);
return {
type: "zodiacRulership",
id: `${signId}-${rankKey}-${suitKey}`,
label: `Sign type: ${modalityName} · ${signSymbol} ${signName}`.trim(),
data: {
signId,
signName,
symbol: signSymbol,
modality,
rank: card.rank,
suit: card.suit
},
__key: `zodiacRulership|${signId}|${rankKey}|${suitKey}`
};
}
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);
}
return;
}
updateListSelection(elements);
}
function clearChildren(element) {
if (element) {
element.replaceChildren();
}
}
function updateHouseSelection(elements) {
tarotHouseUi.updateSelection?.(elements);
}
function renderHouseOfCards(elements) {
tarotHouseUi.render?.(elements);
}
function buildTypeLabel(card) {
if (card.arcana === "Major") {
return typeof card.number === "number"
? `Major Arcana · ${card.number}`
: "Major Arcana";
}
const parts = ["Minor Arcana"];
if (card.rank) {
parts.push(card.rank);
}
if (card.suit) {
parts.push(card.suit);
}
return parts.join(" · ");
}
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 findSephirahForMinorCard(card, kabTree) {
if (!card || card.arcana !== "Minor" || !kabTree) {
return null;
}
const rankKey = String(card.rank || "").trim().toLowerCase();
const plural = MINOR_PLURAL_BY_RANK[rankKey];
if (!plural) {
return null;
}
const matcher = new RegExp(`\\b4\\s+${plural}\\b`, "i");
return (kabTree.sephiroth || []).find((seph) => matcher.test(String(seph?.tarot || ""))) || null;
}
function formatRelation(relation) {
if (typeof relation === "string") {
return relation;
}
if (!relation || typeof relation !== "object") {
return "";
}
if (typeof relation.label === "string" && relation.label.trim()) {
return relation.label;
}
if (relation.type === "hebrewLetter" && relation.data) {
const glyph = relation.data.glyph || "";
const name = relation.data.name || relation.id || "Unknown";
const latin = relation.data.latin ? ` (${relation.data.latin})` : "";
const index = Number.isFinite(relation.data.index) ? relation.data.index : "?";
const value = Number.isFinite(relation.data.value) ? relation.data.value : "?";
const meaning = relation.data.meaning ? ` · ${relation.data.meaning}` : "";
return `Hebrew Letter: ${glyph} ${name}${latin} (index ${index}, value ${value})${meaning}`.trim();
}
if (typeof relation.type === "string" && typeof relation.id === "string") {
return `${relation.type}: ${relation.id}`;
}
return "";
}
function relationKey(relation, index) {
const safeType = String(relation?.type || "relation");
const safeId = String(relation?.id || index || "0");
const safeLabel = String(relation?.label || relation?.text || "");
return `${safeType}|${safeId}|${safeLabel}`;
}
function normalizeRelationObject(relation, index) {
if (relation && typeof relation === "object") {
const label = formatRelation(relation);
if (!label) {
return null;
}
return {
...relation,
label,
__key: relationKey(relation, index)
};
}
const text = formatRelation(relation);
if (!text) {
return null;
}
return {
type: "text",
id: `text-${index}`,
label: text,
data: { value: text },
__key: relationKey({ type: "text", id: `text-${index}`, label: text }, index)
};
}
function formatRelationDataLines(relation) {
if (!relation || typeof relation !== "object") {
return "--";
}
const data = relation.data;
if (!data || typeof data !== "object") {
return "(no additional relation data)";
}
const lines = Object.entries(data)
.filter(([, value]) => value !== null && value !== undefined && String(value).trim() !== "")
.map(([key, value]) => `${key}: ${value}`);
return lines.length ? lines.join("\n") : "(no additional relation data)";
}
function buildCubeRelationsForCard(card) {
if (typeof tarotRelationsUi.buildCubeRelationsForCard !== "function") {
return [];
}
return tarotRelationsUi.buildCubeRelationsForCard(card, state.magickDataset);
}
// Returns nav dispatch config for relations that have a corresponding section,
// null for informational-only relations.
function getRelationNavTarget(relation) {
const t = relation?.type;
const d = relation?.data || {};
if ((t === "planetCorrespondence" || t === "decanRuler") && d.planetId) {
return {
event: "nav:planet",
detail: { planetId: d.planetId },
label: `Open ${d.name || d.planetId} in Planets`
};
}
if (t === "planet") {
const planetId = normalizeRelationId(d.name || relation?.id || "");
if (!planetId) return null;
return {
event: "nav:planet",
detail: { planetId },
label: `Open ${d.name || planetId} in Planets`
};
}
if (t === "element") {
const elementId = d.elementId || relation?.id;
if (!elementId) {
return null;
}
return {
event: "nav:elements",
detail: { elementId },
label: `Open ${d.name || elementId} in Elements`
};
}
if (t === "tetragrammaton") {
if (!d.hebrewLetterId) {
return null;
}
return {
event: "nav:alphabet",
detail: { alphabet: "hebrew", hebrewLetterId: d.hebrewLetterId },
label: `Open ${d.letter || d.hebrewLetterId} in Alphabet`
};
}
if (t === "tarotCard") {
const cardName = d.cardName || relation?.id;
if (!cardName) {
return null;
}
return {
event: "nav:tarot-trump",
detail: { cardName },
label: `Open ${cardName} in Tarot`
};
}
if (t === "zodiacRulership") {
const signId = d.signId || relation?.id;
if (!signId) {
return null;
}
return {
event: "nav:zodiac",
detail: { signId },
label: `Open ${d.signName || signId} in Zodiac`
};
}
if (t === "zodiacCorrespondence" || t === "zodiac") {
const signId = d.signId || relation?.id || normalizeRelationId(d.name || "");
if (!signId) {
return null;
}
return {
event: "nav:zodiac",
detail: { signId },
label: `Open ${d.name || signId} in Zodiac`
};
}
if (t === "decan") {
const signId = d.signId || normalizeRelationId(d.signName || relation?.id || "");
if (!signId) {
return null;
}
return {
event: "nav:zodiac",
detail: { signId },
label: `Open ${d.signName || signId} in Zodiac`
};
}
if (t === "hebrewLetter") {
const hebrewLetterId = d.id || relation?.id;
if (!hebrewLetterId) {
return null;
}
return {
event: "nav:alphabet",
detail: { alphabet: "hebrew", hebrewLetterId },
label: `Open ${d.name || hebrewLetterId} in Alphabet`
};
}
if (t === "calendarMonth") {
const monthId = d.monthId || relation?.id;
if (!monthId) {
return null;
}
return {
event: "nav:calendar-month",
detail: { monthId },
label: `Open ${d.name || monthId} in Calendar`
};
}
if (t === "cubeFace") {
const wallId = d.wallId || relation?.id;
if (!wallId) {
return null;
}
return {
event: "nav:cube",
detail: { wallId, edgeId: "" },
label: `Open ${d.wallName || wallId} face in Cube`
};
}
if (t === "cubeEdge") {
const edgeId = d.edgeId || relation?.id;
if (!edgeId) {
return null;
}
return {
event: "nav:cube",
detail: { edgeId, wallId: d.wallId || undefined },
label: `Open ${d.edgeName || edgeId} edge in Cube`
};
}
if (t === "cubeConnector") {
const connectorId = d.connectorId || relation?.id;
if (!connectorId) {
return null;
}
return {
event: "nav:cube",
detail: { connectorId },
label: `Open ${d.connectorName || connectorId} connector in Cube`
};
}
if (t === "cubeCenter") {
return {
event: "nav:cube",
detail: { nodeType: "center", primalPoint: true },
label: "Open Primal Point in Cube"
};
}
return null;
}
function createRelationListItem(relation) {
const item = document.createElement("li");
const navTarget = getRelationNavTarget(relation);
const button = document.createElement("button");
button.type = "button";
button.className = "tarot-relation-btn";
button.dataset.relationKey = relation.__key;
button.textContent = relation.label;
item.appendChild(button);
if (!navTarget) {
button.classList.add("tarot-relation-btn-static");
}
button.addEventListener("click", () => {
if (navTarget) {
document.dispatchEvent(new CustomEvent(navTarget.event, { detail: navTarget.detail }));
}
});
if (navTarget) {
item.className = "tarot-rel-item";
const navBtn = document.createElement("button");
navBtn.type = "button";
navBtn.className = "tarot-rel-nav-btn";
navBtn.title = navTarget.label;
navBtn.textContent = "\u2197";
navBtn.addEventListener("click", (e) => {
e.stopPropagation();
document.dispatchEvent(new CustomEvent(navTarget.event, { detail: navTarget.detail }));
});
item.appendChild(navBtn);
}
return item;
}
function renderStaticRelationGroup(targetEl, cardEl, relations) {
clearChildren(targetEl);
if (!targetEl || !cardEl) return;
if (!relations.length) {
cardEl.hidden = true;
return;
}
cardEl.hidden = false;
relations.forEach((relation) => {
targetEl.appendChild(createRelationListItem(relation));
});
}
function renderDetail(card, elements) {
if (!card || !elements) {
return;
}
const cardDisplayName = getDisplayCardName(card);
const imageUrl = typeof resolveTarotCardImage === "function"
? resolveTarotCardImage(card.name)
: null;
if (elements.tarotDetailImageEl) {
if (imageUrl) {
elements.tarotDetailImageEl.src = imageUrl;
elements.tarotDetailImageEl.alt = cardDisplayName || card.name;
elements.tarotDetailImageEl.style.display = "block";
elements.tarotDetailImageEl.style.cursor = "zoom-in";
elements.tarotDetailImageEl.title = "Click to enlarge";
} else {
elements.tarotDetailImageEl.removeAttribute("src");
elements.tarotDetailImageEl.alt = "";
elements.tarotDetailImageEl.style.display = "none";
elements.tarotDetailImageEl.style.cursor = "default";
elements.tarotDetailImageEl.removeAttribute("title");
}
}
if (elements.tarotDetailNameEl) {
elements.tarotDetailNameEl.textContent = cardDisplayName || card.name;
}
if (elements.tarotDetailTypeEl) {
elements.tarotDetailTypeEl.textContent = buildTypeLabel(card);
}
if (elements.tarotDetailSummaryEl) {
elements.tarotDetailSummaryEl.textContent = card.summary || "--";
}
if (elements.tarotDetailUprightEl) {
elements.tarotDetailUprightEl.textContent = card.meanings?.upright || "--";
}
if (elements.tarotDetailReversedEl) {
elements.tarotDetailReversedEl.textContent = card.meanings?.reversed || "--";
}
const meaningText = String(card.meaning || card.meanings?.upright || "").trim();
if (elements.tarotMetaMeaningCardEl && elements.tarotDetailMeaningEl) {
if (meaningText) {
elements.tarotMetaMeaningCardEl.hidden = false;
elements.tarotDetailMeaningEl.textContent = meaningText;
} else {
elements.tarotMetaMeaningCardEl.hidden = true;
elements.tarotDetailMeaningEl.textContent = "--";
}
}
clearChildren(elements.tarotDetailKeywordsEl);
(card.keywords || []).forEach((keyword) => {
const chip = document.createElement("span");
chip.className = "tarot-keyword-chip";
chip.textContent = keyword;
elements.tarotDetailKeywordsEl?.appendChild(chip);
});
const allRelations = (card.relations || [])
.map((relation, index) => normalizeRelationObject(relation, index))
.filter(Boolean);
const uniqueByKey = new Set();
const dedupedRelations = allRelations.filter((relation) => {
const key = `${relation.type || "relation"}|${relation.id || ""}|${relation.label || ""}`;
if (uniqueByKey.has(key)) return false;
uniqueByKey.add(key);
return true;
});
const planetRelations = dedupedRelations.filter((relation) =>
relation.type === "planetCorrespondence" || relation.type === "decanRuler" || relation.type === "planet"
);
const zodiacRelations = dedupedRelations.filter((relation) =>
relation.type === "zodiacCorrespondence" || relation.type === "zodiac" || relation.type === "decan"
);
const courtDateRelations = dedupedRelations.filter((relation) => relation.type === "courtDateWindow");
const hebrewRelations = dedupedRelations.filter((relation) => relation.type === "hebrewLetter");
const baseElementRelations = dedupedRelations.filter((relation) => relation.type === "element");
const elementRelations = buildElementRelationsForCard(card, baseElementRelations);
const tetragrammatonRelations = buildTetragrammatonRelationsForCard(card);
const smallCardRulershipRelation = buildSmallCardRulershipRelation(card);
const zodiacRelationsWithRulership = smallCardRulershipRelation
? [...zodiacRelations, smallCardRulershipRelation]
: zodiacRelations;
const smallCardCourtLinkRelations = buildSmallCardCourtLinkRelations(card, dedupedRelations);
const mergedCourtDateRelations = [...courtDateRelations, ...smallCardCourtLinkRelations];
const cubeRelations = buildCubeRelationsForCard(card);
const monthRelations = (state.monthRefsByCardId.get(card.id) || []).map((month, index) => {
const dateRange = String(month?.dateRange || "").trim();
const context = String(month?.context || "").trim();
const labelBase = dateRange || month.name;
const label = context ? `${labelBase} · ${context}` : labelBase;
return {
type: "calendarMonth",
id: month.id,
label,
data: {
monthId: month.id,
name: month.name,
monthOrder: Number.isFinite(Number(month.order)) ? Number(month.order) : null,
dateRange: dateRange || null,
dateStart: month.startToken || null,
dateEnd: month.endToken || null,
context: context || null,
source: month.source || null
},
__key: `calendarMonth|${month.id}|${month.uniqueKey || index}`
};
});
const relationMonthRows = dedupedRelations
.filter((relation) => relation.type === "calendarMonth")
.map((relation) => {
const dateRange = String(relation?.data?.dateRange || "").trim();
const baseName = relation?.data?.name || relation.label;
const label = dateRange && baseName
? `${baseName} · ${dateRange}`
: baseName;
return {
type: "calendarMonth",
id: relation?.data?.monthId || relation.id,
label,
data: {
monthId: relation?.data?.monthId || relation.id,
name: relation?.data?.name || relation.label,
monthOrder: Number.isFinite(Number(relation?.data?.monthOrder))
? Number(relation.data.monthOrder)
: null,
dateRange: dateRange || null,
dateStart: relation?.data?.dateStart || null,
dateEnd: relation?.data?.dateEnd || null,
context: relation?.data?.signName || null
},
__key: relation.__key
};
})
.filter((entry) => entry.data.monthId);
const mergedMonthMap = new Map();
[...monthRelations, ...relationMonthRows].forEach((entry) => {
const monthId = entry?.data?.monthId;
if (!monthId) {
return;
}
const key = [
monthId,
String(entry?.data?.dateRange || "").trim().toLowerCase(),
String(entry?.data?.context || "").trim().toLowerCase(),
String(entry?.label || "").trim().toLowerCase()
].join("|");
if (!mergedMonthMap.has(key)) {
mergedMonthMap.set(key, entry);
}
});
const mergedMonthRelations = [...mergedMonthMap.values()].sort((left, right) => {
const orderLeft = Number.isFinite(Number(left?.data?.monthOrder)) ? Number(left.data.monthOrder) : 999;
const orderRight = Number.isFinite(Number(right?.data?.monthOrder)) ? Number(right.data.monthOrder) : 999;
if (orderLeft !== orderRight) {
return orderLeft - orderRight;
}
const startLeft = parseMonthDayToken(left?.data?.dateStart);
const startRight = parseMonthDayToken(right?.data?.dateStart);
const dayLeft = startLeft ? startLeft.day : 999;
const dayRight = startRight ? startRight.day : 999;
if (dayLeft !== dayRight) {
return dayLeft - dayRight;
}
return String(left.label || "").localeCompare(String(right.label || ""));
});
renderStaticRelationGroup(elements.tarotDetailPlanetEl, elements.tarotMetaPlanetCardEl, planetRelations);
renderStaticRelationGroup(elements.tarotDetailElementEl, elements.tarotMetaElementCardEl, elementRelations);
renderStaticRelationGroup(elements.tarotDetailTetragrammatonEl, elements.tarotMetaTetragrammatonCardEl, tetragrammatonRelations);
renderStaticRelationGroup(elements.tarotDetailZodiacEl, elements.tarotMetaZodiacCardEl, zodiacRelationsWithRulership);
renderStaticRelationGroup(elements.tarotDetailCourtDateEl, elements.tarotMetaCourtDateCardEl, mergedCourtDateRelations);
renderStaticRelationGroup(elements.tarotDetailHebrewEl, elements.tarotMetaHebrewCardEl, hebrewRelations);
renderStaticRelationGroup(elements.tarotDetailCubeEl, elements.tarotMetaCubeCardEl, cubeRelations);
renderStaticRelationGroup(elements.tarotDetailCalendarEl, elements.tarotMetaCalendarCardEl, mergedMonthRelations);
// ── Kabbalah Tree path cross-reference ─────────────────────────────────
const kabPathEl = elements.tarotKabPathEl;
if (kabPathEl) {
const kabTree = state.magickDataset?.grouped?.kabbalah?.["kabbalah-tree"];
const kabPath = (card.arcana === "Major" && typeof card.number === "number" && kabTree)
? kabTree.paths.find(p => p.tarot?.trumpNumber === card.number)
: null;
const kabSeph = !kabPath ? findSephirahForMinorCard(card, kabTree) : null;
if (kabPath) {
const letter = kabPath.hebrewLetter || {};
const fromName = kabTree.sephiroth.find(s => s.number === kabPath.connects.from)?.name || kabPath.connects.from;
const toName = kabTree.sephiroth.find(s => s.number === kabPath.connects.to)?.name || kabPath.connects.to;
const astro = kabPath.astrology ? `${kabPath.astrology.name} (${kabPath.astrology.type})` : "";
kabPathEl.innerHTML = `
<strong>Kabbalah Tree &#8212; Path ${kabPath.pathNumber}</strong>
<div class="tarot-kab-path-row">
<span class="tarot-kab-letter" title="${letter.transliteration || ""}">${letter.char || ""}</span>
<span class="tarot-kab-meta">
<span class="tarot-kab-name">${letter.transliteration || ""} &mdash; &ldquo;${letter.meaning || ""}&rdquo; &middot; ${letter.letterType || ""}</span>
<span class="tarot-kab-connects">${fromName} &rarr; ${toName}${astro ? " &middot; " + astro : ""}</span>
</span>
</div>`;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "kab-tarot-link";
btn.textContent = `View Path ${kabPath.pathNumber} in Kabbalah Tree`;
btn.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent("tarot:view-kab-path", {
detail: { pathNumber: kabPath.pathNumber }
}));
});
kabPathEl.appendChild(btn);
kabPathEl.hidden = false;
} else if (kabSeph) {
const hebrewName = kabSeph.nameHebrew ? ` (${kabSeph.nameHebrew})` : "";
const translation = kabSeph.translation ? `${kabSeph.translation}` : "";
const planetInfo = kabSeph.planet || "";
const tarotInfo = kabSeph.tarot ? ` · ${kabSeph.tarot}` : "";
kabPathEl.innerHTML = `
<strong>Kabbalah Tree &#8212; Sephirah ${kabSeph.number}</strong>
<div class="tarot-kab-path-row">
<span class="tarot-kab-letter" title="${kabSeph.name || ""}">${kabSeph.number}</span>
<span class="tarot-kab-meta">
<span class="tarot-kab-name">${kabSeph.name || ""}${hebrewName}${translation}</span>
<span class="tarot-kab-connects">${planetInfo}${tarotInfo}</span>
</span>
</div>`;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "kab-tarot-link";
btn.textContent = `View Sephirah ${kabSeph.number} in Kabbalah Tree`;
btn.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent("tarot:view-kab-path", {
detail: { pathNumber: kabSeph.number }
}));
});
kabPathEl.appendChild(btn);
kabPathEl.hidden = false;
} else {
kabPathEl.hidden = true;
kabPathEl.innerHTML = "";
}
}
}
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 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);
}
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`;
}
}
function ensureTarotSection(referenceData, magickDataset = null) {
state.referenceData = referenceData || state.referenceData;
if (magickDataset) {
state.magickDataset = magickDataset;
}
tarotHouseUi.init?.({
resolveTarotCardImage,
getDisplayCardName,
clearChildren,
normalizeTarotCardLookupName,
selectCardById,
getCards: () => state.cards,
getSelectedCardId: () => state.selectedCardId
});
const elements = getElements();
if (state.initialized) {
state.monthRefsByCardId = buildMonthReferencesByCard(referenceData, state.cards);
state.courtCardByDecanId = buildCourtCardByDecanId(state.cards);
renderHouseOfCards(elements);
if (state.selectedCardId) {
const selected = state.cards.find((card) => card.id === state.selectedCardId);
if (selected) {
renderDetail(selected, elements);
}
}
return;
}
if (!elements.tarotCardListEl || !elements.tarotDetailNameEl) {
return;
}
const databaseBuilder = window.TarotCardDatabase?.buildTarotDatabase;
if (typeof databaseBuilder !== "function") {
return;
}
const cards = databaseBuilder(referenceData, magickDataset).map((card) => ({
...card,
id: cardId(card)
}));
state.cards = cards;
state.monthRefsByCardId = buildMonthReferencesByCard(referenceData, cards);
state.courtCardByDecanId = buildCourtCardByDecanId(cards);
state.filteredCards = [...cards];
renderList(elements);
renderHouseOfCards(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();
});
}
if (elements.tarotDetailImageEl) {
elements.tarotDetailImageEl.addEventListener("click", () => {
const src = elements.tarotDetailImageEl.getAttribute("src") || "";
if (!src || elements.tarotDetailImageEl.style.display === "none") {
return;
}
window.TarotUiLightbox?.open?.(src, elements.tarotDetailImageEl.alt || "Tarot card enlarged image");
});
}
state.initialized = true;
}
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);
const listItem = el.tarotCardListEl?.querySelector(`[data-card-id="${card.id}"]`);
listItem?.scrollIntoView({ block: "nearest" });
}
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);
el.tarotCardListEl
?.querySelector(`[data-card-id="${card.id}"]`)
?.scrollIntoView({ block: "nearest" });
}
window.TarotSectionUi = {
ensureTarotSection,
selectCardByTrump,
selectCardByName,
getCards: () => state.cards
};
})();