Reverse-geocode at zoom 14 and prefer neighbourhood/suburb ahead of the
city so labels read 'Gowanus, New York' when OSM has that detail, falling
back to the city (and county) when it doesn't.
Replace the recurring '±7-day window' phrasing in the UI with plain
language ('a typical day', 'days around this date', 'typical climate').
215 lines
10 KiB
JavaScript
215 lines
10 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.
|
||
// Self-contained; re-declares the small shared tier constants (like calendar.js)
|
||
// so the page stays independent of app.js.
|
||
|
||
const TIER_COLORS = {
|
||
"rec-hot": "#8f0e20", "very-hot": "#dd6b52", "hot": "#ef9351", "warm": "#f6c18a",
|
||
"normal": "#4a9d5b",
|
||
"cool": "#92c5de", "cold": "#4393c3", "very-cold": "#2166ac", "rec-cold": "#0a2f6b",
|
||
};
|
||
const PRECIP_COLORS = {
|
||
"wet-1": "#d9f0d3", "wet-2": "#b7e2b1", "wet-3": "#8fd18f", "wet-4": "#5cba9f", "wet-5": "#35a1c0",
|
||
"wet-6": "#2b8cbe", "wet-7": "#226bb3", "wet-8": "#164a97", "wet-9": "#08306b",
|
||
};
|
||
const DRY_COLOR = "#c9a24a";
|
||
|
||
const fmtTemp = (v) => (v == null ? "—" : `${Math.round(v)}°`);
|
||
const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`);
|
||
const fmtWind = (v) => (v == null ? "—" : `${Math.round(v)} mph`);
|
||
const fmtHumid = (v) => (v == null ? "—" : `${v.toFixed(1)} g/m³`);
|
||
const ord = (n) => {
|
||
const r = Math.round(n), s = ["th", "st", "nd", "rd"], v = r % 100;
|
||
return r + (s[(v - 20) % 10] || s[v] || s[0]);
|
||
};
|
||
|
||
// 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] || "");
|
||
|
||
// Monochrome line icons (currentColor) — no emoji, matching the site's dark look.
|
||
const WX = (paths) =>
|
||
`<svg class="wx" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
||
const WX_CLOUD = `<path d="M20 16.6A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25"/>`;
|
||
const WX_ICONS = {
|
||
sun: WX(`<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.2" y1="4.2" x2="5.6" y2="5.6"/><line x1="18.4" y1="18.4" x2="19.8" y2="19.8"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.2" y1="19.8" x2="5.6" y2="18.4"/><line x1="18.4" y1="5.6" x2="19.8" y2="4.2"/>`),
|
||
drizzle: WX(`${WX_CLOUD}<line x1="8" y1="19" x2="8" y2="21"/><line x1="16" y1="19" x2="16" y2="21"/><line x1="12" y1="20" x2="12" y2="22"/>`),
|
||
rain: WX(`${WX_CLOUD}<line x1="8" y1="19" x2="8" y2="22"/><line x1="16" y1="19" x2="16" y2="22"/><line x1="12" y1="19" x2="12" y2="23"/>`),
|
||
storm: WX(`<path d="M19 16.9A5 5 0 0 0 18 7h-1.26a8 8 0 1 0-11.62 9"/><polyline points="13 11 9 17 15 17 11 23"/>`),
|
||
snow: WX(`<path d="M20 17.6A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25"/><line x1="8" y1="18" x2="8" y2="18"/><line x1="12" y1="20" x2="12" y2="20"/><line x1="16" y1="18" x2="16" y2="18"/><line x1="12" y1="16" x2="12" y2="16"/>`),
|
||
};
|
||
|
||
// Plain-language "what the day was like" descriptor from the observed values —
|
||
// mirrors the calendar tooltip. (No dry-streak here, so no-rain days read "clear".)
|
||
function weatherType(t, p) {
|
||
const wet = p != null && p >= 0.01;
|
||
const freezing = t != null && t <= 34;
|
||
const temp = t == null ? "" :
|
||
t >= 95 ? "Scorching" : t >= 85 ? "Hot" : t >= 72 ? "Warm" :
|
||
t >= 58 ? "Mild" : t >= 45 ? "Cool" : t >= 32 ? "Cold" : "Frigid";
|
||
let sky, icon;
|
||
if (wet && freezing) { sky = p >= 0.3 ? "heavy snow" : "snow"; icon = WX_ICONS.snow; }
|
||
else if (wet && p >= 1.0) { sky = "downpour"; icon = WX_ICONS.storm; }
|
||
else if (wet && p >= 0.3) { sky = "rain"; icon = WX_ICONS.rain; }
|
||
else if (wet) { sky = "light rain"; icon = WX_ICONS.drizzle; }
|
||
else { sky = "clear"; icon = WX_ICONS.sun; }
|
||
const text = !temp ? sky : wet ? `${temp}, ${sky}` : `${temp} & ${sky}`;
|
||
return { icon, text: text.charAt(0).toUpperCase() + text.slice(1) };
|
||
}
|
||
|
||
let selected = null; // {lat, lon}
|
||
let curDate = null; // "YYYY-MM-DD" currently shown
|
||
let latest = null; // newest date available in the record
|
||
const todayISO = () => new Date().toISOString().slice(0, 10);
|
||
|
||
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");
|
||
findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Find a location</span>`;
|
||
findBtn.addEventListener("click", () => {
|
||
window.LocationPicker.open(selected, (lat, lon) => { selected = { lat, lon }; 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 ----
|
||
async function fetchDay() {
|
||
if (!selected) return;
|
||
const dateQ = curDate ? `&date=${curDate}` : "";
|
||
history.replaceState(null, "", `#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}${curDate ? `&date=${curDate}` : ""}`);
|
||
placeholder.hidden = true;
|
||
dayHead.hidden = false;
|
||
dayHead.innerHTML = `<p class="spinner">Building the percentile breakdown…</p>`;
|
||
dayBody.innerHTML = "";
|
||
let data;
|
||
try {
|
||
data = await window.Thermograph.getJSON(`api/v2/day?lat=${selected.lat}&lon=${selected.lon}${dateQ}`, window.Thermograph.TTL.day);
|
||
} catch (err) {
|
||
dayHead.innerHTML = `<p class="error">${err.message}</p>`;
|
||
return;
|
||
}
|
||
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
|
||
}
|
||
|
||
function render(data) {
|
||
const d = data.detail;
|
||
const cell = data.cell;
|
||
const place = data.place || `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`;
|
||
locLabel.textContent = place;
|
||
locLabel.hidden = false;
|
||
findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Change location</span>`;
|
||
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} · ~${cell.area_sq_mi} sq mi cell
|
||
· ${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();
|
||
}
|
||
})();
|