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)}
`;
- 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 [
- '"
- ].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 `
+
+ `;
+ }
+
+ function renderAssociationsCard(month) {
+ const monthOrder = Number(month?.order);
+ const associations = {
+ ...(month?.associations || {}),
+ ...(Number.isFinite(monthOrder) ? { numberValue: Math.trunc(monthOrder) } : {})
+ };
+
+ return `
+
+ `;
+ }
+
+ function renderEventsCard(month) {
+ const currentState = getState();
+ const allEvents = Array.isArray(month?.events) ? month.events : [];
+ if (!allEvents.length) {
+ return `
+
+ `;
+ }
+
+ 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 `
+
+ `;
+ }
+
+ const rows = events.map((event) => {
+ const dateText = event?.date || event?.dateRange || "--";
+ return `
+
+
+ ${event?.name || "Untitled"}
+ ${dateText}
+
+
${event?.description || ""}
+ ${buildAssociationButtons(event?.associations)}
+
+ `;
+ }).join("");
+
+ return `
+
+ `;
+ }
+
+ function renderHolidaysCard(month, title = "Holiday Repository") {
+ const currentState = getState();
+ const allHolidays = api.buildHolidayList(month);
+ if (!allHolidays.length) {
+ return `
+
+ `;
+ }
+
+ 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 `
+
+ `;
+ }
+
+ 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 `
+
+ `;
+ }
+
+ 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 `
+
+ `;
+ }
+
+ 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 `
+
+ `;
+ }
+
+ 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 `
+
+ `;
+ }
+
+ 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 `
+
+ `;
+ }
+
+ 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 `
+
+ `;
+ }
+
+ 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 `
+
+ `;
+ }
+
+ 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 `
+
+ `;
+ }
+
+ 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
+ ? ``
+ : "";
+
+ 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 `
+
+ `;
+ }
+
+ 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 [
+ '"
+ ].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 `
-
- `;
- }
-
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 `
-
- `;
- }
-
- function renderAssociationsCard(month) {
- const monthOrder = Number(month?.order);
- const associations = {
- ...(month?.associations || {}),
- ...(Number.isFinite(monthOrder) ? { numberValue: Math.trunc(monthOrder) } : {})
- };
-
- return `
-
- `;
- }
-
- function renderEventsCard(month) {
- const allEvents = Array.isArray(month?.events) ? month.events : [];
- if (!allEvents.length) {
- return `
-
- `;
- }
-
- 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 `
-
- `;
- }
-
- const rows = events.map((event) => {
- const dateText = event?.date || event?.dateRange || "--";
- return `
-
-
- ${event?.name || "Untitled"}
- ${dateText}
-
-
${event?.description || ""}
- ${buildAssociationButtons(event?.associations)}
-
- `;
- }).join("");
-
- return `
-
- `;
- }
-
- function renderHolidaysCard(month, title = "Holiday Repository") {
- const allHolidays = buildHolidayList(month);
- if (!allHolidays.length) {
- return `
-
- `;
- }
-
- 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 `
-
- `;
- }
-
- 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 `
-
- `;
- }
-
- 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 `
-
- `;
- }
-
- 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 `
-
- `;
- }
-
- 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 `
-
- `;
- }
-
- 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 `
-
- `;
- }
-
- 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 `
-
- `;
- }
-
- 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 `
-
- `;
- }
-
- 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
- ? ``
- : "";
-
- 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 `
-
- `;
- }
-
- 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)}
`;
+ } else if (cardBackImageSrc) {
+ faceMarkup = '
';
+ } 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 @@
-
-
-
Kabbalah
-
This Kabbalah landing page is intentionally blank for now. Use the Kabbalah menu to open Tree or Cube.
-
+
+
+
+
+
Rosicrucian Cross
+
Select a Hebrew letter petal to explore the path
+
+
+
@@ -757,25 +769,47 @@
-
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+