// Weekly (map) page. Imports replace the old script-tag ordering contract.
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
import { fmtTemp, toUnit, onUnitChange } from "./units.js";
import { getJSON, TTL, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
import { TIER_COLORS, SCALE_TEMP, drynessColor, ord, 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));
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() ? `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 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)}%`);
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 render(data) {
recentData = 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) =>
`
`;
}
// #rrggbb -> rgba() with alpha, so band tints derive from the same site colors.
const hexA = (hex, a) => {
let h = (hex || "").trim().replace("#", "");
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
const n = parseInt(h || "888888", 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
};
function chartPalette() {
// Read straight from the site's CSS variables so the chart matches the rest of
// the UI and follows the light/dark theme automatically. hiBand/loBand are
// applied once per tier ring (p1-99, p10-90, p25-75, p40-60) and stack, so keep
// each layer light — the center (Normal) darkens naturally.
const css = getComputedStyle(document.documentElement);
const v = (name, f) => (css.getPropertyValue(name).trim() || f);
const dark = matchMedia("(prefers-color-scheme: dark)").matches;
const hi = v("--very-hot", "#dd6b52"); // daily-high series = warm tier red
const lo = v("--cold", "#4393c3"); // daily-low series = cool tier blue
return {
surface: v("--surface-2", dark ? "#1e242c" : "#eef1f5"),
ink: v("--text", dark ? "#e7ecf2" : "#1a2029"),
muted: v("--muted", dark ? "#9aa6b2" : "#5b6673"),
grid: v("--border", dark ? "#2a323c" : "#dde3ea"),
accent: v("--accent", "#f0803c"),
hi, lo,
// Distinct line/point accents for the three added series; the graded fan
// behind them still uses the shared diverging tier colors (same as temps).
feels: v("--accent", "#f0803c"), // "feels like" = orange accent
humid: "#2ea9bf", // humidity = cyan
wind: "#5aa9a3", // wind speed = teal
gust: "#8f86d8", // wind gust = periwinkle
precip: v("--wet-6", "#2b8cbe"),
hiBand: hexA(hi, dark ? 0.15 : 0.11),
loBand: hexA(lo, dark ? 0.17 : 0.12),
};
}
// W/PW are recomputed per build from the container width (see buildChart), so a
// wide monitor gets a genuinely wider drawing — more room between days — instead
// of the same 720-unit canvas magnified.
let W = 720;
const H = 340;
const PL = 46, PR = 62, plotTop = 48, plotBot = 288, xLabY = 308;
let PW = W - PL - PR;
// Read a percentile from a normals object, falling back to nearby keys so an
// older/partial API response (missing p1/p30/p70/p99) still renders a chart.
const PCT_FALLBACK = {
p1: ["p1", "p10", "min", "p50"], p10: ["p10", "p50"],
p25: ["p25", "p30", "p50"], p40: ["p40", "p30", "p50"],
p60: ["p60", "p70", "p50"], p75: ["p75", "p70", "p50"],
p90: ["p90", "p50"], p99: ["p99", "p90", "max", "p50"],
};
const pget = (o, k) => {
for (const kk of (PCT_FALLBACK[k] || [k])) if (o && o[kk] != null) return o[kk];
return null;
};
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 at a steady ~1.5 CSS px per SVG unit,
// so text renders the same size everywhere while wide monitors gain plot room.
// Phones keep the 720-unit floor (the SVG just scales down).
W = Math.max(720, Math.min(1400, Math.round((wrap.clientWidth || 720) / 1.5)));
PW = W - PL - PR;
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 = `
${svg}
`;
document.getElementById("chart-toggle").addEventListener("click", (e) => {
const b = e.target.closest("button[data-metric]");
if (!b || b.dataset.metric === chartMetric) return;
chartMetric = b.dataset.metric;
try { localStorage.setItem("thermograph:chartMetric", chartMetric); } catch (e) {}
buildChart(data);
});
attachChartHover(wrap);
}
// Per-series display meta for the diverging-scale metrics. `sfx` is the unit
// appended to axis + point labels ("°"); wind carries its unit in the subtitle
// instead of on every point to keep the plot uncluttered.
const SERIES = {
tmax: (C) => ({ color: C.hi, label: "Daily high", sfx: "°" }),
tmin: (C) => ({ color: C.lo, label: "Daily low", sfx: "°" }),
feels: (C) => ({ color: C.feels, label: "Feels like", sfx: "°" }),
humid: (C) => ({ color: C.humid, label: "Absolute humidity (g/m³)", sfx: "" }),
wind: (C) => ({ color: C.wind, label: "Wind speed (mph)", sfx: "" }),
gust: (C) => ({ color: C.gust, label: "Wind gust (mph)", sfx: "" }),
};
// Percentile "fan" tiers — bands between successive percentile marks, colored to
// match the calendar. Temperature is diverging (cold→green→hot); precipitation
// uses the sequential rain-intensity ramp.
const TEMP_FAN = [
["p90", "p99", "very-hot"], ["p75", "p90", "hot"], ["p60", "p75", "warm"],
["p40", "p60", "normal"], ["p25", "p40", "cool"], ["p10", "p25", "cold"], ["p1", "p10", "very-cold"],
];
const RAIN_FAN = [
["p90", "p99", "wet-8"], ["p75", "p90", "wet-7"], ["p60", "p75", "wet-6"],
["p40", "p60", "wet-5"], ["p25", "p40", "wet-4"], ["p10", "p25", "wet-3"], ["p1", "p10", "wet-2"],
];
// Shared line-series chart: an optional shaded percentile fan + dashed median, the
// value line with dots, and labeled points — so temperature, wind, humidity,
// precipitation and dry streak all read in one consistent style.
function lineChart(cfg) {
const { days, n, xFor, C, key, color, fan, hasNormals, baseZero,
getVal, getPct, dotColor, fmtAxis, fmtLabel, labelOn,
subtitle, legend, aria } = cfg;
// y-range: the plotted values plus the fan extremes (p1/p99) when present.
const vals = [];
days.forEach((d) => {
const v = getVal(d); if (v != null && !isNaN(v)) vals.push(v);
if (hasNormals && d.normals && d.normals[key]) {
const u = pget(d.normals[key], "p99"), l = pget(d.normals[key], "p1");
if (u != null) vals.push(u); if (l != null) vals.push(l);
}
});
let finite = vals.filter((v) => v != null && !isNaN(v));
if (!finite.length) finite = [0, 1];
let lo = Math.min(...finite), hi = Math.max(...finite);
if (baseZero) lo = Math.min(lo, 0);
if (hi <= lo) hi = lo + 1;
const pad = baseZero ? Math.max((hi - lo) * 0.12, 0.01) : Math.max((hi - lo) * 0.08, 3);
hi += pad; if (!baseZero) lo -= pad;
const yT = (v) => plotBot - ((v - lo) / (hi - lo)) * (plotBot - plotTop);
let grid = "";
for (let t = 0; t <= 4; t++) {
const v = lo + ((hi - lo) * t) / 4, y = yT(v).toFixed(1);
grid += ``;
grid += `${fmtAxis(v)}`;
}
// Shaded fan bands between percentile marks (the "highlights"), same as the calendar tiers.
const band = (dnKey, upKey, cls) => {
let top = "", bot = "";
days.forEach((d, i) => { const u = d.normals && pget(d.normals[key], upKey); if (u == null) return;
top += (top ? "L" : "M") + xFor(i).toFixed(1) + " " + yT(u).toFixed(1) + " "; });
for (let i = n - 1; i >= 0; i--) { const dd = days[i].normals && pget(days[i].normals[key], dnKey); if (dd == null) continue;
bot += "L" + xFor(i).toFixed(1) + " " + yT(dd).toFixed(1) + " "; }
return top ? `` : "";
};
const fanSvg = (hasNormals && fan) ? fan.map((t) => band(t[0], t[1], t[2])).join("") : "";
const normTrace = (pctKey) => {
let p = "", st = false;
days.forEach((d, i) => { const v = d.normals && pget(d.normals[key], pctKey); if (v == null) return;
p += (st ? "L" : "M") + xFor(i).toFixed(1) + " " + yT(v).toFixed(1) + " "; st = true; });
return p;
};
const valTrace = () => {
let p = "", st = false;
days.forEach((d, i) => { const v = getVal(d); if (v == null || isNaN(v)) { st = false; return; }
p += (st ? "L" : "M") + xFor(i).toFixed(1) + " " + yT(v).toFixed(1) + " "; st = true; });
return p;
};
const medianSvg = hasNormals
? `` : "";
const dots = days.map((d, i) => {
const v = getVal(d); if (v == null || isNaN(v)) return "";
return ``;
}).join("");
const labels = days.map((d, i) => {
const v = getVal(d); if (v == null || isNaN(v)) return "";
if (labelOn && !labelOn(v, d)) return "";
const x = xFor(i).toFixed(1), y = yT(v);
const vy = (y - plotTop < 26 ? y + 15 : y - 15).toFixed(1); // flip below near the top
const pv = getPct ? getPct(d) : null;
const pct = pv == null ? "" :
`${ord(pv)}`;
return `${fmtLabel(v)}${pct}`;
}).join("");
const content = grid + fanSvg + medianSvg +
`` +
dots + labels;
return { content, legend, color, subtitle, aria };
}
// One temperature-scale series (High/Low/Feels/Wind/Gust).
function tempChart(key, days, n, xFor, C) {
const s = SERIES[key](C);
// Temperature series (° suffix) show in the active unit; wind/gust stay in mph.
// The plot keeps its °F domain — only the labels convert — so positions hold.
const isTemp = s.sfx === "°";
const fmtV = (v) => `${Math.round(isTemp ? toUnit(v) : v)}${s.sfx}`;
return lineChart({
days, n, xFor, C, key, color: s.color, fan: TEMP_FAN, hasNormals: true, baseZero: false,
getVal: (d) => (d[key] ? d[key].value : null),
getPct: (d) => (d[key] ? d[key].percentile : null),
fmtAxis: fmtV,
fmtLabel: fmtV,
legend:
`${s.label} (dashed = median)` +
`Band shaded by grade tier` +
`Points labeled value · percentile — grade from the band tier (scale below)`,
subtitle: `${s.label} — recent trend vs normal`,
aria: `Recent ${s.label.toLowerCase()} versus the normal range.`,
});
}
// Precipitation as a line vs its normal range — same fan/median/labels style as
// temperature, so the metrics read consistently. Rain days are labeled; dry days
// sit on the baseline. The fan is the rain-intensity tier ramp.
function precipChart(days, n, xFor, C) {
return lineChart({
days, n, xFor, C, key: "precip", color: C.precip, fan: RAIN_FAN, hasNormals: true, baseZero: true,
getVal: (d) => (d.precip ? d.precip.value : null),
getPct: (d) => (d.precip ? d.precip.percentile : null),
// Rain days take their intensity-tier color; no-rain days warm with the dry
// streak (tan→red) so a dry spell reads as dry, matching the Dry chart.
dotColor: (d) => (d.precip && d.precip.value > 0 ? (TIER_COLORS[d.precip.class] || C.precip) : (drynessColor(d.dsr) || C.precip)),
fmtAxis: (v) => `${v.toFixed(2)}"`,
fmtLabel: (v) => `${v.toFixed(2)}"`,
labelOn: (v) => v > 0, // only label rain days; dry days rest on the baseline
legend:
`Precipitation (dashed = median)` +
`Band shaded by rain-intensity tier` +
`Rain days labeled amount · percentile (rain scale below)`,
subtitle: "Precipitation — daily totals vs normal",
aria: "Daily precipitation totals versus the normal range.",
});
}
// Dry streak as a line: days since last rain, dots warming with the streak; a rain
// day drops the line to 0 with a blue dot. (No climatology fan — it's not graded.)
function dryChart(days, n, xFor, C) {
return lineChart({
days, n, xFor, C, key: "dry", color: drynessColor(9), fan: null, hasNormals: false, baseZero: true,
getVal: (d) => (d.dsr == null ? null : d.dsr),
getPct: () => null,
dotColor: (d) => drynessColor(d.dsr),
fmtAxis: (v) => `${Math.round(v)}d`,
fmtLabel: (v) => `${Math.round(v)}`,
labelOn: (v) => v > 0, // label the streak length; rain days (0) just show the dot
legend:
`Days since last rain — dry streak` +
`Rain that day (streak = 0)`,
subtitle: "Dry streak — days since last measurable rain",
aria: "Days since last measurable rain, as a line.",
});
}
function attachChartHover(wrap) {
const svg = wrap.querySelector("svg");
const tip = wrap.querySelector("#chart-tip");
const cross = wrap.querySelector("#crosshair");
const C = chartPalette();
const pctLine = (label, g, unit, color) => {
if (!g) return `
${label} —
`;
const pct = g.percentile == null ? "" : ` · ${ord(g.percentile)} pct`;
const gc = TIER_COLORS[g.class] || "";
// "°" marks a temperature line — show it in the active unit; others are as-is.
const val = unit === "°" ? Math.round(toUnit(g.value)) : g.value;
return `
${label} ${val}${unit}${pct} ${g.grade}
`;
};
// 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 = `