import { API } from "/static/api.js";
import { initSidebar, getSelection, setActiveTab as setSidebarTab,
setManifest, getTreemapAxis, setTreemapAxis } from "/static/selection.js";
import { installShortcuts } from "/static/shortcuts.js";
const esc = (s) => String(s ?? "").replace(/[&<>"']/g,
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
let MANIFEST = null;
const SLUG = { treemap: "treemap", timeseries: "series", scatter: "scatter", map: "geo" };
const FROM_SLUG = { treemap: "treemap", series: "timeseries", scatter: "scatter", geo: "map" };
const VIEW_ID = { treemap: "treemap", series: "timeseries", scatter: "scatter", geo: "map" };
let VIEWS = []; let ENTITY_NOUN = "entity";
const BASE = (typeof window !== "undefined" && window.__BASE__) || "";
function routeFromURL() {
let path = location.pathname;
if (BASE && path.startsWith(BASE)) path = path.slice(BASE.length);
const m = path.match(/^\/([^/]+)\/(.+)$/);
if (m && m[1] === ENTITY_NOUN) return { view: "entity", id: decodeURIComponent(m[2]) };
const p = path.replace(/^\//, "");
return { view: FROM_SLUG[p] || VIEWS[0] || "treemap" };
}
function urlFor(route) {
if (route.view === "entity")
return `${BASE}/${ENTITY_NOUN}/${encodeURIComponent(route.id).replace(/%2F/g, "/")}`;
return BASE + "/" + (SLUG[route.view] || "treemap");
}
function setActiveView(route, { push = true } = {}) {
const isDetail = route.view === "entity";
for (const s of [...VIEWS, "entity"]) {
document.querySelector(`#tab-${s}`)
?.classList.toggle("active", isDetail ? s === "entity" : s === route.view);
}
for (const t of VIEWS) {
document.querySelector(`nav#tabs button[data-tab="${t}"]`)
?.classList.toggle("active", !isDetail && t === route.view);
}
document.body.classList.toggle("entity-mode", isDetail);
setSidebarTab(isDetail ? "entity" : route.view);
document.dispatchEvent(new CustomEvent("view-changed",
{ detail: isDetail ? "entity" : route.view }));
if (isDetail) import("/static/viz/entity.js").then(m => m.renderEntity(route.id));
if (push) {
const url = urlFor(route);
if (location.pathname !== url) history.pushState({ ...route }, "", url);
}
}
const TAB_ICON = { treemap: "▦", timeseries: "∿", scatter: "◇", map: "◉" };
const TAB_LABEL = { treemap: "Tree", timeseries: "Line", scatter: "Scatter", map: "Map" };
function buildShell(man) {
document.title = man.title || "taxa";
const title = document.querySelector("#page-title a") || document.querySelector("#page-title");
if (title) title.textContent = man.title || "taxa";
if (man.theme && Object.keys(man.theme).length) {
const css = ":root{" + Object.entries(man.theme).map(([k, v]) => `${k}:${v}`).join(";") + "}";
const el = document.createElement("style"); el.textContent = css;
document.head.appendChild(el);
}
const order = ["treemap", "series", "scatter", "geo"];
VIEWS = order.filter(k => man.views[k]).map(k => VIEW_ID[k]);
const nav = document.querySelector("nav#tabs");
nav.innerHTML = VIEWS.map(v =>
`<button data-tab="${v}"><span class="ico">${TAB_ICON[v] || "•"}</span> ${TAB_LABEL[v] || v}</button>`
).join("");
const content = document.querySelector("#content");
content.innerHTML = VIEWS.map(v => `<section id="tab-${v}" class="tab"></section>`).join("")
+ `<section id="tab-entity" class="tab"></section>`;
ENTITY_NOUN = man.entity_noun || "entity";
const search = document.querySelector("#entity-search");
if (search) search.placeholder = "Search…";
const wrap = document.querySelector("#entity-search-wrap");
if (wrap && !man.views.detail) wrap.style.display = "none";
}
function initSearch() {
const input = document.querySelector("#entity-search");
const list = document.querySelector("#entity-search-results");
if (!input || !list) return;
let timer = null, items = [], active = -1;
const close = () => { list.hidden = true; list.innerHTML = ""; items = []; active = -1; };
const go = async (r) => {
if (!r) return;
close(); input.value = ""; input.blur();
if (r.is_leaf && r.id != null) { setActiveView({ view: "entity", id: r.id }); return; }
setActiveView({ view: "treemap" });
if (r.axis) setTreemapAxis(r.axis);
const { focusTreemap } = await import("/static/viz/treemap.js");
focusTreemap(r.path || []);
};
const render = () => {
if (!items.length) { close(); return; }
list.innerHTML = items.map((r, i) =>
`<li data-idx="${i}" class="${i === active ? "active" : ""}">
${r.level_label ? `<span class="sr-sym">${esc(r.level_label)}</span>` : ""}
<span class="sr-name">${esc(r.name ?? r.id ?? "")}</span></li>`).join("");
list.hidden = false;
for (const li of list.querySelectorAll("li"))
li.addEventListener("mousedown", e => { e.preventDefault(); go(items[+li.dataset.idx]); });
};
input.addEventListener("input", () => {
const q = input.value.trim(); clearTimeout(timer);
if (!q) { close(); return; }
timer = setTimeout(async () => {
try { items = await API.search(q, getTreemapAxis()); active = -1; render(); } catch { close(); }
}, 150);
});
input.addEventListener("keydown", e => {
if (list.hidden) return;
if (e.key === "ArrowDown") { active = Math.min(active + 1, items.length - 1); render(); e.preventDefault(); }
else if (e.key === "ArrowUp") { active = Math.max(active - 1, 0); render(); e.preventDefault(); }
else if (e.key === "Enter") { const p = active >= 0 ? items[active] : items[0]; if (p) { go(p); e.preventDefault(); } }
else if (e.key === "Escape") close();
});
document.addEventListener("click", e => { if (!e.target.closest("#entity-search-wrap")) close(); });
}
async function initDatasets() {
let datasets = [];
try { datasets = await API.datasets(); } catch { datasets = []; }
const wanted = new URLSearchParams(location.search).get("ds");
const current = (datasets.find(d => d.id === wanted) || datasets[0] || {}).id;
if (current) API.setDataset(current);
const right = document.querySelector(".header-right");
if (right && datasets.length > 1) {
const sel = document.createElement("select");
sel.id = "dataset-select";
sel.setAttribute("aria-label", "dataset");
for (const d of datasets) {
const o = document.createElement("option");
o.value = d.id; o.textContent = d.label;
if (d.id === current) o.selected = true;
sel.appendChild(o);
}
sel.addEventListener("change", () => {
const p = new URLSearchParams(location.search);
p.set("ds", sel.value);
location.search = p.toString();
});
right.insertBefore(sel, right.querySelector("#toggle-sidebar"));
}
}
async function init() {
await initDatasets();
try {
MANIFEST = await API.manifest();
} catch (e) {
document.body.innerHTML = `<p style="padding:2rem;font-family:monospace">manifest error: ${e.message}</p>`;
return;
}
setManifest(MANIFEST);
buildShell(MANIFEST);
initSearch();
for (const btn of document.querySelectorAll("nav#tabs button"))
btn.addEventListener("click", () => setActiveView({ view: btn.dataset.tab }));
window.addEventListener("popstate", () => setActiveView(routeFromURL(), { push: false }));
const hamb = document.querySelector("#toggle-sidebar");
if (hamb) hamb.addEventListener("click", () => document.body.classList.toggle("sidebar-hidden"));
await initSidebar();
if (MANIFEST.views.treemap) (await import("/static/viz/treemap.js")).setupTreemap();
if (MANIFEST.views.series) (await import("/static/viz/timeseries.js")).setupTimeseries();
if (MANIFEST.views.scatter) (await import("/static/viz/scatter.js")).setupScatter();
if (MANIFEST.views.geo) (await import("/static/viz/geomap.js")).setupGeomap();
if (MANIFEST.views.detail) (await import("/static/viz/entity.js")).setupEntity();
if (MANIFEST.views.treemap) installShortcuts();
setActiveView(routeFromURL(), { push: false });
}
document.addEventListener("DOMContentLoaded", init);