const version = new URL(import.meta.url).searchParams.get("v") || "dev";
const { makePlot, rankedMethods } = await import(`./explorer-plot.mjs?v=${version}`);
const $ = (selector) => document.querySelector(selector);
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]);
const display = (value) => value == null ? "—" : typeof value === "number" ? value.toLocaleString("en-US", { maximumFractionDigits: 4 }) : escapeHtml(String(value).replaceAll("_", " "));
const state = { catalog: null, scope: [], sample: null, selected: new Set(), loading: null, request: null };
let plotly;
function loadPlotly() {
plotly ||= new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "/vendor/plotly.min.js";
script.onload = () => resolve(window.Plotly);
script.onerror = () => { script.remove(); plotly = null; reject(new Error("Unable to load the chart. Please try again.")); };
document.head.append(script);
});
return plotly;
}
function options(id, values, preferred) {
const select = $(`#${id}`);
select.innerHTML = values.map((value) => `${escapeHtml(value)} `).join("");
if (values.map(String).includes(String(preferred))) select.value = preferred;
}
function updateSelection(target = null) {
const fields = ["dataset", "term", "item", "window"];
const current = Object.fromEntries(fields.map((field) => [field, $(`#sample-${field}`).value]));
const effect = $("#sample-effect").value;
let candidates = state.catalog.samples.filter((entry) => !effect || entry.effects.includes(effect));
options("sample-dataset", [...new Set(candidates.map((entry) => entry.dataset))].sort(), target?.dataset ?? current.dataset);
candidates = candidates.filter((entry) => entry.dataset === $("#sample-dataset").value);
options("sample-term", ["short", "medium", "long"].filter((term) => candidates.some((entry) => entry.term === term)), target?.term ?? current.term);
candidates = candidates.filter((entry) => entry.term === $("#sample-term").value);
state.scope = candidates.sort((a, b) => a.item_id.localeCompare(b.item_id, undefined, { numeric: true }) || a.window_id - b.window_id);
options("sample-item", [...new Set(candidates.map((entry) => entry.item_id))], target?.item_id ?? current.item);
candidates = candidates.filter((entry) => entry.item_id === $("#sample-item").value);
options("sample-window", candidates.map((entry) => entry.window_id), target?.window_id ?? current.window);
const selected = candidates.find((entry) => String(entry.window_id) === $("#sample-window").value);
if (selected) loadSample(selected);
else {
state.request?.abort();
state.sample = null;
$("#sample-metadata").textContent = "";
$("#sample-content").hidden = true;
$("#sample-count").textContent = "No examples match this selection.";
$("#sample-previous").disabled = $("#sample-next").disabled = true;
}
}
function renderEffects(sample) {
const macro = sample.macro;
$("#sample-macro").innerHTML = `
Period ${display(macro.seasonality?.period)}Waveform ${display(macro.seasonality?.waveform)}Level Range [${(macro.level_range || []).map(display).join(", ")}]
Segment Direction Shape Duration ${(macro.trend || []).map((segment, index) => `${index + 1} ${display(segment.direction)} ${display(segment.shape)} ${(segment.duration * 100).toFixed(1)}% `).join("")}
`;
const columns = ["family", "subtype", "direction", "amplitude_trend", "envelope_change", "start", "end", "intensity"];
$("#sample-micro").innerHTML = sample.micro.length ? sample.micro.map((event, index) => `${index + 1} ${columns.map((field) => `${display(event[field])} `).join("")} `).join("") : 'No Micro effects. ';
$("#sample-score-body").innerHTML = rankedMethods(sample.forecasts).map((method) => `${escapeHtml(method.name)} ${escapeHtml(method.category)} ${escapeHtml(method.input)} ${method.mase.toFixed(4)} `).join("");
$(".sample-score-shell").scrollTop = 0;
}
async function renderPlot() {
const sample = state.sample;
if (!sample) return;
const Plotly = await loadPlotly();
if (sample !== state.sample) return;
const figure = makePlot(sample, state.selected, $("#history-context").value);
await Plotly.react("sample-plot", figure.data, figure.layout, figure.config);
}
function showError(error) {
$("#explorer-error").textContent = error.message;
$("#explorer-warning").classList.remove("hidden");
}
async function loadSample(entry) {
state.request?.abort();
const controller = new AbortController();
state.request = controller;
$("#explorer-warning").classList.add("hidden");
const content = $("#sample-content");
// Keep the previous sample in the layout so loading cannot collapse the page.
content.setAttribute("aria-busy", "true");
const index = state.scope.findIndex((sample) => sample.id === entry.id);
$("#sample-count").textContent = `${index + 1} / ${state.scope.length.toLocaleString()} examples`;
$("#sample-previous").disabled = index === 0;
$("#sample-next").disabled = index === state.scope.length - 1;
if (content.hidden) $("#sample-metadata").textContent = "Loading example…";
try {
const response = await fetch(`/api/explorer/sample/${entry.id}`, { signal: controller.signal });
if (!response.ok) throw new Error("This example is currently unavailable. Please try again.");
const sample = await response.json();
if (controller.signal.aborted) return;
if (state.sample?.id !== sample.id) {
const best = rankedMethods(sample.forecasts)[0];
state.selected = new Set(best ? [best.id] : []);
}
state.sample = sample;
$("#sample-content").hidden = false;
$("#sample-metadata").textContent = `${sample.dataset} · ${sample.term} · item ${sample.item_id} · window ${sample.window_id} · frequency ${sample.frequency} · ${sample.n_history.toLocaleString()} history points · ${sample.n_horizon.toLocaleString()} future points`;
renderEffects(sample);
await renderPlot();
} catch (error) {
if (error.name !== "AbortError" && !controller.signal.aborted) {
if (content.hidden) $("#sample-metadata").textContent = "Example unavailable";
showError(error);
}
} finally {
// An aborted request must not clear the next request's loading state.
if (state.request === controller) content.removeAttribute("aria-busy");
}
}
async function initialize() {
$("#explorer-warning").classList.add("hidden");
$("#sample-count").textContent = "Loading examples…";
try {
const response = await fetch("/api/explorer/catalog");
if (!response.ok) throw new Error("Examples are currently unavailable. Please try again later.");
state.catalog = await response.json();
const effects = [...new Set(state.catalog.samples.flatMap((entry) => entry.effects))].sort();
$("#sample-effect").innerHTML = 'All effects ' + effects.map((effect) => `${escapeHtml(effect.replaceAll("_", " ").replaceAll("/", " / "))} `).join("");
$("#explorer-filters").disabled = false;
updateSelection();
} catch (error) {
state.loading = null;
$("#sample-count").textContent = "Examples unavailable";
showError(error);
}
}
export async function activateExplorer() {
if (!state.catalog) {
state.loading ||= initialize();
await state.loading;
} else if (state.sample && window.Plotly) {
window.Plotly.Plots.resize($("#sample-plot"));
}
}
for (const field of ["dataset", "term", "item", "window", "effect"]) $(`#sample-${field}`).addEventListener("change", () => updateSelection());
for (const direction of ["previous", "next"]) $(`#sample-${direction}`).addEventListener("click", () => {
const index = state.scope.findIndex((entry) => entry.dataset === $("#sample-dataset").value && entry.term === $("#sample-term").value && entry.item_id === $("#sample-item").value && String(entry.window_id) === $("#sample-window").value);
const entry = state.scope[index + (direction === "previous" ? -1 : 1)];
if (entry) updateSelection(entry);
});
$("#sample-score-body").addEventListener("change", (event) => {
const id = event.target.dataset.method;
if (!id) return;
if (event.target.checked) state.selected.add(id); else state.selected.delete(id);
renderPlot().catch(showError);
});
$("#history-context").addEventListener("change", () => renderPlot().catch(showError));
$("#explorer-retry").addEventListener("click", () => { if (state.catalog) updateSelection(); else activateExplorer(); });