diff --git a/static/app.js b/static/app.js index 99a02e9..db39ab3 100644 --- a/static/app.js +++ b/static/app.js @@ -88,14 +88,16 @@ async function runGrade() { render(data); } -const fmtTemp = (v) => (v == null ? "—" : `${Math.round(v)}°`); +// Temps flow through the shared unit formatter (°F from the API, shown in the +// active unit); the others are unit-agnostic. +const fmtTemp = (v) => window.Thermograph.fmtTemp(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³`); // 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 = (v) => (v == null ? "—" : `${Math.round(v)}°`); +const cTemp = (v) => window.Thermograph.fmtTemp(v); // 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) : "·"); @@ -246,6 +248,15 @@ function daysTable(rows, todayDate) { let recentData = null; // /grade response (past) — the base render let forecastData = null; // /forecast response (next 7 days), fetched in the background +// Repaint everything in the newly-picked unit, preserving the already-loaded +// forecast (render() clears it and refetches from cache; restore it right away). +window.Thermograph.onUnitChange(() => { + if (!recentData) return; + const fc = forecastData; + render(recentData); + if (fc) { forecastData = fc; renderSeries(); } +}); + const addDaysISO = (iso, n) => { const d = new Date(iso + "T00:00:00Z"); d.setUTCDate(d.getUTCDate() + n); @@ -592,12 +603,16 @@ function lineChart(cfg) { // 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 ? window.Thermograph.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: (v) => `${Math.round(v)}${s.sfx}`, - fmtLabel: (v) => `${Math.round(v)}${s.sfx}`, + fmtAxis: fmtV, + fmtLabel: fmtV, legend: `${s.label} (dashed = median)` + `Band shaded by grade tier` + @@ -669,7 +684,9 @@ function attachChartHover(wrap) { if (!g) return `
${label}
`; const pct = g.percentile == null ? "" : ` · ${ord(g.percentile)} pct`; const gc = TIER_COLORS[g.class] || ""; - return `
${label} ${g.value}${unit}${pct} ${g.grade}
`; + // "°" marks a temperature line — show it in the active unit; others are as-is. + const val = unit === "°" ? Math.round(window.Thermograph.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 @@ -689,7 +706,7 @@ function attachChartHover(wrap) { ${pctLine("Gust", d.gust, " mph", C.gust)} ${pctLine("Precip", d.precip, '"', C.precip)}
Dry ${d.dsr == null ? "—" : d.dsr === 0 ? "rain today" : `${d.dsr} d since rain`}
-
normal ${nt ? Math.round(nt.p50) : "—"}° / ${ni ? Math.round(ni.p50) : "—"}°
`; +
normal ${nt ? fmtTemp(nt.p50) : "—"} / ${ni ? fmtTemp(ni.p50) : "—"}
`; tip.hidden = false; const box = wrap.getBoundingClientRect(); let px = e.clientX - box.left + 12; @@ -730,7 +747,7 @@ function exportTableSvg(C) { const cell = (x, g, isPcp) => { if (!g) return ``; const color = TIER_COLORS[g.class] || C.ink; - const v = isPcp ? `${g.value.toFixed(2)}"` : `${Math.round(g.value)}°`; + const v = isPcp ? `${g.value.toFixed(2)}"` : fmtTemp(g.value); return `${v}` + `${esc(g.grade)}`; }; diff --git a/static/calendar.js b/static/calendar.js index 1d7f69b..9e8eaa4 100644 --- a/static/calendar.js +++ b/static/calendar.js @@ -52,7 +52,9 @@ const SCALE_RAIN = [ ]; const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; -const fmtTemp = (v) => (v == null ? "—" : `${Math.round(v)}°`); +// Temps go through the shared unit formatter (the day tooltip reads it live, so a +// unit switch shows on the next hover); the others are unit-agnostic. +const fmtTemp = (v) => window.Thermograph.fmtTemp(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³`); diff --git a/static/day.js b/static/day.js index 920c8b8..5dc4320 100644 --- a/static/day.js +++ b/static/day.js @@ -16,7 +16,8 @@ const PRECIP_COLORS = { }; const DRY_COLOR = "#c9a24a"; -const fmtTemp = (v) => (v == null ? "—" : `${Math.round(v)}°`); +// Temps go through the shared unit formatter; the rest are unit-agnostic. +const fmtTemp = (v) => window.Thermograph.fmtTemp(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³`); @@ -141,7 +142,11 @@ function finishDay(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 cell = data.cell; const place = data.place || `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`; diff --git a/static/nav.js b/static/nav.js index 9967875..e7f217d 100644 --- a/static/nav.js +++ b/static/nav.js @@ -49,10 +49,68 @@ function refreshNav(lat, lon, date) { }); } +// ---- temperature units (shared °C / °F toggle) ---- +// The API always speaks Fahrenheit; the unit is a pure display concern. Pages +// format temps through `fmtTempU`/`toUnit` and re-render on `onUnitChange`, so the +// stored/plotted values stay in °F and only the numbers shown flip. +const UNIT_KEY = "thermograph:unit"; +let _unit = (localStorage.getItem(UNIT_KEY) === "C") ? "C" : "F"; +const _unitCbs = []; + +function getUnit() { return _unit; } +// A Fahrenheit value as a number in the active unit. +function toUnit(vF) { return _unit === "C" ? (vF - 32) * 5 / 9 : vF; } +// A Fahrenheit value formatted as a rounded temperature in the active unit. +// `withLetter` appends the C/F letter (off by default — the nav toggle shows it). +function fmtTempU(vF, withLetter) { + if (vF == null || isNaN(vF)) return "—"; + return `${Math.round(toUnit(vF))}°${withLetter ? _unit : ""}`; +} +function onUnitChange(cb) { _unitCbs.push(cb); } +function setUnit(u) { + const nu = (u === "C") ? "C" : "F"; + if (nu === _unit) return; + _unit = nu; + try { localStorage.setItem(UNIT_KEY, _unit); } catch (e) {} + document.querySelectorAll(".unit-toggle").forEach(syncUnitToggle); + _unitCbs.forEach((cb) => { try { cb(_unit); } catch (e) {} }); +} + +function syncUnitToggle(wrap) { + wrap.querySelectorAll("button[data-unit]").forEach((b) => { + const on = b.dataset.unit === _unit; + b.classList.toggle("active", on); + b.setAttribute("aria-pressed", on ? "true" : "false"); + }); +} + +// Drop a segmented °F/°C control into the header's top-right, ahead of the view +// switcher. Skipped on Compare, whose comfort slider is inherently °F-based. +function buildUnitToggle() { + const brand = document.querySelector(".brand"); + if (!brand || brand.querySelector(".unit-toggle")) return; + const active = document.querySelector(".view-nav a.active"); + if (active && active.dataset.view === "compare") return; + const wrap = document.createElement("div"); + wrap.className = "unit-toggle"; + wrap.setAttribute("role", "group"); + wrap.setAttribute("aria-label", "Temperature units"); + wrap.innerHTML = '' + + ''; + wrap.addEventListener("click", (e) => { + const b = e.target.closest("button[data-unit]"); + if (b) setUnit(b.dataset.unit); + }); + const nav = brand.querySelector(".view-nav"); + brand.insertBefore(wrap, nav); // sits just left of the view tabs (top-right) + syncUnitToggle(wrap); +} + (function initNav() { document.querySelectorAll(".view-nav a[data-view]").forEach((a) => { a.dataset.base = a.getAttribute("href"); }); + buildUnitToggle(); const loc = loadLastLocation(); if (loc) refreshNav(loc.lat, loc.lon, loc.date); })(); @@ -278,4 +336,5 @@ function prefetchNeighbors(lat, lon) { } } -window.Thermograph = { loadLastLocation, saveLastLocation, getJSON, prefetchViews, hasFreshCache, TTL }; +window.Thermograph = { loadLastLocation, saveLastLocation, getJSON, prefetchViews, hasFreshCache, TTL, + getUnit, toUnit, fmtTemp: fmtTempU, onUnitChange, setUnit }; diff --git a/static/style.css b/static/style.css index db7cc2c..36762ca 100644 --- a/static/style.css +++ b/static/style.css @@ -246,6 +246,23 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } .tk-label { font-weight: 600; } .tk-note { margin: 0 0 16px; font-size: 12.5px; } +/* --- header °F/°C unit toggle (sits at the top-right, left of the tabs) --- */ +.unit-toggle { + margin-left: auto; align-self: center; display: inline-flex; gap: 2px; + background: var(--surface-2); border: 1px solid var(--border); + border-radius: 11px; padding: 3px; +} +.unit-toggle button { + border: 0; background: transparent; color: var(--muted); cursor: pointer; + font-size: 14px; font-weight: 700; padding: 8px 12px; border-radius: 8px; + min-height: 38px; min-width: 44px; line-height: 1; +} +.unit-toggle button.active { background: var(--accent); color: #fff; } +.unit-toggle button:hover:not(.active) { color: var(--text); } +/* The view switcher already claims the right edge; with the toggle to its left, + drop its auto-margin so the two sit together in the corner. */ +.unit-toggle + .view-nav { margin-left: 8px; } + /* --- shared header view switcher (Map · Calendar · Day) --- */ /* The bar is split into equal, fully-clickable segments (one per page) with a clear divider between them, so the whole width is a set of obvious tabs. */ @@ -578,8 +595,10 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } /* Let the header wrap so the view switcher drops to its own full-width row (and stays a comfortable touch target) instead of crowding the title. */ .brand { flex-wrap: wrap; } - /* Full-width bar; each tab is an equal, fully-tappable third. */ + /* Full-width bar; each tab is an equal, fully-tappable third. The unit toggle + stays on the title row (pushed to the top-right); the tabs wrap below it. */ .view-nav { margin-left: 0; width: 100%; } + .unit-toggle + .view-nav { margin-left: 0; } .view-nav a { min-height: 44px; padding: 8px 12px; } main { padding: 14px 14px 40px; }