619 lines
23 KiB
JavaScript
619 lines
23 KiB
JavaScript
|
|
/* 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 };
|
|||
|
|
})();
|
|||
|
|
|