diff --git a/static/app.js b/static/app.js index f2f1857..9d6704d 100644 --- a/static/app.js +++ b/static/app.js @@ -1,6 +1,6 @@ // Weekly (map) page. Imports replace the old script-tag ordering contract. import { loadLastLocation, saveLastLocation, locHash } from "./nav.js"; -import { fmtTemp, onUnitChange } from "./units.js"; +import { fmtTemp, onUnitChange, toWind, windUnit, precipUnit } from "./units.js"; import "./account.js"; // header sign-in entry + notification bell import { getJSON, TTL, prefetchViews } from "./cache.js"; import { initFindButton, setFindLabel } from "./mappicker.js"; @@ -186,9 +186,11 @@ async function runGrade() { // column headers, so values stay short). Dry days label the running dry streak // (see precipCell) rather than the rain amount. const cTemp = fmtTemp; // 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) : "·"); +const cHum = (v) => (v == null ? "—" : `${Math.round(v)}%`); // relative %, unit-invariant +const cWind = (v) => (v == null ? "—" : `${Math.round(toWind(v))}`); +// fmtPrecip carries the precision: two decimals for inches, one for mm (0.04" and +// 1.0mm are the same amount, and "1.02 mm" would imply precision we don't have). +const cRain = (v) => (v == null ? "—" : v > 0 ? fmtPrecip(v, false) : "·"); // Monochrome inline icons (currentColor) so the UI chrome matches the dark theme // instead of using colorful emoji. Feather-style line paths. @@ -302,9 +304,15 @@ function precipCell(d, isFc, isToday) { // Compact "graded days" table — a ROW per metric, a COLUMN per day (newest first). // Wide runs (e.g. 2 weeks) scroll horizontally inside their own container; the // metric-label column stays pinned. Tapping any cell/day opens that day's detail. -const RD_METRICS = [ +// Wind and rain cells are bare numbers to keep the grid narrow, so their row +// label carries the unit — and has to be built per render, since the unit can +// change under us (onUnitChange re-renders). +const RD_METRICS = () => [ ["tmax", "Hi", cTemp], ["tmin", "Lo", cTemp], ["feels", "Feel", cTemp], - ["humid", "Hum", cHum], ["wind", "Wind", cWind], ["gust", "Gust", cWind], ["precip", "Rain", cRain], + ["humid", "Hum", cHum], + ["wind", `Wind ${windUnit()}`, cWind], + ["gust", `Gust ${windUnit()}`, cWind], + ["precip", `Rain ${precipUnit()}`, cRain], ]; function daysTable(rows, targetDate) { if (!rows.length) return ""; @@ -319,7 +327,7 @@ function daysTable(rows, targetDate) { const cls = "rd-colh" + (isTarget(d) ? " rd-today" : isFc(d) ? " rd-fc" : ""); return `
${dow}${md}
`; }).join(""); - const body = RD_METRICS.map(([key, label, fmt]) => + const body = RD_METRICS().map(([key, label, fmt]) => `
${label}
` + rows.map((d) => key === "precip" ? precipCell(d, isFc(d), isTarget(d)) diff --git a/static/chart.js b/static/chart.js index 13939a9..ace1159 100644 --- a/static/chart.js +++ b/static/chart.js @@ -2,7 +2,8 @@ // value trace + labeled points) with per-metric wrappers, the site-matched // palette, and the pointer-driven hover. Extracted from app.js so any page can // plot a metric series; app.js keeps only its page orchestration (buildChart). -import { fmtTemp, toUnit } from "./units.js"; +import { fmtTemp, toUnit, toWind, fmtPrecip, fmtWind, fmtHumid, + windUnit, humidUnit } from "./units.js"; import { TIER_COLORS, drynessColor, pctOrd } from "./shared.js"; // #rrggbb -> rgba() with alpha, so band tints derive from the same site colors. @@ -74,13 +75,15 @@ const pget = (o, k) => { // Per-series display meta for the diverging-scale metrics. `sfx` is the unit // appended to axis + point labels ("°"); wind carries its unit in the subtitle // instead of on every point to keep the plot uncluttered. +// `kind` picks the formatter and says whether the value converts; the labels that +// name a unit are built per render so they follow the active unit system. const SERIES = { - tmax: (C) => ({ color: C.hi, label: "Daily high", sfx: "°" }), - tmin: (C) => ({ color: C.lo, label: "Daily low", sfx: "°" }), - feels: (C) => ({ color: C.feels, label: "Feels like", sfx: "°" }), - humid: (C) => ({ color: C.humid, label: "Absolute humidity (g/m³)", sfx: "" }), - wind: (C) => ({ color: C.wind, label: "Wind speed (mph)", sfx: "" }), - gust: (C) => ({ color: C.gust, label: "Wind gust (mph)", sfx: "" }), + tmax: (C) => ({ color: C.hi, label: "Daily high", sfx: "°", kind: "temp" }), + tmin: (C) => ({ color: C.lo, label: "Daily low", sfx: "°", kind: "temp" }), + feels: (C) => ({ color: C.feels, label: "Feels like", sfx: "°", kind: "temp" }), + humid: (C) => ({ color: C.humid, label: `Absolute humidity (${humidUnit()})`, sfx: "", kind: "humid" }), + wind: (C) => ({ color: C.wind, label: `Wind speed (${windUnit()})`, sfx: "", kind: "wind" }), + gust: (C) => ({ color: C.gust, label: `Wind gust (${windUnit()})`, sfx: "", kind: "wind" }), }; // Percentile "fan" tiers — bands between successive percentile marks, colored to @@ -179,10 +182,11 @@ function lineChart(cfg) { // One temperature-scale series (High/Low/Feels/Wind/Gust). export 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 ? toUnit(v) : v)}${s.sfx}`; + // Labels show the active unit; the plot keeps its imperial domain — only the + // labels convert — so point positions and the fan geometry hold. + const fmtV = (v) => (s.kind === "temp" + ? `${Math.round(toUnit(v))}${s.sfx}` + : s.kind === "wind" ? `${Math.round(toWind(v))}` : `${Math.round(v)}`); 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), @@ -209,8 +213,8 @@ export function precipChart(days, n, xFor, C) { // Rain days take their intensity-tier color; no-rain days warm with the dry // streak (tan→red) so a dry spell reads as dry, matching the Dry chart. dotColor: (d) => (d.precip && d.precip.value > 0 ? (TIER_COLORS[d.precip.class] || C.precip) : (drynessColor(d.dsr) || C.precip)), - fmtAxis: (v) => `${v.toFixed(2)}"`, - fmtLabel: (v) => `${v.toFixed(2)}"`, + fmtAxis: (v) => fmtPrecip(v, false), + fmtLabel: (v) => fmtPrecip(v, false), labelOn: (v) => v > 0, // only label rain days; dry days rest on the baseline legend: `Precipitation (dashed = median)` + @@ -245,13 +249,18 @@ export function attachChartHover(wrap, days) { const tip = wrap.querySelector("#chart-tip"); const cross = wrap.querySelector("#crosshair"); const C = chartPalette(); - const pctLine = (label, g, unit, color) => { + // Every measurable line converts to the active unit system; `kind` picks how. + const TIP_FMT = { + temp: (v) => `${Math.round(toUnit(v))}°`, + humid: (v) => fmtHumid(v), + wind: (v) => fmtWind(v), + precip: (v) => fmtPrecip(v), + }; + const pctLine = (label, g, kind, color) => { if (!g) return `
${label}
`; const pct = g.percentile == null ? "" : ` · ${pctOrd(g.percentile)} pct`; const gc = TIER_COLORS[g.class] || ""; - // "°" marks a temperature line — show it in the active unit; others are as-is. - const val = unit === "°" ? Math.round(toUnit(g.value)) : g.value; - return `
${label} ${val}${unit}${pct} ${g.grade}
`; + return `
${label} ${TIP_FMT[kind](g.value)}${pct} ${g.grade}
`; }; // Pointer events cover mouse, touch and pen with one path, so tapping/dragging @@ -263,13 +272,13 @@ export function attachChartHover(wrap, days) { const dt = new Date(d.date + "T00:00:00"); const nt = d.normals.tmax, ni = d.normals.tmin; tip.innerHTML = `
${dt.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })}
- ${pctLine("High", d.tmax, "°", C.hi)} - ${pctLine("Low", d.tmin, "°", C.lo)} - ${pctLine("Feels", d.feels, "°", C.feels)} - ${pctLine("Humidity", d.humid, " g/m³", C.humid)} - ${pctLine("Wind", d.wind, " mph", C.wind)} - ${pctLine("Gust", d.gust, " mph", C.gust)} - ${pctLine("Precip", d.precip, '"', C.precip)} + ${pctLine("High", d.tmax, "temp", C.hi)} + ${pctLine("Low", d.tmin, "temp", C.lo)} + ${pctLine("Feels", d.feels, "temp", C.feels)} + ${pctLine("Humidity", d.humid, "humid", C.humid)} + ${pctLine("Wind", d.wind, "wind", C.wind)} + ${pctLine("Gust", d.gust, "wind", C.gust)} + ${pctLine("Precip", d.precip, "precip", C.precip)}
Dry ${d.dsr == null ? "—" : d.dsr === 0 ? "rain today" : `${d.dsr} d since rain`}
normal ${nt ? fmtTemp(nt.p50) : "—"} / ${ni ? fmtTemp(ni.p50) : "—"}
`; tip.hidden = false; diff --git a/static/climate.js b/static/climate.js index d30f9b6..c252824 100644 --- a/static/climate.js +++ b/static/climate.js @@ -1,16 +1,31 @@ -// Client-side temperature converter for the server-rendered climate/city/record -// pages. Those pages render every temperature as `72°F` (see content.py `_temp`/`_temp_bare`) so the -// value is real in the HTML for crawlers and no-JS visitors. Here we rewrite each -// span to the active unit on load and whenever the °F/°C toggle changes — reusing -// the same unit machinery as the interactive pages. Importing units.js also boots -// its toggle-injection IIFE, so this one script gives the pages a working toggle. -import { fmtTemp, onUnitChange } from "./units.js"; +// Client-side unit converter for the server-rendered climate/city/record pages. +// Those pages render every measurement as a span carrying the imperial value — +// `23°C`, `.precip[data-precip-in]`, +// `.wind[data-wind-mph]` (see content.py `_temp`/`_precip`/`_wind`) — so the number +// is real in the HTML for crawlers and no-JS visitors, already in the city's own +// convention. Here we rewrite each span to the active unit on load and whenever the +// toggle changes, reusing the same unit machinery as the interactive pages. +// Importing units.js also boots its toggle-injection IIFE, so this one script gives +// the pages a working toggle. +// +// Absolute humidity is g/m³ in both systems, so it is rendered as plain text with +// no span and nothing to repaint here. +import { fmtTemp, fmtPrecip, fmtWind, onUnitChange } from "./units.js"; + +// [selector, data attribute, formatter] — the attribute is always the imperial +// value, which is what makes repainting idempotent. +const PAINTERS = [ + [".temp[data-temp-f]", "tempF", (v, el) => fmtTemp(v, !el.hasAttribute("data-bare"))], + [".precip[data-precip-in]", "precipIn", (v) => fmtPrecip(v)], + [".wind[data-wind-mph]", "windMph", (v) => fmtWind(v)], +]; function paint() { - for (const el of document.querySelectorAll(".temp[data-temp-f]")) { - const f = parseFloat(el.dataset.tempF); - if (!Number.isNaN(f)) el.textContent = fmtTemp(f, !el.hasAttribute("data-bare")); + for (const [sel, attr, fmt] of PAINTERS) { + for (const el of document.querySelectorAll(sel)) { + const v = parseFloat(el.dataset[attr]); + if (!Number.isNaN(v)) el.textContent = fmt(v, el); + } } } diff --git a/static/legend.html b/static/legend.html index 8ac9db2..786ac88 100644 --- a/static/legend.html +++ b/static/legend.html @@ -79,14 +79,14 @@

The metrics

-
High / Low
The day's highest and lowest air temperature (°F).
+
High / Low
The day's highest and lowest air temperature (°F).
Feels
Apparent temperature — what the air actually felt like once humidity and wind are factored in, taking whichever apparent extreme (heat-index high or wind-chill low) sits further from a temperate baseline, graded against its own history.
Humid
Absolute humidity: grams of water vapor per cubic meter of air (g/m³).
-
Wind
Average sustained wind speed (mph).
-
Gust
Peak wind gust for the day (mph).
-
Precip
Total precipitation (inches), graded by intensity among rain days.
+
Wind
Average sustained wind speed (mph).
+
Gust
Peak wind gust for the day (mph).
+
Precip
Total precipitation (inches), graded by intensity among rain days.
Dry streak
Consecutive days since the last measurable rain — the color deepens the longer it's been dry, and resets to blue on a rain day.
diff --git a/static/shared.js b/static/shared.js index 91bad7c..0c221f5 100644 --- a/static/shared.js +++ b/static/shared.js @@ -2,7 +2,7 @@ // constant and helper here previously existed as a copy in app.js, calendar.js, // day.js, compare.js and/or legend.html; pages import what they need so a tier // color, scale label or formatter has exactly one home. -import { fmtTemp } from "./units.js"; +import { fmtTemp, fmtPrecip, fmtWind, fmtHumid, getUnit } from "./units.js"; // ---- tier colors ---- // style.css's :root custom properties are the source of truth (they're not // re-themed, so reading them once at load is safe). The JS map exists because @@ -113,14 +113,16 @@ export function metricBuckets(days, metric) { }); } -// Format a single metric value in the metric's own unit — temps follow the active -// °C/°F unit; humidity, wind, precip and dry-streak are fixed-unit. +// Format a single metric value in the metric's own unit. Everything measurable +// follows the active unit system via units.js; a dry-streak is a day count and +// has no unit to follow. function fmtMetricVal(unit, v) { if (v == null || isNaN(v)) return ""; if (unit === "temp") return fmtTemp(v); - if (unit === "humid") return `${Math.round(v)} g/m³`; - if (unit === "wind") return `${Math.round(v)} mph`; - if (unit === "precip") return `${v.toFixed(2)}″`; + if (unit === "humid") return fmtHumid(v); + if (unit === "wind") return fmtWind(v); + // The tier bar is tight on width, so precip keeps the ″ glyph over " mm"/" in". + if (unit === "precip") return `${fmtPrecip(v, false)}${getUnit() === "C" ? "mm" : "″"}`; if (unit === "dsr") return `${Math.round(v)}d`; return `${Math.round(v)}`; } @@ -172,10 +174,11 @@ export function distStrip(buckets, showCount) { } // ---- formatters & tiny utils ---- -// (Temperatures go through nav.js's unit-aware fmtTemp; these are unit-agnostic.) -export const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`); -export const fmtWind = (v) => (v == null ? "—" : `${Math.round(v)} mph`); -export const fmtHumid = (v) => (v == null ? "—" : `${v.toFixed(1)} g/m³`); +// Every measure follows the active unit system; units.js owns the conversions and +// these are re-exported so the pages that already import them here keep working. +// (Re-exporting the imported bindings, not `export … from` — this module already +// imports them above, and declaring both is a duplicate-binding SyntaxError.) +export { fmtPrecip, fmtWind, fmtHumid }; export 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]); diff --git a/static/style.css b/static/style.css index e462cae..e119f15 100644 --- a/static/style.css +++ b/static/style.css @@ -798,10 +798,17 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } } .rd-mlabel { position: sticky; left: 0; z-index: 2; background: var(--surface); - display: flex; align-items: center; padding-right: 6px; + display: flex; align-items: center; gap: 4px; padding-right: 6px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); } +/* Wind/rain cells are bare numbers to keep the grid narrow, so the row label + carries the unit — quieter than the label itself, and lowercase since "km/h" + and "mm" are unit symbols, not words. */ +.rd-unit { + font-weight: 500; text-transform: none; letter-spacing: 0; + font-size: 10px; opacity: .75; +} .rd-colh { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 0; padding-bottom: 3px; font-size: 10px; color: var(--muted); diff --git a/static/units.js b/static/units.js index 14f12ba..49ee291 100644 --- a/static/units.js +++ b/static/units.js @@ -1,7 +1,16 @@ -// Temperature units — the shared °C/°F toggle and unit-aware formatting. -// The API always speaks Fahrenheit; the unit is a pure display concern. Pages -// format temps through `fmtTemp`/`toUnit` and re-render on `onUnitChange`, so the -// stored/plotted values stay in °F and only the numbers shown flip. +// Units — the shared toggle and unit-aware formatting for every measure we show. +// The API always speaks imperial (°F, inches, mph); the unit is a pure display +// concern. Pages format through fmtTemp/fmtPrecip/fmtWind and re-render on +// `onUnitChange`, so stored and plotted values stay imperial and only the numbers +// shown flip. +// +// One flag drives all of them: picking °C also gives mm and km/h. A reader who +// wants Celsius wants the rest metric too, and a per-measure control would be +// three toggles in a header that has room for one. The button still shows °F/°C +// because temperature is what people look for. +// +// Absolute humidity (g/m³) is metric in both systems — there is no imperial unit +// for it anyone would recognise — so it is formatted here but never converted. const UNIT_KEY = "thermograph:unit"; @@ -57,14 +66,75 @@ export function fmtDelta(dF, withLetter) { return `${Math.round(d)}°${withLetter ? _unit : ""}`; } +// --- precipitation (stored in inches) ---------------------------------------- +const MM_PER_IN = 25.4; + +// Inches as a number in the active unit. +export function toPrecip(vIn) { return _unit === "C" ? vIn * MM_PER_IN : vIn; } + +// Millimetres get one decimal and inches two: 0.04 in and 1.0 mm are the same +// amount, and rendering "1.02 mm" would imply a precision the source lacks. +// The suffix is " in"/" mm" to match what content.py renders server-side, so a +// climate page's repaint reproduces the text it shipped with rather than swapping +// the glyph. Compact surfaces (the tier bar) pass withUnit=false and add their own. +export function fmtPrecip(vIn, withUnit = true) { + if (vIn == null || isNaN(vIn)) return "—"; + return _unit === "C" + ? `${(vIn * MM_PER_IN).toFixed(1)}${withUnit ? " mm" : ""}` + : `${vIn.toFixed(2)}${withUnit ? " in" : ""}`; +} + +export const precipUnit = () => (_unit === "C" ? "mm" : "in"); + +// --- wind (stored in mph) ---------------------------------------------------- +const KMH_PER_MPH = 1.609344; + +export function toWind(vMph) { return _unit === "C" ? vMph * KMH_PER_MPH : vMph; } + +export function fmtWind(vMph, withUnit = true) { + if (vMph == null || isNaN(vMph)) return "—"; + const v = Math.round(toWind(vMph)); + return `${v}${withUnit ? (_unit === "C" ? " km/h" : " mph") : ""}`; +} + +export const windUnit = () => (_unit === "C" ? "km/h" : "mph"); + +// --- absolute humidity (g/m³ in both systems) -------------------------------- +export function fmtHumid(v, withUnit = true) { + if (v == null || isNaN(v)) return "—"; + return `${v.toFixed(1)}${withUnit ? " g/m³" : ""}`; +} + +export const humidUnit = () => "g/m³"; + export function onUnitChange(cb) { _unitCbs.push(cb); } +// --- unit symbols in prose --------------------------------------------------- +// Static copy that names a unit ("wind speed (mph)") marks it up as +// mph. The HTML ships the imperial symbol so +// there's something sensible with no JS; this keeps it honest once a unit is +// known. Runs on load and on every toggle, wherever units.js is imported. +const UNIT_LABEL = { + temp: () => `°${_unit}`, + precip: () => (_unit === "C" ? "mm" : "inches"), + wind: () => windUnit(), + humid: () => "g/m³", +}; + +function paintUnitLabels() { + for (const el of document.querySelectorAll("[data-unit-label]")) { + const f = UNIT_LABEL[el.dataset.unitLabel]; + if (f) el.textContent = f(); + } +} + export 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); + paintUnitLabels(); _unitCbs.forEach((cb) => { try { cb(_unit); } catch (e) {} }); } @@ -85,7 +155,9 @@ function syncUnitToggle(wrap) { const wrap = document.createElement("div"); wrap.className = "unit-toggle"; wrap.setAttribute("role", "group"); - wrap.setAttribute("aria-label", "Temperature units"); + // Labelled for what it actually switches now — temperature, precipitation and + // wind — even though the buttons read °F/°C. + wrap.setAttribute("aria-label", "Units"); wrap.innerHTML = '' + ''; wrap.addEventListener("click", (e) => { @@ -100,3 +172,5 @@ function syncUnitToggle(wrap) { else brand.insertBefore(wrap, brand.querySelector(".view-nav")); syncUnitToggle(wrap); })(); + +paintUnitLabels();