"use strict";
let selected = null; // {lat, lon}
const dateInput = document.getElementById("date-input");
const todayBtn = document.getElementById("today-btn");
const todayISO = () => new Date().toISOString().slice(0, 10);
dateInput.value = todayISO();
dateInput.max = dateInput.value;
// The "Today" chip snaps the target date back to today; it stays lit while the
// target already is today, so it doubles as a "you're viewing now" indicator.
function syncTodayBtn() {
todayBtn.classList.toggle("active", dateInput.value === todayISO());
}
syncTodayBtn();
todayBtn.addEventListener("click", () => {
const t = todayISO();
if (dateInput.value === t) return;
dateInput.value = t;
dateInput.max = t;
syncTodayBtn();
if (selected) runGrade();
});
function selectLocation(lat, lon) {
selected = { lat, lon };
runGrade();
}
// ---- location picker ----
// The Find button opens the shared modal map picker; choosing a spot loads it.
const findBtn = document.getElementById("find-btn");
const locLabel = document.getElementById("loc-label");
findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} Find a location`;
findBtn.addEventListener("click", () => {
window.LocationPicker.open(selected, (lat, lon) => selectLocation(lat, lon));
});
dateInput.addEventListener("change", () => { syncTodayBtn(); selected && runGrade(); });
// ---- grade request + render ----
const placeholder = document.getElementById("placeholder");
const results = document.getElementById("results");
let lastGraded = null; // most recent /api/grade response (for the PNG export)
// Clicking a graded day (any cell or its date header) opens its full breakdown.
results.addEventListener("click", (e) => {
const row = e.target.closest(".rd-grid [data-date]");
if (!row || !selected) return;
location.href = `day#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${row.dataset.date}`;
});
let gradeSeq = 0; // supersedes an in-flight load when the user picks a new spot/date
async function runGrade() {
if (!selected) return;
updateHash();
placeholder.hidden = true;
results.hidden = false;
const seq = ++gradeSeq;
// Delay the spinner: a cache-served render lands in a few ms, so blanking the
// panel immediately would just flash. Only a genuinely slow (network) load
// ever shows it.
const spin = setTimeout(() => {
if (seq === gradeSeq) results.innerHTML = `
Fetching decades of climate history…
`;
}, 150);
// Omit the date when it's today, so the URL (and cache key) matches the prefetch
// fired from the other views — a same-tab navigation then reuses the response.
const q = `lat=${selected.lat}&lon=${selected.lon}`;
const url = dateInput.value === todayISO() ? `api/grade?${q}` : `api/grade?${q}&date=${dateInput.value}`;
let data;
try {
// Stale-while-revalidate: a stale cached copy renders immediately and the
// update callback repaints if the background revalidation finds new data.
data = await window.Thermograph.getJSON(url, window.Thermograph.TTL.grade, false, (upd) => {
if (seq === gradeSeq) render(upd);
});
} catch (err) {
clearTimeout(spin);
if (seq !== gradeSeq) return;
results.innerHTML = `
${err.message}
`;
return;
}
clearTimeout(spin);
if (seq !== gradeSeq) return;
render(data);
}
const fmtTemp = (v) => (v == null ? "—" : `${Math.round(v)}°`);
const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`);
const fmtWind = (v) => (v == null ? "—" : `${Math.round(v)} mph`);
const fmtHumid = (v) => (v == null ? "—" : `${v.toFixed(1)} g/m³`);
// Compact cell formatters for the dense recent/forecast table (units live in the
// column headers, so values stay short). Dry days label the running dry streak
// (see precipCell) rather than the rain amount.
const cTemp = (v) => (v == null ? "—" : `${Math.round(v)}°`);
const cHum = (v) => (v == null ? "—" : `${Math.round(v)}%`);
const cWind = (v) => (v == null ? "—" : `${Math.round(v)}`);
const cRain = (v) => (v == null ? "—" : v > 0 ? v.toFixed(2) : "·");
// Monochrome inline icons (currentColor) so the UI chrome matches the dark theme
// instead of using colorful emoji. Feather-style line paths.
const IC = {
link: ``,
download: ``,
calendar: ``,
};
function placeLabel(data) {
return data.place || `${data.cell.center_lat.toFixed(3)}, ${data.cell.center_lon.toFixed(3)}`;
}
function render(data) {
recentData = data;
forecastData = null; // invalidate any forecast from a previous location/date
// Reflect the chosen place beside the Find button (which becomes "Change").
locLabel.textContent = placeLabel(data);
locLabel.hidden = false;
findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} Change location`;
const c = data.climatology;
const cell = data.cell;
const yrs = c.year_range ? `${c.year_range[0]}–${c.year_range[1]}` : "—";
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;
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 normalVal = clim ? fmt(clim.p50) : "—";
const cat = g ? (TIER_COLORS[g.class] || "") : "";
const big = g ? fmt(g.value) : normalVal;
const grade = g
? `${g.grade}`
: "";
return `
`;
}
// ---- Recent + Forecast 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
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
host.innerHTML = `
`;
};
// Pointer events cover mouse, touch and pen with one path, so tapping/dragging
// the chart works on phones (mousemove never fires on touchscreens).
const showTip = (r, e) => {
const d = chartDays[+r.dataset.i];
const x = +r.getAttribute("x") + +r.getAttribute("width") / 2;
cross.setAttribute("x1", x); cross.setAttribute("x2", x); cross.setAttribute("opacity", "0.7");
const dt = new Date(d.date + "T00:00:00");
const nt = d.normals.tmax, ni = d.normals.tmin;
tip.innerHTML = `