From 57dfbe109ec058a1d2c61211512be9ba21b6a3f1 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sun, 19 Jul 2026 12:28:58 -0700 Subject: [PATCH] Report rain in whole millimetres; lift tier spans out of the bars (#192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- static/app.js | 3 +-- static/shared.js | 58 +++++++++++++++++++++++++++++++++++------------- static/style.css | 32 +++++++++++++------------- static/units.js | 20 ++++++++--------- 4 files changed, 70 insertions(+), 43 deletions(-) diff --git a/static/app.js b/static/app.js index a2b41d7..9d28fba 100644 --- a/static/app.js +++ b/static/app.js @@ -188,8 +188,7 @@ async function runGrade() { const cTemp = fmtTemp; // active unit, bare degree 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 in either unit, since cm and -// inches are close enough in scale to need the same resolution. +// fmtPrecip carries the precision: whole millimetres, two decimals for inches. const cRain = (v) => (v == null ? "—" : v > 0 ? fmtPrecip(v, false) : "·"); // Monochrome inline icons (currentColor) so the UI chrome matches the dark theme diff --git a/static/shared.js b/static/shared.js index e124d65..1f8008a 100644 --- a/static/shared.js +++ b/static/shared.js @@ -2,7 +2,8 @@ // 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, fmtPrecip, fmtWind, fmtHumid, getUnit } from "./units.js"; +import { fmtTemp, fmtPrecip, fmtWind, fmtHumid, getUnit, + toUnit, toWind, toPrecip } 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 @@ -121,17 +122,43 @@ function fmtMetricVal(unit, v) { if (unit === "temp") return fmtTemp(v); 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 " cm"/" in". - if (unit === "precip") return `${fmtPrecip(v, false)}${getUnit() === "C" ? "cm" : "″"}`; + // 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)}`; } +// The low–high span for a tier, as bare numbers. No unit: this line sits directly +// under the average, which carries it, and a column is ~38px wide on a phone — +// repeating " g/m³" on both ends is what made the old in-bar label clip. +// Two decimals, trailing zeros dropped, and no leading zero on a sub-1 value — +// ".93" instead of "0.93". Inch rainfall is almost all sub-1, and that leading +// character is the one that pushed the range past a phone column's width. +const _trim = (n) => parseFloat(n.toFixed(2)).toString().replace(/^0\./, "."); +function fmtMetricRange(unit, lo, hi) { + if (lo == null || hi == null || isNaN(lo) || isNaN(hi)) return ""; + if (unit === "temp") return `${Math.round(toUnit(lo))}–${Math.round(toUnit(hi))}`; + if (unit === "wind") return `${Math.round(toWind(lo))}–${Math.round(toWind(hi))}`; + if (unit === "humid") return `${Math.round(lo)}–${Math.round(hi)}`; + if (unit === "precip") { + // Same precision the average above it uses — whole millimetres, hundredths of + // an inch — or the two lines disagree ("52mm" over "12.7–136.91"). + const f = (v) => _trim(getUnit() === "C" ? Math.round(toPrecip(v)) : toPrecip(v)); + return `${f(lo)}–${f(hi)}`; + } + return `${Math.round(lo)}–${Math.round(hi)}`; +} + // Render buckets from metricBuckets() as a connected bar chart, one bar per category -// low→high: the category's average value sits above its bar, its Max/Min values inside -// it, and the share (or raw count when showCount is set) with the category name below. -// Bar height encodes frequency within the bucket's scale group (wet/dry/temp scale -// apart), floored so the in-bar label stays legible. Assumes ≥1 day (callers guard). +// low→high: the category's average sits above its bar with the low–high span just +// under it, and the share (or raw count when showCount is set) with the category +// name below. Bar height encodes frequency within the bucket's scale group (wet/dry +// /temp scale apart). Assumes ≥1 day (callers guard). +// +// The span used to sit *inside* the bar as a "Max 62° / Min 17°" pill. A column is +// ~38px on a phone, so it clipped (and had already been shrunk to 8px to cope); +// above the bar it has the full column width and needs no contrast trick to stay +// readable over nine different tier colours. export function distStrip(buckets, showCount) { const total = buckets.reduce((s, b) => s + b.n, 0); const groupTotal = {}, groupCount = {}, maxByGroup = {}; @@ -146,7 +173,10 @@ export function distStrip(buckets, showCount) { return n > 0 && r === 0 ? `${p.toFixed(1)}%` : `${r}%`; }; const valText = (n, group) => !n ? "0" : (showCount ? n.toLocaleString() : pctText(n, group)); - const BAR_MIN = 30, BAR_MAX = 74; // px — the floor keeps the two-line Max/Min label readable + // px. Nothing lives inside the bar any more, so the floor only has to keep a + // non-empty bucket visible rather than hold two lines of text — which lets the + // heights encode frequency more honestly than the old 30px floor did. + const BAR_MIN = 14, BAR_MAX = 74; const cols = buckets.map((b) => { let avg = null, lo = null, hi = null; if (b.values && b.values.length) { @@ -156,16 +186,14 @@ export function distStrip(buckets, showCount) { } const h = b.n ? Math.round(BAR_MIN + (BAR_MAX - BAR_MIN) * (b.n / maxByGroup[b.group])) : 0; const bar = h - ? `
${lo != null - ? `` + - `Max${fmtMetricVal(b.unit, hi)}` + - `Min${fmtMetricVal(b.unit, lo)}` + - `` - : ""}
` + ? `
` : `
`; + const range = lo != null ? fmtMetricRange(b.unit, lo, hi) : ""; return `
` + + `title="${b.label} · ${pctText(b.n, b.group)} (${b.n})${avg != null ? ` · avg ${fmtMetricVal(b.unit, avg)}` : ""}` + + `${lo != null ? ` · ${fmtMetricVal(b.unit, lo)} to ${fmtMetricVal(b.unit, hi)}` : ""}">` + `${avg != null ? fmtMetricVal(b.unit, avg) : ""}` + + `${range}` + bar + `${valText(b.n, b.group)}` + `${b.label}
`; diff --git a/static/style.css b/static/style.css index e119f15..adc5c96 100644 --- a/static/style.css +++ b/static/style.css @@ -1069,22 +1069,16 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } display: flex; align-items: center; justify-content: center; } .ct-bar-0 { height: 2px; opacity: .35; } -/* Max/Min values inside the bar — a translucent dark pill (reads on any tier color) - holding two rows: key on the left, value on the right. */ +/* The tier's low–high span, directly under the average and above the bar. It used + to sit inside the bar as a dark pill; a column is ~38px on a phone, so it clipped + there however small the type got. Out here it gets the whole column width, and + needs no scrim to stay legible over nine different tier colours. */ .ct-range { - display: flex; flex-direction: column; gap: 1px; - max-width: 100%; overflow: hidden; white-space: nowrap; - font-size: 9px; font-weight: 700; line-height: 1.15; - color: #fff; background: rgba(12, 15, 19, .55); border-radius: 3px; padding: 2px 3px; -} -.ct-mm { display: flex; justify-content: space-between; gap: 5px; } -.ct-mm-k { font-weight: 600; opacity: .68; } -/* On phones the distribution columns get narrow; shrink the in-bar Max/Min text a - touch (and tighten the key↔value gap + side padding) so three-figure values like - "Max 106°" stay fully visible instead of clipping to "Max 10". */ -@media (max-width: 640px) { - .ct-range { font-size: 8px; padding: 2px; } - .ct-mm { gap: 2px; } + min-height: 1.1em; margin-bottom: 3px; + font-size: 9px; font-weight: 600; line-height: 1; + font-variant-numeric: tabular-nums; + color: var(--muted); opacity: .9; + white-space: nowrap; overflow: hidden; } /* Share + category name, below the bar. */ .ct-cap { display: flex; flex-direction: column; align-items: center; gap: 1px; margin-top: 5px; } @@ -1096,6 +1090,9 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } .ct-label { min-height: 2.2em; font-size: 10px; line-height: 1.1; font-weight: 600; color: var(--muted); overflow: hidden; + /* The strip's columns sit 1px apart, so long neighbouring names ("Light–Mod" + next to "Moderate") ran into each other and read as one word. */ + padding: 0 2px; } /* Days filtered out by the weather filter fade back so matches pop. */ @@ -1515,8 +1512,11 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } .guide-metrics dt { margin-top: 10px; } /* Tighter distribution columns so a 9-tier temp strip fits a phone without - scrolling (precip's 10th column still scrolls inside its own .ct-strip). */ + scrolling (precip's 10th column still scrolls inside its own .ct-strip). + The low–high line shrinks to match: at 34px a column has room for "17–62" + comfortably, and for precip's ".04–.46" once the leading zero is dropped. */ .ct-col { min-width: 34px; } + .ct-range { font-size: 8px; } .cmp-params { grid-template-columns: 1fr; } .cmp-basis-block .metric-toggle button { flex: 1 0 22%; padding: 10px 4px; } diff --git a/static/units.js b/static/units.js index aefa0f1..8a5689b 100644 --- a/static/units.js +++ b/static/units.js @@ -4,7 +4,7 @@ // `onUnitChange`, so stored and plotted values stay imperial and only the numbers // shown flip. // -// One flag drives all of them: picking °C also gives cm and km/h. A reader who +// 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. @@ -67,25 +67,25 @@ export function fmtDelta(dF, withLetter) { } // --- precipitation (stored in inches) ---------------------------------------- -const CM_PER_IN = 2.54; +const MM_PER_IN = 25.4; // Inches as a number in the active unit. -export function toPrecip(vIn) { return _unit === "C" ? vIn * CM_PER_IN : vIn; } +export function toPrecip(vIn) { return _unit === "C" ? vIn * MM_PER_IN : vIn; } -// Both units get two decimals. They are only 2.54x apart, so cm needs the same -// resolution inches have — at one decimal every light-rain day would collapse to -// 0.0 or 0.1 cm, and the source itself is hundredths of an inch. -// The suffix is " in"/" cm" to match what content.py renders server-side, so a +// 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 // 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 * CM_PER_IN).toFixed(2)}${withUnit ? " cm" : ""}` + ? `${Math.round(vIn * MM_PER_IN)}${withUnit ? " mm" : ""}` : `${vIn.toFixed(2)}${withUnit ? " in" : ""}`; } -export const precipUnit = () => (_unit === "C" ? "cm" : "in"); +export const precipUnit = () => (_unit === "C" ? "mm" : "in"); // --- wind (stored in mph) ---------------------------------------------------- const KMH_PER_MPH = 1.609344; @@ -117,7 +117,7 @@ export function onUnitChange(cb) { _unitCbs.push(cb); } // known. Runs on load and on every toggle, wherever units.js is imported. const UNIT_LABEL = { temp: () => `°${_unit}`, - precip: () => (_unit === "C" ? "cm" : "inches"), + precip: () => (_unit === "C" ? "mm" : "inches"), wind: () => windUnit(), humid: () => "g/m³", };