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]) => `
${label}
` + 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 `
${header}${body}
`; } -// ---- 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 = `
Trend vs normal${hasFc ? " · recent → forecast" : ""}
${tierKeyHtml()}
Daily, graded${hasFc ? " — recent & 7-day forecast" : ""}
← OlderNewer →
- ${daysTable([...combined].reverse(), today) || '

Nothing to grade.

'} + ${daysTable([...days].reverse(), target) || '

Nothing to grade.

'} `; - 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 = ` ${built.subtitle} ${title} ${built.content} - ${todayDiv} + ${marks} ${xlab} ${hits} diff --git a/static/style.css b/static/style.css index a81d4a6..c8e7e07 100644 --- a/static/style.css +++ b/static/style.css @@ -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); }