// Weekly (map) page. Imports replace the old script-tag ordering contract.
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
import { fmtTemp, onUnitChange, toWind, windUnit, precipUnit } from "./units.js";
import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver
import { getJSON, TTL, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette,
tempChart, precipChart, dryChart, attachChartHover } from "./chart.js";
import { track } from "./digest.js";
import { TIER_COLORS, SCALE_TEMP, drynessColor, pctOrd, esc, todayISO, placeLabel,
fmtPrecip, fmtWind, fmtHumid } from "./shared.js";
let selected = null; // {lat, lon}
const dateInput = document.getElementById("date-input");
const todayBtn = document.getElementById("today-btn");
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 };
// Show the raw coordinates immediately; render() replaces them with the resolved
// neighborhood + city name once the grade fetch returns.
locLabel.textContent = `${lat.toFixed(3)}, ${lon.toFixed(3)}`;
locLabel.hidden = false;
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");
initFindButton(findBtn, "Find a location",
() => selected, (lat, lon) => selectLocation(lat, lon));
// ---- hero ----
// The hero's grade card is server-rendered showing the most unusual city we're
// tracking; once the visitor has a place of their own it re-points at that. The
// frame and its text slots already exist in the HTML, so swapping the numbers in
// shifts nothing on the page.
const hero = document.getElementById("hero");
const heroLocate = document.getElementById("hero-locate");
const heroPick = document.getElementById("hero-pick");
const heroMsg = document.getElementById("hero-locate-msg");
function locateMsg(text) {
if (!heroMsg) return;
heroMsg.textContent = text || "";
heroMsg.hidden = !text;
}
// Browsers expose geolocation ONLY on secure origins, but `navigator.geolocation`
// still exists on plain http:// — getCurrentPosition just fails with a permission
// error. So feature-testing the object is not enough: without the isSecureContext
// check the button is a dead control on the plain-HTTP LAN dev server (and on any
// phone reaching it by IP), which is exactly how it shipped broken.
// Rather than offer a button that cannot work, promote the map picker instead.
const canLocate = !!navigator.geolocation && window.isSecureContext;
if (heroLocate && !canLocate) {
heroLocate.hidden = true;
heroPick?.classList.replace("btn-ghost", "btn-primary");
}
heroLocate?.addEventListener("click", () => {
track("home.locate");
const label = heroLocate.textContent;
const restore = () => {
heroLocate.disabled = false;
heroLocate.textContent = label;
};
// Geolocation can take seconds (or hang until the timeout), so say so —
// a button that looks inert is indistinguishable from a broken one.
heroLocate.disabled = true;
heroLocate.textContent = "Locating…";
locateMsg("");
navigator.geolocation.getCurrentPosition(
(pos) => {
restore();
selectLocation(pos.coords.latitude, pos.coords.longitude);
},
(err) => {
restore();
// Always say something: a declined prompt or a timeout must not look
// like the button did nothing.
locateMsg(
err.code === err.PERMISSION_DENIED
? "Location permission was declined. Pick a spot on the map instead."
: err.code === err.TIMEOUT
? "That took too long. Try again, or pick a spot on the map."
: "Your location isn't available right now. Pick a spot on the map instead.",
);
},
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 },
);
});
heroPick?.addEventListener("click", () => { locateMsg(""); findBtn.click(); });
/** Re-point the hero card at the place the visitor actually chose. */
function updateHero(data) {
if (!hero) return;
const card = document.getElementById("hero-grade");
const targetDay = data.recent.find((d) => d.date === data.target_date);
const g = targetDay && (targetDay.tmax || targetDay.tmin);
if (!card || !g) return;
document.getElementById("hero-where").textContent = `Today in ${placeLabel(data)}`;
document.getElementById("hero-band").textContent = g.grade;
document.getElementById("hero-detail").textContent =
`${targetDay.tmax === g ? "High temp" : "Low temp"} in the ${pctOrd(g.percentile)} percentile`;
// Band colour rides the same token set as everything else; the band word is
// always present too, so colour is never the only signal.
card.style.setProperty("--cat", TIER_COLORS[g.class] || "");
hero.querySelector(".grade-why")?.remove();
}
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${locHash(selected.lat, selected.lon, 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() ? uv(`grade?${q}`) : uv(`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 getJSON(url, 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);
}
// 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 = fmtTemp; // active unit, bare degree
const cHum = (v) => (v == null ? "—" : `${Math.round(v)}%`); // relative %, unit-invariant
const cWind = (v) => (v == null ? "—" : `${Math.round(toWind(v))}`);
// fmtPrecip carries the precision: whole millimetres, two decimals for inches.
const cRain = (v) => (v == null ? "—" : v > 0 ? fmtPrecip(v, false) : "·");
// 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 render(data) {
recentData = data;
updateHero(data);
// 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
// results have rendered (otherwise the name would show twice).
locLabel.hidden = true;
locLabel.textContent = "";
setFindLabel(findBtn, "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. 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${locHash(selected.lat, selected.lon, data.target_date)}`;
const cmpCard = (label, key, clim, fmt) => {
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;
const grade = g
? `${g.grade}`
: "";
return `
`;
}
// ---- graded-days timeline ----
// The header normals stay fixed on the target day; the chart + graded-days table
// 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.
onUnitChange(() => {
if (recentData) render(recentData);
});
// Rebuild the chart when the window is resized so its drawing width keeps
// tracking the container (debounced — resize fires continuously while dragging).
let resizeTimer = null;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => { if (recentData) renderSeries(); }, 160);
});
function renderSeries() {
const host = document.getElementById("series");
if (!host || !recentData) return;
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(recentData);
scrollTableToTarget(host, target);
}
// 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 = 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);
});
}
// ---- trend chart (self-contained inline SVG) ----
function tierKeyHtml() {
// Highest tier first (Near Record hot → Near Record cold).
const segs = [...SCALE_TEMP].reverse().map((t) =>
`
`;
}
let chartDays = [];
// Which chart category is shown. Persisted so a refresh keeps your selection.
let chartMetric = localStorage.getItem("thermograph:chartMetric") || "tmax";
function buildChart(data) {
const wrap = document.getElementById("chart-wrap");
const C = chartPalette();
// Drawing width tracks the on-screen box (see chart.js) so wide monitors gain
// plot room while phones keep the 720-unit floor (the SVG just scales down).
setChartWidth(wrap.clientWidth);
lastGraded = data; // the series currently drawn (for PNG export)
chartDays = [...data.recent].reverse(); // oldest -> newest
const days = chartDays, n = days.length;
if (!n) { wrap.innerHTML = ""; return; }
const xFor = (i) => (n === 1 ? PL + PW / 2 : PL + (i * PW) / (n - 1));
// shared: x date labels
const step = n > 9 ? 2 : 1;
let xlab = "";
days.forEach((d, i) => {
if (i % step && i !== n - 1) return;
const dt = new Date(d.date + "T00:00:00");
xlab += `${dt.getMonth() + 1}/${dt.getDate()}`;
});
// shared: hover hit targets over the plot
let hits = "";
days.forEach((_, i) => {
const w = n === 1 ? PW : PW / (n - 1);
hits += ``;
});
const built = chartMetric === "precip" ? precipChart(days, n, xFor, C)
: chartMetric === "dry" ? dryChart(days, n, xFor, C)
: tempChart(chartMetric, days, n, xFor, C);
// 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) {
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)} · ${data.target_date}`;
const svg = ``;
const btn = (m, label) => ``;
wrap.innerHTML = `