diff --git a/app.js b/app.js index 2278fff..a3cad0e 100644 --- a/app.js +++ b/app.js @@ -11,12 +11,22 @@ const { ensureKabbalahSection } = window.KabbalahSectionUi || {}; const { ensureCubeSection } = window.CubeSectionUi || {}; const { ensureAlphabetSection } = window.AlphabetSectionUi || {}; const { ensureZodiacSection } = window.ZodiacSectionUi || {}; -const { ensureQuizSection, registerQuizCategory } = window.QuizSectionUi || {}; +const { ensureQuizSection } = window.QuizSectionUi || {}; const { ensureGodsSection } = window.GodsSectionUi || {}; const { ensureEnochianSection } = window.EnochianSectionUi || {}; const { ensureCalendarSection } = window.CalendarSectionUi || {}; const { ensureHolidaySection } = window.HolidaySectionUi || {}; const { ensureNatalPanel } = window.TarotNatalUi || {}; +const { ensureNumbersSection, selectNumberEntry, normalizeNumberValue } = window.TarotNumbersUi || {}; +const tarotSpreadUi = window.TarotSpreadUi || {}; +const settingsUi = window.TarotSettingsUi || {}; +const chromeUi = window.TarotChromeUi || {}; +const navigationUi = window.TarotNavigationUi || {}; +const calendarFormattingUi = window.TarotCalendarFormatting || {}; +const calendarVisualsUi = window.TarotCalendarVisuals || {}; +const homeUi = window.TarotHomeUi || {}; +const sectionStateUi = window.TarotSectionStateUi || {}; +const appRuntime = window.TarotAppRuntime || {}; const statusEl = document.getElementById("status"); const monthStripEl = document.getElementById("month-strip"); @@ -43,8 +53,6 @@ const openCalendarEl = document.getElementById("open-calendar"); const openCalendarMonthsEl = document.getElementById("open-calendar-months"); const openHolidaysEl = document.getElementById("open-holidays"); const openTarotEl = document.getElementById("open-tarot"); -const openTarotCardsEl = document.getElementById("open-tarot-cards"); -const openTarotSpreadEl = document.getElementById("open-tarot-spread"); const openAstronomyEl = document.getElementById("open-astronomy"); const openPlanetsEl = document.getElementById("open-planets"); const openCyclesEl = document.getElementById("open-cycles"); @@ -60,35 +68,10 @@ const openNatalEl = document.getElementById("open-natal"); const openQuizEl = document.getElementById("open-quiz"); const openGodsEl = document.getElementById("open-gods"); const openEnochianEl = document.getElementById("open-enochian"); -const openSettingsEl = document.getElementById("open-settings"); -const closeSettingsEl = document.getElementById("close-settings"); -const settingsPopupEl = document.getElementById("settings-popup"); -const settingsPopupCardEl = document.getElementById("settings-popup-card"); -const topbarDropdownEls = Array.from(document.querySelectorAll(".topbar-dropdown")); const latEl = document.getElementById("lat"); const lngEl = document.getElementById("lng"); -const timeFormatEl = document.getElementById("time-format"); -const birthDateEl = document.getElementById("birth-date"); -const tarotDeckEl = document.getElementById("tarot-deck"); -const saveSettingsEl = document.getElementById("save-settings"); -const useLocationEl = document.getElementById("use-location"); const nowSkyLayerEl = document.getElementById("now-sky-layer"); const nowPanelEl = document.getElementById("now-panel"); -const tarotBrowseViewEl = document.getElementById("tarot-browse-view"); -const tarotSpreadViewEl = document.getElementById("tarot-spread-view"); -const tarotSpreadBackEl = document.getElementById("tarot-spread-back"); -const tarotSpreadBtnThreeEl = document.getElementById("tarot-spread-btn-three"); -const tarotSpreadBtnCelticEl = document.getElementById("tarot-spread-btn-celtic"); -const tarotSpreadRedrawEl = document.getElementById("tarot-spread-redraw"); -const tarotSpreadMeaningsEl = document.getElementById("tarot-spread-meanings"); -const tarotSpreadBoardEl = document.getElementById("tarot-spread-board"); -const numbersCountEl = document.getElementById("numbers-count"); -const numbersListEl = document.getElementById("numbers-list"); -const numbersDetailNameEl = document.getElementById("numbers-detail-name"); -const numbersDetailTypeEl = document.getElementById("numbers-detail-type"); -const numbersDetailSummaryEl = document.getElementById("numbers-detail-summary"); -const numbersDetailBodyEl = document.getElementById("numbers-detail-body"); -const numbersSpecialPanelEl = document.getElementById("numbers-special-panel"); const nowElements = { nowHourEl: document.getElementById("now-hour"), @@ -118,12 +101,7 @@ const baseWeekOptions = { }; const PLANET_CALENDAR_ORDER = ["saturn", "jupiter", "mars", "sol", "venus", "mercury", "luna"]; -const SETTINGS_STORAGE_KEY = "tarot-time-settings-v1"; const DEFAULT_TAROT_DECK = "ceremonial-magick"; -const SIDEBAR_COLLAPSE_STORAGE_PREFIX = "tarot-sidebar-collapsed:"; -const DETAIL_COLLAPSE_STORAGE_PREFIX = "tarot-detail-collapsed:"; -const DEFAULT_DATASET_ENTRY_COLLAPSED = true; -const DEFAULT_DATASET_DETAIL_COLLAPSED = false; const DEFAULT_SETTINGS = { latitude: 51.5074, longitude: -0.1278, @@ -221,1724 +199,37 @@ const calendar = new tui.Calendar("#calendar", { } }); -let referenceData = null; -let magickDataset = null; -let currentGeo = null; -let nowInterval = null; -let centeredDayKey = getDateKey(new Date()); -let renderInProgress = false; -let currentTimeFormat = "minutes"; +appRuntime.init?.({ + calendar, + baseWeekOptions, + defaultSettings: DEFAULT_SETTINGS, + latEl, + lngEl, + nowElements, + calendarVisualsUi, + homeUi, + onStatus: (text) => setStatus(text), + services: { + getCenteredWeekStartDay, + getDateKey, + loadReferenceData, + loadMagickDataset, + buildWeekEvents, + updateNowPanel + }, + ensure: { + ensureTarotSection, + ensurePlanetSection, + ensureCyclesSection, + ensureIChingSection, + ensureCalendarSection, + ensureHolidaySection, + ensureNatalPanel, + ensureQuizSection + } +}); + let currentSettings = { ...DEFAULT_SETTINGS }; -let monthStripResizeFrame = null; -let lastNowSkyGeoKey = ""; -let lastNowSkySourceUrl = ""; -let activeSection = "home"; -let activeTarotSpread = null; // null = browse view; "three-card" | "celtic-cross" = spread view -let activeTarotSpreadDraw = []; -let numbersSectionInitialized = false; -let activeNumberValue = 0; -const NUMBERS_SPECIAL_BASE_VALUES = [1, 2, 3, 4]; -const numbersSpecialFlipState = new Map(); - -const DEFAULT_NUMBER_ENTRIES = Array.from({ length: 10 }, (_, value) => ({ - value, - label: `${value}`, - opposite: 9 - value, - digitalRoot: value, - summary: "", - keywords: [], - associations: { - kabbalahNode: value === 0 ? 10 : value, - playingSuit: "hearts" - } -})); - -function normalizeNumberValue(value) { - const parsed = Number(value); - if (!Number.isFinite(parsed)) { - return 0; - } - const normalized = Math.trunc(parsed); - if (normalized < 0) { - return 0; - } - if (normalized > 9) { - return 9; - } - return normalized; -} - -function normalizeNumberEntry(rawEntry) { - if (!rawEntry || typeof rawEntry !== "object") { - return null; - } - - const value = normalizeNumberValue(rawEntry.value); - const oppositeRaw = Number(rawEntry.opposite); - const opposite = Number.isFinite(oppositeRaw) - ? normalizeNumberValue(oppositeRaw) - : (9 - value); - const digitalRootRaw = Number(rawEntry.digitalRoot); - const digitalRoot = Number.isFinite(digitalRootRaw) - ? normalizeNumberValue(digitalRootRaw) - : value; - const kabbalahNodeRaw = Number(rawEntry?.associations?.kabbalahNode); - const kabbalahNode = Number.isFinite(kabbalahNodeRaw) - ? Math.max(1, Math.trunc(kabbalahNodeRaw)) - : (value === 0 ? 10 : value); - const tarotTrumpNumbersRaw = Array.isArray(rawEntry?.associations?.tarotTrumpNumbers) - ? rawEntry.associations.tarotTrumpNumbers - : []; - const tarotTrumpNumbers = Array.from(new Set( - tarotTrumpNumbersRaw - .map((item) => Number(item)) - .filter((item) => Number.isFinite(item)) - .map((item) => Math.trunc(item)) - )); - const playingSuitRaw = String(rawEntry?.associations?.playingSuit || "").trim().toLowerCase(); - const playingSuit = ["hearts", "diamonds", "clubs", "spades"].includes(playingSuitRaw) - ? playingSuitRaw - : "hearts"; - - return { - value, - label: String(rawEntry.label || value), - opposite, - digitalRoot, - summary: String(rawEntry.summary || ""), - keywords: Array.isArray(rawEntry.keywords) - ? rawEntry.keywords.map((keyword) => String(keyword || "").trim()).filter(Boolean) - : [], - associations: { - kabbalahNode, - tarotTrumpNumbers, - playingSuit - } - }; -} - -function getNumbersDatasetEntries() { - const numbersData = magickDataset?.grouped?.numbers; - const rawEntries = Array.isArray(numbersData) - ? numbersData - : (Array.isArray(numbersData?.entries) ? numbersData.entries : []); - - const normalizedEntries = rawEntries - .map((entry) => normalizeNumberEntry(entry)) - .filter(Boolean) - .sort((left, right) => left.value - right.value); - - return normalizedEntries.length - ? normalizedEntries - : DEFAULT_NUMBER_ENTRIES; -} - -function getNumberEntryByValue(value) { - const entries = getNumbersDatasetEntries(); - const normalized = normalizeNumberValue(value); - return entries.find((entry) => entry.value === normalized) || entries[0] || null; -} - -function getCalendarMonthLinksForNumber(value) { - const normalized = normalizeNumberValue(value); - const calendarGroups = [ - { - calendarId: "gregorian", - calendarLabel: "Gregorian", - months: Array.isArray(referenceData?.calendarMonths) ? referenceData.calendarMonths : [] - }, - { - calendarId: "hebrew", - calendarLabel: "Hebrew", - months: Array.isArray(referenceData?.hebrewCalendar?.months) ? referenceData.hebrewCalendar.months : [] - }, - { - calendarId: "islamic", - calendarLabel: "Islamic", - months: Array.isArray(referenceData?.islamicCalendar?.months) ? referenceData.islamicCalendar.months : [] - }, - { - calendarId: "wheel-of-year", - calendarLabel: "Wheel of the Year", - months: Array.isArray(referenceData?.wheelOfYear?.months) ? referenceData.wheelOfYear.months : [] - } - ]; - - const links = []; - calendarGroups.forEach((group) => { - group.months.forEach((month) => { - const monthOrder = Number(month?.order); - const normalizedOrder = Number.isFinite(monthOrder) ? Math.trunc(monthOrder) : null; - const monthRoot = normalizedOrder != null ? computeDigitalRoot(normalizedOrder) : null; - if (monthRoot !== normalized) { - return; - } - - links.push({ - calendarId: group.calendarId, - calendarLabel: group.calendarLabel, - monthId: String(month.id || "").trim(), - monthName: String(month.name || month.id || "Month").trim(), - monthOrder: normalizedOrder - }); - }); - }); - - return links.filter((link) => link.monthId); -} - -const PLAYING_SUIT_SYMBOL = { - hearts: "♥", - diamonds: "♦", - clubs: "♣", - spades: "♠" -}; - -const PLAYING_SUIT_LABEL = { - hearts: "Hearts", - diamonds: "Diamonds", - clubs: "Clubs", - spades: "Spades" -}; - -const PLAYING_SUIT_TO_TAROT = { - hearts: "Cups", - diamonds: "Pentacles", - clubs: "Wands", - spades: "Swords" -}; - -const PLAYING_RANKS = [ - { rank: "A", rankLabel: "Ace", rankValue: 1 }, - { rank: "2", rankLabel: "Two", rankValue: 2 }, - { rank: "3", rankLabel: "Three", rankValue: 3 }, - { rank: "4", rankLabel: "Four", rankValue: 4 }, - { rank: "5", rankLabel: "Five", rankValue: 5 }, - { rank: "6", rankLabel: "Six", rankValue: 6 }, - { rank: "7", rankLabel: "Seven", rankValue: 7 }, - { rank: "8", rankLabel: "Eight", rankValue: 8 }, - { rank: "9", rankLabel: "Nine", rankValue: 9 }, - { rank: "10", rankLabel: "Ten", rankValue: 10 }, - { rank: "J", rankLabel: "Jack", rankValue: null }, - { rank: "Q", rankLabel: "Queen", rankValue: null }, - { rank: "K", rankLabel: "King", rankValue: null } -]; - -function rankLabelToTarotMinorRank(rankLabel) { - const key = String(rankLabel || "").trim().toLowerCase(); - if (key === "10" || key === "ten") return "Princess"; - if (key === "j" || key === "jack") return "Prince"; - if (key === "q" || key === "queen") return "Queen"; - if (key === "k" || key === "king") return "Knight"; - return String(rankLabel || "").trim(); -} - -function buildFallbackPlayingDeckEntries() { - const entries = []; - Object.keys(PLAYING_SUIT_SYMBOL).forEach((suit) => { - PLAYING_RANKS.forEach((rank) => { - const tarotSuit = PLAYING_SUIT_TO_TAROT[suit]; - const tarotRank = rankLabelToTarotMinorRank(rank.rankLabel); - entries.push({ - id: `${rank.rank}${PLAYING_SUIT_SYMBOL[suit]}`, - suit, - suitLabel: PLAYING_SUIT_LABEL[suit], - suitSymbol: PLAYING_SUIT_SYMBOL[suit], - rank: rank.rank, - rankLabel: rank.rankLabel, - rankValue: rank.rankValue, - tarotSuit, - tarotCard: `${tarotRank} of ${tarotSuit}` - }); - }); - }); - return entries; -} - -function getPlayingDeckEntries() { - const deckData = magickDataset?.grouped?.["playing-cards-52"]; - const rawEntries = Array.isArray(deckData) - ? deckData - : (Array.isArray(deckData?.entries) ? deckData.entries : []); - - if (!rawEntries.length) { - return buildFallbackPlayingDeckEntries(); - } - - return rawEntries - .map((entry) => { - const suit = String(entry?.suit || "").trim().toLowerCase(); - const rankLabel = String(entry?.rankLabel || "").trim(); - const rank = String(entry?.rank || "").trim(); - if (!suit || !rank) { - return null; - } - - const suitSymbol = String(entry?.suitSymbol || PLAYING_SUIT_SYMBOL[suit] || "").trim(); - const tarotSuit = String(entry?.tarotSuit || PLAYING_SUIT_TO_TAROT[suit] || "").trim(); - const tarotCard = String(entry?.tarotCard || "").trim(); - const rankValueRaw = Number(entry?.rankValue); - const rankValue = Number.isFinite(rankValueRaw) ? Math.trunc(rankValueRaw) : null; - - return { - id: String(entry?.id || `${rank}${suitSymbol}`).trim(), - suit, - suitLabel: String(entry?.suitLabel || PLAYING_SUIT_LABEL[suit] || suit).trim(), - suitSymbol, - rank, - rankLabel: rankLabel || rank, - rankValue, - tarotSuit, - tarotCard: tarotCard || `${rankLabelToTarotMinorRank(rankLabel || rank)} of ${tarotSuit}` - }; - }) - .filter(Boolean); -} - -function findPlayingCardBySuitAndValue(entries, suit, value) { - const normalizedSuit = String(suit || "").trim().toLowerCase(); - const targetValue = Number(value); - return entries.find((entry) => entry.suit === normalizedSuit && Number(entry.rankValue) === targetValue) || null; -} - -function buildNumbersSpecialCardSlots(playingSuit) { - const suit = String(playingSuit || "hearts").trim().toLowerCase(); - const selectedSuit = ["hearts", "diamonds", "clubs", "spades"].includes(suit) ? suit : "hearts"; - const deckEntries = getPlayingDeckEntries(); - - const cardEl = document.createElement("div"); - cardEl.className = "numbers-detail-card numbers-special-card-section"; - - const headingEl = document.createElement("strong"); - headingEl.textContent = "4 Card Arrangement"; - - const subEl = document.createElement("div"); - subEl.className = "numbers-detail-text numbers-detail-text--muted"; - subEl.textContent = `Click a card to flip to its opposite (${PLAYING_SUIT_LABEL[selectedSuit]} ↔ ${PLAYING_SUIT_TO_TAROT[selectedSuit]}).`; - - const boardEl = document.createElement("div"); - boardEl.className = "numbers-special-board"; - - NUMBERS_SPECIAL_BASE_VALUES.forEach((baseValue) => { - const oppositeValue = 9 - baseValue; - const frontCard = findPlayingCardBySuitAndValue(deckEntries, selectedSuit, baseValue); - const backCard = findPlayingCardBySuitAndValue(deckEntries, selectedSuit, oppositeValue); - if (!frontCard || !backCard) { - return; - } - - const slotKey = `${selectedSuit}:${baseValue}`; - const isFlipped = Boolean(numbersSpecialFlipState.get(slotKey)); - - const faceBtn = document.createElement("button"); - faceBtn.type = "button"; - faceBtn.className = `numbers-special-card${isFlipped ? " is-flipped" : ""}`; - faceBtn.setAttribute("aria-pressed", isFlipped ? "true" : "false"); - faceBtn.setAttribute("aria-label", `${frontCard.rankLabel} of ${frontCard.suitLabel}. Click to flip to ${backCard.rankLabel}.`); - faceBtn.dataset.suit = selectedSuit; - - const innerEl = document.createElement("div"); - innerEl.className = "numbers-special-card-inner"; - - const frontFaceEl = document.createElement("div"); - frontFaceEl.className = "numbers-special-card-face numbers-special-card-face--front"; - - const frontRankEl = document.createElement("div"); - frontRankEl.className = "numbers-special-card-rank"; - frontRankEl.textContent = frontCard.rankLabel; - - const frontSuitEl = document.createElement("div"); - frontSuitEl.className = "numbers-special-card-suit"; - frontSuitEl.textContent = frontCard.suitSymbol; - - const frontMetaEl = document.createElement("div"); - frontMetaEl.className = "numbers-special-card-meta"; - frontMetaEl.textContent = frontCard.tarotCard; - - frontFaceEl.append(frontRankEl, frontSuitEl, frontMetaEl); - - const backFaceEl = document.createElement("div"); - backFaceEl.className = "numbers-special-card-face numbers-special-card-face--back"; - - const backTagEl = document.createElement("div"); - backTagEl.className = "numbers-special-card-tag"; - backTagEl.textContent = "Opposite"; - - const backRankEl = document.createElement("div"); - backRankEl.className = "numbers-special-card-rank"; - backRankEl.textContent = backCard.rankLabel; - - const backSuitEl = document.createElement("div"); - backSuitEl.className = "numbers-special-card-suit"; - backSuitEl.textContent = backCard.suitSymbol; - - const backMetaEl = document.createElement("div"); - backMetaEl.className = "numbers-special-card-meta"; - backMetaEl.textContent = backCard.tarotCard; - - backFaceEl.append(backTagEl, backRankEl, backSuitEl, backMetaEl); - - innerEl.append(frontFaceEl, backFaceEl); - faceBtn.append(innerEl); - - faceBtn.addEventListener("click", () => { - const next = !Boolean(numbersSpecialFlipState.get(slotKey)); - numbersSpecialFlipState.set(slotKey, next); - faceBtn.classList.toggle("is-flipped", next); - faceBtn.setAttribute("aria-pressed", next ? "true" : "false"); - }); - - boardEl.appendChild(faceBtn); - }); - - if (!boardEl.childElementCount) { - const emptyEl = document.createElement("div"); - emptyEl.className = "numbers-detail-text numbers-detail-text--muted"; - emptyEl.textContent = "No card slots available for this mapping yet."; - boardEl.appendChild(emptyEl); - } - - cardEl.append(headingEl, subEl, boardEl); - return cardEl; -} - -function renderNumbersSpecialPanel(value) { - if (!numbersSpecialPanelEl) { - return; - } - - const entry = getNumberEntryByValue(value); - const playingSuit = entry?.associations?.playingSuit || "hearts"; - const boardCardEl = buildNumbersSpecialCardSlots(playingSuit); - numbersSpecialPanelEl.replaceChildren(boardCardEl); -} - -function computeDigitalRoot(value) { - let current = Math.abs(Math.trunc(Number(value))); - if (!Number.isFinite(current)) { - return null; - } - while (current >= 10) { - current = String(current) - .split("") - .reduce((sum, digit) => sum + Number(digit), 0); - } - return current; -} - -function describeDigitalRootReduction(value) { - let current = Math.abs(Math.trunc(Number(value))); - if (!Number.isFinite(current)) { - return ""; - } - - if (current < 10) { - return `${current} → ${current}`; - } - - const parts = [`${current}`]; - while (current >= 10) { - const digits = String(current).split("").map((digit) => Number(digit)); - const sum = digits.reduce((acc, digit) => acc + digit, 0); - parts.push(`${digits.join(" + ")} = ${sum}`); - current = sum; - } - - return parts.join(" → "); -} - -function parseTarotCardNumber(rawValue) { - if (typeof rawValue === "number") { - return Number.isFinite(rawValue) ? Math.trunc(rawValue) : null; - } - - if (typeof rawValue === "string") { - const trimmed = rawValue.trim(); - if (!trimmed || !/^-?\d+$/.test(trimmed)) { - return null; - } - return Number(trimmed); - } - - return null; -} - -const TAROT_RANK_NUMBER_MAP = { - ace: 1, - two: 2, - three: 3, - four: 4, - five: 5, - six: 6, - seven: 7, - eight: 8, - nine: 9, - ten: 10 -}; - -function extractTarotCardNumericValue(card) { - const directNumber = parseTarotCardNumber(card?.number); - if (directNumber !== null) { - return directNumber; - } - - const rankKey = String(card?.rank || "").trim().toLowerCase(); - if (Object.prototype.hasOwnProperty.call(TAROT_RANK_NUMBER_MAP, rankKey)) { - return TAROT_RANK_NUMBER_MAP[rankKey]; - } - - const numerologyRelation = Array.isArray(card?.relations) - ? card.relations.find((relation) => String(relation?.type || "").trim().toLowerCase() === "numerology") - : null; - const relationValue = Number(numerologyRelation?.data?.value); - if (Number.isFinite(relationValue)) { - return Math.trunc(relationValue); - } - - return null; -} - -function getAlphabetPositionLinksForDigitalRoot(targetRoot) { - const alphabets = magickDataset?.grouped?.alphabets; - if (!alphabets || typeof alphabets !== "object") { - return []; - } - - const links = []; - - const addLink = (alphabetLabel, entry, buttonLabel, detail) => { - const index = Number(entry?.index); - if (!Number.isFinite(index)) { - return; - } - - const normalizedIndex = Math.trunc(index); - if (computeDigitalRoot(normalizedIndex) !== targetRoot) { - return; - } - - links.push({ - alphabet: alphabetLabel, - index: normalizedIndex, - label: buttonLabel, - detail - }); - }; - - const toTitle = (value) => String(value || "") - .trim() - .replace(/[_-]+/g, " ") - .replace(/\s+/g, " ") - .toLowerCase() - .replace(/\b([a-z])/g, (match, ch) => ch.toUpperCase()); - - const englishEntries = Array.isArray(alphabets.english) ? alphabets.english : []; - englishEntries.forEach((entry) => { - const letter = String(entry?.letter || "").trim(); - if (!letter) { - return; - } - - addLink( - "English", - entry, - `${letter}`, - { - alphabet: "english", - englishLetter: letter - } - ); - }); - - const greekEntries = Array.isArray(alphabets.greek) ? alphabets.greek : []; - greekEntries.forEach((entry) => { - const greekName = String(entry?.name || "").trim(); - if (!greekName) { - return; - } - - const glyph = String(entry?.char || "").trim(); - const displayName = String(entry?.displayName || toTitle(greekName)).trim(); - addLink( - "Greek", - entry, - glyph ? `${displayName} - ${glyph}` : displayName, - { - alphabet: "greek", - greekName - } - ); - }); - - const hebrewEntries = Array.isArray(alphabets.hebrew) ? alphabets.hebrew : []; - hebrewEntries.forEach((entry) => { - const hebrewLetterId = String(entry?.hebrewLetterId || "").trim(); - if (!hebrewLetterId) { - return; - } - - const glyph = String(entry?.char || "").trim(); - const name = String(entry?.name || hebrewLetterId).trim(); - const displayName = toTitle(name); - addLink( - "Hebrew", - entry, - glyph ? `${displayName} - ${glyph}` : displayName, - { - alphabet: "hebrew", - hebrewLetterId - } - ); - }); - - const arabicEntries = Array.isArray(alphabets.arabic) ? alphabets.arabic : []; - arabicEntries.forEach((entry) => { - const arabicName = String(entry?.name || "").trim(); - if (!arabicName) { - return; - } - - const glyph = String(entry?.char || "").trim(); - const displayName = toTitle(arabicName); - addLink( - "Arabic", - entry, - glyph ? `${displayName} - ${glyph}` : displayName, - { - alphabet: "arabic", - arabicName - } - ); - }); - - const enochianEntries = Array.isArray(alphabets.enochian) ? alphabets.enochian : []; - enochianEntries.forEach((entry) => { - const enochianId = String(entry?.id || "").trim(); - if (!enochianId) { - return; - } - - const title = String(entry?.title || enochianId).trim(); - const displayName = toTitle(title); - addLink( - "Enochian", - entry, - `${displayName}`, - { - alphabet: "enochian", - enochianId - } - ); - }); - - return links.sort((left, right) => { - if (left.index !== right.index) { - return left.index - right.index; - } - const alphabetCompare = left.alphabet.localeCompare(right.alphabet); - if (alphabetCompare !== 0) { - return alphabetCompare; - } - return left.label.localeCompare(right.label); - }); -} - -function getTarotCardsForDigitalRoot(targetRoot, numberEntry = null) { - if (typeof ensureTarotSection === "function" && referenceData) { - ensureTarotSection(referenceData, magickDataset); - } - - const allCards = window.TarotSectionUi?.getCards?.() || []; - const explicitTrumpNumbers = Array.isArray(numberEntry?.associations?.tarotTrumpNumbers) - ? numberEntry.associations.tarotTrumpNumbers - .map((value) => Number(value)) - .filter((value) => Number.isFinite(value)) - .map((value) => Math.trunc(value)) - : []; - - const filteredCards = explicitTrumpNumbers.length - ? allCards.filter((card) => { - const numberValue = parseTarotCardNumber(card?.number); - return card?.arcana === "Major" && numberValue !== null && explicitTrumpNumbers.includes(numberValue); - }) - : allCards.filter((card) => { - const numberValue = extractTarotCardNumericValue(card); - return numberValue !== null && computeDigitalRoot(numberValue) === targetRoot; - }); - - return filteredCards - .sort((left, right) => { - const leftNumber = extractTarotCardNumericValue(left); - const rightNumber = extractTarotCardNumericValue(right); - if (leftNumber !== rightNumber) { - return (leftNumber ?? 0) - (rightNumber ?? 0); - } - if (left?.arcana !== right?.arcana) { - return left?.arcana === "Major" ? -1 : 1; - } - return String(left?.name || "").localeCompare(String(right?.name || "")); - }); -} - -function renderNumbersList() { - if (!numbersListEl) { - return; - } - - const entries = getNumbersDatasetEntries(); - if (!entries.some((entry) => entry.value === activeNumberValue)) { - activeNumberValue = entries[0]?.value ?? 0; - } - - const fragment = document.createDocumentFragment(); - entries.forEach((entry) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = `planet-list-item${entry.value === activeNumberValue ? " is-selected" : ""}`; - button.dataset.numberValue = String(entry.value); - button.setAttribute("role", "option"); - button.setAttribute("aria-selected", entry.value === activeNumberValue ? "true" : "false"); - - const nameEl = document.createElement("span"); - nameEl.className = "planet-list-name"; - nameEl.textContent = `${entry.label}`; - - const metaEl = document.createElement("span"); - metaEl.className = "planet-list-meta"; - metaEl.textContent = `Opposite ${entry.opposite}`; - - button.append(nameEl, metaEl); - fragment.appendChild(button); - }); - - numbersListEl.replaceChildren(fragment); - if (numbersCountEl) { - numbersCountEl.textContent = `${entries.length} entries`; - } -} - -function renderNumberDetail(value) { - const entry = getNumberEntryByValue(value); - if (!entry) { - return; - } - - const normalized = entry.value; - const opposite = entry.opposite; - const rootTarget = normalizeNumberValue(entry.digitalRoot); - - if (numbersDetailNameEl) { - numbersDetailNameEl.textContent = `Number ${normalized} · ${entry.label}`; - } - - if (numbersDetailTypeEl) { - numbersDetailTypeEl.textContent = `Opposite: ${opposite}`; - } - - if (numbersDetailSummaryEl) { - numbersDetailSummaryEl.textContent = entry.summary || ""; - } - - renderNumbersSpecialPanel(normalized); - - if (!numbersDetailBodyEl) { - return; - } - - numbersDetailBodyEl.replaceChildren(); - - const pairCardEl = document.createElement("div"); - pairCardEl.className = "numbers-detail-card"; - - const pairHeadingEl = document.createElement("strong"); - pairHeadingEl.textContent = "Number Pair"; - - const pairTextEl = document.createElement("div"); - pairTextEl.className = "numbers-detail-text"; - pairTextEl.textContent = `Opposite: ${opposite}`; - - const keywordText = entry.keywords.length - ? `Keywords: ${entry.keywords.join(", ")}` - : "Keywords: --"; - const pairKeywordsEl = document.createElement("div"); - pairKeywordsEl.className = "numbers-detail-text numbers-detail-text--muted"; - pairKeywordsEl.textContent = keywordText; - - const oppositeBtn = document.createElement("button"); - oppositeBtn.type = "button"; - oppositeBtn.className = "numbers-nav-btn"; - oppositeBtn.textContent = `Open Opposite Number ${opposite}`; - oppositeBtn.addEventListener("click", () => { - selectNumberEntry(opposite); - }); - - pairCardEl.append(pairHeadingEl, pairTextEl, pairKeywordsEl, oppositeBtn); - - const kabbalahCardEl = document.createElement("div"); - kabbalahCardEl.className = "numbers-detail-card"; - - const kabbalahHeadingEl = document.createElement("strong"); - kabbalahHeadingEl.textContent = "Kabbalah Link"; - - const kabbalahNode = Number(entry?.associations?.kabbalahNode); - const kabbalahTextEl = document.createElement("div"); - kabbalahTextEl.className = "numbers-detail-text"; - kabbalahTextEl.textContent = `Tree node target: ${kabbalahNode}`; - - const kabbalahBtn = document.createElement("button"); - kabbalahBtn.type = "button"; - kabbalahBtn.className = "numbers-nav-btn"; - kabbalahBtn.textContent = `Open Kabbalah Tree Node ${kabbalahNode}`; - kabbalahBtn.addEventListener("click", () => { - document.dispatchEvent(new CustomEvent("nav:kabbalah-path", { - detail: { pathNo: kabbalahNode } - })); - }); - - kabbalahCardEl.append(kabbalahHeadingEl, kabbalahTextEl, kabbalahBtn); - - const alphabetCardEl = document.createElement("div"); - alphabetCardEl.className = "numbers-detail-card"; - - const alphabetHeadingEl = document.createElement("strong"); - alphabetHeadingEl.textContent = "Alphabet Links"; - - const alphabetLinksWrapEl = document.createElement("div"); - alphabetLinksWrapEl.className = "numbers-links-wrap"; - - const alphabetLinks = getAlphabetPositionLinksForDigitalRoot(rootTarget); - if (!alphabetLinks.length) { - const emptyAlphabetEl = document.createElement("div"); - emptyAlphabetEl.className = "numbers-detail-text numbers-detail-text--muted"; - emptyAlphabetEl.textContent = "No alphabet position entries found for this digital root yet."; - alphabetLinksWrapEl.appendChild(emptyAlphabetEl); - } else { - alphabetLinks.forEach((link) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "numbers-nav-btn"; - button.textContent = `${link.alphabet}: ${link.label}`; - button.addEventListener("click", () => { - document.dispatchEvent(new CustomEvent("nav:alphabet", { - detail: link.detail - })); - }); - alphabetLinksWrapEl.appendChild(button); - }); - } - - alphabetCardEl.append(alphabetHeadingEl, alphabetLinksWrapEl); - - const tarotCardEl = document.createElement("div"); - tarotCardEl.className = "numbers-detail-card"; - - const tarotHeadingEl = document.createElement("strong"); - tarotHeadingEl.textContent = "Tarot Links"; - - const tarotLinksWrapEl = document.createElement("div"); - tarotLinksWrapEl.className = "numbers-links-wrap"; - - const tarotCards = getTarotCardsForDigitalRoot(rootTarget, entry); - if (!tarotCards.length) { - const emptyEl = document.createElement("div"); - emptyEl.className = "numbers-detail-text numbers-detail-text--muted"; - emptyEl.textContent = "No tarot numeric entries found yet for this root. Add card numbers to map them."; - tarotLinksWrapEl.appendChild(emptyEl); - } else { - tarotCards.forEach((card) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "numbers-nav-btn"; - button.textContent = `${card.name}`; - button.addEventListener("click", () => { - document.dispatchEvent(new CustomEvent("nav:tarot-trump", { - detail: { cardName: card.name } - })); - }); - tarotLinksWrapEl.appendChild(button); - }); - } - - tarotCardEl.append(tarotHeadingEl, tarotLinksWrapEl); - - const calendarCardEl = document.createElement("div"); - calendarCardEl.className = "numbers-detail-card"; - - const calendarHeadingEl = document.createElement("strong"); - calendarHeadingEl.textContent = "Calendar Links"; - - const calendarLinksWrapEl = document.createElement("div"); - calendarLinksWrapEl.className = "numbers-links-wrap"; - - const calendarLinks = getCalendarMonthLinksForNumber(normalized); - if (!calendarLinks.length) { - const emptyCalendarEl = document.createElement("div"); - emptyCalendarEl.className = "numbers-detail-text numbers-detail-text--muted"; - emptyCalendarEl.textContent = "No calendar months currently mapped to this number."; - calendarLinksWrapEl.appendChild(emptyCalendarEl); - } else { - calendarLinks.forEach((link) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "numbers-nav-btn"; - button.textContent = `${link.calendarLabel}: ${link.monthName} (Month ${link.monthOrder})`; - button.addEventListener("click", () => { - document.dispatchEvent(new CustomEvent("nav:calendar-month", { - detail: { - calendarId: link.calendarId, - monthId: link.monthId - } - })); - }); - calendarLinksWrapEl.appendChild(button); - }); - } - - calendarCardEl.append(calendarHeadingEl, calendarLinksWrapEl); - - numbersDetailBodyEl.append(pairCardEl, kabbalahCardEl, alphabetCardEl, tarotCardEl, calendarCardEl); -} - -function selectNumberEntry(value) { - const entry = getNumberEntryByValue(value); - activeNumberValue = entry ? entry.value : 0; - renderNumbersList(); - renderNumberDetail(activeNumberValue); -} - -function ensureNumbersSection() { - if (!numbersListEl) { - return; - } - - if (!numbersSectionInitialized) { - numbersListEl.addEventListener("click", (event) => { - const target = event.target; - if (!(target instanceof Node)) { - return; - } - const button = target instanceof Element - ? target.closest(".planet-list-item") - : null; - if (!(button instanceof HTMLButtonElement)) { - return; - } - const value = Number(button.dataset.numberValue); - if (!Number.isFinite(value)) { - return; - } - selectNumberEntry(value); - }); - - numbersSectionInitialized = true; - } - - renderNumbersList(); - renderNumberDetail(activeNumberValue); -} - -const THREE_CARD_POSITIONS = [ - { pos: "past", label: "Past" }, - { pos: "present", label: "Present" }, - { pos: "future", label: "Future" } -]; - -const CELTIC_CROSS_POSITIONS = [ - { pos: "crown", label: "Crown" }, - { pos: "out", label: "Outcome" }, - { pos: "past", label: "Recent Past" }, - { pos: "present", label: "Present" }, - { pos: "near-fut", label: "Near Future" }, - { pos: "hope", label: "Hopes & Fears" }, - { pos: "chall", label: "Challenge" }, - { pos: "env", label: "Environment" }, - { pos: "found", label: "Foundation" }, - { pos: "self", label: "Self" } -]; - -function normalizeTarotSpread(value) { - return value === "celtic-cross" ? "celtic-cross" : "three-card"; -} - -function drawNFromDeck(n) { - const allCards = window.TarotSectionUi?.getCards?.() || []; - if (!allCards.length) return []; - - const shuffled = [...allCards]; - for (let index = shuffled.length - 1; index > 0; index -= 1) { - const swapIndex = Math.floor(Math.random() * (index + 1)); - [shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]]; - } - - return shuffled.slice(0, n).map((card) => ({ - ...card, - reversed: Math.random() < 0.3 - })); -} - -function escapeHtml(value) { - return String(value || "") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/\"/g, """) - .replace(/'/g, "'"); -} - -function getSpreadPositions(spreadId) { - return spreadId === "celtic-cross" ? CELTIC_CROSS_POSITIONS : THREE_CARD_POSITIONS; -} - -function regenerateTarotSpreadDraw() { - const normalizedSpread = normalizeTarotSpread(activeTarotSpread); - const positions = getSpreadPositions(normalizedSpread); - const cards = drawNFromDeck(positions.length); - activeTarotSpreadDraw = positions.map((position, index) => ({ - position, - card: cards[index] || null - })); -} - -function renderTarotSpreadMeanings() { - if (!tarotSpreadMeaningsEl) { - return; - } - - if (!activeTarotSpreadDraw.length || activeTarotSpreadDraw.some((entry) => !entry.card)) { - tarotSpreadMeaningsEl.innerHTML = ""; - return; - } - - tarotSpreadMeaningsEl.innerHTML = activeTarotSpreadDraw.map((entry) => { - const positionLabel = escapeHtml(entry.position.label).toUpperCase(); - const card = entry.card; - const cardName = escapeHtml(card.name || "Unknown Card"); - const meaningText = escapeHtml(card.reversed ? (card.meanings?.reversed || card.summary || "--") : (card.meanings?.upright || card.summary || "--")); - const keywords = Array.isArray(card.keywords) - ? card.keywords.map((keyword) => String(keyword || "").trim()).filter(Boolean) - : []; - const keywordMarkup = keywords.length - ? `
Keywords: ${escapeHtml(keywords.join(", "))}
` - : ""; - const orientationMarkup = card.reversed - ? " (Reversed)" - : ""; - - return `
` - + `
${positionLabel}: ${cardName}${orientationMarkup}
` - + `
${meaningText}
` - + keywordMarkup - + `
`; - }).join(""); -} - -function renderTarotSpread() { - if (!tarotSpreadBoardEl) return; - const normalizedSpread = normalizeTarotSpread(activeTarotSpread); - const isCeltic = normalizedSpread === "celtic-cross"; - - if (!activeTarotSpreadDraw.length) { - regenerateTarotSpreadDraw(); - } - - tarotSpreadBoardEl.className = `tarot-spread-board tarot-spread-board--${isCeltic ? "celtic" : "three"}`; - - if (!activeTarotSpreadDraw.length || activeTarotSpreadDraw.some((entry) => !entry.card)) { - tarotSpreadBoardEl.innerHTML = `
Tarot deck not loaded yet — open Cards first, then return to Spread.
`; - if (tarotSpreadMeaningsEl) { - tarotSpreadMeaningsEl.innerHTML = ""; - } - return; - } - - renderTarotSpreadMeanings(); - - tarotSpreadBoardEl.innerHTML = activeTarotSpreadDraw.map((entry) => { - const position = entry.position; - const card = entry.card; - const imgSrc = window.TarotCardImages?.resolveTarotCardImage?.(card.name); - const reversed = card.reversed; - const wrapClass = reversed ? "spread-card-wrap is-reversed" : "spread-card-wrap"; - const imgHtml = imgSrc - ? `${escapeHtml(card.name)}` - : `
${escapeHtml(card.name)}
`; - const reversedTag = reversed ? `Reversed` : ""; - return `
` - + `
${escapeHtml(position.label)}
` - + `
${imgHtml}
` - + `
${escapeHtml(card.name)}${reversedTag}
` - + `
`; - }).join(""); -} - -function applyTarotSpreadViewState() { - const isSpreadOpen = activeTarotSpread !== null; - const isCeltic = activeTarotSpread === "celtic-cross"; - const isTarotActive = activeSection === "tarot"; - - if (tarotBrowseViewEl) tarotBrowseViewEl.hidden = isSpreadOpen; - if (tarotSpreadViewEl) tarotSpreadViewEl.hidden = !isSpreadOpen; - - if (tarotSpreadBtnThreeEl) tarotSpreadBtnThreeEl.classList.toggle("is-active", isSpreadOpen && !isCeltic); - if (tarotSpreadBtnCelticEl) tarotSpreadBtnCelticEl.classList.toggle("is-active", isSpreadOpen && isCeltic); - - if (openTarotCardsEl) openTarotCardsEl.classList.toggle("is-active", isTarotActive && !isSpreadOpen); - if (openTarotSpreadEl) openTarotSpreadEl.classList.toggle("is-active", isTarotActive && isSpreadOpen); -} - -function showTarotCardsView() { - activeTarotSpread = null; - activeTarotSpreadDraw = []; - applyTarotSpreadViewState(); - if (typeof ensureTarotSection === "function" && referenceData) { - ensureTarotSection(referenceData, magickDataset); - } - const detailPanelEl = document.querySelector("#tarot-browse-view .tarot-detail-panel"); - if (detailPanelEl instanceof HTMLElement) { - detailPanelEl.scrollTop = 0; - } -} - -function showTarotSpreadView(spreadId = "three-card") { - activeTarotSpread = normalizeTarotSpread(spreadId); - regenerateTarotSpreadDraw(); - applyTarotSpreadViewState(); - if (typeof ensureTarotSection === "function" && referenceData) { - ensureTarotSection(referenceData, magickDataset); - } - renderTarotSpread(); -} - -function setTarotSpread(spreadId, openTarotSection = false) { - if (openTarotSection) { - setActiveSection("tarot"); - } - showTarotSpreadView(spreadId); -} - -const DEFAULT_WEEKDAY_RULERS = { - 0: { symbol: "☉", name: "Sol" }, - 1: { symbol: "☾", name: "Luna" }, - 2: { symbol: "♂", name: "Mars" }, - 3: { symbol: "☿", name: "Mercury" }, - 4: { symbol: "♃", name: "Jupiter" }, - 5: { symbol: "♀", name: "Venus" }, - 6: { symbol: "♄", name: "Saturn" } -}; - -function getWeekdayIndexFromName(weekdayName) { - const normalized = String(weekdayName || "").trim().toLowerCase(); - if (normalized === "sunday") return 0; - if (normalized === "monday") return 1; - if (normalized === "tuesday") return 2; - if (normalized === "wednesday") return 3; - if (normalized === "thursday") return 4; - if (normalized === "friday") return 5; - if (normalized === "saturday") return 6; - return null; -} - -function buildWeekdayRulerLookup(planets) { - const lookup = { ...DEFAULT_WEEKDAY_RULERS }; - if (!planets || typeof planets !== "object") { - return lookup; - } - - Object.values(planets).forEach((planet) => { - const weekdayIndex = getWeekdayIndexFromName(planet?.weekday); - if (weekdayIndex === null) { - return; - } - - lookup[weekdayIndex] = { - symbol: planet?.symbol || lookup[weekdayIndex].symbol, - name: planet?.name || lookup[weekdayIndex].name - }; - }); - - return lookup; -} - -function clamp(value, min, max) { - return Math.min(max, Math.max(min, value)); -} - -function lerp(start, end, t) { - return start + (end - start) * t; -} - -function lerpRgb(from, to, t) { - return [ - Math.round(lerp(from[0], to[0], t)), - Math.round(lerp(from[1], to[1], t)), - Math.round(lerp(from[2], to[2], t)) - ]; -} - -function rgbString(rgb) { - return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; -} - -function getActiveGeoForRuler() { - if (currentGeo) { - return currentGeo; - } - - try { - return parseGeoInput(); - } catch { - return null; - } -} - -function buildSunRulerGradient(geo, date) { - if (!window.SunCalc || !geo || !date) { - return null; - } - - const dayStart = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0); - const sampleCount = 48; - const samples = []; - - for (let index = 0; index <= sampleCount; index += 1) { - const sampleDate = new Date(dayStart.getTime() + index * 30 * 60 * 1000); - const position = window.SunCalc.getPosition(sampleDate, geo.latitude, geo.longitude); - const altitudeDeg = (position.altitude * 180) / Math.PI; - samples.push(altitudeDeg); - } - - const maxAltitude = Math.max(...samples); - - const NIGHT = [6, 7, 10]; - const PRE_DAWN = [22, 26, 38]; - const SUN_RED = [176, 45, 36]; - const SUN_ORANGE = [246, 133, 54]; - const SKY_BLUE = [58, 134, 255]; - - const nightFloor = -8; - const twilightEdge = -2; - const redToOrangeEdge = 2; - const orangeToBlueEdge = 8; - const daylightRange = Math.max(1, maxAltitude - orangeToBlueEdge); - - const stops = samples.map((altitudeDeg, index) => { - let color; - - if (altitudeDeg <= nightFloor) { - color = NIGHT; - } else if (altitudeDeg <= twilightEdge) { - const t = clamp((altitudeDeg - nightFloor) / (twilightEdge - nightFloor), 0, 1); - color = lerpRgb(NIGHT, PRE_DAWN, t); - } else if (altitudeDeg <= redToOrangeEdge) { - const t = clamp((altitudeDeg - twilightEdge) / (redToOrangeEdge - twilightEdge), 0, 1); - color = lerpRgb(PRE_DAWN, SUN_RED, t); - } else if (altitudeDeg <= orangeToBlueEdge) { - const t = clamp((altitudeDeg - redToOrangeEdge) / (orangeToBlueEdge - redToOrangeEdge), 0, 1); - color = lerpRgb(SUN_RED, SUN_ORANGE, t); - } else { - const t = clamp((altitudeDeg - orangeToBlueEdge) / daylightRange, 0, 1); - color = lerpRgb(SUN_ORANGE, SKY_BLUE, t); - } - - const pct = ((index / sampleCount) * 100).toFixed(2); - return `${rgbString(color)} ${pct}%`; - }); - - return `linear-gradient(to bottom, ${stops.join(", ")})`; -} - -function applySunRulerGradient(referenceDate = new Date()) { - const geo = getActiveGeoForRuler(); - if (!geo) { - return; - } - - const gradient = buildSunRulerGradient(geo, referenceDate); - if (!gradient) { - return; - } - - const rulerColumns = document.querySelectorAll(".toastui-calendar-timegrid-time-column"); - rulerColumns.forEach((column) => { - column.style.backgroundImage = gradient; - column.style.backgroundRepeat = "no-repeat"; - column.style.backgroundSize = "100% 100%"; - }); -} - -function normalizeDateLike(value) { - if (value instanceof Date) { - return value; - } - if (value && typeof value.getTime === "function") { - return new Date(value.getTime()); - } - return new Date(value); -} - -function getTimeParts(dateLike) { - const date = normalizeDateLike(dateLike); - const hours = date.getHours(); - const minutes = date.getMinutes(); - return { - hours, - minutes, - totalMinutes: hours * 60 + minutes - }; -} - -function formatHourStyle(dateLike) { - const { totalMinutes } = getTimeParts(dateLike); - return `${Math.floor(totalMinutes / 60)}hr`; -} - -function formatMinuteStyle(dateLike) { - const { totalMinutes } = getTimeParts(dateLike); - return `${totalMinutes}m`; -} - -function formatSecondStyle(dateLike) { - const { totalMinutes } = getTimeParts(dateLike); - const totalSeconds = totalMinutes * 60; - return `${totalSeconds}s`; -} - -function formatCalendarTime(dateLike) { - if (currentTimeFormat === "hours") { - return formatHourStyle(dateLike); - } - if (currentTimeFormat === "seconds") { - return formatSecondStyle(dateLike); - } - return formatMinuteStyle(dateLike); -} - -function formatCalendarTimeFromTemplatePayload(payload) { - if (payload && typeof payload.hour === "number") { - const hours = payload.hour; - const minutes = typeof payload.minutes === "number" ? payload.minutes : 0; - const totalMinutes = hours * 60 + minutes; - - if (currentTimeFormat === "hours") { - return `${Math.floor(totalMinutes / 60)}hr`; - } - - if (currentTimeFormat === "seconds") { - return `${totalMinutes * 60}s`; - } - - return `${totalMinutes}m`; - } - - if (payload && payload.time) { - return formatCalendarTime(payload.time); - } - - if (currentTimeFormat === "hours") { - return "12am"; - } - if (currentTimeFormat === "seconds") { - return "0s"; - } - return "0m"; -} - -function getMoonPhaseGlyph(phaseName) { - if (phaseName === "New Moon") return "🌑"; - if (phaseName === "Waxing Crescent") return "🌒"; - if (phaseName === "First Quarter") return "🌓"; - if (phaseName === "Waxing Gibbous") return "🌔"; - if (phaseName === "Full Moon") return "🌕"; - if (phaseName === "Waning Gibbous") return "🌖"; - if (phaseName === "Last Quarter") return "🌗"; - return "🌘"; -} - -function applyDynamicNowIndicatorVisual(referenceDate = new Date()) { - if (!currentGeo || !window.SunCalc) { - return; - } - - const labelEl = document.querySelector( - ".toastui-calendar-timegrid-time-column .toastui-calendar-timegrid-current-time" - ); - const markerEl = document.querySelector( - ".toastui-calendar-timegrid .toastui-calendar-timegrid-now-indicator .toastui-calendar-timegrid-now-indicator-marker" - ); - - if (!labelEl || !markerEl) { - return; - } - - const sunPosition = window.SunCalc.getPosition(referenceDate, currentGeo.latitude, currentGeo.longitude); - const sunAltitudeDeg = (sunPosition.altitude * 180) / Math.PI; - const isSunMode = sunAltitudeDeg >= -4; - - let icon = "☀️"; - let visualKey = "sun-0"; - - labelEl.classList.remove("is-sun", "is-moon"); - markerEl.classList.remove("is-sun", "is-moon"); - - if (isSunMode) { - const intensity = clamp((sunAltitudeDeg + 4) / 70, 0, 1); - const intensityPercent = Math.round(intensity * 100); - - icon = "☀️"; - visualKey = `sun-${intensityPercent}`; - - labelEl.classList.add("is-sun"); - markerEl.classList.add("is-sun"); - - labelEl.style.setProperty("--sun-glow-size", `${Math.round(8 + intensity * 16)}px`); - labelEl.style.setProperty("--sun-glow-alpha", (0.35 + intensity * 0.55).toFixed(2)); - markerEl.style.setProperty("--sun-marker-glow-size", `${Math.round(10 + intensity * 24)}px`); - markerEl.style.setProperty("--sun-marker-ray-opacity", (0.45 + intensity * 0.5).toFixed(2)); - - labelEl.title = `Sun altitude ${sunAltitudeDeg.toFixed(1)}°`; - } else { - const moonIllum = window.SunCalc.getMoonIllumination(referenceDate); - const moonPct = Math.round(moonIllum.fraction * 100); - const moonPhaseName = getMoonPhaseName(moonIllum.phase); - - icon = getMoonPhaseGlyph(moonPhaseName); - visualKey = `moon-${moonPct}-${moonPhaseName}`; - - labelEl.classList.add("is-moon"); - markerEl.classList.add("is-moon"); - - labelEl.style.setProperty("--moon-glow-alpha", (0.2 + moonIllum.fraction * 0.45).toFixed(2)); - markerEl.style.setProperty("--moon-glow-alpha", (0.2 + moonIllum.fraction * 0.45).toFixed(2)); - - labelEl.title = `${moonPhaseName} (${moonPct}%)`; - } - - if (labelEl.dataset.celestialKey !== visualKey) { - labelEl.innerHTML = [ - '', - `${icon}`, - "" - ].join(""); - labelEl.dataset.celestialKey = visualKey; - } -} - -function convertAxisTimeToMinutes(text) { - const normalized = String(text || "").trim().toLowerCase(); - if (!normalized) { - return null; - } - - const minuteMatch = normalized.match(/^(\d{1,4})m$/); - if (minuteMatch) { - return `${Number(minuteMatch[1])}m`; - } - - const secondMatch = normalized.match(/^(\d{1,6})s$/); - if (secondMatch) { - return `${Math.floor(Number(secondMatch[1]) / 60)}m`; - } - - const hourMatch = normalized.match(/^(\d{1,2})hr$/); - if (hourMatch) { - return `${Number(hourMatch[1]) * 60}m`; - } - - const ampmMatch = normalized.match(/^(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?\s*(am|pm)$/); - if (ampmMatch) { - let hour = Number(ampmMatch[1]) % 12; - const minutes = Number(ampmMatch[2] || "0"); - const suffix = ampmMatch[3]; - if (suffix === "pm") { - hour += 12; - } - return `${hour * 60 + minutes}m`; - } - - const twentyFourMatch = normalized.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); - if (twentyFourMatch) { - const hour = Number(twentyFourMatch[1]); - const minutes = Number(twentyFourMatch[2]); - return `${hour * 60 + minutes}m`; - } - - return null; -} - -function convertAxisTimeToSeconds(text) { - const minuteLabel = convertAxisTimeToMinutes(text); - if (!minuteLabel) { - return null; - } - - const minutes = Number(minuteLabel.replace("m", "")); - if (Number.isNaN(minutes)) { - return null; - } - - return `${minutes * 60}s`; -} - -function convertAxisTimeToHours(text) { - const minuteLabel = convertAxisTimeToMinutes(text); - if (!minuteLabel) { - return null; - } - - const minutes = Number(minuteLabel.replace("m", "")); - if (Number.isNaN(minutes)) { - return null; - } - - return `${Math.floor(minutes / 60)}hr`; -} - -function forceAxisLabelFormat() { - const labelNodes = document.querySelectorAll( - ".toastui-calendar-timegrid-time-column .toastui-calendar-timegrid-time-label" - ); - - labelNodes.forEach((node) => { - if (!node.dataset.originalLabel) { - node.dataset.originalLabel = node.textContent; - } - - if (currentTimeFormat === "minutes") { - const converted = convertAxisTimeToMinutes(node.dataset.originalLabel); - if (converted) { - node.textContent = converted; - } - } else if (currentTimeFormat === "seconds") { - const converted = convertAxisTimeToSeconds(node.dataset.originalLabel); - if (converted) { - node.textContent = converted; - } - } else if (currentTimeFormat === "hours") { - const converted = convertAxisTimeToHours(node.dataset.originalLabel); - if (converted) { - node.textContent = converted; - } - } else { - node.textContent = node.dataset.originalLabel; - } - }); -} - -function getVisibleWeekDates() { - if (typeof calendar.getDateRangeStart !== "function") { - return []; - } - - const rangeStart = calendar.getDateRangeStart(); - if (!rangeStart) { - return []; - } - - const startDateLike = normalizeDateLike(rangeStart); - const startDate = new Date( - startDateLike.getFullYear(), - startDateLike.getMonth(), - startDateLike.getDate(), - 0, - 0, - 0, - 0 - ); - - return Array.from({ length: 7 }, (_, dayOffset) => { - const day = new Date(startDate); - day.setDate(startDate.getDate() + dayOffset); - return day; - }); -} - -function buildMonthSpans(days) { - if (!Array.isArray(days) || days.length === 0) { - return []; - } - - const monthFormatter = new Intl.DateTimeFormat(undefined, { - month: "long", - year: "numeric" - }); - - const spans = []; - let currentStart = 1; - let currentMonth = days[0].getMonth(); - let currentYear = days[0].getFullYear(); - - for (let index = 1; index <= days.length; index += 1) { - const day = days[index]; - const monthChanged = !day || day.getMonth() !== currentMonth || day.getFullYear() !== currentYear; - - if (!monthChanged) { - continue; - } - - const spanEnd = index; - spans.push({ - start: currentStart, - end: spanEnd, - label: monthFormatter.format(new Date(currentYear, currentMonth, 1)) - }); - - if (day) { - currentStart = index + 1; - currentMonth = day.getMonth(); - currentYear = day.getFullYear(); - } - } - - return spans; -} - -function syncMonthStripGeometry() { - if (!monthStripEl) { - return; - } - - const calendarEl = document.getElementById("calendar"); - if (!calendarEl) { - return; - } - - const dayNameItems = calendarEl.querySelectorAll( - ".toastui-calendar-week-view-day-names .toastui-calendar-day-name-item.toastui-calendar-week" - ); - - if (dayNameItems.length < 7) { - monthStripEl.style.paddingLeft = "0"; - monthStripEl.style.paddingRight = "0"; - return; - } - - const calendarRect = calendarEl.getBoundingClientRect(); - const firstRect = dayNameItems[0].getBoundingClientRect(); - const lastRect = dayNameItems[6].getBoundingClientRect(); - - const leftPad = Math.max(0, firstRect.left - calendarRect.left); - const rightPad = Math.max(0, calendarRect.right - lastRect.right); - - monthStripEl.style.paddingLeft = `${leftPad}px`; - monthStripEl.style.paddingRight = `${rightPad}px`; -} - -function updateMonthStrip() { - if (!monthStripEl) { - return; - } - - const days = getVisibleWeekDates(); - const spans = buildMonthSpans(days); - - monthStripEl.replaceChildren(); - - if (!spans.length) { - return; - } - - const trackEl = document.createElement("div"); - trackEl.className = "month-strip-track"; - - spans.forEach((span) => { - const segmentEl = document.createElement("div"); - segmentEl.className = "month-strip-segment"; - segmentEl.style.gridColumn = `${span.start} / ${span.end + 1}`; - segmentEl.textContent = span.label; - trackEl.appendChild(segmentEl); - }); - - monthStripEl.appendChild(trackEl); - syncMonthStripGeometry(); -} - -function createCalendarTemplates() { - const weekdayRulerLookup = buildWeekdayRulerLookup(referenceData?.planets); - - // TIME / SIGN / NAME formatter for week time plates. - // This intentionally keeps each event compact and visually consistent. - const getPlateFields = (event) => { - const fromRawSign = event?.raw?.planetSymbol; - const fromRawName = event?.raw?.planetName; - - if (fromRawSign || fromRawName) { - return { - sign: fromRawSign || "", - name: fromRawName || "" - }; - } - - // Fallback parser for any time event that does not provide `raw` planet metadata. - // Example title pattern: "♂ Mars · The Tower" - const title = String(event?.title || "").trim(); - const beforeTarot = title.split("·")[0].trim(); - const parts = beforeTarot.split(/\s+/).filter(Boolean); - - if (parts.length >= 2) { - return { - sign: parts[0], - name: parts.slice(1).join(" ") - }; - } - - return { - sign: "", - name: beforeTarot - }; - }; - - // Returns exactly three lines for the event block text: - // 1) TIME 2) SIGN 3) NAME - const formatEventPlateText = (event) => { - const timeLabel = formatCalendarTime(event.start); - const { sign, name } = getPlateFields(event); - const safeName = name || String(event?.title || "").trim(); - const safeSign = sign || "•"; - return `${timeLabel}\n${safeSign}\n${safeName}`; - }; - - const renderWeekDayHeader = (weekDayNameData) => { - const dateNumber = String(weekDayNameData?.date ?? "").padStart(2, "0"); - const dayLabel = String(weekDayNameData?.dayName || ""); - const ruler = weekdayRulerLookup[weekDayNameData?.day] || { symbol: "•", name: "" }; - - return [ - '
', - `${dateNumber}`, - `${dayLabel}`, - `${ruler.symbol}`, - "
" - ].join(""); - }; - - return { - timegridDisplayPrimaryTime: (props) => formatCalendarTimeFromTemplatePayload(props), - timegridDisplayTime: (props) => formatCalendarTimeFromTemplatePayload(props), - timegridNowIndicatorLabel: (props) => formatCalendarTimeFromTemplatePayload(props), - weekDayName: (weekDayNameData) => renderWeekDayHeader(weekDayNameData), - time: (event) => formatEventPlateText(event) - }; -} - -function applyTimeFormatTemplates() { - calendar.setOptions({ - template: createCalendarTemplates() - }); - calendar.render(); - - requestAnimationFrame(() => { - forceAxisLabelFormat(); - applySunRulerGradient(); - applyDynamicNowIndicatorVisual(); - updateMonthStrip(); - requestAnimationFrame(() => { - forceAxisLabelFormat(); - applySunRulerGradient(); - applyDynamicNowIndicatorVisual(); - updateMonthStrip(); - }); - }); -} function setStatus(text) { if (!statusEl) { @@ -1948,1545 +239,179 @@ function setStatus(text) { statusEl.textContent = text; } -function normalizeGeoForSky(geo) { - const latitude = Number(geo?.latitude); - const longitude = Number(geo?.longitude); - - if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { - return null; - } - - return { - latitude: Number(latitude.toFixed(4)), - longitude: Number(longitude.toFixed(4)) - }; -} - -function buildStellariumObserverUrl(geo) { - const normalizedGeo = normalizeGeoForSky(geo); - if (!normalizedGeo) { - return ""; - } - - const stellariumUrl = new URL("https://stellarium-web.org/"); - stellariumUrl.searchParams.set("lat", String(normalizedGeo.latitude)); - stellariumUrl.searchParams.set("lng", String(normalizedGeo.longitude)); - stellariumUrl.searchParams.set("elev", "0"); - stellariumUrl.searchParams.set("date", new Date().toISOString()); - stellariumUrl.searchParams.set("az", "0"); - stellariumUrl.searchParams.set("alt", "90"); - stellariumUrl.searchParams.set("fov", "180"); - - return stellariumUrl.toString(); -} - -function syncNowSkyBackground(geo, force = false) { - if (!nowSkyLayerEl || !geo) { - return; - } - - const normalizedGeo = normalizeGeoForSky(geo); - if (!normalizedGeo) { - return; - } - - const geoKey = `${normalizedGeo.latitude.toFixed(4)},${normalizedGeo.longitude.toFixed(4)}`; - const stellariumUrl = buildStellariumObserverUrl(normalizedGeo); - if (!stellariumUrl) { - return; - } - - if (!force && geoKey === lastNowSkyGeoKey && stellariumUrl === lastNowSkySourceUrl) { - return; - } - - if (stellariumUrl === lastNowSkySourceUrl) { - return; - } - - nowSkyLayerEl.src = stellariumUrl; - lastNowSkyGeoKey = geoKey; - lastNowSkySourceUrl = stellariumUrl; -} - -function syncNowPanelTheme(referenceDate = new Date()) { - if (!nowPanelEl) { - return; - } - - if (!currentGeo || !window.SunCalc) { - nowPanelEl.classList.remove("is-day"); - nowPanelEl.classList.add("is-night"); - return; - } - - const sunPosition = window.SunCalc.getPosition(referenceDate, currentGeo.latitude, currentGeo.longitude); - const sunAltitudeDeg = (sunPosition.altitude * 180) / Math.PI; - const isDaytime = sunAltitudeDeg >= -4; - - nowPanelEl.classList.toggle("is-day", isDaytime); - nowPanelEl.classList.toggle("is-night", !isDaytime); -} - -function openSettingsPopup() { - if (!settingsPopupEl) { - return; - } - - settingsPopupEl.hidden = false; - if (openSettingsEl) { - openSettingsEl.setAttribute("aria-expanded", "true"); - } -} - -function closeSettingsPopup() { - if (!settingsPopupEl) { - return; - } - - settingsPopupEl.hidden = true; - if (openSettingsEl) { - openSettingsEl.setAttribute("aria-expanded", "false"); - } -} - -function loadSidebarCollapsedState(storageKey) { - try { - const raw = window.localStorage?.getItem(storageKey); - if (raw === "1") { - return true; - } - if (raw === "0") { - return false; - } - return null; - } catch { - return null; - } -} - -function saveSidebarCollapsedState(storageKey, collapsed) { - try { - window.localStorage?.setItem(storageKey, collapsed ? "1" : "0"); - } catch { - // Ignore storage failures silently. - } -} - -function initializeSidebarPopouts() { - const layouts = document.querySelectorAll(".planet-layout, .tarot-layout, .kab-layout"); - - layouts.forEach((layout, index) => { - if (!(layout instanceof HTMLElement)) { - return; - } - - const panel = Array.from(layout.children).find((child) => ( - child instanceof HTMLElement - && child.matches("aside.planet-list-panel, aside.tarot-list-panel, aside.kab-tree-panel") - )); - - if (!(panel instanceof HTMLElement) || panel.dataset.sidebarPopoutReady === "1") { - return; - } - - const header = panel.querySelector(".planet-list-header, .tarot-list-header"); - if (!(header instanceof HTMLElement)) { - return; - } - - panel.dataset.sidebarPopoutReady = "1"; - - const sectionId = layout.closest("section")?.id || `layout-${index + 1}`; - const panelId = panel.id || `${sectionId}-entry-panel`; - panel.id = panelId; - - const storageKey = `${SIDEBAR_COLLAPSE_STORAGE_PREFIX}${sectionId}`; - - const collapseBtn = document.createElement("button"); - collapseBtn.type = "button"; - collapseBtn.className = "sidebar-toggle-inline"; - collapseBtn.textContent = "Hide Panel"; - collapseBtn.setAttribute("aria-label", "Hide entry panel"); - collapseBtn.setAttribute("aria-controls", panelId); - header.appendChild(collapseBtn); - - const openBtn = document.createElement("button"); - openBtn.type = "button"; - openBtn.className = "sidebar-popout-open"; - openBtn.textContent = "Show Panel"; - openBtn.setAttribute("aria-label", "Show entry panel"); - openBtn.setAttribute("aria-controls", panelId); - openBtn.hidden = true; - layout.appendChild(openBtn); - - const applyCollapsedState = (collapsed, persist = true) => { - layout.classList.toggle("layout-sidebar-collapsed", collapsed); - collapseBtn.setAttribute("aria-expanded", collapsed ? "false" : "true"); - openBtn.setAttribute("aria-expanded", collapsed ? "false" : "true"); - openBtn.hidden = !collapsed; - - if (persist) { - saveSidebarCollapsedState(storageKey, collapsed); - } - }; - - collapseBtn.addEventListener("click", () => { - applyCollapsedState(true); - }); - - openBtn.addEventListener("click", () => { - applyCollapsedState(false); - }); - - const storedCollapsed = loadSidebarCollapsedState(storageKey); - applyCollapsedState(storedCollapsed == null ? DEFAULT_DATASET_ENTRY_COLLAPSED : storedCollapsed, false); - }); -} - -function initializeDetailPopouts() { - const layouts = document.querySelectorAll(".planet-layout, .tarot-layout, .kab-layout"); - - layouts.forEach((layout, index) => { - if (!(layout instanceof HTMLElement)) { - return; - } - - const detailPanel = Array.from(layout.children).find((child) => ( - child instanceof HTMLElement - && child.matches("section.planet-detail-panel, section.tarot-detail-panel, section.kab-detail-panel") - )); - - if (!(detailPanel instanceof HTMLElement) || detailPanel.dataset.detailPopoutReady === "1") { - return; - } - - const heading = detailPanel.querySelector(".planet-detail-heading, .tarot-detail-heading"); - if (!(heading instanceof HTMLElement)) { - return; - } - - detailPanel.dataset.detailPopoutReady = "1"; - - const sectionId = layout.closest("section")?.id || `layout-${index + 1}`; - const panelId = detailPanel.id || `${sectionId}-detail-panel`; - detailPanel.id = panelId; - - const detailStorageKey = `${DETAIL_COLLAPSE_STORAGE_PREFIX}${sectionId}`; - const sidebarStorageKey = `${SIDEBAR_COLLAPSE_STORAGE_PREFIX}${sectionId}`; - - const collapseBtn = document.createElement("button"); - collapseBtn.type = "button"; - collapseBtn.className = "detail-toggle-inline"; - collapseBtn.textContent = "Hide Detail"; - collapseBtn.setAttribute("aria-label", "Hide detail panel"); - collapseBtn.setAttribute("aria-controls", panelId); - heading.appendChild(collapseBtn); - - const openBtn = document.createElement("button"); - openBtn.type = "button"; - openBtn.className = "detail-popout-open"; - openBtn.textContent = "Show Detail"; - openBtn.setAttribute("aria-label", "Show detail panel"); - openBtn.setAttribute("aria-controls", panelId); - openBtn.hidden = true; - layout.appendChild(openBtn); - - const applyCollapsedState = (collapsed, persist = true) => { - if (collapsed && layout.classList.contains("layout-sidebar-collapsed")) { - layout.classList.remove("layout-sidebar-collapsed"); - const sidebarOpenBtn = layout.querySelector(".sidebar-popout-open"); - if (sidebarOpenBtn instanceof HTMLButtonElement) { - sidebarOpenBtn.hidden = true; - sidebarOpenBtn.setAttribute("aria-expanded", "true"); - } - const sidebarCollapseBtn = layout.querySelector(".sidebar-toggle-inline"); - if (sidebarCollapseBtn instanceof HTMLButtonElement) { - sidebarCollapseBtn.setAttribute("aria-expanded", "true"); - } - saveSidebarCollapsedState(sidebarStorageKey, false); - } - - layout.classList.toggle("layout-detail-collapsed", collapsed); - collapseBtn.setAttribute("aria-expanded", collapsed ? "false" : "true"); - openBtn.setAttribute("aria-expanded", collapsed ? "false" : "true"); - openBtn.hidden = !collapsed; - - if (persist) { - saveSidebarCollapsedState(detailStorageKey, collapsed); - } - }; - - collapseBtn.addEventListener("click", () => { - applyCollapsedState(true); - }); - - openBtn.addEventListener("click", () => { - applyCollapsedState(false); - }); - - const storedCollapsed = loadSidebarCollapsedState(detailStorageKey); - const shouldForceOpenForTarot = sectionId === "tarot-section"; - const initialCollapsed = shouldForceOpenForTarot - ? false - : (storedCollapsed == null ? DEFAULT_DATASET_DETAIL_COLLAPSED : storedCollapsed); - applyCollapsedState(initialCollapsed, false); - }); -} - -function setActiveSection(nextSection) { - const normalized = nextSection === "home" || nextSection === "calendar" || nextSection === "holidays" || nextSection === "tarot" || nextSection === "astronomy" || nextSection === "planets" || nextSection === "cycles" || nextSection === "natal" || nextSection === "elements" || nextSection === "iching" || nextSection === "kabbalah" || nextSection === "kabbalah-tree" || nextSection === "cube" || nextSection === "alphabet" || nextSection === "numbers" || nextSection === "zodiac" || nextSection === "quiz" || nextSection === "gods" || nextSection === "enochian" - ? nextSection - : "home"; - activeSection = normalized; - - const isHomeOpen = activeSection === "home"; - const isCalendarOpen = activeSection === "calendar"; - const isHolidaysOpen = activeSection === "holidays"; - const isCalendarMenuOpen = isCalendarOpen || isHolidaysOpen; - const isTarotOpen = activeSection === "tarot"; - const isAstronomyOpen = activeSection === "astronomy"; - const isPlanetOpen = activeSection === "planets"; - const isCyclesOpen = activeSection === "cycles"; - const isNatalOpen = activeSection === "natal"; - const isZodiacOpen = activeSection === "zodiac"; - const isAstronomyMenuOpen = isAstronomyOpen || isPlanetOpen || isCyclesOpen || isZodiacOpen || isNatalOpen; - const isElementsOpen = activeSection === "elements"; - const isIChingOpen = activeSection === "iching"; - const isKabbalahOpen = activeSection === "kabbalah"; - const isKabbalahTreeOpen = activeSection === "kabbalah-tree"; - const isCubeOpen = activeSection === "cube"; - const isKabbalahMenuOpen = isKabbalahOpen || isKabbalahTreeOpen || isCubeOpen; - const isAlphabetOpen = activeSection === "alphabet"; - const isNumbersOpen = activeSection === "numbers"; - const isQuizOpen = activeSection === "quiz"; - const isGodsOpen = activeSection === "gods"; - const isEnochianOpen = activeSection === "enochian"; - - if (calendarSectionEl) { - calendarSectionEl.hidden = !isCalendarOpen; - } - - if (holidaySectionEl) { - holidaySectionEl.hidden = !isHolidaysOpen; - } - - if (tarotSectionEl) { - tarotSectionEl.hidden = !isTarotOpen; - } - - if (astronomySectionEl) { - astronomySectionEl.hidden = !isAstronomyOpen; - } - - if (planetSectionEl) { - planetSectionEl.hidden = !isPlanetOpen; - } - - if (cyclesSectionEl) { - cyclesSectionEl.hidden = !isCyclesOpen; - } - - if (natalSectionEl) { - natalSectionEl.hidden = !isNatalOpen; - } - - if (elementsSectionEl) { - elementsSectionEl.hidden = !isElementsOpen; - } - - if (ichingSectionEl) { - ichingSectionEl.hidden = !isIChingOpen; - } - - if (kabbalahSectionEl) { - kabbalahSectionEl.hidden = !isKabbalahOpen; - } - - if (kabbalahTreeSectionEl) { - kabbalahTreeSectionEl.hidden = !isKabbalahTreeOpen; - } - - if (cubeSectionEl) { - cubeSectionEl.hidden = !isCubeOpen; - } - - if (alphabetSectionEl) { - alphabetSectionEl.hidden = !isAlphabetOpen; - } - - if (numbersSectionEl) { - numbersSectionEl.hidden = !isNumbersOpen; - } - - if (zodiacSectionEl) { - zodiacSectionEl.hidden = !isZodiacOpen; - } - - if (quizSectionEl) { - quizSectionEl.hidden = !isQuizOpen; - } - - if (godsSectionEl) { - godsSectionEl.hidden = !isGodsOpen; - } - - if (enochianSectionEl) { - enochianSectionEl.hidden = !isEnochianOpen; - } - - if (nowPanelEl) { - nowPanelEl.hidden = !isHomeOpen; - } - - if (monthStripEl) { - monthStripEl.hidden = !isHomeOpen; - } - - if (calendarEl) { - calendarEl.hidden = !isHomeOpen; - } - - if (openCalendarEl) { - openCalendarEl.setAttribute("aria-pressed", isCalendarMenuOpen ? "true" : "false"); - } - - if (openCalendarMonthsEl) { - openCalendarMonthsEl.classList.toggle("is-active", isCalendarOpen); - } - - if (openHolidaysEl) { - openHolidaysEl.classList.toggle("is-active", isHolidaysOpen); - } - - if (openTarotEl) { - openTarotEl.setAttribute("aria-pressed", isTarotOpen ? "true" : "false"); - } - - applyTarotSpreadViewState(); - - if (openAstronomyEl) { - openAstronomyEl.setAttribute("aria-pressed", isAstronomyMenuOpen ? "true" : "false"); - } - - if (openPlanetsEl) { - openPlanetsEl.classList.toggle("is-active", isPlanetOpen); - } - - if (openCyclesEl) { - openCyclesEl.classList.toggle("is-active", isCyclesOpen); - } - - if (openElementsEl) { - openElementsEl.setAttribute("aria-pressed", isElementsOpen ? "true" : "false"); - } - - if (openIChingEl) { - openIChingEl.setAttribute("aria-pressed", isIChingOpen ? "true" : "false"); - } - - if (openKabbalahEl) { - openKabbalahEl.setAttribute("aria-pressed", isKabbalahMenuOpen ? "true" : "false"); - } - - if (openKabbalahTreeEl) { - openKabbalahTreeEl.classList.toggle("is-active", isKabbalahTreeOpen); - } - - if (openKabbalahCubeEl) { - openKabbalahCubeEl.classList.toggle("is-active", isCubeOpen); - } - - if (openAlphabetEl) { - openAlphabetEl.setAttribute("aria-pressed", isAlphabetOpen ? "true" : "false"); - } - - if (openNumbersEl) { - openNumbersEl.setAttribute("aria-pressed", isNumbersOpen ? "true" : "false"); - } - - if (openZodiacEl) { - openZodiacEl.classList.toggle("is-active", isZodiacOpen); - } - - if (openNatalEl) { - openNatalEl.classList.toggle("is-active", isNatalOpen); - } - - if (openQuizEl) { - openQuizEl.setAttribute("aria-pressed", isQuizOpen ? "true" : "false"); - } - - if (openGodsEl) { - openGodsEl.setAttribute("aria-pressed", isGodsOpen ? "true" : "false"); - } - - if (openEnochianEl) { - openEnochianEl.setAttribute("aria-pressed", isEnochianOpen ? "true" : "false"); - } - - if (!isHomeOpen) { - closeSettingsPopup(); - } - - if (isCalendarOpen) { - if (typeof ensureCalendarSection === "function" && referenceData) { - ensureCalendarSection(referenceData, magickDataset); - } - return; - } - - if (isHolidaysOpen) { - if (typeof ensureHolidaySection === "function" && referenceData) { - ensureHolidaySection(referenceData, magickDataset); - } - return; - } - - if (isTarotOpen) { - if (typeof ensureTarotSection === "function" && referenceData) { - ensureTarotSection(referenceData, magickDataset); - } - if (activeTarotSpread !== null) { - renderTarotSpread(); - } - return; - } - - if (isPlanetOpen) { - if (typeof ensurePlanetSection === "function" && referenceData) { - ensurePlanetSection(referenceData, magickDataset); - } - return; - } - - if (isCyclesOpen) { - if (typeof ensureCyclesSection === "function" && referenceData) { - ensureCyclesSection(referenceData); - } - return; - } - - if (isElementsOpen) { - if (typeof ensureElementsSection === "function" && magickDataset) { - ensureElementsSection(magickDataset); - } - return; - } - - if (isIChingOpen) { - if (typeof ensureIChingSection === "function" && referenceData) { - ensureIChingSection(referenceData); - } - return; - } - - if (isKabbalahTreeOpen) { - if (typeof ensureKabbalahSection === "function" && magickDataset) { - ensureKabbalahSection(magickDataset); - } - return; - } - - if (isCubeOpen) { - if (typeof ensureCubeSection === "function" && magickDataset) { - ensureCubeSection(magickDataset, referenceData); - } - return; - } - - if (isAlphabetOpen) { - if (typeof ensureAlphabetSection === "function" && magickDataset) { - ensureAlphabetSection(magickDataset, referenceData); - } - return; - } - - if (isNumbersOpen) { - ensureNumbersSection(); - return; - } - - if (isZodiacOpen) { - if (typeof ensureZodiacSection === "function" && referenceData && magickDataset) { - ensureZodiacSection(referenceData, magickDataset); - } - return; - } - - if (isNatalOpen) { - if (typeof ensureNatalPanel === "function") { - ensureNatalPanel(referenceData); - } - return; - } - - if (isQuizOpen) { - if (typeof ensureQuizSection === "function" && referenceData && magickDataset) { - ensureQuizSection(referenceData, magickDataset); - } - return; - } - - if (isGodsOpen) { - if (typeof ensureGodsSection === "function" && magickDataset) { - ensureGodsSection(magickDataset, referenceData); - } - return; - } - - if (isEnochianOpen) { - if (typeof ensureEnochianSection === "function" && magickDataset) { - ensureEnochianSection(magickDataset, referenceData); - } - return; - } - - requestAnimationFrame(() => { - calendar.render(); - updateMonthStrip(); - syncNowPanelTheme(new Date()); - }); -} - -function applyCenteredWeekWindow(date) { - const startDayOfWeek = getCenteredWeekStartDay(date); - calendar.setOptions({ - week: { - ...baseWeekOptions, - startDayOfWeek - } - }); - applyTimeFormatTemplates(); - calendar.changeView("week"); - calendar.setDate(date); -} - -function parseGeoInput() { - const latitude = Number(latEl.value); - const longitude = Number(lngEl.value); - - if (Number.isNaN(latitude) || Number.isNaN(longitude)) { - throw new Error("Latitude/Longitude must be valid numbers."); - } - - return { latitude, longitude }; -} - -function normalizeTimeFormat(value) { - if (value === "hours") { - return "hours"; - } - - if (value === "seconds") { - return "seconds"; - } - - return "minutes"; -} - -function normalizeBirthDate(value) { - const normalized = String(value || "").trim(); - if (!normalized) { - return ""; - } - - return /^\d{4}-\d{2}-\d{2}$/.test(normalized) ? normalized : ""; -} - -function getKnownTarotDeckIds() { - const knownDeckIds = new Set(); - const deckOptions = window.TarotCardImages?.getDeckOptions?.(); - - if (Array.isArray(deckOptions)) { - deckOptions.forEach((option) => { - const id = String(option?.id || "").trim().toLowerCase(); - if (id) { - knownDeckIds.add(id); - } - }); - } - - if (!knownDeckIds.size) { - knownDeckIds.add(DEFAULT_TAROT_DECK); - } - - return knownDeckIds; -} - -function getFallbackTarotDeckId() { - const deckOptions = window.TarotCardImages?.getDeckOptions?.(); - if (Array.isArray(deckOptions)) { - for (let i = 0; i < deckOptions.length; i += 1) { - const id = String(deckOptions[i]?.id || "").trim().toLowerCase(); - if (id) { - return id; - } - } - } - - return DEFAULT_TAROT_DECK; -} - -function normalizeTarotDeck(value) { - const normalized = String(value || "").trim().toLowerCase(); - const knownDeckIds = getKnownTarotDeckIds(); - - if (knownDeckIds.has(normalized)) { - return normalized; - } - - return getFallbackTarotDeckId(); -} - -function parseStoredNumber(value, fallback) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; -} - -function normalizeSettings(settings) { - return { - latitude: parseStoredNumber(settings?.latitude, DEFAULT_SETTINGS.latitude), - longitude: parseStoredNumber(settings?.longitude, DEFAULT_SETTINGS.longitude), - timeFormat: normalizeTimeFormat(settings?.timeFormat), - birthDate: normalizeBirthDate(settings?.birthDate), - tarotDeck: normalizeTarotDeck(settings?.tarotDeck) - }; -} - -function getResolvedTimeZone() { - try { - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - return String(timeZone || ""); - } catch { - return ""; - } -} - -function buildBirthDateParts(birthDate) { - const normalized = normalizeBirthDate(birthDate); - if (!normalized) { - return null; - } - - const [year, month, day] = normalized.split("-").map((value) => Number(value)); - if (!year || !month || !day) { - return null; - } - - const localNoon = new Date(year, month - 1, day, 12, 0, 0, 0); - const utcNoon = new Date(Date.UTC(year, month - 1, day, 12, 0, 0, 0)); - - return { - year, - month, - day, - isoDate: normalized, - localNoonIso: localNoon.toISOString(), - utcNoonIso: utcNoon.toISOString(), - timezoneOffsetMinutesAtNoon: localNoon.getTimezoneOffset() - }; -} - -function buildNatalContext(settings) { - const normalized = normalizeSettings(settings); - const birthDateParts = buildBirthDateParts(normalized.birthDate); - const timeZone = getResolvedTimeZone(); - - return { - latitude: normalized.latitude, - longitude: normalized.longitude, - birthDate: normalized.birthDate || null, - birthDateParts, - timeZone: timeZone || "UTC", - timezoneOffsetMinutesNow: new Date().getTimezoneOffset(), - timezoneOffsetMinutesAtBirthDateNoon: birthDateParts?.timezoneOffsetMinutesAtNoon ?? null - }; -} - -function emitSettingsUpdated(settings) { - const normalized = normalizeSettings(settings); - const natalContext = buildNatalContext(normalized); - document.dispatchEvent(new CustomEvent("settings:updated", { - detail: { - settings: normalized, - natalContext - } - })); -} - -function loadSavedSettings() { - try { - const raw = window.localStorage.getItem(SETTINGS_STORAGE_KEY); - if (!raw) { - return { ...DEFAULT_SETTINGS }; - } - - const parsed = JSON.parse(raw); - return normalizeSettings(parsed); - } catch { - return { ...DEFAULT_SETTINGS }; - } -} - -function saveSettings(settings) { - try { - const normalized = normalizeSettings(settings); - window.localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(normalized)); - return true; - } catch { - return false; - } -} - -function syncTarotDeckInputOptions() { - if (!tarotDeckEl) { - return; - } - - const deckOptions = window.TarotCardImages?.getDeckOptions?.(); - const previousValue = String(tarotDeckEl.value || "").trim().toLowerCase(); - tarotDeckEl.innerHTML = ""; - - if (!Array.isArray(deckOptions) || !deckOptions.length) { - const emptyOption = document.createElement("option"); - emptyOption.value = DEFAULT_TAROT_DECK; - emptyOption.textContent = "No deck manifests found"; - tarotDeckEl.appendChild(emptyOption); - tarotDeckEl.disabled = true; - return; - } - - tarotDeckEl.disabled = false; - - deckOptions.forEach((option) => { - const id = String(option?.id || "").trim().toLowerCase(); - if (!id) { - return; - } - - const label = String(option?.label || id); - const optionEl = document.createElement("option"); - optionEl.value = id; - optionEl.textContent = label; - tarotDeckEl.appendChild(optionEl); - }); - - const normalizedPrevious = normalizeTarotDeck(previousValue); - tarotDeckEl.value = normalizedPrevious; -} - -function applySettingsToInputs(settings) { - syncTarotDeckInputOptions(); - const normalized = normalizeSettings(settings); - latEl.value = String(normalized.latitude); - lngEl.value = String(normalized.longitude); - timeFormatEl.value = normalized.timeFormat; - birthDateEl.value = normalized.birthDate; - if (tarotDeckEl) { - tarotDeckEl.value = normalized.tarotDeck; - } - if (window.TarotCardImages?.setActiveDeck) { - window.TarotCardImages.setActiveDeck(normalized.tarotDeck); - } - currentTimeFormat = normalized.timeFormat; - currentSettings = normalized; -} - -function getSettingsFromInputs() { - const latitude = Number(latEl.value); - const longitude = Number(lngEl.value); - - if (Number.isNaN(latitude) || Number.isNaN(longitude)) { - throw new Error("Latitude/Longitude must be valid numbers."); - } - - return normalizeSettings({ - latitude, - longitude, - timeFormat: normalizeTimeFormat(timeFormatEl.value), - birthDate: normalizeBirthDate(birthDateEl.value), - tarotDeck: normalizeTarotDeck(tarotDeckEl?.value) - }); -} - -function handleSaveSettings() { - try { - const settings = getSettingsFromInputs(); - applySettingsToInputs(settings); - syncNowSkyBackground({ latitude: settings.latitude, longitude: settings.longitude }, true); - const didPersist = saveSettings(settings); - emitSettingsUpdated(currentSettings); - if (activeSection !== "home") { - setActiveSection(activeSection); - } - closeSettingsPopup(); - void renderWeek(); - - if (!didPersist) { - setStatus("Settings applied for this session. Browser storage is unavailable."); - } - } catch (error) { - setStatus(error.message || "Unable to save settings."); - } -} - -function startNowTicker() { - if (nowInterval) { - clearInterval(nowInterval); - } - - const tick = () => { - if (!referenceData || !currentGeo || renderInProgress) { - return; - } - - const now = new Date(); - syncNowPanelTheme(now); - const currentDayKey = getDateKey(now); - if (currentDayKey !== centeredDayKey) { - centeredDayKey = currentDayKey; - void renderWeek(); - return; - } - - updateNowPanel(referenceData, currentGeo, nowElements, currentTimeFormat); - applyDynamicNowIndicatorVisual(now); - }; - - tick(); - nowInterval = setInterval(tick, 1000); -} - -async function renderWeek() { - if (renderInProgress) { - return; - } - - renderInProgress = true; - - try { - currentGeo = parseGeoInput(); - syncNowPanelTheme(new Date()); - syncNowSkyBackground(currentGeo); - - if (!referenceData || !magickDataset) { - setStatus("Loading planetary, sign and decan tarot correspondences..."); - const [loadedReference, loadedMagick] = await Promise.all([ - referenceData ? Promise.resolve(referenceData) : loadReferenceData(), - magickDataset - ? Promise.resolve(magickDataset) - : loadMagickDataset().catch(() => null) - ]); - - referenceData = loadedReference; - magickDataset = loadedMagick; - } - - if (typeof ensureTarotSection === "function") { - ensureTarotSection(referenceData, magickDataset); - } - - if (typeof ensurePlanetSection === "function") { - ensurePlanetSection(referenceData, magickDataset); - } - - if (typeof ensureCyclesSection === "function") { - ensureCyclesSection(referenceData); - } - - if (typeof ensureIChingSection === "function") { - ensureIChingSection(referenceData); - } - - if (typeof ensureCalendarSection === "function") { - ensureCalendarSection(referenceData, magickDataset); - } - - if (typeof ensureHolidaySection === "function") { - ensureHolidaySection(referenceData, magickDataset); - } - - if (typeof ensureNatalPanel === "function") { - ensureNatalPanel(referenceData); - } - - if (typeof ensureQuizSection === "function") { - ensureQuizSection(referenceData, magickDataset); - } - - const anchorDate = new Date(); - centeredDayKey = getDateKey(anchorDate); - - applyCenteredWeekWindow(anchorDate); - - const events = buildWeekEvents(currentGeo, referenceData, anchorDate); - calendar.clear(); - calendar.createEvents(events); - applySunRulerGradient(anchorDate); - updateMonthStrip(); - requestAnimationFrame(updateMonthStrip); - - setStatus(`Rendered ${events.length} planetary + tarot events for lat ${currentGeo.latitude}, lng ${currentGeo.longitude}.`); - startNowTicker(); - } catch (error) { - setStatus(error.message || "Failed to render calendar."); - } finally { - renderInProgress = false; - } -} - -function requestGeoLocation() { - if (!navigator.geolocation) { - setStatus("Geolocation not available in this browser."); - return; - } - - setStatus("Getting your location..."); - navigator.geolocation.getCurrentPosition( - ({ coords }) => { - latEl.value = coords.latitude.toFixed(4); - lngEl.value = coords.longitude.toFixed(4); - syncNowSkyBackground({ latitude: coords.latitude, longitude: coords.longitude }, true); - setStatus("Location set from browser. Click Save Settings to refresh."); - }, - (err) => { - const detail = err?.message || `code ${err?.code ?? "unknown"}`; - setStatus(`Could not get location (${detail}).`); - }, - { enableHighAccuracy: true, timeout: 10000 } - ); -} - -function setTopbarDropdownOpen(dropdownEl, isOpen) { - if (!(dropdownEl instanceof HTMLElement)) { - return; - } - - dropdownEl.classList.toggle("is-open", Boolean(isOpen)); - const trigger = dropdownEl.querySelector("button[aria-haspopup='menu']"); - if (trigger) { - trigger.setAttribute("aria-expanded", isOpen ? "true" : "false"); - } -} - -function closeTopbarDropdowns(exceptEl = null) { - topbarDropdownEls.forEach((dropdownEl) => { - if (exceptEl && dropdownEl === exceptEl) { - return; - } - setTopbarDropdownOpen(dropdownEl, false); - }); -} - -function bindTopbarDropdownInteractions() { - if (!topbarDropdownEls.length) { - return; - } - - topbarDropdownEls.forEach((dropdownEl) => { - const trigger = dropdownEl.querySelector("button[aria-haspopup='menu']"); - if (!(trigger instanceof HTMLElement)) { - return; - } - - setTopbarDropdownOpen(dropdownEl, false); - - dropdownEl.addEventListener("mouseenter", () => { - setTopbarDropdownOpen(dropdownEl, true); - }); - - dropdownEl.addEventListener("mouseleave", () => { - setTopbarDropdownOpen(dropdownEl, false); - }); - - dropdownEl.addEventListener("focusout", (event) => { - const nextTarget = event.relatedTarget; - if (!(nextTarget instanceof Node) || !dropdownEl.contains(nextTarget)) { - setTopbarDropdownOpen(dropdownEl, false); - } - }); - - trigger.addEventListener("click", (event) => { - event.stopPropagation(); - const nextOpen = !dropdownEl.classList.contains("is-open"); - closeTopbarDropdowns(dropdownEl); - setTopbarDropdownOpen(dropdownEl, nextOpen); - }); - - const menuItems = dropdownEl.querySelectorAll(".topbar-dropdown-menu [role='menuitem']"); - menuItems.forEach((menuItem) => { - menuItem.addEventListener("click", () => { - closeTopbarDropdowns(); - }); - }); - }); -} - -if (saveSettingsEl) { - saveSettingsEl.addEventListener("click", handleSaveSettings); -} - -useLocationEl.addEventListener("click", requestGeoLocation); - -if (openSettingsEl) { - openSettingsEl.addEventListener("click", (event) => { - event.stopPropagation(); - if (settingsPopupEl?.hidden) { - openSettingsPopup(); - } else { - closeSettingsPopup(); - } - }); -} - -if (openTarotEl) { - openTarotEl.addEventListener("click", () => { - if (activeSection === "tarot") { - setActiveSection("home"); - } else { - setActiveSection("tarot"); - showTarotCardsView(); - } - }); -} - -if (openTarotCardsEl) { - openTarotCardsEl.addEventListener("click", () => { - setActiveSection("tarot"); - showTarotCardsView(); - }); -} - -if (openTarotSpreadEl) { - openTarotSpreadEl.addEventListener("click", () => { - setTarotSpread("three-card", true); - }); -} - -if (tarotSpreadBackEl) { - tarotSpreadBackEl.addEventListener("click", () => { - showTarotCardsView(); - }); -} - -if (tarotSpreadBtnThreeEl) { - tarotSpreadBtnThreeEl.addEventListener("click", () => { - showTarotSpreadView("three-card"); - }); -} - -if (tarotSpreadBtnCelticEl) { - tarotSpreadBtnCelticEl.addEventListener("click", () => { - showTarotSpreadView("celtic-cross"); - }); -} - -if (tarotSpreadRedrawEl) { - tarotSpreadRedrawEl.addEventListener("click", () => { - regenerateTarotSpreadDraw(); - renderTarotSpread(); - }); -} - -if (openAstronomyEl) { - openAstronomyEl.addEventListener("click", () => { - setActiveSection(activeSection === "astronomy" ? "home" : "astronomy"); - }); -} - -if (openPlanetsEl) { - openPlanetsEl.addEventListener("click", () => { - setActiveSection(activeSection === "planets" ? "home" : "planets"); - }); -} - -if (openCyclesEl) { - openCyclesEl.addEventListener("click", () => { - setActiveSection(activeSection === "cycles" ? "home" : "cycles"); - }); -} - -if (openElementsEl) { - openElementsEl.addEventListener("click", () => { - setActiveSection(activeSection === "elements" ? "home" : "elements"); - }); -} - -if (openIChingEl) { - openIChingEl.addEventListener("click", () => { - setActiveSection(activeSection === "iching" ? "home" : "iching"); - }); -} - -if (openKabbalahEl) { - openKabbalahEl.addEventListener("click", () => { - setActiveSection(activeSection === "kabbalah" ? "home" : "kabbalah"); - }); -} - -if (openKabbalahTreeEl) { - openKabbalahTreeEl.addEventListener("click", () => { - setActiveSection(activeSection === "kabbalah-tree" ? "home" : "kabbalah-tree"); - }); -} - -if (openKabbalahCubeEl) { - openKabbalahCubeEl.addEventListener("click", () => { - setActiveSection(activeSection === "cube" ? "home" : "cube"); - }); -} - -if (openAlphabetEl) { - openAlphabetEl.addEventListener("click", () => { - setActiveSection(activeSection === "alphabet" ? "home" : "alphabet"); - }); -} - -if (openNumbersEl) { - openNumbersEl.addEventListener("click", () => { - setActiveSection(activeSection === "numbers" ? "home" : "numbers"); - }); -} - -if (openZodiacEl) { - openZodiacEl.addEventListener("click", () => { - setActiveSection(activeSection === "zodiac" ? "home" : "zodiac"); - }); -} - -if (openNatalEl) { - openNatalEl.addEventListener("click", () => { - setActiveSection(activeSection === "natal" ? "home" : "natal"); - }); -} - -if (openQuizEl) { - openQuizEl.addEventListener("click", () => { - setActiveSection(activeSection === "quiz" ? "home" : "quiz"); - }); -} - -if (openGodsEl) { - openGodsEl.addEventListener("click", () => { - setActiveSection(activeSection === "gods" ? "home" : "gods"); - }); -} - -if (openEnochianEl) { - openEnochianEl.addEventListener("click", () => { - setActiveSection(activeSection === "enochian" ? "home" : "enochian"); - }); -} - -if (openCalendarEl) { - openCalendarEl.addEventListener("click", () => { - const isCalendarMenuActive = activeSection === "calendar" || activeSection === "holidays"; - setActiveSection(isCalendarMenuActive ? "home" : "calendar"); - }); -} - -if (openCalendarMonthsEl) { - openCalendarMonthsEl.addEventListener("click", () => { - setActiveSection(activeSection === "calendar" ? "home" : "calendar"); - }); -} - -if (openHolidaysEl) { - openHolidaysEl.addEventListener("click", () => { - setActiveSection(activeSection === "holidays" ? "home" : "holidays"); - }); -} - -bindTopbarDropdownInteractions(); - -document.addEventListener("nav:cube", (e) => { - if (typeof ensureCubeSection === "function" && magickDataset) { - ensureCubeSection(magickDataset, referenceData); - } - - setActiveSection("cube"); - - const detail = e?.detail || {}; - requestAnimationFrame(() => { - const ui = window.CubeSectionUi; - const selected = ui?.selectPlacement?.(detail); - if (!selected && detail?.wallId) { - ui?.selectWallById?.(detail.wallId); - } - }); +window.TarotNumbersUi?.init?.({ + getReferenceData: () => appRuntime.getReferenceData?.() || null, + getMagickDataset: () => appRuntime.getMagickDataset?.() || null, + ensureTarotSection }); -document.addEventListener("nav:zodiac", (e) => { - if (typeof ensureZodiacSection === "function" && referenceData && magickDataset) { - ensureZodiacSection(referenceData, magickDataset); - } - setActiveSection("zodiac"); - const signId = e?.detail?.signId; - if (signId) { - requestAnimationFrame(() => { - window.ZodiacSectionUi?.selectBySignId?.(signId); - }); +window.TarotSpreadUi?.init?.({ + ensureTarotSection, + getReferenceData: () => appRuntime.getReferenceData?.() || null, + getMagickDataset: () => appRuntime.getMagickDataset?.() || null, + getActiveSection: () => sectionStateUi.getActiveSection?.() || "home", + setActiveSection: (section) => sectionStateUi.setActiveSection?.(section) +}); + +sectionStateUi.init?.({ + calendar, + tarotSpreadUi, + settingsUi, + calendarVisualsUi, + homeUi, + getReferenceData: () => appRuntime.getReferenceData?.() || null, + getMagickDataset: () => appRuntime.getMagickDataset?.() || null, + elements: { + calendarEl, + monthStripEl, + nowPanelEl, + calendarSectionEl, + holidaySectionEl, + tarotSectionEl, + astronomySectionEl, + natalSectionEl, + planetSectionEl, + cyclesSectionEl, + elementsSectionEl, + ichingSectionEl, + kabbalahSectionEl, + kabbalahTreeSectionEl, + cubeSectionEl, + alphabetSectionEl, + numbersSectionEl, + zodiacSectionEl, + quizSectionEl, + godsSectionEl, + enochianSectionEl, + openCalendarEl, + openCalendarMonthsEl, + openHolidaysEl, + openTarotEl, + openAstronomyEl, + openPlanetsEl, + openCyclesEl, + openElementsEl, + openIChingEl, + openKabbalahEl, + openKabbalahTreeEl, + openKabbalahCubeEl, + openAlphabetEl, + openNumbersEl, + openZodiacEl, + openNatalEl, + openQuizEl, + openGodsEl, + openEnochianEl + }, + ensure: { + ensureTarotSection, + ensurePlanetSection, + ensureCyclesSection, + ensureElementsSection, + ensureIChingSection, + ensureKabbalahSection, + ensureCubeSection, + ensureAlphabetSection, + ensureZodiacSection, + ensureQuizSection, + ensureGodsSection, + ensureEnochianSection, + ensureCalendarSection, + ensureHolidaySection, + ensureNatalPanel, + ensureNumbersSection } }); -document.addEventListener("nav:alphabet", (e) => { - if (typeof ensureAlphabetSection === "function" && magickDataset) { - ensureAlphabetSection(magickDataset, referenceData); - } - setActiveSection("alphabet"); - - const alphabet = e?.detail?.alphabet; - const hebrewLetterId = e?.detail?.hebrewLetterId; - const greekName = e?.detail?.greekName; - const englishLetter = e?.detail?.englishLetter; - const arabicName = e?.detail?.arabicName; - const enochianId = e?.detail?.enochianId; - - requestAnimationFrame(() => { - const ui = window.AlphabetSectionUi; - if ((alphabet === "hebrew" || (!alphabet && hebrewLetterId)) && hebrewLetterId) { - ui?.selectLetterByHebrewId?.(hebrewLetterId); - return; - } - if (alphabet === "greek" && greekName) { - ui?.selectGreekLetterByName?.(greekName); - return; - } - if (alphabet === "english" && englishLetter) { - ui?.selectEnglishLetter?.(englishLetter); - return; - } - if (alphabet === "arabic" && arabicName) { - ui?.selectArabicLetter?.(arabicName); - return; - } - if (alphabet === "enochian" && enochianId) { - ui?.selectEnochianLetter?.(enochianId); - } - }); +settingsUi.init?.({ + defaultSettings: DEFAULT_SETTINGS, + onSettingsApplied: (settings) => { + appRuntime.applySettings?.(settings); + currentSettings = settings; + }, + onSyncSkyBackground: (geo, force) => homeUi.syncNowSkyBackground?.(geo, force), + onStatus: (text) => setStatus(text), + getActiveSection: () => sectionStateUi.getActiveSection?.() || "home", + onReopenActiveSection: (section) => sectionStateUi.setActiveSection?.(section), + onRenderWeek: () => appRuntime.renderWeek?.() }); -document.addEventListener("nav:number", (e) => { - const rawValue = e?.detail?.value; - const normalizedValue = normalizeNumberValue(rawValue); - if (normalizedValue === null) { - return; - } - - setActiveSection("numbers"); - requestAnimationFrame(() => { - selectNumberEntry(normalizedValue); - }); +chromeUi.init?.(); +calendarFormattingUi.init?.({ + getCurrentTimeFormat: () => appRuntime.getCurrentTimeFormat?.() || "minutes", + getReferenceData: () => appRuntime.getReferenceData?.() || null }); - -document.addEventListener("nav:iching", (e) => { - if (typeof ensureIChingSection === "function" && referenceData) { - ensureIChingSection(referenceData); - } - - setActiveSection("iching"); - - const hexagramNumber = e?.detail?.hexagramNumber; - const planetaryInfluence = e?.detail?.planetaryInfluence; - - requestAnimationFrame(() => { - const ui = window.IChingSectionUi; - if (hexagramNumber != null) { - ui?.selectByHexagramNumber?.(hexagramNumber); - return; - } - if (planetaryInfluence) { - ui?.selectByPlanetaryInfluence?.(planetaryInfluence); - } - }); +calendarVisualsUi.init?.({ + calendar, + monthStripEl, + getCurrentGeo: () => appRuntime.getCurrentGeo?.() || null, + parseGeoInput: () => appRuntime.parseGeoInput?.(), + getMoonPhaseName }); - -document.addEventListener("nav:gods", (e) => { - if (typeof ensureGodsSection === "function" && magickDataset) { - ensureGodsSection(magickDataset, referenceData); - } - setActiveSection("gods"); - const godId = e?.detail?.godId; - const godName = e?.detail?.godName; - const pathNo = e?.detail?.pathNo; - requestAnimationFrame(() => { - const ui = window.GodsSectionUi; - const viaId = godId ? ui?.selectById?.(godId) : false; - const viaName = !viaId && godName ? ui?.selectByName?.(godName) : false; - if (!viaId && !viaName && pathNo != null) { - ui?.selectByPathNo?.(pathNo); - } - }); +homeUi.init?.({ + nowSkyLayerEl, + nowPanelEl, + getCurrentGeo: () => appRuntime.getCurrentGeo?.() || null }); - -document.addEventListener("nav:calendar-month", (e) => { - const calendarId = e?.detail?.calendarId; - const monthId = e?.detail?.monthId; - if (!monthId) return; - - if (typeof ensureCalendarSection === "function" && referenceData) { - ensureCalendarSection(referenceData, magickDataset); +navigationUi.init?.({ + tarotSpreadUi, + getActiveSection: () => sectionStateUi.getActiveSection?.() || "home", + setActiveSection: (section) => sectionStateUi.setActiveSection?.(section), + getReferenceData: () => appRuntime.getReferenceData?.() || null, + getMagickDataset: () => appRuntime.getMagickDataset?.() || null, + normalizeNumberValue, + selectNumberEntry, + elements: { + openCalendarEl, + openCalendarMonthsEl, + openHolidaysEl, + openTarotEl, + openAstronomyEl, + openPlanetsEl, + openCyclesEl, + openElementsEl, + openIChingEl, + openKabbalahEl, + openKabbalahTreeEl, + openKabbalahCubeEl, + openAlphabetEl, + openNumbersEl, + openZodiacEl, + openNatalEl, + openQuizEl, + openGodsEl, + openEnochianEl + }, + ensure: { + ensureTarotSection, + ensurePlanetSection, + ensureCyclesSection, + ensureElementsSection, + ensureIChingSection, + ensureKabbalahSection, + ensureCubeSection, + ensureAlphabetSection, + ensureZodiacSection, + ensureGodsSection, + ensureCalendarSection } - - setActiveSection("calendar"); - - requestAnimationFrame(() => { - if (calendarId) { - window.CalendarSectionUi?.selectCalendarType?.(calendarId); - } - window.CalendarSectionUi?.selectByMonthId?.(monthId); - }); -}); - -document.addEventListener("nav:kabbalah-path", (e) => { - const pathNo = e?.detail?.pathNo; - const { ensureKabbalahSection } = window.KabbalahSectionUi || {}; - if (typeof ensureKabbalahSection === "function" && magickDataset) { - ensureKabbalahSection(magickDataset); - } - setActiveSection("kabbalah-tree"); - if (pathNo != null) { - requestAnimationFrame(() => { - window.KabbalahSectionUi?.selectNode?.(pathNo); - }); - } -}); - -document.addEventListener("nav:planet", (e) => { - const planetId = e?.detail?.planetId; - if (!planetId) return; - if (typeof ensurePlanetSection === "function" && referenceData) { - ensurePlanetSection(referenceData, magickDataset); - } - setActiveSection("planets"); - requestAnimationFrame(() => { - window.PlanetSectionUi?.selectByPlanetId?.(planetId); - }); -}); - -document.addEventListener("nav:elements", (e) => { - const elementId = e?.detail?.elementId; - if (!elementId) { - return; - } - - if (typeof ensureElementsSection === "function" && magickDataset) { - ensureElementsSection(magickDataset); - } - - setActiveSection("elements"); - - requestAnimationFrame(() => { - window.ElementsSectionUi?.selectByElementId?.(elementId); - }); -}); - -document.addEventListener("nav:tarot-trump", (e) => { - if (typeof ensureTarotSection === "function" && referenceData) { - ensureTarotSection(referenceData, magickDataset); - } - setActiveSection("tarot"); - const { trumpNumber, cardName } = e?.detail || {}; - requestAnimationFrame(() => { - if (trumpNumber != null) { - window.TarotSectionUi?.selectCardByTrump?.(trumpNumber); - } else if (cardName) { - window.TarotSectionUi?.selectCardByName?.(cardName); - } - }); -}); - -document.addEventListener("kab:view-trump", (e) => { - setActiveSection("tarot"); - const trumpNumber = e?.detail?.trumpNumber; - if (trumpNumber != null) { - if (typeof ensureTarotSection === "function" && referenceData) { - ensureTarotSection(referenceData, magickDataset); - } - requestAnimationFrame(() => { - window.TarotSectionUi?.selectCardByTrump?.(trumpNumber); - }); - } -}); - -document.addEventListener("tarot:view-kab-path", (e) => { - setActiveSection("kabbalah-tree"); - const pathNumber = e?.detail?.pathNumber; - if (pathNumber != null) { - requestAnimationFrame(() => { - const kabbalahUi = window.KabbalahSectionUi; - if (typeof kabbalahUi?.selectNode === "function") { - kabbalahUi.selectNode(pathNumber); - } else { - kabbalahUi?.selectPathByNumber?.(pathNumber); - } - }); - } -}); - -if (closeSettingsEl) { - closeSettingsEl.addEventListener("click", closeSettingsPopup); -} - -document.addEventListener("click", (event) => { - const clickTarget = event.target; - if (clickTarget instanceof Node && topbarDropdownEls.some((dropdownEl) => dropdownEl.contains(clickTarget))) { - return; - } - closeTopbarDropdowns(); - - if (!settingsPopupEl || settingsPopupEl.hidden) { - return; - } - - if (!(clickTarget instanceof Node)) { - return; - } - - if (settingsPopupCardEl?.contains(clickTarget) || openSettingsEl?.contains(clickTarget)) { - return; - } - - closeSettingsPopup(); -}); - -document.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - closeTopbarDropdowns(); - closeSettingsPopup(); - } -}); - -window.addEventListener("resize", () => { - if (monthStripResizeFrame) { - cancelAnimationFrame(monthStripResizeFrame); - } - monthStripResizeFrame = requestAnimationFrame(() => { - monthStripResizeFrame = null; - updateMonthStrip(); - }); }); window.TarotNatal = { ...(window.TarotNatal || {}), getSettings() { - return { ...currentSettings }; + return appRuntime.getCurrentSettings?.() || { ...currentSettings }; }, getContext() { - return buildNatalContext(currentSettings); + return settingsUi.buildNatalContext?.(appRuntime.getCurrentSettings?.() || currentSettings) || null; }, buildContextFromSettings(settings) { - return buildNatalContext(settings); + return settingsUi.buildNatalContext?.(settings) || null; } }; -const initialSettings = loadSavedSettings(); -applySettingsToInputs(initialSettings); -emitSettingsUpdated(currentSettings); -initializeSidebarPopouts(); -initializeDetailPopouts(); -syncNowSkyBackground({ latitude: initialSettings.latitude, longitude: initialSettings.longitude }, true); -setActiveSection("home"); +const initialSettings = settingsUi.loadInitialSettingsAndApply?.() || { ...DEFAULT_SETTINGS }; +homeUi.syncNowSkyBackground?.({ latitude: initialSettings.latitude, longitude: initialSettings.longitude }, true); +sectionStateUi.setActiveSection?.("home"); -void renderWeek(); +void appRuntime.renderWeek?.(); diff --git a/app/app-runtime.js b/app/app-runtime.js new file mode 100644 index 0000000..7b089f6 --- /dev/null +++ b/app/app-runtime.js @@ -0,0 +1,199 @@ +(function () { + "use strict"; + + let config = { + calendar: null, + baseWeekOptions: null, + defaultSettings: null, + latEl: null, + lngEl: null, + nowElements: null, + calendarVisualsUi: null, + homeUi: null, + onStatus: null, + services: {}, + ensure: {} + }; + + let referenceData = null; + let magickDataset = null; + let currentGeo = null; + let nowInterval = null; + let centeredDayKey = ""; + let renderInProgress = false; + let currentTimeFormat = "minutes"; + let currentSettings = null; + + function setStatus(text) { + config.onStatus?.(text); + } + + function getReferenceData() { + return referenceData; + } + + function getMagickDataset() { + return magickDataset; + } + + function getCurrentGeo() { + return currentGeo; + } + + function getCurrentTimeFormat() { + return currentTimeFormat; + } + + function getCurrentSettings() { + return currentSettings ? { ...currentSettings } : null; + } + + function parseGeoInput() { + const latitude = Number(config.latEl?.value); + const longitude = Number(config.lngEl?.value); + + if (Number.isNaN(latitude) || Number.isNaN(longitude)) { + throw new Error("Latitude/Longitude must be valid numbers."); + } + + return { latitude, longitude }; + } + + function applyCenteredWeekWindow(date) { + const startDayOfWeek = config.services.getCenteredWeekStartDay?.(date) ?? 0; + config.calendar?.setOptions?.({ + week: { + ...(config.baseWeekOptions || {}), + startDayOfWeek + } + }); + config.calendarVisualsUi?.applyTimeFormatTemplates?.(); + config.calendar?.changeView?.("week"); + config.calendar?.setDate?.(date); + } + + function startNowTicker() { + if (nowInterval) { + clearInterval(nowInterval); + } + + const tick = () => { + if (!referenceData || !currentGeo || renderInProgress) { + return; + } + + const now = new Date(); + config.homeUi?.syncNowPanelTheme?.(now); + const currentDayKey = config.services.getDateKey?.(now) || ""; + if (currentDayKey !== centeredDayKey) { + centeredDayKey = currentDayKey; + void renderWeek(); + return; + } + + config.services.updateNowPanel?.(referenceData, currentGeo, config.nowElements, currentTimeFormat); + config.calendarVisualsUi?.applyDynamicNowIndicatorVisual?.(now); + }; + + tick(); + nowInterval = setInterval(tick, 1000); + } + + async function renderWeek() { + if (renderInProgress) { + return; + } + + renderInProgress = true; + + try { + currentGeo = parseGeoInput(); + config.homeUi?.syncNowPanelTheme?.(new Date()); + config.homeUi?.syncNowSkyBackground?.(currentGeo); + + if (!referenceData || !magickDataset) { + setStatus("Loading planetary, sign and decan tarot correspondences..."); + const [loadedReference, loadedMagick] = await Promise.all([ + referenceData ? Promise.resolve(referenceData) : config.services.loadReferenceData?.(), + magickDataset + ? Promise.resolve(magickDataset) + : config.services.loadMagickDataset?.().catch(() => null) + ]); + + referenceData = loadedReference; + magickDataset = loadedMagick; + } + + config.ensure.ensureTarotSection?.(referenceData, magickDataset); + config.ensure.ensurePlanetSection?.(referenceData, magickDataset); + config.ensure.ensureCyclesSection?.(referenceData); + config.ensure.ensureIChingSection?.(referenceData); + config.ensure.ensureCalendarSection?.(referenceData, magickDataset); + config.ensure.ensureHolidaySection?.(referenceData, magickDataset); + config.ensure.ensureNatalPanel?.(referenceData); + config.ensure.ensureQuizSection?.(referenceData, magickDataset); + + const anchorDate = new Date(); + centeredDayKey = config.services.getDateKey?.(anchorDate) || ""; + + applyCenteredWeekWindow(anchorDate); + + const events = config.services.buildWeekEvents?.(currentGeo, referenceData, anchorDate) || []; + config.calendar?.clear?.(); + config.calendar?.createEvents?.(events); + config.calendarVisualsUi?.applySunRulerGradient?.(anchorDate); + config.calendarVisualsUi?.updateMonthStrip?.(); + requestAnimationFrame(() => { + config.calendarVisualsUi?.updateMonthStrip?.(); + }); + + setStatus(`Rendered ${events.length} planetary + tarot events for lat ${currentGeo.latitude}, lng ${currentGeo.longitude}.`); + startNowTicker(); + } catch (error) { + setStatus(error?.message || "Failed to render calendar."); + } finally { + renderInProgress = false; + } + } + + function applySettings(settings) { + currentTimeFormat = settings?.timeFormat || "minutes"; + currentSettings = settings ? { ...settings } : { ...(config.defaultSettings || {}) }; + } + + function init(nextConfig = {}) { + config = { + ...config, + ...nextConfig, + services: { + ...(config.services || {}), + ...(nextConfig.services || {}) + }, + ensure: { + ...(config.ensure || {}), + ...(nextConfig.ensure || {}) + } + }; + + if (!currentSettings) { + currentSettings = { ...(config.defaultSettings || {}) }; + currentTimeFormat = currentSettings.timeFormat || "minutes"; + } + + centeredDayKey = config.services.getDateKey?.(new Date()) || centeredDayKey; + } + + window.TarotAppRuntime = { + ...(window.TarotAppRuntime || {}), + init, + parseGeoInput, + applyCenteredWeekWindow, + renderWeek, + applySettings, + getReferenceData, + getMagickDataset, + getCurrentGeo, + getCurrentTimeFormat, + getCurrentSettings + }; +})(); diff --git a/app/card-images.js b/app/card-images.js index 02246a4..73d54a7 100644 --- a/app/card-images.js +++ b/app/card-images.js @@ -101,9 +101,10 @@ const DECK_REGISTRY_PATH = "asset/tarot deck/decks.json"; - const deckManifestSources = buildDeckManifestSources(); + let deckManifestSources = buildDeckManifestSources(); const manifestCache = new Map(); + const cardBackCache = new Map(); let activeDeckId = DEFAULT_DECK_ID; function canonicalMajorName(cardName) { @@ -132,16 +133,17 @@ } function normalizeDeckId(deckId) { + const sources = getDeckManifestSources(); const normalized = String(deckId || "").trim().toLowerCase(); - if (deckManifestSources[normalized]) { + if (sources[normalized]) { return normalized; } - if (deckManifestSources[DEFAULT_DECK_ID]) { + if (sources[DEFAULT_DECK_ID]) { return DEFAULT_DECK_ID; } - const fallbackId = Object.keys(deckManifestSources)[0]; + const fallbackId = Object.keys(sources)[0]; return fallbackId || DEFAULT_DECK_ID; } @@ -241,6 +243,41 @@ }); } + function isRemoteAssetPath(pathValue) { + return /^(https?:)?\/\//i.test(String(pathValue || "")); + } + + function toDeckAssetPath(manifest, relativeOrAbsolutePath) { + const normalizedPath = String(relativeOrAbsolutePath || "").trim(); + if (!normalizedPath) { + return ""; + } + + if (isRemoteAssetPath(normalizedPath) || normalizedPath.startsWith("/")) { + return normalizedPath; + } + + return `${manifest.basePath}/${normalizedPath.replace(/^\.\//, "")}`; + } + + function resolveDeckCardBackPath(manifest) { + if (!manifest) { + return null; + } + + const explicitCardBack = String(manifest.cardBack || "").trim(); + if (explicitCardBack) { + return toDeckAssetPath(manifest, explicitCardBack) || null; + } + + const detectedCardBack = String(manifest.cardBackPath || "").trim(); + if (detectedCardBack) { + return toDeckAssetPath(manifest, detectedCardBack) || null; + } + + return null; + } + function readManifestJsonSync(path) { try { const request = new XMLHttpRequest(); @@ -276,7 +313,8 @@ id, label: String(entry?.label || id), basePath, - manifestPath + manifestPath, + cardBackPath: String(entry?.cardBackPath || "").trim() }; }); @@ -292,6 +330,14 @@ return toDeckSourceMap(registryDecks); } + function getDeckManifestSources(forceRefresh = false) { + if (forceRefresh || !deckManifestSources || Object.keys(deckManifestSources).length === 0) { + deckManifestSources = buildDeckManifestSources(); + } + + return deckManifestSources || {}; + } + function normalizeDeckManifest(source, rawManifest) { if (!rawManifest || typeof rawManifest !== "object") { return null; @@ -337,6 +383,8 @@ id: source.id, label: String(rawManifest.label || source.label || source.id), basePath: String(source.basePath || "").replace(/\/$/, ""), + cardBack: String(rawManifest.cardBack || "").trim(), + cardBackPath: String(source.cardBackPath || "").trim(), majors: rawManifest.majors || {}, minors: rawManifest.minors || {}, nameOverrides, @@ -351,15 +399,22 @@ return manifestCache.get(normalizedDeckId); } - const source = deckManifestSources[normalizedDeckId]; + let sources = getDeckManifestSources(); + let source = sources[normalizedDeckId]; + if (!source) { + sources = getDeckManifestSources(true); + source = sources[normalizedDeckId]; + } + if (!source) { - manifestCache.set(normalizedDeckId, null); return null; } const rawManifest = readManifestJsonSync(source.manifestPath); const normalizedManifest = normalizeDeckManifest(source, rawManifest); - manifestCache.set(normalizedDeckId, normalizedManifest); + if (normalizedManifest) { + manifestCache.set(normalizedDeckId, normalizedManifest); + } return normalizedManifest; } @@ -531,11 +586,23 @@ return encodeURI(activePath); } - if (activeDeckId !== DEFAULT_DECK_ID) { - const fallbackPath = resolveWithDeck(DEFAULT_DECK_ID, cardName); - if (fallbackPath) { - return encodeURI(fallbackPath); - } + return null; + } + + function resolveTarotCardBackImage(optionsOrDeckId) { + const { resolvedDeckId } = resolveDeckOptions(optionsOrDeckId); + + if (cardBackCache.has(resolvedDeckId)) { + const cachedPath = cardBackCache.get(resolvedDeckId); + return cachedPath ? encodeURI(cachedPath) : null; + } + + const manifest = getDeckManifest(resolvedDeckId); + const activeBackPath = resolveDeckCardBackPath(manifest); + cardBackCache.set(resolvedDeckId, activeBackPath || null); + + if (activeBackPath) { + return encodeURI(activeBackPath); } return null; @@ -629,7 +696,7 @@ } function getDeckOptions() { - return Object.values(deckManifestSources).map((source) => { + return Object.values(getDeckManifestSources()).map((source) => { const manifest = getDeckManifest(source.id); return { id: source.id, @@ -645,6 +712,7 @@ window.TarotCardImages = { resolveTarotCardImage, + resolveTarotCardBackImage, getTarotCardDisplayName, getTarotCardSearchAliases, setActiveDeck, diff --git a/app/styles.css b/app/styles.css index a09277d..57f3941 100644 --- a/app/styles.css +++ b/app/styles.css @@ -1084,6 +1084,132 @@ letter-spacing: 0.04em; } + .kab-rose-layout { + height: 100%; + display: grid; + grid-template-columns: minmax(520px, 1.2fr) minmax(320px, 1fr); + min-height: 0; + } + + .kab-rose-panel { + min-width: 0; + overflow: auto; + border-right: 1px solid #27272a; + background: + radial-gradient(circle at 52% 40%, rgba(255, 255, 255, 0.06), transparent 36%), + linear-gradient(180deg, #020617 0%, #02030a 100%); + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + min-height: 0; + } + + .kab-rose-intro { + padding: 8px 14px 0; + color: #a1a1aa; + font-size: 12px; + letter-spacing: 0.02em; + } + + .kab-rose-cross-container { + min-height: 0; + display: flex; + justify-content: center; + align-items: center; + padding: 8px 8px 14px; + } + + .kab-rose-cross-container > .kab-rose-svg { + width: min(100%, 980px); + max-height: min(100%, 1160px); + display: block; + } + + .kab-rose-petal { + cursor: pointer; + outline: none; + } + + .kab-rose-petal-shape { + transition: transform 120ms ease, filter 120ms ease, stroke 120ms ease; + } + + .kab-rose-petal:hover .kab-rose-petal-shape, + .kab-rose-petal:focus-visible .kab-rose-petal-shape { + transform: scale(1.07); + filter: brightness(1.14); + stroke: rgba(255, 255, 255, 0.75); + stroke-width: 2.4; + } + + .kab-rose-petal.kab-path-active .kab-rose-petal-shape { + filter: brightness(1.2); + stroke: #f8fafc; + stroke-width: 2.6; + } + + .kab-rose-petal-letter { + font-family: "Noto Sans Hebrew", var(--font-script-main), serif; + font-size: 34px; + font-weight: 700; + pointer-events: none; + fill: #f8fafc; + text-shadow: 0 1px 5px rgba(0, 0, 0, 0.66); + } + + .kab-rose-petal-number { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + pointer-events: none; + fill: rgba(241, 245, 249, 0.95); + } + + .kab-rose-arm-glyph { + pointer-events: none; + text-shadow: 0 1px 8px rgba(0, 0, 0, 0.72); + } + + .kab-rose-petal--mother .kab-rose-petal-letter, + .kab-rose-petal--double .kab-rose-petal-letter { + fill: #111827; + text-shadow: none; + } + + .kab-rose-petal--mother .kab-rose-petal-number, + .kab-rose-petal--double .kab-rose-petal-number { + fill: rgba(17, 24, 39, 0.92); + } + + @media (max-width: 1220px) { + .kab-rose-layout { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(360px, auto) minmax(0, 1fr); + } + + .kab-rose-panel { + border-right: none; + border-bottom: 1px solid #27272a; + } + + .kab-rose-cross-container > .kab-rose-svg { + width: min(100%, 860px); + } + } + + @media (max-width: 760px) { + .kab-rose-cross-container { + padding: 6px; + } + + .kab-rose-cross-container > .kab-rose-svg { + width: min(100%, 700px); + } + + .kab-rose-petal-letter { + font-size: 30px; + } + } + .natal-chart-summary { margin-top: 10px; margin-bottom: 0; @@ -1322,6 +1448,16 @@ transition: filter 120ms ease, opacity 120ms ease; } + .cube-tarot-image { + cursor: zoom-in; + transition: filter 120ms ease, transform 120ms ease; + } + + .cube-tarot-image:hover { + filter: drop-shadow(0 0 4px rgba(224, 231, 255, 0.92)); + transform: translateY(-0.6px); + } + .cube-direction:hover .cube-direction-card, .cube-direction-card.is-active { filter: drop-shadow(0 0 3px currentColor) drop-shadow(0 0 8px currentColor); @@ -1598,6 +1734,15 @@ filter: drop-shadow(0 0 5px rgba(112, 96, 176, 0.78)); } + .kab-path-tarot { + cursor: zoom-in; + transition: filter 120ms ease; + } + + .kab-path-tarot:hover { + filter: drop-shadow(0 0 6px rgba(196, 181, 253, 0.85)); + } + .kab-path-lbl.kab-path-active { fill: #c8b8f8 !important; } @@ -1933,6 +2078,14 @@ grid-row: 1 / -1; } + #tarot-spread-board { + order: 2; + } + + #tarot-spread-meanings { + order: 3; + } + #tarot-spread-view[hidden] { display: none !important; } @@ -1998,6 +2151,13 @@ margin-left: auto; transition: background 120ms, border-color 120ms; } + + #tarot-spread-reveal-all:disabled, + .tarot-spread-redraw-btn:disabled { + opacity: 0.56; + cursor: default; + filter: saturate(0.72); + } .tarot-spread-redraw-btn:hover { background: #312e81; border-color: #6366f1; @@ -2054,97 +2214,236 @@ /* ── Spread Board ──────────────────────────────────── */ .tarot-spread-board { - display: flex; - flex-wrap: wrap; - gap: 1.25rem; - justify-content: center; - padding: 0.5rem 0 1.5rem; + --spread-card-width: 116px; + --spread-card-height: 184px; + display: grid; + gap: 1rem; + align-items: start; + justify-items: center; + padding: 1.1rem 1rem 1.5rem; + border: 1px solid #27272a; + border-radius: 18px; + background: + radial-gradient(circle at 20% 12%, rgba(236, 72, 153, 0.14), transparent 40%), + radial-gradient(circle at 84% 86%, rgba(59, 130, 246, 0.14), transparent 44%), + linear-gradient(180deg, #0f0f1d 0%, #13131f 38%, #10101a 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 18px 30px rgba(2, 6, 23, 0.44); } .tarot-spread-board--three { - flex-wrap: nowrap; + grid-template-columns: repeat(3, var(--spread-card-width)); justify-content: center; - gap: 2rem; + column-gap: 1.25rem; + row-gap: 1rem; + width: max-content; + max-width: 100%; + margin-inline: auto; } .tarot-spread-board--celtic { - display: grid; grid-template-areas: - ". crown . out" - "past present near-fut hope" - ". chall . env" - ". found . self"; - grid-template-columns: 1fr 1fr 1fr 1fr; - gap: 0.8rem 1rem; - justify-items: center; + ". crown . out ." + "past present near-fut hope ." + ". chall . env ." + ". found . self ."; + grid-template-columns: repeat(5, var(--spread-card-width)); + justify-content: center; + column-gap: 1rem; + row-gap: 0.9rem; + width: max-content; + max-width: 100%; + margin-inline: auto; } - .spread-position { grid-area: unset; } - .spread-position[data-pos="crown"] { grid-area: crown; } - .spread-position[data-pos="out"] { grid-area: out; } - .spread-position[data-pos="past"] { grid-area: past; } - .spread-position[data-pos="present"] { grid-area: present; } - .spread-position[data-pos="near-fut"] { grid-area: near-fut; } - .spread-position[data-pos="hope"] { grid-area: hope; } - .spread-position[data-pos="chall"] { grid-area: chall; } - .spread-position[data-pos="env"] { grid-area: env; } - .spread-position[data-pos="found"] { grid-area: found; } - .spread-position[data-pos="self"] { grid-area: self; } + .tarot-spread-board--three .spread-position { + grid-area: auto; + } + + .tarot-spread-board--celtic .spread-position { + grid-area: unset; + } + + .tarot-spread-board--celtic .spread-position[data-pos="crown"] { grid-area: crown; } + .tarot-spread-board--celtic .spread-position[data-pos="out"] { grid-area: out; } + .tarot-spread-board--celtic .spread-position[data-pos="past"] { grid-area: past; } + .tarot-spread-board--celtic .spread-position[data-pos="present"] { grid-area: present; } + .tarot-spread-board--celtic .spread-position[data-pos="near-fut"] { grid-area: near-fut; } + .tarot-spread-board--celtic .spread-position[data-pos="hope"] { grid-area: hope; } + .tarot-spread-board--celtic .spread-position[data-pos="chall"] { grid-area: chall; } + .tarot-spread-board--celtic .spread-position[data-pos="env"] { grid-area: env; } + .tarot-spread-board--celtic .spread-position[data-pos="found"] { grid-area: found; } + .tarot-spread-board--celtic .spread-position[data-pos="self"] { grid-area: self; } .spread-position { + width: var(--spread-card-width); display: flex; flex-direction: column; align-items: center; - gap: 0.4rem; - max-width: 130px; + gap: 0.5rem; } .spread-pos-label { - font-size: 0.68rem; - color: #a5b4fc; + font-size: 0.66rem; + color: #c4b5fd; text-transform: uppercase; - letter-spacing: 0.07em; + letter-spacing: 0.09em; text-align: center; line-height: 1.2; + border: 1px solid rgba(167, 139, 250, 0.45); + border-radius: 999px; + padding: 0.17rem 0.55rem; + background: rgba(76, 29, 149, 0.2); } .spread-card-wrap { - border-radius: 8px; + appearance: none; + border: 1px solid rgba(168, 162, 158, 0.34); + border-radius: 13px; overflow: hidden; - box-shadow: 0 4px 18px rgba(0,0,0,0.55); - border: 1px solid rgba(255,255,255,0.1); - background: #18181b; + background: #09090f; + width: 100%; + height: var(--spread-card-height); + display: block; + padding: 0; + cursor: pointer; + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.1); + transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease; } - .spread-card-wrap.is-reversed .spread-card-img { + .spread-card-wrap:hover { + transform: translateY(-3px); + border-color: rgba(196, 181, 253, 0.75); + box-shadow: 0 14px 30px rgba(2, 6, 23, 0.65), 0 0 0 1px rgba(196, 181, 253, 0.26); + } + + .spread-card-wrap:focus-visible { + outline: none; + border-color: #c4b5fd; + box-shadow: 0 0 0 2px rgba(196, 181, 253, 0.36), 0 10px 24px rgba(2, 6, 23, 0.56); + } + + .spread-card-wrap.is-facedown { + background: + linear-gradient(150deg, rgba(190, 24, 93, 0.45), rgba(49, 46, 129, 0.55)), + repeating-linear-gradient( + 45deg, + rgba(255, 255, 255, 0.08) 0, + rgba(255, 255, 255, 0.08) 6px, + rgba(0, 0, 0, 0.08) 6px, + rgba(0, 0, 0, 0.08) 12px + ); + } + + .spread-card-wrap.is-revealed.is-reversed .spread-card-img { transform: rotate(180deg); } - .spread-card-img { - width: 90px; - height: auto; + .spread-card-img, + .spread-card-back-img { + width: 100%; + height: 100%; display: block; + object-fit: cover; + } + + .spread-card-back-fallback { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.74rem; + letter-spacing: 0.16em; + color: #e9d5ff; + font-weight: 700; + text-transform: uppercase; + text-shadow: 0 1px 8px rgba(0, 0, 0, 0.85); + } + + .spread-card-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 0.6rem; + font-size: 0.72rem; + color: #e4e4e7; + background: #18181b; } .spread-card-name { - font-size: 0.74rem; - color: #d4d4d8; + font-size: 0.66rem; + color: #fda4af; text-align: center; line-height: 1.3; + min-height: 1.2em; + width: 100%; + } + + .spread-reveal-hint { + display: block; + font-size: 0.62rem; + color: #a1a1aa; + letter-spacing: 0.03em; + text-transform: uppercase; } .spread-reversed-tag { display: block; font-size: 0.66rem; color: #fb7185; - margin-top: 0.1rem; + margin-top: 0.05rem; + } + + .tarot-spread-meanings-empty { + border: 1px dashed #3f3f46; + border-radius: 10px; + padding: 10px; + color: #a1a1aa; + font-size: 0.8rem; + text-align: center; + background: rgba(9, 9, 11, 0.72); } .spread-empty { - color: #52525b; + color: #71717a; padding: 2.5rem; text-align: center; - font-size: 0.9rem; + font-size: 0.92rem; + } + + @media (max-width: 980px) { + .tarot-spread-board--celtic { + grid-template-areas: + "crown out" + "past present" + "near-fut hope" + "chall env" + "found self"; + grid-template-columns: repeat(2, var(--spread-card-width)); + width: max-content; + max-width: 100%; + } + } + + @media (max-width: 720px) { + .tarot-spread-board { + --spread-card-width: 106px; + --spread-card-height: 170px; + padding: 0.8rem 0.65rem 1rem; + } + + .tarot-spread-board--three { + grid-template-columns: 1fr; + width: var(--spread-card-width); + max-width: 100%; + } + + .spread-position { + width: var(--spread-card-width); + } } .alpha-dl dd { margin: 0; } .alpha-badge { diff --git a/app/ui-alphabet-detail.js b/app/ui-alphabet-detail.js new file mode 100644 index 0000000..2ffd088 --- /dev/null +++ b/app/ui-alphabet-detail.js @@ -0,0 +1,608 @@ +(function () { + "use strict"; + + function computeDigitalRoot(value) { + let current = Math.abs(Math.trunc(Number(value))); + if (!Number.isFinite(current)) { + return null; + } + + while (current >= 10) { + current = String(current) + .split("") + .reduce((sum, digit) => sum + Number(digit), 0); + } + + return current; + } + + function describeDigitalRootReduction(value, digitalRoot) { + const normalized = Math.abs(Math.trunc(Number(value))); + if (!Number.isFinite(normalized) || !Number.isFinite(digitalRoot)) { + return ""; + } + + if (normalized < 10) { + return String(normalized); + } + + return `${String(normalized).split("").join(" + ")} = ${digitalRoot}`; + } + + function renderPositionDigitalRootCard(letter, alphabet, context, orderLabel) { + const index = Number(letter?.index); + if (!Number.isFinite(index)) { + return ""; + } + + const position = Math.trunc(index); + if (position <= 0) { + return ""; + } + + const digitalRoot = computeDigitalRoot(position); + if (!Number.isFinite(digitalRoot)) { + return ""; + } + + const entries = Array.isArray(context.alphabets?.[alphabet]) ? context.alphabets[alphabet] : []; + const countText = entries.length ? ` of ${entries.length}` : ""; + const orderText = orderLabel ? ` (${orderLabel})` : ""; + const reductionText = describeDigitalRootReduction(position, digitalRoot); + const openNumberBtn = context.navBtn(`View Number ${digitalRoot}`, "nav:number", { value: digitalRoot }); + + return context.card("Position Digital Root", ` +
+
Position
#${position}${countText}${orderText}
+
Digital Root
${digitalRoot}${reductionText ? ` (${reductionText})` : ""}
+
+
${openNumberBtn}
+ `); + } + + function monthRefsForLetter(letter, context) { + const hebrewLetterId = context.normalizeId(letter?.hebrewLetterId); + if (!hebrewLetterId) { + return []; + } + return context.monthRefsByHebrewId.get(hebrewLetterId) || []; + } + + function calendarMonthsCard(monthRefs, titleLabel, context) { + if (!monthRefs.length) { + return ""; + } + + const monthButtons = monthRefs + .map((month) => context.navBtn(month.label || month.name, "nav:calendar-month", { "month-id": month.id })) + .join(""); + + return context.card("Calendar Months", ` +
${titleLabel}
+
${monthButtons}
+ `); + } + + function renderAstrologyCard(astrology, context) { + if (!astrology) return ""; + const { type, name } = astrology; + const id = (name || "").toLowerCase(); + + if (type === "planet") { + const sym = context.PLANET_SYMBOLS[id] || ""; + const cubePlacement = context.getCubePlacementForPlanet(id); + const cubeBtn = context.cubePlacementBtn(cubePlacement, { "planet-id": id }); + return context.card("Astrology", ` +
+
Type
Planet
+
Ruler
${sym} ${context.cap(id)}
+
+
+ + ${cubeBtn} +
+ `); + } + if (type === "zodiac") { + const sym = context.ZODIAC_SYMBOLS[id] || ""; + const cubePlacement = context.getCubePlacementForSign(id); + const cubeBtn = context.cubePlacementBtn(cubePlacement, { "sign-id": id }); + return context.card("Astrology", ` +
+
Type
Zodiac Sign
+
Sign
${sym} ${context.cap(id)}
+
+
+ + ${cubeBtn} +
+ `); + } + if (type === "element") { + const elemEmoji = { air: "💨", water: "💧", fire: "🔥", earth: "🌍" }; + return context.card("Astrology", ` +
+
Type
Element
+
Element
${elemEmoji[id] || ""} ${context.cap(id)}
+
+ `); + } + return context.card("Astrology", ` +
+
Type
${context.cap(type)}
+
Name
${context.cap(name)}
+
+ `); + } + + function renderHebrewDualityCard(letter, context) { + const duality = context.HEBREW_DOUBLE_DUALITY[context.normalizeId(letter?.hebrewLetterId)]; + if (!duality) { + return ""; + } + + return context.card("Duality", ` +
+
Polarity
${duality.left} / ${duality.right}
+
+ `); + } + + function renderHebrewFourWorldsCard(letter, context) { + const letterId = context.normalizeLetterId(letter?.hebrewLetterId || letter?.transliteration || letter?.char); + if (!letterId) { + return ""; + } + + const rows = (Array.isArray(context.fourWorldLayers) ? context.fourWorldLayers : []) + .filter((entry) => entry?.hebrewLetterId === letterId); + + if (!rows.length) { + return ""; + } + + const body = rows.map((entry) => { + const pathBtn = Number.isFinite(Number(entry?.pathNumber)) + ? context.navBtn(`View Path ${entry.pathNumber}`, "nav:kabbalah-path", { "path-no": Number(entry.pathNumber) }) + : ""; + + return ` +
+
+ ${entry.slot}: ${entry.letterChar} — ${entry.world} + ${entry.soulLayer} +
+
${entry.worldLayer}${entry.worldDescription ? ` · ${entry.worldDescription}` : ""}
+
${entry.soulLayer}${entry.soulTitle ? ` — ${entry.soulTitle}` : ""}${entry.soulDescription ? `: ${entry.soulDescription}` : ""}
+
${pathBtn}
+
+ `; + }).join(""); + + return context.card("Qabalistic Worlds & Soul Layers", `
${body}
`); + } + + function normalizeLatinLetter(value) { + return String(value || "") + .trim() + .toUpperCase() + .replace(/[^A-Z]/g, ""); + } + + function extractEnglishLetterRefs(value) { + if (Array.isArray(value)) { + return [...new Set(value.map((entry) => normalizeLatinLetter(entry)).filter(Boolean))]; + } + + return [...new Set( + String(value || "") + .split(/[\s,;|\/]+/) + .map((entry) => normalizeLatinLetter(entry)) + .filter(Boolean) + )]; + } + + function renderAlphabetEquivalentCard(activeAlphabet, letter, context) { + const hebrewLetters = Array.isArray(context.alphabets?.hebrew) ? context.alphabets.hebrew : []; + const greekLetters = Array.isArray(context.alphabets?.greek) ? context.alphabets.greek : []; + const englishLetters = Array.isArray(context.alphabets?.english) ? context.alphabets.english : []; + const arabicLetters = Array.isArray(context.alphabets?.arabic) ? context.alphabets.arabic : []; + const enochianLetters = Array.isArray(context.alphabets?.enochian) ? context.alphabets.enochian : []; + const linkedHebrewIds = new Set(); + const linkedEnglishLetters = new Set(); + const buttons = []; + + function addHebrewId(value) { + const id = context.normalizeId(value); + if (id) { + linkedHebrewIds.add(id); + } + } + + function addEnglishLetter(value) { + const code = normalizeLatinLetter(value); + if (!code) { + return; + } + + linkedEnglishLetters.add(code); + englishLetters + .filter((entry) => normalizeLatinLetter(entry?.letter) === code) + .forEach((entry) => addHebrewId(entry?.hebrewLetterId)); + } + + if (activeAlphabet === "hebrew") { + addHebrewId(letter?.hebrewLetterId); + } else if (activeAlphabet === "greek") { + addHebrewId(letter?.hebrewLetterId); + englishLetters + .filter((entry) => context.normalizeId(entry?.greekEquivalent) === context.normalizeId(letter?.name)) + .forEach((entry) => addEnglishLetter(entry?.letter)); + } else if (activeAlphabet === "english") { + addEnglishLetter(letter?.letter); + addHebrewId(letter?.hebrewLetterId); + } else if (activeAlphabet === "arabic") { + addHebrewId(letter?.hebrewLetterId); + } else if (activeAlphabet === "enochian") { + extractEnglishLetterRefs(letter?.englishLetters).forEach((code) => addEnglishLetter(code)); + addHebrewId(letter?.hebrewLetterId); + } + + if (!linkedHebrewIds.size && !linkedEnglishLetters.size) { + return ""; + } + + const activeHebrewKey = context.normalizeId(letter?.hebrewLetterId); + const activeGreekKey = context.normalizeId(letter?.name); + const activeEnglishKey = normalizeLatinLetter(letter?.letter); + const activeArabicKey = context.normalizeId(letter?.name); + const activeEnochianKey = context.normalizeId(letter?.id || letter?.char || letter?.title); + + hebrewLetters.forEach((heb) => { + const key = context.normalizeId(heb?.hebrewLetterId); + if (!key || !linkedHebrewIds.has(key)) { + return; + } + if (activeAlphabet === "hebrew" && key === activeHebrewKey) { + return; + } + + buttons.push(``); + }); + + greekLetters.forEach((grk) => { + const key = context.normalizeId(grk?.name); + const viaHebrew = linkedHebrewIds.has(context.normalizeId(grk?.hebrewLetterId)); + const viaEnglish = englishLetters.some((eng) => ( + linkedEnglishLetters.has(normalizeLatinLetter(eng?.letter)) + && context.normalizeId(eng?.greekEquivalent) === key + )); + if (!(viaHebrew || viaEnglish)) { + return; + } + if (activeAlphabet === "greek" && key === activeGreekKey) { + return; + } + + buttons.push(``); + }); + + englishLetters.forEach((eng) => { + const key = normalizeLatinLetter(eng?.letter); + const viaLetter = linkedEnglishLetters.has(key); + const viaHebrew = linkedHebrewIds.has(context.normalizeId(eng?.hebrewLetterId)); + if (!(viaLetter || viaHebrew)) { + return; + } + if (activeAlphabet === "english" && key === activeEnglishKey) { + return; + } + + buttons.push(``); + }); + + arabicLetters.forEach((arb) => { + const key = context.normalizeId(arb?.name); + if (!linkedHebrewIds.has(context.normalizeId(arb?.hebrewLetterId))) { + return; + } + if (activeAlphabet === "arabic" && key === activeArabicKey) { + return; + } + + buttons.push(``); + }); + + enochianLetters.forEach((eno) => { + const key = context.normalizeId(eno?.id || eno?.char || eno?.title); + const englishRefs = extractEnglishLetterRefs(eno?.englishLetters); + const viaHebrew = linkedHebrewIds.has(context.normalizeId(eno?.hebrewLetterId)); + const viaEnglish = englishRefs.some((code) => linkedEnglishLetters.has(code)); + if (!(viaHebrew || viaEnglish)) { + return; + } + if (activeAlphabet === "enochian" && key === activeEnochianKey) { + return; + } + + buttons.push(``); + }); + + if (!buttons.length) { + return ""; + } + + return context.card("ALPHABET EQUIVALENT", `
${buttons.join("")}
`); + } + + function renderHebrewDetail(context) { + const { letter, detailSubEl, detailBodyEl } = context; + detailSubEl.textContent = `${letter.name} — ${letter.transliteration}`; + detailBodyEl.innerHTML = ""; + + const sections = []; + sections.push(context.card("Letter Details", ` +
+
Character
${letter.char}
+
Name
${letter.name}
+
Transliteration
${letter.transliteration}
+
Meaning
${letter.meaning}
+
Gematria Value
${letter.numerology}
+
Letter Type
${letter.letterType}
+
Position
#${letter.index} of 22
+
+ `)); + + const positionRootCard = renderPositionDigitalRootCard(letter, "hebrew", context); + if (positionRootCard) { + sections.push(positionRootCard); + } + + if (letter.letterType === "double") { + const dualityCard = renderHebrewDualityCard(letter, context); + if (dualityCard) { + sections.push(dualityCard); + } + } + + const fourWorldsCard = renderHebrewFourWorldsCard(letter, context); + if (fourWorldsCard) { + sections.push(fourWorldsCard); + } + + if (letter.astrology) { + sections.push(renderAstrologyCard(letter.astrology, context)); + } + + if (letter.kabbalahPathNumber) { + const tarotPart = letter.tarot + ? `
Tarot Card
${letter.tarot.card} (Trump ${letter.tarot.trumpNumber})
` + : ""; + const kabBtn = context.navBtn("View Kabbalah Path", "tarot:view-kab-path", { "path-number": letter.kabbalahPathNumber }); + const tarotBtn = letter.tarot + ? context.navBtn("View Tarot Card", "kab:view-trump", { "trump-number": letter.tarot.trumpNumber }) + : ""; + const cubePlacement = context.getCubePlacementForHebrewLetter(letter.hebrewLetterId, letter.kabbalahPathNumber); + const cubeBtn = context.cubePlacementBtn(cubePlacement, { + "hebrew-letter-id": letter.hebrewLetterId, + "path-no": letter.kabbalahPathNumber + }); + sections.push(context.card("Kabbalah & Tarot", ` +
+
Path Number
${letter.kabbalahPathNumber}
+ ${tarotPart} +
+
${kabBtn}${tarotBtn}${cubeBtn}
+ `)); + } + + const monthRefs = monthRefsForLetter(letter, context); + const monthCard = calendarMonthsCard(monthRefs, `Calendar correspondences linked to ${letter.name}.`, context); + if (monthCard) { + sections.push(monthCard); + } + + const equivalentsCard = renderAlphabetEquivalentCard("hebrew", letter, context); + if (equivalentsCard) { + sections.push(equivalentsCard); + } + + detailBodyEl.innerHTML = sections.join(""); + context.attachDetailListeners(); + } + + function renderGreekDetail(context) { + const { letter, detailSubEl, detailBodyEl } = context; + const archaicBadge = letter.archaic ? ' archaic' : ""; + detailSubEl.textContent = `${letter.displayName}${letter.archaic ? " (archaic)" : ""} — ${letter.transliteration}`; + detailBodyEl.innerHTML = ""; + + const sections = []; + const charRow = letter.charFinal + ? `
Form (final)
${letter.charFinal}
` + : ""; + sections.push(context.card("Letter Details", ` +
+
Uppercase
${letter.char}
+
Lowercase
${letter.charLower || "—"}
+ ${charRow} +
Name
${letter.displayName}${archaicBadge}
+
Transliteration
${letter.transliteration}
+
IPA
${letter.ipa || "—"}
+
Isopsephy Value
${letter.numerology}
+
Meaning / Origin
${letter.meaning || "—"}
+
+ `)); + + const positionRootCard = renderPositionDigitalRootCard(letter, "greek", context); + if (positionRootCard) { + sections.push(positionRootCard); + } + + const equivalentsCard = renderAlphabetEquivalentCard("greek", letter, context); + if (equivalentsCard) { + sections.push(equivalentsCard); + } + + const monthRefs = monthRefsForLetter(letter, context); + const monthCard = calendarMonthsCard(monthRefs, `Calendar correspondences inherited via ${letter.displayName}'s Hebrew origin.`, context); + if (monthCard) { + sections.push(monthCard); + } + + detailBodyEl.innerHTML = sections.join(""); + context.attachDetailListeners(); + } + + function renderEnglishDetail(context) { + const { letter, detailSubEl, detailBodyEl } = context; + detailSubEl.textContent = `Letter ${letter.letter} · position #${letter.index}`; + detailBodyEl.innerHTML = ""; + + const sections = []; + sections.push(context.card("Letter Details", ` +
+
Letter
${letter.letter}
+
Position
#${letter.index} of 26
+
IPA
${letter.ipa || "—"}
+
Pythagorean Value
${letter.pythagorean}
+
+ `)); + + const positionRootCard = renderPositionDigitalRootCard(letter, "english", context); + if (positionRootCard) { + sections.push(positionRootCard); + } + + const equivalentsCard = renderAlphabetEquivalentCard("english", letter, context); + if (equivalentsCard) { + sections.push(equivalentsCard); + } + + const monthRefs = monthRefsForLetter(letter, context); + const monthCard = calendarMonthsCard(monthRefs, "Calendar correspondences linked through this letter's Hebrew correspondence.", context); + if (monthCard) { + sections.push(monthCard); + } + + detailBodyEl.innerHTML = sections.join(""); + context.attachDetailListeners(); + } + + function renderArabicDetail(context) { + const { letter, detailSubEl, detailBodyEl } = context; + detailSubEl.textContent = `${context.arabicDisplayName(letter)} — ${letter.transliteration}`; + detailBodyEl.innerHTML = ""; + + const sections = []; + const forms = letter.forms || {}; + const formParts = [ + forms.isolated ? `${forms.isolated}
isolated
` : "", + forms.final ? `${forms.final}
final
` : "", + forms.medial ? `${forms.medial}
medial
` : "", + forms.initial ? `${forms.initial}
initial
` : "" + ].filter(Boolean); + + sections.push(context.card("Letter Details", ` +
+
Arabic Name
${letter.nameArabic}
+
Transliteration
${letter.transliteration}
+
IPA
${letter.ipa || "—"}
+
Abjad Value
${letter.abjad}
+
Meaning
${letter.meaning || "—"}
+
Category
${letter.category}
+
Position
#${letter.index} of 28 (Abjad order)
+
+ `)); + + const positionRootCard = renderPositionDigitalRootCard(letter, "arabic", context, "Abjad order"); + if (positionRootCard) { + sections.push(positionRootCard); + } + + if (formParts.length) { + sections.push(context.card("Letter Forms", `
${formParts.join("")}
`)); + } + + const equivalentsCard = renderAlphabetEquivalentCard("arabic", letter, context); + if (equivalentsCard) { + sections.push(equivalentsCard); + } + + detailBodyEl.innerHTML = sections.join(""); + context.attachDetailListeners(); + } + + function renderEnochianDetail(context) { + const { letter, detailSubEl, detailBodyEl } = context; + const englishRefs = extractEnglishLetterRefs(letter?.englishLetters); + detailSubEl.textContent = `${letter.title} — ${letter.transliteration}`; + detailBodyEl.innerHTML = ""; + + const sections = []; + sections.push(context.card("Letter Details", ` +
+
Character
${context.enochianGlyphImageHtml(letter, "alpha-enochian-glyph-img alpha-enochian-glyph-img--detail-row")}
+
Name
${letter.title}
+
English Letters
${englishRefs.join(" / ") || "—"}
+
Transliteration
${letter.transliteration || "—"}
+
Element / Planet
${letter.elementOrPlanet || "—"}
+
Tarot
${letter.tarot || "—"}
+
Numerology
${letter.numerology || "—"}
+
Glyph Source
Local cache: asset/img/enochian (sourced from dCode set)
+
Position
#${letter.index} of 21
+
+ `)); + + const positionRootCard = renderPositionDigitalRootCard(letter, "enochian", context); + if (positionRootCard) { + sections.push(positionRootCard); + } + + const equivalentsCard = renderAlphabetEquivalentCard("enochian", letter, context); + if (equivalentsCard) { + sections.push(equivalentsCard); + } + + const monthRefs = monthRefsForLetter(letter, context); + const monthCard = calendarMonthsCard(monthRefs, "Calendar correspondences linked through this letter's Hebrew correspondence.", context); + if (monthCard) { + sections.push(monthCard); + } + + detailBodyEl.innerHTML = sections.join(""); + context.attachDetailListeners(); + } + + function renderDetail(context) { + const alphabet = context.alphabet; + if (alphabet === "hebrew") { + renderHebrewDetail(context); + } else if (alphabet === "greek") { + renderGreekDetail(context); + } else if (alphabet === "english") { + renderEnglishDetail(context); + } else if (alphabet === "arabic") { + renderArabicDetail(context); + } else if (alphabet === "enochian") { + renderEnochianDetail(context); + } + } + + window.AlphabetDetailUi = { renderDetail }; +})(); \ No newline at end of file diff --git a/app/ui-alphabet-gematria.js b/app/ui-alphabet-gematria.js new file mode 100644 index 0000000..adf2670 --- /dev/null +++ b/app/ui-alphabet-gematria.js @@ -0,0 +1,353 @@ +(function () { + "use strict"; + + let config = { + getAlphabets: () => null, + getGematriaElements: () => ({ + cipherEl: null, + inputEl: null, + resultEl: null, + breakdownEl: null + }) + }; + + const state = { + loadingPromise: null, + db: null, + listenersBound: false, + activeCipherId: "", + inputText: "", + scriptCharMap: new Map() + }; + + function getAlphabets() { + return config.getAlphabets?.() || null; + } + + function getElements() { + return config.getGematriaElements?.() || { + cipherEl: null, + inputEl: null, + resultEl: null, + breakdownEl: null + }; + } + + function getFallbackGematriaDb() { + return { + baseAlphabet: "abcdefghijklmnopqrstuvwxyz", + ciphers: [ + { + id: "simple-ordinal", + name: "Simple Ordinal", + description: "A=1 ... Z=26", + values: Array.from({ length: 26 }, (_, index) => index + 1) + } + ] + }; + } + + function normalizeGematriaText(value) { + return String(value || "") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase(); + } + + function transliterationToBaseLetters(transliteration, baseAlphabet) { + const normalized = normalizeGematriaText(transliteration); + if (!normalized) { + return ""; + } + + const primaryVariant = normalized.split(/[\/,;|]/)[0] || normalized; + const primaryLetters = [...primaryVariant].filter((char) => baseAlphabet.includes(char)); + if (primaryLetters.length) { + return primaryLetters[0]; + } + + const allLetters = [...normalized].filter((char) => baseAlphabet.includes(char)); + return allLetters[0] || ""; + } + + function addScriptCharMapEntry(map, scriptChar, mappedLetters) { + const key = String(scriptChar || "").trim(); + const value = String(mappedLetters || "").trim(); + if (!key || !value) { + return; + } + + map.set(key, value); + } + + function buildGematriaScriptMap(baseAlphabet) { + const map = new Map(); + const alphabets = getAlphabets() || {}; + const hebrewLetters = Array.isArray(alphabets.hebrew) ? alphabets.hebrew : []; + const greekLetters = Array.isArray(alphabets.greek) ? alphabets.greek : []; + + hebrewLetters.forEach((entry) => { + const mapped = transliterationToBaseLetters(entry?.transliteration, baseAlphabet); + addScriptCharMapEntry(map, entry?.char, mapped); + }); + + greekLetters.forEach((entry) => { + const mapped = transliterationToBaseLetters(entry?.transliteration, baseAlphabet); + addScriptCharMapEntry(map, entry?.char, mapped); + addScriptCharMapEntry(map, entry?.charLower, mapped); + addScriptCharMapEntry(map, entry?.charFinal, mapped); + }); + + const hebrewFinalForms = { + ך: "k", + ם: "m", + ן: "n", + ף: "p", + ץ: "t" + }; + + Object.entries(hebrewFinalForms).forEach(([char, mapped]) => { + if (!map.has(char) && baseAlphabet.includes(mapped)) { + addScriptCharMapEntry(map, char, mapped); + } + }); + + if (!map.has("ς") && baseAlphabet.includes("s")) { + addScriptCharMapEntry(map, "ς", "s"); + } + + return map; + } + + function refreshScriptMap(baseAlphabetOverride = "") { + const db = state.db || getFallbackGematriaDb(); + const baseAlphabet = String(baseAlphabetOverride || db.baseAlphabet || "abcdefghijklmnopqrstuvwxyz").toLowerCase(); + state.scriptCharMap = buildGematriaScriptMap(baseAlphabet); + } + + function sanitizeGematriaDb(db) { + const baseAlphabet = String(db?.baseAlphabet || "abcdefghijklmnopqrstuvwxyz").toLowerCase(); + const ciphers = Array.isArray(db?.ciphers) + ? db.ciphers + .map((cipher) => { + const id = String(cipher?.id || "").trim(); + const name = String(cipher?.name || "").trim(); + const values = Array.isArray(cipher?.values) + ? cipher.values.map((value) => Number(value)) + : []; + + if (!id || !name || values.length !== baseAlphabet.length || values.some((value) => !Number.isFinite(value))) { + return null; + } + + return { + id, + name, + description: String(cipher?.description || "").trim(), + values + }; + }) + .filter(Boolean) + : []; + + if (!ciphers.length) { + return getFallbackGematriaDb(); + } + + return { + baseAlphabet, + ciphers + }; + } + + async function loadGematriaDb() { + if (state.db) { + return state.db; + } + + if (state.loadingPromise) { + return state.loadingPromise; + } + + state.loadingPromise = fetch("data/gematria-ciphers.json") + .then((response) => { + if (!response.ok) { + throw new Error(`Failed to load gematria ciphers (${response.status})`); + } + return response.json(); + }) + .then((db) => { + state.db = sanitizeGematriaDb(db); + return state.db; + }) + .catch(() => { + state.db = getFallbackGematriaDb(); + return state.db; + }) + .finally(() => { + state.loadingPromise = null; + }); + + return state.loadingPromise; + } + + function getActiveGematriaCipher() { + const db = state.db || getFallbackGematriaDb(); + const ciphers = Array.isArray(db.ciphers) ? db.ciphers : []; + if (!ciphers.length) { + return null; + } + + const selectedId = state.activeCipherId || ciphers[0].id; + return ciphers.find((cipher) => cipher.id === selectedId) || ciphers[0]; + } + + function renderGematriaCipherOptions() { + const { cipherEl } = getElements(); + if (!cipherEl) { + return; + } + + const db = state.db || getFallbackGematriaDb(); + const ciphers = Array.isArray(db.ciphers) ? db.ciphers : []; + + cipherEl.innerHTML = ""; + ciphers.forEach((cipher) => { + const option = document.createElement("option"); + option.value = cipher.id; + option.textContent = cipher.name; + if (cipher.description) { + option.title = cipher.description; + } + cipherEl.appendChild(option); + }); + + const activeCipher = getActiveGematriaCipher(); + state.activeCipherId = activeCipher?.id || ""; + cipherEl.value = state.activeCipherId; + } + + function computeGematria(text, cipher, baseAlphabet) { + const normalizedInput = normalizeGematriaText(text); + const scriptMap = state.scriptCharMap instanceof Map + ? state.scriptCharMap + : new Map(); + + const letterParts = []; + let total = 0; + let count = 0; + + [...normalizedInput].forEach((char) => { + const mappedLetters = baseAlphabet.includes(char) + ? char + : (scriptMap.get(char) || ""); + + if (!mappedLetters) { + return; + } + + [...mappedLetters].forEach((mappedChar) => { + const index = baseAlphabet.indexOf(mappedChar); + if (index < 0) { + return; + } + + const value = Number(cipher.values[index]); + if (!Number.isFinite(value)) { + return; + } + + count += 1; + total += value; + letterParts.push(`${mappedChar.toUpperCase()}(${value})`); + }); + }); + + return { + total, + count, + breakdown: letterParts.join(" + ") + }; + } + + function renderGematriaResult() { + const { resultEl, breakdownEl } = getElements(); + if (!resultEl || !breakdownEl) { + return; + } + + const db = state.db || getFallbackGematriaDb(); + if (!(state.scriptCharMap instanceof Map) || !state.scriptCharMap.size) { + refreshScriptMap(db.baseAlphabet); + } + + const cipher = getActiveGematriaCipher(); + if (!cipher) { + resultEl.textContent = "Total: --"; + breakdownEl.textContent = "No ciphers available."; + return; + } + + const { total, count, breakdown } = computeGematria(state.inputText, cipher, db.baseAlphabet); + + resultEl.textContent = `Total: ${total}`; + if (!count) { + breakdownEl.textContent = `Using ${cipher.name}. Enter English, Greek, or Hebrew letters to calculate.`; + return; + } + + breakdownEl.textContent = `${cipher.name} · ${count} letters · ${breakdown} = ${total}`; + } + + function bindGematriaListeners() { + const { cipherEl, inputEl } = getElements(); + if (state.listenersBound || !cipherEl || !inputEl) { + return; + } + + cipherEl.addEventListener("change", () => { + state.activeCipherId = String(cipherEl.value || "").trim(); + renderGematriaResult(); + }); + + inputEl.addEventListener("input", () => { + state.inputText = inputEl.value || ""; + renderGematriaResult(); + }); + + state.listenersBound = true; + } + + function ensureCalculator() { + const { cipherEl, inputEl, resultEl, breakdownEl } = getElements(); + if (!cipherEl || !inputEl || !resultEl || !breakdownEl) { + return; + } + + bindGematriaListeners(); + + if (inputEl.value !== state.inputText) { + inputEl.value = state.inputText; + } + + void loadGematriaDb().then(() => { + refreshScriptMap((state.db || getFallbackGematriaDb()).baseAlphabet); + renderGematriaCipherOptions(); + renderGematriaResult(); + }); + } + + function init(nextConfig = {}) { + config = { + ...config, + ...nextConfig + }; + } + + window.AlphabetGematriaUi = { + ...(window.AlphabetGematriaUi || {}), + init, + refreshScriptMap, + ensureCalculator + }; +})(); \ No newline at end of file diff --git a/app/ui-alphabet-references.js b/app/ui-alphabet-references.js new file mode 100644 index 0000000..4111b7f --- /dev/null +++ b/app/ui-alphabet-references.js @@ -0,0 +1,470 @@ +/* ui-alphabet-references.js — Alphabet calendar and cube reference builders */ +(function () { + "use strict"; + + function normalizeId(value) { + return String(value || "").trim().toLowerCase(); + } + + function cap(value) { + return value ? value.charAt(0).toUpperCase() + value.slice(1) : ""; + } + + function buildMonthReferencesByHebrew(referenceData, alphabets) { + const map = new Map(); + const months = Array.isArray(referenceData?.calendarMonths) ? referenceData.calendarMonths : []; + const holidays = Array.isArray(referenceData?.celestialHolidays) ? referenceData.celestialHolidays : []; + const monthById = new Map(months.map((month) => [month.id, month])); + const hebrewLetters = Array.isArray(alphabets?.hebrew) ? alphabets.hebrew : []; + + const profiles = hebrewLetters + .filter((letter) => letter?.hebrewLetterId) + .map((letter) => { + const astrologyType = normalizeId(letter?.astrology?.type); + const astrologyName = normalizeId(letter?.astrology?.name); + return { + hebrewLetterId: normalizeId(letter.hebrewLetterId), + tarotTrumpNumber: Number.isFinite(Number(letter?.tarot?.trumpNumber)) + ? Number(letter.tarot.trumpNumber) + : null, + kabbalahPathNumber: Number.isFinite(Number(letter?.kabbalahPathNumber)) + ? Number(letter.kabbalahPathNumber) + : null, + planetId: astrologyType === "planet" ? astrologyName : "", + zodiacSignId: astrologyType === "zodiac" ? astrologyName : "" + }; + }); + + function parseMonthDayToken(value) { + const text = String(value || "").trim(); + const match = text.match(/^(\d{1,2})-(\d{1,2})$/); + if (!match) { + return null; + } + + const monthNo = Number(match[1]); + const dayNo = Number(match[2]); + if (!Number.isInteger(monthNo) || !Number.isInteger(dayNo) || monthNo < 1 || monthNo > 12 || dayNo < 1 || dayNo > 31) { + return null; + } + + return { month: monthNo, day: dayNo }; + } + + function parseMonthDayTokensFromText(value) { + const text = String(value || ""); + const matches = [...text.matchAll(/(\d{1,2})-(\d{1,2})/g)]; + return matches + .map((match) => ({ month: Number(match[1]), day: Number(match[2]) })) + .filter((token) => Number.isInteger(token.month) && Number.isInteger(token.day) && token.month >= 1 && token.month <= 12 && token.day >= 1 && token.day <= 31); + } + + function toDateToken(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 = toDateToken(startToken, 2025); + const endBase = toDateToken(endToken, 2025); + if (!startDate || !endBase) { + return []; + } + + const wrapsYear = endBase.getTime() < startDate.getTime(); + const endDate = wrapsYear ? toDateToken(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 tokenToString(monthNo, dayNo) { + return `${String(monthNo).padStart(2, "0")}-${String(dayNo).padStart(2, "0")}`; + } + + function formatRangeLabel(monthName, startDay, endDay) { + if (!Number.isFinite(startDay) || !Number.isFinite(endDay)) { + return monthName; + } + if (startDay === endDay) { + return `${monthName} ${startDay}`; + } + return `${monthName} ${startDay}-${endDay}`; + } + + function resolveRangeForMonth(month, options = {}) { + const monthOrder = Number(month?.order); + const monthStart = parseMonthDayToken(month?.start); + const monthEnd = parseMonthDayToken(month?.end); + if (!Number.isFinite(monthOrder) || !monthStart || !monthEnd) { + return { + startToken: String(month?.start || "").trim() || null, + endToken: String(month?.end || "").trim() || null, + label: month?.name || month?.id || "", + isFullMonth: true + }; + } + + let startToken = parseMonthDayToken(options.startToken); + let endToken = parseMonthDayToken(options.endToken); + + if (!startToken || !endToken) { + const tokens = parseMonthDayTokensFromText(options.rawDateText); + if (tokens.length >= 2) { + startToken = tokens[0]; + endToken = tokens[1]; + } else if (tokens.length === 1) { + startToken = tokens[0]; + endToken = tokens[0]; + } + } + + if (!startToken || !endToken) { + startToken = monthStart; + endToken = monthEnd; + } + + const segments = splitMonthDayRangeByMonth(startToken, endToken); + const segment = segments.find((entry) => entry.monthNo === monthOrder) || null; + + const useStart = segment ? { month: monthOrder, day: segment.startDay } : startToken; + const useEnd = segment ? { month: monthOrder, day: segment.endDay } : endToken; + const startText = tokenToString(useStart.month, useStart.day); + const endText = tokenToString(useEnd.month, useEnd.day); + const isFullMonth = startText === month.start && endText === month.end; + + return { + startToken: startText, + endToken: endText, + label: isFullMonth + ? (month.name || month.id) + : formatRangeLabel(month.name || month.id, useStart.day, useEnd.day), + isFullMonth + }; + } + + function pushRef(hebrewLetterId, month, options = {}) { + if (!hebrewLetterId || !month?.id) { + return; + } + + if (!map.has(hebrewLetterId)) { + map.set(hebrewLetterId, []); + } + + const rows = map.get(hebrewLetterId); + const range = resolveRangeForMonth(month, options); + const rowKey = `${month.id}|${range.startToken || ""}|${range.endToken || ""}`; + if (rows.some((entry) => entry.key === rowKey)) { + return; + } + + rows.push({ + id: month.id, + name: month.name || month.id, + order: Number.isFinite(Number(month.order)) ? Number(month.order) : 999, + label: range.label, + startToken: range.startToken, + endToken: range.endToken, + isFullMonth: range.isFullMonth, + key: rowKey + }); + } + + function collectRefs(associations, month, options = {}) { + if (!associations || typeof associations !== "object") { + return; + } + + const assocHebrewId = normalizeId(associations.hebrewLetterId); + const assocTarotTrump = Number.isFinite(Number(associations.tarotTrumpNumber)) + ? Number(associations.tarotTrumpNumber) + : null; + const assocPath = Number.isFinite(Number(associations.kabbalahPathNumber)) + ? Number(associations.kabbalahPathNumber) + : null; + const assocPlanetId = normalizeId(associations.planetId); + const assocSignId = normalizeId(associations.zodiacSignId); + + profiles.forEach((profile) => { + if (!profile.hebrewLetterId) { + return; + } + + const matchesDirect = assocHebrewId && assocHebrewId === profile.hebrewLetterId; + const matchesTarot = assocTarotTrump != null && profile.tarotTrumpNumber === assocTarotTrump; + const matchesPath = assocPath != null && profile.kabbalahPathNumber === assocPath; + const matchesPlanet = profile.planetId && assocPlanetId && profile.planetId === assocPlanetId; + const matchesZodiac = profile.zodiacSignId && assocSignId && profile.zodiacSignId === assocSignId; + + if (matchesDirect || matchesTarot || matchesPath || matchesPlanet || matchesZodiac) { + pushRef(profile.hebrewLetterId, month, options); + } + }); + } + + months.forEach((month) => { + collectRefs(month?.associations, month); + + const events = Array.isArray(month?.events) ? month.events : []; + events.forEach((event) => { + collectRefs(event?.associations, month, { + rawDateText: event?.dateRange || event?.date || "" + }); + }); + }); + + holidays.forEach((holiday) => { + const month = monthById.get(holiday?.monthId); + if (!month) { + return; + } + collectRefs(holiday?.associations, month, { + rawDateText: holiday?.dateRange || holiday?.date || "" + }); + }); + + map.forEach((rows, key) => { + const preciseMonthIds = new Set( + rows + .filter((entry) => !entry.isFullMonth) + .map((entry) => entry.id) + ); + + const filtered = rows.filter((entry) => { + if (!entry.isFullMonth) { + return true; + } + return !preciseMonthIds.has(entry.id); + }); + + filtered.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.label || left.name || "").localeCompare(String(right.label || right.name || "")); + }); + + map.set(key, filtered); + }); + + return map; + } + + function createEmptyCubeRefs() { + return { + hebrewPlacementById: new Map(), + signPlacementById: new Map(), + planetPlacementById: new Map(), + pathPlacementByNo: new Map() + }; + } + + function normalizeLetterId(value) { + const key = normalizeId(value).replace(/[^a-z]/g, ""); + const aliases = { + aleph: "alef", + beth: "bet", + zain: "zayin", + cheth: "het", + chet: "het", + daleth: "dalet", + teth: "tet", + peh: "pe", + tzaddi: "tsadi", + tzadi: "tsadi", + tzade: "tsadi", + tsaddi: "tsadi", + qoph: "qof", + taw: "tav", + tau: "tav" + }; + return aliases[key] || key; + } + + function edgeWalls(edge) { + const explicitWalls = Array.isArray(edge?.walls) + ? edge.walls.map((wallId) => normalizeId(wallId)).filter(Boolean) + : []; + + if (explicitWalls.length >= 2) { + return explicitWalls.slice(0, 2); + } + + return normalizeId(edge?.id) + .split("-") + .map((wallId) => normalizeId(wallId)) + .filter(Boolean) + .slice(0, 2); + } + + function edgeLabel(edge) { + const explicitName = String(edge?.name || "").trim(); + if (explicitName) { + return explicitName; + } + + return edgeWalls(edge) + .map((part) => cap(part)) + .join(" "); + } + + function resolveCubeDirectionLabel(wallId, edge) { + const normalizedWallId = normalizeId(wallId); + const edgeId = normalizeId(edge?.id); + if (!normalizedWallId || !edgeId) { + return ""; + } + + const cubeUi = window.CubeSectionUi; + if (cubeUi && typeof cubeUi.getEdgeDirectionLabelForWall === "function") { + const directionLabel = String(cubeUi.getEdgeDirectionLabelForWall(normalizedWallId, edgeId) || "").trim(); + if (directionLabel) { + return directionLabel; + } + } + + return edgeLabel(edge); + } + + function makeCubePlacement(wall, edge = null) { + const wallId = normalizeId(wall?.id); + const edgeId = normalizeId(edge?.id); + return { + wallId, + edgeId, + wallName: wall?.name || cap(wallId), + edgeName: resolveCubeDirectionLabel(wallId, edge) + }; + } + + function setPlacementIfMissing(map, key, placement) { + if (!key || map.has(key) || !placement?.wallId) { + return; + } + map.set(key, placement); + } + + function buildCubeReferences(magickDataset) { + const refs = createEmptyCubeRefs(); + const cube = magickDataset?.grouped?.kabbalah?.cube || {}; + const walls = Array.isArray(cube?.walls) ? cube.walls : []; + const edges = Array.isArray(cube?.edges) ? cube.edges : []; + const paths = Array.isArray(magickDataset?.grouped?.kabbalah?.["kabbalah-tree"]?.paths) + ? magickDataset.grouped.kabbalah["kabbalah-tree"].paths + : []; + + const wallById = new Map( + walls.map((wall) => [normalizeId(wall?.id), wall]) + ); + const firstEdgeByWallId = new Map(); + + const pathByLetterId = new Map( + paths + .map((path) => [normalizeLetterId(path?.hebrewLetter?.transliteration), path]) + .filter(([letterId]) => Boolean(letterId)) + ); + + edges.forEach((edge) => { + edgeWalls(edge).forEach((wallId) => { + if (!firstEdgeByWallId.has(wallId)) { + firstEdgeByWallId.set(wallId, edge); + } + }); + }); + + walls.forEach((wall) => { + const wallHebrewLetterId = normalizeLetterId(wall?.hebrewLetterId || wall?.associations?.hebrewLetterId); + + let wallPlacement; + if (wallHebrewLetterId) { + wallPlacement = { + wallId: normalizeId(wall?.id), + edgeId: "", + wallName: wall?.name || cap(normalizeId(wall?.id)), + edgeName: "Face" + }; + } else { + const placementEdge = firstEdgeByWallId.get(normalizeId(wall?.id)) || null; + wallPlacement = makeCubePlacement(wall, placementEdge); + } + + setPlacementIfMissing(refs.hebrewPlacementById, wallHebrewLetterId, wallPlacement); + + const wallPath = pathByLetterId.get(wallHebrewLetterId) || null; + const wallSignId = normalizeId(wallPath?.astrology?.type) === "zodiac" + ? normalizeId(wallPath?.astrology?.name) + : ""; + setPlacementIfMissing(refs.signPlacementById, wallSignId, wallPlacement); + + const wallPathNo = Number(wallPath?.pathNumber); + if (Number.isFinite(wallPathNo)) { + setPlacementIfMissing(refs.pathPlacementByNo, wallPathNo, wallPlacement); + } + + const wallPlanet = normalizeId(wall?.associations?.planetId); + if (wallPlanet) { + setPlacementIfMissing(refs.planetPlacementById, wallPlanet, wallPlacement); + } + }); + + edges.forEach((edge) => { + const wallsForEdge = edgeWalls(edge); + const primaryWallId = wallsForEdge[0]; + const primaryWall = wallById.get(primaryWallId) || { + id: primaryWallId, + name: cap(primaryWallId) + }; + + const placement = makeCubePlacement(primaryWall, edge); + const hebrewLetterId = normalizeLetterId(edge?.hebrewLetterId || edge?.associations?.hebrewLetterId); + setPlacementIfMissing(refs.hebrewPlacementById, hebrewLetterId, placement); + + const path = pathByLetterId.get(hebrewLetterId) || null; + const signId = normalizeId(path?.astrology?.type) === "zodiac" + ? normalizeId(path?.astrology?.name) + : ""; + setPlacementIfMissing(refs.signPlacementById, signId, placement); + + const pathNo = Number(path?.pathNumber); + if (Number.isFinite(pathNo)) { + setPlacementIfMissing(refs.pathPlacementByNo, pathNo, placement); + } + }); + + return refs; + } + + window.AlphabetReferenceBuilders = { + buildMonthReferencesByHebrew, + buildCubeReferences + }; +})(); \ No newline at end of file diff --git a/app/ui-alphabet.js b/app/ui-alphabet.js index f9a9253..8138082 100644 --- a/app/ui-alphabet.js +++ b/app/ui-alphabet.js @@ -2,6 +2,8 @@ (function () { "use strict"; + const alphabetGematriaUi = window.AlphabetGematriaUi || {}; + const state = { initialized: false, alphabets: null, @@ -13,22 +15,16 @@ }, fourWorldLayers: [], monthRefsByHebrewId: new Map(), + const alphabetReferenceBuilders = window.AlphabetReferenceBuilders || {}; cubeRefs: { hebrewPlacementById: new Map(), signPlacementById: new Map(), planetPlacementById: new Map(), pathPlacementByNo: new Map() - }, - gematria: { - loadingPromise: null, - db: null, - listenersBound: false, - activeCipherId: "", - inputText: "", - scriptCharMap: new Map() } }; + const alphabetDetailUi = window.AlphabetDetailUi || {}; // ── Arabic display name table ───────────────────────────────────────── const ARABIC_DISPLAY_NAMES = { alif: "Alif", ba: "Ba", jeem: "Jeem", dal: "Dal", ha: "H\u0101", @@ -70,300 +66,22 @@ gematriaBreakdownEl = document.getElementById("alpha-gematria-breakdown"); } - function getFallbackGematriaDb() { + function getGematriaElements() { + getElements(); return { - baseAlphabet: "abcdefghijklmnopqrstuvwxyz", - ciphers: [ - { - id: "simple-ordinal", - name: "Simple Ordinal", - description: "A=1 ... Z=26", - values: Array.from({ length: 26 }, (_, index) => index + 1) - } - ] + cipherEl: gematriaCipherEl, + inputEl: gematriaInputEl, + resultEl: gematriaResultEl, + breakdownEl: gematriaBreakdownEl }; } - function normalizeGematriaText(value) { - return String(value || "") - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .toLowerCase(); - } - - function transliterationToBaseLetters(transliteration, baseAlphabet) { - const normalized = normalizeGematriaText(transliteration); - if (!normalized) { - return ""; - } - - const primaryVariant = normalized.split(/[\/,;|]/)[0] || normalized; - const primaryLetters = [...primaryVariant].filter((char) => baseAlphabet.includes(char)); - if (primaryLetters.length) { - return primaryLetters[0]; - } - - const allLetters = [...normalized].filter((char) => baseAlphabet.includes(char)); - return allLetters[0] || ""; - } - - function addScriptCharMapEntry(map, scriptChar, mappedLetters) { - const key = String(scriptChar || "").trim(); - const value = String(mappedLetters || "").trim(); - if (!key || !value) { - return; - } - map.set(key, value); - } - - function buildGematriaScriptMap(baseAlphabet) { - const map = new Map(); - const hebrewLetters = Array.isArray(state.alphabets?.hebrew) ? state.alphabets.hebrew : []; - const greekLetters = Array.isArray(state.alphabets?.greek) ? state.alphabets.greek : []; - - hebrewLetters.forEach((entry) => { - const mapped = transliterationToBaseLetters(entry?.transliteration, baseAlphabet); - addScriptCharMapEntry(map, entry?.char, mapped); - }); - - greekLetters.forEach((entry) => { - const mapped = transliterationToBaseLetters(entry?.transliteration, baseAlphabet); - addScriptCharMapEntry(map, entry?.char, mapped); - addScriptCharMapEntry(map, entry?.charLower, mapped); - addScriptCharMapEntry(map, entry?.charFinal, mapped); - }); - - const hebrewFinalForms = { - ך: "k", - ם: "m", - ן: "n", - ף: "p", - ץ: "t" - }; - - Object.entries(hebrewFinalForms).forEach(([char, mapped]) => { - if (!map.has(char) && baseAlphabet.includes(mapped)) { - addScriptCharMapEntry(map, char, mapped); - } - }); - - if (!map.has("ς") && baseAlphabet.includes("s")) { - addScriptCharMapEntry(map, "ς", "s"); - } - - return map; - } - - function refreshGematriaScriptMap(baseAlphabet) { - state.gematria.scriptCharMap = buildGematriaScriptMap(baseAlphabet); - } - - function sanitizeGematriaDb(db) { - const baseAlphabet = String(db?.baseAlphabet || "abcdefghijklmnopqrstuvwxyz").toLowerCase(); - const ciphers = Array.isArray(db?.ciphers) - ? db.ciphers - .map((cipher) => { - const id = String(cipher?.id || "").trim(); - const name = String(cipher?.name || "").trim(); - const values = Array.isArray(cipher?.values) - ? cipher.values.map((value) => Number(value)) - : []; - - if (!id || !name || values.length !== baseAlphabet.length || values.some((value) => !Number.isFinite(value))) { - return null; - } - - return { - id, - name, - description: String(cipher?.description || "").trim(), - values - }; - }) - .filter(Boolean) - : []; - - if (!ciphers.length) { - return getFallbackGematriaDb(); - } - - return { - baseAlphabet, - ciphers - }; - } - - async function loadGematriaDb() { - if (state.gematria.db) { - return state.gematria.db; - } - - if (state.gematria.loadingPromise) { - return state.gematria.loadingPromise; - } - - state.gematria.loadingPromise = fetch("data/gematria-ciphers.json") - .then((response) => { - if (!response.ok) { - throw new Error(`Failed to load gematria ciphers (${response.status})`); - } - return response.json(); - }) - .then((db) => { - state.gematria.db = sanitizeGematriaDb(db); - return state.gematria.db; - }) - .catch(() => { - state.gematria.db = getFallbackGematriaDb(); - return state.gematria.db; - }) - .finally(() => { - state.gematria.loadingPromise = null; - }); - - return state.gematria.loadingPromise; - } - - function getActiveGematriaCipher() { - const db = state.gematria.db || getFallbackGematriaDb(); - const ciphers = Array.isArray(db.ciphers) ? db.ciphers : []; - if (!ciphers.length) { - return null; - } - - const selectedId = state.gematria.activeCipherId || ciphers[0].id; - return ciphers.find((cipher) => cipher.id === selectedId) || ciphers[0]; - } - - function renderGematriaCipherOptions() { - if (!gematriaCipherEl) { - return; - } - - const db = state.gematria.db || getFallbackGematriaDb(); - const ciphers = Array.isArray(db.ciphers) ? db.ciphers : []; - - gematriaCipherEl.innerHTML = ""; - ciphers.forEach((cipher) => { - const option = document.createElement("option"); - option.value = cipher.id; - option.textContent = cipher.name; - if (cipher.description) { - option.title = cipher.description; - } - gematriaCipherEl.appendChild(option); - }); - - const activeCipher = getActiveGematriaCipher(); - state.gematria.activeCipherId = activeCipher?.id || ""; - gematriaCipherEl.value = state.gematria.activeCipherId; - } - - function computeGematria(text, cipher, baseAlphabet) { - const normalizedInput = normalizeGematriaText(text); - const scriptMap = state.gematria.scriptCharMap instanceof Map - ? state.gematria.scriptCharMap - : new Map(); - - const letterParts = []; - let total = 0; - let count = 0; - - [...normalizedInput].forEach((char) => { - const mappedLetters = baseAlphabet.includes(char) - ? char - : (scriptMap.get(char) || ""); - - if (!mappedLetters) { - return; - } - - [...mappedLetters].forEach((mappedChar) => { - const index = baseAlphabet.indexOf(mappedChar); - if (index < 0) { - return; - } - - const value = Number(cipher.values[index]); - if (!Number.isFinite(value)) { - return; - } - - count += 1; - total += value; - letterParts.push(`${mappedChar.toUpperCase()}(${value})`); - }); - }); - - return { - total, - count, - breakdown: letterParts.join(" + ") - }; - } - - function renderGematriaResult() { - if (!gematriaResultEl || !gematriaBreakdownEl) { - return; - } - - const db = state.gematria.db || getFallbackGematriaDb(); - if (!(state.gematria.scriptCharMap instanceof Map) || !state.gematria.scriptCharMap.size) { - refreshGematriaScriptMap(db.baseAlphabet); - } - const cipher = getActiveGematriaCipher(); - if (!cipher) { - gematriaResultEl.textContent = "Total: --"; - gematriaBreakdownEl.textContent = "No ciphers available."; - return; - } - - const { total, count, breakdown } = computeGematria(state.gematria.inputText, cipher, db.baseAlphabet); - - gematriaResultEl.textContent = `Total: ${total}`; - if (!count) { - gematriaBreakdownEl.textContent = `Using ${cipher.name}. Enter English, Greek, or Hebrew letters to calculate.`; - return; - } - - gematriaBreakdownEl.textContent = `${cipher.name} · ${count} letters · ${breakdown} = ${total}`; - } - - function bindGematriaListeners() { - if (state.gematria.listenersBound || !gematriaCipherEl || !gematriaInputEl) { - return; - } - - gematriaCipherEl.addEventListener("change", () => { - state.gematria.activeCipherId = String(gematriaCipherEl.value || "").trim(); - renderGematriaResult(); - }); - - gematriaInputEl.addEventListener("input", () => { - state.gematria.inputText = gematriaInputEl.value || ""; - renderGematriaResult(); - }); - - state.gematria.listenersBound = true; - } - function ensureGematriaCalculator() { - getElements(); - if (!gematriaCipherEl || !gematriaInputEl || !gematriaResultEl || !gematriaBreakdownEl) { - return; - } - - bindGematriaListeners(); - - if (gematriaInputEl.value !== state.gematria.inputText) { - gematriaInputEl.value = state.gematria.inputText; - } - - void loadGematriaDb().then(() => { - refreshGematriaScriptMap((state.gematria.db || getFallbackGematriaDb()).baseAlphabet); - renderGematriaCipherOptions(); - renderGematriaResult(); + alphabetGematriaUi.init?.({ + getAlphabets: () => state.alphabets, + getGematriaElements }); + alphabetGematriaUi.ensureCalculator?.(); } // ── Data helpers ───────────────────────────────────────────────────── @@ -688,11 +406,9 @@ detailNameEl.classList.toggle("alpha-detail-glyph--arabic", alphabet === "arabic"); detailNameEl.classList.toggle("alpha-detail-glyph--enochian", alphabet === "enochian"); - if (alphabet === "hebrew") renderHebrewDetail(letter); - else if (alphabet === "greek") renderGreekDetail(letter); - else if (alphabet === "english") renderEnglishDetail(letter); - else if (alphabet === "arabic") renderArabicDetail(letter); - else if (alphabet === "enochian") renderEnochianDetail(letter); + if (typeof alphabetDetailUi.renderDetail === "function") { + alphabetDetailUi.renderDetail(getDetailRenderContext(letter, alphabet)); + } } function card(title, bodyHTML) { @@ -802,272 +518,11 @@ } function buildMonthReferencesByHebrew(referenceData, alphabets) { - const map = new Map(); - const months = Array.isArray(referenceData?.calendarMonths) ? referenceData.calendarMonths : []; - const holidays = Array.isArray(referenceData?.celestialHolidays) ? referenceData.celestialHolidays : []; - const monthById = new Map(months.map((month) => [month.id, month])); - const hebrewLetters = Array.isArray(alphabets?.hebrew) ? alphabets.hebrew : []; - - const profiles = hebrewLetters - .filter((letter) => letter?.hebrewLetterId) - .map((letter) => { - const astrologyType = normalizeId(letter?.astrology?.type); - const astrologyName = normalizeId(letter?.astrology?.name); - return { - hebrewLetterId: normalizeId(letter.hebrewLetterId), - tarotTrumpNumber: Number.isFinite(Number(letter?.tarot?.trumpNumber)) - ? Number(letter.tarot.trumpNumber) - : null, - kabbalahPathNumber: Number.isFinite(Number(letter?.kabbalahPathNumber)) - ? Number(letter.kabbalahPathNumber) - : null, - planetId: astrologyType === "planet" ? astrologyName : "", - zodiacSignId: astrologyType === "zodiac" ? astrologyName : "" - }; - }); - - function parseMonthDayToken(value) { - const text = String(value || "").trim(); - const match = text.match(/^(\d{1,2})-(\d{1,2})$/); - if (!match) { - return null; - } - - const monthNo = Number(match[1]); - const dayNo = Number(match[2]); - if (!Number.isInteger(monthNo) || !Number.isInteger(dayNo) || monthNo < 1 || monthNo > 12 || dayNo < 1 || dayNo > 31) { - return null; - } - - return { month: monthNo, day: dayNo }; + if (typeof alphabetReferenceBuilders.buildMonthReferencesByHebrew !== "function") { + return new Map(); } - function parseMonthDayTokensFromText(value) { - const text = String(value || ""); - const matches = [...text.matchAll(/(\d{1,2})-(\d{1,2})/g)]; - return matches - .map((match) => ({ month: Number(match[1]), day: Number(match[2]) })) - .filter((token) => Number.isInteger(token.month) && Number.isInteger(token.day) && token.month >= 1 && token.month <= 12 && token.day >= 1 && token.day <= 31); - } - - function toDateToken(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 = toDateToken(startToken, 2025); - const endBase = toDateToken(endToken, 2025); - if (!startDate || !endBase) { - return []; - } - - const wrapsYear = endBase.getTime() < startDate.getTime(); - const endDate = wrapsYear ? toDateToken(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 tokenToString(monthNo, dayNo) { - return `${String(monthNo).padStart(2, "0")}-${String(dayNo).padStart(2, "0")}`; - } - - function formatRangeLabel(monthName, startDay, endDay) { - if (!Number.isFinite(startDay) || !Number.isFinite(endDay)) { - return monthName; - } - if (startDay === endDay) { - return `${monthName} ${startDay}`; - } - return `${monthName} ${startDay}-${endDay}`; - } - - function resolveRangeForMonth(month, options = {}) { - const monthOrder = Number(month?.order); - const monthStart = parseMonthDayToken(month?.start); - const monthEnd = parseMonthDayToken(month?.end); - if (!Number.isFinite(monthOrder) || !monthStart || !monthEnd) { - return { - startToken: String(month?.start || "").trim() || null, - endToken: String(month?.end || "").trim() || null, - label: month?.name || month?.id || "", - isFullMonth: true - }; - } - - let startToken = parseMonthDayToken(options.startToken); - let endToken = parseMonthDayToken(options.endToken); - - if (!startToken || !endToken) { - const tokens = parseMonthDayTokensFromText(options.rawDateText); - if (tokens.length >= 2) { - startToken = tokens[0]; - endToken = tokens[1]; - } else if (tokens.length === 1) { - startToken = tokens[0]; - endToken = tokens[0]; - } - } - - if (!startToken || !endToken) { - startToken = monthStart; - endToken = monthEnd; - } - - const segments = splitMonthDayRangeByMonth(startToken, endToken); - const segment = segments.find((entry) => entry.monthNo === monthOrder) || null; - - const useStart = segment ? { month: monthOrder, day: segment.startDay } : startToken; - const useEnd = segment ? { month: monthOrder, day: segment.endDay } : endToken; - const startText = tokenToString(useStart.month, useStart.day); - const endText = tokenToString(useEnd.month, useEnd.day); - const isFullMonth = startText === month.start && endText === month.end; - - return { - startToken: startText, - endToken: endText, - label: isFullMonth - ? (month.name || month.id) - : formatRangeLabel(month.name || month.id, useStart.day, useEnd.day), - isFullMonth - }; - } - - function pushRef(hebrewLetterId, month, options = {}) { - if (!hebrewLetterId || !month?.id) { - return; - } - - if (!map.has(hebrewLetterId)) { - map.set(hebrewLetterId, []); - } - - const rows = map.get(hebrewLetterId); - const range = resolveRangeForMonth(month, options); - const rowKey = `${month.id}|${range.startToken || ""}|${range.endToken || ""}`; - if (rows.some((entry) => entry.key === rowKey)) { - return; - } - - rows.push({ - id: month.id, - name: month.name || month.id, - order: Number.isFinite(Number(month.order)) ? Number(month.order) : 999, - label: range.label, - startToken: range.startToken, - endToken: range.endToken, - isFullMonth: range.isFullMonth, - key: rowKey - }); - } - - function collectRefs(associations, month, options = {}) { - if (!associations || typeof associations !== "object") { - return; - } - - const assocHebrewId = normalizeId(associations.hebrewLetterId); - const assocTarotTrump = Number.isFinite(Number(associations.tarotTrumpNumber)) - ? Number(associations.tarotTrumpNumber) - : null; - const assocPath = Number.isFinite(Number(associations.kabbalahPathNumber)) - ? Number(associations.kabbalahPathNumber) - : null; - const assocPlanetId = normalizeId(associations.planetId); - const assocSignId = normalizeId(associations.zodiacSignId); - - profiles.forEach((profile) => { - if (!profile.hebrewLetterId) { - return; - } - - const matchesDirect = assocHebrewId && assocHebrewId === profile.hebrewLetterId; - const matchesTarot = assocTarotTrump != null && profile.tarotTrumpNumber === assocTarotTrump; - const matchesPath = assocPath != null && profile.kabbalahPathNumber === assocPath; - const matchesPlanet = profile.planetId && assocPlanetId && profile.planetId === assocPlanetId; - const matchesZodiac = profile.zodiacSignId && assocSignId && profile.zodiacSignId === assocSignId; - - if (matchesDirect || matchesTarot || matchesPath || matchesPlanet || matchesZodiac) { - pushRef(profile.hebrewLetterId, month, options); - } - }); - } - - months.forEach((month) => { - collectRefs(month?.associations, month); - - const events = Array.isArray(month?.events) ? month.events : []; - events.forEach((event) => { - collectRefs(event?.associations, month, { - rawDateText: event?.dateRange || event?.date || "" - }); - }); - }); - - holidays.forEach((holiday) => { - const month = monthById.get(holiday?.monthId); - if (!month) { - return; - } - collectRefs(holiday?.associations, month, { - rawDateText: holiday?.dateRange || holiday?.date || "" - }); - }); - - map.forEach((rows, key) => { - const preciseMonthIds = new Set( - rows - .filter((entry) => !entry.isFullMonth) - .map((entry) => entry.id) - ); - - const filtered = rows.filter((entry) => { - if (!entry.isFullMonth) { - return true; - } - return !preciseMonthIds.has(entry.id); - }); - - filtered.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.label || left.name || "").localeCompare(String(right.label || right.name || "")); - }); - - map.set(key, filtered); - }); - - return map; + return alphabetReferenceBuilders.buildMonthReferencesByHebrew(referenceData, alphabets); } function createEmptyCubeRefs() { @@ -1079,192 +534,12 @@ }; } - function normalizeLetterId(value) { - const key = normalizeId(value).replace(/[^a-z]/g, ""); - const aliases = { - aleph: "alef", - beth: "bet", - zain: "zayin", - cheth: "het", - chet: "het", - daleth: "dalet", - teth: "tet", - peh: "pe", - tzaddi: "tsadi", - tzadi: "tsadi", - tzade: "tsadi", - tsaddi: "tsadi", - qoph: "qof", - taw: "tav", - tau: "tav" - }; - return aliases[key] || key; - } - - function edgeWalls(edge) { - const explicitWalls = Array.isArray(edge?.walls) - ? edge.walls.map((wallId) => normalizeId(wallId)).filter(Boolean) - : []; - - if (explicitWalls.length >= 2) { - return explicitWalls.slice(0, 2); - } - - return normalizeId(edge?.id) - .split("-") - .map((wallId) => normalizeId(wallId)) - .filter(Boolean) - .slice(0, 2); - } - - function edgeLabel(edge) { - const explicitName = String(edge?.name || "").trim(); - if (explicitName) { - return explicitName; - } - - return edgeWalls(edge) - .map((part) => cap(part)) - .join(" "); - } - - function resolveCubeDirectionLabel(wallId, edge) { - const normalizedWallId = normalizeId(wallId); - const edgeId = normalizeId(edge?.id); - if (!normalizedWallId || !edgeId) { - return ""; - } - - const cubeUi = window.CubeSectionUi; - if (cubeUi && typeof cubeUi.getEdgeDirectionLabelForWall === "function") { - const directionLabel = String(cubeUi.getEdgeDirectionLabelForWall(normalizedWallId, edgeId) || "").trim(); - if (directionLabel) { - return directionLabel; - } - } - - return edgeLabel(edge); - } - - function makeCubePlacement(wall, edge = null) { - const wallId = normalizeId(wall?.id); - const edgeId = normalizeId(edge?.id); - return { - wallId, - edgeId, - wallName: wall?.name || cap(wallId), - edgeName: resolveCubeDirectionLabel(wallId, edge) - }; - } - - function setPlacementIfMissing(map, key, placement) { - if (!key || map.has(key) || !placement?.wallId) { - return; - } - map.set(key, placement); - } - function buildCubeReferences(magickDataset) { - const refs = createEmptyCubeRefs(); - const cube = magickDataset?.grouped?.kabbalah?.cube || {}; - const walls = Array.isArray(cube?.walls) - ? cube.walls - : []; - const edges = Array.isArray(cube?.edges) - ? cube.edges - : []; - const paths = Array.isArray(magickDataset?.grouped?.kabbalah?.["kabbalah-tree"]?.paths) - ? magickDataset.grouped.kabbalah["kabbalah-tree"].paths - : []; + if (typeof alphabetReferenceBuilders.buildCubeReferences !== "function") { + return createEmptyCubeRefs(); + } - const wallById = new Map( - walls.map((wall) => [normalizeId(wall?.id), wall]) - ); - const firstEdgeByWallId = new Map(); - - const pathByLetterId = new Map( - paths - .map((path) => [normalizeLetterId(path?.hebrewLetter?.transliteration), path]) - .filter(([letterId]) => Boolean(letterId)) - ); - - edges.forEach((edge) => { - edgeWalls(edge).forEach((wallId) => { - if (!firstEdgeByWallId.has(wallId)) { - firstEdgeByWallId.set(wallId, edge); - } - }); - }); - - walls.forEach((wall) => { - // each wall has a "face" letter; when we build a cube reference for that - // letter we want the label to read “Face” rather than arbitrarily using - // the first edge we encounter on that wall. previously we always - // computed `placementEdge` from the first edge, which produced labels - // like “East Wall – North” for the dalet face. instead we create a - // custom placement object for face letters with an empty edge id and a - // fixed edgeName of “Face”. - const wallHebrewLetterId = normalizeLetterId(wall?.hebrewLetterId || wall?.associations?.hebrewLetterId); - - let wallPlacement; - if (wallHebrewLetterId) { - // face letter; label should emphasise the face rather than a direction - wallPlacement = { - wallId: normalizeId(wall?.id), - edgeId: "", - wallName: wall?.name || cap(normalizeId(wall?.id)), - edgeName: "Face" - }; - } else { - // fall back to normal edge-based placement - const placementEdge = firstEdgeByWallId.get(normalizeId(wall?.id)) || null; - wallPlacement = makeCubePlacement(wall, placementEdge); - } - - setPlacementIfMissing(refs.hebrewPlacementById, wallHebrewLetterId, wallPlacement); - - const wallPath = pathByLetterId.get(wallHebrewLetterId) || null; - const wallSignId = normalizeId(wallPath?.astrology?.type) === "zodiac" - ? normalizeId(wallPath?.astrology?.name) - : ""; - setPlacementIfMissing(refs.signPlacementById, wallSignId, wallPlacement); - - const wallPathNo = Number(wallPath?.pathNumber); - if (Number.isFinite(wallPathNo)) { - setPlacementIfMissing(refs.pathPlacementByNo, wallPathNo, wallPlacement); - } - - const wallPlanet = normalizeId(wall?.associations?.planetId); - if (wallPlanet) { - setPlacementIfMissing(refs.planetPlacementById, wallPlanet, wallPlacement); - } - }); - - edges.forEach((edge) => { - const wallsForEdge = edgeWalls(edge); - const primaryWallId = wallsForEdge[0]; - const primaryWall = wallById.get(primaryWallId) || { - id: primaryWallId, - name: cap(primaryWallId) - }; - - const placement = makeCubePlacement(primaryWall, edge); - const hebrewLetterId = normalizeLetterId(edge?.hebrewLetterId || edge?.associations?.hebrewLetterId); - setPlacementIfMissing(refs.hebrewPlacementById, hebrewLetterId, placement); - - const path = pathByLetterId.get(hebrewLetterId) || null; - const signId = normalizeId(path?.astrology?.type) === "zodiac" - ? normalizeId(path?.astrology?.name) - : ""; - setPlacementIfMissing(refs.signPlacementById, signId, placement); - - const pathNo = Number(path?.pathNumber); - if (Number.isFinite(pathNo)) { - setPlacementIfMissing(refs.pathPlacementByNo, pathNo, placement); - } - }); - - return refs; + return alphabetReferenceBuilders.buildCubeReferences(magickDataset); } function getCubePlacementForHebrewLetter(hebrewLetterId, pathNo = null) { @@ -1320,600 +595,36 @@ function cap(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : ""; } - function renderAstrologyCard(astrology) { - if (!astrology) return ""; - const { type, name } = astrology; - const id = (name || "").toLowerCase(); - - if (type === "planet") { - const sym = PLANET_SYMBOLS[id] || ""; - const cubePlacement = getCubePlacementForPlanet(id); - const cubeBtn = cubePlacementBtn(cubePlacement, { "planet-id": id }); - return card("Astrology", ` -
-
Type
Planet
-
Ruler
${sym} ${cap(id)}
-
-
- - ${cubeBtn} -
- `); - } - if (type === "zodiac") { - const sym = ZODIAC_SYMBOLS[id] || ""; - const cubePlacement = getCubePlacementForSign(id); - const cubeBtn = cubePlacementBtn(cubePlacement, { "sign-id": id }); - return card("Astrology", ` -
-
Type
Zodiac Sign
-
Sign
${sym} ${cap(id)}
-
-
- - ${cubeBtn} -
- `); - } - if (type === "element") { - const elemEmoji = { air: "💨", water: "💧", fire: "🔥", earth: "🌍" }; - return card("Astrology", ` -
-
Type
Element
-
Element
${elemEmoji[id] || ""} ${cap(id)}
-
- `); - } - return card("Astrology", ` -
-
Type
${cap(type)}
-
Name
${cap(name)}
-
- `); - } function navBtn(label, event, detail) { const attrs = Object.entries(detail).map(([k, v]) => `data-${k}="${v}"`).join(" "); return ``; } - function computeDigitalRoot(value) { - let current = Math.abs(Math.trunc(Number(value))); - if (!Number.isFinite(current)) { - return null; - } - - while (current >= 10) { - current = String(current) - .split("") - .reduce((sum, digit) => sum + Number(digit), 0); - } - - return current; - } - - function describeDigitalRootReduction(value, digitalRoot) { - const normalized = Math.abs(Math.trunc(Number(value))); - if (!Number.isFinite(normalized) || !Number.isFinite(digitalRoot)) { - return ""; - } - - if (normalized < 10) { - return String(normalized); - } - - return `${String(normalized).split("").join(" + ")} = ${digitalRoot}`; - } - - function renderPositionDigitalRootCard(letter, alphabet, orderLabel) { - const index = Number(letter?.index); - if (!Number.isFinite(index)) { - return ""; - } - - const position = Math.trunc(index); - if (position <= 0) { - return ""; - } - - const digitalRoot = computeDigitalRoot(position); - if (!Number.isFinite(digitalRoot)) { - return ""; - } - - const entries = Array.isArray(state.alphabets?.[alphabet]) ? state.alphabets[alphabet] : []; - const countText = entries.length ? ` of ${entries.length}` : ""; - const orderText = orderLabel ? ` (${orderLabel})` : ""; - const reductionText = describeDigitalRootReduction(position, digitalRoot); - const openNumberBtn = navBtn(`View Number ${digitalRoot}`, "nav:number", { value: digitalRoot }); - - return card("Position Digital Root", ` -
-
Position
#${position}${countText}${orderText}
-
Digital Root
${digitalRoot}${reductionText ? ` (${reductionText})` : ""}
-
-
${openNumberBtn}
- `); - } - - function monthRefsForLetter(letter) { - const hebrewLetterId = normalizeId(letter?.hebrewLetterId); - if (!hebrewLetterId) { - return []; - } - return state.monthRefsByHebrewId.get(hebrewLetterId) || []; - } - - function calendarMonthsCard(monthRefs, titleLabel) { - if (!monthRefs.length) { - return ""; - } - - const monthButtons = monthRefs - .map((month) => navBtn(month.label || month.name, "nav:calendar-month", { "month-id": month.id })) - .join(""); - - return card("Calendar Months", ` -
${titleLabel}
-
${monthButtons}
- `); - } - - function renderHebrewDualityCard(letter) { - const duality = HEBREW_DOUBLE_DUALITY[normalizeId(letter?.hebrewLetterId)]; - if (!duality) { - return ""; - } - - return card("Duality", ` -
-
Polarity
${duality.left} / ${duality.right}
-
- `); - } - - function renderHebrewFourWorldsCard(letter) { - const letterId = normalizeLetterId(letter?.hebrewLetterId || letter?.transliteration || letter?.char); - if (!letterId) { - return ""; - } - - const rows = (Array.isArray(state.fourWorldLayers) ? state.fourWorldLayers : []) - .filter((entry) => entry?.hebrewLetterId === letterId); - - if (!rows.length) { - return ""; - } - - const body = rows.map((entry) => { - const pathBtn = Number.isFinite(Number(entry?.pathNumber)) - ? navBtn(`View Path ${entry.pathNumber}`, "nav:kabbalah-path", { "path-no": Number(entry.pathNumber) }) - : ""; - - return ` -
-
- ${entry.slot}: ${entry.letterChar} — ${entry.world} - ${entry.soulLayer} -
-
${entry.worldLayer}${entry.worldDescription ? ` · ${entry.worldDescription}` : ""}
-
${entry.soulLayer}${entry.soulTitle ? ` — ${entry.soulTitle}` : ""}${entry.soulDescription ? `: ${entry.soulDescription}` : ""}
-
${pathBtn}
-
- `; - }).join(""); - - return card("Qabalistic Worlds & Soul Layers", `
${body}
`); - } - - function normalizeLatinLetter(value) { - return String(value || "") - .trim() - .toUpperCase() - .replace(/[^A-Z]/g, ""); - } - - function extractEnglishLetterRefs(value) { - if (Array.isArray(value)) { - return [...new Set(value.map((entry) => normalizeLatinLetter(entry)).filter(Boolean))]; - } - - return [...new Set( - String(value || "") - .split(/[\s,;|\/]+/) - .map((entry) => normalizeLatinLetter(entry)) - .filter(Boolean) - )]; - } - - function renderAlphabetEquivalentCard(activeAlphabet, letter) { - const hebrewLetters = Array.isArray(state.alphabets?.hebrew) ? state.alphabets.hebrew : []; - const greekLetters = Array.isArray(state.alphabets?.greek) ? state.alphabets.greek : []; - const englishLetters = Array.isArray(state.alphabets?.english) ? state.alphabets.english : []; - const arabicLetters = Array.isArray(state.alphabets?.arabic) ? state.alphabets.arabic : []; - const enochianLetters = Array.isArray(state.alphabets?.enochian) ? state.alphabets.enochian : []; - const linkedHebrewIds = new Set(); - const linkedEnglishLetters = new Set(); - const buttons = []; - - function addHebrewId(value) { - const id = normalizeId(value); - if (id) { - linkedHebrewIds.add(id); - } - } - - function addEnglishLetter(value) { - const code = normalizeLatinLetter(value); - if (!code) { - return; - } - - linkedEnglishLetters.add(code); - englishLetters - .filter((entry) => normalizeLatinLetter(entry?.letter) === code) - .forEach((entry) => addHebrewId(entry?.hebrewLetterId)); - } - - if (activeAlphabet === "hebrew") { - addHebrewId(letter?.hebrewLetterId); - } else if (activeAlphabet === "greek") { - addHebrewId(letter?.hebrewLetterId); - englishLetters - .filter((entry) => normalizeId(entry?.greekEquivalent) === normalizeId(letter?.name)) - .forEach((entry) => addEnglishLetter(entry?.letter)); - } else if (activeAlphabet === "english") { - addEnglishLetter(letter?.letter); - addHebrewId(letter?.hebrewLetterId); - } else if (activeAlphabet === "arabic") { - addHebrewId(letter?.hebrewLetterId); - } else if (activeAlphabet === "enochian") { - extractEnglishLetterRefs(letter?.englishLetters).forEach((code) => addEnglishLetter(code)); - addHebrewId(letter?.hebrewLetterId); - } - - if (!linkedHebrewIds.size && !linkedEnglishLetters.size) { - return ""; - } - - const activeHebrewKey = normalizeId(letter?.hebrewLetterId); - const activeGreekKey = normalizeId(letter?.name); - const activeEnglishKey = normalizeLatinLetter(letter?.letter); - const activeArabicKey = normalizeId(letter?.name); - const activeEnochianKey = normalizeId(letter?.id || letter?.char || letter?.title); - - hebrewLetters.forEach((heb) => { - const key = normalizeId(heb?.hebrewLetterId); - if (!key || !linkedHebrewIds.has(key)) { - return; - } - if (activeAlphabet === "hebrew" && key === activeHebrewKey) { - return; - } - - buttons.push(``); - }); - - greekLetters.forEach((grk) => { - const key = normalizeId(grk?.name); - const viaHebrew = linkedHebrewIds.has(normalizeId(grk?.hebrewLetterId)); - const viaEnglish = englishLetters.some((eng) => ( - linkedEnglishLetters.has(normalizeLatinLetter(eng?.letter)) - && normalizeId(eng?.greekEquivalent) === key - )); - if (!(viaHebrew || viaEnglish)) { - return; - } - if (activeAlphabet === "greek" && key === activeGreekKey) { - return; - } - - buttons.push(``); - }); - - englishLetters.forEach((eng) => { - const key = normalizeLatinLetter(eng?.letter); - const viaLetter = linkedEnglishLetters.has(key); - const viaHebrew = linkedHebrewIds.has(normalizeId(eng?.hebrewLetterId)); - if (!(viaLetter || viaHebrew)) { - return; - } - if (activeAlphabet === "english" && key === activeEnglishKey) { - return; - } - - buttons.push(``); - }); - - arabicLetters.forEach((arb) => { - const key = normalizeId(arb?.name); - if (!linkedHebrewIds.has(normalizeId(arb?.hebrewLetterId))) { - return; - } - if (activeAlphabet === "arabic" && key === activeArabicKey) { - return; - } - - buttons.push(``); - }); - - enochianLetters.forEach((eno) => { - const key = normalizeId(eno?.id || eno?.char || eno?.title); - const englishRefs = extractEnglishLetterRefs(eno?.englishLetters); - const viaHebrew = linkedHebrewIds.has(normalizeId(eno?.hebrewLetterId)); - const viaEnglish = englishRefs.some((code) => linkedEnglishLetters.has(code)); - if (!(viaHebrew || viaEnglish)) { - return; - } - if (activeAlphabet === "enochian" && key === activeEnochianKey) { - return; - } - - buttons.push(``); - }); - - if (!buttons.length) { - return ""; - } - - return card("ALPHABET EQUIVALENT", `
${buttons.join("")}
`); - } - - function renderHebrewDetail(letter) { - detailSubEl.textContent = `${letter.name} — ${letter.transliteration}`; - detailBodyEl.innerHTML = ""; - - const sections = []; - - // Basics - sections.push(card("Letter Details", ` -
-
Character
${letter.char}
-
Name
${letter.name}
-
Transliteration
${letter.transliteration}
-
Meaning
${letter.meaning}
-
Gematria Value
${letter.numerology}
-
Letter Type
${letter.letterType}
-
Position
#${letter.index} of 22
-
- `)); - - const positionRootCard = renderPositionDigitalRootCard(letter, "hebrew"); - if (positionRootCard) { - sections.push(positionRootCard); - } - - if (letter.letterType === "double") { - const dualityCard = renderHebrewDualityCard(letter); - if (dualityCard) { - sections.push(dualityCard); - } - } - - const fourWorldsCard = renderHebrewFourWorldsCard(letter); - if (fourWorldsCard) { - sections.push(fourWorldsCard); - } - - // Astrology - if (letter.astrology) { - sections.push(renderAstrologyCard(letter.astrology)); - } - - // Kabbalah Path + Tarot - if (letter.kabbalahPathNumber) { - const tarotPart = letter.tarot - ? `
Tarot Card
${letter.tarot.card} (Trump ${letter.tarot.trumpNumber})
` - : ""; - const kabBtn = navBtn("View Kabbalah Path", "tarot:view-kab-path", { "path-number": letter.kabbalahPathNumber }); - const tarotBtn = letter.tarot - ? navBtn("View Tarot Card", "kab:view-trump", { "trump-number": letter.tarot.trumpNumber }) - : ""; - const cubePlacement = getCubePlacementForHebrewLetter(letter.hebrewLetterId, letter.kabbalahPathNumber); - const cubeBtn = cubePlacementBtn(cubePlacement, { - "hebrew-letter-id": letter.hebrewLetterId, - "path-no": letter.kabbalahPathNumber - }); - sections.push(card("Kabbalah & Tarot", ` -
-
Path Number
${letter.kabbalahPathNumber}
- ${tarotPart} -
-
${kabBtn}${tarotBtn}${cubeBtn}
- `)); - } - - const monthRefs = monthRefsForLetter(letter); - const monthCard = calendarMonthsCard(monthRefs, `Calendar correspondences linked to ${letter.name}.`); - if (monthCard) { - sections.push(monthCard); - } - - const equivalentsCard = renderAlphabetEquivalentCard("hebrew", letter); - if (equivalentsCard) { - sections.push(equivalentsCard); - } - - detailBodyEl.innerHTML = sections.join(""); - attachDetailListeners(); - } - - function renderGreekDetail(letter) { - const archaicBadge = letter.archaic ? ' archaic' : ""; - detailSubEl.textContent = `${letter.displayName}${letter.archaic ? " (archaic)" : ""} — ${letter.transliteration}`; - detailBodyEl.innerHTML = ""; - - const sections = []; - - const charRow = letter.charFinal - ? `
Form (final)
${letter.charFinal}
` - : ""; - sections.push(card("Letter Details", ` -
-
Uppercase
${letter.char}
-
Lowercase
${letter.charLower || "—"}
- ${charRow} -
Name
${letter.displayName}${archaicBadge}
-
Transliteration
${letter.transliteration}
-
IPA
${letter.ipa || "—"}
-
Isopsephy Value
${letter.numerology}
-
Meaning / Origin
${letter.meaning || "—"}
-
- `)); - - const positionRootCard = renderPositionDigitalRootCard(letter, "greek"); - if (positionRootCard) { - sections.push(positionRootCard); - } - - const equivalentsCard = renderAlphabetEquivalentCard("greek", letter); - if (equivalentsCard) { - sections.push(equivalentsCard); - } - - const monthRefs = monthRefsForLetter(letter); - const monthCard = calendarMonthsCard(monthRefs, `Calendar correspondences inherited via ${letter.displayName}'s Hebrew origin.`); - if (monthCard) { - sections.push(monthCard); - } - - detailBodyEl.innerHTML = sections.join(""); - attachDetailListeners(); - } - - function renderEnglishDetail(letter) { - detailSubEl.textContent = `Letter ${letter.letter} · position #${letter.index}`; - detailBodyEl.innerHTML = ""; - - const sections = []; - - sections.push(card("Letter Details", ` -
-
Letter
${letter.letter}
-
Position
#${letter.index} of 26
-
IPA
${letter.ipa || "—"}
-
Pythagorean Value
${letter.pythagorean}
-
- `)); - - const positionRootCard = renderPositionDigitalRootCard(letter, "english"); - if (positionRootCard) { - sections.push(positionRootCard); - } - - const equivalentsCard = renderAlphabetEquivalentCard("english", letter); - if (equivalentsCard) { - sections.push(equivalentsCard); - } - - const monthRefs = monthRefsForLetter(letter); - const monthCard = calendarMonthsCard(monthRefs, `Calendar correspondences linked through this letter's Hebrew correspondence.`); - if (monthCard) { - sections.push(monthCard); - } - - detailBodyEl.innerHTML = sections.join(""); - attachDetailListeners(); - } - - function renderArabicDetail(letter) { - detailSubEl.textContent = `${arabicDisplayName(letter)} — ${letter.transliteration}`; - detailBodyEl.innerHTML = ""; - - const sections = []; - - // Letter forms row - const f = letter.forms || {}; - const formParts = [ - f.isolated ? `${f.isolated}
isolated
` : "", - f.final ? `${f.final}
final
` : "", - f.medial ? `${f.medial}
medial
` : "", - f.initial ? `${f.initial}
initial
` : "" - ].filter(Boolean); - - sections.push(card("Letter Details", ` -
-
Arabic Name
${letter.nameArabic}
-
Transliteration
${letter.transliteration}
-
IPA
${letter.ipa || "—"}
-
Abjad Value
${letter.abjad}
-
Meaning
${letter.meaning || "—"}
-
Category
${letter.category}
-
Position
#${letter.index} of 28 (Abjad order)
-
- `)); - - const positionRootCard = renderPositionDigitalRootCard(letter, "arabic", "Abjad order"); - if (positionRootCard) { - sections.push(positionRootCard); - } - - if (formParts.length) { - sections.push(card("Letter Forms", `
${formParts.join("")}
`)); - } - - const equivalentsCard = renderAlphabetEquivalentCard("arabic", letter); - if (equivalentsCard) { - sections.push(equivalentsCard); - } - - detailBodyEl.innerHTML = sections.join(""); - attachDetailListeners(); - } - - function renderEnochianDetail(letter) { - const englishRefs = extractEnglishLetterRefs(letter?.englishLetters); - detailSubEl.textContent = `${letter.title} — ${letter.transliteration}`; - detailBodyEl.innerHTML = ""; - - const sections = []; - - sections.push(card("Letter Details", ` -
-
Character
${enochianGlyphImageHtml(letter, "alpha-enochian-glyph-img alpha-enochian-glyph-img--detail-row")}
-
Name
${letter.title}
-
English Letters
${englishRefs.join(" / ") || "—"}
-
Transliteration
${letter.transliteration || "—"}
-
Element / Planet
${letter.elementOrPlanet || "—"}
-
Tarot
${letter.tarot || "—"}
-
Numerology
${letter.numerology || "—"}
-
Glyph Source
Local cache: asset/img/enochian (sourced from dCode set)
-
Position
#${letter.index} of 21
-
- `)); - - const positionRootCard = renderPositionDigitalRootCard(letter, "enochian"); - if (positionRootCard) { - sections.push(positionRootCard); - } - - const equivalentsCard = renderAlphabetEquivalentCard("enochian", letter); - if (equivalentsCard) { - sections.push(equivalentsCard); - } - - const monthRefs = monthRefsForLetter(letter); - const monthCard = calendarMonthsCard(monthRefs, `Calendar correspondences linked through this letter's Hebrew correspondence.`); - if (monthCard) { - sections.push(monthCard); - } - - detailBodyEl.innerHTML = sections.join(""); - attachDetailListeners(); + function getDetailRenderContext(letter, alphabet) { + return { + letter, + alphabet, + detailSubEl, + detailBodyEl, + alphabets: state.alphabets, + fourWorldLayers: state.fourWorldLayers, + monthRefsByHebrewId: state.monthRefsByHebrewId, + PLANET_SYMBOLS, + ZODIAC_SYMBOLS, + HEBREW_DOUBLE_DUALITY, + card, + navBtn, + cap, + normalizeId, + normalizeLetterId, + getCubePlacementForPlanet, + getCubePlacementForSign, + getCubePlacementForHebrewLetter, + cubePlacementBtn, + arabicDisplayName, + enochianGlyphImageHtml, + attachDetailListeners + }; } // ── Event delegation on detail body ────────────────────────────────── @@ -1995,9 +706,7 @@ if (alphabetData) { state.alphabets = alphabetData; - if (state.gematria.db?.baseAlphabet) { - refreshGematriaScriptMap(state.gematria.db.baseAlphabet); - } + alphabetGematriaUi.refreshScriptMap?.(); } state.fourWorldLayers = buildFourWorldLayersFromDataset(magickDataset); diff --git a/app/ui-calendar-dates.js b/app/ui-calendar-dates.js new file mode 100644 index 0000000..e553896 --- /dev/null +++ b/app/ui-calendar-dates.js @@ -0,0 +1,651 @@ +(function () { + "use strict"; + + const HEBREW_MONTH_ALIAS_BY_ID = { + nisan: ["nisan"], + iyar: ["iyar"], + sivan: ["sivan"], + tammuz: ["tamuz", "tammuz"], + av: ["av"], + elul: ["elul"], + tishrei: ["tishri", "tishrei"], + cheshvan: ["heshvan", "cheshvan", "marcheshvan"], + kislev: ["kislev"], + tevet: ["tevet"], + shvat: ["shevat", "shvat"], + adar: ["adar", "adar i", "adar 1"], + "adar-ii": ["adar ii", "adar 2"] + }; + + const MONTH_NAME_TO_INDEX = { + january: 0, + february: 1, + march: 2, + april: 3, + may: 4, + june: 5, + july: 6, + august: 7, + september: 8, + october: 9, + november: 10, + december: 11 + }; + + const GREGORIAN_MONTH_ID_TO_ORDER = { + january: 1, + february: 2, + march: 3, + april: 4, + may: 5, + june: 6, + july: 7, + august: 8, + september: 9, + october: 10, + november: 11, + december: 12 + }; + + let config = {}; + + function getSelectedYear() { + return Number(config.getSelectedYear?.()) || new Date().getFullYear(); + } + + function getSelectedCalendar() { + return String(config.getSelectedCalendar?.() || "gregorian").trim().toLowerCase(); + } + + function getIslamicMonths() { + return config.getIslamicMonths?.() || []; + } + + function parseMonthDayToken(token) { + const [month, day] = String(token || "").split("-").map((part) => Number(part)); + if (!Number.isFinite(month) || !Number.isFinite(day)) { + return null; + } + return { month, day }; + } + + function monthDayDate(monthDay, year) { + const parsed = parseMonthDayToken(monthDay); + if (!parsed) { + return null; + } + return new Date(year, parsed.month - 1, parsed.day); + } + + function buildSignDateBounds(sign) { + const start = monthDayDate(sign?.start, 2025); + const endBase = monthDayDate(sign?.end, 2025); + if (!start || !endBase) { + return null; + } + + const wrapsYear = endBase.getTime() < start.getTime(); + const end = wrapsYear ? monthDayDate(sign?.end, 2026) : endBase; + if (!end) { + return null; + } + + return { start, end }; + } + + function addDays(date, days) { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; + } + + function formatDateLabel(date) { + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } + + function monthDayOrdinal(month, day) { + if (!Number.isFinite(month) || !Number.isFinite(day)) { + return null; + } + const base = new Date(2025, Math.trunc(month) - 1, Math.trunc(day), 12, 0, 0, 0); + if (Number.isNaN(base.getTime())) { + return null; + } + const start = new Date(2025, 0, 1, 12, 0, 0, 0); + const diff = base.getTime() - start.getTime(); + return Math.floor(diff / (24 * 60 * 60 * 1000)) + 1; + } + + function isMonthDayInRange(targetMonth, targetDay, startMonth, startDay, endMonth, endDay) { + const target = monthDayOrdinal(targetMonth, targetDay); + const start = monthDayOrdinal(startMonth, startDay); + const end = monthDayOrdinal(endMonth, endDay); + if (!Number.isFinite(target) || !Number.isFinite(start) || !Number.isFinite(end)) { + return false; + } + + if (end >= start) { + return target >= start && target <= end; + } + return target >= start || target <= end; + } + + function parseMonthDayTokensFromText(value) { + const text = String(value || ""); + const matches = [...text.matchAll(/(\d{2})-(\d{2})/g)]; + return matches + .map((match) => ({ month: Number(match[1]), day: Number(match[2]) })) + .filter((token) => Number.isFinite(token.month) && Number.isFinite(token.day)); + } + + function parseDayRangeFromText(value) { + const text = String(value || ""); + const range = text.match(/\b(\d{1,2})\s*[–-]\s*(\d{1,2})\b/); + if (!range) { + return null; + } + + const startDay = Number(range[1]); + const endDay = Number(range[2]); + if (!Number.isFinite(startDay) || !Number.isFinite(endDay)) { + return null; + } + + return { startDay, endDay }; + } + + function isoToDateAtNoon(iso) { + const text = String(iso || "").trim(); + if (!text) { + return null; + } + const parsed = new Date(`${text}T12:00:00`); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + + function getDaysInMonth(year, monthOrder) { + if (!Number.isFinite(year) || !Number.isFinite(monthOrder)) { + return null; + } + return new Date(year, monthOrder, 0).getDate(); + } + + function getMonthStartWeekday(year, monthOrder) { + const date = new Date(year, monthOrder - 1, 1); + return date.toLocaleDateString(undefined, { weekday: "long" }); + } + + function parseMonthRange(month) { + const startText = String(month?.start || "").trim(); + const endText = String(month?.end || "").trim(); + if (!startText || !endText) { + return "--"; + } + return `${startText} to ${endText}`; + } + + function getGregorianMonthOrderFromId(monthId) { + if (!monthId) { + return null; + } + const key = String(monthId).trim().toLowerCase(); + const value = GREGORIAN_MONTH_ID_TO_ORDER[key]; + return Number.isFinite(value) ? value : null; + } + + function normalizeCalendarText(value) { + return String(value || "") + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/['`´ʻ’]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); + } + + function readNumericPart(parts, partType) { + const raw = parts.find((part) => part.type === partType)?.value; + if (!raw) { + return null; + } + + const digits = String(raw).replace(/[^0-9]/g, ""); + if (!digits) { + return null; + } + + const parsed = Number(digits); + return Number.isFinite(parsed) ? parsed : null; + } + + function formatGregorianReferenceDate(date) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + return "--"; + } + + return date.toLocaleDateString(undefined, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric" + }); + } + + function formatCalendarDateFromGregorian(date, calendarId) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + return "--"; + } + + const locale = calendarId === "hebrew" + ? "en-u-ca-hebrew" + : (calendarId === "islamic" ? "en-u-ca-islamic" : "en"); + + return new Intl.DateTimeFormat(locale, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric" + }).format(date); + } + + function getGregorianMonthStartDate(monthOrder, year = getSelectedYear()) { + if (!Number.isFinite(monthOrder) || !Number.isFinite(year)) { + return null; + } + + return new Date(Math.trunc(year), Math.trunc(monthOrder) - 1, 1, 12, 0, 0, 0); + } + + function getHebrewMonthAliases(month) { + const aliases = []; + const idAliases = HEBREW_MONTH_ALIAS_BY_ID[String(month?.id || "").toLowerCase()] || []; + aliases.push(...idAliases); + + const nameAlias = normalizeCalendarText(month?.name); + if (nameAlias) { + aliases.push(nameAlias); + } + + return Array.from(new Set(aliases.map((alias) => normalizeCalendarText(alias)).filter(Boolean))); + } + + function findHebrewMonthStartInGregorianYear(month, year) { + const aliases = getHebrewMonthAliases(month); + if (!aliases.length || !Number.isFinite(year)) { + return null; + } + + const formatter = new Intl.DateTimeFormat("en-u-ca-hebrew", { + day: "numeric", + month: "long", + year: "numeric" + }); + + const cursor = new Date(Math.trunc(year), 0, 1, 12, 0, 0, 0); + const end = new Date(Math.trunc(year), 11, 31, 12, 0, 0, 0); + + while (cursor.getTime() <= end.getTime()) { + const parts = formatter.formatToParts(cursor); + const day = readNumericPart(parts, "day"); + const monthName = normalizeCalendarText(parts.find((part) => part.type === "month")?.value); + if (day === 1 && aliases.includes(monthName)) { + return new Date(cursor); + } + cursor.setDate(cursor.getDate() + 1); + } + + return null; + } + + function findIslamicMonthStartInGregorianYear(month, year) { + const targetOrder = Number(month?.order); + if (!Number.isFinite(targetOrder) || !Number.isFinite(year)) { + return null; + } + + const formatter = new Intl.DateTimeFormat("en-u-ca-islamic", { + day: "numeric", + month: "numeric", + year: "numeric" + }); + + const cursor = new Date(Math.trunc(year), 0, 1, 12, 0, 0, 0); + const end = new Date(Math.trunc(year), 11, 31, 12, 0, 0, 0); + + while (cursor.getTime() <= end.getTime()) { + const parts = formatter.formatToParts(cursor); + const day = readNumericPart(parts, "day"); + const monthNo = readNumericPart(parts, "month"); + if (day === 1 && monthNo === Math.trunc(targetOrder)) { + return new Date(cursor); + } + cursor.setDate(cursor.getDate() + 1); + } + + return null; + } + + function parseFirstMonthDayFromText(dateText) { + const text = String(dateText || "").replace(/~/g, " "); + const firstSegment = text.split("/")[0] || text; + const match = firstSegment.match(/(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2})/i); + if (!match) { + return null; + } + + const monthIndex = MONTH_NAME_TO_INDEX[String(match[1]).toLowerCase()]; + const day = Number(match[2]); + if (!Number.isFinite(monthIndex) || !Number.isFinite(day)) { + return null; + } + + return { monthIndex, day }; + } + + function parseMonthDayStartToken(token) { + const match = String(token || "").match(/(\d{2})-(\d{2})/); + if (!match) { + return null; + } + + const month = Number(match[1]); + const day = Number(match[2]); + if (!Number.isFinite(month) || !Number.isFinite(day)) { + return null; + } + + return { month, day }; + } + + function createDateAtNoon(year, monthIndex, dayOfMonth) { + return new Date(Math.trunc(year), monthIndex, Math.trunc(dayOfMonth), 12, 0, 0, 0); + } + + function computeWesternEasterDate(year) { + const y = Math.trunc(Number(year)); + if (!Number.isFinite(y)) { + return null; + } + + const a = y % 19; + const b = Math.floor(y / 100); + const c = y % 100; + const d = Math.floor(b / 4); + const e = b % 4; + const f = Math.floor((b + 8) / 25); + const g = Math.floor((b - f + 1) / 3); + const h = (19 * a + b - d - g + 15) % 30; + const i = Math.floor(c / 4); + const k = c % 4; + const l = (32 + 2 * e + 2 * i - h - k) % 7; + const m = Math.floor((a + 11 * h + 22 * l) / 451); + const month = Math.floor((h + l - 7 * m + 114) / 31); + const day = ((h + l - 7 * m + 114) % 31) + 1; + return createDateAtNoon(y, month - 1, day); + } + + function computeNthWeekdayOfMonth(year, monthIndex, weekday, ordinal) { + const y = Math.trunc(Number(year)); + if (!Number.isFinite(y)) { + return null; + } + + const first = createDateAtNoon(y, monthIndex, 1); + const firstWeekday = first.getDay(); + const offset = (weekday - firstWeekday + 7) % 7; + const dayOfMonth = 1 + offset + (Math.trunc(ordinal) - 1) * 7; + const daysInMonth = new Date(y, monthIndex + 1, 0).getDate(); + if (dayOfMonth > daysInMonth) { + return null; + } + return createDateAtNoon(y, monthIndex, dayOfMonth); + } + + function resolveGregorianDateRule(rule, year = getSelectedYear()) { + const key = String(rule || "").trim().toLowerCase(); + if (!key) { + return null; + } + + if (key === "gregorian-easter-sunday") { + return computeWesternEasterDate(year); + } + + if (key === "gregorian-good-friday") { + const easter = computeWesternEasterDate(year); + if (!(easter instanceof Date) || Number.isNaN(easter.getTime())) { + return null; + } + return createDateAtNoon(easter.getFullYear(), easter.getMonth(), easter.getDate() - 2); + } + + if (key === "gregorian-thanksgiving-us") { + return computeNthWeekdayOfMonth(year, 10, 4, 4); + } + + return null; + } + + function findHebrewMonthDayInGregorianYear(monthId, day, year) { + const aliases = HEBREW_MONTH_ALIAS_BY_ID[String(monthId || "").toLowerCase()] || []; + const targetDay = Number(day); + if (!aliases.length || !Number.isFinite(targetDay) || !Number.isFinite(year)) { + return null; + } + + const normalizedAliases = aliases.map((alias) => normalizeCalendarText(alias)).filter(Boolean); + const formatter = new Intl.DateTimeFormat("en-u-ca-hebrew", { + day: "numeric", + month: "long", + year: "numeric" + }); + + const cursor = new Date(Math.trunc(year), 0, 1, 12, 0, 0, 0); + const end = new Date(Math.trunc(year), 11, 31, 12, 0, 0, 0); + + while (cursor.getTime() <= end.getTime()) { + const parts = formatter.formatToParts(cursor); + const currentDay = readNumericPart(parts, "day"); + const monthName = normalizeCalendarText(parts.find((part) => part.type === "month")?.value); + if (currentDay === Math.trunc(targetDay) && normalizedAliases.includes(monthName)) { + return new Date(cursor); + } + cursor.setDate(cursor.getDate() + 1); + } + + return null; + } + + function getIslamicMonthOrderById(monthId) { + const month = getIslamicMonths().find((item) => item?.id === monthId); + const order = Number(month?.order); + return Number.isFinite(order) ? Math.trunc(order) : null; + } + + function findIslamicMonthDayInGregorianYear(monthId, day, year) { + const monthOrder = getIslamicMonthOrderById(monthId); + const targetDay = Number(day); + if (!Number.isFinite(monthOrder) || !Number.isFinite(targetDay) || !Number.isFinite(year)) { + return null; + } + + const formatter = new Intl.DateTimeFormat("en-u-ca-islamic", { + day: "numeric", + month: "numeric", + year: "numeric" + }); + + const cursor = new Date(Math.trunc(year), 0, 1, 12, 0, 0, 0); + const end = new Date(Math.trunc(year), 11, 31, 12, 0, 0, 0); + + while (cursor.getTime() <= end.getTime()) { + const parts = formatter.formatToParts(cursor); + const currentDay = readNumericPart(parts, "day"); + const currentMonth = readNumericPart(parts, "month"); + if (currentDay === Math.trunc(targetDay) && currentMonth === monthOrder) { + return new Date(cursor); + } + cursor.setDate(cursor.getDate() + 1); + } + + return null; + } + + function resolveHolidayGregorianDate(holiday) { + if (!holiday || typeof holiday !== "object") { + return null; + } + + const calendarId = String(holiday.calendarId || "").trim().toLowerCase(); + const monthId = String(holiday.monthId || "").trim().toLowerCase(); + const day = Number(holiday.day); + const selectedYear = getSelectedYear(); + + if (calendarId === "gregorian") { + if (holiday?.dateRule) { + const ruledDate = resolveGregorianDateRule(holiday.dateRule, selectedYear); + if (ruledDate) { + return ruledDate; + } + } + + const monthDay = parseMonthDayStartToken(holiday.monthDayStart) || parseMonthDayStartToken(holiday.dateText); + if (monthDay) { + return new Date(selectedYear, monthDay.month - 1, monthDay.day, 12, 0, 0, 0); + } + const order = getGregorianMonthOrderFromId(monthId); + if (Number.isFinite(order) && Number.isFinite(day)) { + return new Date(selectedYear, order - 1, Math.trunc(day), 12, 0, 0, 0); + } + return null; + } + + if (calendarId === "hebrew") { + return findHebrewMonthDayInGregorianYear(monthId, day, selectedYear); + } + + if (calendarId === "islamic") { + return findIslamicMonthDayInGregorianYear(monthId, day, selectedYear); + } + + if (calendarId === "wheel-of-year") { + const monthDay = parseMonthDayStartToken(holiday.monthDayStart) || parseFirstMonthDayFromText(holiday.dateText); + if (monthDay?.month && monthDay?.day) { + return new Date(selectedYear, monthDay.month - 1, monthDay.day, 12, 0, 0, 0); + } + if (monthDay?.monthIndex != null && monthDay?.day) { + return new Date(selectedYear, monthDay.monthIndex, monthDay.day, 12, 0, 0, 0); + } + } + + return null; + } + + function findWheelMonthStartInGregorianYear(month, year) { + const parsed = parseFirstMonthDayFromText(month?.date); + if (!parsed || !Number.isFinite(year)) { + return null; + } + + return new Date(Math.trunc(year), parsed.monthIndex, parsed.day, 12, 0, 0, 0); + } + + function getGregorianReferenceDateForCalendarMonth(month) { + const calId = getSelectedCalendar(); + const selectedYear = getSelectedYear(); + if (calId === "gregorian") { + return getGregorianMonthStartDate(Number(month?.order), selectedYear); + } + if (calId === "hebrew") { + return findHebrewMonthStartInGregorianYear(month, selectedYear); + } + if (calId === "islamic") { + return findIslamicMonthStartInGregorianYear(month, selectedYear); + } + if (calId === "wheel-of-year") { + return findWheelMonthStartInGregorianYear(month, selectedYear); + } + return null; + } + + function formatIsoDate(date) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + return ""; + } + + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + return `${year}-${month}-${day}`; + } + + function resolveCalendarDayToGregorian(month, dayNumber) { + const calId = getSelectedCalendar(); + const selectedYear = getSelectedYear(); + const day = Math.trunc(Number(dayNumber)); + if (!Number.isFinite(day) || day <= 0) { + return null; + } + + if (calId === "gregorian") { + const monthOrder = Number(month?.order); + if (!Number.isFinite(monthOrder)) { + return null; + } + return new Date(selectedYear, monthOrder - 1, day, 12, 0, 0, 0); + } + + if (calId === "hebrew") { + return findHebrewMonthDayInGregorianYear(month?.id, day, selectedYear); + } + + if (calId === "islamic") { + return findIslamicMonthDayInGregorianYear(month?.id, day, selectedYear); + } + + return null; + } + + function intersectDateRanges(startA, endA, startB, endB) { + const start = startA.getTime() > startB.getTime() ? startA : startB; + const end = endA.getTime() < endB.getTime() ? endA : endB; + return start.getTime() <= end.getTime() ? { start, end } : null; + } + + function init(nextConfig = {}) { + config = { + ...config, + ...nextConfig + }; + } + + window.TarotCalendarDates = { + ...(window.TarotCalendarDates || {}), + init, + parseMonthDayToken, + buildSignDateBounds, + addDays, + formatDateLabel, + isMonthDayInRange, + parseMonthDayTokensFromText, + parseDayRangeFromText, + isoToDateAtNoon, + getDaysInMonth, + getMonthStartWeekday, + parseMonthRange, + normalizeCalendarText, + formatGregorianReferenceDate, + formatCalendarDateFromGregorian, + getGregorianMonthStartDate, + findHebrewMonthStartInGregorianYear, + findIslamicMonthStartInGregorianYear, + parseFirstMonthDayFromText, + parseMonthDayStartToken, + resolveHolidayGregorianDate, + findWheelMonthStartInGregorianYear, + getGregorianReferenceDateForCalendarMonth, + formatIsoDate, + resolveCalendarDayToGregorian, + intersectDateRanges + }; +})(); \ No newline at end of file diff --git a/app/ui-calendar-detail.js b/app/ui-calendar-detail.js new file mode 100644 index 0000000..4dc3450 --- /dev/null +++ b/app/ui-calendar-detail.js @@ -0,0 +1,999 @@ +(function () { + "use strict"; + + const api = { + getState: () => ({}), + getElements: () => ({}), + getSelectedMonth: () => null, + getSelectedDayFilterContext: () => null, + clearSelectedDayFilter: () => {}, + toggleDayFilterEntry: () => {}, + toggleDayRangeFilter: () => {}, + getMonthSubtitle: () => "", + getMonthDayLinkRows: () => [], + buildDecanTarotRowsForMonth: () => [], + buildHolidayList: () => [], + matchesSearch: () => true, + eventSearchText: () => "", + holidaySearchText: () => "", + getDisplayTarotName: (cardName) => cardName || "", + cap: (value) => String(value || "").trim(), + formatGregorianReferenceDate: () => "--", + getDaysInMonth: () => null, + getMonthStartWeekday: () => "--", + getGregorianMonthStartDate: () => null, + formatCalendarDateFromGregorian: () => "--", + parseMonthDayToken: () => null, + parseMonthDayTokensFromText: () => [], + parseMonthDayStartToken: () => null, + parseDayRangeFromText: () => null, + parseMonthRange: () => "", + formatIsoDate: () => "", + resolveHolidayGregorianDate: () => null, + isMonthDayInRange: () => false, + intersectDateRanges: () => null, + getGregorianReferenceDateForCalendarMonth: () => null, + normalizeCalendarText: (value) => String(value || "").trim().toLowerCase(), + findGodIdByName: () => null + }; + + function init(config) { + Object.assign(api, config || {}); + } + + function getState() { + return api.getState?.() || {}; + } + + function planetLabel(planetId) { + if (!planetId) { + return "Planet"; + } + + const planet = getState().planetsById?.get(planetId); + if (!planet) { + return api.cap(planetId); + } + + return `${planet.symbol || ""} ${planet.name || api.cap(planetId)}`.trim(); + } + + function zodiacLabel(signId) { + if (!signId) { + return "Zodiac"; + } + + const sign = getState().signsById?.get(signId); + if (!sign) { + return api.cap(signId); + } + + return `${sign.symbol || ""} ${sign.name || api.cap(signId)}`.trim(); + } + + function godLabel(godId, godName) { + if (godName) { + return godName; + } + + if (!godId) { + return "Deity"; + } + + const god = getState().godsById?.get(godId); + return god?.name || api.cap(godId); + } + + function hebrewLabel(hebrewLetterId) { + if (!hebrewLetterId) { + return "Hebrew Letter"; + } + + const letter = getState().hebrewById?.get(hebrewLetterId); + if (!letter) { + return api.cap(hebrewLetterId); + } + + return `${letter.char || ""} ${letter.name || api.cap(hebrewLetterId)}`.trim(); + } + + function computeDigitalRoot(value) { + let current = Math.abs(Math.trunc(Number(value))); + if (!Number.isFinite(current)) { + return null; + } + + while (current >= 10) { + current = String(current) + .split("") + .reduce((sum, digit) => sum + Number(digit), 0); + } + + return current; + } + + function buildAssociationButtons(associations) { + if (!associations || typeof associations !== "object") { + return '
--
'; + } + + const buttons = []; + + if (associations.planetId) { + buttons.push( + `` + ); + } + + if (associations.zodiacSignId) { + buttons.push( + `` + ); + } + + if (Number.isFinite(Number(associations.numberValue))) { + const rawNumber = Math.trunc(Number(associations.numberValue)); + if (rawNumber >= 0) { + const numberValue = computeDigitalRoot(rawNumber); + if (numberValue != null) { + const label = rawNumber === numberValue + ? `Number ${numberValue}` + : `Number ${numberValue} (from ${rawNumber})`; + buttons.push( + `` + ); + } + } + } + + if (associations.tarotCard) { + const explicitTrumpNumber = Number(associations.tarotTrumpNumber); + const tarotTrumpNumber = Number.isFinite(explicitTrumpNumber) ? explicitTrumpNumber : null; + const tarotLabel = api.getDisplayTarotName(associations.tarotCard, tarotTrumpNumber); + buttons.push( + `` + ); + } + + if (associations.godId || associations.godName) { + const label = godLabel(associations.godId, associations.godName); + buttons.push( + `` + ); + } + + if (associations.hebrewLetterId) { + buttons.push( + `` + ); + } + + if (associations.kabbalahPathNumber != null) { + buttons.push( + `` + ); + } + + if (associations.iChingPlanetaryInfluence) { + buttons.push( + `` + ); + } + + if (!buttons.length) { + return '
--
'; + } + + return `
${buttons.join("")}
`; + } + + function renderFactsCard(month) { + const currentState = getState(); + const monthOrder = Number(month?.order); + const daysInMonth = api.getDaysInMonth(currentState.selectedYear, monthOrder); + const hoursInMonth = Number.isFinite(daysInMonth) ? daysInMonth * 24 : null; + const firstWeekday = Number.isFinite(monthOrder) + ? api.getMonthStartWeekday(currentState.selectedYear, monthOrder) + : "--"; + const gregorianStartDate = api.getGregorianMonthStartDate(monthOrder); + const hebrewStartReference = api.formatCalendarDateFromGregorian(gregorianStartDate, "hebrew"); + const islamicStartReference = api.formatCalendarDateFromGregorian(gregorianStartDate, "islamic"); + + return ` +
+ Month Facts +
+
+
Year
${currentState.selectedYear}
+
Start Date (Gregorian)
${api.formatGregorianReferenceDate(gregorianStartDate)}
+
Days
${daysInMonth ?? "--"}
+
Hours
${hoursInMonth ?? "--"}
+
Starts On
${firstWeekday}
+
Hebrew On 1st
${hebrewStartReference}
+
Islamic On 1st
${islamicStartReference}
+
North Season
${month.seasonNorth || "--"}
+
South Season
${month.seasonSouth || "--"}
+
+
+
+ `; + } + + function renderAssociationsCard(month) { + const monthOrder = Number(month?.order); + const associations = { + ...(month?.associations || {}), + ...(Number.isFinite(monthOrder) ? { numberValue: Math.trunc(monthOrder) } : {}) + }; + + return ` +
+ Associations +
${month.coreTheme || "--"}
+ ${buildAssociationButtons(associations)} +
+ `; + } + + function renderEventsCard(month) { + const currentState = getState(); + const allEvents = Array.isArray(month?.events) ? month.events : []; + if (!allEvents.length) { + return ` +
+ Monthly Events +
No monthly events listed.
+
+ `; + } + + const selectedDay = api.getSelectedDayFilterContext(month); + + function eventMatchesDay(event) { + if (!selectedDay) { + return true; + } + + return selectedDay.entries.some((entry) => { + const targetDate = entry.gregorianDate; + const targetMonth = targetDate?.getMonth() + 1; + const targetDayNo = targetDate?.getDate(); + + const explicitDate = api.parseMonthDayToken(event?.date); + if (explicitDate && Number.isFinite(targetMonth) && Number.isFinite(targetDayNo)) { + return explicitDate.month === targetMonth && explicitDate.day === targetDayNo; + } + + const rangeTokens = api.parseMonthDayTokensFromText(event?.dateRange || event?.dateText || ""); + if (rangeTokens.length >= 2 && Number.isFinite(targetMonth) && Number.isFinite(targetDayNo)) { + const start = rangeTokens[0]; + const end = rangeTokens[1]; + return api.isMonthDayInRange(targetMonth, targetDayNo, start.month, start.day, end.month, end.day); + } + + const dayRange = api.parseDayRangeFromText(event?.date || event?.dateRange || event?.dateText || ""); + if (dayRange) { + return entry.dayNumber >= dayRange.startDay && entry.dayNumber <= dayRange.endDay; + } + + return false; + }); + } + + const dayFiltered = allEvents.filter((event) => eventMatchesDay(event)); + const events = currentState.searchQuery + ? dayFiltered.filter((event) => api.matchesSearch(api.eventSearchText(event))) + : dayFiltered; + + if (!events.length) { + return ` +
+ Monthly Events +
No monthly events match current search.
+
+ `; + } + + const rows = events.map((event) => { + const dateText = event?.date || event?.dateRange || "--"; + return ` +
+
+ ${event?.name || "Untitled"} + ${dateText} +
+
${event?.description || ""}
+ ${buildAssociationButtons(event?.associations)} +
+ `; + }).join(""); + + return ` +
+ Monthly Events +
${rows}
+
+ `; + } + + function renderHolidaysCard(month, title = "Holiday Repository") { + const currentState = getState(); + const allHolidays = api.buildHolidayList(month); + if (!allHolidays.length) { + return ` +
+ ${title} +
No holidays listed in the repository for this month.
+
+ `; + } + + const selectedDay = api.getSelectedDayFilterContext(month); + + function holidayMatchesDay(holiday) { + if (!selectedDay) { + return true; + } + + return selectedDay.entries.some((entry) => { + const targetDate = entry.gregorianDate; + const targetMonth = targetDate?.getMonth() + 1; + const targetDayNo = targetDate?.getDate(); + + const exactResolved = api.resolveHolidayGregorianDate(holiday); + if (exactResolved instanceof Date && !Number.isNaN(exactResolved.getTime()) && targetDate instanceof Date) { + return api.formatIsoDate(exactResolved) === api.formatIsoDate(targetDate); + } + + if (currentState.selectedCalendar === "gregorian" && Number.isFinite(targetMonth) && Number.isFinite(targetDayNo)) { + const tokens = api.parseMonthDayTokensFromText(holiday?.dateText || holiday?.dateRange || ""); + if (tokens.length >= 2) { + const start = tokens[0]; + const end = tokens[1]; + return api.isMonthDayInRange(targetMonth, targetDayNo, start.month, start.day, end.month, end.day); + } + + if (tokens.length === 1) { + const single = tokens[0]; + return single.month === targetMonth && single.day === targetDayNo; + } + + const direct = api.parseMonthDayStartToken(holiday?.monthDayStart || holiday?.dateText || ""); + if (direct) { + return direct.month === targetMonth && direct.day === targetDayNo; + } + + if (Number.isFinite(Number(holiday?.day))) { + return Number(holiday.day) === entry.dayNumber; + } + } + + const localRange = api.parseDayRangeFromText(holiday?.dateText || holiday?.dateRange || ""); + if (localRange) { + return entry.dayNumber >= localRange.startDay && entry.dayNumber <= localRange.endDay; + } + + return false; + }); + } + + const dayFiltered = allHolidays.filter((holiday) => holidayMatchesDay(holiday)); + const holidays = currentState.searchQuery + ? dayFiltered.filter((holiday) => api.matchesSearch(api.holidaySearchText(holiday))) + : dayFiltered; + + if (!holidays.length) { + return ` +
+ ${title} +
No holidays match current filters.
+
+ `; + } + + const rows = holidays.map((holiday) => { + const dateText = holiday?.dateText || holiday?.dateRange || holiday?.date || "--"; + return ` +
+
+ ${holiday?.name || "Untitled"} + ${dateText} +
+
${holiday?.description || holiday?.kind || ""}
+ ${buildAssociationButtons(holiday?.associations)} +
+ `; + }).join(""); + + return ` +
+ ${title} +
${rows}
+
+ `; + } + + function findSignIdByAstrologyName(name) { + const token = api.normalizeCalendarText(name); + if (!token) { + return null; + } + + for (const [signId, sign] of getState().signsById || []) { + const idToken = api.normalizeCalendarText(signId); + const nameToken = api.normalizeCalendarText(sign?.name?.en || sign?.name || ""); + if (token === idToken || token === nameToken) { + return signId; + } + } + + return null; + } + + function buildMajorArcanaRowsForMonth(month) { + const currentState = getState(); + if (currentState.selectedCalendar !== "gregorian") { + return []; + } + + const monthOrder = Number(month?.order); + if (!Number.isFinite(monthOrder)) { + return []; + } + + const monthStart = new Date(currentState.selectedYear, monthOrder - 1, 1, 12, 0, 0, 0); + const monthEnd = new Date(currentState.selectedYear, monthOrder, 0, 12, 0, 0, 0); + const rows = []; + + currentState.hebrewById?.forEach((letter) => { + const astrologyType = api.normalizeCalendarText(letter?.astrology?.type); + if (astrologyType !== "zodiac") { + return; + } + + const signId = findSignIdByAstrologyName(letter?.astrology?.name); + const sign = signId ? currentState.signsById?.get(signId) : null; + if (!sign) { + return; + } + + const startToken = api.parseMonthDayToken(sign?.start); + const endToken = api.parseMonthDayToken(sign?.end); + if (!startToken || !endToken) { + return; + } + + const spanStart = new Date(currentState.selectedYear, startToken.month - 1, startToken.day, 12, 0, 0, 0); + const spanEnd = new Date(currentState.selectedYear, endToken.month - 1, endToken.day, 12, 0, 0, 0); + const wraps = spanEnd.getTime() < spanStart.getTime(); + + const segments = wraps + ? [ + { + start: spanStart, + end: new Date(currentState.selectedYear, 11, 31, 12, 0, 0, 0) + }, + { + start: new Date(currentState.selectedYear, 0, 1, 12, 0, 0, 0), + end: spanEnd + } + ] + : [{ start: spanStart, end: spanEnd }]; + + segments.forEach((segment) => { + const overlap = api.intersectDateRanges(segment.start, segment.end, monthStart, monthEnd); + if (!overlap) { + return; + } + + const rangeStartDay = overlap.start.getDate(); + const rangeEndDay = overlap.end.getDate(); + const cardName = String(letter?.tarot?.card || "").trim(); + const trumpNumber = Number(letter?.tarot?.trumpNumber); + if (!cardName) { + return; + } + + rows.push({ + id: `${signId}-${rangeStartDay}-${rangeEndDay}`, + signId, + signName: sign?.name?.en || sign?.name || signId, + signSymbol: sign?.symbol || "", + cardName, + trumpNumber: Number.isFinite(trumpNumber) ? Math.trunc(trumpNumber) : null, + hebrewLetterId: String(letter?.hebrewLetterId || "").trim(), + hebrewLetterName: String(letter?.name || "").trim(), + hebrewLetterChar: String(letter?.char || "").trim(), + dayStart: rangeStartDay, + dayEnd: rangeEndDay, + rangeLabel: `${month?.name || "Month"} ${rangeStartDay}-${rangeEndDay}` + }); + }); + }); + + rows.sort((left, right) => { + if (left.dayStart !== right.dayStart) { + return left.dayStart - right.dayStart; + } + return left.cardName.localeCompare(right.cardName); + }); + + return rows; + } + + function renderMajorArcanaCard(month) { + const selectedDay = api.getSelectedDayFilterContext(month); + const allRows = buildMajorArcanaRowsForMonth(month); + + const rows = selectedDay + ? allRows.filter((row) => selectedDay.entries.some((entry) => entry.dayNumber >= row.dayStart && entry.dayNumber <= row.dayEnd)) + : allRows; + + if (!rows.length) { + return ` +
+ Major Arcana Windows +
No major arcana windows for this month.
+
+ `; + } + + const list = rows.map((row) => { + const label = row.hebrewLetterId + ? `${row.hebrewLetterChar ? `${row.hebrewLetterChar} ` : ""}${row.hebrewLetterName || row.hebrewLetterId}` + : "--"; + const displayCardName = api.getDisplayTarotName(row.cardName, row.trumpNumber); + + return ` +
+
+ ${displayCardName}${row.trumpNumber != null ? ` · Trump ${row.trumpNumber}` : ""} + ${row.rangeLabel} +
+
${row.signSymbol} ${row.signName} · Hebrew: ${label}
+
+ + + ${row.hebrewLetterId ? `` : ""} +
+
+ `; + }).join(""); + + return ` +
+ Major Arcana Windows +
${list}
+
+ `; + } + + function renderDecanTarotCard(month) { + const selectedDay = api.getSelectedDayFilterContext(month); + const allRows = api.buildDecanTarotRowsForMonth(month); + const rows = selectedDay + ? allRows.filter((row) => selectedDay.entries.some((entry) => { + const targetDate = entry.gregorianDate; + if (!(targetDate instanceof Date) || Number.isNaN(targetDate.getTime())) { + return false; + } + + const targetMonth = targetDate.getMonth() + 1; + const targetDayNo = targetDate.getDate(); + return api.isMonthDayInRange( + targetMonth, + targetDayNo, + row.startMonth, + row.startDay, + row.endMonth, + row.endDay + ); + })) + : allRows; + + if (!rows.length) { + return ` +
+ Decan Tarot Windows +
No decan tarot windows for this month.
+
+ `; + } + + const list = rows.map((row) => { + const displayCardName = api.getDisplayTarotName(row.cardName); + return ` +
+
+ ${row.signSymbol} ${row.signName} · Decan ${row.decanIndex} + ${row.startDegree}°–${row.endDegree}° · ${row.dateRange} +
+
+ +
+
+ `; + }).join(""); + + return ` +
+ Decan Tarot Windows +
${list}
+
+ `; + } + + function renderDayLinksCard(month) { + const rows = api.getMonthDayLinkRows(month); + if (!rows.length) { + return ""; + } + + const selectedContext = api.getSelectedDayFilterContext(month); + const selectedDaySet = selectedContext?.dayNumbers || new Set(); + const selectedDays = selectedContext?.entries?.map((entry) => entry.dayNumber) || []; + const selectedSummary = selectedDays.length ? selectedDays.join(", ") : ""; + + const links = rows.map((row) => { + if (!row.isResolved) { + return `${row.day}`; + } + + const isSelected = selectedDaySet.has(Number(row.day)); + return ``; + }).join(""); + + const clearButton = selectedContext + ? '' + : ""; + + const helperText = selectedContext + ? `
Filtered to days: ${selectedSummary}
` + : ""; + + return ` +
+ Day Links +
Filter this month to events, holidays, and data connected to a specific day.
+ ${helperText} +
${links}
+ ${clearButton ? `
${clearButton}
` : ""} +
+ `; + } + + function renderHebrewMonthDetail(month) { + const currentState = getState(); + const gregorianStartDate = api.getGregorianReferenceDateForCalendarMonth(month); + const factsRows = [ + ["Hebrew Name", month.nativeName || "--"], + ["Month Order", month.leapYearOnly ? `${month.order} (leap year only)` : String(month.order)], + ["Gregorian Reference Year", String(currentState.selectedYear)], + ["Month Start (Gregorian)", api.formatGregorianReferenceDate(gregorianStartDate)], + ["Days", month.daysVariant ? `${month.days}–${month.daysVariant} (varies)` : String(month.days || "--")], + ["Season", month.season || "--"], + ["Zodiac Sign", api.cap(month.zodiacSign) || "--"], + ["Tribe of Israel", month.tribe || "--"], + ["Sense", month.sense || "--"], + ["Hebrew Letter", month.hebrewLetter || "--"] + ].map(([dt, dd]) => `
${dt}
${dd}
`).join(""); + + const monthOrder = Number(month?.order); + const navButtons = buildAssociationButtons({ + ...(month?.associations || {}), + ...(Number.isFinite(monthOrder) ? { numberValue: Math.trunc(monthOrder) } : {}) + }); + const connectionsCard = navButtons + ? `
Connections${navButtons}
` + : ""; + + return ` +
+
+ Month Facts +
+
${factsRows}
+
+
+ ${connectionsCard} +
+ About ${month.name} +
${month.description || "--"}
+
+ ${renderDayLinksCard(month)} + ${renderHolidaysCard(month, "Holiday Repository")} +
+ `; + } + + function renderIslamicMonthDetail(month) { + const currentState = getState(); + const gregorianStartDate = api.getGregorianReferenceDateForCalendarMonth(month); + const factsRows = [ + ["Arabic Name", month.nativeName || "--"], + ["Month Order", String(month.order)], + ["Gregorian Reference Year", String(currentState.selectedYear)], + ["Month Start (Gregorian)", api.formatGregorianReferenceDate(gregorianStartDate)], + ["Meaning", month.meaning || "--"], + ["Days", month.daysVariant ? `${month.days}–${month.daysVariant} (varies)` : String(month.days || "--")], + ["Sacred Month", month.sacred ? "Yes - warfare prohibited" : "No"] + ].map(([dt, dd]) => `
${dt}
${dd}
`).join(""); + + const monthOrder = Number(month?.order); + const hasNumberLink = Number.isFinite(monthOrder) && monthOrder >= 0; + const navButtons = hasNumberLink + ? buildAssociationButtons({ numberValue: Math.trunc(monthOrder) }) + : ""; + const connectionsCard = hasNumberLink + ? `
Connections${navButtons}
` + : ""; + + return ` +
+
+ Month Facts +
+
${factsRows}
+
+
+ ${connectionsCard} +
+ About ${month.name} +
${month.description || "--"}
+
+ ${renderDayLinksCard(month)} + ${renderHolidaysCard(month, "Holiday Repository")} +
+ `; + } + + function buildWheelDeityButtons(deities) { + const buttons = []; + (Array.isArray(deities) ? deities : []).forEach((rawName) => { + const cleanName = String(rawName || "").replace(/\s*\/.*$/, "").replace(/\s*\(.*\)$/, "").trim(); + const godId = api.findGodIdByName(cleanName) || api.findGodIdByName(rawName); + if (!godId) { + return; + } + + const god = getState().godsById?.get(godId); + const label = god?.name || cleanName; + buttons.push(``); + }); + return buttons; + } + + function renderWheelMonthDetail(month) { + const currentState = getState(); + const gregorianStartDate = api.getGregorianReferenceDateForCalendarMonth(month); + const assoc = month?.associations; + const themes = Array.isArray(assoc?.themes) ? assoc.themes.join(", ") : "--"; + const deities = Array.isArray(assoc?.deities) ? assoc.deities.join(", ") : "--"; + const colors = Array.isArray(assoc?.colors) ? assoc.colors.join(", ") : "--"; + const herbs = Array.isArray(assoc?.herbs) ? assoc.herbs.join(", ") : "--"; + + const factsRows = [ + ["Date", month.date || "--"], + ["Type", api.cap(month.type) || "--"], + ["Gregorian Reference Year", String(currentState.selectedYear)], + ["Start (Gregorian)", api.formatGregorianReferenceDate(gregorianStartDate)], + ["Season", month.season || "--"], + ["Element", api.cap(month.element) || "--"], + ["Direction", assoc?.direction || "--"] + ].map(([dt, dd]) => `
${dt}
${dd}
`).join(""); + + const assocRows = [ + ["Themes", themes], + ["Deities", deities], + ["Colors", colors], + ["Herbs", herbs] + ].map(([dt, dd]) => `
${dt}
${dd}
`).join(""); + + const deityButtons = buildWheelDeityButtons(assoc?.deities); + const deityLinksCard = deityButtons.length + ? `
Linked Deities
${deityButtons.join("")}
` + : ""; + + const monthOrder = Number(month?.order); + const hasNumberLink = Number.isFinite(monthOrder) && monthOrder >= 0; + const numberButtons = hasNumberLink + ? buildAssociationButtons({ numberValue: Math.trunc(monthOrder) }) + : ""; + const numberLinksCard = hasNumberLink + ? `
Connections${numberButtons}
` + : ""; + + return ` +
+
+ Sabbat Facts +
+
${factsRows}
+
+
+
+ About ${month.name} +
${month.description || "--"}
+
+
+ Associations +
+
${assocRows}
+
+
+ ${renderDayLinksCard(month)} + ${numberLinksCard} + ${deityLinksCard} + ${renderHolidaysCard(month, "Holiday Repository")} +
+ `; + } + + function attachNavHandlers(detailBodyEl) { + if (!detailBodyEl) { + return; + } + + detailBodyEl.querySelectorAll("[data-nav]").forEach((button) => { + button.addEventListener("click", () => { + const navType = button.dataset.nav; + + if (navType === "planet" && button.dataset.planetId) { + document.dispatchEvent(new CustomEvent("nav:planet", { + detail: { planetId: button.dataset.planetId } + })); + return; + } + + if (navType === "zodiac" && button.dataset.signId) { + document.dispatchEvent(new CustomEvent("nav:zodiac", { + detail: { signId: button.dataset.signId } + })); + return; + } + + if (navType === "number" && button.dataset.numberValue) { + document.dispatchEvent(new CustomEvent("nav:number", { + detail: { value: Number(button.dataset.numberValue) } + })); + return; + } + + if (navType === "tarot-card" && button.dataset.cardName) { + const trumpNumber = Number(button.dataset.trumpNumber); + document.dispatchEvent(new CustomEvent("nav:tarot-trump", { + detail: { + cardName: button.dataset.cardName, + trumpNumber: Number.isFinite(trumpNumber) ? trumpNumber : undefined + } + })); + return; + } + + if (navType === "god") { + document.dispatchEvent(new CustomEvent("nav:gods", { + detail: { + godId: button.dataset.godId || undefined, + godName: button.dataset.godName || undefined + } + })); + return; + } + + if (navType === "alphabet" && button.dataset.hebrewLetterId) { + document.dispatchEvent(new CustomEvent("nav:alphabet", { + detail: { + alphabet: "hebrew", + hebrewLetterId: button.dataset.hebrewLetterId + } + })); + return; + } + + if (navType === "kabbalah" && button.dataset.pathNo) { + document.dispatchEvent(new CustomEvent("nav:kabbalah-path", { + detail: { pathNo: Number(button.dataset.pathNo) } + })); + return; + } + + if (navType === "iching" && button.dataset.planetaryInfluence) { + document.dispatchEvent(new CustomEvent("nav:iching", { + detail: { + planetaryInfluence: button.dataset.planetaryInfluence + } + })); + return; + } + + if (navType === "calendar-month" && button.dataset.monthId) { + document.dispatchEvent(new CustomEvent("nav:calendar-month", { + detail: { + calendarId: button.dataset.calendarId || undefined, + monthId: button.dataset.monthId + } + })); + return; + } + + if (navType === "calendar-day" && button.dataset.dayNumber) { + const month = api.getSelectedMonth(); + const dayNumber = Number(button.dataset.dayNumber); + if (!month || !Number.isFinite(dayNumber)) { + return; + } + + api.toggleDayFilterEntry(month, dayNumber, button.dataset.gregorianDate); + renderDetail(api.getElements()); + return; + } + + if (navType === "calendar-day-range" && button.dataset.rangeStart && button.dataset.rangeEnd) { + const month = api.getSelectedMonth(); + if (!month) { + return; + } + + api.toggleDayRangeFilter(month, Number(button.dataset.rangeStart), Number(button.dataset.rangeEnd)); + renderDetail(api.getElements()); + return; + } + + if (navType === "calendar-day-clear") { + api.clearSelectedDayFilter(); + renderDetail(api.getElements()); + } + }); + }); + } + + function renderDetail(elements) { + const { detailNameEl, detailSubEl, detailBodyEl } = elements || {}; + if (!detailBodyEl || !detailNameEl || !detailSubEl) { + return; + } + + const month = api.getSelectedMonth(); + if (!month) { + detailNameEl.textContent = "--"; + detailSubEl.textContent = "Select a month to explore"; + detailBodyEl.innerHTML = ""; + return; + } + + detailNameEl.textContent = month.name || month.id; + + const currentState = getState(); + if (currentState.selectedCalendar === "gregorian") { + detailSubEl.textContent = `${api.parseMonthRange(month)} · ${month.coreTheme || "Month correspondences"}`; + detailBodyEl.innerHTML = ` +
+ ${renderFactsCard(month)} + ${renderDayLinksCard(month)} + ${renderAssociationsCard(month)} + ${renderMajorArcanaCard(month)} + ${renderDecanTarotCard(month)} + ${renderEventsCard(month)} + ${renderHolidaysCard(month, "Holiday Repository")} +
+ `; + } else if (currentState.selectedCalendar === "hebrew") { + detailSubEl.textContent = api.getMonthSubtitle(month); + detailBodyEl.innerHTML = renderHebrewMonthDetail(month); + } else if (currentState.selectedCalendar === "islamic") { + detailSubEl.textContent = api.getMonthSubtitle(month); + detailBodyEl.innerHTML = renderIslamicMonthDetail(month); + } else { + detailSubEl.textContent = api.getMonthSubtitle(month); + detailBodyEl.innerHTML = renderWheelMonthDetail(month); + } + + attachNavHandlers(detailBodyEl); + } + + window.TarotCalendarDetail = { + init, + renderDetail, + attachNavHandlers + }; +})(); \ No newline at end of file diff --git a/app/ui-calendar-formatting.js b/app/ui-calendar-formatting.js new file mode 100644 index 0000000..5293d11 --- /dev/null +++ b/app/ui-calendar-formatting.js @@ -0,0 +1,314 @@ +(function () { + "use strict"; + + const DEFAULT_WEEKDAY_RULERS = { + 0: { symbol: "☉", name: "Sol" }, + 1: { symbol: "☾", name: "Luna" }, + 2: { symbol: "♂", name: "Mars" }, + 3: { symbol: "☿", name: "Mercury" }, + 4: { symbol: "♃", name: "Jupiter" }, + 5: { symbol: "♀", name: "Venus" }, + 6: { symbol: "♄", name: "Saturn" } + }; + + let config = {}; + + function getCurrentTimeFormat() { + return config.getCurrentTimeFormat?.() || "minutes"; + } + + function getReferenceData() { + return config.getReferenceData?.() || null; + } + + function getWeekdayIndexFromName(weekdayName) { + const normalized = String(weekdayName || "").trim().toLowerCase(); + if (normalized === "sunday") return 0; + if (normalized === "monday") return 1; + if (normalized === "tuesday") return 2; + if (normalized === "wednesday") return 3; + if (normalized === "thursday") return 4; + if (normalized === "friday") return 5; + if (normalized === "saturday") return 6; + return null; + } + + function buildWeekdayRulerLookup(planets) { + const lookup = { ...DEFAULT_WEEKDAY_RULERS }; + if (!planets || typeof planets !== "object") { + return lookup; + } + + Object.values(planets).forEach((planet) => { + const weekdayIndex = getWeekdayIndexFromName(planet?.weekday); + if (weekdayIndex === null) { + return; + } + + lookup[weekdayIndex] = { + symbol: planet?.symbol || lookup[weekdayIndex].symbol, + name: planet?.name || lookup[weekdayIndex].name + }; + }); + + return lookup; + } + + function normalizeDateLike(value) { + if (value instanceof Date) { + return value; + } + if (value && typeof value.getTime === "function") { + return new Date(value.getTime()); + } + return new Date(value); + } + + function getTimeParts(dateLike) { + const date = normalizeDateLike(dateLike); + const hours = date.getHours(); + const minutes = date.getMinutes(); + return { + hours, + minutes, + totalMinutes: hours * 60 + minutes + }; + } + + function formatHourStyle(dateLike) { + const { totalMinutes } = getTimeParts(dateLike); + return `${Math.floor(totalMinutes / 60)}hr`; + } + + function formatMinuteStyle(dateLike) { + const { totalMinutes } = getTimeParts(dateLike); + return `${totalMinutes}m`; + } + + function formatSecondStyle(dateLike) { + const { totalMinutes } = getTimeParts(dateLike); + const totalSeconds = totalMinutes * 60; + return `${totalSeconds}s`; + } + + function formatCalendarTime(dateLike) { + const currentTimeFormat = getCurrentTimeFormat(); + if (currentTimeFormat === "hours") { + return formatHourStyle(dateLike); + } + if (currentTimeFormat === "seconds") { + return formatSecondStyle(dateLike); + } + return formatMinuteStyle(dateLike); + } + + function formatCalendarTimeFromTemplatePayload(payload) { + const currentTimeFormat = getCurrentTimeFormat(); + if (payload && typeof payload.hour === "number") { + const hours = payload.hour; + const minutes = typeof payload.minutes === "number" ? payload.minutes : 0; + const totalMinutes = hours * 60 + minutes; + + if (currentTimeFormat === "hours") { + return `${Math.floor(totalMinutes / 60)}hr`; + } + + if (currentTimeFormat === "seconds") { + return `${totalMinutes * 60}s`; + } + + return `${totalMinutes}m`; + } + + if (payload && payload.time) { + return formatCalendarTime(payload.time); + } + + if (currentTimeFormat === "hours") { + return "12am"; + } + if (currentTimeFormat === "seconds") { + return "0s"; + } + return "0m"; + } + + function convertAxisTimeToMinutes(text) { + const normalized = String(text || "").trim().toLowerCase(); + if (!normalized) { + return null; + } + + const minuteMatch = normalized.match(/^(\d{1,4})m$/); + if (minuteMatch) { + return `${Number(minuteMatch[1])}m`; + } + + const secondMatch = normalized.match(/^(\d{1,6})s$/); + if (secondMatch) { + return `${Math.floor(Number(secondMatch[1]) / 60)}m`; + } + + const hourMatch = normalized.match(/^(\d{1,2})hr$/); + if (hourMatch) { + return `${Number(hourMatch[1]) * 60}m`; + } + + const ampmMatch = normalized.match(/^(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?\s*(am|pm)$/); + if (ampmMatch) { + let hour = Number(ampmMatch[1]) % 12; + const minutes = Number(ampmMatch[2] || "0"); + const suffix = ampmMatch[4]; + if (suffix === "pm") { + hour += 12; + } + return `${hour * 60 + minutes}m`; + } + + const twentyFourMatch = normalized.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); + if (twentyFourMatch) { + const hour = Number(twentyFourMatch[1]); + const minutes = Number(twentyFourMatch[2]); + return `${hour * 60 + minutes}m`; + } + + return null; + } + + function convertAxisTimeToSeconds(text) { + const minuteLabel = convertAxisTimeToMinutes(text); + if (!minuteLabel) { + return null; + } + + const minutes = Number(minuteLabel.replace("m", "")); + if (Number.isNaN(minutes)) { + return null; + } + + return `${minutes * 60}s`; + } + + function convertAxisTimeToHours(text) { + const minuteLabel = convertAxisTimeToMinutes(text); + if (!minuteLabel) { + return null; + } + + const minutes = Number(minuteLabel.replace("m", "")); + if (Number.isNaN(minutes)) { + return null; + } + + return `${Math.floor(minutes / 60)}hr`; + } + + function forceAxisLabelFormat() { + const labelNodes = document.querySelectorAll( + ".toastui-calendar-timegrid-time-column .toastui-calendar-timegrid-time-label" + ); + const currentTimeFormat = getCurrentTimeFormat(); + + labelNodes.forEach((node) => { + if (!node.dataset.originalLabel) { + node.dataset.originalLabel = node.textContent; + } + + if (currentTimeFormat === "minutes") { + const converted = convertAxisTimeToMinutes(node.dataset.originalLabel); + if (converted) { + node.textContent = converted; + } + } else if (currentTimeFormat === "seconds") { + const converted = convertAxisTimeToSeconds(node.dataset.originalLabel); + if (converted) { + node.textContent = converted; + } + } else if (currentTimeFormat === "hours") { + const converted = convertAxisTimeToHours(node.dataset.originalLabel); + if (converted) { + node.textContent = converted; + } + } else { + node.textContent = node.dataset.originalLabel; + } + }); + } + + function createCalendarTemplates() { + const weekdayRulerLookup = buildWeekdayRulerLookup(getReferenceData()?.planets); + + const getPlateFields = (event) => { + const fromRawSign = event?.raw?.planetSymbol; + const fromRawName = event?.raw?.planetName; + + if (fromRawSign || fromRawName) { + return { + sign: fromRawSign || "", + name: fromRawName || "" + }; + } + + const title = String(event?.title || "").trim(); + const beforeTarot = title.split("·")[0].trim(); + const parts = beforeTarot.split(/\s+/).filter(Boolean); + + if (parts.length >= 2) { + return { + sign: parts[0], + name: parts.slice(1).join(" ") + }; + } + + return { + sign: "", + name: beforeTarot + }; + }; + + const formatEventPlateText = (event) => { + const timeLabel = formatCalendarTime(event.start); + const { sign, name } = getPlateFields(event); + const safeName = name || String(event?.title || "").trim(); + const safeSign = sign || "•"; + return `${timeLabel}\n${safeSign}\n${safeName}`; + }; + + const renderWeekDayHeader = (weekDayNameData) => { + const dateNumber = String(weekDayNameData?.date ?? "").padStart(2, "0"); + const dayLabel = String(weekDayNameData?.dayName || ""); + const ruler = weekdayRulerLookup[weekDayNameData?.day] || { symbol: "•", name: "" }; + + return [ + '
', + `${dateNumber}`, + `${dayLabel}`, + `${ruler.symbol}`, + "
" + ].join(""); + }; + + return { + timegridDisplayPrimaryTime: (props) => formatCalendarTimeFromTemplatePayload(props), + timegridDisplayTime: (props) => formatCalendarTimeFromTemplatePayload(props), + timegridNowIndicatorLabel: (props) => formatCalendarTimeFromTemplatePayload(props), + weekDayName: (weekDayNameData) => renderWeekDayHeader(weekDayNameData), + time: (event) => formatEventPlateText(event) + }; + } + + function init(nextConfig = {}) { + config = { + ...config, + ...nextConfig + }; + } + + window.TarotCalendarFormatting = { + ...(window.TarotCalendarFormatting || {}), + init, + normalizeDateLike, + createCalendarTemplates, + forceAxisLabelFormat + }; +})(); \ No newline at end of file diff --git a/app/ui-calendar-visuals.js b/app/ui-calendar-visuals.js new file mode 100644 index 0000000..a6136d3 --- /dev/null +++ b/app/ui-calendar-visuals.js @@ -0,0 +1,421 @@ +(function () { + "use strict"; + + let config = {}; + let monthStripResizeFrame = null; + let initialized = false; + + function getCalendar() { + return config.calendar || null; + } + + function getMonthStripEl() { + return config.monthStripEl || null; + } + + function getCurrentGeo() { + return config.getCurrentGeo?.() || null; + } + + function getFormattingUi() { + return window.TarotCalendarFormatting || {}; + } + + function normalizeCalendarDateLike(value) { + const formattingUi = getFormattingUi(); + if (typeof formattingUi.normalizeDateLike === "function") { + return formattingUi.normalizeDateLike(value); + } + + if (value instanceof Date) { + return value; + } + + if (value && typeof value.getTime === "function") { + return new Date(value.getTime()); + } + + return new Date(value); + } + + function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); + } + + function lerp(start, end, t) { + return start + (end - start) * t; + } + + function lerpRgb(from, to, t) { + return [ + Math.round(lerp(from[0], to[0], t)), + Math.round(lerp(from[1], to[1], t)), + Math.round(lerp(from[2], to[2], t)) + ]; + } + + function rgbString(rgb) { + return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; + } + + function getActiveGeoForRuler() { + const currentGeo = getCurrentGeo(); + if (currentGeo) { + return currentGeo; + } + + try { + return config.parseGeoInput?.() || null; + } catch { + return null; + } + } + + function buildSunRulerGradient(geo, date) { + if (!window.SunCalc || !geo || !date) { + return null; + } + + const dayStart = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0); + const sampleCount = 48; + const samples = []; + + for (let index = 0; index <= sampleCount; index += 1) { + const sampleDate = new Date(dayStart.getTime() + index * 30 * 60 * 1000); + const position = window.SunCalc.getPosition(sampleDate, geo.latitude, geo.longitude); + const altitudeDeg = (position.altitude * 180) / Math.PI; + samples.push(altitudeDeg); + } + + const maxAltitude = Math.max(...samples); + + const NIGHT = [6, 7, 10]; + const PRE_DAWN = [22, 26, 38]; + const SUN_RED = [176, 45, 36]; + const SUN_ORANGE = [246, 133, 54]; + const SKY_BLUE = [58, 134, 255]; + + const nightFloor = -8; + const twilightEdge = -2; + const redToOrangeEdge = 2; + const orangeToBlueEdge = 8; + const daylightRange = Math.max(1, maxAltitude - orangeToBlueEdge); + + const stops = samples.map((altitudeDeg, index) => { + let color; + + if (altitudeDeg <= nightFloor) { + color = NIGHT; + } else if (altitudeDeg <= twilightEdge) { + const t = clamp((altitudeDeg - nightFloor) / (twilightEdge - nightFloor), 0, 1); + color = lerpRgb(NIGHT, PRE_DAWN, t); + } else if (altitudeDeg <= redToOrangeEdge) { + const t = clamp((altitudeDeg - twilightEdge) / (redToOrangeEdge - twilightEdge), 0, 1); + color = lerpRgb(PRE_DAWN, SUN_RED, t); + } else if (altitudeDeg <= orangeToBlueEdge) { + const t = clamp((altitudeDeg - redToOrangeEdge) / (orangeToBlueEdge - redToOrangeEdge), 0, 1); + color = lerpRgb(SUN_RED, SUN_ORANGE, t); + } else { + const t = clamp((altitudeDeg - orangeToBlueEdge) / daylightRange, 0, 1); + color = lerpRgb(SUN_ORANGE, SKY_BLUE, t); + } + + const pct = ((index / sampleCount) * 100).toFixed(2); + return `${rgbString(color)} ${pct}%`; + }); + + return `linear-gradient(to bottom, ${stops.join(", ")})`; + } + + function applySunRulerGradient(referenceDate = new Date()) { + const geo = getActiveGeoForRuler(); + if (!geo) { + return; + } + + const gradient = buildSunRulerGradient(geo, referenceDate); + if (!gradient) { + return; + } + + const rulerColumns = document.querySelectorAll(".toastui-calendar-timegrid-time-column"); + rulerColumns.forEach((column) => { + column.style.backgroundImage = gradient; + column.style.backgroundRepeat = "no-repeat"; + column.style.backgroundSize = "100% 100%"; + }); + } + + function getMoonPhaseGlyph(phaseName) { + if (phaseName === "New Moon") return "🌑"; + if (phaseName === "Waxing Crescent") return "🌒"; + if (phaseName === "First Quarter") return "🌓"; + if (phaseName === "Waxing Gibbous") return "🌔"; + if (phaseName === "Full Moon") return "🌕"; + if (phaseName === "Waning Gibbous") return "🌖"; + if (phaseName === "Last Quarter") return "🌗"; + return "🌘"; + } + + function applyDynamicNowIndicatorVisual(referenceDate = new Date()) { + const currentGeo = getCurrentGeo(); + if (!currentGeo || !window.SunCalc) { + return; + } + + const labelEl = document.querySelector( + ".toastui-calendar-timegrid-time-column .toastui-calendar-timegrid-current-time" + ); + const markerEl = document.querySelector( + ".toastui-calendar-timegrid .toastui-calendar-timegrid-now-indicator .toastui-calendar-timegrid-now-indicator-marker" + ); + + if (!labelEl || !markerEl) { + return; + } + + const sunPosition = window.SunCalc.getPosition(referenceDate, currentGeo.latitude, currentGeo.longitude); + const sunAltitudeDeg = (sunPosition.altitude * 180) / Math.PI; + const isSunMode = sunAltitudeDeg >= -4; + + let icon = "☀️"; + let visualKey = "sun-0"; + + labelEl.classList.remove("is-sun", "is-moon"); + markerEl.classList.remove("is-sun", "is-moon"); + + if (isSunMode) { + const intensity = clamp((sunAltitudeDeg + 4) / 70, 0, 1); + const intensityPercent = Math.round(intensity * 100); + + icon = "☀️"; + visualKey = `sun-${intensityPercent}`; + + labelEl.classList.add("is-sun"); + markerEl.classList.add("is-sun"); + + labelEl.style.setProperty("--sun-glow-size", `${Math.round(8 + intensity * 16)}px`); + labelEl.style.setProperty("--sun-glow-alpha", (0.35 + intensity * 0.55).toFixed(2)); + markerEl.style.setProperty("--sun-marker-glow-size", `${Math.round(10 + intensity * 24)}px`); + markerEl.style.setProperty("--sun-marker-ray-opacity", (0.45 + intensity * 0.5).toFixed(2)); + + labelEl.title = `Sun altitude ${sunAltitudeDeg.toFixed(1)}°`; + } else { + const moonIllum = window.SunCalc.getMoonIllumination(referenceDate); + const moonPct = Math.round(moonIllum.fraction * 100); + const moonPhaseName = config.getMoonPhaseName?.(moonIllum.phase) || "Waning Crescent"; + + icon = getMoonPhaseGlyph(moonPhaseName); + visualKey = `moon-${moonPct}-${moonPhaseName}`; + + labelEl.classList.add("is-moon"); + markerEl.classList.add("is-moon"); + + labelEl.style.setProperty("--moon-glow-alpha", (0.2 + moonIllum.fraction * 0.45).toFixed(2)); + markerEl.style.setProperty("--moon-glow-alpha", (0.2 + moonIllum.fraction * 0.45).toFixed(2)); + + labelEl.title = `${moonPhaseName} (${moonPct}%)`; + } + + if (labelEl.dataset.celestialKey !== visualKey) { + labelEl.innerHTML = [ + '', + `${icon}`, + "" + ].join(""); + labelEl.dataset.celestialKey = visualKey; + } + } + + function getVisibleWeekDates() { + const calendar = getCalendar(); + if (!calendar || typeof calendar.getDateRangeStart !== "function") { + return []; + } + + const rangeStart = calendar.getDateRangeStart(); + if (!rangeStart) { + return []; + } + + const startDateLike = normalizeCalendarDateLike(rangeStart); + const startDate = new Date( + startDateLike.getFullYear(), + startDateLike.getMonth(), + startDateLike.getDate(), + 0, + 0, + 0, + 0 + ); + + return Array.from({ length: 7 }, (_, dayOffset) => { + const day = new Date(startDate); + day.setDate(startDate.getDate() + dayOffset); + return day; + }); + } + + function buildMonthSpans(days) { + if (!Array.isArray(days) || days.length === 0) { + return []; + } + + const monthFormatter = new Intl.DateTimeFormat(undefined, { + month: "long", + year: "numeric" + }); + + const spans = []; + let currentStart = 1; + let currentMonth = days[0].getMonth(); + let currentYear = days[0].getFullYear(); + + for (let index = 1; index <= days.length; index += 1) { + const day = days[index]; + const monthChanged = !day || day.getMonth() !== currentMonth || day.getFullYear() !== currentYear; + + if (!monthChanged) { + continue; + } + + const spanEnd = index; + spans.push({ + start: currentStart, + end: spanEnd, + label: monthFormatter.format(new Date(currentYear, currentMonth, 1)) + }); + + if (day) { + currentStart = index + 1; + currentMonth = day.getMonth(); + currentYear = day.getFullYear(); + } + } + + return spans; + } + + function syncMonthStripGeometry() { + const monthStripEl = getMonthStripEl(); + if (!monthStripEl) { + return; + } + + const calendarEl = document.getElementById("calendar"); + if (!calendarEl) { + return; + } + + const dayNameItems = calendarEl.querySelectorAll( + ".toastui-calendar-week-view-day-names .toastui-calendar-day-name-item.toastui-calendar-week" + ); + + if (dayNameItems.length < 7) { + monthStripEl.style.paddingLeft = "0"; + monthStripEl.style.paddingRight = "0"; + return; + } + + const calendarRect = calendarEl.getBoundingClientRect(); + const firstRect = dayNameItems[0].getBoundingClientRect(); + const lastRect = dayNameItems[6].getBoundingClientRect(); + + const leftPad = Math.max(0, firstRect.left - calendarRect.left); + const rightPad = Math.max(0, calendarRect.right - lastRect.right); + + monthStripEl.style.paddingLeft = `${leftPad}px`; + monthStripEl.style.paddingRight = `${rightPad}px`; + } + + function updateMonthStrip() { + const monthStripEl = getMonthStripEl(); + if (!monthStripEl) { + return; + } + + const days = getVisibleWeekDates(); + const spans = buildMonthSpans(days); + + monthStripEl.replaceChildren(); + + if (!spans.length) { + return; + } + + const trackEl = document.createElement("div"); + trackEl.className = "month-strip-track"; + + spans.forEach((span) => { + const segmentEl = document.createElement("div"); + segmentEl.className = "month-strip-segment"; + segmentEl.style.gridColumn = `${span.start} / ${span.end + 1}`; + segmentEl.textContent = span.label; + trackEl.appendChild(segmentEl); + }); + + monthStripEl.appendChild(trackEl); + syncMonthStripGeometry(); + } + + function applyTimeFormatTemplates() { + const calendar = getCalendar(); + const formattingUi = getFormattingUi(); + if (!calendar) { + return; + } + + calendar.setOptions({ + template: formattingUi.createCalendarTemplates?.() || {} + }); + calendar.render(); + + requestAnimationFrame(() => { + formattingUi.forceAxisLabelFormat?.(); + applySunRulerGradient(); + applyDynamicNowIndicatorVisual(); + updateMonthStrip(); + requestAnimationFrame(() => { + formattingUi.forceAxisLabelFormat?.(); + applySunRulerGradient(); + applyDynamicNowIndicatorVisual(); + updateMonthStrip(); + }); + }); + } + + function bindWindowResize() { + window.addEventListener("resize", () => { + if (monthStripResizeFrame) { + cancelAnimationFrame(monthStripResizeFrame); + } + monthStripResizeFrame = requestAnimationFrame(() => { + monthStripResizeFrame = null; + updateMonthStrip(); + }); + }); + } + + function init(nextConfig = {}) { + config = { + ...config, + ...nextConfig + }; + + if (initialized) { + return; + } + + initialized = true; + bindWindowResize(); + } + + window.TarotCalendarVisuals = { + ...(window.TarotCalendarVisuals || {}), + init, + applySunRulerGradient, + applyDynamicNowIndicatorVisual, + updateMonthStrip, + applyTimeFormatTemplates + }; +})(); diff --git a/app/ui-calendar.js b/app/ui-calendar.js index 3fc41d2..5e47500 100644 --- a/app/ui-calendar.js +++ b/app/ui-calendar.js @@ -3,6 +3,31 @@ "use strict"; const { getTarotCardDisplayName, getTarotCardSearchAliases } = window.TarotCardImages || {}; + const calendarDatesUi = window.TarotCalendarDates || {}; + const calendarDetailUi = window.TarotCalendarDetail || {}; + const { + addDays, + buildSignDateBounds, + formatCalendarDateFromGregorian, + formatDateLabel, + formatGregorianReferenceDate, + formatIsoDate, + getDaysInMonth, + getGregorianMonthStartDate, + getGregorianReferenceDateForCalendarMonth, + getMonthStartWeekday, + intersectDateRanges, + isMonthDayInRange, + isoToDateAtNoon, + normalizeCalendarText, + parseDayRangeFromText, + parseMonthDayStartToken, + parseMonthDayToken, + parseMonthDayTokensFromText, + parseMonthRange, + resolveCalendarDayToGregorian, + resolveHolidayGregorianDate + } = calendarDatesUi; const state = { initialized: false, @@ -75,87 +100,12 @@ "the world": 21 }; - const MINOR_NUMBER_WORD = { - 1: "Ace", - 2: "Two", - 3: "Three", - 4: "Four", - 5: "Five", - 6: "Six", - 7: "Seven", - 8: "Eight", - 9: "Nine", - 10: "Ten" - }; - - const HEBREW_MONTH_ALIAS_BY_ID = { - nisan: ["nisan"], - iyar: ["iyar"], - sivan: ["sivan"], - tammuz: ["tamuz", "tammuz"], - av: ["av"], - elul: ["elul"], - tishrei: ["tishri", "tishrei"], - cheshvan: ["heshvan", "cheshvan", "marcheshvan"], - kislev: ["kislev"], - tevet: ["tevet"], - shvat: ["shevat", "shvat"], - adar: ["adar", "adar i", "adar 1"], - "adar-ii": ["adar ii", "adar 2"] - }; - - const MONTH_NAME_TO_INDEX = { - january: 0, - february: 1, - march: 2, - april: 3, - may: 4, - june: 5, - july: 6, - august: 7, - september: 8, - october: 9, - november: 10, - december: 11 - }; - - const GREGORIAN_MONTH_ID_TO_ORDER = { - january: 1, - february: 2, - march: 3, - april: 4, - may: 5, - june: 6, - july: 7, - august: 8, - september: 9, - october: 10, - november: 11, - december: 12 - }; - - function getElements() { - return { - monthListEl: document.getElementById("calendar-month-list"), - listTitleEl: document.getElementById("calendar-list-title"), - monthCountEl: document.getElementById("calendar-month-count"), - yearInputEl: document.getElementById("calendar-year-input"), - calendarYearWrapEl: document.getElementById("calendar-year-wrap"), - calendarTypeEl: document.getElementById("calendar-type-select"), - searchInputEl: document.getElementById("calendar-search-input"), - searchClearEl: document.getElementById("calendar-search-clear"), - detailNameEl: document.getElementById("calendar-detail-name"), - detailSubEl: document.getElementById("calendar-detail-sub"), - detailBodyEl: document.getElementById("calendar-detail-body") - }; - } - function normalizeText(value) { return String(value || "").trim(); } function normalizeSearchValue(value) { - return String(value || "").trim().toLowerCase(); + return normalizeText(value).toLowerCase(); } function cap(value) { @@ -164,8 +114,7 @@ } function normalizeTarotName(value) { - return String(value || "") - .trim() + return normalizeText(value) .toLowerCase() .replace(/\s+/g, " "); } @@ -175,13 +124,16 @@ 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; } @@ -201,145 +153,53 @@ return getTarotCardDisplayName(cardName) || cardName; } - function normalizeMinorTarotCardName(cardName) { - const text = String(cardName || "").trim(); - if (!text) { - return ""; - } - - const match = text.match(/^(\d{1,2})\s+of\s+(.+)$/i); - if (!match) { - return text.replace(/\b(pentacles?|coins?)\b/i, "Disks"); - } - - const numeric = Number(match[1]); - const suitRaw = String(match[2] || "").trim(); - const rank = MINOR_NUMBER_WORD[numeric] || String(numeric); - const suit = suitRaw.replace(/\b(pentacles?|coins?)\b/i, "Disks"); - return `${rank} of ${suit}`; - } - - function parseMonthDayToken(token) { - const [month, day] = String(token || "").split("-").map((part) => Number(part)); - if (!Number.isFinite(month) || !Number.isFinite(day)) { - return null; - } - return { month, day }; - } - - function monthDayDate(monthDay, year) { - const parsed = parseMonthDayToken(monthDay); - if (!parsed) { - return null; - } - return new Date(year, parsed.month - 1, parsed.day); - } - - function buildSignDateBounds(sign) { - const start = monthDayDate(sign?.start, 2025); - const endBase = monthDayDate(sign?.end, 2025); - if (!start || !endBase) { - return null; - } - - const wrapsYear = endBase.getTime() < start.getTime(); - const end = wrapsYear ? monthDayDate(sign?.end, 2026) : endBase; - if (!end) { - return null; - } - - return { start, end }; - } - - function addDays(date, days) { - const next = new Date(date); - next.setDate(next.getDate() + days); - return next; - } - - function formatDateLabel(date) { - return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); - } - - function monthDayOrdinal(month, day) { - if (!Number.isFinite(month) || !Number.isFinite(day)) { - return null; - } - const base = new Date(2025, Math.trunc(month) - 1, Math.trunc(day), 12, 0, 0, 0); - if (Number.isNaN(base.getTime())) { - return null; - } - const start = new Date(2025, 0, 1, 12, 0, 0, 0); - const diff = base.getTime() - start.getTime(); - return Math.floor(diff / (24 * 60 * 60 * 1000)) + 1; - } - - function isMonthDayInRange(targetMonth, targetDay, startMonth, startDay, endMonth, endDay) { - const target = monthDayOrdinal(targetMonth, targetDay); - const start = monthDayOrdinal(startMonth, startDay); - const end = monthDayOrdinal(endMonth, endDay); - if (!Number.isFinite(target) || !Number.isFinite(start) || !Number.isFinite(end)) { - return false; - } - - if (end >= start) { - return target >= start && target <= end; - } - return target >= start || target <= end; - } - - function parseMonthDayTokensFromText(value) { - const text = String(value || ""); - const matches = [...text.matchAll(/(\d{2})-(\d{2})/g)]; - return matches - .map((match) => ({ month: Number(match[1]), day: Number(match[2]) })) - .filter((token) => Number.isFinite(token.month) && Number.isFinite(token.day)); - } - - function parseDayRangeFromText(value) { - const text = String(value || ""); - const range = text.match(/\b(\d{1,2})\s*[–-]\s*(\d{1,2})\b/); - if (!range) { - return null; - } - - const startDay = Number(range[1]); - const endDay = Number(range[2]); - if (!Number.isFinite(startDay) || !Number.isFinite(endDay)) { - return null; - } - - return { startDay, endDay }; - } - - function isoToDateAtNoon(iso) { - const text = String(iso || "").trim(); - if (!text) { - return null; - } - const parsed = new Date(`${text}T12:00:00`); - return Number.isNaN(parsed.getTime()) ? null : parsed; + function normalizeMinorTarotCardName(value) { + return normalizeTarotName(value) + .split(" ") + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); } function normalizeDayFilterEntry(dayNumber, gregorianIso) { - const day = Math.trunc(Number(dayNumber)); - if (!Number.isFinite(day) || day <= 0) { - return null; - } - - const iso = String(gregorianIso || "").trim(); - if (!iso) { + const nextDayNumber = Math.trunc(Number(dayNumber)); + const nextIso = normalizeText(gregorianIso); + if (!Number.isFinite(nextDayNumber) || nextDayNumber <= 0 || !nextIso) { return null; } return { - dayNumber: day, - gregorianIso: iso + dayNumber: nextDayNumber, + gregorianIso: nextIso }; } function sortDayFilterEntries(entries) { - return [...entries].sort((left, right) => left.dayNumber - right.dayNumber || left.gregorianIso.localeCompare(right.gregorianIso)); + const deduped = new Map(); + (Array.isArray(entries) ? entries : []).forEach((entry) => { + const normalized = normalizeDayFilterEntry(entry?.dayNumber, entry?.gregorianIso); + if (normalized) { + deduped.set(normalized.dayNumber, normalized); + } + }); + + return [...deduped.values()].sort((left, right) => left.dayNumber - right.dayNumber); + } + + function getElements() { + return { + monthListEl: document.getElementById("calendar-month-list"), + monthCountEl: document.getElementById("calendar-month-count"), + listTitleEl: document.getElementById("calendar-list-title"), + calendarTypeEl: document.getElementById("calendar-type-select"), + calendarYearWrapEl: document.getElementById("calendar-year-wrap"), + yearInputEl: document.getElementById("calendar-year-input"), + searchInputEl: document.getElementById("calendar-search-input"), + searchClearEl: document.getElementById("calendar-search-clear"), + detailNameEl: document.getElementById("calendar-detail-name"), + detailSubEl: document.getElementById("calendar-detail-sub"), + detailBodyEl: document.getElementById("calendar-detail-body") + }; } function ensureDayFilterContext(month) { @@ -371,7 +231,7 @@ return; } - const entries = state.selectedDayEntries; + const entries = [...state.selectedDayEntries]; const existingIndex = entries.findIndex((entry) => entry.dayNumber === next.dayNumber); if (existingIndex >= 0) { entries.splice(existingIndex, 1); @@ -430,7 +290,6 @@ if (state.selectedDayCalendarId !== state.selectedCalendar) { return null; } - if (!Array.isArray(state.selectedDayEntries) || !state.selectedDayEntries.length) { return null; } @@ -575,10 +434,9 @@ } Object.values(planetsObj).forEach((planet) => { - if (!planet?.id) { - return; + if (planet?.id) { + map.set(planet.id, planet); } - map.set(planet.id, planet); }); return map; @@ -591,10 +449,9 @@ } signs.forEach((sign) => { - if (!sign?.id) { - return; + if (sign?.id) { + map.set(sign.id, sign); } - map.set(sign.id, sign); }); return map; @@ -603,28 +460,32 @@ function buildGodsMap(magickDataset) { const gods = magickDataset?.grouped?.gods?.gods; const map = new Map(); - if (!Array.isArray(gods)) { return map; } gods.forEach((god) => { - if (!god?.id) { - return; + if (god?.id) { + map.set(god.id, god); } - map.set(god.id, god); }); return map; } function findGodIdByName(name) { - if (!name || !state.godsById) return null; - const normalized = String(name).trim().toLowerCase().replace(/^the\s+/, ""); - for (const [id, god] of state.godsById) { - const godName = String(god.name || "").trim().toLowerCase().replace(/^the\s+/, ""); - if (godName === normalized || id.toLowerCase() === normalized) return id; + if (!name) { + return null; } + + const normalized = normalizeText(name).toLowerCase().replace(/^the\s+/, ""); + for (const [id, god] of state.godsById) { + const godName = normalizeText(god?.name).toLowerCase().replace(/^the\s+/, ""); + if (godName === normalized || id.toLowerCase() === normalized) { + return id; + } + } + return null; } @@ -636,10 +497,9 @@ } letters.forEach((letter) => { - if (!letter?.hebrewLetterId) { - return; + if (letter?.hebrewLetterId) { + map.set(letter.hebrewLetterId, letter); } - map.set(letter.hebrewLetterId, letter); }); return map; @@ -653,461 +513,18 @@ return state.months.find((month) => month.id === state.selectedMonthId) || null; } - function getDaysInMonth(year, monthOrder) { - if (!Number.isFinite(year) || !Number.isFinite(monthOrder)) { - return null; - } - return new Date(year, monthOrder, 0).getDate(); - } - - function getMonthStartWeekday(year, monthOrder) { - const date = new Date(year, monthOrder - 1, 1); - return date.toLocaleDateString(undefined, { weekday: "long" }); - } - - function parseMonthRange(month) { - const startText = normalizeText(month?.start); - const endText = normalizeText(month?.end); - if (!startText || !endText) { - return "--"; - } - return `${startText} to ${endText}`; - } - - function getGregorianMonthOrderFromId(monthId) { - if (!monthId) { - return null; - } - const key = String(monthId).trim().toLowerCase(); - const value = GREGORIAN_MONTH_ID_TO_ORDER[key]; - return Number.isFinite(value) ? value : null; - } - - function normalizeCalendarText(value) { - return String(value || "") - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/['`´ʻ’]/g, "") - .toLowerCase() - .replace(/[^a-z0-9]+/g, " ") - .trim(); - } - - function readNumericPart(parts, partType) { - const raw = parts.find((part) => part.type === partType)?.value; - if (!raw) { - return null; - } - - const digits = String(raw).replace(/[^0-9]/g, ""); - if (!digits) { - return null; - } - - const parsed = Number(digits); - return Number.isFinite(parsed) ? parsed : null; - } - - function formatGregorianReferenceDate(date) { - if (!(date instanceof Date) || Number.isNaN(date.getTime())) { - return "--"; - } - - return date.toLocaleDateString(undefined, { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric" - }); - } - - function formatCalendarDateFromGregorian(date, calendarId) { - if (!(date instanceof Date) || Number.isNaN(date.getTime())) { - return "--"; - } - - const locale = calendarId === "hebrew" - ? "en-u-ca-hebrew" - : (calendarId === "islamic" ? "en-u-ca-islamic" : "en"); - - return new Intl.DateTimeFormat(locale, { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric" - }).format(date); - } - - function getGregorianMonthStartDate(monthOrder, year = state.selectedYear) { - if (!Number.isFinite(monthOrder) || !Number.isFinite(year)) { - return null; - } - - return new Date(Math.trunc(year), Math.trunc(monthOrder) - 1, 1, 12, 0, 0, 0); - } - - function getHebrewMonthAliases(month) { - const aliases = []; - const idAliases = HEBREW_MONTH_ALIAS_BY_ID[String(month?.id || "").toLowerCase()] || []; - aliases.push(...idAliases); - - const nameAlias = normalizeCalendarText(month?.name); - if (nameAlias) { - aliases.push(nameAlias); - } - - return Array.from(new Set(aliases.map((alias) => normalizeCalendarText(alias)).filter(Boolean))); - } - - function findHebrewMonthStartInGregorianYear(month, year) { - const aliases = getHebrewMonthAliases(month); - if (!aliases.length || !Number.isFinite(year)) { - return null; - } - - const formatter = new Intl.DateTimeFormat("en-u-ca-hebrew", { - day: "numeric", - month: "long", - year: "numeric" - }); - - const cursor = new Date(Math.trunc(year), 0, 1, 12, 0, 0, 0); - const end = new Date(Math.trunc(year), 11, 31, 12, 0, 0, 0); - - while (cursor.getTime() <= end.getTime()) { - const parts = formatter.formatToParts(cursor); - const day = readNumericPart(parts, "day"); - const monthName = normalizeCalendarText(parts.find((part) => part.type === "month")?.value); - if (day === 1 && aliases.includes(monthName)) { - return new Date(cursor); - } - cursor.setDate(cursor.getDate() + 1); - } - - return null; - } - - function findIslamicMonthStartInGregorianYear(month, year) { - const targetOrder = Number(month?.order); - if (!Number.isFinite(targetOrder) || !Number.isFinite(year)) { - return null; - } - - const formatter = new Intl.DateTimeFormat("en-u-ca-islamic", { - day: "numeric", - month: "numeric", - year: "numeric" - }); - - const cursor = new Date(Math.trunc(year), 0, 1, 12, 0, 0, 0); - const end = new Date(Math.trunc(year), 11, 31, 12, 0, 0, 0); - - while (cursor.getTime() <= end.getTime()) { - const parts = formatter.formatToParts(cursor); - const day = readNumericPart(parts, "day"); - const monthNo = readNumericPart(parts, "month"); - if (day === 1 && monthNo === Math.trunc(targetOrder)) { - return new Date(cursor); - } - cursor.setDate(cursor.getDate() + 1); - } - - return null; - } - - function parseFirstMonthDayFromText(dateText) { - const text = String(dateText || "").replace(/~/g, " "); - const firstSegment = text.split("/")[0] || text; - const match = firstSegment.match(/(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2})/i); - if (!match) { - return null; - } - - const monthIndex = MONTH_NAME_TO_INDEX[String(match[1]).toLowerCase()]; - const day = Number(match[2]); - if (!Number.isFinite(monthIndex) || !Number.isFinite(day)) { - return null; - } - - return { monthIndex, day }; - } - - function parseMonthDayStartToken(token) { - const match = String(token || "").match(/(\d{2})-(\d{2})/); - if (!match) { - return null; - } - - const month = Number(match[1]); - const day = Number(match[2]); - if (!Number.isFinite(month) || !Number.isFinite(day)) { - return null; - } - - return { month, day }; - } - - function createDateAtNoon(year, monthIndex, dayOfMonth) { - return new Date(Math.trunc(year), monthIndex, Math.trunc(dayOfMonth), 12, 0, 0, 0); - } - - function computeWesternEasterDate(year) { - const y = Math.trunc(Number(year)); - if (!Number.isFinite(y)) { - return null; - } - - // Meeus/Jones/Butcher Gregorian algorithm. - const a = y % 19; - const b = Math.floor(y / 100); - const c = y % 100; - const d = Math.floor(b / 4); - const e = b % 4; - const f = Math.floor((b + 8) / 25); - const g = Math.floor((b - f + 1) / 3); - const h = (19 * a + b - d - g + 15) % 30; - const i = Math.floor(c / 4); - const k = c % 4; - const l = (32 + 2 * e + 2 * i - h - k) % 7; - const m = Math.floor((a + 11 * h + 22 * l) / 451); - const month = Math.floor((h + l - 7 * m + 114) / 31); - const day = ((h + l - 7 * m + 114) % 31) + 1; - return createDateAtNoon(y, month - 1, day); - } - - function computeNthWeekdayOfMonth(year, monthIndex, weekday, ordinal) { - const y = Math.trunc(Number(year)); - if (!Number.isFinite(y)) { - return null; - } - - const first = createDateAtNoon(y, monthIndex, 1); - const firstWeekday = first.getDay(); - const offset = (weekday - firstWeekday + 7) % 7; - const dayOfMonth = 1 + offset + (Math.trunc(ordinal) - 1) * 7; - const daysInMonth = new Date(y, monthIndex + 1, 0).getDate(); - if (dayOfMonth > daysInMonth) { - return null; - } - return createDateAtNoon(y, monthIndex, dayOfMonth); - } - - function resolveGregorianDateRule(rule) { - const key = String(rule || "").trim().toLowerCase(); - if (!key) { - return null; - } - - if (key === "gregorian-easter-sunday") { - return computeWesternEasterDate(state.selectedYear); - } - - if (key === "gregorian-good-friday") { - const easter = computeWesternEasterDate(state.selectedYear); - if (!(easter instanceof Date) || Number.isNaN(easter.getTime())) { - return null; - } - return createDateAtNoon(easter.getFullYear(), easter.getMonth(), easter.getDate() - 2); - } - - if (key === "gregorian-thanksgiving-us") { - // US Thanksgiving: 4th Thursday of November. - return computeNthWeekdayOfMonth(state.selectedYear, 10, 4, 4); - } - - return null; - } - - function findHebrewMonthDayInGregorianYear(monthId, day, year) { - const aliases = HEBREW_MONTH_ALIAS_BY_ID[String(monthId || "").toLowerCase()] || []; - const targetDay = Number(day); - if (!aliases.length || !Number.isFinite(targetDay) || !Number.isFinite(year)) { - return null; - } - - const normalizedAliases = aliases.map((alias) => normalizeCalendarText(alias)).filter(Boolean); - const formatter = new Intl.DateTimeFormat("en-u-ca-hebrew", { - day: "numeric", - month: "long", - year: "numeric" - }); - - const cursor = new Date(Math.trunc(year), 0, 1, 12, 0, 0, 0); - const end = new Date(Math.trunc(year), 11, 31, 12, 0, 0, 0); - - while (cursor.getTime() <= end.getTime()) { - const parts = formatter.formatToParts(cursor); - const currentDay = readNumericPart(parts, "day"); - const monthName = normalizeCalendarText(parts.find((part) => part.type === "month")?.value); - if (currentDay === Math.trunc(targetDay) && normalizedAliases.includes(monthName)) { - return new Date(cursor); - } - cursor.setDate(cursor.getDate() + 1); - } - - return null; - } - - function getIslamicMonthOrderById(monthId) { - const month = (state.calendarData?.islamic || []).find((item) => item?.id === monthId); - const order = Number(month?.order); - return Number.isFinite(order) ? Math.trunc(order) : null; - } - - function findIslamicMonthDayInGregorianYear(monthId, day, year) { - const monthOrder = getIslamicMonthOrderById(monthId); - const targetDay = Number(day); - if (!Number.isFinite(monthOrder) || !Number.isFinite(targetDay) || !Number.isFinite(year)) { - return null; - } - - const formatter = new Intl.DateTimeFormat("en-u-ca-islamic", { - day: "numeric", - month: "numeric", - year: "numeric" - }); - - const cursor = new Date(Math.trunc(year), 0, 1, 12, 0, 0, 0); - const end = new Date(Math.trunc(year), 11, 31, 12, 0, 0, 0); - - while (cursor.getTime() <= end.getTime()) { - const parts = formatter.formatToParts(cursor); - const currentDay = readNumericPart(parts, "day"); - const currentMonth = readNumericPart(parts, "month"); - if (currentDay === Math.trunc(targetDay) && currentMonth === monthOrder) { - return new Date(cursor); - } - cursor.setDate(cursor.getDate() + 1); - } - - return null; - } - - function resolveHolidayGregorianDate(holiday) { - if (!holiday || typeof holiday !== "object") { - return null; - } - - const calendarId = String(holiday.calendarId || "").trim().toLowerCase(); - const monthId = String(holiday.monthId || "").trim().toLowerCase(); - const day = Number(holiday.day); - - if (calendarId === "gregorian") { - if (holiday?.dateRule) { - const ruledDate = resolveGregorianDateRule(holiday.dateRule); - if (ruledDate) { - return ruledDate; - } - } - - const monthDay = parseMonthDayStartToken(holiday.monthDayStart) || parseMonthDayStartToken(holiday.dateText); - if (monthDay) { - return new Date(state.selectedYear, monthDay.month - 1, monthDay.day, 12, 0, 0, 0); - } - const order = getGregorianMonthOrderFromId(monthId); - if (Number.isFinite(order) && Number.isFinite(day)) { - return new Date(state.selectedYear, order - 1, Math.trunc(day), 12, 0, 0, 0); - } - return null; - } - - if (calendarId === "hebrew") { - return findHebrewMonthDayInGregorianYear(monthId, day, state.selectedYear); - } - - if (calendarId === "islamic") { - return findIslamicMonthDayInGregorianYear(monthId, day, state.selectedYear); - } - - if (calendarId === "wheel-of-year") { - const monthDay = parseMonthDayStartToken(holiday.monthDayStart) || parseFirstMonthDayFromText(holiday.dateText); - if (monthDay?.month && monthDay?.day) { - return new Date(state.selectedYear, monthDay.month - 1, monthDay.day, 12, 0, 0, 0); - } - if (monthDay?.monthIndex != null && monthDay?.day) { - return new Date(state.selectedYear, monthDay.monthIndex, monthDay.day, 12, 0, 0, 0); - } - } - - return null; - } - - function findWheelMonthStartInGregorianYear(month, year) { - const parsed = parseFirstMonthDayFromText(month?.date); - if (!parsed || !Number.isFinite(year)) { - return null; - } - - return new Date(Math.trunc(year), parsed.monthIndex, parsed.day, 12, 0, 0, 0); - } - - function getGregorianReferenceDateForCalendarMonth(month) { - const calId = state.selectedCalendar; - if (calId === "gregorian") { - return getGregorianMonthStartDate(Number(month?.order)); - } - if (calId === "hebrew") { - return findHebrewMonthStartInGregorianYear(month, state.selectedYear); - } - if (calId === "islamic") { - return findIslamicMonthStartInGregorianYear(month, state.selectedYear); - } - if (calId === "wheel-of-year") { - return findWheelMonthStartInGregorianYear(month, state.selectedYear); - } - return null; - } - function getMonthSubtitle(month) { - const calId = state.selectedCalendar; - if (calId === "hebrew" || calId === "islamic") { + if (state.selectedCalendar === "hebrew" || state.selectedCalendar === "islamic") { const native = month.nativeName ? ` · ${month.nativeName}` : ""; const days = month.days ? ` · ${month.days} days` : ""; return `${month.season || ""}${native}${days}`; } - if (calId === "wheel-of-year") { + if (state.selectedCalendar === "wheel-of-year") { return [month.date, month.type, month.season].filter(Boolean).join(" · "); } return parseMonthRange(month); } - function formatIsoDate(date) { - if (!(date instanceof Date) || Number.isNaN(date.getTime())) { - return ""; - } - - const year = date.getFullYear(); - const month = `${date.getMonth() + 1}`.padStart(2, "0"); - const day = `${date.getDate()}`.padStart(2, "0"); - return `${year}-${month}-${day}`; - } - - function resolveCalendarDayToGregorian(month, dayNumber) { - const calId = state.selectedCalendar; - const day = Math.trunc(Number(dayNumber)); - if (!Number.isFinite(day) || day <= 0) { - return null; - } - - if (calId === "gregorian") { - const monthOrder = Number(month?.order); - if (!Number.isFinite(monthOrder)) { - return null; - } - return new Date(state.selectedYear, monthOrder - 1, day, 12, 0, 0, 0); - } - - if (calId === "hebrew") { - return findHebrewMonthDayInGregorianYear(month?.id, day, state.selectedYear); - } - - if (calId === "islamic") { - return findIslamicMonthDayInGregorianYear(month?.id, day, state.selectedYear); - } - - return null; - } - function getMonthDayLinkRows(month) { const cacheKey = `${state.selectedCalendar}|${state.selectedYear}|${month?.id || ""}`; if (state.dayLinksCache.has(cacheKey)) { @@ -1148,47 +565,6 @@ return rows; } - function renderDayLinksCard(month) { - const rows = getMonthDayLinkRows(month); - if (!rows.length) { - return ""; - } - - const selectedContext = getSelectedDayFilterContext(month); - const selectedDaySet = selectedContext?.dayNumbers || new Set(); - const selectedDays = selectedContext?.entries?.map((entry) => entry.dayNumber) || []; - const selectedSummary = selectedDays.length - ? selectedDays.join(", ") - : ""; - - const links = rows.map((row) => { - if (!row.isResolved) { - return `${row.day}`; - } - - const isSelected = selectedDaySet.has(Number(row.day)); - return ``; - }).join(""); - - const clearButton = selectedContext - ? `` - : ""; - - const helperText = selectedContext - ? `
Filtered to days: ${selectedSummary}
` - : ""; - - return ` -
- Day Links -
Filter this month to events, holidays, and data connected to a specific day.
- ${helperText} -
${links}
- ${clearButton ? `
${clearButton}
` : ""} -
- `; - } - function renderList(elements) { const { monthListEl, monthCountEl, listTitleEl } = elements; if (!monthListEl) { @@ -1204,16 +580,13 @@ itemEl.setAttribute("role", "option"); itemEl.setAttribute("aria-selected", isSelected ? "true" : "false"); itemEl.dataset.monthId = month.id; - itemEl.innerHTML = `
${month.name || month.id}
${getMonthSubtitle(month)}
`; - itemEl.addEventListener("click", () => { selectByMonthId(month.id, elements); }); - monthListEl.appendChild(itemEl); }); @@ -1228,149 +601,6 @@ } } - function planetLabel(planetId) { - if (!planetId) { - return "Planet"; - } - - const planet = state.planetsById.get(planetId); - if (!planet) { - return cap(planetId); - } - - return `${planet.symbol || ""} ${planet.name || cap(planetId)}`.trim(); - } - - function zodiacLabel(signId) { - if (!signId) { - return "Zodiac"; - } - - const sign = state.signsById.get(signId); - if (!sign) { - return cap(signId); - } - - return `${sign.symbol || ""} ${sign.name || cap(signId)}`.trim(); - } - - function godLabel(godId, godName) { - if (godName) { - return godName; - } - - if (!godId) { - return "Deity"; - } - - const god = state.godsById.get(godId); - return god?.name || cap(godId); - } - - function hebrewLabel(hebrewLetterId) { - if (!hebrewLetterId) { - return "Hebrew Letter"; - } - - const letter = state.hebrewById.get(hebrewLetterId); - if (!letter) { - return cap(hebrewLetterId); - } - - return `${letter.char || ""} ${letter.name || cap(hebrewLetterId)}`.trim(); - } - - function computeDigitalRoot(value) { - let current = Math.abs(Math.trunc(Number(value))); - if (!Number.isFinite(current)) { - return null; - } - - while (current >= 10) { - current = String(current) - .split("") - .reduce((sum, digit) => sum + Number(digit), 0); - } - - return current; - } - - function buildAssociationButtons(associations) { - if (!associations || typeof associations !== "object") { - return "
--
"; - } - - const buttons = []; - - if (associations.planetId) { - buttons.push( - `` - ); - } - - if (associations.zodiacSignId) { - buttons.push( - `` - ); - } - - if (Number.isFinite(Number(associations.numberValue))) { - const rawNumber = Math.trunc(Number(associations.numberValue)); - if (rawNumber >= 0) { - const numberValue = computeDigitalRoot(rawNumber); - if (numberValue != null) { - const label = rawNumber === numberValue - ? `Number ${numberValue}` - : `Number ${numberValue} (from ${rawNumber})`; - buttons.push( - `` - ); - } - } - } - - if (associations.tarotCard) { - const trumpNumber = resolveTarotTrumpNumber(associations.tarotCard); - const explicitTrumpNumber = Number(associations.tarotTrumpNumber); - const tarotTrumpNumber = Number.isFinite(explicitTrumpNumber) ? explicitTrumpNumber : trumpNumber; - const tarotLabel = getDisplayTarotName(associations.tarotCard, tarotTrumpNumber); - buttons.push( - `` - ); - } - - if (associations.godId || associations.godName) { - const label = godLabel(associations.godId, associations.godName); - buttons.push( - `` - ); - } - - if (associations.hebrewLetterId) { - buttons.push( - `` - ); - } - - if (associations.kabbalahPathNumber != null) { - buttons.push( - `` - ); - } - - if (associations.iChingPlanetaryInfluence) { - buttons.push( - `` - ); - } - - if (!buttons.length) { - return "
--
"; - } - - return `
${buttons.join("")}
`; - } - function associationSearchText(associations) { if (!associations || typeof associations !== "object") { return ""; @@ -1424,17 +654,16 @@ const monthOrder = Number(month?.order); const fromRepo = state.calendarHolidays.filter((holiday) => { - const holidayCalendarId = String(holiday?.calendarId || "").trim().toLowerCase(); + const holidayCalendarId = normalizeText(holiday?.calendarId).toLowerCase(); if (holidayCalendarId !== calendarId) { return false; } - const isDirectMonthMatch = String(holiday?.monthId || "").trim().toLowerCase() === String(month?.id || "").trim().toLowerCase(); + const isDirectMonthMatch = normalizeText(holiday?.monthId).toLowerCase() === normalizeText(month?.id).toLowerCase(); if (isDirectMonthMatch) { return true; } - // For movable Gregorian holidays, place the holiday under the computed month for the selected year. if (calendarId === "gregorian" && holiday?.dateRule && Number.isFinite(monthOrder)) { const computedDate = resolveHolidayGregorianDate(holiday); return computedDate instanceof Date @@ -1458,43 +687,38 @@ if (Number.isFinite(leftDay) && Number.isFinite(rightDay) && leftDay !== rightDay) { return leftDay - rightDay; } - return String(left?.name || "").localeCompare(String(right?.name || "")); + return normalizeText(left?.name).localeCompare(normalizeText(right?.name)); }); } - // Legacy fallback for old Gregorian-only holiday structure. const seen = new Set(); const ordered = []; - (month.holidayIds || []).forEach((holidayId) => { + (month?.holidayIds || []).forEach((holidayId) => { const holiday = state.holidays.find((item) => item.id === holidayId); - if (!holiday || seen.has(holiday.id)) { - return; + if (holiday && !seen.has(holiday.id)) { + seen.add(holiday.id); + ordered.push(holiday); } - seen.add(holiday.id); - ordered.push(holiday); }); state.holidays.forEach((holiday) => { - if (holiday?.monthId !== month.id || seen.has(holiday.id)) { - return; + if (holiday?.monthId === month.id && !seen.has(holiday.id)) { + seen.add(holiday.id); + ordered.push(holiday); } - seen.add(holiday.id); - ordered.push(holiday); }); return ordered; } function buildMonthSearchText(month) { - const calId = state.selectedCalendar; const monthHolidays = buildHolidayList(month); const holidayText = monthHolidays.map((holiday) => holidaySearchText(holiday)).join(" "); - if (calId === "gregorian") { + if (state.selectedCalendar === "gregorian") { const events = Array.isArray(month?.events) ? month.events : []; - - const searchable = [ + return normalizeSearchValue([ month?.name, month?.id, month?.start, @@ -1505,9 +729,7 @@ associationSearchText(month?.associations), events.map((event) => eventSearchText(event)).join(" "), holidayText - ]; - - return normalizeSearchValue(searchable.filter(Boolean).join(" ")); + ].filter(Boolean).join(" ")); } const wheelAssocText = month?.associations @@ -1519,7 +741,7 @@ ].filter(Boolean).join(" ") : ""; - const searchable = [ + return normalizeSearchValue([ month?.name, month?.id, month?.nativeName, @@ -1534,9 +756,7 @@ month?.hebrewLetter, holidayText, wheelAssocText - ]; - - return normalizeSearchValue(searchable.filter(Boolean).join(" ")); + ].filter(Boolean).join(" ")); } function matchesSearch(searchText) { @@ -1555,6 +775,10 @@ } } + function renderDetail(elements) { + calendarDetailUi.renderDetail?.(elements); + } + function applySearchFilter(elements) { state.filteredMonths = state.searchQuery ? state.months.filter((month) => matchesSearch(buildMonthSearchText(month))) @@ -1569,786 +793,6 @@ renderDetail(elements); } - function renderFactsCard(month) { - const monthOrder = Number(month?.order); - const daysInMonth = getDaysInMonth(state.selectedYear, monthOrder); - const hoursInMonth = Number.isFinite(daysInMonth) ? daysInMonth * 24 : null; - const firstWeekday = Number.isFinite(monthOrder) - ? getMonthStartWeekday(state.selectedYear, monthOrder) - : "--"; - const gregorianStartDate = getGregorianMonthStartDate(monthOrder); - const hebrewStartReference = formatCalendarDateFromGregorian(gregorianStartDate, "hebrew"); - const islamicStartReference = formatCalendarDateFromGregorian(gregorianStartDate, "islamic"); - - return ` -
- Month Facts -
-
-
Year
${state.selectedYear}
-
Start Date (Gregorian)
${formatGregorianReferenceDate(gregorianStartDate)}
-
Days
${daysInMonth ?? "--"}
-
Hours
${hoursInMonth ?? "--"}
-
Starts On
${firstWeekday}
-
Hebrew On 1st
${hebrewStartReference}
-
Islamic On 1st
${islamicStartReference}
-
North Season
${month.seasonNorth || "--"}
-
South Season
${month.seasonSouth || "--"}
-
-
-
- `; - } - - function renderAssociationsCard(month) { - const monthOrder = Number(month?.order); - const associations = { - ...(month?.associations || {}), - ...(Number.isFinite(monthOrder) ? { numberValue: Math.trunc(monthOrder) } : {}) - }; - - return ` -
- Associations -
${month.coreTheme || "--"}
- ${buildAssociationButtons(associations)} -
- `; - } - - function renderEventsCard(month) { - const allEvents = Array.isArray(month?.events) ? month.events : []; - if (!allEvents.length) { - return ` -
- Monthly Events -
No monthly events listed.
-
- `; - } - - const selectedDay = getSelectedDayFilterContext(month); - - function eventMatchesDay(event) { - if (!selectedDay) { - return true; - } - - return selectedDay.entries.some((entry) => { - const targetDate = entry.gregorianDate; - const targetMonth = targetDate?.getMonth() + 1; - const targetDayNo = targetDate?.getDate(); - - const explicitDate = parseMonthDayToken(event?.date); - if (explicitDate && Number.isFinite(targetMonth) && Number.isFinite(targetDayNo)) { - return explicitDate.month === targetMonth && explicitDate.day === targetDayNo; - } - - const rangeTokens = parseMonthDayTokensFromText(event?.dateRange || event?.dateText || ""); - if (rangeTokens.length >= 2 && Number.isFinite(targetMonth) && Number.isFinite(targetDayNo)) { - const start = rangeTokens[0]; - const end = rangeTokens[1]; - return isMonthDayInRange(targetMonth, targetDayNo, start.month, start.day, end.month, end.day); - } - - const dayRange = parseDayRangeFromText(event?.date || event?.dateRange || event?.dateText || ""); - if (dayRange) { - return entry.dayNumber >= dayRange.startDay && entry.dayNumber <= dayRange.endDay; - } - - return false; - }); - } - - const dayFiltered = allEvents.filter((event) => eventMatchesDay(event)); - const events = state.searchQuery - ? dayFiltered.filter((event) => matchesSearch(eventSearchText(event))) - : dayFiltered; - - if (!events.length) { - return ` -
- Monthly Events -
No monthly events match current search.
-
- `; - } - - const rows = events.map((event) => { - const dateText = event?.date || event?.dateRange || "--"; - return ` -
-
- ${event?.name || "Untitled"} - ${dateText} -
-
${event?.description || ""}
- ${buildAssociationButtons(event?.associations)} -
- `; - }).join(""); - - return ` -
- Monthly Events -
${rows}
-
- `; - } - - function renderHolidaysCard(month, title = "Holiday Repository") { - const allHolidays = buildHolidayList(month); - if (!allHolidays.length) { - return ` -
- ${title} -
No holidays listed in the repository for this month.
-
- `; - } - - const selectedDay = getSelectedDayFilterContext(month); - - function holidayMatchesDay(holiday) { - if (!selectedDay) { - return true; - } - - return selectedDay.entries.some((entry) => { - const targetDate = entry.gregorianDate; - const targetMonth = targetDate?.getMonth() + 1; - const targetDayNo = targetDate?.getDate(); - - const exactResolved = resolveHolidayGregorianDate(holiday); - if (exactResolved instanceof Date && !Number.isNaN(exactResolved.getTime()) && targetDate instanceof Date) { - return formatIsoDate(exactResolved) === formatIsoDate(targetDate); - } - - if (state.selectedCalendar === "gregorian" && Number.isFinite(targetMonth) && Number.isFinite(targetDayNo)) { - const tokens = parseMonthDayTokensFromText(holiday?.dateText || holiday?.dateRange || ""); - if (tokens.length >= 2) { - const start = tokens[0]; - const end = tokens[1]; - return isMonthDayInRange(targetMonth, targetDayNo, start.month, start.day, end.month, end.day); - } - - if (tokens.length === 1) { - const single = tokens[0]; - return single.month === targetMonth && single.day === targetDayNo; - } - - const direct = parseMonthDayStartToken(holiday?.monthDayStart || holiday?.dateText || ""); - if (direct) { - return direct.month === targetMonth && direct.day === targetDayNo; - } - - if (Number.isFinite(Number(holiday?.day))) { - return Number(holiday.day) === entry.dayNumber; - } - } - - const localRange = parseDayRangeFromText(holiday?.dateText || holiday?.dateRange || ""); - if (localRange) { - return entry.dayNumber >= localRange.startDay && entry.dayNumber <= localRange.endDay; - } - - if (Number.isFinite(Number(holiday?.day))) { - return Number(holiday.day) === entry.dayNumber; - } - - return false; - }); - } - - const dayFiltered = allHolidays.filter((holiday) => holidayMatchesDay(holiday)); - const holidays = state.searchQuery - ? dayFiltered.filter((holiday) => matchesSearch(holidaySearchText(holiday))) - : dayFiltered; - - if (!holidays.length) { - return ` -
- ${title} -
No holidays match current search.
-
- `; - } - - const rows = holidays.map((holiday) => { - const dateText = holiday?.dateText || holiday?.date || holiday?.dateRange || "--"; - const gregorianDate = resolveHolidayGregorianDate(holiday); - const gregorianRef = formatGregorianReferenceDate(gregorianDate); - const hebrewRef = formatCalendarDateFromGregorian(gregorianDate, "hebrew"); - const islamicRef = formatCalendarDateFromGregorian(gregorianDate, "islamic"); - const conversionConfidence = String(holiday?.conversionConfidence || holiday?.datePrecision || "approximate").toLowerCase(); - const conversionLabel = (!(gregorianDate instanceof Date) || Number.isNaN(gregorianDate.getTime())) - ? "Conversion: unresolved" - : (conversionConfidence === "exact" ? "Conversion: exact" : "Conversion: approximate"); - return ` -
-
- ${holiday?.name || "Untitled"} - ${dateText} -
-
${cap(holiday?.kind || holiday?.calendarId || "observance")}
-
${conversionLabel}
-
Gregorian: ${gregorianRef}
-
Hebrew: ${hebrewRef}
-
Islamic: ${islamicRef}
-
${holiday?.description || ""}
- ${buildAssociationButtons(holiday?.associations)} -
- `; - }).join(""); - - return ` -
- ${title} -
${rows}
-
- `; - } - - function findSignIdByAstrologyName(name) { - const token = normalizeCalendarText(name); - if (!token) { - return null; - } - - for (const [signId, sign] of state.signsById) { - const idToken = normalizeCalendarText(signId); - const nameToken = normalizeCalendarText(sign?.name?.en || sign?.name || ""); - if (token === idToken || token === nameToken) { - return signId; - } - } - - return null; - } - - function intersectDateRanges(startA, endA, startB, endB) { - const start = startA.getTime() > startB.getTime() ? startA : startB; - const end = endA.getTime() < endB.getTime() ? endA : endB; - return start.getTime() <= end.getTime() ? { start, end } : null; - } - - function buildMajorArcanaRowsForMonth(month) { - if (state.selectedCalendar !== "gregorian") { - return []; - } - - const monthOrder = Number(month?.order); - if (!Number.isFinite(monthOrder)) { - return []; - } - - const monthStart = new Date(state.selectedYear, monthOrder - 1, 1, 12, 0, 0, 0); - const monthEnd = new Date(state.selectedYear, monthOrder, 0, 12, 0, 0, 0); - const rows = []; - - state.hebrewById.forEach((letter) => { - const astrologyType = normalizeCalendarText(letter?.astrology?.type); - if (astrologyType !== "zodiac") { - return; - } - - const signId = findSignIdByAstrologyName(letter?.astrology?.name); - const sign = signId ? state.signsById.get(signId) : null; - if (!sign) { - return; - } - - const startToken = parseMonthDayToken(sign?.start); - const endToken = parseMonthDayToken(sign?.end); - if (!startToken || !endToken) { - return; - } - - const spanStart = new Date(state.selectedYear, startToken.month - 1, startToken.day, 12, 0, 0, 0); - const spanEnd = new Date(state.selectedYear, endToken.month - 1, endToken.day, 12, 0, 0, 0); - const wraps = spanEnd.getTime() < spanStart.getTime(); - - const segments = wraps - ? [ - { - start: spanStart, - end: new Date(state.selectedYear, 11, 31, 12, 0, 0, 0) - }, - { - start: new Date(state.selectedYear, 0, 1, 12, 0, 0, 0), - end: spanEnd - } - ] - : [{ start: spanStart, end: spanEnd }]; - - segments.forEach((segment) => { - const overlap = intersectDateRanges(segment.start, segment.end, monthStart, monthEnd); - if (!overlap) { - return; - } - - const rangeStartDay = overlap.start.getDate(); - const rangeEndDay = overlap.end.getDate(); - const cardName = String(letter?.tarot?.card || "").trim(); - const trumpNumber = Number(letter?.tarot?.trumpNumber); - if (!cardName) { - return; - } - - rows.push({ - id: `${signId}-${rangeStartDay}-${rangeEndDay}`, - signId, - signName: sign?.name?.en || sign?.name || signId, - signSymbol: sign?.symbol || "", - cardName, - trumpNumber: Number.isFinite(trumpNumber) ? Math.trunc(trumpNumber) : null, - hebrewLetterId: String(letter?.hebrewLetterId || "").trim(), - hebrewLetterName: String(letter?.name || "").trim(), - hebrewLetterChar: String(letter?.char || "").trim(), - dayStart: rangeStartDay, - dayEnd: rangeEndDay, - rangeLabel: `${month?.name || "Month"} ${rangeStartDay}-${rangeEndDay}` - }); - }); - }); - - rows.sort((left, right) => { - if (left.dayStart !== right.dayStart) { - return left.dayStart - right.dayStart; - } - return left.cardName.localeCompare(right.cardName); - }); - - return rows; - } - - function renderMajorArcanaCard(month) { - const selectedDay = getSelectedDayFilterContext(month); - const allRows = buildMajorArcanaRowsForMonth(month); - - const rows = selectedDay - ? allRows.filter((row) => selectedDay.entries.some((entry) => entry.dayNumber >= row.dayStart && entry.dayNumber <= row.dayEnd)) - : allRows; - - if (!rows.length) { - return ` -
- Major Arcana Windows -
No major arcana windows for this month.
-
- `; - } - - const list = rows.map((row) => { - const hebrewLabel = row.hebrewLetterId - ? `${row.hebrewLetterChar ? `${row.hebrewLetterChar} ` : ""}${row.hebrewLetterName || row.hebrewLetterId}` - : "--"; - const displayCardName = getDisplayTarotName(row.cardName, row.trumpNumber); - - return ` -
-
- ${displayCardName}${row.trumpNumber != null ? ` · Trump ${row.trumpNumber}` : ""} - ${row.rangeLabel} -
-
${row.signSymbol} ${row.signName} · Hebrew: ${hebrewLabel}
-
- - - ${row.hebrewLetterId ? `` : ""} -
-
- `; - }).join(""); - - return ` -
- Major Arcana Windows -
${list}
-
- `; - } - - function renderDecanTarotCard(month) { - const selectedDay = getSelectedDayFilterContext(month); - const allRows = buildDecanTarotRowsForMonth(month); - const rows = selectedDay - ? allRows.filter((row) => selectedDay.entries.some((entry) => { - const targetDate = entry.gregorianDate; - if (!(targetDate instanceof Date) || Number.isNaN(targetDate.getTime())) { - return false; - } - - const targetMonth = targetDate.getMonth() + 1; - const targetDayNo = targetDate.getDate(); - return isMonthDayInRange( - targetMonth, - targetDayNo, - row.startMonth, - row.startDay, - row.endMonth, - row.endDay - ); - })) - : allRows; - - if (!rows.length) { - return ` -
- Decan Tarot Windows -
No decan tarot windows for this month.
-
- `; - } - - const list = rows.map((row) => { - const displayCardName = getDisplayTarotName(row.cardName); - return ` -
-
- ${row.signSymbol} ${row.signName} · Decan ${row.decanIndex} - ${row.startDegree}°–${row.endDegree}° · ${row.dateRange} -
-
- -
-
- `; - }).join(""); - - return ` -
- Decan Tarot Windows -
${list}
-
- `; - } - - function attachNavHandlers(detailBodyEl) { - if (!detailBodyEl) { - return; - } - - detailBodyEl.querySelectorAll("[data-nav]").forEach((button) => { - button.addEventListener("click", () => { - const navType = button.dataset.nav; - - if (navType === "planet" && button.dataset.planetId) { - document.dispatchEvent(new CustomEvent("nav:planet", { - detail: { planetId: button.dataset.planetId } - })); - return; - } - - if (navType === "zodiac" && button.dataset.signId) { - document.dispatchEvent(new CustomEvent("nav:zodiac", { - detail: { signId: button.dataset.signId } - })); - return; - } - - if (navType === "number" && button.dataset.numberValue) { - document.dispatchEvent(new CustomEvent("nav:number", { - detail: { value: Number(button.dataset.numberValue) } - })); - return; - } - - if (navType === "tarot-card" && button.dataset.cardName) { - const trumpNumber = Number(button.dataset.trumpNumber); - document.dispatchEvent(new CustomEvent("nav:tarot-trump", { - detail: { - cardName: button.dataset.cardName, - trumpNumber: Number.isFinite(trumpNumber) ? trumpNumber : undefined - } - })); - return; - } - - if (navType === "god") { - document.dispatchEvent(new CustomEvent("nav:gods", { - detail: { - godId: button.dataset.godId || undefined, - godName: button.dataset.godName || undefined - } - })); - return; - } - - if (navType === "alphabet" && button.dataset.hebrewLetterId) { - document.dispatchEvent(new CustomEvent("nav:alphabet", { - detail: { - alphabet: "hebrew", - hebrewLetterId: button.dataset.hebrewLetterId - } - })); - return; - } - - if (navType === "kabbalah" && button.dataset.pathNo) { - document.dispatchEvent(new CustomEvent("nav:kabbalah-path", { - detail: { pathNo: Number(button.dataset.pathNo) } - })); - return; - } - - if (navType === "iching" && button.dataset.planetaryInfluence) { - document.dispatchEvent(new CustomEvent("nav:iching", { - detail: { - planetaryInfluence: button.dataset.planetaryInfluence - } - })); - return; - } - - if (navType === "calendar-month" && button.dataset.monthId) { - document.dispatchEvent(new CustomEvent("nav:calendar-month", { - detail: { - calendarId: button.dataset.calendarId || undefined, - monthId: button.dataset.monthId - } - })); - return; - } - - if (navType === "calendar-day" && button.dataset.dayNumber) { - const month = getSelectedMonth(); - const dayNumber = Number(button.dataset.dayNumber); - if (!month || !Number.isFinite(dayNumber)) { - return; - } - - toggleDayFilterEntry(month, dayNumber, button.dataset.gregorianDate); - renderDetail(getElements()); - return; - } - - if (navType === "calendar-day-range" && button.dataset.rangeStart && button.dataset.rangeEnd) { - const month = getSelectedMonth(); - if (!month) { - return; - } - - toggleDayRangeFilter(month, Number(button.dataset.rangeStart), Number(button.dataset.rangeEnd)); - renderDetail(getElements()); - return; - } - - if (navType === "calendar-day-clear") { - clearSelectedDayFilter(); - renderDetail(getElements()); - } - }); - }); - } - - function renderHebrewMonthDetail(month) { - const gregorianStartDate = getGregorianReferenceDateForCalendarMonth(month); - const factsRows = [ - ["Hebrew Name", month.nativeName || "--"], - ["Month Order", month.leapYearOnly ? `${month.order} (leap year only)` : String(month.order)], - ["Gregorian Reference Year", String(state.selectedYear)], - ["Month Start (Gregorian)", formatGregorianReferenceDate(gregorianStartDate)], - ["Days", month.daysVariant ? `${month.days}–${month.daysVariant} (varies)` : String(month.days || "--")], - ["Season", month.season || "--"], - ["Zodiac Sign", cap(month.zodiacSign) || "--"], - ["Tribe of Israel", month.tribe || "--"], - ["Sense", month.sense || "--"], - ["Hebrew Letter", month.hebrewLetter || "--"] - ].map(([dt, dd]) => `
${dt}
${dd}
`).join(""); - - const monthOrder = Number(month?.order); - const navButtons = buildAssociationButtons({ - ...(month?.associations || {}), - ...(Number.isFinite(monthOrder) ? { numberValue: Math.trunc(monthOrder) } : {}) - }); - const connectionsCard = navButtons - ? `
Connections${navButtons}
` - : ""; - - return ` -
-
- Month Facts -
-
${factsRows}
-
-
- ${connectionsCard} -
- About ${month.name} -
${month.description || "--"}
-
- ${renderDayLinksCard(month)} - ${renderHolidaysCard(month, "Holiday Repository")} -
- `; - } - - function renderIslamicMonthDetail(month) { - const gregorianStartDate = getGregorianReferenceDateForCalendarMonth(month); - const factsRows = [ - ["Arabic Name", month.nativeName || "--"], - ["Month Order", String(month.order)], - ["Gregorian Reference Year", String(state.selectedYear)], - ["Month Start (Gregorian)", formatGregorianReferenceDate(gregorianStartDate)], - ["Meaning", month.meaning || "--"], - ["Days", month.daysVariant ? `${month.days}–${month.daysVariant} (varies)` : String(month.days || "--")], - ["Sacred Month", month.sacred ? "Yes — warfare prohibited" : "No"] - ].map(([dt, dd]) => `
${dt}
${dd}
`).join(""); - - const monthOrder = Number(month?.order); - const hasNumberLink = Number.isFinite(monthOrder) && monthOrder >= 0; - const navButtons = hasNumberLink - ? buildAssociationButtons({ numberValue: Math.trunc(monthOrder) }) - : ""; - const connectionsCard = hasNumberLink - ? `
Connections${navButtons}
` - : ""; - - return ` -
-
- Month Facts -
-
${factsRows}
-
-
- ${connectionsCard} -
- About ${month.name} -
${month.description || "--"}
-
- ${renderDayLinksCard(month)} - ${renderHolidaysCard(month, "Holiday Repository")} -
- `; - } - - function buildWheelDeityButtons(deities) { - const buttons = []; - (Array.isArray(deities) ? deities : []).forEach((rawName) => { - // Strip qualifiers like "(early)" or "/ Father Christmas" before matching - const cleanName = String(rawName || "").replace(/\s*\/.*$/, "").replace(/\s*\(.*\)$/, "").trim(); - const godId = findGodIdByName(cleanName) || findGodIdByName(rawName); - if (!godId) return; - const god = state.godsById.get(godId); - const label = god?.name || cleanName; - buttons.push(``); - }); - return buttons; - } - - function renderWheelMonthDetail(month) { - const gregorianStartDate = getGregorianReferenceDateForCalendarMonth(month); - const assoc = month?.associations; - const themes = Array.isArray(assoc?.themes) ? assoc.themes.join(", ") : "--"; - const deities = Array.isArray(assoc?.deities) ? assoc.deities.join(", ") : "--"; - const colors = Array.isArray(assoc?.colors) ? assoc.colors.join(", ") : "--"; - const herbs = Array.isArray(assoc?.herbs) ? assoc.herbs.join(", ") : "--"; - - const factsRows = [ - ["Date", month.date || "--"], - ["Type", cap(month.type) || "--"], - ["Gregorian Reference Year", String(state.selectedYear)], - ["Start (Gregorian)", formatGregorianReferenceDate(gregorianStartDate)], - ["Season", month.season || "--"], - ["Element", cap(month.element) || "--"], - ["Direction", assoc?.direction || "--"] - ].map(([dt, dd]) => `
${dt}
${dd}
`).join(""); - - const assocRows = [ - ["Themes", themes], - ["Deities", deities], - ["Colors", colors], - ["Herbs", herbs] - ].map(([dt, dd]) => `
${dt}
${dd}
`).join(""); - - const deityButtons = buildWheelDeityButtons(assoc?.deities); - const deityLinksCard = deityButtons.length - ? `
Linked Deities
${deityButtons.join("")}
` - : ""; - - const monthOrder = Number(month?.order); - const hasNumberLink = Number.isFinite(monthOrder) && monthOrder >= 0; - const numberButtons = hasNumberLink - ? buildAssociationButtons({ numberValue: Math.trunc(monthOrder) }) - : ""; - const numberLinksCard = hasNumberLink - ? `
Connections${numberButtons}
` - : ""; - - return ` -
-
- Sabbat Facts -
-
${factsRows}
-
-
-
- About ${month.name} -
${month.description || "--"}
-
-
- Associations -
-
${assocRows}
-
-
- ${renderDayLinksCard(month)} - ${numberLinksCard} - ${deityLinksCard} - ${renderHolidaysCard(month, "Holiday Repository")} -
- `; - } - - function renderDetail(elements) { - const { detailNameEl, detailSubEl, detailBodyEl } = elements; - if (!detailBodyEl || !detailNameEl || !detailSubEl) { - return; - } - - const month = getSelectedMonth(); - if (!month) { - detailNameEl.textContent = "--"; - detailSubEl.textContent = "Select a month to explore"; - detailBodyEl.innerHTML = ""; - return; - } - - detailNameEl.textContent = month.name || month.id; - - const calId = state.selectedCalendar; - - if (calId === "gregorian") { - detailSubEl.textContent = `${parseMonthRange(month)} · ${month.coreTheme || "Month correspondences"}`; - detailBodyEl.innerHTML = ` -
- ${renderFactsCard(month)} - ${renderDayLinksCard(month)} - ${renderAssociationsCard(month)} - ${renderMajorArcanaCard(month)} - ${renderDecanTarotCard(month)} - ${renderEventsCard(month)} - ${renderHolidaysCard(month, "Holiday Repository")} -
- `; - } else if (calId === "hebrew") { - detailSubEl.textContent = getMonthSubtitle(month); - detailBodyEl.innerHTML = renderHebrewMonthDetail(month); - } else if (calId === "islamic") { - detailSubEl.textContent = getMonthSubtitle(month); - detailBodyEl.innerHTML = renderIslamicMonthDetail(month); - } else { - detailSubEl.textContent = getMonthSubtitle(month); - detailBodyEl.innerHTML = renderWheelMonthDetail(month); - } - - attachNavHandlers(detailBodyEl); - } - function selectByMonthId(monthId, elements = getElements()) { const target = state.months.find((month) => month.id === monthId); if (!target) { @@ -2452,8 +896,7 @@ elements.calendarTypeEl.value = state.selectedCalendar; elements.calendarTypeEl.addEventListener("change", () => { - const calId = String(elements.calendarTypeEl.value || "gregorian"); - loadCalendarType(calId, elements); + loadCalendarType(String(elements.calendarTypeEl.value || "gregorian"), elements); }); } @@ -2462,6 +905,48 @@ return; } + calendarDatesUi.init?.({ + getSelectedYear: () => state.selectedYear, + getSelectedCalendar: () => state.selectedCalendar, + getIslamicMonths: () => state.calendarData?.islamic || [] + }); + + calendarDetailUi.init?.({ + getState: () => state, + getElements, + getSelectedMonth, + getSelectedDayFilterContext, + clearSelectedDayFilter, + toggleDayFilterEntry, + toggleDayRangeFilter, + getMonthSubtitle, + getMonthDayLinkRows, + buildDecanTarotRowsForMonth, + buildHolidayList, + matchesSearch, + eventSearchText, + holidaySearchText, + getDisplayTarotName, + cap, + formatGregorianReferenceDate, + getDaysInMonth, + getMonthStartWeekday, + getGregorianMonthStartDate, + formatCalendarDateFromGregorian, + parseMonthDayToken, + parseMonthDayTokensFromText, + parseMonthDayStartToken, + parseDayRangeFromText, + parseMonthRange, + formatIsoDate, + resolveHolidayGregorianDate, + isMonthDayInRange, + intersectDateRanges, + getGregorianReferenceDateForCalendarMonth, + normalizeCalendarText, + findGodIdByName + }); + state.referenceData = referenceData; state.magickDataset = magickDataset || null; state.dayLinksCache = new Map(); @@ -2485,7 +970,6 @@ state.filteredMonths = [...state.months]; const elements = getElements(); - if (elements.calendarYearWrapEl) { elements.calendarYearWrapEl.hidden = false; } diff --git a/app/ui-chrome.js b/app/ui-chrome.js new file mode 100644 index 0000000..ad49079 --- /dev/null +++ b/app/ui-chrome.js @@ -0,0 +1,290 @@ +(function () { + "use strict"; + + const SIDEBAR_COLLAPSE_STORAGE_PREFIX = "tarot-sidebar-collapsed:"; + const DETAIL_COLLAPSE_STORAGE_PREFIX = "tarot-detail-collapsed:"; + const DEFAULT_DATASET_ENTRY_COLLAPSED = true; + const DEFAULT_DATASET_DETAIL_COLLAPSED = false; + + function loadSidebarCollapsedState(storageKey) { + try { + const raw = window.localStorage?.getItem(storageKey); + if (raw === "1") { + return true; + } + if (raw === "0") { + return false; + } + return null; + } catch { + return null; + } + } + + function saveSidebarCollapsedState(storageKey, collapsed) { + try { + window.localStorage?.setItem(storageKey, collapsed ? "1" : "0"); + } catch { + // Ignore storage failures silently. + } + } + + function initializeSidebarPopouts() { + const layouts = document.querySelectorAll(".planet-layout, .tarot-layout, .kab-layout"); + + layouts.forEach((layout, index) => { + if (!(layout instanceof HTMLElement)) { + return; + } + + const panel = Array.from(layout.children).find((child) => ( + child instanceof HTMLElement + && child.matches("aside.planet-list-panel, aside.tarot-list-panel, aside.kab-tree-panel") + )); + + if (!(panel instanceof HTMLElement) || panel.dataset.sidebarPopoutReady === "1") { + return; + } + + const header = panel.querySelector(".planet-list-header, .tarot-list-header"); + if (!(header instanceof HTMLElement)) { + return; + } + + panel.dataset.sidebarPopoutReady = "1"; + + const sectionId = layout.closest("section")?.id || `layout-${index + 1}`; + const panelId = panel.id || `${sectionId}-entry-panel`; + panel.id = panelId; + + const storageKey = `${SIDEBAR_COLLAPSE_STORAGE_PREFIX}${sectionId}`; + + const collapseBtn = document.createElement("button"); + collapseBtn.type = "button"; + collapseBtn.className = "sidebar-toggle-inline"; + collapseBtn.textContent = "Hide Panel"; + collapseBtn.setAttribute("aria-label", "Hide entry panel"); + collapseBtn.setAttribute("aria-controls", panelId); + header.appendChild(collapseBtn); + + const openBtn = document.createElement("button"); + openBtn.type = "button"; + openBtn.className = "sidebar-popout-open"; + openBtn.textContent = "Show Panel"; + openBtn.setAttribute("aria-label", "Show entry panel"); + openBtn.setAttribute("aria-controls", panelId); + openBtn.hidden = true; + layout.appendChild(openBtn); + + const applyCollapsedState = (collapsed, persist = true) => { + layout.classList.toggle("layout-sidebar-collapsed", collapsed); + collapseBtn.setAttribute("aria-expanded", collapsed ? "false" : "true"); + openBtn.setAttribute("aria-expanded", collapsed ? "false" : "true"); + openBtn.hidden = !collapsed; + + if (persist) { + saveSidebarCollapsedState(storageKey, collapsed); + } + }; + + collapseBtn.addEventListener("click", () => { + applyCollapsedState(true); + }); + + openBtn.addEventListener("click", () => { + applyCollapsedState(false); + }); + + const storedCollapsed = loadSidebarCollapsedState(storageKey); + applyCollapsedState(storedCollapsed == null ? DEFAULT_DATASET_ENTRY_COLLAPSED : storedCollapsed, false); + }); + } + + function initializeDetailPopouts() { + const layouts = document.querySelectorAll(".planet-layout, .tarot-layout, .kab-layout"); + + layouts.forEach((layout, index) => { + if (!(layout instanceof HTMLElement)) { + return; + } + + const detailPanel = Array.from(layout.children).find((child) => ( + child instanceof HTMLElement + && child.matches("section.planet-detail-panel, section.tarot-detail-panel, section.kab-detail-panel") + )); + + if (!(detailPanel instanceof HTMLElement) || detailPanel.dataset.detailPopoutReady === "1") { + return; + } + + const heading = detailPanel.querySelector(".planet-detail-heading, .tarot-detail-heading"); + if (!(heading instanceof HTMLElement)) { + return; + } + + detailPanel.dataset.detailPopoutReady = "1"; + + const sectionId = layout.closest("section")?.id || `layout-${index + 1}`; + const panelId = detailPanel.id || `${sectionId}-detail-panel`; + detailPanel.id = panelId; + + const detailStorageKey = `${DETAIL_COLLAPSE_STORAGE_PREFIX}${sectionId}`; + const sidebarStorageKey = `${SIDEBAR_COLLAPSE_STORAGE_PREFIX}${sectionId}`; + + const collapseBtn = document.createElement("button"); + collapseBtn.type = "button"; + collapseBtn.className = "detail-toggle-inline"; + collapseBtn.textContent = "Hide Detail"; + collapseBtn.setAttribute("aria-label", "Hide detail panel"); + collapseBtn.setAttribute("aria-controls", panelId); + heading.appendChild(collapseBtn); + + const openBtn = document.createElement("button"); + openBtn.type = "button"; + openBtn.className = "detail-popout-open"; + openBtn.textContent = "Show Detail"; + openBtn.setAttribute("aria-label", "Show detail panel"); + openBtn.setAttribute("aria-controls", panelId); + openBtn.hidden = true; + layout.appendChild(openBtn); + + const applyCollapsedState = (collapsed, persist = true) => { + if (collapsed && layout.classList.contains("layout-sidebar-collapsed")) { + layout.classList.remove("layout-sidebar-collapsed"); + const sidebarOpenBtn = layout.querySelector(".sidebar-popout-open"); + if (sidebarOpenBtn instanceof HTMLButtonElement) { + sidebarOpenBtn.hidden = true; + sidebarOpenBtn.setAttribute("aria-expanded", "true"); + } + const sidebarCollapseBtn = layout.querySelector(".sidebar-toggle-inline"); + if (sidebarCollapseBtn instanceof HTMLButtonElement) { + sidebarCollapseBtn.setAttribute("aria-expanded", "true"); + } + saveSidebarCollapsedState(sidebarStorageKey, false); + } + + layout.classList.toggle("layout-detail-collapsed", collapsed); + collapseBtn.setAttribute("aria-expanded", collapsed ? "false" : "true"); + openBtn.setAttribute("aria-expanded", collapsed ? "false" : "true"); + openBtn.hidden = !collapsed; + + if (persist) { + saveSidebarCollapsedState(detailStorageKey, collapsed); + } + }; + + collapseBtn.addEventListener("click", () => { + applyCollapsedState(true); + }); + + openBtn.addEventListener("click", () => { + applyCollapsedState(false); + }); + + const storedCollapsed = loadSidebarCollapsedState(detailStorageKey); + const shouldForceOpenForTarot = sectionId === "tarot-section"; + const initialCollapsed = shouldForceOpenForTarot + ? false + : (storedCollapsed == null ? DEFAULT_DATASET_DETAIL_COLLAPSED : storedCollapsed); + applyCollapsedState(initialCollapsed, false); + }); + } + + function setTopbarDropdownOpen(dropdownEl, isOpen) { + if (!(dropdownEl instanceof HTMLElement)) { + return; + } + + dropdownEl.classList.toggle("is-open", Boolean(isOpen)); + const trigger = dropdownEl.querySelector("button[aria-haspopup='menu']"); + if (trigger) { + trigger.setAttribute("aria-expanded", isOpen ? "true" : "false"); + } + } + + function closeTopbarDropdowns(exceptEl = null) { + const topbarDropdownEls = Array.from(document.querySelectorAll(".topbar-dropdown")); + topbarDropdownEls.forEach((dropdownEl) => { + if (exceptEl && dropdownEl === exceptEl) { + return; + } + setTopbarDropdownOpen(dropdownEl, false); + }); + } + + function bindTopbarDropdownInteractions() { + const topbarDropdownEls = Array.from(document.querySelectorAll(".topbar-dropdown")); + if (!topbarDropdownEls.length) { + return; + } + + topbarDropdownEls.forEach((dropdownEl) => { + const trigger = dropdownEl.querySelector("button[aria-haspopup='menu']"); + if (!(trigger instanceof HTMLElement)) { + return; + } + + setTopbarDropdownOpen(dropdownEl, false); + + dropdownEl.addEventListener("mouseenter", () => { + setTopbarDropdownOpen(dropdownEl, true); + }); + + dropdownEl.addEventListener("mouseleave", () => { + setTopbarDropdownOpen(dropdownEl, false); + }); + + dropdownEl.addEventListener("focusout", (event) => { + const nextTarget = event.relatedTarget; + if (!(nextTarget instanceof Node) || !dropdownEl.contains(nextTarget)) { + setTopbarDropdownOpen(dropdownEl, false); + } + }); + + trigger.addEventListener("click", (event) => { + event.stopPropagation(); + const nextOpen = !dropdownEl.classList.contains("is-open"); + closeTopbarDropdowns(dropdownEl); + setTopbarDropdownOpen(dropdownEl, nextOpen); + }); + + const menuItems = dropdownEl.querySelectorAll(".topbar-dropdown-menu [role='menuitem']"); + menuItems.forEach((menuItem) => { + menuItem.addEventListener("click", () => { + closeTopbarDropdowns(); + }); + }); + }); + + document.addEventListener("click", (event) => { + const clickTarget = event.target; + if (clickTarget instanceof Node && topbarDropdownEls.some((dropdownEl) => dropdownEl.contains(clickTarget))) { + return; + } + + closeTopbarDropdowns(); + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeTopbarDropdowns(); + } + }); + } + + function init() { + initializeSidebarPopouts(); + initializeDetailPopouts(); + bindTopbarDropdownInteractions(); + } + + window.TarotChromeUi = { + ...(window.TarotChromeUi || {}), + init, + initializeSidebarPopouts, + initializeDetailPopouts, + setTopbarDropdownOpen, + closeTopbarDropdowns, + bindTopbarDropdownInteractions + }; +})(); diff --git a/app/ui-cube-detail.js b/app/ui-cube-detail.js new file mode 100644 index 0000000..0d369fe --- /dev/null +++ b/app/ui-cube-detail.js @@ -0,0 +1,538 @@ +/* ui-cube-detail.js — Cube detail pane rendering */ +(function () { + "use strict"; + + function toDisplayText(value) { + return String(value ?? "").trim(); + } + + function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'"); + } + + function toDetailValueMarkup(value) { + const text = toDisplayText(value); + return text ? escapeHtml(text) : '!'; + } + + function createMetaCard(title, bodyContent) { + const card = document.createElement("div"); + card.className = "planet-meta-card"; + + const titleEl = document.createElement("strong"); + titleEl.textContent = title; + card.appendChild(titleEl); + + if (typeof bodyContent === "string") { + const bodyEl = document.createElement("p"); + bodyEl.className = "planet-text"; + bodyEl.textContent = bodyContent; + card.appendChild(bodyEl); + } else if (bodyContent instanceof Node) { + card.appendChild(bodyContent); + } + + return card; + } + + function createNavButton(label, eventName, detail) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "kab-god-link"; + button.textContent = `${label} ↗`; + button.addEventListener("click", () => { + document.dispatchEvent(new CustomEvent(eventName, { detail })); + }); + return button; + } + + function renderCenterDetail(context) { + const { state, elements, getCubeCenterData, getCenterLetterId, getCenterLetterSymbol, toFiniteNumber } = context; + if (!state.showPrimalPoint) { + return false; + } + + const center = getCubeCenterData(); + if (!center || !elements?.detailNameEl || !elements?.detailSubEl || !elements?.detailBodyEl) { + return false; + } + + const centerLetterId = getCenterLetterId(center); + const centerLetter = getCenterLetterSymbol(center); + const centerLetterText = centerLetterId + ? `${centerLetter ? `${centerLetter} ` : ""}${toDisplayText(centerLetterId)}` + : ""; + const centerElement = toDisplayText(center?.element); + + elements.detailNameEl.textContent = "Primal Point"; + elements.detailSubEl.textContent = [centerLetterText, centerElement].filter(Boolean).join(" · ") || "Center of the Cube"; + + const bodyEl = elements.detailBodyEl; + bodyEl.innerHTML = ""; + + const summary = document.createElement("div"); + summary.className = "planet-text"; + summary.innerHTML = ` +
+
Name
${toDetailValueMarkup(center?.name)}
+
Letter
${toDetailValueMarkup(centerLetterText)}
+
Element
${toDetailValueMarkup(center?.element)}
+
+ `; + bodyEl.appendChild(createMetaCard("Center Details", summary)); + + if (Array.isArray(center?.keywords) && center.keywords.length) { + bodyEl.appendChild(createMetaCard("Keywords", center.keywords.join(", "))); + } + + if (center?.description) { + bodyEl.appendChild(createMetaCard("Description", center.description)); + } + + const associations = center?.associations || {}; + const links = document.createElement("div"); + links.className = "kab-god-links"; + + if (centerLetterId) { + links.appendChild(createNavButton(centerLetter || "!", "nav:alphabet", { + alphabet: "hebrew", + hebrewLetterId: centerLetterId + })); + } + + const centerTrumpNo = toFiniteNumber(associations?.tarotTrumpNumber); + const centerTarotCard = toDisplayText(associations?.tarotCard); + if (centerTarotCard || centerTrumpNo != null) { + links.appendChild(createNavButton(centerTarotCard || `Trump ${centerTrumpNo}`, "nav:tarot-trump", { + cardName: centerTarotCard, + trumpNumber: centerTrumpNo + })); + } + + const centerPathNo = toFiniteNumber(associations?.kabbalahPathNumber); + if (centerPathNo != null) { + links.appendChild(createNavButton(`Path ${centerPathNo}`, "nav:kabbalah-path", { + pathNo: centerPathNo + })); + } + + if (links.childElementCount) { + const linksCard = document.createElement("div"); + linksCard.className = "planet-meta-card"; + linksCard.innerHTML = "Correspondence Links"; + linksCard.appendChild(links); + bodyEl.appendChild(linksCard); + } + + return true; + } + + function renderConnectorDetail(context) { + const { + state, + elements, + walls, + normalizeId, + normalizeLetterKey, + formatDirectionName, + getWallById, + getConnectorById, + getConnectorPathEntry, + getHebrewLetterSymbol, + toFiniteNumber + } = context; + + const connector = getConnectorById(state.selectedConnectorId); + if (!connector || !elements?.detailNameEl || !elements?.detailSubEl || !elements?.detailBodyEl) { + return false; + } + + const fromWallId = normalizeId(connector?.fromWallId); + const toWallId = normalizeId(connector?.toWallId); + const fromWall = getWallById(fromWallId) || walls.find((entry) => normalizeId(entry?.id) === fromWallId) || null; + const toWall = getWallById(toWallId) || walls.find((entry) => normalizeId(entry?.id) === toWallId) || null; + const connectorPath = getConnectorPathEntry(connector); + + const letterId = normalizeLetterKey(connector?.hebrewLetterId); + const letterSymbol = getHebrewLetterSymbol(letterId); + const letterText = letterId + ? `${letterSymbol ? `${letterSymbol} ` : ""}${toDisplayText(letterId)}` + : ""; + + const pathNo = toFiniteNumber(connectorPath?.pathNumber); + const tarotCard = toDisplayText(connectorPath?.tarot?.card); + const tarotTrumpNumber = toFiniteNumber(connectorPath?.tarot?.trumpNumber); + const astrologyType = toDisplayText(connectorPath?.astrology?.type); + const astrologyName = toDisplayText(connectorPath?.astrology?.name); + const astrologySummary = [astrologyType, astrologyName].filter(Boolean).join(": "); + + elements.detailNameEl.textContent = connector?.name || "Mother Connector"; + elements.detailSubEl.textContent = ["Mother Letter", letterText].filter(Boolean).join(" · ") || "Mother Letter"; + + const bodyEl = elements.detailBodyEl; + bodyEl.innerHTML = ""; + + const summary = document.createElement("div"); + summary.className = "planet-text"; + summary.innerHTML = ` +
+
Letter
${toDetailValueMarkup(letterText)}
+
From
${toDetailValueMarkup(fromWall?.name || formatDirectionName(fromWallId))}
+
To
${toDetailValueMarkup(toWall?.name || formatDirectionName(toWallId))}
+
Tarot
${toDetailValueMarkup(tarotCard || (tarotTrumpNumber != null ? `Trump ${tarotTrumpNumber}` : ""))}
+
+ `; + bodyEl.appendChild(createMetaCard("Connector Details", summary)); + + if (astrologySummary) { + bodyEl.appendChild(createMetaCard("Astrology", astrologySummary)); + } + + const links = document.createElement("div"); + links.className = "kab-god-links"; + + if (letterId) { + links.appendChild(createNavButton(letterSymbol || "!", "nav:alphabet", { + alphabet: "hebrew", + hebrewLetterId: letterId + })); + } + + if (pathNo != null) { + links.appendChild(createNavButton(`Path ${pathNo}`, "nav:kabbalah-path", { pathNo })); + } + + if (tarotCard || tarotTrumpNumber != null) { + links.appendChild(createNavButton(tarotCard || `Trump ${tarotTrumpNumber}`, "nav:tarot-trump", { + cardName: tarotCard, + trumpNumber: tarotTrumpNumber + })); + } + + if (links.childElementCount) { + const linksCard = document.createElement("div"); + linksCard.className = "planet-meta-card"; + linksCard.innerHTML = "Correspondence Links"; + linksCard.appendChild(links); + bodyEl.appendChild(linksCard); + } + + return true; + } + + function renderEdgeCard(context, wall, detailBodyEl, wallEdgeDirections) { + const { + state, + normalizeId, + normalizeEdgeId, + formatDirectionName, + formatEdgeName, + getEdgeById, + getEdgesForWall, + getEdges, + getEdgeWalls, + getEdgeLetterId, + getEdgeLetter, + getEdgePathEntry, + getEdgeAstrologySymbol, + toFiniteNumber + } = context; + + const wallId = normalizeId(wall?.id); + const selectedEdge = getEdgeById(state.selectedEdgeId) + || getEdgesForWall(wallId)[0] + || getEdges()[0] + || null; + if (!selectedEdge) { + return; + } + + state.selectedEdgeId = normalizeEdgeId(selectedEdge.id); + + const edgeDirection = wallEdgeDirections.get(normalizeEdgeId(selectedEdge.id)); + const edgeName = edgeDirection + ? formatDirectionName(edgeDirection) + : (toDisplayText(selectedEdge.name) || formatEdgeName(selectedEdge.id)); + const edgeWalls = getEdgeWalls(selectedEdge) + .map((entry) => entry.charAt(0).toUpperCase() + entry.slice(1)) + .join(" · "); + + const edgeLetterId = getEdgeLetterId(selectedEdge); + const edgeLetter = getEdgeLetter(selectedEdge); + const edgePath = getEdgePathEntry(selectedEdge); + const astrologyType = toDisplayText(edgePath?.astrology?.type); + const astrologyName = toDisplayText(edgePath?.astrology?.name); + const astrologySymbol = getEdgeAstrologySymbol(selectedEdge); + const astrologyText = astrologySymbol && astrologyName + ? `${astrologySymbol} ${astrologyName}` + : astrologySymbol || astrologyName; + + const pathNo = toFiniteNumber(edgePath?.pathNumber); + const tarotCard = toDisplayText(edgePath?.tarot?.card); + const tarotTrumpNumber = toFiniteNumber(edgePath?.tarot?.trumpNumber); + + const edgeCard = document.createElement("div"); + edgeCard.className = "planet-meta-card"; + + const title = document.createElement("strong"); + title.textContent = `Edge · ${edgeName}`; + edgeCard.appendChild(title); + + const dlWrap = document.createElement("div"); + dlWrap.className = "planet-text"; + dlWrap.innerHTML = ` +
+
Direction
${toDetailValueMarkup(edgeName)}
+
Edge
${toDetailValueMarkup(edgeWalls)}
+
Letter
${toDetailValueMarkup(edgeLetter)}
+
Astrology
${toDetailValueMarkup(astrologyText)}
+
Tarot
${toDetailValueMarkup(tarotCard)}
+
+ `; + edgeCard.appendChild(dlWrap); + + if (Array.isArray(selectedEdge.keywords) && selectedEdge.keywords.length) { + const keywords = document.createElement("p"); + keywords.className = "planet-text"; + keywords.textContent = selectedEdge.keywords.join(", "); + edgeCard.appendChild(keywords); + } + + if (selectedEdge.description) { + const description = document.createElement("p"); + description.className = "planet-text"; + description.textContent = selectedEdge.description; + edgeCard.appendChild(description); + } + + const links = document.createElement("div"); + links.className = "kab-god-links"; + + if (edgeLetterId) { + links.appendChild(createNavButton(edgeLetter || "!", "nav:alphabet", { + alphabet: "hebrew", + hebrewLetterId: edgeLetterId + })); + } + + if (astrologyType === "zodiac" && astrologyName) { + links.appendChild(createNavButton(astrologyName, "nav:zodiac", { + signId: normalizeId(astrologyName) + })); + } + + if (tarotCard) { + links.appendChild(createNavButton(tarotCard, "nav:tarot-trump", { + cardName: tarotCard, + trumpNumber: tarotTrumpNumber + })); + } + + if (pathNo != null) { + links.appendChild(createNavButton(`Path ${pathNo}`, "nav:kabbalah-path", { pathNo })); + } + + if (links.childElementCount) { + edgeCard.appendChild(links); + } + + detailBodyEl.appendChild(edgeCard); + } + + function renderWallDetail(context) { + const { + state, + elements, + walls, + normalizeId, + normalizeEdgeId, + formatDirectionName, + formatEdgeName, + getWallById, + getEdgesForWall, + getWallEdgeDirections, + getWallFaceLetterId, + getWallFaceLetter, + getHebrewLetterName, + getEdgeLetter, + localDirectionOrder, + localDirectionRank, + onSelectWall, + onSelectEdge + } = context; + + const wall = getWallById(state.selectedWallId) || walls[0] || null; + if (!wall || !elements?.detailNameEl || !elements?.detailSubEl || !elements?.detailBodyEl) { + if (elements?.detailNameEl) { + elements.detailNameEl.textContent = "Cube data unavailable"; + } + if (elements?.detailSubEl) { + elements.detailSubEl.textContent = "Could not load cube dataset."; + } + if (elements?.detailBodyEl) { + elements.detailBodyEl.innerHTML = ""; + } + return; + } + + state.selectedWallId = normalizeId(wall.id); + + const wallPlanet = toDisplayText(wall?.planet) || "!"; + const wallElement = toDisplayText(wall?.element) || "!"; + const wallFaceLetterId = getWallFaceLetterId(wall); + const wallFaceLetter = getWallFaceLetter(wall); + const wallFaceLetterText = wallFaceLetterId + ? `${wallFaceLetter ? `${wallFaceLetter} ` : ""}${toDisplayText(wallFaceLetterId)}` + : ""; + elements.detailNameEl.textContent = `${wall.name} Wall`; + elements.detailSubEl.textContent = `${wallElement} · ${wallPlanet}`; + + const bodyEl = elements.detailBodyEl; + bodyEl.innerHTML = ""; + + const summary = document.createElement("div"); + summary.className = "planet-text"; + summary.innerHTML = ` +
+
Opposite
${toDetailValueMarkup(wall.opposite)}
+
Face Letter
${toDetailValueMarkup(wallFaceLetterText)}
+
Element
${toDetailValueMarkup(wall.element)}
+
Planet
${toDetailValueMarkup(wall.planet)}
+
Archangel
${toDetailValueMarkup(wall.archangel)}
+
+ `; + bodyEl.appendChild(createMetaCard("Wall Details", summary)); + + if (Array.isArray(wall.keywords) && wall.keywords.length) { + bodyEl.appendChild(createMetaCard("Keywords", wall.keywords.join(", "))); + } + + if (wall.description) { + bodyEl.appendChild(createMetaCard("Description", wall.description)); + } + + const wallLinksCard = document.createElement("div"); + wallLinksCard.className = "planet-meta-card"; + wallLinksCard.innerHTML = "Correspondence Links"; + const wallLinks = document.createElement("div"); + wallLinks.className = "kab-god-links"; + + if (wallFaceLetterId) { + const wallFaceLetterName = getHebrewLetterName(wallFaceLetterId) || toDisplayText(wallFaceLetterId); + const faceLetterText = [wallFaceLetter, wallFaceLetterName].filter(Boolean).join(" "); + const faceLetterLabel = faceLetterText + ? `Face ${faceLetterText}` + : "Face !"; + wallLinks.appendChild(createNavButton(faceLetterLabel, "nav:alphabet", { + alphabet: "hebrew", + hebrewLetterId: wallFaceLetterId + })); + } + + const wallAssociations = wall.associations || {}; + if (wallAssociations.planetId) { + wallLinks.appendChild(createNavButton(toDisplayText(wall.planet) || "!", "nav:planet", { + planetId: wallAssociations.planetId + })); + } + + if (wallAssociations.godName) { + wallLinks.appendChild(createNavButton(wallAssociations.godName, "nav:gods", { + godName: wallAssociations.godName + })); + } + + if (wall.oppositeWallId) { + const oppositeWall = getWallById(wall.oppositeWallId); + const internal = document.createElement("button"); + internal.type = "button"; + internal.className = "kab-god-link"; + internal.textContent = `Opposite: ${oppositeWall?.name || wall.oppositeWallId}`; + internal.addEventListener("click", () => { + onSelectWall(wall.oppositeWallId); + }); + wallLinks.appendChild(internal); + } + + if (wallLinks.childElementCount) { + wallLinksCard.appendChild(wallLinks); + bodyEl.appendChild(wallLinksCard); + } + + const edgesCard = document.createElement("div"); + edgesCard.className = "planet-meta-card"; + edgesCard.innerHTML = "Wall Edges"; + + const chips = document.createElement("div"); + chips.className = "kab-chips"; + + const wallEdgeDirections = getWallEdgeDirections(wall); + const wallEdges = getEdgesForWall(wall) + .slice() + .sort((left, right) => { + const leftDirection = wallEdgeDirections.get(normalizeEdgeId(left?.id)); + const rightDirection = wallEdgeDirections.get(normalizeEdgeId(right?.id)); + const leftRank = localDirectionRank[leftDirection] ?? localDirectionOrder.length; + const rightRank = localDirectionRank[rightDirection] ?? localDirectionOrder.length; + if (leftRank !== rightRank) { + return leftRank - rightRank; + } + return normalizeEdgeId(left?.id).localeCompare(normalizeEdgeId(right?.id)); + }); + + wallEdges.forEach((edge) => { + const id = normalizeEdgeId(edge.id); + const chipLetter = getEdgeLetter(edge); + const chipIsMissing = !chipLetter; + const direction = wallEdgeDirections.get(id); + const directionLabel = direction + ? formatDirectionName(direction) + : (toDisplayText(edge.name) || formatEdgeName(edge.id)); + const chip = document.createElement("span"); + chip.className = `kab-chip${id === normalizeEdgeId(state.selectedEdgeId) ? " is-active" : ""}${chipIsMissing ? " is-missing" : ""}`; + chip.setAttribute("role", "button"); + chip.setAttribute("tabindex", "0"); + chip.textContent = `${directionLabel} · ${chipLetter || "!"}`; + + const selectEdge = () => { + onSelectEdge(id, wall.id); + }; + + chip.addEventListener("click", selectEdge); + chip.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + selectEdge(); + } + }); + + chips.appendChild(chip); + }); + + edgesCard.appendChild(chips); + bodyEl.appendChild(edgesCard); + + renderEdgeCard(context, wall, bodyEl, wallEdgeDirections); + } + + function renderDetail(context) { + if (context.state.selectedNodeType === "connector" && renderConnectorDetail(context)) { + return; + } + + if (context.state.selectedNodeType === "center" && renderCenterDetail(context)) { + return; + } + + renderWallDetail(context); + } + + window.CubeDetailUi = { + renderDetail + }; +})(); \ No newline at end of file diff --git a/app/ui-cube.js b/app/ui-cube.js index c17457b..fbc6693 100644 --- a/app/ui-cube.js +++ b/app/ui-cube.js @@ -119,6 +119,7 @@ above: { x: -90, y: 0 }, below: { x: 90, y: 0 } }; + const cubeDetailUi = window.CubeDetailUi || {}; function getElements() { return { @@ -655,6 +656,22 @@ return window.TarotCardImages.resolveTarotCardImage(name) || null; } + function openTarotCardLightbox(cardName, fallbackSrc = "", fallbackLabel = "") { + const openLightbox = window.TarotUiLightbox?.open; + if (typeof openLightbox !== "function") { + return false; + } + + const src = toDisplayText(fallbackSrc) || resolveCardImageUrl(cardName); + if (!src) { + return false; + } + + const label = toDisplayText(cardName) || toDisplayText(fallbackLabel) || "Tarot card"; + openLightbox(src, label); + return true; + } + function applyPlacement(placement) { const fallbackWallId = normalizeId(getWalls()[0]?.id); const nextWallId = normalizeId(placement?.wallId || placement?.wall?.id || state.selectedWallId || fallbackWallId); @@ -678,55 +695,10 @@ return true; } - function createMetaCard(title, bodyContent) { - const card = document.createElement("div"); - card.className = "planet-meta-card"; - - const titleEl = document.createElement("strong"); - titleEl.textContent = title; - card.appendChild(titleEl); - - if (typeof bodyContent === "string") { - const bodyEl = document.createElement("p"); - bodyEl.className = "planet-text"; - bodyEl.textContent = bodyContent; - card.appendChild(bodyEl); - } else if (bodyContent instanceof Node) { - card.appendChild(bodyContent); - } - - return card; - } - - function createNavButton(label, eventName, detail) { - const button = document.createElement("button"); - button.type = "button"; - button.className = "kab-god-link"; - button.textContent = `${label} ↗`; - button.addEventListener("click", () => { - document.dispatchEvent(new CustomEvent(eventName, { detail })); - }); - return button; - } - function toDisplayText(value) { return String(value ?? "").trim(); } - function escapeHtml(value) { - return String(value) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/\"/g, """) - .replace(/'/g, "'"); - } - - function toDetailValueMarkup(value) { - const text = toDisplayText(value); - return text ? escapeHtml(text) : '!'; - } - function renderFaceSvg(containerEl, walls) { if (!containerEl) { return; @@ -819,7 +791,9 @@ defs.appendChild(clipPath); const cardW = 40, cardH = 60; + const wallTarotCard = getWallTarotCard(wall); const cardImg = document.createElementNS(svgNS, "image"); + cardImg.setAttribute("class", "cube-tarot-image cube-face-card"); cardImg.setAttribute("href", cardUrl); cardImg.setAttribute("x", String((faceGlyphAnchor.x - cardW / 2).toFixed(2))); cardImg.setAttribute("y", String((faceGlyphAnchor.y - cardH / 2).toFixed(2))); @@ -828,13 +802,19 @@ cardImg.setAttribute("clip-path", `url(#${clipId})`); cardImg.setAttribute("role", "button"); cardImg.setAttribute("tabindex", "0"); - cardImg.setAttribute("aria-label", `Cube wall ${wall?.name || wallId}`); + cardImg.setAttribute("aria-label", `Open ${wallTarotCard || (wall?.name || wallId)} card image`); cardImg.setAttribute("preserveAspectRatio", "xMidYMid meet"); - cardImg.addEventListener("click", selectWall); + cardImg.addEventListener("click", (event) => { + event.stopPropagation(); + selectWall(); + openTarotCardLightbox(wallTarotCard, cardUrl, `${wall?.name || wallId} wall tarot card`); + }); cardImg.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); + event.stopPropagation(); selectWall(); + openTarotCardLightbox(wallTarotCard, cardUrl, `${wall?.name || wallId} wall tarot card`); } }); svg.appendChild(cardImg); @@ -922,16 +902,40 @@ const labelX = ((from.x + to.x) / 2) + (perpX * shift); const labelY = ((from.y + to.y) / 2) + (perpY * shift); + const selectConnector = () => { + state.selectedNodeType = "connector"; + state.selectedConnectorId = connectorId; + render(getElements()); + }; + if (state.markerDisplayMode === "tarot" && connectorCardUrl) { const cardW = 18; const cardH = 27; + const connectorTarotCard = getConnectorTarotCard(connector); const connectorImg = document.createElementNS(svgNS, "image"); + connectorImg.setAttribute("class", "cube-tarot-image cube-connector-card"); connectorImg.setAttribute("href", connectorCardUrl); connectorImg.setAttribute("x", String((labelX - cardW / 2).toFixed(2))); connectorImg.setAttribute("y", String((labelY - cardH / 2).toFixed(2))); connectorImg.setAttribute("width", String(cardW)); connectorImg.setAttribute("height", String(cardH)); + connectorImg.setAttribute("role", "button"); + connectorImg.setAttribute("tabindex", "0"); + connectorImg.setAttribute("aria-label", `Open ${connectorTarotCard || connector?.name || "connector"} card image`); connectorImg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + connectorImg.addEventListener("click", (event) => { + event.stopPropagation(); + selectConnector(); + openTarotCardLightbox(connectorTarotCard, connectorCardUrl, connector?.name || "Mother connector"); + }); + connectorImg.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.stopPropagation(); + selectConnector(); + openTarotCardLightbox(connectorTarotCard, connectorCardUrl, connector?.name || "Mother connector"); + } + }); group.appendChild(connectorImg); } else { const connectorText = document.createElementNS(svgNS, "text"); @@ -948,12 +952,6 @@ group.appendChild(connectorText); } - const selectConnector = () => { - state.selectedNodeType = "connector"; - state.selectedConnectorId = connectorId; - render(getElements()); - }; - group.addEventListener("click", selectConnector); group.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { @@ -1049,14 +1047,31 @@ if (edgeCardUrl) { const cardW = edgeIsActive ? 28 : 20; const cardH = edgeIsActive ? 42 : 30; + const edgeTarotCard = getEdgeTarotCard(edge); const cardImg = document.createElementNS(svgNS, "image"); - cardImg.setAttribute("class", `cube-direction-card${edgeIsActive ? " is-active" : ""}`); + cardImg.setAttribute("class", `cube-tarot-image cube-direction-card${edgeIsActive ? " is-active" : ""}`); cardImg.setAttribute("href", edgeCardUrl); cardImg.setAttribute("x", String((labelX - cardW / 2).toFixed(2))); cardImg.setAttribute("y", String((labelY - cardH / 2).toFixed(2))); cardImg.setAttribute("width", String(cardW)); cardImg.setAttribute("height", String(cardH)); + cardImg.setAttribute("role", "button"); + cardImg.setAttribute("tabindex", "0"); + cardImg.setAttribute("aria-label", `Open ${edgeTarotCard || edge?.name || "edge"} card image`); cardImg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + cardImg.addEventListener("click", (event) => { + event.stopPropagation(); + selectEdge(); + openTarotCardLightbox(edgeTarotCard, edgeCardUrl, edge?.name || "Cube edge"); + }); + cardImg.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.stopPropagation(); + selectEdge(); + openTarotCardLightbox(edgeTarotCard, edgeCardUrl, edge?.name || "Cube edge"); + } + }); marker.appendChild(cardImg); } else { const markerText = document.createElementNS(svgNS, "text"); @@ -1117,13 +1132,35 @@ if (state.markerDisplayMode === "tarot" && centerCardUrl) { const cardW = 24; const cardH = 36; + const centerTarotCard = getCenterTarotCard(center); const centerImg = document.createElementNS(svgNS, "image"); + centerImg.setAttribute("class", "cube-tarot-image cube-center-card"); centerImg.setAttribute("href", centerCardUrl); centerImg.setAttribute("x", String((CUBE_VIEW_CENTER.x - cardW / 2).toFixed(2))); centerImg.setAttribute("y", String((CUBE_VIEW_CENTER.y - cardH / 2).toFixed(2))); centerImg.setAttribute("width", String(cardW)); centerImg.setAttribute("height", String(cardH)); + centerImg.setAttribute("role", "button"); + centerImg.setAttribute("tabindex", "0"); + centerImg.setAttribute("aria-label", `Open ${centerTarotCard || "Primal Point"} card image`); centerImg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + centerImg.addEventListener("click", (event) => { + event.stopPropagation(); + state.selectedNodeType = "center"; + state.selectedConnectorId = null; + render(getElements()); + openTarotCardLightbox(centerTarotCard, centerCardUrl, "Primal Point"); + }); + centerImg.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.stopPropagation(); + state.selectedNodeType = "center"; + state.selectedConnectorId = null; + render(getElements()); + openTarotCardLightbox(centerTarotCard, centerCardUrl, "Primal Point"); + } + }); centerMarker.appendChild(centerImg); } else { const centerText = document.createElementNS(svgNS, "text"); @@ -1172,287 +1209,43 @@ containerEl.replaceChildren(svg); } - function renderCenterDetail(elements) { - if (!state.showPrimalPoint) { + function selectEdgeById(edgeId, preferredWallId = "") { + const edge = getEdgeById(edgeId); + if (!edge) { return false; } - const center = getCubeCenterData(); - if (!center || !elements?.detailNameEl || !elements?.detailSubEl || !elements?.detailBodyEl) { - return false; - } - - const centerLetterId = getCenterLetterId(center); - const centerLetter = getCenterLetterSymbol(center); - const centerLetterText = centerLetterId - ? `${centerLetter ? `${centerLetter} ` : ""}${toDisplayText(centerLetterId)}` - : ""; - const centerElement = toDisplayText(center?.element); - - elements.detailNameEl.textContent = "Primal Point"; - elements.detailSubEl.textContent = [centerLetterText, centerElement].filter(Boolean).join(" · ") || "Center of the Cube"; - - const bodyEl = elements.detailBodyEl; - bodyEl.innerHTML = ""; - - const summary = document.createElement("div"); - summary.className = "planet-text"; - summary.innerHTML = ` -
-
Name
${toDetailValueMarkup(center?.name)}
-
Letter
${toDetailValueMarkup(centerLetterText)}
-
Element
${toDetailValueMarkup(center?.element)}
-
- `; - bodyEl.appendChild(createMetaCard("Center Details", summary)); - - if (Array.isArray(center?.keywords) && center.keywords.length) { - bodyEl.appendChild(createMetaCard("Keywords", center.keywords.join(", "))); - } - - if (center?.description) { - bodyEl.appendChild(createMetaCard("Description", center.description)); - } - - const associations = center?.associations || {}; - const links = document.createElement("div"); - links.className = "kab-god-links"; - - if (centerLetterId) { - links.appendChild(createNavButton(centerLetter || "!", "nav:alphabet", { - alphabet: "hebrew", - hebrewLetterId: centerLetterId - })); - } - - const centerTrumpNo = toFiniteNumber(associations?.tarotTrumpNumber); - const centerTarotCard = toDisplayText(associations?.tarotCard); - if (centerTarotCard || centerTrumpNo != null) { - links.appendChild(createNavButton(centerTarotCard || `Trump ${centerTrumpNo}`, "nav:tarot-trump", { - cardName: centerTarotCard, - trumpNumber: centerTrumpNo - })); - } - - const centerPathNo = toFiniteNumber(associations?.kabbalahPathNumber); - if (centerPathNo != null) { - links.appendChild(createNavButton(`Path ${centerPathNo}`, "nav:kabbalah-path", { - pathNo: centerPathNo - })); - } - - if (links.childElementCount) { - const linksCard = document.createElement("div"); - linksCard.className = "planet-meta-card"; - linksCard.innerHTML = "Correspondence Links"; - linksCard.appendChild(links); - bodyEl.appendChild(linksCard); + const currentWallId = normalizeId(state.selectedWallId); + const preferredId = normalizeId(preferredWallId); + const edgeWalls = getEdgeWalls(edge); + const nextWallId = preferredId && edgeWalls.includes(preferredId) + ? preferredId + : (edgeWalls.includes(currentWallId) ? currentWallId : (edgeWalls[0] || currentWallId)); + + state.selectedEdgeId = normalizeEdgeId(edge.id); + state.selectedNodeType = "wall"; + state.selectedConnectorId = null; + + if (nextWallId) { + if (nextWallId !== currentWallId) { + state.selectedWallId = nextWallId; + snapRotationToWall(nextWallId); + } else if (!state.selectedWallId) { + state.selectedWallId = nextWallId; + } } + render(getElements()); return true; } - function renderConnectorDetail(elements, walls) { - const connector = getConnectorById(state.selectedConnectorId); - if (!connector || !elements?.detailNameEl || !elements?.detailSubEl || !elements?.detailBodyEl) { - return false; - } - - const fromWallId = normalizeId(connector?.fromWallId); - const toWallId = normalizeId(connector?.toWallId); - const fromWall = getWallById(fromWallId) || walls.find((entry) => normalizeId(entry?.id) === fromWallId) || null; - const toWall = getWallById(toWallId) || walls.find((entry) => normalizeId(entry?.id) === toWallId) || null; - const connectorPath = getConnectorPathEntry(connector); - - const letterId = normalizeLetterKey(connector?.hebrewLetterId); - const letterSymbol = getHebrewLetterSymbol(letterId); - const letterText = letterId - ? `${letterSymbol ? `${letterSymbol} ` : ""}${toDisplayText(letterId)}` - : ""; - - const pathNo = toFiniteNumber(connectorPath?.pathNumber); - const tarotCard = toDisplayText(connectorPath?.tarot?.card); - const tarotTrumpNumber = toFiniteNumber(connectorPath?.tarot?.trumpNumber); - const astrologyType = toDisplayText(connectorPath?.astrology?.type); - const astrologyName = toDisplayText(connectorPath?.astrology?.name); - const astrologySummary = [astrologyType, astrologyName].filter(Boolean).join(": "); - - elements.detailNameEl.textContent = connector?.name || "Mother Connector"; - elements.detailSubEl.textContent = ["Mother Letter", letterText].filter(Boolean).join(" · ") || "Mother Letter"; - - const bodyEl = elements.detailBodyEl; - bodyEl.innerHTML = ""; - - const summary = document.createElement("div"); - summary.className = "planet-text"; - summary.innerHTML = ` -
-
Letter
${toDetailValueMarkup(letterText)}
-
From
${toDetailValueMarkup(fromWall?.name || formatDirectionName(fromWallId))}
-
To
${toDetailValueMarkup(toWall?.name || formatDirectionName(toWallId))}
-
Tarot
${toDetailValueMarkup(tarotCard || (tarotTrumpNumber != null ? `Trump ${tarotTrumpNumber}` : ""))}
-
- `; - bodyEl.appendChild(createMetaCard("Connector Details", summary)); - - if (astrologySummary) { - bodyEl.appendChild(createMetaCard("Astrology", astrologySummary)); - } - - const links = document.createElement("div"); - links.className = "kab-god-links"; - - if (letterId) { - links.appendChild(createNavButton(letterSymbol || "!", "nav:alphabet", { - alphabet: "hebrew", - hebrewLetterId: letterId - })); - } - - if (pathNo != null) { - links.appendChild(createNavButton(`Path ${pathNo}`, "nav:kabbalah-path", { - pathNo - })); - } - - if (tarotCard || tarotTrumpNumber != null) { - links.appendChild(createNavButton(tarotCard || `Trump ${tarotTrumpNumber}`, "nav:tarot-trump", { - cardName: tarotCard, - trumpNumber: tarotTrumpNumber - })); - } - - if (links.childElementCount) { - const linksCard = document.createElement("div"); - linksCard.className = "planet-meta-card"; - linksCard.innerHTML = "Correspondence Links"; - linksCard.appendChild(links); - bodyEl.appendChild(linksCard); - } - - return true; - } - - function renderEdgeCard(wall, detailBodyEl, wallEdgeDirections = new Map()) { - const wallId = normalizeId(wall?.id); - const selectedEdge = getEdgeById(state.selectedEdgeId) - || getEdgesForWall(wallId)[0] - || getEdges()[0] - || null; - if (!selectedEdge) { - return; - } - - state.selectedEdgeId = normalizeEdgeId(selectedEdge.id); - - const edgeDirection = wallEdgeDirections.get(normalizeEdgeId(selectedEdge.id)); - const edgeName = edgeDirection - ? formatDirectionName(edgeDirection) - : (toDisplayText(selectedEdge.name) || formatEdgeName(selectedEdge.id)); - const edgeWalls = getEdgeWalls(selectedEdge) - .map((entry) => entry.charAt(0).toUpperCase() + entry.slice(1)) - .join(" · "); - - const edgeLetterId = getEdgeLetterId(selectedEdge); - const edgeLetter = getEdgeLetter(selectedEdge); - const edgePath = getEdgePathEntry(selectedEdge); - const astrologyType = toDisplayText(edgePath?.astrology?.type); - const astrologyName = toDisplayText(edgePath?.astrology?.name); - const astrologySymbol = getEdgeAstrologySymbol(selectedEdge); - const astrologyText = astrologySymbol && astrologyName - ? `${astrologySymbol} ${astrologyName}` - : astrologySymbol || astrologyName; - - const pathNo = toFiniteNumber(edgePath?.pathNumber); - const tarotCard = toDisplayText(edgePath?.tarot?.card); - const tarotTrumpNumber = toFiniteNumber(edgePath?.tarot?.trumpNumber); - - const edgeCard = document.createElement("div"); - edgeCard.className = "planet-meta-card"; - - const title = document.createElement("strong"); - title.textContent = `Edge · ${edgeName}`; - edgeCard.appendChild(title); - - const dlWrap = document.createElement("div"); - dlWrap.className = "planet-text"; - dlWrap.innerHTML = ` -
-
Direction
${toDetailValueMarkup(edgeName)}
-
Edge
${toDetailValueMarkup(edgeWalls)}
-
Letter
${toDetailValueMarkup(edgeLetter)}
-
Astrology
${toDetailValueMarkup(astrologyText)}
-
Tarot
${toDetailValueMarkup(tarotCard)}
-
- `; - edgeCard.appendChild(dlWrap); - - if (Array.isArray(selectedEdge.keywords) && selectedEdge.keywords.length) { - const keywords = document.createElement("p"); - keywords.className = "planet-text"; - keywords.textContent = selectedEdge.keywords.join(", "); - edgeCard.appendChild(keywords); - } - - if (selectedEdge.description) { - const description = document.createElement("p"); - description.className = "planet-text"; - description.textContent = selectedEdge.description; - edgeCard.appendChild(description); - } - - const links = document.createElement("div"); - links.className = "kab-god-links"; - - if (edgeLetterId) { - links.appendChild(createNavButton(edgeLetter || "!", "nav:alphabet", { - alphabet: "hebrew", - hebrewLetterId: edgeLetterId - })); - } - - if (astrologyType === "zodiac" && astrologyName) { - links.appendChild(createNavButton(astrologyName, "nav:zodiac", { - signId: normalizeId(astrologyName) - })); - } - - if (tarotCard) { - links.appendChild(createNavButton(tarotCard, "nav:tarot-trump", { - cardName: tarotCard, - trumpNumber: tarotTrumpNumber - })); - } - - if (pathNo != null) { - links.appendChild(createNavButton(`Path ${pathNo}`, "nav:kabbalah-path", { - pathNo - })); - } - - if (links.childElementCount) { - edgeCard.appendChild(links); - } - - detailBodyEl.appendChild(edgeCard); - } - function renderDetail(elements, walls) { - if (state.selectedNodeType === "connector" && renderConnectorDetail(elements, walls)) { - return; - } - - if (state.selectedNodeType === "center" && renderCenterDetail(elements)) { - return; - } - - const wall = getWallById(state.selectedWallId) || walls[0] || null; - if (!wall || !elements?.detailNameEl || !elements?.detailSubEl || !elements?.detailBodyEl) { + if (typeof cubeDetailUi.renderDetail !== "function") { if (elements?.detailNameEl) { elements.detailNameEl.textContent = "Cube data unavailable"; } if (elements?.detailSubEl) { - elements.detailSubEl.textContent = "Could not load cube dataset."; + elements.detailSubEl.textContent = "Cube detail renderer missing."; } if (elements?.detailBodyEl) { elements.detailBodyEl.innerHTML = ""; @@ -1460,152 +1253,40 @@ return; } - state.selectedWallId = normalizeId(wall.id); - - const wallPlanet = toDisplayText(wall?.planet) || "!"; - const wallElement = toDisplayText(wall?.element) || "!"; - const wallFaceLetterId = getWallFaceLetterId(wall); - const wallFaceLetter = getWallFaceLetter(wall); - const wallFaceLetterText = wallFaceLetterId - ? `${wallFaceLetter ? `${wallFaceLetter} ` : ""}${toDisplayText(wallFaceLetterId)}` - : ""; - elements.detailNameEl.textContent = `${wall.name} Wall`; - elements.detailSubEl.textContent = `${wallElement} · ${wallPlanet}`; - - const bodyEl = elements.detailBodyEl; - bodyEl.innerHTML = ""; - - const summary = document.createElement("div"); - summary.className = "planet-text"; - summary.innerHTML = ` -
-
Opposite
${toDetailValueMarkup(wall.opposite)}
-
Face Letter
${toDetailValueMarkup(wallFaceLetterText)}
-
Element
${toDetailValueMarkup(wall.element)}
-
Planet
${toDetailValueMarkup(wall.planet)}
-
Archangel
${toDetailValueMarkup(wall.archangel)}
-
- `; - bodyEl.appendChild(createMetaCard("Wall Details", summary)); - - if (Array.isArray(wall.keywords) && wall.keywords.length) { - bodyEl.appendChild(createMetaCard("Keywords", wall.keywords.join(", "))); - } - - if (wall.description) { - bodyEl.appendChild(createMetaCard("Description", wall.description)); - } - - const wallLinksCard = document.createElement("div"); - wallLinksCard.className = "planet-meta-card"; - wallLinksCard.innerHTML = "Correspondence Links"; - const wallLinks = document.createElement("div"); - wallLinks.className = "kab-god-links"; - - if (wallFaceLetterId) { - const wallFaceLetterName = getHebrewLetterName(wallFaceLetterId) || toDisplayText(wallFaceLetterId); - const faceLetterText = [wallFaceLetter, wallFaceLetterName].filter(Boolean).join(" "); - const faceLetterLabel = faceLetterText - ? `Face ${faceLetterText}` - : "Face !"; - wallLinks.appendChild(createNavButton(faceLetterLabel, "nav:alphabet", { - alphabet: "hebrew", - hebrewLetterId: wallFaceLetterId - })); - } - - const wallAssociations = wall.associations || {}; - if (wallAssociations.planetId) { - wallLinks.appendChild(createNavButton(toDisplayText(wall.planet) || "!", "nav:planet", { - planetId: wallAssociations.planetId - })); - } - - if (wallAssociations.godName) { - wallLinks.appendChild(createNavButton(wallAssociations.godName, "nav:gods", { - godName: wallAssociations.godName - })); - } - - if (wall.oppositeWallId) { - const oppositeWall = getWallById(wall.oppositeWallId); - const internal = document.createElement("button"); - internal.type = "button"; - internal.className = "kab-god-link"; - internal.textContent = `Opposite: ${oppositeWall?.name || wall.oppositeWallId}`; - internal.addEventListener("click", () => { - state.selectedWallId = normalizeId(wall.oppositeWallId); - state.selectedEdgeId = normalizeEdgeId(getEdgesForWall(state.selectedWallId)[0]?.id || getEdges()[0]?.id); - state.selectedNodeType = "wall"; - state.selectedConnectorId = null; - snapRotationToWall(state.selectedWallId); - render(getElements()); - }); - wallLinks.appendChild(internal); - } - - if (wallLinks.childElementCount) { - wallLinksCard.appendChild(wallLinks); - bodyEl.appendChild(wallLinksCard); - } - - const edgesCard = document.createElement("div"); - edgesCard.className = "planet-meta-card"; - edgesCard.innerHTML = "Wall Edges"; - - const chips = document.createElement("div"); - chips.className = "kab-chips"; - - const wallEdgeDirections = getWallEdgeDirections(wall); - const wallEdges = getEdgesForWall(wall) - .slice() - .sort((left, right) => { - const leftDirection = wallEdgeDirections.get(normalizeEdgeId(left?.id)); - const rightDirection = wallEdgeDirections.get(normalizeEdgeId(right?.id)); - const leftRank = LOCAL_DIRECTION_RANK[leftDirection] ?? LOCAL_DIRECTION_ORDER.length; - const rightRank = LOCAL_DIRECTION_RANK[rightDirection] ?? LOCAL_DIRECTION_ORDER.length; - if (leftRank !== rightRank) { - return leftRank - rightRank; - } - return normalizeEdgeId(left?.id).localeCompare(normalizeEdgeId(right?.id)); - }); - - wallEdges.forEach((edge) => { - const id = normalizeEdgeId(edge.id); - const chipLetter = getEdgeLetter(edge); - const chipIsMissing = !chipLetter; - const direction = wallEdgeDirections.get(id); - const directionLabel = direction - ? formatDirectionName(direction) - : (toDisplayText(edge.name) || formatEdgeName(edge.id)); - const chip = document.createElement("span"); - chip.className = `kab-chip${id === normalizeEdgeId(state.selectedEdgeId) ? " is-active" : ""}${chipIsMissing ? " is-missing" : ""}`; - chip.setAttribute("role", "button"); - chip.setAttribute("tabindex", "0"); - chip.textContent = `${directionLabel} · ${chipLetter || "!"}`; - - const selectEdge = () => { - state.selectedEdgeId = id; - state.selectedNodeType = "wall"; - state.selectedConnectorId = null; - render(getElements()); - }; - - chip.addEventListener("click", selectEdge); - chip.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - selectEdge(); - } - }); - - chips.appendChild(chip); + cubeDetailUi.renderDetail({ + state, + elements, + walls, + normalizeId, + normalizeEdgeId, + normalizeLetterKey, + formatDirectionName, + formatEdgeName, + toFiniteNumber, + getWallById, + getEdgeById, + getEdges, + getEdgeWalls, + getEdgesForWall, + getWallEdgeDirections, + getConnectorById, + getConnectorPathEntry, + getCubeCenterData, + getCenterLetterId, + getCenterLetterSymbol, + getEdgeLetterId, + getEdgeLetter, + getEdgePathEntry, + getEdgeAstrologySymbol, + getWallFaceLetterId, + getWallFaceLetter, + getHebrewLetterName, + getHebrewLetterSymbol, + localDirectionOrder: LOCAL_DIRECTION_ORDER, + localDirectionRank: LOCAL_DIRECTION_RANK, + onSelectWall: selectWallById, + onSelectEdge: selectEdgeById }); - - edgesCard.appendChild(chips); - bodyEl.appendChild(edgesCard); - - renderEdgeCard(wall, bodyEl, wallEdgeDirections); } function render(elements) { diff --git a/app/ui-home-calendar.js b/app/ui-home-calendar.js new file mode 100644 index 0000000..b88a05b --- /dev/null +++ b/app/ui-home-calendar.js @@ -0,0 +1,116 @@ + (function () { + "use strict"; + + let config = {}; + let lastNowSkyGeoKey = ""; + let lastNowSkySourceUrl = ""; + + function getNowSkyLayerEl() { + return config.nowSkyLayerEl || null; + } + + function getNowPanelEl() { + return config.nowPanelEl || null; + } + + function getCurrentGeo() { + return config.getCurrentGeo?.() || null; + } + + function normalizeGeoForSky(geo) { + const latitude = Number(geo?.latitude); + const longitude = Number(geo?.longitude); + + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + return null; + } + + return { + latitude: Number(latitude.toFixed(4)), + longitude: Number(longitude.toFixed(4)) + }; + } + + function buildStellariumObserverUrl(geo) { + const normalizedGeo = normalizeGeoForSky(geo); + if (!normalizedGeo) { + return ""; + } + + const stellariumUrl = new URL("https://stellarium-web.org/"); + stellariumUrl.searchParams.set("lat", String(normalizedGeo.latitude)); + stellariumUrl.searchParams.set("lng", String(normalizedGeo.longitude)); + stellariumUrl.searchParams.set("elev", "0"); + stellariumUrl.searchParams.set("date", new Date().toISOString()); + stellariumUrl.searchParams.set("az", "0"); + stellariumUrl.searchParams.set("alt", "90"); + stellariumUrl.searchParams.set("fov", "180"); + + return stellariumUrl.toString(); + } + + function syncNowSkyBackground(geo, force = false) { + const nowSkyLayerEl = getNowSkyLayerEl(); + if (!nowSkyLayerEl || !geo) { + return; + } + + const normalizedGeo = normalizeGeoForSky(geo); + if (!normalizedGeo) { + return; + } + + const geoKey = `${normalizedGeo.latitude.toFixed(4)},${normalizedGeo.longitude.toFixed(4)}`; + const stellariumUrl = buildStellariumObserverUrl(normalizedGeo); + if (!stellariumUrl) { + return; + } + + if (!force && geoKey === lastNowSkyGeoKey && stellariumUrl === lastNowSkySourceUrl) { + return; + } + + if (stellariumUrl === lastNowSkySourceUrl) { + return; + } + + nowSkyLayerEl.src = stellariumUrl; + lastNowSkyGeoKey = geoKey; + lastNowSkySourceUrl = stellariumUrl; + } + + function syncNowPanelTheme(referenceDate = new Date()) { + const nowPanelEl = getNowPanelEl(); + if (!nowPanelEl) { + return; + } + + const currentGeo = getCurrentGeo(); + if (!currentGeo || !window.SunCalc) { + nowPanelEl.classList.remove("is-day"); + nowPanelEl.classList.add("is-night"); + return; + } + + const sunPosition = window.SunCalc.getPosition(referenceDate, currentGeo.latitude, currentGeo.longitude); + const sunAltitudeDeg = (sunPosition.altitude * 180) / Math.PI; + const isDaytime = sunAltitudeDeg >= -4; + + nowPanelEl.classList.toggle("is-day", isDaytime); + nowPanelEl.classList.toggle("is-night", !isDaytime); + } + + function init(nextConfig = {}) { + config = { + ...config, + ...nextConfig + }; + } + + window.TarotHomeUi = { + ...(window.TarotHomeUi || {}), + init, + syncNowSkyBackground, + syncNowPanelTheme + }; +})(); diff --git a/app/ui-kabbalah-detail.js b/app/ui-kabbalah-detail.js new file mode 100644 index 0000000..2be6303 --- /dev/null +++ b/app/ui-kabbalah-detail.js @@ -0,0 +1,509 @@ +(function () { + "use strict"; + + const PLANET_ID_TO_LABEL = { + saturn: "Saturn", + jupiter: "Jupiter", + mars: "Mars", + sol: "Sol", + venus: "Venus", + mercury: "Mercury", + luna: "Luna" + }; + + const MINOR_RANK_BY_PLURAL = { + aces: "Ace", + twos: "Two", + threes: "Three", + fours: "Four", + fives: "Five", + sixes: "Six", + sevens: "Seven", + eights: "Eight", + nines: "Nine", + tens: "Ten" + }; + + const MINOR_SUITS = ["Wands", "Cups", "Swords", "Disks"]; + + const DEFAULT_FOUR_QABALISTIC_WORLD_LAYERS = [ + { + slot: "Yod", + letterChar: "י", + hebrewToken: "yod", + world: "Atziluth", + worldLayer: "Archetypal World (God’s Will)", + worldDescription: "World of gods or specific facets or divine qualities.", + soulLayer: "Chiah", + soulTitle: "Life Force", + soulDescription: "The Chiah is the Life Force itself and our true identity as reflection of Supreme Consciousness." + }, + { + slot: "Heh", + letterChar: "ה", + hebrewToken: "he", + world: "Briah", + worldLayer: "Creative World (God’s Love)", + worldDescription: "World of archangels, executors of divine qualities.", + soulLayer: "Neshamah", + soulTitle: "Soul-Intuition", + soulDescription: "The Neshamah is the part of our soul that transcends the thinking process." + }, + { + slot: "Vav", + letterChar: "ו", + hebrewToken: "vav", + world: "Yetzirah", + worldLayer: "Formative World (God’s Mind)", + worldDescription: "World of angels who work under archangelic direction.", + soulLayer: "Ruach", + soulTitle: "Intellect", + soulDescription: "The Ruach is the thinking mind that often dominates attention and identity." + }, + { + slot: "Heh (final)", + letterChar: "ה", + hebrewToken: "he", + world: "Assiah", + worldLayer: "Material World (God’s Creation)", + worldDescription: "World of spirits that infuse matter and energy through specialized duties.", + soulLayer: "Nephesh", + soulTitle: "Animal Soul", + soulDescription: "The Nephesh is instinctive consciousness expressed through appetite, emotion, sex drive, and survival." + } + ]; + + function metaCard(label, value, wide) { + const card = document.createElement("div"); + card.className = wide ? "planet-meta-card kab-wide-card" : "planet-meta-card"; + card.innerHTML = `${label}

${value || "—"}

`; + return card; + } + + function createNavButton(label, eventName, detail) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "kab-god-link"; + btn.textContent = `${label} ↗`; + btn.addEventListener("click", () => { + document.dispatchEvent(new CustomEvent(eventName, { detail })); + }); + return btn; + } + + function appendLinkRow(card, buttons) { + if (!buttons?.length) return; + const row = document.createElement("div"); + row.className = "kab-god-links"; + buttons.forEach((button) => row.appendChild(button)); + card.appendChild(row); + } + + function buildPlanetLuminaryCard(planetValue, context) { + const card = metaCard("Planet / Luminary", planetValue); + const planetId = context.resolvePlanetId(planetValue); + if (planetId) { + appendLinkRow(card, [ + createNavButton(`View ${PLANET_ID_TO_LABEL[planetId] || planetValue} in Planets`, "nav:planet", { planetId }) + ]); + return card; + } + + const zodiacId = context.resolveZodiacId(planetValue); + if (zodiacId) { + appendLinkRow(card, [ + createNavButton(`View ${zodiacId.charAt(0).toUpperCase() + zodiacId.slice(1)} in Zodiac`, "nav:zodiac", { signId: zodiacId }) + ]); + } + return card; + } + + function extractMinorRank(attribution) { + const match = String(attribution || "").match(/\bthe\s+4\s+(aces|twos|threes|fours|fives|sixes|sevens|eights|nines|tens)\b/i); + if (!match) return null; + return MINOR_RANK_BY_PLURAL[(match[1] || "").toLowerCase()] || null; + } + + function buildMinorTarotNames(attribution) { + const rank = extractMinorRank(attribution); + if (!rank) return []; + return MINOR_SUITS.map((suit) => `${rank} of ${suit}`); + } + + function buildTarotAttributionCard(attribution) { + const card = metaCard("Tarot Attribution", attribution); + const minorCards = buildMinorTarotNames(attribution); + if (minorCards.length) { + appendLinkRow(card, minorCards.map((cardName) => + createNavButton(cardName, "nav:tarot-trump", { cardName }) + )); + } + return card; + } + + function buildAstrologyCard(astrology, context) { + const astroText = astrology ? `${astrology.name} (${astrology.type})` : "—"; + const card = metaCard("Astrology", astroText); + if (astrology?.type === "planet") { + const planetId = context.resolvePlanetId(astrology.name); + if (planetId) { + appendLinkRow(card, [ + createNavButton(`View ${PLANET_ID_TO_LABEL[planetId] || astrology.name} in Planets`, "nav:planet", { planetId }) + ]); + } + } else if (astrology?.type === "zodiac") { + const signId = context.resolveZodiacId(astrology.name); + if (signId) { + appendLinkRow(card, [ + createNavButton(`View ${signId.charAt(0).toUpperCase() + signId.slice(1)} in Zodiac`, "nav:zodiac", { signId }) + ]); + } + } + return card; + } + + function buildConnectsCard(path, fromName, toName) { + const card = metaCard("Connects", `${fromName} → ${toName}`); + appendLinkRow(card, [ + createNavButton(`View ${fromName}`, "nav:kabbalah-path", { pathNo: Number(path.connects.from) }), + createNavButton(`View ${toName}`, "nav:kabbalah-path", { pathNo: Number(path.connects.to) }) + ]); + return card; + } + + function buildHebrewLetterCard(letter, context) { + const label = `${letter.char || ""} ${letter.transliteration || ""} — "${letter.meaning || ""}" (${letter.letterType || ""})`; + const card = metaCard("Hebrew Letter", label); + const hebrewLetterId = context.resolveHebrewLetterId(letter.transliteration || letter.char || ""); + + if (hebrewLetterId) { + appendLinkRow(card, [ + createNavButton(`View ${letter.transliteration || letter.char || "Letter"} in Alphabet`, "nav:alphabet", { + alphabet: "hebrew", + hebrewLetterId + }) + ]); + } + + return card; + } + + function buildFourWorldsCard(tree, activeHebrewToken, context) { + const activeToken = String(activeHebrewToken || "").trim().toLowerCase(); + const worldLayers = Array.isArray(context.fourWorldLayers) && context.fourWorldLayers.length + ? context.fourWorldLayers + : DEFAULT_FOUR_QABALISTIC_WORLD_LAYERS; + + const card = document.createElement("div"); + card.className = "planet-meta-card kab-wide-card"; + + const title = document.createElement("strong"); + title.textContent = "Four Qabalistic Worlds & Soul Layers"; + card.appendChild(title); + + const stack = document.createElement("div"); + stack.className = "cal-item-stack"; + + worldLayers.forEach((layer) => { + const row = document.createElement("div"); + row.className = "cal-item-row"; + + const isActive = Boolean(activeToken) && activeToken === String(layer.hebrewToken || "").trim().toLowerCase(); + + const head = document.createElement("div"); + head.className = "cal-item-head"; + head.innerHTML = ` + ${layer.slot}: ${layer.letterChar} — ${layer.world} + ${layer.soulLayer} + `; + row.appendChild(head); + + const worldLine = document.createElement("div"); + worldLine.className = "planet-text"; + worldLine.textContent = `${layer.worldLayer} · ${layer.worldDescription}`; + row.appendChild(worldLine); + + const soulLine = document.createElement("div"); + soulLine.className = "planet-text"; + soulLine.textContent = `${layer.soulLayer} — ${layer.soulTitle}: ${layer.soulDescription}`; + row.appendChild(soulLine); + + const buttonRow = []; + const hebrewLetterId = context.resolveHebrewLetterId(layer.hebrewToken); + if (hebrewLetterId) { + buttonRow.push( + createNavButton(`View ${layer.letterChar} in Alphabet`, "nav:alphabet", { + alphabet: "hebrew", + hebrewLetterId + }) + ); + } + + const linkedPath = context.findPathByHebrewToken(tree, layer.hebrewToken); + if (linkedPath?.pathNumber != null) { + buttonRow.push( + createNavButton(`View Path ${linkedPath.pathNumber}`, "nav:kabbalah-path", { pathNo: Number(linkedPath.pathNumber) }) + ); + } + + appendLinkRow(row, buttonRow); + + if (isActive) { + row.style.borderColor = "#818cf8"; + } + + stack.appendChild(row); + }); + + card.appendChild(stack); + return card; + } + + function splitCorrespondenceNames(value) { + return String(value || "") + .split(/,|;|·|\/|\bor\b|\band\b|\+/i) + .map((item) => item.trim()) + .filter(Boolean); + } + + function uniqueNames(values) { + const seen = new Set(); + const output = []; + values.forEach((name) => { + const key = String(name || "").toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + output.push(name); + }); + return output; + } + + function godLinksCard(label, names, pathNo, metaText) { + const card = document.createElement("div"); + card.className = "planet-meta-card"; + + const title = document.createElement("strong"); + title.textContent = label; + card.appendChild(title); + + if (metaText) { + const meta = document.createElement("p"); + meta.className = "planet-text kab-god-meta"; + meta.textContent = metaText; + card.appendChild(meta); + } + + const row = document.createElement("div"); + row.className = "kab-god-links"; + + names.forEach((name) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "kab-god-link"; + btn.textContent = name; + btn.addEventListener("click", () => { + document.dispatchEvent(new CustomEvent("nav:gods", { + detail: { godName: name, pathNo: Number(pathNo) } + })); + }); + row.appendChild(btn); + }); + + card.appendChild(row); + return card; + } + + function appendGodsCards(pathNo, elements, godsData) { + const gd = godsData?.[String(pathNo)]; + if (!gd) return; + + const hasAny = gd.greek || gd.roman || gd.egyptian || gd.egyptianPractical + || gd.elohim || gd.archangel || gd.angelicOrder; + if (!hasAny) return; + + const sep = document.createElement("div"); + sep.className = "planet-meta-card kab-wide-card"; + sep.innerHTML = `Divine Correspondences`; + elements.detailBodyEl.appendChild(sep); + + const greekNames = uniqueNames(splitCorrespondenceNames(gd.greek)); + const romanNames = uniqueNames(splitCorrespondenceNames(gd.roman)); + const egyptNames = uniqueNames([ + ...splitCorrespondenceNames(gd.egyptianPractical), + ...splitCorrespondenceNames(gd.egyptian) + ]); + + if (greekNames.length) { + elements.detailBodyEl.appendChild(godLinksCard("Greek", greekNames, pathNo)); + } + if (romanNames.length) { + elements.detailBodyEl.appendChild(godLinksCard("Roman", romanNames, pathNo)); + } + if (egyptNames.length) { + elements.detailBodyEl.appendChild(godLinksCard("Egyptian", egyptNames, pathNo)); + } + + if (gd.elohim) { + const g = gd.elohim; + const meta = `${g.hebrew}${g.meaning ? " — " + g.meaning : ""}`; + elements.detailBodyEl.appendChild(godLinksCard( + "God Name", + uniqueNames(splitCorrespondenceNames(g.transliteration)), + pathNo, + meta + )); + } + if (gd.archangel) { + const a = gd.archangel; + const meta = `${a.hebrew}`; + elements.detailBodyEl.appendChild(godLinksCard( + "Archangel", + uniqueNames(splitCorrespondenceNames(a.transliteration)), + pathNo, + meta + )); + } + if (gd.angelicOrder) { + const o = gd.angelicOrder; + elements.detailBodyEl.appendChild(metaCard( + "Angelic Order", + `${o.hebrew} ${o.transliteration}${o.meaning ? " — " + o.meaning : ""}` + )); + } + } + + function renderSephiraDetail(context) { + const { seph, tree, elements } = context; + elements.detailNameEl.textContent = `${seph.number} · ${seph.name}`; + elements.detailSubEl.textContent = + [seph.nameHebrew, seph.translation, seph.planet].filter(Boolean).join(" · "); + + elements.detailBodyEl.innerHTML = ""; + elements.detailBodyEl.appendChild(buildFourWorldsCard(tree, "", context)); + elements.detailBodyEl.appendChild(buildPlanetLuminaryCard(seph.planet, context)); + elements.detailBodyEl.appendChild(metaCard("Intelligence", seph.intelligence)); + elements.detailBodyEl.appendChild(buildTarotAttributionCard(seph.tarot)); + + if (seph.description) { + elements.detailBodyEl.appendChild( + metaCard(seph.name, seph.description, true) + ); + } + + const connected = tree.paths.filter( + (entry) => entry.connects.from === seph.number || entry.connects.to === seph.number + ); + if (connected.length) { + const card = document.createElement("div"); + card.className = "planet-meta-card kab-wide-card"; + const chips = connected.map((entry) => + `` + + `${entry.hebrewLetter?.char || ""} ${entry.pathNumber}` + + `` + ).join(""); + card.innerHTML = `Connected Paths
${chips}
`; + elements.detailBodyEl.appendChild(card); + + card.querySelectorAll(".kab-chip[data-path]").forEach((chip) => { + const handler = () => { + const path = tree.paths.find((entry) => entry.pathNumber === Number(chip.dataset.path)); + if (path && typeof context.onPathSelect === "function") { + context.onPathSelect(path); + } + }; + chip.addEventListener("click", handler); + chip.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handler(); + } + }); + }); + } + + appendGodsCards(seph.number, elements, context.godsData); + } + + function renderPathDetail(context) { + const { path, tree, elements } = context; + const letter = path.hebrewLetter || {}; + const fromName = tree.sephiroth.find((entry) => entry.number === path.connects.from)?.name || path.connects.from; + const toName = tree.sephiroth.find((entry) => entry.number === path.connects.to)?.name || path.connects.to; + const astro = path.astrology ? `${path.astrology.name} (${path.astrology.type})` : "—"; + const tarotStr = path.tarot?.card + ? `${path.tarot.card}${path.tarot.trumpNumber != null ? " · Trump " + path.tarot.trumpNumber : ""}` + : "—"; + + elements.detailNameEl.textContent = + `Path ${path.pathNumber} · ${letter.char || ""} ${letter.transliteration || ""}`; + elements.detailSubEl.textContent = [path.tarot?.card, astro].filter(Boolean).join(" · "); + + elements.detailBodyEl.innerHTML = ""; + elements.detailBodyEl.appendChild(buildFourWorldsCard(tree, context.activeHebrewToken, context)); + elements.detailBodyEl.appendChild(buildConnectsCard(path, fromName, toName)); + elements.detailBodyEl.appendChild(buildHebrewLetterCard(letter, context)); + elements.detailBodyEl.appendChild(buildAstrologyCard(path.astrology, context)); + + const tarotMetaCard = document.createElement("div"); + tarotMetaCard.className = "planet-meta-card"; + const tarotLabel = document.createElement("strong"); + tarotLabel.textContent = "Tarot"; + tarotMetaCard.appendChild(tarotLabel); + if (path.tarot?.card && path.tarot.trumpNumber != null) { + const tarotBtn = document.createElement("button"); + tarotBtn.type = "button"; + tarotBtn.className = "kab-tarot-link"; + tarotBtn.textContent = `${path.tarot.card} · Trump ${path.tarot.trumpNumber}`; + tarotBtn.title = "Open in Tarot section"; + tarotBtn.addEventListener("click", () => { + document.dispatchEvent(new CustomEvent("kab:view-trump", { + detail: { trumpNumber: path.tarot.trumpNumber } + })); + }); + tarotMetaCard.appendChild(tarotBtn); + } else { + const tarotP = document.createElement("p"); + tarotP.className = "planet-text"; + tarotP.textContent = tarotStr || "—"; + tarotMetaCard.appendChild(tarotP); + } + elements.detailBodyEl.appendChild(tarotMetaCard); + + elements.detailBodyEl.appendChild(metaCard("Intelligence", path.intelligence)); + elements.detailBodyEl.appendChild(metaCard("Pillar", path.pillar)); + + if (path.description) { + const desc = document.createElement("div"); + desc.className = "planet-meta-card kab-wide-card"; + desc.innerHTML = + `Path ${path.pathNumber} — Sefer Yetzirah` + + `

${path.description.replace(/\n/g, "

")}

`; + elements.detailBodyEl.appendChild(desc); + } + + appendGodsCards(path.pathNumber, elements, context.godsData); + } + + function renderRoseLandingIntro(roseElements) { + if (!roseElements?.detailNameEl || !roseElements?.detailSubEl || !roseElements?.detailBodyEl) { + return; + } + + roseElements.detailNameEl.textContent = "Rosicrucian Cross"; + roseElements.detailSubEl.textContent = "Select a Hebrew letter petal to explore a Tree path"; + + const introCard = document.createElement("div"); + introCard.className = "planet-meta-card kab-wide-card"; + introCard.innerHTML = "Interactive Path Crosswalk" + + "

Each petal maps to one of the 22 Hebrew letter paths (11-32). Click any large Hebrew letter to view astrology, tarot, and path intelligence details.

"; + + roseElements.detailBodyEl.innerHTML = ""; + roseElements.detailBodyEl.appendChild(introCard); + } + + window.KabbalahDetailUi = { + renderSephiraDetail, + renderPathDetail, + renderRoseLandingIntro + }; +})(); \ No newline at end of file diff --git a/app/ui-kabbalah.js b/app/ui-kabbalah.js index 6ade11d..f9fbb90 100644 --- a/app/ui-kabbalah.js +++ b/app/ui-kabbalah.js @@ -61,6 +61,8 @@ selectedPathNumber: null }; + const kabbalahDetailUi = window.KabbalahDetailUi || {}; + const PLANET_NAME_TO_ID = { saturn: "saturn", jupiter: "jupiter", @@ -88,31 +90,6 @@ pisces: "pisces" }; - const PLANET_ID_TO_LABEL = { - saturn: "Saturn", - jupiter: "Jupiter", - mars: "Mars", - sol: "Sol", - venus: "Venus", - mercury: "Mercury", - luna: "Luna" - }; - - const MINOR_RANK_BY_PLURAL = { - aces: "Ace", - twos: "Two", - threes: "Three", - fours: "Four", - fives: "Five", - sixes: "Six", - sevens: "Seven", - eights: "Eight", - nines: "Nine", - tens: "Ten" - }; - - const MINOR_SUITS = ["Wands", "Cups", "Swords", "Disks"]; - const HEBREW_LETTER_ALIASES = { aleph: "alef", alef: "alef", @@ -274,6 +251,22 @@ pathLetterToggleEl: document.getElementById("kab-path-letter-toggle"), pathNumberToggleEl: document.getElementById("kab-path-number-toggle"), pathTarotToggleEl: document.getElementById("kab-path-tarot-toggle"), + roseCrossContainerEl: document.getElementById("kab-rose-cross-container"), + roseDetailNameEl: document.getElementById("kab-rose-detail-name"), + roseDetailSubEl: document.getElementById("kab-rose-detail-sub"), + roseDetailBodyEl: document.getElementById("kab-rose-detail-body"), + }; + } + + function getRoseDetailElements(elements) { + if (!elements) { + return null; + } + + return { + detailNameEl: elements.roseDetailNameEl, + detailSubEl: elements.roseDetailSubEl, + detailBodyEl: elements.roseDetailBodyEl }; } @@ -286,6 +279,37 @@ return window.TarotCardImages.resolveTarotCardImage(cardName); } + function getSvgImageHref(imageEl) { + if (!(imageEl instanceof SVGElement)) { + return ""; + } + + return String( + imageEl.getAttribute("href") + || imageEl.getAttributeNS("http://www.w3.org/1999/xlink", "href") + || "" + ).trim(); + } + + function openTarotLightboxForPath(path, fallbackSrc = "") { + const openLightbox = window.TarotUiLightbox?.open; + if (typeof openLightbox !== "function") { + return false; + } + + const cardName = String(path?.tarot?.card || "").trim(); + const src = String(fallbackSrc || resolvePathTarotImage(path) || "").trim(); + if (!src) { + return false; + } + + const fallbackLabel = Number.isFinite(Number(path?.pathNumber)) + ? `Path ${path.pathNumber} tarot card` + : "Path tarot card"; + openLightbox(src, cardName || fallbackLabel); + return true; + } + function getPathLabel(path) { const glyph = String(path?.hebrewLetter?.char || "").trim(); const pathNumber = Number(path?.pathNumber); @@ -312,6 +336,8 @@ return el; } + // Rosicrucian cross SVG construction lives in app/ui-rosicrucian-cross.js. + // ─── build the full SVG tree ───────────────────────────────────────────────── function buildTreeSVG(tree) { const svg = svgEl("svg", { @@ -491,14 +517,6 @@ return svg; } - // ─── detail panel helpers ─────────────────────────────────────────────────── - function metaCard(label, value, wide) { - const card = document.createElement("div"); - card.className = wide ? "planet-meta-card kab-wide-card" : "planet-meta-card"; - card.innerHTML = `${label}

${value || "—"}

`; - return card; - } - function normalizeText(value) { return String(value || "").trim().toLowerCase(); } @@ -569,114 +587,6 @@ return null; } - function createNavButton(label, eventName, detail) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "kab-god-link"; - btn.textContent = `${label} ↗`; - btn.addEventListener("click", () => { - document.dispatchEvent(new CustomEvent(eventName, { detail })); - }); - return btn; - } - - function appendLinkRow(card, buttons) { - if (!buttons?.length) return; - const row = document.createElement("div"); - row.className = "kab-god-links"; - buttons.forEach((button) => row.appendChild(button)); - card.appendChild(row); - } - - function buildPlanetLuminaryCard(planetValue) { - const card = metaCard("Planet / Luminary", planetValue); - const planetId = resolvePlanetId(planetValue); - if (planetId) { - appendLinkRow(card, [ - createNavButton(`View ${PLANET_ID_TO_LABEL[planetId] || planetValue} in Planets`, "nav:planet", { planetId }) - ]); - return card; - } - - const zodiacId = resolveZodiacId(planetValue); - if (zodiacId) { - appendLinkRow(card, [ - createNavButton(`View ${zodiacId.charAt(0).toUpperCase() + zodiacId.slice(1)} in Zodiac`, "nav:zodiac", { signId: zodiacId }) - ]); - } - return card; - } - - function extractMinorRank(attribution) { - const match = String(attribution || "").match(/\bthe\s+4\s+(aces|twos|threes|fours|fives|sixes|sevens|eights|nines|tens)\b/i); - if (!match) return null; - return MINOR_RANK_BY_PLURAL[(match[1] || "").toLowerCase()] || null; - } - - function buildMinorTarotNames(attribution) { - const rank = extractMinorRank(attribution); - if (!rank) return []; - return MINOR_SUITS.map((suit) => `${rank} of ${suit}`); - } - - function buildTarotAttributionCard(attribution) { - const card = metaCard("Tarot Attribution", attribution); - const minorCards = buildMinorTarotNames(attribution); - if (minorCards.length) { - appendLinkRow(card, minorCards.map((cardName) => - createNavButton(cardName, "nav:tarot-trump", { cardName }) - )); - } - return card; - } - - function buildAstrologyCard(astrology) { - const astroText = astrology ? `${astrology.name} (${astrology.type})` : "—"; - const card = metaCard("Astrology", astroText); - if (astrology?.type === "planet") { - const planetId = resolvePlanetId(astrology.name); - if (planetId) { - appendLinkRow(card, [ - createNavButton(`View ${PLANET_ID_TO_LABEL[planetId] || astrology.name} in Planets`, "nav:planet", { planetId }) - ]); - } - } else if (astrology?.type === "zodiac") { - const signId = resolveZodiacId(astrology.name); - if (signId) { - appendLinkRow(card, [ - createNavButton(`View ${signId.charAt(0).toUpperCase() + signId.slice(1)} in Zodiac`, "nav:zodiac", { signId }) - ]); - } - } - return card; - } - - function buildConnectsCard(path, fromName, toName) { - const card = metaCard("Connects", `${fromName} → ${toName}`); - appendLinkRow(card, [ - createNavButton(`View ${fromName}`, "nav:kabbalah-path", { pathNo: Number(path.connects.from) }), - createNavButton(`View ${toName}`, "nav:kabbalah-path", { pathNo: Number(path.connects.to) }) - ]); - return card; - } - - function buildHebrewLetterCard(letter) { - const label = `${letter.char || ""} ${letter.transliteration || ""} — "${letter.meaning || ""}" (${letter.letterType || ""})`; - const card = metaCard("Hebrew Letter", label); - const hebrewLetterId = resolveHebrewLetterId(letter.transliteration || letter.char || ""); - - if (hebrewLetterId) { - appendLinkRow(card, [ - createNavButton(`View ${letter.transliteration || letter.char || "Letter"} in Alphabet`, "nav:alphabet", { - alphabet: "hebrew", - hebrewLetterId - }) - ]); - } - - return card; - } - function findPathByHebrewToken(tree, hebrewToken) { const canonicalToken = HEBREW_LETTER_ALIASES[normalizeLetterToken(hebrewToken)] || normalizeLetterToken(hebrewToken); if (!canonicalToken) { @@ -691,200 +601,27 @@ }) || null; } - function buildFourWorldsCard(tree, activeLetterToken = "") { - const activeToken = HEBREW_LETTER_ALIASES[normalizeLetterToken(activeLetterToken)] || normalizeLetterToken(activeLetterToken); - const worldLayers = Array.isArray(state.fourWorldLayers) && state.fourWorldLayers.length - ? state.fourWorldLayers - : DEFAULT_FOUR_QABALISTIC_WORLD_LAYERS; - - const card = document.createElement("div"); - card.className = "planet-meta-card kab-wide-card"; - - const title = document.createElement("strong"); - title.textContent = "Four Qabalistic Worlds & Soul Layers"; - card.appendChild(title); - - const stack = document.createElement("div"); - stack.className = "cal-item-stack"; - - worldLayers.forEach((layer) => { - const row = document.createElement("div"); - row.className = "cal-item-row"; - - const isActive = Boolean(activeToken) && activeToken === layer.hebrewToken; - - const head = document.createElement("div"); - head.className = "cal-item-head"; - head.innerHTML = ` - ${layer.slot}: ${layer.letterChar} — ${layer.world} - ${layer.soulLayer} - `; - row.appendChild(head); - - const worldLine = document.createElement("div"); - worldLine.className = "planet-text"; - worldLine.textContent = `${layer.worldLayer} · ${layer.worldDescription}`; - row.appendChild(worldLine); - - const soulLine = document.createElement("div"); - soulLine.className = "planet-text"; - soulLine.textContent = `${layer.soulLayer} — ${layer.soulTitle}: ${layer.soulDescription}`; - row.appendChild(soulLine); - - const buttonRow = []; - const hebrewLetterId = resolveHebrewLetterId(layer.hebrewToken); - if (hebrewLetterId) { - buttonRow.push( - createNavButton(`View ${layer.letterChar} in Alphabet`, "nav:alphabet", { - alphabet: "hebrew", - hebrewLetterId - }) - ); - } - - const linkedPath = findPathByHebrewToken(tree, layer.hebrewToken); - if (linkedPath?.pathNumber != null) { - buttonRow.push( - createNavButton(`View Path ${linkedPath.pathNumber}`, "nav:kabbalah-path", { pathNo: Number(linkedPath.pathNumber) }) - ); - } - - appendLinkRow(row, buttonRow); - - if (isActive) { - row.style.borderColor = "#818cf8"; - } - - stack.appendChild(row); - }); - - card.appendChild(stack); - return card; - } - - function splitCorrespondenceNames(value) { - return String(value || "") - .split(/,|;|·|\/|\bor\b|\band\b|\+/i) - .map((item) => item.trim()) - .filter(Boolean); - } - - function uniqueNames(values) { - const seen = new Set(); - const output = []; - values.forEach((name) => { - const key = String(name || "").toLowerCase(); - if (seen.has(key)) return; - seen.add(key); - output.push(name); - }); - return output; - } - - function godLinksCard(label, names, pathNo, metaText) { - const card = document.createElement("div"); - card.className = "planet-meta-card"; - - const title = document.createElement("strong"); - title.textContent = label; - card.appendChild(title); - - if (metaText) { - const meta = document.createElement("p"); - meta.className = "planet-text kab-god-meta"; - meta.textContent = metaText; - card.appendChild(meta); - } - - const row = document.createElement("div"); - row.className = "kab-god-links"; - - names.forEach((name) => { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "kab-god-link"; - btn.textContent = name; - btn.addEventListener("click", () => { - document.dispatchEvent(new CustomEvent("nav:gods", { - detail: { godName: name, pathNo: Number(pathNo) } - })); - }); - row.appendChild(btn); - }); - - card.appendChild(row); - return card; + function getDetailRenderContext(tree, elements, extra = {}) { + return { + tree, + elements, + godsData: state.godsData, + fourWorldLayers: state.fourWorldLayers, + resolvePlanetId, + resolveZodiacId, + resolveHebrewLetterId, + findPathByHebrewToken, + ...extra + }; } function clearHighlights() { document.querySelectorAll(".kab-node, .kab-node-glow") .forEach(el => el.classList.remove("kab-node-active")); - document.querySelectorAll(".kab-path-hit, .kab-path-line, .kab-path-lbl, .kab-path-tarot") + document.querySelectorAll(".kab-path-hit, .kab-path-line, .kab-path-lbl, .kab-path-tarot, .kab-rose-petal") .forEach(el => el.classList.remove("kab-path-active")); } - // ─── helper: append divine correspondences from gods.json ───────────────────── - function appendGodsCards(pathNo, elements) { - const gd = state.godsData[String(pathNo)]; - if (!gd) return; - - const hasAny = gd.greek || gd.roman || gd.egyptian || gd.egyptianPractical - || gd.elohim || gd.archangel || gd.angelicOrder; - if (!hasAny) return; - - const sep = document.createElement("div"); - sep.className = "planet-meta-card kab-wide-card"; - sep.innerHTML = `Divine Correspondences`; - elements.detailBodyEl.appendChild(sep); - - const greekNames = uniqueNames(splitCorrespondenceNames(gd.greek)); - const romanNames = uniqueNames(splitCorrespondenceNames(gd.roman)); - const egyptNames = uniqueNames([ - ...splitCorrespondenceNames(gd.egyptianPractical), - ...splitCorrespondenceNames(gd.egyptian) - ]); - - if (greekNames.length) { - elements.detailBodyEl.appendChild(godLinksCard("Greek", greekNames, pathNo)); - } - if (romanNames.length) { - elements.detailBodyEl.appendChild(godLinksCard("Roman", romanNames, pathNo)); - } - if (egyptNames.length) { - elements.detailBodyEl.appendChild(godLinksCard("Egyptian", egyptNames, pathNo)); - } - - if (gd.elohim) { - const g = gd.elohim; - const meta = `${g.hebrew}${g.meaning ? " — " + g.meaning : ""}`; - elements.detailBodyEl.appendChild(godLinksCard( - "God Name", - uniqueNames(splitCorrespondenceNames(g.transliteration)), - pathNo, - meta - )); - } - if (gd.archangel) { - const a = gd.archangel; - const meta = `${a.hebrew}`; - elements.detailBodyEl.appendChild(godLinksCard( - "Archangel", - uniqueNames(splitCorrespondenceNames(a.transliteration)), - pathNo, - meta - )); - } - if (gd.angelicOrder) { - const o = gd.angelicOrder; - elements.detailBodyEl.appendChild(metaCard( - "Angelic Order", - `${o.hebrew} ${o.transliteration}${o.meaning ? " — " + o.meaning : ""}` - )); - } - - } - - // ─── render sephira detail ─────────────────────────────────────────────────── function renderSephiraDetail(seph, tree, elements) { state.selectedSephiraNumber = Number(seph?.number); state.selectedPathNumber = null; @@ -893,53 +630,14 @@ document.querySelectorAll(`.kab-node[data-sephira="${seph.number}"], .kab-node-glow[data-sephira="${seph.number}"]`) .forEach(el => el.classList.add("kab-node-active")); - elements.detailNameEl.textContent = `${seph.number} · ${seph.name}`; - elements.detailSubEl.textContent = - [seph.nameHebrew, seph.translation, seph.planet].filter(Boolean).join(" · "); - - elements.detailBodyEl.innerHTML = ""; - elements.detailBodyEl.appendChild(buildFourWorldsCard(tree)); - elements.detailBodyEl.appendChild(buildPlanetLuminaryCard(seph.planet)); - elements.detailBodyEl.appendChild(metaCard("Intelligence", seph.intelligence)); - elements.detailBodyEl.appendChild(buildTarotAttributionCard(seph.tarot)); - - if (seph.description) { - elements.detailBodyEl.appendChild( - metaCard(seph.name, seph.description, true) - ); + if (typeof kabbalahDetailUi.renderSephiraDetail === "function") { + kabbalahDetailUi.renderSephiraDetail(getDetailRenderContext(tree, elements, { + seph, + onPathSelect: (path) => renderPathDetail(path, tree, elements) + })); } - - // Quick-access chips for connected paths - const connected = tree.paths.filter( - p => p.connects.from === seph.number || p.connects.to === seph.number - ); - if (connected.length) { - const card = document.createElement("div"); - card.className = "planet-meta-card kab-wide-card"; - const chips = connected.map(p => - `` - + `${p.hebrewLetter?.char || ""} ${p.pathNumber}` - + `` - ).join(""); - card.innerHTML = `Connected Paths
${chips}
`; - elements.detailBodyEl.appendChild(card); - - card.querySelectorAll(".kab-chip[data-path]").forEach(chip => { - const handler = () => { - const path = tree.paths.find(p => p.pathNumber === Number(chip.dataset.path)); - if (path) renderPathDetail(path, tree, elements); - }; - chip.addEventListener("click", handler); - chip.addEventListener("keydown", e => { - if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handler(); } - }); - }); - } - - appendGodsCards(seph.number, elements); } - // ─── render path detail ────────────────────────────────────────────────────── function renderPathDetail(path, tree, elements) { state.selectedPathNumber = Number(path?.pathNumber); state.selectedSephiraNumber = null; @@ -948,70 +646,30 @@ document.querySelectorAll(`[data-path="${path.pathNumber}"]`) .forEach(el => el.classList.add("kab-path-active")); - const letter = path.hebrewLetter || {}; - const fromName = tree.sephiroth.find(s => s.number === path.connects.from)?.name || path.connects.from; - const toName = tree.sephiroth.find(s => s.number === path.connects.to)?.name || path.connects.to; - const astro = path.astrology ? `${path.astrology.name} (${path.astrology.type})` : "—"; - const tarotStr = path.tarot?.card - ? `${path.tarot.card}${path.tarot.trumpNumber != null ? " · Trump " + path.tarot.trumpNumber : ""}` - : "—"; - - elements.detailNameEl.textContent = - `Path ${path.pathNumber} · ${letter.char || ""} ${letter.transliteration || ""}`; - elements.detailSubEl.textContent = [path.tarot?.card, astro].filter(Boolean).join(" · "); - - elements.detailBodyEl.innerHTML = ""; - elements.detailBodyEl.appendChild(buildFourWorldsCard(tree, letter.transliteration || letter.char || "")); - elements.detailBodyEl.appendChild(buildConnectsCard(path, fromName, toName)); - elements.detailBodyEl.appendChild(buildHebrewLetterCard(letter)); - elements.detailBodyEl.appendChild(buildAstrologyCard(path.astrology)); - - // Tarot card — clickable if a trump card is associated - const tarotMetaCard = document.createElement("div"); - tarotMetaCard.className = "planet-meta-card"; - const tarotLabel = document.createElement("strong"); - tarotLabel.textContent = "Tarot"; - tarotMetaCard.appendChild(tarotLabel); - if (path.tarot?.card && path.tarot.trumpNumber != null) { - const tarotBtn = document.createElement("button"); - tarotBtn.type = "button"; - tarotBtn.className = "kab-tarot-link"; - tarotBtn.textContent = `${path.tarot.card} · Trump ${path.tarot.trumpNumber}`; - tarotBtn.title = "Open in Tarot section"; - tarotBtn.addEventListener("click", () => { - document.dispatchEvent(new CustomEvent("kab:view-trump", { - detail: { trumpNumber: path.tarot.trumpNumber } - })); - }); - tarotMetaCard.appendChild(tarotBtn); - } else { - const tarotP = document.createElement("p"); - tarotP.className = "planet-text"; - tarotP.textContent = tarotStr || "—"; - tarotMetaCard.appendChild(tarotP); + if (typeof kabbalahDetailUi.renderPathDetail === "function") { + kabbalahDetailUi.renderPathDetail(getDetailRenderContext(tree, elements, { + path, + activeHebrewToken: normalizeLetterToken(path?.hebrewLetter?.transliteration || path?.hebrewLetter?.char || "") + })); } - elements.detailBodyEl.appendChild(tarotMetaCard); - - elements.detailBodyEl.appendChild(metaCard("Intelligence", path.intelligence)); - elements.detailBodyEl.appendChild(metaCard("Pillar", path.pillar)); - - if (path.description) { - const desc = document.createElement("div"); - desc.className = "planet-meta-card kab-wide-card"; - desc.innerHTML = - `Path ${path.pathNumber} — Sefer Yetzirah` - + `

${path.description.replace(/\n/g, "

")}

`; - elements.detailBodyEl.appendChild(desc); - } - - appendGodsCards(path.pathNumber, elements); } function bindTreeInteractions(svg, tree, elements) { // Delegate clicks via element's own data attributes svg.addEventListener("click", e => { - const sephNum = e.target.dataset?.sephira; - const pathNum = e.target.dataset?.path; + const clickTarget = e.target instanceof Element ? e.target : null; + const sephNum = clickTarget?.dataset?.sephira; + const pathNum = clickTarget?.dataset?.path; + + if (pathNum != null && clickTarget?.classList?.contains("kab-path-tarot")) { + const p = tree.paths.find(x => x.pathNumber === Number(pathNum)); + if (p) { + openTarotLightboxForPath(p, getSvgImageHref(clickTarget)); + renderPathDetail(p, tree, elements); + } + return; + } + if (sephNum != null) { const s = tree.sephiroth.find(x => x.number === Number(sephNum)); if (s) renderSephiraDetail(s, tree, elements); @@ -1027,7 +685,12 @@ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); const p = tree.paths.find(x => x.pathNumber === Number(el.dataset.path)); - if (p) renderPathDetail(p, tree, elements); + if (p) { + if (el.classList.contains("kab-path-tarot")) { + openTarotLightboxForPath(p, getSvgImageHref(el)); + } + renderPathDetail(p, tree, elements); + } } }); }); @@ -1044,6 +707,94 @@ }); } + function bindRoseCrossInteractions(svg, tree, roseElements) { + if (!svg || !roseElements?.detailBodyEl) { + return; + } + + const openPathFromTarget = (targetEl) => { + if (!(targetEl instanceof Element)) { + return; + } + + const petal = targetEl.closest(".kab-rose-petal[data-path]"); + if (!(petal instanceof SVGElement)) { + return; + } + + const pathNumber = Number(petal.dataset.path); + if (!Number.isFinite(pathNumber)) { + return; + } + + const path = tree.paths.find((entry) => entry.pathNumber === pathNumber); + if (path) { + renderPathDetail(path, tree, roseElements); + } + }; + + svg.addEventListener("click", (event) => { + openPathFromTarget(event.target); + }); + + svg.querySelectorAll(".kab-rose-petal[data-path]").forEach((petal) => { + petal.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openPathFromTarget(petal); + } + }); + }); + } + + function renderRoseLandingIntro(roseElements) { + if (typeof kabbalahDetailUi.renderRoseLandingIntro === "function") { + kabbalahDetailUi.renderRoseLandingIntro(roseElements); + } + } + + function renderRoseCross(elements) { + if (!state.tree || !elements?.roseCrossContainerEl) { + return; + } + + const roseElements = getRoseDetailElements(elements); + if (!roseElements?.detailBodyEl) { + return; + } + + const roseBuilder = window.KabbalahRosicrucianCross?.buildRosicrucianCrossSVG; + if (typeof roseBuilder !== "function") { + return; + } + + const roseSvg = roseBuilder(state.tree); + elements.roseCrossContainerEl.innerHTML = ""; + elements.roseCrossContainerEl.appendChild(roseSvg); + bindRoseCrossInteractions(roseSvg, state.tree, roseElements); + } + + function renderRoseCurrentSelection(elements) { + if (!state.tree) { + return; + } + + const roseElements = getRoseDetailElements(elements); + if (!roseElements?.detailBodyEl) { + return; + } + + if (Number.isFinite(Number(state.selectedPathNumber))) { + const selectedPath = state.tree.paths.find((entry) => entry.pathNumber === Number(state.selectedPathNumber)); + if (selectedPath) { + renderPathDetail(selectedPath, state.tree, roseElements); + return; + } + } + + renderRoseLandingIntro(roseElements); + } + function renderTree(elements) { if (!state.tree || !elements?.treeContainerEl) { return; @@ -1119,6 +870,8 @@ renderTree(elements); renderCurrentSelection(elements); + renderRoseCross(elements); + renderRoseCurrentSelection(elements); } function selectPathByNumber(pathNumber) { diff --git a/app/ui-navigation.js b/app/ui-navigation.js new file mode 100644 index 0000000..a588d00 --- /dev/null +++ b/app/ui-navigation.js @@ -0,0 +1,402 @@ +(function () { + "use strict"; + + let config = {}; + let initialized = false; + + function getActiveSection() { + return typeof config.getActiveSection === "function" + ? config.getActiveSection() + : "home"; + } + + function setActiveSection(section) { + config.setActiveSection?.(section); + } + + function getReferenceData() { + return config.getReferenceData?.() || null; + } + + function getMagickDataset() { + return config.getMagickDataset?.() || null; + } + + function bindClick(element, handler) { + if (!element) { + return; + } + + element.addEventListener("click", handler); + } + + function bindTopLevelNavButtons() { + const elements = config.elements || {}; + + bindClick(elements.openTarotEl, () => { + if (getActiveSection() === "tarot") { + setActiveSection("home"); + } else { + setActiveSection("tarot"); + config.tarotSpreadUi?.showCardsView?.(); + } + }); + + bindClick(elements.openAstronomyEl, () => { + setActiveSection(getActiveSection() === "astronomy" ? "home" : "astronomy"); + }); + + bindClick(elements.openPlanetsEl, () => { + setActiveSection(getActiveSection() === "planets" ? "home" : "planets"); + }); + + bindClick(elements.openCyclesEl, () => { + setActiveSection(getActiveSection() === "cycles" ? "home" : "cycles"); + }); + + bindClick(elements.openElementsEl, () => { + setActiveSection(getActiveSection() === "elements" ? "home" : "elements"); + }); + + bindClick(elements.openIChingEl, () => { + setActiveSection(getActiveSection() === "iching" ? "home" : "iching"); + }); + + bindClick(elements.openKabbalahEl, () => { + setActiveSection(getActiveSection() === "kabbalah" ? "home" : "kabbalah"); + }); + + bindClick(elements.openKabbalahTreeEl, () => { + setActiveSection(getActiveSection() === "kabbalah-tree" ? "home" : "kabbalah-tree"); + }); + + bindClick(elements.openKabbalahCubeEl, () => { + setActiveSection(getActiveSection() === "cube" ? "home" : "cube"); + }); + + bindClick(elements.openAlphabetEl, () => { + setActiveSection(getActiveSection() === "alphabet" ? "home" : "alphabet"); + }); + + bindClick(elements.openNumbersEl, () => { + setActiveSection(getActiveSection() === "numbers" ? "home" : "numbers"); + }); + + bindClick(elements.openZodiacEl, () => { + setActiveSection(getActiveSection() === "zodiac" ? "home" : "zodiac"); + }); + + bindClick(elements.openNatalEl, () => { + setActiveSection(getActiveSection() === "natal" ? "home" : "natal"); + }); + + bindClick(elements.openQuizEl, () => { + setActiveSection(getActiveSection() === "quiz" ? "home" : "quiz"); + }); + + bindClick(elements.openGodsEl, () => { + setActiveSection(getActiveSection() === "gods" ? "home" : "gods"); + }); + + bindClick(elements.openEnochianEl, () => { + setActiveSection(getActiveSection() === "enochian" ? "home" : "enochian"); + }); + + bindClick(elements.openCalendarEl, () => { + const activeSection = getActiveSection(); + const isCalendarMenuActive = activeSection === "calendar" || activeSection === "holidays"; + setActiveSection(isCalendarMenuActive ? "home" : "calendar"); + }); + + bindClick(elements.openCalendarMonthsEl, () => { + setActiveSection(getActiveSection() === "calendar" ? "home" : "calendar"); + }); + + bindClick(elements.openHolidaysEl, () => { + setActiveSection(getActiveSection() === "holidays" ? "home" : "holidays"); + }); + } + + function bindCustomNavEvents() { + const ensure = config.ensure || {}; + + document.addEventListener("nav:cube", (event) => { + const referenceData = getReferenceData(); + const magickDataset = getMagickDataset(); + if (typeof ensure.ensureCubeSection === "function" && magickDataset) { + ensure.ensureCubeSection(magickDataset, referenceData); + } + + setActiveSection("cube"); + + const detail = event?.detail || {}; + requestAnimationFrame(() => { + const ui = window.CubeSectionUi; + const selected = ui?.selectPlacement?.(detail); + if (!selected && detail?.wallId) { + ui?.selectWallById?.(detail.wallId); + } + }); + }); + + document.addEventListener("nav:zodiac", (event) => { + const referenceData = getReferenceData(); + const magickDataset = getMagickDataset(); + if (typeof ensure.ensureZodiacSection === "function" && referenceData && magickDataset) { + ensure.ensureZodiacSection(referenceData, magickDataset); + } + setActiveSection("zodiac"); + const signId = event?.detail?.signId; + if (signId) { + requestAnimationFrame(() => { + window.ZodiacSectionUi?.selectBySignId?.(signId); + }); + } + }); + + document.addEventListener("nav:alphabet", (event) => { + const referenceData = getReferenceData(); + const magickDataset = getMagickDataset(); + if (typeof ensure.ensureAlphabetSection === "function" && magickDataset) { + ensure.ensureAlphabetSection(magickDataset, referenceData); + } + setActiveSection("alphabet"); + + const alphabet = event?.detail?.alphabet; + const hebrewLetterId = event?.detail?.hebrewLetterId; + const greekName = event?.detail?.greekName; + const englishLetter = event?.detail?.englishLetter; + const arabicName = event?.detail?.arabicName; + const enochianId = event?.detail?.enochianId; + + requestAnimationFrame(() => { + const ui = window.AlphabetSectionUi; + if ((alphabet === "hebrew" || (!alphabet && hebrewLetterId)) && hebrewLetterId) { + ui?.selectLetterByHebrewId?.(hebrewLetterId); + return; + } + if (alphabet === "greek" && greekName) { + ui?.selectGreekLetterByName?.(greekName); + return; + } + if (alphabet === "english" && englishLetter) { + ui?.selectEnglishLetter?.(englishLetter); + return; + } + if (alphabet === "arabic" && arabicName) { + ui?.selectArabicLetter?.(arabicName); + return; + } + if (alphabet === "enochian" && enochianId) { + ui?.selectEnochianLetter?.(enochianId); + } + }); + }); + + document.addEventListener("nav:number", (event) => { + const rawValue = event?.detail?.value; + const normalizedValue = typeof config.normalizeNumberValue === "function" + ? config.normalizeNumberValue(rawValue) + : 0; + if (normalizedValue === null) { + return; + } + + setActiveSection("numbers"); + requestAnimationFrame(() => { + if (typeof config.selectNumberEntry === "function") { + config.selectNumberEntry(normalizedValue); + } + }); + }); + + document.addEventListener("nav:iching", (event) => { + const referenceData = getReferenceData(); + if (typeof ensure.ensureIChingSection === "function" && referenceData) { + ensure.ensureIChingSection(referenceData); + } + + setActiveSection("iching"); + + const hexagramNumber = event?.detail?.hexagramNumber; + const planetaryInfluence = event?.detail?.planetaryInfluence; + + requestAnimationFrame(() => { + const ui = window.IChingSectionUi; + if (hexagramNumber != null) { + ui?.selectByHexagramNumber?.(hexagramNumber); + return; + } + if (planetaryInfluence) { + ui?.selectByPlanetaryInfluence?.(planetaryInfluence); + } + }); + }); + + document.addEventListener("nav:gods", (event) => { + const referenceData = getReferenceData(); + const magickDataset = getMagickDataset(); + if (typeof ensure.ensureGodsSection === "function" && magickDataset) { + ensure.ensureGodsSection(magickDataset, referenceData); + } + setActiveSection("gods"); + const godId = event?.detail?.godId; + const godName = event?.detail?.godName; + const pathNo = event?.detail?.pathNo; + requestAnimationFrame(() => { + const ui = window.GodsSectionUi; + const viaId = godId ? ui?.selectById?.(godId) : false; + const viaName = !viaId && godName ? ui?.selectByName?.(godName) : false; + if (!viaId && !viaName && pathNo != null) { + ui?.selectByPathNo?.(pathNo); + } + }); + }); + + document.addEventListener("nav:calendar-month", (event) => { + const referenceData = getReferenceData(); + const magickDataset = getMagickDataset(); + const calendarId = event?.detail?.calendarId; + const monthId = event?.detail?.monthId; + if (!monthId) { + return; + } + + if (typeof ensure.ensureCalendarSection === "function" && referenceData) { + ensure.ensureCalendarSection(referenceData, magickDataset); + } + + setActiveSection("calendar"); + + requestAnimationFrame(() => { + if (calendarId) { + window.CalendarSectionUi?.selectCalendarType?.(calendarId); + } + window.CalendarSectionUi?.selectByMonthId?.(monthId); + }); + }); + + document.addEventListener("nav:kabbalah-path", (event) => { + const magickDataset = getMagickDataset(); + const pathNo = event?.detail?.pathNo; + if (typeof ensure.ensureKabbalahSection === "function" && magickDataset) { + ensure.ensureKabbalahSection(magickDataset); + } + setActiveSection("kabbalah-tree"); + if (pathNo != null) { + requestAnimationFrame(() => { + window.KabbalahSectionUi?.selectNode?.(pathNo); + }); + } + }); + + document.addEventListener("nav:planet", (event) => { + const referenceData = getReferenceData(); + const magickDataset = getMagickDataset(); + const planetId = event?.detail?.planetId; + if (!planetId) { + return; + } + if (typeof ensure.ensurePlanetSection === "function" && referenceData) { + ensure.ensurePlanetSection(referenceData, magickDataset); + } + setActiveSection("planets"); + requestAnimationFrame(() => { + window.PlanetSectionUi?.selectByPlanetId?.(planetId); + }); + }); + + document.addEventListener("nav:elements", (event) => { + const magickDataset = getMagickDataset(); + const elementId = event?.detail?.elementId; + if (!elementId) { + return; + } + + if (typeof ensure.ensureElementsSection === "function" && magickDataset) { + ensure.ensureElementsSection(magickDataset); + } + + setActiveSection("elements"); + + requestAnimationFrame(() => { + window.ElementsSectionUi?.selectByElementId?.(elementId); + }); + }); + + document.addEventListener("nav:tarot-trump", (event) => { + const referenceData = getReferenceData(); + const magickDataset = getMagickDataset(); + if (typeof ensure.ensureTarotSection === "function" && referenceData) { + ensure.ensureTarotSection(referenceData, magickDataset); + } + setActiveSection("tarot"); + const { trumpNumber, cardName } = event?.detail || {}; + requestAnimationFrame(() => { + if (trumpNumber != null) { + window.TarotSectionUi?.selectCardByTrump?.(trumpNumber); + } else if (cardName) { + window.TarotSectionUi?.selectCardByName?.(cardName); + } + }); + }); + + document.addEventListener("kab:view-trump", (event) => { + const referenceData = getReferenceData(); + const magickDataset = getMagickDataset(); + setActiveSection("tarot"); + const trumpNumber = event?.detail?.trumpNumber; + if (trumpNumber != null) { + if (typeof ensure.ensureTarotSection === "function" && referenceData) { + ensure.ensureTarotSection(referenceData, magickDataset); + } + requestAnimationFrame(() => { + window.TarotSectionUi?.selectCardByTrump?.(trumpNumber); + }); + } + }); + + document.addEventListener("tarot:view-kab-path", (event) => { + setActiveSection("kabbalah-tree"); + const pathNumber = event?.detail?.pathNumber; + if (pathNumber != null) { + requestAnimationFrame(() => { + const kabbalahUi = window.KabbalahSectionUi; + if (typeof kabbalahUi?.selectNode === "function") { + kabbalahUi.selectNode(pathNumber); + } else { + kabbalahUi?.selectPathByNumber?.(pathNumber); + } + }); + } + }); + } + + function init(nextConfig = {}) { + config = { + ...config, + ...nextConfig, + elements: { + ...(config.elements || {}), + ...(nextConfig.elements || {}) + }, + ensure: { + ...(config.ensure || {}), + ...(nextConfig.ensure || {}) + } + }; + + if (initialized) { + return; + } + + initialized = true; + bindTopLevelNavButtons(); + bindCustomNavEvents(); + } + + window.TarotNavigationUi = { + ...(window.TarotNavigationUi || {}), + init + }; +})(); diff --git a/app/ui-numbers.js b/app/ui-numbers.js new file mode 100644 index 0000000..7b18a62 --- /dev/null +++ b/app/ui-numbers.js @@ -0,0 +1,932 @@ +(function () { + "use strict"; + + let initialized = false; + let activeNumberValue = 0; + let config = { + getReferenceData: () => null, + getMagickDataset: () => null, + ensureTarotSection: null + }; + + const NUMBERS_SPECIAL_BASE_VALUES = [1, 2, 3, 4]; + const numbersSpecialFlipState = new Map(); + + const DEFAULT_NUMBER_ENTRIES = Array.from({ length: 10 }, (_, value) => ({ + value, + label: `${value}`, + opposite: 9 - value, + digitalRoot: value, + summary: "", + keywords: [], + associations: { + kabbalahNode: value === 0 ? 10 : value, + playingSuit: "hearts" + } + })); + + const PLAYING_SUIT_SYMBOL = { + hearts: "♥", + diamonds: "♦", + clubs: "♣", + spades: "♠" + }; + + const PLAYING_SUIT_LABEL = { + hearts: "Hearts", + diamonds: "Diamonds", + clubs: "Clubs", + spades: "Spades" + }; + + const PLAYING_SUIT_TO_TAROT = { + hearts: "Cups", + diamonds: "Pentacles", + clubs: "Wands", + spades: "Swords" + }; + + const PLAYING_RANKS = [ + { rank: "A", rankLabel: "Ace", rankValue: 1 }, + { rank: "2", rankLabel: "Two", rankValue: 2 }, + { rank: "3", rankLabel: "Three", rankValue: 3 }, + { rank: "4", rankLabel: "Four", rankValue: 4 }, + { rank: "5", rankLabel: "Five", rankValue: 5 }, + { rank: "6", rankLabel: "Six", rankValue: 6 }, + { rank: "7", rankLabel: "Seven", rankValue: 7 }, + { rank: "8", rankLabel: "Eight", rankValue: 8 }, + { rank: "9", rankLabel: "Nine", rankValue: 9 }, + { rank: "10", rankLabel: "Ten", rankValue: 10 }, + { rank: "J", rankLabel: "Jack", rankValue: null }, + { rank: "Q", rankLabel: "Queen", rankValue: null }, + { rank: "K", rankLabel: "King", rankValue: null } + ]; + + const TAROT_RANK_NUMBER_MAP = { + ace: 1, + two: 2, + three: 3, + four: 4, + five: 5, + six: 6, + seven: 7, + eight: 8, + nine: 9, + ten: 10 + }; + + function getReferenceData() { + return typeof config.getReferenceData === "function" ? config.getReferenceData() : null; + } + + function getMagickDataset() { + return typeof config.getMagickDataset === "function" ? config.getMagickDataset() : null; + } + + function getElements() { + return { + countEl: document.getElementById("numbers-count"), + listEl: document.getElementById("numbers-list"), + detailNameEl: document.getElementById("numbers-detail-name"), + detailTypeEl: document.getElementById("numbers-detail-type"), + detailSummaryEl: document.getElementById("numbers-detail-summary"), + detailBodyEl: document.getElementById("numbers-detail-body"), + specialPanelEl: document.getElementById("numbers-special-panel") + }; + } + + function normalizeNumberValue(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return 0; + } + const normalized = Math.trunc(parsed); + if (normalized < 0) { + return 0; + } + if (normalized > 9) { + return 9; + } + return normalized; + } + + function normalizeNumberEntry(rawEntry) { + if (!rawEntry || typeof rawEntry !== "object") { + return null; + } + + const value = normalizeNumberValue(rawEntry.value); + const oppositeRaw = Number(rawEntry.opposite); + const opposite = Number.isFinite(oppositeRaw) + ? normalizeNumberValue(oppositeRaw) + : (9 - value); + const digitalRootRaw = Number(rawEntry.digitalRoot); + const digitalRoot = Number.isFinite(digitalRootRaw) + ? normalizeNumberValue(digitalRootRaw) + : value; + const kabbalahNodeRaw = Number(rawEntry?.associations?.kabbalahNode); + const kabbalahNode = Number.isFinite(kabbalahNodeRaw) + ? Math.max(1, Math.trunc(kabbalahNodeRaw)) + : (value === 0 ? 10 : value); + const tarotTrumpNumbersRaw = Array.isArray(rawEntry?.associations?.tarotTrumpNumbers) + ? rawEntry.associations.tarotTrumpNumbers + : []; + const tarotTrumpNumbers = Array.from(new Set( + tarotTrumpNumbersRaw + .map((item) => Number(item)) + .filter((item) => Number.isFinite(item)) + .map((item) => Math.trunc(item)) + )); + const playingSuitRaw = String(rawEntry?.associations?.playingSuit || "").trim().toLowerCase(); + const playingSuit = ["hearts", "diamonds", "clubs", "spades"].includes(playingSuitRaw) + ? playingSuitRaw + : "hearts"; + + return { + value, + label: String(rawEntry.label || value), + opposite, + digitalRoot, + summary: String(rawEntry.summary || ""), + keywords: Array.isArray(rawEntry.keywords) + ? rawEntry.keywords.map((keyword) => String(keyword || "").trim()).filter(Boolean) + : [], + associations: { + kabbalahNode, + tarotTrumpNumbers, + playingSuit + } + }; + } + + function getNumbersDatasetEntries() { + const numbersData = getMagickDataset()?.grouped?.numbers; + const rawEntries = Array.isArray(numbersData) + ? numbersData + : (Array.isArray(numbersData?.entries) ? numbersData.entries : []); + + const normalizedEntries = rawEntries + .map((entry) => normalizeNumberEntry(entry)) + .filter(Boolean) + .sort((left, right) => left.value - right.value); + + return normalizedEntries.length + ? normalizedEntries + : DEFAULT_NUMBER_ENTRIES; + } + + function getNumberEntryByValue(value) { + const entries = getNumbersDatasetEntries(); + const normalized = normalizeNumberValue(value); + return entries.find((entry) => entry.value === normalized) || entries[0] || null; + } + + function computeDigitalRoot(value) { + let current = Math.abs(Math.trunc(Number(value))); + if (!Number.isFinite(current)) { + return null; + } + while (current >= 10) { + current = String(current) + .split("") + .reduce((sum, digit) => sum + Number(digit), 0); + } + return current; + } + + function getCalendarMonthLinksForNumber(value) { + const referenceData = getReferenceData(); + const normalized = normalizeNumberValue(value); + const calendarGroups = [ + { + calendarId: "gregorian", + calendarLabel: "Gregorian", + months: Array.isArray(referenceData?.calendarMonths) ? referenceData.calendarMonths : [] + }, + { + calendarId: "hebrew", + calendarLabel: "Hebrew", + months: Array.isArray(referenceData?.hebrewCalendar?.months) ? referenceData.hebrewCalendar.months : [] + }, + { + calendarId: "islamic", + calendarLabel: "Islamic", + months: Array.isArray(referenceData?.islamicCalendar?.months) ? referenceData.islamicCalendar.months : [] + }, + { + calendarId: "wheel-of-year", + calendarLabel: "Wheel of the Year", + months: Array.isArray(referenceData?.wheelOfYear?.months) ? referenceData.wheelOfYear.months : [] + } + ]; + + const links = []; + calendarGroups.forEach((group) => { + group.months.forEach((month) => { + const monthOrder = Number(month?.order); + const normalizedOrder = Number.isFinite(monthOrder) ? Math.trunc(monthOrder) : null; + const monthRoot = normalizedOrder != null ? computeDigitalRoot(normalizedOrder) : null; + if (monthRoot !== normalized) { + return; + } + + links.push({ + calendarId: group.calendarId, + calendarLabel: group.calendarLabel, + monthId: String(month.id || "").trim(), + monthName: String(month.name || month.id || "Month").trim(), + monthOrder: normalizedOrder + }); + }); + }); + + return links.filter((link) => link.monthId); + } + + function rankLabelToTarotMinorRank(rankLabel) { + const key = String(rankLabel || "").trim().toLowerCase(); + if (key === "10" || key === "ten") return "Princess"; + if (key === "j" || key === "jack") return "Prince"; + if (key === "q" || key === "queen") return "Queen"; + if (key === "k" || key === "king") return "Knight"; + return String(rankLabel || "").trim(); + } + + function buildFallbackPlayingDeckEntries() { + const entries = []; + Object.keys(PLAYING_SUIT_SYMBOL).forEach((suit) => { + PLAYING_RANKS.forEach((rank) => { + const tarotSuit = PLAYING_SUIT_TO_TAROT[suit]; + const tarotRank = rankLabelToTarotMinorRank(rank.rankLabel); + entries.push({ + id: `${rank.rank}${PLAYING_SUIT_SYMBOL[suit]}`, + suit, + suitLabel: PLAYING_SUIT_LABEL[suit], + suitSymbol: PLAYING_SUIT_SYMBOL[suit], + rank: rank.rank, + rankLabel: rank.rankLabel, + rankValue: rank.rankValue, + tarotSuit, + tarotCard: `${tarotRank} of ${tarotSuit}` + }); + }); + }); + return entries; + } + + function getPlayingDeckEntries() { + const deckData = getMagickDataset()?.grouped?.["playing-cards-52"]; + const rawEntries = Array.isArray(deckData) + ? deckData + : (Array.isArray(deckData?.entries) ? deckData.entries : []); + + if (!rawEntries.length) { + return buildFallbackPlayingDeckEntries(); + } + + return rawEntries + .map((entry) => { + const suit = String(entry?.suit || "").trim().toLowerCase(); + const rankLabel = String(entry?.rankLabel || "").trim(); + const rank = String(entry?.rank || "").trim(); + if (!suit || !rank) { + return null; + } + + const suitSymbol = String(entry?.suitSymbol || PLAYING_SUIT_SYMBOL[suit] || "").trim(); + const tarotSuit = String(entry?.tarotSuit || PLAYING_SUIT_TO_TAROT[suit] || "").trim(); + const tarotCard = String(entry?.tarotCard || "").trim(); + const rankValueRaw = Number(entry?.rankValue); + const rankValue = Number.isFinite(rankValueRaw) ? Math.trunc(rankValueRaw) : null; + + return { + id: String(entry?.id || `${rank}${suitSymbol}`).trim(), + suit, + suitLabel: String(entry?.suitLabel || PLAYING_SUIT_LABEL[suit] || suit).trim(), + suitSymbol, + rank, + rankLabel: rankLabel || rank, + rankValue, + tarotSuit, + tarotCard: tarotCard || `${rankLabelToTarotMinorRank(rankLabel || rank)} of ${tarotSuit}` + }; + }) + .filter(Boolean); + } + + function findPlayingCardBySuitAndValue(entries, suit, value) { + const normalizedSuit = String(suit || "").trim().toLowerCase(); + const targetValue = Number(value); + return entries.find((entry) => entry.suit === normalizedSuit && Number(entry.rankValue) === targetValue) || null; + } + + function buildNumbersSpecialCardSlots(playingSuit) { + const suit = String(playingSuit || "hearts").trim().toLowerCase(); + const selectedSuit = ["hearts", "diamonds", "clubs", "spades"].includes(suit) ? suit : "hearts"; + const deckEntries = getPlayingDeckEntries(); + + const cardEl = document.createElement("div"); + cardEl.className = "numbers-detail-card numbers-special-card-section"; + + const headingEl = document.createElement("strong"); + headingEl.textContent = "4 Card Arrangement"; + + const subEl = document.createElement("div"); + subEl.className = "numbers-detail-text numbers-detail-text--muted"; + subEl.textContent = `Click a card to flip to its opposite (${PLAYING_SUIT_LABEL[selectedSuit]} ↔ ${PLAYING_SUIT_TO_TAROT[selectedSuit]}).`; + + const boardEl = document.createElement("div"); + boardEl.className = "numbers-special-board"; + + NUMBERS_SPECIAL_BASE_VALUES.forEach((baseValue) => { + const oppositeValue = 9 - baseValue; + const frontCard = findPlayingCardBySuitAndValue(deckEntries, selectedSuit, baseValue); + const backCard = findPlayingCardBySuitAndValue(deckEntries, selectedSuit, oppositeValue); + if (!frontCard || !backCard) { + return; + } + + const slotKey = `${selectedSuit}:${baseValue}`; + const isFlipped = Boolean(numbersSpecialFlipState.get(slotKey)); + + const faceBtn = document.createElement("button"); + faceBtn.type = "button"; + faceBtn.className = `numbers-special-card${isFlipped ? " is-flipped" : ""}`; + faceBtn.setAttribute("aria-pressed", isFlipped ? "true" : "false"); + faceBtn.setAttribute("aria-label", `${frontCard.rankLabel} of ${frontCard.suitLabel}. Click to flip to ${backCard.rankLabel}.`); + faceBtn.dataset.suit = selectedSuit; + + const innerEl = document.createElement("div"); + innerEl.className = "numbers-special-card-inner"; + + const frontFaceEl = document.createElement("div"); + frontFaceEl.className = "numbers-special-card-face numbers-special-card-face--front"; + + const frontRankEl = document.createElement("div"); + frontRankEl.className = "numbers-special-card-rank"; + frontRankEl.textContent = frontCard.rankLabel; + + const frontSuitEl = document.createElement("div"); + frontSuitEl.className = "numbers-special-card-suit"; + frontSuitEl.textContent = frontCard.suitSymbol; + + const frontMetaEl = document.createElement("div"); + frontMetaEl.className = "numbers-special-card-meta"; + frontMetaEl.textContent = frontCard.tarotCard; + + frontFaceEl.append(frontRankEl, frontSuitEl, frontMetaEl); + + const backFaceEl = document.createElement("div"); + backFaceEl.className = "numbers-special-card-face numbers-special-card-face--back"; + + const backTagEl = document.createElement("div"); + backTagEl.className = "numbers-special-card-tag"; + backTagEl.textContent = "Opposite"; + + const backRankEl = document.createElement("div"); + backRankEl.className = "numbers-special-card-rank"; + backRankEl.textContent = backCard.rankLabel; + + const backSuitEl = document.createElement("div"); + backSuitEl.className = "numbers-special-card-suit"; + backSuitEl.textContent = backCard.suitSymbol; + + const backMetaEl = document.createElement("div"); + backMetaEl.className = "numbers-special-card-meta"; + backMetaEl.textContent = backCard.tarotCard; + + backFaceEl.append(backTagEl, backRankEl, backSuitEl, backMetaEl); + + innerEl.append(frontFaceEl, backFaceEl); + faceBtn.append(innerEl); + + faceBtn.addEventListener("click", () => { + const next = !Boolean(numbersSpecialFlipState.get(slotKey)); + numbersSpecialFlipState.set(slotKey, next); + faceBtn.classList.toggle("is-flipped", next); + faceBtn.setAttribute("aria-pressed", next ? "true" : "false"); + }); + + boardEl.appendChild(faceBtn); + }); + + if (!boardEl.childElementCount) { + const emptyEl = document.createElement("div"); + emptyEl.className = "numbers-detail-text numbers-detail-text--muted"; + emptyEl.textContent = "No card slots available for this mapping yet."; + boardEl.appendChild(emptyEl); + } + + cardEl.append(headingEl, subEl, boardEl); + return cardEl; + } + + function renderNumbersSpecialPanel(value) { + const { specialPanelEl } = getElements(); + if (!specialPanelEl) { + return; + } + + const entry = getNumberEntryByValue(value); + const playingSuit = entry?.associations?.playingSuit || "hearts"; + const boardCardEl = buildNumbersSpecialCardSlots(playingSuit); + specialPanelEl.replaceChildren(boardCardEl); + } + + function parseTarotCardNumber(rawValue) { + if (typeof rawValue === "number") { + return Number.isFinite(rawValue) ? Math.trunc(rawValue) : null; + } + + if (typeof rawValue === "string") { + const trimmed = rawValue.trim(); + if (!trimmed || !/^-?\d+$/.test(trimmed)) { + return null; + } + return Number(trimmed); + } + + return null; + } + + function extractTarotCardNumericValue(card) { + const directNumber = parseTarotCardNumber(card?.number); + if (directNumber !== null) { + return directNumber; + } + + const rankKey = String(card?.rank || "").trim().toLowerCase(); + if (Object.prototype.hasOwnProperty.call(TAROT_RANK_NUMBER_MAP, rankKey)) { + return TAROT_RANK_NUMBER_MAP[rankKey]; + } + + const numerologyRelation = Array.isArray(card?.relations) + ? card.relations.find((relation) => String(relation?.type || "").trim().toLowerCase() === "numerology") + : null; + const relationValue = Number(numerologyRelation?.data?.value); + if (Number.isFinite(relationValue)) { + return Math.trunc(relationValue); + } + + return null; + } + + function getAlphabetPositionLinksForDigitalRoot(targetRoot) { + const alphabets = getMagickDataset()?.grouped?.alphabets; + if (!alphabets || typeof alphabets !== "object") { + return []; + } + + const links = []; + + const addLink = (alphabetLabel, entry, buttonLabel, detail) => { + const index = Number(entry?.index); + if (!Number.isFinite(index)) { + return; + } + + const normalizedIndex = Math.trunc(index); + if (computeDigitalRoot(normalizedIndex) !== targetRoot) { + return; + } + + links.push({ + alphabet: alphabetLabel, + index: normalizedIndex, + label: buttonLabel, + detail + }); + }; + + const toTitle = (value) => String(value || "") + .trim() + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .toLowerCase() + .replace(/\b([a-z])/g, (match, ch) => ch.toUpperCase()); + + const englishEntries = Array.isArray(alphabets.english) ? alphabets.english : []; + englishEntries.forEach((entry) => { + const letter = String(entry?.letter || "").trim(); + if (!letter) { + return; + } + + addLink( + "English", + entry, + `${letter}`, + { + alphabet: "english", + englishLetter: letter + } + ); + }); + + const greekEntries = Array.isArray(alphabets.greek) ? alphabets.greek : []; + greekEntries.forEach((entry) => { + const greekName = String(entry?.name || "").trim(); + if (!greekName) { + return; + } + + const glyph = String(entry?.char || "").trim(); + const displayName = String(entry?.displayName || toTitle(greekName)).trim(); + addLink( + "Greek", + entry, + glyph ? `${displayName} - ${glyph}` : displayName, + { + alphabet: "greek", + greekName + } + ); + }); + + const hebrewEntries = Array.isArray(alphabets.hebrew) ? alphabets.hebrew : []; + hebrewEntries.forEach((entry) => { + const hebrewLetterId = String(entry?.hebrewLetterId || "").trim(); + if (!hebrewLetterId) { + return; + } + + const glyph = String(entry?.char || "").trim(); + const name = String(entry?.name || hebrewLetterId).trim(); + const displayName = toTitle(name); + addLink( + "Hebrew", + entry, + glyph ? `${displayName} - ${glyph}` : displayName, + { + alphabet: "hebrew", + hebrewLetterId + } + ); + }); + + const arabicEntries = Array.isArray(alphabets.arabic) ? alphabets.arabic : []; + arabicEntries.forEach((entry) => { + const arabicName = String(entry?.name || "").trim(); + if (!arabicName) { + return; + } + + const glyph = String(entry?.char || "").trim(); + const displayName = toTitle(arabicName); + addLink( + "Arabic", + entry, + glyph ? `${displayName} - ${glyph}` : displayName, + { + alphabet: "arabic", + arabicName + } + ); + }); + + const enochianEntries = Array.isArray(alphabets.enochian) ? alphabets.enochian : []; + enochianEntries.forEach((entry) => { + const enochianId = String(entry?.id || "").trim(); + if (!enochianId) { + return; + } + + const title = String(entry?.title || enochianId).trim(); + const displayName = toTitle(title); + addLink( + "Enochian", + entry, + `${displayName}`, + { + alphabet: "enochian", + enochianId + } + ); + }); + + return links.sort((left, right) => { + if (left.index !== right.index) { + return left.index - right.index; + } + const alphabetCompare = left.alphabet.localeCompare(right.alphabet); + if (alphabetCompare !== 0) { + return alphabetCompare; + } + return left.label.localeCompare(right.label); + }); + } + + function getTarotCardsForDigitalRoot(targetRoot, numberEntry = null) { + const referenceData = getReferenceData(); + const magickDataset = getMagickDataset(); + if (typeof config.ensureTarotSection === "function" && referenceData) { + config.ensureTarotSection(referenceData, magickDataset); + } + + const allCards = window.TarotSectionUi?.getCards?.() || []; + const explicitTrumpNumbers = Array.isArray(numberEntry?.associations?.tarotTrumpNumbers) + ? numberEntry.associations.tarotTrumpNumbers + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)) + .map((value) => Math.trunc(value)) + : []; + + const filteredCards = explicitTrumpNumbers.length + ? allCards.filter((card) => { + const numberValue = parseTarotCardNumber(card?.number); + return card?.arcana === "Major" && numberValue !== null && explicitTrumpNumbers.includes(numberValue); + }) + : allCards.filter((card) => { + const numberValue = extractTarotCardNumericValue(card); + return numberValue !== null && computeDigitalRoot(numberValue) === targetRoot; + }); + + return filteredCards + .sort((left, right) => { + const leftNumber = extractTarotCardNumericValue(left); + const rightNumber = extractTarotCardNumericValue(right); + if (leftNumber !== rightNumber) { + return (leftNumber ?? 0) - (rightNumber ?? 0); + } + if (left?.arcana !== right?.arcana) { + return left?.arcana === "Major" ? -1 : 1; + } + return String(left?.name || "").localeCompare(String(right?.name || "")); + }); + } + + function renderNumbersList() { + const { listEl, countEl } = getElements(); + if (!listEl) { + return; + } + + const entries = getNumbersDatasetEntries(); + if (!entries.some((entry) => entry.value === activeNumberValue)) { + activeNumberValue = entries[0]?.value ?? 0; + } + + const fragment = document.createDocumentFragment(); + entries.forEach((entry) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `planet-list-item${entry.value === activeNumberValue ? " is-selected" : ""}`; + button.dataset.numberValue = String(entry.value); + button.setAttribute("role", "option"); + button.setAttribute("aria-selected", entry.value === activeNumberValue ? "true" : "false"); + + const nameEl = document.createElement("span"); + nameEl.className = "planet-list-name"; + nameEl.textContent = `${entry.label}`; + + const metaEl = document.createElement("span"); + metaEl.className = "planet-list-meta"; + metaEl.textContent = `Opposite ${entry.opposite}`; + + button.append(nameEl, metaEl); + fragment.appendChild(button); + }); + + listEl.replaceChildren(fragment); + if (countEl) { + countEl.textContent = `${entries.length} entries`; + } + } + + function renderNumberDetail(value) { + const { detailNameEl, detailTypeEl, detailSummaryEl, detailBodyEl } = getElements(); + const entry = getNumberEntryByValue(value); + if (!entry) { + return; + } + + const normalized = entry.value; + const opposite = entry.opposite; + const rootTarget = normalizeNumberValue(entry.digitalRoot); + + if (detailNameEl) { + detailNameEl.textContent = `Number ${normalized} · ${entry.label}`; + } + + if (detailTypeEl) { + detailTypeEl.textContent = `Opposite: ${opposite}`; + } + + if (detailSummaryEl) { + detailSummaryEl.textContent = entry.summary || ""; + } + + renderNumbersSpecialPanel(normalized); + + if (!detailBodyEl) { + return; + } + + detailBodyEl.replaceChildren(); + + const pairCardEl = document.createElement("div"); + pairCardEl.className = "numbers-detail-card"; + + const pairHeadingEl = document.createElement("strong"); + pairHeadingEl.textContent = "Number Pair"; + + const pairTextEl = document.createElement("div"); + pairTextEl.className = "numbers-detail-text"; + pairTextEl.textContent = `Opposite: ${opposite}`; + + const keywordText = entry.keywords.length + ? `Keywords: ${entry.keywords.join(", ")}` + : "Keywords: --"; + const pairKeywordsEl = document.createElement("div"); + pairKeywordsEl.className = "numbers-detail-text numbers-detail-text--muted"; + pairKeywordsEl.textContent = keywordText; + + const oppositeBtn = document.createElement("button"); + oppositeBtn.type = "button"; + oppositeBtn.className = "numbers-nav-btn"; + oppositeBtn.textContent = `Open Opposite Number ${opposite}`; + oppositeBtn.addEventListener("click", () => { + selectNumberEntry(opposite); + }); + + pairCardEl.append(pairHeadingEl, pairTextEl, pairKeywordsEl, oppositeBtn); + + const kabbalahCardEl = document.createElement("div"); + kabbalahCardEl.className = "numbers-detail-card"; + + const kabbalahHeadingEl = document.createElement("strong"); + kabbalahHeadingEl.textContent = "Kabbalah Link"; + + const kabbalahNode = Number(entry?.associations?.kabbalahNode); + const kabbalahTextEl = document.createElement("div"); + kabbalahTextEl.className = "numbers-detail-text"; + kabbalahTextEl.textContent = `Tree node target: ${kabbalahNode}`; + + const kabbalahBtn = document.createElement("button"); + kabbalahBtn.type = "button"; + kabbalahBtn.className = "numbers-nav-btn"; + kabbalahBtn.textContent = `Open Kabbalah Tree Node ${kabbalahNode}`; + kabbalahBtn.addEventListener("click", () => { + document.dispatchEvent(new CustomEvent("nav:kabbalah-path", { + detail: { pathNo: kabbalahNode } + })); + }); + + kabbalahCardEl.append(kabbalahHeadingEl, kabbalahTextEl, kabbalahBtn); + + const alphabetCardEl = document.createElement("div"); + alphabetCardEl.className = "numbers-detail-card"; + + const alphabetHeadingEl = document.createElement("strong"); + alphabetHeadingEl.textContent = "Alphabet Links"; + + const alphabetLinksWrapEl = document.createElement("div"); + alphabetLinksWrapEl.className = "numbers-links-wrap"; + + const alphabetLinks = getAlphabetPositionLinksForDigitalRoot(rootTarget); + if (!alphabetLinks.length) { + const emptyAlphabetEl = document.createElement("div"); + emptyAlphabetEl.className = "numbers-detail-text numbers-detail-text--muted"; + emptyAlphabetEl.textContent = "No alphabet position entries found for this digital root yet."; + alphabetLinksWrapEl.appendChild(emptyAlphabetEl); + } else { + alphabetLinks.forEach((link) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "numbers-nav-btn"; + button.textContent = `${link.alphabet}: ${link.label}`; + button.addEventListener("click", () => { + document.dispatchEvent(new CustomEvent("nav:alphabet", { + detail: link.detail + })); + }); + alphabetLinksWrapEl.appendChild(button); + }); + } + + alphabetCardEl.append(alphabetHeadingEl, alphabetLinksWrapEl); + + const tarotCardEl = document.createElement("div"); + tarotCardEl.className = "numbers-detail-card"; + + const tarotHeadingEl = document.createElement("strong"); + tarotHeadingEl.textContent = "Tarot Links"; + + const tarotLinksWrapEl = document.createElement("div"); + tarotLinksWrapEl.className = "numbers-links-wrap"; + + const tarotCards = getTarotCardsForDigitalRoot(rootTarget, entry); + if (!tarotCards.length) { + const emptyEl = document.createElement("div"); + emptyEl.className = "numbers-detail-text numbers-detail-text--muted"; + emptyEl.textContent = "No tarot numeric entries found yet for this root. Add card numbers to map them."; + tarotLinksWrapEl.appendChild(emptyEl); + } else { + tarotCards.forEach((card) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "numbers-nav-btn"; + button.textContent = `${card.name}`; + button.addEventListener("click", () => { + document.dispatchEvent(new CustomEvent("nav:tarot-trump", { + detail: { cardName: card.name } + })); + }); + tarotLinksWrapEl.appendChild(button); + }); + } + + tarotCardEl.append(tarotHeadingEl, tarotLinksWrapEl); + + const calendarCardEl = document.createElement("div"); + calendarCardEl.className = "numbers-detail-card"; + + const calendarHeadingEl = document.createElement("strong"); + calendarHeadingEl.textContent = "Calendar Links"; + + const calendarLinksWrapEl = document.createElement("div"); + calendarLinksWrapEl.className = "numbers-links-wrap"; + + const calendarLinks = getCalendarMonthLinksForNumber(normalized); + if (!calendarLinks.length) { + const emptyCalendarEl = document.createElement("div"); + emptyCalendarEl.className = "numbers-detail-text numbers-detail-text--muted"; + emptyCalendarEl.textContent = "No calendar months currently mapped to this number."; + calendarLinksWrapEl.appendChild(emptyCalendarEl); + } else { + calendarLinks.forEach((link) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "numbers-nav-btn"; + button.textContent = `${link.calendarLabel}: ${link.monthName} (Month ${link.monthOrder})`; + button.addEventListener("click", () => { + document.dispatchEvent(new CustomEvent("nav:calendar-month", { + detail: { + calendarId: link.calendarId, + monthId: link.monthId + } + })); + }); + calendarLinksWrapEl.appendChild(button); + }); + } + + calendarCardEl.append(calendarHeadingEl, calendarLinksWrapEl); + + detailBodyEl.append(pairCardEl, kabbalahCardEl, alphabetCardEl, tarotCardEl, calendarCardEl); + } + + function selectNumberEntry(value) { + const entry = getNumberEntryByValue(value); + activeNumberValue = entry ? entry.value : 0; + renderNumbersList(); + renderNumberDetail(activeNumberValue); + } + + function ensureNumbersSection() { + const { listEl } = getElements(); + if (!listEl) { + return; + } + + if (!initialized) { + listEl.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Node)) { + return; + } + const button = target instanceof Element + ? target.closest(".planet-list-item") + : null; + if (!(button instanceof HTMLButtonElement)) { + return; + } + const value = Number(button.dataset.numberValue); + if (!Number.isFinite(value)) { + return; + } + selectNumberEntry(value); + }); + + initialized = true; + } + + renderNumbersList(); + renderNumberDetail(activeNumberValue); + } + + function init(nextConfig = {}) { + config = { + ...config, + ...nextConfig + }; + } + + window.TarotNumbersUi = { + ...(window.TarotNumbersUi || {}), + init, + ensureNumbersSection, + selectNumberEntry, + normalizeNumberValue + }; +})(); diff --git a/app/ui-quiz-bank.js b/app/ui-quiz-bank.js new file mode 100644 index 0000000..798798d --- /dev/null +++ b/app/ui-quiz-bank.js @@ -0,0 +1,947 @@ +/* ui-quiz-bank.js — Built-in quiz question bank generation */ +(function () { + "use strict"; + + function toTitleCase(value) { + const text = String(value || "").trim().toLowerCase(); + if (!text) { + return ""; + } + return text.charAt(0).toUpperCase() + text.slice(1); + } + + function normalizeOption(value) { + return String(value || "").trim(); + } + + function normalizeKey(value) { + return normalizeOption(value).toLowerCase(); + } + + function toUniqueOptionList(values) { + const seen = new Set(); + const unique = []; + + (values || []).forEach((value) => { + const formatted = normalizeOption(value); + if (!formatted) { + return; + } + + const key = normalizeKey(formatted); + if (seen.has(key)) { + return; + } + + seen.add(key); + unique.push(formatted); + }); + + return unique; + } + + function resolveDifficultyValue(valueByDifficulty, difficulty = "normal") { + if (valueByDifficulty == null) { + return ""; + } + + if (typeof valueByDifficulty !== "object" || Array.isArray(valueByDifficulty)) { + return valueByDifficulty; + } + + if (Object.prototype.hasOwnProperty.call(valueByDifficulty, difficulty)) { + return valueByDifficulty[difficulty]; + } + + if (Object.prototype.hasOwnProperty.call(valueByDifficulty, "normal")) { + return valueByDifficulty.normal; + } + + if (Object.prototype.hasOwnProperty.call(valueByDifficulty, "easy")) { + return valueByDifficulty.easy; + } + + if (Object.prototype.hasOwnProperty.call(valueByDifficulty, "hard")) { + return valueByDifficulty.hard; + } + + return ""; + } + + function createQuestionTemplate(payload, poolValues) { + const key = String(payload?.key || "").trim(); + const promptByDifficulty = payload?.promptByDifficulty ?? payload?.prompt; + const answerByDifficulty = payload?.answerByDifficulty ?? payload?.answer; + const poolByDifficulty = poolValues; + const categoryId = String(payload?.categoryId || "").trim(); + const category = String(payload?.category || "Correspondence").trim(); + + const defaultPrompt = String(resolveDifficultyValue(promptByDifficulty, "normal") || "").trim(); + const defaultAnswer = normalizeOption(resolveDifficultyValue(answerByDifficulty, "normal")); + const defaultPool = toUniqueOptionList(resolveDifficultyValue(poolByDifficulty, "normal") || []); + + if (!key || !defaultPrompt || !defaultAnswer || !categoryId || !category) { + return null; + } + + if (!defaultPool.some((value) => normalizeKey(value) === normalizeKey(defaultAnswer))) { + defaultPool.push(defaultAnswer); + } + + const distractorCount = defaultPool.filter((value) => normalizeKey(value) !== normalizeKey(defaultAnswer)).length; + if (distractorCount < 3) { + return null; + } + + return { + key, + categoryId, + category, + promptByDifficulty, + answerByDifficulty, + poolByDifficulty + }; + } + + function buildQuestionBank(referenceData, magickDataset, dynamicCategoryRegistry) { + const grouped = magickDataset?.grouped || {}; + const alphabets = grouped.alphabets || {}; + const englishLetters = Array.isArray(alphabets?.english) ? alphabets.english : []; + const hebrewLetters = Array.isArray(alphabets?.hebrew) ? alphabets.hebrew : []; + const kabbalahTree = grouped?.kabbalah?.["kabbalah-tree"] || {}; + const treePaths = Array.isArray(kabbalahTree?.paths) ? kabbalahTree.paths : []; + const treeSephiroth = Array.isArray(kabbalahTree?.sephiroth) ? kabbalahTree.sephiroth : []; + const sephirotById = grouped?.kabbalah?.sephirot && typeof grouped.kabbalah.sephirot === "object" + ? grouped.kabbalah.sephirot + : {}; + const cube = grouped?.kabbalah?.cube && typeof grouped.kabbalah.cube === "object" + ? grouped.kabbalah.cube + : {}; + const cubeWalls = Array.isArray(cube?.walls) ? cube.walls : []; + const cubeEdges = Array.isArray(cube?.edges) ? cube.edges : []; + const cubeCenter = cube?.center && typeof cube.center === "object" ? cube.center : null; + const playingCardsData = grouped?.["playing-cards-52"]; + const playingCards = Array.isArray(playingCardsData) + ? playingCardsData + : (Array.isArray(playingCardsData?.entries) ? playingCardsData.entries : []); + const signs = Array.isArray(referenceData?.signs) ? referenceData.signs : []; + const planetsById = referenceData?.planets && typeof referenceData.planets === "object" + ? referenceData.planets + : {}; + const planets = Object.values(planetsById); + const decansBySign = referenceData?.decansBySign && typeof referenceData.decansBySign === "object" + ? referenceData.decansBySign + : {}; + + const normalizeId = (value) => String(value || "").trim().toLowerCase(); + + const toRomanNumeral = (value) => { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return String(value || ""); + } + + const intValue = Math.trunc(numeric); + const lookup = [ + [1000, "M"], + [900, "CM"], + [500, "D"], + [400, "CD"], + [100, "C"], + [90, "XC"], + [50, "L"], + [40, "XL"], + [10, "X"], + [9, "IX"], + [5, "V"], + [4, "IV"], + [1, "I"] + ]; + + let current = intValue; + let result = ""; + lookup.forEach(([size, symbol]) => { + while (current >= size) { + result += symbol; + current -= size; + } + }); + + return result || String(intValue); + }; + + const labelFromId = (value) => { + const id = String(value || "").trim(); + if (!id) { + return ""; + } + return id + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .split(" ") + .map((part) => part ? part.charAt(0).toUpperCase() + part.slice(1) : "") + .join(" "); + }; + + const getPlanetLabelById = (planetId) => { + const normalized = normalizeId(planetId); + if (!normalized) { + return ""; + } + + const directPlanet = planetsById[normalized]; + if (directPlanet?.name) { + return directPlanet.name; + } + + if (normalized === "primum-mobile") { + return "Primum Mobile"; + } + if (normalized === "olam-yesodot") { + return "Earth / Elements"; + } + + return labelFromId(normalized); + }; + + const hebrewById = new Map( + hebrewLetters + .filter((entry) => entry?.hebrewLetterId) + .map((entry) => [normalizeId(entry.hebrewLetterId), entry]) + ); + + const formatHebrewLetterLabel = (entry, fallbackId = "") => { + if (entry?.name && entry?.char) { + return `${entry.name} (${entry.char})`; + } + if (entry?.name) { + return entry.name; + } + if (entry?.char) { + return entry.char; + } + return labelFromId(fallbackId); + }; + + const sephiraNameByNumber = new Map( + treeSephiroth + .filter((entry) => Number.isFinite(Number(entry?.number)) && entry?.name) + .map((entry) => [Math.trunc(Number(entry.number)), String(entry.name)]) + ); + + const sephiraNameById = new Map( + treeSephiroth + .filter((entry) => entry?.sephiraId && entry?.name) + .map((entry) => [normalizeId(entry.sephiraId), String(entry.name)]) + ); + + const getSephiraName = (numberValue, idValue) => { + const numberKey = Number(numberValue); + if (Number.isFinite(numberKey)) { + const byNumber = sephiraNameByNumber.get(Math.trunc(numberKey)); + if (byNumber) { + return byNumber; + } + } + + const byId = sephiraNameById.get(normalizeId(idValue)); + if (byId) { + return byId; + } + + if (Number.isFinite(numberKey)) { + return `Sephira ${Math.trunc(numberKey)}`; + } + + return labelFromId(idValue); + }; + + const formatPathLetter = (path) => { + const transliteration = String(path?.hebrewLetter?.transliteration || "").trim(); + const glyph = String(path?.hebrewLetter?.char || "").trim(); + + if (transliteration && glyph) { + return `${transliteration} (${glyph})`; + } + if (transliteration) { + return transliteration; + } + if (glyph) { + return glyph; + } + return ""; + }; + + const flattenDecans = Object.values(decansBySign) + .flatMap((entries) => (Array.isArray(entries) ? entries : [])); + + const signNameById = new Map( + signs + .filter((entry) => entry?.id && entry?.name) + .map((entry) => [normalizeId(entry.id), String(entry.name)]) + ); + + const formatDecanLabel = (decan) => { + const signName = signNameById.get(normalizeId(decan?.signId)) || labelFromId(decan?.signId); + const index = Number(decan?.index); + if (!signName || !Number.isFinite(index)) { + return ""; + } + return `${signName} Decan ${toRomanNumeral(index)}`; + }; + + const bank = []; + + const englishGematriaPool = englishLetters + .map((item) => (Number.isFinite(Number(item?.pythagorean)) ? String(item.pythagorean) : "")) + .filter(Boolean); + + const hebrewNumerologyPool = hebrewLetters + .map((item) => (Number.isFinite(Number(item?.numerology)) ? String(item.numerology) : "")) + .filter(Boolean); + + const hebrewNameAndCharPool = hebrewLetters + .filter((item) => item?.name && item?.char) + .map((item) => `${item.name} (${item.char})`); + + const hebrewCharPool = hebrewLetters + .map((item) => item?.char) + .filter(Boolean); + + const planetNamePool = planets + .map((planet) => planet?.name) + .filter(Boolean); + + const planetWeekdayPool = planets + .map((planet) => planet?.weekday) + .filter(Boolean); + + const zodiacElementPool = signs + .map((sign) => toTitleCase(sign?.element)) + .filter(Boolean); + + const zodiacTarotPool = signs + .map((sign) => sign?.tarot?.majorArcana) + .filter(Boolean); + + const pathNumberPool = toUniqueOptionList( + treePaths + .map((path) => { + const pathNo = Number(path?.pathNumber); + return Number.isFinite(pathNo) ? String(Math.trunc(pathNo)) : ""; + }) + ); + + const pathLetterPool = toUniqueOptionList(treePaths.map((path) => formatPathLetter(path))); + const pathTarotPool = toUniqueOptionList(treePaths.map((path) => normalizeOption(path?.tarot?.card))); + + const decanLabelPool = toUniqueOptionList(flattenDecans.map((decan) => formatDecanLabel(decan))); + const decanRulerPool = toUniqueOptionList( + flattenDecans.map((decan) => getPlanetLabelById(decan?.rulerPlanetId)) + ); + + const cubeWallLabelPool = toUniqueOptionList( + cubeWalls.map((wall) => `${String(wall?.name || labelFromId(wall?.id)).trim()} Wall`) + ); + + const cubeEdgeLabelPool = toUniqueOptionList( + cubeEdges.map((edge) => `${String(edge?.name || labelFromId(edge?.id)).trim()} Edge`) + ); + + const cubeLocationPool = toUniqueOptionList([ + ...cubeWallLabelPool, + ...cubeEdgeLabelPool, + "Center" + ]); + + const cubeHebrewLetterPool = toUniqueOptionList([ + ...cubeWalls.map((wall) => { + const hebrew = hebrewById.get(normalizeId(wall?.hebrewLetterId)); + return formatHebrewLetterLabel(hebrew, wall?.hebrewLetterId); + }), + ...cubeEdges.map((edge) => { + const hebrew = hebrewById.get(normalizeId(edge?.hebrewLetterId)); + return formatHebrewLetterLabel(hebrew, edge?.hebrewLetterId); + }), + formatHebrewLetterLabel(hebrewById.get(normalizeId(cubeCenter?.hebrewLetterId)), cubeCenter?.hebrewLetterId) + ]); + + const playingTarotPool = toUniqueOptionList( + playingCards.map((entry) => normalizeOption(entry?.tarotCard)) + ); + + englishLetters.forEach((entry) => { + if (!entry?.letter || !Number.isFinite(Number(entry?.pythagorean))) { + return; + } + + const template = createQuestionTemplate( + { + key: `english-gematria:${entry.letter}`, + categoryId: "english-gematria", + category: "English Gematria", + promptByDifficulty: `${entry.letter} has a simple gematria value of`, + answerByDifficulty: String(entry.pythagorean) + }, + englishGematriaPool + ); + + if (template) { + bank.push(template); + } + }); + + hebrewLetters.forEach((entry) => { + if (!entry?.name || !entry?.char || !Number.isFinite(Number(entry?.numerology))) { + return; + } + + const template = createQuestionTemplate( + { + key: `hebrew-number:${entry.hebrewLetterId || entry.name}`, + categoryId: "hebrew-numerology", + category: "Hebrew Gematria", + promptByDifficulty: { + easy: `${entry.name} (${entry.char}) has a gematria value of`, + normal: `${entry.name} (${entry.char}) has a gematria value of`, + hard: `${entry.char} has a gematria value of` + }, + answerByDifficulty: String(entry.numerology) + }, + hebrewNumerologyPool + ); + + if (template) { + bank.push(template); + } + }); + + englishLetters.forEach((entry) => { + if (!entry?.letter || !entry?.hebrewLetterId) { + return; + } + + const mappedHebrew = hebrewById.get(normalizeId(entry.hebrewLetterId)); + if (!mappedHebrew?.name || !mappedHebrew?.char) { + return; + } + + const template = createQuestionTemplate( + { + key: `english-hebrew:${entry.letter}`, + categoryId: "english-hebrew-mapping", + category: "Alphabet Mapping", + promptByDifficulty: { + easy: `${entry.letter} maps to which Hebrew letter`, + normal: `${entry.letter} maps to which Hebrew letter`, + hard: `${entry.letter} maps to which Hebrew glyph` + }, + answerByDifficulty: { + easy: `${mappedHebrew.name} (${mappedHebrew.char})`, + normal: `${mappedHebrew.name} (${mappedHebrew.char})`, + hard: mappedHebrew.char + } + }, + { + easy: hebrewNameAndCharPool, + normal: hebrewNameAndCharPool, + hard: hebrewCharPool + } + ); + + if (template) { + bank.push(template); + } + }); + + signs.forEach((entry) => { + if (!entry?.name || !entry?.rulingPlanetId) { + return; + } + + const rulerName = planetsById[normalizeId(entry.rulingPlanetId)]?.name; + if (!rulerName) { + return; + } + + const template = createQuestionTemplate( + { + key: `zodiac-ruler:${entry.id || entry.name}`, + categoryId: "zodiac-rulers", + category: "Zodiac Rulers", + promptByDifficulty: `${entry.name} is ruled by`, + answerByDifficulty: rulerName + }, + planetNamePool + ); + + if (template) { + bank.push(template); + } + }); + + signs.forEach((entry) => { + if (!entry?.name || !entry?.element) { + return; + } + + const template = createQuestionTemplate( + { + key: `zodiac-element:${entry.id || entry.name}`, + categoryId: "zodiac-elements", + category: "Zodiac Elements", + promptByDifficulty: `${entry.name} is`, + answerByDifficulty: toTitleCase(entry.element) + }, + zodiacElementPool + ); + + if (template) { + bank.push(template); + } + }); + + planets.forEach((entry) => { + if (!entry?.name || !entry?.weekday) { + return; + } + + const template = createQuestionTemplate( + { + key: `planet-weekday:${entry.id || entry.name}`, + categoryId: "planetary-weekdays", + category: "Planetary Weekdays", + promptByDifficulty: `${entry.name} corresponds to`, + answerByDifficulty: entry.weekday + }, + planetWeekdayPool + ); + + if (template) { + bank.push(template); + } + }); + + signs.forEach((entry) => { + if (!entry?.name || !entry?.tarot?.majorArcana) { + return; + } + + const template = createQuestionTemplate( + { + key: `zodiac-tarot:${entry.id || entry.name}`, + categoryId: "zodiac-tarot", + category: "Zodiac ↔ Tarot", + promptByDifficulty: `${entry.name} corresponds to`, + answerByDifficulty: entry.tarot.majorArcana + }, + zodiacTarotPool + ); + + if (template) { + bank.push(template); + } + }); + + treePaths.forEach((path) => { + const pathNo = Number(path?.pathNumber); + if (!Number.isFinite(pathNo)) { + return; + } + + const pathNumberLabel = String(Math.trunc(pathNo)); + const fromNo = Number(path?.connects?.from); + const toNo = Number(path?.connects?.to); + const fromName = getSephiraName(fromNo, path?.connectIds?.from); + const toName = getSephiraName(toNo, path?.connectIds?.to); + const pathLetter = formatPathLetter(path); + const tarotCard = normalizeOption(path?.tarot?.card); + + if (fromName && toName) { + const template = createQuestionTemplate( + { + key: `kabbalah-path-between:${pathNumberLabel}`, + categoryId: "kabbalah-path-between-sephirot", + category: "Kabbalah Paths", + promptByDifficulty: { + easy: `Which path is between ${fromName} and ${toName}`, + normal: `What path connects ${fromName} and ${toName}`, + hard: `${fromName} ↔ ${toName} is which path` + }, + answerByDifficulty: pathNumberLabel + }, + pathNumberPool + ); + + if (template) { + bank.push(template); + } + } + + if (pathLetter) { + const numberToLetterTemplate = createQuestionTemplate( + { + key: `kabbalah-path-letter:${pathNumberLabel}`, + categoryId: "kabbalah-path-letter", + category: "Kabbalah Paths", + promptByDifficulty: { + easy: `Which letter is on Path ${pathNumberLabel}`, + normal: `Path ${pathNumberLabel} carries which Hebrew letter`, + hard: `Letter on Path ${pathNumberLabel}` + }, + answerByDifficulty: pathLetter + }, + pathLetterPool + ); + + if (numberToLetterTemplate) { + bank.push(numberToLetterTemplate); + } + + const letterToNumberTemplate = createQuestionTemplate( + { + key: `kabbalah-letter-path-number:${pathNumberLabel}`, + categoryId: "kabbalah-path-letter", + category: "Kabbalah Paths", + promptByDifficulty: { + easy: `${pathLetter} belongs to which path`, + normal: `${pathLetter} corresponds to Path`, + hard: `${pathLetter} is on Path` + }, + answerByDifficulty: pathNumberLabel + }, + pathNumberPool + ); + + if (letterToNumberTemplate) { + bank.push(letterToNumberTemplate); + } + } + + if (tarotCard) { + const pathToTarotTemplate = createQuestionTemplate( + { + key: `kabbalah-path-tarot:${pathNumberLabel}`, + categoryId: "kabbalah-path-tarot", + category: "Kabbalah ↔ Tarot", + promptByDifficulty: { + easy: `Path ${pathNumberLabel} corresponds to which Tarot trump`, + normal: `Which Tarot trump is on Path ${pathNumberLabel}`, + hard: `Tarot trump on Path ${pathNumberLabel}` + }, + answerByDifficulty: tarotCard + }, + pathTarotPool + ); + + if (pathToTarotTemplate) { + bank.push(pathToTarotTemplate); + } + + const tarotToPathTemplate = createQuestionTemplate( + { + key: `tarot-trump-path:${pathNumberLabel}`, + categoryId: "kabbalah-path-tarot", + category: "Tarot ↔ Kabbalah", + promptByDifficulty: { + easy: `${tarotCard} is on which path`, + normal: `Which path corresponds to ${tarotCard}`, + hard: `${tarotCard} corresponds to Path` + }, + answerByDifficulty: pathNumberLabel + }, + pathNumberPool + ); + + if (tarotToPathTemplate) { + bank.push(tarotToPathTemplate); + } + } + }); + + Object.values(sephirotById).forEach((sephira) => { + const sephiraName = String(sephira?.name?.roman || sephira?.name?.en || "").trim(); + const planetLabel = getPlanetLabelById(sephira?.planetId); + if (!sephiraName || !planetLabel) { + return; + } + + const template = createQuestionTemplate( + { + key: `sephirot-planet:${normalizeId(sephira?.id || sephiraName)}`, + categoryId: "sephirot-planets", + category: "Sephirot ↔ Planet", + promptByDifficulty: { + easy: `${sephiraName} corresponds to which planet`, + normal: `Planetary correspondence of ${sephiraName}`, + hard: `${sephiraName} corresponds to` + }, + answerByDifficulty: planetLabel + }, + toUniqueOptionList(Object.values(sephirotById).map((entry) => getPlanetLabelById(entry?.planetId))) + ); + + if (template) { + bank.push(template); + } + }); + + flattenDecans.forEach((decan) => { + const decanId = String(decan?.id || "").trim(); + const card = normalizeOption(decan?.tarotMinorArcana); + const decanLabel = formatDecanLabel(decan); + const rulerLabel = getPlanetLabelById(decan?.rulerPlanetId); + + if (!decanId || !card) { + return; + } + + if (decanLabel) { + const template = createQuestionTemplate( + { + key: `tarot-decan-sign:${decanId}`, + categoryId: "tarot-decan-sign", + category: "Tarot Decans", + promptByDifficulty: { + easy: `${card} belongs to which decan`, + normal: `Which decan contains ${card}`, + hard: `${card} is in` + }, + answerByDifficulty: decanLabel + }, + decanLabelPool + ); + + if (template) { + bank.push(template); + } + } + + if (rulerLabel) { + const template = createQuestionTemplate( + { + key: `tarot-decan-ruler:${decanId}`, + categoryId: "tarot-decan-ruler", + category: "Tarot Decans", + promptByDifficulty: { + easy: `The decan of ${card} is ruled by`, + normal: `Who rules the decan for ${card}`, + hard: `${card} decan ruler` + }, + answerByDifficulty: rulerLabel + }, + decanRulerPool + ); + + if (template) { + bank.push(template); + } + } + }); + + cubeWalls.forEach((wall) => { + const wallName = String(wall?.name || labelFromId(wall?.id)).trim(); + const wallLabel = wallName ? `${wallName} Wall` : ""; + const tarotCard = normalizeOption(wall?.associations?.tarotCard); + const hebrew = hebrewById.get(normalizeId(wall?.hebrewLetterId)); + const hebrewLabel = formatHebrewLetterLabel(hebrew, wall?.hebrewLetterId); + + if (tarotCard && wallLabel) { + const template = createQuestionTemplate( + { + key: `tarot-cube-wall:${normalizeId(wall?.id || wallName)}`, + categoryId: "tarot-cube-location", + category: "Tarot ↔ Cube", + promptByDifficulty: { + easy: `${tarotCard} is on which Cube wall`, + normal: `Where is ${tarotCard} on the Cube`, + hard: `${tarotCard} location on Cube` + }, + answerByDifficulty: wallLabel + }, + cubeLocationPool + ); + + if (template) { + bank.push(template); + } + } + + if (wallLabel && hebrewLabel) { + const template = createQuestionTemplate( + { + key: `cube-wall-letter:${normalizeId(wall?.id || wallName)}`, + categoryId: "cube-hebrew-letter", + category: "Cube ↔ Hebrew", + promptByDifficulty: { + easy: `${wallLabel} corresponds to which Hebrew letter`, + normal: `Which Hebrew letter is on ${wallLabel}`, + hard: `${wallLabel} letter` + }, + answerByDifficulty: hebrewLabel + }, + cubeHebrewLetterPool + ); + + if (template) { + bank.push(template); + } + } + }); + + cubeEdges.forEach((edge) => { + const edgeName = String(edge?.name || labelFromId(edge?.id)).trim(); + const edgeLabel = edgeName ? `${edgeName} Edge` : ""; + const hebrew = hebrewById.get(normalizeId(edge?.hebrewLetterId)); + const hebrewLabel = formatHebrewLetterLabel(hebrew, edge?.hebrewLetterId); + const tarotCard = normalizeOption(hebrew?.tarot?.card); + + if (tarotCard && edgeLabel) { + const template = createQuestionTemplate( + { + key: `tarot-cube-edge:${normalizeId(edge?.id || edgeName)}`, + categoryId: "tarot-cube-location", + category: "Tarot ↔ Cube", + promptByDifficulty: { + easy: `${tarotCard} is on which Cube edge`, + normal: `Where is ${tarotCard} on the Cube edges`, + hard: `${tarotCard} edge location` + }, + answerByDifficulty: edgeLabel + }, + cubeLocationPool + ); + + if (template) { + bank.push(template); + } + } + + if (edgeLabel && hebrewLabel) { + const template = createQuestionTemplate( + { + key: `cube-edge-letter:${normalizeId(edge?.id || edgeName)}`, + categoryId: "cube-hebrew-letter", + category: "Cube ↔ Hebrew", + promptByDifficulty: { + easy: `${edgeLabel} corresponds to which Hebrew letter`, + normal: `Which Hebrew letter is on ${edgeLabel}`, + hard: `${edgeLabel} letter` + }, + answerByDifficulty: hebrewLabel + }, + cubeHebrewLetterPool + ); + + if (template) { + bank.push(template); + } + } + }); + + if (cubeCenter) { + const centerTarot = normalizeOption(cubeCenter?.associations?.tarotCard || cubeCenter?.tarotCard); + const centerHebrew = hebrewById.get(normalizeId(cubeCenter?.hebrewLetterId)); + const centerHebrewLabel = formatHebrewLetterLabel(centerHebrew, cubeCenter?.hebrewLetterId); + + if (centerTarot) { + const template = createQuestionTemplate( + { + key: "tarot-cube-center", + categoryId: "tarot-cube-location", + category: "Tarot ↔ Cube", + promptByDifficulty: { + easy: `${centerTarot} is located at which Cube position`, + normal: `Where is ${centerTarot} on the Cube`, + hard: `${centerTarot} Cube location` + }, + answerByDifficulty: "Center" + }, + cubeLocationPool + ); + + if (template) { + bank.push(template); + } + } + + if (centerHebrewLabel) { + const template = createQuestionTemplate( + { + key: "cube-center-letter", + categoryId: "cube-hebrew-letter", + category: "Cube ↔ Hebrew", + promptByDifficulty: { + easy: "The Cube center corresponds to which Hebrew letter", + normal: "Which Hebrew letter is at the Cube center", + hard: "Cube center letter" + }, + answerByDifficulty: centerHebrewLabel + }, + cubeHebrewLetterPool + ); + + if (template) { + bank.push(template); + } + } + } + + playingCards.forEach((entry) => { + const cardId = String(entry?.id || "").trim(); + const rankLabel = normalizeOption(entry?.rankLabel || entry?.rank); + const suitLabel = normalizeOption(entry?.suitLabel || labelFromId(entry?.suit)); + const tarotCard = normalizeOption(entry?.tarotCard); + + if (!cardId || !rankLabel || !suitLabel || !tarotCard) { + return; + } + + const template = createQuestionTemplate( + { + key: `playing-card-tarot:${cardId}`, + categoryId: "playing-card-tarot", + category: "Playing Card ↔ Tarot", + promptByDifficulty: { + easy: `${rankLabel} of ${suitLabel} maps to which Tarot card`, + normal: `${rankLabel} of ${suitLabel} corresponds to`, + hard: `${rankLabel} of ${suitLabel} maps to` + }, + answerByDifficulty: tarotCard + }, + playingTarotPool + ); + + if (template) { + bank.push(template); + } + }); + + (dynamicCategoryRegistry || []).forEach(({ builder }) => { + try { + const dynamicTemplates = builder(referenceData, magickDataset); + if (Array.isArray(dynamicTemplates)) { + dynamicTemplates.forEach((template) => { + if (template) { + bank.push(template); + } + }); + } + } catch (_error) { + // Skip broken plugins silently to preserve quiz availability. + } + }); + + return bank; + } + + window.QuizQuestionBank = { + buildQuestionBank, + createQuestionTemplate, + normalizeKey, + normalizeOption, + toTitleCase, + toUniqueOptionList + }; +})(); \ No newline at end of file diff --git a/app/ui-quiz.js b/app/ui-quiz.js index dce68fb..eb8c639 100644 --- a/app/ui-quiz.js +++ b/app/ui-quiz.js @@ -45,6 +45,7 @@ // Dynamic category plugin registry — populated by registerQuizCategory() const DYNAMIC_CATEGORY_REGISTRY = []; + const quizQuestionBank = window.QuizQuestionBank || {}; function registerQuizCategory(id, label, builder) { if (typeof id !== "string" || !id || typeof builder !== "function") { @@ -238,41 +239,6 @@ }; } - function createQuestionTemplate(payload, poolValues) { - const key = String(payload?.key || "").trim(); - const promptByDifficulty = payload?.promptByDifficulty ?? payload?.prompt; - const answerByDifficulty = payload?.answerByDifficulty ?? payload?.answer; - const poolByDifficulty = poolValues; - const categoryId = String(payload?.categoryId || "").trim(); - const category = String(payload?.category || "Correspondence").trim(); - - const defaultPrompt = String(resolveDifficultyValue(promptByDifficulty, "normal") || "").trim(); - const defaultAnswer = normalizeOption(resolveDifficultyValue(answerByDifficulty, "normal")); - const defaultPool = toUniqueOptionList(resolveDifficultyValue(poolByDifficulty, "normal") || []); - - if (!key || !defaultPrompt || !defaultAnswer || !categoryId || !category) { - return null; - } - - if (!defaultPool.some((value) => normalizeKey(value) === normalizeKey(defaultAnswer))) { - defaultPool.push(defaultAnswer); - } - - const distractorCount = defaultPool.filter((value) => normalizeKey(value) !== normalizeKey(defaultAnswer)).length; - if (distractorCount < 3) { - return null; - } - - return { - key, - categoryId, - category, - promptByDifficulty, - answerByDifficulty, - poolByDifficulty - }; - } - function instantiateQuestion(template) { if (!template) { return null; @@ -303,837 +269,15 @@ } function buildQuestionBank(referenceData, magickDataset) { - const grouped = magickDataset?.grouped || {}; - const alphabets = grouped.alphabets || {}; - const englishLetters = Array.isArray(alphabets?.english) ? alphabets.english : []; - const hebrewLetters = Array.isArray(alphabets?.hebrew) ? alphabets.hebrew : []; - const kabbalahTree = grouped?.kabbalah?.["kabbalah-tree"] || {}; - const treePaths = Array.isArray(kabbalahTree?.paths) ? kabbalahTree.paths : []; - const treeSephiroth = Array.isArray(kabbalahTree?.sephiroth) ? kabbalahTree.sephiroth : []; - const sephirotById = grouped?.kabbalah?.sephirot && typeof grouped.kabbalah.sephirot === "object" - ? grouped.kabbalah.sephirot - : {}; - const cube = grouped?.kabbalah?.cube && typeof grouped.kabbalah.cube === "object" - ? grouped.kabbalah.cube - : {}; - const cubeWalls = Array.isArray(cube?.walls) ? cube.walls : []; - const cubeEdges = Array.isArray(cube?.edges) ? cube.edges : []; - const cubeCenter = cube?.center && typeof cube.center === "object" ? cube.center : null; - const playingCardsData = grouped?.["playing-cards-52"]; - const playingCards = Array.isArray(playingCardsData) - ? playingCardsData - : (Array.isArray(playingCardsData?.entries) ? playingCardsData.entries : []); - const signs = Array.isArray(referenceData?.signs) ? referenceData.signs : []; - const planetsById = referenceData?.planets && typeof referenceData.planets === "object" - ? referenceData.planets - : {}; - const planets = Object.values(planetsById); - const decansBySign = referenceData?.decansBySign && typeof referenceData.decansBySign === "object" - ? referenceData.decansBySign - : {}; - - const normalizeId = (value) => String(value || "").trim().toLowerCase(); - - const toRomanNumeral = (value) => { - const numeric = Number(value); - if (!Number.isFinite(numeric) || numeric <= 0) { - return String(value || ""); - } - - const intValue = Math.trunc(numeric); - const lookup = [ - [1000, "M"], - [900, "CM"], - [500, "D"], - [400, "CD"], - [100, "C"], - [90, "XC"], - [50, "L"], - [40, "XL"], - [10, "X"], - [9, "IX"], - [5, "V"], - [4, "IV"], - [1, "I"] - ]; - - let current = intValue; - let result = ""; - lookup.forEach(([size, symbol]) => { - while (current >= size) { - result += symbol; - current -= size; - } - }); - - return result || String(intValue); - }; - - const labelFromId = (value) => { - const id = String(value || "").trim(); - if (!id) { - return ""; - } - return id - .replace(/[_-]+/g, " ") - .replace(/\s+/g, " ") - .trim() - .split(" ") - .map((part) => part ? part.charAt(0).toUpperCase() + part.slice(1) : "") - .join(" "); - }; - - const getPlanetLabelById = (planetId) => { - const normalized = normalizeId(planetId); - if (!normalized) { - return ""; - } - - const directPlanet = planetsById[normalized]; - if (directPlanet?.name) { - return directPlanet.name; - } - - if (normalized === "primum-mobile") { - return "Primum Mobile"; - } - if (normalized === "olam-yesodot") { - return "Earth / Elements"; - } - - return labelFromId(normalized); - }; - - const hebrewById = new Map( - hebrewLetters - .filter((entry) => entry?.hebrewLetterId) - .map((entry) => [normalizeId(entry.hebrewLetterId), entry]) - ); - - const formatHebrewLetterLabel = (entry, fallbackId = "") => { - if (entry?.name && entry?.char) { - return `${entry.name} (${entry.char})`; - } - if (entry?.name) { - return entry.name; - } - if (entry?.char) { - return entry.char; - } - return labelFromId(fallbackId); - }; - - const sephiraNameByNumber = new Map( - treeSephiroth - .filter((entry) => Number.isFinite(Number(entry?.number)) && entry?.name) - .map((entry) => [Math.trunc(Number(entry.number)), String(entry.name)]) - ); - - const sephiraNameById = new Map( - treeSephiroth - .filter((entry) => entry?.sephiraId && entry?.name) - .map((entry) => [normalizeId(entry.sephiraId), String(entry.name)]) - ); - - const getSephiraName = (numberValue, idValue) => { - const numberKey = Number(numberValue); - if (Number.isFinite(numberKey)) { - const byNumber = sephiraNameByNumber.get(Math.trunc(numberKey)); - if (byNumber) { - return byNumber; - } - } - - const byId = sephiraNameById.get(normalizeId(idValue)); - if (byId) { - return byId; - } - - if (Number.isFinite(numberKey)) { - return `Sephira ${Math.trunc(numberKey)}`; - } - - return labelFromId(idValue); - }; - - const formatPathLetter = (path) => { - const transliteration = String(path?.hebrewLetter?.transliteration || "").trim(); - const glyph = String(path?.hebrewLetter?.char || "").trim(); - - if (transliteration && glyph) { - return `${transliteration} (${glyph})`; - } - if (transliteration) { - return transliteration; - } - if (glyph) { - return glyph; - } - return ""; - }; - - const flattenDecans = Object.values(decansBySign) - .flatMap((entries) => (Array.isArray(entries) ? entries : [])); - - const signNameById = new Map( - signs - .filter((entry) => entry?.id && entry?.name) - .map((entry) => [normalizeId(entry.id), String(entry.name)]) - ); - - const formatDecanLabel = (decan) => { - const signName = signNameById.get(normalizeId(decan?.signId)) || labelFromId(decan?.signId); - const index = Number(decan?.index); - if (!signName || !Number.isFinite(index)) { - return ""; - } - return `${signName} Decan ${toRomanNumeral(index)}`; - }; - - const bank = []; - - const englishGematriaPool = englishLetters - .map((item) => (Number.isFinite(Number(item?.pythagorean)) ? String(item.pythagorean) : "")) - .filter(Boolean); - - const hebrewNumerologyPool = hebrewLetters - .map((item) => (Number.isFinite(Number(item?.numerology)) ? String(item.numerology) : "")) - .filter(Boolean); - - const hebrewNameAndCharPool = hebrewLetters - .filter((item) => item?.name && item?.char) - .map((item) => `${item.name} (${item.char})`); - - const hebrewCharPool = hebrewLetters - .map((item) => item?.char) - .filter(Boolean); - - const planetNamePool = planets - .map((planet) => planet?.name) - .filter(Boolean); - - const planetWeekdayPool = planets - .map((planet) => planet?.weekday) - .filter(Boolean); - - const zodiacElementPool = signs - .map((sign) => toTitleCase(sign?.element)) - .filter(Boolean); - - const zodiacTarotPool = signs - .map((sign) => sign?.tarot?.majorArcana) - .filter(Boolean); - - const pathNumberPool = toUniqueOptionList( - treePaths - .map((path) => { - const pathNo = Number(path?.pathNumber); - return Number.isFinite(pathNo) ? String(Math.trunc(pathNo)) : ""; - }) - ); - - const pathLetterPool = toUniqueOptionList(treePaths.map((path) => formatPathLetter(path))); - const pathTarotPool = toUniqueOptionList(treePaths.map((path) => normalizeOption(path?.tarot?.card))); - - const decanLabelPool = toUniqueOptionList(flattenDecans.map((decan) => formatDecanLabel(decan))); - const decanRulerPool = toUniqueOptionList( - flattenDecans.map((decan) => getPlanetLabelById(decan?.rulerPlanetId)) - ); - - const cubeWallLabelPool = toUniqueOptionList( - cubeWalls.map((wall) => `${String(wall?.name || labelFromId(wall?.id)).trim()} Wall`) - ); - - const cubeEdgeLabelPool = toUniqueOptionList( - cubeEdges.map((edge) => `${String(edge?.name || labelFromId(edge?.id)).trim()} Edge`) - ); - - const cubeLocationPool = toUniqueOptionList([ - ...cubeWallLabelPool, - ...cubeEdgeLabelPool, - "Center" - ]); - - const cubeHebrewLetterPool = toUniqueOptionList([ - ...cubeWalls.map((wall) => { - const hebrew = hebrewById.get(normalizeId(wall?.hebrewLetterId)); - return formatHebrewLetterLabel(hebrew, wall?.hebrewLetterId); - }), - ...cubeEdges.map((edge) => { - const hebrew = hebrewById.get(normalizeId(edge?.hebrewLetterId)); - return formatHebrewLetterLabel(hebrew, edge?.hebrewLetterId); - }), - formatHebrewLetterLabel(hebrewById.get(normalizeId(cubeCenter?.hebrewLetterId)), cubeCenter?.hebrewLetterId) - ]); - - const playingTarotPool = toUniqueOptionList( - playingCards.map((entry) => normalizeOption(entry?.tarotCard)) - ); - - englishLetters.forEach((entry) => { - if (!entry?.letter || !Number.isFinite(Number(entry?.pythagorean))) { - return; - } - - const template = createQuestionTemplate( - { - key: `english-gematria:${entry.letter}`, - categoryId: "english-gematria", - category: "English Gematria", - promptByDifficulty: `${entry.letter} has a simple gematria value of`, - answerByDifficulty: String(entry.pythagorean) - }, - englishGematriaPool - ); - - if (template) { - bank.push(template); - } - }); - - hebrewLetters.forEach((entry) => { - if (!entry?.name || !entry?.char || !Number.isFinite(Number(entry?.numerology))) { - return; - } - - const template = createQuestionTemplate( - { - key: `hebrew-number:${entry.hebrewLetterId || entry.name}`, - categoryId: "hebrew-numerology", - category: "Hebrew Gematria", - promptByDifficulty: { - easy: `${entry.name} (${entry.char}) has a gematria value of`, - normal: `${entry.name} (${entry.char}) has a gematria value of`, - hard: `${entry.char} has a gematria value of` - }, - answerByDifficulty: String(entry.numerology) - }, - hebrewNumerologyPool - ); - - if (template) { - bank.push(template); - } - }); - - englishLetters.forEach((entry) => { - if (!entry?.letter || !entry?.hebrewLetterId) { - return; - } - - const mappedHebrew = hebrewById.get(String(entry.hebrewLetterId)); - if (!mappedHebrew?.name || !mappedHebrew?.char) { - return; - } - - const template = createQuestionTemplate( - { - key: `english-hebrew:${entry.letter}`, - categoryId: "english-hebrew-mapping", - category: "Alphabet Mapping", - promptByDifficulty: { - easy: `${entry.letter} maps to which Hebrew letter`, - normal: `${entry.letter} maps to which Hebrew letter`, - hard: `${entry.letter} maps to which Hebrew glyph` - }, - answerByDifficulty: { - easy: `${mappedHebrew.name} (${mappedHebrew.char})`, - normal: `${mappedHebrew.name} (${mappedHebrew.char})`, - hard: mappedHebrew.char - } - }, - { - easy: hebrewNameAndCharPool, - normal: hebrewNameAndCharPool, - hard: hebrewCharPool - } - ); - - if (template) { - bank.push(template); - } - }); - - signs.forEach((entry) => { - if (!entry?.name || !entry?.rulingPlanetId) { - return; - } - - const rulerName = planetsById[String(entry.rulingPlanetId)]?.name; - if (!rulerName) { - return; - } - - const template = createQuestionTemplate( - { - key: `zodiac-ruler:${entry.id || entry.name}`, - categoryId: "zodiac-rulers", - category: "Zodiac Rulers", - promptByDifficulty: `${entry.name} is ruled by`, - answerByDifficulty: rulerName - }, - planetNamePool - ); - - if (template) { - bank.push(template); - } - }); - - signs.forEach((entry) => { - if (!entry?.name || !entry?.element) { - return; - } - - const template = createQuestionTemplate( - { - key: `zodiac-element:${entry.id || entry.name}`, - categoryId: "zodiac-elements", - category: "Zodiac Elements", - promptByDifficulty: `${entry.name} is`, - answerByDifficulty: toTitleCase(entry.element) - }, - zodiacElementPool - ); - - if (template) { - bank.push(template); - } - }); - - planets.forEach((entry) => { - if (!entry?.name || !entry?.weekday) { - return; - } - - const template = createQuestionTemplate( - { - key: `planet-weekday:${entry.id || entry.name}`, - categoryId: "planetary-weekdays", - category: "Planetary Weekdays", - promptByDifficulty: `${entry.name} corresponds to`, - answerByDifficulty: entry.weekday - }, - planetWeekdayPool - ); - - if (template) { - bank.push(template); - } - }); - - signs.forEach((entry) => { - if (!entry?.name || !entry?.tarot?.majorArcana) { - return; - } - - const template = createQuestionTemplate( - { - key: `zodiac-tarot:${entry.id || entry.name}`, - categoryId: "zodiac-tarot", - category: "Zodiac ↔ Tarot", - promptByDifficulty: `${entry.name} corresponds to`, - answerByDifficulty: entry.tarot.majorArcana - }, - zodiacTarotPool - ); - - if (template) { - bank.push(template); - } - }); - - treePaths.forEach((path) => { - const pathNo = Number(path?.pathNumber); - if (!Number.isFinite(pathNo)) { - return; - } - - const pathNumberLabel = String(Math.trunc(pathNo)); - const fromNo = Number(path?.connects?.from); - const toNo = Number(path?.connects?.to); - const fromName = getSephiraName(fromNo, path?.connectIds?.from); - const toName = getSephiraName(toNo, path?.connectIds?.to); - const pathLetter = formatPathLetter(path); - const tarotCard = normalizeOption(path?.tarot?.card); - - if (fromName && toName) { - const template = createQuestionTemplate( - { - key: `kabbalah-path-between:${pathNumberLabel}`, - categoryId: "kabbalah-path-between-sephirot", - category: "Kabbalah Paths", - promptByDifficulty: { - easy: `Which path is between ${fromName} and ${toName}`, - normal: `What path connects ${fromName} and ${toName}`, - hard: `${fromName} ↔ ${toName} is which path` - }, - answerByDifficulty: pathNumberLabel - }, - pathNumberPool - ); - - if (template) { - bank.push(template); - } - } - - if (pathLetter) { - const numberToLetterTemplate = createQuestionTemplate( - { - key: `kabbalah-path-letter:${pathNumberLabel}`, - categoryId: "kabbalah-path-letter", - category: "Kabbalah Paths", - promptByDifficulty: { - easy: `Which letter is on Path ${pathNumberLabel}`, - normal: `Path ${pathNumberLabel} carries which Hebrew letter`, - hard: `Letter on Path ${pathNumberLabel}` - }, - answerByDifficulty: pathLetter - }, - pathLetterPool - ); - - if (numberToLetterTemplate) { - bank.push(numberToLetterTemplate); - } - - const letterToNumberTemplate = createQuestionTemplate( - { - key: `kabbalah-letter-path-number:${pathNumberLabel}`, - categoryId: "kabbalah-path-letter", - category: "Kabbalah Paths", - promptByDifficulty: { - easy: `${pathLetter} belongs to which path`, - normal: `${pathLetter} corresponds to Path`, - hard: `${pathLetter} is on Path` - }, - answerByDifficulty: pathNumberLabel - }, - pathNumberPool - ); - - if (letterToNumberTemplate) { - bank.push(letterToNumberTemplate); - } - } - - if (tarotCard) { - const pathToTarotTemplate = createQuestionTemplate( - { - key: `kabbalah-path-tarot:${pathNumberLabel}`, - categoryId: "kabbalah-path-tarot", - category: "Kabbalah ↔ Tarot", - promptByDifficulty: { - easy: `Path ${pathNumberLabel} corresponds to which Tarot trump`, - normal: `Which Tarot trump is on Path ${pathNumberLabel}`, - hard: `Tarot trump on Path ${pathNumberLabel}` - }, - answerByDifficulty: tarotCard - }, - pathTarotPool - ); - - if (pathToTarotTemplate) { - bank.push(pathToTarotTemplate); - } - - const tarotToPathTemplate = createQuestionTemplate( - { - key: `tarot-trump-path:${pathNumberLabel}`, - categoryId: "kabbalah-path-tarot", - category: "Tarot ↔ Kabbalah", - promptByDifficulty: { - easy: `${tarotCard} is on which path`, - normal: `Which path corresponds to ${tarotCard}`, - hard: `${tarotCard} corresponds to Path` - }, - answerByDifficulty: pathNumberLabel - }, - pathNumberPool - ); - - if (tarotToPathTemplate) { - bank.push(tarotToPathTemplate); - } - } - }); - - Object.values(sephirotById).forEach((sephira) => { - const sephiraName = String(sephira?.name?.roman || sephira?.name?.en || "").trim(); - const planetLabel = getPlanetLabelById(sephira?.planetId); - if (!sephiraName || !planetLabel) { - return; - } - - const template = createQuestionTemplate( - { - key: `sephirot-planet:${normalizeId(sephira?.id || sephiraName)}`, - categoryId: "sephirot-planets", - category: "Sephirot ↔ Planet", - promptByDifficulty: { - easy: `${sephiraName} corresponds to which planet`, - normal: `Planetary correspondence of ${sephiraName}`, - hard: `${sephiraName} corresponds to` - }, - answerByDifficulty: planetLabel - }, - toUniqueOptionList(Object.values(sephirotById).map((entry) => getPlanetLabelById(entry?.planetId))) - ); - - if (template) { - bank.push(template); - } - }); - - flattenDecans.forEach((decan) => { - const decanId = String(decan?.id || "").trim(); - const card = normalizeOption(decan?.tarotMinorArcana); - const decanLabel = formatDecanLabel(decan); - const rulerLabel = getPlanetLabelById(decan?.rulerPlanetId); - - if (!decanId || !card) { - return; - } - - if (decanLabel) { - const template = createQuestionTemplate( - { - key: `tarot-decan-sign:${decanId}`, - categoryId: "tarot-decan-sign", - category: "Tarot Decans", - promptByDifficulty: { - easy: `${card} belongs to which decan`, - normal: `Which decan contains ${card}`, - hard: `${card} is in` - }, - answerByDifficulty: decanLabel - }, - decanLabelPool - ); - - if (template) { - bank.push(template); - } - } - - if (rulerLabel) { - const template = createQuestionTemplate( - { - key: `tarot-decan-ruler:${decanId}`, - categoryId: "tarot-decan-ruler", - category: "Tarot Decans", - promptByDifficulty: { - easy: `The decan of ${card} is ruled by`, - normal: `Who rules the decan for ${card}`, - hard: `${card} decan ruler` - }, - answerByDifficulty: rulerLabel - }, - decanRulerPool - ); - - if (template) { - bank.push(template); - } - } - }); - - cubeWalls.forEach((wall) => { - const wallName = String(wall?.name || labelFromId(wall?.id)).trim(); - const wallLabel = wallName ? `${wallName} Wall` : ""; - const tarotCard = normalizeOption(wall?.associations?.tarotCard); - const hebrew = hebrewById.get(normalizeId(wall?.hebrewLetterId)); - const hebrewLabel = formatHebrewLetterLabel(hebrew, wall?.hebrewLetterId); - - if (tarotCard && wallLabel) { - const template = createQuestionTemplate( - { - key: `tarot-cube-wall:${normalizeId(wall?.id || wallName)}`, - categoryId: "tarot-cube-location", - category: "Tarot ↔ Cube", - promptByDifficulty: { - easy: `${tarotCard} is on which Cube wall`, - normal: `Where is ${tarotCard} on the Cube`, - hard: `${tarotCard} location on Cube` - }, - answerByDifficulty: wallLabel - }, - cubeLocationPool - ); - - if (template) { - bank.push(template); - } - } - - if (wallLabel && hebrewLabel) { - const template = createQuestionTemplate( - { - key: `cube-wall-letter:${normalizeId(wall?.id || wallName)}`, - categoryId: "cube-hebrew-letter", - category: "Cube ↔ Hebrew", - promptByDifficulty: { - easy: `${wallLabel} corresponds to which Hebrew letter`, - normal: `Which Hebrew letter is on ${wallLabel}`, - hard: `${wallLabel} letter` - }, - answerByDifficulty: hebrewLabel - }, - cubeHebrewLetterPool - ); - - if (template) { - bank.push(template); - } - } - }); - - cubeEdges.forEach((edge) => { - const edgeName = String(edge?.name || labelFromId(edge?.id)).trim(); - const edgeLabel = edgeName ? `${edgeName} Edge` : ""; - const hebrew = hebrewById.get(normalizeId(edge?.hebrewLetterId)); - const hebrewLabel = formatHebrewLetterLabel(hebrew, edge?.hebrewLetterId); - const tarotCard = normalizeOption(hebrew?.tarot?.card); - - if (tarotCard && edgeLabel) { - const template = createQuestionTemplate( - { - key: `tarot-cube-edge:${normalizeId(edge?.id || edgeName)}`, - categoryId: "tarot-cube-location", - category: "Tarot ↔ Cube", - promptByDifficulty: { - easy: `${tarotCard} is on which Cube edge`, - normal: `Where is ${tarotCard} on the Cube edges`, - hard: `${tarotCard} edge location` - }, - answerByDifficulty: edgeLabel - }, - cubeLocationPool - ); - - if (template) { - bank.push(template); - } - } - - if (edgeLabel && hebrewLabel) { - const template = createQuestionTemplate( - { - key: `cube-edge-letter:${normalizeId(edge?.id || edgeName)}`, - categoryId: "cube-hebrew-letter", - category: "Cube ↔ Hebrew", - promptByDifficulty: { - easy: `${edgeLabel} corresponds to which Hebrew letter`, - normal: `Which Hebrew letter is on ${edgeLabel}`, - hard: `${edgeLabel} letter` - }, - answerByDifficulty: hebrewLabel - }, - cubeHebrewLetterPool - ); - - if (template) { - bank.push(template); - } - } - }); - - if (cubeCenter) { - const centerTarot = normalizeOption(cubeCenter?.associations?.tarotCard || cubeCenter?.tarotCard); - const centerHebrew = hebrewById.get(normalizeId(cubeCenter?.hebrewLetterId)); - const centerHebrewLabel = formatHebrewLetterLabel(centerHebrew, cubeCenter?.hebrewLetterId); - - if (centerTarot) { - const template = createQuestionTemplate( - { - key: "tarot-cube-center", - categoryId: "tarot-cube-location", - category: "Tarot ↔ Cube", - promptByDifficulty: { - easy: `${centerTarot} is located at which Cube position`, - normal: `Where is ${centerTarot} on the Cube`, - hard: `${centerTarot} Cube location` - }, - answerByDifficulty: "Center" - }, - cubeLocationPool - ); - - if (template) { - bank.push(template); - } - } - - if (centerHebrewLabel) { - const template = createQuestionTemplate( - { - key: "cube-center-letter", - categoryId: "cube-hebrew-letter", - category: "Cube ↔ Hebrew", - promptByDifficulty: { - easy: "The Cube center corresponds to which Hebrew letter", - normal: "Which Hebrew letter is at the Cube center", - hard: "Cube center letter" - }, - answerByDifficulty: centerHebrewLabel - }, - cubeHebrewLetterPool - ); - - if (template) { - bank.push(template); - } - } + if (typeof quizQuestionBank.buildQuestionBank !== "function") { + return []; } - playingCards.forEach((entry) => { - const cardId = String(entry?.id || "").trim(); - const rankLabel = normalizeOption(entry?.rankLabel || entry?.rank); - const suitLabel = normalizeOption(entry?.suitLabel || labelFromId(entry?.suit)); - const tarotCard = normalizeOption(entry?.tarotCard); - - if (!cardId || !rankLabel || !suitLabel || !tarotCard) { - return; - } - - const template = createQuestionTemplate( - { - key: `playing-card-tarot:${cardId}`, - categoryId: "playing-card-tarot", - category: "Playing Card ↔ Tarot", - promptByDifficulty: { - easy: `${rankLabel} of ${suitLabel} maps to which Tarot card`, - normal: `${rankLabel} of ${suitLabel} corresponds to`, - hard: `${rankLabel} of ${suitLabel} maps to` - }, - answerByDifficulty: tarotCard - }, - playingTarotPool - ); - - if (template) { - bank.push(template); - } - }); - - // Dynamic plugin categories - DYNAMIC_CATEGORY_REGISTRY.forEach(({ builder }) => { - try { - const dynamicTemplates = builder(referenceData, magickDataset); - if (Array.isArray(dynamicTemplates)) { - dynamicTemplates.forEach((t) => { - if (t) { - bank.push(t); - } - }); - } - } catch (_e) { - // skip broken plugins silently - } - }); - - return bank; + return quizQuestionBank.buildQuestionBank( + referenceData, + magickDataset, + DYNAMIC_CATEGORY_REGISTRY + ); } function refreshQuestionBank(referenceData, magickDataset) { diff --git a/app/ui-rosicrucian-cross.js b/app/ui-rosicrucian-cross.js new file mode 100644 index 0000000..fdcff72 --- /dev/null +++ b/app/ui-rosicrucian-cross.js @@ -0,0 +1,270 @@ +(function () { + "use strict"; + + const NS = "http://www.w3.org/2000/svg"; + + function normalizeText(value) { + return String(value || "").trim().toLowerCase(); + } + + function svgEl(tag, attrs, text) { + const el = document.createElementNS(NS, tag); + Object.entries(attrs || {}).forEach(([key, value]) => { + el.setAttribute(key, String(value)); + }); + if (text != null) { + el.textContent = text; + } + return el; + } + + function normalizeLetterType(value) { + const normalized = normalizeText(value); + if (normalized.includes("mother")) return "mother"; + if (normalized.includes("double")) return "double"; + if (normalized.includes("simple")) return "simple"; + return "other"; + } + + function getRosePaletteForType(letterType) { + if (letterType === "mother") { + return ["#facc15", "#4ade80", "#f97316"]; + } + + if (letterType === "double") { + return ["#fde047", "#fb7185", "#fdba74", "#34d399", "#60a5fa", "#c084fc", "#fca5a5"]; + } + + if (letterType === "simple") { + return [ + "#ef4444", "#f97316", "#f59e0b", "#eab308", "#84cc16", "#22c55e", + "#14b8a6", "#06b6d4", "#3b82f6", "#6366f1", "#8b5cf6", "#d946ef" + ]; + } + + return ["#71717a", "#a1a1aa", "#52525b"]; + } + + function appendRosePetalRing(svg, paths, options) { + if (!Array.isArray(paths) || !paths.length) { + return; + } + + const cx = Number(options?.cx) || 490; + const cy = Number(options?.cy) || 560; + const ringRadius = Number(options?.ringRadius) || 200; + const petalRadius = Number(options?.petalRadius) || 38; + const startDeg = Number(options?.startDeg) || -90; + const letterType = String(options?.letterType || "other"); + const className = String(options?.className || ""); + const palette = getRosePaletteForType(letterType); + + paths.forEach((path, index) => { + const angle = ((startDeg + ((360 / paths.length) * index)) * Math.PI) / 180; + const px = cx + Math.cos(angle) * ringRadius; + const py = cy + Math.sin(angle) * ringRadius; + const letterChar = String(path?.hebrewLetter?.char || "?").trim() || "?"; + const transliteration = String(path?.hebrewLetter?.transliteration || "").trim(); + const tarotCard = String(path?.tarot?.card || "").trim(); + const fill = palette[index % palette.length]; + + const group = svgEl("g", { + class: `kab-rose-petal ${className}`.trim(), + "data-path": path.pathNumber, + role: "button", + tabindex: "0", + "aria-label": `Path ${path.pathNumber}: ${transliteration} ${letterChar}${tarotCard ? ` - ${tarotCard}` : ""}` + }); + + group.appendChild(svgEl("circle", { + cx: px.toFixed(2), + cy: py.toFixed(2), + r: petalRadius.toFixed(2), + class: "kab-rose-petal-shape", + fill, + stroke: "rgba(255,255,255,0.45)", + "stroke-width": "1.5", + style: "transform-box: fill-box; transform-origin: center;" + })); + + group.appendChild(svgEl("text", { + x: px.toFixed(2), + y: (py + 4).toFixed(2), + class: "kab-rose-petal-letter", + "text-anchor": "middle", + "dominant-baseline": "middle" + }, letterChar)); + + group.appendChild(svgEl("text", { + x: px.toFixed(2), + y: (py + petalRadius - 10).toFixed(2), + class: "kab-rose-petal-number", + "text-anchor": "middle", + "dominant-baseline": "middle" + }, String(path.pathNumber))); + + svg.appendChild(group); + }); + } + + function buildRosicrucianCrossSVG(tree) { + const cx = 490; + const cy = 560; + + const svg = svgEl("svg", { + viewBox: "0 0 980 1180", + width: "100%", + role: "img", + "aria-label": "Rosicrucian cross with Hebrew letter petals", + class: "kab-rose-svg" + }); + + for (let index = 0; index < 16; index += 1) { + const angle = ((index * 22.5) - 90) * (Math.PI / 180); + const baseAngle = 7 * (Math.PI / 180); + const innerRadius = 232; + const outerRadius = index % 2 === 0 ? 350 : 320; + const x1 = cx + Math.cos(angle - baseAngle) * innerRadius; + const y1 = cy + Math.sin(angle - baseAngle) * innerRadius; + const x2 = cx + Math.cos(angle + baseAngle) * innerRadius; + const y2 = cy + Math.sin(angle + baseAngle) * innerRadius; + const x3 = cx + Math.cos(angle) * outerRadius; + const y3 = cy + Math.sin(angle) * outerRadius; + svg.appendChild(svgEl("polygon", { + points: `${x1.toFixed(2)},${y1.toFixed(2)} ${x2.toFixed(2)},${y2.toFixed(2)} ${x3.toFixed(2)},${y3.toFixed(2)}`, + fill: "#f8fafc", + stroke: "#0f172a", + "stroke-opacity": "0.18", + "stroke-width": "1" + })); + } + + svg.appendChild(svgEl("rect", { x: 408, y: 86, width: 164, height: 404, rx: 26, fill: "#f6e512", stroke: "#111827", "stroke-opacity": "0.55", "stroke-width": "1.6" })); + svg.appendChild(svgEl("rect", { x: 96, y: 462, width: 348, height: 154, rx: 22, fill: "#ef1c24", stroke: "#111827", "stroke-opacity": "0.55", "stroke-width": "1.6" })); + svg.appendChild(svgEl("rect", { x: 536, y: 462, width: 348, height: 154, rx: 22, fill: "#1537ee", stroke: "#111827", "stroke-opacity": "0.55", "stroke-width": "1.6" })); + svg.appendChild(svgEl("rect", { x: 408, y: 616, width: 164, height: 286, rx: 0, fill: "#f3f4f6", stroke: "#111827", "stroke-opacity": "0.45", "stroke-width": "1.3" })); + + svg.appendChild(svgEl("polygon", { points: "408,902 490,902 408,980", fill: "#b3482f" })); + svg.appendChild(svgEl("polygon", { points: "490,902 572,902 572,980", fill: "#506b1c" })); + svg.appendChild(svgEl("polygon", { points: "408,902 490,902 490,980", fill: "#d4aa15" })); + svg.appendChild(svgEl("polygon", { points: "408,980 572,980 490,1106", fill: "#020617" })); + + [ + { cx: 490, cy: 90, r: 52, fill: "#f6e512" }, + { cx: 430, cy: 154, r: 48, fill: "#f6e512" }, + { cx: 550, cy: 154, r: 48, fill: "#f6e512" }, + { cx: 90, cy: 539, r: 52, fill: "#ef1c24" }, + { cx: 154, cy: 480, r: 48, fill: "#ef1c24" }, + { cx: 154, cy: 598, r: 48, fill: "#ef1c24" }, + { cx: 890, cy: 539, r: 52, fill: "#1537ee" }, + { cx: 826, cy: 480, r: 48, fill: "#1537ee" }, + { cx: 826, cy: 598, r: 48, fill: "#1537ee" }, + { cx: 430, cy: 1038, r: 48, fill: "#b3482f" }, + { cx: 550, cy: 1038, r: 48, fill: "#506b1c" }, + { cx: 490, cy: 1110, r: 72, fill: "#020617" } + ].forEach((entry) => { + svg.appendChild(svgEl("circle", { + cx: entry.cx, + cy: entry.cy, + r: entry.r, + fill: entry.fill, + stroke: "#111827", + "stroke-opacity": "0.56", + "stroke-width": "1.6" + })); + }); + + [ + { x: 490, y: 128, t: "☿", c: "#a21caf", s: 50 }, + { x: 490, y: 206, t: "✶", c: "#a21caf", s: 56 }, + { x: 172, y: 539, t: "✶", c: "#16a34a", s: 62 }, + { x: 810, y: 539, t: "✶", c: "#fb923c", s: 62 }, + { x: 490, y: 778, t: "✡", c: "#52525b", s: 66 }, + { x: 490, y: 996, t: "✶", c: "#f8fafc", s: 62 }, + { x: 490, y: 1118, t: "☿", c: "#f8fafc", s: 56 } + ].forEach((glyph) => { + svg.appendChild(svgEl("text", { + x: glyph.x, + y: glyph.y, + "text-anchor": "middle", + "dominant-baseline": "middle", + class: "kab-rose-arm-glyph", + fill: glyph.c, + "font-size": String(glyph.s), + "font-weight": "700" + }, glyph.t)); + }); + + svg.appendChild(svgEl("circle", { cx, cy, r: 286, fill: "rgba(2, 6, 23, 0.42)", stroke: "rgba(248, 250, 252, 0.24)", "stroke-width": "2" })); + svg.appendChild(svgEl("circle", { cx, cy, r: 252, fill: "rgba(15, 23, 42, 0.32)", stroke: "rgba(248, 250, 252, 0.2)", "stroke-width": "1.5" })); + + const pathEntries = Array.isArray(tree?.paths) + ? [...tree.paths].sort((left, right) => Number(left?.pathNumber) - Number(right?.pathNumber)) + : []; + + const grouped = { + mother: [], + double: [], + simple: [], + other: [] + }; + + pathEntries.forEach((entry) => { + const letterType = normalizeLetterType(entry?.hebrewLetter?.letterType); + grouped[letterType].push(entry); + }); + + appendRosePetalRing(svg, grouped.simple, { + cx, + cy, + ringRadius: 216, + petalRadius: 34, + startDeg: -90, + letterType: "simple", + className: "kab-rose-petal--simple" + }); + + appendRosePetalRing(svg, grouped.double, { + cx, + cy, + ringRadius: 154, + petalRadius: 36, + startDeg: -78, + letterType: "double", + className: "kab-rose-petal--double" + }); + + appendRosePetalRing(svg, grouped.mother, { + cx, + cy, + ringRadius: 96, + petalRadius: 40, + startDeg: -90, + letterType: "mother", + className: "kab-rose-petal--mother" + }); + + appendRosePetalRing(svg, grouped.other, { + cx, + cy, + ringRadius: 274, + petalRadius: 30, + startDeg: -90, + letterType: "other", + className: "kab-rose-petal--other" + }); + + svg.appendChild(svgEl("circle", { cx, cy, r: 64, fill: "#f8fafc", stroke: "#111827", "stroke-width": "1.7" })); + svg.appendChild(svgEl("circle", { cx, cy, r: 44, fill: "#facc15", stroke: "#111827", "stroke-width": "1.4" })); + svg.appendChild(svgEl("path", { d: "M490 516 L490 604 M446 560 L534 560", stroke: "#111827", "stroke-width": "8", "stroke-linecap": "round" })); + svg.appendChild(svgEl("circle", { cx, cy, r: 22, fill: "#db2777", stroke: "#111827", "stroke-width": "1.1" })); + svg.appendChild(svgEl("circle", { cx, cy, r: 10, fill: "#f59e0b", stroke: "#111827", "stroke-width": "1" })); + + return svg; + } + + window.KabbalahRosicrucianCross = { + ...(window.KabbalahRosicrucianCross || {}), + buildRosicrucianCrossSVG + }; +})(); diff --git a/app/ui-section-state.js b/app/ui-section-state.js new file mode 100644 index 0000000..a02bc2d --- /dev/null +++ b/app/ui-section-state.js @@ -0,0 +1,264 @@ +(function () { + "use strict"; + + const VALID_SECTIONS = new Set([ + "home", + "calendar", + "holidays", + "tarot", + "astronomy", + "planets", + "cycles", + "natal", + "elements", + "iching", + "kabbalah", + "kabbalah-tree", + "cube", + "alphabet", + "numbers", + "zodiac", + "quiz", + "gods", + "enochian" + ]); + + let activeSection = "home"; + let config = { + elements: {}, + ensure: {}, + getReferenceData: () => null, + getMagickDataset: () => null, + calendarVisualsUi: null, + tarotSpreadUi: null, + settingsUi: null, + homeUi: null, + calendar: null + }; + + function setHidden(element, hidden) { + if (element) { + element.hidden = hidden; + } + } + + function setPressed(element, pressed) { + if (element) { + element.setAttribute("aria-pressed", pressed ? "true" : "false"); + } + } + + function toggleActive(element, active) { + if (element) { + element.classList.toggle("is-active", active); + } + } + + function getReferenceData() { + return config.getReferenceData?.() || null; + } + + function getMagickDataset() { + return config.getMagickDataset?.() || null; + } + + function renderHomeFallback() { + requestAnimationFrame(() => { + config.calendar?.render?.(); + config.calendarVisualsUi?.updateMonthStrip?.(); + config.homeUi?.syncNowPanelTheme?.(new Date()); + }); + } + + function setActiveSection(nextSection) { + const normalized = VALID_SECTIONS.has(nextSection) ? nextSection : "home"; + activeSection = normalized; + + const elements = config.elements || {}; + const ensure = config.ensure || {}; + const referenceData = getReferenceData(); + const magickDataset = getMagickDataset(); + + const isHomeOpen = activeSection === "home"; + const isCalendarOpen = activeSection === "calendar"; + const isHolidaysOpen = activeSection === "holidays"; + const isCalendarMenuOpen = isCalendarOpen || isHolidaysOpen; + const isTarotOpen = activeSection === "tarot"; + const isAstronomyOpen = activeSection === "astronomy"; + const isPlanetOpen = activeSection === "planets"; + const isCyclesOpen = activeSection === "cycles"; + const isNatalOpen = activeSection === "natal"; + const isZodiacOpen = activeSection === "zodiac"; + const isAstronomyMenuOpen = isAstronomyOpen || isPlanetOpen || isCyclesOpen || isZodiacOpen || isNatalOpen; + const isElementsOpen = activeSection === "elements"; + const isIChingOpen = activeSection === "iching"; + const isKabbalahOpen = activeSection === "kabbalah"; + const isKabbalahTreeOpen = activeSection === "kabbalah-tree"; + const isCubeOpen = activeSection === "cube"; + const isKabbalahMenuOpen = isKabbalahOpen || isKabbalahTreeOpen || isCubeOpen; + const isAlphabetOpen = activeSection === "alphabet"; + const isNumbersOpen = activeSection === "numbers"; + const isQuizOpen = activeSection === "quiz"; + const isGodsOpen = activeSection === "gods"; + const isEnochianOpen = activeSection === "enochian"; + + setHidden(elements.calendarSectionEl, !isCalendarOpen); + setHidden(elements.holidaySectionEl, !isHolidaysOpen); + setHidden(elements.tarotSectionEl, !isTarotOpen); + setHidden(elements.astronomySectionEl, !isAstronomyOpen); + setHidden(elements.planetSectionEl, !isPlanetOpen); + setHidden(elements.cyclesSectionEl, !isCyclesOpen); + setHidden(elements.natalSectionEl, !isNatalOpen); + setHidden(elements.elementsSectionEl, !isElementsOpen); + setHidden(elements.ichingSectionEl, !isIChingOpen); + setHidden(elements.kabbalahSectionEl, !isKabbalahOpen); + setHidden(elements.kabbalahTreeSectionEl, !isKabbalahTreeOpen); + setHidden(elements.cubeSectionEl, !isCubeOpen); + setHidden(elements.alphabetSectionEl, !isAlphabetOpen); + setHidden(elements.numbersSectionEl, !isNumbersOpen); + setHidden(elements.zodiacSectionEl, !isZodiacOpen); + setHidden(elements.quizSectionEl, !isQuizOpen); + setHidden(elements.godsSectionEl, !isGodsOpen); + setHidden(elements.enochianSectionEl, !isEnochianOpen); + setHidden(elements.nowPanelEl, !isHomeOpen); + setHidden(elements.monthStripEl, !isHomeOpen); + setHidden(elements.calendarEl, !isHomeOpen); + + setPressed(elements.openCalendarEl, isCalendarMenuOpen); + toggleActive(elements.openCalendarMonthsEl, isCalendarOpen); + toggleActive(elements.openHolidaysEl, isHolidaysOpen); + setPressed(elements.openTarotEl, isTarotOpen); + config.tarotSpreadUi?.applyViewState?.(); + setPressed(elements.openAstronomyEl, isAstronomyMenuOpen); + toggleActive(elements.openPlanetsEl, isPlanetOpen); + toggleActive(elements.openCyclesEl, isCyclesOpen); + setPressed(elements.openElementsEl, isElementsOpen); + setPressed(elements.openIChingEl, isIChingOpen); + setPressed(elements.openKabbalahEl, isKabbalahMenuOpen); + toggleActive(elements.openKabbalahTreeEl, isKabbalahTreeOpen); + toggleActive(elements.openKabbalahCubeEl, isCubeOpen); + setPressed(elements.openAlphabetEl, isAlphabetOpen); + setPressed(elements.openNumbersEl, isNumbersOpen); + toggleActive(elements.openZodiacEl, isZodiacOpen); + toggleActive(elements.openNatalEl, isNatalOpen); + setPressed(elements.openQuizEl, isQuizOpen); + setPressed(elements.openGodsEl, isGodsOpen); + setPressed(elements.openEnochianEl, isEnochianOpen); + + if (!isHomeOpen) { + config.settingsUi?.closeSettingsPopup?.(); + } + + if (isCalendarOpen) { + ensure.ensureCalendarSection?.(referenceData, magickDataset); + return; + } + + if (isHolidaysOpen) { + ensure.ensureHolidaySection?.(referenceData, magickDataset); + return; + } + + if (isTarotOpen) { + if (typeof config.tarotSpreadUi?.handleSectionActivated === "function") { + config.tarotSpreadUi.handleSectionActivated(); + } else { + ensure.ensureTarotSection?.(referenceData, magickDataset); + } + return; + } + + if (isPlanetOpen) { + ensure.ensurePlanetSection?.(referenceData, magickDataset); + return; + } + + if (isCyclesOpen) { + ensure.ensureCyclesSection?.(referenceData); + return; + } + + if (isElementsOpen) { + ensure.ensureElementsSection?.(magickDataset); + return; + } + + if (isIChingOpen) { + ensure.ensureIChingSection?.(referenceData); + return; + } + + if (isKabbalahOpen || isKabbalahTreeOpen) { + ensure.ensureKabbalahSection?.(magickDataset); + return; + } + + if (isCubeOpen) { + ensure.ensureCubeSection?.(magickDataset, referenceData); + return; + } + + if (isAlphabetOpen) { + ensure.ensureAlphabetSection?.(magickDataset, referenceData); + return; + } + + if (isNumbersOpen) { + ensure.ensureNumbersSection?.(); + return; + } + + if (isZodiacOpen) { + ensure.ensureZodiacSection?.(referenceData, magickDataset); + return; + } + + if (isNatalOpen) { + ensure.ensureNatalPanel?.(referenceData); + return; + } + + if (isQuizOpen) { + ensure.ensureQuizSection?.(referenceData, magickDataset); + return; + } + + if (isGodsOpen) { + ensure.ensureGodsSection?.(magickDataset, referenceData); + return; + } + + if (isEnochianOpen) { + ensure.ensureEnochianSection?.(magickDataset, referenceData); + return; + } + + renderHomeFallback(); + } + + function getActiveSection() { + return activeSection; + } + + function init(nextConfig = {}) { + config = { + ...config, + ...nextConfig, + elements: { + ...(config.elements || {}), + ...(nextConfig.elements || {}) + }, + ensure: { + ...(config.ensure || {}), + ...(nextConfig.ensure || {}) + } + }; + } + + window.TarotSectionStateUi = { + ...(window.TarotSectionStateUi || {}), + init, + getActiveSection, + setActiveSection + }; +})(); diff --git a/app/ui-settings.js b/app/ui-settings.js new file mode 100644 index 0000000..c249e27 --- /dev/null +++ b/app/ui-settings.js @@ -0,0 +1,453 @@ +(function () { + "use strict"; + + const SETTINGS_STORAGE_KEY = "tarot-time-settings-v1"; + + let config = { + defaultSettings: { + latitude: 51.5074, + longitude: -0.1278, + timeFormat: "minutes", + birthDate: "", + tarotDeck: "ceremonial-magick" + }, + onSettingsApplied: null, + onSyncSkyBackground: null, + onStatus: null, + onReopenActiveSection: null, + getActiveSection: null, + onRenderWeek: null + }; + + function getElements() { + return { + openSettingsEl: document.getElementById("open-settings"), + closeSettingsEl: document.getElementById("close-settings"), + settingsPopupEl: document.getElementById("settings-popup"), + settingsPopupCardEl: document.getElementById("settings-popup-card"), + latEl: document.getElementById("lat"), + lngEl: document.getElementById("lng"), + timeFormatEl: document.getElementById("time-format"), + birthDateEl: document.getElementById("birth-date"), + tarotDeckEl: document.getElementById("tarot-deck"), + saveSettingsEl: document.getElementById("save-settings"), + useLocationEl: document.getElementById("use-location") + }; + } + + function setStatus(text) { + if (typeof config.onStatus === "function") { + config.onStatus(text); + } + } + + function applyExternalSettings(settings) { + if (typeof config.onSettingsApplied === "function") { + config.onSettingsApplied(settings); + } + } + + function syncSky(geo, force) { + if (typeof config.onSyncSkyBackground === "function") { + config.onSyncSkyBackground(geo, force); + } + } + + function normalizeTimeFormat(value) { + if (value === "hours") { + return "hours"; + } + + if (value === "seconds") { + return "seconds"; + } + + return "minutes"; + } + + function normalizeBirthDate(value) { + const normalized = String(value || "").trim(); + if (!normalized) { + return ""; + } + + return /^\d{4}-\d{2}-\d{2}$/.test(normalized) ? normalized : ""; + } + + function getKnownTarotDeckIds() { + const knownDeckIds = new Set(); + const deckOptions = window.TarotCardImages?.getDeckOptions?.(); + + if (Array.isArray(deckOptions)) { + deckOptions.forEach((option) => { + const id = String(option?.id || "").trim().toLowerCase(); + if (id) { + knownDeckIds.add(id); + } + }); + } + + if (!knownDeckIds.size) { + knownDeckIds.add(String(config.defaultSettings?.tarotDeck || "ceremonial-magick").trim().toLowerCase()); + } + + return knownDeckIds; + } + + function getFallbackTarotDeckId() { + const deckOptions = window.TarotCardImages?.getDeckOptions?.(); + if (Array.isArray(deckOptions)) { + for (let i = 0; i < deckOptions.length; i += 1) { + const id = String(deckOptions[i]?.id || "").trim().toLowerCase(); + if (id) { + return id; + } + } + } + + return String(config.defaultSettings?.tarotDeck || "ceremonial-magick").trim().toLowerCase(); + } + + function normalizeTarotDeck(value) { + const normalized = String(value || "").trim().toLowerCase(); + const knownDeckIds = getKnownTarotDeckIds(); + + if (knownDeckIds.has(normalized)) { + return normalized; + } + + return getFallbackTarotDeckId(); + } + + function parseStoredNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; + } + + function normalizeSettings(settings) { + return { + latitude: parseStoredNumber(settings?.latitude, config.defaultSettings.latitude), + longitude: parseStoredNumber(settings?.longitude, config.defaultSettings.longitude), + timeFormat: normalizeTimeFormat(settings?.timeFormat), + birthDate: normalizeBirthDate(settings?.birthDate), + tarotDeck: normalizeTarotDeck(settings?.tarotDeck) + }; + } + + function getResolvedTimeZone() { + try { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + return String(timeZone || ""); + } catch { + return ""; + } + } + + function buildBirthDateParts(birthDate) { + const normalized = normalizeBirthDate(birthDate); + if (!normalized) { + return null; + } + + const [year, month, day] = normalized.split("-").map((value) => Number(value)); + if (!year || !month || !day) { + return null; + } + + const localNoon = new Date(year, month - 1, day, 12, 0, 0, 0); + const utcNoon = new Date(Date.UTC(year, month - 1, day, 12, 0, 0, 0)); + + return { + year, + month, + day, + isoDate: normalized, + localNoonIso: localNoon.toISOString(), + utcNoonIso: utcNoon.toISOString(), + timezoneOffsetMinutesAtNoon: localNoon.getTimezoneOffset() + }; + } + + function buildNatalContext(settings) { + const normalized = normalizeSettings(settings); + const birthDateParts = buildBirthDateParts(normalized.birthDate); + const timeZone = getResolvedTimeZone(); + + return { + latitude: normalized.latitude, + longitude: normalized.longitude, + birthDate: normalized.birthDate || null, + birthDateParts, + timeZone: timeZone || "UTC", + timezoneOffsetMinutesNow: new Date().getTimezoneOffset(), + timezoneOffsetMinutesAtBirthDateNoon: birthDateParts?.timezoneOffsetMinutesAtNoon ?? null + }; + } + + function emitSettingsUpdated(settings) { + const normalized = normalizeSettings(settings); + const natalContext = buildNatalContext(normalized); + document.dispatchEvent(new CustomEvent("settings:updated", { + detail: { + settings: normalized, + natalContext + } + })); + } + + function loadSavedSettings() { + try { + const raw = window.localStorage.getItem(SETTINGS_STORAGE_KEY); + if (!raw) { + return { ...config.defaultSettings }; + } + + const parsed = JSON.parse(raw); + return normalizeSettings(parsed); + } catch { + return { ...config.defaultSettings }; + } + } + + function saveSettings(settings) { + try { + const normalized = normalizeSettings(settings); + window.localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(normalized)); + return true; + } catch { + return false; + } + } + + function syncTarotDeckInputOptions() { + const { tarotDeckEl } = getElements(); + if (!tarotDeckEl) { + return; + } + + const deckOptions = window.TarotCardImages?.getDeckOptions?.(); + const previousValue = String(tarotDeckEl.value || "").trim().toLowerCase(); + tarotDeckEl.innerHTML = ""; + + if (!Array.isArray(deckOptions) || !deckOptions.length) { + const emptyOption = document.createElement("option"); + emptyOption.value = String(config.defaultSettings?.tarotDeck || "ceremonial-magick").trim().toLowerCase(); + emptyOption.textContent = "No deck manifests found"; + tarotDeckEl.appendChild(emptyOption); + tarotDeckEl.disabled = true; + return; + } + + tarotDeckEl.disabled = false; + + deckOptions.forEach((option) => { + const id = String(option?.id || "").trim().toLowerCase(); + if (!id) { + return; + } + + const label = String(option?.label || id); + const optionEl = document.createElement("option"); + optionEl.value = id; + optionEl.textContent = label; + tarotDeckEl.appendChild(optionEl); + }); + + tarotDeckEl.value = normalizeTarotDeck(previousValue); + } + + function applySettingsToInputs(settings) { + const { latEl, lngEl, timeFormatEl, birthDateEl, tarotDeckEl } = getElements(); + syncTarotDeckInputOptions(); + const normalized = normalizeSettings(settings); + latEl.value = String(normalized.latitude); + lngEl.value = String(normalized.longitude); + timeFormatEl.value = normalized.timeFormat; + birthDateEl.value = normalized.birthDate; + if (tarotDeckEl) { + tarotDeckEl.value = normalized.tarotDeck; + } + if (window.TarotCardImages?.setActiveDeck) { + window.TarotCardImages.setActiveDeck(normalized.tarotDeck); + } + applyExternalSettings(normalized); + return normalized; + } + + function getSettingsFromInputs() { + const { latEl, lngEl, timeFormatEl, birthDateEl, tarotDeckEl } = getElements(); + const latitude = Number(latEl.value); + const longitude = Number(lngEl.value); + + if (Number.isNaN(latitude) || Number.isNaN(longitude)) { + throw new Error("Latitude/Longitude must be valid numbers."); + } + + return normalizeSettings({ + latitude, + longitude, + timeFormat: normalizeTimeFormat(timeFormatEl.value), + birthDate: normalizeBirthDate(birthDateEl.value), + tarotDeck: normalizeTarotDeck(tarotDeckEl?.value) + }); + } + + function openSettingsPopup() { + const { settingsPopupEl, openSettingsEl } = getElements(); + if (!settingsPopupEl) { + return; + } + + settingsPopupEl.hidden = false; + if (openSettingsEl) { + openSettingsEl.setAttribute("aria-expanded", "true"); + } + } + + function closeSettingsPopup() { + const { settingsPopupEl, openSettingsEl } = getElements(); + if (!settingsPopupEl) { + return; + } + + settingsPopupEl.hidden = true; + if (openSettingsEl) { + openSettingsEl.setAttribute("aria-expanded", "false"); + } + } + + async function handleSaveSettings() { + try { + const settings = getSettingsFromInputs(); + const normalized = applySettingsToInputs(settings); + syncSky({ latitude: normalized.latitude, longitude: normalized.longitude }, true); + const didPersist = saveSettings(normalized); + emitSettingsUpdated(normalized); + if (typeof config.getActiveSection === "function" && config.getActiveSection() !== "home") { + config.onReopenActiveSection?.(config.getActiveSection()); + } + closeSettingsPopup(); + if (typeof config.onRenderWeek === "function") { + await config.onRenderWeek(); + } + + if (!didPersist) { + setStatus("Settings applied for this session. Browser storage is unavailable."); + } + } catch (error) { + setStatus(error?.message || "Unable to save settings."); + } + } + + function requestGeoLocation() { + const { latEl, lngEl } = getElements(); + if (!navigator.geolocation) { + setStatus("Geolocation not available in this browser."); + return; + } + + setStatus("Getting your location..."); + navigator.geolocation.getCurrentPosition( + ({ coords }) => { + latEl.value = coords.latitude.toFixed(4); + lngEl.value = coords.longitude.toFixed(4); + syncSky({ latitude: coords.latitude, longitude: coords.longitude }, true); + setStatus("Location set from browser. Click Save Settings to refresh."); + }, + (err) => { + const detail = err?.message || `code ${err?.code ?? "unknown"}`; + setStatus(`Could not get location (${detail}).`); + }, + { enableHighAccuracy: true, timeout: 10000 } + ); + } + + function bindInteractions() { + const { + saveSettingsEl, + useLocationEl, + openSettingsEl, + closeSettingsEl, + settingsPopupEl, + settingsPopupCardEl + } = getElements(); + + if (saveSettingsEl) { + saveSettingsEl.addEventListener("click", () => { + void handleSaveSettings(); + }); + } + + if (useLocationEl) { + useLocationEl.addEventListener("click", requestGeoLocation); + } + + if (openSettingsEl) { + openSettingsEl.addEventListener("click", (event) => { + event.stopPropagation(); + if (settingsPopupEl?.hidden) { + openSettingsPopup(); + } else { + closeSettingsPopup(); + } + }); + } + + if (closeSettingsEl) { + closeSettingsEl.addEventListener("click", closeSettingsPopup); + } + + document.addEventListener("click", (event) => { + const clickTarget = event.target; + if (!settingsPopupEl || settingsPopupEl.hidden) { + return; + } + + if (!(clickTarget instanceof Node)) { + return; + } + + if (settingsPopupCardEl?.contains(clickTarget) || openSettingsEl?.contains(clickTarget)) { + return; + } + + closeSettingsPopup(); + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeSettingsPopup(); + } + }); + } + + function init(nextConfig = {}) { + config = { + ...config, + ...nextConfig, + defaultSettings: { + ...config.defaultSettings, + ...(nextConfig.defaultSettings || {}) + } + }; + + bindInteractions(); + } + + function loadInitialSettingsAndApply() { + const initialSettings = loadSavedSettings(); + const normalized = applySettingsToInputs(initialSettings); + emitSettingsUpdated(normalized); + return normalized; + } + + window.TarotSettingsUi = { + ...(window.TarotSettingsUi || {}), + init, + openSettingsPopup, + closeSettingsPopup, + loadInitialSettingsAndApply, + buildNatalContext, + normalizeSettings + }; +})(); diff --git a/app/ui-tarot-house.js b/app/ui-tarot-house.js new file mode 100644 index 0000000..681983e --- /dev/null +++ b/app/ui-tarot-house.js @@ -0,0 +1,227 @@ +(function () { + "use strict"; + + 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 config = { + resolveTarotCardImage: null, + getDisplayCardName: (card) => card?.name || "", + clearChildren: () => {}, + normalizeTarotCardLookupName: (value) => String(value || "").trim().toLowerCase(), + selectCardById: () => {}, + getCards: () => [], + getSelectedCardId: () => "" + }; + + function init(nextConfig = {}) { + Object.assign(config, nextConfig || {}); + } + + function getCardLookupMap(cards) { + const lookup = new Map(); + (Array.isArray(cards) ? cards : []).forEach((card) => { + const key = config.normalizeTarotCardLookupName(card?.name); + if (key) { + lookup.set(key, card); + } + }); + return lookup; + } + + function buildMinorCardName(rankNumber, suit) { + const number = Number(rankNumber); + const suitName = String(suit || "").trim(); + const rankName = ({ 1: "Ace", 2: "Two", 3: "Three", 4: "Four", 5: "Five", 6: "Six", 7: "Seven", 8: "Eight", 9: "Nine", 10: "Ten" })[number]; + if (!rankName || !suitName) { + return ""; + } + return `${rankName} 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 = config.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 (Array.isArray(cards) ? 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 = config.getDisplayCardName(card); + button.title = cardDisplayName || card.name; + button.setAttribute("aria-label", cardDisplayName || card.name); + button.dataset.houseCardId = card.id; + const imageUrl = typeof config.resolveTarotCardImage === "function" + ? config.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", () => { + config.selectCardById(card.id, elements); + elements?.tarotCardListEl + ?.querySelector(`[data-card-id="${card.id}"]`) + ?.scrollIntoView({ block: "nearest" }); + }); + + return button; + } + + function updateSelection(elements) { + if (!elements?.tarotHouseOfCardsEl) { + return; + } + + const selectedCardId = config.getSelectedCardId(); + const buttons = elements.tarotHouseOfCardsEl.querySelectorAll(".tarot-house-card-btn[data-house-card-id]"); + buttons.forEach((button) => { + const isSelected = button.dataset.houseCardId === 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, cards) { + const rowEl = document.createElement("div"); + rowEl.className = "tarot-house-trump-row"; + + (trumpNumbers || []).forEach((trumpNumber) => { + const card = findMajorCardByTrumpNumber(cards, trumpNumber); + rowEl.appendChild(createHouseCardButton(card, elements)); + }); + + containerEl.appendChild(rowEl); + } + + function render(elements) { + if (!elements?.tarotHouseOfCardsEl) { + return; + } + + const cards = config.getCards(); + config.clearChildren(elements.tarotHouseOfCardsEl); + const cardLookupMap = getCardLookupMap(cards); + + const trumpSectionEl = document.createElement("div"); + trumpSectionEl.className = "tarot-house-trumps"; + HOUSE_TRUMP_ROWS.forEach((trumpRow) => { + appendHouseTrumpRow(trumpSectionEl, trumpRow, elements, cards); + }); + + 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); + updateSelection(elements); + } + + window.TarotHouseUi = { + init, + render, + updateSelection + }; +})(); \ No newline at end of file diff --git a/app/ui-tarot-lightbox.js b/app/ui-tarot-lightbox.js new file mode 100644 index 0000000..54855b9 --- /dev/null +++ b/app/ui-tarot-lightbox.js @@ -0,0 +1,176 @@ +(function () { + "use strict"; + + let overlayEl = null; + let imageEl = null; + let zoomed = false; + + const LIGHTBOX_ZOOM_SCALE = 6.66; + + function resetZoom() { + if (!imageEl) { + return; + } + + zoomed = false; + imageEl.style.transform = "scale(1)"; + imageEl.style.transformOrigin = "center center"; + imageEl.style.cursor = "zoom-in"; + } + + function updateZoomOrigin(clientX, clientY) { + if (!zoomed || !imageEl) { + return; + } + + const rect = imageEl.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)); + imageEl.style.transformOrigin = `${x}% ${y}%`; + } + + function isPointOnCard(clientX, clientY) { + if (!imageEl) { + return false; + } + + const rect = imageEl.getBoundingClientRect(); + const naturalWidth = imageEl.naturalWidth; + const naturalHeight = imageEl.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 ensure() { + if (overlayEl && imageEl) { + return; + } + + overlayEl = document.createElement("div"); + overlayEl.setAttribute("aria-hidden", "true"); + overlayEl.style.position = "fixed"; + overlayEl.style.inset = "0"; + overlayEl.style.background = "rgba(0, 0, 0, 0.82)"; + overlayEl.style.display = "none"; + overlayEl.style.alignItems = "center"; + overlayEl.style.justifyContent = "center"; + overlayEl.style.zIndex = "9999"; + overlayEl.style.padding = "0"; + + imageEl = document.createElement("img"); + imageEl.alt = "Tarot card enlarged image"; + imageEl.style.maxWidth = "100vw"; + imageEl.style.maxHeight = "100vh"; + imageEl.style.width = "100vw"; + imageEl.style.height = "100vh"; + imageEl.style.objectFit = "contain"; + imageEl.style.borderRadius = "0"; + imageEl.style.boxShadow = "none"; + imageEl.style.border = "none"; + imageEl.style.cursor = "zoom-in"; + imageEl.style.transform = "scale(1)"; + imageEl.style.transformOrigin = "center center"; + imageEl.style.transition = "transform 120ms ease-out"; + imageEl.style.userSelect = "none"; + + overlayEl.appendChild(imageEl); + + const close = () => { + if (!overlayEl || !imageEl) { + return; + } + overlayEl.style.display = "none"; + overlayEl.setAttribute("aria-hidden", "true"); + imageEl.removeAttribute("src"); + resetZoom(); + }; + + overlayEl.addEventListener("click", (event) => { + if (event.target === overlayEl) { + close(); + } + }); + + imageEl.addEventListener("click", (event) => { + event.stopPropagation(); + if (!isPointOnCard(event.clientX, event.clientY)) { + close(); + return; + } + + if (!zoomed) { + zoomed = true; + imageEl.style.transform = `scale(${LIGHTBOX_ZOOM_SCALE})`; + imageEl.style.cursor = "zoom-out"; + updateZoomOrigin(event.clientX, event.clientY); + return; + } + + resetZoom(); + }); + + imageEl.addEventListener("mousemove", (event) => { + updateZoomOrigin(event.clientX, event.clientY); + }); + + imageEl.addEventListener("mouseleave", () => { + if (zoomed) { + imageEl.style.transformOrigin = "center center"; + } + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + close(); + } + }); + + document.body.appendChild(overlayEl); + } + + function open(src, altText) { + if (!src) { + return; + } + + ensure(); + if (!overlayEl || !imageEl) { + return; + } + + imageEl.src = src; + imageEl.alt = altText || "Tarot card enlarged image"; + resetZoom(); + overlayEl.style.display = "flex"; + overlayEl.setAttribute("aria-hidden", "false"); + } + + window.TarotUiLightbox = { + ...(window.TarotUiLightbox || {}), + open + }; +})(); \ No newline at end of file diff --git a/app/ui-tarot-relations.js b/app/ui-tarot-relations.js new file mode 100644 index 0000000..62a4b09 --- /dev/null +++ b/app/ui-tarot-relations.js @@ -0,0 +1,734 @@ +/* ui-tarot-relations.js — Tarot relation builders */ +(function () { + "use strict"; + + 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 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 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 + }; + + function normalizeRelationId(value) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); + } + + function normalizeTarotName(value) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/\s+/g, " "); + } + + 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 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, courtCardByDecanId) { + 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 = 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 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 buildCubeFaceRelationsForCard(card, magickDataset) { + const cube = 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, magickDataset) { + const cube = magickDataset?.grouped?.kabbalah?.cube; + const tree = 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, magickDataset) { + const tree = 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, magickDataset) { + const center = 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, magickDataset) { + return [ + ...buildCubeFaceRelationsForCard(card, magickDataset), + ...buildCubeEdgeRelationsForCard(card, magickDataset), + ...buildCubePrimalPointRelationsForCard(card, magickDataset), + ...buildCubeMotherConnectorRelationsForCard(card, magickDataset) + ]; + } + + window.TarotRelationsUi = { + buildCourtCardByDecanId, + buildSmallCardCourtLinkRelations, + buildMonthReferencesByCard, + buildCubeRelationsForCard, + parseMonthDayToken + }; +})(); \ No newline at end of file diff --git a/app/ui-tarot-spread.js b/app/ui-tarot-spread.js new file mode 100644 index 0000000..88a8585 --- /dev/null +++ b/app/ui-tarot-spread.js @@ -0,0 +1,425 @@ +(function () { + "use strict"; + + let initialized = false; + let activeTarotSpread = null; + let activeTarotSpreadDraw = []; + let config = { + ensureTarotSection: null, + getReferenceData: () => null, + getMagickDataset: () => null, + getActiveSection: () => "home", + setActiveSection: null + }; + + const THREE_CARD_POSITIONS = [ + { pos: "past", label: "Past" }, + { pos: "present", label: "Present" }, + { pos: "future", label: "Future" } + ]; + + const CELTIC_CROSS_POSITIONS = [ + { pos: "crown", label: "Crown" }, + { pos: "out", label: "Outcome" }, + { pos: "past", label: "Recent Past" }, + { pos: "present", label: "Present" }, + { pos: "near-fut", label: "Near Future" }, + { pos: "hope", label: "Hopes & Fears" }, + { pos: "chall", label: "Challenge" }, + { pos: "env", label: "Environment" }, + { pos: "found", label: "Foundation" }, + { pos: "self", label: "Self" } + ]; + + function getElements() { + return { + openTarotCardsEl: document.getElementById("open-tarot-cards"), + openTarotSpreadEl: document.getElementById("open-tarot-spread"), + tarotBrowseViewEl: document.getElementById("tarot-browse-view"), + tarotSpreadViewEl: document.getElementById("tarot-spread-view"), + tarotSpreadBackEl: document.getElementById("tarot-spread-back"), + tarotSpreadBtnThreeEl: document.getElementById("tarot-spread-btn-three"), + tarotSpreadBtnCelticEl: document.getElementById("tarot-spread-btn-celtic"), + tarotSpreadRevealAllEl: document.getElementById("tarot-spread-reveal-all"), + tarotSpreadRedrawEl: document.getElementById("tarot-spread-redraw"), + tarotSpreadMeaningsEl: document.getElementById("tarot-spread-meanings"), + tarotSpreadBoardEl: document.getElementById("tarot-spread-board") + }; + } + + function ensureTarotBrowseData() { + const referenceData = typeof config.getReferenceData === "function" ? config.getReferenceData() : null; + const magickDataset = typeof config.getMagickDataset === "function" ? config.getMagickDataset() : null; + if (typeof config.ensureTarotSection === "function" && referenceData) { + config.ensureTarotSection(referenceData, magickDataset); + } + } + + function normalizeTarotSpread(value) { + return value === "celtic-cross" ? "celtic-cross" : "three-card"; + } + + function drawNFromDeck(n) { + const allCards = window.TarotSectionUi?.getCards?.() || []; + if (!allCards.length) return []; + + const shuffled = [...allCards]; + for (let index = shuffled.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(Math.random() * (index + 1)); + [shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]]; + } + + return shuffled.slice(0, n).map((card) => ({ + ...card, + reversed: Math.random() < 0.3 + })); + } + + function escapeHtml(value) { + return String(value || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'"); + } + + function getSpreadPositions(spreadId) { + return spreadId === "celtic-cross" ? CELTIC_CROSS_POSITIONS : THREE_CARD_POSITIONS; + } + + function regenerateTarotSpreadDraw() { + const normalizedSpread = normalizeTarotSpread(activeTarotSpread); + const positions = getSpreadPositions(normalizedSpread); + const cards = drawNFromDeck(positions.length); + activeTarotSpreadDraw = positions.map((position, index) => ({ + position, + card: cards[index] || null, + revealed: false + })); + } + + function renderTarotSpreadMeanings() { + const { tarotSpreadMeaningsEl } = getElements(); + if (!tarotSpreadMeaningsEl) { + return; + } + + if (!activeTarotSpreadDraw.length || activeTarotSpreadDraw.some((entry) => !entry.card)) { + tarotSpreadMeaningsEl.innerHTML = ""; + return; + } + + const revealedEntries = activeTarotSpreadDraw.filter((entry) => entry.card && entry.revealed); + if (!revealedEntries.length) { + tarotSpreadMeaningsEl.innerHTML = '
Cards are face down. Click a card to reveal its meaning.
'; + return; + } + + const hiddenCount = activeTarotSpreadDraw.length - revealedEntries.length; + const hiddenHintMarkup = hiddenCount > 0 + ? `
${hiddenCount} card${hiddenCount === 1 ? "" : "s"} still face down.
` + : ""; + + tarotSpreadMeaningsEl.innerHTML = revealedEntries.map((entry) => { + const positionLabel = escapeHtml(entry.position.label).toUpperCase(); + const card = entry.card; + const cardName = escapeHtml(card.name || "Unknown Card"); + const meaningText = escapeHtml(card.reversed ? (card.meanings?.reversed || card.summary || "--") : (card.meanings?.upright || card.summary || "--")); + const keywords = Array.isArray(card.keywords) + ? card.keywords.map((keyword) => String(keyword || "").trim()).filter(Boolean) + : []; + const keywordMarkup = keywords.length + ? `
Keywords: ${escapeHtml(keywords.join(", "))}
` + : ""; + const orientationMarkup = card.reversed + ? ' (Reversed)' + : ""; + + return `
` + + `
${positionLabel}: ${cardName}${orientationMarkup}
` + + `
${meaningText}
` + + keywordMarkup + + `
`; + }).join("") + hiddenHintMarkup; + } + + function renderTarotSpread() { + const { tarotSpreadBoardEl, tarotSpreadMeaningsEl, tarotSpreadRevealAllEl } = getElements(); + if (!tarotSpreadBoardEl) { + return; + } + + const normalizedSpread = normalizeTarotSpread(activeTarotSpread); + const isCeltic = normalizedSpread === "celtic-cross"; + const cardBackImageSrc = String(window.TarotCardImages?.resolveTarotCardBackImage?.() || "").trim(); + + if (!activeTarotSpreadDraw.length) { + regenerateTarotSpreadDraw(); + } + + tarotSpreadBoardEl.className = `tarot-spread-board tarot-spread-board--${isCeltic ? "celtic" : "three"}`; + + if (!activeTarotSpreadDraw.length || activeTarotSpreadDraw.some((entry) => !entry.card)) { + tarotSpreadBoardEl.innerHTML = '
Tarot deck not loaded yet - open Cards first, then return to Spread.
'; + if (tarotSpreadMeaningsEl) { + tarotSpreadMeaningsEl.innerHTML = ""; + } + if (tarotSpreadRevealAllEl) { + tarotSpreadRevealAllEl.disabled = true; + tarotSpreadRevealAllEl.textContent = "Reveal All"; + } + return; + } + + if (tarotSpreadRevealAllEl) { + const totalCards = activeTarotSpreadDraw.length; + const revealedCount = activeTarotSpreadDraw.reduce((count, entry) => ( + count + (entry?.card && entry.revealed ? 1 : 0) + ), 0); + tarotSpreadRevealAllEl.disabled = revealedCount >= totalCards; + tarotSpreadRevealAllEl.textContent = revealedCount >= totalCards + ? "All Revealed" + : `Reveal All (${totalCards - revealedCount})`; + } + + renderTarotSpreadMeanings(); + + tarotSpreadBoardEl.innerHTML = activeTarotSpreadDraw.map((entry, index) => { + const position = entry.position; + const card = entry.card; + const imgSrc = window.TarotCardImages?.resolveTarotCardImage?.(card.name); + const isRevealed = Boolean(entry.revealed); + const cardBackAttr = cardBackImageSrc + ? ` data-card-back-src="${escapeHtml(cardBackImageSrc)}"` + : ""; + const reversed = card.reversed; + const wrapClass = [ + "spread-card-wrap", + isRevealed ? "is-revealed" : "is-facedown", + (isRevealed && reversed) ? "is-reversed" : "" + ].filter(Boolean).join(" "); + + let faceMarkup = ""; + if (isRevealed) { + faceMarkup = imgSrc + ? `${escapeHtml(card.name)}` + : `
${escapeHtml(card.name)}
`; + } else if (cardBackImageSrc) { + faceMarkup = 'Face-down tarot card'; + } else { + faceMarkup = '
CARD BACK
'; + } + + const reversedTag = isRevealed && reversed + ? 'Reversed' + : ""; + const buttonAriaLabel = isRevealed + ? `Open ${escapeHtml(card.name)} for ${escapeHtml(position.label)} in fullscreen` + : `Reveal ${escapeHtml(position.label)} card`; + + return `
` + + `
${escapeHtml(position.label)}
` + + `` + + (reversedTag ? `
${reversedTag}
` : "") + + `
`; + }).join(""); + } + + function applyViewState() { + const { + openTarotCardsEl, + openTarotSpreadEl, + tarotBrowseViewEl, + tarotSpreadViewEl, + tarotSpreadBtnThreeEl, + tarotSpreadBtnCelticEl + } = getElements(); + const isSpreadOpen = activeTarotSpread !== null; + const isCeltic = activeTarotSpread === "celtic-cross"; + const isTarotActive = typeof config.getActiveSection === "function" && config.getActiveSection() === "tarot"; + + if (tarotBrowseViewEl) tarotBrowseViewEl.hidden = isSpreadOpen; + if (tarotSpreadViewEl) tarotSpreadViewEl.hidden = !isSpreadOpen; + + if (tarotSpreadBtnThreeEl) tarotSpreadBtnThreeEl.classList.toggle("is-active", isSpreadOpen && !isCeltic); + if (tarotSpreadBtnCelticEl) tarotSpreadBtnCelticEl.classList.toggle("is-active", isSpreadOpen && isCeltic); + + if (openTarotCardsEl) openTarotCardsEl.classList.toggle("is-active", isTarotActive && !isSpreadOpen); + if (openTarotSpreadEl) openTarotSpreadEl.classList.toggle("is-active", isTarotActive && isSpreadOpen); + } + + function showCardsView() { + activeTarotSpread = null; + activeTarotSpreadDraw = []; + applyViewState(); + ensureTarotBrowseData(); + const detailPanelEl = document.querySelector("#tarot-browse-view .tarot-detail-panel"); + if (detailPanelEl instanceof HTMLElement) { + detailPanelEl.scrollTop = 0; + } + } + + function showTarotSpreadView(spreadId = "three-card") { + activeTarotSpread = normalizeTarotSpread(spreadId); + regenerateTarotSpreadDraw(); + applyViewState(); + ensureTarotBrowseData(); + renderTarotSpread(); + } + + function setSpread(spreadId, openTarotSection = false) { + if (openTarotSection && typeof config.setActiveSection === "function") { + config.setActiveSection("tarot"); + } + showTarotSpreadView(spreadId); + } + + function revealAll() { + if (!activeTarotSpreadDraw.length) { + regenerateTarotSpreadDraw(); + } + + activeTarotSpreadDraw.forEach((entry) => { + if (entry?.card) { + entry.revealed = true; + } + }); + + renderTarotSpread(); + } + + function handleBoardClick(event) { + const target = event.target; + if (!(target instanceof Node)) { + return; + } + + const button = target instanceof Element + ? target.closest(".spread-card-wrap[data-spread-index]") + : null; + if (!(button instanceof HTMLButtonElement)) { + return; + } + + const spreadIndex = Number(button.dataset.spreadIndex); + if (!Number.isInteger(spreadIndex) || spreadIndex < 0 || spreadIndex >= activeTarotSpreadDraw.length) { + return; + } + + const spreadEntry = activeTarotSpreadDraw[spreadIndex]; + if (!spreadEntry?.card) { + return; + } + + if (!spreadEntry.revealed) { + spreadEntry.revealed = true; + renderTarotSpread(); + return; + } + + const imageSrc = window.TarotCardImages?.resolveTarotCardImage?.(spreadEntry.card.name); + if (imageSrc) { + window.TarotUiLightbox?.open?.(imageSrc, `${spreadEntry.card.name} (${spreadEntry.position?.label || "Spread"})`); + } + } + + function bindEvents() { + const { + openTarotCardsEl, + openTarotSpreadEl, + tarotSpreadBackEl, + tarotSpreadBtnThreeEl, + tarotSpreadBtnCelticEl, + tarotSpreadRevealAllEl, + tarotSpreadRedrawEl, + tarotSpreadBoardEl + } = getElements(); + + if (openTarotCardsEl) { + openTarotCardsEl.addEventListener("click", () => { + if (typeof config.setActiveSection === "function") { + config.setActiveSection("tarot"); + } + showCardsView(); + }); + } + + if (openTarotSpreadEl) { + openTarotSpreadEl.addEventListener("click", () => { + setSpread("three-card", true); + }); + } + + if (tarotSpreadBackEl) { + tarotSpreadBackEl.addEventListener("click", () => { + showCardsView(); + }); + } + + if (tarotSpreadBtnThreeEl) { + tarotSpreadBtnThreeEl.addEventListener("click", () => { + showTarotSpreadView("three-card"); + }); + } + + if (tarotSpreadBtnCelticEl) { + tarotSpreadBtnCelticEl.addEventListener("click", () => { + showTarotSpreadView("celtic-cross"); + }); + } + + if (tarotSpreadRedrawEl) { + tarotSpreadRedrawEl.addEventListener("click", () => { + regenerateTarotSpreadDraw(); + renderTarotSpread(); + }); + } + + if (tarotSpreadRevealAllEl) { + tarotSpreadRevealAllEl.addEventListener("click", revealAll); + } + + if (tarotSpreadBoardEl) { + tarotSpreadBoardEl.addEventListener("click", handleBoardClick); + } + } + + function handleSectionActivated() { + ensureTarotBrowseData(); + applyViewState(); + if (activeTarotSpread !== null) { + renderTarotSpread(); + } + } + + function init(nextConfig = {}) { + config = { + ...config, + ...nextConfig + }; + + if (initialized) { + applyViewState(); + return; + } + + bindEvents(); + applyViewState(); + initialized = true; + } + + window.TarotSpreadUi = { + ...(window.TarotSpreadUi || {}), + init, + applyViewState, + showCardsView, + showTarotSpreadView, + setSpread, + handleSectionActivated, + renderTarotSpread, + isSpreadOpen() { + return activeTarotSpread !== null; + } + }; +})(); diff --git a/app/ui-tarot.js b/app/ui-tarot.js index 24f454a..1dd4ade 100644 --- a/app/ui-tarot.js +++ b/app/ui-tarot.js @@ -1,5 +1,7 @@ (function () { const { resolveTarotCardImage, getTarotCardDisplayName, getTarotCardSearchAliases } = window.TarotCardImages || {}; + const tarotHouseUi = window.TarotHouseUi || {}; + const tarotRelationsUi = window.TarotRelationsUi || {}; const state = { initialized: false, @@ -13,173 +15,6 @@ 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, @@ -241,39 +76,6 @@ 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", @@ -659,441 +461,31 @@ } 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; + if (typeof tarotRelationsUi.buildCourtCardByDecanId !== "function") { + return new Map(); + } + return tarotRelationsUi.buildCourtCardByDecanId(cards); } function buildSmallCardCourtLinkRelations(card, relations) { - if (!card || card.arcana !== "Minor") { + if (typeof tarotRelationsUi.buildSmallCardCourtLinkRelations !== "function") { 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; + return tarotRelationsUi.buildSmallCardCourtLinkRelations(card, relations, state.courtCardByDecanId); } function parseMonthDayToken(value) { - const text = String(value || "").trim(); - const match = text.match(/^(\d{1,2})-(\d{1,2})$/); - if (!match) { + if (typeof tarotRelationsUi.parseMonthDayToken !== "function") { 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}`; + return tarotRelationsUi.parseMonthDayToken(value); } 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; + if (typeof tarotRelationsUi.buildMonthReferencesByCard !== "function") { + return new Map(); } - - 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; + return tarotRelationsUi.buildMonthReferencesByCard(referenceData, cards); } function relationToSearchText(relation) { @@ -1165,185 +557,12 @@ } } - 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); + tarotHouseUi.updateSelection?.(elements); } 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); + tarotHouseUi.render?.(elements); } function buildTypeLabel(card) { @@ -1474,185 +693,11 @@ 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) - ]; + if (typeof tarotRelationsUi.buildCubeRelationsForCard !== "function") { + return []; + } + return tarotRelationsUi.buildCubeRelationsForCard(card, state.magickDataset); } // Returns nav dispatch config for relations that have a corresponding section, @@ -2192,6 +1237,16 @@ state.magickDataset = magickDataset; } + tarotHouseUi.init?.({ + resolveTarotCardImage, + getDisplayCardName, + clearChildren, + normalizeTarotCardLookupName, + selectCardById, + getCards: () => state.cards, + getSelectedCardId: () => state.selectedCardId + }); + const elements = getElements(); if (state.initialized) { @@ -2276,7 +1331,7 @@ if (!src || elements.tarotDetailImageEl.style.display === "none") { return; } - openTarotImageLightbox(src, elements.tarotDetailImageEl.alt || "Tarot card enlarged image"); + window.TarotUiLightbox?.open?.(src, elements.tarotDetailImageEl.alt || "Tarot card enlarged image"); }); } diff --git a/index.html b/index.html index 7d670e5..836a11d 100644 --- a/index.html +++ b/index.html @@ -260,6 +260,7 @@ +
@@ -474,11 +475,22 @@ @@ -757,25 +769,47 @@ - + + + + + + - + + + + + + + + + + + + + + + + + + diff --git a/readme b/readme new file mode 100644 index 0000000..1046e39 --- /dev/null +++ b/readme @@ -0,0 +1,86 @@ +![Tarot Time Preview](https://code.glowers.club/goyimnose/tarot-time/raw/branch/master/asset/img/index.webp) + +# Tarot Time + +A web-based esoteric correspondence app for tarot, astrology, calendars, symbols, and related systems. + +[![Node.js](https://img.shields.io/badge/Node.js-18%2B-5FA04E?logo=node.js&logoColor=white)](https://nodejs.org/) +[![Git](https://img.shields.io/badge/VCS-Git-F05032?logo=git&logoColor=white)](https://git-scm.com/) +[![Forgejo Repo](https://img.shields.io/badge/Forgejo-tarot--time-0E9384?logo=forgejo&logoColor=white)](https://code.glowers.club/goyimnose/tarot-time) +[![Deck Repository](https://img.shields.io/badge/Decks-tarot--deck-1D4ED8)](https://code.glowers.club/goyimnose/tarot-deck) + +## Features + +- Correspondence explorer for multiple occult/esoteric systems. +- Tarot deck support via a generated deck registry. +- Pluggable deck structure using per-deck `deck.json` manifests. +- Fast local static serving with `http-server`. + +## Quick Start + +1. Install Node.js: https://nodejs.org/en/download +2. Clone this repository. +3. Install dependencies. +4. Start the app. + +```powershell +git clone https://code.glowers.club/goyimnose/tarot-time.git +Set-Location .\tarot-time +npm install +npm run start +``` + +The app opens in your browser (typically at `http://127.0.0.1:8080`). + +## Deck Repository (Install Ready) + +Use this companion repository for downloadable decks: + +- https://code.glowers.club/goyimnose/tarot-deck + +Typical flow: + +1. Clone the deck repository somewhere local. +2. Copy one or more deck folders into `asset/tarot deck/`. +3. Validate and regenerate the deck registry. + +```powershell +git clone https://code.glowers.club/goyimnose/tarot-deck.git +# Copy selected deck folder(s) into Tarot Time deck directory. +npm run validate:decks +npm run start +``` + +## Deck Authoring and Validation + +Deck discovery is registry-driven and generated automatically. + +1. Copy `asset/tarot deck/_template/` to a new folder under `asset/tarot deck/`. +2. Rename the new folder and update its `deck.json`. +3. Add card image files matching the naming rules in the manifest. +4. Run `npm run validate:decks` before publishing/testing. +5. Run `npm run start` (or `npm run generate:decks`) to rebuild `asset/tarot deck/decks.json`. + +Rules and behavior: + +- Folders without `deck.json` are ignored. +- Folders beginning with `_` or `.` are ignored (safe for `_template`). +- `_template` includes `deck.canonical-map.example.json` for explicit major-card file mapping. +- `_template/STRUCTURE.md` documents recommended `majors/` and `minors/` layouts. +- Decks can define `cardBack` in `deck.json`; if omitted, `back.webp/png/jpg/jpeg/avif/gif` in the deck root is auto-detected. +- Manifests may override labels with `nameOverrides` and `minorNameOverrides`. +- Invalid manifests or missing mapped files are skipped with terminal warnings. + +## NPM Scripts + +| Command | Description | +| --- | --- | +| `npm run start` | Generate deck registry, then serve the app locally and open `index.html`. | +| `npm run dev` | Alias of `npm run start`. | +| `npm run generate:decks` | Rebuild `asset/tarot deck/decks.json`. | +| `npm run validate:decks` | Strict validation only (no write), exits on manifest/file problems. | + +## Project Links + +- Main app: https://code.glowers.club/goyimnose/tarot-time +- Deck repo: https://code.glowers.club/goyimnose/tarot-deck diff --git a/scripts/generate-decks-registry.cjs b/scripts/generate-decks-registry.cjs index c468312..42f6dd0 100644 --- a/scripts/generate-decks-registry.cjs +++ b/scripts/generate-decks-registry.cjs @@ -8,6 +8,7 @@ const ignoredFolderNames = new Set(["template", "templates", "example", "example const tarotSuits = ["wands", "cups", "swords", "disks"]; const majorTrumpNumbers = Array.from({ length: 22 }, (_, index) => index); const expectedMinorCardCount = 56; +const cardBackCandidateExtensions = ["webp", "png", "jpg", "jpeg", "avif", "gif"]; function isPlainObject(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); @@ -29,6 +30,10 @@ function asNonEmptyString(value) { return normalized || null; } +function isRemoteAssetPath(value) { + return /^(https?:)?\/\//i.test(String(value || "").trim()); +} + function toTitleCase(value) { const normalized = String(value || "").trim().toLowerCase(); if (!normalized) { @@ -241,6 +246,10 @@ function validateDeckManifest(manifest) { errors.push("majorNameOverridesByTrump must be an object when provided"); } + if (manifest.cardBack != null && !asNonEmptyString(manifest.cardBack)) { + errors.push("cardBack must be a non-empty string when provided"); + } + return errors; } @@ -358,6 +367,15 @@ function getReferencedMinorFiles(manifest) { return []; } +function getReferencedCardBackFiles(manifest) { + const cardBack = asNonEmptyString(manifest?.cardBack); + if (!cardBack || isRemoteAssetPath(cardBack)) { + return []; + } + + return [cardBack]; +} + function summarizeMissingFiles(fileList) { const maxPreview = 8; const preview = fileList.slice(0, maxPreview).join(", "); @@ -368,11 +386,30 @@ function summarizeMissingFiles(fileList) { return `${preview}, ... (+${fileList.length - maxPreview} more)`; } +function detectDeckCardBackRelativePath(folderName, manifest) { + const explicitCardBack = asNonEmptyString(manifest?.cardBack); + if (explicitCardBack) { + return explicitCardBack; + } + + const deckFolderPath = path.join(decksRoot, folderName); + for (let index = 0; index < cardBackCandidateExtensions.length; index += 1) { + const extension = cardBackCandidateExtensions[index]; + const candidateName = `back.${extension}`; + if (fs.existsSync(path.join(deckFolderPath, candidateName))) { + return candidateName; + } + } + + return null; +} + function auditDeckFiles(folderName, manifest) { const deckFolderPath = path.join(decksRoot, folderName); const referencedFiles = [ ...getReferencedMajorFiles(manifest), - ...getReferencedMinorFiles(manifest) + ...getReferencedMinorFiles(manifest), + ...getReferencedCardBackFiles(manifest) ] .map((relativePath) => String(relativePath || "").trim()) .filter(Boolean); @@ -453,6 +490,7 @@ function compileDeckRegistry() { const id = (idFromManifest || fallbackId).toLowerCase(); const label = labelFromManifest || folderName; const basePath = `asset/tarot deck/${folderName}`; + const cardBackPath = detectDeckCardBackRelativePath(folderName, manifest); if (seenIds.has(id)) { warnings.push(`Skipped '${folderName}': duplicate deck id '${id}'`); @@ -465,7 +503,8 @@ function compileDeckRegistry() { id, label, basePath, - manifestPath: `${basePath}/deck.json` + manifestPath: `${basePath}/deck.json`, + ...(cardBackPath ? { cardBackPath } : {}) }); });