refraction almost completed

This commit is contained in:
2026-03-07 13:38:13 -08:00
parent 3c07a13547
commit d44483de5e
37 changed files with 8506 additions and 7145 deletions

View 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
};
})();

View 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 };
})();

View File

@@ -459,52 +459,49 @@
tav: "tav" 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) { function getTarotDbConfig(referenceData) {
const db = referenceData?.tarotDatabase; return tarotDatabaseBuilders.getTarotDbConfig(referenceData);
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
};
} }
function normalizeCardName(value) { function normalizeCardName(value) {
@@ -516,290 +513,27 @@
} }
function canonicalCardName(value) { function canonicalCardName(value) {
const normalized = normalizeCardName(value); return tarotDatabaseBuilders.canonicalCardName(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;
} }
function formatMonthDayLabel(date) { function formatMonthDayLabel(date) {
if (!(date instanceof Date)) { return tarotDatabaseBuilders.formatMonthDayLabel(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()}`;
} }
function applyMeaningText(cards, referenceData) { function applyMeaningText(cards, referenceData) {
const majorByTrumpNumber = referenceData?.tarotDatabase?.meanings?.majorByTrumpNumber; return tarotDatabaseBuilders.applyMeaningText(cards, referenceData);
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) { function listMonthNumbersBetween(start, end) {
if (!(start instanceof Date) || !(end instanceof Date)) { return tarotDatabaseBuilders.listMonthNumbersBetween(start, end);
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) { function buildDecanMetadata(decan, sign) {
if (!decan || !sign) { return tarotDatabaseBuilders.buildDecanMetadata(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, "");
} }
function buildHebrewLetterLookup(magickDataset) { function buildHebrewLetterLookup(magickDataset) {
const letters = magickDataset?.grouped?.hebrewLetters; return tarotDatabaseBuilders.buildHebrewLetterLookup(magickDataset);
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;
} }
function normalizeRelationId(value) { function normalizeRelationId(value) {
@@ -811,528 +545,39 @@
} }
function createRelation(type, id, label, data = null) { function createRelation(type, id, label, data = null) {
return { return tarotDatabaseBuilders.createRelation(type, id, label, data);
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) { function parseLegacyRelation(text) {
const raw = String(text || "").trim(); return tarotDatabaseBuilders.parseLegacyRelation(text);
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 });
} }
function buildHebrewLetterRelation(hebrewLetterId, hebrewLookup) { function buildHebrewLetterRelation(hebrewLetterId, hebrewLookup) {
if (!hebrewLetterId || !hebrewLookup) { return tarotDatabaseBuilders.buildHebrewLetterRelation(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);
}
} }
function buildMajorDynamicRelations(referenceData) { function buildMajorDynamicRelations(referenceData) {
const relationMap = new Map(); return tarotDatabaseBuilders.buildMajorDynamicRelations(referenceData);
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) { function buildMinorDecanRelations(referenceData) {
const relationMap = new Map(); return tarotDatabaseBuilders.buildMinorDecanRelations(referenceData);
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;
} }
function buildMajorCards(referenceData, magickDataset) { function buildMajorCards(referenceData, magickDataset) {
const tarotDb = getTarotDbConfig(referenceData); return tarotDatabaseAssembly.buildMajorCards(referenceData, magickDataset);
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
]
};
});
} }
function buildNumberMinorCards(referenceData) { function buildNumberMinorCards(referenceData) {
const tarotDb = getTarotDbConfig(referenceData); return tarotDatabaseAssembly.buildNumberMinorCards(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;
} }
function buildCourtMinorCards(referenceData) { function buildCourtMinorCards(referenceData) {
const tarotDb = getTarotDbConfig(referenceData); return tarotDatabaseAssembly.buildCourtMinorCards(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;
} }
function buildTarotDatabase(referenceData, magickDataset = null) { function buildTarotDatabase(referenceData, magickDataset = null) {
const cards = [ return tarotDatabaseAssembly.buildTarotDatabase(referenceData, magickDataset);
...buildMajorCards(referenceData, magickDataset),
...buildNumberMinorCards(referenceData),
...buildCourtMinorCards(referenceData)
];
return applyMeaningText(cards, referenceData);
} }
window.TarotCardDatabase = { window.TarotCardDatabase = {

173
app/ui-alphabet-kabbalah.js Normal file
View 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
};
})();

View File

@@ -3,6 +3,23 @@
"use strict"; "use strict";
const alphabetGematriaUi = window.AlphabetGematriaUi || {}; 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 = { const state = {
initialized: false, initialized: false,
@@ -15,7 +32,6 @@
}, },
fourWorldLayers: [], fourWorldLayers: [],
monthRefsByHebrewId: new Map(), monthRefsByHebrewId: new Map(),
const alphabetReferenceBuilders = window.AlphabetReferenceBuilders || {};
cubeRefs: { cubeRefs: {
hebrewPlacementById: new Map(), hebrewPlacementById: new Map(),
signPlacementById: new Map(), signPlacementById: new Map(),
@@ -24,7 +40,6 @@
} }
}; };
const alphabetDetailUi = window.AlphabetDetailUi || {};
// ── Arabic display name table ───────────────────────────────────────── // ── Arabic display name table ─────────────────────────────────────────
const ARABIC_DISPLAY_NAMES = { const ARABIC_DISPLAY_NAMES = {
alif: "Alif", ba: "Ba", jeem: "Jeem", dal: "Dal", ha: "H\u0101", alif: "Alif", ba: "Ba", jeem: "Jeem", dal: "Dal", ha: "H\u0101",
@@ -437,84 +452,19 @@
}; };
function normalizeId(value) { function normalizeId(value) {
return String(value || "").trim().toLowerCase(); return alphabetKabbalahUi.normalizeId(value);
} }
function normalizeSoulId(value) { function normalizeLetterId(value) {
return String(value || "") return alphabetKabbalahUi.normalizeLetterId(value);
.trim() }
.toLowerCase()
.replace(/[^a-z]/g, ""); function titleCase(value) {
return alphabetKabbalahUi.titleCase(value);
} }
function buildFourWorldLayersFromDataset(magickDataset) { function buildFourWorldLayersFromDataset(magickDataset) {
const worlds = magickDataset?.grouped?.kabbalah?.fourWorlds; return alphabetKabbalahUi.buildFourWorldLayersFromDataset(magickDataset);
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 buildMonthReferencesByHebrew(referenceData, alphabets) { function buildMonthReferencesByHebrew(referenceData, alphabets) {
@@ -526,12 +476,7 @@
} }
function createEmptyCubeRefs() { function createEmptyCubeRefs() {
return { return alphabetKabbalahUi.createEmptyCubeRefs();
hebrewPlacementById: new Map(),
signPlacementById: new Map(),
planetPlacementById: new Map(),
pathPlacementByNo: new Map()
};
} }
function buildCubeReferences(magickDataset) { function buildCubeReferences(magickDataset) {
@@ -543,54 +488,19 @@
} }
function getCubePlacementForHebrewLetter(hebrewLetterId, pathNo = null) { function getCubePlacementForHebrewLetter(hebrewLetterId, pathNo = null) {
const normalizedLetterId = normalizeId(hebrewLetterId); return alphabetKabbalahUi.getCubePlacementForHebrewLetter(state.cubeRefs, hebrewLetterId, pathNo);
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;
} }
function getCubePlacementForPlanet(planetId) { function getCubePlacementForPlanet(planetId) {
const normalizedPlanetId = normalizeId(planetId); return alphabetKabbalahUi.getCubePlacementForPlanet(state.cubeRefs, planetId);
return normalizedPlanetId ? state.cubeRefs.planetPlacementById.get(normalizedPlanetId) || null : null;
} }
function getCubePlacementForSign(signId) { function getCubePlacementForSign(signId) {
const normalizedSignId = normalizeId(signId); return alphabetKabbalahUi.getCubePlacementForSign(state.cubeRefs, 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}`;
} }
function cubePlacementBtn(placement, fallbackDetail = null) { function cubePlacementBtn(placement, fallbackDetail = null) {
if (!placement) { return alphabetKabbalahUi.buildCubePlacementButton(placement, navBtn, fallbackDetail);
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);
} }
function cap(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : ""; } function cap(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : ""; }

329
app/ui-calendar-data.js Normal file
View 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
};
})();

View 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
};
})();

View File

@@ -37,6 +37,17 @@
findGodIdByName: () => null 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) { function init(config) {
Object.assign(api, config || {}); Object.assign(api, config || {});
} }
@@ -413,420 +424,17 @@
`; `;
} }
function findSignIdByAstrologyName(name) { function getPanelRenderContext(month) {
const token = api.normalizeCalendarText(name); return {
if (!token) { month,
return null; api,
} getState,
buildAssociationButtons,
for (const [signId, sign] of getState().signsById || []) { renderFactsCard,
const idToken = api.normalizeCalendarText(signId); renderAssociationsCard,
const nameToken = api.normalizeCalendarText(sign?.name?.en || sign?.name || ""); renderEventsCard,
if (token === idToken || token === nameToken) { renderHolidaysCard
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 attachNavHandlers(detailBodyEl) { function attachNavHandlers(detailBodyEl) {
@@ -964,28 +572,19 @@
detailNameEl.textContent = month.name || month.id; detailNameEl.textContent = month.name || month.id;
const currentState = getState(); const currentState = getState();
const panelContext = getPanelRenderContext(month);
if (currentState.selectedCalendar === "gregorian") { if (currentState.selectedCalendar === "gregorian") {
detailSubEl.textContent = `${api.parseMonthRange(month)} · ${month.coreTheme || "Month correspondences"}`; detailSubEl.textContent = `${api.parseMonthRange(month)} · ${month.coreTheme || "Month correspondences"}`;
detailBodyEl.innerHTML = ` detailBodyEl.innerHTML = calendarDetailPanelsUi.renderGregorianMonthDetail(panelContext);
<div class="planet-meta-grid">
${renderFactsCard(month)}
${renderDayLinksCard(month)}
${renderAssociationsCard(month)}
${renderMajorArcanaCard(month)}
${renderDecanTarotCard(month)}
${renderEventsCard(month)}
${renderHolidaysCard(month, "Holiday Repository")}
</div>
`;
} else if (currentState.selectedCalendar === "hebrew") { } else if (currentState.selectedCalendar === "hebrew") {
detailSubEl.textContent = api.getMonthSubtitle(month); detailSubEl.textContent = api.getMonthSubtitle(month);
detailBodyEl.innerHTML = renderHebrewMonthDetail(month); detailBodyEl.innerHTML = calendarDetailPanelsUi.renderHebrewMonthDetail(panelContext);
} else if (currentState.selectedCalendar === "islamic") { } else if (currentState.selectedCalendar === "islamic") {
detailSubEl.textContent = api.getMonthSubtitle(month); detailSubEl.textContent = api.getMonthSubtitle(month);
detailBodyEl.innerHTML = renderIslamicMonthDetail(month); detailBodyEl.innerHTML = calendarDetailPanelsUi.renderIslamicMonthDetail(panelContext);
} else { } else {
detailSubEl.textContent = api.getMonthSubtitle(month); detailSubEl.textContent = api.getMonthSubtitle(month);
detailBodyEl.innerHTML = renderWheelMonthDetail(month); detailBodyEl.innerHTML = calendarDetailPanelsUi.renderWheelMonthDetail(panelContext);
} }
attachNavHandlers(detailBodyEl); attachNavHandlers(detailBodyEl);

View File

@@ -5,6 +5,7 @@
const { getTarotCardDisplayName, getTarotCardSearchAliases } = window.TarotCardImages || {}; const { getTarotCardDisplayName, getTarotCardSearchAliases } = window.TarotCardImages || {};
const calendarDatesUi = window.TarotCalendarDates || {}; const calendarDatesUi = window.TarotCalendarDates || {};
const calendarDetailUi = window.TarotCalendarDetail || {}; const calendarDetailUi = window.TarotCalendarDetail || {};
const calendarDataUi = window.CalendarDataUi || {};
const { const {
addDays, addDays,
buildSignDateBounds, buildSignDateBounds,
@@ -29,6 +30,17 @@
resolveHolidayGregorianDate resolveHolidayGregorianDate
} = calendarDatesUi; } = 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 = { const state = {
initialized: false, initialized: false,
referenceData: null, 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) { function buildPlanetMap(planetsObj) {
const map = new Map(); const map = new Map();
if (!planetsObj || typeof planetsObj !== "object") { if (!planetsObj || typeof planetsObj !== "object") {
@@ -525,46 +427,6 @@
return parseMonthRange(month); 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) { function renderList(elements) {
const { monthListEl, monthCountEl, listTitleEl } = elements; const { monthListEl, monthCountEl, listTitleEl } = elements;
if (!monthListEl) { if (!monthListEl) {
@@ -601,162 +463,46 @@
} }
} }
function associationSearchText(associations) {
if (!associations || typeof associations !== "object") {
return "";
}
const tarotAliases = associations.tarotCard && typeof getTarotCardSearchAliases === "function" function getCalendarDataContext() {
? getTarotCardSearchAliases(associations.tarotCard, { trumpNumber: associations.tarotTrumpNumber }) return {
: []; state,
normalizeText,
normalizeSearchValue,
normalizeMinorTarotCardName,
getTarotCardSearchAliases,
addDays,
buildSignDateBounds,
formatDateLabel,
formatIsoDate,
getDaysInMonth,
resolveCalendarDayToGregorian,
resolveHolidayGregorianDate
};
}
return [ function buildDecanTarotRowsForMonth(month) {
associations.planetId, return calendarDataUi.buildDecanTarotRowsForMonth(getCalendarDataContext(), month);
associations.zodiacSignId, }
associations.numberValue,
associations.tarotCard, function getMonthDayLinkRows(month) {
associations.tarotTrumpNumber, return calendarDataUi.getMonthDayLinkRows(getCalendarDataContext(), month);
...tarotAliases,
associations.godId,
associations.godName,
associations.hebrewLetterId,
associations.kabbalahPathNumber,
associations.iChingPlanetaryInfluence
].filter(Boolean).join(" ");
} }
function eventSearchText(event) { function eventSearchText(event) {
return normalizeSearchValue([ return calendarDataUi.eventSearchText(getCalendarDataContext(), event);
event?.name,
event?.date,
event?.dateRange,
event?.description,
associationSearchText(event?.associations)
].filter(Boolean).join(" "));
} }
function holidaySearchText(holiday) { function holidaySearchText(holiday) {
return normalizeSearchValue([ return calendarDataUi.holidaySearchText(getCalendarDataContext(), holiday);
holiday?.name,
holiday?.kind,
holiday?.date,
holiday?.dateRange,
holiday?.dateText,
holiday?.monthDayStart,
holiday?.calendarId,
holiday?.description,
associationSearchText(holiday?.associations)
].filter(Boolean).join(" "));
} }
function buildHolidayList(month) { function buildHolidayList(month) {
const calendarId = state.selectedCalendar; return calendarDataUi.buildHolidayList(getCalendarDataContext(), month);
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(month) { function buildMonthSearchText(month) {
const monthHolidays = buildHolidayList(month); return calendarDataUi.buildMonthSearchText(getCalendarDataContext(), 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(" "));
} }
function matchesSearch(searchText) { function matchesSearch(searchText) {

550
app/ui-cube-chassis.js Normal file
View 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
View 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
};
})();

View File

@@ -120,6 +120,8 @@
below: { x: 90, y: 0 } below: { x: 90, y: 0 }
}; };
const cubeDetailUi = window.CubeDetailUi || {}; const cubeDetailUi = window.CubeDetailUi || {};
const cubeChassisUi = window.CubeChassisUi || {};
const cubeMathHelpers = window.CubeMathHelpers || {};
function getElements() { function getElements() {
return { return {
@@ -250,144 +252,48 @@
return Number.isFinite(numeric) ? numeric : null; 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) { function normalizeAngle(angle) {
let next = angle; return cubeMathUi.normalizeAngle(angle);
while (next > 180) {
next -= 360;
}
while (next <= -180) {
next += 360;
}
return next;
} }
function setRotation(nextX, nextY) { function setRotation(nextX, nextY) {
state.rotationX = normalizeAngle(nextX); cubeMathUi.setRotation(nextX, nextY);
state.rotationY = normalizeAngle(nextY);
} }
function snapRotationToWall(wallId) { function snapRotationToWall(wallId) {
const target = WALL_FRONT_ROTATIONS[normalizeId(wallId)]; cubeMathUi.snapRotationToWall(wallId);
if (!target) {
return;
}
setRotation(target.x, target.y);
} }
function facePoint(quad, u, v) { function facePoint(quad, u, v) {
const weight0 = ((1 - u) * (1 - v)) / 4; return cubeMathUi.facePoint(quad, u, v);
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) { function projectVerticesForRotation(rotationX, rotationY) {
const yaw = (rotationY * Math.PI) / 180; return cubeMathUi.projectVerticesForRotation(rotationX, rotationY);
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() { function projectVertices() {
return projectVerticesForRotation(state.rotationX, state.rotationY); return cubeMathUi.projectVertices();
} }
function getEdgeGeometryById(edgeId) { function getEdgeGeometryById(edgeId) {
const canonicalId = normalizeEdgeId(edgeId); return cubeMathUi.getEdgeGeometryById(edgeId);
const geometryIndex = EDGE_GEOMETRY_KEYS.indexOf(canonicalId);
if (geometryIndex < 0) {
return null;
}
return EDGE_GEOMETRY[geometryIndex] || null;
} }
function getWallEdgeDirections(wallOrWallId) { function getWallEdgeDirections(wallOrWallId) {
const wallId = normalizeId(typeof wallOrWallId === "string" ? wallOrWallId : wallOrWallId?.id); return cubeMathUi.getWallEdgeDirections(wallOrWallId);
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) { function getEdgeDirectionForWall(wallId, edgeId) {
const wallKey = normalizeId(wallId); return cubeMathUi.getEdgeDirectionForWall(wallId, edgeId);
const edgeKey = normalizeEdgeId(edgeId);
if (!wallKey || !edgeKey) {
return "";
}
const directions = getWallEdgeDirections(wallKey);
return directions.get(edgeKey) || "";
} }
function getEdgeDirectionLabelForWall(wallId, edgeId) { function getEdgeDirectionLabelForWall(wallId, edgeId) {
return formatDirectionName(getEdgeDirectionForWall(wallId, edgeId)); return cubeMathUi.getEdgeDirectionLabelForWall(wallId, edgeId);
} }
function bindRotationControls(elements) { function bindRotationControls(elements) {
@@ -444,94 +350,15 @@
} }
function getHebrewLetterSymbol(hebrewLetterId) { function getHebrewLetterSymbol(hebrewLetterId) {
const id = normalizeLetterKey(hebrewLetterId); return cubeMathUi.getHebrewLetterSymbol(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) { function getHebrewLetterName(hebrewLetterId) {
const id = normalizeLetterKey(hebrewLetterId); return cubeMathUi.getHebrewLetterName(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) { function getAstrologySymbol(type, name) {
const normalizedType = normalizeId(type); return cubeMathUi.getAstrologySymbol(type, name);
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 getEdgeLetterId(edge) { function getEdgeLetterId(edge) {
@@ -556,16 +383,11 @@
} }
function getCenterLetterId(center = null) { function getCenterLetterId(center = null) {
const entry = center || getCubeCenterData(); return cubeMathUi.getCenterLetterId(center);
return normalizeLetterKey(entry?.hebrewLetterId || entry?.associations?.hebrewLetterId || entry?.letter);
} }
function getCenterLetterSymbol(center = null) { function getCenterLetterSymbol(center = null) {
const centerLetterId = getCenterLetterId(center); return cubeMathUi.getCenterLetterSymbol(center);
if (!centerLetterId) {
return "";
}
return getHebrewLetterSymbol(centerLetterId);
} }
function getConnectorById(connectorId) { function getConnectorById(connectorId) {
@@ -591,42 +413,35 @@
return state.kabbalahPathsByLetterId.get(hebrewLetterId) || null; 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) { function getEdgeAstrologySymbol(edge) {
const pathEntry = getEdgePathEntry(edge); return cubeMathUi.getEdgeAstrologySymbol(edge);
const astrology = pathEntry?.astrology || {};
return getAstrologySymbol(astrology.type, astrology.name);
} }
function getEdgeMarkerDisplay(edge) { function getEdgeMarkerDisplay(edge) {
const letter = getEdgeLetter(edge); return cubeMathUi.getEdgeMarkerDisplay(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 };
} }
function getEdgeLetter(edge) { function getEdgeLetter(edge) {
const hebrewLetterId = getEdgeLetterId(edge); return cubeMathUi.getEdgeLetter(edge);
if (!hebrewLetterId) {
return "";
}
return getHebrewLetterSymbol(hebrewLetterId);
} }
function getWallTarotCard(wall) { function getWallTarotCard(wall) {
@@ -700,513 +515,47 @@
} }
function renderFaceSvg(containerEl, walls) { function renderFaceSvg(containerEl, walls) {
if (!containerEl) { if (typeof cubeChassisUi.renderFaceSvg !== "function") {
if (containerEl) {
containerEl.replaceChildren();
}
return; return;
} }
const svgNS = "http://www.w3.org/2000/svg"; cubeChassisUi.renderFaceSvg({
const svg = document.createElementNS(svgNS, "svg"); state,
svg.setAttribute("viewBox", "0 0 240 220"); containerEl,
svg.setAttribute("width", "100%"); walls,
svg.setAttribute("class", "cube-svg"); normalizeId,
svg.setAttribute("role", "img"); projectVertices,
svg.setAttribute("aria-label", "Cube of Space interactive chassis"); FACE_GEOMETRY,
facePoint,
const wallById = new Map(walls.map((wall) => [normalizeId(wall?.id), wall])); normalizeEdgeId,
const projectedVertices = projectVertices(); getEdges,
const faces = Object.entries(FACE_GEOMETRY) getEdgesForWall,
.map(([wallId, indices]) => { EDGE_GEOMETRY,
const wall = wallById.get(wallId); EDGE_GEOMETRY_KEYS,
if (!wall) { formatEdgeName,
return null; getEdgeWalls,
} getElements,
render,
const quad = indices.map((index) => projectedVertices[index]); snapRotationToWall,
const avgDepth = quad.reduce((sum, point) => sum + point.z, 0) / quad.length; getWallFaceLetter,
getWallTarotCard,
return { resolveCardImageUrl,
wallId, openTarotCardLightbox,
wall, MOTHER_CONNECTORS,
quad, formatDirectionName,
depth: avgDepth, getConnectorTarotCard,
pointsText: quad.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`).join(" ") getHebrewLetterSymbol,
}; toDisplayText,
}) CUBE_VIEW_CENTER,
.filter(Boolean) getEdgeMarkerDisplay,
.sort((left, right) => left.depth - right.depth); getEdgeTarotCard,
getCubeCenterData,
faces.forEach((faceData) => { getCenterTarotCard,
const { wallId, wall, quad, pointsText } = faceData; getCenterLetterSymbol
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);
}); });
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 = "") { function selectEdgeById(edgeId, preferredWallId = "") {

225
app/ui-gods-references.js Normal file
View 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
};
})();

View File

@@ -3,6 +3,14 @@
* Kabbalah paths are shown only as a reference at the bottom of each detail view. * 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 ────────────────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────────
const state = { const state = {
initialized: false, initialized: false,
@@ -61,223 +69,6 @@
return null; 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 ───────────────────────────────────────────────────────────────── // ── Filter ─────────────────────────────────────────────────────────────────
function applyFilter() { function applyFilter() {
const q = state.searchQuery.toLowerCase(); const q = state.searchQuery.toLowerCase();
@@ -557,7 +348,7 @@
// ── Init ─────────────────────────────────────────────────────────────────── // ── Init ───────────────────────────────────────────────────────────────────
function ensureGodsSection(magickDataset, referenceData = null) { function ensureGodsSection(magickDataset, referenceData = null) {
if (referenceData) { if (referenceData) {
state.monthRefsByGodId = buildMonthReferencesByGod(referenceData); state.monthRefsByGodId = godReferenceBuilders.buildMonthReferencesByGod(referenceData);
} }
if (state.initialized) { if (state.initialized) {

472
app/ui-holidays-data.js Normal file
View 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
View 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
};
})();

View File

@@ -3,6 +3,28 @@
"use strict"; "use strict";
const { getTarotCardDisplayName, getTarotCardSearchAliases } = window.TarotCardImages || {}; 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 = { const state = {
initialized: false, initialized: false,
@@ -69,52 +91,6 @@
"the world": 21 "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() { function getElements() {
return { return {
sourceSelectEl: document.getElementById("holiday-source-select"), sourceSelectEl: document.getElementById("holiday-source-select"),
@@ -180,583 +156,27 @@
return getTarotCardDisplayName(cardName) || cardName; return getTarotCardDisplayName(cardName) || cardName;
} }
function normalizeCalendarText(value) { function getRenderContext(elements = getElements()) {
return String(value || "") return {
.normalize("NFKD") elements,
.replace(/[\u0300-\u036f]/g, "") state,
.replace(/['`]/g, "") cap,
.toLowerCase() normalizeSearchValue,
.replace(/[^a-z0-9]+/g, " ") getDisplayTarotName,
.trim(); resolveTarotTrumpNumber,
} getTarotCardSearchAliases,
calendarLabel: holidayDataUi.calendarLabel,
function readNumericPart(parts, partType) { monthLabelForCalendar: (calendarId, monthId) => holidayDataUi.monthLabelForCalendar(state.calendarData, calendarId, monthId),
const raw = parts.find((part) => part.type === partType)?.value; normalizeSourceFilter: holidayDataUi.normalizeSourceFilter,
if (!raw) { filterBySource,
return null; resolveHolidayGregorianDate: (holiday) => holidayDataUi.resolveHolidayGregorianDate(holiday, {
} selectedYear: state.selectedYear,
calendarData: state.calendarData
const digits = String(raw).replace(/[^0-9]/g, ""); }),
if (!digits) { formatGregorianReferenceDate: holidayDataUi.formatGregorianReferenceDate,
return null; formatCalendarDateFromGregorian: holidayDataUi.formatCalendarDateFromGregorian,
} selectByHolidayId
};
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 getSelectedHoliday() { function getSelectedHoliday() {
@@ -764,7 +184,7 @@
} }
function filterBySource(holidays) { function filterBySource(holidays) {
const source = normalizeSourceFilter(state.selectedSource); const source = holidayDataUi.normalizeSourceFilter(state.selectedSource);
if (source === "all") { if (source === "all") {
return [...holidays]; return [...holidays];
} }
@@ -773,7 +193,7 @@
function syncControls(elements) { function syncControls(elements) {
if (elements.sourceSelectEl) { if (elements.sourceSelectEl) {
elements.sourceSelectEl.value = normalizeSourceFilter(state.selectedSource); elements.sourceSelectEl.value = holidayDataUi.normalizeSourceFilter(state.selectedSource);
} }
if (elements.yearInputEl) { if (elements.yearInputEl) {
elements.yearInputEl.value = String(state.selectedYear); elements.yearInputEl.value = String(state.selectedYear);
@@ -787,99 +207,11 @@
} }
function renderList(elements) { function renderList(elements) {
const { listEl, countEl } = elements; holidayRenderUi.renderList(getRenderContext(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) { function renderHolidayDetail(holiday) {
const gregorianDate = resolveHolidayGregorianDate(holiday); return holidayRenderUi.renderHolidayDetail(holiday, getRenderContext());
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>
`;
} }
function renderDetail(elements) { function renderDetail(elements) {
@@ -897,7 +229,7 @@
} }
detailNameEl.textContent = holiday?.name || holiday?.id || "Holiday"; 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); detailBodyEl.innerHTML = renderHolidayDetail(holiday);
attachNavHandlers(detailBodyEl); attachNavHandlers(detailBodyEl);
} }
@@ -905,7 +237,7 @@
function applyFilters(elements) { function applyFilters(elements) {
const sourceFiltered = filterBySource(state.holidays); const sourceFiltered = filterBySource(state.holidays);
state.filteredHolidays = state.searchQuery state.filteredHolidays = state.searchQuery
? sourceFiltered.filter((holiday) => holidaySearchText(holiday).includes(state.searchQuery)) ? sourceFiltered.filter((holiday) => holidayRenderUi.holidaySearchText(holiday, getRenderContext()).includes(state.searchQuery))
: sourceFiltered; : sourceFiltered;
if (!state.filteredHolidays.some((holiday) => holiday.id === state.selectedHolidayId)) { if (!state.filteredHolidays.some((holiday) => holiday.id === state.selectedHolidayId)) {
@@ -924,12 +256,12 @@
} }
const targetCalendar = String(target.calendarId || "").trim().toLowerCase(); const targetCalendar = String(target.calendarId || "").trim().toLowerCase();
const activeFilter = normalizeSourceFilter(state.selectedSource); const activeFilter = holidayDataUi.normalizeSourceFilter(state.selectedSource);
if (activeFilter !== "all" && activeFilter !== targetCalendar) { if (activeFilter !== "all" && activeFilter !== targetCalendar) {
state.selectedSource = targetCalendar || "all"; state.selectedSource = targetCalendar || "all";
} }
if (state.searchQuery && !holidaySearchText(target).includes(state.searchQuery)) { if (state.searchQuery && !holidayRenderUi.holidaySearchText(target, getRenderContext()).includes(state.searchQuery)) {
state.searchQuery = ""; state.searchQuery = "";
} }
@@ -941,7 +273,7 @@
function bindControls(elements) { function bindControls(elements) {
if (elements.sourceSelectEl) { if (elements.sourceSelectEl) {
elements.sourceSelectEl.addEventListener("change", () => { elements.sourceSelectEl.addEventListener("change", () => {
state.selectedSource = normalizeSourceFilter(elements.sourceSelectEl.value); state.selectedSource = holidayDataUi.normalizeSourceFilter(elements.sourceSelectEl.value);
applyFilters(elements); applyFilters(elements);
}); });
} }
@@ -1073,19 +405,13 @@
state.referenceData = referenceData; state.referenceData = referenceData;
state.magickDataset = magickDataset || null; state.magickDataset = magickDataset || null;
state.planetsById = buildPlanetMap(referenceData.planets); state.planetsById = holidayDataUi.buildPlanetMap(referenceData.planets);
state.signsById = buildSignsMap(referenceData.signs); state.signsById = holidayDataUi.buildSignsMap(referenceData.signs);
state.godsById = buildGodsMap(state.magickDataset); state.godsById = holidayDataUi.buildGodsMap(state.magickDataset);
state.hebrewById = buildHebrewMap(state.magickDataset); state.hebrewById = holidayDataUi.buildHebrewMap(state.magickDataset);
state.calendarData = { state.calendarData = holidayDataUi.buildCalendarData(referenceData);
gregorian: Array.isArray(referenceData.calendarMonths) ? referenceData.calendarMonths : [], state.holidays = holidayDataUi.buildAllHolidays(state.referenceData);
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();
if (!state.selectedHolidayId || !state.holidays.some((holiday) => holiday.id === state.selectedHolidayId)) { if (!state.selectedHolidayId || !state.holidays.some((holiday) => holiday.id === state.selectedHolidayId)) {
state.selectedHolidayId = state.holidays[0]?.id || null; state.selectedHolidayId = state.holidays[0]?.id || null;
} }

253
app/ui-iching-references.js Normal file
View 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
};
})();

View File

@@ -1,4 +1,12 @@
(function () { (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 { getTarotCardSearchAliases } = window.TarotCardImages || {};
const state = { const state = {
@@ -87,237 +95,6 @@
return normalizePlanetInfluence(ICHING_PLANET_BY_PLANET_ID[planetId]); 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) { function getBinaryPattern(value, expectedLength = 0) {
const raw = String(value || "").trim(); const raw = String(value || "").trim();
if (!raw) { if (!raw) {
@@ -723,7 +500,12 @@
const elements = getElements(); const elements = getElements();
if (state.initialized) { 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); const selected = state.hexagrams.find((hexagram) => hexagram.number === state.selectedNumber);
if (selected) { if (selected) {
renderDetail(selected, elements); renderDetail(selected, elements);
@@ -780,7 +562,12 @@
.filter((entry) => Number.isFinite(entry.number)) .filter((entry) => Number.isFinite(entry.number))
.sort((a, b) => a.number - b.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]; state.filteredHexagrams = [...state.hexagrams];
renderList(elements); renderList(elements);

388
app/ui-kabbalah-views.js Normal file
View 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
};
})();

View File

@@ -62,6 +62,14 @@
}; };
const kabbalahDetailUi = window.KabbalahDetailUi || {}; 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 = { const PLANET_NAME_TO_ID = {
saturn: "saturn", 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) { function normalizeText(value) {
return String(value || "").trim().toLowerCase(); 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) { function renderRoseLandingIntro(roseElements) {
if (typeof kabbalahDetailUi.renderRoseLandingIntro === "function") { if (typeof kabbalahDetailUi.renderRoseLandingIntro === "function") {
@@ -753,25 +422,29 @@
} }
} }
function renderRoseCross(elements) { function getViewRenderContext(elements) {
if (!state.tree || !elements?.roseCrossContainerEl) { return {
return; state,
} tree: state.tree,
elements,
const roseElements = getRoseDetailElements(elements); getRoseDetailElements,
if (!roseElements?.detailBodyEl) { renderSephiraDetail,
return; renderPathDetail,
} NS,
R,
const roseBuilder = window.KabbalahRosicrucianCross?.buildRosicrucianCrossSVG; NODE_POS,
if (typeof roseBuilder !== "function") { SEPH_FILL,
return; DARK_TEXT,
} DAAT,
PATH_MARKER_SCALE,
const roseSvg = roseBuilder(state.tree); PATH_LABEL_RADIUS,
elements.roseCrossContainerEl.innerHTML = ""; PATH_LABEL_FONT_SIZE,
elements.roseCrossContainerEl.appendChild(roseSvg); PATH_TAROT_WIDTH,
bindRoseCrossInteractions(roseSvg, state.tree, roseElements); PATH_TAROT_HEIGHT,
PATH_LABEL_OFFSET_WITH_TAROT,
PATH_TAROT_OFFSET_WITH_LABEL,
PATH_TAROT_OFFSET_NO_LABEL
};
} }
function renderRoseCurrentSelection(elements) { function renderRoseCurrentSelection(elements) {
@@ -795,15 +468,12 @@
renderRoseLandingIntro(roseElements); renderRoseLandingIntro(roseElements);
} }
function renderTree(elements) { function renderRoseCross(elements) {
if (!state.tree || !elements?.treeContainerEl) { kabbalahViewsUi.renderRoseCross(getViewRenderContext(elements));
return; }
}
const svg = buildTreeSVG(state.tree); function renderTree(elements) {
elements.treeContainerEl.innerHTML = ""; kabbalahViewsUi.renderTree(getViewRenderContext(elements));
elements.treeContainerEl.appendChild(svg);
bindTreeInteractions(svg, state.tree, elements);
} }
function renderCurrentSelection(elements) { function renderCurrentSelection(elements) {

519
app/ui-now-helpers.js Normal file
View 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
};
})();

View File

@@ -1,517 +1,27 @@
(function () { (function () {
"use strict";
const { const {
DAY_IN_MS, DAY_IN_MS,
getDateKey, getDateKey,
getMoonPhaseName, getMoonPhaseName,
getDecanForDate,
calcPlanetaryHoursForDayAndLocation calcPlanetaryHoursForDayAndLocation
} = window.TarotCalc; } = 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 moonCountdownCache = null;
let decanCountdownCache = 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") { function updateNowPanel(referenceData, geo, elements, timeFormat = "minutes") {
if (!referenceData || !geo || !elements) { if (!referenceData || !geo || !elements) {
@@ -543,12 +53,12 @@
const hourCardName = planet?.tarot?.majorArcana || ""; const hourCardName = planet?.tarot?.majorArcana || "";
const hourTrumpNumber = planet?.tarot?.number; const hourTrumpNumber = planet?.tarot?.number;
elements.nowHourTarotEl.textContent = hourCardName elements.nowHourTarotEl.textContent = hourCardName
? getDisplayTarotName(hourCardName, hourTrumpNumber) ? nowUiHelpers.getDisplayTarotName(hourCardName, hourTrumpNumber)
: "--"; : "--";
} }
const msLeft = Math.max(0, currentHour.end.getTime() - now.getTime()); 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) { if (elements.nowHourNextEl) {
const nextHour = allHours.find( const nextHour = allHours.find(
@@ -564,7 +74,7 @@
} }
} }
setNowCardImage( nowUiHelpers.setNowCardImage(
elements.nowHourCardEl, elements.nowHourCardEl,
planet?.tarot?.majorArcana, planet?.tarot?.majorArcana,
"Current planetary hour card", "Current planetary hour card",
@@ -579,15 +89,15 @@
if (elements.nowHourNextEl) { if (elements.nowHourNextEl) {
elements.nowHourNextEl.textContent = "> --"; 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 moonIllum = window.SunCalc.getMoonIllumination(now);
const moonPhase = getMoonPhaseName(moonIllum.phase); const moonPhase = getMoonPhaseName(moonIllum.phase);
const moonTarot = referenceData.planets.luna?.tarot?.majorArcana || "The High Priestess"; const moonTarot = referenceData.planets.luna?.tarot?.majorArcana || "The High Priestess";
elements.nowMoonEl.textContent = `${moonPhase} (${Math.round(moonIllum.fraction * 100)}%)`; elements.nowMoonEl.textContent = `${moonPhase} (${Math.round(moonIllum.fraction * 100)}%)`;
elements.nowMoonTarotEl.textContent = getDisplayTarotName(moonTarot, referenceData.planets.luna?.tarot?.number); elements.nowMoonTarotEl.textContent = nowUiHelpers.getDisplayTarotName(moonTarot, referenceData.planets.luna?.tarot?.number);
setNowCardImage( nowUiHelpers.setNowCardImage(
elements.nowMoonCardEl, elements.nowMoonCardEl,
moonTarot, moonTarot,
"Current moon phase card", "Current moon phase card",
@@ -595,13 +105,13 @@
); );
if (!moonCountdownCache || moonCountdownCache.fromPhase !== moonPhase || now >= moonCountdownCache.changeAt) { if (!moonCountdownCache || moonCountdownCache.fromPhase !== moonPhase || now >= moonCountdownCache.changeAt) {
moonCountdownCache = findNextMoonPhaseTransition(now); moonCountdownCache = nowUiHelpers.findNextMoonPhaseTransition(now);
} }
if (elements.nowMoonCountdownEl) { if (elements.nowMoonCountdownEl) {
if (moonCountdownCache?.changeAt) { if (moonCountdownCache?.changeAt) {
const remaining = moonCountdownCache.changeAt.getTime() - now.getTime(); const remaining = moonCountdownCache.changeAt.getTime() - now.getTime();
elements.nowMoonCountdownEl.textContent = formatCountdown(remaining, timeFormat); elements.nowMoonCountdownEl.textContent = nowUiHelpers.formatCountdown(remaining, timeFormat);
if (elements.nowMoonNextEl) { if (elements.nowMoonNextEl) {
elements.nowMoonNextEl.textContent = `> ${moonCountdownCache.nextPhase}`; elements.nowMoonNextEl.textContent = `> ${moonCountdownCache.nextPhase}`;
} }
@@ -621,24 +131,24 @@
const signStartDate = getSignStartDate(now, sunInfo.sign); const signStartDate = getSignStartDate(now, sunInfo.sign);
const daysSinceSignStart = (now.getTime() - signStartDate.getTime()) / DAY_IN_MS; const daysSinceSignStart = (now.getTime() - signStartDate.getTime()) / DAY_IN_MS;
const signDegree = Math.min(29.9, Math.max(0, daysSinceSignStart)); 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)}°)`; elements.nowDecanEl.textContent = `${sunInfo.sign.symbol} ${sunInfo.sign.name} · ${signMajorName} (${signDegree.toFixed(1)}°)`;
const currentDecanKey = `${sunInfo.sign.id}-${sunInfo.decan?.index || 1}`; const currentDecanKey = `${sunInfo.sign.id}-${sunInfo.decan?.index || 1}`;
if (!decanCountdownCache || decanCountdownCache.key !== currentDecanKey || now >= decanCountdownCache.changeAt) { 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) { if (sunInfo.decan) {
const decanCardName = sunInfo.decan.tarotMinorArcana; const decanCardName = sunInfo.decan.tarotMinorArcana;
elements.nowDecanTarotEl.textContent = getDisplayTarotName(decanCardName); elements.nowDecanTarotEl.textContent = nowUiHelpers.getDisplayTarotName(decanCardName);
setNowCardImage(elements.nowDecanCardEl, sunInfo.decan.tarotMinorArcana, "Current decan card"); nowUiHelpers.setNowCardImage(elements.nowDecanCardEl, sunInfo.decan.tarotMinorArcana, "Current decan card");
} else { } else {
const signTarotName = sunInfo.sign.tarot?.majorArcana || "--"; const signTarotName = sunInfo.sign.tarot?.majorArcana || "--";
elements.nowDecanTarotEl.textContent = signTarotName === "--" elements.nowDecanTarotEl.textContent = signTarotName === "--"
? "--" ? "--"
: getDisplayTarotName(signTarotName, sunInfo.sign.tarot?.trumpNumber); : nowUiHelpers.getDisplayTarotName(signTarotName, sunInfo.sign.tarot?.trumpNumber);
setNowCardImage( nowUiHelpers.setNowCardImage(
elements.nowDecanCardEl, elements.nowDecanCardEl,
sunInfo.sign.tarot?.majorArcana, sunInfo.sign.tarot?.majorArcana,
"Current decan card", "Current decan card",
@@ -649,9 +159,9 @@
if (elements.nowDecanCountdownEl) { if (elements.nowDecanCountdownEl) {
if (decanCountdownCache?.changeAt) { if (decanCountdownCache?.changeAt) {
const remaining = decanCountdownCache.changeAt.getTime() - now.getTime(); const remaining = decanCountdownCache.changeAt.getTime() - now.getTime();
elements.nowDecanCountdownEl.textContent = formatCountdown(remaining, timeFormat); elements.nowDecanCountdownEl.textContent = nowUiHelpers.formatCountdown(remaining, timeFormat);
if (elements.nowDecanNextEl) { if (elements.nowDecanNextEl) {
elements.nowDecanNextEl.textContent = `> ${getDisplayTarotName(decanCountdownCache.nextLabel)}`; elements.nowDecanNextEl.textContent = `> ${nowUiHelpers.getDisplayTarotName(decanCountdownCache.nextLabel)}`;
} }
} else { } else {
elements.nowDecanCountdownEl.textContent = "--"; elements.nowDecanCountdownEl.textContent = "--";
@@ -663,7 +173,7 @@
} else { } else {
elements.nowDecanEl.textContent = "--"; elements.nowDecanEl.textContent = "--";
elements.nowDecanTarotEl.textContent = "--"; elements.nowDecanTarotEl.textContent = "--";
setNowCardImage(elements.nowDecanCardEl, null, "Current decan card"); nowUiHelpers.setNowCardImage(elements.nowDecanCardEl, null, "Current decan card");
if (elements.nowDecanCountdownEl) { if (elements.nowDecanCountdownEl) {
elements.nowDecanCountdownEl.textContent = "--"; elements.nowDecanCountdownEl.textContent = "--";
} }
@@ -672,7 +182,7 @@
} }
} }
updateNowStats(referenceData, elements, now); nowUiHelpers.updateNowStats(referenceData, elements, now);
return { return {
dayKey, dayKey,

634
app/ui-numbers-detail.js Normal file
View 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
};
})();

View File

@@ -11,6 +11,11 @@
const NUMBERS_SPECIAL_BASE_VALUES = [1, 2, 3, 4]; const NUMBERS_SPECIAL_BASE_VALUES = [1, 2, 3, 4];
const numbersSpecialFlipState = new Map(); 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) => ({ const DEFAULT_NUMBER_ENTRIES = Array.from({ length: 10 }, (_, value) => ({
value, value,
@@ -194,55 +199,6 @@
return current; 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) { function rankLabelToTarotMinorRank(rankLabel) {
const key = String(rankLabel || "").trim().toLowerCase(); const key = String(rankLabel || "").trim().toLowerCase();
if (key === "10" || key === "ten") return "Princess"; if (key === "10" || key === "ten") return "Princess";
@@ -252,409 +208,6 @@
return String(rankLabel || "").trim(); 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() { function renderNumbersList() {
const { listEl, countEl } = getElements(); const { listEl, countEl } = getElements();
if (!listEl) { 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) { function renderNumberDetail(value) {
const { detailNameEl, detailTypeEl, detailSummaryEl, detailBodyEl } = getElements(); numbersDetailUi.renderNumberDetail(getDetailRenderContext(value));
const entry = getNumberEntryByValue(value);
if (!entry) {
return;
}
const normalized = entry.value;
const opposite = entry.opposite;
const rootTarget = normalizeNumberValue(entry.digitalRoot);
if (detailNameEl) {
detailNameEl.textContent = `Number ${normalized} · ${entry.label}`;
}
if (detailTypeEl) {
detailTypeEl.textContent = `Opposite: ${opposite}`;
}
if (detailSummaryEl) {
detailSummaryEl.textContent = entry.summary || "";
}
renderNumbersSpecialPanel(normalized);
if (!detailBodyEl) {
return;
}
detailBodyEl.replaceChildren();
const pairCardEl = document.createElement("div");
pairCardEl.className = "numbers-detail-card";
const pairHeadingEl = document.createElement("strong");
pairHeadingEl.textContent = "Number Pair";
const pairTextEl = document.createElement("div");
pairTextEl.className = "numbers-detail-text";
pairTextEl.textContent = `Opposite: ${opposite}`;
const keywordText = entry.keywords.length
? `Keywords: ${entry.keywords.join(", ")}`
: "Keywords: --";
const pairKeywordsEl = document.createElement("div");
pairKeywordsEl.className = "numbers-detail-text numbers-detail-text--muted";
pairKeywordsEl.textContent = keywordText;
const oppositeBtn = document.createElement("button");
oppositeBtn.type = "button";
oppositeBtn.className = "numbers-nav-btn";
oppositeBtn.textContent = `Open Opposite Number ${opposite}`;
oppositeBtn.addEventListener("click", () => {
selectNumberEntry(opposite);
});
pairCardEl.append(pairHeadingEl, pairTextEl, pairKeywordsEl, oppositeBtn);
const kabbalahCardEl = document.createElement("div");
kabbalahCardEl.className = "numbers-detail-card";
const kabbalahHeadingEl = document.createElement("strong");
kabbalahHeadingEl.textContent = "Kabbalah Link";
const kabbalahNode = Number(entry?.associations?.kabbalahNode);
const kabbalahTextEl = document.createElement("div");
kabbalahTextEl.className = "numbers-detail-text";
kabbalahTextEl.textContent = `Tree node target: ${kabbalahNode}`;
const kabbalahBtn = document.createElement("button");
kabbalahBtn.type = "button";
kabbalahBtn.className = "numbers-nav-btn";
kabbalahBtn.textContent = `Open Kabbalah Tree Node ${kabbalahNode}`;
kabbalahBtn.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent("nav:kabbalah-path", {
detail: { pathNo: kabbalahNode }
}));
});
kabbalahCardEl.append(kabbalahHeadingEl, kabbalahTextEl, kabbalahBtn);
const alphabetCardEl = document.createElement("div");
alphabetCardEl.className = "numbers-detail-card";
const alphabetHeadingEl = document.createElement("strong");
alphabetHeadingEl.textContent = "Alphabet Links";
const alphabetLinksWrapEl = document.createElement("div");
alphabetLinksWrapEl.className = "numbers-links-wrap";
const alphabetLinks = getAlphabetPositionLinksForDigitalRoot(rootTarget);
if (!alphabetLinks.length) {
const emptyAlphabetEl = document.createElement("div");
emptyAlphabetEl.className = "numbers-detail-text numbers-detail-text--muted";
emptyAlphabetEl.textContent = "No alphabet position entries found for this digital root yet.";
alphabetLinksWrapEl.appendChild(emptyAlphabetEl);
} else {
alphabetLinks.forEach((link) => {
const button = document.createElement("button");
button.type = "button";
button.className = "numbers-nav-btn";
button.textContent = `${link.alphabet}: ${link.label}`;
button.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent("nav:alphabet", {
detail: link.detail
}));
});
alphabetLinksWrapEl.appendChild(button);
});
}
alphabetCardEl.append(alphabetHeadingEl, alphabetLinksWrapEl);
const tarotCardEl = document.createElement("div");
tarotCardEl.className = "numbers-detail-card";
const tarotHeadingEl = document.createElement("strong");
tarotHeadingEl.textContent = "Tarot Links";
const tarotLinksWrapEl = document.createElement("div");
tarotLinksWrapEl.className = "numbers-links-wrap";
const tarotCards = getTarotCardsForDigitalRoot(rootTarget, entry);
if (!tarotCards.length) {
const emptyEl = document.createElement("div");
emptyEl.className = "numbers-detail-text numbers-detail-text--muted";
emptyEl.textContent = "No tarot numeric entries found yet for this root. Add card numbers to map them.";
tarotLinksWrapEl.appendChild(emptyEl);
} else {
tarotCards.forEach((card) => {
const button = document.createElement("button");
button.type = "button";
button.className = "numbers-nav-btn";
button.textContent = `${card.name}`;
button.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent("nav:tarot-trump", {
detail: { cardName: card.name }
}));
});
tarotLinksWrapEl.appendChild(button);
});
}
tarotCardEl.append(tarotHeadingEl, tarotLinksWrapEl);
const calendarCardEl = document.createElement("div");
calendarCardEl.className = "numbers-detail-card";
const calendarHeadingEl = document.createElement("strong");
calendarHeadingEl.textContent = "Calendar Links";
const calendarLinksWrapEl = document.createElement("div");
calendarLinksWrapEl.className = "numbers-links-wrap";
const calendarLinks = getCalendarMonthLinksForNumber(normalized);
if (!calendarLinks.length) {
const emptyCalendarEl = document.createElement("div");
emptyCalendarEl.className = "numbers-detail-text numbers-detail-text--muted";
emptyCalendarEl.textContent = "No calendar months currently mapped to this number.";
calendarLinksWrapEl.appendChild(emptyCalendarEl);
} else {
calendarLinks.forEach((link) => {
const button = document.createElement("button");
button.type = "button";
button.className = "numbers-nav-btn";
button.textContent = `${link.calendarLabel}: ${link.monthName} (Month ${link.monthOrder})`;
button.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent("nav:calendar-month", {
detail: {
calendarId: link.calendarId,
monthId: link.monthId
}
}));
});
calendarLinksWrapEl.appendChild(button);
});
}
calendarCardEl.append(calendarHeadingEl, calendarLinksWrapEl);
detailBodyEl.append(pairCardEl, kabbalahCardEl, alphabetCardEl, tarotCardEl, calendarCardEl);
} }
function selectNumberEntry(value) { function selectNumberEntry(value) {

View 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
};
})();

View File

@@ -1,5 +1,15 @@
(function () { (function () {
"use strict";
const { getTarotCardDisplayName, getTarotCardSearchAliases } = window.TarotCardImages || {}; 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 = { const state = {
initialized: false, initialized: false,
@@ -68,326 +78,6 @@
return map; 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() { function getElements() {
return { return {
planetCardListEl: document.getElementById("planet-card-list"), planetCardListEl: document.getElementById("planet-card-list"),
@@ -815,8 +505,15 @@
}, {}); }, {});
state.kabbalahTargetsByPlanetId = buildKabbalahTargetsByPlanet(magickDataset); state.kabbalahTargetsByPlanetId = buildKabbalahTargetsByPlanet(magickDataset);
state.monthRefsByPlanetId = buildMonthReferencesByPlanet(referenceData); state.monthRefsByPlanetId = planetReferenceBuilders.buildMonthReferencesByPlanet({
state.cubePlacementsByPlanetId = buildCubePlacementsByPlanet(magickDataset); referenceData,
toPlanetId,
normalizePlanetToken
});
state.cubePlacementsByPlanetId = planetReferenceBuilders.buildCubePlacementsByPlanet({
magickDataset,
toPlanetId
});
state.entries = baseList.map((entry) => { state.entries = baseList.map((entry) => {
const byId = correspondences[entry.id] || null; const byId = correspondences[entry.id] || null;

View 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
};
})();

View 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
};
})();

View File

@@ -2,6 +2,12 @@
(function () { (function () {
"use strict"; "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) { function toTitleCase(value) {
const text = String(value || "").trim().toLowerCase(); const text = String(value || "").trim().toLowerCase();
if (!text) { if (!text) {
@@ -104,817 +110,14 @@
} }
function buildQuestionBank(referenceData, magickDataset, dynamicCategoryRegistry) { function buildQuestionBank(referenceData, magickDataset, dynamicCategoryRegistry) {
const grouped = magickDataset?.grouped || {}; const bank = quizQuestionBankBuiltins.buildBuiltInQuestionBank({
const alphabets = grouped.alphabets || {}; referenceData,
const englishLetters = Array.isArray(alphabets?.english) ? alphabets.english : []; magickDataset,
const hebrewLetters = Array.isArray(alphabets?.hebrew) ? alphabets.hebrew : []; helpers: {
const kabbalahTree = grouped?.kabbalah?.["kabbalah-tree"] || {}; toTitleCase,
const treePaths = Array.isArray(kabbalahTree?.paths) ? kabbalahTree.paths : []; normalizeOption,
const treeSephiroth = Array.isArray(kabbalahTree?.sephiroth) ? kabbalahTree.sephiroth : []; toUniqueOptionList,
const sephirotById = grouped?.kabbalah?.sephirot && typeof grouped.kabbalah.sephirot === "object" createQuestionTemplate
? 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);
} }
}); });

View 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
View 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 &#8212; 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 || ""} &mdash; &ldquo;${letter.meaning || ""}&rdquo; &middot; ${letter.letterType || ""}</span>
<span class="tarot-kab-connects">${fromName} &rarr; ${toName}${astro ? " &middot; " + 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 &#8212; 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
};
})();

View 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
};
})();

View File

@@ -2,6 +2,9 @@
const { resolveTarotCardImage, getTarotCardDisplayName, getTarotCardSearchAliases } = window.TarotCardImages || {}; const { resolveTarotCardImage, getTarotCardDisplayName, getTarotCardSearchAliases } = window.TarotCardImages || {};
const tarotHouseUi = window.TarotHouseUi || {}; const tarotHouseUi = window.TarotHouseUi || {};
const tarotRelationsUi = window.TarotRelationsUi || {}; const tarotRelationsUi = window.TarotRelationsUi || {};
const tarotCardDerivations = window.TarotCardDerivations || {};
const tarotDetailUi = window.TarotDetailUi || {};
const tarotRelationDisplay = window.TarotRelationDisplay || {};
const state = { const state = {
initialized: false, initialized: false,
@@ -242,6 +245,56 @@
.replace(/(^-|-$)/g, ""); .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) { function normalizeSearchValue(value) {
return String(value || "").trim().toLowerCase(); return String(value || "").trim().toLowerCase();
} }
@@ -299,165 +352,16 @@
.join(" "); .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 = []) { function buildElementRelationsForCard(card, baseElementRelations = []) {
if (!card) { return tarotCardDerivationsUi.buildElementRelationsForCard(card, baseElementRelations);
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;
} }
function buildTetragrammatonRelationsForCard(card) { function buildTetragrammatonRelationsForCard(card) {
if (!card) { return tarotCardDerivationsUi.buildTetragrammatonRelationsForCard(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) { function buildSmallCardRulershipRelation(card) {
if (!card || card.arcana !== "Minor") { return tarotCardDerivationsUi.buildSmallCardRulershipRelation(card);
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}`
};
} }
function buildCourtCardByDecanId(cards) { function buildCourtCardByDecanId(cards) {
@@ -566,21 +470,7 @@
} }
function buildTypeLabel(card) { function buildTypeLabel(card) {
if (card.arcana === "Major") { return tarotCardDerivationsUi.buildTypeLabel(card);
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(" · ");
} }
const MINOR_PLURAL_BY_RANK = { const MINOR_PLURAL_BY_RANK = {
@@ -597,100 +487,23 @@
}; };
function findSephirahForMinorCard(card, kabTree) { function findSephirahForMinorCard(card, kabTree) {
if (!card || card.arcana !== "Minor" || !kabTree) { return tarotCardDerivationsUi.findSephirahForMinorCard(card, 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;
} }
function formatRelation(relation) { function formatRelation(relation) {
if (typeof relation === "string") { return tarotRelationDisplayUi.formatRelation(relation);
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) { function relationKey(relation, index) {
const safeType = String(relation?.type || "relation"); return tarotRelationDisplayUi.relationKey(relation, index);
const safeId = String(relation?.id || index || "0");
const safeLabel = String(relation?.label || relation?.text || "");
return `${safeType}|${safeId}|${safeLabel}`;
} }
function normalizeRelationObject(relation, index) { function normalizeRelationObject(relation, index) {
if (relation && typeof relation === "object") { return tarotRelationDisplayUi.normalizeRelationObject(relation, index);
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) { function formatRelationDataLines(relation) {
if (!relation || typeof relation !== "object") { return tarotRelationDisplayUi.formatRelationDataLines(relation);
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 buildCubeRelationsForCard(card) { function buildCubeRelationsForCard(card) {
@@ -703,472 +516,19 @@
// Returns nav dispatch config for relations that have a corresponding section, // Returns nav dispatch config for relations that have a corresponding section,
// null for informational-only relations. // null for informational-only relations.
function getRelationNavTarget(relation) { function getRelationNavTarget(relation) {
const t = relation?.type; return tarotRelationDisplayUi.getRelationNavTarget(relation);
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) { function createRelationListItem(relation) {
const item = document.createElement("li"); return tarotRelationDisplayUi.createRelationListItem(relation);
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;
} }
function renderStaticRelationGroup(targetEl, cardEl, relations) { function renderStaticRelationGroup(targetEl, cardEl, relations) {
clearChildren(targetEl); tarotDetailRenderer.renderStaticRelationGroup(targetEl, cardEl, relations);
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) { function renderDetail(card, elements) {
if (!card || !elements) { tarotDetailRenderer.renderDetail(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 &#8212; 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 || ""} &mdash; &ldquo;${letter.meaning || ""}&rdquo; &middot; ${letter.letterType || ""}</span>
<span class="tarot-kab-connects">${fromName} &rarr; ${toName}${astro ? " &middot; " + 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 &#8212; 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 = "";
}
}
} }
function updateListSelection(elements) { function updateListSelection(elements) {

246
app/ui-zodiac-references.js Normal file
View 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
};
})();

View File

@@ -2,6 +2,17 @@
(function () { (function () {
"use strict"; "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 = { const ELEMENT_STYLE = {
fire: { emoji: "🔥", badge: "zod-badge--fire", label: "Fire" }, fire: { emoji: "🔥", badge: "zod-badge--fire", label: "Fire" },
earth: { emoji: "🌍", badge: "zod-badge--earth", label: "Earth" }, earth: { emoji: "🌍", badge: "zod-badge--earth", label: "Earth" },
@@ -14,8 +25,6 @@
venus: "♀︎", mercury: "☿︎", luna: "☾︎" venus: "♀︎", mercury: "☿︎", luna: "☾︎"
}; };
const MONTH_NAMES = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const state = { const state = {
initialized: false, initialized: false,
entries: [], entries: [],
@@ -58,233 +67,19 @@
} }
function formatDateRange(rulesFrom) { function formatDateRange(rulesFrom) {
if (!Array.isArray(rulesFrom) || rulesFrom.length < 2) return "—"; return zodiacReferenceBuilders.formatDateRange(rulesFrom);
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) { function buildMonthReferencesBySign(referenceData) {
const map = new Map(); return zodiacReferenceBuilders.buildMonthReferencesBySign(referenceData);
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;
} }
function buildCubeSignPlacements(magickDataset) { function buildCubeSignPlacements(magickDataset) {
const placements = new Map(); return zodiacReferenceBuilders.buildCubeSignPlacements(magickDataset);
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) { function cubePlacementLabel(placement) {
const wallName = placement?.wallName || "Wall"; return zodiacReferenceBuilders.cubePlacementLabel(placement);
const edgeName = placement?.edgeName || "Direction";
return `Cube: ${wallName} Wall - ${edgeName}`;
} }
// ── List ────────────────────────────────────────────────────────────── // ── List ──────────────────────────────────────────────────────────────

View File

@@ -773,33 +773,54 @@
<script src="app/ui-tarot-lightbox.js?v=20260307b"></script> <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-house.js?v=20260307b"></script>
<script src="app/ui-tarot-relations.js"></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-now.js"></script>
<script src="app/ui-natal.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/tarot-database.js"></script>
<script src="app/ui-calendar-dates.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-detail.js"></script>
<script src="app/ui-calendar-data.js"></script>
<script src="app/ui-calendar.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-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-tarot.js?v=20260307b"></script>
<script src="app/ui-planets-references.js"></script>
<script src="app/ui-planets.js"></script> <script src="app/ui-planets.js"></script>
<script src="app/ui-cycles.js"></script> <script src="app/ui-cycles.js"></script>
<script src="app/ui-elements.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-iching.js"></script>
<script src="app/ui-rosicrucian-cross.js"></script> <script src="app/ui-rosicrucian-cross.js"></script>
<script src="app/ui-kabbalah-detail.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-kabbalah.js"></script>
<script src="app/ui-cube-detail.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-cube.js"></script>
<script src="app/ui-alphabet-gematria.js"></script> <script src="app/ui-alphabet-gematria.js"></script>
<script src="app/ui-alphabet-references.js"></script> <script src="app/ui-alphabet-references.js"></script>
<script src="app/ui-alphabet-detail.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-alphabet.js"></script>
<script src="app/ui-zodiac-references.js"></script>
<script src="app/ui-zodiac.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-bank.js"></script>
<script src="app/ui-quiz.js"></script> <script src="app/ui-quiz.js"></script>
<script src="app/quiz-calendars.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-gods.js"></script>
<script src="app/ui-enochian.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-numbers.js"></script>
<script src="app/ui-tarot-spread.js"></script> <script src="app/ui-tarot-spread.js"></script>
<script src="app/ui-settings.js"></script> <script src="app/ui-settings.js"></script>