Merge remote-tracking branch 'origin/dev' into map-color-boost
# Conflicts: # frontend/mappicker.js # frontend/style.css
This commit is contained in:
commit
52bfeef8bd
5 changed files with 113 additions and 11 deletions
|
|
@ -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:
|
||||
`<span><i style="background:${s.color}"></i>${s.label} (dashed = median)</span>` +
|
||||
`<span><i class="swatch-band" style="background:${hexA(TIER_COLORS.normal, 0.5)};border-color:${TIER_COLORS.normal}"></i>Band shaded by grade tier</span>` +
|
||||
|
|
@ -669,7 +684,9 @@ function attachChartHover(wrap) {
|
|||
if (!g) return `<div><b style="color:${color}">${label}</b> —</div>`;
|
||||
const pct = g.percentile == null ? "" : ` · ${ord(g.percentile)} pct`;
|
||||
const gc = TIER_COLORS[g.class] || "";
|
||||
return `<div><b style="color:${color}">${label}</b> ${g.value}${unit}${pct} <span class="tt-grade" style="color:${gc}">${g.grade}</span></div>`;
|
||||
// "°" 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 `<div><b style="color:${color}">${label}</b> ${val}${unit}${pct} <span class="tt-grade" style="color:${gc}">${g.grade}</span></div>`;
|
||||
};
|
||||
|
||||
// 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)}
|
||||
<div><b>Dry</b> ${d.dsr == null ? "—" : d.dsr === 0 ? "rain today" : `${d.dsr} d since rain`}</div>
|
||||
<div class="tt-norm">normal ${nt ? Math.round(nt.p50) : "—"}° / ${ni ? Math.round(ni.p50) : "—"}°</div>`;
|
||||
<div class="tt-norm">normal ${nt ? fmtTemp(nt.p50) : "—"} / ${ni ? fmtTemp(ni.p50) : "—"}</div>`;
|
||||
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 `<text x="${x}" y="0" font-size="12" fill="${C.muted}">—</text>`;
|
||||
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 `<text x="${x}" y="0"><tspan font-size="13" font-weight="700" fill="${C.ink}">${v}</tspan>` +
|
||||
`<tspan dx="7" font-size="11" font-weight="600" fill="${color}">${esc(g.grade)}</tspan></text>`;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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³`);
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
|
|
|
|||
|
|
@ -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 = '<button type="button" data-unit="F">°F</button>'
|
||||
+ '<button type="button" data-unit="C">°C</button>';
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue