diff --git a/static/app.js b/static/app.js
index ccff27e..0d62e2b 100644
--- a/static/app.js
+++ b/static/app.js
@@ -120,7 +120,6 @@ function placeLabel(data) {
function render(data) {
recentData = data;
- forecastData = null; // invalidate any forecast from a previous location/date
// The place name appears once — as the results heading (
) below, which also
// carries the coordinates + climatology context. The top label only holds the
// transient coordinates written on selection, so hide it now that the named
@@ -134,11 +133,13 @@ function render(data) {
const coords = `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`;
// Each card compares the target day's value to the normal (median) for that
- // metric, tinted by its grade, and links to the full single-day breakdown.
- const today = data.recent[0] || null;
+ // metric, tinted by its grade, and links to the full single-day breakdown. The
+ // window now runs past the target into the forecast, so the target is looked up
+ // by date rather than taken as the newest row.
+ const targetDay = data.recent.find((d) => d.date === data.target_date) || null;
const dayHref = `day#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${data.target_date}`;
const cmpCard = (label, key, clim, fmt) => {
- const g = today ? today[key] : null;
+ const g = targetDay ? targetDay[key] : null;
const normalVal = clim ? fmt(clim.p50) : "—";
const cat = g ? (TIER_COLORS[g.class] || "") : "";
const big = g ? fmt(g.value) : normalVal;
@@ -188,8 +189,7 @@ function render(data) {
`;
- renderSeries(); // fills #series (chart + graded-days timeline) from recent data
- fetchForecast(); // merge the 7-day forecast into the timeline when it arrives
+ renderSeries(); // fills #series (chart + graded-days timeline) from the window
window.Thermograph.prefetchViews(selected.lat, selected.lon, "grade"); // warm calendar/forecast
document.getElementById("btn-link").onclick = copyShareLink;
document.getElementById("btn-png").onclick = downloadChartPng;
@@ -228,108 +228,68 @@ const RD_METRICS = [
["tmax", "Hi", cTemp], ["tmin", "Lo", cTemp], ["feels", "Feel", cTemp],
["humid", "Hum", cHum], ["wind", "Wind", cWind], ["gust", "Gust", cWind], ["precip", "Rain", cRain],
];
-function daysTable(rows, todayDate) {
+function daysTable(rows, targetDate) {
if (!rows.length) return "";
- const isFc = (d) => todayDate && d.date > todayDate; // future = forecast columns
+ const todayDate = todayISO();
+ const isFc = (d) => d.date > todayDate; // future = forecast columns
+ const isTarget = (d) => d.date === targetDate; // the graded target day (orange marker)
const cols = `grid-template-columns: 46px repeat(${rows.length}, minmax(42px, 1fr));`;
const header = `` + rows.map((d) => {
const dt = new Date(d.date + "T00:00:00");
const dow = dt.toLocaleDateString(undefined, { weekday: "short" });
const md = dt.toLocaleDateString(undefined, { month: "numeric", day: "numeric" });
- const cls = "rd-colh" + (isFc(d) ? " rd-fc" : d.date === todayDate ? " rd-today" : "");
+ const cls = "rd-colh" + (isTarget(d) ? " rd-today" : isFc(d) ? " rd-fc" : "");
return `
${dow}${md}
`;
}).join("");
const body = RD_METRICS.map(([key, label, fmt]) =>
`
`;
}
-// ---- Recent + Forecast timeline ----
+// ---- graded-days timeline ----
// The header normals stay fixed on the target day; the chart + graded-days table
-// below show one continuous timeline: recent history through the 7-day forecast,
-// chronological, opened with today at the left edge.
-let recentData = null; // /grade response (past) — the base render
-let forecastData = null; // /forecast response (next 7 days), fetched in the background
+// below show one continuous window built server-side: two weeks of history before
+// the target, the target itself (orange marker), then the days after it — real
+// observations when they're in the past, the forward forecast when they run into
+// the future (see backend _build_grade).
+let recentData = null; // /grade response — the whole window, newest-first
-// Repaint everything in the newly-picked unit, preserving the already-loaded
-// forecast (render() clears it and refetches from cache; restore it right away).
+// Repaint everything in the newly-picked unit.
window.Thermograph.onUnitChange(() => {
- if (!recentData) return;
- const fc = forecastData;
- render(recentData);
- if (fc) { forecastData = fc; renderSeries(); }
+ if (recentData) render(recentData);
});
-const addDaysISO = (iso, n) => {
- const d = new Date(iso + "T00:00:00Z");
- d.setUTCDate(d.getUTCDate() + n);
- return d.toISOString().slice(0, 10);
-};
-
-// Newest-first overall: forecast (furthest→nearest) then past (newest→oldest).
-// The forecast is only merged while it *continues* the observed run with no gap —
-// so viewing a past week (whose recent days end well before today's live forecast)
-// shows just that week instead of a disconnected forecast block after a date gap.
-// Bounded to within 13 days of the anchored date and at most a week of forecast.
-function combinedDays() {
- const past = recentData ? recentData.recent : []; // newest-first; newest = target_date
- const rawFut = forecastData ? forecastData.recent : []; // furthest-first (tomorrow…+7 reversed)
- if (!rawFut.length || !past.length) return [...rawFut, ...past];
-
- const limit = addDaysISO(recentData.target_date, 13); // forecast must fall within 13 days
- let prev = past[0].date; // last observed day (the anchor)
- const kept = [];
- for (const f of [...rawFut].reverse()) { // walk oldest→newest
- if (kept.length >= 7 || f.date > limit) break; // cap a week / 13-day window
- if (f.date !== addDaysISO(prev, 1)) break; // a gap — stop merging the forecast
- kept.push(f);
- prev = f.date;
- }
- return [...kept.reverse(), ...past]; // restore furthest-first ordering
-}
-
-async function fetchForecast() {
- if (!selected) return;
- const { lat, lon } = selected;
- try {
- const d = await window.Thermograph.getJSON(`api/v2/forecast?lat=${lat}&lon=${lon}`, window.Thermograph.TTL.forecast);
- if (!selected || selected.lat !== lat || selected.lon !== lon) return; // user moved on
- forecastData = d;
- renderSeries();
- } catch (err) { /* forecast is a bonus — keep the recent view on failure */ }
-}
-
function renderSeries() {
const host = document.getElementById("series");
if (!host || !recentData) return;
- const combined = combinedDays(); // newest-first
- const chartData = { ...recentData, recent: combined };
- const today = recentData.target_date;
- const hasFc = combined.some((d) => d.date > today); // forecast days actually merged in
+ const days = recentData.recent; // newest-first: history · target · after/forecast
+ const target = recentData.target_date;
+ const hasFc = days.some((d) => d.date > todayISO()); // window runs into the forecast
host.innerHTML = `
'}
`;
- buildChart(chartData);
- scrollTableToToday(host, today, hasFc);
+ buildChart(recentData);
+ scrollTableToTarget(host, target);
}
-// Open the timeline with today's column pinned at the left edge (after the sticky
-// metric labels); before the forecast loads, just show the most-recent day.
-function scrollTableToToday(host, today, hasFc) {
+// Open the timeline centered on the target day's column (the orange marker), so
+// the two weeks of history and the days after it are both in view.
+function scrollTableToTarget(host, target) {
const scroll = host.querySelector(".rd-scroll");
if (!scroll) return;
requestAnimationFrame(() => {
- const col = hasFc && scroll.querySelector(`.rd-colh[data-date="${today}"]`);
- scroll.scrollLeft = col ? Math.max(0, col.offsetLeft - 46) : scroll.scrollWidth;
+ const col = scroll.querySelector(`.rd-colh[data-date="${target}"]`);
+ if (!col) { scroll.scrollLeft = scroll.scrollWidth; return; }
+ scroll.scrollLeft = Math.max(0, col.offsetLeft - (scroll.clientWidth - col.offsetWidth) / 2);
});
}
@@ -462,22 +422,32 @@ function buildChart(data) {
: chartMetric === "dry" ? dryChart(days, n, xFor, C)
: tempChart(chartMetric, days, n, xFor, C);
- // Divider between the last observed day and the forecast, when the chart spans both.
- let todayDiv = "";
+ // Solid orange marker line ON the target day, plus a dashed "forecast →" divider
+ // where the window crosses from the past into the future — drawn only when it's
+ // clearly separated from the target marker (i.e. the target is in the past; when
+ // the target is today the marker itself already sits at that boundary).
+ let marks = "";
const tIdx = days.findIndex((d) => d.date === data.target_date);
- if (tIdx >= 0 && tIdx < n - 1) {
- const xd = ((xFor(tIdx) + xFor(tIdx + 1)) / 2).toFixed(1);
- todayDiv = ``
- + `forecast →`;
+ if (tIdx >= 0) {
+ const xt = xFor(tIdx).toFixed(1);
+ const td = new Date(data.target_date + "T00:00:00");
+ marks += ``
+ + `${td.getMonth() + 1}/${td.getDate()}`;
+ }
+ const fIdx = days.findIndex((d) => d.date > todayISO()); // first forecast (future) day
+ if (fIdx > tIdx + 1) {
+ const xd = ((xFor(fIdx - 1) + xFor(fIdx)) / 2).toFixed(1);
+ marks += ``
+ + `forecast →`;
}
- const title = `${placeLabel(data)} · through ${data.target_date}`;
+ const title = `${placeLabel(data)} · ${data.target_date}`;
const svg = `