"use strict";
// The explorer page: every forecast is read from data/, computed ahead of time with the
// simons package and the released weights.
const $ = (id) => document.getElementById(id);
const dark = matchMedia("(prefers-color-scheme: dark)");
const GRID = "rgba(128, 148, 148, 0.18)";
const FORECAST = "#09A1A9";
const PLOT_CONFIG = { displayModeBar: false, responsive: true };
const CONTROLS = ["size", "cutoff", "horizon"]; // the fieldsets of chips
const loaded = {}; // example name -> its data file
let index; // data/index.json
let state; // {example, size, horizon, k (cutoff preset)}
let picks = 0; // examples picked so far: only the latest pick's data file is shown
function escapeHtml(text) {
return String(text).replace(/&/g, "&").replace(//g, ">")
.replace(/"/g, """);
}
function colours() {
return dark.matches
? { ink: "#BDC2C2", history: "#999E9E", baseline: "#BDC2C2", band: "189,194,194",
actual: "#b79aff", panel: "#1E2020", shade: "rgba(255,255,255,0.06)" }
: { ink: "#536363", history: "#637373", baseline: "#3B4545", band: "59,69,69",
actual: "#6D3FE0", panel: "#fff", shade: "rgba(18,33,33,0.05)" };
}
// A covariate's display name; the raw column name stays the key
function label(ex, name) {
return (ex.labels || {})[name] || name;
}
// The comparison forecast: the value one season earlier, or the last value when the season is 1
function naive(run) {
return run.season > 1 ? "seasonal naive" : "naive (last value)";
}
// The chosen forecast: its run, and the plotted window as positions in the example's arrays.
function chosen() {
const ex = loaded[state.example];
const horizon = state.horizon;
const run = (ex.runs[state.size] || {})[`${horizon}|${state.k}`];
if (!run) throw new Error(`No forecast for ${state.example}, ${state.size}, ${horizon}, ${state.k}`);
const preset = ex.cutoffs[horizon][state.k];
const withCov = ex.covariates.length > 0; // drawn beside the forecast without them
const at = preset.cutoff - ex.offset;
const start = Math.max(0, preset.cutoff - Math.max(2 * horizon, 48)) - ex.offset;
const future = ex.dates.slice(at, at + horizon);
if (start < 0 || future.length !== horizon) throw new Error("The plotted window is not stored");
return { ex, run, horizon, withCov, at, start, future,
actual: ex.values.slice(at, at + horizon),
span: [ex.dates[start], future[horizon - 1]] }; // the x range of both charts
}
// Controls
// Radio chips. While the options stay the same only the ticks change, so the focused chip and
// arrow keys keep working; new options get new inputs, and the focus moves to the ticked one.
function chips(id, options, value, onPick) {
const box = $(id).querySelector(".chips");
const focused = box.contains(document.activeElement);
const signature = JSON.stringify(options);
if (box.dataset.options !== signature) {
box.dataset.options = signature;
box.replaceChildren(...options.map(([key, text]) => {
const chip = document.createElement("label");
chip.className = "chip";
const input = document.createElement("input");
input.type = "radio";
input.name = id;
input.addEventListener("change", () => onPick(key));
chip.append(input, text);
return chip;
}));
}
box.querySelectorAll("input").forEach((input, i) => { input.checked = options[i][0] === value; });
if (focused && !box.contains(document.activeElement)) box.querySelector("input:checked").focus();
}
function renderControls(ex) {
chips("size", index.sizes.map((s) => [s, s]), state.size, (size) => update({ size }));
chips("cutoff", ex.cutoffs[state.horizon].map((c, k) => [k, c.label]), state.k,
(k) => update({ k }));
chips("horizon", ex.horizons.map((h) => [h, String(h)]), state.horizon,
(horizon) => update({ horizon }));
$("cutoff").querySelector("legend").textContent = `Cutoff${ex.daily ? "" : " (UTC)"}`;
$("about-note").textContent = ex.note;
}
// Headline, chart key and scores
function renderHeading({ run, withCov }) {
const base = run.scores[withCov ? "plain" : "naive"].MASE;
const lead = run.scores[withCov ? "with_covariates" : "plain"].MASE;
const against = withCov ? "with covariates" : `than ${naive(run)}`;
let title = `Higher error ${against}`;
if (lead === base) {
title = `Same error ${withCov ? "with covariates" : `as ${naive(run)}`}`;
} else if (base > 0) {
const change = Math.abs(100 * (lead / base - 1));
const amount = change < 0.5 ? "Under 1%" : `${Math.round(change)}%`;
title = `${amount} ${lead < base ? "lower" : "higher"} error ${against}`;
}
const keys = [["lead", withCov ? "With covariates" : "Median forecast"]];
if (withCov) keys.push(["baseline", "Without covariates"]);
keys.push(["band", "10–90% and 30–70% bands"], ["actual", "Actuals"], ["history", "History"]);
$("result-head").innerHTML =
`
${escapeHtml(title)}
` +
`` +
keys.map(([c, text]) => `${escapeHtml(text)}`).join("") +
"
";
}
function renderScores({ run, withCov }) {
const rows = withCov ? [["With covariates", run.scores.with_covariates]] : [];
rows.push([withCov ? "Without covariates" : `Simons-${state.size}`, run.scores.plain]);
const name = naive(run);
rows.push([name[0].toUpperCase() + name.slice(1), run.scores.naive]);
const reference = run.season > 1
? `a seasonal naive forecast, which repeats the value ${run.season} steps earlier`
: "a naive forecast, which repeats the last value";
// MASE also as a bar, relative to the largest of the rows
const longest = Math.max(...rows.map(([, s]) => s.MASE));
const bar = (s) => (longest > 0 ? (100 * s.MASE / longest).toFixed(1) : 0);
$("sp-scores").innerHTML =
`Scores (lower is better)
` +
`| Forecast | MASE | CRPS |
` +
`` +
rows.map(([text, s]) => `| ${escapeHtml(text)} | ` +
` | ` +
`${s.MASE.toFixed(3)} | ` +
`${s.CRPS.toFixed(3)} |
`).join("") +
`
`;
$("scores-note").textContent =
`MASE: the error of the median forecast, relative to ${reference}. ` +
`CRPS: the error of the whole quantile forecast, relative to the size of the actual values.`;
}
// Charts
function layout(c, height, daily, span) {
return {
height, margin: { l: 48, r: 16, t: 48, b: 48 },
// The panel's colour, which Plotly also gives the line behind the hover spike
paper_bgcolor: c.panel, plot_bgcolor: c.panel,
font: { family: "Inter, sans-serif", color: c.ink, size: 12 },
hovermode: "x unified", showlegend: false, dragmode: false,
hoverlabel: { bgcolor: "#F8F8F8", font: { color: "#122121" }, namelength: -1 },
// Short date ticks that stay upright on a phone; the second line shows when it changes
xaxis: { showgrid: false, linecolor: GRID, zeroline: false, nticks: 4, tickangle: 0,
tickformat: daily ? "%b %-d\n%Y" : "%H:%M\n%b %-d", range: span,
spikethickness: 1, spikecolor: c.ink, spikedash: "solid" },
yaxis: { gridcolor: GRID, zeroline: false, nticks: 5 },
};
}
// The forecast span, shaded behind the lines so that it reads apart from the history
function forecastShade(c, from, to, axis) {
return { type: "rect", xref: "x", yref: `${axis} domain`, x0: from, x1: to, y0: 0, y1: 1,
fillcolor: c.shade, line: { width: 0 }, layer: "below" };
}
function cutoffLine(c, date, axis) {
return { type: "line", xref: "x", yref: `${axis} domain`, x0: date, x1: date, y0: 0, y1: 1,
line: { color: c.ink, width: 1, dash: "dot" } };
}
function renderForecast({ ex, run, horizon, withCov, at, start, future, actual, span }) {
const c = colours();
const lead = withCov ? run.with : run.plain;
const forecasts = withCov ? [[run.plain, c.band]] : [];
forecasts.push([lead, "9,161,169"]);
const traces = [];
// The last history value, where every forecast line and band starts, so that they join
// the history across the cutoff. Without it, the bands start at the first future step.
const anchorX = ex.dates[at - 1];
const anchorY = ex.values[at - 1];
const anchored = anchorY !== null && anchorY !== undefined;
const fromAnchor = (y) => (anchored ? [anchorY, ...y] : y);
const bandX = anchored ? [anchorX, ...future] : future;
// A hover-free segment from the anchor to a series' first value, in that series' style
const connect = (y, line) => {
if (anchored) traces.push({ x: [anchorX, future[0]], y: [anchorY, y[0]], mode: "lines",
hoverinfo: "skip", line });
};
// Bands: 10-90% and 30-70% (the stored quantiles are 10, 30, 50, 70 and 90%)
for (const [q, rgb] of forecasts) {
for (const [low, high, alpha] of [[0, 4, 0.10], [1, 3, 0.22]]) {
traces.push({ x: bandX, y: fromAnchor(q[high]), mode: "lines", line: { width: 0 },
hoverinfo: "skip" });
traces.push({ x: bandX, y: fromAnchor(q[low]), mode: "lines", line: { width: 0 },
fill: "tonexty", fillcolor: `rgba(${rgb},${alpha})`, hoverinfo: "skip" });
}
}
// A line needs two points: short forecasts get markers, and when the window has missing
// values, history and actuals get small ones so that a value between two gaps shows.
const history = ex.values.slice(start, at);
const gaps = history.includes(null) || actual.includes(null);
const dots = gaps ? { mode: "lines+markers", marker: { size: 2.5 } } : { mode: "lines" };
traces.push({ x: ex.dates.slice(start, at), y: history, name: "History", ...dots,
line: { color: c.history, width: 1.5 } });
const mode = horizon < 20 ? "lines+markers" : "lines";
const short = horizon <= 30; // the actuals' markers suit short forecasts only
const leadLine = { color: FORECAST, width: 2.4 };
connect(lead[2], leadLine);
traces.push({ x: future, y: lead[2], name: withCov ? "With covariates" : "Median forecast",
mode, line: leadLine });
// The baseline goes over the median, which would otherwise hide it
if (withCov) {
const line = { color: c.baseline, width: 1.6, dash: "dash" };
connect(run.plain[2], line);
traces.push({ x: future, y: run.plain[2], name: "Without covariates", mode, line });
}
const actualLine = { color: c.actual, width: 1.6 };
connect(actual, actualLine);
traces.push({ x: future, y: actual, name: "Actuals",
...(short ? { mode: "lines+markers", marker: { size: 4 } } : dots),
line: actualLine });
for (const trace of traces) trace.cliponaxis = false; // whole markers at the range's ends
// A y range that fits history, actuals and the outer bands
const parts = [history, actual, run.plain[0], run.plain[4]];
if (withCov) parts.push(run.with[0], run.with[4]);
const values = parts.flat().filter((v) => v !== null);
const low = Math.min(...values);
const high = Math.max(...values);
const pad = high > low ? 0.06 * (high - low) : Math.max(Math.abs(high), 1) * 0.1;
const figure = layout(c, 380, ex.daily, span);
figure.yaxis.range = [low - pad, high + pad];
figure.title = { text: escapeHtml(ex.unit), font: { size: 13 }, x: 0.02 };
figure.shapes = [forecastShade(c, ex.dates[at - 1], span[1], "y"),
cutoffLine(c, ex.dates[at - 1], "y")];
const side = { xref: "x", yref: "paper", x: ex.dates[at - 1], y: 1, yanchor: "bottom",
showarrow: false, font: { color: c.ink, size: 11 } };
figure.annotations = [{ ...side, text: "History", xanchor: "right", xshift: -8 },
{ ...side, text: "Forecast", xanchor: "left", xshift: 8 }];
Plotly.react("main-plot", traces, figure, PLOT_CONFIG);
}
function renderCovariates({ ex, run, horizon, withCov, at, start, span }) {
const box = $("covariate-plot");
box.hidden = !withCov;
if (!withCov) return;
const c = colours();
const names = ex.covariates;
const gap = Math.min(0.08, 0.5 / names.length);
const row = (1 - gap * (names.length - 1)) / names.length;
const figure = layout(c, 100 + 110 * names.length, ex.daily, span);
figure.annotations = [];
figure.shapes = [];
const traces = names.map((name, i) => {
const axis = i === 0 ? "y" : `y${i + 1}`;
const top = 1 - i * (row + gap);
const text = escapeHtml(label(ex, name));
figure[axis.replace("y", "yaxis")] = { ...figure.yaxis, domain: [top - row, top] };
figure.annotations.push({ text, xref: "paper", yref: "paper", x: 0,
y: top, xanchor: "left", yanchor: "bottom", yshift: 8,
showarrow: false, font: { color: c.ink, size: 13 } });
figure.shapes.push(forecastShade(c, ex.dates[at - 1], span[1], axis),
cutoffLine(c, ex.dates[at - 1], axis));
// The values the model read: the history, and the future steps known at the cutoff
return { x: ex.dates.slice(start, at + horizon), yaxis: axis, name: text, mode: "lines",
y: ex.covariate_values[name].slice(start, at + horizon)
.map((v, j) => (start + j < at + run.known ? v : null)),
line: { color: c.history, width: 1.5 } };
});
figure.xaxis.anchor = names.length === 1 ? "y" : `y${names.length}`;
Plotly.react(box, traces, figure, PLOT_CONFIG);
Plotly.Plots.resize(box); // a chart last drawn while hidden takes the current width
}
// State
function render() {
// Nothing to draw while the picked example loads or after it failed to load
if ($("example").value !== state.example || !loaded[state.example]) return;
try {
const view = chosen();
renderControls(view.ex);
renderHeading(view);
renderScores(view);
$("main-plot").hidden = false;
if (window.Plotly) {
renderForecast(view);
renderCovariates(view);
} else {
const note = document.createElement("p");
note.className = "chart-missing";
note.textContent = "The chart library did not load.";
$("main-plot").replaceChildren(note);
}
} catch (error) {
fail("The forecast could not be shown", error);
}
waiting(false);
}
// While an example loads, the last one's results and controls dim and the controls are off
function waiting(on) {
$("workspace").classList.toggle("loading", on);
$("results").setAttribute("aria-busy", String(on));
for (const id of CONTROLS) $(id).disabled = on;
}
function clearResults() {
if (window.Plotly) {
Plotly.purge("main-plot");
Plotly.purge("covariate-plot");
}
$("main-plot").replaceChildren();
$("main-plot").hidden = true;
$("covariate-plot").hidden = true;
$("sp-scores").replaceChildren();
$("scores-note").textContent = "";
}
function fail(title, error, detail = error.message) {
console.error(error);
clearResults();
$("result-head").innerHTML = `${escapeHtml(title)}` +
`
${escapeHtml(detail)}
`;
}
function update(change) {
Object.assign(state, change);
render();
}
async function choose(name) {
const pick = ++picks;
if (!loaded[name]) {
waiting(true);
$("result-head").innerHTML = `Loading the forecasts
` +
`
${escapeHtml(name)}
`;
const entry = index.examples.find((e) => e.name === name);
try {
const response = await fetch(`data/${entry.file}`);
if (!response.ok) throw new Error(`data/${entry.file}: HTTP ${response.status}`);
loaded[name] = await response.json();
} catch (error) {
if (pick !== picks) return; // another example was picked while this one loaded
$("workspace").classList.remove("loading");
$("results").setAttribute("aria-busy", "false");
$("about-note").textContent = "";
fail("The example could not be loaded", error,
`${name}: ${error.message}. Reload the page or pick another example.`);
return;
}
if (pick !== picks) return;
}
const ex = loaded[name];
update({ example: name, horizon: ex.default_horizon, k: ex.default_cutoff ?? 0 });
}
async function main() {
const response = await fetch("data/index.json");
if (!response.ok) throw new Error(`data/index.json: HTTP ${response.status}`);
index = await response.json();
state = { example: index.default_example, size: index.default_size };
const select = $("example");
select.replaceChildren(...index.examples.map((e) => new Option(e.name, e.name)));
select.value = index.default_example;
select.addEventListener("change", () => choose(select.value));
dark.addEventListener("change", render);
await choose(index.default_example);
}
main().catch((error) => fail("The page could not load its data", error));