diff --git a/app/tarot-database-assembly.js b/app/tarot-database-assembly.js
new file mode 100644
index 0000000..80b0877
--- /dev/null
+++ b/app/tarot-database-assembly.js
@@ -0,0 +1,318 @@
+/* tarot-database-assembly.js — Tarot card assembly helpers */
+(function () {
+ "use strict";
+
+ function createTarotDatabaseAssembly(dependencies) {
+ const getTarotDbConfig = dependencies?.getTarotDbConfig;
+ const canonicalCardName = dependencies?.canonicalCardName;
+ const formatMonthDayLabel = dependencies?.formatMonthDayLabel;
+ const applyMeaningText = dependencies?.applyMeaningText;
+ const buildDecanMetadata = dependencies?.buildDecanMetadata;
+ const listMonthNumbersBetween = dependencies?.listMonthNumbersBetween;
+ const buildHebrewLetterLookup = dependencies?.buildHebrewLetterLookup;
+ const createRelation = dependencies?.createRelation;
+ const parseLegacyRelation = dependencies?.parseLegacyRelation;
+ const buildHebrewLetterRelation = dependencies?.buildHebrewLetterRelation;
+ const buildMajorDynamicRelations = dependencies?.buildMajorDynamicRelations;
+ const buildMinorDecanRelations = dependencies?.buildMinorDecanRelations;
+
+ const monthNameByNumber = dependencies?.monthNameByNumber && typeof dependencies.monthNameByNumber === "object"
+ ? dependencies.monthNameByNumber
+ : {};
+ const monthIdByNumber = dependencies?.monthIdByNumber && typeof dependencies.monthIdByNumber === "object"
+ ? dependencies.monthIdByNumber
+ : {};
+ const majorHebrewLetterIdByCard = dependencies?.majorHebrewLetterIdByCard && typeof dependencies.majorHebrewLetterIdByCard === "object"
+ ? dependencies.majorHebrewLetterIdByCard
+ : {};
+
+ if (
+ typeof getTarotDbConfig !== "function"
+ || typeof canonicalCardName !== "function"
+ || typeof formatMonthDayLabel !== "function"
+ || typeof applyMeaningText !== "function"
+ || typeof buildDecanMetadata !== "function"
+ || typeof listMonthNumbersBetween !== "function"
+ || typeof buildHebrewLetterLookup !== "function"
+ || typeof createRelation !== "function"
+ || typeof parseLegacyRelation !== "function"
+ || typeof buildHebrewLetterRelation !== "function"
+ || typeof buildMajorDynamicRelations !== "function"
+ || typeof buildMinorDecanRelations !== "function"
+ ) {
+ throw new Error("Tarot database assembly dependencies are incomplete");
+ }
+
+ function buildMajorCards(referenceData, magickDataset) {
+ const tarotDb = getTarotDbConfig(referenceData);
+ const dynamicRelations = buildMajorDynamicRelations(referenceData);
+ const hebrewLookup = buildHebrewLetterLookup(magickDataset);
+
+ return tarotDb.majorCards.map((card) => {
+ const canonicalName = canonicalCardName(card.name);
+ const dynamic = dynamicRelations.get(canonicalName) || [];
+ const hebrewLetterId = majorHebrewLetterIdByCard[canonicalName] || null;
+ const hebrewLetterRelation = buildHebrewLetterRelation(hebrewLetterId, hebrewLookup);
+ const staticRelations = (card.relations || [])
+ .map((relation) => parseLegacyRelation(relation))
+ .filter((relation) => relation.type !== "hebrewLetter" && relation.type !== "zodiac" && relation.type !== "planet");
+
+ return {
+ arcana: "Major",
+ name: card.name,
+ number: card.number,
+ suit: null,
+ rank: null,
+ hebrewLetterId,
+ hebrewLetter: hebrewLetterRelation?.data || null,
+ summary: card.summary,
+ meanings: {
+ upright: card.upright,
+ reversed: card.reversed
+ },
+ keywords: [...card.keywords],
+ relations: [
+ ...staticRelations,
+ ...(hebrewLetterRelation ? [hebrewLetterRelation] : []),
+ ...dynamic
+ ]
+ };
+ });
+ }
+
+ function buildNumberMinorCards(referenceData) {
+ const tarotDb = getTarotDbConfig(referenceData);
+ const decanRelations = buildMinorDecanRelations(referenceData);
+ const cards = [];
+
+ tarotDb.suits.forEach((suit) => {
+ const suitKey = suit.toLowerCase();
+ const suitInfo = tarotDb.suitInfo[suitKey];
+ if (!suitInfo) {
+ return;
+ }
+
+ tarotDb.numberRanks.forEach((rank) => {
+ const rankKey = rank.toLowerCase();
+ const rankInfo = tarotDb.rankInfo[rankKey];
+ if (!rankInfo) {
+ return;
+ }
+ const cardName = `${rank} of ${suit}`;
+ const dynamicRelations = decanRelations.get(canonicalCardName(cardName)) || [];
+
+ cards.push({
+ arcana: "Minor",
+ name: cardName,
+ number: null,
+ suit,
+ rank,
+ summary: `${rank} energy expressed through ${suitInfo.domain}.`,
+ meanings: {
+ upright: `${rankInfo.upright} In ${suit}, this emphasizes ${suitInfo.domain}.`,
+ reversed: `${rankInfo.reversed} In ${suit}, this may distort ${suitInfo.domain}.`
+ },
+ keywords: [...rankInfo.keywords, ...suitInfo.keywords],
+ relations: [
+ createRelation("element", suitInfo.element, `Element: ${suitInfo.element}`, {
+ name: suitInfo.element
+ }),
+ createRelation("suitDomain", `${suitKey}-${rankKey}`, `Suit domain: ${suitInfo.domain}`, {
+ suit,
+ rank,
+ domain: suitInfo.domain
+ }),
+ createRelation("numerology", rankInfo.number, `Numerology: ${rankInfo.number}`, {
+ value: rankInfo.number
+ }),
+ ...dynamicRelations
+ ]
+ });
+ });
+ });
+
+ return cards;
+ }
+
+ function buildCourtMinorCards(referenceData) {
+ const tarotDb = getTarotDbConfig(referenceData);
+ const cards = [];
+ const signs = Array.isArray(referenceData?.signs) ? referenceData.signs : [];
+ const signById = Object.fromEntries(signs.map((sign) => [sign.id, sign]));
+
+ const decanById = new Map();
+ const decansBySign = referenceData?.decansBySign || {};
+ Object.entries(decansBySign).forEach(([signId, decans]) => {
+ const sign = signById[signId];
+ if (!sign || !Array.isArray(decans)) {
+ return;
+ }
+ decans.forEach((decan) => {
+ if (!decan?.id) {
+ return;
+ }
+ const meta = buildDecanMetadata(decan, sign);
+ if (meta) {
+ decanById.set(decan.id, meta);
+ }
+ });
+ });
+
+ tarotDb.suits.forEach((suit) => {
+ const suitKey = suit.toLowerCase();
+ const suitInfo = tarotDb.suitInfo[suitKey];
+ if (!suitInfo) {
+ return;
+ }
+
+ tarotDb.courtRanks.forEach((rank) => {
+ const rankKey = rank.toLowerCase();
+ const courtInfo = tarotDb.courtInfo[rankKey];
+ if (!courtInfo) {
+ return;
+ }
+ const cardName = `${rank} of ${suit}`;
+ const windowDecanIds = tarotDb.courtDecanWindows[cardName] || [];
+ const windowDecans = windowDecanIds
+ .map((decanId) => decanById.get(decanId) || null)
+ .filter(Boolean);
+
+ const dynamicRelations = [];
+ const monthKeys = new Set();
+
+ windowDecans.forEach((meta) => {
+ dynamicRelations.push(
+ createRelation(
+ "decan",
+ `${meta.signId}-${meta.index}-${rankKey}-${suitKey}`,
+ `Decan ${meta.index}: ${meta.signSymbol} ${meta.signName} (${meta.startDegree}°–${meta.endDegree}°)${meta.dateRange ? ` · ${meta.dateRange.label}` : ""}`.trim(),
+ {
+ signId: meta.signId,
+ signName: meta.signName,
+ signSymbol: meta.signSymbol,
+ index: meta.index,
+ startDegree: meta.startDegree,
+ endDegree: meta.endDegree,
+ dateStart: meta.dateRange?.startToken || null,
+ dateEnd: meta.dateRange?.endToken || null,
+ dateRange: meta.dateRange?.label || null
+ }
+ )
+ );
+
+ const dateRange = meta.dateRange;
+ if (dateRange?.start && dateRange?.end) {
+ const monthNumbers = listMonthNumbersBetween(dateRange.start, dateRange.end);
+ monthNumbers.forEach((monthNo) => {
+ const monthId = monthIdByNumber[monthNo];
+ const monthName = monthNameByNumber[monthNo] || `Month ${monthNo}`;
+ const monthKey = `${monthId}:${meta.signId}:${meta.index}`;
+ if (!monthId || monthKeys.has(monthKey)) {
+ return;
+ }
+ monthKeys.add(monthKey);
+
+ dynamicRelations.push(
+ createRelation(
+ "calendarMonth",
+ `${monthId}-${meta.signId}-${meta.index}-${rankKey}-${suitKey}`,
+ `Calendar month: ${monthName} (${meta.signName} decan ${meta.index})`,
+ {
+ monthId,
+ name: monthName,
+ monthOrder: monthNo,
+ signId: meta.signId,
+ signName: meta.signName,
+ decanIndex: meta.index,
+ dateRange: meta.dateRange?.label || null
+ }
+ )
+ );
+ });
+ }
+ });
+
+ if (windowDecans.length) {
+ const firstRange = windowDecans[0].dateRange;
+ const lastRange = windowDecans[windowDecans.length - 1].dateRange;
+ const windowLabel = firstRange && lastRange
+ ? `${formatMonthDayLabel(firstRange.start)}–${formatMonthDayLabel(lastRange.end)}`
+ : "--";
+
+ dynamicRelations.unshift(
+ createRelation(
+ "courtDateWindow",
+ `${rankKey}-${suitKey}`,
+ `Court date window: ${windowLabel}`,
+ {
+ dateStart: firstRange?.startToken || null,
+ dateEnd: lastRange?.endToken || null,
+ dateRange: windowLabel,
+ decanIds: windowDecanIds
+ }
+ )
+ );
+ }
+
+ cards.push({
+ arcana: "Minor",
+ name: cardName,
+ number: null,
+ suit,
+ rank,
+ summary: `${rank} as ${courtInfo.role} within ${suitInfo.domain}.`,
+ meanings: {
+ upright: `${courtInfo.upright} In ${suit}, this guides ${suitInfo.domain}.`,
+ reversed: `${courtInfo.reversed} In ${suit}, this complicates ${suitInfo.domain}.`
+ },
+ keywords: [...courtInfo.keywords, ...suitInfo.keywords],
+ relations: [
+ createRelation("element", suitInfo.element, `Element: ${suitInfo.element}`, {
+ name: suitInfo.element
+ }),
+ createRelation(
+ "elementalFace",
+ `${rankKey}-${suitKey}`,
+ `${courtInfo.elementalFace} ${suitInfo.element}`,
+ {
+ rank,
+ suit,
+ elementalFace: courtInfo.elementalFace,
+ element: suitInfo.element
+ }
+ ),
+ createRelation("courtRole", rankKey, `Court role: ${courtInfo.role}`, {
+ rank,
+ role: courtInfo.role
+ }),
+ ...dynamicRelations
+ ]
+ });
+ });
+ });
+
+ return cards;
+ }
+
+ function buildTarotDatabase(referenceData, magickDataset = null) {
+ const cards = [
+ ...buildMajorCards(referenceData, magickDataset),
+ ...buildNumberMinorCards(referenceData),
+ ...buildCourtMinorCards(referenceData)
+ ];
+
+ return applyMeaningText(cards, referenceData);
+ }
+
+ return {
+ buildCourtMinorCards,
+ buildMajorCards,
+ buildNumberMinorCards,
+ buildTarotDatabase
+ };
+ }
+
+ window.TarotDatabaseAssembly = {
+ createTarotDatabaseAssembly
+ };
+})();
\ No newline at end of file
diff --git a/app/tarot-database-builders.js b/app/tarot-database-builders.js
new file mode 100644
index 0000000..1b7e3bc
--- /dev/null
+++ b/app/tarot-database-builders.js
@@ -0,0 +1,620 @@
+(function () {
+ "use strict";
+
+ function createTarotDatabaseHelpers(config) {
+ const majorCards = Array.isArray(config?.majorCards) ? config.majorCards : [];
+ const suits = Array.isArray(config?.suits) ? config.suits : [];
+ const numberRanks = Array.isArray(config?.numberRanks) ? config.numberRanks : [];
+ const courtRanks = Array.isArray(config?.courtRanks) ? config.courtRanks : [];
+ const suitInfo = config?.suitInfo && typeof config.suitInfo === "object" ? config.suitInfo : {};
+ const rankInfo = config?.rankInfo && typeof config.rankInfo === "object" ? config.rankInfo : {};
+ const courtInfo = config?.courtInfo && typeof config.courtInfo === "object" ? config.courtInfo : {};
+ const courtDecanWindows = config?.courtDecanWindows && typeof config.courtDecanWindows === "object" ? config.courtDecanWindows : {};
+ const majorAliases = config?.majorAliases && typeof config.majorAliases === "object" ? config.majorAliases : {};
+ const minorNumeralAliases = config?.minorNumeralAliases && typeof config.minorNumeralAliases === "object" ? config.minorNumeralAliases : {};
+ const monthNameByNumber = config?.monthNameByNumber && typeof config.monthNameByNumber === "object" ? config.monthNameByNumber : {};
+ const monthIdByNumber = config?.monthIdByNumber && typeof config.monthIdByNumber === "object" ? config.monthIdByNumber : {};
+ const monthShort = Array.isArray(config?.monthShort) ? config.monthShort : [];
+ const hebrewLetterAliases = config?.hebrewLetterAliases && typeof config.hebrewLetterAliases === "object" ? config.hebrewLetterAliases : {};
+
+ function getTarotDbConfig(referenceData) {
+ const db = referenceData?.tarotDatabase;
+ const hasDb = db && typeof db === "object";
+
+ return {
+ majorCards: hasDb && Array.isArray(db.majorCards) && db.majorCards.length ? db.majorCards : majorCards,
+ suits: hasDb && Array.isArray(db.suits) && db.suits.length ? db.suits : suits,
+ numberRanks: hasDb && Array.isArray(db.numberRanks) && db.numberRanks.length ? db.numberRanks : numberRanks,
+ courtRanks: hasDb && Array.isArray(db.courtRanks) && db.courtRanks.length ? db.courtRanks : courtRanks,
+ suitInfo: hasDb && db.suitInfo && typeof db.suitInfo === "object" ? db.suitInfo : suitInfo,
+ rankInfo: hasDb && db.rankInfo && typeof db.rankInfo === "object" ? db.rankInfo : rankInfo,
+ courtInfo: hasDb && db.courtInfo && typeof db.courtInfo === "object" ? db.courtInfo : courtInfo,
+ courtDecanWindows: hasDb && db.courtDecanWindows && typeof db.courtDecanWindows === "object" ? db.courtDecanWindows : courtDecanWindows
+ };
+ }
+
+ function normalizeCardName(value) {
+ return String(value || "")
+ .trim()
+ .toLowerCase()
+ .replace(/^the\s+/, "")
+ .replace(/\s+/g, " ");
+ }
+
+ function canonicalCardName(value) {
+ const normalized = normalizeCardName(value);
+ const majorCanonical = majorAliases[normalized] || normalized;
+ const withSuitAliases = majorCanonical.replace(/\bof\s+(pentacles?|coins?)\b/i, "of disks");
+ const numberMatch = withSuitAliases.match(/^(\d{1,2})\s+of\s+(.+)$/i);
+
+ if (numberMatch) {
+ const number = Number(numberMatch[1]);
+ const suit = String(numberMatch[2] || "").trim();
+ const numberWord = minorNumeralAliases[number];
+ if (numberWord && suit) {
+ return `${numberWord} of ${suit}`;
+ }
+ }
+
+ return withSuitAliases;
+ }
+
+ function parseMonthDay(value) {
+ const [month, day] = String(value || "").split("-").map((part) => Number(part));
+ if (!Number.isFinite(month) || !Number.isFinite(day)) {
+ return null;
+ }
+ return { month, day };
+ }
+
+ function monthDayToDate(monthDay, year) {
+ const parsed = parseMonthDay(monthDay);
+ if (!parsed) {
+ return null;
+ }
+ return new Date(year, parsed.month - 1, parsed.day);
+ }
+
+ function addDays(date, days) {
+ const next = new Date(date);
+ next.setDate(next.getDate() + days);
+ return next;
+ }
+
+ function formatMonthDayLabel(date) {
+ if (!(date instanceof Date)) {
+ return "--";
+ }
+ return `${monthShort[date.getMonth()]} ${date.getDate()}`;
+ }
+
+ function formatMonthDayToken(date) {
+ if (!(date instanceof Date)) {
+ return "";
+ }
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${month}-${day}`;
+ }
+
+ function normalizeMinorTarotCardName(value) {
+ const normalized = canonicalCardName(value);
+ return normalized
+ .split(" ")
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(" ");
+ }
+
+ function deriveSummaryFromMeaning(meaningText, fallbackSummary) {
+ const normalized = String(meaningText || "")
+ .replace(/\s+/g, " ")
+ .trim();
+
+ if (!normalized) {
+ return fallbackSummary;
+ }
+
+ const sentenceMatch = normalized.match(/^(.+?[.!?])(?:\s|$)/);
+ if (sentenceMatch && sentenceMatch[1]) {
+ return sentenceMatch[1].trim();
+ }
+
+ if (normalized.length <= 220) {
+ return normalized;
+ }
+
+ return `${normalized.slice(0, 217).trimEnd()}…`;
+ }
+
+ function applyMeaningText(cards, referenceData) {
+ const majorByTrumpNumber = referenceData?.tarotDatabase?.meanings?.majorByTrumpNumber;
+ const byCardName = referenceData?.tarotDatabase?.meanings?.byCardName;
+
+ if ((!majorByTrumpNumber || typeof majorByTrumpNumber !== "object")
+ && (!byCardName || typeof byCardName !== "object")) {
+ return cards;
+ }
+
+ return cards.map((card) => {
+ const trumpNumber = Number(card?.number);
+ const isMajorTrumpCard = card?.arcana === "Major" && Number.isFinite(trumpNumber);
+ const canonicalName = canonicalCardName(card?.name);
+
+ const majorMeaning = isMajorTrumpCard
+ ? String(majorByTrumpNumber?.[trumpNumber] || "").trim()
+ : "";
+ const nameMeaning = String(byCardName?.[canonicalName] || "").trim();
+ const selectedMeaning = majorMeaning || nameMeaning;
+
+ if (!selectedMeaning) {
+ return card;
+ }
+
+ return {
+ ...card,
+ meaning: selectedMeaning,
+ summary: deriveSummaryFromMeaning(selectedMeaning, card.summary),
+ meanings: {
+ upright: selectedMeaning,
+ reversed: card?.meanings?.reversed || ""
+ }
+ };
+ });
+ }
+
+ function getSignDateBounds(sign) {
+ const start = monthDayToDate(sign?.start, 2025);
+ const endBase = monthDayToDate(sign?.end, 2025);
+ if (!start || !endBase) {
+ return null;
+ }
+
+ const wraps = endBase.getTime() < start.getTime();
+ const end = wraps ? monthDayToDate(sign?.end, 2026) : endBase;
+ if (!end) {
+ return null;
+ }
+
+ return { start, end };
+ }
+
+ function buildDecanDateRange(sign, decanIndex) {
+ const bounds = getSignDateBounds(sign);
+ if (!bounds || !Number.isFinite(Number(decanIndex))) {
+ return null;
+ }
+
+ const index = Number(decanIndex);
+ const start = addDays(bounds.start, (index - 1) * 10);
+ const nominalEnd = addDays(start, 9);
+ const end = nominalEnd.getTime() > bounds.end.getTime() ? bounds.end : nominalEnd;
+
+ return {
+ start,
+ end,
+ startMonth: start.getMonth() + 1,
+ endMonth: end.getMonth() + 1,
+ startToken: formatMonthDayToken(start),
+ endToken: formatMonthDayToken(end),
+ label: `${formatMonthDayLabel(start)}–${formatMonthDayLabel(end)}`
+ };
+ }
+
+ function listMonthNumbersBetween(start, end) {
+ if (!(start instanceof Date) || !(end instanceof Date)) {
+ return [];
+ }
+
+ const result = [];
+ const seen = new Set();
+ const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
+ const limit = new Date(end.getFullYear(), end.getMonth(), 1);
+
+ while (cursor.getTime() <= limit.getTime()) {
+ const monthNo = cursor.getMonth() + 1;
+ if (!seen.has(monthNo)) {
+ seen.add(monthNo);
+ result.push(monthNo);
+ }
+ cursor.setMonth(cursor.getMonth() + 1);
+ }
+
+ return result;
+ }
+
+ function getSignName(sign, fallback) {
+ return sign?.name?.en || sign?.name || sign?.id || fallback || "Unknown";
+ }
+
+ function buildDecanMetadata(decan, sign) {
+ if (!decan || !sign) {
+ return null;
+ }
+
+ const index = Number(decan.index);
+ if (!Number.isFinite(index)) {
+ return null;
+ }
+
+ const startDegree = (index - 1) * 10;
+ const endDegree = startDegree + 10;
+ const dateRange = buildDecanDateRange(sign, index);
+
+ return {
+ decan,
+ sign,
+ index,
+ signId: sign.id,
+ signName: getSignName(sign, decan.signId),
+ signSymbol: sign.symbol || "",
+ startDegree,
+ endDegree,
+ dateRange,
+ normalizedCardName: normalizeMinorTarotCardName(decan.tarotMinorArcana || "")
+ };
+ }
+
+ function normalizeRelationId(value) {
+ return String(value || "")
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/(^-|-$)/g, "") || "unknown";
+ }
+
+ function createRelation(type, id, label, data = null) {
+ return {
+ type,
+ id: normalizeRelationId(id),
+ label: String(label || "").trim(),
+ data
+ };
+ }
+
+ function relationSignature(value) {
+ if (!value || typeof value !== "object") {
+ return String(value || "");
+ }
+
+ return `${value.type || "text"}|${value.id || ""}|${value.label || ""}`;
+ }
+
+ function parseLegacyRelation(text) {
+ const raw = String(text || "").trim();
+ const match = raw.match(/^([^:]+):\s*(.+)$/);
+ if (!match) {
+ return createRelation("note", raw, raw, { value: raw });
+ }
+
+ const key = normalizeRelationId(match[1]);
+ const value = String(match[2] || "").trim();
+
+ if (key === "element") {
+ return createRelation("element", value, `Element: ${value}`, { name: value });
+ }
+
+ if (key === "planet") {
+ return createRelation("planet", value, `Planet: ${value}`, { name: value });
+ }
+
+ if (key === "zodiac") {
+ return createRelation("zodiac", value, `Zodiac: ${value}`, { name: value });
+ }
+
+ if (key === "suit-domain") {
+ return createRelation("suitDomain", value, `Suit domain: ${value}`, { value });
+ }
+
+ if (key === "numerology") {
+ const numeric = Number(value);
+ return createRelation("numerology", value, `Numerology: ${value}`, {
+ value: Number.isFinite(numeric) ? numeric : value
+ });
+ }
+
+ if (key === "court-role") {
+ return createRelation("courtRole", value, `Court role: ${value}`, { value });
+ }
+
+ if (key === "hebrew-letter") {
+ const normalized = normalizeHebrewKey(value);
+ const canonical = hebrewLetterAliases[normalized] || normalized;
+ return createRelation("hebrewLetter", canonical, `Hebrew Letter: ${value}`, {
+ requestedName: value
+ });
+ }
+
+ return createRelation(key || "relation", value, raw, { value });
+ }
+
+ function normalizeHebrewKey(value) {
+ return String(value || "")
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z]/g, "");
+ }
+
+ function buildHebrewLetterLookup(magickDataset) {
+ const letters = magickDataset?.grouped?.hebrewLetters;
+ const lookup = new Map();
+
+ if (!letters || typeof letters !== "object") {
+ return lookup;
+ }
+
+ Object.entries(letters).forEach(([letterId, entry]) => {
+ const idKey = normalizeHebrewKey(letterId);
+ const canonicalKey = hebrewLetterAliases[idKey] || idKey;
+
+ if (canonicalKey && !lookup.has(canonicalKey)) {
+ lookup.set(canonicalKey, entry);
+ }
+
+ const nameKey = normalizeHebrewKey(entry?.letter?.name);
+ const canonicalNameKey = hebrewLetterAliases[nameKey] || nameKey;
+ if (canonicalNameKey && !lookup.has(canonicalNameKey)) {
+ lookup.set(canonicalNameKey, entry);
+ }
+
+ const entryIdKey = normalizeHebrewKey(entry?.id);
+ const canonicalEntryIdKey = hebrewLetterAliases[entryIdKey] || entryIdKey;
+ if (canonicalEntryIdKey && !lookup.has(canonicalEntryIdKey)) {
+ lookup.set(canonicalEntryIdKey, entry);
+ }
+ });
+
+ return lookup;
+ }
+
+ function buildHebrewLetterRelation(hebrewLetterId, hebrewLookup) {
+ if (!hebrewLetterId || !hebrewLookup) {
+ return null;
+ }
+
+ const normalizedId = normalizeHebrewKey(hebrewLetterId);
+ const canonicalId = hebrewLetterAliases[normalizedId] || normalizedId;
+ const entry = hebrewLookup.get(canonicalId);
+ if (!entry) {
+ return createRelation("hebrewLetter", canonicalId, `Hebrew Letter: ${hebrewLetterId}`, null);
+ }
+
+ const glyph = entry?.letter?.he || "";
+ const name = entry?.letter?.name || hebrewLetterId;
+ const latin = entry?.letter?.latin || "";
+ const index = Number.isFinite(entry?.index) ? entry.index : null;
+ const value = Number.isFinite(entry?.value) ? entry.value : null;
+ const meaning = entry?.meaning?.en || "";
+
+ const indexText = index !== null ? index : "?";
+ const valueText = value !== null ? value : "?";
+ const meaningText = meaning ? ` · ${meaning}` : "";
+
+ return createRelation(
+ "hebrewLetter",
+ entry?.id || canonicalId,
+ `Hebrew Letter: ${glyph} ${name} (${latin}) (index ${indexText}, value ${valueText})${meaningText}`.trim(),
+ {
+ id: entry?.id || canonicalId,
+ glyph,
+ name,
+ latin,
+ index,
+ value,
+ meaning
+ }
+ );
+ }
+
+ function pushMapValue(map, key, value) {
+ if (!key || !value) {
+ return;
+ }
+
+ if (!map.has(key)) {
+ map.set(key, []);
+ }
+
+ const existing = map.get(key);
+ const signature = relationSignature(value);
+ const duplicate = existing.some((entry) => relationSignature(entry) === signature);
+
+ if (!duplicate) {
+ existing.push(value);
+ }
+ }
+
+ function collectCalendarMonthRelationsFromDecan(targetKey, relationMap, decanMeta) {
+ const dateRange = decanMeta?.dateRange;
+ if (!dateRange?.start || !dateRange?.end) {
+ return;
+ }
+
+ const monthNumbers = listMonthNumbersBetween(dateRange.start, dateRange.end);
+ monthNumbers.forEach((monthNo) => {
+ const monthId = monthIdByNumber[monthNo];
+ const monthName = monthNameByNumber[monthNo] || `Month ${monthNo}`;
+ if (!monthId) {
+ return;
+ }
+
+ pushMapValue(
+ relationMap,
+ targetKey,
+ createRelation(
+ "calendarMonth",
+ `${monthId}-${decanMeta.signId}-${decanMeta.index}`,
+ `Calendar month: ${monthName} (${decanMeta.signName} decan ${decanMeta.index})`,
+ {
+ monthId,
+ name: monthName,
+ monthOrder: monthNo,
+ signId: decanMeta.signId,
+ signName: decanMeta.signName,
+ decanIndex: decanMeta.index,
+ dateRange: dateRange.label
+ }
+ )
+ );
+ });
+ }
+
+ function buildMajorDynamicRelations(referenceData) {
+ const relationMap = new Map();
+
+ const planets = referenceData?.planets && typeof referenceData.planets === "object"
+ ? Object.values(referenceData.planets)
+ : [];
+
+ planets.forEach((planet) => {
+ const cardName = planet?.tarot?.majorArcana;
+ if (!cardName) {
+ return;
+ }
+
+ const relation = createRelation(
+ "planetCorrespondence",
+ planet?.id || planet?.name || cardName,
+ `Planet correspondence: ${planet.symbol || ""} ${planet.name || ""}`.trim(),
+ {
+ planetId: planet?.id || null,
+ symbol: planet?.symbol || "",
+ name: planet?.name || ""
+ }
+ );
+ pushMapValue(relationMap, canonicalCardName(cardName), relation);
+ });
+
+ const signs = Array.isArray(referenceData?.signs) ? referenceData.signs : [];
+ signs.forEach((sign) => {
+ const cardName = sign?.tarot?.majorArcana;
+ if (!cardName) {
+ return;
+ }
+
+ const relation = createRelation(
+ "zodiacCorrespondence",
+ sign?.id || sign?.name || cardName,
+ `Zodiac correspondence: ${sign.symbol || ""} ${sign.name || ""}`.trim(),
+ {
+ signId: sign?.id || null,
+ symbol: sign?.symbol || "",
+ name: sign?.name || ""
+ }
+ );
+ pushMapValue(relationMap, canonicalCardName(cardName), relation);
+ });
+
+ return relationMap;
+ }
+
+ function buildMinorDecanRelations(referenceData) {
+ const relationMap = new Map();
+ const signs = Array.isArray(referenceData?.signs) ? referenceData.signs : [];
+ const signById = Object.fromEntries(signs.map((sign) => [sign.id, sign]));
+ const planets = referenceData?.planets || {};
+
+ if (!referenceData?.decansBySign || typeof referenceData.decansBySign !== "object") {
+ return relationMap;
+ }
+
+ Object.entries(referenceData.decansBySign).forEach(([signId, decans]) => {
+ const sign = signById[signId];
+ if (!Array.isArray(decans) || !sign) {
+ return;
+ }
+
+ decans.forEach((decan) => {
+ const cardName = decan?.tarotMinorArcana;
+ if (!cardName) {
+ return;
+ }
+
+ const decanMeta = buildDecanMetadata(decan, sign);
+ if (!decanMeta) {
+ return;
+ }
+
+ const { startDegree, endDegree, dateRange, signId: metaSignId, signName, signSymbol, index } = decanMeta;
+ const ruler = planets[decan.rulerPlanetId] || null;
+ const cardKey = canonicalCardName(cardName);
+
+ pushMapValue(
+ relationMap,
+ cardKey,
+ createRelation(
+ "zodiac",
+ metaSignId,
+ `Zodiac: ${sign.symbol || ""} ${signName}`.trim(),
+ {
+ signId: metaSignId,
+ signName,
+ symbol: sign.symbol || ""
+ }
+ )
+ );
+
+ pushMapValue(
+ relationMap,
+ cardKey,
+ createRelation(
+ "decan",
+ `${metaSignId}-${index}`,
+ `Decan ${decan.index}: ${sign.symbol || ""} ${signName} (${startDegree}°–${endDegree}°)${dateRange ? ` · ${dateRange.label}` : ""}`.trim(),
+ {
+ signId: metaSignId,
+ signName,
+ signSymbol,
+ index,
+ startDegree,
+ endDegree,
+ dateStart: dateRange?.startToken || null,
+ dateEnd: dateRange?.endToken || null,
+ dateRange: dateRange?.label || null
+ }
+ )
+ );
+
+ collectCalendarMonthRelationsFromDecan(cardKey, relationMap, decanMeta);
+
+ if (ruler) {
+ pushMapValue(
+ relationMap,
+ cardKey,
+ createRelation(
+ "decanRuler",
+ `${metaSignId}-${index}-${decan.rulerPlanetId}`,
+ `Decan ruler: ${ruler.symbol || ""} ${ruler.name || decan.rulerPlanetId}`.trim(),
+ {
+ signId: metaSignId,
+ decanIndex: index,
+ planetId: decan.rulerPlanetId,
+ symbol: ruler.symbol || "",
+ name: ruler.name || decan.rulerPlanetId
+ }
+ )
+ );
+ }
+ });
+ });
+
+ return relationMap;
+ }
+
+ return {
+ getTarotDbConfig,
+ canonicalCardName,
+ formatMonthDayLabel,
+ applyMeaningText,
+ buildDecanMetadata,
+ listMonthNumbersBetween,
+ buildHebrewLetterLookup,
+ createRelation,
+ parseLegacyRelation,
+ buildHebrewLetterRelation,
+ buildMajorDynamicRelations,
+ buildMinorDecanRelations
+ };
+ }
+
+ window.TarotDatabaseBuilders = { createTarotDatabaseHelpers };
+})();
\ No newline at end of file
diff --git a/app/tarot-database.js b/app/tarot-database.js
index a6b3854..d7b3713 100644
--- a/app/tarot-database.js
+++ b/app/tarot-database.js
@@ -459,52 +459,49 @@
tav: "tav"
};
+ const createTarotDatabaseHelpers = window.TarotDatabaseBuilders?.createTarotDatabaseHelpers;
+ const createTarotDatabaseAssembly = window.TarotDatabaseAssembly?.createTarotDatabaseAssembly;
+ if (typeof createTarotDatabaseHelpers !== "function" || typeof createTarotDatabaseAssembly !== "function") {
+ throw new Error("TarotDatabaseBuilders and TarotDatabaseAssembly modules must load before tarot-database.js");
+ }
+
+ const tarotDatabaseBuilders = createTarotDatabaseHelpers({
+ majorCards: MAJOR_CARDS,
+ suits: SUITS,
+ numberRanks: NUMBER_RANKS,
+ courtRanks: COURT_RANKS,
+ suitInfo: SUIT_INFO,
+ rankInfo: RANK_INFO,
+ courtInfo: COURT_INFO,
+ courtDecanWindows: COURT_DECAN_WINDOWS,
+ majorAliases: MAJOR_ALIASES,
+ minorNumeralAliases: MINOR_NUMERAL_ALIASES,
+ monthNameByNumber: MONTH_NAME_BY_NUMBER,
+ monthIdByNumber: MONTH_ID_BY_NUMBER,
+ monthShort: MONTH_SHORT,
+ hebrewLetterAliases: HEBREW_LETTER_ALIASES
+ });
+
+ const tarotDatabaseAssembly = createTarotDatabaseAssembly({
+ getTarotDbConfig: tarotDatabaseBuilders.getTarotDbConfig,
+ canonicalCardName: tarotDatabaseBuilders.canonicalCardName,
+ formatMonthDayLabel: tarotDatabaseBuilders.formatMonthDayLabel,
+ applyMeaningText: tarotDatabaseBuilders.applyMeaningText,
+ buildDecanMetadata: tarotDatabaseBuilders.buildDecanMetadata,
+ listMonthNumbersBetween: tarotDatabaseBuilders.listMonthNumbersBetween,
+ buildHebrewLetterLookup: tarotDatabaseBuilders.buildHebrewLetterLookup,
+ createRelation: tarotDatabaseBuilders.createRelation,
+ parseLegacyRelation: tarotDatabaseBuilders.parseLegacyRelation,
+ buildHebrewLetterRelation: tarotDatabaseBuilders.buildHebrewLetterRelation,
+ buildMajorDynamicRelations: tarotDatabaseBuilders.buildMajorDynamicRelations,
+ buildMinorDecanRelations: tarotDatabaseBuilders.buildMinorDecanRelations,
+ monthNameByNumber: MONTH_NAME_BY_NUMBER,
+ monthIdByNumber: MONTH_ID_BY_NUMBER,
+ majorHebrewLetterIdByCard: MAJOR_HEBREW_LETTER_ID_BY_CARD
+ });
+
function getTarotDbConfig(referenceData) {
- const db = referenceData?.tarotDatabase;
- const hasDb = db && typeof db === "object";
-
- const majorCards = hasDb && Array.isArray(db.majorCards) && db.majorCards.length
- ? db.majorCards
- : MAJOR_CARDS;
-
- const suits = hasDb && Array.isArray(db.suits) && db.suits.length
- ? db.suits
- : SUITS;
-
- const numberRanks = hasDb && Array.isArray(db.numberRanks) && db.numberRanks.length
- ? db.numberRanks
- : NUMBER_RANKS;
-
- const courtRanks = hasDb && Array.isArray(db.courtRanks) && db.courtRanks.length
- ? db.courtRanks
- : COURT_RANKS;
-
- const suitInfo = hasDb && db.suitInfo && typeof db.suitInfo === "object"
- ? db.suitInfo
- : SUIT_INFO;
-
- const rankInfo = hasDb && db.rankInfo && typeof db.rankInfo === "object"
- ? db.rankInfo
- : RANK_INFO;
-
- const courtInfo = hasDb && db.courtInfo && typeof db.courtInfo === "object"
- ? db.courtInfo
- : COURT_INFO;
-
- const courtDecanWindows = hasDb && db.courtDecanWindows && typeof db.courtDecanWindows === "object"
- ? db.courtDecanWindows
- : COURT_DECAN_WINDOWS;
-
- return {
- majorCards,
- suits,
- numberRanks,
- courtRanks,
- suitInfo,
- rankInfo,
- courtInfo,
- courtDecanWindows
- };
+ return tarotDatabaseBuilders.getTarotDbConfig(referenceData);
}
function normalizeCardName(value) {
@@ -516,290 +513,27 @@
}
function canonicalCardName(value) {
- const normalized = normalizeCardName(value);
- const majorCanonical = MAJOR_ALIASES[normalized] || normalized;
- const withSuitAliases = majorCanonical.replace(/\bof\s+(pentacles?|coins?)\b/i, "of disks");
- const numberMatch = withSuitAliases.match(/^(\d{1,2})\s+of\s+(.+)$/i);
-
- if (numberMatch) {
- const number = Number(numberMatch[1]);
- const suit = String(numberMatch[2] || "").trim();
- const numberWord = MINOR_NUMERAL_ALIASES[number];
- if (numberWord && suit) {
- return `${numberWord} of ${suit}`;
- }
- }
-
- return withSuitAliases;
- }
-
- function parseMonthDay(value) {
- const [month, day] = String(value || "").split("-").map((part) => Number(part));
- if (!Number.isFinite(month) || !Number.isFinite(day)) {
- return null;
- }
- return { month, day };
- }
-
- function monthDayToDate(monthDay, year) {
- const parsed = parseMonthDay(monthDay);
- if (!parsed) {
- return null;
- }
- return new Date(year, parsed.month - 1, parsed.day);
- }
-
- function addDays(date, days) {
- const next = new Date(date);
- next.setDate(next.getDate() + days);
- return next;
+ return tarotDatabaseBuilders.canonicalCardName(value);
}
function formatMonthDayLabel(date) {
- if (!(date instanceof Date)) {
- return "--";
- }
- return `${MONTH_SHORT[date.getMonth()]} ${date.getDate()}`;
- }
-
- function formatMonthDayToken(date) {
- if (!(date instanceof Date)) {
- return "";
- }
- const month = String(date.getMonth() + 1).padStart(2, "0");
- const day = String(date.getDate()).padStart(2, "0");
- return `${month}-${day}`;
- }
-
- function normalizeMinorTarotCardName(value) {
- const normalized = canonicalCardName(value);
- return normalized
- .split(" ")
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
- .join(" ");
- }
-
- function deriveSummaryFromMeaning(meaningText, fallbackSummary) {
- const normalized = String(meaningText || "")
- .replace(/\s+/g, " ")
- .trim();
-
- if (!normalized) {
- return fallbackSummary;
- }
-
- const sentenceMatch = normalized.match(/^(.+?[.!?])(?:\s|$)/);
- if (sentenceMatch && sentenceMatch[1]) {
- return sentenceMatch[1].trim();
- }
-
- if (normalized.length <= 220) {
- return normalized;
- }
-
- return `${normalized.slice(0, 217).trimEnd()}…`;
+ return tarotDatabaseBuilders.formatMonthDayLabel(date);
}
function applyMeaningText(cards, referenceData) {
- const majorByTrumpNumber = referenceData?.tarotDatabase?.meanings?.majorByTrumpNumber;
- const byCardName = referenceData?.tarotDatabase?.meanings?.byCardName;
-
- if ((!majorByTrumpNumber || typeof majorByTrumpNumber !== "object")
- && (!byCardName || typeof byCardName !== "object")) {
- return cards;
- }
-
- return cards.map((card) => {
- const trumpNumber = Number(card?.number);
- const isMajorTrumpCard = card?.arcana === "Major" && Number.isFinite(trumpNumber);
- const canonicalName = canonicalCardName(card?.name);
-
- const majorMeaning = isMajorTrumpCard
- ? String(majorByTrumpNumber?.[trumpNumber] || "").trim()
- : "";
- const nameMeaning = String(byCardName?.[canonicalName] || "").trim();
- const selectedMeaning = majorMeaning || nameMeaning;
-
- if (!selectedMeaning) {
- return card;
- }
-
- return {
- ...card,
- meaning: selectedMeaning,
- summary: deriveSummaryFromMeaning(selectedMeaning, card.summary),
- meanings: {
- upright: selectedMeaning,
- reversed: card?.meanings?.reversed || ""
- }
- };
- });
- }
-
- function getSignDateBounds(sign) {
- const start = monthDayToDate(sign?.start, 2025);
- const endBase = monthDayToDate(sign?.end, 2025);
- if (!start || !endBase) {
- return null;
- }
-
- const wraps = endBase.getTime() < start.getTime();
- const end = wraps ? monthDayToDate(sign?.end, 2026) : endBase;
- if (!end) {
- return null;
- }
-
- return { start, end };
- }
-
- function buildDecanDateRange(sign, decanIndex) {
- const bounds = getSignDateBounds(sign);
- if (!bounds || !Number.isFinite(Number(decanIndex))) {
- return null;
- }
-
- const index = Number(decanIndex);
- const start = addDays(bounds.start, (index - 1) * 10);
- const nominalEnd = addDays(start, 9);
- const end = nominalEnd.getTime() > bounds.end.getTime() ? bounds.end : nominalEnd;
-
- return {
- start,
- end,
- startMonth: start.getMonth() + 1,
- endMonth: end.getMonth() + 1,
- startToken: formatMonthDayToken(start),
- endToken: formatMonthDayToken(end),
- label: `${formatMonthDayLabel(start)}–${formatMonthDayLabel(end)}`
- };
+ return tarotDatabaseBuilders.applyMeaningText(cards, referenceData);
}
function listMonthNumbersBetween(start, end) {
- if (!(start instanceof Date) || !(end instanceof Date)) {
- return [];
- }
-
- const result = [];
- const seen = new Set();
- const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
- const limit = new Date(end.getFullYear(), end.getMonth(), 1);
-
- while (cursor.getTime() <= limit.getTime()) {
- const monthNo = cursor.getMonth() + 1;
- if (!seen.has(monthNo)) {
- seen.add(monthNo);
- result.push(monthNo);
- }
- cursor.setMonth(cursor.getMonth() + 1);
- }
-
- return result;
- }
-
- function getSignName(sign, fallback) {
- return sign?.name?.en || sign?.name || sign?.id || fallback || "Unknown";
+ return tarotDatabaseBuilders.listMonthNumbersBetween(start, end);
}
function buildDecanMetadata(decan, sign) {
- if (!decan || !sign) {
- return null;
- }
-
- const index = Number(decan.index);
- if (!Number.isFinite(index)) {
- return null;
- }
-
- const startDegree = (index - 1) * 10;
- const endDegree = startDegree + 10;
- const dateRange = buildDecanDateRange(sign, index);
-
- return {
- decan,
- sign,
- index,
- signId: sign.id,
- signName: getSignName(sign, decan.signId),
- signSymbol: sign.symbol || "",
- startDegree,
- endDegree,
- dateRange,
- normalizedCardName: normalizeMinorTarotCardName(decan.tarotMinorArcana || "")
- };
- }
-
- function collectCalendarMonthRelationsFromDecan(targetKey, relationMap, decanMeta) {
- const dateRange = decanMeta?.dateRange;
- if (!dateRange?.start || !dateRange?.end) {
- return;
- }
-
- const monthNumbers = listMonthNumbersBetween(dateRange.start, dateRange.end);
- monthNumbers.forEach((monthNo) => {
- const monthId = MONTH_ID_BY_NUMBER[monthNo];
- const monthName = MONTH_NAME_BY_NUMBER[monthNo] || `Month ${monthNo}`;
- if (!monthId) {
- return;
- }
-
- pushMapValue(
- relationMap,
- targetKey,
- createRelation(
- "calendarMonth",
- `${monthId}-${decanMeta.signId}-${decanMeta.index}`,
- `Calendar month: ${monthName} (${decanMeta.signName} decan ${decanMeta.index})`,
- {
- monthId,
- name: monthName,
- monthOrder: monthNo,
- signId: decanMeta.signId,
- signName: decanMeta.signName,
- decanIndex: decanMeta.index,
- dateRange: dateRange.label
- }
- )
- );
- });
- }
-
- function normalizeHebrewKey(value) {
- return String(value || "")
- .trim()
- .toLowerCase()
- .replace(/[^a-z]/g, "");
+ return tarotDatabaseBuilders.buildDecanMetadata(decan, sign);
}
function buildHebrewLetterLookup(magickDataset) {
- const letters = magickDataset?.grouped?.hebrewLetters;
- const lookup = new Map();
-
- if (!letters || typeof letters !== "object") {
- return lookup;
- }
-
- Object.entries(letters).forEach(([letterId, entry]) => {
- const idKey = normalizeHebrewKey(letterId);
- const canonicalKey = HEBREW_LETTER_ALIASES[idKey] || idKey;
-
- if (canonicalKey && !lookup.has(canonicalKey)) {
- lookup.set(canonicalKey, entry);
- }
-
- const nameKey = normalizeHebrewKey(entry?.letter?.name);
- const canonicalNameKey = HEBREW_LETTER_ALIASES[nameKey] || nameKey;
- if (canonicalNameKey && !lookup.has(canonicalNameKey)) {
- lookup.set(canonicalNameKey, entry);
- }
-
- const entryIdKey = normalizeHebrewKey(entry?.id);
- const canonicalEntryIdKey = HEBREW_LETTER_ALIASES[entryIdKey] || entryIdKey;
- if (canonicalEntryIdKey && !lookup.has(canonicalEntryIdKey)) {
- lookup.set(canonicalEntryIdKey, entry);
- }
- });
-
- return lookup;
+ return tarotDatabaseBuilders.buildHebrewLetterLookup(magickDataset);
}
function normalizeRelationId(value) {
@@ -811,528 +545,39 @@
}
function createRelation(type, id, label, data = null) {
- return {
- type,
- id: normalizeRelationId(id),
- label: String(label || "").trim(),
- data
- };
- }
-
- function relationSignature(value) {
- if (!value || typeof value !== "object") {
- return String(value || "");
- }
-
- return `${value.type || "text"}|${value.id || ""}|${value.label || ""}`;
+ return tarotDatabaseBuilders.createRelation(type, id, label, data);
}
function parseLegacyRelation(text) {
- const raw = String(text || "").trim();
- const match = raw.match(/^([^:]+):\s*(.+)$/);
- if (!match) {
- return createRelation("note", raw, raw, { value: raw });
- }
-
- const key = normalizeRelationId(match[1]);
- const value = String(match[2] || "").trim();
-
- if (key === "element") {
- return createRelation("element", value, `Element: ${value}`, { name: value });
- }
-
- if (key === "planet") {
- return createRelation("planet", value, `Planet: ${value}`, { name: value });
- }
-
- if (key === "zodiac") {
- return createRelation("zodiac", value, `Zodiac: ${value}`, { name: value });
- }
-
- if (key === "suit-domain") {
- return createRelation("suitDomain", value, `Suit domain: ${value}`, { value });
- }
-
- if (key === "numerology") {
- const numeric = Number(value);
- return createRelation("numerology", value, `Numerology: ${value}`, {
- value: Number.isFinite(numeric) ? numeric : value
- });
- }
-
- if (key === "court-role") {
- return createRelation("courtRole", value, `Court role: ${value}`, { value });
- }
-
- if (key === "hebrew-letter") {
- const normalized = normalizeHebrewKey(value);
- const canonical = HEBREW_LETTER_ALIASES[normalized] || normalized;
- return createRelation("hebrewLetter", canonical, `Hebrew Letter: ${value}`, {
- requestedName: value
- });
- }
-
- return createRelation(key || "relation", value, raw, { value });
+ return tarotDatabaseBuilders.parseLegacyRelation(text);
}
function buildHebrewLetterRelation(hebrewLetterId, hebrewLookup) {
- if (!hebrewLetterId || !hebrewLookup) {
- return null;
- }
-
- const normalizedId = normalizeHebrewKey(hebrewLetterId);
- const canonicalId = HEBREW_LETTER_ALIASES[normalizedId] || normalizedId;
- const entry = hebrewLookup.get(canonicalId);
- if (!entry) {
- return createRelation("hebrewLetter", canonicalId, `Hebrew Letter: ${hebrewLetterId}`, null);
- }
-
- const glyph = entry?.letter?.he || "";
- const name = entry?.letter?.name || hebrewLetterId;
- const latin = entry?.letter?.latin || "";
- const index = Number.isFinite(entry?.index) ? entry.index : null;
- const value = Number.isFinite(entry?.value) ? entry.value : null;
- const meaning = entry?.meaning?.en || "";
-
- const indexText = index !== null ? index : "?";
- const valueText = value !== null ? value : "?";
- const meaningText = meaning ? ` · ${meaning}` : "";
-
- return createRelation(
- "hebrewLetter",
- entry?.id || canonicalId,
- `Hebrew Letter: ${glyph} ${name} (${latin}) (index ${indexText}, value ${valueText})${meaningText}`.trim(),
- {
- id: entry?.id || canonicalId,
- glyph,
- name,
- latin,
- index,
- value,
- meaning
- }
- );
- }
-
- function pushMapValue(map, key, value) {
- if (!key || !value) {
- return;
- }
-
- if (!map.has(key)) {
- map.set(key, []);
- }
-
- const existing = map.get(key);
- const signature = relationSignature(value);
- const duplicate = existing.some((entry) => relationSignature(entry) === signature);
-
- if (!duplicate) {
- existing.push(value);
- }
+ return tarotDatabaseBuilders.buildHebrewLetterRelation(hebrewLetterId, hebrewLookup);
}
function buildMajorDynamicRelations(referenceData) {
- const relationMap = new Map();
-
- const planets = referenceData?.planets && typeof referenceData.planets === "object"
- ? Object.values(referenceData.planets)
- : [];
-
- planets.forEach((planet) => {
- const cardName = planet?.tarot?.majorArcana;
- if (!cardName) {
- return;
- }
-
- const relation = createRelation(
- "planetCorrespondence",
- planet?.id || planet?.name || cardName,
- `Planet correspondence: ${planet.symbol || ""} ${planet.name || ""}`.trim(),
- {
- planetId: planet?.id || null,
- symbol: planet?.symbol || "",
- name: planet?.name || ""
- }
- );
- pushMapValue(relationMap, canonicalCardName(cardName), relation);
- });
-
- const signs = Array.isArray(referenceData?.signs) ? referenceData.signs : [];
- signs.forEach((sign) => {
- const cardName = sign?.tarot?.majorArcana;
- if (!cardName) {
- return;
- }
-
- const relation = createRelation(
- "zodiacCorrespondence",
- sign?.id || sign?.name || cardName,
- `Zodiac correspondence: ${sign.symbol || ""} ${sign.name || ""}`.trim(),
- {
- signId: sign?.id || null,
- symbol: sign?.symbol || "",
- name: sign?.name || ""
- }
- );
- pushMapValue(relationMap, canonicalCardName(cardName), relation);
- });
-
- return relationMap;
+ return tarotDatabaseBuilders.buildMajorDynamicRelations(referenceData);
}
function buildMinorDecanRelations(referenceData) {
- const relationMap = new Map();
- const signs = Array.isArray(referenceData?.signs) ? referenceData.signs : [];
- const signById = Object.fromEntries(signs.map((sign) => [sign.id, sign]));
- const planets = referenceData?.planets || {};
-
- if (!referenceData?.decansBySign || typeof referenceData.decansBySign !== "object") {
- return relationMap;
- }
-
- Object.entries(referenceData.decansBySign).forEach(([signId, decans]) => {
- const sign = signById[signId];
- if (!Array.isArray(decans) || !sign) {
- return;
- }
-
- decans.forEach((decan) => {
- const cardName = decan?.tarotMinorArcana;
- if (!cardName) {
- return;
- }
-
- const decanMeta = buildDecanMetadata(decan, sign);
- if (!decanMeta) {
- return;
- }
-
- const { startDegree, endDegree, dateRange, signId, signName, signSymbol, index } = decanMeta;
- const ruler = planets[decan.rulerPlanetId] || null;
- const cardKey = canonicalCardName(cardName);
-
- pushMapValue(
- relationMap,
- cardKey,
- createRelation(
- "zodiac",
- signId,
- `Zodiac: ${sign.symbol || ""} ${signName}`.trim(),
- {
- signId,
- signName,
- symbol: sign.symbol || ""
- }
- )
- );
-
- pushMapValue(
- relationMap,
- cardKey,
- createRelation(
- "decan",
- `${signId}-${index}`,
- `Decan ${decan.index}: ${sign.symbol || ""} ${signName} (${startDegree}°–${endDegree}°)${dateRange ? ` · ${dateRange.label}` : ""}`.trim(),
- {
- signId,
- signName,
- signSymbol,
- index,
- startDegree,
- endDegree,
- dateStart: dateRange?.startToken || null,
- dateEnd: dateRange?.endToken || null,
- dateRange: dateRange?.label || null
- }
- )
- );
-
- collectCalendarMonthRelationsFromDecan(cardKey, relationMap, decanMeta);
-
- if (ruler) {
- pushMapValue(
- relationMap,
- cardKey,
- createRelation(
- "decanRuler",
- `${signId}-${index}-${decan.rulerPlanetId}`,
- `Decan ruler: ${ruler.symbol || ""} ${ruler.name || decan.rulerPlanetId}`.trim(),
- {
- signId,
- decanIndex: index,
- planetId: decan.rulerPlanetId,
- symbol: ruler.symbol || "",
- name: ruler.name || decan.rulerPlanetId
- }
- )
- );
- }
- });
- });
-
- return relationMap;
+ return tarotDatabaseBuilders.buildMinorDecanRelations(referenceData);
}
function buildMajorCards(referenceData, magickDataset) {
- const tarotDb = getTarotDbConfig(referenceData);
- const dynamicRelations = buildMajorDynamicRelations(referenceData);
- const hebrewLookup = buildHebrewLetterLookup(magickDataset);
-
- return tarotDb.majorCards.map((card) => {
- const canonicalName = canonicalCardName(card.name);
- const dynamic = dynamicRelations.get(canonicalName) || [];
- const hebrewLetterId = MAJOR_HEBREW_LETTER_ID_BY_CARD[canonicalName] || null;
- const hebrewLetterRelation = buildHebrewLetterRelation(hebrewLetterId, hebrewLookup);
- const staticRelations = (card.relations || [])
- .map((relation) => parseLegacyRelation(relation))
- .filter((relation) => relation.type !== "hebrewLetter" && relation.type !== "zodiac" && relation.type !== "planet");
-
- return {
- arcana: "Major",
- name: card.name,
- number: card.number,
- suit: null,
- rank: null,
- hebrewLetterId,
- hebrewLetter: hebrewLetterRelation?.data || null,
- summary: card.summary,
- meanings: {
- upright: card.upright,
- reversed: card.reversed
- },
- keywords: [...card.keywords],
- relations: [
- ...staticRelations,
- ...(hebrewLetterRelation ? [hebrewLetterRelation] : []),
- ...dynamic
- ]
- };
- });
+ return tarotDatabaseAssembly.buildMajorCards(referenceData, magickDataset);
}
function buildNumberMinorCards(referenceData) {
- const tarotDb = getTarotDbConfig(referenceData);
- const decanRelations = buildMinorDecanRelations(referenceData);
- const cards = [];
-
- tarotDb.suits.forEach((suit) => {
- const suitKey = suit.toLowerCase();
- const suitInfo = tarotDb.suitInfo[suitKey];
- if (!suitInfo) {
- return;
- }
-
- tarotDb.numberRanks.forEach((rank) => {
- const rankKey = rank.toLowerCase();
- const rankInfo = tarotDb.rankInfo[rankKey];
- if (!rankInfo) {
- return;
- }
- const cardName = `${rank} of ${suit}`;
- const dynamicRelations = decanRelations.get(canonicalCardName(cardName)) || [];
-
- cards.push({
- arcana: "Minor",
- name: cardName,
- number: null,
- suit,
- rank,
- summary: `${rank} energy expressed through ${suitInfo.domain}.`,
- meanings: {
- upright: `${rankInfo.upright} In ${suit}, this emphasizes ${suitInfo.domain}.`,
- reversed: `${rankInfo.reversed} In ${suit}, this may distort ${suitInfo.domain}.`
- },
- keywords: [...rankInfo.keywords, ...suitInfo.keywords],
- relations: [
- createRelation("element", suitInfo.element, `Element: ${suitInfo.element}`, {
- name: suitInfo.element
- }),
- createRelation("suitDomain", `${suitKey}-${rankKey}`, `Suit domain: ${suitInfo.domain}`, {
- suit: suit,
- rank,
- domain: suitInfo.domain
- }),
- createRelation("numerology", rankInfo.number, `Numerology: ${rankInfo.number}`, {
- value: rankInfo.number
- }),
- ...dynamicRelations
- ]
- });
- });
- });
-
- return cards;
+ return tarotDatabaseAssembly.buildNumberMinorCards(referenceData);
}
function buildCourtMinorCards(referenceData) {
- const tarotDb = getTarotDbConfig(referenceData);
- const cards = [];
- const signs = Array.isArray(referenceData?.signs) ? referenceData.signs : [];
- const signById = Object.fromEntries(signs.map((sign) => [sign.id, sign]));
-
- const decanById = new Map();
- const decansBySign = referenceData?.decansBySign || {};
- Object.entries(decansBySign).forEach(([signId, decans]) => {
- const sign = signById[signId];
- if (!sign || !Array.isArray(decans)) {
- return;
- }
- decans.forEach((decan) => {
- if (!decan?.id) {
- return;
- }
- const meta = buildDecanMetadata(decan, sign);
- if (meta) {
- decanById.set(decan.id, meta);
- }
- });
- });
-
- tarotDb.suits.forEach((suit) => {
- const suitKey = suit.toLowerCase();
- const suitInfo = tarotDb.suitInfo[suitKey];
- if (!suitInfo) {
- return;
- }
-
- tarotDb.courtRanks.forEach((rank) => {
- const rankKey = rank.toLowerCase();
- const courtInfo = tarotDb.courtInfo[rankKey];
- if (!courtInfo) {
- return;
- }
- const cardName = `${rank} of ${suit}`;
- const windowDecanIds = tarotDb.courtDecanWindows[cardName] || [];
- const windowDecans = windowDecanIds
- .map((decanId) => decanById.get(decanId) || null)
- .filter(Boolean);
-
- const dynamicRelations = [];
- const monthKeys = new Set();
-
- windowDecans.forEach((meta) => {
- dynamicRelations.push(
- createRelation(
- "decan",
- `${meta.signId}-${meta.index}-${rankKey}-${suitKey}`,
- `Decan ${meta.index}: ${meta.signSymbol} ${meta.signName} (${meta.startDegree}°–${meta.endDegree}°)${meta.dateRange ? ` · ${meta.dateRange.label}` : ""}`.trim(),
- {
- signId: meta.signId,
- signName: meta.signName,
- signSymbol: meta.signSymbol,
- index: meta.index,
- startDegree: meta.startDegree,
- endDegree: meta.endDegree,
- dateStart: meta.dateRange?.startToken || null,
- dateEnd: meta.dateRange?.endToken || null,
- dateRange: meta.dateRange?.label || null
- }
- )
- );
-
- const dateRange = meta.dateRange;
- if (dateRange?.start && dateRange?.end) {
- const monthNumbers = listMonthNumbersBetween(dateRange.start, dateRange.end);
- monthNumbers.forEach((monthNo) => {
- const monthId = MONTH_ID_BY_NUMBER[monthNo];
- const monthName = MONTH_NAME_BY_NUMBER[monthNo] || `Month ${monthNo}`;
- const monthKey = `${monthId}:${meta.signId}:${meta.index}`;
- if (!monthId || monthKeys.has(monthKey)) {
- return;
- }
- monthKeys.add(monthKey);
-
- dynamicRelations.push(
- createRelation(
- "calendarMonth",
- `${monthId}-${meta.signId}-${meta.index}-${rankKey}-${suitKey}`,
- `Calendar month: ${monthName} (${meta.signName} decan ${meta.index})`,
- {
- monthId,
- name: monthName,
- monthOrder: monthNo,
- signId: meta.signId,
- signName: meta.signName,
- decanIndex: meta.index,
- dateRange: meta.dateRange?.label || null
- }
- )
- );
- });
- }
- });
-
- if (windowDecans.length) {
- const firstRange = windowDecans[0].dateRange;
- const lastRange = windowDecans[windowDecans.length - 1].dateRange;
- const windowLabel = firstRange && lastRange
- ? `${formatMonthDayLabel(firstRange.start)}–${formatMonthDayLabel(lastRange.end)}`
- : "--";
-
- dynamicRelations.unshift(
- createRelation(
- "courtDateWindow",
- `${rankKey}-${suitKey}`,
- `Court date window: ${windowLabel}`,
- {
- dateStart: firstRange?.startToken || null,
- dateEnd: lastRange?.endToken || null,
- dateRange: windowLabel,
- decanIds: windowDecanIds
- }
- )
- );
- }
-
- cards.push({
- arcana: "Minor",
- name: cardName,
- number: null,
- suit,
- rank,
- summary: `${rank} as ${courtInfo.role} within ${suitInfo.domain}.`,
- meanings: {
- upright: `${courtInfo.upright} In ${suit}, this guides ${suitInfo.domain}.`,
- reversed: `${courtInfo.reversed} In ${suit}, this complicates ${suitInfo.domain}.`
- },
- keywords: [...courtInfo.keywords, ...suitInfo.keywords],
- relations: [
- createRelation("element", suitInfo.element, `Element: ${suitInfo.element}`, {
- name: suitInfo.element
- }),
- createRelation(
- "elementalFace",
- `${rankKey}-${suitKey}`,
- `${courtInfo.elementalFace} ${suitInfo.element}`,
- {
- rank,
- suit,
- elementalFace: courtInfo.elementalFace,
- element: suitInfo.element
- }
- ),
- createRelation("courtRole", rankKey, `Court role: ${courtInfo.role}`, {
- rank,
- role: courtInfo.role
- }),
- ...dynamicRelations
- ]
- });
- });
- });
-
- return cards;
+ return tarotDatabaseAssembly.buildCourtMinorCards(referenceData);
}
function buildTarotDatabase(referenceData, magickDataset = null) {
- const cards = [
- ...buildMajorCards(referenceData, magickDataset),
- ...buildNumberMinorCards(referenceData),
- ...buildCourtMinorCards(referenceData)
- ];
-
- return applyMeaningText(cards, referenceData);
+ return tarotDatabaseAssembly.buildTarotDatabase(referenceData, magickDataset);
}
window.TarotCardDatabase = {
diff --git a/app/ui-alphabet-kabbalah.js b/app/ui-alphabet-kabbalah.js
new file mode 100644
index 0000000..0ac2a1e
--- /dev/null
+++ b/app/ui-alphabet-kabbalah.js
@@ -0,0 +1,173 @@
+/* ui-alphabet-kabbalah.js — Shared Kabbalah and cube helpers for the alphabet section */
+(function () {
+ "use strict";
+
+ function normalizeId(value) {
+ return String(value || "").trim().toLowerCase();
+ }
+
+ function normalizeLetterId(value) {
+ return String(value || "")
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z]/g, "");
+ }
+
+ function titleCase(value) {
+ return String(value || "")
+ .split(/[\s_-]+/)
+ .filter(Boolean)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(" ");
+ }
+
+ function normalizeSoulId(value) {
+ return String(value || "")
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z]/g, "");
+ }
+
+ function buildFourWorldLayersFromDataset(magickDataset) {
+ const worlds = magickDataset?.grouped?.kabbalah?.fourWorlds;
+ const souls = magickDataset?.grouped?.kabbalah?.souls;
+ const paths = Array.isArray(magickDataset?.grouped?.kabbalah?.["kabbalah-tree"]?.paths)
+ ? magickDataset.grouped.kabbalah["kabbalah-tree"].paths
+ : [];
+
+ if (!worlds || typeof worlds !== "object") {
+ return [];
+ }
+
+ const soulAliases = {
+ chiah: "chaya",
+ chaya: "chaya",
+ neshamah: "neshama",
+ neshama: "neshama",
+ ruach: "ruach",
+ nephesh: "nephesh"
+ };
+
+ const pathByLetterId = new Map();
+ paths.forEach((path) => {
+ const letterId = normalizeLetterId(path?.hebrewLetter?.transliteration || path?.hebrewLetter?.char);
+ const pathNo = Number(path?.pathNumber);
+ if (!letterId || !Number.isFinite(pathNo) || pathByLetterId.has(letterId)) {
+ return;
+ }
+ pathByLetterId.set(letterId, pathNo);
+ });
+
+ const worldOrder = ["atzilut", "briah", "yetzirah", "assiah"];
+
+ return worldOrder
+ .map((worldId) => {
+ const world = worlds?.[worldId];
+ if (!world || typeof world !== "object") {
+ return null;
+ }
+
+ const tetragrammaton = world?.tetragrammaton && typeof world.tetragrammaton === "object"
+ ? world.tetragrammaton
+ : {};
+
+ const letterId = normalizeLetterId(tetragrammaton?.hebrewLetterId);
+ const rawSoulId = normalizeSoulId(world?.soulId);
+ const soulId = soulAliases[rawSoulId] || rawSoulId;
+ const soul = souls?.[soulId] && typeof souls[soulId] === "object"
+ ? souls[soulId]
+ : null;
+
+ const slot = tetragrammaton?.isFinal
+ ? `${String(tetragrammaton?.slot || "Heh")} (final)`
+ : String(tetragrammaton?.slot || "");
+
+ return {
+ slot,
+ letterChar: String(tetragrammaton?.letterChar || ""),
+ hebrewLetterId: letterId,
+ world: String(world?.name?.roman || titleCase(worldId)),
+ worldLayer: String(world?.worldLayer?.en || world?.desc?.en || ""),
+ worldDescription: String(world?.worldDescription?.en || ""),
+ soulLayer: String(soul?.name?.roman || titleCase(rawSoulId || soulId)),
+ soulTitle: String(soul?.title?.en || titleCase(soul?.name?.en || "")),
+ soulDescription: String(soul?.desc?.en || ""),
+ pathNumber: pathByLetterId.get(letterId) || null
+ };
+ })
+ .filter(Boolean);
+ }
+
+ function createEmptyCubeRefs() {
+ return {
+ hebrewPlacementById: new Map(),
+ signPlacementById: new Map(),
+ planetPlacementById: new Map(),
+ pathPlacementByNo: new Map()
+ };
+ }
+
+ function getCubePlacementForHebrewLetter(cubeRefs, hebrewLetterId, pathNo = null) {
+ const normalizedLetterId = normalizeId(hebrewLetterId);
+ if (normalizedLetterId && cubeRefs?.hebrewPlacementById?.has(normalizedLetterId)) {
+ return cubeRefs.hebrewPlacementById.get(normalizedLetterId);
+ }
+
+ const numericPath = Number(pathNo);
+ if (Number.isFinite(numericPath) && cubeRefs?.pathPlacementByNo?.has(numericPath)) {
+ return cubeRefs.pathPlacementByNo.get(numericPath);
+ }
+
+ return null;
+ }
+
+ function getCubePlacementForPlanet(cubeRefs, planetId) {
+ const normalizedPlanetId = normalizeId(planetId);
+ return normalizedPlanetId ? cubeRefs?.planetPlacementById?.get(normalizedPlanetId) || null : null;
+ }
+
+ function getCubePlacementForSign(cubeRefs, signId) {
+ const normalizedSignId = normalizeId(signId);
+ return normalizedSignId ? cubeRefs?.signPlacementById?.get(normalizedSignId) || null : null;
+ }
+
+ function cubePlacementLabel(placement) {
+ const wallName = placement?.wallName || "Wall";
+ const edgeName = placement?.edgeName || "Direction";
+ return `Cube: ${wallName} Wall - ${edgeName}`;
+ }
+
+ function buildCubePlacementButton(placement, navBtn, fallbackDetail = null) {
+ if (!placement || typeof navBtn !== "function") {
+ return "";
+ }
+
+ const detail = {
+ "wall-id": placement.wallId,
+ "edge-id": placement.edgeId
+ };
+
+ if (fallbackDetail && typeof fallbackDetail === "object") {
+ Object.entries(fallbackDetail).forEach(([key, value]) => {
+ if (value !== undefined && value !== null && value !== "") {
+ detail[key] = value;
+ }
+ });
+ }
+
+ return navBtn(cubePlacementLabel(placement), "nav:cube", detail);
+ }
+
+ window.AlphabetKabbalahUi = {
+ buildCubePlacementButton,
+ buildFourWorldLayersFromDataset,
+ createEmptyCubeRefs,
+ cubePlacementLabel,
+ getCubePlacementForHebrewLetter,
+ getCubePlacementForPlanet,
+ getCubePlacementForSign,
+ normalizeId,
+ normalizeLetterId,
+ titleCase
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-alphabet.js b/app/ui-alphabet.js
index 8138082..e9d878f 100644
--- a/app/ui-alphabet.js
+++ b/app/ui-alphabet.js
@@ -3,6 +3,23 @@
"use strict";
const alphabetGematriaUi = window.AlphabetGematriaUi || {};
+ const alphabetKabbalahUi = window.AlphabetKabbalahUi || {};
+ const alphabetReferenceBuilders = window.AlphabetReferenceBuilders || {};
+ const alphabetDetailUi = window.AlphabetDetailUi || {};
+
+ if (
+ typeof alphabetKabbalahUi.buildCubePlacementButton !== "function"
+ || typeof alphabetKabbalahUi.buildFourWorldLayersFromDataset !== "function"
+ || typeof alphabetKabbalahUi.createEmptyCubeRefs !== "function"
+ || typeof alphabetKabbalahUi.getCubePlacementForHebrewLetter !== "function"
+ || typeof alphabetKabbalahUi.getCubePlacementForPlanet !== "function"
+ || typeof alphabetKabbalahUi.getCubePlacementForSign !== "function"
+ || typeof alphabetKabbalahUi.normalizeId !== "function"
+ || typeof alphabetKabbalahUi.normalizeLetterId !== "function"
+ || typeof alphabetKabbalahUi.titleCase !== "function"
+ ) {
+ throw new Error("AlphabetKabbalahUi module must load before ui-alphabet.js");
+ }
const state = {
initialized: false,
@@ -15,7 +32,6 @@
},
fourWorldLayers: [],
monthRefsByHebrewId: new Map(),
- const alphabetReferenceBuilders = window.AlphabetReferenceBuilders || {};
cubeRefs: {
hebrewPlacementById: new Map(),
signPlacementById: new Map(),
@@ -24,7 +40,6 @@
}
};
- const alphabetDetailUi = window.AlphabetDetailUi || {};
// ── Arabic display name table ─────────────────────────────────────────
const ARABIC_DISPLAY_NAMES = {
alif: "Alif", ba: "Ba", jeem: "Jeem", dal: "Dal", ha: "H\u0101",
@@ -437,84 +452,19 @@
};
function normalizeId(value) {
- return String(value || "").trim().toLowerCase();
+ return alphabetKabbalahUi.normalizeId(value);
}
- function normalizeSoulId(value) {
- return String(value || "")
- .trim()
- .toLowerCase()
- .replace(/[^a-z]/g, "");
+ function normalizeLetterId(value) {
+ return alphabetKabbalahUi.normalizeLetterId(value);
+ }
+
+ function titleCase(value) {
+ return alphabetKabbalahUi.titleCase(value);
}
function buildFourWorldLayersFromDataset(magickDataset) {
- const worlds = magickDataset?.grouped?.kabbalah?.fourWorlds;
- const souls = magickDataset?.grouped?.kabbalah?.souls;
- const paths = Array.isArray(magickDataset?.grouped?.kabbalah?.["kabbalah-tree"]?.paths)
- ? magickDataset.grouped.kabbalah["kabbalah-tree"].paths
- : [];
-
- if (!worlds || typeof worlds !== "object") {
- return [];
- }
-
- const soulAliases = {
- chiah: "chaya",
- chaya: "chaya",
- neshamah: "neshama",
- neshama: "neshama",
- ruach: "ruach",
- nephesh: "nephesh"
- };
-
- const pathByLetterId = new Map();
- paths.forEach((path) => {
- const letterId = normalizeLetterId(path?.hebrewLetter?.transliteration || path?.hebrewLetter?.char);
- const pathNo = Number(path?.pathNumber);
- if (!letterId || !Number.isFinite(pathNo) || pathByLetterId.has(letterId)) {
- return;
- }
- pathByLetterId.set(letterId, pathNo);
- });
-
- const worldOrder = ["atzilut", "briah", "yetzirah", "assiah"];
-
- return worldOrder
- .map((worldId) => {
- const world = worlds?.[worldId];
- if (!world || typeof world !== "object") {
- return null;
- }
-
- const tetragrammaton = world?.tetragrammaton && typeof world.tetragrammaton === "object"
- ? world.tetragrammaton
- : {};
-
- const letterId = normalizeLetterId(tetragrammaton?.hebrewLetterId);
- const rawSoulId = normalizeSoulId(world?.soulId);
- const soulId = soulAliases[rawSoulId] || rawSoulId;
- const soul = souls?.[soulId] && typeof souls[soulId] === "object"
- ? souls[soulId]
- : null;
-
- const slot = tetragrammaton?.isFinal
- ? `${String(tetragrammaton?.slot || "Heh")} (final)`
- : String(tetragrammaton?.slot || "");
-
- return {
- slot,
- letterChar: String(tetragrammaton?.letterChar || ""),
- hebrewLetterId: letterId,
- world: String(world?.name?.roman || titleCase(worldId)),
- worldLayer: String(world?.worldLayer?.en || world?.desc?.en || ""),
- worldDescription: String(world?.worldDescription?.en || ""),
- soulLayer: String(soul?.name?.roman || titleCase(rawSoulId || soulId)),
- soulTitle: String(soul?.title?.en || titleCase(soul?.name?.en || "")),
- soulDescription: String(soul?.desc?.en || ""),
- pathNumber: pathByLetterId.get(letterId) || null
- };
- })
- .filter(Boolean);
+ return alphabetKabbalahUi.buildFourWorldLayersFromDataset(magickDataset);
}
function buildMonthReferencesByHebrew(referenceData, alphabets) {
@@ -526,12 +476,7 @@
}
function createEmptyCubeRefs() {
- return {
- hebrewPlacementById: new Map(),
- signPlacementById: new Map(),
- planetPlacementById: new Map(),
- pathPlacementByNo: new Map()
- };
+ return alphabetKabbalahUi.createEmptyCubeRefs();
}
function buildCubeReferences(magickDataset) {
@@ -543,54 +488,19 @@
}
function getCubePlacementForHebrewLetter(hebrewLetterId, pathNo = null) {
- const normalizedLetterId = normalizeId(hebrewLetterId);
- if (normalizedLetterId && state.cubeRefs.hebrewPlacementById.has(normalizedLetterId)) {
- return state.cubeRefs.hebrewPlacementById.get(normalizedLetterId);
- }
-
- const numericPath = Number(pathNo);
- if (Number.isFinite(numericPath) && state.cubeRefs.pathPlacementByNo.has(numericPath)) {
- return state.cubeRefs.pathPlacementByNo.get(numericPath);
- }
-
- return null;
+ return alphabetKabbalahUi.getCubePlacementForHebrewLetter(state.cubeRefs, hebrewLetterId, pathNo);
}
function getCubePlacementForPlanet(planetId) {
- const normalizedPlanetId = normalizeId(planetId);
- return normalizedPlanetId ? state.cubeRefs.planetPlacementById.get(normalizedPlanetId) || null : null;
+ return alphabetKabbalahUi.getCubePlacementForPlanet(state.cubeRefs, planetId);
}
function getCubePlacementForSign(signId) {
- const normalizedSignId = normalizeId(signId);
- return normalizedSignId ? state.cubeRefs.signPlacementById.get(normalizedSignId) || null : null;
- }
-
- function cubePlacementLabel(placement) {
- const wallName = placement?.wallName || "Wall";
- const edgeName = placement?.edgeName || "Direction";
- return `Cube: ${wallName} Wall - ${edgeName}`;
+ return alphabetKabbalahUi.getCubePlacementForSign(state.cubeRefs, signId);
}
function cubePlacementBtn(placement, fallbackDetail = null) {
- if (!placement) {
- return "";
- }
-
- const detail = {
- "wall-id": placement.wallId,
- "edge-id": placement.edgeId
- };
-
- if (fallbackDetail && typeof fallbackDetail === "object") {
- Object.entries(fallbackDetail).forEach(([key, value]) => {
- if (value !== undefined && value !== null && value !== "") {
- detail[key] = value;
- }
- });
- }
-
- return navBtn(cubePlacementLabel(placement), "nav:cube", detail);
+ return alphabetKabbalahUi.buildCubePlacementButton(placement, navBtn, fallbackDetail);
}
function cap(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : ""; }
diff --git a/app/ui-calendar-data.js b/app/ui-calendar-data.js
new file mode 100644
index 0000000..5398f8f
--- /dev/null
+++ b/app/ui-calendar-data.js
@@ -0,0 +1,329 @@
+(function () {
+ "use strict";
+
+ function buildDecanWindow(context, sign, decanIndex) {
+ const { buildSignDateBounds, addDays, formatDateLabel } = context;
+ const bounds = buildSignDateBounds(sign);
+ const index = Number(decanIndex);
+ if (!bounds || !Number.isFinite(index)) {
+ return null;
+ }
+
+ const start = addDays(bounds.start, (index - 1) * 10);
+ const nominalEnd = addDays(start, 9);
+ const end = nominalEnd.getTime() > bounds.end.getTime() ? bounds.end : nominalEnd;
+
+ return {
+ start,
+ end,
+ label: `${formatDateLabel(start)}–${formatDateLabel(end)}`
+ };
+ }
+
+ function listMonthNumbersBetween(start, end) {
+ const result = [];
+ const seen = new Set();
+ const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
+ const limit = new Date(end.getFullYear(), end.getMonth(), 1);
+
+ while (cursor.getTime() <= limit.getTime()) {
+ const monthNo = cursor.getMonth() + 1;
+ if (!seen.has(monthNo)) {
+ seen.add(monthNo);
+ result.push(monthNo);
+ }
+ cursor.setMonth(cursor.getMonth() + 1);
+ }
+
+ return result;
+ }
+
+ function buildDecanTarotRowsForMonth(context, month) {
+ const { state, normalizeMinorTarotCardName } = context;
+ const monthOrder = Number(month?.order);
+ if (!Number.isFinite(monthOrder)) {
+ return [];
+ }
+
+ const rows = [];
+ const seen = new Set();
+ const decansBySign = state.referenceData?.decansBySign || {};
+
+ Object.entries(decansBySign).forEach(([signId, decans]) => {
+ const sign = state.signsById.get(signId);
+ if (!sign || !Array.isArray(decans)) {
+ return;
+ }
+
+ decans.forEach((decan) => {
+ const window = buildDecanWindow(context, sign, decan?.index);
+ if (!window) {
+ return;
+ }
+
+ const monthsTouched = listMonthNumbersBetween(window.start, window.end);
+ if (!monthsTouched.includes(monthOrder)) {
+ return;
+ }
+
+ const cardName = normalizeMinorTarotCardName(decan?.tarotMinorArcana);
+ if (!cardName) {
+ return;
+ }
+
+ const key = `${cardName}|${signId}|${decan.index}`;
+ if (seen.has(key)) {
+ return;
+ }
+ seen.add(key);
+
+ const startDegree = (Number(decan.index) - 1) * 10;
+ const endDegree = startDegree + 10;
+ const signName = sign?.name?.en || sign?.name || signId;
+
+ rows.push({
+ cardName,
+ signId,
+ signName,
+ signSymbol: sign?.symbol || "",
+ decanIndex: Number(decan.index),
+ startDegree,
+ endDegree,
+ startTime: window.start.getTime(),
+ endTime: window.end.getTime(),
+ startMonth: window.start.getMonth() + 1,
+ startDay: window.start.getDate(),
+ endMonth: window.end.getMonth() + 1,
+ endDay: window.end.getDate(),
+ dateRange: window.label
+ });
+ });
+ });
+
+ rows.sort((left, right) => {
+ if (left.startTime !== right.startTime) {
+ return left.startTime - right.startTime;
+ }
+ if (left.decanIndex !== right.decanIndex) {
+ return left.decanIndex - right.decanIndex;
+ }
+ return left.cardName.localeCompare(right.cardName);
+ });
+
+ return rows;
+ }
+
+ function getMonthDayLinkRows(context, month) {
+ const { state, getDaysInMonth, resolveCalendarDayToGregorian, formatIsoDate } = context;
+ const cacheKey = `${state.selectedCalendar}|${state.selectedYear}|${month?.id || ""}`;
+ if (state.dayLinksCache.has(cacheKey)) {
+ return state.dayLinksCache.get(cacheKey);
+ }
+
+ let dayCount = null;
+ if (state.selectedCalendar === "gregorian") {
+ dayCount = getDaysInMonth(state.selectedYear, Number(month?.order));
+ } else if (state.selectedCalendar === "hebrew" || state.selectedCalendar === "islamic") {
+ const baseDays = Number(month?.days);
+ const variantDays = Number(month?.daysVariant);
+ if (Number.isFinite(baseDays) && Number.isFinite(variantDays)) {
+ dayCount = Math.max(Math.trunc(baseDays), Math.trunc(variantDays));
+ } else if (Number.isFinite(baseDays)) {
+ dayCount = Math.trunc(baseDays);
+ } else if (Number.isFinite(variantDays)) {
+ dayCount = Math.trunc(variantDays);
+ }
+ }
+
+ if (!Number.isFinite(dayCount) || dayCount <= 0) {
+ state.dayLinksCache.set(cacheKey, []);
+ return [];
+ }
+
+ const rows = [];
+ for (let day = 1; day <= dayCount; day += 1) {
+ const gregorianDate = resolveCalendarDayToGregorian(month, day);
+ rows.push({
+ day,
+ gregorianDate: formatIsoDate(gregorianDate),
+ isResolved: Boolean(gregorianDate && !Number.isNaN(gregorianDate.getTime()))
+ });
+ }
+
+ state.dayLinksCache.set(cacheKey, rows);
+ return rows;
+ }
+
+ function associationSearchText(context, associations) {
+ const { getTarotCardSearchAliases } = context;
+ if (!associations || typeof associations !== "object") {
+ return "";
+ }
+
+ const tarotAliases = associations.tarotCard && typeof getTarotCardSearchAliases === "function"
+ ? getTarotCardSearchAliases(associations.tarotCard, { trumpNumber: associations.tarotTrumpNumber })
+ : [];
+
+ return [
+ associations.planetId,
+ associations.zodiacSignId,
+ associations.numberValue,
+ associations.tarotCard,
+ associations.tarotTrumpNumber,
+ ...tarotAliases,
+ associations.godId,
+ associations.godName,
+ associations.hebrewLetterId,
+ associations.kabbalahPathNumber,
+ associations.iChingPlanetaryInfluence
+ ].filter(Boolean).join(" ");
+ }
+
+ function eventSearchText(context, event) {
+ const { normalizeSearchValue } = context;
+ return normalizeSearchValue([
+ event?.name,
+ event?.date,
+ event?.dateRange,
+ event?.description,
+ associationSearchText(context, event?.associations)
+ ].filter(Boolean).join(" "));
+ }
+
+ function holidaySearchText(context, holiday) {
+ const { normalizeSearchValue } = context;
+ return normalizeSearchValue([
+ holiday?.name,
+ holiday?.kind,
+ holiday?.date,
+ holiday?.dateRange,
+ holiday?.dateText,
+ holiday?.monthDayStart,
+ holiday?.calendarId,
+ holiday?.description,
+ associationSearchText(context, holiday?.associations)
+ ].filter(Boolean).join(" "));
+ }
+
+ function buildHolidayList(context, month) {
+ const { state, normalizeText, resolveHolidayGregorianDate } = context;
+ const calendarId = state.selectedCalendar;
+ const monthOrder = Number(month?.order);
+
+ const fromRepo = state.calendarHolidays.filter((holiday) => {
+ const holidayCalendarId = normalizeText(holiday?.calendarId).toLowerCase();
+ if (holidayCalendarId !== calendarId) {
+ return false;
+ }
+
+ const isDirectMonthMatch = normalizeText(holiday?.monthId).toLowerCase() === normalizeText(month?.id).toLowerCase();
+ if (isDirectMonthMatch) {
+ return true;
+ }
+
+ if (calendarId === "gregorian" && holiday?.dateRule && Number.isFinite(monthOrder)) {
+ const computedDate = resolveHolidayGregorianDate(holiday);
+ return computedDate instanceof Date
+ && !Number.isNaN(computedDate.getTime())
+ && (computedDate.getMonth() + 1) === Math.trunc(monthOrder);
+ }
+
+ return false;
+ });
+
+ if (fromRepo.length) {
+ return [...fromRepo].sort((left, right) => {
+ const leftDate = resolveHolidayGregorianDate(left);
+ const rightDate = resolveHolidayGregorianDate(right);
+ const leftDay = Number.isFinite(Number(left?.day))
+ ? Number(left.day)
+ : ((leftDate instanceof Date && !Number.isNaN(leftDate.getTime())) ? leftDate.getDate() : NaN);
+ const rightDay = Number.isFinite(Number(right?.day))
+ ? Number(right.day)
+ : ((rightDate instanceof Date && !Number.isNaN(rightDate.getTime())) ? rightDate.getDate() : NaN);
+ if (Number.isFinite(leftDay) && Number.isFinite(rightDay) && leftDay !== rightDay) {
+ return leftDay - rightDay;
+ }
+ return normalizeText(left?.name).localeCompare(normalizeText(right?.name));
+ });
+ }
+
+ const seen = new Set();
+ const ordered = [];
+
+ (month?.holidayIds || []).forEach((holidayId) => {
+ const holiday = state.holidays.find((item) => item.id === holidayId);
+ if (holiday && !seen.has(holiday.id)) {
+ seen.add(holiday.id);
+ ordered.push(holiday);
+ }
+ });
+
+ state.holidays.forEach((holiday) => {
+ if (holiday?.monthId === month.id && !seen.has(holiday.id)) {
+ seen.add(holiday.id);
+ ordered.push(holiday);
+ }
+ });
+
+ return ordered;
+ }
+
+ function buildMonthSearchText(context, month) {
+ const { state, normalizeSearchValue } = context;
+ const monthHolidays = buildHolidayList(context, month);
+ const holidayText = monthHolidays.map((holiday) => holidaySearchText(context, holiday)).join(" ");
+
+ if (state.selectedCalendar === "gregorian") {
+ const events = Array.isArray(month?.events) ? month.events : [];
+ return normalizeSearchValue([
+ month?.name,
+ month?.id,
+ month?.start,
+ month?.end,
+ month?.coreTheme,
+ month?.seasonNorth,
+ month?.seasonSouth,
+ associationSearchText(context, month?.associations),
+ events.map((event) => eventSearchText(context, event)).join(" "),
+ holidayText
+ ].filter(Boolean).join(" "));
+ }
+
+ const wheelAssocText = month?.associations
+ ? [
+ Array.isArray(month.associations.themes) ? month.associations.themes.join(" ") : "",
+ Array.isArray(month.associations.deities) ? month.associations.deities.join(" ") : "",
+ month.associations.element,
+ month.associations.direction
+ ].filter(Boolean).join(" ")
+ : "";
+
+ return normalizeSearchValue([
+ month?.name,
+ month?.id,
+ month?.nativeName,
+ month?.meaning,
+ month?.season,
+ month?.description,
+ month?.zodiacSign,
+ month?.tribe,
+ month?.element,
+ month?.type,
+ month?.date,
+ month?.hebrewLetter,
+ holidayText,
+ wheelAssocText
+ ].filter(Boolean).join(" "));
+ }
+
+ window.CalendarDataUi = {
+ getMonthDayLinkRows,
+ buildDecanTarotRowsForMonth,
+ associationSearchText,
+ eventSearchText,
+ holidaySearchText,
+ buildHolidayList,
+ buildMonthSearchText
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-calendar-detail-panels.js b/app/ui-calendar-detail-panels.js
new file mode 100644
index 0000000..2563413
--- /dev/null
+++ b/app/ui-calendar-detail-panels.js
@@ -0,0 +1,457 @@
+(function () {
+ "use strict";
+
+ function findSignIdByAstrologyName(name, context) {
+ const { api, getState } = context;
+ 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(context) {
+ const { month, api, getState } = context;
+ 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, context);
+ 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(context) {
+ const { month, api } = context;
+ const selectedDay = api.getSelectedDayFilterContext(month);
+ const allRows = buildMajorArcanaRowsForMonth(context);
+
+ 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(context) {
+ const { month, api } = context;
+ 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(context) {
+ const { month, api } = context;
+ 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 renderGregorianMonthDetail(context) {
+ const {
+ renderFactsCard,
+ renderAssociationsCard,
+ renderEventsCard,
+ renderHolidaysCard,
+ month
+ } = context;
+
+ return `
+
+ ${renderFactsCard(month)}
+ ${renderDayLinksCard(context)}
+ ${renderAssociationsCard(month)}
+ ${renderMajorArcanaCard(context)}
+ ${renderDecanTarotCard(context)}
+ ${renderEventsCard(month)}
+ ${renderHolidaysCard(month, "Holiday Repository")}
+
+ `;
+ }
+
+ function renderHebrewMonthDetail(context) {
+ const { month, api, getState, buildAssociationButtons, renderHolidaysCard } = context;
+ 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(context) {
+ const { month, api, getState, buildAssociationButtons, renderHolidaysCard } = context;
+ 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, context) {
+ const { api, getState } = context;
+ 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(context) {
+ const { month, api, getState, buildAssociationButtons, renderHolidaysCard } = context;
+ 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, context);
+ 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 `
+
+ `;
+ }
+
+ window.CalendarDetailPanelsUi = {
+ renderGregorianMonthDetail,
+ renderHebrewMonthDetail,
+ renderIslamicMonthDetail,
+ renderWheelMonthDetail
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-calendar-detail.js b/app/ui-calendar-detail.js
index 4dc3450..5fb152c 100644
--- a/app/ui-calendar-detail.js
+++ b/app/ui-calendar-detail.js
@@ -37,6 +37,17 @@
findGodIdByName: () => null
};
+ const calendarDetailPanelsUi = window.CalendarDetailPanelsUi || {};
+
+ if (
+ typeof calendarDetailPanelsUi.renderGregorianMonthDetail !== "function"
+ || typeof calendarDetailPanelsUi.renderHebrewMonthDetail !== "function"
+ || typeof calendarDetailPanelsUi.renderIslamicMonthDetail !== "function"
+ || typeof calendarDetailPanelsUi.renderWheelMonthDetail !== "function"
+ ) {
+ throw new Error("CalendarDetailPanelsUi module must load before ui-calendar-detail.js");
+ }
+
function init(config) {
Object.assign(api, config || {});
}
@@ -413,420 +424,17 @@
`;
}
- 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 getPanelRenderContext(month) {
+ return {
+ month,
+ api,
+ getState,
+ buildAssociationButtons,
+ renderFactsCard,
+ renderAssociationsCard,
+ renderEventsCard,
+ renderHolidaysCard
+ };
}
function attachNavHandlers(detailBodyEl) {
@@ -964,28 +572,19 @@
detailNameEl.textContent = month.name || month.id;
const currentState = getState();
+ const panelContext = getPanelRenderContext(month);
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")}
-
- `;
+ detailBodyEl.innerHTML = calendarDetailPanelsUi.renderGregorianMonthDetail(panelContext);
} else if (currentState.selectedCalendar === "hebrew") {
detailSubEl.textContent = api.getMonthSubtitle(month);
- detailBodyEl.innerHTML = renderHebrewMonthDetail(month);
+ detailBodyEl.innerHTML = calendarDetailPanelsUi.renderHebrewMonthDetail(panelContext);
} else if (currentState.selectedCalendar === "islamic") {
detailSubEl.textContent = api.getMonthSubtitle(month);
- detailBodyEl.innerHTML = renderIslamicMonthDetail(month);
+ detailBodyEl.innerHTML = calendarDetailPanelsUi.renderIslamicMonthDetail(panelContext);
} else {
detailSubEl.textContent = api.getMonthSubtitle(month);
- detailBodyEl.innerHTML = renderWheelMonthDetail(month);
+ detailBodyEl.innerHTML = calendarDetailPanelsUi.renderWheelMonthDetail(panelContext);
}
attachNavHandlers(detailBodyEl);
diff --git a/app/ui-calendar.js b/app/ui-calendar.js
index 5e47500..34bf133 100644
--- a/app/ui-calendar.js
+++ b/app/ui-calendar.js
@@ -5,6 +5,7 @@
const { getTarotCardDisplayName, getTarotCardSearchAliases } = window.TarotCardImages || {};
const calendarDatesUi = window.TarotCalendarDates || {};
const calendarDetailUi = window.TarotCalendarDetail || {};
+ const calendarDataUi = window.CalendarDataUi || {};
const {
addDays,
buildSignDateBounds,
@@ -29,6 +30,17 @@
resolveHolidayGregorianDate
} = calendarDatesUi;
+ if (
+ typeof calendarDataUi.getMonthDayLinkRows !== "function"
+ || typeof calendarDataUi.buildDecanTarotRowsForMonth !== "function"
+ || typeof calendarDataUi.eventSearchText !== "function"
+ || typeof calendarDataUi.holidaySearchText !== "function"
+ || typeof calendarDataUi.buildHolidayList !== "function"
+ || typeof calendarDataUi.buildMonthSearchText !== "function"
+ ) {
+ throw new Error("CalendarDataUi module must load before ui-calendar.js");
+ }
+
const state = {
initialized: false,
referenceData: null,
@@ -317,116 +329,6 @@
};
}
- function buildDecanWindow(sign, decanIndex) {
- const bounds = buildSignDateBounds(sign);
- const index = Number(decanIndex);
- if (!bounds || !Number.isFinite(index)) {
- return null;
- }
-
- const start = addDays(bounds.start, (index - 1) * 10);
- const nominalEnd = addDays(start, 9);
- const end = nominalEnd.getTime() > bounds.end.getTime() ? bounds.end : nominalEnd;
-
- return {
- start,
- end,
- label: `${formatDateLabel(start)}–${formatDateLabel(end)}`
- };
- }
-
- function listMonthNumbersBetween(start, end) {
- const result = [];
- const seen = new Set();
- const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
- const limit = new Date(end.getFullYear(), end.getMonth(), 1);
-
- while (cursor.getTime() <= limit.getTime()) {
- const monthNo = cursor.getMonth() + 1;
- if (!seen.has(monthNo)) {
- seen.add(monthNo);
- result.push(monthNo);
- }
- cursor.setMonth(cursor.getMonth() + 1);
- }
-
- return result;
- }
-
- function buildDecanTarotRowsForMonth(month) {
- const monthOrder = Number(month?.order);
- if (!Number.isFinite(monthOrder)) {
- return [];
- }
-
- const rows = [];
- const seen = new Set();
- const decansBySign = state.referenceData?.decansBySign || {};
-
- Object.entries(decansBySign).forEach(([signId, decans]) => {
- const sign = state.signsById.get(signId);
- if (!sign || !Array.isArray(decans)) {
- return;
- }
-
- decans.forEach((decan) => {
- const window = buildDecanWindow(sign, decan?.index);
- if (!window) {
- return;
- }
-
- const monthsTouched = listMonthNumbersBetween(window.start, window.end);
- if (!monthsTouched.includes(monthOrder)) {
- return;
- }
-
- const cardName = normalizeMinorTarotCardName(decan?.tarotMinorArcana);
- if (!cardName) {
- return;
- }
-
- const key = `${cardName}|${signId}|${decan.index}`;
- if (seen.has(key)) {
- return;
- }
- seen.add(key);
-
- const startDegree = (Number(decan.index) - 1) * 10;
- const endDegree = startDegree + 10;
- const signName = sign?.name?.en || sign?.name || signId;
-
- rows.push({
- cardName,
- signId,
- signName,
- signSymbol: sign?.symbol || "",
- decanIndex: Number(decan.index),
- startDegree,
- endDegree,
- startTime: window.start.getTime(),
- endTime: window.end.getTime(),
- startMonth: window.start.getMonth() + 1,
- startDay: window.start.getDate(),
- endMonth: window.end.getMonth() + 1,
- endDay: window.end.getDate(),
- dateRange: window.label
- });
- });
- });
-
- rows.sort((left, right) => {
- if (left.startTime !== right.startTime) {
- return left.startTime - right.startTime;
- }
- if (left.decanIndex !== right.decanIndex) {
- return left.decanIndex - right.decanIndex;
- }
- return left.cardName.localeCompare(right.cardName);
- });
-
- return rows;
- }
-
function buildPlanetMap(planetsObj) {
const map = new Map();
if (!planetsObj || typeof planetsObj !== "object") {
@@ -525,46 +427,6 @@
return parseMonthRange(month);
}
- function getMonthDayLinkRows(month) {
- const cacheKey = `${state.selectedCalendar}|${state.selectedYear}|${month?.id || ""}`;
- if (state.dayLinksCache.has(cacheKey)) {
- return state.dayLinksCache.get(cacheKey);
- }
-
- let dayCount = null;
- if (state.selectedCalendar === "gregorian") {
- dayCount = getDaysInMonth(state.selectedYear, Number(month?.order));
- } else if (state.selectedCalendar === "hebrew" || state.selectedCalendar === "islamic") {
- const baseDays = Number(month?.days);
- const variantDays = Number(month?.daysVariant);
- if (Number.isFinite(baseDays) && Number.isFinite(variantDays)) {
- dayCount = Math.max(Math.trunc(baseDays), Math.trunc(variantDays));
- } else if (Number.isFinite(baseDays)) {
- dayCount = Math.trunc(baseDays);
- } else if (Number.isFinite(variantDays)) {
- dayCount = Math.trunc(variantDays);
- }
- }
-
- if (!Number.isFinite(dayCount) || dayCount <= 0) {
- state.dayLinksCache.set(cacheKey, []);
- return [];
- }
-
- const rows = [];
- for (let day = 1; day <= dayCount; day += 1) {
- const gregorianDate = resolveCalendarDayToGregorian(month, day);
- rows.push({
- day,
- gregorianDate: formatIsoDate(gregorianDate),
- isResolved: Boolean(gregorianDate && !Number.isNaN(gregorianDate.getTime()))
- });
- }
-
- state.dayLinksCache.set(cacheKey, rows);
- return rows;
- }
-
function renderList(elements) {
const { monthListEl, monthCountEl, listTitleEl } = elements;
if (!monthListEl) {
@@ -601,162 +463,46 @@
}
}
- function associationSearchText(associations) {
- if (!associations || typeof associations !== "object") {
- return "";
- }
- const tarotAliases = associations.tarotCard && typeof getTarotCardSearchAliases === "function"
- ? getTarotCardSearchAliases(associations.tarotCard, { trumpNumber: associations.tarotTrumpNumber })
- : [];
+ function getCalendarDataContext() {
+ return {
+ state,
+ normalizeText,
+ normalizeSearchValue,
+ normalizeMinorTarotCardName,
+ getTarotCardSearchAliases,
+ addDays,
+ buildSignDateBounds,
+ formatDateLabel,
+ formatIsoDate,
+ getDaysInMonth,
+ resolveCalendarDayToGregorian,
+ resolveHolidayGregorianDate
+ };
+ }
- return [
- associations.planetId,
- associations.zodiacSignId,
- associations.numberValue,
- associations.tarotCard,
- associations.tarotTrumpNumber,
- ...tarotAliases,
- associations.godId,
- associations.godName,
- associations.hebrewLetterId,
- associations.kabbalahPathNumber,
- associations.iChingPlanetaryInfluence
- ].filter(Boolean).join(" ");
+ function buildDecanTarotRowsForMonth(month) {
+ return calendarDataUi.buildDecanTarotRowsForMonth(getCalendarDataContext(), month);
+ }
+
+ function getMonthDayLinkRows(month) {
+ return calendarDataUi.getMonthDayLinkRows(getCalendarDataContext(), month);
}
function eventSearchText(event) {
- return normalizeSearchValue([
- event?.name,
- event?.date,
- event?.dateRange,
- event?.description,
- associationSearchText(event?.associations)
- ].filter(Boolean).join(" "));
+ return calendarDataUi.eventSearchText(getCalendarDataContext(), event);
}
function holidaySearchText(holiday) {
- return normalizeSearchValue([
- holiday?.name,
- holiday?.kind,
- holiday?.date,
- holiday?.dateRange,
- holiday?.dateText,
- holiday?.monthDayStart,
- holiday?.calendarId,
- holiday?.description,
- associationSearchText(holiday?.associations)
- ].filter(Boolean).join(" "));
+ return calendarDataUi.holidaySearchText(getCalendarDataContext(), holiday);
}
function buildHolidayList(month) {
- const calendarId = state.selectedCalendar;
- const monthOrder = Number(month?.order);
-
- const fromRepo = state.calendarHolidays.filter((holiday) => {
- const holidayCalendarId = normalizeText(holiday?.calendarId).toLowerCase();
- if (holidayCalendarId !== calendarId) {
- return false;
- }
-
- const isDirectMonthMatch = normalizeText(holiday?.monthId).toLowerCase() === normalizeText(month?.id).toLowerCase();
- if (isDirectMonthMatch) {
- return true;
- }
-
- if (calendarId === "gregorian" && holiday?.dateRule && Number.isFinite(monthOrder)) {
- const computedDate = resolveHolidayGregorianDate(holiday);
- return computedDate instanceof Date
- && !Number.isNaN(computedDate.getTime())
- && (computedDate.getMonth() + 1) === Math.trunc(monthOrder);
- }
-
- return false;
- });
-
- if (fromRepo.length) {
- return [...fromRepo].sort((left, right) => {
- const leftDate = resolveHolidayGregorianDate(left);
- const rightDate = resolveHolidayGregorianDate(right);
- const leftDay = Number.isFinite(Number(left?.day))
- ? Number(left.day)
- : ((leftDate instanceof Date && !Number.isNaN(leftDate.getTime())) ? leftDate.getDate() : NaN);
- const rightDay = Number.isFinite(Number(right?.day))
- ? Number(right.day)
- : ((rightDate instanceof Date && !Number.isNaN(rightDate.getTime())) ? rightDate.getDate() : NaN);
- if (Number.isFinite(leftDay) && Number.isFinite(rightDay) && leftDay !== rightDay) {
- return leftDay - rightDay;
- }
- return normalizeText(left?.name).localeCompare(normalizeText(right?.name));
- });
- }
-
- const seen = new Set();
- const ordered = [];
-
- (month?.holidayIds || []).forEach((holidayId) => {
- const holiday = state.holidays.find((item) => item.id === holidayId);
- if (holiday && !seen.has(holiday.id)) {
- seen.add(holiday.id);
- ordered.push(holiday);
- }
- });
-
- state.holidays.forEach((holiday) => {
- if (holiday?.monthId === month.id && !seen.has(holiday.id)) {
- seen.add(holiday.id);
- ordered.push(holiday);
- }
- });
-
- return ordered;
+ return calendarDataUi.buildHolidayList(getCalendarDataContext(), month);
}
function buildMonthSearchText(month) {
- const monthHolidays = buildHolidayList(month);
- const holidayText = monthHolidays.map((holiday) => holidaySearchText(holiday)).join(" ");
-
- if (state.selectedCalendar === "gregorian") {
- const events = Array.isArray(month?.events) ? month.events : [];
- return normalizeSearchValue([
- month?.name,
- month?.id,
- month?.start,
- month?.end,
- month?.coreTheme,
- month?.seasonNorth,
- month?.seasonSouth,
- associationSearchText(month?.associations),
- events.map((event) => eventSearchText(event)).join(" "),
- holidayText
- ].filter(Boolean).join(" "));
- }
-
- const wheelAssocText = month?.associations
- ? [
- Array.isArray(month.associations.themes) ? month.associations.themes.join(" ") : "",
- Array.isArray(month.associations.deities) ? month.associations.deities.join(" ") : "",
- month.associations.element,
- month.associations.direction
- ].filter(Boolean).join(" ")
- : "";
-
- return normalizeSearchValue([
- month?.name,
- month?.id,
- month?.nativeName,
- month?.meaning,
- month?.season,
- month?.description,
- month?.zodiacSign,
- month?.tribe,
- month?.element,
- month?.type,
- month?.date,
- month?.hebrewLetter,
- holidayText,
- wheelAssocText
- ].filter(Boolean).join(" "));
+ return calendarDataUi.buildMonthSearchText(getCalendarDataContext(), month);
}
function matchesSearch(searchText) {
diff --git a/app/ui-cube-chassis.js b/app/ui-cube-chassis.js
new file mode 100644
index 0000000..1b450df
--- /dev/null
+++ b/app/ui-cube-chassis.js
@@ -0,0 +1,550 @@
+(function () {
+ "use strict";
+
+ function renderFaceSvg(context) {
+ const {
+ state,
+ containerEl,
+ walls,
+ normalizeId,
+ projectVertices,
+ FACE_GEOMETRY,
+ facePoint,
+ normalizeEdgeId,
+ getEdges,
+ EDGE_GEOMETRY,
+ EDGE_GEOMETRY_KEYS,
+ formatEdgeName,
+ getEdgeWalls,
+ getElements,
+ render,
+ snapRotationToWall,
+ getWallFaceLetter,
+ getWallTarotCard,
+ resolveCardImageUrl,
+ openTarotCardLightbox,
+ MOTHER_CONNECTORS,
+ formatDirectionName,
+ getConnectorTarotCard,
+ getHebrewLetterSymbol,
+ toDisplayText,
+ CUBE_VIEW_CENTER,
+ getEdgeMarkerDisplay,
+ getEdgeTarotCard,
+ getCubeCenterData,
+ getCenterTarotCard,
+ getCenterLetterSymbol
+ } = context;
+
+ if (!containerEl) {
+ return;
+ }
+
+ const svgNS = "http://www.w3.org/2000/svg";
+ const svg = document.createElementNS(svgNS, "svg");
+ svg.setAttribute("viewBox", "0 0 240 220");
+ svg.setAttribute("width", "100%");
+ svg.setAttribute("class", "cube-svg");
+ svg.setAttribute("role", "img");
+ svg.setAttribute("aria-label", "Cube of Space interactive chassis");
+
+ const wallById = new Map(walls.map((wall) => [normalizeId(wall?.id), wall]));
+ const projectedVertices = projectVertices();
+ const faces = Object.entries(FACE_GEOMETRY)
+ .map(([wallId, indices]) => {
+ const wall = wallById.get(wallId);
+ if (!wall) {
+ return null;
+ }
+
+ const quad = indices.map((index) => projectedVertices[index]);
+ const avgDepth = quad.reduce((sum, point) => sum + point.z, 0) / quad.length;
+
+ return {
+ wallId,
+ wall,
+ quad,
+ depth: avgDepth,
+ pointsText: quad.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`).join(" ")
+ };
+ })
+ .filter(Boolean)
+ .sort((left, right) => left.depth - right.depth);
+
+ faces.forEach((faceData) => {
+ const { wallId, wall, quad, pointsText } = faceData;
+
+ const isActive = wallId === normalizeId(state.selectedWallId);
+ const polygon = document.createElementNS(svgNS, "polygon");
+ polygon.setAttribute("points", pointsText);
+ polygon.setAttribute("class", `cube-face${isActive ? " is-active" : ""}`);
+ polygon.setAttribute("fill", "#000");
+ polygon.setAttribute("fill-opacity", isActive ? "0.78" : "0.62");
+ polygon.setAttribute("stroke", "currentColor");
+ polygon.setAttribute("stroke-opacity", isActive ? "0.92" : "0.68");
+ polygon.setAttribute("stroke-width", isActive ? "2.5" : "1");
+ polygon.setAttribute("data-wall-id", wallId);
+ polygon.setAttribute("role", "button");
+ polygon.setAttribute("tabindex", "0");
+ polygon.setAttribute("aria-label", `Cube wall ${wall?.name || wallId}`);
+
+ const selectWall = () => {
+ state.selectedWallId = wallId;
+ state.selectedEdgeId = normalizeEdgeId(context.getEdgesForWall(wallId)[0]?.id || getEdges()[0]?.id);
+ state.selectedNodeType = "wall";
+ state.selectedConnectorId = null;
+ snapRotationToWall(wallId);
+ render(getElements());
+ };
+
+ polygon.addEventListener("click", selectWall);
+ polygon.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ selectWall();
+ }
+ });
+
+ svg.appendChild(polygon);
+
+ const wallFaceLetter = getWallFaceLetter(wall);
+ const faceGlyphAnchor = facePoint(quad, 0, 0);
+
+ if (state.markerDisplayMode === "tarot") {
+ const cardUrl = resolveCardImageUrl(getWallTarotCard(wall));
+ if (cardUrl) {
+ let defs = svg.querySelector("defs");
+ if (!defs) {
+ defs = document.createElementNS(svgNS, "defs");
+ svg.insertBefore(defs, svg.firstChild);
+ }
+ const clipId = `face-clip-${wallId}`;
+ const clipPath = document.createElementNS(svgNS, "clipPath");
+ clipPath.setAttribute("id", clipId);
+ const clipPoly = document.createElementNS(svgNS, "polygon");
+ clipPoly.setAttribute("points", pointsText);
+ clipPath.appendChild(clipPoly);
+ defs.appendChild(clipPath);
+
+ const cardW = 40;
+ const 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)));
+ cardImg.setAttribute("width", String(cardW));
+ cardImg.setAttribute("height", String(cardH));
+ cardImg.setAttribute("clip-path", `url(#${clipId})`);
+ cardImg.setAttribute("role", "button");
+ cardImg.setAttribute("tabindex", "0");
+ cardImg.setAttribute("aria-label", `Open ${wallTarotCard || (wall?.name || wallId)} card image`);
+ cardImg.setAttribute("preserveAspectRatio", "xMidYMid meet");
+ 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);
+ }
+ } else {
+ const faceGlyph = document.createElementNS(svgNS, "text");
+ faceGlyph.setAttribute(
+ "class",
+ `cube-face-symbol${isActive ? " is-active" : ""}${wallFaceLetter ? "" : " is-missing"}`
+ );
+ faceGlyph.setAttribute("x", String(faceGlyphAnchor.x));
+ faceGlyph.setAttribute("y", String(faceGlyphAnchor.y));
+ faceGlyph.setAttribute("text-anchor", "middle");
+ faceGlyph.setAttribute("dominant-baseline", "middle");
+ faceGlyph.setAttribute("pointer-events", "none");
+ faceGlyph.textContent = wallFaceLetter || "!";
+ svg.appendChild(faceGlyph);
+ }
+
+ const labelAnchor = facePoint(quad, 0, 0.9);
+ const label = document.createElementNS(svgNS, "text");
+ label.setAttribute("class", `cube-face-label${isActive ? " is-active" : ""}`);
+ label.setAttribute("x", String(labelAnchor.x));
+ label.setAttribute("y", String(labelAnchor.y));
+ label.setAttribute("text-anchor", "middle");
+ label.setAttribute("dominant-baseline", "middle");
+ label.setAttribute("pointer-events", "none");
+ label.textContent = wall?.name || wallId;
+ svg.appendChild(label);
+ });
+
+ const faceCenterByWallId = new Map(
+ faces.map((faceData) => [faceData.wallId, facePoint(faceData.quad, 0, 0)])
+ );
+
+ if (state.showConnectorLines) {
+ MOTHER_CONNECTORS.forEach((connector, connectorIndex) => {
+ const fromWallId = normalizeId(connector?.fromWallId);
+ const toWallId = normalizeId(connector?.toWallId);
+ const from = faceCenterByWallId.get(fromWallId);
+ const to = faceCenterByWallId.get(toWallId);
+ if (!from || !to) {
+ return;
+ }
+
+ const connectorId = normalizeId(connector?.id);
+ const isActive = state.selectedNodeType === "connector"
+ && normalizeId(state.selectedConnectorId) === connectorId;
+ const connectorLetter = getHebrewLetterSymbol(connector?.hebrewLetterId);
+ const connectorCardUrl = state.markerDisplayMode === "tarot"
+ ? resolveCardImageUrl(getConnectorTarotCard(connector))
+ : null;
+
+ const group = document.createElementNS(svgNS, "g");
+ group.setAttribute("class", `cube-connector${isActive ? " is-active" : ""}`);
+ group.setAttribute("role", "button");
+ group.setAttribute("tabindex", "0");
+ group.setAttribute(
+ "aria-label",
+ `Mother connector ${formatDirectionName(fromWallId)} to ${formatDirectionName(toWallId)}`
+ );
+
+ const connectorLine = document.createElementNS(svgNS, "line");
+ connectorLine.setAttribute("class", `cube-connector-line${isActive ? " is-active" : ""}`);
+ connectorLine.setAttribute("x1", from.x.toFixed(2));
+ connectorLine.setAttribute("y1", from.y.toFixed(2));
+ connectorLine.setAttribute("x2", to.x.toFixed(2));
+ connectorLine.setAttribute("y2", to.y.toFixed(2));
+ group.appendChild(connectorLine);
+
+ const connectorHit = document.createElementNS(svgNS, "line");
+ connectorHit.setAttribute("class", "cube-connector-hit");
+ connectorHit.setAttribute("x1", from.x.toFixed(2));
+ connectorHit.setAttribute("y1", from.y.toFixed(2));
+ connectorHit.setAttribute("x2", to.x.toFixed(2));
+ connectorHit.setAttribute("y2", to.y.toFixed(2));
+ group.appendChild(connectorHit);
+
+ const dx = to.x - from.x;
+ const dy = to.y - from.y;
+ const length = Math.hypot(dx, dy) || 1;
+ const perpX = -dy / length;
+ const perpY = dx / length;
+ const shift = (connectorIndex - 1) * 12;
+ 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");
+ connectorText.setAttribute(
+ "class",
+ `cube-connector-symbol${isActive ? " is-active" : ""}${connectorLetter ? "" : " is-missing"}`
+ );
+ connectorText.setAttribute("x", String(labelX));
+ connectorText.setAttribute("y", String(labelY));
+ connectorText.setAttribute("text-anchor", "middle");
+ connectorText.setAttribute("dominant-baseline", "middle");
+ connectorText.setAttribute("pointer-events", "none");
+ connectorText.textContent = connectorLetter || "!";
+ group.appendChild(connectorText);
+ }
+
+ group.addEventListener("click", selectConnector);
+ group.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ selectConnector();
+ }
+ });
+
+ svg.appendChild(group);
+ });
+ }
+
+ const edgeById = new Map(
+ getEdges().map((edge) => [normalizeEdgeId(edge?.id), edge])
+ );
+
+ EDGE_GEOMETRY.forEach(([fromIndex, toIndex], edgeIndex) => {
+ const edgeId = EDGE_GEOMETRY_KEYS[edgeIndex];
+ const edge = edgeById.get(edgeId) || {
+ id: edgeId,
+ name: formatEdgeName(edgeId),
+ walls: edgeId.split("-")
+ };
+ const markerDisplay = getEdgeMarkerDisplay(edge);
+ const edgeWalls = getEdgeWalls(edge);
+
+ const wallIsActive = edgeWalls.includes(normalizeId(state.selectedWallId));
+ const edgeIsActive = normalizeEdgeId(state.selectedEdgeId) === edgeId;
+
+ const from = projectedVertices[fromIndex];
+ const to = projectedVertices[toIndex];
+
+ const line = document.createElementNS(svgNS, "line");
+ line.setAttribute("x1", from.x.toFixed(2));
+ line.setAttribute("y1", from.y.toFixed(2));
+ line.setAttribute("x2", to.x.toFixed(2));
+ line.setAttribute("y2", to.y.toFixed(2));
+ line.setAttribute("stroke", "currentColor");
+ line.setAttribute("stroke-opacity", edgeIsActive ? "0.94" : (wallIsActive ? "0.70" : "0.32"));
+ line.setAttribute("stroke-width", edgeIsActive ? "2.4" : (wallIsActive ? "1.9" : "1.4"));
+ line.setAttribute("class", `cube-edge-line${edgeIsActive ? " is-active" : ""}`);
+ line.setAttribute("role", "button");
+ line.setAttribute("tabindex", "0");
+ line.setAttribute("aria-label", `Cube edge ${toDisplayText(edge?.name) || formatEdgeName(edgeId)}`);
+
+ const selectEdge = () => {
+ state.selectedEdgeId = edgeId;
+ state.selectedNodeType = "wall";
+ state.selectedConnectorId = null;
+ if (!edgeWalls.includes(normalizeId(state.selectedWallId)) && edgeWalls[0]) {
+ state.selectedWallId = edgeWalls[0];
+ snapRotationToWall(state.selectedWallId);
+ }
+ render(getElements());
+ };
+
+ line.addEventListener("click", selectEdge);
+ line.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ selectEdge();
+ }
+ });
+ svg.appendChild(line);
+
+ const dx = to.x - from.x;
+ const dy = to.y - from.y;
+ const length = Math.hypot(dx, dy) || 1;
+ const normalX = -dy / length;
+ const normalY = dx / length;
+ const midpointX = (from.x + to.x) / 2;
+ const midpointY = (from.y + to.y) / 2;
+
+ const centerVectorX = midpointX - CUBE_VIEW_CENTER.x;
+ const centerVectorY = midpointY - CUBE_VIEW_CENTER.y;
+ const normalSign = (centerVectorX * normalX + centerVectorY * normalY) >= 0 ? 1 : -1;
+
+ const markerOffset = edgeIsActive ? 17 : (wallIsActive ? 13 : 12);
+ const labelX = midpointX + (normalX * markerOffset * normalSign);
+ const labelY = midpointY + (normalY * markerOffset * normalSign);
+
+ const marker = document.createElementNS(svgNS, "g");
+ marker.setAttribute(
+ "class",
+ `cube-direction${wallIsActive ? " is-wall-active" : ""}${edgeIsActive ? " is-active" : ""}`
+ );
+ marker.setAttribute("role", "button");
+ marker.setAttribute("tabindex", "0");
+ marker.setAttribute("aria-label", `Cube edge ${toDisplayText(edge?.name) || formatEdgeName(edgeId)}`);
+
+ if (state.markerDisplayMode === "tarot") {
+ const edgeCardUrl = resolveCardImageUrl(getEdgeTarotCard(edge));
+ 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-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");
+ markerText.setAttribute("class", "cube-direction-letter is-missing");
+ markerText.setAttribute("x", String(labelX));
+ markerText.setAttribute("y", String(labelY));
+ markerText.setAttribute("text-anchor", "middle");
+ markerText.setAttribute("dominant-baseline", "middle");
+ markerText.textContent = "!";
+ marker.appendChild(markerText);
+ }
+ } else {
+ const markerText = document.createElementNS(svgNS, "text");
+ markerText.setAttribute(
+ "class",
+ `cube-direction-letter${markerDisplay.isMissing ? " is-missing" : ""}`
+ );
+ markerText.setAttribute("x", String(labelX));
+ markerText.setAttribute("y", String(labelY));
+ markerText.setAttribute("text-anchor", "middle");
+ markerText.setAttribute("dominant-baseline", "middle");
+ markerText.textContent = markerDisplay.text;
+ marker.appendChild(markerText);
+ }
+
+ marker.addEventListener("click", selectEdge);
+ marker.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ selectEdge();
+ }
+ });
+
+ svg.appendChild(marker);
+ });
+
+ const center = getCubeCenterData();
+ if (center && state.showPrimalPoint) {
+ const centerLetter = getCenterLetterSymbol(center);
+ const centerCardUrl = state.markerDisplayMode === "tarot"
+ ? resolveCardImageUrl(getCenterTarotCard(center))
+ : null;
+ const centerActive = state.selectedNodeType === "center";
+
+ const centerMarker = document.createElementNS(svgNS, "g");
+ centerMarker.setAttribute("class", `cube-center${centerActive ? " is-active" : ""}`);
+ centerMarker.setAttribute("role", "button");
+ centerMarker.setAttribute("tabindex", "0");
+ centerMarker.setAttribute("aria-label", "Cube primal point");
+
+ const centerHit = document.createElementNS(svgNS, "circle");
+ centerHit.setAttribute("class", "cube-center-hit");
+ centerHit.setAttribute("cx", String(CUBE_VIEW_CENTER.x));
+ centerHit.setAttribute("cy", String(CUBE_VIEW_CENTER.y));
+ centerHit.setAttribute("r", "18");
+ centerMarker.appendChild(centerHit);
+
+ 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");
+ centerText.setAttribute(
+ "class",
+ `cube-center-symbol${centerActive ? " is-active" : ""}${centerLetter ? "" : " is-missing"}`
+ );
+ centerText.setAttribute("x", String(CUBE_VIEW_CENTER.x));
+ centerText.setAttribute("y", String(CUBE_VIEW_CENTER.y));
+ centerText.setAttribute("text-anchor", "middle");
+ centerText.setAttribute("dominant-baseline", "middle");
+ centerText.setAttribute("pointer-events", "none");
+ centerText.textContent = centerLetter || "!";
+ centerMarker.appendChild(centerText);
+ }
+
+ const selectCenter = () => {
+ state.selectedNodeType = "center";
+ state.selectedConnectorId = null;
+ render(getElements());
+ };
+
+ centerMarker.addEventListener("click", selectCenter);
+ centerMarker.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ selectCenter();
+ }
+ });
+
+ svg.appendChild(centerMarker);
+ }
+
+ if (state.markerDisplayMode === "tarot") {
+ Array.from(svg.querySelectorAll("g.cube-direction")).forEach((group) => {
+ svg.appendChild(group);
+ });
+
+ if (state.showConnectorLines) {
+ Array.from(svg.querySelectorAll("g.cube-connector")).forEach((group) => {
+ svg.appendChild(group);
+ });
+ }
+ }
+
+ containerEl.replaceChildren(svg);
+ }
+
+ window.CubeChassisUi = { renderFaceSvg };
+})();
\ No newline at end of file
diff --git a/app/ui-cube-math.js b/app/ui-cube-math.js
new file mode 100644
index 0000000..82b4beb
--- /dev/null
+++ b/app/ui-cube-math.js
@@ -0,0 +1,331 @@
+(function () {
+ "use strict";
+
+ function createCubeMathHelpers(dependencies) {
+ const {
+ state,
+ CUBE_VERTICES,
+ FACE_GEOMETRY,
+ EDGE_GEOMETRY,
+ EDGE_GEOMETRY_KEYS,
+ CUBE_VIEW_CENTER,
+ WALL_FRONT_ROTATIONS,
+ LOCAL_DIRECTION_VIEW_MAP,
+ normalizeId,
+ normalizeLetterKey,
+ normalizeEdgeId,
+ formatDirectionName,
+ getEdgesForWall,
+ getEdgePathEntry,
+ getEdgeLetterId,
+ getCubeCenterData
+ } = dependencies || {};
+
+ function normalizeAngle(angle) {
+ let next = angle;
+ while (next > 180) {
+ next -= 360;
+ }
+ while (next <= -180) {
+ next += 360;
+ }
+ return next;
+ }
+
+ function setRotation(nextX, nextY) {
+ state.rotationX = normalizeAngle(nextX);
+ state.rotationY = normalizeAngle(nextY);
+ }
+
+ function snapRotationToWall(wallId) {
+ const target = WALL_FRONT_ROTATIONS[normalizeId(wallId)];
+ if (!target) {
+ return;
+ }
+ setRotation(target.x, target.y);
+ }
+
+ function facePoint(quad, u, v) {
+ const weight0 = ((1 - u) * (1 - v)) / 4;
+ const weight1 = ((1 + u) * (1 - v)) / 4;
+ const weight2 = ((1 + u) * (1 + v)) / 4;
+ const weight3 = ((1 - u) * (1 + v)) / 4;
+
+ return {
+ x: quad[0].x * weight0 + quad[1].x * weight1 + quad[2].x * weight2 + quad[3].x * weight3,
+ y: quad[0].y * weight0 + quad[1].y * weight1 + quad[2].y * weight2 + quad[3].y * weight3
+ };
+ }
+
+ function projectVerticesForRotation(rotationX, rotationY) {
+ const yaw = (rotationY * Math.PI) / 180;
+ const pitch = (rotationX * Math.PI) / 180;
+
+ const cosY = Math.cos(yaw);
+ const sinY = Math.sin(yaw);
+ const cosX = Math.cos(pitch);
+ const sinX = Math.sin(pitch);
+
+ const centerX = CUBE_VIEW_CENTER.x;
+ const centerY = CUBE_VIEW_CENTER.y;
+ const scale = 54;
+ const camera = 4.6;
+
+ return CUBE_VERTICES.map(([x, y, z]) => {
+ const x1 = x * cosY + z * sinY;
+ const z1 = -x * sinY + z * cosY;
+
+ const y2 = y * cosX - z1 * sinX;
+ const z2 = y * sinX + z1 * cosX;
+
+ const perspective = camera / (camera - z2);
+
+ return {
+ x: centerX + x1 * scale * perspective,
+ y: centerY + y2 * scale * perspective,
+ z: z2
+ };
+ });
+ }
+
+ function projectVertices() {
+ return projectVerticesForRotation(state.rotationX, state.rotationY);
+ }
+
+ function getEdgeGeometryById(edgeId) {
+ const canonicalId = normalizeEdgeId(edgeId);
+ const geometryIndex = EDGE_GEOMETRY_KEYS.indexOf(canonicalId);
+ if (geometryIndex < 0) {
+ return null;
+ }
+ return EDGE_GEOMETRY[geometryIndex] || null;
+ }
+
+ function getWallEdgeDirections(wallOrWallId) {
+ const wallId = normalizeId(typeof wallOrWallId === "string" ? wallOrWallId : wallOrWallId?.id);
+ const faceIndices = FACE_GEOMETRY[wallId];
+ if (!Array.isArray(faceIndices) || faceIndices.length !== 4) {
+ return new Map();
+ }
+
+ const frontRotation = WALL_FRONT_ROTATIONS[wallId] || {
+ x: state.rotationX,
+ y: state.rotationY
+ };
+ const projectedVertices = projectVerticesForRotation(frontRotation.x, frontRotation.y);
+ const quad = faceIndices.map((index) => projectedVertices[index]);
+ const center = facePoint(quad, 0, 0);
+ const directionsByEdgeId = new Map();
+
+ getEdgesForWall(wallId).forEach((edge) => {
+ const geometry = getEdgeGeometryById(edge?.id);
+ if (!geometry) {
+ return;
+ }
+
+ const [fromIndex, toIndex] = geometry;
+ const from = projectedVertices[fromIndex];
+ const to = projectedVertices[toIndex];
+ if (!from || !to) {
+ return;
+ }
+
+ const midpointX = (from.x + to.x) / 2;
+ const midpointY = (from.y + to.y) / 2;
+ const dx = midpointX - center.x;
+ const dy = midpointY - center.y;
+
+ const directionByPosition = Math.abs(dx) >= Math.abs(dy)
+ ? (dx >= 0 ? "east" : "west")
+ : (dy >= 0 ? "south" : "north");
+ const direction = LOCAL_DIRECTION_VIEW_MAP[directionByPosition] || directionByPosition;
+
+ directionsByEdgeId.set(normalizeEdgeId(edge?.id), direction);
+ });
+
+ return directionsByEdgeId;
+ }
+
+ function getEdgeDirectionForWall(wallId, edgeId) {
+ const wallKey = normalizeId(wallId);
+ const edgeKey = normalizeEdgeId(edgeId);
+ if (!wallKey || !edgeKey) {
+ return "";
+ }
+
+ const directions = getWallEdgeDirections(wallKey);
+ return directions.get(edgeKey) || "";
+ }
+
+ function getEdgeDirectionLabelForWall(wallId, edgeId) {
+ return formatDirectionName(getEdgeDirectionForWall(wallId, edgeId));
+ }
+
+ function getHebrewLetterSymbol(hebrewLetterId) {
+ const id = normalizeLetterKey(hebrewLetterId);
+ if (!id || !state.hebrewLetters) {
+ return "";
+ }
+
+ const entry = state.hebrewLetters[id];
+ if (!entry || typeof entry !== "object") {
+ return "";
+ }
+
+ const symbol = String(
+ entry?.letter?.he || entry?.he || entry?.glyph || entry?.symbol || ""
+ ).trim();
+
+ return symbol;
+ }
+
+ function getHebrewLetterName(hebrewLetterId) {
+ const id = normalizeLetterKey(hebrewLetterId);
+ if (!id || !state.hebrewLetters) {
+ return "";
+ }
+
+ const entry = state.hebrewLetters[id];
+ if (!entry || typeof entry !== "object") {
+ return "";
+ }
+
+ const name = String(entry?.letter?.name || entry?.name || "").trim();
+ return name;
+ }
+
+ function getAstrologySymbol(type, name) {
+ const normalizedType = normalizeId(type);
+ const normalizedName = normalizeId(name);
+
+ const planetSymbols = {
+ mercury: "☿︎",
+ venus: "♀︎",
+ mars: "♂︎",
+ jupiter: "♃︎",
+ saturn: "♄︎",
+ sol: "☉︎",
+ sun: "☉︎",
+ luna: "☾︎",
+ moon: "☾︎",
+ earth: "⊕",
+ uranus: "♅︎",
+ neptune: "♆︎",
+ pluto: "♇︎"
+ };
+
+ const zodiacSymbols = {
+ aries: "♈︎",
+ taurus: "♉︎",
+ gemini: "♊︎",
+ cancer: "♋︎",
+ leo: "♌︎",
+ virgo: "♍︎",
+ libra: "♎︎",
+ scorpio: "♏︎",
+ sagittarius: "♐︎",
+ capricorn: "♑︎",
+ aquarius: "♒︎",
+ pisces: "♓︎"
+ };
+
+ const elementSymbols = {
+ fire: "🜂",
+ water: "🜄",
+ air: "🜁",
+ earth: "🜃",
+ spirit: "🜀"
+ };
+
+ if (normalizedType === "planet") {
+ return planetSymbols[normalizedName] || "";
+ }
+
+ if (normalizedType === "zodiac") {
+ return zodiacSymbols[normalizedName] || "";
+ }
+
+ if (normalizedType === "element") {
+ return elementSymbols[normalizedName] || "";
+ }
+
+ return "";
+ }
+
+ function getCenterLetterId(center = null) {
+ const entry = center || getCubeCenterData();
+ return normalizeLetterKey(entry?.hebrewLetterId || entry?.associations?.hebrewLetterId || entry?.letter);
+ }
+
+ function getCenterLetterSymbol(center = null) {
+ const centerLetterId = getCenterLetterId(center);
+ if (!centerLetterId) {
+ return "";
+ }
+ return getHebrewLetterSymbol(centerLetterId);
+ }
+
+ function getEdgeAstrologySymbol(edge) {
+ const pathEntry = getEdgePathEntry(edge);
+ const astrology = pathEntry?.astrology || {};
+ return getAstrologySymbol(astrology.type, astrology.name);
+ }
+
+ function getEdgeLetter(edge) {
+ const hebrewLetterId = getEdgeLetterId(edge);
+ if (!hebrewLetterId) {
+ return "";
+ }
+
+ return getHebrewLetterSymbol(hebrewLetterId);
+ }
+
+ function getEdgeMarkerDisplay(edge) {
+ const letter = getEdgeLetter(edge);
+ const astro = getEdgeAstrologySymbol(edge);
+
+ if (state.markerDisplayMode === "letter") {
+ return letter
+ ? { text: letter, isMissing: false }
+ : { text: "!", isMissing: true };
+ }
+
+ if (state.markerDisplayMode === "astro") {
+ return astro
+ ? { text: astro, isMissing: false }
+ : { text: "!", isMissing: true };
+ }
+
+ if (letter && astro) {
+ return { text: `${letter} ${astro}`, isMissing: false };
+ }
+
+ return { text: "!", isMissing: true };
+ }
+
+ return {
+ normalizeAngle,
+ setRotation,
+ snapRotationToWall,
+ facePoint,
+ projectVerticesForRotation,
+ projectVertices,
+ getEdgeGeometryById,
+ getWallEdgeDirections,
+ getEdgeDirectionForWall,
+ getEdgeDirectionLabelForWall,
+ getHebrewLetterSymbol,
+ getHebrewLetterName,
+ getAstrologySymbol,
+ getCenterLetterId,
+ getCenterLetterSymbol,
+ getEdgeAstrologySymbol,
+ getEdgeMarkerDisplay,
+ getEdgeLetter
+ };
+ }
+
+ window.CubeMathHelpers = {
+ createCubeMathHelpers
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-cube.js b/app/ui-cube.js
index fbc6693..3886a80 100644
--- a/app/ui-cube.js
+++ b/app/ui-cube.js
@@ -120,6 +120,8 @@
below: { x: 90, y: 0 }
};
const cubeDetailUi = window.CubeDetailUi || {};
+ const cubeChassisUi = window.CubeChassisUi || {};
+ const cubeMathHelpers = window.CubeMathHelpers || {};
function getElements() {
return {
@@ -250,144 +252,48 @@
return Number.isFinite(numeric) ? numeric : null;
}
+ if (typeof cubeMathHelpers.createCubeMathHelpers !== "function") {
+ throw new Error("CubeMathHelpers.createCubeMathHelpers is unavailable. Ensure app/ui-cube-math.js loads before app/ui-cube.js.");
+ }
+
function normalizeAngle(angle) {
- let next = angle;
- while (next > 180) {
- next -= 360;
- }
- while (next <= -180) {
- next += 360;
- }
- return next;
+ return cubeMathUi.normalizeAngle(angle);
}
function setRotation(nextX, nextY) {
- state.rotationX = normalizeAngle(nextX);
- state.rotationY = normalizeAngle(nextY);
+ cubeMathUi.setRotation(nextX, nextY);
}
function snapRotationToWall(wallId) {
- const target = WALL_FRONT_ROTATIONS[normalizeId(wallId)];
- if (!target) {
- return;
- }
- setRotation(target.x, target.y);
+ cubeMathUi.snapRotationToWall(wallId);
}
function facePoint(quad, u, v) {
- const weight0 = ((1 - u) * (1 - v)) / 4;
- const weight1 = ((1 + u) * (1 - v)) / 4;
- const weight2 = ((1 + u) * (1 + v)) / 4;
- const weight3 = ((1 - u) * (1 + v)) / 4;
-
- return {
- x: quad[0].x * weight0 + quad[1].x * weight1 + quad[2].x * weight2 + quad[3].x * weight3,
- y: quad[0].y * weight0 + quad[1].y * weight1 + quad[2].y * weight2 + quad[3].y * weight3
- };
+ return cubeMathUi.facePoint(quad, u, v);
}
function projectVerticesForRotation(rotationX, rotationY) {
- const yaw = (rotationY * Math.PI) / 180;
- const pitch = (rotationX * Math.PI) / 180;
-
- const cosY = Math.cos(yaw);
- const sinY = Math.sin(yaw);
- const cosX = Math.cos(pitch);
- const sinX = Math.sin(pitch);
-
- const centerX = CUBE_VIEW_CENTER.x;
- const centerY = CUBE_VIEW_CENTER.y;
- const scale = 54;
- const camera = 4.6;
-
- return CUBE_VERTICES.map(([x, y, z]) => {
- const x1 = x * cosY + z * sinY;
- const z1 = -x * sinY + z * cosY;
-
- const y2 = y * cosX - z1 * sinX;
- const z2 = y * sinX + z1 * cosX;
-
- const perspective = camera / (camera - z2);
-
- return {
- x: centerX + x1 * scale * perspective,
- y: centerY + y2 * scale * perspective,
- z: z2
- };
- });
+ return cubeMathUi.projectVerticesForRotation(rotationX, rotationY);
}
function projectVertices() {
- return projectVerticesForRotation(state.rotationX, state.rotationY);
+ return cubeMathUi.projectVertices();
}
function getEdgeGeometryById(edgeId) {
- const canonicalId = normalizeEdgeId(edgeId);
- const geometryIndex = EDGE_GEOMETRY_KEYS.indexOf(canonicalId);
- if (geometryIndex < 0) {
- return null;
- }
- return EDGE_GEOMETRY[geometryIndex] || null;
+ return cubeMathUi.getEdgeGeometryById(edgeId);
}
function getWallEdgeDirections(wallOrWallId) {
- const wallId = normalizeId(typeof wallOrWallId === "string" ? wallOrWallId : wallOrWallId?.id);
- const faceIndices = FACE_GEOMETRY[wallId];
- if (!Array.isArray(faceIndices) || faceIndices.length !== 4) {
- return new Map();
- }
-
- const frontRotation = WALL_FRONT_ROTATIONS[wallId] || {
- x: state.rotationX,
- y: state.rotationY
- };
- const projectedVertices = projectVerticesForRotation(frontRotation.x, frontRotation.y);
- const quad = faceIndices.map((index) => projectedVertices[index]);
- const center = facePoint(quad, 0, 0);
- const directionsByEdgeId = new Map();
-
- getEdgesForWall(wallId).forEach((edge) => {
- const geometry = getEdgeGeometryById(edge?.id);
- if (!geometry) {
- return;
- }
-
- const [fromIndex, toIndex] = geometry;
- const from = projectedVertices[fromIndex];
- const to = projectedVertices[toIndex];
- if (!from || !to) {
- return;
- }
-
- const midpointX = (from.x + to.x) / 2;
- const midpointY = (from.y + to.y) / 2;
- const dx = midpointX - center.x;
- const dy = midpointY - center.y;
-
- const directionByPosition = Math.abs(dx) >= Math.abs(dy)
- ? (dx >= 0 ? "east" : "west")
- : (dy >= 0 ? "south" : "north");
- const direction = LOCAL_DIRECTION_VIEW_MAP[directionByPosition] || directionByPosition;
-
- directionsByEdgeId.set(normalizeEdgeId(edge?.id), direction);
- });
-
- return directionsByEdgeId;
+ return cubeMathUi.getWallEdgeDirections(wallOrWallId);
}
function getEdgeDirectionForWall(wallId, edgeId) {
- const wallKey = normalizeId(wallId);
- const edgeKey = normalizeEdgeId(edgeId);
- if (!wallKey || !edgeKey) {
- return "";
- }
-
- const directions = getWallEdgeDirections(wallKey);
- return directions.get(edgeKey) || "";
+ return cubeMathUi.getEdgeDirectionForWall(wallId, edgeId);
}
function getEdgeDirectionLabelForWall(wallId, edgeId) {
- return formatDirectionName(getEdgeDirectionForWall(wallId, edgeId));
+ return cubeMathUi.getEdgeDirectionLabelForWall(wallId, edgeId);
}
function bindRotationControls(elements) {
@@ -444,94 +350,15 @@
}
function getHebrewLetterSymbol(hebrewLetterId) {
- const id = normalizeLetterKey(hebrewLetterId);
- if (!id || !state.hebrewLetters) {
- return "";
- }
-
- const entry = state.hebrewLetters[id];
- if (!entry || typeof entry !== "object") {
- return "";
- }
-
- const symbol = String(
- entry?.letter?.he || entry?.he || entry?.glyph || entry?.symbol || ""
- ).trim();
-
- return symbol;
+ return cubeMathUi.getHebrewLetterSymbol(hebrewLetterId);
}
function getHebrewLetterName(hebrewLetterId) {
- const id = normalizeLetterKey(hebrewLetterId);
- if (!id || !state.hebrewLetters) {
- return "";
- }
-
- const entry = state.hebrewLetters[id];
- if (!entry || typeof entry !== "object") {
- return "";
- }
-
- const name = String(entry?.letter?.name || entry?.name || "").trim();
- return name;
+ return cubeMathUi.getHebrewLetterName(hebrewLetterId);
}
function getAstrologySymbol(type, name) {
- const normalizedType = normalizeId(type);
- const normalizedName = normalizeId(name);
-
- const planetSymbols = {
- mercury: "☿︎",
- venus: "♀︎",
- mars: "♂︎",
- jupiter: "♃︎",
- saturn: "♄︎",
- sol: "☉︎",
- sun: "☉︎",
- luna: "☾︎",
- moon: "☾︎",
- earth: "⊕",
- uranus: "♅︎",
- neptune: "♆︎",
- pluto: "♇︎"
- };
-
- const zodiacSymbols = {
- aries: "♈︎",
- taurus: "♉︎",
- gemini: "♊︎",
- cancer: "♋︎",
- leo: "♌︎",
- virgo: "♍︎",
- libra: "♎︎",
- scorpio: "♏︎",
- sagittarius: "♐︎",
- capricorn: "♑︎",
- aquarius: "♒︎",
- pisces: "♓︎"
- };
-
- const elementSymbols = {
- fire: "🜂",
- water: "🜄",
- air: "🜁",
- earth: "🜃",
- spirit: "🜀"
- };
-
- if (normalizedType === "planet") {
- return planetSymbols[normalizedName] || "";
- }
-
- if (normalizedType === "zodiac") {
- return zodiacSymbols[normalizedName] || "";
- }
-
- if (normalizedType === "element") {
- return elementSymbols[normalizedName] || "";
- }
-
- return "";
+ return cubeMathUi.getAstrologySymbol(type, name);
}
function getEdgeLetterId(edge) {
@@ -556,16 +383,11 @@
}
function getCenterLetterId(center = null) {
- const entry = center || getCubeCenterData();
- return normalizeLetterKey(entry?.hebrewLetterId || entry?.associations?.hebrewLetterId || entry?.letter);
+ return cubeMathUi.getCenterLetterId(center);
}
function getCenterLetterSymbol(center = null) {
- const centerLetterId = getCenterLetterId(center);
- if (!centerLetterId) {
- return "";
- }
- return getHebrewLetterSymbol(centerLetterId);
+ return cubeMathUi.getCenterLetterSymbol(center);
}
function getConnectorById(connectorId) {
@@ -591,42 +413,35 @@
return state.kabbalahPathsByLetterId.get(hebrewLetterId) || null;
}
+ const cubeMathUi = cubeMathHelpers.createCubeMathHelpers({
+ state,
+ CUBE_VERTICES,
+ FACE_GEOMETRY,
+ EDGE_GEOMETRY,
+ EDGE_GEOMETRY_KEYS,
+ CUBE_VIEW_CENTER,
+ WALL_FRONT_ROTATIONS,
+ LOCAL_DIRECTION_VIEW_MAP,
+ normalizeId,
+ normalizeLetterKey,
+ normalizeEdgeId,
+ formatDirectionName,
+ getEdgesForWall,
+ getEdgePathEntry,
+ getEdgeLetterId,
+ getCubeCenterData
+ });
+
function getEdgeAstrologySymbol(edge) {
- const pathEntry = getEdgePathEntry(edge);
- const astrology = pathEntry?.astrology || {};
- return getAstrologySymbol(astrology.type, astrology.name);
+ return cubeMathUi.getEdgeAstrologySymbol(edge);
}
function getEdgeMarkerDisplay(edge) {
- const letter = getEdgeLetter(edge);
- const astro = getEdgeAstrologySymbol(edge);
-
- if (state.markerDisplayMode === "letter") {
- return letter
- ? { text: letter, isMissing: false }
- : { text: "!", isMissing: true };
- }
-
- if (state.markerDisplayMode === "astro") {
- return astro
- ? { text: astro, isMissing: false }
- : { text: "!", isMissing: true };
- }
-
- if (letter && astro) {
- return { text: `${letter} ${astro}`, isMissing: false };
- }
-
- return { text: "!", isMissing: true };
+ return cubeMathUi.getEdgeMarkerDisplay(edge);
}
function getEdgeLetter(edge) {
- const hebrewLetterId = getEdgeLetterId(edge);
- if (!hebrewLetterId) {
- return "";
- }
-
- return getHebrewLetterSymbol(hebrewLetterId);
+ return cubeMathUi.getEdgeLetter(edge);
}
function getWallTarotCard(wall) {
@@ -700,513 +515,47 @@
}
function renderFaceSvg(containerEl, walls) {
- if (!containerEl) {
+ if (typeof cubeChassisUi.renderFaceSvg !== "function") {
+ if (containerEl) {
+ containerEl.replaceChildren();
+ }
return;
}
- const svgNS = "http://www.w3.org/2000/svg";
- const svg = document.createElementNS(svgNS, "svg");
- svg.setAttribute("viewBox", "0 0 240 220");
- svg.setAttribute("width", "100%");
- svg.setAttribute("class", "cube-svg");
- svg.setAttribute("role", "img");
- svg.setAttribute("aria-label", "Cube of Space interactive chassis");
-
- const wallById = new Map(walls.map((wall) => [normalizeId(wall?.id), wall]));
- const projectedVertices = projectVertices();
- const faces = Object.entries(FACE_GEOMETRY)
- .map(([wallId, indices]) => {
- const wall = wallById.get(wallId);
- if (!wall) {
- return null;
- }
-
- const quad = indices.map((index) => projectedVertices[index]);
- const avgDepth = quad.reduce((sum, point) => sum + point.z, 0) / quad.length;
-
- return {
- wallId,
- wall,
- quad,
- depth: avgDepth,
- pointsText: quad.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`).join(" ")
- };
- })
- .filter(Boolean)
- .sort((left, right) => left.depth - right.depth);
-
- faces.forEach((faceData) => {
- const { wallId, wall, quad, pointsText } = faceData;
-
- const isActive = wallId === normalizeId(state.selectedWallId);
- const polygon = document.createElementNS(svgNS, "polygon");
- polygon.setAttribute("points", pointsText);
- polygon.setAttribute("class", `cube-face${isActive ? " is-active" : ""}`);
- polygon.setAttribute("fill", "#000");
- polygon.setAttribute("fill-opacity", isActive ? "0.78" : "0.62");
- polygon.setAttribute("stroke", "currentColor");
- polygon.setAttribute("stroke-opacity", isActive ? "0.92" : "0.68");
- polygon.setAttribute("stroke-width", isActive ? "2.5" : "1");
- polygon.setAttribute("data-wall-id", wallId);
- polygon.setAttribute("role", "button");
- polygon.setAttribute("tabindex", "0");
- polygon.setAttribute("aria-label", `Cube wall ${wall?.name || wallId}`);
-
- const selectWall = () => {
- state.selectedWallId = wallId;
- state.selectedEdgeId = normalizeEdgeId(getEdgesForWall(wallId)[0]?.id || getEdges()[0]?.id);
- state.selectedNodeType = "wall";
- state.selectedConnectorId = null;
- snapRotationToWall(wallId);
- render(getElements());
- };
-
- polygon.addEventListener("click", selectWall);
- polygon.addEventListener("keydown", (event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- selectWall();
- }
- });
-
- svg.appendChild(polygon);
-
- const wallFaceLetter = getWallFaceLetter(wall);
- const faceGlyphAnchor = facePoint(quad, 0, 0);
-
- if (state.markerDisplayMode === "tarot") {
- const cardUrl = resolveCardImageUrl(getWallTarotCard(wall));
- if (cardUrl) {
- let defs = svg.querySelector("defs");
- if (!defs) {
- defs = document.createElementNS(svgNS, "defs");
- svg.insertBefore(defs, svg.firstChild);
- }
- const clipId = `face-clip-${wallId}`;
- const clipPath = document.createElementNS(svgNS, "clipPath");
- clipPath.setAttribute("id", clipId);
- const clipPoly = document.createElementNS(svgNS, "polygon");
- clipPoly.setAttribute("points", pointsText);
- clipPath.appendChild(clipPoly);
- 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)));
- cardImg.setAttribute("width", String(cardW));
- cardImg.setAttribute("height", String(cardH));
- cardImg.setAttribute("clip-path", `url(#${clipId})`);
- cardImg.setAttribute("role", "button");
- cardImg.setAttribute("tabindex", "0");
- cardImg.setAttribute("aria-label", `Open ${wallTarotCard || (wall?.name || wallId)} card image`);
- cardImg.setAttribute("preserveAspectRatio", "xMidYMid meet");
- 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);
- }
- } else {
- const faceGlyph = document.createElementNS(svgNS, "text");
- faceGlyph.setAttribute(
- "class",
- `cube-face-symbol${isActive ? " is-active" : ""}${wallFaceLetter ? "" : " is-missing"}`
- );
- faceGlyph.setAttribute("x", String(faceGlyphAnchor.x));
- faceGlyph.setAttribute("y", String(faceGlyphAnchor.y));
- faceGlyph.setAttribute("text-anchor", "middle");
- faceGlyph.setAttribute("dominant-baseline", "middle");
- faceGlyph.setAttribute("pointer-events", "none");
- faceGlyph.textContent = wallFaceLetter || "!";
- svg.appendChild(faceGlyph);
- }
-
- const labelAnchor = facePoint(quad, 0, 0.9);
- const label = document.createElementNS(svgNS, "text");
- label.setAttribute("class", `cube-face-label${isActive ? " is-active" : ""}`);
- label.setAttribute("x", String(labelAnchor.x));
- label.setAttribute("y", String(labelAnchor.y));
- label.setAttribute("text-anchor", "middle");
- label.setAttribute("dominant-baseline", "middle");
- label.setAttribute("pointer-events", "none");
- label.textContent = wall?.name || wallId;
- svg.appendChild(label);
+ cubeChassisUi.renderFaceSvg({
+ state,
+ containerEl,
+ walls,
+ normalizeId,
+ projectVertices,
+ FACE_GEOMETRY,
+ facePoint,
+ normalizeEdgeId,
+ getEdges,
+ getEdgesForWall,
+ EDGE_GEOMETRY,
+ EDGE_GEOMETRY_KEYS,
+ formatEdgeName,
+ getEdgeWalls,
+ getElements,
+ render,
+ snapRotationToWall,
+ getWallFaceLetter,
+ getWallTarotCard,
+ resolveCardImageUrl,
+ openTarotCardLightbox,
+ MOTHER_CONNECTORS,
+ formatDirectionName,
+ getConnectorTarotCard,
+ getHebrewLetterSymbol,
+ toDisplayText,
+ CUBE_VIEW_CENTER,
+ getEdgeMarkerDisplay,
+ getEdgeTarotCard,
+ getCubeCenterData,
+ getCenterTarotCard,
+ getCenterLetterSymbol
});
-
- const faceCenterByWallId = new Map(
- faces.map((faceData) => [faceData.wallId, facePoint(faceData.quad, 0, 0)])
- );
-
- if (state.showConnectorLines) {
- MOTHER_CONNECTORS.forEach((connector, connectorIndex) => {
- const fromWallId = normalizeId(connector?.fromWallId);
- const toWallId = normalizeId(connector?.toWallId);
- const from = faceCenterByWallId.get(fromWallId);
- const to = faceCenterByWallId.get(toWallId);
- if (!from || !to) {
- return;
- }
-
- const connectorId = normalizeId(connector?.id);
- const isActive = state.selectedNodeType === "connector"
- && normalizeId(state.selectedConnectorId) === connectorId;
- const connectorLetter = getHebrewLetterSymbol(connector?.hebrewLetterId);
- const connectorCardUrl = state.markerDisplayMode === "tarot"
- ? resolveCardImageUrl(getConnectorTarotCard(connector))
- : null;
-
- const group = document.createElementNS(svgNS, "g");
- group.setAttribute("class", `cube-connector${isActive ? " is-active" : ""}`);
- group.setAttribute("role", "button");
- group.setAttribute("tabindex", "0");
- group.setAttribute(
- "aria-label",
- `Mother connector ${formatDirectionName(fromWallId)} to ${formatDirectionName(toWallId)}`
- );
-
- const connectorLine = document.createElementNS(svgNS, "line");
- connectorLine.setAttribute("class", `cube-connector-line${isActive ? " is-active" : ""}`);
- connectorLine.setAttribute("x1", from.x.toFixed(2));
- connectorLine.setAttribute("y1", from.y.toFixed(2));
- connectorLine.setAttribute("x2", to.x.toFixed(2));
- connectorLine.setAttribute("y2", to.y.toFixed(2));
- group.appendChild(connectorLine);
-
- const connectorHit = document.createElementNS(svgNS, "line");
- connectorHit.setAttribute("class", "cube-connector-hit");
- connectorHit.setAttribute("x1", from.x.toFixed(2));
- connectorHit.setAttribute("y1", from.y.toFixed(2));
- connectorHit.setAttribute("x2", to.x.toFixed(2));
- connectorHit.setAttribute("y2", to.y.toFixed(2));
- group.appendChild(connectorHit);
-
- const dx = to.x - from.x;
- const dy = to.y - from.y;
- const length = Math.hypot(dx, dy) || 1;
- const perpX = -dy / length;
- const perpY = dx / length;
- const shift = (connectorIndex - 1) * 12;
- 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");
- connectorText.setAttribute(
- "class",
- `cube-connector-symbol${isActive ? " is-active" : ""}${connectorLetter ? "" : " is-missing"}`
- );
- connectorText.setAttribute("x", String(labelX));
- connectorText.setAttribute("y", String(labelY));
- connectorText.setAttribute("text-anchor", "middle");
- connectorText.setAttribute("dominant-baseline", "middle");
- connectorText.setAttribute("pointer-events", "none");
- connectorText.textContent = connectorLetter || "!";
- group.appendChild(connectorText);
- }
-
- group.addEventListener("click", selectConnector);
- group.addEventListener("keydown", (event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- selectConnector();
- }
- });
-
- svg.appendChild(group);
- });
- }
-
- const edgeById = new Map(
- getEdges().map((edge) => [normalizeEdgeId(edge?.id), edge])
- );
-
- EDGE_GEOMETRY.forEach(([fromIndex, toIndex], edgeIndex) => {
- const edgeId = EDGE_GEOMETRY_KEYS[edgeIndex];
- const edge = edgeById.get(edgeId) || {
- id: edgeId,
- name: formatEdgeName(edgeId),
- walls: edgeId.split("-")
- };
- const markerDisplay = getEdgeMarkerDisplay(edge);
- const edgeWalls = getEdgeWalls(edge);
-
- const wallIsActive = edgeWalls.includes(normalizeId(state.selectedWallId));
- const edgeIsActive = normalizeEdgeId(state.selectedEdgeId) === edgeId;
-
- const from = projectedVertices[fromIndex];
- const to = projectedVertices[toIndex];
-
- const line = document.createElementNS(svgNS, "line");
- line.setAttribute("x1", from.x.toFixed(2));
- line.setAttribute("y1", from.y.toFixed(2));
- line.setAttribute("x2", to.x.toFixed(2));
- line.setAttribute("y2", to.y.toFixed(2));
- line.setAttribute("stroke", "currentColor");
- line.setAttribute("stroke-opacity", edgeIsActive ? "0.94" : (wallIsActive ? "0.70" : "0.32"));
- line.setAttribute("stroke-width", edgeIsActive ? "2.4" : (wallIsActive ? "1.9" : "1.4"));
- line.setAttribute("class", `cube-edge-line${edgeIsActive ? " is-active" : ""}`);
- line.setAttribute("role", "button");
- line.setAttribute("tabindex", "0");
- line.setAttribute("aria-label", `Cube edge ${toDisplayText(edge?.name) || formatEdgeName(edgeId)}`);
-
- const selectEdge = () => {
- state.selectedEdgeId = edgeId;
- state.selectedNodeType = "wall";
- state.selectedConnectorId = null;
- if (!edgeWalls.includes(normalizeId(state.selectedWallId)) && edgeWalls[0]) {
- state.selectedWallId = edgeWalls[0];
- snapRotationToWall(state.selectedWallId);
- }
- render(getElements());
- };
-
- line.addEventListener("click", selectEdge);
- line.addEventListener("keydown", (event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- selectEdge();
- }
- });
- svg.appendChild(line);
-
- const dx = to.x - from.x;
- const dy = to.y - from.y;
- const length = Math.hypot(dx, dy) || 1;
- const normalX = -dy / length;
- const normalY = dx / length;
- const midpointX = (from.x + to.x) / 2;
- const midpointY = (from.y + to.y) / 2;
-
- const centerVectorX = midpointX - CUBE_VIEW_CENTER.x;
- const centerVectorY = midpointY - CUBE_VIEW_CENTER.y;
- const normalSign = (centerVectorX * normalX + centerVectorY * normalY) >= 0 ? 1 : -1;
-
- const markerOffset = edgeIsActive ? 17 : (wallIsActive ? 13 : 12);
- const labelX = midpointX + (normalX * markerOffset * normalSign);
- const labelY = midpointY + (normalY * markerOffset * normalSign);
-
- const marker = document.createElementNS(svgNS, "g");
- marker.setAttribute(
- "class",
- `cube-direction${wallIsActive ? " is-wall-active" : ""}${edgeIsActive ? " is-active" : ""}`
- );
- marker.setAttribute("role", "button");
- marker.setAttribute("tabindex", "0");
- marker.setAttribute("aria-label", `Cube edge ${toDisplayText(edge?.name) || formatEdgeName(edgeId)}`);
-
- if (state.markerDisplayMode === "tarot") {
- const edgeCardUrl = resolveCardImageUrl(getEdgeTarotCard(edge));
- 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-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");
- markerText.setAttribute("class", "cube-direction-letter is-missing");
- markerText.setAttribute("x", String(labelX));
- markerText.setAttribute("y", String(labelY));
- markerText.setAttribute("text-anchor", "middle");
- markerText.setAttribute("dominant-baseline", "middle");
- markerText.textContent = "!";
- marker.appendChild(markerText);
- }
- } else {
- const markerText = document.createElementNS(svgNS, "text");
- markerText.setAttribute(
- "class",
- `cube-direction-letter${markerDisplay.isMissing ? " is-missing" : ""}`
- );
- markerText.setAttribute("x", String(labelX));
- markerText.setAttribute("y", String(labelY));
- markerText.setAttribute("text-anchor", "middle");
- markerText.setAttribute("dominant-baseline", "middle");
- markerText.textContent = markerDisplay.text;
- marker.appendChild(markerText);
- }
-
- marker.addEventListener("click", selectEdge);
- marker.addEventListener("keydown", (event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- selectEdge();
- }
- });
-
- svg.appendChild(marker);
- });
-
- const center = getCubeCenterData();
- if (center && state.showPrimalPoint) {
- const centerLetter = getCenterLetterSymbol(center);
- const centerCardUrl = state.markerDisplayMode === "tarot"
- ? resolveCardImageUrl(getCenterTarotCard(center))
- : null;
- const centerActive = state.selectedNodeType === "center";
-
- const centerMarker = document.createElementNS(svgNS, "g");
- centerMarker.setAttribute("class", `cube-center${centerActive ? " is-active" : ""}`);
- centerMarker.setAttribute("role", "button");
- centerMarker.setAttribute("tabindex", "0");
- centerMarker.setAttribute("aria-label", "Cube primal point");
-
- const centerHit = document.createElementNS(svgNS, "circle");
- centerHit.setAttribute("class", "cube-center-hit");
- centerHit.setAttribute("cx", String(CUBE_VIEW_CENTER.x));
- centerHit.setAttribute("cy", String(CUBE_VIEW_CENTER.y));
- centerHit.setAttribute("r", "18");
- centerMarker.appendChild(centerHit);
-
- 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");
- centerText.setAttribute(
- "class",
- `cube-center-symbol${centerActive ? " is-active" : ""}${centerLetter ? "" : " is-missing"}`
- );
- centerText.setAttribute("x", String(CUBE_VIEW_CENTER.x));
- centerText.setAttribute("y", String(CUBE_VIEW_CENTER.y));
- centerText.setAttribute("text-anchor", "middle");
- centerText.setAttribute("dominant-baseline", "middle");
- centerText.setAttribute("pointer-events", "none");
- centerText.textContent = centerLetter || "!";
- centerMarker.appendChild(centerText);
- }
-
- const selectCenter = () => {
- state.selectedNodeType = "center";
- state.selectedConnectorId = null;
- render(getElements());
- };
-
- centerMarker.addEventListener("click", selectCenter);
- centerMarker.addEventListener("keydown", (event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- selectCenter();
- }
- });
-
- svg.appendChild(centerMarker);
- }
-
- if (state.markerDisplayMode === "tarot") {
- Array.from(svg.querySelectorAll("g.cube-direction")).forEach((group) => {
- svg.appendChild(group);
- });
-
- if (state.showConnectorLines) {
- Array.from(svg.querySelectorAll("g.cube-connector")).forEach((group) => {
- svg.appendChild(group);
- });
- }
- }
-
- containerEl.replaceChildren(svg);
}
function selectEdgeById(edgeId, preferredWallId = "") {
diff --git a/app/ui-gods-references.js b/app/ui-gods-references.js
new file mode 100644
index 0000000..1ecbefd
--- /dev/null
+++ b/app/ui-gods-references.js
@@ -0,0 +1,225 @@
+/* ui-gods-references.js — Month reference builders for the gods section */
+(() => {
+ "use strict";
+
+ function buildMonthReferencesByGod(referenceData) {
+ 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]));
+
+ 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(godId, month, options = {}) {
+ if (!godId || !month?.id) return;
+ const key = String(godId).trim().toLowerCase();
+ if (!key) return;
+
+ if (!map.has(key)) {
+ map.set(key, []);
+ }
+
+ const rows = map.get(key);
+ 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
+ });
+ }
+
+ months.forEach((month) => {
+ pushRef(month?.associations?.godId, month);
+
+ const events = Array.isArray(month?.events) ? month.events : [];
+ events.forEach((event) => {
+ pushRef(event?.associations?.godId, month, {
+ rawDateText: event?.dateRange || event?.date || ""
+ });
+ });
+ });
+
+ holidays.forEach((holiday) => {
+ const month = monthById.get(holiday?.monthId);
+ if (month) {
+ pushRef(holiday?.associations?.godId, 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;
+ }
+
+ window.GodReferenceBuilders = {
+ buildMonthReferencesByGod
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-gods.js b/app/ui-gods.js
index ad68bee..99cb26d 100644
--- a/app/ui-gods.js
+++ b/app/ui-gods.js
@@ -3,6 +3,14 @@
* Kabbalah paths are shown only as a reference at the bottom of each detail view.
*/
(() => {
+ "use strict";
+
+ const godReferenceBuilders = window.GodReferenceBuilders || {};
+
+ if (typeof godReferenceBuilders.buildMonthReferencesByGod !== "function") {
+ throw new Error("GodReferenceBuilders module must load before ui-gods.js");
+ }
+
// ── State ──────────────────────────────────────────────────────────────────
const state = {
initialized: false,
@@ -61,223 +69,6 @@
return null;
}
- function buildMonthReferencesByGod(referenceData) {
- 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]));
-
- 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(godId, month, options = {}) {
- if (!godId || !month?.id) return;
- const key = String(godId).trim().toLowerCase();
- if (!key) return;
-
- if (!map.has(key)) {
- map.set(key, []);
- }
-
- const rows = map.get(key);
- 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
- });
- }
-
- months.forEach((month) => {
- pushRef(month?.associations?.godId, month);
-
- const events = Array.isArray(month?.events) ? month.events : [];
- events.forEach((event) => {
- pushRef(event?.associations?.godId, month, {
- rawDateText: event?.dateRange || event?.date || ""
- });
- });
- });
-
- holidays.forEach((holiday) => {
- const month = monthById.get(holiday?.monthId);
- if (month) {
- pushRef(holiday?.associations?.godId, 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;
- }
-
// ── Filter ─────────────────────────────────────────────────────────────────
function applyFilter() {
const q = state.searchQuery.toLowerCase();
@@ -557,7 +348,7 @@
// ── Init ───────────────────────────────────────────────────────────────────
function ensureGodsSection(magickDataset, referenceData = null) {
if (referenceData) {
- state.monthRefsByGodId = buildMonthReferencesByGod(referenceData);
+ state.monthRefsByGodId = godReferenceBuilders.buildMonthReferencesByGod(referenceData);
}
if (state.initialized) {
diff --git a/app/ui-holidays-data.js b/app/ui-holidays-data.js
new file mode 100644
index 0000000..2af758b
--- /dev/null
+++ b/app/ui-holidays-data.js
@@ -0,0 +1,472 @@
+/* ui-holidays-data.js - Holiday data and date resolution helpers */
+(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
+ };
+
+ 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 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 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, selectedYear) {
+ const key = String(rule || "").trim().toLowerCase();
+ if (!key) {
+ return null;
+ }
+
+ if (key === "gregorian-easter-sunday") {
+ return computeWesternEasterDate(selectedYear);
+ }
+
+ if (key === "gregorian-good-friday") {
+ const easter = computeWesternEasterDate(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") {
+ return computeNthWeekdayOfMonth(selectedYear, 10, 4, 4);
+ }
+
+ 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 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, calendarData) {
+ const month = (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, calendarData) {
+ const monthOrder = getIslamicMonthOrderById(monthId, calendarData);
+ 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, options = {}) {
+ if (!holiday || typeof holiday !== "object") {
+ return null;
+ }
+
+ const selectedYear = Number(options.selectedYear);
+ const calendarData = options.calendarData || {};
+ 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, 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, calendarData);
+ }
+
+ 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 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 buildPlanetMap(planetsObj) {
+ const map = new Map();
+ if (!planetsObj || typeof planetsObj !== "object") {
+ return map;
+ }
+
+ Object.values(planetsObj).forEach((planet) => {
+ if (!planet?.id) {
+ return;
+ }
+ map.set(planet.id, planet);
+ });
+
+ return map;
+ }
+
+ function buildSignsMap(signs) {
+ const map = new Map();
+ if (!Array.isArray(signs)) {
+ return map;
+ }
+
+ signs.forEach((sign) => {
+ if (!sign?.id) {
+ return;
+ }
+ map.set(sign.id, sign);
+ });
+
+ return map;
+ }
+
+ 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;
+ }
+ map.set(god.id, god);
+ });
+
+ return map;
+ }
+
+ function buildHebrewMap(magickDataset) {
+ const map = new Map();
+ const letters = magickDataset?.grouped?.alphabets?.hebrew;
+ if (!Array.isArray(letters)) {
+ return map;
+ }
+
+ letters.forEach((letter) => {
+ if (!letter?.hebrewLetterId) {
+ return;
+ }
+ map.set(letter.hebrewLetterId, letter);
+ });
+
+ return map;
+ }
+
+ function buildCalendarData(referenceData) {
+ return {
+ gregorian: Array.isArray(referenceData?.calendarMonths) ? referenceData.calendarMonths : [],
+ hebrew: Array.isArray(referenceData?.hebrewCalendar?.months) ? referenceData.hebrewCalendar.months : [],
+ islamic: Array.isArray(referenceData?.islamicCalendar?.months) ? referenceData.islamicCalendar.months : [],
+ "wheel-of-year": Array.isArray(referenceData?.wheelOfYear?.months) ? referenceData.wheelOfYear.months : []
+ };
+ }
+
+ function calendarLabel(calendarId) {
+ const key = String(calendarId || "").trim().toLowerCase();
+ if (key === "hebrew") return "Hebrew";
+ if (key === "islamic") return "Islamic";
+ if (key === "wheel-of-year") return "Wheel of the Year";
+ return "Gregorian";
+ }
+
+ function monthLabelForCalendar(calendarData, calendarId, monthId) {
+ const months = calendarData?.[calendarId];
+ if (!Array.isArray(months)) {
+ return monthId || "--";
+ }
+
+ const month = months.find((entry) => String(entry?.id || "").toLowerCase() === String(monthId || "").toLowerCase());
+ return month?.name || monthId || "--";
+ }
+
+ function normalizeSourceFilter(value) {
+ const key = String(value || "").trim().toLowerCase();
+ if (key === "gregorian" || key === "hebrew" || key === "islamic" || key === "wheel-of-year") {
+ return key;
+ }
+ return "all";
+ }
+
+ function buildAllHolidays(referenceData) {
+ if (Array.isArray(referenceData?.calendarHolidays) && referenceData.calendarHolidays.length) {
+ return [...referenceData.calendarHolidays].sort((left, right) => {
+ const calCmp = calendarLabel(left?.calendarId).localeCompare(calendarLabel(right?.calendarId));
+ if (calCmp !== 0) return calCmp;
+
+ const leftDay = Number(left?.day);
+ const rightDay = Number(right?.day);
+ if (Number.isFinite(leftDay) && Number.isFinite(rightDay) && leftDay !== rightDay) {
+ return leftDay - rightDay;
+ }
+
+ return String(left?.name || "").localeCompare(String(right?.name || ""));
+ });
+ }
+
+ const legacy = Array.isArray(referenceData?.celestialHolidays) ? referenceData.celestialHolidays : [];
+ return legacy.map((holiday) => ({
+ ...holiday,
+ calendarId: "gregorian",
+ dateText: holiday?.date || holiday?.dateRange || ""
+ }));
+ }
+
+ window.HolidayDataUi = {
+ buildAllHolidays,
+ buildCalendarData,
+ buildGodsMap,
+ buildHebrewMap,
+ buildPlanetMap,
+ buildSignsMap,
+ calendarLabel,
+ formatCalendarDateFromGregorian,
+ formatGregorianReferenceDate,
+ monthLabelForCalendar,
+ normalizeSourceFilter,
+ resolveHolidayGregorianDate
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-holidays-render.js b/app/ui-holidays-render.js
new file mode 100644
index 0000000..549490c
--- /dev/null
+++ b/app/ui-holidays-render.js
@@ -0,0 +1,311 @@
+/* ui-holidays-render.js - Render/search helpers for the holiday repository */
+(function () {
+ "use strict";
+
+ function planetLabel(planetId, context) {
+ const { state, cap } = context;
+ 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, context) {
+ const { state, cap } = context;
+ 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, context) {
+ const { state, cap } = context;
+ if (godName) {
+ return godName;
+ }
+
+ if (!godId) {
+ return "Deity";
+ }
+
+ const god = state.godsById.get(godId);
+ return god?.name || cap(godId);
+ }
+
+ function hebrewLabel(hebrewLetterId, context) {
+ const { state, cap } = context;
+ 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, context) {
+ const { getDisplayTarotName, resolveTarotTrumpNumber } = context;
+ 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, context);
+ 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, context) {
+ const { getTarotCardSearchAliases } = context;
+ if (!associations || typeof associations !== "object") {
+ return "";
+ }
+
+ const tarotAliases = associations.tarotCard && typeof getTarotCardSearchAliases === "function"
+ ? getTarotCardSearchAliases(associations.tarotCard, { trumpNumber: associations.tarotTrumpNumber })
+ : [];
+
+ return [
+ associations.planetId,
+ associations.zodiacSignId,
+ associations.numberValue,
+ associations.tarotCard,
+ associations.tarotTrumpNumber,
+ ...tarotAliases,
+ associations.godId,
+ associations.godName,
+ associations.hebrewLetterId,
+ associations.kabbalahPathNumber,
+ associations.iChingPlanetaryInfluence
+ ].filter(Boolean).join(" ");
+ }
+
+ function holidaySearchText(holiday, context) {
+ const { normalizeSearchValue } = context;
+ return normalizeSearchValue([
+ holiday?.name,
+ holiday?.kind,
+ holiday?.date,
+ holiday?.dateRange,
+ holiday?.dateText,
+ holiday?.monthDayStart,
+ holiday?.calendarId,
+ holiday?.description,
+ associationSearchText(holiday?.associations, context)
+ ].filter(Boolean).join(" "));
+ }
+
+ function renderList(context) {
+ const {
+ elements,
+ state,
+ filterBySource,
+ normalizeSourceFilter,
+ calendarLabel,
+ monthLabelForCalendar,
+ selectByHolidayId
+ } = context;
+ const { listEl, countEl } = elements;
+ if (!listEl) {
+ return;
+ }
+
+ listEl.innerHTML = "";
+
+ state.filteredHolidays.forEach((holiday) => {
+ const isSelected = holiday.id === state.selectedHolidayId;
+ const itemEl = document.createElement("div");
+ itemEl.className = `planet-list-item${isSelected ? " is-selected" : ""}`;
+ itemEl.setAttribute("role", "option");
+ itemEl.setAttribute("aria-selected", isSelected ? "true" : "false");
+ itemEl.dataset.holidayId = holiday.id;
+
+ const sourceCalendar = calendarLabel(holiday.calendarId);
+ const sourceMonth = monthLabelForCalendar(holiday.calendarId, holiday.monthId);
+ const sourceDate = holiday?.dateText || holiday?.date || holiday?.dateRange || "--";
+
+ itemEl.innerHTML = `
+ ${holiday?.name || holiday?.id || "Holiday"}
+ ${sourceCalendar} - ${sourceMonth} - ${sourceDate}
+ `;
+
+ itemEl.addEventListener("click", () => {
+ selectByHolidayId(holiday.id, elements);
+ });
+
+ listEl.appendChild(itemEl);
+ });
+
+ if (countEl) {
+ const sourceFiltered = filterBySource(state.holidays);
+ const activeFilter = normalizeSourceFilter(state.selectedSource);
+ const sourceLabel = activeFilter === "all"
+ ? ""
+ : ` (${calendarLabel(activeFilter)})`;
+ countEl.textContent = state.searchQuery
+ ? `${state.filteredHolidays.length} of ${sourceFiltered.length} holidays${sourceLabel}`
+ : `${sourceFiltered.length} holidays${sourceLabel}`;
+ }
+ }
+
+ function renderHolidayDetail(holiday, context) {
+ const {
+ state,
+ calendarLabel,
+ monthLabelForCalendar,
+ resolveHolidayGregorianDate,
+ formatGregorianReferenceDate,
+ formatCalendarDateFromGregorian
+ } = context;
+ const gregorianDate = resolveHolidayGregorianDate(holiday);
+ const gregorianRef = formatGregorianReferenceDate(gregorianDate);
+ const hebrewRef = formatCalendarDateFromGregorian(gregorianDate, "hebrew");
+ const islamicRef = formatCalendarDateFromGregorian(gregorianDate, "islamic");
+ const confidence = String(holiday?.conversionConfidence || holiday?.datePrecision || "approximate").toLowerCase();
+ const confidenceLabel = (!(gregorianDate instanceof Date) || Number.isNaN(gregorianDate.getTime()))
+ ? "unresolved"
+ : (confidence === "exact" ? "exact" : "approximate");
+ const monthName = monthLabelForCalendar(holiday?.calendarId, holiday?.monthId);
+ const holidayDate = holiday?.dateText || holiday?.date || holiday?.dateRange || "--";
+ const sourceMonthLink = holiday?.monthId
+ ? ``
+ : "";
+
+ return `
+
+ `;
+ }
+
+ window.HolidayRenderUi = {
+ holidaySearchText,
+ renderList,
+ renderHolidayDetail
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-holidays.js b/app/ui-holidays.js
index 6a5c999..ac7173a 100644
--- a/app/ui-holidays.js
+++ b/app/ui-holidays.js
@@ -3,6 +3,28 @@
"use strict";
const { getTarotCardDisplayName, getTarotCardSearchAliases } = window.TarotCardImages || {};
+ const holidayDataUi = window.HolidayDataUi || {};
+ const holidayRenderUi = window.HolidayRenderUi || {};
+
+ if (
+ typeof holidayDataUi.buildAllHolidays !== "function"
+ || typeof holidayDataUi.buildCalendarData !== "function"
+ || typeof holidayDataUi.buildGodsMap !== "function"
+ || typeof holidayDataUi.buildHebrewMap !== "function"
+ || typeof holidayDataUi.buildPlanetMap !== "function"
+ || typeof holidayDataUi.buildSignsMap !== "function"
+ || typeof holidayDataUi.calendarLabel !== "function"
+ || typeof holidayDataUi.formatCalendarDateFromGregorian !== "function"
+ || typeof holidayDataUi.formatGregorianReferenceDate !== "function"
+ || typeof holidayDataUi.monthLabelForCalendar !== "function"
+ || typeof holidayDataUi.normalizeSourceFilter !== "function"
+ || typeof holidayDataUi.resolveHolidayGregorianDate !== "function"
+ || typeof holidayRenderUi.holidaySearchText !== "function"
+ || typeof holidayRenderUi.renderList !== "function"
+ || typeof holidayRenderUi.renderHolidayDetail !== "function"
+ ) {
+ throw new Error("HolidayDataUi and HolidayRenderUi modules must load before ui-holidays.js");
+ }
const state = {
initialized: false,
@@ -69,52 +91,6 @@
"the world": 21
};
- 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 {
sourceSelectEl: document.getElementById("holiday-source-select"),
@@ -180,583 +156,27 @@
return getTarotCardDisplayName(cardName) || cardName;
}
- 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 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 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 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 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 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 buildPlanetMap(planetsObj) {
- const map = new Map();
- if (!planetsObj || typeof planetsObj !== "object") {
- return map;
- }
-
- Object.values(planetsObj).forEach((planet) => {
- if (!planet?.id) {
- return;
- }
- map.set(planet.id, planet);
- });
-
- return map;
- }
-
- function buildSignsMap(signs) {
- const map = new Map();
- if (!Array.isArray(signs)) {
- return map;
- }
-
- signs.forEach((sign) => {
- if (!sign?.id) {
- return;
- }
- map.set(sign.id, sign);
- });
-
- return map;
- }
-
- 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;
- }
- map.set(god.id, god);
- });
-
- return map;
- }
-
- function buildHebrewMap(magickDataset) {
- const map = new Map();
- const letters = magickDataset?.grouped?.alphabets?.hebrew;
- if (!Array.isArray(letters)) {
- return map;
- }
-
- letters.forEach((letter) => {
- if (!letter?.hebrewLetterId) {
- return;
- }
- map.set(letter.hebrewLetterId, letter);
- });
-
- return map;
- }
-
- function calendarLabel(calendarId) {
- const key = String(calendarId || "").trim().toLowerCase();
- if (key === "hebrew") return "Hebrew";
- if (key === "islamic") return "Islamic";
- if (key === "wheel-of-year") return "Wheel of the Year";
- return "Gregorian";
- }
-
- function monthLabelForCalendar(calendarId, monthId) {
- const months = state.calendarData?.[calendarId];
- if (!Array.isArray(months)) {
- return monthId || "--";
- }
-
- const month = months.find((entry) => String(entry?.id || "").toLowerCase() === String(monthId || "").toLowerCase());
- return month?.name || monthId || "--";
- }
-
- function normalizeSourceFilter(value) {
- const key = String(value || "").trim().toLowerCase();
- if (key === "gregorian" || key === "hebrew" || key === "islamic" || key === "wheel-of-year") {
- return key;
- }
- return "all";
- }
-
- function buildAllHolidays() {
- if (Array.isArray(state.referenceData?.calendarHolidays) && state.referenceData.calendarHolidays.length) {
- return [...state.referenceData.calendarHolidays].sort((left, right) => {
- const calCmp = calendarLabel(left?.calendarId).localeCompare(calendarLabel(right?.calendarId));
- if (calCmp !== 0) return calCmp;
-
- const leftDay = Number(left?.day);
- const rightDay = Number(right?.day);
- if (Number.isFinite(leftDay) && Number.isFinite(rightDay) && leftDay !== rightDay) {
- return leftDay - rightDay;
- }
-
- return String(left?.name || "").localeCompare(String(right?.name || ""));
- });
- }
-
- const legacy = Array.isArray(state.referenceData?.celestialHolidays) ? state.referenceData.celestialHolidays : [];
- return legacy.map((holiday) => ({
- ...holiday,
- calendarId: "gregorian",
- dateText: holiday?.date || holiday?.dateRange || ""
- }));
- }
-
- 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 "";
- }
-
- const tarotAliases = associations.tarotCard && typeof getTarotCardSearchAliases === "function"
- ? getTarotCardSearchAliases(associations.tarotCard, { trumpNumber: associations.tarotTrumpNumber })
- : [];
-
- return [
- associations.planetId,
- associations.zodiacSignId,
- associations.numberValue,
- associations.tarotCard,
- associations.tarotTrumpNumber,
- ...tarotAliases,
- associations.godId,
- associations.godName,
- associations.hebrewLetterId,
- associations.kabbalahPathNumber,
- associations.iChingPlanetaryInfluence
- ].filter(Boolean).join(" ");
- }
-
- function holidaySearchText(holiday) {
- return normalizeSearchValue([
- holiday?.name,
- holiday?.kind,
- holiday?.date,
- holiday?.dateRange,
- holiday?.dateText,
- holiday?.monthDayStart,
- holiday?.calendarId,
- holiday?.description,
- associationSearchText(holiday?.associations)
- ].filter(Boolean).join(" "));
+ function getRenderContext(elements = getElements()) {
+ return {
+ elements,
+ state,
+ cap,
+ normalizeSearchValue,
+ getDisplayTarotName,
+ resolveTarotTrumpNumber,
+ getTarotCardSearchAliases,
+ calendarLabel: holidayDataUi.calendarLabel,
+ monthLabelForCalendar: (calendarId, monthId) => holidayDataUi.monthLabelForCalendar(state.calendarData, calendarId, monthId),
+ normalizeSourceFilter: holidayDataUi.normalizeSourceFilter,
+ filterBySource,
+ resolveHolidayGregorianDate: (holiday) => holidayDataUi.resolveHolidayGregorianDate(holiday, {
+ selectedYear: state.selectedYear,
+ calendarData: state.calendarData
+ }),
+ formatGregorianReferenceDate: holidayDataUi.formatGregorianReferenceDate,
+ formatCalendarDateFromGregorian: holidayDataUi.formatCalendarDateFromGregorian,
+ selectByHolidayId
+ };
}
function getSelectedHoliday() {
@@ -764,7 +184,7 @@
}
function filterBySource(holidays) {
- const source = normalizeSourceFilter(state.selectedSource);
+ const source = holidayDataUi.normalizeSourceFilter(state.selectedSource);
if (source === "all") {
return [...holidays];
}
@@ -773,7 +193,7 @@
function syncControls(elements) {
if (elements.sourceSelectEl) {
- elements.sourceSelectEl.value = normalizeSourceFilter(state.selectedSource);
+ elements.sourceSelectEl.value = holidayDataUi.normalizeSourceFilter(state.selectedSource);
}
if (elements.yearInputEl) {
elements.yearInputEl.value = String(state.selectedYear);
@@ -787,99 +207,11 @@
}
function renderList(elements) {
- const { listEl, countEl } = elements;
- if (!listEl) {
- return;
- }
-
- listEl.innerHTML = "";
-
- state.filteredHolidays.forEach((holiday) => {
- const isSelected = holiday.id === state.selectedHolidayId;
- const itemEl = document.createElement("div");
- itemEl.className = `planet-list-item${isSelected ? " is-selected" : ""}`;
- itemEl.setAttribute("role", "option");
- itemEl.setAttribute("aria-selected", isSelected ? "true" : "false");
- itemEl.dataset.holidayId = holiday.id;
-
- const sourceCalendar = calendarLabel(holiday.calendarId);
- const sourceMonth = monthLabelForCalendar(holiday.calendarId, holiday.monthId);
- const sourceDate = holiday?.dateText || holiday?.date || holiday?.dateRange || "--";
-
- itemEl.innerHTML = `
- ${holiday?.name || holiday?.id || "Holiday"}
- ${sourceCalendar} - ${sourceMonth} - ${sourceDate}
- `;
-
- itemEl.addEventListener("click", () => {
- selectByHolidayId(holiday.id, elements);
- });
-
- listEl.appendChild(itemEl);
- });
-
- if (countEl) {
- const sourceFiltered = filterBySource(state.holidays);
- const activeFilter = normalizeSourceFilter(state.selectedSource);
- const sourceLabel = activeFilter === "all"
- ? ""
- : ` (${calendarLabel(activeFilter)})`;
- countEl.textContent = state.searchQuery
- ? `${state.filteredHolidays.length} of ${sourceFiltered.length} holidays${sourceLabel}`
- : `${sourceFiltered.length} holidays${sourceLabel}`;
- }
+ holidayRenderUi.renderList(getRenderContext(elements));
}
function renderHolidayDetail(holiday) {
- const gregorianDate = resolveHolidayGregorianDate(holiday);
- const gregorianRef = formatGregorianReferenceDate(gregorianDate);
- const hebrewRef = formatCalendarDateFromGregorian(gregorianDate, "hebrew");
- const islamicRef = formatCalendarDateFromGregorian(gregorianDate, "islamic");
- const confidence = String(holiday?.conversionConfidence || holiday?.datePrecision || "approximate").toLowerCase();
- const confidenceLabel = (!(gregorianDate instanceof Date) || Number.isNaN(gregorianDate.getTime()))
- ? "unresolved"
- : (confidence === "exact" ? "exact" : "approximate");
- const monthName = monthLabelForCalendar(holiday?.calendarId, holiday?.monthId);
- const holidayDate = holiday?.dateText || holiday?.date || holiday?.dateRange || "--";
- const sourceMonthLink = holiday?.monthId
- ? ``
- : "";
-
- return `
-
- `;
+ return holidayRenderUi.renderHolidayDetail(holiday, getRenderContext());
}
function renderDetail(elements) {
@@ -897,7 +229,7 @@
}
detailNameEl.textContent = holiday?.name || holiday?.id || "Holiday";
- detailSubEl.textContent = `${calendarLabel(holiday?.calendarId)} - ${monthLabelForCalendar(holiday?.calendarId, holiday?.monthId)}`;
+ detailSubEl.textContent = `${holidayDataUi.calendarLabel(holiday?.calendarId)} - ${holidayDataUi.monthLabelForCalendar(state.calendarData, holiday?.calendarId, holiday?.monthId)}`;
detailBodyEl.innerHTML = renderHolidayDetail(holiday);
attachNavHandlers(detailBodyEl);
}
@@ -905,7 +237,7 @@
function applyFilters(elements) {
const sourceFiltered = filterBySource(state.holidays);
state.filteredHolidays = state.searchQuery
- ? sourceFiltered.filter((holiday) => holidaySearchText(holiday).includes(state.searchQuery))
+ ? sourceFiltered.filter((holiday) => holidayRenderUi.holidaySearchText(holiday, getRenderContext()).includes(state.searchQuery))
: sourceFiltered;
if (!state.filteredHolidays.some((holiday) => holiday.id === state.selectedHolidayId)) {
@@ -924,12 +256,12 @@
}
const targetCalendar = String(target.calendarId || "").trim().toLowerCase();
- const activeFilter = normalizeSourceFilter(state.selectedSource);
+ const activeFilter = holidayDataUi.normalizeSourceFilter(state.selectedSource);
if (activeFilter !== "all" && activeFilter !== targetCalendar) {
state.selectedSource = targetCalendar || "all";
}
- if (state.searchQuery && !holidaySearchText(target).includes(state.searchQuery)) {
+ if (state.searchQuery && !holidayRenderUi.holidaySearchText(target, getRenderContext()).includes(state.searchQuery)) {
state.searchQuery = "";
}
@@ -941,7 +273,7 @@
function bindControls(elements) {
if (elements.sourceSelectEl) {
elements.sourceSelectEl.addEventListener("change", () => {
- state.selectedSource = normalizeSourceFilter(elements.sourceSelectEl.value);
+ state.selectedSource = holidayDataUi.normalizeSourceFilter(elements.sourceSelectEl.value);
applyFilters(elements);
});
}
@@ -1073,19 +405,13 @@
state.referenceData = referenceData;
state.magickDataset = magickDataset || null;
- state.planetsById = buildPlanetMap(referenceData.planets);
- state.signsById = buildSignsMap(referenceData.signs);
- state.godsById = buildGodsMap(state.magickDataset);
- state.hebrewById = buildHebrewMap(state.magickDataset);
+ state.planetsById = holidayDataUi.buildPlanetMap(referenceData.planets);
+ state.signsById = holidayDataUi.buildSignsMap(referenceData.signs);
+ state.godsById = holidayDataUi.buildGodsMap(state.magickDataset);
+ state.hebrewById = holidayDataUi.buildHebrewMap(state.magickDataset);
- state.calendarData = {
- gregorian: Array.isArray(referenceData.calendarMonths) ? referenceData.calendarMonths : [],
- hebrew: Array.isArray(referenceData.hebrewCalendar?.months) ? referenceData.hebrewCalendar.months : [],
- islamic: Array.isArray(referenceData.islamicCalendar?.months) ? referenceData.islamicCalendar.months : [],
- "wheel-of-year": Array.isArray(referenceData.wheelOfYear?.months) ? referenceData.wheelOfYear.months : []
- };
-
- state.holidays = buildAllHolidays();
+ state.calendarData = holidayDataUi.buildCalendarData(referenceData);
+ state.holidays = holidayDataUi.buildAllHolidays(state.referenceData);
if (!state.selectedHolidayId || !state.holidays.some((holiday) => holiday.id === state.selectedHolidayId)) {
state.selectedHolidayId = state.holidays[0]?.id || null;
}
diff --git a/app/ui-iching-references.js b/app/ui-iching-references.js
new file mode 100644
index 0000000..adad7a2
--- /dev/null
+++ b/app/ui-iching-references.js
@@ -0,0 +1,253 @@
+/* ui-iching-references.js — Month reference builders for the I Ching section */
+(function () {
+ "use strict";
+
+ function buildMonthReferencesByHexagram(context) {
+ const {
+ referenceData,
+ hexagrams,
+ normalizePlanetInfluence,
+ resolveAssociationPlanetInfluence
+ } = context || {};
+
+ if (
+ typeof normalizePlanetInfluence !== "function"
+ || typeof resolveAssociationPlanetInfluence !== "function"
+ ) {
+ return new Map();
+ }
+
+ 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]));
+
+ 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(hexagramNumber, month, options = {}) {
+ if (!Number.isFinite(hexagramNumber) || !month?.id) {
+ return;
+ }
+
+ if (!map.has(hexagramNumber)) {
+ map.set(hexagramNumber, []);
+ }
+
+ const rows = map.get(hexagramNumber);
+ 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 = {}) {
+ const associationInfluence = resolveAssociationPlanetInfluence(associations);
+ if (!associationInfluence) {
+ return;
+ }
+
+ (hexagrams || []).forEach((hexagram) => {
+ const hexagramInfluence = normalizePlanetInfluence(hexagram?.planetaryInfluence);
+ if (hexagramInfluence && hexagramInfluence === associationInfluence) {
+ pushRef(hexagram.number, 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;
+ }
+
+ window.IChingReferenceBuilders = {
+ buildMonthReferencesByHexagram
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-iching.js b/app/ui-iching.js
index dc20472..77b95e1 100644
--- a/app/ui-iching.js
+++ b/app/ui-iching.js
@@ -1,4 +1,12 @@
(function () {
+ "use strict";
+
+ const iChingReferenceBuilders = window.IChingReferenceBuilders || {};
+
+ if (typeof iChingReferenceBuilders.buildMonthReferencesByHexagram !== "function") {
+ throw new Error("IChingReferenceBuilders module must load before ui-iching.js");
+ }
+
const { getTarotCardSearchAliases } = window.TarotCardImages || {};
const state = {
@@ -87,237 +95,6 @@
return normalizePlanetInfluence(ICHING_PLANET_BY_PLANET_ID[planetId]);
}
- function buildMonthReferencesByHexagram(referenceData, hexagrams) {
- 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]));
-
- 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(hexagramNumber, month, options = {}) {
- if (!Number.isFinite(hexagramNumber) || !month?.id) {
- return;
- }
-
- if (!map.has(hexagramNumber)) {
- map.set(hexagramNumber, []);
- }
-
- const rows = map.get(hexagramNumber);
- 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 = {}) {
- const associationInfluence = resolveAssociationPlanetInfluence(associations);
- if (!associationInfluence) {
- return;
- }
-
- hexagrams.forEach((hexagram) => {
- const hexagramInfluence = normalizePlanetInfluence(hexagram?.planetaryInfluence);
- if (hexagramInfluence && hexagramInfluence === associationInfluence) {
- pushRef(hexagram.number, 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 getBinaryPattern(value, expectedLength = 0) {
const raw = String(value || "").trim();
if (!raw) {
@@ -723,7 +500,12 @@
const elements = getElements();
if (state.initialized) {
- state.monthRefsByHexagramNumber = buildMonthReferencesByHexagram(referenceData, state.hexagrams);
+ state.monthRefsByHexagramNumber = iChingReferenceBuilders.buildMonthReferencesByHexagram({
+ referenceData,
+ hexagrams: state.hexagrams,
+ normalizePlanetInfluence,
+ resolveAssociationPlanetInfluence
+ });
const selected = state.hexagrams.find((hexagram) => hexagram.number === state.selectedNumber);
if (selected) {
renderDetail(selected, elements);
@@ -780,7 +562,12 @@
.filter((entry) => Number.isFinite(entry.number))
.sort((a, b) => a.number - b.number);
- state.monthRefsByHexagramNumber = buildMonthReferencesByHexagram(referenceData, state.hexagrams);
+ state.monthRefsByHexagramNumber = iChingReferenceBuilders.buildMonthReferencesByHexagram({
+ referenceData,
+ hexagrams: state.hexagrams,
+ normalizePlanetInfluence,
+ resolveAssociationPlanetInfluence
+ });
state.filteredHexagrams = [...state.hexagrams];
renderList(elements);
diff --git a/app/ui-kabbalah-views.js b/app/ui-kabbalah-views.js
new file mode 100644
index 0000000..f8520c9
--- /dev/null
+++ b/app/ui-kabbalah-views.js
@@ -0,0 +1,388 @@
+(function () {
+ "use strict";
+
+ function resolvePathTarotImage(path) {
+ const cardName = String(path?.tarot?.card || "").trim();
+ if (!cardName || typeof window.TarotCardImages?.resolveTarotCardImage !== "function") {
+ return null;
+ }
+
+ 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(context, path) {
+ const glyph = String(path?.hebrewLetter?.char || "").trim();
+ const pathNumber = Number(path?.pathNumber);
+ const parts = [];
+
+ if (context.state.showPathLetters && glyph) {
+ parts.push(glyph);
+ }
+
+ if (context.state.showPathNumbers && Number.isFinite(pathNumber)) {
+ parts.push(String(pathNumber));
+ }
+
+ return parts.join(" ");
+ }
+
+ function svgEl(context, tag, attrs, text) {
+ const el = document.createElementNS(context.NS, tag);
+ for (const [key, value] of Object.entries(attrs || {})) {
+ el.setAttribute(key, String(value));
+ }
+ if (text != null) {
+ el.textContent = text;
+ }
+ return el;
+ }
+
+ function buildTreeSVG(context) {
+ const {
+ tree,
+ state,
+ NODE_POS,
+ SEPH_FILL,
+ DARK_TEXT,
+ DAAT,
+ PATH_LABEL_RADIUS,
+ PATH_LABEL_FONT_SIZE,
+ PATH_TAROT_WIDTH,
+ PATH_TAROT_HEIGHT,
+ PATH_LABEL_OFFSET_WITH_TAROT,
+ PATH_TAROT_OFFSET_WITH_LABEL,
+ PATH_TAROT_OFFSET_NO_LABEL,
+ R
+ } = context;
+ const svg = svgEl(context, "svg", {
+ viewBox: "0 0 240 470",
+ width: "100%",
+ role: "img",
+ "aria-label": "Kabbalah Tree of Life diagram",
+ class: "kab-svg"
+ });
+
+ svg.appendChild(svgEl(context, "rect", {
+ x: 113, y: 30, width: 14, height: 420,
+ rx: 7, fill: "#ffffff07", "pointer-events": "none"
+ }));
+ svg.appendChild(svgEl(context, "rect", {
+ x: 33, y: 88, width: 14, height: 255,
+ rx: 7, fill: "#ff220010", "pointer-events": "none"
+ }));
+ svg.appendChild(svgEl(context, "rect", {
+ x: 193, y: 88, width: 14, height: 255,
+ rx: 7, fill: "#2244ff10", "pointer-events": "none"
+ }));
+
+ [
+ { x: 198, y: 73, text: "Mercy", anchor: "middle" },
+ { x: 120, y: 17, text: "Balance", anchor: "middle" },
+ { x: 42, y: 73, text: "Severity", anchor: "middle" }
+ ].forEach(({ x, y, text, anchor }) => {
+ svg.appendChild(svgEl(context, "text", {
+ x, y, "text-anchor": anchor, "dominant-baseline": "auto",
+ fill: "#42425a", "font-size": "6", "pointer-events": "none"
+ }, text));
+ });
+
+ tree.paths.forEach((path) => {
+ const [x1, y1] = NODE_POS[path.connects.from];
+ const [x2, y2] = NODE_POS[path.connects.to];
+ const mx = (x1 + x2) / 2;
+ const my = (y1 + y2) / 2;
+ const tarotImage = state.showPathTarotCards ? resolvePathTarotImage(path) : null;
+ const hasTarotImage = Boolean(tarotImage);
+ const pathLabel = getPathLabel(context, path);
+ const hasLabel = Boolean(pathLabel);
+ const labelY = hasTarotImage && hasLabel ? my - PATH_LABEL_OFFSET_WITH_TAROT : my;
+
+ svg.appendChild(svgEl(context, "line", {
+ x1, y1, x2, y2,
+ class: "kab-path-line",
+ "data-path": path.pathNumber,
+ stroke: "#3c3c5c",
+ "stroke-width": "1.5",
+ "pointer-events": "none"
+ }));
+
+ svg.appendChild(svgEl(context, "line", {
+ x1, y1, x2, y2,
+ class: "kab-path-hit",
+ "data-path": path.pathNumber,
+ stroke: "transparent",
+ "stroke-width": String(12 * context.PATH_MARKER_SCALE),
+ role: "button",
+ tabindex: "0",
+ "aria-label": `Path ${path.pathNumber}: ${path.hebrewLetter?.transliteration || ""} — ${path.tarot?.card || ""}`,
+ style: "cursor:pointer"
+ }));
+
+ if (hasLabel) {
+ svg.appendChild(svgEl(context, "circle", {
+ cx: mx, cy: labelY, r: PATH_LABEL_RADIUS.toFixed(2),
+ fill: "#0d0d1c", opacity: "0.82",
+ "pointer-events": "none"
+ }));
+
+ svg.appendChild(svgEl(context, "text", {
+ x: mx, y: labelY + 1,
+ "text-anchor": "middle",
+ "dominant-baseline": "middle",
+ class: "kab-path-lbl",
+ "data-path": path.pathNumber,
+ fill: "#a8a8e0",
+ "font-size": PATH_LABEL_FONT_SIZE.toFixed(2),
+ "pointer-events": "none"
+ }, pathLabel));
+ }
+
+ if (hasTarotImage) {
+ const tarotY = hasLabel
+ ? my + PATH_TAROT_OFFSET_WITH_LABEL
+ : my - PATH_TAROT_OFFSET_NO_LABEL;
+ svg.appendChild(svgEl(context, "image", {
+ href: tarotImage,
+ x: (mx - (PATH_TAROT_WIDTH / 2)).toFixed(2),
+ y: tarotY.toFixed(2),
+ width: PATH_TAROT_WIDTH.toFixed(2),
+ height: PATH_TAROT_HEIGHT.toFixed(2),
+ preserveAspectRatio: "xMidYMid meet",
+ class: "kab-path-tarot",
+ "data-path": path.pathNumber,
+ role: "button",
+ tabindex: "0",
+ "aria-label": `Path ${path.pathNumber} Tarot card ${path.tarot?.card || ""}`,
+ style: "cursor:pointer"
+ }));
+ }
+ });
+
+ svg.appendChild(svgEl(context, "circle", {
+ cx: DAAT[0], cy: DAAT[1], r: "9",
+ fill: "none", stroke: "#3c3c5c",
+ "stroke-dasharray": "3 2", "stroke-width": "1",
+ "pointer-events": "none"
+ }));
+ svg.appendChild(svgEl(context, "text", {
+ x: DAAT[0] + 13, y: DAAT[1] + 1,
+ "text-anchor": "start", "dominant-baseline": "middle",
+ fill: "#3c3c5c", "font-size": "6.5", "pointer-events": "none"
+ }, "Da'at"));
+
+ tree.sephiroth.forEach((seph) => {
+ const [cx, cy] = NODE_POS[seph.number];
+ const fill = SEPH_FILL[seph.number] || "#555";
+ const isLeft = cx < 80;
+ const isMid = cx === 120;
+
+ svg.appendChild(svgEl(context, "circle", {
+ cx, cy, r: "16",
+ fill, opacity: "0.12",
+ class: "kab-node-glow",
+ "data-sephira": seph.number,
+ "pointer-events": "none"
+ }));
+
+ svg.appendChild(svgEl(context, "circle", {
+ cx, cy, r: R,
+ fill, stroke: "#00000040", "stroke-width": "1",
+ class: "kab-node",
+ "data-sephira": seph.number,
+ role: "button",
+ tabindex: "0",
+ "aria-label": `Sephira ${seph.number}: ${seph.name}`,
+ style: "cursor:pointer"
+ }));
+
+ svg.appendChild(svgEl(context, "text", {
+ x: cx, y: cy + 0.5,
+ "text-anchor": "middle", "dominant-baseline": "middle",
+ fill: DARK_TEXT.has(seph.number) ? "#111" : "#fff",
+ "font-size": "8", "font-weight": "bold",
+ "pointer-events": "none"
+ }, String(seph.number)));
+
+ const lx = isLeft ? cx - R - 4 : cx + R + 4;
+ svg.appendChild(svgEl(context, "text", {
+ x: isMid ? cx : lx,
+ y: isMid ? cy + R + 8 : cy,
+ "text-anchor": isMid ? "middle" : (isLeft ? "end" : "start"),
+ "dominant-baseline": isMid ? "auto" : "middle",
+ fill: "#c0c0d4",
+ "font-size": "7.5", "pointer-events": "none",
+ class: "kab-node-lbl"
+ }, seph.name));
+ });
+
+ return svg;
+ }
+
+ function bindTreeInteractions(context, svg) {
+ const { tree, elements, renderSephiraDetail, renderPathDetail } = context;
+ svg.addEventListener("click", (event) => {
+ const clickTarget = event.target instanceof Element ? event.target : null;
+ const sephNum = clickTarget?.dataset?.sephira;
+ const pathNum = clickTarget?.dataset?.path;
+
+ if (pathNum != null && clickTarget?.classList?.contains("kab-path-tarot")) {
+ const path = tree.paths.find((entry) => entry.pathNumber === Number(pathNum));
+ if (path) {
+ openTarotLightboxForPath(path, getSvgImageHref(clickTarget));
+ renderPathDetail(path, tree, elements);
+ }
+ return;
+ }
+
+ if (sephNum != null) {
+ const seph = tree.sephiroth.find((entry) => entry.number === Number(sephNum));
+ if (seph) {
+ renderSephiraDetail(seph, tree, elements);
+ }
+ } else if (pathNum != null) {
+ const path = tree.paths.find((entry) => entry.pathNumber === Number(pathNum));
+ if (path) {
+ renderPathDetail(path, tree, elements);
+ }
+ }
+ });
+
+ svg.querySelectorAll(".kab-path-hit, .kab-path-tarot").forEach((element) => {
+ element.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ const path = tree.paths.find((entry) => entry.pathNumber === Number(element.dataset.path));
+ if (path) {
+ if (element.classList.contains("kab-path-tarot")) {
+ openTarotLightboxForPath(path, getSvgImageHref(element));
+ }
+ renderPathDetail(path, tree, elements);
+ }
+ }
+ });
+ });
+
+ svg.querySelectorAll(".kab-node").forEach((element) => {
+ element.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ const seph = tree.sephiroth.find((entry) => entry.number === Number(element.dataset.sephira));
+ if (seph) {
+ renderSephiraDetail(seph, tree, elements);
+ }
+ }
+ });
+ });
+ }
+
+ function bindRoseCrossInteractions(context, svg, roseElements) {
+ const { tree, renderPathDetail } = context;
+ 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 renderRoseCross(context) {
+ const { state, elements, getRoseDetailElements } = context;
+ 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(context, roseSvg, roseElements);
+ }
+
+ function renderTree(context) {
+ const { state, elements } = context;
+ if (!state.tree || !elements?.treeContainerEl) {
+ return;
+ }
+
+ const svg = buildTreeSVG(context);
+ elements.treeContainerEl.innerHTML = "";
+ elements.treeContainerEl.appendChild(svg);
+ bindTreeInteractions(context, svg);
+ }
+
+ window.KabbalahViewsUi = {
+ renderTree,
+ renderRoseCross
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-kabbalah.js b/app/ui-kabbalah.js
index f9fbb90..c6b668d 100644
--- a/app/ui-kabbalah.js
+++ b/app/ui-kabbalah.js
@@ -62,6 +62,14 @@
};
const kabbalahDetailUi = window.KabbalahDetailUi || {};
+ const kabbalahViewsUi = window.KabbalahViewsUi || {};
+
+ if (
+ typeof kabbalahViewsUi.renderTree !== "function"
+ || typeof kabbalahViewsUi.renderRoseCross !== "function"
+ ) {
+ throw new Error("KabbalahViewsUi module must load before ui-kabbalah.js");
+ }
const PLANET_NAME_TO_ID = {
saturn: "saturn",
@@ -270,253 +278,6 @@
};
}
- function resolvePathTarotImage(path) {
- const cardName = String(path?.tarot?.card || "").trim();
- if (!cardName || typeof window.TarotCardImages?.resolveTarotCardImage !== "function") {
- return null;
- }
-
- 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);
- const parts = [];
-
- if (state.showPathLetters && glyph) {
- parts.push(glyph);
- }
-
- if (state.showPathNumbers && Number.isFinite(pathNumber)) {
- parts.push(String(pathNumber));
- }
-
- return parts.join(" ");
- }
-
- // ─── SVG element factory ────────────────────────────────────────────────────
- function svgEl(tag, attrs, text) {
- const el = document.createElementNS(NS, tag);
- for (const [k, v] of Object.entries(attrs || {})) {
- el.setAttribute(k, String(v));
- }
- if (text != null) el.textContent = text;
- 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", {
- viewBox: "0 0 240 470",
- width: "100%",
- role: "img",
- "aria-label": "Kabbalah Tree of Life diagram",
- class: "kab-svg",
- });
-
- // Subtle pillar background tracks
- svg.appendChild(svgEl("rect", {
- x: 113, y: 30, width: 14, height: 420,
- rx: 7, fill: "#ffffff07", "pointer-events": "none",
- }));
- svg.appendChild(svgEl("rect", {
- x: 33, y: 88, width: 14, height: 255,
- rx: 7, fill: "#ff220010", "pointer-events": "none",
- }));
- svg.appendChild(svgEl("rect", {
- x: 193, y: 88, width: 14, height: 255,
- rx: 7, fill: "#2244ff10", "pointer-events": "none",
- }));
-
- // Pillar labels
- [
- { x: 198, y: 73, text: "Mercy", anchor: "middle" },
- { x: 120, y: 17, text: "Balance", anchor: "middle" },
- { x: 42, y: 73, text: "Severity", anchor: "middle" },
- ].forEach(({ x, y, text, anchor }) => {
- svg.appendChild(svgEl("text", {
- x, y, "text-anchor": anchor, "dominant-baseline": "auto",
- fill: "#42425a", "font-size": "6", "pointer-events": "none",
- }, text));
- });
-
- // ── path lines (drawn before sephiroth so nodes sit on top) ──────────────
- tree.paths.forEach(path => {
- const [x1, y1] = NODE_POS[path.connects.from];
- const [x2, y2] = NODE_POS[path.connects.to];
- const mx = (x1 + x2) / 2;
- const my = (y1 + y2) / 2;
- const tarotImage = state.showPathTarotCards ? resolvePathTarotImage(path) : null;
- const hasTarotImage = Boolean(tarotImage);
- const pathLabel = getPathLabel(path);
- const hasLabel = Boolean(pathLabel);
- const labelY = hasTarotImage && hasLabel ? my - PATH_LABEL_OFFSET_WITH_TAROT : my;
-
- // Visual line (thin)
- svg.appendChild(svgEl("line", {
- x1, y1, x2, y2,
- class: "kab-path-line",
- "data-path": path.pathNumber,
- stroke: "#3c3c5c",
- "stroke-width": "1.5",
- "pointer-events": "none",
- }));
-
- // Invisible wide hit area for easy clicking
- svg.appendChild(svgEl("line", {
- x1, y1, x2, y2,
- class: "kab-path-hit",
- "data-path": path.pathNumber,
- stroke: "transparent",
- "stroke-width": String(12 * PATH_MARKER_SCALE),
- role: "button",
- tabindex: "0",
- "aria-label": `Path ${path.pathNumber}: ${path.hebrewLetter?.transliteration || ""} — ${path.tarot?.card || ""}`,
- style: "cursor:pointer",
- }));
-
- if (hasLabel) {
- // Background disc for legibility behind path label
- svg.appendChild(svgEl("circle", {
- cx: mx, cy: labelY, r: PATH_LABEL_RADIUS.toFixed(2),
- fill: "#0d0d1c", opacity: "0.82",
- "pointer-events": "none",
- }));
-
- // Path label at path midpoint
- svg.appendChild(svgEl("text", {
- x: mx, y: labelY + 1,
- "text-anchor": "middle",
- "dominant-baseline": "middle",
- class: "kab-path-lbl",
- "data-path": path.pathNumber,
- fill: "#a8a8e0",
- "font-size": PATH_LABEL_FONT_SIZE.toFixed(2),
- "pointer-events": "none",
- }, pathLabel));
- }
-
- if (hasTarotImage) {
- const tarotY = hasLabel
- ? my + PATH_TAROT_OFFSET_WITH_LABEL
- : my - PATH_TAROT_OFFSET_NO_LABEL;
- svg.appendChild(svgEl("image", {
- href: tarotImage,
- x: (mx - (PATH_TAROT_WIDTH / 2)).toFixed(2),
- y: tarotY.toFixed(2),
- width: PATH_TAROT_WIDTH.toFixed(2),
- height: PATH_TAROT_HEIGHT.toFixed(2),
- preserveAspectRatio: "xMidYMid meet",
- class: "kab-path-tarot",
- "data-path": path.pathNumber,
- role: "button",
- tabindex: "0",
- "aria-label": `Path ${path.pathNumber} Tarot card ${path.tarot?.card || ""}`,
- style: "cursor:pointer"
- }));
- }
- });
-
- // ── Da'at — phantom sephira (dashed, informational only) ────────────────
- svg.appendChild(svgEl("circle", {
- cx: DAAT[0], cy: DAAT[1], r: "9",
- fill: "none", stroke: "#3c3c5c",
- "stroke-dasharray": "3 2", "stroke-width": "1",
- "pointer-events": "none",
- }));
- svg.appendChild(svgEl("text", {
- x: DAAT[0] + 13, y: DAAT[1] + 1,
- "text-anchor": "start", "dominant-baseline": "middle",
- fill: "#3c3c5c", "font-size": "6.5", "pointer-events": "none",
- }, "Da'at"));
-
- // ── sephiroth circles (drawn last, on top of paths) ──────────────────────
- tree.sephiroth.forEach(seph => {
- const [cx, cy] = NODE_POS[seph.number];
- const fill = SEPH_FILL[seph.number] || "#555";
- const isLeft = cx < 80;
- const isMid = cx === 120;
-
- // Glow halo (subtle, pointer-events:none)
- svg.appendChild(svgEl("circle", {
- cx, cy, r: "16",
- fill, opacity: "0.12",
- class: "kab-node-glow",
- "data-sephira": seph.number,
- "pointer-events": "none",
- }));
-
- // Main clickable circle
- svg.appendChild(svgEl("circle", {
- cx, cy, r: R,
- fill, stroke: "#00000040", "stroke-width": "1",
- class: "kab-node",
- "data-sephira": seph.number,
- role: "button",
- tabindex: "0",
- "aria-label": `Sephira ${seph.number}: ${seph.name}`,
- style: "cursor:pointer",
- }));
-
- // Sephira number inside the circle
- svg.appendChild(svgEl("text", {
- x: cx, y: cy + 0.5,
- "text-anchor": "middle", "dominant-baseline": "middle",
- fill: DARK_TEXT.has(seph.number) ? "#111" : "#fff",
- "font-size": "8", "font-weight": "bold",
- "pointer-events": "none",
- }, String(seph.number)));
-
- // Name label beside the circle
- const lx = isLeft ? cx - R - 4 : cx + R + 4;
- svg.appendChild(svgEl("text", {
- x: isMid ? cx : lx,
- y: isMid ? cy + R + 8 : cy,
- "text-anchor": isMid ? "middle" : (isLeft ? "end" : "start"),
- "dominant-baseline": isMid ? "auto" : "middle",
- fill: "#c0c0d4",
- "font-size": "7.5", "pointer-events": "none",
- class: "kab-node-lbl",
- }, seph.name));
- });
-
- return svg;
- }
-
function normalizeText(value) {
return String(value || "").trim().toLowerCase();
}
@@ -654,98 +415,6 @@
}
}
- function bindTreeInteractions(svg, tree, elements) {
- // Delegate clicks via element's own data attributes
- svg.addEventListener("click", e => {
- 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);
- } else if (pathNum != null) {
- const p = tree.paths.find(x => x.pathNumber === Number(pathNum));
- if (p) renderPathDetail(p, tree, elements);
- }
- });
-
- // Keyboard access for path hit-areas and tarot images
- svg.querySelectorAll(".kab-path-hit, .kab-path-tarot").forEach(el => {
- el.addEventListener("keydown", e => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- const p = tree.paths.find(x => x.pathNumber === Number(el.dataset.path));
- if (p) {
- if (el.classList.contains("kab-path-tarot")) {
- openTarotLightboxForPath(p, getSvgImageHref(el));
- }
- renderPathDetail(p, tree, elements);
- }
- }
- });
- });
-
- // Keyboard access for sephira circles
- svg.querySelectorAll(".kab-node").forEach(el => {
- el.addEventListener("keydown", e => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- const s = tree.sephiroth.find(x => x.number === Number(el.dataset.sephira));
- if (s) renderSephiraDetail(s, tree, elements);
- }
- });
- });
- }
-
- 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") {
@@ -753,25 +422,29 @@
}
}
- 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 getViewRenderContext(elements) {
+ return {
+ state,
+ tree: state.tree,
+ elements,
+ getRoseDetailElements,
+ renderSephiraDetail,
+ renderPathDetail,
+ NS,
+ R,
+ NODE_POS,
+ SEPH_FILL,
+ DARK_TEXT,
+ DAAT,
+ PATH_MARKER_SCALE,
+ PATH_LABEL_RADIUS,
+ PATH_LABEL_FONT_SIZE,
+ PATH_TAROT_WIDTH,
+ PATH_TAROT_HEIGHT,
+ PATH_LABEL_OFFSET_WITH_TAROT,
+ PATH_TAROT_OFFSET_WITH_LABEL,
+ PATH_TAROT_OFFSET_NO_LABEL
+ };
}
function renderRoseCurrentSelection(elements) {
@@ -795,15 +468,12 @@
renderRoseLandingIntro(roseElements);
}
- function renderTree(elements) {
- if (!state.tree || !elements?.treeContainerEl) {
- return;
- }
+ function renderRoseCross(elements) {
+ kabbalahViewsUi.renderRoseCross(getViewRenderContext(elements));
+ }
- const svg = buildTreeSVG(state.tree);
- elements.treeContainerEl.innerHTML = "";
- elements.treeContainerEl.appendChild(svg);
- bindTreeInteractions(svg, state.tree, elements);
+ function renderTree(elements) {
+ kabbalahViewsUi.renderTree(getViewRenderContext(elements));
}
function renderCurrentSelection(elements) {
diff --git a/app/ui-now-helpers.js b/app/ui-now-helpers.js
new file mode 100644
index 0000000..6e42e7d
--- /dev/null
+++ b/app/ui-now-helpers.js
@@ -0,0 +1,519 @@
+/* ui-now-helpers.js — Lightbox and astronomy/countdown helpers for the Now panel */
+(function () {
+ "use strict";
+
+ const { DAY_IN_MS, getMoonPhaseName, getDecanForDate } = window.TarotCalc || {};
+ const { resolveTarotCardImage, getTarotCardDisplayName } = window.TarotCardImages || {};
+
+ let nowLightboxOverlayEl = null;
+ let nowLightboxImageEl = null;
+ let nowLightboxZoomed = false;
+
+ const LIGHTBOX_ZOOM_SCALE = 6.66;
+
+ const PLANETARY_BODIES = [
+ { id: "sol", astronomyBody: "Sun", fallbackName: "Sun", fallbackSymbol: "☉︎" },
+ { id: "luna", astronomyBody: "Moon", fallbackName: "Moon", fallbackSymbol: "☾︎" },
+ { id: "mercury", astronomyBody: "Mercury", fallbackName: "Mercury", fallbackSymbol: "☿︎" },
+ { id: "venus", astronomyBody: "Venus", fallbackName: "Venus", fallbackSymbol: "♀︎" },
+ { id: "mars", astronomyBody: "Mars", fallbackName: "Mars", fallbackSymbol: "♂︎" },
+ { id: "jupiter", astronomyBody: "Jupiter", fallbackName: "Jupiter", fallbackSymbol: "♃︎" },
+ { id: "saturn", astronomyBody: "Saturn", fallbackName: "Saturn", fallbackSymbol: "♄︎" },
+ { id: "uranus", astronomyBody: "Uranus", fallbackName: "Uranus", fallbackSymbol: "♅︎" },
+ { id: "neptune", astronomyBody: "Neptune", fallbackName: "Neptune", fallbackSymbol: "♆︎" },
+ { id: "pluto", astronomyBody: "Pluto", fallbackName: "Pluto", fallbackSymbol: "♇︎" }
+ ];
+
+ function resetNowLightboxZoom() {
+ if (!nowLightboxImageEl) {
+ return;
+ }
+
+ nowLightboxZoomed = false;
+ nowLightboxImageEl.style.transform = "scale(1)";
+ nowLightboxImageEl.style.transformOrigin = "center center";
+ nowLightboxImageEl.style.cursor = "zoom-in";
+ }
+
+ function updateNowLightboxZoomOrigin(clientX, clientY) {
+ if (!nowLightboxZoomed || !nowLightboxImageEl) {
+ return;
+ }
+
+ const rect = nowLightboxImageEl.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));
+ nowLightboxImageEl.style.transformOrigin = `${x}% ${y}%`;
+ }
+
+ function isNowLightboxPointOnCard(clientX, clientY) {
+ if (!nowLightboxImageEl) {
+ return false;
+ }
+
+ const rect = nowLightboxImageEl.getBoundingClientRect();
+ const naturalWidth = nowLightboxImageEl.naturalWidth;
+ const naturalHeight = nowLightboxImageEl.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 ensureNowImageLightbox() {
+ if (nowLightboxOverlayEl && nowLightboxImageEl) {
+ return;
+ }
+
+ nowLightboxOverlayEl = document.createElement("div");
+ nowLightboxOverlayEl.setAttribute("aria-hidden", "true");
+ nowLightboxOverlayEl.style.position = "fixed";
+ nowLightboxOverlayEl.style.inset = "0";
+ nowLightboxOverlayEl.style.background = "rgba(0, 0, 0, 0.82)";
+ nowLightboxOverlayEl.style.display = "none";
+ nowLightboxOverlayEl.style.alignItems = "center";
+ nowLightboxOverlayEl.style.justifyContent = "center";
+ nowLightboxOverlayEl.style.zIndex = "9999";
+ nowLightboxOverlayEl.style.padding = "0";
+
+ const image = document.createElement("img");
+ image.alt = "Now 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";
+
+ nowLightboxImageEl = image;
+ nowLightboxOverlayEl.appendChild(image);
+
+ const closeLightbox = () => {
+ if (!nowLightboxOverlayEl || !nowLightboxImageEl) {
+ return;
+ }
+ nowLightboxOverlayEl.style.display = "none";
+ nowLightboxOverlayEl.setAttribute("aria-hidden", "true");
+ nowLightboxImageEl.removeAttribute("src");
+ resetNowLightboxZoom();
+ };
+
+ nowLightboxOverlayEl.addEventListener("click", (event) => {
+ if (event.target === nowLightboxOverlayEl) {
+ closeLightbox();
+ }
+ });
+
+ nowLightboxImageEl.addEventListener("click", (event) => {
+ event.stopPropagation();
+ if (!isNowLightboxPointOnCard(event.clientX, event.clientY)) {
+ closeLightbox();
+ return;
+ }
+ if (!nowLightboxZoomed) {
+ nowLightboxZoomed = true;
+ nowLightboxImageEl.style.transform = `scale(${LIGHTBOX_ZOOM_SCALE})`;
+ nowLightboxImageEl.style.cursor = "zoom-out";
+ updateNowLightboxZoomOrigin(event.clientX, event.clientY);
+ return;
+ }
+ resetNowLightboxZoom();
+ });
+
+ nowLightboxImageEl.addEventListener("mousemove", (event) => {
+ updateNowLightboxZoomOrigin(event.clientX, event.clientY);
+ });
+
+ nowLightboxImageEl.addEventListener("mouseleave", () => {
+ if (nowLightboxZoomed) {
+ nowLightboxImageEl.style.transformOrigin = "center center";
+ }
+ });
+
+ document.addEventListener("keydown", (event) => {
+ if (event.key === "Escape") {
+ closeLightbox();
+ }
+ });
+
+ document.body.appendChild(nowLightboxOverlayEl);
+ }
+
+ function openNowImageLightbox(src, altText) {
+ if (!src) {
+ return;
+ }
+
+ ensureNowImageLightbox();
+ if (!nowLightboxOverlayEl || !nowLightboxImageEl) {
+ return;
+ }
+
+ nowLightboxImageEl.src = src;
+ nowLightboxImageEl.alt = altText || "Now card enlarged image";
+ resetNowLightboxZoom();
+ nowLightboxOverlayEl.style.display = "flex";
+ nowLightboxOverlayEl.setAttribute("aria-hidden", "false");
+ }
+
+ function getDisplayTarotName(cardName, trumpNumber) {
+ if (!cardName) {
+ return "";
+ }
+ if (typeof getTarotCardDisplayName !== "function") {
+ return cardName;
+ }
+ if (Number.isFinite(Number(trumpNumber))) {
+ return getTarotCardDisplayName(cardName, { trumpNumber: Number(trumpNumber) }) || cardName;
+ }
+ return getTarotCardDisplayName(cardName) || cardName;
+ }
+
+ function bindNowCardLightbox(imageEl) {
+ if (!(imageEl instanceof HTMLImageElement) || imageEl.dataset.lightboxBound === "true") {
+ return;
+ }
+
+ imageEl.dataset.lightboxBound = "true";
+ imageEl.style.cursor = "zoom-in";
+ imageEl.title = "Click to enlarge";
+ imageEl.addEventListener("click", () => {
+ const src = imageEl.getAttribute("src");
+ if (!src || imageEl.style.display === "none") {
+ return;
+ }
+ openNowImageLightbox(src, imageEl.alt || "Now card enlarged image");
+ });
+ }
+
+ function normalizeLongitude(value) {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) {
+ return null;
+ }
+
+ return ((numeric % 360) + 360) % 360;
+ }
+
+ function getSortedSigns(signs) {
+ if (!Array.isArray(signs)) {
+ return [];
+ }
+
+ return [...signs].sort((a, b) => (a.order || 0) - (b.order || 0));
+ }
+
+ function getSignForLongitude(longitude, signs) {
+ const normalized = normalizeLongitude(longitude);
+ if (normalized === null) {
+ return null;
+ }
+
+ const sortedSigns = getSortedSigns(signs);
+ if (!sortedSigns.length) {
+ return null;
+ }
+
+ const signIndex = Math.min(sortedSigns.length - 1, Math.floor(normalized / 30));
+ const sign = sortedSigns[signIndex] || null;
+ if (!sign) {
+ return null;
+ }
+
+ return {
+ sign,
+ degreeInSign: normalized - signIndex * 30,
+ absoluteLongitude: normalized
+ };
+ }
+
+ function getSabianSymbolForLongitude(longitude, sabianSymbols) {
+ const normalized = normalizeLongitude(longitude);
+ if (normalized === null || !Array.isArray(sabianSymbols) || !sabianSymbols.length) {
+ return null;
+ }
+
+ const absoluteDegree = Math.floor(normalized) + 1;
+ return sabianSymbols.find((entry) => Number(entry?.absoluteDegree) === absoluteDegree) || null;
+ }
+
+ function calculatePlanetPositions(referenceData, now) {
+ if (!window.Astronomy || !referenceData) {
+ return [];
+ }
+
+ const positions = [];
+
+ PLANETARY_BODIES.forEach((body) => {
+ try {
+ const geoVector = window.Astronomy.GeoVector(body.astronomyBody, now, true);
+ const ecliptic = window.Astronomy.Ecliptic(geoVector);
+ const signInfo = getSignForLongitude(ecliptic?.elon, referenceData.signs);
+ if (!signInfo?.sign) {
+ return;
+ }
+
+ const planetInfo = referenceData.planets?.[body.id] || null;
+ const symbol = planetInfo?.symbol || body.fallbackSymbol;
+ const name = planetInfo?.name || body.fallbackName;
+
+ positions.push({
+ id: body.id,
+ symbol,
+ name,
+ longitude: signInfo.absoluteLongitude,
+ sign: signInfo.sign,
+ degreeInSign: signInfo.degreeInSign,
+ label: `${symbol} ${name}: ${signInfo.sign.symbol} ${signInfo.sign.name} ${signInfo.degreeInSign.toFixed(1)}°`
+ });
+ } catch {
+ }
+ });
+
+ return positions;
+ }
+
+ function updateNowStats(referenceData, elements, now) {
+ const planetPositions = calculatePlanetPositions(referenceData, now);
+
+ if (elements.nowStatsPlanetsEl) {
+ elements.nowStatsPlanetsEl.replaceChildren();
+
+ if (!planetPositions.length) {
+ elements.nowStatsPlanetsEl.textContent = "--";
+ } else {
+ planetPositions.forEach((position) => {
+ const item = document.createElement("div");
+ item.className = "now-stats-planet";
+ item.textContent = position.label;
+ elements.nowStatsPlanetsEl.appendChild(item);
+ });
+ }
+ }
+
+ if (elements.nowStatsSabianEl) {
+ const sunPosition = planetPositions.find((entry) => entry.id === "sol") || null;
+ const moonPosition = planetPositions.find((entry) => entry.id === "luna") || null;
+ const sunSabianSymbol = sunPosition
+ ? getSabianSymbolForLongitude(sunPosition.longitude, referenceData.sabianSymbols)
+ : null;
+ const moonSabianSymbol = moonPosition
+ ? getSabianSymbolForLongitude(moonPosition.longitude, referenceData.sabianSymbols)
+ : null;
+
+ const sunLine = sunSabianSymbol?.phrase
+ ? `Sun Sabian ${sunSabianSymbol.absoluteDegree}: ${sunSabianSymbol.phrase}`
+ : "Sun Sabian: --";
+ const moonLine = moonSabianSymbol?.phrase
+ ? `Moon Sabian ${moonSabianSymbol.absoluteDegree}: ${moonSabianSymbol.phrase}`
+ : "Moon Sabian: --";
+
+ elements.nowStatsSabianEl.textContent = `${sunLine}\n${moonLine}`;
+ }
+ }
+
+ function formatCountdown(ms, mode) {
+ if (!Number.isFinite(ms) || ms <= 0) {
+ if (mode === "hours") {
+ return "0.0 hours";
+ }
+ if (mode === "seconds") {
+ return "0s";
+ }
+ return "0m";
+ }
+
+ if (mode === "hours") {
+ return `${(ms / 3600000).toFixed(1)} hours`;
+ }
+
+ if (mode === "seconds") {
+ return `${Math.floor(ms / 1000)}s`;
+ }
+
+ return `${Math.floor(ms / 60000)}m`;
+ }
+
+ function parseMonthDay(monthDay) {
+ const [month, day] = String(monthDay || "").split("-").map(Number);
+ return { month, day };
+ }
+
+ function getCurrentPhaseName(date) {
+ return getMoonPhaseName(window.SunCalc.getMoonIllumination(date).phase);
+ }
+
+ function findNextMoonPhaseTransition(now) {
+ const currentPhase = getCurrentPhaseName(now);
+ const stepMs = 15 * 60 * 1000;
+ const maxMs = 40 * DAY_IN_MS;
+
+ let previousTime = now.getTime();
+ let previousPhase = currentPhase;
+
+ for (let t = previousTime + stepMs; t <= previousTime + maxMs; t += stepMs) {
+ const phaseName = getCurrentPhaseName(new Date(t));
+ if (phaseName !== previousPhase) {
+ let low = previousTime;
+ let high = t;
+ while (high - low > 1000) {
+ const mid = Math.floor((low + high) / 2);
+ const midPhase = getCurrentPhaseName(new Date(mid));
+ if (midPhase === currentPhase) {
+ low = mid;
+ } else {
+ high = mid;
+ }
+ }
+
+ const transitionAt = new Date(high);
+ const nextPhase = getCurrentPhaseName(new Date(high + 1000));
+ return {
+ fromPhase: currentPhase,
+ nextPhase,
+ changeAt: transitionAt
+ };
+ }
+ previousTime = t;
+ previousPhase = phaseName;
+ }
+
+ return null;
+ }
+
+ function getSignStartDate(now, sign) {
+ const { month: startMonth, day: startDay } = parseMonthDay(sign.start);
+ const { month: endMonth } = parseMonthDay(sign.end);
+ const wrapsYear = startMonth > endMonth;
+
+ let year = now.getFullYear();
+ const nowMonth = now.getMonth() + 1;
+ const nowDay = now.getDate();
+
+ if (wrapsYear && (nowMonth < startMonth || (nowMonth === startMonth && nowDay < startDay))) {
+ year -= 1;
+ }
+
+ return new Date(year, startMonth - 1, startDay);
+ }
+
+ function getNextSign(signs, currentSign) {
+ const sorted = [...signs].sort((a, b) => (a.order || 0) - (b.order || 0));
+ const index = sorted.findIndex((entry) => entry.id === currentSign.id);
+ if (index < 0) {
+ return null;
+ }
+ return sorted[(index + 1) % sorted.length] || null;
+ }
+
+ function getDecanByIndex(decansBySign, signId, index) {
+ const signDecans = decansBySign[signId] || [];
+ return signDecans.find((entry) => entry.index === index) || null;
+ }
+
+ function findNextDecanTransition(now, signs, decansBySign) {
+ const currentInfo = getDecanForDate(now, signs, decansBySign);
+ if (!currentInfo?.sign) {
+ return null;
+ }
+
+ const currentIndex = currentInfo.decan?.index || 1;
+ const signStart = getSignStartDate(now, currentInfo.sign);
+
+ if (currentIndex < 3) {
+ const changeAt = new Date(signStart.getTime() + currentIndex * 10 * DAY_IN_MS);
+ const nextDecan = getDecanByIndex(decansBySign, currentInfo.sign.id, currentIndex + 1);
+ const nextLabel = nextDecan?.tarotMinorArcana || `${currentInfo.sign.name} Decan ${currentIndex + 1}`;
+
+ return {
+ key: `${currentInfo.sign.id}-${currentIndex}`,
+ changeAt,
+ nextLabel
+ };
+ }
+
+ const nextSign = getNextSign(signs, currentInfo.sign);
+ if (!nextSign) {
+ return null;
+ }
+
+ const { month: nextMonth, day: nextDay } = parseMonthDay(nextSign.start);
+ let year = now.getFullYear();
+ let changeAt = new Date(year, nextMonth - 1, nextDay);
+ if (changeAt.getTime() <= now.getTime()) {
+ changeAt = new Date(year + 1, nextMonth - 1, nextDay);
+ }
+
+ const nextDecan = getDecanByIndex(decansBySign, nextSign.id, 1);
+
+ return {
+ key: `${currentInfo.sign.id}-${currentIndex}`,
+ changeAt,
+ nextLabel: nextDecan?.tarotMinorArcana || `${nextSign.name} Decan 1`
+ };
+ }
+
+ function setNowCardImage(imageEl, cardName, fallbackLabel, trumpNumber) {
+ if (!imageEl) {
+ return;
+ }
+
+ bindNowCardLightbox(imageEl);
+
+ if (!cardName || typeof resolveTarotCardImage !== "function") {
+ imageEl.style.display = "none";
+ imageEl.removeAttribute("src");
+ return;
+ }
+
+ const src = resolveTarotCardImage(cardName);
+ if (!src) {
+ imageEl.style.display = "none";
+ imageEl.removeAttribute("src");
+ return;
+ }
+
+ imageEl.src = src;
+ const displayName = getDisplayTarotName(cardName, trumpNumber);
+ imageEl.alt = `${fallbackLabel}: ${displayName}`;
+ imageEl.style.display = "block";
+ }
+
+ window.NowUiHelpers = {
+ findNextDecanTransition,
+ findNextMoonPhaseTransition,
+ formatCountdown,
+ getDisplayTarotName,
+ setNowCardImage,
+ updateNowStats
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-now.js b/app/ui-now.js
index 0b47dd5..7070bff 100644
--- a/app/ui-now.js
+++ b/app/ui-now.js
@@ -1,517 +1,27 @@
(function () {
+ "use strict";
+
const {
DAY_IN_MS,
getDateKey,
getMoonPhaseName,
- getDecanForDate,
calcPlanetaryHoursForDayAndLocation
} = window.TarotCalc;
- const { resolveTarotCardImage, getTarotCardDisplayName } = window.TarotCardImages || {};
+ const nowUiHelpers = window.NowUiHelpers || {};
+
+ if (
+ typeof nowUiHelpers.findNextDecanTransition !== "function"
+ || typeof nowUiHelpers.findNextMoonPhaseTransition !== "function"
+ || typeof nowUiHelpers.formatCountdown !== "function"
+ || typeof nowUiHelpers.getDisplayTarotName !== "function"
+ || typeof nowUiHelpers.setNowCardImage !== "function"
+ || typeof nowUiHelpers.updateNowStats !== "function"
+ ) {
+ throw new Error("NowUiHelpers module must load before ui-now.js");
+ }
let moonCountdownCache = null;
let decanCountdownCache = null;
- let nowLightboxOverlayEl = null;
- let nowLightboxImageEl = null;
- let nowLightboxZoomed = false;
-
- const LIGHTBOX_ZOOM_SCALE = 6.66;
-
- const PLANETARY_BODIES = [
- { id: "sol", astronomyBody: "Sun", fallbackName: "Sun", fallbackSymbol: "☉︎" },
- { id: "luna", astronomyBody: "Moon", fallbackName: "Moon", fallbackSymbol: "☾︎" },
- { id: "mercury", astronomyBody: "Mercury", fallbackName: "Mercury", fallbackSymbol: "☿︎" },
- { id: "venus", astronomyBody: "Venus", fallbackName: "Venus", fallbackSymbol: "♀︎" },
- { id: "mars", astronomyBody: "Mars", fallbackName: "Mars", fallbackSymbol: "♂︎" },
- { id: "jupiter", astronomyBody: "Jupiter", fallbackName: "Jupiter", fallbackSymbol: "♃︎" },
- { id: "saturn", astronomyBody: "Saturn", fallbackName: "Saturn", fallbackSymbol: "♄︎" },
- { id: "uranus", astronomyBody: "Uranus", fallbackName: "Uranus", fallbackSymbol: "♅︎" },
- { id: "neptune", astronomyBody: "Neptune", fallbackName: "Neptune", fallbackSymbol: "♆︎" },
- { id: "pluto", astronomyBody: "Pluto", fallbackName: "Pluto", fallbackSymbol: "♇︎" }
- ];
-
- function resetNowLightboxZoom() {
- if (!nowLightboxImageEl) {
- return;
- }
-
- nowLightboxZoomed = false;
- nowLightboxImageEl.style.transform = "scale(1)";
- nowLightboxImageEl.style.transformOrigin = "center center";
- nowLightboxImageEl.style.cursor = "zoom-in";
- }
-
- function updateNowLightboxZoomOrigin(clientX, clientY) {
- if (!nowLightboxZoomed || !nowLightboxImageEl) {
- return;
- }
-
- const rect = nowLightboxImageEl.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));
- nowLightboxImageEl.style.transformOrigin = `${x}% ${y}%`;
- }
-
- function isNowLightboxPointOnCard(clientX, clientY) {
- if (!nowLightboxImageEl) {
- return false;
- }
-
- const rect = nowLightboxImageEl.getBoundingClientRect();
- const naturalWidth = nowLightboxImageEl.naturalWidth;
- const naturalHeight = nowLightboxImageEl.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 ensureNowImageLightbox() {
- if (nowLightboxOverlayEl && nowLightboxImageEl) {
- return;
- }
-
- nowLightboxOverlayEl = document.createElement("div");
- nowLightboxOverlayEl.setAttribute("aria-hidden", "true");
- nowLightboxOverlayEl.style.position = "fixed";
- nowLightboxOverlayEl.style.inset = "0";
- nowLightboxOverlayEl.style.background = "rgba(0, 0, 0, 0.82)";
- nowLightboxOverlayEl.style.display = "none";
- nowLightboxOverlayEl.style.alignItems = "center";
- nowLightboxOverlayEl.style.justifyContent = "center";
- nowLightboxOverlayEl.style.zIndex = "9999";
- nowLightboxOverlayEl.style.padding = "0";
-
- const image = document.createElement("img");
- image.alt = "Now 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";
-
- nowLightboxImageEl = image;
- nowLightboxOverlayEl.appendChild(image);
-
- const closeLightbox = () => {
- if (!nowLightboxOverlayEl || !nowLightboxImageEl) {
- return;
- }
- nowLightboxOverlayEl.style.display = "none";
- nowLightboxOverlayEl.setAttribute("aria-hidden", "true");
- nowLightboxImageEl.removeAttribute("src");
- resetNowLightboxZoom();
- };
-
- nowLightboxOverlayEl.addEventListener("click", (event) => {
- if (event.target === nowLightboxOverlayEl) {
- closeLightbox();
- }
- });
-
- nowLightboxImageEl.addEventListener("click", (event) => {
- event.stopPropagation();
- if (!isNowLightboxPointOnCard(event.clientX, event.clientY)) {
- closeLightbox();
- return;
- }
- if (!nowLightboxZoomed) {
- nowLightboxZoomed = true;
- nowLightboxImageEl.style.transform = `scale(${LIGHTBOX_ZOOM_SCALE})`;
- nowLightboxImageEl.style.cursor = "zoom-out";
- updateNowLightboxZoomOrigin(event.clientX, event.clientY);
- return;
- }
- resetNowLightboxZoom();
- });
-
- nowLightboxImageEl.addEventListener("mousemove", (event) => {
- updateNowLightboxZoomOrigin(event.clientX, event.clientY);
- });
-
- nowLightboxImageEl.addEventListener("mouseleave", () => {
- if (nowLightboxZoomed) {
- nowLightboxImageEl.style.transformOrigin = "center center";
- }
- });
-
- document.addEventListener("keydown", (event) => {
- if (event.key === "Escape") {
- closeLightbox();
- }
- });
-
- document.body.appendChild(nowLightboxOverlayEl);
- }
-
- function openNowImageLightbox(src, altText) {
- if (!src) {
- return;
- }
-
- ensureNowImageLightbox();
- if (!nowLightboxOverlayEl || !nowLightboxImageEl) {
- return;
- }
-
- nowLightboxImageEl.src = src;
- nowLightboxImageEl.alt = altText || "Now card enlarged image";
- resetNowLightboxZoom();
- nowLightboxOverlayEl.style.display = "flex";
- nowLightboxOverlayEl.setAttribute("aria-hidden", "false");
- }
-
- function getDisplayTarotName(cardName, trumpNumber) {
- if (!cardName) {
- return "";
- }
- if (typeof getTarotCardDisplayName !== "function") {
- return cardName;
- }
- if (Number.isFinite(Number(trumpNumber))) {
- return getTarotCardDisplayName(cardName, { trumpNumber: Number(trumpNumber) }) || cardName;
- }
- return getTarotCardDisplayName(cardName) || cardName;
- }
-
- function bindNowCardLightbox(imageEl) {
- if (!(imageEl instanceof HTMLImageElement) || imageEl.dataset.lightboxBound === "true") {
- return;
- }
-
- imageEl.dataset.lightboxBound = "true";
- imageEl.style.cursor = "zoom-in";
- imageEl.title = "Click to enlarge";
- imageEl.addEventListener("click", () => {
- const src = imageEl.getAttribute("src");
- if (!src || imageEl.style.display === "none") {
- return;
- }
- openNowImageLightbox(src, imageEl.alt || "Now card enlarged image");
- });
- }
-
- function normalizeLongitude(value) {
- const numeric = Number(value);
- if (!Number.isFinite(numeric)) {
- return null;
- }
-
- return ((numeric % 360) + 360) % 360;
- }
-
- function getSortedSigns(signs) {
- if (!Array.isArray(signs)) {
- return [];
- }
-
- return [...signs].sort((a, b) => (a.order || 0) - (b.order || 0));
- }
-
- function getSignForLongitude(longitude, signs) {
- const normalized = normalizeLongitude(longitude);
- if (normalized === null) {
- return null;
- }
-
- const sortedSigns = getSortedSigns(signs);
- if (!sortedSigns.length) {
- return null;
- }
-
- const signIndex = Math.min(sortedSigns.length - 1, Math.floor(normalized / 30));
- const sign = sortedSigns[signIndex] || null;
- if (!sign) {
- return null;
- }
-
- return {
- sign,
- degreeInSign: normalized - signIndex * 30,
- absoluteLongitude: normalized
- };
- }
-
- function getSabianSymbolForLongitude(longitude, sabianSymbols) {
- const normalized = normalizeLongitude(longitude);
- if (normalized === null || !Array.isArray(sabianSymbols) || !sabianSymbols.length) {
- return null;
- }
-
- const absoluteDegree = Math.floor(normalized) + 1;
- return sabianSymbols.find((entry) => Number(entry?.absoluteDegree) === absoluteDegree) || null;
- }
-
- function calculatePlanetPositions(referenceData, now) {
- if (!window.Astronomy || !referenceData) {
- return [];
- }
-
- const positions = [];
-
- PLANETARY_BODIES.forEach((body) => {
- try {
- const geoVector = window.Astronomy.GeoVector(body.astronomyBody, now, true);
- const ecliptic = window.Astronomy.Ecliptic(geoVector);
- const signInfo = getSignForLongitude(ecliptic?.elon, referenceData.signs);
- if (!signInfo?.sign) {
- return;
- }
-
- const planetInfo = referenceData.planets?.[body.id] || null;
- const symbol = planetInfo?.symbol || body.fallbackSymbol;
- const name = planetInfo?.name || body.fallbackName;
-
- positions.push({
- id: body.id,
- symbol,
- name,
- longitude: signInfo.absoluteLongitude,
- sign: signInfo.sign,
- degreeInSign: signInfo.degreeInSign,
- label: `${symbol} ${name}: ${signInfo.sign.symbol} ${signInfo.sign.name} ${signInfo.degreeInSign.toFixed(1)}°`
- });
- } catch {
- }
- });
-
- return positions;
- }
-
- function updateNowStats(referenceData, elements, now) {
- const planetPositions = calculatePlanetPositions(referenceData, now);
-
- if (elements.nowStatsPlanetsEl) {
- elements.nowStatsPlanetsEl.replaceChildren();
-
- if (!planetPositions.length) {
- elements.nowStatsPlanetsEl.textContent = "--";
- } else {
- planetPositions.forEach((position) => {
- const item = document.createElement("div");
- item.className = "now-stats-planet";
- item.textContent = position.label;
- elements.nowStatsPlanetsEl.appendChild(item);
- });
- }
- }
-
- if (elements.nowStatsSabianEl) {
- const sunPosition = planetPositions.find((entry) => entry.id === "sol") || null;
- const moonPosition = planetPositions.find((entry) => entry.id === "luna") || null;
- const sunSabianSymbol = sunPosition
- ? getSabianSymbolForLongitude(sunPosition.longitude, referenceData.sabianSymbols)
- : null;
- const moonSabianSymbol = moonPosition
- ? getSabianSymbolForLongitude(moonPosition.longitude, referenceData.sabianSymbols)
- : null;
-
- const sunLine = sunSabianSymbol?.phrase
- ? `Sun Sabian ${sunSabianSymbol.absoluteDegree}: ${sunSabianSymbol.phrase}`
- : "Sun Sabian: --";
- const moonLine = moonSabianSymbol?.phrase
- ? `Moon Sabian ${moonSabianSymbol.absoluteDegree}: ${moonSabianSymbol.phrase}`
- : "Moon Sabian: --";
-
- elements.nowStatsSabianEl.textContent = `${sunLine}\n${moonLine}`;
- }
- }
-
- function formatCountdown(ms, mode) {
- if (!Number.isFinite(ms) || ms <= 0) {
- if (mode === "hours") {
- return "0.0 hours";
- }
- if (mode === "seconds") {
- return "0s";
- }
- return "0m";
- }
-
- if (mode === "hours") {
- return `${(ms / 3600000).toFixed(1)} hours`;
- }
-
- if (mode === "seconds") {
- return `${Math.floor(ms / 1000)}s`;
- }
-
- return `${Math.floor(ms / 60000)}m`;
- }
-
- function parseMonthDay(monthDay) {
- const [month, day] = String(monthDay || "").split("-").map(Number);
- return { month, day };
- }
-
- function getCurrentPhaseName(date) {
- return getMoonPhaseName(window.SunCalc.getMoonIllumination(date).phase);
- }
-
- function findNextMoonPhaseTransition(now) {
- const currentPhase = getCurrentPhaseName(now);
- const stepMs = 15 * 60 * 1000;
- const maxMs = 40 * DAY_IN_MS;
-
- let previousTime = now.getTime();
- let previousPhase = currentPhase;
-
- for (let t = previousTime + stepMs; t <= previousTime + maxMs; t += stepMs) {
- const phaseName = getCurrentPhaseName(new Date(t));
- if (phaseName !== previousPhase) {
- let low = previousTime;
- let high = t;
- while (high - low > 1000) {
- const mid = Math.floor((low + high) / 2);
- const midPhase = getCurrentPhaseName(new Date(mid));
- if (midPhase === currentPhase) {
- low = mid;
- } else {
- high = mid;
- }
- }
-
- const transitionAt = new Date(high);
- const nextPhase = getCurrentPhaseName(new Date(high + 1000));
- return {
- fromPhase: currentPhase,
- nextPhase,
- changeAt: transitionAt
- };
- }
- previousTime = t;
- previousPhase = phaseName;
- }
-
- return null;
- }
-
- function getSignStartDate(now, sign) {
- const { month: startMonth, day: startDay } = parseMonthDay(sign.start);
- const { month: endMonth } = parseMonthDay(sign.end);
- const wrapsYear = startMonth > endMonth;
-
- let year = now.getFullYear();
- const nowMonth = now.getMonth() + 1;
- const nowDay = now.getDate();
-
- if (wrapsYear && (nowMonth < startMonth || (nowMonth === startMonth && nowDay < startDay))) {
- year -= 1;
- }
-
- return new Date(year, startMonth - 1, startDay);
- }
-
- function getNextSign(signs, currentSign) {
- const sorted = [...signs].sort((a, b) => (a.order || 0) - (b.order || 0));
- const index = sorted.findIndex((entry) => entry.id === currentSign.id);
- if (index < 0) {
- return null;
- }
- return sorted[(index + 1) % sorted.length] || null;
- }
-
- function getDecanByIndex(decansBySign, signId, index) {
- const signDecans = decansBySign[signId] || [];
- return signDecans.find((entry) => entry.index === index) || null;
- }
-
- function findNextDecanTransition(now, signs, decansBySign) {
- const currentInfo = getDecanForDate(now, signs, decansBySign);
- if (!currentInfo?.sign) {
- return null;
- }
-
- const currentIndex = currentInfo.decan?.index || 1;
- const signStart = getSignStartDate(now, currentInfo.sign);
-
- if (currentIndex < 3) {
- const changeAt = new Date(signStart.getTime() + currentIndex * 10 * DAY_IN_MS);
- const nextDecan = getDecanByIndex(decansBySign, currentInfo.sign.id, currentIndex + 1);
- const nextLabel = nextDecan?.tarotMinorArcana || `${currentInfo.sign.name} Decan ${currentIndex + 1}`;
-
- return {
- key: `${currentInfo.sign.id}-${currentIndex}`,
- changeAt,
- nextLabel
- };
- }
-
- const nextSign = getNextSign(signs, currentInfo.sign);
- if (!nextSign) {
- return null;
- }
-
- const { month: nextMonth, day: nextDay } = parseMonthDay(nextSign.start);
- let year = now.getFullYear();
- let changeAt = new Date(year, nextMonth - 1, nextDay);
- if (changeAt.getTime() <= now.getTime()) {
- changeAt = new Date(year + 1, nextMonth - 1, nextDay);
- }
-
- const nextDecan = getDecanByIndex(decansBySign, nextSign.id, 1);
-
- return {
- key: `${currentInfo.sign.id}-${currentIndex}`,
- changeAt,
- nextLabel: nextDecan?.tarotMinorArcana || `${nextSign.name} Decan 1`
- };
- }
-
- function setNowCardImage(imageEl, cardName, fallbackLabel, trumpNumber) {
- if (!imageEl) {
- return;
- }
-
- bindNowCardLightbox(imageEl);
-
- if (!cardName || typeof resolveTarotCardImage !== "function") {
- imageEl.style.display = "none";
- imageEl.removeAttribute("src");
- return;
- }
-
- const src = resolveTarotCardImage(cardName);
- if (!src) {
- imageEl.style.display = "none";
- imageEl.removeAttribute("src");
- return;
- }
-
- imageEl.src = src;
- const displayName = getDisplayTarotName(cardName, trumpNumber);
- imageEl.alt = `${fallbackLabel}: ${displayName}`;
- imageEl.style.display = "block";
- }
function updateNowPanel(referenceData, geo, elements, timeFormat = "minutes") {
if (!referenceData || !geo || !elements) {
@@ -543,12 +53,12 @@
const hourCardName = planet?.tarot?.majorArcana || "";
const hourTrumpNumber = planet?.tarot?.number;
elements.nowHourTarotEl.textContent = hourCardName
- ? getDisplayTarotName(hourCardName, hourTrumpNumber)
+ ? nowUiHelpers.getDisplayTarotName(hourCardName, hourTrumpNumber)
: "--";
}
const msLeft = Math.max(0, currentHour.end.getTime() - now.getTime());
- elements.nowCountdownEl.textContent = formatCountdown(msLeft, timeFormat);
+ elements.nowCountdownEl.textContent = nowUiHelpers.formatCountdown(msLeft, timeFormat);
if (elements.nowHourNextEl) {
const nextHour = allHours.find(
@@ -564,7 +74,7 @@
}
}
- setNowCardImage(
+ nowUiHelpers.setNowCardImage(
elements.nowHourCardEl,
planet?.tarot?.majorArcana,
"Current planetary hour card",
@@ -579,15 +89,15 @@
if (elements.nowHourNextEl) {
elements.nowHourNextEl.textContent = "> --";
}
- setNowCardImage(elements.nowHourCardEl, null, "Current planetary hour card");
+ nowUiHelpers.setNowCardImage(elements.nowHourCardEl, null, "Current planetary hour card");
}
const moonIllum = window.SunCalc.getMoonIllumination(now);
const moonPhase = getMoonPhaseName(moonIllum.phase);
const moonTarot = referenceData.planets.luna?.tarot?.majorArcana || "The High Priestess";
elements.nowMoonEl.textContent = `${moonPhase} (${Math.round(moonIllum.fraction * 100)}%)`;
- elements.nowMoonTarotEl.textContent = getDisplayTarotName(moonTarot, referenceData.planets.luna?.tarot?.number);
- setNowCardImage(
+ elements.nowMoonTarotEl.textContent = nowUiHelpers.getDisplayTarotName(moonTarot, referenceData.planets.luna?.tarot?.number);
+ nowUiHelpers.setNowCardImage(
elements.nowMoonCardEl,
moonTarot,
"Current moon phase card",
@@ -595,13 +105,13 @@
);
if (!moonCountdownCache || moonCountdownCache.fromPhase !== moonPhase || now >= moonCountdownCache.changeAt) {
- moonCountdownCache = findNextMoonPhaseTransition(now);
+ moonCountdownCache = nowUiHelpers.findNextMoonPhaseTransition(now);
}
if (elements.nowMoonCountdownEl) {
if (moonCountdownCache?.changeAt) {
const remaining = moonCountdownCache.changeAt.getTime() - now.getTime();
- elements.nowMoonCountdownEl.textContent = formatCountdown(remaining, timeFormat);
+ elements.nowMoonCountdownEl.textContent = nowUiHelpers.formatCountdown(remaining, timeFormat);
if (elements.nowMoonNextEl) {
elements.nowMoonNextEl.textContent = `> ${moonCountdownCache.nextPhase}`;
}
@@ -621,24 +131,24 @@
const signStartDate = getSignStartDate(now, sunInfo.sign);
const daysSinceSignStart = (now.getTime() - signStartDate.getTime()) / DAY_IN_MS;
const signDegree = Math.min(29.9, Math.max(0, daysSinceSignStart));
- const signMajorName = getDisplayTarotName(sunInfo.sign.tarot.majorArcana, sunInfo.sign.tarot.trumpNumber);
+ const signMajorName = nowUiHelpers.getDisplayTarotName(sunInfo.sign.tarot.majorArcana, sunInfo.sign.tarot.trumpNumber);
elements.nowDecanEl.textContent = `${sunInfo.sign.symbol} ${sunInfo.sign.name} · ${signMajorName} (${signDegree.toFixed(1)}°)`;
const currentDecanKey = `${sunInfo.sign.id}-${sunInfo.decan?.index || 1}`;
if (!decanCountdownCache || decanCountdownCache.key !== currentDecanKey || now >= decanCountdownCache.changeAt) {
- decanCountdownCache = findNextDecanTransition(now, referenceData.signs, referenceData.decansBySign);
+ decanCountdownCache = nowUiHelpers.findNextDecanTransition(now, referenceData.signs, referenceData.decansBySign);
}
if (sunInfo.decan) {
const decanCardName = sunInfo.decan.tarotMinorArcana;
- elements.nowDecanTarotEl.textContent = getDisplayTarotName(decanCardName);
- setNowCardImage(elements.nowDecanCardEl, sunInfo.decan.tarotMinorArcana, "Current decan card");
+ elements.nowDecanTarotEl.textContent = nowUiHelpers.getDisplayTarotName(decanCardName);
+ nowUiHelpers.setNowCardImage(elements.nowDecanCardEl, sunInfo.decan.tarotMinorArcana, "Current decan card");
} else {
const signTarotName = sunInfo.sign.tarot?.majorArcana || "--";
elements.nowDecanTarotEl.textContent = signTarotName === "--"
? "--"
- : getDisplayTarotName(signTarotName, sunInfo.sign.tarot?.trumpNumber);
- setNowCardImage(
+ : nowUiHelpers.getDisplayTarotName(signTarotName, sunInfo.sign.tarot?.trumpNumber);
+ nowUiHelpers.setNowCardImage(
elements.nowDecanCardEl,
sunInfo.sign.tarot?.majorArcana,
"Current decan card",
@@ -649,9 +159,9 @@
if (elements.nowDecanCountdownEl) {
if (decanCountdownCache?.changeAt) {
const remaining = decanCountdownCache.changeAt.getTime() - now.getTime();
- elements.nowDecanCountdownEl.textContent = formatCountdown(remaining, timeFormat);
+ elements.nowDecanCountdownEl.textContent = nowUiHelpers.formatCountdown(remaining, timeFormat);
if (elements.nowDecanNextEl) {
- elements.nowDecanNextEl.textContent = `> ${getDisplayTarotName(decanCountdownCache.nextLabel)}`;
+ elements.nowDecanNextEl.textContent = `> ${nowUiHelpers.getDisplayTarotName(decanCountdownCache.nextLabel)}`;
}
} else {
elements.nowDecanCountdownEl.textContent = "--";
@@ -663,7 +173,7 @@
} else {
elements.nowDecanEl.textContent = "--";
elements.nowDecanTarotEl.textContent = "--";
- setNowCardImage(elements.nowDecanCardEl, null, "Current decan card");
+ nowUiHelpers.setNowCardImage(elements.nowDecanCardEl, null, "Current decan card");
if (elements.nowDecanCountdownEl) {
elements.nowDecanCountdownEl.textContent = "--";
}
@@ -672,7 +182,7 @@
}
}
- updateNowStats(referenceData, elements, now);
+ nowUiHelpers.updateNowStats(referenceData, elements, now);
return {
dayKey,
diff --git a/app/ui-numbers-detail.js b/app/ui-numbers-detail.js
new file mode 100644
index 0000000..be2e413
--- /dev/null
+++ b/app/ui-numbers-detail.js
@@ -0,0 +1,634 @@
+(function () {
+ "use strict";
+
+ function getCalendarMonthLinksForNumber(context, value) {
+ const { getReferenceData, normalizeNumberValue, computeDigitalRoot } = context;
+ 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 buildFallbackPlayingDeckEntries(context) {
+ const { PLAYING_SUIT_SYMBOL, PLAYING_SUIT_LABEL, PLAYING_SUIT_TO_TAROT, PLAYING_RANKS, rankLabelToTarotMinorRank } = context;
+ 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(context) {
+ const {
+ getMagickDataset,
+ PLAYING_SUIT_SYMBOL,
+ PLAYING_SUIT_LABEL,
+ PLAYING_SUIT_TO_TAROT,
+ rankLabelToTarotMinorRank
+ } = context;
+ const deckData = getMagickDataset()?.grouped?.["playing-cards-52"];
+ const rawEntries = Array.isArray(deckData)
+ ? deckData
+ : (Array.isArray(deckData?.entries) ? deckData.entries : []);
+
+ if (!rawEntries.length) {
+ return buildFallbackPlayingDeckEntries(context);
+ }
+
+ 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(context, playingSuit) {
+ const {
+ PLAYING_SUIT_LABEL,
+ PLAYING_SUIT_TO_TAROT,
+ NUMBERS_SPECIAL_BASE_VALUES,
+ numbersSpecialFlipState
+ } = context;
+ const suit = String(playingSuit || "hearts").trim().toLowerCase();
+ const selectedSuit = ["hearts", "diamonds", "clubs", "spades"].includes(suit) ? suit : "hearts";
+ const deckEntries = getPlayingDeckEntries(context);
+
+ 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(context, value, entry) {
+ const { specialPanelEl } = context.elements;
+ if (!specialPanelEl) {
+ return;
+ }
+
+ const playingSuit = entry?.associations?.playingSuit || "hearts";
+ const boardCardEl = buildNumbersSpecialCardSlots(context, 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(context, card) {
+ const { TAROT_RANK_NUMBER_MAP } = context;
+ 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(context, targetRoot) {
+ const { getMagickDataset, computeDigitalRoot } = context;
+ 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(context, targetRoot, numberEntry = null) {
+ const { getReferenceData, getMagickDataset, ensureTarotSection, computeDigitalRoot } = context;
+ const referenceData = getReferenceData();
+ const magickDataset = getMagickDataset();
+ 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(context, card);
+ return numberValue !== null && computeDigitalRoot(numberValue) === targetRoot;
+ });
+
+ return filteredCards.sort((left, right) => {
+ const leftNumber = extractTarotCardNumericValue(context, left);
+ const rightNumber = extractTarotCardNumericValue(context, 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 renderNumberDetail(context) {
+ const { elements, getNumberEntryByValue, normalizeNumberValue, selectNumberEntry } = context;
+ const { detailNameEl, detailTypeEl, detailSummaryEl, detailBodyEl } = elements;
+ const entry = getNumberEntryByValue(context.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(context, normalized, entry);
+
+ 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(context, 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(context, 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(context, 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);
+ }
+
+ window.NumbersDetailUi = {
+ renderNumberDetail
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-numbers.js b/app/ui-numbers.js
index 7b18a62..7769a55 100644
--- a/app/ui-numbers.js
+++ b/app/ui-numbers.js
@@ -11,6 +11,11 @@
const NUMBERS_SPECIAL_BASE_VALUES = [1, 2, 3, 4];
const numbersSpecialFlipState = new Map();
+ const numbersDetailUi = window.NumbersDetailUi || {};
+
+ if (typeof numbersDetailUi.renderNumberDetail !== "function") {
+ throw new Error("NumbersDetailUi module must load before ui-numbers.js");
+ }
const DEFAULT_NUMBER_ENTRIES = Array.from({ length: 10 }, (_, value) => ({
value,
@@ -194,55 +199,6 @@
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";
@@ -252,409 +208,6 @@
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) {
@@ -693,187 +246,30 @@
}
}
+ function getDetailRenderContext(value) {
+ return {
+ value,
+ elements: getElements(),
+ getReferenceData,
+ getMagickDataset,
+ getNumberEntryByValue,
+ normalizeNumberValue,
+ computeDigitalRoot,
+ rankLabelToTarotMinorRank,
+ ensureTarotSection: config.ensureTarotSection,
+ selectNumberEntry,
+ NUMBERS_SPECIAL_BASE_VALUES,
+ numbersSpecialFlipState,
+ PLAYING_SUIT_SYMBOL,
+ PLAYING_SUIT_LABEL,
+ PLAYING_SUIT_TO_TAROT,
+ PLAYING_RANKS,
+ TAROT_RANK_NUMBER_MAP
+ };
+ }
+
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);
+ numbersDetailUi.renderNumberDetail(getDetailRenderContext(value));
}
function selectNumberEntry(value) {
diff --git a/app/ui-planets-references.js b/app/ui-planets-references.js
new file mode 100644
index 0000000..c842ad7
--- /dev/null
+++ b/app/ui-planets-references.js
@@ -0,0 +1,330 @@
+(function () {
+ "use strict";
+
+ function buildMonthReferencesByPlanet(context) {
+ const { referenceData, toPlanetId, normalizePlanetToken } = context;
+ 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]));
+
+ 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(planetToken, month, options = {}) {
+ const planetId = toPlanetId(planetToken) || normalizePlanetToken(planetToken);
+ if (!planetId || !month?.id) {
+ return;
+ }
+
+ if (!map.has(planetId)) {
+ map.set(planetId, []);
+ }
+
+ const rows = map.get(planetId);
+ const range = resolveRangeForMonth(month, options);
+ const key = `${month.id}|${range.startToken || ""}|${range.endToken || ""}`;
+ if (rows.some((entry) => entry.key === key)) {
+ 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
+ });
+ }
+
+ months.forEach((month) => {
+ pushRef(month?.associations?.planetId, month);
+ const events = Array.isArray(month?.events) ? month.events : [];
+ events.forEach((event) => {
+ pushRef(event?.associations?.planetId, month, {
+ rawDateText: event?.dateRange || event?.date || ""
+ });
+ });
+ });
+
+ holidays.forEach((holiday) => {
+ const month = monthById.get(holiday?.monthId);
+ if (!month) {
+ return;
+ }
+ pushRef(holiday?.associations?.planetId, 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 buildCubePlacementsByPlanet(context) {
+ const { magickDataset, toPlanetId } = context;
+ const map = new Map();
+ const cube = magickDataset?.grouped?.kabbalah?.cube || {};
+ const walls = Array.isArray(cube?.walls)
+ ? cube.walls
+ : [];
+ const edges = Array.isArray(cube?.edges)
+ ? cube.edges
+ : [];
+
+ function edgeWalls(edge) {
+ const explicitWalls = Array.isArray(edge?.walls)
+ ? edge.walls.map((wallId) => String(wallId || "").trim().toLowerCase()).filter(Boolean)
+ : [];
+
+ if (explicitWalls.length >= 2) {
+ return explicitWalls.slice(0, 2);
+ }
+
+ return String(edge?.id || "")
+ .trim()
+ .toLowerCase()
+ .split("-")
+ .map((wallId) => wallId.trim())
+ .filter(Boolean)
+ .slice(0, 2);
+ }
+
+ function edgeLabel(edge) {
+ const explicitName = String(edge?.name || "").trim();
+ if (explicitName) {
+ return explicitName;
+ }
+ return edgeWalls(edge)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(" ");
+ }
+
+ function resolveCubeDirectionLabel(wallId, edge) {
+ const normalizedWallId = String(wallId || "").trim().toLowerCase();
+ const edgeId = String(edge?.id || "").trim().toLowerCase();
+ 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);
+ }
+
+ const firstEdgeByWallId = new Map();
+ edges.forEach((edge) => {
+ edgeWalls(edge).forEach((wallId) => {
+ if (!firstEdgeByWallId.has(wallId)) {
+ firstEdgeByWallId.set(wallId, edge);
+ }
+ });
+ });
+
+ function pushPlacement(planetId, placement) {
+ if (!planetId || !placement?.wallId || !placement?.edgeId) {
+ return;
+ }
+
+ if (!map.has(planetId)) {
+ map.set(planetId, []);
+ }
+
+ const rows = map.get(planetId);
+ const key = `${placement.wallId}:${placement.edgeId}`;
+ if (rows.some((row) => `${row.wallId}:${row.edgeId}` === key)) {
+ return;
+ }
+
+ rows.push(placement);
+ }
+
+ walls.forEach((wall) => {
+ const planetId = toPlanetId(wall?.associations?.planetId || wall?.planet);
+ if (!planetId) {
+ return;
+ }
+
+ const wallId = String(wall?.id || "").trim().toLowerCase();
+ const edge = firstEdgeByWallId.get(wallId) || null;
+
+ pushPlacement(planetId, {
+ wallId,
+ edgeId: String(edge?.id || "").trim().toLowerCase(),
+ label: `Cube: ${wall?.name || "Wall"} Wall - ${resolveCubeDirectionLabel(wallId, edge) || "Direction"}`
+ });
+ });
+
+ return map;
+ }
+
+ window.PlanetReferenceBuilders = {
+ buildMonthReferencesByPlanet,
+ buildCubePlacementsByPlanet
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-planets.js b/app/ui-planets.js
index 87d411f..c3d0bbb 100644
--- a/app/ui-planets.js
+++ b/app/ui-planets.js
@@ -1,5 +1,15 @@
(function () {
+ "use strict";
+
const { getTarotCardDisplayName, getTarotCardSearchAliases } = window.TarotCardImages || {};
+ const planetReferenceBuilders = window.PlanetReferenceBuilders || {};
+
+ if (
+ typeof planetReferenceBuilders.buildMonthReferencesByPlanet !== "function"
+ || typeof planetReferenceBuilders.buildCubePlacementsByPlanet !== "function"
+ ) {
+ throw new Error("PlanetReferenceBuilders module must load before ui-planets.js");
+ }
const state = {
initialized: false,
@@ -68,326 +78,6 @@
return map;
}
- function buildMonthReferencesByPlanet(referenceData) {
- 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]));
-
- 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(planetToken, month, options = {}) {
- const planetId = toPlanetId(planetToken) || normalizePlanetToken(planetToken);
- if (!planetId || !month?.id) {
- return;
- }
-
- if (!map.has(planetId)) {
- map.set(planetId, []);
- }
-
- const rows = map.get(planetId);
- const range = resolveRangeForMonth(month, options);
- const key = `${month.id}|${range.startToken || ""}|${range.endToken || ""}`;
- if (rows.some((entry) => entry.key === key)) {
- 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
- });
- }
-
- months.forEach((month) => {
- pushRef(month?.associations?.planetId, month);
- const events = Array.isArray(month?.events) ? month.events : [];
- events.forEach((event) => {
- pushRef(event?.associations?.planetId, month, {
- rawDateText: event?.dateRange || event?.date || ""
- });
- });
- });
-
- holidays.forEach((holiday) => {
- const month = monthById.get(holiday?.monthId);
- if (!month) {
- return;
- }
- pushRef(holiday?.associations?.planetId, 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 buildCubePlacementsByPlanet(magickDataset) {
- const map = new Map();
- const cube = magickDataset?.grouped?.kabbalah?.cube || {};
- const walls = Array.isArray(cube?.walls)
- ? cube.walls
- : [];
- const edges = Array.isArray(cube?.edges)
- ? cube.edges
- : [];
-
- function edgeWalls(edge) {
- const explicitWalls = Array.isArray(edge?.walls)
- ? edge.walls.map((wallId) => String(wallId || "").trim().toLowerCase()).filter(Boolean)
- : [];
-
- if (explicitWalls.length >= 2) {
- return explicitWalls.slice(0, 2);
- }
-
- return String(edge?.id || "")
- .trim()
- .toLowerCase()
- .split("-")
- .map((wallId) => wallId.trim())
- .filter(Boolean)
- .slice(0, 2);
- }
-
- function edgeLabel(edge) {
- const explicitName = String(edge?.name || "").trim();
- if (explicitName) {
- return explicitName;
- }
- return edgeWalls(edge)
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
- .join(" ");
- }
-
- function resolveCubeDirectionLabel(wallId, edge) {
- const normalizedWallId = String(wallId || "").trim().toLowerCase();
- const edgeId = String(edge?.id || "").trim().toLowerCase();
- 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);
- }
-
- const firstEdgeByWallId = new Map();
- edges.forEach((edge) => {
- edgeWalls(edge).forEach((wallId) => {
- if (!firstEdgeByWallId.has(wallId)) {
- firstEdgeByWallId.set(wallId, edge);
- }
- });
- });
-
- function pushPlacement(planetId, placement) {
- if (!planetId || !placement?.wallId || !placement?.edgeId) {
- return;
- }
-
- if (!map.has(planetId)) {
- map.set(planetId, []);
- }
-
- const rows = map.get(planetId);
- const key = `${placement.wallId}:${placement.edgeId}`;
- if (rows.some((row) => `${row.wallId}:${row.edgeId}` === key)) {
- return;
- }
-
- rows.push(placement);
- }
-
- walls.forEach((wall) => {
- const planetId = toPlanetId(wall?.associations?.planetId || wall?.planet);
- if (!planetId) {
- return;
- }
-
- const wallId = String(wall?.id || "").trim().toLowerCase();
- const edge = firstEdgeByWallId.get(wallId) || null;
-
- pushPlacement(planetId, {
- wallId,
- edgeId: String(edge?.id || "").trim().toLowerCase(),
- label: `Cube: ${wall?.name || "Wall"} Wall - ${resolveCubeDirectionLabel(wallId, edge) || "Direction"}`
- });
- });
-
- return map;
- }
-
function getElements() {
return {
planetCardListEl: document.getElementById("planet-card-list"),
@@ -815,8 +505,15 @@
}, {});
state.kabbalahTargetsByPlanetId = buildKabbalahTargetsByPlanet(magickDataset);
- state.monthRefsByPlanetId = buildMonthReferencesByPlanet(referenceData);
- state.cubePlacementsByPlanetId = buildCubePlacementsByPlanet(magickDataset);
+ state.monthRefsByPlanetId = planetReferenceBuilders.buildMonthReferencesByPlanet({
+ referenceData,
+ toPlanetId,
+ normalizePlanetToken
+ });
+ state.cubePlacementsByPlanetId = planetReferenceBuilders.buildCubePlacementsByPlanet({
+ magickDataset,
+ toPlanetId
+ });
state.entries = baseList.map((entry) => {
const byId = correspondences[entry.id] || null;
diff --git a/app/ui-quiz-bank-builtins-domains.js b/app/ui-quiz-bank-builtins-domains.js
new file mode 100644
index 0000000..f39aaea
--- /dev/null
+++ b/app/ui-quiz-bank-builtins-domains.js
@@ -0,0 +1,613 @@
+/* ui-quiz-bank-builtins-domains.js — Built-in quiz domain template generation */
+(function () {
+ "use strict";
+
+ function appendBuiltInQuestionBankDomains(context) {
+ const {
+ bank,
+ helpers,
+ englishLetters,
+ hebrewLetters,
+ hebrewById,
+ signs,
+ planets,
+ planetsById,
+ treePaths,
+ sephirotById,
+ flattenDecans,
+ cubeWalls,
+ cubeEdges,
+ cubeCenter,
+ playingCards,
+ pools
+ } = context || {};
+
+ const {
+ createQuestionTemplate,
+ normalizeId,
+ normalizeOption,
+ toTitleCase,
+ formatHebrewLetterLabel,
+ getPlanetLabelById,
+ getSephiraName,
+ formatPathLetter,
+ formatDecanLabel,
+ labelFromId
+ } = helpers || {};
+
+ if (!Array.isArray(bank) || typeof createQuestionTemplate !== "function") {
+ return;
+ }
+
+ const {
+ englishGematriaPool,
+ hebrewNumerologyPool,
+ hebrewNameAndCharPool,
+ hebrewCharPool,
+ planetNamePool,
+ planetWeekdayPool,
+ zodiacElementPool,
+ zodiacTarotPool,
+ pathNumberPool,
+ pathLetterPool,
+ pathTarotPool,
+ sephirotPlanetPool,
+ decanLabelPool,
+ decanRulerPool,
+ cubeLocationPool,
+ cubeHebrewLetterPool,
+ playingTarotPool
+ } = pools || {};
+
+ (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
+ },
+ sephirotPlanetPool
+ );
+
+ 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);
+ }
+ });
+ }
+
+ window.QuizQuestionBankBuiltInDomains = {
+ appendBuiltInQuestionBankDomains
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-quiz-bank-builtins.js b/app/ui-quiz-bank-builtins.js
new file mode 100644
index 0000000..35017c5
--- /dev/null
+++ b/app/ui-quiz-bank-builtins.js
@@ -0,0 +1,358 @@
+/* ui-quiz-bank-builtins.js — Built-in quiz template generation */
+(function () {
+ "use strict";
+
+ const quizQuestionBankBuiltInDomains = window.QuizQuestionBankBuiltInDomains || {};
+
+ if (typeof quizQuestionBankBuiltInDomains.appendBuiltInQuestionBankDomains !== "function") {
+ throw new Error("QuizQuestionBankBuiltInDomains module must load before ui-quiz-bank-builtins.js");
+ }
+
+ function buildBuiltInQuestionBank(context) {
+ const {
+ referenceData,
+ magickDataset,
+ helpers
+ } = context || {};
+
+ const {
+ toTitleCase,
+ normalizeOption,
+ toUniqueOptionList,
+ createQuestionTemplate
+ } = helpers || {};
+
+ if (
+ typeof toTitleCase !== "function"
+ || typeof normalizeOption !== "function"
+ || typeof toUniqueOptionList !== "function"
+ || typeof createQuestionTemplate !== "function"
+ ) {
+ return [];
+ }
+
+ 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 sephirotPlanetPool = toUniqueOptionList(
+ Object.values(sephirotById).map((entry) => getPlanetLabelById(entry?.planetId))
+ );
+
+ 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))
+ );
+
+ quizQuestionBankBuiltInDomains.appendBuiltInQuestionBankDomains({
+ bank,
+ englishLetters,
+ hebrewLetters,
+ hebrewById,
+ signs,
+ planets,
+ planetsById,
+ treePaths,
+ sephirotById,
+ flattenDecans,
+ cubeWalls,
+ cubeEdges,
+ cubeCenter,
+ playingCards,
+ pools: {
+ englishGematriaPool,
+ hebrewNumerologyPool,
+ hebrewNameAndCharPool,
+ hebrewCharPool,
+ planetNamePool,
+ planetWeekdayPool,
+ zodiacElementPool,
+ zodiacTarotPool,
+ pathNumberPool,
+ pathLetterPool,
+ pathTarotPool,
+ sephirotPlanetPool,
+ decanLabelPool,
+ decanRulerPool,
+ cubeLocationPool,
+ cubeHebrewLetterPool,
+ playingTarotPool
+ },
+ helpers: {
+ createQuestionTemplate,
+ normalizeId,
+ normalizeOption,
+ toTitleCase,
+ formatHebrewLetterLabel,
+ getPlanetLabelById,
+ getSephiraName,
+ formatPathLetter,
+ formatDecanLabel,
+ labelFromId
+ }
+ });
+
+ return bank;
+ }
+
+ window.QuizQuestionBankBuiltins = {
+ buildBuiltInQuestionBank
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-quiz-bank.js b/app/ui-quiz-bank.js
index 798798d..6891302 100644
--- a/app/ui-quiz-bank.js
+++ b/app/ui-quiz-bank.js
@@ -2,6 +2,12 @@
(function () {
"use strict";
+ const quizQuestionBankBuiltins = window.QuizQuestionBankBuiltins || {};
+
+ if (typeof quizQuestionBankBuiltins.buildBuiltInQuestionBank !== "function") {
+ throw new Error("QuizQuestionBankBuiltins module must load before ui-quiz-bank.js");
+ }
+
function toTitleCase(value) {
const text = String(value || "").trim().toLowerCase();
if (!text) {
@@ -104,817 +110,14 @@
}
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);
+ const bank = quizQuestionBankBuiltins.buildBuiltInQuestionBank({
+ referenceData,
+ magickDataset,
+ helpers: {
+ toTitleCase,
+ normalizeOption,
+ toUniqueOptionList,
+ createQuestionTemplate
}
});
diff --git a/app/ui-tarot-card-derivations.js b/app/ui-tarot-card-derivations.js
new file mode 100644
index 0000000..a75e448
--- /dev/null
+++ b/app/ui-tarot-card-derivations.js
@@ -0,0 +1,231 @@
+(function () {
+ function createTarotCardDerivations(dependencies) {
+ const {
+ normalizeRelationId,
+ normalizeTarotCardLookupName,
+ toTitleCase,
+ getReferenceData,
+ ELEMENT_NAME_BY_ID,
+ ELEMENT_HEBREW_LETTER_BY_ID,
+ ELEMENT_HEBREW_CHAR_BY_ID,
+ HEBREW_LETTER_ID_BY_TETRAGRAMMATON_LETTER,
+ ACE_ELEMENT_BY_CARD_NAME,
+ COURT_ELEMENT_BY_RANK,
+ MINOR_RANK_NUMBER_BY_NAME,
+ SMALL_CARD_SIGN_BY_MODALITY_AND_SUIT,
+ MINOR_PLURAL_BY_RANK
+ } = dependencies || {};
+
+ function buildTypeLabel(card) {
+ if (card.arcana === "Major") {
+ return typeof card.number === "number"
+ ? `Major Arcana · ${card.number}`
+ : "Major Arcana";
+ }
+
+ const parts = ["Minor Arcana"];
+ if (card.rank) {
+ parts.push(card.rank);
+ }
+ if (card.suit) {
+ parts.push(card.suit);
+ }
+
+ return parts.join(" · ");
+ }
+
+ function resolveElementIdForCard(card) {
+ if (!card) {
+ return "";
+ }
+
+ const cardLookupName = normalizeTarotCardLookupName(card.name);
+ const rankKey = String(card.rank || "").trim().toLowerCase();
+
+ return ACE_ELEMENT_BY_CARD_NAME[cardLookupName] || COURT_ELEMENT_BY_RANK[rankKey] || "";
+ }
+
+ function createElementRelation(card, elementId, sourceKind, sourceLabel) {
+ if (!card || !elementId) {
+ return null;
+ }
+
+ const elementName = ELEMENT_NAME_BY_ID[elementId] || toTitleCase(elementId);
+ const hebrewLetter = ELEMENT_HEBREW_LETTER_BY_ID[elementId] || "";
+ const hebrewChar = ELEMENT_HEBREW_CHAR_BY_ID[elementId] || "";
+ const relationLabel = `${elementName}${hebrewChar ? ` (${hebrewChar})` : (hebrewLetter ? ` (${hebrewLetter})` : "")} · ${sourceLabel}`;
+
+ return {
+ type: "element",
+ id: elementId,
+ label: relationLabel,
+ data: {
+ elementId,
+ name: elementName,
+ tarotCard: card.name,
+ hebrewLetter,
+ hebrewChar,
+ sourceKind,
+ sourceLabel,
+ rank: card.rank || "",
+ suit: card.suit || ""
+ },
+ __key: `element|${elementId}|${sourceKind}|${normalizeRelationId(sourceLabel)}|${card.id || normalizeTarotCardLookupName(card.name)}`
+ };
+ }
+
+ function buildElementRelationsForCard(card, baseElementRelations = []) {
+ if (!card) {
+ return [];
+ }
+
+ if (card.arcana === "Major") {
+ return Array.isArray(baseElementRelations) ? [...baseElementRelations] : [];
+ }
+
+ const relations = [];
+
+ const suitKey = String(card.suit || "").trim().toLowerCase();
+ const suitElementId = {
+ wands: "fire",
+ cups: "water",
+ swords: "air",
+ disks: "earth"
+ }[suitKey] || "";
+ if (suitElementId) {
+ const suitRelation = createElementRelation(card, suitElementId, "suit", `Suit: ${card.suit}`);
+ if (suitRelation) {
+ relations.push(suitRelation);
+ }
+ }
+
+ const rankKey = String(card.rank || "").trim().toLowerCase();
+ const courtElementId = COURT_ELEMENT_BY_RANK[rankKey] || "";
+ if (courtElementId) {
+ const courtRelation = createElementRelation(card, courtElementId, "court", `Court: ${card.rank}`);
+ if (courtRelation) {
+ relations.push(courtRelation);
+ }
+ }
+
+ return relations;
+ }
+
+ function buildTetragrammatonRelationsForCard(card) {
+ if (!card) {
+ return [];
+ }
+
+ const elementId = resolveElementIdForCard(card);
+ if (!elementId) {
+ return [];
+ }
+
+ const letter = ELEMENT_HEBREW_LETTER_BY_ID[elementId] || "";
+ if (!letter) {
+ return [];
+ }
+
+ const elementName = ELEMENT_NAME_BY_ID[elementId] || elementId;
+ const letterKey = String(letter || "").trim().toLowerCase();
+ const hebrewLetterId = HEBREW_LETTER_ID_BY_TETRAGRAMMATON_LETTER[letterKey] || "";
+
+ return [{
+ type: "tetragrammaton",
+ id: `${letterKey}-${elementId}`,
+ label: `${letter} · ${elementName}`,
+ data: {
+ letter,
+ elementId,
+ elementName,
+ hebrewLetterId
+ },
+ __key: `tetragrammaton|${letterKey}|${elementId}|${card.id || normalizeTarotCardLookupName(card.name)}`
+ }];
+ }
+
+ function getSmallCardModality(rankNumber) {
+ const numeric = Number(rankNumber);
+ if (!Number.isFinite(numeric) || numeric < 2 || numeric > 10) {
+ return "";
+ }
+
+ if (numeric <= 4) {
+ return "cardinal";
+ }
+ if (numeric <= 7) {
+ return "fixed";
+ }
+ return "mutable";
+ }
+
+ function buildSmallCardRulershipRelation(card) {
+ if (!card || card.arcana !== "Minor") {
+ return null;
+ }
+
+ const rankKey = String(card.rank || "").trim().toLowerCase();
+ const rankNumber = MINOR_RANK_NUMBER_BY_NAME[rankKey];
+ const modality = getSmallCardModality(rankNumber);
+ if (!modality) {
+ return null;
+ }
+
+ const suitKey = String(card.suit || "").trim().toLowerCase();
+ const signId = SMALL_CARD_SIGN_BY_MODALITY_AND_SUIT[modality]?.[suitKey] || "";
+ if (!signId) {
+ return null;
+ }
+
+ const referenceData = typeof getReferenceData === "function" ? getReferenceData() : null;
+ const sign = (Array.isArray(referenceData?.signs) ? referenceData.signs : [])
+ .find((entry) => String(entry?.id || "").trim().toLowerCase() === signId);
+
+ const signName = String(sign?.name || toTitleCase(signId));
+ const signSymbol = String(sign?.symbol || "").trim();
+ const modalityName = toTitleCase(modality);
+
+ return {
+ type: "zodiacRulership",
+ id: `${signId}-${rankKey}-${suitKey}`,
+ label: `Sign type: ${modalityName} · ${signSymbol} ${signName}`.trim(),
+ data: {
+ signId,
+ signName,
+ symbol: signSymbol,
+ modality,
+ rank: card.rank,
+ suit: card.suit
+ },
+ __key: `zodiacRulership|${signId}|${rankKey}|${suitKey}`
+ };
+ }
+
+ function findSephirahForMinorCard(card, kabTree) {
+ if (!card || card.arcana !== "Minor" || !kabTree) {
+ return null;
+ }
+
+ const rankKey = String(card.rank || "").trim().toLowerCase();
+ const plural = MINOR_PLURAL_BY_RANK[rankKey];
+ if (!plural) {
+ return null;
+ }
+
+ const matcher = new RegExp(`\\b4\\s+${plural}\\b`, "i");
+ return (kabTree.sephiroth || []).find((seph) => matcher.test(String(seph?.tarot || ""))) || null;
+ }
+
+ return {
+ buildTypeLabel,
+ buildElementRelationsForCard,
+ buildTetragrammatonRelationsForCard,
+ buildSmallCardRulershipRelation,
+ findSephirahForMinorCard
+ };
+ }
+
+ window.TarotCardDerivations = {
+ createTarotCardDerivations
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-tarot-detail.js b/app/ui-tarot-detail.js
new file mode 100644
index 0000000..cc27e03
--- /dev/null
+++ b/app/ui-tarot-detail.js
@@ -0,0 +1,312 @@
+(function () {
+ function createTarotDetailRenderer(dependencies) {
+ const {
+ getMonthRefsByCardId,
+ getMagickDataset,
+ resolveTarotCardImage,
+ getDisplayCardName,
+ buildTypeLabel,
+ clearChildren,
+ normalizeRelationObject,
+ buildElementRelationsForCard,
+ buildTetragrammatonRelationsForCard,
+ buildSmallCardRulershipRelation,
+ buildSmallCardCourtLinkRelations,
+ buildCubeRelationsForCard,
+ parseMonthDayToken,
+ createRelationListItem,
+ findSephirahForMinorCard
+ } = dependencies || {};
+
+ function renderStaticRelationGroup(targetEl, cardEl, relations) {
+ clearChildren(targetEl);
+ if (!targetEl || !cardEl) return;
+ if (!relations.length) {
+ cardEl.hidden = true;
+ return;
+ }
+
+ cardEl.hidden = false;
+
+ relations.forEach((relation) => {
+ targetEl.appendChild(createRelationListItem(relation));
+ });
+ }
+
+ function renderDetail(card, elements) {
+ if (!card || !elements) {
+ return;
+ }
+
+ const cardDisplayName = getDisplayCardName(card);
+ const imageUrl = typeof resolveTarotCardImage === "function"
+ ? resolveTarotCardImage(card.name)
+ : null;
+
+ if (elements.tarotDetailImageEl) {
+ if (imageUrl) {
+ elements.tarotDetailImageEl.src = imageUrl;
+ elements.tarotDetailImageEl.alt = cardDisplayName || card.name;
+ elements.tarotDetailImageEl.style.display = "block";
+ elements.tarotDetailImageEl.style.cursor = "zoom-in";
+ elements.tarotDetailImageEl.title = "Click to enlarge";
+ } else {
+ elements.tarotDetailImageEl.removeAttribute("src");
+ elements.tarotDetailImageEl.alt = "";
+ elements.tarotDetailImageEl.style.display = "none";
+ elements.tarotDetailImageEl.style.cursor = "default";
+ elements.tarotDetailImageEl.removeAttribute("title");
+ }
+ }
+
+ if (elements.tarotDetailNameEl) {
+ elements.tarotDetailNameEl.textContent = cardDisplayName || card.name;
+ }
+
+ if (elements.tarotDetailTypeEl) {
+ elements.tarotDetailTypeEl.textContent = buildTypeLabel(card);
+ }
+
+ if (elements.tarotDetailSummaryEl) {
+ elements.tarotDetailSummaryEl.textContent = card.summary || "--";
+ }
+
+ if (elements.tarotDetailUprightEl) {
+ elements.tarotDetailUprightEl.textContent = card.meanings?.upright || "--";
+ }
+
+ if (elements.tarotDetailReversedEl) {
+ elements.tarotDetailReversedEl.textContent = card.meanings?.reversed || "--";
+ }
+
+ const meaningText = String(card.meaning || card.meanings?.upright || "").trim();
+ if (elements.tarotMetaMeaningCardEl && elements.tarotDetailMeaningEl) {
+ if (meaningText) {
+ elements.tarotMetaMeaningCardEl.hidden = false;
+ elements.tarotDetailMeaningEl.textContent = meaningText;
+ } else {
+ elements.tarotMetaMeaningCardEl.hidden = true;
+ elements.tarotDetailMeaningEl.textContent = "--";
+ }
+ }
+
+ clearChildren(elements.tarotDetailKeywordsEl);
+ (card.keywords || []).forEach((keyword) => {
+ const chip = document.createElement("span");
+ chip.className = "tarot-keyword-chip";
+ chip.textContent = keyword;
+ elements.tarotDetailKeywordsEl?.appendChild(chip);
+ });
+
+ const allRelations = (card.relations || [])
+ .map((relation, index) => normalizeRelationObject(relation, index))
+ .filter(Boolean);
+
+ const uniqueByKey = new Set();
+ const dedupedRelations = allRelations.filter((relation) => {
+ const key = `${relation.type || "relation"}|${relation.id || ""}|${relation.label || ""}`;
+ if (uniqueByKey.has(key)) return false;
+ uniqueByKey.add(key);
+ return true;
+ });
+
+ const planetRelations = dedupedRelations.filter((relation) =>
+ relation.type === "planetCorrespondence" || relation.type === "decanRuler" || relation.type === "planet"
+ );
+
+ const zodiacRelations = dedupedRelations.filter((relation) =>
+ relation.type === "zodiacCorrespondence" || relation.type === "zodiac" || relation.type === "decan"
+ );
+
+ const courtDateRelations = dedupedRelations.filter((relation) => relation.type === "courtDateWindow");
+
+ const hebrewRelations = dedupedRelations.filter((relation) => relation.type === "hebrewLetter");
+ const baseElementRelations = dedupedRelations.filter((relation) => relation.type === "element");
+ const elementRelations = buildElementRelationsForCard(card, baseElementRelations);
+ const tetragrammatonRelations = buildTetragrammatonRelationsForCard(card);
+ const smallCardRulershipRelation = buildSmallCardRulershipRelation(card);
+ const zodiacRelationsWithRulership = smallCardRulershipRelation
+ ? [...zodiacRelations, smallCardRulershipRelation]
+ : zodiacRelations;
+ const smallCardCourtLinkRelations = buildSmallCardCourtLinkRelations(card, dedupedRelations);
+ const mergedCourtDateRelations = [...courtDateRelations, ...smallCardCourtLinkRelations];
+ const cubeRelations = buildCubeRelationsForCard(card);
+ const monthRelations = (getMonthRefsByCardId().get(card.id) || []).map((month, index) => {
+ const dateRange = String(month?.dateRange || "").trim();
+ const context = String(month?.context || "").trim();
+ const labelBase = dateRange || month.name;
+ const label = context ? `${labelBase} · ${context}` : labelBase;
+
+ return {
+ type: "calendarMonth",
+ id: month.id,
+ label,
+ data: {
+ monthId: month.id,
+ name: month.name,
+ monthOrder: Number.isFinite(Number(month.order)) ? Number(month.order) : null,
+ dateRange: dateRange || null,
+ dateStart: month.startToken || null,
+ dateEnd: month.endToken || null,
+ context: context || null,
+ source: month.source || null
+ },
+ __key: `calendarMonth|${month.id}|${month.uniqueKey || index}`
+ };
+ });
+
+ const relationMonthRows = dedupedRelations
+ .filter((relation) => relation.type === "calendarMonth")
+ .map((relation) => {
+ const dateRange = String(relation?.data?.dateRange || "").trim();
+ const baseName = relation?.data?.name || relation.label;
+ const label = dateRange && baseName
+ ? `${baseName} · ${dateRange}`
+ : baseName;
+
+ return {
+ type: "calendarMonth",
+ id: relation?.data?.monthId || relation.id,
+ label,
+ data: {
+ monthId: relation?.data?.monthId || relation.id,
+ name: relation?.data?.name || relation.label,
+ monthOrder: Number.isFinite(Number(relation?.data?.monthOrder))
+ ? Number(relation.data.monthOrder)
+ : null,
+ dateRange: dateRange || null,
+ dateStart: relation?.data?.dateStart || null,
+ dateEnd: relation?.data?.dateEnd || null,
+ context: relation?.data?.signName || null
+ },
+ __key: relation.__key
+ };
+ })
+ .filter((entry) => entry.data.monthId);
+
+ const mergedMonthMap = new Map();
+ [...monthRelations, ...relationMonthRows].forEach((entry) => {
+ const monthId = entry?.data?.monthId;
+ if (!monthId) {
+ return;
+ }
+
+ const key = [
+ monthId,
+ String(entry?.data?.dateRange || "").trim().toLowerCase(),
+ String(entry?.data?.context || "").trim().toLowerCase(),
+ String(entry?.label || "").trim().toLowerCase()
+ ].join("|");
+
+ if (!mergedMonthMap.has(key)) {
+ mergedMonthMap.set(key, entry);
+ }
+ });
+
+ const mergedMonthRelations = [...mergedMonthMap.values()].sort((left, right) => {
+ const orderLeft = Number.isFinite(Number(left?.data?.monthOrder)) ? Number(left.data.monthOrder) : 999;
+ const orderRight = Number.isFinite(Number(right?.data?.monthOrder)) ? Number(right.data.monthOrder) : 999;
+
+ if (orderLeft !== orderRight) {
+ return orderLeft - orderRight;
+ }
+
+ const startLeft = parseMonthDayToken(left?.data?.dateStart);
+ const startRight = parseMonthDayToken(right?.data?.dateStart);
+ const dayLeft = startLeft ? startLeft.day : 999;
+ const dayRight = startRight ? startRight.day : 999;
+ if (dayLeft !== dayRight) {
+ return dayLeft - dayRight;
+ }
+
+ return String(left.label || "").localeCompare(String(right.label || ""));
+ });
+
+ renderStaticRelationGroup(elements.tarotDetailPlanetEl, elements.tarotMetaPlanetCardEl, planetRelations);
+ renderStaticRelationGroup(elements.tarotDetailElementEl, elements.tarotMetaElementCardEl, elementRelations);
+ renderStaticRelationGroup(elements.tarotDetailTetragrammatonEl, elements.tarotMetaTetragrammatonCardEl, tetragrammatonRelations);
+ renderStaticRelationGroup(elements.tarotDetailZodiacEl, elements.tarotMetaZodiacCardEl, zodiacRelationsWithRulership);
+ renderStaticRelationGroup(elements.tarotDetailCourtDateEl, elements.tarotMetaCourtDateCardEl, mergedCourtDateRelations);
+ renderStaticRelationGroup(elements.tarotDetailHebrewEl, elements.tarotMetaHebrewCardEl, hebrewRelations);
+ renderStaticRelationGroup(elements.tarotDetailCubeEl, elements.tarotMetaCubeCardEl, cubeRelations);
+ renderStaticRelationGroup(elements.tarotDetailCalendarEl, elements.tarotMetaCalendarCardEl, mergedMonthRelations);
+
+ const kabPathEl = elements.tarotKabPathEl;
+ if (kabPathEl) {
+ const kabTree = getMagickDataset()?.grouped?.kabbalah?.["kabbalah-tree"];
+ const kabPath = (card.arcana === "Major" && typeof card.number === "number" && kabTree)
+ ? kabTree.paths.find((path) => path.tarot?.trumpNumber === card.number)
+ : null;
+ const kabSeph = !kabPath ? findSephirahForMinorCard(card, kabTree) : null;
+
+ if (kabPath) {
+ const letter = kabPath.hebrewLetter || {};
+ const fromName = kabTree.sephiroth.find((seph) => seph.number === kabPath.connects.from)?.name || kabPath.connects.from;
+ const toName = kabTree.sephiroth.find((seph) => seph.number === kabPath.connects.to)?.name || kabPath.connects.to;
+ const astro = kabPath.astrology ? `${kabPath.astrology.name} (${kabPath.astrology.type})` : "";
+
+ kabPathEl.innerHTML = `
+ Kabbalah Tree — Path ${kabPath.pathNumber}
+
+ ${letter.char || ""}
+
+ ${letter.transliteration || ""} — “${letter.meaning || ""}” · ${letter.letterType || ""}
+ ${fromName} → ${toName}${astro ? " · " + astro : ""}
+
+
`;
+
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "kab-tarot-link";
+ btn.textContent = `View Path ${kabPath.pathNumber} in Kabbalah Tree`;
+ btn.addEventListener("click", () => {
+ document.dispatchEvent(new CustomEvent("tarot:view-kab-path", {
+ detail: { pathNumber: kabPath.pathNumber }
+ }));
+ });
+ kabPathEl.appendChild(btn);
+ kabPathEl.hidden = false;
+ } else if (kabSeph) {
+ const hebrewName = kabSeph.nameHebrew ? ` (${kabSeph.nameHebrew})` : "";
+ const translation = kabSeph.translation ? ` — ${kabSeph.translation}` : "";
+ const planetInfo = kabSeph.planet || "";
+ const tarotInfo = kabSeph.tarot ? ` · ${kabSeph.tarot}` : "";
+
+ kabPathEl.innerHTML = `
+ Kabbalah Tree — Sephirah ${kabSeph.number}
+
+ ${kabSeph.number}
+
+ ${kabSeph.name || ""}${hebrewName}${translation}
+ ${planetInfo}${tarotInfo}
+
+
`;
+
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "kab-tarot-link";
+ btn.textContent = `View Sephirah ${kabSeph.number} in Kabbalah Tree`;
+ btn.addEventListener("click", () => {
+ document.dispatchEvent(new CustomEvent("tarot:view-kab-path", {
+ detail: { pathNumber: kabSeph.number }
+ }));
+ });
+ kabPathEl.appendChild(btn);
+ kabPathEl.hidden = false;
+ } else {
+ kabPathEl.hidden = true;
+ kabPathEl.innerHTML = "";
+ }
+ }
+ }
+
+ return {
+ renderStaticRelationGroup,
+ renderDetail
+ };
+ }
+
+ window.TarotDetailUi = {
+ createTarotDetailRenderer
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-tarot-relation-display.js b/app/ui-tarot-relation-display.js
new file mode 100644
index 0000000..934b895
--- /dev/null
+++ b/app/ui-tarot-relation-display.js
@@ -0,0 +1,286 @@
+(function () {
+ function createTarotRelationDisplay(dependencies) {
+ const { normalizeRelationId } = dependencies || {};
+
+ function formatRelation(relation) {
+ if (typeof relation === "string") {
+ return relation;
+ }
+
+ if (!relation || typeof relation !== "object") {
+ return "";
+ }
+
+ if (typeof relation.label === "string" && relation.label.trim()) {
+ return relation.label;
+ }
+
+ if (relation.type === "hebrewLetter" && relation.data) {
+ const glyph = relation.data.glyph || "";
+ const name = relation.data.name || relation.id || "Unknown";
+ const latin = relation.data.latin ? ` (${relation.data.latin})` : "";
+ const index = Number.isFinite(relation.data.index) ? relation.data.index : "?";
+ const value = Number.isFinite(relation.data.value) ? relation.data.value : "?";
+ const meaning = relation.data.meaning ? ` · ${relation.data.meaning}` : "";
+ return `Hebrew Letter: ${glyph} ${name}${latin} (index ${index}, value ${value})${meaning}`.trim();
+ }
+
+ if (typeof relation.type === "string" && typeof relation.id === "string") {
+ return `${relation.type}: ${relation.id}`;
+ }
+
+ return "";
+ }
+
+ function relationKey(relation, index) {
+ const safeType = String(relation?.type || "relation");
+ const safeId = String(relation?.id || index || "0");
+ const safeLabel = String(relation?.label || relation?.text || "");
+ return `${safeType}|${safeId}|${safeLabel}`;
+ }
+
+ function normalizeRelationObject(relation, index) {
+ if (relation && typeof relation === "object") {
+ const label = formatRelation(relation);
+ if (!label) {
+ return null;
+ }
+
+ return {
+ ...relation,
+ label,
+ __key: relationKey(relation, index)
+ };
+ }
+
+ const text = formatRelation(relation);
+ if (!text) {
+ return null;
+ }
+
+ return {
+ type: "text",
+ id: `text-${index}`,
+ label: text,
+ data: { value: text },
+ __key: relationKey({ type: "text", id: `text-${index}`, label: text }, index)
+ };
+ }
+
+ function formatRelationDataLines(relation) {
+ if (!relation || typeof relation !== "object") {
+ return "--";
+ }
+
+ const data = relation.data;
+ if (!data || typeof data !== "object") {
+ return "(no additional relation data)";
+ }
+
+ const lines = Object.entries(data)
+ .filter(([, value]) => value !== null && value !== undefined && String(value).trim() !== "")
+ .map(([key, value]) => `${key}: ${value}`);
+
+ return lines.length ? lines.join("\n") : "(no additional relation data)";
+ }
+
+ function getRelationNavTarget(relation) {
+ const t = relation?.type;
+ const d = relation?.data || {};
+ if ((t === "planetCorrespondence" || t === "decanRuler") && d.planetId) {
+ return {
+ event: "nav:planet",
+ detail: { planetId: d.planetId },
+ label: `Open ${d.name || d.planetId} in Planets`
+ };
+ }
+ if (t === "planet") {
+ const planetId = normalizeRelationId(d.name || relation?.id || "");
+ if (!planetId) return null;
+ return {
+ event: "nav:planet",
+ detail: { planetId },
+ label: `Open ${d.name || planetId} in Planets`
+ };
+ }
+ if (t === "element") {
+ const elementId = d.elementId || relation?.id;
+ if (!elementId) {
+ return null;
+ }
+ return {
+ event: "nav:elements",
+ detail: { elementId },
+ label: `Open ${d.name || elementId} in Elements`
+ };
+ }
+ if (t === "tetragrammaton") {
+ if (!d.hebrewLetterId) {
+ return null;
+ }
+ return {
+ event: "nav:alphabet",
+ detail: { alphabet: "hebrew", hebrewLetterId: d.hebrewLetterId },
+ label: `Open ${d.letter || d.hebrewLetterId} in Alphabet`
+ };
+ }
+ if (t === "tarotCard") {
+ const cardName = d.cardName || relation?.id;
+ if (!cardName) {
+ return null;
+ }
+ return {
+ event: "nav:tarot-trump",
+ detail: { cardName },
+ label: `Open ${cardName} in Tarot`
+ };
+ }
+ if (t === "zodiacRulership") {
+ const signId = d.signId || relation?.id;
+ if (!signId) {
+ return null;
+ }
+ return {
+ event: "nav:zodiac",
+ detail: { signId },
+ label: `Open ${d.signName || signId} in Zodiac`
+ };
+ }
+ if (t === "zodiacCorrespondence" || t === "zodiac") {
+ const signId = d.signId || relation?.id || normalizeRelationId(d.name || "");
+ if (!signId) {
+ return null;
+ }
+ return {
+ event: "nav:zodiac",
+ detail: { signId },
+ label: `Open ${d.name || signId} in Zodiac`
+ };
+ }
+ if (t === "decan") {
+ const signId = d.signId || normalizeRelationId(d.signName || relation?.id || "");
+ if (!signId) {
+ return null;
+ }
+ return {
+ event: "nav:zodiac",
+ detail: { signId },
+ label: `Open ${d.signName || signId} in Zodiac`
+ };
+ }
+ if (t === "hebrewLetter") {
+ const hebrewLetterId = d.id || relation?.id;
+ if (!hebrewLetterId) {
+ return null;
+ }
+ return {
+ event: "nav:alphabet",
+ detail: { alphabet: "hebrew", hebrewLetterId },
+ label: `Open ${d.name || hebrewLetterId} in Alphabet`
+ };
+ }
+ if (t === "calendarMonth") {
+ const monthId = d.monthId || relation?.id;
+ if (!monthId) {
+ return null;
+ }
+ return {
+ event: "nav:calendar-month",
+ detail: { monthId },
+ label: `Open ${d.name || monthId} in Calendar`
+ };
+ }
+ if (t === "cubeFace") {
+ const wallId = d.wallId || relation?.id;
+ if (!wallId) {
+ return null;
+ }
+ return {
+ event: "nav:cube",
+ detail: { wallId, edgeId: "" },
+ label: `Open ${d.wallName || wallId} face in Cube`
+ };
+ }
+ if (t === "cubeEdge") {
+ const edgeId = d.edgeId || relation?.id;
+ if (!edgeId) {
+ return null;
+ }
+ return {
+ event: "nav:cube",
+ detail: { edgeId, wallId: d.wallId || undefined },
+ label: `Open ${d.edgeName || edgeId} edge in Cube`
+ };
+ }
+ if (t === "cubeConnector") {
+ const connectorId = d.connectorId || relation?.id;
+ if (!connectorId) {
+ return null;
+ }
+ return {
+ event: "nav:cube",
+ detail: { connectorId },
+ label: `Open ${d.connectorName || connectorId} connector in Cube`
+ };
+ }
+ if (t === "cubeCenter") {
+ return {
+ event: "nav:cube",
+ detail: { nodeType: "center", primalPoint: true },
+ label: "Open Primal Point in Cube"
+ };
+ }
+ return null;
+ }
+
+ function createRelationListItem(relation) {
+ const item = document.createElement("li");
+ const navTarget = getRelationNavTarget(relation);
+
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "tarot-relation-btn";
+ button.dataset.relationKey = relation.__key;
+ button.textContent = relation.label;
+ item.appendChild(button);
+
+ if (!navTarget) {
+ button.classList.add("tarot-relation-btn-static");
+ }
+ button.addEventListener("click", () => {
+ if (navTarget) {
+ document.dispatchEvent(new CustomEvent(navTarget.event, { detail: navTarget.detail }));
+ }
+ });
+
+ if (navTarget) {
+ item.className = "tarot-rel-item";
+ const navBtn = document.createElement("button");
+ navBtn.type = "button";
+ navBtn.className = "tarot-rel-nav-btn";
+ navBtn.title = navTarget.label;
+ navBtn.textContent = "\u2197";
+ navBtn.addEventListener("click", (event) => {
+ event.stopPropagation();
+ document.dispatchEvent(new CustomEvent(navTarget.event, { detail: navTarget.detail }));
+ });
+ item.appendChild(navBtn);
+ }
+
+ return item;
+ }
+
+ return {
+ formatRelation,
+ relationKey,
+ normalizeRelationObject,
+ formatRelationDataLines,
+ getRelationNavTarget,
+ createRelationListItem
+ };
+ }
+
+ window.TarotRelationDisplay = {
+ createTarotRelationDisplay
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-tarot.js b/app/ui-tarot.js
index 1dd4ade..d392c66 100644
--- a/app/ui-tarot.js
+++ b/app/ui-tarot.js
@@ -2,6 +2,9 @@
const { resolveTarotCardImage, getTarotCardDisplayName, getTarotCardSearchAliases } = window.TarotCardImages || {};
const tarotHouseUi = window.TarotHouseUi || {};
const tarotRelationsUi = window.TarotRelationsUi || {};
+ const tarotCardDerivations = window.TarotCardDerivations || {};
+ const tarotDetailUi = window.TarotDetailUi || {};
+ const tarotRelationDisplay = window.TarotRelationDisplay || {};
const state = {
initialized: false,
@@ -242,6 +245,56 @@
.replace(/(^-|-$)/g, "");
}
+ if (typeof tarotRelationDisplay.createTarotRelationDisplay !== "function") {
+ throw new Error("TarotRelationDisplay.createTarotRelationDisplay is unavailable. Ensure app/ui-tarot-relation-display.js loads before app/ui-tarot.js.");
+ }
+
+ if (typeof tarotCardDerivations.createTarotCardDerivations !== "function") {
+ throw new Error("TarotCardDerivations.createTarotCardDerivations is unavailable. Ensure app/ui-tarot-card-derivations.js loads before app/ui-tarot.js.");
+ }
+
+ if (typeof tarotDetailUi.createTarotDetailRenderer !== "function") {
+ throw new Error("TarotDetailUi.createTarotDetailRenderer is unavailable. Ensure app/ui-tarot-detail.js loads before app/ui-tarot.js.");
+ }
+
+ const tarotCardDerivationsUi = tarotCardDerivations.createTarotCardDerivations({
+ normalizeRelationId,
+ normalizeTarotCardLookupName,
+ toTitleCase,
+ getReferenceData: () => state.referenceData,
+ ELEMENT_NAME_BY_ID,
+ ELEMENT_HEBREW_LETTER_BY_ID,
+ ELEMENT_HEBREW_CHAR_BY_ID,
+ HEBREW_LETTER_ID_BY_TETRAGRAMMATON_LETTER,
+ ACE_ELEMENT_BY_CARD_NAME,
+ COURT_ELEMENT_BY_RANK,
+ MINOR_RANK_NUMBER_BY_NAME,
+ SMALL_CARD_SIGN_BY_MODALITY_AND_SUIT,
+ MINOR_PLURAL_BY_RANK
+ });
+
+ const tarotRelationDisplayUi = tarotRelationDisplay.createTarotRelationDisplay({
+ normalizeRelationId
+ });
+
+ const tarotDetailRenderer = tarotDetailUi.createTarotDetailRenderer({
+ getMonthRefsByCardId: () => state.monthRefsByCardId,
+ getMagickDataset: () => state.magickDataset,
+ resolveTarotCardImage,
+ getDisplayCardName,
+ buildTypeLabel,
+ clearChildren,
+ normalizeRelationObject,
+ buildElementRelationsForCard,
+ buildTetragrammatonRelationsForCard,
+ buildSmallCardRulershipRelation,
+ buildSmallCardCourtLinkRelations,
+ buildCubeRelationsForCard,
+ parseMonthDayToken,
+ createRelationListItem,
+ findSephirahForMinorCard
+ });
+
function normalizeSearchValue(value) {
return String(value || "").trim().toLowerCase();
}
@@ -299,165 +352,16 @@
.join(" ");
}
- function resolveElementIdForCard(card) {
- if (!card) {
- return "";
- }
-
- const cardLookupName = normalizeTarotCardLookupName(card.name);
- const rankKey = String(card.rank || "").trim().toLowerCase();
-
- return ACE_ELEMENT_BY_CARD_NAME[cardLookupName] || COURT_ELEMENT_BY_RANK[rankKey] || "";
- }
-
- function createElementRelation(card, elementId, sourceKind, sourceLabel) {
- if (!card || !elementId) {
- return null;
- }
-
- const elementName = ELEMENT_NAME_BY_ID[elementId] || toTitleCase(elementId);
- const hebrewLetter = ELEMENT_HEBREW_LETTER_BY_ID[elementId] || "";
- const hebrewChar = ELEMENT_HEBREW_CHAR_BY_ID[elementId] || "";
- const relationLabel = `${elementName}${hebrewChar ? ` (${hebrewChar})` : (hebrewLetter ? ` (${hebrewLetter})` : "")} · ${sourceLabel}`;
-
- return {
- type: "element",
- id: elementId,
- label: relationLabel,
- data: {
- elementId,
- name: elementName,
- tarotCard: card.name,
- hebrewLetter,
- hebrewChar,
- sourceKind,
- sourceLabel,
- rank: card.rank || "",
- suit: card.suit || ""
- },
- __key: `element|${elementId}|${sourceKind}|${normalizeRelationId(sourceLabel)}|${card.id || normalizeTarotCardLookupName(card.name)}`
- };
- }
-
function buildElementRelationsForCard(card, baseElementRelations = []) {
- if (!card) {
- return [];
- }
-
- if (card.arcana === "Major") {
- return Array.isArray(baseElementRelations) ? [...baseElementRelations] : [];
- }
-
- const relations = [];
-
- const suitKey = String(card.suit || "").trim().toLowerCase();
- const suitElementId = SUIT_ELEMENT_BY_SUIT[suitKey] || "";
- if (suitElementId) {
- const suitRelation = createElementRelation(card, suitElementId, "suit", `Suit: ${card.suit}`);
- if (suitRelation) {
- relations.push(suitRelation);
- }
- }
-
- const rankKey = String(card.rank || "").trim().toLowerCase();
- const courtElementId = COURT_ELEMENT_BY_RANK[rankKey] || "";
- if (courtElementId) {
- const courtRelation = createElementRelation(card, courtElementId, "court", `Court: ${card.rank}`);
- if (courtRelation) {
- relations.push(courtRelation);
- }
- }
-
- return relations;
+ return tarotCardDerivationsUi.buildElementRelationsForCard(card, baseElementRelations);
}
function buildTetragrammatonRelationsForCard(card) {
- if (!card) {
- return [];
- }
-
- const elementId = resolveElementIdForCard(card);
- if (!elementId) {
- return [];
- }
-
- const letter = ELEMENT_HEBREW_LETTER_BY_ID[elementId] || "";
- if (!letter) {
- return [];
- }
-
- const elementName = ELEMENT_NAME_BY_ID[elementId] || elementId;
- const letterKey = String(letter || "").trim().toLowerCase();
- const hebrewLetterId = HEBREW_LETTER_ID_BY_TETRAGRAMMATON_LETTER[letterKey] || "";
-
- return [{
- type: "tetragrammaton",
- id: `${letterKey}-${elementId}`,
- label: `${letter} · ${elementName}`,
- data: {
- letter,
- elementId,
- elementName,
- hebrewLetterId
- },
- __key: `tetragrammaton|${letterKey}|${elementId}|${card.id || normalizeTarotCardLookupName(card.name)}`
- }];
- }
-
- function getSmallCardModality(rankNumber) {
- const numeric = Number(rankNumber);
- if (!Number.isFinite(numeric) || numeric < 2 || numeric > 10) {
- return "";
- }
-
- if (numeric <= 4) {
- return "cardinal";
- }
- if (numeric <= 7) {
- return "fixed";
- }
- return "mutable";
+ return tarotCardDerivationsUi.buildTetragrammatonRelationsForCard(card);
}
function buildSmallCardRulershipRelation(card) {
- if (!card || card.arcana !== "Minor") {
- return null;
- }
-
- const rankKey = String(card.rank || "").trim().toLowerCase();
- const rankNumber = MINOR_RANK_NUMBER_BY_NAME[rankKey];
- const modality = getSmallCardModality(rankNumber);
- if (!modality) {
- return null;
- }
-
- const suitKey = String(card.suit || "").trim().toLowerCase();
- const signId = SMALL_CARD_SIGN_BY_MODALITY_AND_SUIT[modality]?.[suitKey] || "";
- if (!signId) {
- return null;
- }
-
- const sign = (Array.isArray(state.referenceData?.signs) ? state.referenceData.signs : [])
- .find((entry) => String(entry?.id || "").trim().toLowerCase() === signId);
-
- const signName = String(sign?.name || toTitleCase(signId));
- const signSymbol = String(sign?.symbol || "").trim();
- const modalityName = toTitleCase(modality);
-
- return {
- type: "zodiacRulership",
- id: `${signId}-${rankKey}-${suitKey}`,
- label: `Sign type: ${modalityName} · ${signSymbol} ${signName}`.trim(),
- data: {
- signId,
- signName,
- symbol: signSymbol,
- modality,
- rank: card.rank,
- suit: card.suit
- },
- __key: `zodiacRulership|${signId}|${rankKey}|${suitKey}`
- };
+ return tarotCardDerivationsUi.buildSmallCardRulershipRelation(card);
}
function buildCourtCardByDecanId(cards) {
@@ -566,21 +470,7 @@
}
function buildTypeLabel(card) {
- if (card.arcana === "Major") {
- return typeof card.number === "number"
- ? `Major Arcana · ${card.number}`
- : "Major Arcana";
- }
-
- const parts = ["Minor Arcana"];
- if (card.rank) {
- parts.push(card.rank);
- }
- if (card.suit) {
- parts.push(card.suit);
- }
-
- return parts.join(" · ");
+ return tarotCardDerivationsUi.buildTypeLabel(card);
}
const MINOR_PLURAL_BY_RANK = {
@@ -597,100 +487,23 @@
};
function findSephirahForMinorCard(card, kabTree) {
- if (!card || card.arcana !== "Minor" || !kabTree) {
- return null;
- }
-
- const rankKey = String(card.rank || "").trim().toLowerCase();
- const plural = MINOR_PLURAL_BY_RANK[rankKey];
- if (!plural) {
- return null;
- }
-
- const matcher = new RegExp(`\\b4\\s+${plural}\\b`, "i");
- return (kabTree.sephiroth || []).find((seph) => matcher.test(String(seph?.tarot || ""))) || null;
+ return tarotCardDerivationsUi.findSephirahForMinorCard(card, kabTree);
}
function formatRelation(relation) {
- if (typeof relation === "string") {
- return relation;
- }
-
- if (!relation || typeof relation !== "object") {
- return "";
- }
-
- if (typeof relation.label === "string" && relation.label.trim()) {
- return relation.label;
- }
-
- if (relation.type === "hebrewLetter" && relation.data) {
- const glyph = relation.data.glyph || "";
- const name = relation.data.name || relation.id || "Unknown";
- const latin = relation.data.latin ? ` (${relation.data.latin})` : "";
- const index = Number.isFinite(relation.data.index) ? relation.data.index : "?";
- const value = Number.isFinite(relation.data.value) ? relation.data.value : "?";
- const meaning = relation.data.meaning ? ` · ${relation.data.meaning}` : "";
- return `Hebrew Letter: ${glyph} ${name}${latin} (index ${index}, value ${value})${meaning}`.trim();
- }
-
- if (typeof relation.type === "string" && typeof relation.id === "string") {
- return `${relation.type}: ${relation.id}`;
- }
-
- return "";
+ return tarotRelationDisplayUi.formatRelation(relation);
}
function relationKey(relation, index) {
- const safeType = String(relation?.type || "relation");
- const safeId = String(relation?.id || index || "0");
- const safeLabel = String(relation?.label || relation?.text || "");
- return `${safeType}|${safeId}|${safeLabel}`;
+ return tarotRelationDisplayUi.relationKey(relation, index);
}
function normalizeRelationObject(relation, index) {
- if (relation && typeof relation === "object") {
- const label = formatRelation(relation);
- if (!label) {
- return null;
- }
-
- return {
- ...relation,
- label,
- __key: relationKey(relation, index)
- };
- }
-
- const text = formatRelation(relation);
- if (!text) {
- return null;
- }
-
- return {
- type: "text",
- id: `text-${index}`,
- label: text,
- data: { value: text },
- __key: relationKey({ type: "text", id: `text-${index}`, label: text }, index)
- };
+ return tarotRelationDisplayUi.normalizeRelationObject(relation, index);
}
function formatRelationDataLines(relation) {
- if (!relation || typeof relation !== "object") {
- return "--";
- }
-
- const data = relation.data;
- if (!data || typeof data !== "object") {
- return "(no additional relation data)";
- }
-
- const lines = Object.entries(data)
- .filter(([, value]) => value !== null && value !== undefined && String(value).trim() !== "")
- .map(([key, value]) => `${key}: ${value}`);
-
- return lines.length ? lines.join("\n") : "(no additional relation data)";
+ return tarotRelationDisplayUi.formatRelationDataLines(relation);
}
function buildCubeRelationsForCard(card) {
@@ -703,472 +516,19 @@
// Returns nav dispatch config for relations that have a corresponding section,
// null for informational-only relations.
function getRelationNavTarget(relation) {
- const t = relation?.type;
- const d = relation?.data || {};
- if ((t === "planetCorrespondence" || t === "decanRuler") && d.planetId) {
- return {
- event: "nav:planet",
- detail: { planetId: d.planetId },
- label: `Open ${d.name || d.planetId} in Planets`
- };
- }
- if (t === "planet") {
- const planetId = normalizeRelationId(d.name || relation?.id || "");
- if (!planetId) return null;
- return {
- event: "nav:planet",
- detail: { planetId },
- label: `Open ${d.name || planetId} in Planets`
- };
- }
- if (t === "element") {
- const elementId = d.elementId || relation?.id;
- if (!elementId) {
- return null;
- }
- return {
- event: "nav:elements",
- detail: { elementId },
- label: `Open ${d.name || elementId} in Elements`
- };
- }
- if (t === "tetragrammaton") {
- if (!d.hebrewLetterId) {
- return null;
- }
- return {
- event: "nav:alphabet",
- detail: { alphabet: "hebrew", hebrewLetterId: d.hebrewLetterId },
- label: `Open ${d.letter || d.hebrewLetterId} in Alphabet`
- };
- }
- if (t === "tarotCard") {
- const cardName = d.cardName || relation?.id;
- if (!cardName) {
- return null;
- }
- return {
- event: "nav:tarot-trump",
- detail: { cardName },
- label: `Open ${cardName} in Tarot`
- };
- }
- if (t === "zodiacRulership") {
- const signId = d.signId || relation?.id;
- if (!signId) {
- return null;
- }
- return {
- event: "nav:zodiac",
- detail: { signId },
- label: `Open ${d.signName || signId} in Zodiac`
- };
- }
- if (t === "zodiacCorrespondence" || t === "zodiac") {
- const signId = d.signId || relation?.id || normalizeRelationId(d.name || "");
- if (!signId) {
- return null;
- }
- return {
- event: "nav:zodiac",
- detail: { signId },
- label: `Open ${d.name || signId} in Zodiac`
- };
- }
- if (t === "decan") {
- const signId = d.signId || normalizeRelationId(d.signName || relation?.id || "");
- if (!signId) {
- return null;
- }
- return {
- event: "nav:zodiac",
- detail: { signId },
- label: `Open ${d.signName || signId} in Zodiac`
- };
- }
- if (t === "hebrewLetter") {
- const hebrewLetterId = d.id || relation?.id;
- if (!hebrewLetterId) {
- return null;
- }
- return {
- event: "nav:alphabet",
- detail: { alphabet: "hebrew", hebrewLetterId },
- label: `Open ${d.name || hebrewLetterId} in Alphabet`
- };
- }
- if (t === "calendarMonth") {
- const monthId = d.monthId || relation?.id;
- if (!monthId) {
- return null;
- }
- return {
- event: "nav:calendar-month",
- detail: { monthId },
- label: `Open ${d.name || monthId} in Calendar`
- };
- }
- if (t === "cubeFace") {
- const wallId = d.wallId || relation?.id;
- if (!wallId) {
- return null;
- }
- return {
- event: "nav:cube",
- detail: { wallId, edgeId: "" },
- label: `Open ${d.wallName || wallId} face in Cube`
- };
- }
- if (t === "cubeEdge") {
- const edgeId = d.edgeId || relation?.id;
- if (!edgeId) {
- return null;
- }
- return {
- event: "nav:cube",
- detail: { edgeId, wallId: d.wallId || undefined },
- label: `Open ${d.edgeName || edgeId} edge in Cube`
- };
- }
- if (t === "cubeConnector") {
- const connectorId = d.connectorId || relation?.id;
- if (!connectorId) {
- return null;
- }
- return {
- event: "nav:cube",
- detail: { connectorId },
- label: `Open ${d.connectorName || connectorId} connector in Cube`
- };
- }
- if (t === "cubeCenter") {
- return {
- event: "nav:cube",
- detail: { nodeType: "center", primalPoint: true },
- label: "Open Primal Point in Cube"
- };
- }
- return null;
+ return tarotRelationDisplayUi.getRelationNavTarget(relation);
}
function createRelationListItem(relation) {
- const item = document.createElement("li");
- const navTarget = getRelationNavTarget(relation);
-
- const button = document.createElement("button");
- button.type = "button";
- button.className = "tarot-relation-btn";
- button.dataset.relationKey = relation.__key;
- button.textContent = relation.label;
- item.appendChild(button);
-
- if (!navTarget) {
- button.classList.add("tarot-relation-btn-static");
- }
- button.addEventListener("click", () => {
- if (navTarget) {
- document.dispatchEvent(new CustomEvent(navTarget.event, { detail: navTarget.detail }));
- }
- });
-
- if (navTarget) {
- item.className = "tarot-rel-item";
- const navBtn = document.createElement("button");
- navBtn.type = "button";
- navBtn.className = "tarot-rel-nav-btn";
- navBtn.title = navTarget.label;
- navBtn.textContent = "\u2197";
- navBtn.addEventListener("click", (e) => {
- e.stopPropagation();
- document.dispatchEvent(new CustomEvent(navTarget.event, { detail: navTarget.detail }));
- });
- item.appendChild(navBtn);
- }
-
- return item;
+ return tarotRelationDisplayUi.createRelationListItem(relation);
}
function renderStaticRelationGroup(targetEl, cardEl, relations) {
- clearChildren(targetEl);
- if (!targetEl || !cardEl) return;
- if (!relations.length) {
- cardEl.hidden = true;
- return;
- }
-
- cardEl.hidden = false;
-
- relations.forEach((relation) => {
- targetEl.appendChild(createRelationListItem(relation));
- });
+ tarotDetailRenderer.renderStaticRelationGroup(targetEl, cardEl, relations);
}
function renderDetail(card, elements) {
- if (!card || !elements) {
- return;
- }
-
- const cardDisplayName = getDisplayCardName(card);
- const imageUrl = typeof resolveTarotCardImage === "function"
- ? resolveTarotCardImage(card.name)
- : null;
-
- if (elements.tarotDetailImageEl) {
- if (imageUrl) {
- elements.tarotDetailImageEl.src = imageUrl;
- elements.tarotDetailImageEl.alt = cardDisplayName || card.name;
- elements.tarotDetailImageEl.style.display = "block";
- elements.tarotDetailImageEl.style.cursor = "zoom-in";
- elements.tarotDetailImageEl.title = "Click to enlarge";
- } else {
- elements.tarotDetailImageEl.removeAttribute("src");
- elements.tarotDetailImageEl.alt = "";
- elements.tarotDetailImageEl.style.display = "none";
- elements.tarotDetailImageEl.style.cursor = "default";
- elements.tarotDetailImageEl.removeAttribute("title");
- }
- }
-
- if (elements.tarotDetailNameEl) {
- elements.tarotDetailNameEl.textContent = cardDisplayName || card.name;
- }
-
- if (elements.tarotDetailTypeEl) {
- elements.tarotDetailTypeEl.textContent = buildTypeLabel(card);
- }
-
- if (elements.tarotDetailSummaryEl) {
- elements.tarotDetailSummaryEl.textContent = card.summary || "--";
- }
-
- if (elements.tarotDetailUprightEl) {
- elements.tarotDetailUprightEl.textContent = card.meanings?.upright || "--";
- }
-
- if (elements.tarotDetailReversedEl) {
- elements.tarotDetailReversedEl.textContent = card.meanings?.reversed || "--";
- }
-
- const meaningText = String(card.meaning || card.meanings?.upright || "").trim();
- if (elements.tarotMetaMeaningCardEl && elements.tarotDetailMeaningEl) {
- if (meaningText) {
- elements.tarotMetaMeaningCardEl.hidden = false;
- elements.tarotDetailMeaningEl.textContent = meaningText;
- } else {
- elements.tarotMetaMeaningCardEl.hidden = true;
- elements.tarotDetailMeaningEl.textContent = "--";
- }
- }
-
- clearChildren(elements.tarotDetailKeywordsEl);
- (card.keywords || []).forEach((keyword) => {
- const chip = document.createElement("span");
- chip.className = "tarot-keyword-chip";
- chip.textContent = keyword;
- elements.tarotDetailKeywordsEl?.appendChild(chip);
- });
-
- const allRelations = (card.relations || [])
- .map((relation, index) => normalizeRelationObject(relation, index))
- .filter(Boolean);
-
- const uniqueByKey = new Set();
- const dedupedRelations = allRelations.filter((relation) => {
- const key = `${relation.type || "relation"}|${relation.id || ""}|${relation.label || ""}`;
- if (uniqueByKey.has(key)) return false;
- uniqueByKey.add(key);
- return true;
- });
-
- const planetRelations = dedupedRelations.filter((relation) =>
- relation.type === "planetCorrespondence" || relation.type === "decanRuler" || relation.type === "planet"
- );
-
- const zodiacRelations = dedupedRelations.filter((relation) =>
- relation.type === "zodiacCorrespondence" || relation.type === "zodiac" || relation.type === "decan"
- );
-
- const courtDateRelations = dedupedRelations.filter((relation) => relation.type === "courtDateWindow");
-
- const hebrewRelations = dedupedRelations.filter((relation) => relation.type === "hebrewLetter");
- const baseElementRelations = dedupedRelations.filter((relation) => relation.type === "element");
- const elementRelations = buildElementRelationsForCard(card, baseElementRelations);
- const tetragrammatonRelations = buildTetragrammatonRelationsForCard(card);
- const smallCardRulershipRelation = buildSmallCardRulershipRelation(card);
- const zodiacRelationsWithRulership = smallCardRulershipRelation
- ? [...zodiacRelations, smallCardRulershipRelation]
- : zodiacRelations;
- const smallCardCourtLinkRelations = buildSmallCardCourtLinkRelations(card, dedupedRelations);
- const mergedCourtDateRelations = [...courtDateRelations, ...smallCardCourtLinkRelations];
- const cubeRelations = buildCubeRelationsForCard(card);
- const monthRelations = (state.monthRefsByCardId.get(card.id) || []).map((month, index) => {
- const dateRange = String(month?.dateRange || "").trim();
- const context = String(month?.context || "").trim();
- const labelBase = dateRange || month.name;
- const label = context ? `${labelBase} · ${context}` : labelBase;
-
- return {
- type: "calendarMonth",
- id: month.id,
- label,
- data: {
- monthId: month.id,
- name: month.name,
- monthOrder: Number.isFinite(Number(month.order)) ? Number(month.order) : null,
- dateRange: dateRange || null,
- dateStart: month.startToken || null,
- dateEnd: month.endToken || null,
- context: context || null,
- source: month.source || null
- },
- __key: `calendarMonth|${month.id}|${month.uniqueKey || index}`
- };
- });
-
- const relationMonthRows = dedupedRelations
- .filter((relation) => relation.type === "calendarMonth")
- .map((relation) => {
- const dateRange = String(relation?.data?.dateRange || "").trim();
- const baseName = relation?.data?.name || relation.label;
- const label = dateRange && baseName
- ? `${baseName} · ${dateRange}`
- : baseName;
-
- return {
- type: "calendarMonth",
- id: relation?.data?.monthId || relation.id,
- label,
- data: {
- monthId: relation?.data?.monthId || relation.id,
- name: relation?.data?.name || relation.label,
- monthOrder: Number.isFinite(Number(relation?.data?.monthOrder))
- ? Number(relation.data.monthOrder)
- : null,
- dateRange: dateRange || null,
- dateStart: relation?.data?.dateStart || null,
- dateEnd: relation?.data?.dateEnd || null,
- context: relation?.data?.signName || null
- },
- __key: relation.__key
- };
- })
- .filter((entry) => entry.data.monthId);
-
- const mergedMonthMap = new Map();
- [...monthRelations, ...relationMonthRows].forEach((entry) => {
- const monthId = entry?.data?.monthId;
- if (!monthId) {
- return;
- }
-
- const key = [
- monthId,
- String(entry?.data?.dateRange || "").trim().toLowerCase(),
- String(entry?.data?.context || "").trim().toLowerCase(),
- String(entry?.label || "").trim().toLowerCase()
- ].join("|");
-
- if (!mergedMonthMap.has(key)) {
- mergedMonthMap.set(key, entry);
- }
- });
-
- const mergedMonthRelations = [...mergedMonthMap.values()].sort((left, right) => {
- const orderLeft = Number.isFinite(Number(left?.data?.monthOrder)) ? Number(left.data.monthOrder) : 999;
- const orderRight = Number.isFinite(Number(right?.data?.monthOrder)) ? Number(right.data.monthOrder) : 999;
-
- if (orderLeft !== orderRight) {
- return orderLeft - orderRight;
- }
-
- const startLeft = parseMonthDayToken(left?.data?.dateStart);
- const startRight = parseMonthDayToken(right?.data?.dateStart);
- const dayLeft = startLeft ? startLeft.day : 999;
- const dayRight = startRight ? startRight.day : 999;
- if (dayLeft !== dayRight) {
- return dayLeft - dayRight;
- }
-
- return String(left.label || "").localeCompare(String(right.label || ""));
- });
-
- renderStaticRelationGroup(elements.tarotDetailPlanetEl, elements.tarotMetaPlanetCardEl, planetRelations);
- renderStaticRelationGroup(elements.tarotDetailElementEl, elements.tarotMetaElementCardEl, elementRelations);
- renderStaticRelationGroup(elements.tarotDetailTetragrammatonEl, elements.tarotMetaTetragrammatonCardEl, tetragrammatonRelations);
- renderStaticRelationGroup(elements.tarotDetailZodiacEl, elements.tarotMetaZodiacCardEl, zodiacRelationsWithRulership);
- renderStaticRelationGroup(elements.tarotDetailCourtDateEl, elements.tarotMetaCourtDateCardEl, mergedCourtDateRelations);
- renderStaticRelationGroup(elements.tarotDetailHebrewEl, elements.tarotMetaHebrewCardEl, hebrewRelations);
- renderStaticRelationGroup(elements.tarotDetailCubeEl, elements.tarotMetaCubeCardEl, cubeRelations);
- renderStaticRelationGroup(elements.tarotDetailCalendarEl, elements.tarotMetaCalendarCardEl, mergedMonthRelations);
-
- // ── Kabbalah Tree path cross-reference ─────────────────────────────────
- const kabPathEl = elements.tarotKabPathEl;
- if (kabPathEl) {
- const kabTree = state.magickDataset?.grouped?.kabbalah?.["kabbalah-tree"];
- const kabPath = (card.arcana === "Major" && typeof card.number === "number" && kabTree)
- ? kabTree.paths.find(p => p.tarot?.trumpNumber === card.number)
- : null;
- const kabSeph = !kabPath ? findSephirahForMinorCard(card, kabTree) : null;
-
- if (kabPath) {
- const letter = kabPath.hebrewLetter || {};
- const fromName = kabTree.sephiroth.find(s => s.number === kabPath.connects.from)?.name || kabPath.connects.from;
- const toName = kabTree.sephiroth.find(s => s.number === kabPath.connects.to)?.name || kabPath.connects.to;
- const astro = kabPath.astrology ? `${kabPath.astrology.name} (${kabPath.astrology.type})` : "";
-
- kabPathEl.innerHTML = `
- Kabbalah Tree — Path ${kabPath.pathNumber}
-
- ${letter.char || ""}
-
- ${letter.transliteration || ""} — “${letter.meaning || ""}” · ${letter.letterType || ""}
- ${fromName} → ${toName}${astro ? " · " + astro : ""}
-
-
`;
-
- const btn = document.createElement("button");
- btn.type = "button";
- btn.className = "kab-tarot-link";
- btn.textContent = `View Path ${kabPath.pathNumber} in Kabbalah Tree`;
- btn.addEventListener("click", () => {
- document.dispatchEvent(new CustomEvent("tarot:view-kab-path", {
- detail: { pathNumber: kabPath.pathNumber }
- }));
- });
- kabPathEl.appendChild(btn);
- kabPathEl.hidden = false;
- } else if (kabSeph) {
- const hebrewName = kabSeph.nameHebrew ? ` (${kabSeph.nameHebrew})` : "";
- const translation = kabSeph.translation ? ` — ${kabSeph.translation}` : "";
- const planetInfo = kabSeph.planet || "";
- const tarotInfo = kabSeph.tarot ? ` · ${kabSeph.tarot}` : "";
-
- kabPathEl.innerHTML = `
- Kabbalah Tree — Sephirah ${kabSeph.number}
-
- ${kabSeph.number}
-
- ${kabSeph.name || ""}${hebrewName}${translation}
- ${planetInfo}${tarotInfo}
-
-
`;
-
- const btn = document.createElement("button");
- btn.type = "button";
- btn.className = "kab-tarot-link";
- btn.textContent = `View Sephirah ${kabSeph.number} in Kabbalah Tree`;
- btn.addEventListener("click", () => {
- document.dispatchEvent(new CustomEvent("tarot:view-kab-path", {
- detail: { pathNumber: kabSeph.number }
- }));
- });
- kabPathEl.appendChild(btn);
- kabPathEl.hidden = false;
- } else {
- kabPathEl.hidden = true;
- kabPathEl.innerHTML = "";
- }
- }
+ tarotDetailRenderer.renderDetail(card, elements);
}
function updateListSelection(elements) {
diff --git a/app/ui-zodiac-references.js b/app/ui-zodiac-references.js
new file mode 100644
index 0000000..be1ec30
--- /dev/null
+++ b/app/ui-zodiac-references.js
@@ -0,0 +1,246 @@
+/* ui-zodiac-references.js — Month and cube reference builders for the zodiac section */
+(function () {
+ "use strict";
+
+ const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+
+ function cap(value) {
+ return String(value || "").charAt(0).toUpperCase() + String(value || "").slice(1);
+ }
+
+ function formatDateRange(rulesFrom) {
+ if (!Array.isArray(rulesFrom) || rulesFrom.length < 2) return "—";
+ const [from, to] = rulesFrom;
+ const fMonth = MONTH_NAMES[(from[0] || 1) - 1];
+ const tMonth = MONTH_NAMES[(to[0] || 1) - 1];
+ return `${fMonth} ${from[1]} – ${tMonth} ${to[1]}`;
+ }
+
+ function buildMonthReferencesBySign(referenceData) {
+ 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]));
+ const monthByOrder = new Map(
+ months
+ .filter((month) => Number.isFinite(Number(month?.order)))
+ .map((month) => [Number(month.order), month])
+ );
+
+ function parseMonthDay(value) {
+ const [month, day] = String(value || "").split("-").map((part) => Number(part));
+ if (!Number.isFinite(month) || !Number.isFinite(day)) {
+ return null;
+ }
+ return { month, day };
+ }
+
+ function monthOrdersInRange(startMonth, endMonth) {
+ const orders = [];
+ let cursor = startMonth;
+ let guard = 0;
+
+ while (guard < 13) {
+ orders.push(cursor);
+ if (cursor === endMonth) {
+ break;
+ }
+ cursor = cursor === 12 ? 1 : cursor + 1;
+ guard += 1;
+ }
+
+ return orders;
+ }
+
+ function pushRef(signId, month) {
+ const key = String(signId || "").trim().toLowerCase();
+ if (!key || !month?.id) {
+ return;
+ }
+
+ if (!map.has(key)) {
+ map.set(key, []);
+ }
+
+ const rows = map.get(key);
+ if (rows.some((entry) => entry.id === month.id)) {
+ return;
+ }
+
+ rows.push({
+ id: month.id,
+ name: month.name || month.id,
+ order: Number.isFinite(Number(month.order)) ? Number(month.order) : 999
+ });
+ }
+
+ months.forEach((month) => {
+ pushRef(month?.associations?.zodiacSignId, month);
+ const events = Array.isArray(month?.events) ? month.events : [];
+ events.forEach((event) => {
+ pushRef(event?.associations?.zodiacSignId, month);
+ });
+ });
+
+ holidays.forEach((holiday) => {
+ const month = monthById.get(holiday?.monthId);
+ if (!month) {
+ return;
+ }
+ pushRef(holiday?.associations?.zodiacSignId, month);
+ });
+
+ signs.forEach((sign) => {
+ const start = parseMonthDay(sign?.start);
+ const end = parseMonthDay(sign?.end);
+ if (!start || !end || !sign?.id) {
+ return;
+ }
+
+ monthOrdersInRange(start.month, end.month).forEach((monthOrder) => {
+ const month = monthByOrder.get(monthOrder);
+ if (month) {
+ pushRef(sign.id, month);
+ }
+ });
+ });
+
+ map.forEach((rows, key) => {
+ rows.sort((left, right) => left.order - right.order || left.name.localeCompare(right.name));
+ map.set(key, rows);
+ });
+
+ return map;
+ }
+
+ function buildCubeSignPlacements(magickDataset) {
+ const placements = new Map();
+ 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
+ : [];
+
+ function normalizeLetterId(value) {
+ const key = String(value || "").toLowerCase().replace(/[^a-z]/g, "").trim();
+ 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) => String(wallId || "").trim().toLowerCase()).filter(Boolean)
+ : [];
+
+ if (explicitWalls.length >= 2) {
+ return explicitWalls.slice(0, 2);
+ }
+
+ return String(edge?.id || "")
+ .trim()
+ .toLowerCase()
+ .split("-")
+ .map((wallId) => wallId.trim())
+ .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 = String(wallId || "").trim().toLowerCase();
+ const edgeId = String(edge?.id || "").trim().toLowerCase();
+ 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);
+ }
+
+ const wallById = new Map(
+ walls.map((wall) => [String(wall?.id || "").trim().toLowerCase(), wall])
+ );
+
+ const pathByLetterId = new Map(
+ paths
+ .map((path) => [normalizeLetterId(path?.hebrewLetter?.transliteration), path])
+ .filter(([letterId]) => Boolean(letterId))
+ );
+
+ edges.forEach((edge) => {
+ const letterId = normalizeLetterId(edge?.hebrewLetterId || edge?.associations?.hebrewLetterId);
+ const path = pathByLetterId.get(letterId) || null;
+ const signId = path?.astrology?.type === "zodiac"
+ ? String(path?.astrology?.name || "").trim().toLowerCase()
+ : "";
+
+ if (!signId || placements.has(signId)) {
+ return;
+ }
+
+ const wallsForEdge = edgeWalls(edge);
+ const primaryWallId = wallsForEdge[0] || "";
+ const primaryWall = wallById.get(primaryWallId);
+
+ placements.set(signId, {
+ wallId: primaryWallId,
+ edgeId: String(edge?.id || "").trim().toLowerCase(),
+ wallName: primaryWall?.name || cap(primaryWallId || "wall"),
+ edgeName: resolveCubeDirectionLabel(primaryWallId, edge)
+ });
+ });
+
+ return placements;
+ }
+
+ function cubePlacementLabel(placement) {
+ const wallName = placement?.wallName || "Wall";
+ const edgeName = placement?.edgeName || "Direction";
+ return `Cube: ${wallName} Wall - ${edgeName}`;
+ }
+
+ window.ZodiacReferenceBuilders = {
+ buildCubeSignPlacements,
+ buildMonthReferencesBySign,
+ cubePlacementLabel,
+ formatDateRange
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-zodiac.js b/app/ui-zodiac.js
index 311f967..0dfc1b7 100644
--- a/app/ui-zodiac.js
+++ b/app/ui-zodiac.js
@@ -2,6 +2,17 @@
(function () {
"use strict";
+ const zodiacReferenceBuilders = window.ZodiacReferenceBuilders || {};
+
+ if (
+ typeof zodiacReferenceBuilders.buildCubeSignPlacements !== "function"
+ || typeof zodiacReferenceBuilders.buildMonthReferencesBySign !== "function"
+ || typeof zodiacReferenceBuilders.cubePlacementLabel !== "function"
+ || typeof zodiacReferenceBuilders.formatDateRange !== "function"
+ ) {
+ throw new Error("ZodiacReferenceBuilders module must load before ui-zodiac.js");
+ }
+
const ELEMENT_STYLE = {
fire: { emoji: "🔥", badge: "zod-badge--fire", label: "Fire" },
earth: { emoji: "🌍", badge: "zod-badge--earth", label: "Earth" },
@@ -14,8 +25,6 @@
venus: "♀︎", mercury: "☿︎", luna: "☾︎"
};
- const MONTH_NAMES = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
-
const state = {
initialized: false,
entries: [],
@@ -58,233 +67,19 @@
}
function formatDateRange(rulesFrom) {
- if (!Array.isArray(rulesFrom) || rulesFrom.length < 2) return "—";
- const [from, to] = rulesFrom;
- const fMonth = MONTH_NAMES[(from[0] || 1) - 1];
- const tMonth = MONTH_NAMES[(to[0] || 1) - 1];
- return `${fMonth} ${from[1]} – ${tMonth} ${to[1]}`;
+ return zodiacReferenceBuilders.formatDateRange(rulesFrom);
}
function buildMonthReferencesBySign(referenceData) {
- 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]));
- const monthByOrder = new Map(
- months
- .filter((month) => Number.isFinite(Number(month?.order)))
- .map((month) => [Number(month.order), month])
- );
-
- function parseMonthDay(value) {
- const [month, day] = String(value || "").split("-").map((part) => Number(part));
- if (!Number.isFinite(month) || !Number.isFinite(day)) {
- return null;
- }
- return { month, day };
- }
-
- function monthOrdersInRange(startMonth, endMonth) {
- const orders = [];
- let cursor = startMonth;
- let guard = 0;
-
- while (guard < 13) {
- orders.push(cursor);
- if (cursor === endMonth) {
- break;
- }
- cursor = cursor === 12 ? 1 : cursor + 1;
- guard += 1;
- }
-
- return orders;
- }
-
- function pushRef(signId, month) {
- const key = String(signId || "").trim().toLowerCase();
- if (!key || !month?.id) {
- return;
- }
-
- if (!map.has(key)) {
- map.set(key, []);
- }
-
- const rows = map.get(key);
- if (rows.some((entry) => entry.id === month.id)) {
- return;
- }
-
- rows.push({
- id: month.id,
- name: month.name || month.id,
- order: Number.isFinite(Number(month.order)) ? Number(month.order) : 999
- });
- }
-
- months.forEach((month) => {
- pushRef(month?.associations?.zodiacSignId, month);
- const events = Array.isArray(month?.events) ? month.events : [];
- events.forEach((event) => {
- pushRef(event?.associations?.zodiacSignId, month);
- });
- });
-
- holidays.forEach((holiday) => {
- const month = monthById.get(holiday?.monthId);
- if (!month) {
- return;
- }
- pushRef(holiday?.associations?.zodiacSignId, month);
- });
-
- // Structural month coverage from sign date ranges (e.g., Scorpio spans Oct+Nov).
- signs.forEach((sign) => {
- const start = parseMonthDay(sign?.start);
- const end = parseMonthDay(sign?.end);
- if (!start || !end || !sign?.id) {
- return;
- }
-
- monthOrdersInRange(start.month, end.month).forEach((monthOrder) => {
- const month = monthByOrder.get(monthOrder);
- if (month) {
- pushRef(sign.id, month);
- }
- });
- });
-
- map.forEach((rows, key) => {
- rows.sort((left, right) => left.order - right.order || left.name.localeCompare(right.name));
- map.set(key, rows);
- });
-
- return map;
+ return zodiacReferenceBuilders.buildMonthReferencesBySign(referenceData);
}
function buildCubeSignPlacements(magickDataset) {
- const placements = new Map();
- 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
- : [];
-
- function normalizeLetterId(value) {
- const key = String(value || "").toLowerCase().replace(/[^a-z]/g, "").trim();
- 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) => String(wallId || "").trim().toLowerCase()).filter(Boolean)
- : [];
-
- if (explicitWalls.length >= 2) {
- return explicitWalls.slice(0, 2);
- }
-
- return String(edge?.id || "")
- .trim()
- .toLowerCase()
- .split("-")
- .map((wallId) => wallId.trim())
- .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 = String(wallId || "").trim().toLowerCase();
- const edgeId = String(edge?.id || "").trim().toLowerCase();
- 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);
- }
-
- const wallById = new Map(
- walls.map((wall) => [String(wall?.id || "").trim().toLowerCase(), wall])
- );
-
- const pathByLetterId = new Map(
- paths
- .map((path) => [normalizeLetterId(path?.hebrewLetter?.transliteration), path])
- .filter(([letterId]) => Boolean(letterId))
- );
-
- edges.forEach((edge) => {
- const letterId = normalizeLetterId(edge?.hebrewLetterId || edge?.associations?.hebrewLetterId);
- const path = pathByLetterId.get(letterId) || null;
- const signId = path?.astrology?.type === "zodiac"
- ? String(path?.astrology?.name || "").trim().toLowerCase()
- : "";
-
- if (!signId || placements.has(signId)) {
- return;
- }
-
- const wallsForEdge = edgeWalls(edge);
- const primaryWallId = wallsForEdge[0] || "";
- const primaryWall = wallById.get(primaryWallId);
-
- placements.set(signId, {
- wallId: primaryWallId,
- edgeId: String(edge?.id || "").trim().toLowerCase(),
- wallName: primaryWall?.name || cap(primaryWallId || "wall"),
- edgeName: resolveCubeDirectionLabel(primaryWallId, edge)
- });
- });
-
- return placements;
+ return zodiacReferenceBuilders.buildCubeSignPlacements(magickDataset);
}
function cubePlacementLabel(placement) {
- const wallName = placement?.wallName || "Wall";
- const edgeName = placement?.edgeName || "Direction";
- return `Cube: ${wallName} Wall - ${edgeName}`;
+ return zodiacReferenceBuilders.cubePlacementLabel(placement);
}
// ── List ──────────────────────────────────────────────────────────────
diff --git a/index.html b/index.html
index 836a11d..5aa3fb6 100644
--- a/index.html
+++ b/index.html
@@ -773,33 +773,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+