thermograph/static/day.js
Emi Griffith a251b0df23 Extract shared.js: one home for tier colors, scales, formatters, helpers (#47)
The page scripts each re-declared the shared presentation layer — the tier
color table existed in four places (app.js, calendar.js, day.js, style.css)
and the scale label tables in three (plus legend.html's own drifting copy),
alongside per-page copies of the dryness ramp, formatters, ord, todayISO,
esc, the weather icons/summary, month helpers and the 2-year range chunker.
~240 duplicated lines deleted (net -236 with the new module included).

- frontend/shared.js (IIFE, extends window.Thermograph): tier colors read
  from style.css's :root custom properties at load — the CSS is now the
  single source of truth; the JS map exists only because inline-SVG work
  (chart + PNG export) needs literal values, with hex fallbacks for a
  missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord,
  todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker,
  and the weatherType summary (dsr-aware; the day page just omits dsr).
- nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in
  the shared classic-script scope, and a leaked locHash collided with page
  destructuring (caught by the browser smoke, not by node --check).
  locHash is now exported and used by all 8 former hand-built hash sites.
- mappicker.js: initFindButton/setFindLabel replace the Find-button block
  each page rebuilt.
- legend.html renders its scales from the shared tables, so the guide can
  no longer drift from what the app shows.

Verified: node --check on all JS; 108 backend tests; headless-Chromium
smoke over all five pages against live data — no console/page errors,
legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle,
calendar grid + key + metric switch, day 7 ladders + weather icon,
compare seeded rank card.
2026-07-11 20:21:48 +00:00

196 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
// 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.
// Shared tier colors, formatters, weather summary and helpers live in shared.js.
const { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, ord, fmtPrecip, fmtWind, fmtHumid,
todayISO, weatherType, placeLabel, locHash } = window.Thermograph;
const fmtTemp = (v) => window.Thermograph.fmtTemp(v);
// 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");
window.LocationPicker.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 = `<p class="spinner">Building the percentile breakdown…</p>`;
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 window.Thermograph.getJSON(
`api/v2/day?lat=${selected.lat}&lon=${selected.lon}${dateQ}`, window.Thermograph.TTL.day,
false, (upd) => { if (seq === daySeq) finishDay(upd); });
} catch (err) {
clearTimeout(spin);
if (seq !== daySeq) return;
dayHead.innerHTML = `<p class="error">${err.message}</p>`;
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();
window.Thermograph.saveLastLocation(selected.lat, selected.lon, curDate);
render(data);
window.Thermograph.prefetchViews(selected.lat, selected.lon, "day"); // warm weekly/calendar/forecast
}
let lastData = null; // most recent /day response, for repainting on a unit switch
window.Thermograph.onUnitChange(() => { if (lastData) render(lastData); });
function render(data) {
lastData = data;
const d = data.detail;
const place = placeLabel(data);
locLabel.textContent = place;
locLabel.hidden = false;
window.LocationPicker.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 = `
<h2>${nice}</h2>
${wt ? `<p class="day-weather">${wt.icon} ${wt.text}</p>` : ""}
<p class="meta">${place}
· ${d.n_samples.toLocaleString()} days around this date (${yrs})</p>`;
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("");
}
// 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 = `<span class="day-obs" style="--cat:${tierColor(obs.class)}">${fmt(obs.value)}</span>
<span class="day-obs-meta">${obs.grade}${pct}</span>`;
} else {
summary = `<span class="day-obs-meta">No observation for this day yet</span>`;
}
// 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 = `<span class="ladder-note-left">${noteLeft}</span>` +
`<span class="ladder-range">Historical Range ${fmt(m.ladder.min)}${fmt(m.ladder.max)}</span>`;
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 = `${fmt(t.lo)}${fmt(t.hi)}`;
const marker = t.c === activeClass && obs
? `<span class="ladder-mark">◀ ${fmt(obs.value)}</span>` : "";
return `<div class="ladder-row${active}">
<span class="ladder-sw" style="background:${tierColor(t.c)}"></span>
<span class="ladder-label" style="--cat:${tierColor(t.c)}">${t.label}</span>
<span class="ladder-pct">${pct}</span>
<span class="ladder-val">${val}</span>
<span class="ladder-mk">${marker}</span>
</div>`;
}).join("");
return `<section class="ladder-card">
<div class="ladder-head">
<h3>${title}</h3>
<div class="ladder-summary">${summary}</div>
</div>
<p class="ladder-note">${note}</p>
<div class="ladder">${rows}</div>
</section>`;
}
// ---- restore (hash from a shared link, else the last place you looked at) ----
(function restore() {
const loc = window.Thermograph.loadLastLocation();
if (loc) {
selected = { lat: loc.lat, lon: loc.lon };
curDate = loc.date || todayISO(); // default to today when no date is given
fetchDay();
}
})();