Initial commit
This commit is contained in:
163
app/astro-calcs.js
Normal file
163
app/astro-calcs.js
Normal 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
96
app/calendar-events.js
Normal 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
654
app/card-images.js
Normal 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
372
app/data-service.js
Normal 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
511
app/quiz-calendars.js
Normal 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
3560
app/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
1341
app/tarot-database.js
Normal file
1341
app/tarot-database.js
Normal file
File diff suppressed because it is too large
Load Diff
2081
app/ui-alphabet.js
Normal file
2081
app/ui-alphabet.js
Normal file
File diff suppressed because it is too large
Load Diff
2528
app/ui-calendar.js
Normal file
2528
app/ui-calendar.js
Normal file
File diff suppressed because it is too large
Load Diff
1901
app/ui-cube.js
Normal file
1901
app/ui-cube.js
Normal file
File diff suppressed because it is too large
Load Diff
350
app/ui-cycles.js
Normal file
350
app/ui-cycles.js
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replaceAll("`", "`");
|
||||
}
|
||||
|
||||
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
454
app/ui-elements.js
Normal 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: "2–4", modality: "Cardinal", numbers: [2, 3, 4] },
|
||||
{ label: "5–7", modality: "Fixed", numbers: [5, 6, 7] },
|
||||
{ label: "8–10", 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
459
app/ui-enochian.js
Normal 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
618
app/ui-gods.js
Normal 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 & 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 & 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
1107
app/ui-holidays.js
Normal file
File diff suppressed because it is too large
Load Diff
882
app/ui-iching.js
Normal file
882
app/ui-iching.js
Normal 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
1153
app/ui-kabbalah.js
Normal file
File diff suppressed because it is too large
Load Diff
185
app/ui-natal.js
Normal file
185
app/ui-natal.js
Normal 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
686
app/ui-now.js
Normal 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
898
app/ui-planets.js
Normal 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
1484
app/ui-quiz.js
Normal file
File diff suppressed because it is too large
Load Diff
2314
app/ui-tarot.js
Normal file
2314
app/ui-tarot.js
Normal file
File diff suppressed because it is too large
Load Diff
590
app/ui-zodiac.js
Normal file
590
app/ui-zodiac.js
Normal 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
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user