Files
TaroTime/app/ui-cycles.js

351 lines
9.6 KiB
JavaScript
Raw Permalink Normal View History

2026-03-07 01:09:00 -08:00
// 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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function escapeAttr(value) {
return escapeHtml(value).replaceAll("`", "&#96;");
}
function cssEscape(value) {
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
return CSS.escape(value);
}
return String(value || "").replace(/[^a-zA-Z0-9_\-]/g, "\\$&");
}
function setSelectedCycle(nextId) {
const normalized = String(nextId || "").trim();
if (normalized && state.entries.some((entry) => entry.id === normalized)) {
state.selectedId = normalized;
return;
}
state.selectedId = state.filteredEntries[0]?.id || state.entries[0]?.id || "";
}
function selectedEntry() {
return state.filteredEntries.find((entry) => entry.id === state.selectedId)
|| state.entries.find((entry) => entry.id === state.selectedId)
|| state.filteredEntries[0]
|| state.entries[0]
|| null;
}
function findEntryByReference(reference) {
const token = normalizeLookupToken(reference);
if (!token) {
return null;
}
return state.entries.find((entry) => {
const idToken = normalizeLookupToken(entry.id);
const nameToken = normalizeLookupToken(entry.name);
return token === idToken || token === nameToken;
}) || null;
}
function applyFilter() {
const query = state.searchQuery;
if (!query) {
state.filteredEntries = state.entries.slice();
} else {
state.filteredEntries = state.entries.filter((entry) => entry.searchText.includes(query));
}
if (!state.filteredEntries.some((entry) => entry.id === state.selectedId)) {
state.selectedId = state.filteredEntries[0]?.id || "";
}
}
function syncControls(elements) {
const { searchInputEl, searchClearEl } = elements;
if (searchInputEl) {
searchInputEl.value = state.searchQuery;
}
if (searchClearEl) {
searchClearEl.disabled = !state.searchQuery;
}
}
function renderList(elements) {
const { listEl, countEl } = elements;
if (!(listEl instanceof HTMLElement)) {
return;
}
if (countEl) {
countEl.textContent = state.searchQuery
? `${state.filteredEntries.length} of ${state.entries.length}`
: `${state.entries.length}`;
}
if (!state.filteredEntries.length) {
listEl.innerHTML = '<div class="empty">No cycles match your search.</div>';
return;
}
listEl.innerHTML = "";
state.filteredEntries.forEach((entry) => {
const itemEl = document.createElement("div");
const isSelected = entry.id === state.selectedId;
itemEl.className = `planet-list-item${isSelected ? " is-selected" : ""}`;
itemEl.setAttribute("role", "option");
itemEl.setAttribute("aria-selected", isSelected ? "true" : "false");
itemEl.dataset.cycleId = entry.id;
const periodMeta = entry.period ? ` · ${escapeHtml(entry.period)}` : "";
itemEl.innerHTML = [
`<div class="planet-list-name">${escapeHtml(entry.name)}</div>`,
`<div class="planet-list-meta">${escapeHtml(entry.category)}${periodMeta}</div>`
].join("\n");
itemEl.addEventListener("click", () => {
setSelectedCycle(entry.id);
renderAll();
});
listEl.appendChild(itemEl);
});
}
function renderDetail(elements) {
const {
detailNameEl,
detailTypeEl,
detailSummaryEl,
detailBodyEl
} = elements;
const entry = selectedEntry();
if (!entry) {
if (detailNameEl) detailNameEl.textContent = "No cycle selected";
if (detailTypeEl) detailTypeEl.textContent = "";
if (detailSummaryEl) detailSummaryEl.textContent = "Select a cycle from the list.";
if (detailBodyEl) detailBodyEl.innerHTML = "";
return;
}
if (detailNameEl) detailNameEl.textContent = entry.name;
if (detailTypeEl) detailTypeEl.textContent = entry.category;
if (detailSummaryEl) detailSummaryEl.textContent = entry.description || "No description available.";
const body = [];
if (entry.period) {
body.push(`<p><strong>Period:</strong> ${escapeHtml(entry.period)}</p>`);
}
if (Number.isFinite(entry.periodDays)) {
body.push(`<p><strong>Approx days:</strong> ${escapeHtml(formatDays(entry.periodDays))}</p>`);
}
if (entry.significance) {
body.push(`<p><strong>Significance:</strong> ${escapeHtml(entry.significance)}</p>`);
}
if (entry.related.length) {
const relatedButtons = entry.related
.map((label) => {
const relatedEntry = findEntryByReference(label);
if (!relatedEntry) {
return `<span class="planet-list-meta">${escapeHtml(label)}</span>`;
}
return `<button class="alpha-nav-btn" type="button" data-related-cycle-id="${escapeAttr(relatedEntry.id)}">${escapeHtml(relatedEntry.name)} ↗</button>`;
})
.join("");
body.push([
"<div>",
" <strong>Related:</strong>",
` <div class="alpha-nav-btns">${relatedButtons}</div>`,
"</div>"
].join("\n"));
}
if (detailBodyEl) {
detailBodyEl.innerHTML = body.join("\n");
}
}
function renderAll() {
const elements = getElements();
syncControls(elements);
renderList(elements);
renderDetail(elements);
}
function handleSearchInput() {
const { searchInputEl } = getElements();
state.searchQuery = normalizeSearchValue(searchInputEl?.value);
applyFilter();
renderAll();
}
function handleSearchClear() {
const { searchInputEl } = getElements();
if (searchInputEl) {
searchInputEl.value = "";
searchInputEl.focus();
}
state.searchQuery = "";
applyFilter();
renderAll();
}
function handleRelatedClick(event) {
const target = event.target instanceof Element
? event.target.closest("[data-related-cycle-id]")
: null;
if (!(target instanceof HTMLElement)) {
return;
}
const nextId = target.getAttribute("data-related-cycle-id");
setSelectedCycle(nextId);
renderAll();
}
function bindEvents() {
const { searchInputEl, searchClearEl, detailBodyEl } = getElements();
if (searchInputEl) {
searchInputEl.addEventListener("input", handleSearchInput);
}
if (searchClearEl) {
searchClearEl.addEventListener("click", handleSearchClear);
}
if (detailBodyEl) {
detailBodyEl.addEventListener("click", handleRelatedClick);
}
}
function ensureCyclesSection(referenceData) {
state.referenceData = referenceData || {};
state.entries = buildEntries(state.referenceData);
applyFilter();
if (!state.initialized) {
bindEvents();
state.initialized = true;
}
setSelectedCycle(state.selectedId);
renderAll();
}
function selectCycleById(cycleId) {
setSelectedCycle(cycleId);
renderAll();
}
window.CyclesSectionUi = {
ensureCyclesSection,
selectCycleById
};
})();