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.
196 lines
8.4 KiB
JavaScript
196 lines
8.4 KiB
JavaScript
"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();
|
||
}
|
||
})();
|