Download app.js from abel-lab/simons-forecast-explorer: direct link, hf CLI and curl.
- Browser
- Download file 17 kB
-
https://huggingface.co/spaces/abel-lab/simons-forecast-explorer/resolve/main/app.js
- Command line
-
hf download hf://spaces/abel-lab/simons-forecast-explorer/app.js
-
curl -L -o app.js https://huggingface.co/spaces/abel-lab/simons-forecast-explorer/resolve/main/app.js
17 kB
| ; | |
| // 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, ">") | |
| .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 = | |
| `<div class="result-head"><h2>${escapeHtml(title)}</h2></div>` + | |
| `<div class="chart-key">` + | |
| keys.map(([c, text]) => `<span><i class="${c}"></i>${escapeHtml(text)}</span>`).join("") + | |
| "</div>"; | |
| } | |
| 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 = | |
| `<div class="scores-head">Scores <span>(lower is better)</span></div>` + | |
| `<table><thead><tr><th>Forecast</th><th colspan="2">MASE</th><th>CRPS</th></tr></thead>` + | |
| `<tbody>` + | |
| rows.map(([text, s]) => `<tr><td>${escapeHtml(text)}</td>` + | |
| `<td class="bar"><i style="width:${bar(s)}%"></i></td>` + | |
| `<td>${s.MASE.toFixed(3)}</td>` + | |
| `<td>${s.CRPS.toFixed(3)}</td></tr>`).join("") + | |
| `</tbody></table>`; | |
| $("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 = `<div class="result-head"><h2 class="bad">${escapeHtml(title)}` + | |
| `</h2><p>${escapeHtml(detail)}</p></div>`; | |
| } | |
| function update(change) { | |
| Object.assign(state, change); | |
| render(); | |
| } | |
| async function choose(name) { | |
| const pick = ++picks; | |
| if (!loaded[name]) { | |
| waiting(true); | |
| $("result-head").innerHTML = `<div class="result-head"><h2>Loading the forecasts</h2>` + | |
| `<p>${escapeHtml(name)}</p></div>`; | |
| 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)); | |