Weekly view: center the daily-graded window on the target day (#28)

* Pin the °F/°C toggle to the header's top-right on all widths

The unit toggle sat inside .brand after a flex-basis:auto title block, so on
narrow (phone) widths the long tagline claimed the first row and bumped the
toggle onto its own line mid-header. Give the title block flex:1 1 0 with
min-width:0 so it contributes ~nothing to the wrap decision and shrinks
instead — keeping the toggle on the logo's row, top-right, with the tagline
wrapping beneath and the view tabs on their own row below.

* Weekly view: center the daily-graded window on the target day

Reframe the "Daily, graded" chart + table on the weekly view around the
selected target day instead of ending at it:

- Show two weeks of history before the target, the target itself, then up
  to 7 days after it. Days after the target are real observations when they
  are already in the past and the forward forecast when they run into the
  future.
- Mark the target day with a solid orange line on the chart (and an accent
  edge on its table column); draw the dashed "forecast →" divider only where
  the window actually crosses into the future and it is separated from the
  target marker.

Backend: _build_grade now grades a [target-days, target+after] window built
from both the archive and the recent+forecast bundle (the bundle wins per
date; the archive fills older gaps), so any past target renders its
surrounding week. api/grade gains an `after` param (default 7) and the cache
key includes it; the /cell prefetch bundle builds the matching slice.

Frontend: the chart consumes the server-built window directly — the separate
forecast fetch and gap-merge are gone. The target is looked up by date for
the comparison cards (the newest row is now a forecast day), and the graded
table opens centered on the target column.

* Weekly view: extend the after-target window to 14 days

Bump the daily-graded window's after-target span from 7 to 14 days. A target
far enough in the past now shows 14 observed days after it; a recent target
shows its observed days plus the 1-7 available forecast days, and the forecast
naturally caps at ~7 days out. The after cap stays at 14.
This commit is contained in:
Emi Griffith 2026-07-11 07:58:42 -07:00 committed by GitHub
parent bec0af3bcf
commit 2f38fb8b51
2 changed files with 54 additions and 83 deletions

View file

@ -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 (<h2>) 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) {
<div id="series"></div>
`;
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 = `<div class="rd-corner"></div>` + 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 `<div class="${cls}" data-date="${d.date}" title="See the full breakdown for this day"><b>${dow}</b><span>${md}</span></div>`;
}).join("");
const body = RD_METRICS.map(([key, label, fmt]) =>
`<div class="rd-mlabel">${label}</div>` +
rows.map((d) => key === "precip"
? precipCell(d, isFc(d), d.date === todayDate)
: rdCell(d[key], fmt, d.date, isFc(d), d.date === todayDate)).join("")
? precipCell(d, isFc(d), isTarget(d))
: rdCell(d[key], fmt, d.date, isFc(d), isTarget(d))).join("")
).join("");
return `<div class="rd-scroll"><div class="rd-grid" style="${cols}">${header}${body}</div></div>`;
}
// ---- 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 = `
<div class="section-title">Trend vs normal${hasFc ? " · recent → forecast" : ""}</div>
<div id="chart-wrap"></div>
${tierKeyHtml()}
<div class="section-title">Daily, graded${hasFc ? " — recent &amp; 7-day forecast" : ""}</div>
<div class="rd-orient"><span> Older</span><span>Newer </span></div>
${daysTable([...combined].reverse(), today) || '<p class="muted">Nothing to grade.</p>'}
${daysTable([...days].reverse(), target) || '<p class="muted">Nothing to grade.</p>'}
`;
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 = `<line x1="${xd}" y1="${plotTop}" x2="${xd}" y2="${plotBot}" stroke="${C.accent}" stroke-width="1.2" stroke-dasharray="4 3" opacity="0.85"/>`
+ `<text x="${xd}" y="${plotTop - 3}" text-anchor="middle" font-size="9" font-weight="700" fill="${C.accent}">forecast →</text>`;
if (tIdx >= 0) {
const xt = xFor(tIdx).toFixed(1);
const td = new Date(data.target_date + "T00:00:00");
marks += `<line x1="${xt}" y1="${plotTop}" x2="${xt}" y2="${plotBot}" stroke="${C.accent}" stroke-width="1.8" opacity="0.9"/>`
+ `<text x="${xt}" y="${plotTop - 3}" text-anchor="middle" font-size="9" font-weight="700" fill="${C.accent}">${td.getMonth() + 1}/${td.getDate()}</text>`;
}
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 += `<line x1="${xd}" y1="${plotTop}" x2="${xd}" y2="${plotBot}" stroke="${C.accent}" stroke-width="1.1" stroke-dasharray="4 3" opacity="0.55"/>`
+ `<text x="${xd}" y="${plotTop - 3}" text-anchor="middle" font-size="8.5" font-weight="700" fill="${C.accent}" opacity="0.8">forecast →</text>`;
}
const title = `${placeLabel(data)} · through ${data.target_date}`;
const title = `${placeLabel(data)} · ${data.target_date}`;
const svg = `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" font-family="system-ui, -apple-system, Segoe UI, sans-serif" role="img" aria-label="${built.aria}">
<rect x="0" y="0" width="${W}" height="${H}" fill="${C.surface}"/>
<text x="${PL - 8}" y="24" font-size="12.5" font-weight="700" fill="${built.color}">${built.subtitle}</text>
<text x="${W - PR}" y="24" text-anchor="end" font-size="11" fill="${C.muted}">${title}</text>
${built.content}
${todayDiv}
${marks}
${xlab}
<line id="crosshair" x1="0" y1="${plotTop}" x2="0" y2="${plotBot}" stroke="${C.muted}" stroke-width="1" stroke-dasharray="3 3" opacity="0"/>
${hits}

View file

@ -525,8 +525,9 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
display: flex; justify-content: space-between; margin: 0 0 8px;
font-size: 11.5px; font-weight: 600; letter-spacing: .03em; color: var(--muted);
}
/* Forecast (future) columns: accent-tinted headers. The accent divider marks the
start of *today's* column (the anchored day), with the forecast to its right. */
/* Forecast (future) columns: accent-tinted headers. The .rd-today column is the
graded target day (the orange chart marker), flagged with an accent left edge;
forecast columns sit to the right of today. */
.rd-colh.rd-fc b { color: var(--accent); }
.rd-colh.rd-fc span { color: color-mix(in oklab, var(--accent), var(--muted) 45%); }
.rd-colh.rd-today b { color: var(--accent); }