taxa-server 0.1.0

axum web server for taxa: reproduces the HTTP contract + serves the embedded D3 frontend.
// taxa generic frontend entry point. Boots from /api/manifest: builds the
// header title, tab nav, theme, and content sections from the dataset config,
// then wires only the views the spec declares. Routing / search / view-switching
// are dataset-agnostic.

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";

// Escape dataset-derived strings before they go into innerHTML — a loaded
// dataset is untrusted (ids/labels/values can contain HTML).
const esc = (s) => String(s ?? "").replace(/[&<>"']/g,
  (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));


let MANIFEST = null;
// internal view id -> URL slug. The group-series view keeps the internal id
// "timeseries" (matching the module + DOM ids) but routes as /series.
const SLUG = { treemap: "treemap", timeseries: "series", scatter: "scatter", map: "geo" };
const FROM_SLUG = { treemap: "treemap", series: "timeseries", scatter: "scatter", geo: "map" };
// manifest view keys -> internal view ids
const VIEW_ID = { treemap: "treemap", series: "timeseries", scatter: "scatter", geo: "map" };

let VIEWS = [];          // ordered internal view ids that exist
let ENTITY_NOUN = "entity";

// App base path (server-injected): "" at root, "/<app>" behind the internal LB.
// location.pathname includes it, so strip it when parsing and prepend it when building.
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) {
  // Keep slashes in the id as real path separators (the route is a {path}).
  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);
  }
}

// ── header / tabs / theme from the manifest ──────────────────────────
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";
  // Theme: inject CSS-var overrides.
  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);
  }
  // Tabs + content sections for each configured view (in canonical order).
  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>`;
  // Search spans every tree node (not just entities), so keep the placeholder generic.
  ENTITY_NOUN = man.entity_noun || "entity";
  const search = document.querySelector("#entity-search");
  if (search) search.placeholder = "Search…";
  // Hide the search box entirely if there's no detail view.
  const wrap = document.querySelector("#entity-search-wrap");
  if (wrap && !man.views.detail) wrap.style.display = "none";
}

// ── search box (unchanged logic; only the noun is dynamic) ───────────
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; };
  // Navigate to a result: a leaf (entity) opens its detail; any other tree node
  // switches to the treemap, selects that node's axis, and focuses its path.
  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; }
    // Each row: "<Level> <node name>" (e.g. "Company Stripe").
    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(); });
}

// Dataset switcher: pick the active dataset (?ds= or the first) and point the API
// at it before anything else loads; render a dropdown when there's more than one.
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);
      // full reload: a different dataset has its own manifest/axes/state
      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();
  // Lazily set up each configured view module.
  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();

  // Keyboard shortcuts (monocle defaults; the treemap bindings only act on the
  // treemap tab). Harmless to install even without a treemap view — every
  // binding is gated on its `when` tab. See shortcuts.js to modify/disable.
  if (MANIFEST.views.treemap) installShortcuts();

  setActiveView(routeFromURL(), { push: false });
}

document.addEventListener("DOMContentLoaded", init);