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:
parent
bec0af3bcf
commit
2f38fb8b51
2 changed files with 54 additions and 83 deletions
132
static/app.js
132
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 (<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 & 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}
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue