thermograph/frontend/static/units.js

178 lines
7.6 KiB
JavaScript
Raw Normal View History

Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
// 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.
//
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
// One flag drives all of them: picking °C also gives mm and km/h. A reader who
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
// 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.
2026-07-11 20:28:33 +00:00
const UNIT_KEY = "thermograph:unit";
// First-time default: pick °F only for the countries that actually use it (the US
// plus a handful of territories/nations), otherwise °C — read off the browser
// locale's region subtag (en-US → F, de-DE → C). A stored choice always wins, so
// this only affects visitors who've never toggled. Not persisted, so an unstored
// visitor keeps re-deriving the same locale default until they pick one.
const F_REGIONS = new Set(["US", "PR", "GU", "VI", "AS", "MP", "UM", "BS", "BZ", "KY", "PW", "FM", "MH", "LR"]);
function defaultUnit() {
const langs = (navigator.languages && navigator.languages.length)
? navigator.languages : [navigator.language];
for (const l of langs) {
const m = /-([A-Za-z]{2})\b/.exec(l || "");
if (m) return F_REGIONS.has(m[1].toUpperCase()) ? "F" : "C";
}
return "F"; // no region subtag to go on → keep the historical default
}
Default climate pages to the city's own temperature unit (#189) Search Console shows impressions skewing hard to °C countries — India, South Africa, New Zealand, the UK, Australia — while every climate page rendered °F into the HTML. Search snippets quote the server-rendered text, so a reader in Delhi saw a temperature they had to convert in their head before the page was worth a click. Climate pages now render temperatures in the city's country convention: °F for the US and the handful of other Fahrenheit countries, °C everywhere else. This happens server-side, so it holds with no JS and shows up in the snippet. data-temp-f keeps the raw Fahrenheit in every case — it stays the conversion source of truth, and climate.js repaints from it on toggle exactly as before. The visitor's stored choice still wins over everything; the page default only sits between that and the locale guess, so a first-time visitor stops seeing a °F→°C repaint on load for the majority of our traffic. The unit rides on a ContextVar rather than a parameter because _temp() is reached from both directions — templates call it as a Jinja global, and the context builders call it directly to pre-render the records tables into strings. Threading an argument would mean touching ~20 call sites across three levels of nesting, where missing one yields a silently wrong unit instead of an error. The scope wraps the context builder as well as the render, since the builder is evaluated as an argument and runs first, and it resets in a finally: the page handlers are sync `def`, so Starlette runs them on a recycled threadpool thread and a value left set would leak into the next request that thread serves. Conversion rounds half-up to match JS's Math.round rather than Python's round-half-to-even, so the server prints the number climate.js would and there is no flicker on load for a visitor whose unit already matched. Pages with no single city — the homepage strip, the hub, the interactive views — set no scope, emit no data-unit-default, and keep today's locale behaviour. Precipitation, wind and humidity stay imperial; that inconsistency is worth a follow-up but is outside this change. Two existing assertions were passing for the wrong reason: base.html.j2 mentions "°F/°C" in an HTML comment, so a bare `"°F" in html` check succeeded even on a page whose every temperature was Celsius. They now read the rendered spans. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 18:25:55 +00:00
// A climate page knows which city it is about, so it publishes that country's
// convention on <body data-unit-default>. It sits *between* the stored choice and
// the locale guess: better than the locale (a UK visitor reading about Phoenix
// wants the °F the page already rendered, and repainting it to °C would just be a
// flash), but never better than an explicit toggle. Pages with no single city —
// the homepage, the interactive views — emit no attribute and fall through.
function pageDefault() {
const v = document.body && document.body.dataset.unitDefault;
return (v === "C" || v === "F") ? v : null;
}
const _stored = localStorage.getItem(UNIT_KEY);
Default climate pages to the city's own temperature unit (#189) Search Console shows impressions skewing hard to °C countries — India, South Africa, New Zealand, the UK, Australia — while every climate page rendered °F into the HTML. Search snippets quote the server-rendered text, so a reader in Delhi saw a temperature they had to convert in their head before the page was worth a click. Climate pages now render temperatures in the city's country convention: °F for the US and the handful of other Fahrenheit countries, °C everywhere else. This happens server-side, so it holds with no JS and shows up in the snippet. data-temp-f keeps the raw Fahrenheit in every case — it stays the conversion source of truth, and climate.js repaints from it on toggle exactly as before. The visitor's stored choice still wins over everything; the page default only sits between that and the locale guess, so a first-time visitor stops seeing a °F→°C repaint on load for the majority of our traffic. The unit rides on a ContextVar rather than a parameter because _temp() is reached from both directions — templates call it as a Jinja global, and the context builders call it directly to pre-render the records tables into strings. Threading an argument would mean touching ~20 call sites across three levels of nesting, where missing one yields a silently wrong unit instead of an error. The scope wraps the context builder as well as the render, since the builder is evaluated as an argument and runs first, and it resets in a finally: the page handlers are sync `def`, so Starlette runs them on a recycled threadpool thread and a value left set would leak into the next request that thread serves. Conversion rounds half-up to match JS's Math.round rather than Python's round-half-to-even, so the server prints the number climate.js would and there is no flicker on load for a visitor whose unit already matched. Pages with no single city — the homepage strip, the hub, the interactive views — set no scope, emit no data-unit-default, and keep today's locale behaviour. Precipitation, wind and humidity stay imperial; that inconsistency is worth a follow-up but is outside this change. Two existing assertions were passing for the wrong reason: base.html.j2 mentions "°F/°C" in an HTML comment, so a bare `"°F" in html` check succeeded even on a page whose every temperature was Celsius. They now read the rendered spans. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 18:25:55 +00:00
let _unit = (_stored === "C" || _stored === "F")
? _stored
: (pageDefault() || defaultUnit());
2026-07-11 20:28:33 +00:00
const _unitCbs = [];
export function getUnit() { return _unit; }
// A Fahrenheit value as a number in the active unit.
export 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).
export function fmtTemp(vF, withLetter) {
if (vF == null || isNaN(vF)) return "—";
return `${Math.round(toUnit(vF))}°${withLetter ? _unit : ""}`;
}
// A Fahrenheit-degree *difference* (a span or tolerance, not an absolute
// reading) formatted in the active unit. Differences scale by 5/9 with no 32°
// offset, so they can't go through toUnit/fmtTemp.
export function fmtDelta(dF, withLetter) {
if (dF == null || isNaN(dF)) return "—";
const d = _unit === "C" ? dF * 5 / 9 : dF;
return `${Math.round(d)}°${withLetter ? _unit : ""}`;
}
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
// --- precipitation (stored in inches) ----------------------------------------
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
const MM_PER_IN = 25.4;
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
// Inches as a number in the active unit.
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
export function toPrecip(vIn) { return _unit === "C" ? vIn * MM_PER_IN : vIn; }
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
// Millimetres are whole numbers — that is how rainfall is reported, and a tenth
// of a millimetre is below what the source resolves. Inches keep two decimals,
// since a whole inch of rain is a lot to round to.
// The suffix is " in"/" mm" to match what content.py renders server-side, so a
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
// 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"
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
? `${Math.round(vIn * MM_PER_IN)}${withUnit ? " mm" : ""}`
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
: `${vIn.toFixed(2)}${withUnit ? " in" : ""}`;
}
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
export const precipUnit = () => (_unit === "C" ? "mm" : "in");
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
// --- 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³";
2026-07-11 20:28:33 +00:00
export function onUnitChange(cb) { _unitCbs.push(cb); }
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
// --- unit symbols in prose ---------------------------------------------------
// Static copy that names a unit ("wind speed (mph)") marks it up as
// <span data-unit-label="wind">mph</span>. 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}`,
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
precip: () => (_unit === "C" ? "mm" : "inches"),
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
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();
}
}
2026-07-11 20:28:33 +00:00
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);
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
paintUnitLabels();
2026-07-11 20:28:33 +00:00
_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. A page opts out with data-no-unit-toggle on <body>.
2026-07-11 20:28:33 +00:00
(function buildUnitToggle() {
const brand = document.querySelector(".brand");
if (!brand || brand.querySelector(".unit-toggle")) return;
if (document.body.dataset.noUnitToggle != null) return;
const wrap = document.createElement("div");
wrap.className = "unit-toggle";
wrap.setAttribute("role", "group");
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
// Labelled for what it actually switches now — temperature, precipitation and
// wind — even though the buttons read °F/°C.
wrap.setAttribute("aria-label", "Units");
2026-07-11 20:28:33 +00:00
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);
});
// Lives inside the nav menu panel: display:contents keeps it inline top-right
// on desktop; on phones it stacks inside the hamburger dropdown. Fall back to a
// brand sibling if the panel isn't present.
const panel = brand.querySelector(".nav-panel");
if (panel) panel.appendChild(wrap);
else brand.insertBefore(wrap, brand.querySelector(".view-nav"));
2026-07-11 20:28:33 +00:00
syncUnitToggle(wrap);
})();
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
paintUnitLabels();