// Thermograph single-day detail subpage — for one day + location, shows the value // at every percentile tier boundary in that day-of-year's ±7-day climate window, // and where the observed values land. Talks to /api/v2/day + /api/v2/geocode. import { loadLastLocation, saveLastLocation, locHash } from "./nav.js"; import { fmtTemp, onUnitChange } from "./units.js"; import "./account.js"; // header sign-in entry + notification bell import { getJSON, TTL, prefetchViews } from "./cache.js"; import { initFindButton, setFindLabel } from "./mappicker.js"; import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, ord, fmtPrecip, fmtWind, fmtHumid, todayISO, weatherType, placeLabel } from "./shared.js"; // Color a tier the same way the calendar cell would be colored for it. const tierColor = (c) => (c === "dry" ? DRY_COLOR : TIER_COLORS[c] || PRECIP_COLORS[c] || ""); let selected = null; // {lat, lon} let curDate = null; // "YYYY-MM-DD" currently shown let latest = null; // newest date available in the record const placeholder = document.getElementById("day-placeholder"); const dayHead = document.getElementById("day-head"); const dayBody = document.getElementById("day-body"); const dateInput = document.getElementById("date-input"); // ---- 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) => { selected = { lat, lon }; // Show the raw coordinates immediately; render() replaces them with the // resolved neighborhood + city name once the day fetch returns. locLabel.textContent = `${lat.toFixed(3)}, ${lon.toFixed(3)}`; locLabel.hidden = false; fetchDay(); }); // ---- date navigation ---- dateInput.addEventListener("change", () => { if (dateInput.value) { curDate = dateInput.value; fetchDay(); } }); document.getElementById("prev-day").addEventListener("click", () => stepDay(-1)); document.getElementById("next-day").addEventListener("click", () => stepDay(1)); function stepDay(delta) { if (!curDate) return; const d = new Date(curDate + "T00:00:00"); d.setDate(d.getDate() + delta); const iso = d.toISOString().slice(0, 10); if (iso > todayISO()) return; // don't step past today curDate = iso; fetchDay(); } // ---- fetch + render ---- let daySeq = 0; // supersedes an in-flight load when the user picks a new spot/date async function fetchDay() { if (!selected) return; const dateQ = curDate ? `&date=${curDate}` : ""; history.replaceState(null, "", locHash(selected.lat, selected.lon, curDate)); placeholder.hidden = true; dayHead.hidden = false; const seq = ++daySeq; // Delay the spinner: a cache-served render lands in a few ms, so blanking the // page immediately would just flash. Only a slow (network) load shows it. const spin = setTimeout(() => { if (seq !== daySeq) return; dayHead.innerHTML = `
Building the percentile breakdown…
`; dayBody.innerHTML = ""; }, 150); let data; try { // Stale-while-revalidate: a stale cached copy renders immediately; the update // callback repaints if the background revalidation finds new data. data = await getJSON( `api/v2/day?lat=${selected.lat}&lon=${selected.lon}${dateQ}`, TTL.day, false, (upd) => { if (seq === daySeq) finishDay(upd); }); } catch (err) { clearTimeout(spin); if (seq !== daySeq) return; dayHead.innerHTML = `${err.message}
`; dayBody.innerHTML = ""; return; } clearTimeout(spin); if (seq !== daySeq) return; finishDay(data); } function finishDay(data) { latest = data.latest; curDate = data.detail.date; dateInput.value = curDate; // Allow navigating up to today (recent days beyond the archive come from the // forecast bundle), not just the latest fully-archived day. dateInput.max = todayISO(); document.getElementById("next-day").disabled = curDate >= todayISO(); saveLastLocation(selected.lat, selected.lon, curDate); render(data); prefetchViews(selected.lat, selected.lon, ["day"]); // warm weekly/calendar/forecast } let lastData = null; // most recent /day response, for repainting on a unit switch onUnitChange(() => { if (lastData) render(lastData); }); function render(data) { lastData = data; const d = data.detail; const place = placeLabel(data); locLabel.textContent = place; locLabel.hidden = false; setFindLabel(findBtn, "Change location"); const dt = new Date(d.date + "T00:00:00"); const nice = dt.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" }); const yrs = d.year_range ? `${d.year_range[0]}–${d.year_range[1]}` : "—"; // Weather summary from the observed high + precip (omitted for days with no obs yet). const th = d.metrics.tmax.obs ? d.metrics.tmax.obs.value : null; const pr = d.metrics.precip.obs ? d.metrics.precip.obs.value : null; const wt = th != null || pr != null ? weatherType(th, pr) : null; dayHead.innerHTML = `${wt.icon} ${wt.text}
` : ""} `; dayBody.innerHTML = [ ladderCard("Daily high", d.metrics.tmax, fmtTemp, "temp"), ladderCard("Daily low", d.metrics.tmin, fmtTemp, "temp"), ladderCard("Feels like", d.metrics.feels, fmtTemp, "temp"), ladderCard("Humidity", d.metrics.humid, fmtHumid, "temp"), ladderCard("Wind speed", d.metrics.wind, fmtWind, "temp"), ladderCard("Wind gust", d.metrics.gust, fmtWind, "temp"), ladderCard("Precipitation", d.metrics.precip, fmtPrecip, "precip"), ].join(""); } // Collapse a space-separated unit shared by both bounds ("17.3 g/m³–19.9 g/m³" // → "17.3–19.9 g/m³") so ranges fit the narrow value column on phones. Attached // units (76°, 0.25") pass through untouched. function fmtRange(fmt, lo, hi) { const a = fmt(lo), b = fmt(hi); const i = a.lastIndexOf(" "); if (i > 0 && b.endsWith(a.slice(i))) return `${a.slice(0, i)}–${b}`; return `${a}–${b}`; } // The unit-less value for the ◀ marker — the unit is already on the range beside // it and in the card's summary, and dropping it keeps the marker on one line. const bareVal = (fmt, v) => { const s = fmt(v), i = s.lastIndexOf(" "); return i > 0 ? s.slice(0, i) : s; }; // One metric card: an observed summary line + the tier ladder (highest tier on // top). The tier the observed value falls into is highlighted with its value. function ladderCard(title, m, fmt, kind) { if (!m || !m.ladder) return ""; const obs = m.obs; const activeClass = obs ? obs.class : null; let summary; if (obs) { const pct = obs.percentile == null ? "" : ` · ${ord(obs.percentile)} pct`; summary = `${fmt(obs.value)} `; } else { summary = ``; } // Left: a per-metric summary. Right: the bold, right-aligned Historical Range. const noteLeft = kind === "temp" ? `median ${fmt(m.ladder.median)}` : `${m.ladder.rain_days.toLocaleString()} rain days · dry ${m.ladder.dry_pct}%`; const note = `${noteLeft}` + `Historical Range ${fmtRange(fmt, m.ladder.min, m.ladder.max)}`; const rows = m.ladder.tiers.map((t) => { const active = t.c === activeClass ? " active" : ""; // Dry has no rain percentile — leave that column blank and show the threshold // as the value. Other tiers show their percentile range and value range. let val, pct = t.range; if (t.c === "dry") { val = t.range; pct = ""; } else if (t.hi == null) val = `> ${fmt(t.lo)}`; // top tier: strictly beyond p99 else if (t.lo == null) val = `< ${fmt(t.hi)}`; // bottom tier: strictly below p1 else val = fmtRange(fmt, t.lo, t.hi); const marker = t.c === activeClass && obs ? `◀ ${bareVal(fmt, obs.value)}` : ""; return `${note}