2315 lines
71 KiB
JavaScript
2315 lines
71 KiB
JavaScript
|
|
(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 — 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 || ""} — “${letter.meaning || ""}” · ${letter.letterType || ""}</span>
|
|||
|
|
<span class="tarot-kab-connects">${fromName} → ${toName}${astro ? " · " + 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 — 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
|
|||
|
|
};
|
|||
|
|
})();
|