351 lines
9.6 KiB
JavaScript
351 lines
9.6 KiB
JavaScript
// 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
|
|
};
|
|
})();
|