"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)
` + `` + `` + rows.map(([text, s]) => `` + `` + `` + ``).join("") + `
ForecastMASECRPS
${escapeHtml(text)}${s.MASE.toFixed(3)}${s.CRPS.toFixed(3)}
`; $("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));