Files
TaroTime/app/ui-gods.js
2026-03-07 01:09:00 -08:00

619 lines
23 KiB
JavaScript
Raw Blame History

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