refraction almost completed
This commit is contained in:
318
app/tarot-database-assembly.js
Normal file
318
app/tarot-database-assembly.js
Normal file
@@ -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
|
||||
};
|
||||
})();
|
||||
620
app/tarot-database-builders.js
Normal file
620
app/tarot-database-builders.js
Normal file
@@ -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 };
|
||||
})();
|
||||
@@ -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 = {
|
||||
|
||||
173
app/ui-alphabet-kabbalah.js
Normal file
173
app/ui-alphabet-kabbalah.js
Normal file
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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) : ""; }
|
||||
|
||||
329
app/ui-calendar-data.js
Normal file
329
app/ui-calendar-data.js
Normal file
@@ -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
|
||||
};
|
||||
})();
|
||||
457
app/ui-calendar-detail-panels.js
Normal file
457
app/ui-calendar-detail-panels.js
Normal file
@@ -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 `
|
||||
<div class="planet-meta-card">
|
||||
<strong>Major Arcana Windows</strong>
|
||||
<div class="planet-text">No major arcana windows for this month.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<div class="cal-item-row">
|
||||
<div class="cal-item-head">
|
||||
<span class="cal-item-name">${displayCardName}${row.trumpNumber != null ? ` · Trump ${row.trumpNumber}` : ""}</span>
|
||||
<span class="planet-list-meta">${row.rangeLabel}</span>
|
||||
</div>
|
||||
<div class="planet-list-meta">${row.signSymbol} ${row.signName} · Hebrew: ${label}</div>
|
||||
<div class="alpha-nav-btns">
|
||||
<button class="alpha-nav-btn" data-nav="calendar-day-range" data-range-start="${row.dayStart}" data-range-end="${row.dayEnd}">${row.rangeLabel} ↗</button>
|
||||
<button class="alpha-nav-btn" data-nav="tarot-card" data-card-name="${row.cardName}" data-trump-number="${row.trumpNumber ?? ""}">${displayCardName} ↗</button>
|
||||
${row.hebrewLetterId ? `<button class="alpha-nav-btn" data-nav="alphabet" data-alphabet="hebrew" data-hebrew-letter-id="${row.hebrewLetterId}">${label} ↗</button>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<div class="planet-meta-card">
|
||||
<strong>Major Arcana Windows</strong>
|
||||
<div class="cal-item-stack">${list}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<div class="planet-meta-card">
|
||||
<strong>Decan Tarot Windows</strong>
|
||||
<div class="planet-text">No decan tarot windows for this month.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const list = rows.map((row) => {
|
||||
const displayCardName = api.getDisplayTarotName(row.cardName);
|
||||
return `
|
||||
<div class="cal-item-row">
|
||||
<div class="cal-item-head">
|
||||
<span class="cal-item-name">${row.signSymbol} ${row.signName} · Decan ${row.decanIndex}</span>
|
||||
<span class="planet-list-meta">${row.startDegree}°–${row.endDegree}° · ${row.dateRange}</span>
|
||||
</div>
|
||||
<div class="alpha-nav-btns">
|
||||
<button class="alpha-nav-btn" data-nav="tarot-card" data-card-name="${row.cardName}">${displayCardName} ↗</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<div class="planet-meta-card">
|
||||
<strong>Decan Tarot Windows</strong>
|
||||
<div class="cal-item-stack">${list}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `<span class="planet-list-meta">${row.day}</span>`;
|
||||
}
|
||||
|
||||
const isSelected = selectedDaySet.has(Number(row.day));
|
||||
return `<button class="alpha-nav-btn${isSelected ? " is-selected" : ""}" data-nav="calendar-day" data-day-number="${row.day}" data-gregorian-date="${row.gregorianDate}" aria-pressed="${isSelected ? "true" : "false"}" title="Filter this month by day ${row.day}">${row.day}</button>`;
|
||||
}).join("");
|
||||
|
||||
const clearButton = selectedContext
|
||||
? '<button class="alpha-nav-btn" data-nav="calendar-day-clear" type="button">Show All Days</button>'
|
||||
: "";
|
||||
|
||||
const helperText = selectedContext
|
||||
? `<div class="planet-list-meta">Filtered to days: ${selectedSummary}</div>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="planet-meta-card">
|
||||
<strong>Day Links</strong>
|
||||
<div class="planet-text">Filter this month to events, holidays, and data connected to a specific day.</div>
|
||||
${helperText}
|
||||
<div class="alpha-nav-btns">${links}</div>
|
||||
${clearButton ? `<div class="alpha-nav-btns">${clearButton}</div>` : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGregorianMonthDetail(context) {
|
||||
const {
|
||||
renderFactsCard,
|
||||
renderAssociationsCard,
|
||||
renderEventsCard,
|
||||
renderHolidaysCard,
|
||||
month
|
||||
} = context;
|
||||
|
||||
return `
|
||||
<div class="planet-meta-grid">
|
||||
${renderFactsCard(month)}
|
||||
${renderDayLinksCard(context)}
|
||||
${renderAssociationsCard(month)}
|
||||
${renderMajorArcanaCard(context)}
|
||||
${renderDecanTarotCard(context)}
|
||||
${renderEventsCard(month)}
|
||||
${renderHolidaysCard(month, "Holiday Repository")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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>${dt}</dt><dd>${dd}</dd>`).join("");
|
||||
|
||||
const monthOrder = Number(month?.order);
|
||||
const navButtons = buildAssociationButtons({
|
||||
...(month?.associations || {}),
|
||||
...(Number.isFinite(monthOrder) ? { numberValue: Math.trunc(monthOrder) } : {})
|
||||
});
|
||||
const connectionsCard = navButtons
|
||||
? `<div class="planet-meta-card"><strong>Connections</strong>${navButtons}</div>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="planet-meta-grid">
|
||||
<div class="planet-meta-card">
|
||||
<strong>Month Facts</strong>
|
||||
<div class="planet-text">
|
||||
<dl class="alpha-dl">${factsRows}</dl>
|
||||
</div>
|
||||
</div>
|
||||
${connectionsCard}
|
||||
<div class="planet-meta-card">
|
||||
<strong>About ${month.name}</strong>
|
||||
<div class="planet-text">${month.description || "--"}</div>
|
||||
</div>
|
||||
${renderDayLinksCard(context)}
|
||||
${renderHolidaysCard(month, "Holiday Repository")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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>${dt}</dt><dd>${dd}</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
|
||||
? `<div class="planet-meta-card"><strong>Connections</strong>${navButtons}</div>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="planet-meta-grid">
|
||||
<div class="planet-meta-card">
|
||||
<strong>Month Facts</strong>
|
||||
<div class="planet-text">
|
||||
<dl class="alpha-dl">${factsRows}</dl>
|
||||
</div>
|
||||
</div>
|
||||
${connectionsCard}
|
||||
<div class="planet-meta-card">
|
||||
<strong>About ${month.name}</strong>
|
||||
<div class="planet-text">${month.description || "--"}</div>
|
||||
</div>
|
||||
${renderDayLinksCard(context)}
|
||||
${renderHolidaysCard(month, "Holiday Repository")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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(`<button class="alpha-nav-btn" data-nav="god" data-god-id="${godId}" data-god-name="${label}">${label} ↗</button>`);
|
||||
});
|
||||
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>${dt}</dt><dd>${dd}</dd>`).join("");
|
||||
|
||||
const assocRows = [
|
||||
["Themes", themes],
|
||||
["Deities", deities],
|
||||
["Colors", colors],
|
||||
["Herbs", herbs]
|
||||
].map(([dt, dd]) => `<dt>${dt}</dt><dd class="planet-text">${dd}</dd>`).join("");
|
||||
|
||||
const deityButtons = buildWheelDeityButtons(assoc?.deities, context);
|
||||
const deityLinksCard = deityButtons.length
|
||||
? `<div class="planet-meta-card"><strong>Linked Deities</strong><div class="alpha-nav-btns">${deityButtons.join("")}</div></div>`
|
||||
: "";
|
||||
|
||||
const monthOrder = Number(month?.order);
|
||||
const hasNumberLink = Number.isFinite(monthOrder) && monthOrder >= 0;
|
||||
const numberButtons = hasNumberLink
|
||||
? buildAssociationButtons({ numberValue: Math.trunc(monthOrder) })
|
||||
: "";
|
||||
const numberLinksCard = hasNumberLink
|
||||
? `<div class="planet-meta-card"><strong>Connections</strong>${numberButtons}</div>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="planet-meta-grid">
|
||||
<div class="planet-meta-card">
|
||||
<strong>Sabbat Facts</strong>
|
||||
<div class="planet-text">
|
||||
<dl class="alpha-dl">${factsRows}</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="planet-meta-card">
|
||||
<strong>About ${month.name}</strong>
|
||||
<div class="planet-text">${month.description || "--"}</div>
|
||||
</div>
|
||||
<div class="planet-meta-card">
|
||||
<strong>Associations</strong>
|
||||
<div class="planet-text">
|
||||
<dl class="alpha-dl">${assocRows}</dl>
|
||||
</div>
|
||||
</div>
|
||||
${renderDayLinksCard(context)}
|
||||
${numberLinksCard}
|
||||
${deityLinksCard}
|
||||
${renderHolidaysCard(month, "Holiday Repository")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
window.CalendarDetailPanelsUi = {
|
||||
renderGregorianMonthDetail,
|
||||
renderHebrewMonthDetail,
|
||||
renderIslamicMonthDetail,
|
||||
renderWheelMonthDetail
|
||||
};
|
||||
})();
|
||||
@@ -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 `
|
||||
<div class="planet-meta-card">
|
||||
<strong>Major Arcana Windows</strong>
|
||||
<div class="planet-text">No major arcana windows for this month.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<div class="cal-item-row">
|
||||
<div class="cal-item-head">
|
||||
<span class="cal-item-name">${displayCardName}${row.trumpNumber != null ? ` · Trump ${row.trumpNumber}` : ""}</span>
|
||||
<span class="planet-list-meta">${row.rangeLabel}</span>
|
||||
</div>
|
||||
<div class="planet-list-meta">${row.signSymbol} ${row.signName} · Hebrew: ${label}</div>
|
||||
<div class="alpha-nav-btns">
|
||||
<button class="alpha-nav-btn" data-nav="calendar-day-range" data-range-start="${row.dayStart}" data-range-end="${row.dayEnd}">${row.rangeLabel} ↗</button>
|
||||
<button class="alpha-nav-btn" data-nav="tarot-card" data-card-name="${row.cardName}" data-trump-number="${row.trumpNumber ?? ""}">${displayCardName} ↗</button>
|
||||
${row.hebrewLetterId ? `<button class="alpha-nav-btn" data-nav="alphabet" data-alphabet="hebrew" data-hebrew-letter-id="${row.hebrewLetterId}">${label} ↗</button>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<div class="planet-meta-card">
|
||||
<strong>Major Arcana Windows</strong>
|
||||
<div class="cal-item-stack">${list}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<div class="planet-meta-card">
|
||||
<strong>Decan Tarot Windows</strong>
|
||||
<div class="planet-text">No decan tarot windows for this month.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const list = rows.map((row) => {
|
||||
const displayCardName = api.getDisplayTarotName(row.cardName);
|
||||
return `
|
||||
<div class="cal-item-row">
|
||||
<div class="cal-item-head">
|
||||
<span class="cal-item-name">${row.signSymbol} ${row.signName} · Decan ${row.decanIndex}</span>
|
||||
<span class="planet-list-meta">${row.startDegree}°–${row.endDegree}° · ${row.dateRange}</span>
|
||||
</div>
|
||||
<div class="alpha-nav-btns">
|
||||
<button class="alpha-nav-btn" data-nav="tarot-card" data-card-name="${row.cardName}">${displayCardName} ↗</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<div class="planet-meta-card">
|
||||
<strong>Decan Tarot Windows</strong>
|
||||
<div class="cal-item-stack">${list}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `<span class="planet-list-meta">${row.day}</span>`;
|
||||
}
|
||||
|
||||
const isSelected = selectedDaySet.has(Number(row.day));
|
||||
return `<button class="alpha-nav-btn${isSelected ? " is-selected" : ""}" data-nav="calendar-day" data-day-number="${row.day}" data-gregorian-date="${row.gregorianDate}" aria-pressed="${isSelected ? "true" : "false"}" title="Filter this month by day ${row.day}">${row.day}</button>`;
|
||||
}).join("");
|
||||
|
||||
const clearButton = selectedContext
|
||||
? '<button class="alpha-nav-btn" data-nav="calendar-day-clear" type="button">Show All Days</button>'
|
||||
: "";
|
||||
|
||||
const helperText = selectedContext
|
||||
? `<div class="planet-list-meta">Filtered to days: ${selectedSummary}</div>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="planet-meta-card">
|
||||
<strong>Day Links</strong>
|
||||
<div class="planet-text">Filter this month to events, holidays, and data connected to a specific day.</div>
|
||||
${helperText}
|
||||
<div class="alpha-nav-btns">${links}</div>
|
||||
${clearButton ? `<div class="alpha-nav-btns">${clearButton}</div>` : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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>${dt}</dt><dd>${dd}</dd>`).join("");
|
||||
|
||||
const monthOrder = Number(month?.order);
|
||||
const navButtons = buildAssociationButtons({
|
||||
...(month?.associations || {}),
|
||||
...(Number.isFinite(monthOrder) ? { numberValue: Math.trunc(monthOrder) } : {})
|
||||
});
|
||||
const connectionsCard = navButtons
|
||||
? `<div class="planet-meta-card"><strong>Connections</strong>${navButtons}</div>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="planet-meta-grid">
|
||||
<div class="planet-meta-card">
|
||||
<strong>Month Facts</strong>
|
||||
<div class="planet-text">
|
||||
<dl class="alpha-dl">${factsRows}</dl>
|
||||
</div>
|
||||
</div>
|
||||
${connectionsCard}
|
||||
<div class="planet-meta-card">
|
||||
<strong>About ${month.name}</strong>
|
||||
<div class="planet-text">${month.description || "--"}</div>
|
||||
</div>
|
||||
${renderDayLinksCard(month)}
|
||||
${renderHolidaysCard(month, "Holiday Repository")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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>${dt}</dt><dd>${dd}</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
|
||||
? `<div class="planet-meta-card"><strong>Connections</strong>${navButtons}</div>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="planet-meta-grid">
|
||||
<div class="planet-meta-card">
|
||||
<strong>Month Facts</strong>
|
||||
<div class="planet-text">
|
||||
<dl class="alpha-dl">${factsRows}</dl>
|
||||
</div>
|
||||
</div>
|
||||
${connectionsCard}
|
||||
<div class="planet-meta-card">
|
||||
<strong>About ${month.name}</strong>
|
||||
<div class="planet-text">${month.description || "--"}</div>
|
||||
</div>
|
||||
${renderDayLinksCard(month)}
|
||||
${renderHolidaysCard(month, "Holiday Repository")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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(`<button class="alpha-nav-btn" data-nav="god" data-god-id="${godId}" data-god-name="${label}">${label} ↗</button>`);
|
||||
});
|
||||
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>${dt}</dt><dd>${dd}</dd>`).join("");
|
||||
|
||||
const assocRows = [
|
||||
["Themes", themes],
|
||||
["Deities", deities],
|
||||
["Colors", colors],
|
||||
["Herbs", herbs]
|
||||
].map(([dt, dd]) => `<dt>${dt}</dt><dd class="planet-text">${dd}</dd>`).join("");
|
||||
|
||||
const deityButtons = buildWheelDeityButtons(assoc?.deities);
|
||||
const deityLinksCard = deityButtons.length
|
||||
? `<div class="planet-meta-card"><strong>Linked Deities</strong><div class="alpha-nav-btns">${deityButtons.join("")}</div></div>`
|
||||
: "";
|
||||
|
||||
const monthOrder = Number(month?.order);
|
||||
const hasNumberLink = Number.isFinite(monthOrder) && monthOrder >= 0;
|
||||
const numberButtons = hasNumberLink
|
||||
? buildAssociationButtons({ numberValue: Math.trunc(monthOrder) })
|
||||
: "";
|
||||
const numberLinksCard = hasNumberLink
|
||||
? `<div class="planet-meta-card"><strong>Connections</strong>${numberButtons}</div>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="planet-meta-grid">
|
||||
<div class="planet-meta-card">
|
||||
<strong>Sabbat Facts</strong>
|
||||
<div class="planet-text">
|
||||
<dl class="alpha-dl">${factsRows}</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="planet-meta-card">
|
||||
<strong>About ${month.name}</strong>
|
||||
<div class="planet-text">${month.description || "--"}</div>
|
||||
</div>
|
||||
<div class="planet-meta-card">
|
||||
<strong>Associations</strong>
|
||||
<div class="planet-text">
|
||||
<dl class="alpha-dl">${assocRows}</dl>
|
||||
</div>
|
||||
</div>
|
||||
${renderDayLinksCard(month)}
|
||||
${numberLinksCard}
|
||||
${deityLinksCard}
|
||||
${renderHolidaysCard(month, "Holiday Repository")}
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="planet-meta-grid">
|
||||
${renderFactsCard(month)}
|
||||
${renderDayLinksCard(month)}
|
||||
${renderAssociationsCard(month)}
|
||||
${renderMajorArcanaCard(month)}
|
||||
${renderDecanTarotCard(month)}
|
||||
${renderEventsCard(month)}
|
||||
${renderHolidaysCard(month, "Holiday Repository")}
|
||||
</div>
|
||||
`;
|
||||
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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
550
app/ui-cube-chassis.js
Normal file
550
app/ui-cube-chassis.js
Normal file
@@ -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 };
|
||||
})();
|
||||
331
app/ui-cube-math.js
Normal file
331
app/ui-cube-math.js
Normal file
@@ -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
|
||||
};
|
||||
})();
|
||||
811
app/ui-cube.js
811
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 = "") {
|
||||
|
||||
225
app/ui-gods-references.js
Normal file
225
app/ui-gods-references.js
Normal file
@@ -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
|
||||
};
|
||||
})();
|
||||
227
app/ui-gods.js
227
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) {
|
||||
|
||||
472
app/ui-holidays-data.js
Normal file
472
app/ui-holidays-data.js
Normal file
@@ -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
|
||||
};
|
||||
})();
|
||||
311
app/ui-holidays-render.js
Normal file
311
app/ui-holidays-render.js
Normal file
@@ -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 '<div class="planet-text">--</div>';
|
||||
}
|
||||
|
||||
const buttons = [];
|
||||
|
||||
if (associations.planetId) {
|
||||
buttons.push(
|
||||
`<button class="alpha-nav-btn" data-nav="planet" data-planet-id="${associations.planetId}">${planetLabel(associations.planetId, context)} -></button>`
|
||||
);
|
||||
}
|
||||
|
||||
if (associations.zodiacSignId) {
|
||||
buttons.push(
|
||||
`<button class="alpha-nav-btn" data-nav="zodiac" data-sign-id="${associations.zodiacSignId}">${zodiacLabel(associations.zodiacSignId, context)} -></button>`
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
`<button class="alpha-nav-btn" data-nav="number" data-number-value="${numberValue}">${label} -></button>`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
`<button class="alpha-nav-btn" data-nav="tarot-card" data-card-name="${associations.tarotCard}" data-trump-number="${tarotTrumpNumber ?? ""}">${tarotLabel} -></button>`
|
||||
);
|
||||
}
|
||||
|
||||
if (associations.godId || associations.godName) {
|
||||
const label = godLabel(associations.godId, associations.godName, context);
|
||||
buttons.push(
|
||||
`<button class="alpha-nav-btn" data-nav="god" data-god-id="${associations.godId || ""}" data-god-name="${associations.godName || label}">${label} -></button>`
|
||||
);
|
||||
}
|
||||
|
||||
if (associations.hebrewLetterId) {
|
||||
buttons.push(
|
||||
`<button class="alpha-nav-btn" data-nav="alphabet" data-alphabet="hebrew" data-hebrew-letter-id="${associations.hebrewLetterId}">${hebrewLabel(associations.hebrewLetterId, context)} -></button>`
|
||||
);
|
||||
}
|
||||
|
||||
if (associations.kabbalahPathNumber != null) {
|
||||
buttons.push(
|
||||
`<button class="alpha-nav-btn" data-nav="kabbalah" data-path-no="${associations.kabbalahPathNumber}">Path ${associations.kabbalahPathNumber} -></button>`
|
||||
);
|
||||
}
|
||||
|
||||
if (associations.iChingPlanetaryInfluence) {
|
||||
buttons.push(
|
||||
`<button class="alpha-nav-btn" data-nav="iching" data-planetary-influence="${associations.iChingPlanetaryInfluence}">I Ching - ${associations.iChingPlanetaryInfluence} -></button>`
|
||||
);
|
||||
}
|
||||
|
||||
if (!buttons.length) {
|
||||
return '<div class="planet-text">--</div>';
|
||||
}
|
||||
|
||||
return `<div class="alpha-nav-btns">${buttons.join("")}</div>`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="planet-list-name">${holiday?.name || holiday?.id || "Holiday"}</div>
|
||||
<div class="planet-list-meta">${sourceCalendar} - ${sourceMonth} - ${sourceDate}</div>
|
||||
`;
|
||||
|
||||
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
|
||||
? `<div class="alpha-nav-btns"><button class="alpha-nav-btn" data-nav="calendar-month" data-calendar-id="${holiday.calendarId || ""}" data-month-id="${holiday.monthId}">Open ${calendarLabel(holiday?.calendarId)} ${monthName} -></button></div>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="planet-meta-grid">
|
||||
<div class="planet-meta-card">
|
||||
<strong>Holiday Facts</strong>
|
||||
<div class="planet-text">
|
||||
<dl class="alpha-dl">
|
||||
<dt>Source Calendar</dt><dd>${calendarLabel(holiday?.calendarId)}</dd>
|
||||
<dt>Source Month</dt><dd>${monthName}</dd>
|
||||
<dt>Source Date</dt><dd>${holidayDate}</dd>
|
||||
<dt>Reference Year</dt><dd>${state.selectedYear}</dd>
|
||||
<dt>Conversion</dt><dd>${confidenceLabel}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="planet-meta-card">
|
||||
<strong>Cross-Calendar Dates</strong>
|
||||
<div class="planet-text">
|
||||
<dl class="alpha-dl">
|
||||
<dt>Gregorian</dt><dd>${gregorianRef}</dd>
|
||||
<dt>Hebrew</dt><dd>${hebrewRef}</dd>
|
||||
<dt>Islamic</dt><dd>${islamicRef}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="planet-meta-card">
|
||||
<strong>Description</strong>
|
||||
<div class="planet-text">${holiday?.description || "--"}</div>
|
||||
${sourceMonthLink}
|
||||
</div>
|
||||
<div class="planet-meta-card">
|
||||
<strong>Associations</strong>
|
||||
${buildAssociationButtons(holiday?.associations, context)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
window.HolidayRenderUi = {
|
||||
holidaySearchText,
|
||||
renderList,
|
||||
renderHolidayDetail
|
||||
};
|
||||
})();
|
||||
@@ -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 "<div class=\"planet-text\">--</div>";
|
||||
}
|
||||
|
||||
const buttons = [];
|
||||
|
||||
if (associations.planetId) {
|
||||
buttons.push(
|
||||
`<button class="alpha-nav-btn" data-nav="planet" data-planet-id="${associations.planetId}">${planetLabel(associations.planetId)} -></button>`
|
||||
);
|
||||
}
|
||||
|
||||
if (associations.zodiacSignId) {
|
||||
buttons.push(
|
||||
`<button class="alpha-nav-btn" data-nav="zodiac" data-sign-id="${associations.zodiacSignId}">${zodiacLabel(associations.zodiacSignId)} -></button>`
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
`<button class="alpha-nav-btn" data-nav="number" data-number-value="${numberValue}">${label} -></button>`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
`<button class="alpha-nav-btn" data-nav="tarot-card" data-card-name="${associations.tarotCard}" data-trump-number="${tarotTrumpNumber ?? ""}">${tarotLabel} -></button>`
|
||||
);
|
||||
}
|
||||
|
||||
if (associations.godId || associations.godName) {
|
||||
const label = godLabel(associations.godId, associations.godName);
|
||||
buttons.push(
|
||||
`<button class="alpha-nav-btn" data-nav="god" data-god-id="${associations.godId || ""}" data-god-name="${associations.godName || label}">${label} -></button>`
|
||||
);
|
||||
}
|
||||
|
||||
if (associations.hebrewLetterId) {
|
||||
buttons.push(
|
||||
`<button class="alpha-nav-btn" data-nav="alphabet" data-alphabet="hebrew" data-hebrew-letter-id="${associations.hebrewLetterId}">${hebrewLabel(associations.hebrewLetterId)} -></button>`
|
||||
);
|
||||
}
|
||||
|
||||
if (associations.kabbalahPathNumber != null) {
|
||||
buttons.push(
|
||||
`<button class="alpha-nav-btn" data-nav="kabbalah" data-path-no="${associations.kabbalahPathNumber}">Path ${associations.kabbalahPathNumber} -></button>`
|
||||
);
|
||||
}
|
||||
|
||||
if (associations.iChingPlanetaryInfluence) {
|
||||
buttons.push(
|
||||
`<button class="alpha-nav-btn" data-nav="iching" data-planetary-influence="${associations.iChingPlanetaryInfluence}">I Ching - ${associations.iChingPlanetaryInfluence} -></button>`
|
||||
);
|
||||
}
|
||||
|
||||
if (!buttons.length) {
|
||||
return "<div class=\"planet-text\">--</div>";
|
||||
}
|
||||
|
||||
return `<div class="alpha-nav-btns">${buttons.join("")}</div>`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="planet-list-name">${holiday?.name || holiday?.id || "Holiday"}</div>
|
||||
<div class="planet-list-meta">${sourceCalendar} - ${sourceMonth} - ${sourceDate}</div>
|
||||
`;
|
||||
|
||||
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
|
||||
? `<div class="alpha-nav-btns"><button class="alpha-nav-btn" data-nav="calendar-month" data-calendar-id="${holiday.calendarId || ""}" data-month-id="${holiday.monthId}">Open ${calendarLabel(holiday?.calendarId)} ${monthName} -></button></div>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="planet-meta-grid">
|
||||
<div class="planet-meta-card">
|
||||
<strong>Holiday Facts</strong>
|
||||
<div class="planet-text">
|
||||
<dl class="alpha-dl">
|
||||
<dt>Source Calendar</dt><dd>${calendarLabel(holiday?.calendarId)}</dd>
|
||||
<dt>Source Month</dt><dd>${monthName}</dd>
|
||||
<dt>Source Date</dt><dd>${holidayDate}</dd>
|
||||
<dt>Reference Year</dt><dd>${state.selectedYear}</dd>
|
||||
<dt>Conversion</dt><dd>${confidenceLabel}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="planet-meta-card">
|
||||
<strong>Cross-Calendar Dates</strong>
|
||||
<div class="planet-text">
|
||||
<dl class="alpha-dl">
|
||||
<dt>Gregorian</dt><dd>${gregorianRef}</dd>
|
||||
<dt>Hebrew</dt><dd>${hebrewRef}</dd>
|
||||
<dt>Islamic</dt><dd>${islamicRef}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="planet-meta-card">
|
||||
<strong>Description</strong>
|
||||
<div class="planet-text">${holiday?.description || "--"}</div>
|
||||
${sourceMonthLink}
|
||||
</div>
|
||||
<div class="planet-meta-card">
|
||||
<strong>Associations</strong>
|
||||
${buildAssociationButtons(holiday?.associations)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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;
|
||||
}
|
||||
|
||||
253
app/ui-iching-references.js
Normal file
253
app/ui-iching-references.js
Normal file
@@ -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
|
||||
};
|
||||
})();
|
||||
253
app/ui-iching.js
253
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);
|
||||
|
||||
388
app/ui-kabbalah-views.js
Normal file
388
app/ui-kabbalah-views.js
Normal file
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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) {
|
||||
|
||||
519
app/ui-now-helpers.js
Normal file
519
app/ui-now-helpers.js
Normal file
@@ -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
|
||||
};
|
||||
})();
|
||||
554
app/ui-now.js
554
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,
|
||||
|
||||
634
app/ui-numbers-detail.js
Normal file
634
app/ui-numbers-detail.js
Normal file
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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) {
|
||||
|
||||
330
app/ui-planets-references.js
Normal file
330
app/ui-planets-references.js
Normal file
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
613
app/ui-quiz-bank-builtins-domains.js
Normal file
613
app/ui-quiz-bank-builtins-domains.js
Normal file
@@ -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
|
||||
};
|
||||
})();
|
||||
358
app/ui-quiz-bank-builtins.js
Normal file
358
app/ui-quiz-bank-builtins.js
Normal file
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
231
app/ui-tarot-card-derivations.js
Normal file
231
app/ui-tarot-card-derivations.js
Normal file
@@ -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
|
||||
};
|
||||
})();
|
||||
312
app/ui-tarot-detail.js
Normal file
312
app/ui-tarot-detail.js
Normal file
@@ -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 = `
|
||||
<strong>Kabbalah Tree — Path ${kabPath.pathNumber}</strong>
|
||||
<div class="tarot-kab-path-row">
|
||||
<span class="tarot-kab-letter" title="${letter.transliteration || ""}">${letter.char || ""}</span>
|
||||
<span class="tarot-kab-meta">
|
||||
<span class="tarot-kab-name">${letter.transliteration || ""} — “${letter.meaning || ""}” · ${letter.letterType || ""}</span>
|
||||
<span class="tarot-kab-connects">${fromName} → ${toName}${astro ? " · " + astro : ""}</span>
|
||||
</span>
|
||||
</div>`;
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "kab-tarot-link";
|
||||
btn.textContent = `View Path ${kabPath.pathNumber} in Kabbalah Tree`;
|
||||
btn.addEventListener("click", () => {
|
||||
document.dispatchEvent(new CustomEvent("tarot:view-kab-path", {
|
||||
detail: { pathNumber: kabPath.pathNumber }
|
||||
}));
|
||||
});
|
||||
kabPathEl.appendChild(btn);
|
||||
kabPathEl.hidden = false;
|
||||
} else if (kabSeph) {
|
||||
const hebrewName = kabSeph.nameHebrew ? ` (${kabSeph.nameHebrew})` : "";
|
||||
const translation = kabSeph.translation ? ` — ${kabSeph.translation}` : "";
|
||||
const planetInfo = kabSeph.planet || "";
|
||||
const tarotInfo = kabSeph.tarot ? ` · ${kabSeph.tarot}` : "";
|
||||
|
||||
kabPathEl.innerHTML = `
|
||||
<strong>Kabbalah Tree — Sephirah ${kabSeph.number}</strong>
|
||||
<div class="tarot-kab-path-row">
|
||||
<span class="tarot-kab-letter" title="${kabSeph.name || ""}">${kabSeph.number}</span>
|
||||
<span class="tarot-kab-meta">
|
||||
<span class="tarot-kab-name">${kabSeph.name || ""}${hebrewName}${translation}</span>
|
||||
<span class="tarot-kab-connects">${planetInfo}${tarotInfo}</span>
|
||||
</span>
|
||||
</div>`;
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "kab-tarot-link";
|
||||
btn.textContent = `View Sephirah ${kabSeph.number} in Kabbalah Tree`;
|
||||
btn.addEventListener("click", () => {
|
||||
document.dispatchEvent(new CustomEvent("tarot:view-kab-path", {
|
||||
detail: { pathNumber: kabSeph.number }
|
||||
}));
|
||||
});
|
||||
kabPathEl.appendChild(btn);
|
||||
kabPathEl.hidden = false;
|
||||
} else {
|
||||
kabPathEl.hidden = true;
|
||||
kabPathEl.innerHTML = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
renderStaticRelationGroup,
|
||||
renderDetail
|
||||
};
|
||||
}
|
||||
|
||||
window.TarotDetailUi = {
|
||||
createTarotDetailRenderer
|
||||
};
|
||||
})();
|
||||
286
app/ui-tarot-relation-display.js
Normal file
286
app/ui-tarot-relation-display.js
Normal file
@@ -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
|
||||
};
|
||||
})();
|
||||
772
app/ui-tarot.js
772
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 = `
|
||||
<strong>Kabbalah Tree — Path ${kabPath.pathNumber}</strong>
|
||||
<div class="tarot-kab-path-row">
|
||||
<span class="tarot-kab-letter" title="${letter.transliteration || ""}">${letter.char || ""}</span>
|
||||
<span class="tarot-kab-meta">
|
||||
<span class="tarot-kab-name">${letter.transliteration || ""} — “${letter.meaning || ""}” · ${letter.letterType || ""}</span>
|
||||
<span class="tarot-kab-connects">${fromName} → ${toName}${astro ? " · " + astro : ""}</span>
|
||||
</span>
|
||||
</div>`;
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "kab-tarot-link";
|
||||
btn.textContent = `View Path ${kabPath.pathNumber} in Kabbalah Tree`;
|
||||
btn.addEventListener("click", () => {
|
||||
document.dispatchEvent(new CustomEvent("tarot:view-kab-path", {
|
||||
detail: { pathNumber: kabPath.pathNumber }
|
||||
}));
|
||||
});
|
||||
kabPathEl.appendChild(btn);
|
||||
kabPathEl.hidden = false;
|
||||
} else if (kabSeph) {
|
||||
const hebrewName = kabSeph.nameHebrew ? ` (${kabSeph.nameHebrew})` : "";
|
||||
const translation = kabSeph.translation ? ` — ${kabSeph.translation}` : "";
|
||||
const planetInfo = kabSeph.planet || "";
|
||||
const tarotInfo = kabSeph.tarot ? ` · ${kabSeph.tarot}` : "";
|
||||
|
||||
kabPathEl.innerHTML = `
|
||||
<strong>Kabbalah Tree — Sephirah ${kabSeph.number}</strong>
|
||||
<div class="tarot-kab-path-row">
|
||||
<span class="tarot-kab-letter" title="${kabSeph.name || ""}">${kabSeph.number}</span>
|
||||
<span class="tarot-kab-meta">
|
||||
<span class="tarot-kab-name">${kabSeph.name || ""}${hebrewName}${translation}</span>
|
||||
<span class="tarot-kab-connects">${planetInfo}${tarotInfo}</span>
|
||||
</span>
|
||||
</div>`;
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "kab-tarot-link";
|
||||
btn.textContent = `View Sephirah ${kabSeph.number} in Kabbalah Tree`;
|
||||
btn.addEventListener("click", () => {
|
||||
document.dispatchEvent(new CustomEvent("tarot:view-kab-path", {
|
||||
detail: { pathNumber: kabSeph.number }
|
||||
}));
|
||||
});
|
||||
kabPathEl.appendChild(btn);
|
||||
kabPathEl.hidden = false;
|
||||
} else {
|
||||
kabPathEl.hidden = true;
|
||||
kabPathEl.innerHTML = "";
|
||||
}
|
||||
}
|
||||
tarotDetailRenderer.renderDetail(card, elements);
|
||||
}
|
||||
|
||||
function updateListSelection(elements) {
|
||||
|
||||
246
app/ui-zodiac-references.js
Normal file
246
app/ui-zodiac-references.js
Normal file
@@ -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
|
||||
};
|
||||
})();
|
||||
235
app/ui-zodiac.js
235
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
21
index.html
21
index.html
@@ -773,33 +773,54 @@
|
||||
<script src="app/ui-tarot-lightbox.js?v=20260307b"></script>
|
||||
<script src="app/ui-tarot-house.js?v=20260307b"></script>
|
||||
<script src="app/ui-tarot-relations.js"></script>
|
||||
<script src="app/ui-now-helpers.js"></script>
|
||||
<script src="app/ui-now.js"></script>
|
||||
<script src="app/ui-natal.js"></script>
|
||||
<script src="app/tarot-database-builders.js"></script>
|
||||
<script src="app/tarot-database-assembly.js"></script>
|
||||
<script src="app/tarot-database.js"></script>
|
||||
<script src="app/ui-calendar-dates.js"></script>
|
||||
<script src="app/ui-calendar-detail-panels.js"></script>
|
||||
<script src="app/ui-calendar-detail.js"></script>
|
||||
<script src="app/ui-calendar-data.js"></script>
|
||||
<script src="app/ui-calendar.js"></script>
|
||||
<script src="app/ui-holidays-data.js"></script>
|
||||
<script src="app/ui-holidays-render.js"></script>
|
||||
<script src="app/ui-holidays.js"></script>
|
||||
<script src="app/ui-tarot-card-derivations.js?v=20260307b"></script>
|
||||
<script src="app/ui-tarot-detail.js?v=20260307b"></script>
|
||||
<script src="app/ui-tarot-relation-display.js?v=20260307b"></script>
|
||||
<script src="app/ui-tarot.js?v=20260307b"></script>
|
||||
<script src="app/ui-planets-references.js"></script>
|
||||
<script src="app/ui-planets.js"></script>
|
||||
<script src="app/ui-cycles.js"></script>
|
||||
<script src="app/ui-elements.js"></script>
|
||||
<script src="app/ui-iching-references.js"></script>
|
||||
<script src="app/ui-iching.js"></script>
|
||||
<script src="app/ui-rosicrucian-cross.js"></script>
|
||||
<script src="app/ui-kabbalah-detail.js"></script>
|
||||
<script src="app/ui-kabbalah-views.js"></script>
|
||||
<script src="app/ui-kabbalah.js"></script>
|
||||
<script src="app/ui-cube-detail.js"></script>
|
||||
<script src="app/ui-cube-chassis.js"></script>
|
||||
<script src="app/ui-cube-math.js"></script>
|
||||
<script src="app/ui-cube.js"></script>
|
||||
<script src="app/ui-alphabet-gematria.js"></script>
|
||||
<script src="app/ui-alphabet-references.js"></script>
|
||||
<script src="app/ui-alphabet-detail.js"></script>
|
||||
<script src="app/ui-alphabet-kabbalah.js"></script>
|
||||
<script src="app/ui-alphabet.js"></script>
|
||||
<script src="app/ui-zodiac-references.js"></script>
|
||||
<script src="app/ui-zodiac.js"></script>
|
||||
<script src="app/ui-quiz-bank-builtins-domains.js"></script>
|
||||
<script src="app/ui-quiz-bank-builtins.js"></script>
|
||||
<script src="app/ui-quiz-bank.js"></script>
|
||||
<script src="app/ui-quiz.js"></script>
|
||||
<script src="app/quiz-calendars.js"></script>
|
||||
<script src="app/ui-gods-references.js"></script>
|
||||
<script src="app/ui-gods.js"></script>
|
||||
<script src="app/ui-enochian.js"></script>
|
||||
<script src="app/ui-numbers-detail.js"></script>
|
||||
<script src="app/ui-numbers.js"></script>
|
||||
<script src="app/ui-tarot-spread.js"></script>
|
||||
<script src="app/ui-settings.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user