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 ` +
+ Major Arcana Windows +
No major arcana windows for this month.
+
+ `; + } + + const list = rows.map((row) => { + const label = row.hebrewLetterId + ? `${row.hebrewLetterChar ? `${row.hebrewLetterChar} ` : ""}${row.hebrewLetterName || row.hebrewLetterId}` + : "--"; + const displayCardName = api.getDisplayTarotName(row.cardName, row.trumpNumber); + + return ` +
+
+ ${displayCardName}${row.trumpNumber != null ? ` · Trump ${row.trumpNumber}` : ""} + ${row.rangeLabel} +
+
${row.signSymbol} ${row.signName} · Hebrew: ${label}
+
+ + + ${row.hebrewLetterId ? `` : ""} +
+
+ `; + }).join(""); + + return ` +
+ Major Arcana Windows +
${list}
+
+ `; + } + + function renderDecanTarotCard(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 ` +
+ Decan Tarot Windows +
No decan tarot windows for this month.
+
+ `; + } + + const list = rows.map((row) => { + const displayCardName = api.getDisplayTarotName(row.cardName); + return ` +
+
+ ${row.signSymbol} ${row.signName} · Decan ${row.decanIndex} + ${row.startDegree}°–${row.endDegree}° · ${row.dateRange} +
+
+ +
+
+ `; + }).join(""); + + return ` +
+ Decan Tarot Windows +
${list}
+
+ `; + } + + function renderDayLinksCard(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 ` +
+ Day Links +
Filter this month to events, holidays, and data connected to a specific day.
+ ${helperText} +
${links}
+ ${clearButton ? `
${clearButton}
` : ""} +
+ `; + } + + 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 ` +
+
+ Month Facts +
+
${factsRows}
+
+
+ ${connectionsCard} +
+ About ${month.name} +
${month.description || "--"}
+
+ ${renderDayLinksCard(context)} + ${renderHolidaysCard(month, "Holiday Repository")} +
+ `; + } + + 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 ` +
+
+ Month Facts +
+
${factsRows}
+
+
+ ${connectionsCard} +
+ About ${month.name} +
${month.description || "--"}
+
+ ${renderDayLinksCard(context)} + ${renderHolidaysCard(month, "Holiday Repository")} +
+ `; + } + + 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 + ? `
Linked Deities
${deityButtons.join("")}
` + : ""; + + const monthOrder = Number(month?.order); + const hasNumberLink = Number.isFinite(monthOrder) && monthOrder >= 0; + const numberButtons = hasNumberLink + ? buildAssociationButtons({ numberValue: Math.trunc(monthOrder) }) + : ""; + const numberLinksCard = hasNumberLink + ? `
Connections${numberButtons}
` + : ""; + + return ` +
+
+ Sabbat Facts +
+
${factsRows}
+
+
+
+ About ${month.name} +
${month.description || "--"}
+
+
+ Associations +
+
${assocRows}
+
+
+ ${renderDayLinksCard(context)} + ${numberLinksCard} + ${deityLinksCard} + ${renderHolidaysCard(month, "Holiday Repository")} +
+ `; + } + + 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 ` -
- Major Arcana Windows -
No major arcana windows for this month.
-
- `; - } - - const list = rows.map((row) => { - const label = row.hebrewLetterId - ? `${row.hebrewLetterChar ? `${row.hebrewLetterChar} ` : ""}${row.hebrewLetterName || row.hebrewLetterId}` - : "--"; - const displayCardName = api.getDisplayTarotName(row.cardName, row.trumpNumber); - - return ` -
-
- ${displayCardName}${row.trumpNumber != null ? ` · Trump ${row.trumpNumber}` : ""} - ${row.rangeLabel} -
-
${row.signSymbol} ${row.signName} · Hebrew: ${label}
-
- - - ${row.hebrewLetterId ? `` : ""} -
-
- `; - }).join(""); - - return ` -
- Major Arcana Windows -
${list}
-
- `; - } - - function renderDecanTarotCard(month) { - const selectedDay = api.getSelectedDayFilterContext(month); - const allRows = api.buildDecanTarotRowsForMonth(month); - const rows = selectedDay - ? allRows.filter((row) => selectedDay.entries.some((entry) => { - const targetDate = entry.gregorianDate; - if (!(targetDate instanceof Date) || Number.isNaN(targetDate.getTime())) { - return false; - } - - const targetMonth = targetDate.getMonth() + 1; - const targetDayNo = targetDate.getDate(); - return api.isMonthDayInRange( - targetMonth, - targetDayNo, - row.startMonth, - row.startDay, - row.endMonth, - row.endDay - ); - })) - : allRows; - - if (!rows.length) { - return ` -
- Decan Tarot Windows -
No decan tarot windows for this month.
-
- `; - } - - const list = rows.map((row) => { - const displayCardName = api.getDisplayTarotName(row.cardName); - return ` -
-
- ${row.signSymbol} ${row.signName} · Decan ${row.decanIndex} - ${row.startDegree}°–${row.endDegree}° · ${row.dateRange} -
-
- -
-
- `; - }).join(""); - - return ` -
- Decan Tarot Windows -
${list}
-
- `; - } - - function renderDayLinksCard(month) { - const rows = api.getMonthDayLinkRows(month); - if (!rows.length) { - return ""; - } - - const selectedContext = api.getSelectedDayFilterContext(month); - const selectedDaySet = selectedContext?.dayNumbers || new Set(); - const selectedDays = selectedContext?.entries?.map((entry) => entry.dayNumber) || []; - const selectedSummary = selectedDays.length ? selectedDays.join(", ") : ""; - - const links = rows.map((row) => { - if (!row.isResolved) { - return `${row.day}`; - } - - const isSelected = selectedDaySet.has(Number(row.day)); - return ``; - }).join(""); - - const clearButton = selectedContext - ? '' - : ""; - - const helperText = selectedContext - ? `
Filtered to days: ${selectedSummary}
` - : ""; - - return ` -
- Day Links -
Filter this month to events, holidays, and data connected to a specific day.
- ${helperText} -
${links}
- ${clearButton ? `
${clearButton}
` : ""} -
- `; - } - - function renderHebrewMonthDetail(month) { - const currentState = getState(); - const gregorianStartDate = api.getGregorianReferenceDateForCalendarMonth(month); - const factsRows = [ - ["Hebrew Name", month.nativeName || "--"], - ["Month Order", month.leapYearOnly ? `${month.order} (leap year only)` : String(month.order)], - ["Gregorian Reference Year", String(currentState.selectedYear)], - ["Month Start (Gregorian)", api.formatGregorianReferenceDate(gregorianStartDate)], - ["Days", month.daysVariant ? `${month.days}–${month.daysVariant} (varies)` : String(month.days || "--")], - ["Season", month.season || "--"], - ["Zodiac Sign", api.cap(month.zodiacSign) || "--"], - ["Tribe of Israel", month.tribe || "--"], - ["Sense", month.sense || "--"], - ["Hebrew Letter", month.hebrewLetter || "--"] - ].map(([dt, dd]) => `
${dt}
${dd}
`).join(""); - - const monthOrder = Number(month?.order); - const navButtons = buildAssociationButtons({ - ...(month?.associations || {}), - ...(Number.isFinite(monthOrder) ? { numberValue: Math.trunc(monthOrder) } : {}) - }); - const connectionsCard = navButtons - ? `
Connections${navButtons}
` - : ""; - - return ` -
-
- Month Facts -
-
${factsRows}
-
-
- ${connectionsCard} -
- About ${month.name} -
${month.description || "--"}
-
- ${renderDayLinksCard(month)} - ${renderHolidaysCard(month, "Holiday Repository")} -
- `; - } - - function renderIslamicMonthDetail(month) { - const currentState = getState(); - const gregorianStartDate = api.getGregorianReferenceDateForCalendarMonth(month); - const factsRows = [ - ["Arabic Name", month.nativeName || "--"], - ["Month Order", String(month.order)], - ["Gregorian Reference Year", String(currentState.selectedYear)], - ["Month Start (Gregorian)", api.formatGregorianReferenceDate(gregorianStartDate)], - ["Meaning", month.meaning || "--"], - ["Days", month.daysVariant ? `${month.days}–${month.daysVariant} (varies)` : String(month.days || "--")], - ["Sacred Month", month.sacred ? "Yes - warfare prohibited" : "No"] - ].map(([dt, dd]) => `
${dt}
${dd}
`).join(""); - - const monthOrder = Number(month?.order); - const hasNumberLink = Number.isFinite(monthOrder) && monthOrder >= 0; - const navButtons = hasNumberLink - ? buildAssociationButtons({ numberValue: Math.trunc(monthOrder) }) - : ""; - const connectionsCard = hasNumberLink - ? `
Connections${navButtons}
` - : ""; - - return ` -
-
- Month Facts -
-
${factsRows}
-
-
- ${connectionsCard} -
- About ${month.name} -
${month.description || "--"}
-
- ${renderDayLinksCard(month)} - ${renderHolidaysCard(month, "Holiday Repository")} -
- `; - } - - function buildWheelDeityButtons(deities) { - const buttons = []; - (Array.isArray(deities) ? deities : []).forEach((rawName) => { - const cleanName = String(rawName || "").replace(/\s*\/.*$/, "").replace(/\s*\(.*\)$/, "").trim(); - const godId = api.findGodIdByName(cleanName) || api.findGodIdByName(rawName); - if (!godId) { - return; - } - - const god = getState().godsById?.get(godId); - const label = god?.name || cleanName; - buttons.push(``); - }); - return buttons; - } - - function renderWheelMonthDetail(month) { - const currentState = getState(); - const gregorianStartDate = api.getGregorianReferenceDateForCalendarMonth(month); - const assoc = month?.associations; - const themes = Array.isArray(assoc?.themes) ? assoc.themes.join(", ") : "--"; - const deities = Array.isArray(assoc?.deities) ? assoc.deities.join(", ") : "--"; - const colors = Array.isArray(assoc?.colors) ? assoc.colors.join(", ") : "--"; - const herbs = Array.isArray(assoc?.herbs) ? assoc.herbs.join(", ") : "--"; - - const factsRows = [ - ["Date", month.date || "--"], - ["Type", api.cap(month.type) || "--"], - ["Gregorian Reference Year", String(currentState.selectedYear)], - ["Start (Gregorian)", api.formatGregorianReferenceDate(gregorianStartDate)], - ["Season", month.season || "--"], - ["Element", api.cap(month.element) || "--"], - ["Direction", assoc?.direction || "--"] - ].map(([dt, dd]) => `
${dt}
${dd}
`).join(""); - - const assocRows = [ - ["Themes", themes], - ["Deities", deities], - ["Colors", colors], - ["Herbs", herbs] - ].map(([dt, dd]) => `
${dt}
${dd}
`).join(""); - - const deityButtons = buildWheelDeityButtons(assoc?.deities); - const deityLinksCard = deityButtons.length - ? `
Linked Deities
${deityButtons.join("")}
` - : ""; - - const monthOrder = Number(month?.order); - const hasNumberLink = Number.isFinite(monthOrder) && monthOrder >= 0; - const numberButtons = hasNumberLink - ? buildAssociationButtons({ numberValue: Math.trunc(monthOrder) }) - : ""; - const numberLinksCard = hasNumberLink - ? `
Connections${numberButtons}
` - : ""; - - return ` -
-
- Sabbat Facts -
-
${factsRows}
-
-
-
- About ${month.name} -
${month.description || "--"}
-
-
- Associations -
-
${assocRows}
-
-
- ${renderDayLinksCard(month)} - ${numberLinksCard} - ${deityLinksCard} - ${renderHolidaysCard(month, "Holiday Repository")} -
- `; + function 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 ` +
+
+ Holiday Facts +
+
+
Source Calendar
${calendarLabel(holiday?.calendarId)}
+
Source Month
${monthName}
+
Source Date
${holidayDate}
+
Reference Year
${state.selectedYear}
+
Conversion
${confidenceLabel}
+
+
+
+
+ Cross-Calendar Dates +
+
+
Gregorian
${gregorianRef}
+
Hebrew
${hebrewRef}
+
Islamic
${islamicRef}
+
+
+
+
+ Description +
${holiday?.description || "--"}
+ ${sourceMonthLink} +
+
+ Associations + ${buildAssociationButtons(holiday?.associations, context)} +
+
+ `; + } + + 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 ` -
-
- Holiday Facts -
-
-
Source Calendar
${calendarLabel(holiday?.calendarId)}
-
Source Month
${monthName}
-
Source Date
${holidayDate}
-
Reference Year
${state.selectedYear}
-
Conversion
${confidenceLabel}
-
-
-
-
- Cross-Calendar Dates -
-
-
Gregorian
${gregorianRef}
-
Hebrew
${hebrewRef}
-
Islamic
${islamicRef}
-
-
-
-
- Description -
${holiday?.description || "--"}
- ${sourceMonthLink} -
-
- Associations - ${buildAssociationButtons(holiday?.associations)} -
-
- `; + 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 @@ + + + + + + + + + + + + + + + + + + + + +