Files
TaroTime/app/ui-tarot.js
2026-03-07 01:09:00 -08:00

2315 lines
71 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 state = {
initialized: false,
cards: [],
filteredCards: [],
searchQuery: "",
selectedCardId: "",
magickDataset: null,
referenceData: null,
monthRefsByCardId: new Map(),
courtCardByDecanId: new Map()
};
let tarotLightboxOverlayEl = null;
let tarotLightboxImageEl = null;
let tarotLightboxZoomed = false;
const LIGHTBOX_ZOOM_SCALE = 6.66;
function resetTarotLightboxZoom() {
if (!tarotLightboxImageEl) {
return;
}
tarotLightboxZoomed = false;
tarotLightboxImageEl.style.transform = "scale(1)";
tarotLightboxImageEl.style.transformOrigin = "center center";
tarotLightboxImageEl.style.cursor = "zoom-in";
}
function updateTarotLightboxZoomOrigin(clientX, clientY) {
if (!tarotLightboxZoomed || !tarotLightboxImageEl) {
return;
}
const rect = tarotLightboxImageEl.getBoundingClientRect();
if (!rect.width || !rect.height) {
return;
}
const x = Math.min(100, Math.max(0, ((clientX - rect.left) / rect.width) * 100));
const y = Math.min(100, Math.max(0, ((clientY - rect.top) / rect.height) * 100));
tarotLightboxImageEl.style.transformOrigin = `${x}% ${y}%`;
}
function isTarotLightboxPointOnCard(clientX, clientY) {
if (!tarotLightboxImageEl) {
return false;
}
const rect = tarotLightboxImageEl.getBoundingClientRect();
const naturalWidth = tarotLightboxImageEl.naturalWidth;
const naturalHeight = tarotLightboxImageEl.naturalHeight;
if (!rect.width || !rect.height || !naturalWidth || !naturalHeight) {
return true;
}
const frameAspect = rect.width / rect.height;
const imageAspect = naturalWidth / naturalHeight;
let renderWidth = rect.width;
let renderHeight = rect.height;
if (imageAspect > frameAspect) {
renderHeight = rect.width / imageAspect;
} else {
renderWidth = rect.height * imageAspect;
}
const left = rect.left + (rect.width - renderWidth) / 2;
const top = rect.top + (rect.height - renderHeight) / 2;
const right = left + renderWidth;
const bottom = top + renderHeight;
return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom;
}
function ensureTarotImageLightbox() {
if (tarotLightboxOverlayEl && tarotLightboxImageEl) {
return;
}
tarotLightboxOverlayEl = document.createElement("div");
tarotLightboxOverlayEl.setAttribute("aria-hidden", "true");
tarotLightboxOverlayEl.style.position = "fixed";
tarotLightboxOverlayEl.style.inset = "0";
tarotLightboxOverlayEl.style.background = "rgba(0, 0, 0, 0.82)";
tarotLightboxOverlayEl.style.display = "none";
tarotLightboxOverlayEl.style.alignItems = "center";
tarotLightboxOverlayEl.style.justifyContent = "center";
tarotLightboxOverlayEl.style.zIndex = "9999";
tarotLightboxOverlayEl.style.padding = "0";
const image = document.createElement("img");
image.alt = "Tarot card enlarged image";
image.style.maxWidth = "100vw";
image.style.maxHeight = "100vh";
image.style.width = "100vw";
image.style.height = "100vh";
image.style.objectFit = "contain";
image.style.borderRadius = "0";
image.style.boxShadow = "none";
image.style.border = "none";
image.style.cursor = "zoom-in";
image.style.transform = "scale(1)";
image.style.transformOrigin = "center center";
image.style.transition = "transform 120ms ease-out";
image.style.userSelect = "none";
tarotLightboxImageEl = image;
tarotLightboxOverlayEl.appendChild(image);
const closeLightbox = () => {
if (!tarotLightboxOverlayEl || !tarotLightboxImageEl) {
return;
}
tarotLightboxOverlayEl.style.display = "none";
tarotLightboxOverlayEl.setAttribute("aria-hidden", "true");
tarotLightboxImageEl.removeAttribute("src");
resetTarotLightboxZoom();
};
tarotLightboxOverlayEl.addEventListener("click", (event) => {
if (event.target === tarotLightboxOverlayEl) {
closeLightbox();
}
});
tarotLightboxImageEl.addEventListener("click", (event) => {
event.stopPropagation();
if (!isTarotLightboxPointOnCard(event.clientX, event.clientY)) {
closeLightbox();
return;
}
if (!tarotLightboxZoomed) {
tarotLightboxZoomed = true;
tarotLightboxImageEl.style.transform = `scale(${LIGHTBOX_ZOOM_SCALE})`;
tarotLightboxImageEl.style.cursor = "zoom-out";
updateTarotLightboxZoomOrigin(event.clientX, event.clientY);
return;
}
resetTarotLightboxZoom();
});
tarotLightboxImageEl.addEventListener("mousemove", (event) => {
updateTarotLightboxZoomOrigin(event.clientX, event.clientY);
});
tarotLightboxImageEl.addEventListener("mouseleave", () => {
if (tarotLightboxZoomed) {
tarotLightboxImageEl.style.transformOrigin = "center center";
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeLightbox();
}
});
document.body.appendChild(tarotLightboxOverlayEl);
}
function openTarotImageLightbox(src, altText) {
if (!src) {
return;
}
ensureTarotImageLightbox();
if (!tarotLightboxOverlayEl || !tarotLightboxImageEl) {
return;
}
tarotLightboxImageEl.src = src;
tarotLightboxImageEl.alt = altText || "Tarot card enlarged image";
resetTarotLightboxZoom();
tarotLightboxOverlayEl.style.display = "flex";
tarotLightboxOverlayEl.setAttribute("aria-hidden", "false");
}
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 MINOR_TITLE_WORD_BY_VALUE = {
1: "Ace",
2: "Two",
3: "Three",
4: "Four",
5: "Five",
6: "Six",
7: "Seven",
8: "Eight",
9: "Nine",
10: "Ten"
};
const HOUSE_MINOR_NUMBER_BANDS = [
[2, 3, 4],
[5, 6, 7],
[8, 9, 10],
[2, 3, 4],
[5, 6, 7],
[8, 9, 10]
];
const HOUSE_LEFT_SUITS = ["Wands", "Disks", "Swords", "Cups", "Wands", "Disks"];
const HOUSE_RIGHT_SUITS = ["Swords", "Cups", "Wands", "Disks", "Swords", "Cups"];
const HOUSE_MIDDLE_SUITS = ["Wands", "Cups", "Swords", "Disks"];
const HOUSE_MIDDLE_RANKS = ["Ace", "Knight", "Queen", "Prince", "Princess"];
const HOUSE_TRUMP_ROWS = [
[0],
[20, 21, 12],
[19, 10, 2, 1, 3, 16],
[18, 17, 15, 14, 13, 9, 8, 7, 6, 5, 4],
[11]
];
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) {
const map = new Map();
(cards || []).forEach((card) => {
if (!card || card.arcana !== "Minor") {
return;
}
const rankKey = String(card.rank || "").trim().toLowerCase();
if (rankKey !== "knight" && rankKey !== "queen" && rankKey !== "prince") {
return;
}
const windowRelation = (Array.isArray(card.relations) ? card.relations : [])
.find((relation) => relation && typeof relation === "object" && relation.type === "courtDateWindow");
const decanIds = Array.isArray(windowRelation?.data?.decanIds)
? windowRelation.data.decanIds
: [];
decanIds.forEach((decanId) => {
const decanKey = normalizeRelationId(decanId);
if (!decanKey || map.has(decanKey)) {
return;
}
map.set(decanKey, {
cardName: card.name,
rank: card.rank,
suit: card.suit,
dateRange: String(windowRelation?.data?.dateRange || "").trim()
});
});
});
return map;
}
function buildSmallCardCourtLinkRelations(card, relations) {
if (!card || card.arcana !== "Minor") {
return [];
}
const rankKey = String(card.rank || "").trim().toLowerCase();
const rankNumber = MINOR_RANK_NUMBER_BY_NAME[rankKey];
if (!Number.isFinite(rankNumber) || rankNumber < 2 || rankNumber > 10) {
return [];
}
const decans = (relations || []).filter((relation) => relation?.type === "decan");
if (!decans.length) {
return [];
}
const results = [];
const seenCourtCardNames = new Set();
decans.forEach((decan) => {
const signId = String(decan?.data?.signId || "").trim().toLowerCase();
const decanIndex = Number(decan?.data?.index);
if (!signId || !Number.isFinite(decanIndex)) {
return;
}
const decanId = normalizeRelationId(`${signId}-${decanIndex}`);
const linkedCourt = state.courtCardByDecanId.get(decanId);
if (!linkedCourt?.cardName || seenCourtCardNames.has(linkedCourt.cardName)) {
return;
}
seenCourtCardNames.add(linkedCourt.cardName);
results.push({
type: "tarotCard",
id: `${decanId}-${normalizeRelationId(linkedCourt.cardName)}`,
label: `Shared court date window: ${linkedCourt.cardName}${linkedCourt.dateRange ? ` · ${linkedCourt.dateRange}` : ""}`,
data: {
cardName: linkedCourt.cardName,
dateRange: linkedCourt.dateRange || "",
decanId
},
__key: `tarotCard|${decanId}|${normalizeRelationId(linkedCourt.cardName)}`
});
});
return results;
}
function normalizeHebrewLetterId(value) {
const key = String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z]/g, "");
return HEBREW_LETTER_ALIASES[key] || key;
}
function resolveTarotTrumpNumber(cardName) {
const key = normalizeTarotName(cardName);
if (!key) {
return null;
}
if (Object.prototype.hasOwnProperty.call(TAROT_TRUMP_NUMBER_BY_NAME, key)) {
return TAROT_TRUMP_NUMBER_BY_NAME[key];
}
const withoutLeadingThe = key.replace(/^the\s+/, "");
if (Object.prototype.hasOwnProperty.call(TAROT_TRUMP_NUMBER_BY_NAME, withoutLeadingThe)) {
return TAROT_TRUMP_NUMBER_BY_NAME[withoutLeadingThe];
}
return null;
}
function cardMatchesTarotAssociation(card, tarotCardName) {
const associationName = normalizeTarotName(tarotCardName);
if (!associationName || !card) {
return false;
}
const cardName = normalizeTarotName(card.name);
const cardBare = cardName.replace(/^the\s+/, "");
const assocBare = associationName.replace(/^the\s+/, "");
if (
associationName === cardName ||
associationName === cardBare ||
assocBare === cardName ||
assocBare === cardBare
) {
return true;
}
if (card.arcana === "Major" && Number.isFinite(Number(card.number))) {
const trumpNumber = resolveTarotTrumpNumber(associationName);
if (trumpNumber != null) {
return trumpNumber === Number(card.number);
}
}
return false;
}
function parseMonthDayToken(value) {
const text = String(value || "").trim();
const match = text.match(/^(\d{1,2})-(\d{1,2})$/);
if (!match) {
return null;
}
const month = Number(match[1]);
const day = Number(match[2]);
if (!Number.isInteger(month) || !Number.isInteger(day) || month < 1 || month > 12 || day < 1 || day > 31) {
return null;
}
return { month, day };
}
function toReferenceDate(token, year) {
if (!token) {
return null;
}
return new Date(year, token.month - 1, token.day, 12, 0, 0, 0);
}
function splitMonthDayRangeByMonth(startToken, endToken) {
const startDate = toReferenceDate(startToken, 2025);
const endBase = toReferenceDate(endToken, 2025);
if (!startDate || !endBase) {
return [];
}
const wrapsYear = endBase.getTime() < startDate.getTime();
const endDate = wrapsYear ? toReferenceDate(endToken, 2026) : endBase;
if (!endDate) {
return [];
}
const segments = [];
let cursor = new Date(startDate);
while (cursor.getTime() <= endDate.getTime()) {
const monthEnd = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0, 12, 0, 0, 0);
const segmentEnd = monthEnd.getTime() < endDate.getTime() ? monthEnd : endDate;
segments.push({
monthNo: cursor.getMonth() + 1,
startDay: cursor.getDate(),
endDay: segmentEnd.getDate()
});
cursor = new Date(segmentEnd.getFullYear(), segmentEnd.getMonth(), segmentEnd.getDate() + 1, 12, 0, 0, 0);
}
return segments;
}
function formatMonthDayRangeLabel(monthName, startDay, endDay) {
const start = Number(startDay);
const end = Number(endDay);
if (!Number.isFinite(start) || !Number.isFinite(end)) {
return monthName;
}
if (start === end) {
return `${monthName} ${start}`;
}
return `${monthName} ${start}-${end}`;
}
function buildMonthReferencesByCard(referenceData, cards) {
const map = new Map();
const months = Array.isArray(referenceData?.calendarMonths) ? referenceData.calendarMonths : [];
const holidays = Array.isArray(referenceData?.celestialHolidays) ? referenceData.celestialHolidays : [];
const signs = Array.isArray(referenceData?.signs) ? referenceData.signs : [];
const monthById = new Map(months.map((month) => [month.id, month]));
function parseMonthFromDateToken(value) {
const token = parseMonthDayToken(value);
return token ? token.month : null;
}
function findMonthByNumber(monthNo) {
if (!Number.isInteger(monthNo) || monthNo < 1 || monthNo > 12) {
return null;
}
const byOrder = months.find((month) => Number(month?.order) === monthNo);
if (byOrder) {
return byOrder;
}
return months.find((month) => parseMonthFromDateToken(month?.start) === monthNo) || null;
}
function pushRef(card, month, options = {}) {
if (!card?.id || !month?.id) {
return;
}
if (!map.has(card.id)) {
map.set(card.id, []);
}
const rows = map.get(card.id);
const monthOrder = Number.isFinite(Number(month.order)) ? Number(month.order) : 999;
const startToken = parseMonthDayToken(options.startToken || month.start);
const endToken = parseMonthDayToken(options.endToken || month.end);
const dateRange = String(options.dateRange || "").trim() || (
startToken && endToken
? formatMonthDayRangeLabel(month.name || month.id, startToken.day, endToken.day)
: ""
);
const uniqueKey = [
month.id,
dateRange.toLowerCase(),
String(options.context || "").trim().toLowerCase(),
String(options.source || "").trim().toLowerCase()
].join("|");
if (rows.some((entry) => entry.uniqueKey === uniqueKey)) {
return;
}
rows.push({
id: month.id,
name: month.name || month.id,
order: monthOrder,
startToken: startToken ? `${String(startToken.month).padStart(2, "0")}-${String(startToken.day).padStart(2, "0")}` : null,
endToken: endToken ? `${String(endToken.month).padStart(2, "0")}-${String(endToken.day).padStart(2, "0")}` : null,
dateRange,
context: String(options.context || "").trim(),
source: String(options.source || "").trim(),
uniqueKey
});
}
function captureRefs(associations, month) {
const tarotCardName = associations?.tarotCard;
if (!tarotCardName) {
return;
}
cards.forEach((card) => {
if (cardMatchesTarotAssociation(card, tarotCardName)) {
pushRef(card, month);
}
});
}
months.forEach((month) => {
captureRefs(month?.associations, month);
const events = Array.isArray(month?.events) ? month.events : [];
events.forEach((event) => {
const tarotCardName = event?.associations?.tarotCard;
if (!tarotCardName) {
return;
}
cards.forEach((card) => {
if (!cardMatchesTarotAssociation(card, tarotCardName)) {
return;
}
pushRef(card, month, {
source: "month-event",
context: String(event?.name || "").trim()
});
});
});
});
holidays.forEach((holiday) => {
const month = monthById.get(holiday?.monthId);
if (!month) {
return;
}
const tarotCardName = holiday?.associations?.tarotCard;
if (!tarotCardName) {
return;
}
cards.forEach((card) => {
if (!cardMatchesTarotAssociation(card, tarotCardName)) {
return;
}
pushRef(card, month, {
source: "holiday",
context: String(holiday?.name || "").trim()
});
});
});
signs.forEach((sign) => {
const signTrumpNumber = Number(sign?.tarot?.number);
const signTarotName = sign?.tarot?.majorArcana || sign?.tarot?.card || sign?.tarotCard;
if (!Number.isFinite(signTrumpNumber) && !signTarotName) {
return;
}
const signName = String(sign?.name || sign?.id || "").trim();
const startToken = parseMonthDayToken(sign?.start);
const endToken = parseMonthDayToken(sign?.end);
const monthSegments = splitMonthDayRangeByMonth(startToken, endToken);
const fallbackStartMonthNo = parseMonthFromDateToken(sign?.start);
const fallbackEndMonthNo = parseMonthFromDateToken(sign?.end);
const fallbackStartMonth = findMonthByNumber(fallbackStartMonthNo);
const fallbackEndMonth = findMonthByNumber(fallbackEndMonthNo);
cards.forEach((card) => {
const cardTrumpNumber = Number(card?.number);
const matchesByTrump = card?.arcana === "Major"
&& Number.isFinite(cardTrumpNumber)
&& Number.isFinite(signTrumpNumber)
&& cardTrumpNumber === signTrumpNumber;
const matchesByName = signTarotName ? cardMatchesTarotAssociation(card, signTarotName) : false;
if (!matchesByTrump && !matchesByName) {
return;
}
if (monthSegments.length) {
monthSegments.forEach((segment) => {
const month = findMonthByNumber(segment.monthNo);
if (!month) {
return;
}
pushRef(card, month, {
source: "zodiac-window",
context: signName ? `${signName} window` : "",
startToken: `${String(segment.monthNo).padStart(2, "0")}-${String(segment.startDay).padStart(2, "0")}`,
endToken: `${String(segment.monthNo).padStart(2, "0")}-${String(segment.endDay).padStart(2, "0")}`,
dateRange: formatMonthDayRangeLabel(month.name || month.id, segment.startDay, segment.endDay)
});
});
return;
}
if (fallbackStartMonth) {
pushRef(card, fallbackStartMonth, {
source: "zodiac-window",
context: signName ? `${signName} window` : ""
});
}
if (fallbackEndMonth && (!fallbackStartMonth || fallbackEndMonth.id !== fallbackStartMonth.id)) {
pushRef(card, fallbackEndMonth, {
source: "zodiac-window",
context: signName ? `${signName} window` : ""
});
}
});
});
map.forEach((rows, key) => {
const monthIdsWithZodiacWindows = new Set(
rows
.filter((entry) => entry?.source === "zodiac-window" && entry?.id)
.map((entry) => entry.id)
);
const filteredRows = rows.filter((entry) => {
if (!entry?.id || entry?.source === "zodiac-window") {
return true;
}
if (!monthIdsWithZodiacWindows.has(entry.id)) {
return true;
}
const month = monthById.get(entry.id);
if (!month) {
return true;
}
const isFullMonth = String(entry.startToken || "") === String(month.start || "")
&& String(entry.endToken || "") === String(month.end || "");
return !isFullMonth;
});
filteredRows.sort((left, right) => {
if (left.order !== right.order) {
return left.order - right.order;
}
const startLeft = parseMonthDayToken(left.startToken);
const startRight = parseMonthDayToken(right.startToken);
const dayLeft = startLeft ? startLeft.day : 999;
const dayRight = startRight ? startRight.day : 999;
if (dayLeft !== dayRight) {
return dayLeft - dayRight;
}
return String(left.dateRange || left.name || "").localeCompare(String(right.dateRange || right.name || ""));
});
map.set(key, filteredRows);
});
return map;
}
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 getCardLookupMap(cards) {
const map = new Map();
(cards || []).forEach((card) => {
const key = normalizeTarotCardLookupName(card?.name);
if (key) {
map.set(key, card);
}
});
return map;
}
function buildMinorCardName(rankNumber, suit) {
const rank = MINOR_TITLE_WORD_BY_VALUE[Number(rankNumber)];
const suitName = String(suit || "").trim();
if (!rank || !suitName) {
return "";
}
return `${rank} of ${suitName}`;
}
function buildCourtCardName(rank, suit) {
const rankName = String(rank || "").trim();
const suitName = String(suit || "").trim();
if (!rankName || !suitName) {
return "";
}
return `${rankName} of ${suitName}`;
}
function findCardByLookupName(cardLookupMap, cardName) {
const key = normalizeTarotCardLookupName(cardName);
if (!key) {
return null;
}
return cardLookupMap.get(key) || null;
}
function findMajorCardByTrumpNumber(cards, trumpNumber) {
const target = Number(trumpNumber);
if (!Number.isFinite(target)) {
return null;
}
return (cards || []).find((card) => card?.arcana === "Major" && Number(card?.number) === target) || null;
}
function createHouseCardButton(card, elements) {
const button = document.createElement("button");
button.type = "button";
button.className = "tarot-house-card-btn";
if (!card) {
button.disabled = true;
const fallback = document.createElement("span");
fallback.className = "tarot-house-card-fallback";
fallback.textContent = "Missing";
button.appendChild(fallback);
return button;
}
const cardDisplayName = getDisplayCardName(card);
button.title = cardDisplayName || card.name;
button.setAttribute("aria-label", cardDisplayName || card.name);
button.dataset.houseCardId = card.id;
const imageUrl = typeof resolveTarotCardImage === "function"
? resolveTarotCardImage(card.name)
: null;
if (imageUrl) {
const image = document.createElement("img");
image.className = "tarot-house-card-image";
image.src = imageUrl;
image.alt = cardDisplayName || card.name;
button.appendChild(image);
} else {
const fallback = document.createElement("span");
fallback.className = "tarot-house-card-fallback";
fallback.textContent = cardDisplayName || card.name;
button.appendChild(fallback);
}
button.addEventListener("click", () => {
selectCardById(card.id, elements);
elements?.tarotCardListEl
?.querySelector(`[data-card-id="${card.id}"]`)
?.scrollIntoView({ block: "nearest" });
});
return button;
}
function updateHouseSelection(elements) {
if (!elements?.tarotHouseOfCardsEl) {
return;
}
const buttons = elements.tarotHouseOfCardsEl.querySelectorAll(".tarot-house-card-btn[data-house-card-id]");
buttons.forEach((button) => {
const isSelected = button.dataset.houseCardId === state.selectedCardId;
button.classList.toggle("is-selected", isSelected);
button.setAttribute("aria-current", isSelected ? "true" : "false");
});
}
function appendHouseMinorRow(columnEl, cardLookupMap, numbers, suit, elements) {
const rowEl = document.createElement("div");
rowEl.className = "tarot-house-row";
numbers.forEach((rankNumber) => {
const cardName = buildMinorCardName(rankNumber, suit);
const card = findCardByLookupName(cardLookupMap, cardName);
rowEl.appendChild(createHouseCardButton(card, elements));
});
columnEl.appendChild(rowEl);
}
function appendHouseCourtRow(columnEl, cardLookupMap, rank, elements) {
const rowEl = document.createElement("div");
rowEl.className = "tarot-house-row";
HOUSE_MIDDLE_SUITS.forEach((suit) => {
const cardName = buildCourtCardName(rank, suit);
const card = findCardByLookupName(cardLookupMap, cardName);
rowEl.appendChild(createHouseCardButton(card, elements));
});
columnEl.appendChild(rowEl);
}
function appendHouseTrumpRow(containerEl, trumpNumbers, elements) {
const rowEl = document.createElement("div");
rowEl.className = "tarot-house-trump-row";
(trumpNumbers || []).forEach((trumpNumber) => {
const card = findMajorCardByTrumpNumber(state.cards, trumpNumber);
rowEl.appendChild(createHouseCardButton(card, elements));
});
containerEl.appendChild(rowEl);
}
function renderHouseOfCards(elements) {
if (!elements?.tarotHouseOfCardsEl) {
return;
}
clearChildren(elements.tarotHouseOfCardsEl);
const cardLookupMap = getCardLookupMap(state.cards);
const trumpSectionEl = document.createElement("div");
trumpSectionEl.className = "tarot-house-trumps";
HOUSE_TRUMP_ROWS.forEach((trumpRow) => {
appendHouseTrumpRow(trumpSectionEl, trumpRow, elements);
});
const bottomGridEl = document.createElement("div");
bottomGridEl.className = "tarot-house-bottom-grid";
const leftColumnEl = document.createElement("div");
leftColumnEl.className = "tarot-house-column";
HOUSE_MINOR_NUMBER_BANDS.forEach((numbers, rowIndex) => {
appendHouseMinorRow(leftColumnEl, cardLookupMap, numbers, HOUSE_LEFT_SUITS[rowIndex], elements);
});
const middleColumnEl = document.createElement("div");
middleColumnEl.className = "tarot-house-column";
HOUSE_MIDDLE_RANKS.forEach((rank) => {
appendHouseCourtRow(middleColumnEl, cardLookupMap, rank, elements);
});
const rightColumnEl = document.createElement("div");
rightColumnEl.className = "tarot-house-column";
HOUSE_MINOR_NUMBER_BANDS.forEach((numbers, rowIndex) => {
appendHouseMinorRow(rightColumnEl, cardLookupMap, numbers, HOUSE_RIGHT_SUITS[rowIndex], elements);
});
bottomGridEl.append(leftColumnEl, middleColumnEl, rightColumnEl);
elements.tarotHouseOfCardsEl.append(trumpSectionEl, bottomGridEl);
updateHouseSelection(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 buildCubeFaceRelationsForCard(card) {
const cube = state.magickDataset?.grouped?.kabbalah?.cube;
const walls = Array.isArray(cube?.walls) ? cube.walls : [];
if (!card || !walls.length) {
return [];
}
return walls
.map((wall, index) => {
const wallTarot = wall?.associations?.tarotCard || wall?.tarotCard;
if (!wallTarot || !cardMatchesTarotAssociation(card, wallTarot)) {
return null;
}
const wallId = String(wall?.id || "").trim();
const wallName = String(wall?.name || wallId || "").trim();
if (!wallId) {
return null;
}
return {
type: "cubeFace",
id: wallId,
label: `Cube: ${wallName} Wall - Face`,
data: {
wallId,
wallName,
edgeId: ""
},
__key: `cubeFace|${wallId}|${index}`
};
})
.filter(Boolean);
}
function cardMatchesPathTarot(card, path) {
if (!card || !path) {
return false;
}
const trumpNumber = Number(path?.tarot?.trumpNumber);
if (card?.arcana === "Major" && Number.isFinite(Number(card?.number)) && Number.isFinite(trumpNumber)) {
if (Number(card.number) === trumpNumber) {
return true;
}
}
return cardMatchesTarotAssociation(card, path?.tarot?.card);
}
function buildCubeEdgeRelationsForCard(card) {
const cube = state.magickDataset?.grouped?.kabbalah?.cube;
const tree = state.magickDataset?.grouped?.kabbalah?.["kabbalah-tree"];
const edges = Array.isArray(cube?.edges) ? cube.edges : [];
const paths = Array.isArray(tree?.paths) ? tree.paths : [];
if (!card || !edges.length || !paths.length) {
return [];
}
const pathByLetterId = new Map(
paths
.map((path) => [normalizeHebrewLetterId(path?.hebrewLetter?.transliteration), path])
.filter(([letterId]) => Boolean(letterId))
);
return edges
.map((edge, index) => {
const edgeLetterId = normalizeHebrewLetterId(edge?.hebrewLetterId || edge?.associations?.hebrewLetterId);
if (!edgeLetterId) {
return null;
}
const pathMatch = pathByLetterId.get(edgeLetterId);
if (!pathMatch || !cardMatchesPathTarot(card, pathMatch)) {
return null;
}
const edgeId = String(edge?.id || "").trim();
if (!edgeId) {
return null;
}
const edgeName = String(edge?.name || edgeId).trim();
const wallId = String(Array.isArray(edge?.walls) ? (edge.walls[0] || "") : "").trim();
return {
type: "cubeEdge",
id: edgeId,
label: `Cube: ${edgeName} Edge`,
data: {
edgeId,
edgeName,
wallId: wallId || undefined,
hebrewLetterId: edgeLetterId
},
__key: `cubeEdge|${edgeId}|${index}`
};
})
.filter(Boolean);
}
function buildCubeMotherConnectorRelationsForCard(card) {
const tree = state.magickDataset?.grouped?.kabbalah?.["kabbalah-tree"];
const paths = Array.isArray(tree?.paths) ? tree.paths : [];
const relations = Array.isArray(card?.relations) ? card.relations : [];
return Object.entries(CUBE_MOTHER_CONNECTOR_BY_LETTER)
.map(([letterId, connector]) => {
const pathMatch = paths.find((path) => normalizeHebrewLetterId(path?.hebrewLetter?.transliteration) === letterId) || null;
const matchesByPath = cardMatchesPathTarot(card, pathMatch);
const matchesByHebrewRelation = relations.some((relation) => {
if (relation?.type !== "hebrewLetter") {
return false;
}
const relationLetterId = normalizeHebrewLetterId(
relation?.data?.id || relation?.id || relation?.data?.latin || relation?.data?.name
);
return relationLetterId === letterId;
});
if (!matchesByPath && !matchesByHebrewRelation) {
return null;
}
return {
type: "cubeConnector",
id: connector.connectorId,
label: `Cube: ${connector.connectorName}`,
data: {
connectorId: connector.connectorId,
connectorName: connector.connectorName,
hebrewLetterId: letterId
},
__key: `cubeConnector|${connector.connectorId}|${letterId}`
};
})
.filter(Boolean);
}
function buildCubePrimalPointRelationsForCard(card) {
const center = state.magickDataset?.grouped?.kabbalah?.cube?.center;
if (!center || !card) {
return [];
}
const centerTarot = center?.associations?.tarotCard || center?.tarotCard;
const centerTrump = Number(center?.associations?.tarotTrumpNumber);
const matchesByName = cardMatchesTarotAssociation(card, centerTarot);
const matchesByTrump = card?.arcana === "Major"
&& Number.isFinite(Number(card?.number))
&& Number.isFinite(centerTrump)
&& Number(card.number) === centerTrump;
if (!matchesByName && !matchesByTrump) {
return [];
}
return [{
type: "cubeCenter",
id: "primal-point",
label: "Cube: Primal Point",
data: {
nodeType: "center",
primalPoint: true
},
__key: "cubeCenter|primal-point"
}];
}
function buildCubeRelationsForCard(card) {
return [
...buildCubeFaceRelationsForCard(card),
...buildCubeEdgeRelationsForCard(card),
...buildCubePrimalPointRelationsForCard(card),
...buildCubeMotherConnectorRelationsForCard(card)
];
}
// 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;
}
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;
}
openTarotImageLightbox(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
};
})();