Initial commit

This commit is contained in:
2026-03-07 01:09:00 -08:00
commit af7d63717e
102 changed files with 68739 additions and 0 deletions

163
app/astro-calcs.js Normal file
View File

@@ -0,0 +1,163 @@
(function () {
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const START = ["sol", "luna", "mars", "mercury", "jupiter", "venus", "saturn"];
const CHALDEAN = ["saturn", "jupiter", "mars", "sol", "venus", "mercury", "luna"];
function toTitleCase(value) {
if (!value) return "";
return value.charAt(0).toUpperCase() + value.slice(1);
}
function getDateKey(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function getCenteredWeekStartDay(date) {
return (date.getDay() + 4) % 7;
}
function minutesBetween(a, b) {
return (a.getTime() - b.getTime()) / 60000;
}
function getMoonPhaseName(phase) {
if (phase < 0.03 || phase > 0.97) return "New Moon";
if (phase < 0.22) return "Waxing Crescent";
if (phase < 0.28) return "First Quarter";
if (phase < 0.47) return "Waxing Gibbous";
if (phase < 0.53) return "Full Moon";
if (phase < 0.72) return "Waning Gibbous";
if (phase < 0.78) return "Last Quarter";
return "Waning Crescent";
}
function parseMonthDay(monthDay) {
const [month, day] = monthDay.split("-").map(Number);
return { month, day };
}
function isDateInSign(date, sign) {
const { month: startMonth, day: startDay } = parseMonthDay(sign.start);
const { month: endMonth, day: endDay } = parseMonthDay(sign.end);
const month = date.getMonth() + 1;
const day = date.getDate();
const wrapsYear = startMonth > endMonth;
if (!wrapsYear) {
const afterStart = month > startMonth || (month === startMonth && day >= startDay);
const beforeEnd = month < endMonth || (month === endMonth && day <= endDay);
return afterStart && beforeEnd;
}
const afterStart = month > startMonth || (month === startMonth && day >= startDay);
const beforeEnd = month < endMonth || (month === endMonth && day <= endDay);
return afterStart || beforeEnd;
}
function getSignStartDate(date, sign) {
const { month: startMonth, day: startDay } = parseMonthDay(sign.start);
const month = date.getMonth() + 1;
const day = date.getDate();
const wrapsYear = startMonth > parseMonthDay(sign.end).month;
let year = date.getFullYear();
if (wrapsYear && (month < startMonth || (month === startMonth && day < startDay))) {
year -= 1;
}
return new Date(year, startMonth - 1, startDay);
}
function getSignForDate(date, signs) {
return signs.find((sign) => isDateInSign(date, sign)) || null;
}
function groupDecansBySign(decans) {
const map = {};
for (const decan of decans) {
if (!map[decan.signId]) {
map[decan.signId] = [];
}
map[decan.signId].push(decan);
}
for (const signId of Object.keys(map)) {
map[signId].sort((a, b) => a.index - b.index);
}
return map;
}
function getDecanForDate(date, signs, decansBySign) {
const sign = getSignForDate(date, signs);
if (!sign) return null;
const signDecans = decansBySign[sign.id] || [];
if (!signDecans.length) {
return { sign, decan: null };
}
const signStartDate = getSignStartDate(date, sign);
const daysSinceSignStart = Math.floor((date.getTime() - signStartDate.getTime()) / DAY_IN_MS);
let index = Math.floor(daysSinceSignStart / 10) + 1;
if (index < 1) index = 1;
if (index > 3) index = 3;
const decan = signDecans.find((entry) => entry.index === index) || signDecans[0];
return { sign, decan };
}
function calcPlanetaryHoursForDayAndLocation(date, geo) {
const sunCalc = window.SunCalc;
if (!sunCalc) {
throw new Error("SunCalc library is not loaded.");
}
const solar = sunCalc.getTimes(date, geo.latitude, geo.longitude);
const nextDay = new Date(date.getTime() + DAY_IN_MS);
const solarNext = sunCalc.getTimes(nextDay, geo.latitude, geo.longitude);
const dayOfWeek = date.getDay();
const chaldeanStartPos = CHALDEAN.indexOf(START[dayOfWeek]);
const dayHourInMinutes = minutesBetween(solar.sunset, solar.sunrise) / 12;
const nightHourInMinutes = minutesBetween(solarNext.sunrise, solar.sunset) / 12;
const hours = [];
for (let hour = 0; hour < 12; hour += 1) {
const start = new Date(solar.sunrise.getTime() + dayHourInMinutes * hour * 60_000);
const end = new Date(start.getTime() + dayHourInMinutes * 60_000);
hours.push({
start,
end,
planetId: CHALDEAN[(chaldeanStartPos + hour) % 7],
isDaylight: true
});
}
for (let hour = 12; hour < 24; hour += 1) {
const start = new Date(solar.sunset.getTime() + nightHourInMinutes * (hour - 12) * 60_000);
const end = new Date(start.getTime() + nightHourInMinutes * 60_000);
hours.push({
start,
end,
planetId: CHALDEAN[(chaldeanStartPos + hour) % 7],
isDaylight: false
});
}
return hours;
}
window.TarotCalc = {
DAY_IN_MS,
toTitleCase,
getDateKey,
getCenteredWeekStartDay,
getMoonPhaseName,
groupDecansBySign,
getDecanForDate,
calcPlanetaryHoursForDayAndLocation
};
})();

96
app/calendar-events.js Normal file
View File

@@ -0,0 +1,96 @@
(function () {
const {
DAY_IN_MS,
toTitleCase,
getMoonPhaseName,
getDecanForDate,
calcPlanetaryHoursForDayAndLocation
} = window.TarotCalc;
const PLANET_CALENDAR_IDS = new Set(["saturn", "jupiter", "mars", "sol", "venus", "mercury", "luna"]);
const BACKFILL_DAYS = 4;
const FORECAST_DAYS = 7;
function buildWeekEvents(geo, referenceData, anchorDate) {
const baseDate = anchorDate || new Date();
const events = [];
let runningId = 1;
for (let offset = -BACKFILL_DAYS; offset <= FORECAST_DAYS; offset += 1) {
const date = new Date(baseDate.getTime() + offset * DAY_IN_MS);
const hours = calcPlanetaryHoursForDayAndLocation(date, geo);
const moonIllum = window.SunCalc.getMoonIllumination(date);
const moonPhase = getMoonPhaseName(moonIllum.phase);
const sunInfo = getDecanForDate(date, referenceData.signs, referenceData.decansBySign);
const moonTarot = referenceData.planets.luna?.tarot?.majorArcana || "The High Priestess";
events.push({
id: `moon-${offset}`,
calendarId: "astrology",
category: "allday",
title: `Moon: ${moonPhase} (${Math.round(moonIllum.fraction * 100)}%) · ${moonTarot}`,
start: new Date(date.getFullYear(), date.getMonth(), date.getDate()),
end: new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59),
isReadOnly: true
});
if (sunInfo?.sign) {
const rulerPlanet = referenceData.planets[sunInfo.sign.rulingPlanetId];
const bodyParts = [
`${sunInfo.sign.symbol} ${toTitleCase(sunInfo.sign.element)} ${toTitleCase(sunInfo.sign.modality)}`,
rulerPlanet ? `Sign ruler: ${rulerPlanet.symbol} ${rulerPlanet.name}` : `Sign ruler: ${sunInfo.sign.rulingPlanetId}`
];
if (sunInfo.decan) {
const decanRuler = referenceData.planets[sunInfo.decan.rulerPlanetId];
const decanText = decanRuler
? `Decan ${sunInfo.decan.index}: ${sunInfo.decan.tarotMinorArcana} (${decanRuler.symbol} ${decanRuler.name})`
: `Decan ${sunInfo.decan.index}: ${sunInfo.decan.tarotMinorArcana}`;
bodyParts.unshift(decanText);
}
events.push({
id: `sun-${offset}`,
calendarId: "astrology",
category: "allday",
title: `Sun in ${sunInfo.sign.name} · ${sunInfo.sign.tarot.majorArcana}`,
body: bodyParts.join("\n"),
start: new Date(date.getFullYear(), date.getMonth(), date.getDate()),
end: new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59),
isReadOnly: true
});
}
for (const hour of hours) {
const planet = referenceData.planets[hour.planetId];
if (!planet) continue;
const calendarId = PLANET_CALENDAR_IDS.has(hour.planetId)
? `planet-${hour.planetId}`
: "planetary";
events.push({
id: `ph-${runningId++}`,
calendarId,
category: "time",
title: `${planet.symbol} ${planet.name} · ${planet.tarot.majorArcana}`,
body: `${planet.weekday} current · ${hour.isDaylight ? "Day" : "Night"} hour\n${planet.magickTypes}`,
raw: {
planetSymbol: planet.symbol,
planetName: planet.name,
tarotName: planet.tarot.majorArcana
},
start: hour.start,
end: hour.end,
isReadOnly: true
});
}
}
return events;
}
window.TarotEventBuilder = {
buildWeekEvents
};
})();

654
app/card-images.js Normal file
View File

@@ -0,0 +1,654 @@
(function () {
const DEFAULT_DECK_ID = "ceremonial-magick";
const trumpNumberByCanonicalName = {
fool: 0,
magus: 1,
magician: 1,
"high priestess": 2,
empress: 3,
emperor: 4,
hierophant: 5,
lovers: 6,
chariot: 7,
lust: 8,
strength: 8,
hermit: 9,
fortune: 10,
"wheel of fortune": 10,
justice: 11,
"hanged man": 12,
death: 13,
art: 14,
temperance: 14,
devil: 15,
tower: 16,
star: 17,
moon: 18,
sun: 19,
aeon: 20,
judgement: 20,
judgment: 20,
universe: 21,
world: 21
};
const pipValueByToken = {
ace: 1,
two: 2,
three: 3,
four: 4,
five: 5,
six: 6,
seven: 7,
eight: 8,
nine: 9,
ten: 10,
"2": 2,
"3": 3,
"4": 4,
"5": 5,
"6": 6,
"7": 7,
"8": 8,
"9": 9,
"10": 10
};
const rankWordByPipValue = {
1: "Ace",
2: "Two",
3: "Three",
4: "Four",
5: "Five",
6: "Six",
7: "Seven",
8: "Eight",
9: "Nine",
10: "Ten"
};
const trumpRomanToNumber = {
I: 1,
II: 2,
III: 3,
IV: 4,
V: 5,
VI: 6,
VII: 7,
VIII: 8,
IX: 9,
X: 10,
XI: 11,
XII: 12,
XIII: 13,
XIV: 14,
XV: 15,
XVI: 16,
XVII: 17,
XVIII: 18,
XIX: 19,
XX: 20,
XXI: 21
};
const suitSearchAliasesById = {
wands: ["wands"],
cups: ["cups"],
swords: ["swords"],
disks: ["disks", "pentacles", "coins"]
};
const DECK_REGISTRY_PATH = "asset/tarot deck/decks.json";
const deckManifestSources = buildDeckManifestSources();
const manifestCache = new Map();
let activeDeckId = DEFAULT_DECK_ID;
function canonicalMajorName(cardName) {
return String(cardName || "")
.trim()
.toLowerCase()
.replace(/^the\s+/, "")
.replace(/\s+/g, " ");
}
function canonicalMinorName(cardName) {
const parsedMinor = parseMinorCard(cardName);
if (!parsedMinor) {
return "";
}
return `${String(parsedMinor.rankKey || "").trim().toLowerCase()} of ${parsedMinor.suitId}`;
}
function toTitleCase(value) {
const normalized = String(value || "").trim().toLowerCase();
if (!normalized) {
return "";
}
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
}
function normalizeDeckId(deckId) {
const normalized = String(deckId || "").trim().toLowerCase();
if (deckManifestSources[normalized]) {
return normalized;
}
if (deckManifestSources[DEFAULT_DECK_ID]) {
return DEFAULT_DECK_ID;
}
const fallbackId = Object.keys(deckManifestSources)[0];
return fallbackId || DEFAULT_DECK_ID;
}
function normalizeTrumpNumber(value) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 21) {
return null;
}
return parsed;
}
function parseTrumpNumberKey(value) {
const normalized = String(value || "").trim().toUpperCase();
if (!normalized) {
return null;
}
if (/^\d+$/.test(normalized)) {
return normalizeTrumpNumber(Number(normalized));
}
if (Object.prototype.hasOwnProperty.call(trumpRomanToNumber, normalized)) {
return normalizeTrumpNumber(trumpRomanToNumber[normalized]);
}
return null;
}
function normalizeSuitId(suitInput) {
const suit = String(suitInput || "").trim().toLowerCase();
if (suit === "pentacles") {
return "disks";
}
return suit;
}
function resolveDeckOptions(optionsOrDeckId) {
let resolvedDeckId = activeDeckId;
let trumpNumber = null;
if (typeof optionsOrDeckId === "string") {
resolvedDeckId = normalizeDeckId(optionsOrDeckId);
} else if (optionsOrDeckId && typeof optionsOrDeckId === "object") {
if (optionsOrDeckId.deckId) {
resolvedDeckId = normalizeDeckId(optionsOrDeckId.deckId);
}
trumpNumber = normalizeTrumpNumber(optionsOrDeckId.trumpNumber);
}
return { resolvedDeckId, trumpNumber };
}
function parseMinorCard(cardName) {
const match = String(cardName || "")
.trim()
.match(/^(ace|two|three|four|five|six|seven|eight|nine|ten|knight|queen|prince|princess|king|page|[2-9]|10)\s+of\s+(cups|wands|swords|pentacles|disks)$/i);
if (!match) {
return null;
}
const rankToken = String(match[1] || "").toLowerCase();
const suitId = normalizeSuitId(match[2]);
const pipValue = pipValueByToken[rankToken] ?? null;
if (Number.isFinite(pipValue)) {
const rankWord = rankWordByPipValue[pipValue] || "";
return {
suitId,
pipValue,
court: "",
rankWord,
rankKey: rankWord.toLowerCase()
};
}
const courtWord = toTitleCase(rankToken);
if (!courtWord) {
return null;
}
return {
suitId,
pipValue: null,
court: rankToken,
rankWord: courtWord,
rankKey: rankToken
};
}
function applyTemplate(template, variables) {
return String(template || "")
.replace(/\{([a-zA-Z0-9_]+)\}/g, (_, token) => {
const value = variables[token];
return value == null ? "" : String(value);
});
}
function readManifestJsonSync(path) {
try {
const request = new XMLHttpRequest();
request.open("GET", encodeURI(path), false);
request.send(null);
const okStatus = (request.status >= 200 && request.status < 300) || request.status === 0;
if (!okStatus || !request.responseText) {
return null;
}
return JSON.parse(request.responseText);
} catch {
return null;
}
}
function toDeckSourceMap(sourceList) {
const sourceMap = {};
if (!Array.isArray(sourceList)) {
return sourceMap;
}
sourceList.forEach((entry) => {
const id = String(entry?.id || "").trim().toLowerCase();
const basePath = String(entry?.basePath || "").trim().replace(/\/$/, "");
const manifestPath = String(entry?.manifestPath || "").trim();
if (!id || !basePath || !manifestPath) {
return;
}
sourceMap[id] = {
id,
label: String(entry?.label || id),
basePath,
manifestPath
};
});
return sourceMap;
}
function buildDeckManifestSources() {
const registry = readManifestJsonSync(DECK_REGISTRY_PATH);
const registryDecks = Array.isArray(registry)
? registry
: (Array.isArray(registry?.decks) ? registry.decks : null);
return toDeckSourceMap(registryDecks);
}
function normalizeDeckManifest(source, rawManifest) {
if (!rawManifest || typeof rawManifest !== "object") {
return null;
}
const rawNameOverrides = rawManifest.nameOverrides;
const nameOverrides = {};
if (rawNameOverrides && typeof rawNameOverrides === "object") {
Object.entries(rawNameOverrides).forEach(([rawKey, rawValue]) => {
const key = canonicalMajorName(rawKey);
const value = String(rawValue || "").trim();
if (key && value) {
nameOverrides[key] = value;
}
});
}
const rawMajorNameOverridesByTrump = rawManifest.majorNameOverridesByTrump;
const majorNameOverridesByTrump = {};
if (rawMajorNameOverridesByTrump && typeof rawMajorNameOverridesByTrump === "object") {
Object.entries(rawMajorNameOverridesByTrump).forEach(([rawKey, rawValue]) => {
const trumpNumber = parseTrumpNumberKey(rawKey);
const value = String(rawValue || "").trim();
if (Number.isInteger(trumpNumber) && value) {
majorNameOverridesByTrump[trumpNumber] = value;
}
});
}
const rawMinorNameOverrides = rawManifest.minorNameOverrides;
const minorNameOverrides = {};
if (rawMinorNameOverrides && typeof rawMinorNameOverrides === "object") {
Object.entries(rawMinorNameOverrides).forEach(([rawKey, rawValue]) => {
const key = canonicalMinorName(rawKey);
const value = String(rawValue || "").trim();
if (key && value) {
minorNameOverrides[key] = value;
}
});
}
return {
id: source.id,
label: String(rawManifest.label || source.label || source.id),
basePath: String(source.basePath || "").replace(/\/$/, ""),
majors: rawManifest.majors || {},
minors: rawManifest.minors || {},
nameOverrides,
minorNameOverrides,
majorNameOverridesByTrump
};
}
function getDeckManifest(deckId) {
const normalizedDeckId = normalizeDeckId(deckId);
if (manifestCache.has(normalizedDeckId)) {
return manifestCache.get(normalizedDeckId);
}
const source = deckManifestSources[normalizedDeckId];
if (!source) {
manifestCache.set(normalizedDeckId, null);
return null;
}
const rawManifest = readManifestJsonSync(source.manifestPath);
const normalizedManifest = normalizeDeckManifest(source, rawManifest);
manifestCache.set(normalizedDeckId, normalizedManifest);
return normalizedManifest;
}
function getRankIndex(minorRule, parsedMinor) {
if (!minorRule || !parsedMinor) {
return null;
}
const lowerRankWord = String(parsedMinor.rankWord || "").toLowerCase();
const lowerRankKey = String(parsedMinor.rankKey || "").toLowerCase();
const indexByKey = minorRule.rankIndexByKey;
if (indexByKey && typeof indexByKey === "object") {
const mapped = Number(indexByKey[lowerRankKey]);
if (Number.isInteger(mapped) && mapped >= 0) {
return mapped;
}
}
const rankOrder = Array.isArray(minorRule.rankOrder) ? minorRule.rankOrder : [];
for (let i = 0; i < rankOrder.length; i += 1) {
const candidate = String(rankOrder[i] || "").toLowerCase();
if (candidate && (candidate === lowerRankWord || candidate === lowerRankKey)) {
return i;
}
}
return null;
}
function resolveMajorFile(manifest, canonicalName) {
const majorRule = manifest?.majors;
if (!majorRule || typeof majorRule !== "object") {
return null;
}
if (majorRule.mode === "canonical-map") {
const cards = majorRule.cards || {};
const fileName = cards[canonicalName];
return typeof fileName === "string" && fileName ? fileName : null;
}
const trumpNo = trumpNumberByCanonicalName[canonicalName];
if (!Number.isInteger(trumpNo) || trumpNo < 0 || trumpNo > 21) {
return null;
}
if (majorRule.mode === "trump-map") {
const cards = majorRule.cards || {};
const fileName = cards[String(trumpNo)] ?? cards[trumpNo];
return typeof fileName === "string" && fileName ? fileName : null;
}
if (majorRule.mode === "trump-template") {
const numberPad = Number.isInteger(majorRule.numberPad) ? majorRule.numberPad : 2;
const template = String(majorRule.template || "{number}.png");
const number = String(trumpNo).padStart(numberPad, "0");
return applyTemplate(template, {
trump: trumpNo,
number
});
}
return null;
}
function resolveMinorFile(manifest, parsedMinor) {
const minorRule = manifest?.minors;
if (!minorRule || typeof minorRule !== "object") {
return null;
}
const rankIndex = getRankIndex(minorRule, parsedMinor);
if (!Number.isInteger(rankIndex) || rankIndex < 0) {
return null;
}
if (minorRule.mode === "suit-base-and-rank-order") {
const suitBaseRaw = Number(minorRule?.suitBase?.[parsedMinor.suitId]);
if (!Number.isFinite(suitBaseRaw)) {
return null;
}
const numberPad = Number.isInteger(minorRule.numberPad) ? minorRule.numberPad : 2;
const cardNumber = String(suitBaseRaw + rankIndex).padStart(numberPad, "0");
const suitWord = String(minorRule?.suitLabel?.[parsedMinor.suitId] || toTitleCase(parsedMinor.suitId));
const template = String(minorRule.template || "{number}_{rank} {suit}.webp");
return applyTemplate(template, {
number: cardNumber,
rank: parsedMinor.rankWord,
rankKey: parsedMinor.rankKey,
suit: suitWord,
suitId: parsedMinor.suitId,
index: rankIndex + 1
});
}
if (minorRule.mode === "suit-prefix-and-rank-order") {
const suitPrefix = minorRule?.suitPrefix?.[parsedMinor.suitId];
if (!suitPrefix) {
return null;
}
const indexStart = Number.isInteger(minorRule.indexStart) ? minorRule.indexStart : 1;
const indexPad = Number.isInteger(minorRule.indexPad) ? minorRule.indexPad : 2;
const suitIndex = String(indexStart + rankIndex).padStart(indexPad, "0");
const template = String(minorRule.template || "{suit}{index}.png");
return applyTemplate(template, {
suit: suitPrefix,
suitId: parsedMinor.suitId,
index: suitIndex,
rank: parsedMinor.rankWord,
rankKey: parsedMinor.rankKey
});
}
if (minorRule.mode === "suit-base-number-template") {
const suitBaseRaw = Number(minorRule?.suitBase?.[parsedMinor.suitId]);
if (!Number.isFinite(suitBaseRaw)) {
return null;
}
const numberPad = Number.isInteger(minorRule.numberPad) ? minorRule.numberPad : 2;
const cardNumber = String(suitBaseRaw + rankIndex).padStart(numberPad, "0");
const template = String(minorRule.template || "{number}.png");
return applyTemplate(template, {
number: cardNumber,
suitId: parsedMinor.suitId,
rank: parsedMinor.rankWord,
rankKey: parsedMinor.rankKey,
index: rankIndex
});
}
return null;
}
function resolveWithDeck(deckId, cardName) {
const manifest = getDeckManifest(deckId);
if (!manifest) {
return null;
}
const canonical = canonicalMajorName(cardName);
const majorFile = resolveMajorFile(manifest, canonical);
if (majorFile) {
return `${manifest.basePath}/${majorFile}`;
}
const parsedMinor = parseMinorCard(cardName);
if (!parsedMinor) {
return null;
}
const minorFile = resolveMinorFile(manifest, parsedMinor);
if (!minorFile) {
return null;
}
return `${manifest.basePath}/${minorFile}`;
}
function resolveTarotCardImage(cardName) {
const activePath = resolveWithDeck(activeDeckId, cardName);
if (activePath) {
return encodeURI(activePath);
}
if (activeDeckId !== DEFAULT_DECK_ID) {
const fallbackPath = resolveWithDeck(DEFAULT_DECK_ID, cardName);
if (fallbackPath) {
return encodeURI(fallbackPath);
}
}
return null;
}
function resolveDisplayNameWithDeck(deckId, cardName, trumpNumber) {
const manifest = getDeckManifest(deckId);
const fallbackName = String(cardName || "").trim();
if (!manifest) {
return fallbackName;
}
let resolvedTrumpNumber = normalizeTrumpNumber(trumpNumber);
if (!Number.isInteger(resolvedTrumpNumber)) {
const canonical = canonicalMajorName(cardName);
resolvedTrumpNumber = normalizeTrumpNumber(trumpNumberByCanonicalName[canonical]);
}
if (Number.isInteger(resolvedTrumpNumber)) {
const byTrump = manifest?.majorNameOverridesByTrump?.[resolvedTrumpNumber];
if (byTrump) {
return byTrump;
}
}
const canonical = canonicalMajorName(cardName);
const override = manifest?.nameOverrides?.[canonical];
if (override) {
return override;
}
const minorKey = canonicalMinorName(cardName);
const minorOverride = manifest?.minorNameOverrides?.[minorKey];
if (minorOverride) {
return minorOverride;
}
return fallbackName;
}
function getTarotCardSearchAliases(cardName, optionsOrDeckId) {
const fallbackName = String(cardName || "").trim();
if (!fallbackName) {
return [];
}
const { resolvedDeckId, trumpNumber } = resolveDeckOptions(optionsOrDeckId);
const aliases = new Set();
aliases.add(fallbackName);
const displayName = String(resolveDisplayNameWithDeck(resolvedDeckId, fallbackName, trumpNumber) || "").trim();
if (displayName) {
aliases.add(displayName);
}
const canonicalMajor = canonicalMajorName(fallbackName);
const resolvedTrumpNumber = Number.isInteger(normalizeTrumpNumber(trumpNumber))
? normalizeTrumpNumber(trumpNumber)
: normalizeTrumpNumber(trumpNumberByCanonicalName[canonicalMajor]);
if (Number.isInteger(resolvedTrumpNumber)) {
aliases.add(canonicalMajor);
aliases.add(`the ${canonicalMajor}`);
aliases.add(`trump ${resolvedTrumpNumber}`);
}
const parsedMinor = parseMinorCard(fallbackName);
if (parsedMinor) {
const suitAliases = suitSearchAliasesById[parsedMinor.suitId] || [parsedMinor.suitId];
suitAliases.forEach((suitAlias) => {
aliases.add(`${parsedMinor.rankKey} of ${suitAlias}`);
if (Number.isInteger(parsedMinor.pipValue)) {
aliases.add(`${parsedMinor.pipValue} of ${suitAlias}`);
}
});
}
return Array.from(aliases);
}
function getTarotCardDisplayName(cardName, optionsOrDeckId) {
const { resolvedDeckId, trumpNumber } = resolveDeckOptions(optionsOrDeckId);
return resolveDisplayNameWithDeck(resolvedDeckId, cardName, trumpNumber);
}
function setActiveDeck(deckId) {
activeDeckId = normalizeDeckId(deckId);
getDeckManifest(activeDeckId);
return activeDeckId;
}
function getDeckOptions() {
return Object.values(deckManifestSources).map((source) => {
const manifest = getDeckManifest(source.id);
return {
id: source.id,
label: manifest?.label || source.label
};
});
}
document.addEventListener("settings:updated", (event) => {
const nextDeck = event?.detail?.settings?.tarotDeck;
setActiveDeck(nextDeck);
});
window.TarotCardImages = {
resolveTarotCardImage,
getTarotCardDisplayName,
getTarotCardSearchAliases,
setActiveDeck,
getDeckOptions,
getActiveDeck: () => activeDeckId
};
})();

372
app/data-service.js Normal file
View File

@@ -0,0 +1,372 @@
(function () {
let magickManifestCache = null;
let magickDataCache = null;
const DATA_ROOT = "data";
const MAGICK_ROOT = DATA_ROOT;
const TAROT_TRUMP_NUMBER_BY_NAME = {
"the fool": 0,
fool: 0,
"the magus": 1,
magus: 1,
magician: 1,
"the high priestess": 2,
"high priestess": 2,
"the empress": 3,
empress: 3,
"the emperor": 4,
emperor: 4,
"the hierophant": 5,
hierophant: 5,
"the lovers": 6,
lovers: 6,
"the chariot": 7,
chariot: 7,
strength: 8,
lust: 8,
"the hermit": 9,
hermit: 9,
fortune: 10,
"wheel of fortune": 10,
justice: 11,
"the hanged man": 12,
"hanged man": 12,
death: 13,
temperance: 14,
art: 14,
"the devil": 15,
devil: 15,
"the tower": 16,
tower: 16,
"the star": 17,
star: 17,
"the moon": 18,
moon: 18,
"the sun": 19,
sun: 19,
aeon: 20,
judgement: 20,
judgment: 20,
universe: 21,
world: 21,
"the world": 21
};
const HEBREW_BY_TRUMP_NUMBER = {
0: { hebrewLetterId: "alef", kabbalahPathNumber: 11 },
1: { hebrewLetterId: "bet", kabbalahPathNumber: 12 },
2: { hebrewLetterId: "gimel", kabbalahPathNumber: 13 },
3: { hebrewLetterId: "dalet", kabbalahPathNumber: 14 },
4: { hebrewLetterId: "he", kabbalahPathNumber: 15 },
5: { hebrewLetterId: "vav", kabbalahPathNumber: 16 },
6: { hebrewLetterId: "zayin", kabbalahPathNumber: 17 },
7: { hebrewLetterId: "het", kabbalahPathNumber: 18 },
8: { hebrewLetterId: "tet", kabbalahPathNumber: 19 },
9: { hebrewLetterId: "yod", kabbalahPathNumber: 20 },
10: { hebrewLetterId: "kaf", kabbalahPathNumber: 21 },
11: { hebrewLetterId: "lamed", kabbalahPathNumber: 22 },
12: { hebrewLetterId: "mem", kabbalahPathNumber: 23 },
13: { hebrewLetterId: "nun", kabbalahPathNumber: 24 },
14: { hebrewLetterId: "samekh", kabbalahPathNumber: 25 },
15: { hebrewLetterId: "ayin", kabbalahPathNumber: 26 },
16: { hebrewLetterId: "pe", kabbalahPathNumber: 27 },
17: { hebrewLetterId: "tsadi", kabbalahPathNumber: 28 },
18: { hebrewLetterId: "qof", kabbalahPathNumber: 29 },
19: { hebrewLetterId: "resh", kabbalahPathNumber: 30 },
20: { hebrewLetterId: "shin", kabbalahPathNumber: 31 },
21: { hebrewLetterId: "tav", kabbalahPathNumber: 32 }
};
const ICHING_PLANET_BY_PLANET_ID = {
sol: "Sun",
luna: "Moon",
mercury: "Mercury",
venus: "Venus",
mars: "Mars",
jupiter: "Jupiter",
saturn: "Saturn",
earth: "Earth",
uranus: "Uranus",
neptune: "Neptune",
pluto: "Pluto"
};
async function fetchJson(path) {
const response = await fetch(path);
if (!response.ok) {
throw new Error(`Failed to load ${path} (${response.status})`);
}
return response.json();
}
function buildObjectPath(target, pathParts, value) {
let cursor = target;
for (let index = 0; index < pathParts.length - 1; index += 1) {
const part = pathParts[index];
if (!cursor[part] || typeof cursor[part] !== "object") {
cursor[part] = {};
}
cursor = cursor[part];
}
cursor[pathParts[pathParts.length - 1]] = value;
}
function normalizeTarotName(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
}
function resolveTarotTrumpNumber(cardName) {
const key = normalizeTarotName(cardName);
if (!key) {
return null;
}
if (Object.prototype.hasOwnProperty.call(TAROT_TRUMP_NUMBER_BY_NAME, key)) {
return TAROT_TRUMP_NUMBER_BY_NAME[key];
}
const withoutLeadingThe = key.replace(/^the\s+/, "");
if (Object.prototype.hasOwnProperty.call(TAROT_TRUMP_NUMBER_BY_NAME, withoutLeadingThe)) {
return TAROT_TRUMP_NUMBER_BY_NAME[withoutLeadingThe];
}
return null;
}
function enrichAssociation(associations) {
if (!associations || typeof associations !== "object") {
return associations;
}
const next = { ...associations };
if (next.tarotCard) {
const trumpNumber = resolveTarotTrumpNumber(next.tarotCard);
if (trumpNumber != null) {
if (!Number.isFinite(Number(next.tarotTrumpNumber))) {
next.tarotTrumpNumber = trumpNumber;
}
const hebrew = HEBREW_BY_TRUMP_NUMBER[trumpNumber];
if (hebrew) {
if (!next.hebrewLetterId) {
next.hebrewLetterId = hebrew.hebrewLetterId;
}
if (!Number.isFinite(Number(next.kabbalahPathNumber))) {
next.kabbalahPathNumber = hebrew.kabbalahPathNumber;
}
}
}
}
const planetId = String(next.planetId || "").trim().toLowerCase();
if (!next.iChingPlanetaryInfluence && planetId) {
const influence = ICHING_PLANET_BY_PLANET_ID[planetId];
if (influence) {
next.iChingPlanetaryInfluence = influence;
}
}
return next;
}
function enrichCalendarMonth(month) {
const events = Array.isArray(month?.events)
? month.events.map((event) => ({
...event,
associations: enrichAssociation(event?.associations)
}))
: [];
return {
...month,
associations: enrichAssociation(month?.associations),
events
};
}
function enrichCelestialHoliday(holiday) {
return {
...holiday,
associations: enrichAssociation(holiday?.associations)
};
}
function enrichCalendarHoliday(holiday) {
return {
...holiday,
associations: enrichAssociation(holiday?.associations)
};
}
async function loadMagickManifest() {
if (magickManifestCache) {
return magickManifestCache;
}
magickManifestCache = await fetchJson(`${MAGICK_ROOT}/MANIFEST.json`);
return magickManifestCache;
}
async function loadMagickDataset() {
if (magickDataCache) {
return magickDataCache;
}
const manifest = await loadMagickManifest();
const files = Array.isArray(manifest?.files) ? manifest.files : [];
const jsonFiles = files.filter((file) => file.endsWith(".json"));
const entries = await Promise.all(
jsonFiles.map(async (relativePath) => {
const data = await fetchJson(`${MAGICK_ROOT}/${relativePath}`);
return [relativePath, data];
})
);
const grouped = {};
entries.forEach(([relativePath, data]) => {
const noExtensionPath = relativePath.replace(/\.json$/i, "");
const pathParts = noExtensionPath.split("/").filter(Boolean);
if (!pathParts.length) {
return;
}
buildObjectPath(grouped, pathParts, data);
});
magickDataCache = {
manifest,
grouped,
files: Object.fromEntries(entries)
};
return magickDataCache;
}
async function loadReferenceData() {
const { groupDecansBySign } = window.TarotCalc;
const [
planetsJson,
signsJson,
decansJson,
sabianJson,
planetScienceJson,
iChingJson,
calendarMonthsJson,
celestialHolidaysJson,
calendarHolidaysJson,
astronomyCyclesJson,
tarotDatabaseJson,
hebrewCalendarJson,
islamicCalendarJson,
wheelOfYearJson
] = await Promise.all([
fetchJson(`${DATA_ROOT}/planetary-correspondences.json`),
fetchJson(`${DATA_ROOT}/signs.json`),
fetchJson(`${DATA_ROOT}/decans.json`),
fetchJson(`${DATA_ROOT}/sabian-symbols.json`),
fetchJson(`${DATA_ROOT}/planet-science.json`),
fetchJson(`${DATA_ROOT}/i-ching.json`),
fetchJson(`${DATA_ROOT}/calendar-months.json`),
fetchJson(`${DATA_ROOT}/celestial-holidays.json`),
fetchJson(`${DATA_ROOT}/calendar-holidays.json`).catch(() => ({})),
fetchJson(`${DATA_ROOT}/astronomy-cycles.json`).catch(() => ({})),
fetchJson(`${DATA_ROOT}/tarot-database.json`).catch(() => ({})),
fetchJson(`${DATA_ROOT}/hebrew-calendar.json`).catch(() => ({})),
fetchJson(`${DATA_ROOT}/islamic-calendar.json`).catch(() => ({})),
fetchJson(`${DATA_ROOT}/wheel-of-year.json`).catch(() => ({}))
]);
const planets = planetsJson.planets || {};
const signs = signsJson.signs || [];
const decans = decansJson.decans || [];
const sabianSymbols = Array.isArray(sabianJson?.symbols) ? sabianJson.symbols : [];
const planetScience = Array.isArray(planetScienceJson?.planets)
? planetScienceJson.planets
: [];
const iChing = {
trigrams: Array.isArray(iChingJson?.trigrams) ? iChingJson.trigrams : [],
hexagrams: Array.isArray(iChingJson?.hexagrams) ? iChingJson.hexagrams : [],
correspondences: {
meta: iChingJson?.correspondences?.meta && typeof iChingJson.correspondences.meta === "object"
? iChingJson.correspondences.meta
: {},
tarotToTrigram: Array.isArray(iChingJson?.correspondences?.tarotToTrigram)
? iChingJson.correspondences.tarotToTrigram
: []
}
};
const calendarMonths = Array.isArray(calendarMonthsJson?.months)
? calendarMonthsJson.months.map((month) => enrichCalendarMonth(month))
: [];
const celestialHolidays = Array.isArray(celestialHolidaysJson?.holidays)
? celestialHolidaysJson.holidays.map((holiday) => enrichCelestialHoliday(holiday))
: [];
const calendarHolidays = Array.isArray(calendarHolidaysJson?.holidays)
? calendarHolidaysJson.holidays.map((holiday) => enrichCalendarHoliday(holiday))
: [];
const astronomyCycles = astronomyCyclesJson && typeof astronomyCyclesJson === "object"
? astronomyCyclesJson
: {};
const tarotDatabase = tarotDatabaseJson && typeof tarotDatabaseJson === "object"
? tarotDatabaseJson
: {};
const sourceMeanings = tarotDatabase.meanings && typeof tarotDatabase.meanings === "object"
? tarotDatabase.meanings
: {};
if (!sourceMeanings.majorByTrumpNumber || typeof sourceMeanings.majorByTrumpNumber !== "object") {
sourceMeanings.majorByTrumpNumber = {};
}
const existingByCardName = sourceMeanings.byCardName && typeof sourceMeanings.byCardName === "object"
? sourceMeanings.byCardName
: {};
sourceMeanings.byCardName = existingByCardName;
tarotDatabase.meanings = sourceMeanings;
const hebrewCalendar = hebrewCalendarJson && typeof hebrewCalendarJson === "object"
? hebrewCalendarJson
: {};
const islamicCalendar = islamicCalendarJson && typeof islamicCalendarJson === "object"
? islamicCalendarJson
: {};
const wheelOfYear = wheelOfYearJson && typeof wheelOfYearJson === "object"
? wheelOfYearJson
: {};
return {
planets,
signs,
decansBySign: groupDecansBySign(decans),
sabianSymbols,
planetScience,
iChing,
calendarMonths,
celestialHolidays,
calendarHolidays,
astronomyCycles,
tarotDatabase,
hebrewCalendar,
islamicCalendar,
wheelOfYear
};
}
window.TarotDataService = {
loadReferenceData,
loadMagickManifest,
loadMagickDataset
};
})();

511
app/quiz-calendars.js Normal file
View File

@@ -0,0 +1,511 @@
/* quiz-calendars.js — Dynamic quiz category plugin for calendar systems */
/* Registers Hebrew, Islamic, and Wheel of the Year quiz categories with the quiz engine */
(function () {
"use strict";
// ----- shared utilities (mirrored from ui-quiz.js since they aren't exported) -----
function normalizeOption(value) {
return String(value || "").trim();
}
function normalizeKey(value) {
return normalizeOption(value).toLowerCase();
}
function toUniqueOptionList(values) {
const seen = new Set();
const unique = [];
(values || []).forEach((value) => {
const formatted = normalizeOption(value);
if (!formatted) return;
const key = normalizeKey(formatted);
if (seen.has(key)) return;
seen.add(key);
unique.push(formatted);
});
return unique;
}
function shuffle(list) {
const clone = list.slice();
for (let i = clone.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[clone[i], clone[j]] = [clone[j], clone[i]];
}
return clone;
}
function buildOptions(correctValue, poolValues) {
const correct = normalizeOption(correctValue);
if (!correct) return null;
const uniquePool = toUniqueOptionList(poolValues || []);
if (!uniquePool.some((v) => normalizeKey(v) === normalizeKey(correct))) {
uniquePool.push(correct);
}
const distractors = uniquePool.filter((v) => normalizeKey(v) !== normalizeKey(correct));
if (distractors.length < 3) return null;
const selected = shuffle(distractors).slice(0, 3);
const options = shuffle([correct, ...selected]);
const correctIndex = options.findIndex((v) => normalizeKey(v) === normalizeKey(correct));
if (correctIndex < 0 || options.length < 4) return null;
return { options, correctIndex };
}
/**
* Build a validated quiz question template.
* Returns null if there aren't enough distractors for a 4-choice question.
*/
function makeTemplate(key, categoryId, category, prompt, answer, pool) {
const correctStr = normalizeOption(answer);
const promptStr = normalizeOption(prompt);
if (!key || !categoryId || !promptStr || !correctStr) return null;
const uniquePool = toUniqueOptionList(pool || []);
if (!uniquePool.some((v) => normalizeKey(v) === normalizeKey(correctStr))) {
uniquePool.push(correctStr);
}
const distractorCount = uniquePool.filter((v) => normalizeKey(v) !== normalizeKey(correctStr)).length;
if (distractorCount < 3) return null;
return {
key,
categoryId,
category,
promptByDifficulty: promptStr,
answerByDifficulty: correctStr,
poolByDifficulty: uniquePool
};
}
function ordinal(n) {
const num = Number(n);
if (!Number.isFinite(num)) return String(n);
const s = ["th", "st", "nd", "rd"];
const v = num % 100;
return num + (s[(v - 20) % 10] || s[v] || s[0]);
}
function getCalendarHolidayEntries(referenceData, calendarId) {
const all = Array.isArray(referenceData?.calendarHolidays) ? referenceData.calendarHolidays : [];
const target = String(calendarId || "").trim().toLowerCase();
return all.filter((holiday) => String(holiday?.calendarId || "").trim().toLowerCase() === target);
}
// ---- Hebrew Calendar Quiz --------------------------------------------------------
function buildHebrewCalendarQuiz(referenceData) {
const months = Array.isArray(referenceData?.hebrewCalendar?.months)
? referenceData.hebrewCalendar.months
: [];
if (months.length < 4) return [];
const bank = [];
const categoryId = "hebrew-calendar-months";
const category = "Hebrew Calendar";
const regularMonths = months.filter((m) => !m.leapYearOnly);
const namePool = toUniqueOptionList(regularMonths.map((m) => m.name));
const orderPool = toUniqueOptionList(regularMonths.map((m) => ordinal(m.order)));
const nativeNamePool = toUniqueOptionList(regularMonths.map((m) => m.nativeName).filter(Boolean));
const zodiacPool = toUniqueOptionList(
regularMonths.map((m) => m.zodiacSign ? m.zodiacSign.charAt(0).toUpperCase() + m.zodiacSign.slice(1) : "").filter(Boolean)
);
const tribePool = toUniqueOptionList(regularMonths.map((m) => m.tribe).filter(Boolean));
const sensePool = toUniqueOptionList(regularMonths.map((m) => m.sense).filter(Boolean));
regularMonths.forEach((month) => {
const name = month.name;
const orderStr = ordinal(month.order);
const nativeName = month.nativeName;
const zodiac = month.zodiacSign
? month.zodiacSign.charAt(0).toUpperCase() + month.zodiacSign.slice(1)
: null;
// "Which month is Nisan in the Hebrew calendar?" → "1st"
if (namePool.length >= 4 && orderPool.length >= 4) {
const t = makeTemplate(
`hebrew-month-order:${month.id}`,
categoryId,
category,
`${name} is the ___ month of the Hebrew religious year`,
orderStr,
orderPool
);
if (t) bank.push(t);
}
// "The 1st month of the Hebrew calendar is" → "Nisan"
if (namePool.length >= 4 && orderPool.length >= 4) {
const t = makeTemplate(
`hebrew-order-to-name:${month.id}`,
categoryId,
category,
`The ${orderStr} month of the Hebrew religious year is`,
name,
namePool
);
if (t) bank.push(t);
}
// Native name → month name
if (nativeName && nativeNamePool.length >= 4) {
const t = makeTemplate(
`hebrew-native-name:${month.id}`,
categoryId,
category,
`The Hebrew month written as "${nativeName}" is`,
name,
namePool
);
if (t) bank.push(t);
}
// Zodiac association
if (zodiac && zodiacPool.length >= 4) {
const t = makeTemplate(
`hebrew-month-zodiac:${month.id}`,
categoryId,
category,
`The Hebrew month of ${name} corresponds to the zodiac sign`,
zodiac,
zodiacPool
);
if (t) bank.push(t);
}
// Tribe of Israel
if (month.tribe && tribePool.length >= 4) {
const t = makeTemplate(
`hebrew-month-tribe:${month.id}`,
categoryId,
category,
`The Hebrew month of ${name} is associated with the tribe of`,
month.tribe,
tribePool
);
if (t) bank.push(t);
}
// Sense
if (month.sense && sensePool.length >= 4) {
const t = makeTemplate(
`hebrew-month-sense:${month.id}`,
categoryId,
category,
`The sense associated with the Hebrew month of ${name} is`,
month.sense,
sensePool
);
if (t) bank.push(t);
}
});
// Holiday repository-based questions (which month does X fall in?)
const monthNameById = new Map(regularMonths.map((month) => [String(month.id), month.name]));
const allObservances = getCalendarHolidayEntries(referenceData, "hebrew")
.map((holiday) => {
const monthName = monthNameById.get(String(holiday?.monthId || ""));
const obsName = String(holiday?.name || "").trim();
if (!monthName || !obsName) {
return null;
}
return { obsName, monthName };
})
.filter(Boolean);
if (namePool.length >= 4) {
allObservances.forEach(({ obsName, monthName }) => {
const t = makeTemplate(
`hebrew-obs-month:${obsName.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")}`,
categoryId,
category,
`${obsName} occurs in which Hebrew month`,
monthName,
namePool
);
if (t) bank.push(t);
});
}
return bank;
}
// ---- Islamic Calendar Quiz -------------------------------------------------------
function buildIslamicCalendarQuiz(referenceData) {
const months = Array.isArray(referenceData?.islamicCalendar?.months)
? referenceData.islamicCalendar.months
: [];
if (months.length < 4) return [];
const bank = [];
const categoryId = "islamic-calendar-months";
const category = "Islamic Calendar";
const namePool = toUniqueOptionList(months.map((m) => m.name));
const orderPool = toUniqueOptionList(months.map((m) => ordinal(m.order)));
const meaningPool = toUniqueOptionList(months.map((m) => m.meaning).filter(Boolean));
months.forEach((month) => {
const name = month.name;
const orderStr = ordinal(month.order);
// Order → name
const t1 = makeTemplate(
`islamic-order-to-name:${month.id}`,
categoryId,
category,
`The ${orderStr} month of the Islamic calendar is`,
name,
namePool
);
if (t1) bank.push(t1);
// Name → order
const t2 = makeTemplate(
`islamic-month-order:${month.id}`,
categoryId,
category,
`${name} is the ___ month of the Islamic calendar`,
orderStr,
orderPool
);
if (t2) bank.push(t2);
// Meaning of name
if (month.meaning && meaningPool.length >= 4) {
const t3 = makeTemplate(
`islamic-month-meaning:${month.id}`,
categoryId,
category,
`The name "${name}" in Arabic means`,
month.meaning,
meaningPool
);
if (t3) bank.push(t3);
}
// Sacred month identification
if (month.sacred) {
const yesNoPool = ["Yes — warfare prohibited", "No", "Partially sacred", "Conditionally sacred"];
const t4 = makeTemplate(
`islamic-sacred-${month.id}`,
categoryId,
category,
`Is ${name} one of the four sacred months (Al-Ashhur Al-Hurum)?`,
"Yes — warfare prohibited",
yesNoPool
);
if (t4) bank.push(t4);
}
});
// Observance-based: "Ramadan is the Islamic month of ___" type
const observanceFacts = [
{ q: "The Islamic month of obligatory fasting (Sawm) is", a: "Ramadan" },
{ q: "Eid al-Fitr is celebrated in which Islamic month", a: "Shawwal" },
{ q: "Eid al-Adha falls in which Islamic month", a: "Dhu al-Hijja" },
{ q: "The Hajj pilgrimage takes place in which month", a: "Dhu al-Hijja" },
{ q: "The Prophet Muhammad's birth (Mawlid al-Nabi) is in", a: "Rabi' al-Awwal" },
{ q: "Ashura falls in which Islamic month", a: "Muharram" },
{ q: "Laylat al-Mi'raj (Night of Ascension) is in which month", a: "Rajab" },
{ q: "The Islamic New Year (Hijri New Year) begins in", a: "Muharram" }
];
observanceFacts.forEach(({ q, a }) => {
if (namePool.some((n) => normalizeKey(n) === normalizeKey(a))) {
const t = makeTemplate(
`islamic-fact:${q.slice(0, 30).toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")}`,
categoryId,
category,
q,
a,
namePool
);
if (t) bank.push(t);
}
});
return bank;
}
// ---- Wheel of the Year Quiz ------------------------------------------------------
function buildWheelOfYearQuiz(referenceData) {
const months = Array.isArray(referenceData?.wheelOfYear?.months)
? referenceData.wheelOfYear.months
: [];
if (months.length < 4) return [];
const bank = [];
const categoryId = "wheel-of-year";
const category = "Wheel of the Year";
const namePool = toUniqueOptionList(months.map((m) => m.name));
const typePool = toUniqueOptionList(months.map((m) => m.type ? m.type.charAt(0).toUpperCase() + m.type.slice(1) : "").filter(Boolean));
const elementPool = toUniqueOptionList(
months.map((m) => m.element || (m.associations && m.associations.element) || "").filter(Boolean)
);
const datePool = toUniqueOptionList(months.map((m) => m.date).filter(Boolean));
months.forEach((month) => {
const name = month.name;
const date = month.date;
const element = month.element || "";
const direction = month.associations?.direction || "";
const directionPool = toUniqueOptionList(months.map((m) => m.associations?.direction || "").filter(Boolean));
// Date → Sabbat name
if (date && datePool.length >= 4) {
const t1 = makeTemplate(
`wheel-date-name:${month.id}`,
categoryId,
category,
`The Sabbat on ${date} is`,
name,
namePool
);
if (t1) bank.push(t1);
}
// Sabbat name → date
if (date && datePool.length >= 4) {
const t2 = makeTemplate(
`wheel-name-date:${month.id}`,
categoryId,
category,
`${name} falls on`,
date,
datePool
);
if (t2) bank.push(t2);
}
// Festival type (solar / cross-quarter)
if (month.type && typePool.length >= 2) {
const capType = month.type.charAt(0).toUpperCase() + month.type.slice(1);
const t3 = makeTemplate(
`wheel-type:${month.id}`,
categoryId,
category,
`${name} is a ___ festival`,
capType,
typePool
);
if (t3) bank.push(t3);
}
// Element association
if (element && elementPool.length >= 4) {
const t4 = makeTemplate(
`wheel-element:${month.id}`,
categoryId,
category,
`The primary element associated with ${name} is`,
element,
elementPool
);
if (t4) bank.push(t4);
}
// Direction
if (direction && directionPool.length >= 4) {
const t5 = makeTemplate(
`wheel-direction:${month.id}`,
categoryId,
category,
`The direction associated with ${name} is`,
direction,
directionPool
);
if (t5) bank.push(t5);
}
// Deities pool question
const deities = Array.isArray(month.associations?.deities) ? month.associations.deities : [];
const allDeities = toUniqueOptionList(
months.flatMap((m) => Array.isArray(m.associations?.deities) ? m.associations.deities : [])
);
if (deities.length > 0 && allDeities.length >= 4) {
const mainDeity = deities[0];
const t6 = makeTemplate(
`wheel-deity:${month.id}`,
categoryId,
category,
`${mainDeity} is primarily associated with which Sabbat`,
name,
namePool
);
if (t6) bank.push(t6);
}
});
// Fixed knowledge questions
const wheelFacts = [
{ q: "The Celtic New Year Sabbat is", a: "Samhain" },
{ q: "Which Sabbat marks the longest night of the year", a: "Yule (Winter Solstice)" },
{ q: "The Spring Equinox Sabbat is called", a: "Ostara (Spring Equinox)" },
{ q: "The Summer Solstice Sabbat is called", a: "Litha (Summer Solstice)" },
{ q: "Which Sabbat is the first harvest festival", a: "Lughnasadh" },
{ q: "The Autumn Equinox Sabbat is called", a: "Mabon (Autumn Equinox)" },
{ q: "Which Sabbat is associated with the goddess Brigid", a: "Imbolc" },
{ q: "Beltane celebrates the beginning of which season", a: "Summer" }
];
wheelFacts.forEach(({ q, a }, index) => {
const pool = index < 7 ? namePool : toUniqueOptionList(["Spring", "Summer", "Autumn / Fall", "Winter"]);
if (pool.some((p) => normalizeKey(p) === normalizeKey(a))) {
const t = makeTemplate(
`wheel-fact-${index}`,
categoryId,
category,
q,
a,
pool
);
if (t) bank.push(t);
}
});
return bank;
}
// ---- Registration ----------------------------------------------------------------
function registerCalendarQuizCategories() {
const { registerQuizCategory } = window.QuizSectionUi || {};
if (typeof registerQuizCategory !== "function") {
return;
}
registerQuizCategory(
"hebrew-calendar-months",
"Hebrew Calendar",
(referenceData) => buildHebrewCalendarQuiz(referenceData)
);
registerQuizCategory(
"islamic-calendar-months",
"Islamic Calendar",
(referenceData) => buildIslamicCalendarQuiz(referenceData)
);
registerQuizCategory(
"wheel-of-year",
"Wheel of the Year",
(referenceData) => buildWheelOfYearQuiz(referenceData)
);
}
// Register immediately — ui-quiz.js loads before this file
registerCalendarQuizCategories();
window.QuizCalendarsPlugin = {
registerCalendarQuizCategories
};
})();

3560
app/styles.css Normal file

File diff suppressed because it is too large Load Diff

1341
app/tarot-database.js Normal file

File diff suppressed because it is too large Load Diff

2081
app/ui-alphabet.js Normal file

File diff suppressed because it is too large Load Diff

2528
app/ui-calendar.js Normal file

File diff suppressed because it is too large Load Diff

1901
app/ui-cube.js Normal file

File diff suppressed because it is too large Load Diff

350
app/ui-cycles.js Normal file
View File

@@ -0,0 +1,350 @@
// app/ui-cycles.js
(function () {
"use strict";
const state = {
initialized: false,
referenceData: null,
entries: [],
filteredEntries: [],
searchQuery: "",
selectedId: ""
};
function getElements() {
return {
searchInputEl: document.getElementById("cycles-search-input"),
searchClearEl: document.getElementById("cycles-search-clear"),
countEl: document.getElementById("cycles-count"),
listEl: document.getElementById("cycles-list"),
detailNameEl: document.getElementById("cycles-detail-name"),
detailTypeEl: document.getElementById("cycles-detail-type"),
detailSummaryEl: document.getElementById("cycles-detail-summary"),
detailBodyEl: document.getElementById("cycles-detail-body")
};
}
function normalizeSearchValue(value) {
return String(value || "").trim().toLowerCase();
}
function normalizeLookupToken(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
function normalizeCycle(raw, index) {
const name = String(raw?.name || "").trim();
if (!name) {
return null;
}
const id = String(raw?.id || `cycle-${index + 1}`).trim();
const category = String(raw?.category || "Uncategorized").trim();
const period = String(raw?.period || "").trim();
const description = String(raw?.description || "").trim();
const significance = String(raw?.significance || "").trim();
const related = Array.isArray(raw?.related)
? raw.related.map((item) => String(item || "").trim()).filter(Boolean)
: [];
const periodDaysRaw = Number(raw?.periodDays);
const periodDays = Number.isFinite(periodDaysRaw) ? periodDaysRaw : null;
const searchText = normalizeSearchValue([
name,
category,
period,
description,
significance,
related.join(" ")
].join(" "));
return {
id,
name,
category,
period,
periodDays,
description,
significance,
related,
searchText
};
}
function buildEntries(referenceData) {
const rows = Array.isArray(referenceData?.astronomyCycles?.cycles)
? referenceData.astronomyCycles.cycles
: [];
return rows
.map((row, index) => normalizeCycle(row, index))
.filter(Boolean)
.sort((left, right) => left.name.localeCompare(right.name));
}
function formatDays(value) {
if (!Number.isFinite(value)) {
return "";
}
return value >= 1000
? Math.round(value).toLocaleString()
: value.toFixed(3).replace(/\.0+$/, "");
}
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function escapeAttr(value) {
return escapeHtml(value).replaceAll("`", "&#96;");
}
function cssEscape(value) {
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
return CSS.escape(value);
}
return String(value || "").replace(/[^a-zA-Z0-9_\-]/g, "\\$&");
}
function setSelectedCycle(nextId) {
const normalized = String(nextId || "").trim();
if (normalized && state.entries.some((entry) => entry.id === normalized)) {
state.selectedId = normalized;
return;
}
state.selectedId = state.filteredEntries[0]?.id || state.entries[0]?.id || "";
}
function selectedEntry() {
return state.filteredEntries.find((entry) => entry.id === state.selectedId)
|| state.entries.find((entry) => entry.id === state.selectedId)
|| state.filteredEntries[0]
|| state.entries[0]
|| null;
}
function findEntryByReference(reference) {
const token = normalizeLookupToken(reference);
if (!token) {
return null;
}
return state.entries.find((entry) => {
const idToken = normalizeLookupToken(entry.id);
const nameToken = normalizeLookupToken(entry.name);
return token === idToken || token === nameToken;
}) || null;
}
function applyFilter() {
const query = state.searchQuery;
if (!query) {
state.filteredEntries = state.entries.slice();
} else {
state.filteredEntries = state.entries.filter((entry) => entry.searchText.includes(query));
}
if (!state.filteredEntries.some((entry) => entry.id === state.selectedId)) {
state.selectedId = state.filteredEntries[0]?.id || "";
}
}
function syncControls(elements) {
const { searchInputEl, searchClearEl } = elements;
if (searchInputEl) {
searchInputEl.value = state.searchQuery;
}
if (searchClearEl) {
searchClearEl.disabled = !state.searchQuery;
}
}
function renderList(elements) {
const { listEl, countEl } = elements;
if (!(listEl instanceof HTMLElement)) {
return;
}
if (countEl) {
countEl.textContent = state.searchQuery
? `${state.filteredEntries.length} of ${state.entries.length}`
: `${state.entries.length}`;
}
if (!state.filteredEntries.length) {
listEl.innerHTML = '<div class="empty">No cycles match your search.</div>';
return;
}
listEl.innerHTML = "";
state.filteredEntries.forEach((entry) => {
const itemEl = document.createElement("div");
const isSelected = entry.id === state.selectedId;
itemEl.className = `planet-list-item${isSelected ? " is-selected" : ""}`;
itemEl.setAttribute("role", "option");
itemEl.setAttribute("aria-selected", isSelected ? "true" : "false");
itemEl.dataset.cycleId = entry.id;
const periodMeta = entry.period ? ` · ${escapeHtml(entry.period)}` : "";
itemEl.innerHTML = [
`<div class="planet-list-name">${escapeHtml(entry.name)}</div>`,
`<div class="planet-list-meta">${escapeHtml(entry.category)}${periodMeta}</div>`
].join("\n");
itemEl.addEventListener("click", () => {
setSelectedCycle(entry.id);
renderAll();
});
listEl.appendChild(itemEl);
});
}
function renderDetail(elements) {
const {
detailNameEl,
detailTypeEl,
detailSummaryEl,
detailBodyEl
} = elements;
const entry = selectedEntry();
if (!entry) {
if (detailNameEl) detailNameEl.textContent = "No cycle selected";
if (detailTypeEl) detailTypeEl.textContent = "";
if (detailSummaryEl) detailSummaryEl.textContent = "Select a cycle from the list.";
if (detailBodyEl) detailBodyEl.innerHTML = "";
return;
}
if (detailNameEl) detailNameEl.textContent = entry.name;
if (detailTypeEl) detailTypeEl.textContent = entry.category;
if (detailSummaryEl) detailSummaryEl.textContent = entry.description || "No description available.";
const body = [];
if (entry.period) {
body.push(`<p><strong>Period:</strong> ${escapeHtml(entry.period)}</p>`);
}
if (Number.isFinite(entry.periodDays)) {
body.push(`<p><strong>Approx days:</strong> ${escapeHtml(formatDays(entry.periodDays))}</p>`);
}
if (entry.significance) {
body.push(`<p><strong>Significance:</strong> ${escapeHtml(entry.significance)}</p>`);
}
if (entry.related.length) {
const relatedButtons = entry.related
.map((label) => {
const relatedEntry = findEntryByReference(label);
if (!relatedEntry) {
return `<span class="planet-list-meta">${escapeHtml(label)}</span>`;
}
return `<button class="alpha-nav-btn" type="button" data-related-cycle-id="${escapeAttr(relatedEntry.id)}">${escapeHtml(relatedEntry.name)} ↗</button>`;
})
.join("");
body.push([
"<div>",
" <strong>Related:</strong>",
` <div class="alpha-nav-btns">${relatedButtons}</div>`,
"</div>"
].join("\n"));
}
if (detailBodyEl) {
detailBodyEl.innerHTML = body.join("\n");
}
}
function renderAll() {
const elements = getElements();
syncControls(elements);
renderList(elements);
renderDetail(elements);
}
function handleSearchInput() {
const { searchInputEl } = getElements();
state.searchQuery = normalizeSearchValue(searchInputEl?.value);
applyFilter();
renderAll();
}
function handleSearchClear() {
const { searchInputEl } = getElements();
if (searchInputEl) {
searchInputEl.value = "";
searchInputEl.focus();
}
state.searchQuery = "";
applyFilter();
renderAll();
}
function handleRelatedClick(event) {
const target = event.target instanceof Element
? event.target.closest("[data-related-cycle-id]")
: null;
if (!(target instanceof HTMLElement)) {
return;
}
const nextId = target.getAttribute("data-related-cycle-id");
setSelectedCycle(nextId);
renderAll();
}
function bindEvents() {
const { searchInputEl, searchClearEl, detailBodyEl } = getElements();
if (searchInputEl) {
searchInputEl.addEventListener("input", handleSearchInput);
}
if (searchClearEl) {
searchClearEl.addEventListener("click", handleSearchClear);
}
if (detailBodyEl) {
detailBodyEl.addEventListener("click", handleRelatedClick);
}
}
function ensureCyclesSection(referenceData) {
state.referenceData = referenceData || {};
state.entries = buildEntries(state.referenceData);
applyFilter();
if (!state.initialized) {
bindEvents();
state.initialized = true;
}
setSelectedCycle(state.selectedId);
renderAll();
}
function selectCycleById(cycleId) {
setSelectedCycle(cycleId);
renderAll();
}
window.CyclesSectionUi = {
ensureCyclesSection,
selectCycleById
};
})();

454
app/ui-elements.js Normal file
View File

@@ -0,0 +1,454 @@
(function () {
"use strict";
const { getTarotCardSearchAliases } = window.TarotCardImages || {};
const CLASSICAL_ELEMENT_IDS = ["fire", "water", "air", "earth"];
const ACE_BY_ELEMENT_ID = {
water: "Ace of Cups",
fire: "Ace of Wands",
air: "Ace of Swords",
earth: "Ace of Disks"
};
const HEBREW_LETTER_NAME_BY_ELEMENT_ID = {
fire: "Yod",
water: "Heh",
air: "Vav",
earth: "Heh"
};
const HEBREW_LETTER_CHAR_BY_ELEMENT_ID = {
fire: "י",
water: "ה",
air: "ו",
earth: "ה"
};
const COURT_RANK_BY_ELEMENT_ID = {
fire: "Knight",
water: "Queen",
air: "Prince",
earth: "Princess"
};
const COURT_SUITS = ["Wands", "Cups", "Swords", "Disks"];
const SUIT_BY_ELEMENT_ID = {
fire: "Wands",
water: "Cups",
air: "Swords",
earth: "Disks"
};
const SMALL_CARD_GROUPS = [
{ label: "24", modality: "Cardinal", numbers: [2, 3, 4] },
{ label: "57", modality: "Fixed", numbers: [5, 6, 7] },
{ label: "810", modality: "Mutable", numbers: [8, 9, 10] }
];
const SIGN_BY_ELEMENT_AND_MODALITY = {
fire: { cardinal: "aries", fixed: "leo", mutable: "sagittarius" },
water: { cardinal: "cancer", fixed: "scorpio", mutable: "pisces" },
air: { cardinal: "libra", fixed: "aquarius", mutable: "gemini" },
earth: { cardinal: "capricorn", fixed: "taurus", mutable: "virgo" }
};
const state = {
initialized: false,
entries: [],
filteredEntries: [],
selectedId: "",
searchQuery: ""
};
function getElements() {
return {
listEl: document.getElementById("elements-list"),
countEl: document.getElementById("elements-count"),
searchEl: document.getElementById("elements-search-input"),
searchClearEl: document.getElementById("elements-search-clear"),
detailNameEl: document.getElementById("elements-detail-name"),
detailSubEl: document.getElementById("elements-detail-sub"),
detailBodyEl: document.getElementById("elements-detail-body")
};
}
function normalize(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
}
function titleCase(value) {
return String(value || "")
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function buildTarotAliasText(cardNames) {
if (typeof getTarotCardSearchAliases !== "function") {
return Array.isArray(cardNames) ? cardNames.join(" ") : "";
}
const aliases = new Set();
(Array.isArray(cardNames) ? cardNames : []).forEach((cardName) => {
getTarotCardSearchAliases(cardName).forEach((alias) => aliases.add(alias));
});
return Array.from(aliases).join(" ");
}
function buildSmallCardGroupsForElement(elementId) {
const suit = SUIT_BY_ELEMENT_ID[elementId] || "";
const signByModality = SIGN_BY_ELEMENT_AND_MODALITY[elementId] || {};
return SMALL_CARD_GROUPS.map((group) => {
const signId = String(signByModality[String(group.modality || "").toLowerCase()] || "").trim();
const signName = titleCase(signId);
const cardNames = group.numbers.map((number) => `${number} of ${suit}`);
return {
rangeLabel: group.label,
modality: group.modality,
signId,
signName,
cardNames
};
});
}
function buildEntries(magickDataset) {
const source = magickDataset?.grouped?.alchemy?.elements;
if (!source || typeof source !== "object") {
return [];
}
return CLASSICAL_ELEMENT_IDS
.map((id) => {
const item = source[id];
if (!item || typeof item !== "object") {
return null;
}
const name = String(item?.name?.en || item?.name || titleCase(id)).trim() || titleCase(id);
const symbol = String(item?.symbol || "").trim();
const aceCardName = ACE_BY_ELEMENT_ID[id] || "";
const hebrewLetter = HEBREW_LETTER_CHAR_BY_ELEMENT_ID[id] || "";
const hebrewLetterName = HEBREW_LETTER_NAME_BY_ELEMENT_ID[id] || "";
const courtRank = COURT_RANK_BY_ELEMENT_ID[id] || "";
const courtCardNames = courtRank
? COURT_SUITS.map((suit) => `${courtRank} of ${suit}`)
: [];
const smallCardGroups = buildSmallCardGroupsForElement(id);
const smallCardNames = smallCardGroups.flatMap((group) => group.cardNames || []);
const tarotAliasText = buildTarotAliasText([aceCardName, ...courtCardNames, ...smallCardNames]);
return {
id,
name,
symbol,
elementalId: String(item?.elementalId || "").trim(),
aceCardName,
hebrewLetter,
hebrewLetterName,
courtRank,
courtCardNames,
smallCardGroups,
searchText: normalize(`${id} ${name} ${symbol} ${aceCardName} ${hebrewLetter} ${hebrewLetterName} ${courtRank} ${courtCardNames.join(" ")} ${smallCardGroups.map((group) => `${group.modality} ${group.signName} ${group.cardNames.join(" ")}`).join(" ")} ${tarotAliasText}`)
};
})
.filter(Boolean);
}
function findEntryById(id) {
const normalizedId = normalize(id);
return state.entries.find((entry) => entry.id === normalizedId) || null;
}
function renderList(elements) {
if (!elements?.listEl) {
return;
}
elements.listEl.replaceChildren();
state.filteredEntries.forEach((entry) => {
const button = document.createElement("button");
button.type = "button";
button.className = "planet-list-item";
button.dataset.elementId = entry.id;
button.setAttribute("role", "option");
const isSelected = entry.id === state.selectedId;
button.classList.toggle("is-selected", isSelected);
button.setAttribute("aria-selected", isSelected ? "true" : "false");
const name = document.createElement("span");
name.className = "planet-list-name";
name.textContent = `${entry.symbol} ${entry.name}`.trim();
const meta = document.createElement("span");
meta.className = "planet-list-meta";
meta.textContent = `Letter: ${entry.hebrewLetter || "--"} · Ace: ${entry.aceCardName || "--"} · Court: ${entry.courtRank || "--"}`;
button.append(name, meta);
elements.listEl.appendChild(button);
});
if (elements.countEl) {
elements.countEl.textContent = `${state.filteredEntries.length} elements`;
}
if (!state.filteredEntries.length) {
const empty = document.createElement("div");
empty.className = "planet-text";
empty.style.padding = "16px";
empty.style.color = "#71717a";
empty.textContent = "No elements match your search.";
elements.listEl.appendChild(empty);
}
}
function renderDetail(elements) {
if (!elements?.detailNameEl || !elements.detailSubEl || !elements.detailBodyEl) {
return;
}
const entry = findEntryById(state.selectedId);
elements.detailBodyEl.replaceChildren();
if (!entry) {
elements.detailNameEl.textContent = "--";
elements.detailSubEl.textContent = "Select an element to explore";
return;
}
elements.detailNameEl.textContent = `${entry.symbol} ${entry.name}`.trim();
elements.detailSubEl.textContent = "Classical Element";
const grid = document.createElement("div");
grid.className = "planet-meta-grid";
const detailsCard = document.createElement("div");
detailsCard.className = "planet-meta-card";
detailsCard.innerHTML = `
<strong>Element Details</strong>
<dl class="alpha-dl">
<dt>Name</dt><dd>${entry.name}</dd>
<dt>Symbol</dt><dd>${entry.symbol || "--"}</dd>
<dt>Hebrew Letter</dt><dd>${entry.hebrewLetter || "--"}</dd>
<dt>Court Rank</dt><dd>${entry.courtRank || "--"}</dd>
<dt>ID</dt><dd>${entry.id}</dd>
</dl>
`;
const tarotCard = document.createElement("div");
tarotCard.className = "planet-meta-card";
const tarotTitle = document.createElement("strong");
tarotTitle.textContent = "Tarot Correspondence";
const tarotText = document.createElement("div");
tarotText.className = "planet-text";
tarotText.textContent = [
entry.aceCardName ? `Ace: ${entry.aceCardName}` : "",
entry.courtRank ? `Court Rank: ${entry.courtRank} (all suits)` : ""
].filter(Boolean).join(" · ") || "--";
tarotCard.append(tarotTitle, tarotText);
if (entry.aceCardName || entry.courtCardNames.length) {
const navWrap = document.createElement("div");
navWrap.className = "alpha-nav-btns";
if (entry.aceCardName) {
const tarotBtn = document.createElement("button");
tarotBtn.type = "button";
tarotBtn.className = "alpha-nav-btn";
tarotBtn.textContent = `Open ${entry.aceCardName}`;
tarotBtn.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent("nav:tarot-trump", {
detail: { cardName: entry.aceCardName }
}));
});
navWrap.appendChild(tarotBtn);
}
entry.courtCardNames.forEach((cardName) => {
const courtBtn = document.createElement("button");
courtBtn.type = "button";
courtBtn.className = "alpha-nav-btn";
courtBtn.textContent = `Open ${cardName}`;
courtBtn.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent("nav:tarot-trump", {
detail: { cardName }
}));
});
navWrap.appendChild(courtBtn);
});
tarotCard.appendChild(navWrap);
}
const smallCardCard = document.createElement("div");
smallCardCard.className = "planet-meta-card";
const smallCardTitle = document.createElement("strong");
smallCardTitle.textContent = "Small Card Sign Types";
smallCardCard.appendChild(smallCardTitle);
const smallCardStack = document.createElement("div");
smallCardStack.className = "cal-item-stack";
(entry.smallCardGroups || []).forEach((group) => {
const row = document.createElement("div");
row.className = "cal-item-row";
const head = document.createElement("div");
head.className = "cal-item-head";
head.innerHTML = `
<span class="cal-item-name">${group.rangeLabel} · ${group.modality}</span>
<span class="planet-list-meta">${group.signName || "--"}</span>
`;
row.appendChild(head);
const navWrap = document.createElement("div");
navWrap.className = "alpha-nav-btns";
if (group.signId) {
const signBtn = document.createElement("button");
signBtn.type = "button";
signBtn.className = "alpha-nav-btn";
signBtn.textContent = `Open ${group.signName}`;
signBtn.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent("nav:zodiac", {
detail: { signId: group.signId }
}));
});
navWrap.appendChild(signBtn);
}
(group.cardNames || []).forEach((cardName) => {
const cardBtn = document.createElement("button");
cardBtn.type = "button";
cardBtn.className = "alpha-nav-btn";
cardBtn.textContent = `Open ${cardName}`;
cardBtn.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent("nav:tarot-trump", {
detail: { cardName }
}));
});
navWrap.appendChild(cardBtn);
});
row.appendChild(navWrap);
smallCardStack.appendChild(row);
});
smallCardCard.appendChild(smallCardStack);
grid.append(detailsCard, tarotCard, smallCardCard);
elements.detailBodyEl.appendChild(grid);
}
function applyFilter(elements) {
const query = normalize(state.searchQuery);
state.filteredEntries = query
? state.entries.filter((entry) => entry.searchText.includes(query))
: [...state.entries];
if (elements?.searchClearEl) {
elements.searchClearEl.disabled = !query;
}
if (!state.filteredEntries.some((entry) => entry.id === state.selectedId)) {
state.selectedId = state.filteredEntries[0]?.id || "";
}
renderList(elements);
renderDetail(elements);
}
function selectByElementId(elementId) {
const target = findEntryById(elementId);
if (!target) {
return false;
}
const elements = getElements();
state.selectedId = target.id;
renderList(elements);
renderDetail(elements);
const listItem = elements.listEl?.querySelector(`[data-element-id="${target.id}"]`);
listItem?.scrollIntoView({ block: "nearest" });
return true;
}
function ensureElementsSection(magickDataset) {
const elements = getElements();
if (!elements.listEl || !elements.detailBodyEl) {
return;
}
state.entries = buildEntries(magickDataset);
if (!state.selectedId && state.entries.length) {
state.selectedId = state.entries[0].id;
}
applyFilter(elements);
if (state.initialized) {
return;
}
elements.listEl.addEventListener("click", (event) => {
const target = event.target instanceof Element
? event.target.closest(".planet-list-item")
: null;
if (!(target instanceof HTMLButtonElement)) {
return;
}
const elementId = target.dataset.elementId;
if (!elementId) {
return;
}
state.selectedId = elementId;
renderList(elements);
renderDetail(elements);
});
if (elements.searchEl) {
elements.searchEl.addEventListener("input", () => {
state.searchQuery = elements.searchEl.value || "";
applyFilter(elements);
});
}
if (elements.searchClearEl && elements.searchEl) {
elements.searchClearEl.addEventListener("click", () => {
state.searchQuery = "";
elements.searchEl.value = "";
applyFilter(elements);
elements.searchEl.focus();
});
}
state.initialized = true;
}
window.ElementsSectionUi = {
ensureElementsSection,
selectByElementId
};
})();

459
app/ui-enochian.js Normal file
View File

@@ -0,0 +1,459 @@
(function () {
"use strict";
const TABLET_META = {
union: { label: "Enochian Tablet of Union", element: "Spirit", order: 0 },
spirit: { label: "Enochian Tablet of Union", element: "Spirit", order: 0 },
earth: { label: "Enochian Tablet of Earth", element: "Earth", order: 1 },
air: { label: "Enochian Tablet of Air", element: "Air", order: 2 },
water: { label: "Enochian Tablet of Water", element: "Water", order: 3 },
fire: { label: "Enochian Tablet of Fire", element: "Fire", order: 4 }
};
const TAROT_NAME_ALIASES = {
juggler: "magus",
magician: "magus",
strength: "lust",
temperance: "art",
judgement: "aeon",
judgment: "aeon",
charit: "chariot"
};
const state = {
initialized: false,
entries: [],
filteredEntries: [],
selectedId: "",
selectedCell: null,
searchQuery: "",
lettersById: new Map()
};
function getElements() {
return {
listEl: document.getElementById("enochian-list"),
countEl: document.getElementById("enochian-count"),
searchEl: document.getElementById("enochian-search-input"),
searchClearEl: document.getElementById("enochian-search-clear"),
detailNameEl: document.getElementById("enochian-detail-name"),
detailSubEl: document.getElementById("enochian-detail-sub"),
detailBodyEl: document.getElementById("enochian-detail-body")
};
}
function normalize(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
}
function titleCase(value) {
return String(value || "")
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function getTabletMeta(id) {
const normalizedId = normalize(id);
return TABLET_META[normalizedId] || {
label: `Enochian Tablet of ${titleCase(normalizedId || "Unknown")}`,
element: titleCase(normalizedId || "Unknown"),
order: 99
};
}
function buildSearchText(entry) {
return normalize([
entry.id,
entry.label,
entry.element,
entry.rowCount,
entry.colCount,
...entry.uniqueLetters
].join(" "));
}
function buildEntries(magickDataset) {
const tablets = magickDataset?.grouped?.enochian?.tablets;
if (!tablets || typeof tablets !== "object") {
return [];
}
return Object.entries(tablets)
.map(([key, value]) => {
const id = normalize(value?.id || key);
const grid = Array.isArray(value?.grid)
? value.grid.map((row) => (Array.isArray(row)
? row.map((cell) => String(cell || "").trim())
: []))
: [];
const rowCount = grid.length;
const colCount = grid.reduce((max, row) => Math.max(max, row.length), 0);
const uniqueLetters = [...new Set(
grid
.flat()
.map((cell) => String(cell || "").trim().toUpperCase())
.filter(Boolean)
)].sort((left, right) => left.localeCompare(right));
const meta = getTabletMeta(id);
return {
id,
grid,
rowCount,
colCount,
uniqueLetters,
element: meta.element,
label: meta.label,
order: Number(meta.order)
};
})
.sort((left, right) => left.order - right.order || left.label.localeCompare(right.label));
}
function buildLetterMap(magickDataset) {
const letters = magickDataset?.grouped?.enochian?.letters;
if (!letters || typeof letters !== "object") {
return new Map();
}
return new Map(
Object.entries(letters)
.map(([key, value]) => [String(key || "").trim().toUpperCase(), value])
.filter(([key]) => Boolean(key))
);
}
function findEntryById(id) {
return state.entries.find((entry) => entry.id === id) || null;
}
function getDefaultCell(entry) {
if (!entry || !Array.isArray(entry.grid)) {
return null;
}
for (let rowIndex = 0; rowIndex < entry.grid.length; rowIndex += 1) {
const row = entry.grid[rowIndex];
for (let colIndex = 0; colIndex < row.length; colIndex += 1) {
const value = String(row[colIndex] || "").trim();
if (value) {
return { rowIndex, colIndex, value };
}
}
}
return null;
}
function renderList(elements) {
if (!elements?.listEl) {
return;
}
elements.listEl.replaceChildren();
state.filteredEntries.forEach((entry) => {
const button = document.createElement("button");
button.type = "button";
button.className = "enoch-list-item";
button.dataset.tabletId = entry.id;
button.setAttribute("role", "option");
const isSelected = entry.id === state.selectedId;
button.classList.toggle("is-selected", isSelected);
button.setAttribute("aria-selected", isSelected ? "true" : "false");
const name = document.createElement("span");
name.className = "enoch-list-name";
name.textContent = entry.label;
const meta = document.createElement("span");
meta.className = "enoch-list-meta";
meta.textContent = `${entry.element} · ${entry.rowCount}×${entry.colCount} · ${entry.uniqueLetters.length} letters`;
button.append(name, meta);
elements.listEl.appendChild(button);
});
if (elements.countEl) {
elements.countEl.textContent = `${state.filteredEntries.length} tablets`;
}
if (!state.filteredEntries.length) {
const empty = document.createElement("div");
empty.className = "planet-text";
empty.style.padding = "16px";
empty.style.color = "#71717a";
empty.textContent = "No Enochian tablets match your search.";
elements.listEl.appendChild(empty);
}
}
function resolveTarotCardName(value) {
const normalized = normalize(value);
if (!normalized) {
return "";
}
return TAROT_NAME_ALIASES[normalized] || normalized;
}
function renderDetail(elements) {
if (!elements?.detailBodyEl || !elements.detailNameEl || !elements.detailSubEl) {
return;
}
const entry = findEntryById(state.selectedId);
elements.detailBodyEl.replaceChildren();
if (!entry) {
elements.detailNameEl.textContent = "--";
elements.detailSubEl.textContent = "Select a tablet to explore";
return;
}
elements.detailNameEl.textContent = entry.label;
elements.detailSubEl.textContent = `${entry.element} Tablet · ${entry.rowCount} rows × ${entry.colCount} columns`;
if (!state.selectedCell) {
state.selectedCell = getDefaultCell(entry);
}
const detailGrid = document.createElement("div");
detailGrid.className = "planet-meta-grid";
const summaryCard = document.createElement("div");
summaryCard.className = "planet-meta-card";
summaryCard.innerHTML = `
<strong>Tablet Overview</strong>
<div class="enoch-letter-meta">
<div>${entry.label}</div>
<div>${entry.rowCount} rows × ${entry.colCount} columns</div>
<div>Unique letters: ${entry.uniqueLetters.length}</div>
</div>
`;
const gridCard = document.createElement("div");
gridCard.className = "planet-meta-card";
const gridTitle = document.createElement("strong");
gridTitle.textContent = "Tablet Grid";
const gridEl = document.createElement("div");
gridEl.className = "enoch-grid";
entry.grid.forEach((row, rowIndex) => {
const rowEl = document.createElement("div");
rowEl.className = "enoch-grid-row";
row.forEach((cell, colIndex) => {
const value = String(cell || "").trim();
const cellBtn = document.createElement("button");
cellBtn.type = "button";
cellBtn.className = "enoch-grid-cell";
cellBtn.textContent = value || "·";
const isSelectedCell = state.selectedCell
&& state.selectedCell.rowIndex === rowIndex
&& state.selectedCell.colIndex === colIndex;
cellBtn.classList.toggle("is-selected", Boolean(isSelectedCell));
cellBtn.addEventListener("click", () => {
state.selectedCell = { rowIndex, colIndex, value };
renderDetail(elements);
});
rowEl.appendChild(cellBtn);
});
gridEl.appendChild(rowEl);
});
gridCard.append(gridTitle, gridEl);
const letterCard = document.createElement("div");
letterCard.className = "planet-meta-card";
const letterTitle = document.createElement("strong");
letterTitle.textContent = "Selected Letter";
const selectedLetter = String(state.selectedCell?.value || "").trim().toUpperCase();
const letterData = selectedLetter ? state.lettersById.get(selectedLetter) : null;
const letterContent = document.createElement("div");
letterContent.className = "enoch-letter-meta";
if (!selectedLetter) {
letterContent.textContent = "Select any grid cell to inspect its correspondence data.";
} else {
const firstRow = document.createElement("div");
firstRow.className = "enoch-letter-row";
const chip = document.createElement("span");
chip.className = "enoch-letter-chip";
chip.textContent = selectedLetter;
firstRow.appendChild(chip);
const title = document.createElement("span");
title.textContent = letterData?.title
? `${letterData.title}${letterData.english ? ` · ${letterData.english}` : ""}`
: "No letter metadata yet";
firstRow.appendChild(title);
letterContent.appendChild(firstRow);
if (letterData) {
const detailRows = [
["Pronunciation", letterData.pronounciation],
["Planet / Element", letterData["planet/element"]],
["Tarot", letterData.tarot],
["Gematria", letterData.gematria]
];
detailRows.forEach(([label, value]) => {
if (value === undefined || value === null || String(value).trim() === "") {
return;
}
const row = document.createElement("div");
row.className = "enoch-letter-row";
row.innerHTML = `<span style="color:#a1a1aa">${label}:</span><span>${value}</span>`;
letterContent.appendChild(row);
});
const navRow = document.createElement("div");
navRow.className = "enoch-letter-row";
const tarotCardName = resolveTarotCardName(letterData.tarot);
if (tarotCardName) {
const tarotBtn = document.createElement("button");
tarotBtn.type = "button";
tarotBtn.className = "enoch-nav-btn";
tarotBtn.textContent = `Open Tarot (${titleCase(tarotCardName)}) ↗`;
tarotBtn.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent("nav:tarot-trump", {
detail: { cardName: tarotCardName }
}));
});
navRow.appendChild(tarotBtn);
}
const alphabetBtn = document.createElement("button");
alphabetBtn.type = "button";
alphabetBtn.className = "enoch-nav-btn";
alphabetBtn.textContent = "Open Alphabet ↗";
alphabetBtn.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent("nav:alphabet", {
detail: {
alphabet: "english",
englishLetter: selectedLetter
}
}));
});
navRow.appendChild(alphabetBtn);
letterContent.appendChild(navRow);
}
}
letterCard.append(letterTitle, letterContent);
detailGrid.append(summaryCard, letterCard, gridCard);
elements.detailBodyEl.appendChild(detailGrid);
}
function applyFilter(elements) {
const query = normalize(state.searchQuery);
state.filteredEntries = query
? state.entries.filter((entry) => buildSearchText(entry).includes(query))
: [...state.entries];
if (elements?.searchClearEl) {
elements.searchClearEl.disabled = !query;
}
if (!state.filteredEntries.some((entry) => entry.id === state.selectedId)) {
state.selectedId = state.filteredEntries[0]?.id || "";
state.selectedCell = state.selectedId ? getDefaultCell(findEntryById(state.selectedId)) : null;
}
renderList(elements);
renderDetail(elements);
}
function selectByTabletId(tabletId) {
const elements = getElements();
const target = findEntryById(normalize(tabletId));
if (!target) {
return false;
}
state.selectedId = target.id;
state.selectedCell = getDefaultCell(target);
renderList(elements);
renderDetail(elements);
return true;
}
function ensureEnochianSection(magickDataset) {
const elements = getElements();
if (!elements.listEl || !elements.detailBodyEl) {
return;
}
state.entries = buildEntries(magickDataset);
state.lettersById = buildLetterMap(magickDataset);
if (!state.selectedId && state.entries.length) {
state.selectedId = state.entries[0].id;
state.selectedCell = getDefaultCell(state.entries[0]);
}
applyFilter(elements);
if (state.initialized) {
return;
}
elements.listEl.addEventListener("click", (event) => {
const target = event.target instanceof Element
? event.target.closest(".enoch-list-item")
: null;
if (!(target instanceof HTMLButtonElement)) {
return;
}
const tabletId = target.dataset.tabletId;
if (!tabletId) {
return;
}
state.selectedId = tabletId;
state.selectedCell = getDefaultCell(findEntryById(tabletId));
renderList(elements);
renderDetail(elements);
});
if (elements.searchEl) {
elements.searchEl.addEventListener("input", () => {
state.searchQuery = elements.searchEl.value || "";
applyFilter(elements);
});
}
if (elements.searchClearEl && elements.searchEl) {
elements.searchClearEl.addEventListener("click", () => {
state.searchQuery = "";
elements.searchEl.value = "";
applyFilter(elements);
elements.searchEl.focus();
});
}
state.initialized = true;
}
window.EnochianSectionUi = {
ensureEnochianSection,
selectByTabletId
};
})();

618
app/ui-gods.js Normal file
View File

@@ -0,0 +1,618 @@
/* ui-gods.js — Divine Pantheons section
* Individual deity browser: Greek, Roman, Egyptian, Hebrew divine names, Archangels.
* Kabbalah paths are shown only as a reference at the bottom of each detail view.
*/
(() => {
// ── State ──────────────────────────────────────────────────────────────────
const state = {
initialized: false,
gods: [],
godsByName: new Map(),
monthRefsByGodId: new Map(),
filteredGods: [],
selectedId: null,
activeTab: "greek",
searchQuery: ""
};
let listEl, detailNameEl, detailSubEl, detailBodyEl, countEl,
searchInputEl, searchClearEl;
// ── Tab definitions ────────────────────────────────────────────────────────
const TABS = [
{ id: "greek", label: "Greek", emoji: "🏛️" },
{ id: "roman", label: "Roman", emoji: "🦅" },
{ id: "egyptian", label: "Egyptian", emoji: "𓂀" },
{ id: "hebrew", label: "God Names", emoji: "✡️" },
{ id: "archangel", label: "Archangels", emoji: "☀️" },
{ id: "all", label: "All", emoji: "∞" },
];
const PANTHEON_LABEL = {
greek: "Greek", roman: "Roman", egyptian: "Egyptian",
hebrew: "God Names", archangel: "Archangel"
};
function normalizeName(value) {
return String(value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
function tokenizeEquivalent(value) {
return String(value || "")
.replace(/\([^)]*\)/g, " ")
.split(/\/|,|;|\bor\b|\band\b|·|—|/i)
.map((token) => token.trim())
.filter(Boolean);
}
function findEquivalentTarget(equivalent, currentGodId) {
const tokens = tokenizeEquivalent(equivalent);
for (const token of tokens) {
const matches = state.godsByName.get(normalizeName(token));
if (!matches?.length) continue;
const target = matches.find((x) => x.id !== currentGodId);
if (target) return target;
}
return null;
}
function buildMonthReferencesByGod(referenceData) {
const map = new Map();
const months = Array.isArray(referenceData?.calendarMonths) ? referenceData.calendarMonths : [];
const holidays = Array.isArray(referenceData?.celestialHolidays) ? referenceData.celestialHolidays : [];
const monthById = new Map(months.map((month) => [month.id, month]));
function parseMonthDayToken(value) {
const text = String(value || "").trim();
const match = text.match(/^(\d{1,2})-(\d{1,2})$/);
if (!match) {
return null;
}
const monthNo = Number(match[1]);
const dayNo = Number(match[2]);
if (!Number.isInteger(monthNo) || !Number.isInteger(dayNo) || monthNo < 1 || monthNo > 12 || dayNo < 1 || dayNo > 31) {
return null;
}
return { month: monthNo, day: dayNo };
}
function parseMonthDayTokensFromText(value) {
const text = String(value || "");
const matches = [...text.matchAll(/(\d{1,2})-(\d{1,2})/g)];
return matches
.map((match) => ({ month: Number(match[1]), day: Number(match[2]) }))
.filter((token) => Number.isInteger(token.month) && Number.isInteger(token.day) && token.month >= 1 && token.month <= 12 && token.day >= 1 && token.day <= 31);
}
function toDateToken(token, year) {
if (!token) {
return null;
}
return new Date(year, token.month - 1, token.day, 12, 0, 0, 0);
}
function splitMonthDayRangeByMonth(startToken, endToken) {
const startDate = toDateToken(startToken, 2025);
const endBase = toDateToken(endToken, 2025);
if (!startDate || !endBase) {
return [];
}
const wrapsYear = endBase.getTime() < startDate.getTime();
const endDate = wrapsYear ? toDateToken(endToken, 2026) : endBase;
if (!endDate) {
return [];
}
const segments = [];
let cursor = new Date(startDate);
while (cursor.getTime() <= endDate.getTime()) {
const monthEnd = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0, 12, 0, 0, 0);
const segmentEnd = monthEnd.getTime() < endDate.getTime() ? monthEnd : endDate;
segments.push({
monthNo: cursor.getMonth() + 1,
startDay: cursor.getDate(),
endDay: segmentEnd.getDate()
});
cursor = new Date(segmentEnd.getFullYear(), segmentEnd.getMonth(), segmentEnd.getDate() + 1, 12, 0, 0, 0);
}
return segments;
}
function tokenToString(monthNo, dayNo) {
return `${String(monthNo).padStart(2, "0")}-${String(dayNo).padStart(2, "0")}`;
}
function formatRangeLabel(monthName, startDay, endDay) {
if (!Number.isFinite(startDay) || !Number.isFinite(endDay)) {
return monthName;
}
if (startDay === endDay) {
return `${monthName} ${startDay}`;
}
return `${monthName} ${startDay}-${endDay}`;
}
function resolveRangeForMonth(month, options = {}) {
const monthOrder = Number(month?.order);
const monthStart = parseMonthDayToken(month?.start);
const monthEnd = parseMonthDayToken(month?.end);
if (!Number.isFinite(monthOrder) || !monthStart || !monthEnd) {
return {
startToken: String(month?.start || "").trim() || null,
endToken: String(month?.end || "").trim() || null,
label: month?.name || month?.id || "",
isFullMonth: true
};
}
let startToken = parseMonthDayToken(options.startToken);
let endToken = parseMonthDayToken(options.endToken);
if (!startToken || !endToken) {
const tokens = parseMonthDayTokensFromText(options.rawDateText);
if (tokens.length >= 2) {
startToken = tokens[0];
endToken = tokens[1];
} else if (tokens.length === 1) {
startToken = tokens[0];
endToken = tokens[0];
}
}
if (!startToken || !endToken) {
startToken = monthStart;
endToken = monthEnd;
}
const segments = splitMonthDayRangeByMonth(startToken, endToken);
const segment = segments.find((entry) => entry.monthNo === monthOrder) || null;
const useStart = segment ? { month: monthOrder, day: segment.startDay } : startToken;
const useEnd = segment ? { month: monthOrder, day: segment.endDay } : endToken;
const startText = tokenToString(useStart.month, useStart.day);
const endText = tokenToString(useEnd.month, useEnd.day);
const isFullMonth = startText === month.start && endText === month.end;
return {
startToken: startText,
endToken: endText,
label: isFullMonth
? (month.name || month.id)
: formatRangeLabel(month.name || month.id, useStart.day, useEnd.day),
isFullMonth
};
}
function pushRef(godId, month, options = {}) {
if (!godId || !month?.id) return;
const key = String(godId).trim().toLowerCase();
if (!key) return;
if (!map.has(key)) {
map.set(key, []);
}
const rows = map.get(key);
const range = resolveRangeForMonth(month, options);
const rowKey = `${month.id}|${range.startToken || ""}|${range.endToken || ""}`;
if (rows.some((entry) => entry.key === rowKey)) {
return;
}
rows.push({
id: month.id,
name: month.name || month.id,
order: Number.isFinite(Number(month.order)) ? Number(month.order) : 999,
label: range.label,
startToken: range.startToken,
endToken: range.endToken,
isFullMonth: range.isFullMonth,
key: rowKey
});
}
months.forEach((month) => {
pushRef(month?.associations?.godId, month);
const events = Array.isArray(month?.events) ? month.events : [];
events.forEach((event) => {
pushRef(event?.associations?.godId, month, {
rawDateText: event?.dateRange || event?.date || ""
});
});
});
holidays.forEach((holiday) => {
const month = monthById.get(holiday?.monthId);
if (month) {
pushRef(holiday?.associations?.godId, month, {
rawDateText: holiday?.dateRange || holiday?.date || ""
});
}
});
map.forEach((rows, key) => {
const preciseMonthIds = new Set(
rows
.filter((entry) => !entry.isFullMonth)
.map((entry) => entry.id)
);
const filtered = rows.filter((entry) => {
if (!entry.isFullMonth) {
return true;
}
return !preciseMonthIds.has(entry.id);
});
filtered.sort((left, right) => {
if (left.order !== right.order) {
return left.order - right.order;
}
const startLeft = parseMonthDayToken(left.startToken);
const startRight = parseMonthDayToken(right.startToken);
const dayLeft = startLeft ? startLeft.day : 999;
const dayRight = startRight ? startRight.day : 999;
if (dayLeft !== dayRight) {
return dayLeft - dayRight;
}
return String(left.label || left.name || "").localeCompare(String(right.label || right.name || ""));
});
map.set(key, filtered);
});
return map;
}
// ── Filter ─────────────────────────────────────────────────────────────────
function applyFilter() {
const q = state.searchQuery.toLowerCase();
const tab = state.activeTab;
state.filteredGods = state.gods.filter((g) => {
if (tab !== "all" && g.pantheon !== tab) return false;
if (!q) return true;
const hay = [
g.name, g.epithet, g.role,
...(g.domains || []),
...(g.parents || []),
...(g.siblings || []),
...(g.children || []),
...(g.symbols || []),
...(g.equivalents || []),
g.meaning, g.description, g.myth,
].filter(Boolean).join(" ").toLowerCase();
return hay.includes(q);
});
const hasSelected = state.filteredGods.some((g) => g.id === state.selectedId);
if (!hasSelected) {
state.selectedId = state.filteredGods[0]?.id || null;
}
renderList();
renderDetail(state.selectedId);
}
// ── List ───────────────────────────────────────────────────────────────────
function renderList() {
if (!listEl) return;
if (countEl) countEl.textContent = `${state.filteredGods.length} deities`;
if (!state.filteredGods.length) {
listEl.innerHTML = `<div style="padding:20px;color:#71717a;font-size:13px;text-align:center">No matches</div>`;
return;
}
listEl.innerHTML = state.filteredGods.map((g) => {
const isActive = state.selectedId === g.id;
const tag = PANTHEON_LABEL[g.pantheon] || g.pantheon;
return `<div class="gods-list-item${isActive ? " gods-list-active" : ""}" data-id="${g.id}" role="option" tabindex="0" aria-selected="${isActive}">
<div class="gods-list-main">
<span class="gods-list-label">${g.name}</span>
<span class="gods-list-tag">${tag}</span>
</div>
<div class="gods-list-sub">${g.role || g.epithet || "—"}</div>
</div>`;
}).join("");
listEl.querySelectorAll(".gods-list-item").forEach((el) => {
el.addEventListener("click", () => selectGod(el.dataset.id));
el.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") selectGod(el.dataset.id);
});
});
}
// ── Detail ─────────────────────────────────────────────────────────────────
function renderDetail(id) {
if (!detailNameEl) return;
const g = state.gods.find((x) => x.id === id);
if (!g) {
detailNameEl.textContent = "—";
detailSubEl.textContent = "Select a deity to explore";
detailBodyEl.innerHTML = "";
return;
}
detailNameEl.textContent = g.name;
detailSubEl.textContent = g.epithet || g.role || "";
const cards = [];
// ── Role & Domains ──
if (g.role || g.domains?.length) {
const domHtml = g.domains?.length
? `<div class="gods-domain-row">${g.domains.map(d => `<span class="gods-domain-tag">${d}</span>`).join("")}</div>`
: "";
cards.push(`<div class="gods-card">
<div class="gods-card-title">⚡ Role &amp; Domains</div>
${g.role ? `<div class="gods-card-body">${g.role}</div>` : ""}
${domHtml}
</div>`);
}
// ── Family ──
const hasFamily = [g.parents, g.siblings, g.consorts, g.children].some(a => a?.length);
if (hasFamily) {
const rows = [
g.parents?.length ? `<div class="gods-card-row"><span class="gods-field-label">Parents</span>${g.parents.join(", ")}</div>` : "",
g.siblings?.length ? `<div class="gods-card-row"><span class="gods-field-label">Siblings</span>${g.siblings.join(", ")}</div>` : "",
g.consorts?.length ? `<div class="gods-card-row"><span class="gods-field-label">Consort(s)</span>${g.consorts.join(", ")}</div>` : "",
g.children?.length ? `<div class="gods-card-row"><span class="gods-field-label">Children</span>${g.children.join(", ")}</div>` : "",
].filter(Boolean).join("");
cards.push(`<div class="gods-card">
<div class="gods-card-title">👨‍👩‍👧 Family</div>
${rows}
</div>`);
}
// ── Symbols & Sacred ──
if (g.symbols?.length || g.sacredAnimals?.length || g.sacredPlaces?.length) {
const rows = [
g.symbols?.length ? `<div class="gods-card-row"><span class="gods-field-label">Symbols</span>${g.symbols.join(", ")}</div>` : "",
g.sacredAnimals?.length ? `<div class="gods-card-row"><span class="gods-field-label">Sacred animals</span>${g.sacredAnimals.join(", ")}</div>` : "",
g.sacredPlaces?.length ? `<div class="gods-card-row"><span class="gods-field-label">Sacred places</span>${g.sacredPlaces.join(", ")}</div>` : "",
].filter(Boolean).join("");
cards.push(`<div class="gods-card">
<div class="gods-card-title">🔱 Sacred &amp; Symbols</div>
${rows}
</div>`);
}
// ── Hebrew Name (divine names / archangels) ──
if (g.hebrew) {
const title = g.pantheon === "archangel" ? "☀️ Angelic Name" : "✡️ Hebrew Name";
cards.push(`<div class="gods-card gods-card--elohim">
<div class="gods-card-title">${title}</div>
<div class="gods-card-row" style="align-items:baseline;gap:10px;flex-wrap:wrap">
<span class="gods-hebrew">${g.hebrew}</span>
<span class="gods-transliteration">${g.name}</span>
</div>
${g.meaning ? `<div class="gods-card-row" style="color:#a1a1aa;font-size:12px">${g.meaning}</div>` : ""}
${g.sephirah ? `<div class="gods-card-row" style="color:#a1a1aa;font-size:12px">Governs Sephirah ${g.sephirah}</div>` : ""}
</div>`);
}
// ── Equivalents ──
if (g.equivalents?.length) {
const equivalentHtml = g.equivalents.map((equivalent) => {
const target = findEquivalentTarget(equivalent, g.id);
if (target) {
return `<button type="button" class="gods-equivalent-link" data-god-id="${target.id}">${equivalent} ↗</button>`;
}
return `<span class="gods-equivalent-text">${equivalent}</span>`;
}).join("");
cards.push(`<div class="gods-card">
<div class="gods-card-title">⟺ Equivalents</div>
<div class="gods-equivalent-row">${equivalentHtml}</div>
</div>`);
}
const monthRefs = state.monthRefsByGodId.get(String(g.id || "").toLowerCase()) || [];
if (monthRefs.length) {
const monthButtons = monthRefs.map((month) =>
`<button class="gods-nav-btn" data-event="nav:calendar-month" data-month-id="${month.id}">${month.label || month.name} ↗</button>`
).join("");
cards.push(`<div class="gods-card gods-card--kab">
<div class="gods-card-title">📅 Calendar Months</div>
<div class="gods-card-row" style="color:#a1a1aa;font-size:12px">Linked month correspondences for ${g.name}</div>
<div class="gods-nav-row">${monthButtons}</div>
</div>`);
}
// ── Description ──
if (g.description) {
cards.push(`<div class="gods-card gods-card--wide">
<div class="gods-card-title">📖 Description</div>
<div class="gods-card-body" style="white-space:pre-line">${g.description}</div>
</div>`);
}
// ── Myth ──
if (g.myth) {
cards.push(`<div class="gods-card gods-card--wide">
<div class="gods-card-title">📜 Myth</div>
<div class="gods-card-body" style="white-space:pre-line">${g.myth}</div>
</div>`);
}
// ── Poem ──
if (g.poem) {
cards.push(`<div class="gods-card gods-card--wide">
<div class="gods-card-title">✍️ Poem</div>
<div class="gods-card-body" style="white-space:pre-line;font-style:italic">${g.poem}</div>
</div>`);
}
// ── Kabbalah reference (small, at bottom) ──
if (g.kabbalahPaths?.length) {
const btnHtml = g.kabbalahPaths.map(p =>
`<button class="gods-nav-btn" data-event="nav:kabbalah-path" data-path-no="${p}">Path / Sephirah ${p} ↗</button>`
).join("");
cards.push(`<div class="gods-card gods-card--kab">
<div class="gods-card-title">🌳 Kabbalah Reference</div>
<div class="gods-card-row" style="color:#a1a1aa;font-size:12px">Paths: ${g.kabbalahPaths.join(", ")}</div>
<div class="gods-nav-row">${btnHtml}</div>
</div>`);
}
detailBodyEl.innerHTML = `<div class="gods-detail-grid">${cards.join("")}</div>`;
// Attach nav button listeners
detailBodyEl.querySelectorAll(".gods-nav-btn[data-event]").forEach((btn) => {
btn.addEventListener("click", () => {
const evtName = btn.dataset.event;
const detail = {};
Object.entries(btn.dataset).forEach(([key, val]) => {
if (key === "event") return;
const camel = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
detail[camel] = isNaN(Number(val)) || val === "" ? val : Number(val);
});
document.dispatchEvent(new CustomEvent(evtName, { detail }));
});
});
detailBodyEl.querySelectorAll(".gods-equivalent-link[data-god-id]").forEach((btn) => {
btn.addEventListener("click", () => {
const godId = btn.dataset.godId;
if (godId) selectGod(godId);
});
});
}
// ── Tabs ───────────────────────────────────────────────────────────────────
function renderTabs() {
const tabsEl = document.getElementById("gods-tabs");
if (!tabsEl) return;
tabsEl.innerHTML = TABS.map((t) =>
`<button class="gods-tab-btn${state.activeTab === t.id ? " gods-tab-active" : ""}" data-tab="${t.id}">${t.emoji} ${t.label}</button>`
).join("");
tabsEl.querySelectorAll(".gods-tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
state.activeTab = btn.dataset.tab;
renderTabs();
applyFilter();
});
});
}
// ── Public: select god by id ───────────────────────────────────────────────
function selectGod(id) {
const g = state.gods.find((x) => x.id === id);
if (!g) return false;
if (g && state.activeTab !== "all" && g.pantheon !== state.activeTab) {
state.activeTab = "all";
renderTabs();
}
state.selectedId = id;
applyFilter();
requestAnimationFrame(() => {
const item = listEl?.querySelector(`[data-id="${id}"]`);
if (item) item.scrollIntoView({ block: "nearest", behavior: "smooth" });
});
return true;
}
function selectById(godId) {
return selectGod(godId);
}
function selectByName(name) {
const tokens = tokenizeEquivalent(name);
for (const token of tokens) {
const matches = state.godsByName.get(normalizeName(token));
const target = matches?.[0];
if (target?.id) {
return selectGod(target.id);
}
}
return false;
}
// ── Public: navigate here from kabbalah (find first god on that path) ──────
function selectByPathNo(pathNo) {
const no = Number(pathNo);
const g = state.gods.find((x) => x.kabbalahPaths?.includes(no));
if (g) return selectGod(g.id);
return false;
}
// ── Init ───────────────────────────────────────────────────────────────────
function ensureGodsSection(magickDataset, referenceData = null) {
if (referenceData) {
state.monthRefsByGodId = buildMonthReferencesByGod(referenceData);
}
if (state.initialized) {
if (state.selectedId) {
renderDetail(state.selectedId);
}
return;
}
state.initialized = true;
listEl = document.getElementById("gods-list");
detailNameEl = document.getElementById("gods-detail-name");
detailSubEl = document.getElementById("gods-detail-sub");
detailBodyEl = document.getElementById("gods-detail-body");
countEl = document.getElementById("gods-count");
searchInputEl = document.getElementById("gods-search-input");
searchClearEl = document.getElementById("gods-search-clear");
const godsData = magickDataset?.grouped?.["gods"];
if (!godsData?.gods?.length) {
if (listEl) listEl.innerHTML = `<div style="padding:20px;color:#ef4444">Failed to load gods data.</div>`;
return;
}
state.gods = godsData.gods;
state.godsByName = state.gods.reduce((map, god) => {
const key = normalizeName(god.name);
const row = map.get(key) || [];
row.push(god);
map.set(key, row);
return map;
}, new Map());
if (searchInputEl) {
searchInputEl.addEventListener("input", () => {
state.searchQuery = searchInputEl.value;
if (searchClearEl) searchClearEl.disabled = !state.searchQuery;
applyFilter();
});
}
if (searchClearEl) {
searchClearEl.disabled = true;
searchClearEl.addEventListener("click", () => {
state.searchQuery = "";
if (searchInputEl) searchInputEl.value = "";
searchClearEl.disabled = true;
applyFilter();
});
}
renderTabs();
applyFilter();
}
// ── Expose public API ──────────────────────────────────────────────────────
window.GodsSectionUi = { ensureGodsSection, selectByPathNo, selectById, selectByName };
})();

1107
app/ui-holidays.js Normal file

File diff suppressed because it is too large Load Diff

882
app/ui-iching.js Normal file
View File

@@ -0,0 +1,882 @@
(function () {
const { getTarotCardSearchAliases } = window.TarotCardImages || {};
const state = {
initialized: false,
hexagrams: [],
filteredHexagrams: [],
trigramsByName: {},
tarotByTrigramName: {},
monthRefsByHexagramNumber: new Map(),
searchQuery: "",
selectedNumber: null
};
const ICHING_PLANET_BY_PLANET_ID = {
sol: "Sun",
luna: "Moon",
mercury: "Mercury",
venus: "Venus",
mars: "Mars",
jupiter: "Jupiter",
saturn: "Saturn",
earth: "Earth",
uranus: "Uranus",
neptune: "Neptune",
pluto: "Pluto"
};
function getElements() {
return {
ichingCardListEl: document.getElementById("iching-card-list"),
ichingSearchInputEl: document.getElementById("iching-search-input"),
ichingSearchClearEl: document.getElementById("iching-search-clear"),
ichingCountEl: document.getElementById("iching-card-count"),
ichingDetailNameEl: document.getElementById("iching-detail-name"),
ichingDetailTypeEl: document.getElementById("iching-detail-type"),
ichingDetailSummaryEl: document.getElementById("iching-detail-summary"),
ichingDetailJudgementEl: document.getElementById("iching-detail-judgement"),
ichingDetailImageEl: document.getElementById("iching-detail-image"),
ichingDetailBinaryEl: document.getElementById("iching-detail-binary"),
ichingDetailLineEl: document.getElementById("iching-detail-line"),
ichingDetailKeywordsEl: document.getElementById("iching-detail-keywords"),
ichingDetailTrigramsEl: document.getElementById("iching-detail-trigrams"),
ichingDetailPlanetEl: document.getElementById("iching-detail-planet"),
ichingDetailTarotEl: document.getElementById("iching-detail-tarot"),
ichingDetailCalendarEl: document.getElementById("iching-detail-calendar")
};
}
function normalizeSearchValue(value) {
return String(value || "").trim().toLowerCase();
}
function clearChildren(element) {
if (element) {
element.replaceChildren();
}
}
function getTrigramByName(name) {
const key = normalizeSearchValue(name);
return key ? state.trigramsByName[key] || null : null;
}
function normalizePlanetInfluence(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z]/g, "");
}
function resolveAssociationPlanetInfluence(associations) {
if (!associations || typeof associations !== "object") {
return "";
}
const explicit = normalizePlanetInfluence(associations.iChingPlanetaryInfluence || associations.planetaryInfluence);
if (explicit) {
return explicit;
}
const planetId = normalizePlanetInfluence(associations.planetId);
if (!planetId) {
return "";
}
return normalizePlanetInfluence(ICHING_PLANET_BY_PLANET_ID[planetId]);
}
function buildMonthReferencesByHexagram(referenceData, hexagrams) {
const map = new Map();
const months = Array.isArray(referenceData?.calendarMonths) ? referenceData.calendarMonths : [];
const holidays = Array.isArray(referenceData?.celestialHolidays) ? referenceData.celestialHolidays : [];
const monthById = new Map(months.map((month) => [month.id, month]));
function parseMonthDayToken(value) {
const text = String(value || "").trim();
const match = text.match(/^(\d{1,2})-(\d{1,2})$/);
if (!match) {
return null;
}
const monthNo = Number(match[1]);
const dayNo = Number(match[2]);
if (!Number.isInteger(monthNo) || !Number.isInteger(dayNo) || monthNo < 1 || monthNo > 12 || dayNo < 1 || dayNo > 31) {
return null;
}
return { month: monthNo, day: dayNo };
}
function parseMonthDayTokensFromText(value) {
const text = String(value || "");
const matches = [...text.matchAll(/(\d{1,2})-(\d{1,2})/g)];
return matches
.map((match) => ({ month: Number(match[1]), day: Number(match[2]) }))
.filter((token) => Number.isInteger(token.month) && Number.isInteger(token.day) && token.month >= 1 && token.month <= 12 && token.day >= 1 && token.day <= 31);
}
function toDateToken(token, year) {
if (!token) {
return null;
}
return new Date(year, token.month - 1, token.day, 12, 0, 0, 0);
}
function splitMonthDayRangeByMonth(startToken, endToken) {
const startDate = toDateToken(startToken, 2025);
const endBase = toDateToken(endToken, 2025);
if (!startDate || !endBase) {
return [];
}
const wrapsYear = endBase.getTime() < startDate.getTime();
const endDate = wrapsYear ? toDateToken(endToken, 2026) : endBase;
if (!endDate) {
return [];
}
const segments = [];
let cursor = new Date(startDate);
while (cursor.getTime() <= endDate.getTime()) {
const monthEnd = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0, 12, 0, 0, 0);
const segmentEnd = monthEnd.getTime() < endDate.getTime() ? monthEnd : endDate;
segments.push({
monthNo: cursor.getMonth() + 1,
startDay: cursor.getDate(),
endDay: segmentEnd.getDate()
});
cursor = new Date(segmentEnd.getFullYear(), segmentEnd.getMonth(), segmentEnd.getDate() + 1, 12, 0, 0, 0);
}
return segments;
}
function tokenToString(monthNo, dayNo) {
return `${String(monthNo).padStart(2, "0")}-${String(dayNo).padStart(2, "0")}`;
}
function formatRangeLabel(monthName, startDay, endDay) {
if (!Number.isFinite(startDay) || !Number.isFinite(endDay)) {
return monthName;
}
if (startDay === endDay) {
return `${monthName} ${startDay}`;
}
return `${monthName} ${startDay}-${endDay}`;
}
function resolveRangeForMonth(month, options = {}) {
const monthOrder = Number(month?.order);
const monthStart = parseMonthDayToken(month?.start);
const monthEnd = parseMonthDayToken(month?.end);
if (!Number.isFinite(monthOrder) || !monthStart || !monthEnd) {
return {
startToken: String(month?.start || "").trim() || null,
endToken: String(month?.end || "").trim() || null,
label: month?.name || month?.id || "",
isFullMonth: true
};
}
let startToken = parseMonthDayToken(options.startToken);
let endToken = parseMonthDayToken(options.endToken);
if (!startToken || !endToken) {
const tokens = parseMonthDayTokensFromText(options.rawDateText);
if (tokens.length >= 2) {
startToken = tokens[0];
endToken = tokens[1];
} else if (tokens.length === 1) {
startToken = tokens[0];
endToken = tokens[0];
}
}
if (!startToken || !endToken) {
startToken = monthStart;
endToken = monthEnd;
}
const segments = splitMonthDayRangeByMonth(startToken, endToken);
const segment = segments.find((entry) => entry.monthNo === monthOrder) || null;
const useStart = segment ? { month: monthOrder, day: segment.startDay } : startToken;
const useEnd = segment ? { month: monthOrder, day: segment.endDay } : endToken;
const startText = tokenToString(useStart.month, useStart.day);
const endText = tokenToString(useEnd.month, useEnd.day);
const isFullMonth = startText === month.start && endText === month.end;
return {
startToken: startText,
endToken: endText,
label: isFullMonth
? (month.name || month.id)
: formatRangeLabel(month.name || month.id, useStart.day, useEnd.day),
isFullMonth
};
}
function pushRef(hexagramNumber, month, options = {}) {
if (!Number.isFinite(hexagramNumber) || !month?.id) {
return;
}
if (!map.has(hexagramNumber)) {
map.set(hexagramNumber, []);
}
const rows = map.get(hexagramNumber);
const range = resolveRangeForMonth(month, options);
const rowKey = `${month.id}|${range.startToken || ""}|${range.endToken || ""}`;
if (rows.some((entry) => entry.key === rowKey)) {
return;
}
rows.push({
id: month.id,
name: month.name || month.id,
order: Number.isFinite(Number(month.order)) ? Number(month.order) : 999,
label: range.label,
startToken: range.startToken,
endToken: range.endToken,
isFullMonth: range.isFullMonth,
key: rowKey
});
}
function collectRefs(associations, month, options = {}) {
const associationInfluence = resolveAssociationPlanetInfluence(associations);
if (!associationInfluence) {
return;
}
hexagrams.forEach((hexagram) => {
const hexagramInfluence = normalizePlanetInfluence(hexagram?.planetaryInfluence);
if (hexagramInfluence && hexagramInfluence === associationInfluence) {
pushRef(hexagram.number, month, options);
}
});
}
months.forEach((month) => {
collectRefs(month?.associations, month);
const events = Array.isArray(month?.events) ? month.events : [];
events.forEach((event) => {
collectRefs(event?.associations, month, {
rawDateText: event?.dateRange || event?.date || ""
});
});
});
holidays.forEach((holiday) => {
const month = monthById.get(holiday?.monthId);
if (!month) {
return;
}
collectRefs(holiday?.associations, month, {
rawDateText: holiday?.dateRange || holiday?.date || ""
});
});
map.forEach((rows, key) => {
const preciseMonthIds = new Set(
rows
.filter((entry) => !entry.isFullMonth)
.map((entry) => entry.id)
);
const filtered = rows.filter((entry) => {
if (!entry.isFullMonth) {
return true;
}
return !preciseMonthIds.has(entry.id);
});
filtered.sort((left, right) => {
if (left.order !== right.order) {
return left.order - right.order;
}
const startLeft = parseMonthDayToken(left.startToken);
const startRight = parseMonthDayToken(right.startToken);
const dayLeft = startLeft ? startLeft.day : 999;
const dayRight = startRight ? startRight.day : 999;
if (dayLeft !== dayRight) {
return dayLeft - dayRight;
}
return String(left.label || left.name || "").localeCompare(String(right.label || right.name || ""));
});
map.set(key, filtered);
});
return map;
}
function getBinaryPattern(value, expectedLength = 0) {
const raw = String(value || "").trim();
if (!raw) {
return "";
}
if (/^[01]+$/.test(raw)) {
if (!expectedLength || raw.length === expectedLength) {
return raw;
}
return "";
}
if (/^[|:]+$/.test(raw)) {
const mapped = raw
.split("")
.map((char) => (char === "|" ? "1" : "0"))
.join("");
if (!expectedLength || mapped.length === expectedLength) {
return mapped;
}
}
return "";
}
function createLineStack(binaryPattern, variant = "trigram") {
const container = document.createElement("div");
container.className = `iching-lines iching-lines-${variant}`;
binaryPattern.split("").forEach((bit) => {
const line = document.createElement("div");
line.className = bit === "1" ? "iching-line is-yang" : "iching-line is-yin";
container.appendChild(line);
});
return container;
}
function buildHexagramSearchText(entry) {
const upper = getTrigramByName(entry?.upperTrigram);
const lower = getTrigramByName(entry?.lowerTrigram);
const upperCards = upper ? state.tarotByTrigramName[normalizeSearchValue(upper.name)] || [] : [];
const lowerCards = lower ? state.tarotByTrigramName[normalizeSearchValue(lower.name)] || [] : [];
const tarotAliasText = typeof getTarotCardSearchAliases === "function"
? [...upperCards, ...lowerCards]
.flatMap((cardName) => getTarotCardSearchAliases(cardName))
.join(" ")
: [...upperCards, ...lowerCards].join(" ");
const parts = [
entry?.number,
entry?.name,
entry?.chineseName,
entry?.pinyin,
entry?.judgement,
entry?.image,
entry?.upperTrigram,
entry?.lowerTrigram,
entry?.planetaryInfluence,
entry?.binary,
entry?.lineDiagram,
...(Array.isArray(entry?.keywords) ? entry.keywords : []),
upper?.name,
upper?.chineseName,
upper?.pinyin,
upper?.element,
upper?.attribute,
upper?.binary,
upper?.description,
lower?.name,
lower?.chineseName,
lower?.pinyin,
lower?.element,
lower?.attribute,
lower?.binary,
lower?.description,
upperCards.join(" "),
lowerCards.join(" "),
tarotAliasText
];
return normalizeSearchValue(parts.filter((part) => part !== null && part !== undefined).join(" "));
}
function updateSelection(elements) {
if (!elements?.ichingCardListEl) {
return;
}
const buttons = elements.ichingCardListEl.querySelectorAll(".planet-list-item");
buttons.forEach((button) => {
const isSelected = Number(button.dataset.hexagramNumber) === state.selectedNumber;
button.classList.toggle("is-selected", isSelected);
button.setAttribute("aria-selected", isSelected ? "true" : "false");
});
}
function renderEmptyDetail(elements, detailName = "No hexagrams found") {
if (!elements) {
return;
}
if (elements.ichingDetailNameEl) {
elements.ichingDetailNameEl.textContent = detailName;
}
if (elements.ichingDetailTypeEl) {
elements.ichingDetailTypeEl.textContent = "--";
}
if (elements.ichingDetailSummaryEl) {
elements.ichingDetailSummaryEl.textContent = "--";
}
if (elements.ichingDetailJudgementEl) {
elements.ichingDetailJudgementEl.textContent = "--";
}
if (elements.ichingDetailImageEl) {
elements.ichingDetailImageEl.textContent = "--";
}
if (elements.ichingDetailBinaryEl) {
elements.ichingDetailBinaryEl.textContent = "--";
}
if (elements.ichingDetailLineEl) {
clearChildren(elements.ichingDetailLineEl);
elements.ichingDetailLineEl.textContent = "--";
}
if (elements.ichingDetailPlanetEl) {
elements.ichingDetailPlanetEl.textContent = "--";
}
if (elements.ichingDetailTarotEl) {
elements.ichingDetailTarotEl.textContent = "--";
}
if (elements.ichingDetailCalendarEl) {
clearChildren(elements.ichingDetailCalendarEl);
elements.ichingDetailCalendarEl.textContent = "--";
}
clearChildren(elements.ichingDetailKeywordsEl);
clearChildren(elements.ichingDetailTrigramsEl);
}
function renderKeywords(entry, elements) {
clearChildren(elements.ichingDetailKeywordsEl);
const keywords = Array.isArray(entry?.keywords) ? entry.keywords : [];
if (!keywords.length) {
if (elements.ichingDetailKeywordsEl) {
elements.ichingDetailKeywordsEl.textContent = "--";
}
return;
}
keywords.forEach((keyword) => {
const chip = document.createElement("span");
chip.className = "tarot-keyword-chip";
chip.textContent = keyword;
elements.ichingDetailKeywordsEl?.appendChild(chip);
});
}
function createTrigramCard(label, trigram) {
const card = document.createElement("div");
card.className = "iching-trigram-card";
if (!trigram) {
card.textContent = `${label}: --`;
return card;
}
const title = document.createElement("div");
title.className = "iching-trigram-title";
const chinese = trigram.chineseName ? ` (${trigram.chineseName})` : "";
title.textContent = `${label}: ${trigram.name || "--"}${chinese}`;
const meta = document.createElement("div");
meta.className = "iching-trigram-meta";
const attribute = trigram.attribute || "--";
const element = trigram.element || "--";
meta.textContent = `${attribute} · ${element}`;
const diagram = document.createElement("div");
diagram.className = "iching-trigram-diagram";
const binaryPattern = getBinaryPattern(trigram.binary || trigram.lineDiagram, 3);
if (binaryPattern) {
const binaryLabel = document.createElement("div");
binaryLabel.className = "iching-line-label";
binaryLabel.textContent = binaryPattern;
diagram.append(binaryLabel, createLineStack(binaryPattern, "trigram"));
} else {
diagram.textContent = "--";
}
card.append(title, meta, diagram);
if (trigram.description) {
const description = document.createElement("div");
description.className = "planet-text";
description.textContent = trigram.description;
card.appendChild(description);
}
return card;
}
function renderTrigrams(entry, elements) {
clearChildren(elements.ichingDetailTrigramsEl);
const upper = getTrigramByName(entry?.upperTrigram);
const lower = getTrigramByName(entry?.lowerTrigram);
elements.ichingDetailTrigramsEl?.append(
createTrigramCard("Upper", upper),
createTrigramCard("Lower", lower)
);
}
function renderTarotCorrespondences(entry, elements) {
if (!elements?.ichingDetailTarotEl) {
return;
}
const upperKey = normalizeSearchValue(entry?.upperTrigram);
const lowerKey = normalizeSearchValue(entry?.lowerTrigram);
const upperCards = upperKey ? state.tarotByTrigramName[upperKey] || [] : [];
const lowerCards = lowerKey ? state.tarotByTrigramName[lowerKey] || [] : [];
const upperTrigram = upperKey ? state.trigramsByName[upperKey] : null;
const lowerTrigram = lowerKey ? state.trigramsByName[lowerKey] : null;
const upperLabel = upperTrigram?.element || entry?.upperTrigram || "--";
const lowerLabel = lowerTrigram?.element || entry?.lowerTrigram || "--";
const lines = [];
if (upperKey) {
lines.push(`Upper (${upperLabel}): ${upperCards.length ? upperCards.join(", ") : "--"}`);
}
if (lowerKey) {
lines.push(`Lower (${lowerLabel}): ${lowerCards.length ? lowerCards.join(", ") : "--"}`);
}
elements.ichingDetailTarotEl.textContent = lines.length ? lines.join("\n\n") : "--";
}
function renderCalendarMonths(entry, elements) {
if (!elements?.ichingDetailCalendarEl) {
return;
}
clearChildren(elements.ichingDetailCalendarEl);
const rows = state.monthRefsByHexagramNumber.get(entry?.number) || [];
if (!rows.length) {
elements.ichingDetailCalendarEl.textContent = "--";
return;
}
rows.forEach((month) => {
const button = document.createElement("button");
button.type = "button";
button.className = "alpha-nav-btn";
button.textContent = `${month.label || month.name}`;
button.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent("nav:calendar-month", {
detail: { monthId: month.id }
}));
});
elements.ichingDetailCalendarEl.appendChild(button);
});
}
function renderDetail(entry, elements) {
if (!entry || !elements) {
return;
}
const number = Number.isFinite(entry.number) ? entry.number : "--";
if (elements.ichingDetailNameEl) {
elements.ichingDetailNameEl.textContent = `${number} · ${entry.name || "--"}`;
}
if (elements.ichingDetailTypeEl) {
const chinese = entry.chineseName || "--";
const pinyin = entry.pinyin || "--";
const upper = entry.upperTrigram || "--";
const lower = entry.lowerTrigram || "--";
elements.ichingDetailTypeEl.textContent = `Hexagram · ${chinese} · ${pinyin} · ${upper}/${lower}`;
}
if (elements.ichingDetailSummaryEl) {
elements.ichingDetailSummaryEl.textContent = entry.judgement || "--";
}
if (elements.ichingDetailJudgementEl) {
elements.ichingDetailJudgementEl.textContent = entry.judgement || "--";
}
if (elements.ichingDetailImageEl) {
elements.ichingDetailImageEl.textContent = entry.image || "--";
}
if (elements.ichingDetailBinaryEl) {
const binary = entry.binary || "--";
elements.ichingDetailBinaryEl.textContent = `Binary: ${binary}`;
}
if (elements.ichingDetailLineEl) {
clearChildren(elements.ichingDetailLineEl);
const linePattern = getBinaryPattern(entry.binary, 6) || getBinaryPattern(entry.lineDiagram, 6);
if (linePattern) {
elements.ichingDetailLineEl.appendChild(createLineStack(linePattern, "hexagram"));
} else {
elements.ichingDetailLineEl.textContent = "--";
}
}
if (elements.ichingDetailPlanetEl) {
elements.ichingDetailPlanetEl.textContent = entry.planetaryInfluence || "--";
}
renderKeywords(entry, elements);
renderTrigrams(entry, elements);
renderTarotCorrespondences(entry, elements);
renderCalendarMonths(entry, elements);
}
function selectByNumber(number, elements) {
const numeric = Number(number);
if (!Number.isFinite(numeric)) {
return;
}
const entry = state.hexagrams.find((hexagram) => hexagram.number === numeric);
if (!entry) {
return;
}
state.selectedNumber = entry.number;
updateSelection(elements);
renderDetail(entry, elements);
}
function renderList(elements) {
if (!elements?.ichingCardListEl) {
return;
}
clearChildren(elements.ichingCardListEl);
state.filteredHexagrams.forEach((entry) => {
const button = document.createElement("button");
button.type = "button";
button.className = "planet-list-item";
button.dataset.hexagramNumber = String(entry.number);
button.setAttribute("role", "option");
const nameEl = document.createElement("span");
nameEl.className = "planet-list-name";
const number = Number.isFinite(entry.number) ? `#${entry.number} ` : "";
nameEl.textContent = `${number}${entry.name || "--"}`;
const metaEl = document.createElement("span");
metaEl.className = "planet-list-meta";
const upper = entry.upperTrigram || "--";
const lower = entry.lowerTrigram || "--";
metaEl.textContent = `${upper}/${lower} · ${entry.planetaryInfluence || "--"}`;
button.append(nameEl, metaEl);
elements.ichingCardListEl.appendChild(button);
});
if (elements.ichingCountEl) {
elements.ichingCountEl.textContent = state.searchQuery
? `${state.filteredHexagrams.length} of ${state.hexagrams.length} hexagrams`
: `${state.hexagrams.length} hexagrams`;
}
}
function applySearchFilter(elements) {
const query = normalizeSearchValue(state.searchQuery);
state.filteredHexagrams = query
? state.hexagrams.filter((entry) => buildHexagramSearchText(entry).includes(query))
: [...state.hexagrams];
if (elements?.ichingSearchClearEl) {
elements.ichingSearchClearEl.disabled = !query;
}
renderList(elements);
if (!state.filteredHexagrams.some((entry) => entry.number === state.selectedNumber)) {
if (state.filteredHexagrams.length > 0) {
selectByNumber(state.filteredHexagrams[0].number, elements);
} else {
state.selectedNumber = null;
updateSelection(elements);
renderEmptyDetail(elements);
}
return;
}
updateSelection(elements);
}
function ensureIChingSection(referenceData) {
const elements = getElements();
if (state.initialized) {
state.monthRefsByHexagramNumber = buildMonthReferencesByHexagram(referenceData, state.hexagrams);
const selected = state.hexagrams.find((hexagram) => hexagram.number === state.selectedNumber);
if (selected) {
renderDetail(selected, elements);
}
return;
}
if (!elements.ichingCardListEl || !elements.ichingDetailNameEl) {
return;
}
const iChing = referenceData?.iChing;
const trigrams = Array.isArray(iChing?.trigrams) ? iChing.trigrams : [];
const hexagrams = Array.isArray(iChing?.hexagrams) ? iChing.hexagrams : [];
const correspondences = iChing?.correspondences;
if (!hexagrams.length) {
renderEmptyDetail(elements, "I Ching data unavailable");
return;
}
state.trigramsByName = trigrams.reduce((acc, trigram) => {
const key = normalizeSearchValue(trigram?.name);
if (key) {
acc[key] = trigram;
}
return acc;
}, {});
const tarotToTrigram = Array.isArray(correspondences?.tarotToTrigram)
? correspondences.tarotToTrigram
: [];
state.tarotByTrigramName = tarotToTrigram.reduce((acc, row) => {
const key = normalizeSearchValue(row?.trigram);
const tarotCard = String(row?.tarot || "").trim();
if (!key || !tarotCard) {
return acc;
}
if (!Array.isArray(acc[key])) {
acc[key] = [];
}
if (!acc[key].includes(tarotCard)) {
acc[key].push(tarotCard);
}
return acc;
}, {});
state.hexagrams = [...hexagrams]
.map((entry) => ({
...entry,
number: Number(entry?.number)
}))
.filter((entry) => Number.isFinite(entry.number))
.sort((a, b) => a.number - b.number);
state.monthRefsByHexagramNumber = buildMonthReferencesByHexagram(referenceData, state.hexagrams);
state.filteredHexagrams = [...state.hexagrams];
renderList(elements);
if (state.hexagrams.length > 0) {
selectByNumber(state.hexagrams[0].number, elements);
}
elements.ichingCardListEl.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
const button = target instanceof Element
? target.closest(".planet-list-item")
: null;
if (!(button instanceof HTMLButtonElement)) {
return;
}
const selectedNumber = button.dataset.hexagramNumber;
if (!selectedNumber) {
return;
}
selectByNumber(selectedNumber, elements);
});
if (elements.ichingSearchInputEl) {
elements.ichingSearchInputEl.addEventListener("input", () => {
state.searchQuery = elements.ichingSearchInputEl.value || "";
applySearchFilter(elements);
});
}
if (elements.ichingSearchClearEl && elements.ichingSearchInputEl) {
elements.ichingSearchClearEl.addEventListener("click", () => {
elements.ichingSearchInputEl.value = "";
state.searchQuery = "";
applySearchFilter(elements);
elements.ichingSearchInputEl.focus();
});
}
state.initialized = true;
}
function selectByHexagramNumber(number) {
if (!state.initialized) {
return false;
}
const elements = getElements();
const numeric = Number(number);
if (!Number.isFinite(numeric)) {
return false;
}
const entry = state.hexagrams.find((hexagram) => hexagram.number === numeric);
if (!entry) {
return false;
}
selectByNumber(entry.number, elements);
elements.ichingCardListEl
?.querySelector(`[data-hexagram-number="${entry.number}"]`)
?.scrollIntoView({ block: "nearest" });
return true;
}
function selectByPlanetaryInfluence(planetaryInfluence) {
if (!state.initialized) {
return false;
}
const targetInfluence = normalizePlanetInfluence(planetaryInfluence);
if (!targetInfluence) {
return false;
}
const entry = state.hexagrams.find((hexagram) =>
normalizePlanetInfluence(hexagram?.planetaryInfluence) === targetInfluence
);
if (!entry) {
return false;
}
return selectByHexagramNumber(entry.number);
}
window.IChingSectionUi = {
ensureIChingSection,
selectByHexagramNumber,
selectByPlanetaryInfluence
};
})();

1153
app/ui-kabbalah.js Normal file

File diff suppressed because it is too large Load Diff

185
app/ui-natal.js Normal file
View File

@@ -0,0 +1,185 @@
(function () {
const DAY_IN_MS = 24 * 60 * 60 * 1000;
let referenceDataCache = null;
let natalSummaryEl = null;
function getNatalSummaryEl() {
if (!natalSummaryEl) {
natalSummaryEl = document.getElementById("natal-chart-summary")
|| document.getElementById("now-natal-summary");
}
return natalSummaryEl;
}
function parseMonthDay(monthDay) {
const [month, day] = String(monthDay || "").split("-").map(Number);
if (!Number.isFinite(month) || !Number.isFinite(day)) {
return null;
}
return { month, day };
}
function parseBirthDateAsLocalNoon(isoDate) {
const [year, month, day] = String(isoDate || "").split("-").map(Number);
if (!year || !month || !day) {
return null;
}
return new Date(year, month - 1, day, 12, 0, 0, 0);
}
function isDateInSign(date, sign) {
const start = parseMonthDay(sign?.start);
const end = parseMonthDay(sign?.end);
if (!start || !end) {
return false;
}
const month = date.getMonth() + 1;
const day = date.getDate();
const wrapsYear = start.month > end.month;
if (!wrapsYear) {
const afterStart = month > start.month || (month === start.month && day >= start.day);
const beforeEnd = month < end.month || (month === end.month && day <= end.day);
return afterStart && beforeEnd;
}
const afterStart = month > start.month || (month === start.month && day >= start.day);
const beforeEnd = month < end.month || (month === end.month && day <= end.day);
return afterStart || beforeEnd;
}
function getSignStartDate(date, sign) {
const start = parseMonthDay(sign?.start);
const end = parseMonthDay(sign?.end);
if (!start || !end) {
return null;
}
const wrapsYear = start.month > end.month;
const month = date.getMonth() + 1;
const day = date.getDate();
let year = date.getFullYear();
if (wrapsYear && (month < start.month || (month === start.month && day < start.day))) {
year -= 1;
}
return new Date(year, start.month - 1, start.day, 12, 0, 0, 0);
}
function getSunSignAnchor(referenceData, birthDate) {
const signs = Array.isArray(referenceData?.signs) ? referenceData.signs : [];
if (!signs.length || !birthDate) {
return null;
}
const sign = signs.find((candidate) => isDateInSign(birthDate, candidate)) || null;
if (!sign) {
return null;
}
return {
id: sign.id,
name: sign.name,
symbol: sign.symbol || "",
tarotMajorArcana: sign?.tarot?.majorArcana || ""
};
}
function getSunDecanAnchor(referenceData, signId, birthDate) {
if (!signId || !birthDate) {
return null;
}
const sign = (referenceData?.signs || []).find((entry) => entry.id === signId) || null;
if (!sign) {
return null;
}
const signStartDate = getSignStartDate(birthDate, sign);
if (!signStartDate) {
return null;
}
const daysSinceSignStart = Math.max(0, Math.floor((birthDate.getTime() - signStartDate.getTime()) / DAY_IN_MS));
const decanIndex = Math.max(1, Math.min(3, Math.floor(daysSinceSignStart / 10) + 1));
const decans = referenceData?.decansBySign?.[signId] || [];
const decan = decans.find((entry) => Number(entry.index) === decanIndex) || null;
return {
index: decanIndex,
tarotMinorArcana: decan?.tarotMinorArcana || ""
};
}
function buildNatalScaffoldSummary() {
const context = window.TarotNatal?.getContext?.() || null;
if (!context) {
return "Natal Chart Scaffold: unavailable";
}
const birthDate = context.birthDateParts?.isoDate
? parseBirthDateAsLocalNoon(context.birthDateParts.isoDate)
: null;
if (!birthDate) {
return [
"Natal Chart Scaffold",
"Birth Date: --",
`Geo Anchor: ${context.latitude.toFixed(4)}, ${context.longitude.toFixed(4)}`,
"Sun Sign Anchor: --",
"Sun Decan Anchor: --",
"House Scaffold: 12 houses ready (Equal House placeholder), Ascendant/cusps pending birth time"
].join("\n");
}
const sunSign = getSunSignAnchor(referenceDataCache, birthDate);
const sunDecan = getSunDecanAnchor(referenceDataCache, sunSign?.id, birthDate);
const signLabel = sunSign
? `${sunSign.symbol} ${sunSign.name}${sunSign.tarotMajorArcana ? ` · ${sunSign.tarotMajorArcana}` : ""}`
: "--";
const decanLabel = sunDecan
? `Decan ${sunDecan.index}${sunDecan.tarotMinorArcana ? ` · ${sunDecan.tarotMinorArcana}` : ""}`
: "--";
return [
"Natal Chart Scaffold",
`Birth Date: ${context.birthDateParts.isoDate} (${context.timeZone})`,
`Geo Anchor: ${context.latitude.toFixed(4)}, ${context.longitude.toFixed(4)}`,
`Sun Sign Anchor: ${signLabel}`,
`Sun Decan Anchor: ${decanLabel}`,
"House Scaffold: 12 houses ready (Equal House placeholder), Ascendant/cusps pending birth time"
].join("\n");
}
function renderNatalSummary() {
const outputEl = getNatalSummaryEl();
if (!outputEl) {
return;
}
outputEl.textContent = buildNatalScaffoldSummary();
}
function ensureNatalPanel(referenceData) {
if (referenceData && typeof referenceData === "object") {
referenceDataCache = referenceData;
}
renderNatalSummary();
}
document.addEventListener("settings:updated", () => {
renderNatalSummary();
});
window.TarotNatalUi = {
ensureNatalPanel,
renderNatalSummary
};
})();

686
app/ui-now.js Normal file
View File

@@ -0,0 +1,686 @@
(function () {
const {
DAY_IN_MS,
getDateKey,
getMoonPhaseName,
getDecanForDate,
calcPlanetaryHoursForDayAndLocation
} = window.TarotCalc;
const { resolveTarotCardImage, getTarotCardDisplayName } = window.TarotCardImages || {};
let moonCountdownCache = null;
let decanCountdownCache = null;
let nowLightboxOverlayEl = null;
let nowLightboxImageEl = null;
let nowLightboxZoomed = false;
const LIGHTBOX_ZOOM_SCALE = 6.66;
const PLANETARY_BODIES = [
{ id: "sol", astronomyBody: "Sun", fallbackName: "Sun", fallbackSymbol: "☉︎" },
{ id: "luna", astronomyBody: "Moon", fallbackName: "Moon", fallbackSymbol: "☾︎" },
{ id: "mercury", astronomyBody: "Mercury", fallbackName: "Mercury", fallbackSymbol: "☿︎" },
{ id: "venus", astronomyBody: "Venus", fallbackName: "Venus", fallbackSymbol: "♀︎" },
{ id: "mars", astronomyBody: "Mars", fallbackName: "Mars", fallbackSymbol: "♂︎" },
{ id: "jupiter", astronomyBody: "Jupiter", fallbackName: "Jupiter", fallbackSymbol: "♃︎" },
{ id: "saturn", astronomyBody: "Saturn", fallbackName: "Saturn", fallbackSymbol: "♄︎" },
{ id: "uranus", astronomyBody: "Uranus", fallbackName: "Uranus", fallbackSymbol: "♅︎" },
{ id: "neptune", astronomyBody: "Neptune", fallbackName: "Neptune", fallbackSymbol: "♆︎" },
{ id: "pluto", astronomyBody: "Pluto", fallbackName: "Pluto", fallbackSymbol: "♇︎" }
];
function resetNowLightboxZoom() {
if (!nowLightboxImageEl) {
return;
}
nowLightboxZoomed = false;
nowLightboxImageEl.style.transform = "scale(1)";
nowLightboxImageEl.style.transformOrigin = "center center";
nowLightboxImageEl.style.cursor = "zoom-in";
}
function updateNowLightboxZoomOrigin(clientX, clientY) {
if (!nowLightboxZoomed || !nowLightboxImageEl) {
return;
}
const rect = nowLightboxImageEl.getBoundingClientRect();
if (!rect.width || !rect.height) {
return;
}
const x = Math.min(100, Math.max(0, ((clientX - rect.left) / rect.width) * 100));
const y = Math.min(100, Math.max(0, ((clientY - rect.top) / rect.height) * 100));
nowLightboxImageEl.style.transformOrigin = `${x}% ${y}%`;
}
function isNowLightboxPointOnCard(clientX, clientY) {
if (!nowLightboxImageEl) {
return false;
}
const rect = nowLightboxImageEl.getBoundingClientRect();
const naturalWidth = nowLightboxImageEl.naturalWidth;
const naturalHeight = nowLightboxImageEl.naturalHeight;
if (!rect.width || !rect.height || !naturalWidth || !naturalHeight) {
return true;
}
const frameAspect = rect.width / rect.height;
const imageAspect = naturalWidth / naturalHeight;
let renderWidth = rect.width;
let renderHeight = rect.height;
if (imageAspect > frameAspect) {
renderHeight = rect.width / imageAspect;
} else {
renderWidth = rect.height * imageAspect;
}
const left = rect.left + (rect.width - renderWidth) / 2;
const top = rect.top + (rect.height - renderHeight) / 2;
const right = left + renderWidth;
const bottom = top + renderHeight;
return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom;
}
function ensureNowImageLightbox() {
if (nowLightboxOverlayEl && nowLightboxImageEl) {
return;
}
nowLightboxOverlayEl = document.createElement("div");
nowLightboxOverlayEl.setAttribute("aria-hidden", "true");
nowLightboxOverlayEl.style.position = "fixed";
nowLightboxOverlayEl.style.inset = "0";
nowLightboxOverlayEl.style.background = "rgba(0, 0, 0, 0.82)";
nowLightboxOverlayEl.style.display = "none";
nowLightboxOverlayEl.style.alignItems = "center";
nowLightboxOverlayEl.style.justifyContent = "center";
nowLightboxOverlayEl.style.zIndex = "9999";
nowLightboxOverlayEl.style.padding = "0";
const image = document.createElement("img");
image.alt = "Now card enlarged image";
image.style.maxWidth = "100vw";
image.style.maxHeight = "100vh";
image.style.width = "100vw";
image.style.height = "100vh";
image.style.objectFit = "contain";
image.style.borderRadius = "0";
image.style.boxShadow = "none";
image.style.border = "none";
image.style.cursor = "zoom-in";
image.style.transform = "scale(1)";
image.style.transformOrigin = "center center";
image.style.transition = "transform 120ms ease-out";
image.style.userSelect = "none";
nowLightboxImageEl = image;
nowLightboxOverlayEl.appendChild(image);
const closeLightbox = () => {
if (!nowLightboxOverlayEl || !nowLightboxImageEl) {
return;
}
nowLightboxOverlayEl.style.display = "none";
nowLightboxOverlayEl.setAttribute("aria-hidden", "true");
nowLightboxImageEl.removeAttribute("src");
resetNowLightboxZoom();
};
nowLightboxOverlayEl.addEventListener("click", (event) => {
if (event.target === nowLightboxOverlayEl) {
closeLightbox();
}
});
nowLightboxImageEl.addEventListener("click", (event) => {
event.stopPropagation();
if (!isNowLightboxPointOnCard(event.clientX, event.clientY)) {
closeLightbox();
return;
}
if (!nowLightboxZoomed) {
nowLightboxZoomed = true;
nowLightboxImageEl.style.transform = `scale(${LIGHTBOX_ZOOM_SCALE})`;
nowLightboxImageEl.style.cursor = "zoom-out";
updateNowLightboxZoomOrigin(event.clientX, event.clientY);
return;
}
resetNowLightboxZoom();
});
nowLightboxImageEl.addEventListener("mousemove", (event) => {
updateNowLightboxZoomOrigin(event.clientX, event.clientY);
});
nowLightboxImageEl.addEventListener("mouseleave", () => {
if (nowLightboxZoomed) {
nowLightboxImageEl.style.transformOrigin = "center center";
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeLightbox();
}
});
document.body.appendChild(nowLightboxOverlayEl);
}
function openNowImageLightbox(src, altText) {
if (!src) {
return;
}
ensureNowImageLightbox();
if (!nowLightboxOverlayEl || !nowLightboxImageEl) {
return;
}
nowLightboxImageEl.src = src;
nowLightboxImageEl.alt = altText || "Now card enlarged image";
resetNowLightboxZoom();
nowLightboxOverlayEl.style.display = "flex";
nowLightboxOverlayEl.setAttribute("aria-hidden", "false");
}
function getDisplayTarotName(cardName, trumpNumber) {
if (!cardName) {
return "";
}
if (typeof getTarotCardDisplayName !== "function") {
return cardName;
}
if (Number.isFinite(Number(trumpNumber))) {
return getTarotCardDisplayName(cardName, { trumpNumber: Number(trumpNumber) }) || cardName;
}
return getTarotCardDisplayName(cardName) || cardName;
}
function bindNowCardLightbox(imageEl) {
if (!(imageEl instanceof HTMLImageElement) || imageEl.dataset.lightboxBound === "true") {
return;
}
imageEl.dataset.lightboxBound = "true";
imageEl.style.cursor = "zoom-in";
imageEl.title = "Click to enlarge";
imageEl.addEventListener("click", () => {
const src = imageEl.getAttribute("src");
if (!src || imageEl.style.display === "none") {
return;
}
openNowImageLightbox(src, imageEl.alt || "Now card enlarged image");
});
}
function normalizeLongitude(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return null;
}
return ((numeric % 360) + 360) % 360;
}
function getSortedSigns(signs) {
if (!Array.isArray(signs)) {
return [];
}
return [...signs].sort((a, b) => (a.order || 0) - (b.order || 0));
}
function getSignForLongitude(longitude, signs) {
const normalized = normalizeLongitude(longitude);
if (normalized === null) {
return null;
}
const sortedSigns = getSortedSigns(signs);
if (!sortedSigns.length) {
return null;
}
const signIndex = Math.min(sortedSigns.length - 1, Math.floor(normalized / 30));
const sign = sortedSigns[signIndex] || null;
if (!sign) {
return null;
}
return {
sign,
degreeInSign: normalized - signIndex * 30,
absoluteLongitude: normalized
};
}
function getSabianSymbolForLongitude(longitude, sabianSymbols) {
const normalized = normalizeLongitude(longitude);
if (normalized === null || !Array.isArray(sabianSymbols) || !sabianSymbols.length) {
return null;
}
const absoluteDegree = Math.floor(normalized) + 1;
return sabianSymbols.find((entry) => Number(entry?.absoluteDegree) === absoluteDegree) || null;
}
function calculatePlanetPositions(referenceData, now) {
if (!window.Astronomy || !referenceData) {
return [];
}
const positions = [];
PLANETARY_BODIES.forEach((body) => {
try {
const geoVector = window.Astronomy.GeoVector(body.astronomyBody, now, true);
const ecliptic = window.Astronomy.Ecliptic(geoVector);
const signInfo = getSignForLongitude(ecliptic?.elon, referenceData.signs);
if (!signInfo?.sign) {
return;
}
const planetInfo = referenceData.planets?.[body.id] || null;
const symbol = planetInfo?.symbol || body.fallbackSymbol;
const name = planetInfo?.name || body.fallbackName;
positions.push({
id: body.id,
symbol,
name,
longitude: signInfo.absoluteLongitude,
sign: signInfo.sign,
degreeInSign: signInfo.degreeInSign,
label: `${symbol} ${name}: ${signInfo.sign.symbol} ${signInfo.sign.name} ${signInfo.degreeInSign.toFixed(1)}°`
});
} catch {
}
});
return positions;
}
function updateNowStats(referenceData, elements, now) {
const planetPositions = calculatePlanetPositions(referenceData, now);
if (elements.nowStatsPlanetsEl) {
elements.nowStatsPlanetsEl.replaceChildren();
if (!planetPositions.length) {
elements.nowStatsPlanetsEl.textContent = "--";
} else {
planetPositions.forEach((position) => {
const item = document.createElement("div");
item.className = "now-stats-planet";
item.textContent = position.label;
elements.nowStatsPlanetsEl.appendChild(item);
});
}
}
if (elements.nowStatsSabianEl) {
const sunPosition = planetPositions.find((entry) => entry.id === "sol") || null;
const moonPosition = planetPositions.find((entry) => entry.id === "luna") || null;
const sunSabianSymbol = sunPosition
? getSabianSymbolForLongitude(sunPosition.longitude, referenceData.sabianSymbols)
: null;
const moonSabianSymbol = moonPosition
? getSabianSymbolForLongitude(moonPosition.longitude, referenceData.sabianSymbols)
: null;
const sunLine = sunSabianSymbol?.phrase
? `Sun Sabian ${sunSabianSymbol.absoluteDegree}: ${sunSabianSymbol.phrase}`
: "Sun Sabian: --";
const moonLine = moonSabianSymbol?.phrase
? `Moon Sabian ${moonSabianSymbol.absoluteDegree}: ${moonSabianSymbol.phrase}`
: "Moon Sabian: --";
elements.nowStatsSabianEl.textContent = `${sunLine}\n${moonLine}`;
}
}
function formatCountdown(ms, mode) {
if (!Number.isFinite(ms) || ms <= 0) {
if (mode === "hours") {
return "0.0 hours";
}
if (mode === "seconds") {
return "0s";
}
return "0m";
}
if (mode === "hours") {
return `${(ms / 3600000).toFixed(1)} hours`;
}
if (mode === "seconds") {
return `${Math.floor(ms / 1000)}s`;
}
return `${Math.floor(ms / 60000)}m`;
}
function parseMonthDay(monthDay) {
const [month, day] = String(monthDay || "").split("-").map(Number);
return { month, day };
}
function getCurrentPhaseName(date) {
return getMoonPhaseName(window.SunCalc.getMoonIllumination(date).phase);
}
function findNextMoonPhaseTransition(now) {
const currentPhase = getCurrentPhaseName(now);
const stepMs = 15 * 60 * 1000;
const maxMs = 40 * DAY_IN_MS;
let previousTime = now.getTime();
let previousPhase = currentPhase;
for (let t = previousTime + stepMs; t <= previousTime + maxMs; t += stepMs) {
const phaseName = getCurrentPhaseName(new Date(t));
if (phaseName !== previousPhase) {
let low = previousTime;
let high = t;
while (high - low > 1000) {
const mid = Math.floor((low + high) / 2);
const midPhase = getCurrentPhaseName(new Date(mid));
if (midPhase === currentPhase) {
low = mid;
} else {
high = mid;
}
}
const transitionAt = new Date(high);
const nextPhase = getCurrentPhaseName(new Date(high + 1000));
return {
fromPhase: currentPhase,
nextPhase,
changeAt: transitionAt
};
}
previousTime = t;
previousPhase = phaseName;
}
return null;
}
function getSignStartDate(now, sign) {
const { month: startMonth, day: startDay } = parseMonthDay(sign.start);
const { month: endMonth } = parseMonthDay(sign.end);
const wrapsYear = startMonth > endMonth;
let year = now.getFullYear();
const nowMonth = now.getMonth() + 1;
const nowDay = now.getDate();
if (wrapsYear && (nowMonth < startMonth || (nowMonth === startMonth && nowDay < startDay))) {
year -= 1;
}
return new Date(year, startMonth - 1, startDay);
}
function getNextSign(signs, currentSign) {
const sorted = [...signs].sort((a, b) => (a.order || 0) - (b.order || 0));
const index = sorted.findIndex((entry) => entry.id === currentSign.id);
if (index < 0) {
return null;
}
return sorted[(index + 1) % sorted.length] || null;
}
function getDecanByIndex(decansBySign, signId, index) {
const signDecans = decansBySign[signId] || [];
return signDecans.find((entry) => entry.index === index) || null;
}
function findNextDecanTransition(now, signs, decansBySign) {
const currentInfo = getDecanForDate(now, signs, decansBySign);
if (!currentInfo?.sign) {
return null;
}
const currentIndex = currentInfo.decan?.index || 1;
const signStart = getSignStartDate(now, currentInfo.sign);
if (currentIndex < 3) {
const changeAt = new Date(signStart.getTime() + currentIndex * 10 * DAY_IN_MS);
const nextDecan = getDecanByIndex(decansBySign, currentInfo.sign.id, currentIndex + 1);
const nextLabel = nextDecan?.tarotMinorArcana || `${currentInfo.sign.name} Decan ${currentIndex + 1}`;
return {
key: `${currentInfo.sign.id}-${currentIndex}`,
changeAt,
nextLabel
};
}
const nextSign = getNextSign(signs, currentInfo.sign);
if (!nextSign) {
return null;
}
const { month: nextMonth, day: nextDay } = parseMonthDay(nextSign.start);
let year = now.getFullYear();
let changeAt = new Date(year, nextMonth - 1, nextDay);
if (changeAt.getTime() <= now.getTime()) {
changeAt = new Date(year + 1, nextMonth - 1, nextDay);
}
const nextDecan = getDecanByIndex(decansBySign, nextSign.id, 1);
return {
key: `${currentInfo.sign.id}-${currentIndex}`,
changeAt,
nextLabel: nextDecan?.tarotMinorArcana || `${nextSign.name} Decan 1`
};
}
function setNowCardImage(imageEl, cardName, fallbackLabel, trumpNumber) {
if (!imageEl) {
return;
}
bindNowCardLightbox(imageEl);
if (!cardName || typeof resolveTarotCardImage !== "function") {
imageEl.style.display = "none";
imageEl.removeAttribute("src");
return;
}
const src = resolveTarotCardImage(cardName);
if (!src) {
imageEl.style.display = "none";
imageEl.removeAttribute("src");
return;
}
imageEl.src = src;
const displayName = getDisplayTarotName(cardName, trumpNumber);
imageEl.alt = `${fallbackLabel}: ${displayName}`;
imageEl.style.display = "block";
}
function updateNowPanel(referenceData, geo, elements, timeFormat = "minutes") {
if (!referenceData || !geo || !elements) {
return { dayKey: getDateKey(new Date()), skyRefreshKey: "" };
}
const now = new Date();
const dayKey = getDateKey(now);
const todayHours = calcPlanetaryHoursForDayAndLocation(now, geo);
const yesterday = new Date(now.getTime() - DAY_IN_MS);
const yesterdayHours = calcPlanetaryHoursForDayAndLocation(yesterday, geo);
const tomorrow = new Date(now.getTime() + DAY_IN_MS);
const tomorrowHours = calcPlanetaryHoursForDayAndLocation(tomorrow, geo);
const allHours = [...yesterdayHours, ...todayHours, ...tomorrowHours].sort(
(a, b) => a.start.getTime() - b.start.getTime()
);
const currentHour = allHours.find((entry) => now >= entry.start && now < entry.end);
const currentHourSkyKey = currentHour
? `${currentHour.planetId}-${currentHour.start.toISOString()}`
: "no-hour";
if (currentHour) {
const planet = referenceData.planets[currentHour.planetId];
elements.nowHourEl.textContent = planet
? `${planet.symbol} ${planet.name}`
: currentHour.planetId;
if (elements.nowHourTarotEl) {
const hourCardName = planet?.tarot?.majorArcana || "";
const hourTrumpNumber = planet?.tarot?.number;
elements.nowHourTarotEl.textContent = hourCardName
? getDisplayTarotName(hourCardName, hourTrumpNumber)
: "--";
}
const msLeft = Math.max(0, currentHour.end.getTime() - now.getTime());
elements.nowCountdownEl.textContent = formatCountdown(msLeft, timeFormat);
if (elements.nowHourNextEl) {
const nextHour = allHours.find(
(entry) => entry.start.getTime() >= currentHour.end.getTime() - 1000
);
if (nextHour) {
const nextPlanet = referenceData.planets[nextHour.planetId];
elements.nowHourNextEl.textContent = nextPlanet
? `> ${nextPlanet.name}`
: `> ${nextHour.planetId}`;
} else {
elements.nowHourNextEl.textContent = "> --";
}
}
setNowCardImage(
elements.nowHourCardEl,
planet?.tarot?.majorArcana,
"Current planetary hour card",
planet?.tarot?.number
);
} else {
elements.nowHourEl.textContent = "--";
elements.nowCountdownEl.textContent = "--";
if (elements.nowHourTarotEl) {
elements.nowHourTarotEl.textContent = "--";
}
if (elements.nowHourNextEl) {
elements.nowHourNextEl.textContent = "> --";
}
setNowCardImage(elements.nowHourCardEl, null, "Current planetary hour card");
}
const moonIllum = window.SunCalc.getMoonIllumination(now);
const moonPhase = getMoonPhaseName(moonIllum.phase);
const moonTarot = referenceData.planets.luna?.tarot?.majorArcana || "The High Priestess";
elements.nowMoonEl.textContent = `${moonPhase} (${Math.round(moonIllum.fraction * 100)}%)`;
elements.nowMoonTarotEl.textContent = getDisplayTarotName(moonTarot, referenceData.planets.luna?.tarot?.number);
setNowCardImage(
elements.nowMoonCardEl,
moonTarot,
"Current moon phase card",
referenceData.planets.luna?.tarot?.number
);
if (!moonCountdownCache || moonCountdownCache.fromPhase !== moonPhase || now >= moonCountdownCache.changeAt) {
moonCountdownCache = findNextMoonPhaseTransition(now);
}
if (elements.nowMoonCountdownEl) {
if (moonCountdownCache?.changeAt) {
const remaining = moonCountdownCache.changeAt.getTime() - now.getTime();
elements.nowMoonCountdownEl.textContent = formatCountdown(remaining, timeFormat);
if (elements.nowMoonNextEl) {
elements.nowMoonNextEl.textContent = `> ${moonCountdownCache.nextPhase}`;
}
} else {
elements.nowMoonCountdownEl.textContent = "--";
if (elements.nowMoonNextEl) {
elements.nowMoonNextEl.textContent = "> --";
}
}
}
const sunInfo = getDecanForDate(now, referenceData.signs, referenceData.decansBySign);
const decanSkyKey = sunInfo?.sign
? `${sunInfo.sign.id}-${sunInfo.decan?.index || 1}`
: "no-decan";
if (sunInfo?.sign) {
const signStartDate = getSignStartDate(now, sunInfo.sign);
const daysSinceSignStart = (now.getTime() - signStartDate.getTime()) / DAY_IN_MS;
const signDegree = Math.min(29.9, Math.max(0, daysSinceSignStart));
const signMajorName = getDisplayTarotName(sunInfo.sign.tarot.majorArcana, sunInfo.sign.tarot.trumpNumber);
elements.nowDecanEl.textContent = `${sunInfo.sign.symbol} ${sunInfo.sign.name} · ${signMajorName} (${signDegree.toFixed(1)}°)`;
const currentDecanKey = `${sunInfo.sign.id}-${sunInfo.decan?.index || 1}`;
if (!decanCountdownCache || decanCountdownCache.key !== currentDecanKey || now >= decanCountdownCache.changeAt) {
decanCountdownCache = findNextDecanTransition(now, referenceData.signs, referenceData.decansBySign);
}
if (sunInfo.decan) {
const decanCardName = sunInfo.decan.tarotMinorArcana;
elements.nowDecanTarotEl.textContent = getDisplayTarotName(decanCardName);
setNowCardImage(elements.nowDecanCardEl, sunInfo.decan.tarotMinorArcana, "Current decan card");
} else {
const signTarotName = sunInfo.sign.tarot?.majorArcana || "--";
elements.nowDecanTarotEl.textContent = signTarotName === "--"
? "--"
: getDisplayTarotName(signTarotName, sunInfo.sign.tarot?.trumpNumber);
setNowCardImage(
elements.nowDecanCardEl,
sunInfo.sign.tarot?.majorArcana,
"Current decan card",
sunInfo.sign.tarot?.trumpNumber
);
}
if (elements.nowDecanCountdownEl) {
if (decanCountdownCache?.changeAt) {
const remaining = decanCountdownCache.changeAt.getTime() - now.getTime();
elements.nowDecanCountdownEl.textContent = formatCountdown(remaining, timeFormat);
if (elements.nowDecanNextEl) {
elements.nowDecanNextEl.textContent = `> ${getDisplayTarotName(decanCountdownCache.nextLabel)}`;
}
} else {
elements.nowDecanCountdownEl.textContent = "--";
if (elements.nowDecanNextEl) {
elements.nowDecanNextEl.textContent = "> --";
}
}
}
} else {
elements.nowDecanEl.textContent = "--";
elements.nowDecanTarotEl.textContent = "--";
setNowCardImage(elements.nowDecanCardEl, null, "Current decan card");
if (elements.nowDecanCountdownEl) {
elements.nowDecanCountdownEl.textContent = "--";
}
if (elements.nowDecanNextEl) {
elements.nowDecanNextEl.textContent = "> --";
}
}
updateNowStats(referenceData, elements, now);
return {
dayKey,
skyRefreshKey: `${currentHourSkyKey}|${decanSkyKey}|${moonPhase}`
};
}
window.TarotNowUi = {
updateNowPanel
};
})();

898
app/ui-planets.js Normal file
View File

@@ -0,0 +1,898 @@
(function () {
const { getTarotCardDisplayName, getTarotCardSearchAliases } = window.TarotCardImages || {};
const state = {
initialized: false,
entries: [],
filteredEntries: [],
searchQuery: "",
selectedId: "",
kabbalahTargetsByPlanetId: {},
monthRefsByPlanetId: new Map(),
cubePlacementsByPlanetId: new Map()
};
function normalizePlanetToken(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z]/g, "");
}
function toPlanetId(value) {
const token = normalizePlanetToken(value);
if (!token) return null;
if (token === "sun") return "sol";
if (token === "moon") return "luna";
if (["saturn", "jupiter", "mars", "sol", "venus", "mercury", "luna"].includes(token)) {
return token;
}
return null;
}
function appendKabbalahTarget(map, planetId, target) {
if (!planetId || !target) return;
if (!map[planetId]) map[planetId] = [];
const key = `${target.kind}:${target.number}`;
if (map[planetId].some((entry) => `${entry.kind}:${entry.number}` === key)) return;
map[planetId].push(target);
}
function buildKabbalahTargetsByPlanet(magickDataset) {
const tree = magickDataset?.grouped?.kabbalah?.["kabbalah-tree"];
const map = {};
if (!tree) return map;
(tree.sephiroth || []).forEach((seph) => {
const planetId = toPlanetId(seph?.planet);
if (!planetId) return;
appendKabbalahTarget(map, planetId, {
kind: "sephirah",
number: seph.number,
label: `Sephirah ${seph.number} · ${seph.name}`
});
});
(tree.paths || []).forEach((path) => {
if (path?.astrology?.type !== "planet") return;
const planetId = toPlanetId(path?.astrology?.name);
if (!planetId) return;
appendKabbalahTarget(map, planetId, {
kind: "path",
number: path.pathNumber,
label: `Path ${path.pathNumber} · ${path?.tarot?.card || path?.hebrewLetter?.transliteration || ""}`.trim()
});
});
return map;
}
function buildMonthReferencesByPlanet(referenceData) {
const map = new Map();
const months = Array.isArray(referenceData?.calendarMonths) ? referenceData.calendarMonths : [];
const holidays = Array.isArray(referenceData?.celestialHolidays) ? referenceData.celestialHolidays : [];
const monthById = new Map(months.map((month) => [month.id, month]));
function parseMonthDayToken(value) {
const text = String(value || "").trim();
const match = text.match(/^(\d{1,2})-(\d{1,2})$/);
if (!match) {
return null;
}
const monthNo = Number(match[1]);
const dayNo = Number(match[2]);
if (!Number.isInteger(monthNo) || !Number.isInteger(dayNo) || monthNo < 1 || monthNo > 12 || dayNo < 1 || dayNo > 31) {
return null;
}
return { month: monthNo, day: dayNo };
}
function parseMonthDayTokensFromText(value) {
const text = String(value || "");
const matches = [...text.matchAll(/(\d{1,2})-(\d{1,2})/g)];
return matches
.map((match) => ({ month: Number(match[1]), day: Number(match[2]) }))
.filter((token) => Number.isInteger(token.month) && Number.isInteger(token.day) && token.month >= 1 && token.month <= 12 && token.day >= 1 && token.day <= 31);
}
function toDateToken(token, year) {
if (!token) {
return null;
}
return new Date(year, token.month - 1, token.day, 12, 0, 0, 0);
}
function splitMonthDayRangeByMonth(startToken, endToken) {
const startDate = toDateToken(startToken, 2025);
const endBase = toDateToken(endToken, 2025);
if (!startDate || !endBase) {
return [];
}
const wrapsYear = endBase.getTime() < startDate.getTime();
const endDate = wrapsYear ? toDateToken(endToken, 2026) : endBase;
if (!endDate) {
return [];
}
const segments = [];
let cursor = new Date(startDate);
while (cursor.getTime() <= endDate.getTime()) {
const monthEnd = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0, 12, 0, 0, 0);
const segmentEnd = monthEnd.getTime() < endDate.getTime() ? monthEnd : endDate;
segments.push({
monthNo: cursor.getMonth() + 1,
startDay: cursor.getDate(),
endDay: segmentEnd.getDate()
});
cursor = new Date(segmentEnd.getFullYear(), segmentEnd.getMonth(), segmentEnd.getDate() + 1, 12, 0, 0, 0);
}
return segments;
}
function tokenToString(monthNo, dayNo) {
return `${String(monthNo).padStart(2, "0")}-${String(dayNo).padStart(2, "0")}`;
}
function formatRangeLabel(monthName, startDay, endDay) {
if (!Number.isFinite(startDay) || !Number.isFinite(endDay)) {
return monthName;
}
if (startDay === endDay) {
return `${monthName} ${startDay}`;
}
return `${monthName} ${startDay}-${endDay}`;
}
function resolveRangeForMonth(month, options = {}) {
const monthOrder = Number(month?.order);
const monthStart = parseMonthDayToken(month?.start);
const monthEnd = parseMonthDayToken(month?.end);
if (!Number.isFinite(monthOrder) || !monthStart || !monthEnd) {
return {
startToken: String(month?.start || "").trim() || null,
endToken: String(month?.end || "").trim() || null,
label: month?.name || month?.id || "",
isFullMonth: true
};
}
let startToken = parseMonthDayToken(options.startToken);
let endToken = parseMonthDayToken(options.endToken);
if (!startToken || !endToken) {
const tokens = parseMonthDayTokensFromText(options.rawDateText);
if (tokens.length >= 2) {
startToken = tokens[0];
endToken = tokens[1];
} else if (tokens.length === 1) {
startToken = tokens[0];
endToken = tokens[0];
}
}
if (!startToken || !endToken) {
startToken = monthStart;
endToken = monthEnd;
}
const segments = splitMonthDayRangeByMonth(startToken, endToken);
const segment = segments.find((entry) => entry.monthNo === monthOrder) || null;
const useStart = segment ? { month: monthOrder, day: segment.startDay } : startToken;
const useEnd = segment ? { month: monthOrder, day: segment.endDay } : endToken;
const startText = tokenToString(useStart.month, useStart.day);
const endText = tokenToString(useEnd.month, useEnd.day);
const isFullMonth = startText === month.start && endText === month.end;
return {
startToken: startText,
endToken: endText,
label: isFullMonth
? (month.name || month.id)
: formatRangeLabel(month.name || month.id, useStart.day, useEnd.day),
isFullMonth
};
}
function pushRef(planetToken, month, options = {}) {
const planetId = toPlanetId(planetToken) || normalizePlanetToken(planetToken);
if (!planetId || !month?.id) {
return;
}
if (!map.has(planetId)) {
map.set(planetId, []);
}
const rows = map.get(planetId);
const range = resolveRangeForMonth(month, options);
const key = `${month.id}|${range.startToken || ""}|${range.endToken || ""}`;
if (rows.some((entry) => entry.key === key)) {
return;
}
rows.push({
id: month.id,
name: month.name || month.id,
order: Number.isFinite(Number(month.order)) ? Number(month.order) : 999,
label: range.label,
startToken: range.startToken,
endToken: range.endToken,
isFullMonth: range.isFullMonth,
key
});
}
months.forEach((month) => {
pushRef(month?.associations?.planetId, month);
const events = Array.isArray(month?.events) ? month.events : [];
events.forEach((event) => {
pushRef(event?.associations?.planetId, month, {
rawDateText: event?.dateRange || event?.date || ""
});
});
});
holidays.forEach((holiday) => {
const month = monthById.get(holiday?.monthId);
if (!month) {
return;
}
pushRef(holiday?.associations?.planetId, month, {
rawDateText: holiday?.dateRange || holiday?.date || ""
});
});
map.forEach((rows, key) => {
const preciseMonthIds = new Set(
rows
.filter((entry) => !entry.isFullMonth)
.map((entry) => entry.id)
);
const filtered = rows.filter((entry) => {
if (!entry.isFullMonth) {
return true;
}
return !preciseMonthIds.has(entry.id);
});
filtered.sort((left, right) => {
if (left.order !== right.order) {
return left.order - right.order;
}
const startLeft = parseMonthDayToken(left.startToken);
const startRight = parseMonthDayToken(right.startToken);
const dayLeft = startLeft ? startLeft.day : 999;
const dayRight = startRight ? startRight.day : 999;
if (dayLeft !== dayRight) {
return dayLeft - dayRight;
}
return String(left.label || left.name || "").localeCompare(String(right.label || right.name || ""));
});
map.set(key, filtered);
});
return map;
}
function buildCubePlacementsByPlanet(magickDataset) {
const map = new Map();
const cube = magickDataset?.grouped?.kabbalah?.cube || {};
const walls = Array.isArray(cube?.walls)
? cube.walls
: [];
const edges = Array.isArray(cube?.edges)
? cube.edges
: [];
function edgeWalls(edge) {
const explicitWalls = Array.isArray(edge?.walls)
? edge.walls.map((wallId) => String(wallId || "").trim().toLowerCase()).filter(Boolean)
: [];
if (explicitWalls.length >= 2) {
return explicitWalls.slice(0, 2);
}
return String(edge?.id || "")
.trim()
.toLowerCase()
.split("-")
.map((wallId) => wallId.trim())
.filter(Boolean)
.slice(0, 2);
}
function edgeLabel(edge) {
const explicitName = String(edge?.name || "").trim();
if (explicitName) {
return explicitName;
}
return edgeWalls(edge)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function resolveCubeDirectionLabel(wallId, edge) {
const normalizedWallId = String(wallId || "").trim().toLowerCase();
const edgeId = String(edge?.id || "").trim().toLowerCase();
if (!normalizedWallId || !edgeId) {
return "";
}
const cubeUi = window.CubeSectionUi;
if (cubeUi && typeof cubeUi.getEdgeDirectionLabelForWall === "function") {
const directionLabel = String(cubeUi.getEdgeDirectionLabelForWall(normalizedWallId, edgeId) || "").trim();
if (directionLabel) {
return directionLabel;
}
}
return edgeLabel(edge);
}
const firstEdgeByWallId = new Map();
edges.forEach((edge) => {
edgeWalls(edge).forEach((wallId) => {
if (!firstEdgeByWallId.has(wallId)) {
firstEdgeByWallId.set(wallId, edge);
}
});
});
function pushPlacement(planetId, placement) {
if (!planetId || !placement?.wallId || !placement?.edgeId) {
return;
}
if (!map.has(planetId)) {
map.set(planetId, []);
}
const rows = map.get(planetId);
const key = `${placement.wallId}:${placement.edgeId}`;
if (rows.some((row) => `${row.wallId}:${row.edgeId}` === key)) {
return;
}
rows.push(placement);
}
walls.forEach((wall) => {
const planetId = toPlanetId(wall?.associations?.planetId || wall?.planet);
if (!planetId) {
return;
}
const wallId = String(wall?.id || "").trim().toLowerCase();
const edge = firstEdgeByWallId.get(wallId) || null;
pushPlacement(planetId, {
wallId,
edgeId: String(edge?.id || "").trim().toLowerCase(),
label: `Cube: ${wall?.name || "Wall"} Wall - ${resolveCubeDirectionLabel(wallId, edge) || "Direction"}`
});
});
return map;
}
function getElements() {
return {
planetCardListEl: document.getElementById("planet-card-list"),
planetSearchInputEl: document.getElementById("planet-search-input"),
planetSearchClearEl: document.getElementById("planet-search-clear"),
planetCountEl: document.getElementById("planet-card-count"),
planetDetailNameEl: document.getElementById("planet-detail-name"),
planetDetailTypeEl: document.getElementById("planet-detail-type"),
planetDetailSummaryEl: document.getElementById("planet-detail-summary"),
planetDetailFactsEl: document.getElementById("planet-detail-facts"),
planetDetailAtmosphereEl: document.getElementById("planet-detail-atmosphere"),
planetDetailNotableEl: document.getElementById("planet-detail-notable"),
planetDetailCorrespondenceEl: document.getElementById("planet-detail-correspondence")
};
}
function normalizeSearchValue(value) {
return String(value || "").trim().toLowerCase();
}
function buildPlanetSearchText(entry) {
const correspondence = entry?.correspondence || {};
const factValues = buildFactRows(entry).map(([, value]) => String(value || ""));
const tarotAliases = correspondence?.tarot?.majorArcana && typeof getTarotCardSearchAliases === "function"
? getTarotCardSearchAliases(correspondence.tarot.majorArcana)
: [];
const rawNumbers = [
entry?.meanDistanceFromSun?.kmMillions,
entry?.meanDistanceFromSun?.au,
entry?.orbitalPeriod?.days,
entry?.orbitalPeriod?.years,
entry?.rotationPeriodHours,
entry?.radiusKm,
entry?.diameterKm,
entry?.massKg,
entry?.gravityMs2,
entry?.escapeVelocityKms,
entry?.axialTiltDeg,
entry?.averageTempC,
entry?.moons
]
.filter((value) => Number.isFinite(value))
.map((value) => String(value));
const parts = [
entry?.name,
entry?.symbol,
entry?.classification,
entry?.summary,
entry?.atmosphere,
...(Array.isArray(entry?.notableFacts) ? entry.notableFacts : []),
...factValues,
...rawNumbers,
correspondence?.name,
correspondence?.symbol,
correspondence?.weekday,
correspondence?.tarot?.majorArcana,
...tarotAliases
];
return normalizeSearchValue(parts.filter(Boolean).join(" "));
}
function applySearchFilter(elements) {
const query = normalizeSearchValue(state.searchQuery);
state.filteredEntries = query
? state.entries.filter((entry) => buildPlanetSearchText(entry).includes(query))
: [...state.entries];
if (elements?.planetSearchClearEl) {
elements.planetSearchClearEl.disabled = !query;
}
renderList(elements);
if (!state.filteredEntries.some((entry) => entry.id === state.selectedId)) {
if (state.filteredEntries.length > 0) {
selectById(state.filteredEntries[0].id, elements);
}
return;
}
updateSelection(elements);
}
function clearChildren(element) {
if (element) {
element.replaceChildren();
}
}
function toNumber(value) {
return Number.isFinite(value) ? value : null;
}
function formatNumber(value, maximumFractionDigits = 2) {
if (!Number.isFinite(value)) {
return "--";
}
return new Intl.NumberFormat(undefined, {
maximumFractionDigits,
minimumFractionDigits: 0
}).format(value);
}
function formatSignedHours(value) {
if (!Number.isFinite(value)) {
return "--";
}
const absValue = Math.abs(value);
const formatted = `${formatNumber(absValue, 2)} h`;
return value < 0 ? `${formatted} (retrograde)` : formatted;
}
function formatMass(value) {
if (!Number.isFinite(value)) {
return "--";
}
return value.toExponential(3).replace("e+", " × 10^") + " kg";
}
function buildFactRows(entry) {
return [
["Mean distance from Sun", Number.isFinite(entry?.meanDistanceFromSun?.kmMillions) && Number.isFinite(entry?.meanDistanceFromSun?.au)
? `${formatNumber(entry.meanDistanceFromSun.kmMillions, 1)} million km (${formatNumber(entry.meanDistanceFromSun.au, 3)} AU)`
: "--"],
["Orbital period", Number.isFinite(entry?.orbitalPeriod?.days) && Number.isFinite(entry?.orbitalPeriod?.years)
? `${formatNumber(entry.orbitalPeriod.days, 2)} days (${formatNumber(entry.orbitalPeriod.years, 3)} years)`
: "--"],
["Rotation period", formatSignedHours(toNumber(entry?.rotationPeriodHours))],
["Radius", Number.isFinite(entry?.radiusKm) ? `${formatNumber(entry.radiusKm, 1)} km` : "--"],
["Diameter", Number.isFinite(entry?.diameterKm) ? `${formatNumber(entry.diameterKm, 1)} km` : "--"],
["Mass", formatMass(toNumber(entry?.massKg))],
["Surface gravity", Number.isFinite(entry?.gravityMs2) ? `${formatNumber(entry.gravityMs2, 3)} m/s²` : "--"],
["Escape velocity", Number.isFinite(entry?.escapeVelocityKms) ? `${formatNumber(entry.escapeVelocityKms, 2)} km/s` : "--"],
["Axial tilt", Number.isFinite(entry?.axialTiltDeg) ? `${formatNumber(entry.axialTiltDeg, 2)}°` : "--"],
["Average temperature", Number.isFinite(entry?.averageTempC) ? `${formatNumber(entry.averageTempC, 0)} °C` : "--"],
["Moons", Number.isFinite(entry?.moons) ? formatNumber(entry.moons, 0) : "--"]
];
}
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 renderCorrespondence(entry, containerEl) {
if (!containerEl) return;
containerEl.innerHTML = "";
const correspondence = entry?.correspondence;
if (!correspondence || typeof correspondence !== "object") {
const fallback = document.createElement("span");
fallback.className = "planet-text";
fallback.textContent = "No tarot/day correspondence in current local dataset.";
containerEl.appendChild(fallback);
}
const symbol = correspondence?.symbol || "";
const weekday = correspondence?.weekday || "";
const arcana = correspondence?.tarot?.majorArcana || "";
const trumpNo = correspondence?.tarot?.number;
const arcanaLabel = getDisplayTarotName(arcana, trumpNo);
// Symbol + weekday line
if (symbol || weekday) {
const line = document.createElement("span");
line.className = "planet-text";
line.textContent = [symbol, weekday].filter(Boolean).join(" \u00b7 ");
containerEl.appendChild(line);
}
// Tarot card link
if (arcana) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "kab-tarot-link";
btn.style.marginTop = "8px";
btn.textContent = trumpNo != null ? `${arcanaLabel} \u00b7 Trump ${trumpNo}` : arcanaLabel;
btn.title = "Open in Tarot section";
btn.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent("nav:tarot-trump", {
detail: { trumpNumber: trumpNo ?? null, cardName: arcana }
}));
});
containerEl.appendChild(btn);
}
const planetId = toPlanetId(correspondence?.id || entry?.id || entry?.name) ||
normalizePlanetToken(correspondence?.id || entry?.id || entry?.name);
const kabbalahTargets = state.kabbalahTargetsByPlanetId[planetId] || [];
if (kabbalahTargets.length) {
const row = document.createElement("div");
row.className = "kab-god-links";
row.style.marginTop = "8px";
kabbalahTargets.forEach((target) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "kab-god-link";
btn.textContent = `View ${target.label}`;
btn.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent("nav:kabbalah-path", {
detail: { pathNo: Number(target.number) }
}));
});
row.appendChild(btn);
});
containerEl.appendChild(row);
}
const monthRefs = state.monthRefsByPlanetId.get(planetId) || [];
if (monthRefs.length) {
const meta = document.createElement("div");
meta.className = "kab-god-meta";
meta.textContent = "Calendar month correspondences";
containerEl.appendChild(meta);
const row = document.createElement("div");
row.className = "kab-god-links";
monthRefs.forEach((month) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "kab-god-link";
btn.textContent = `${month.label || month.name}`;
btn.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent("nav:calendar-month", {
detail: { monthId: month.id }
}));
});
row.appendChild(btn);
});
containerEl.appendChild(row);
}
const cubePlacements = state.cubePlacementsByPlanetId.get(planetId) || [];
if (cubePlacements.length) {
const meta = document.createElement("div");
meta.className = "kab-god-meta";
meta.textContent = "Cube placements";
containerEl.appendChild(meta);
const row = document.createElement("div");
row.className = "kab-god-links";
cubePlacements.forEach((placement) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "kab-god-link";
btn.textContent = `${placement.label}`;
btn.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent("nav:cube", {
detail: {
planetId,
wallId: placement.wallId,
edgeId: placement.edgeId
}
}));
});
row.appendChild(btn);
});
containerEl.appendChild(row);
}
}
function renderDetail(entry, elements) {
if (!entry || !elements) {
return;
}
if (elements.planetDetailNameEl) {
const symbol = entry.symbol ? `${entry.symbol} ` : "";
elements.planetDetailNameEl.textContent = `${symbol}${entry.name || "--"}`;
}
if (elements.planetDetailTypeEl) {
elements.planetDetailTypeEl.textContent = entry.classification || "--";
}
if (elements.planetDetailSummaryEl) {
elements.planetDetailSummaryEl.textContent = entry.summary || "--";
}
clearChildren(elements.planetDetailFactsEl);
buildFactRows(entry).forEach(([label, value]) => {
const row = document.createElement("div");
row.className = "planet-fact-row";
const labelEl = document.createElement("span");
labelEl.className = "planet-fact-label";
labelEl.textContent = label;
const valueEl = document.createElement("span");
valueEl.className = "planet-fact-value";
valueEl.textContent = value;
row.append(labelEl, valueEl);
elements.planetDetailFactsEl?.appendChild(row);
});
if (elements.planetDetailAtmosphereEl) {
elements.planetDetailAtmosphereEl.textContent = entry.atmosphere || "--";
}
clearChildren(elements.planetDetailNotableEl);
const notableFacts = Array.isArray(entry.notableFacts) ? entry.notableFacts : [];
notableFacts.forEach((fact) => {
const item = document.createElement("li");
item.textContent = fact;
elements.planetDetailNotableEl?.appendChild(item);
});
if (elements.planetDetailCorrespondenceEl) {
renderCorrespondence(entry, elements.planetDetailCorrespondenceEl);
}
}
function updateSelection(elements) {
if (!elements?.planetCardListEl) {
return;
}
const buttons = elements.planetCardListEl.querySelectorAll(".planet-list-item");
buttons.forEach((button) => {
const isSelected = button.dataset.planetId === state.selectedId;
button.classList.toggle("is-selected", isSelected);
button.setAttribute("aria-selected", isSelected ? "true" : "false");
});
}
function selectById(id, elements) {
const entry = state.entries.find((planet) => planet.id === id);
if (!entry) {
return;
}
state.selectedId = entry.id;
updateSelection(elements);
renderDetail(entry, elements);
}
function renderList(elements) {
if (!elements?.planetCardListEl) {
return;
}
clearChildren(elements.planetCardListEl);
state.filteredEntries.forEach((entry) => {
const button = document.createElement("button");
button.type = "button";
button.className = "planet-list-item";
button.dataset.planetId = entry.id;
button.setAttribute("role", "option");
const nameEl = document.createElement("span");
nameEl.className = "planet-list-name";
const symbol = entry.symbol ? `${entry.symbol} ` : "";
nameEl.textContent = `${symbol}${entry.name || "--"}`;
const metaEl = document.createElement("span");
metaEl.className = "planet-list-meta";
metaEl.textContent = entry.classification || "--";
button.append(nameEl, metaEl);
elements.planetCardListEl.appendChild(button);
});
if (elements.planetCountEl) {
elements.planetCountEl.textContent = state.searchQuery
? `${state.filteredEntries.length} of ${state.entries.length} bodies`
: `${state.entries.length} bodies`;
}
}
function ensurePlanetSection(referenceData, magickDataset = null) {
if (state.initialized) {
return;
}
const elements = getElements();
if (!elements.planetCardListEl || !elements.planetDetailNameEl) {
return;
}
const baseList = Array.isArray(referenceData?.planetScience)
? referenceData.planetScience
: [];
if (baseList.length === 0) {
if (elements.planetDetailNameEl) {
elements.planetDetailNameEl.textContent = "Planet data unavailable";
}
if (elements.planetDetailSummaryEl) {
elements.planetDetailSummaryEl.textContent = "Could not load local science facts dataset.";
}
return;
}
const correspondences = referenceData?.planets && typeof referenceData.planets === "object"
? referenceData.planets
: {};
const correspondenceByName = Object.values(correspondences).reduce((acc, value) => {
const key = String(value?.name || "").trim().toLowerCase();
if (key) {
acc[key] = value;
}
return acc;
}, {});
state.kabbalahTargetsByPlanetId = buildKabbalahTargetsByPlanet(magickDataset);
state.monthRefsByPlanetId = buildMonthReferencesByPlanet(referenceData);
state.cubePlacementsByPlanetId = buildCubePlacementsByPlanet(magickDataset);
state.entries = baseList.map((entry) => {
const byId = correspondences[entry.id] || null;
const byName = correspondenceByName[String(entry?.name || "").trim().toLowerCase()] || null;
return {
...entry,
correspondence: byId || byName || null
};
});
state.filteredEntries = [...state.entries];
renderList(elements);
if (state.entries.length > 0) {
selectById(state.entries[0].id, elements);
}
elements.planetCardListEl.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
const button = target instanceof Element
? target.closest(".planet-list-item")
: null;
if (!(button instanceof HTMLButtonElement)) {
return;
}
const selectedId = button.dataset.planetId;
if (!selectedId) {
return;
}
selectById(selectedId, elements);
});
if (elements.planetSearchInputEl) {
elements.planetSearchInputEl.addEventListener("input", () => {
state.searchQuery = elements.planetSearchInputEl.value || "";
applySearchFilter(elements);
});
}
if (elements.planetSearchClearEl && elements.planetSearchInputEl) {
elements.planetSearchClearEl.addEventListener("click", () => {
elements.planetSearchInputEl.value = "";
state.searchQuery = "";
applySearchFilter(elements);
elements.planetSearchInputEl.focus();
});
}
state.initialized = true;
}
function selectByPlanetId(planetId) {
if (!state.initialized) return;
const el = getElements();
const needle = String(planetId || "").toLowerCase();
const entry = state.entries.find(e =>
String(e.id || "").toLowerCase() === needle ||
String(e.correspondence?.id || "").toLowerCase() === needle ||
String(e.name || "").toLowerCase() === needle
);
if (!entry) return;
selectById(entry.id, el);
el.planetCardListEl
?.querySelector(`[data-planet-id="${entry.id}"]`)
?.scrollIntoView({ block: "nearest" });
}
window.PlanetSectionUi = {
ensurePlanetSection,
selectByPlanetId
};
})();

1484
app/ui-quiz.js Normal file

File diff suppressed because it is too large Load Diff

2314
app/ui-tarot.js Normal file

File diff suppressed because it is too large Load Diff

590
app/ui-zodiac.js Normal file
View File

@@ -0,0 +1,590 @@
/* ui-zodiac.js — Zodiac sign browser section */
(function () {
"use strict";
const ELEMENT_STYLE = {
fire: { emoji: "🔥", badge: "zod-badge--fire", label: "Fire" },
earth: { emoji: "🌍", badge: "zod-badge--earth", label: "Earth" },
air: { emoji: "💨", badge: "zod-badge--air", label: "Air" },
water: { emoji: "💧", badge: "zod-badge--water", label: "Water" }
};
const PLANET_SYMBOLS = {
saturn: "♄︎", jupiter: "♃︎", mars: "♂︎", sol: "☉︎",
venus: "♀︎", mercury: "☿︎", luna: "☾︎"
};
const MONTH_NAMES = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const state = {
initialized: false,
entries: [],
filteredEntries: [],
selectedId: null,
searchQuery: "",
kabPaths: [],
decansBySign: {},
monthRefsBySignId: new Map(),
cubePlacementBySignId: new Map()
};
// ── Elements ──────────────────────────────────────────────────────────
function getElements() {
return {
listEl: document.getElementById("zodiac-sign-list"),
countEl: document.getElementById("zodiac-sign-count"),
searchEl: document.getElementById("zodiac-search-input"),
searchClearEl: document.getElementById("zodiac-search-clear"),
detailNameEl: document.getElementById("zodiac-detail-name"),
detailSubEl: document.getElementById("zodiac-detail-sub"),
detailBodyEl: document.getElementById("zodiac-detail-body")
};
}
// ── Normalise ─────────────────────────────────────────────────────────
function norm(s) {
return String(s || "").toLowerCase().replace(/[^a-z0-9 ]/g, "").trim();
}
function cap(s) {
return String(s || "").charAt(0).toUpperCase() + String(s || "").slice(1);
}
function buildSearchText(sign) {
return norm([
sign.name?.en, sign.meaning?.en, sign.elementId, sign.quadruplicity,
sign.planetId, sign.id
].join(" "));
}
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);
});
// 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) {
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}`;
}
// ── List ──────────────────────────────────────────────────────────────
function applyFilter() {
const q = norm(state.searchQuery);
state.filteredEntries = q
? state.entries.filter((s) => buildSearchText(s).includes(q))
: [...state.entries];
}
function renderList(els) {
if (!els.listEl) return;
els.listEl.innerHTML = "";
state.filteredEntries.forEach((sign) => {
const active = sign.id === state.selectedId;
const el = document.createElement("div");
el.className = "planet-list-item" + (active ? " is-selected" : "");
el.setAttribute("role", "option");
el.setAttribute("aria-selected", active ? "true" : "false");
el.dataset.id = sign.id;
const elemStyle = ELEMENT_STYLE[sign.elementId] || {};
el.innerHTML = `
<div class="zod-list-row">
<span class="zod-list-symbol">${sign.symbol || "?"}</span>
<span class="planet-list-name">${sign.name?.en || sign.id}</span>
<span class="zod-list-elem ${elemStyle.badge || ""}">${elemStyle.emoji || ""}</span>
</div>
<div class="planet-list-meta">${cap(sign.elementId)} · ${cap(sign.quadruplicity)} · ${cap(sign.planetId)}</div>
`;
el.addEventListener("click", () => { selectById(sign.id, els); });
els.listEl.appendChild(el);
});
if (els.countEl) {
els.countEl.textContent = state.searchQuery
? `${state.filteredEntries.length} of ${state.entries.length} signs`
: `${state.entries.length} signs`;
}
}
// ── Detail ────────────────────────────────────────────────────────────
function renderDetail(sign, els) {
if (!els.detailNameEl) return;
const elemStyle = ELEMENT_STYLE[sign.elementId] || {};
const polarity = ["fire", "air"].includes(sign.elementId) ? "Masculine / Positive" : "Feminine / Negative";
const kabPath = state.kabPaths.find(
(p) => p.astrology?.type === "zodiac" &&
p.astrology?.name?.toLowerCase() === sign.id
);
const decans = state.decansBySign[sign.id] || [];
const monthRefs = state.monthRefsBySignId.get(String(sign.id || "").toLowerCase()) || [];
const cubePlacement = state.cubePlacementBySignId.get(String(sign.id || "").toLowerCase()) || null;
// Heading
els.detailNameEl.textContent = sign.symbol || sign.id;
els.detailSubEl.textContent = `${sign.name?.en || ""}${sign.meaning?.en || ""}`;
const sections = [];
// ── Sign Details ──────────────────────────────────────────────────
const elemBadge = `<span class="zod-badge ${elemStyle.badge || ""}">${elemStyle.emoji || ""} ${cap(sign.elementId)}</span>`;
const quadBadge = `<span class="zod-badge zod-badge--quad">${cap(sign.quadruplicity)}</span>`;
sections.push(`<div class="planet-meta-card">
<strong>Sign Details</strong>
<div class="planet-text">
<dl class="alpha-dl">
<dt>Symbol</dt><dd>${sign.symbol || "—"}</dd>
<dt>Meaning</dt><dd>${sign.meaning?.en || "—"}</dd>
<dt>Element</dt><dd>${elemBadge}</dd>
<dt>Modality</dt><dd>${quadBadge}</dd>
<dt>Polarity</dt><dd>${polarity}</dd>
<dt>Dates</dt><dd>${formatDateRange(sign.rulesFrom)}</dd>
<dt>Position</dt><dd>#${sign.no} of 12</dd>
</dl>
</div>
</div>`);
// ── Ruling Planet ─────────────────────────────────────────────────
const planetSym = PLANET_SYMBOLS[sign.planetId] || "";
sections.push(`<div class="planet-meta-card">
<strong>Ruling Planet</strong>
<div class="planet-text">
<p style="font-size:22px;margin:0 0 6px">${planetSym} ${cap(sign.planetId)}</p>
<button class="alpha-nav-btn" data-nav="planet" data-planet-id="${sign.planetId}">
View ${cap(sign.planetId)}
</button>
</div>
</div>`);
if (cubePlacement) {
sections.push(`<div class="planet-meta-card">
<strong>Cube of Space</strong>
<div class="planet-text">This sign appears in Cube edge correspondences.</div>
<div class="alpha-nav-btns">
<button class="alpha-nav-btn" data-nav="cube-sign" data-sign-id="${sign.id}" data-wall-id="${cubePlacement.wallId}" data-edge-id="${cubePlacement.edgeId}">
${cubePlacementLabel(cubePlacement)}
</button>
</div>
</div>`);
}
// ── Kabbalah Path + Trump ─────────────────────────────────────────
if (kabPath) {
const hl = kabPath.hebrewLetter || {};
sections.push(`<div class="planet-meta-card">
<strong>Kabbalah & Major Arcana</strong>
<div class="planet-text">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
<span class="zod-hebrew-glyph">${hl.char || ""}</span>
<div>
<div style="font-weight:600">${hl.transliteration || ""} (${hl.meaning || ""})</div>
<div class="planet-list-meta">${cap(hl.letterType || "")} letter · Path ${kabPath.pathNumber}</div>
</div>
</div>
<dl class="alpha-dl" style="margin-bottom:8px">
<dt>Trump Card</dt><dd>${kabPath.tarot?.card || "—"}</dd>
<dt>Intelligence</dt><dd>${kabPath.intelligence || "—"}</dd>
</dl>
<div class="alpha-nav-btns">
<button class="alpha-nav-btn" data-nav="kab-path" data-path-number="${kabPath.pathNumber}">
Kabbalah Path ${kabPath.pathNumber}
</button>
<button class="alpha-nav-btn" data-nav="trump" data-trump-number="${kabPath.tarot?.trumpNumber}">
${kabPath.tarot?.card || "Tarot Card"}
</button>
</div>
</div>
</div>`);
}
// ── Decans & Minor Arcana ─────────────────────────────────────────
if (decans.length) {
const decanRows = decans.map((d) => {
const ord = ["1st","2nd","3rd"][d.index - 1] || d.index;
const sym = PLANET_SYMBOLS[d.rulerPlanetId] || "";
return `<div class="zod-decan-row">
<span class="zod-decan-ord">${ord}</span>
<span class="zod-decan-planet">${sym} ${cap(d.rulerPlanetId)}</span>
<button class="zod-decan-card-btn" data-nav="tarot-card" data-card-name="${d.tarotMinorArcana}">
${d.tarotMinorArcana}
</button>
</div>`;
}).join("");
sections.push(`<div class="planet-meta-card">
<strong>Decans & Minor Arcana</strong>
<div class="planet-text">${decanRows}</div>
</div>`);
}
if (monthRefs.length) {
const monthButtons = monthRefs.map((month) =>
`<button class="alpha-nav-btn" data-nav="calendar-month" data-month-id="${month.id}">${month.name} ↗</button>`
).join("");
sections.push(`<div class="planet-meta-card">
<strong>Calendar Months</strong>
<div class="planet-text">Month correspondences linked to ${sign.name?.en || sign.id}.</div>
<div class="alpha-nav-btns">${monthButtons}</div>
</div>`);
}
// ── Kabbalah extras ───────────────────────────────────────────────
if (sign.tribeOfIsraelId || sign.tetragrammatonPermutation) {
sections.push(`<div class="planet-meta-card">
<strong>Kabbalah Correspondences</strong>
<div class="planet-text">
<dl class="alpha-dl">
${sign.tribeOfIsraelId ? `<dt>Tribe of Israel</dt><dd>${cap(sign.tribeOfIsraelId)}</dd>` : ""}
${sign.tetragrammatonPermutation ? `<dt>Tetragrammaton</dt><dd class="zod-tetra">${sign.tetragrammatonPermutation}</dd>` : ""}
</dl>
</div>
</div>`);
}
els.detailBodyEl.innerHTML = `<div class="planet-meta-grid">${sections.join("")}</div>`;
// Attach button listeners
els.detailBodyEl.querySelectorAll("[data-nav]").forEach((btn) => {
btn.addEventListener("click", () => {
const nav = btn.dataset.nav;
if (nav === "planet") {
document.dispatchEvent(new CustomEvent("nav:planet", {
detail: { planetId: btn.dataset.planetId }
}));
} else if (nav === "kab-path") {
document.dispatchEvent(new CustomEvent("tarot:view-kab-path", {
detail: { pathNumber: Number(btn.dataset.pathNumber) }
}));
} else if (nav === "trump") {
document.dispatchEvent(new CustomEvent("kab:view-trump", {
detail: { trumpNumber: Number(btn.dataset.trumpNumber) }
}));
} else if (nav === "tarot-card") {
document.dispatchEvent(new CustomEvent("nav:tarot-trump", {
detail: { cardName: btn.dataset.cardName }
}));
} else if (nav === "calendar-month") {
document.dispatchEvent(new CustomEvent("nav:calendar-month", {
detail: { monthId: btn.dataset.monthId }
}));
} else if (nav === "cube-sign") {
document.dispatchEvent(new CustomEvent("nav:cube", {
detail: {
signId: btn.dataset.signId,
wallId: btn.dataset.wallId,
edgeId: btn.dataset.edgeId
}
}));
}
});
});
}
function resetDetail(els) {
if (els.detailNameEl) els.detailNameEl.textContent = "--";
if (els.detailSubEl) els.detailSubEl.textContent = "Select a sign to explore";
if (els.detailBodyEl) els.detailBodyEl.innerHTML = "";
}
// ── Selection ─────────────────────────────────────────────────────────
function selectById(id, els) {
const sign = state.entries.find((s) => s.id === id);
if (!sign) return;
state.selectedId = id;
renderList(els);
renderDetail(sign, els);
}
// ── Public select (for incoming navigation) ───────────────────────────
function selectBySignId(signId) {
const els = getElements();
if (!state.initialized) return;
const sign = state.entries.find((s) => s.id === signId);
if (sign) selectById(signId, els);
}
// ── Init ───────────────────────────────────────────────────────────────
function ensureZodiacSection(referenceData, magickDataset) {
state.monthRefsBySignId = buildMonthReferencesBySign(referenceData);
state.cubePlacementBySignId = buildCubeSignPlacements(magickDataset);
if (state.initialized) {
const els = getElements();
const current = state.entries.find((entry) => entry.id === state.selectedId);
if (current) {
renderDetail(current, els);
}
return;
}
state.initialized = true;
const zodiacObj = magickDataset?.grouped?.astrology?.zodiac || {};
state.entries = Object.values(zodiacObj).sort((a, b) => (a.no || 0) - (b.no || 0));
const kabTree = magickDataset?.grouped?.kabbalah?.["kabbalah-tree"];
state.kabPaths = Array.isArray(kabTree?.paths) ? kabTree.paths : [];
state.decansBySign = referenceData?.decansBySign || {};
const els = getElements();
applyFilter();
renderList(els);
if (state.entries.length > 0) {
selectById(state.entries[0].id, els);
}
// Search
if (els.searchEl) {
els.searchEl.addEventListener("input", () => {
state.searchQuery = els.searchEl.value;
if (els.searchClearEl) els.searchClearEl.disabled = !state.searchQuery;
applyFilter();
renderList(els);
if (!state.filteredEntries.some((s) => s.id === state.selectedId)) {
if (state.filteredEntries.length > 0) {
selectById(state.filteredEntries[0].id, els);
} else {
state.selectedId = null;
resetDetail(els);
}
}
});
}
if (els.searchClearEl) {
els.searchClearEl.addEventListener("click", () => {
state.searchQuery = "";
if (els.searchEl) els.searchEl.value = "";
els.searchClearEl.disabled = true;
applyFilter();
renderList(els);
if (state.entries.length > 0) selectById(state.entries[0].id, els);
});
}
}
window.ZodiacSectionUi = {
ensureZodiacSection,
selectBySignId
};
})();