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
This commit is contained in:
Emi Griffith 2026-07-19 12:28:58 -07:00 committed by GitHub
parent b2612ab1ca
commit 57dfbe109e
4 changed files with 70 additions and 43 deletions

View file

@ -188,8 +188,7 @@ async function runGrade() {
const cTemp = fmtTemp; // active unit, bare degree const cTemp = fmtTemp; // active unit, bare degree
const cHum = (v) => (v == null ? "—" : `${Math.round(v)}%`); // relative %, unit-invariant const cHum = (v) => (v == null ? "—" : `${Math.round(v)}%`); // relative %, unit-invariant
const cWind = (v) => (v == null ? "—" : `${Math.round(toWind(v))}`); const cWind = (v) => (v == null ? "—" : `${Math.round(toWind(v))}`);
// fmtPrecip carries the precision — two decimals in either unit, since cm and // fmtPrecip carries the precision: whole millimetres, two decimals for inches.
// inches are close enough in scale to need the same resolution.
const cRain = (v) => (v == null ? "—" : v > 0 ? fmtPrecip(v, false) : "·"); const cRain = (v) => (v == null ? "—" : v > 0 ? fmtPrecip(v, false) : "·");
// Monochrome inline icons (currentColor) so the UI chrome matches the dark theme // Monochrome inline icons (currentColor) so the UI chrome matches the dark theme

View file

@ -2,7 +2,8 @@
// constant and helper here previously existed as a copy in app.js, calendar.js, // 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 // 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. // 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 ---- // ---- tier colors ----
// style.css's :root custom properties are the source of truth (they're not // 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 // 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 === "temp") return fmtTemp(v);
if (unit === "humid") return fmtHumid(v); if (unit === "humid") return fmtHumid(v);
if (unit === "wind") return fmtWind(v); if (unit === "wind") return fmtWind(v);
// The tier bar is tight on width, so precip keeps the ″ glyph over " cm"/" in". // The tier bar is tight on width, so precip keeps the ″ glyph over " mm"/" in".
if (unit === "precip") return `${fmtPrecip(v, false)}${getUnit() === "C" ? "cm" : "″"}`; if (unit === "precip") return `${fmtPrecip(v, false)}${getUnit() === "C" ? "mm" : "″"}`;
if (unit === "dsr") return `${Math.round(v)}d`; if (unit === "dsr") return `${Math.round(v)}d`;
return `${Math.round(v)}`; return `${Math.round(v)}`;
} }
// The lowhigh 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.7136.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 // 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 // low→high: the category's average sits above its bar with the lowhigh span just
// it, and the share (or raw count when showCount is set) with the category name below. // under it, and the share (or raw count when showCount is set) with the category
// Bar height encodes frequency within the bucket's scale group (wet/dry/temp scale // name below. Bar height encodes frequency within the bucket's scale group (wet/dry
// apart), floored so the in-bar label stays legible. Assumes ≥1 day (callers guard). // /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) { export function distStrip(buckets, showCount) {
const total = buckets.reduce((s, b) => s + b.n, 0); const total = buckets.reduce((s, b) => s + b.n, 0);
const groupTotal = {}, groupCount = {}, maxByGroup = {}; const groupTotal = {}, groupCount = {}, maxByGroup = {};
@ -146,7 +173,10 @@ export function distStrip(buckets, showCount) {
return n > 0 && r === 0 ? `${p.toFixed(1)}%` : `${r}%`; return n > 0 && r === 0 ? `${p.toFixed(1)}%` : `${r}%`;
}; };
const valText = (n, group) => !n ? "0" : (showCount ? n.toLocaleString() : pctText(n, group)); 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) => { const cols = buckets.map((b) => {
let avg = null, lo = null, hi = null; let avg = null, lo = null, hi = null;
if (b.values && b.values.length) { 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 h = b.n ? Math.round(BAR_MIN + (BAR_MAX - BAR_MIN) * (b.n / maxByGroup[b.group])) : 0;
const bar = h const bar = h
? `<div class="ct-bar" style="height:${h}px">${lo != null ? `<div class="ct-bar" style="height:${h}px"></div>`
? `<span class="ct-range">` +
`<span class="ct-mm"><span class="ct-mm-k">Max</span>${fmtMetricVal(b.unit, hi)}</span>` +
`<span class="ct-mm"><span class="ct-mm-k">Min</span>${fmtMetricVal(b.unit, lo)}</span>` +
`</span>`
: ""}</div>`
: `<div class="ct-bar ct-bar-0"></div>`; : `<div class="ct-bar ct-bar-0"></div>`;
const range = lo != null ? fmtMetricRange(b.unit, lo, hi) : "";
return `<div class="ct-col${b.cls ? ` ct-${b.cls}` : ""}" style="--c:${b.color}" ` + return `<div class="ct-col${b.cls ? ` ct-${b.cls}` : ""}" style="--c:${b.color}" ` +
`title="${b.label} · ${pctText(b.n, b.group)} (${b.n})${avg != null ? ` · avg ${fmtMetricVal(b.unit, avg)}` : ""}">` + `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)}` : ""}">` +
`<span class="ct-top">${avg != null ? fmtMetricVal(b.unit, avg) : ""}</span>` + `<span class="ct-top">${avg != null ? fmtMetricVal(b.unit, avg) : ""}</span>` +
`<span class="ct-range">${range}</span>` +
bar + bar +
`<span class="ct-cap"><span class="ct-num${b.n ? "" : " ct-num-zero"}">${valText(b.n, b.group)}</span>` + `<span class="ct-cap"><span class="ct-num${b.n ? "" : " ct-num-zero"}">${valText(b.n, b.group)}</span>` +
`<span class="ct-label">${b.label}</span></span></div>`; `<span class="ct-label">${b.label}</span></span></div>`;

View file

@ -1069,22 +1069,16 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
} }
.ct-bar-0 { height: 2px; opacity: .35; } .ct-bar-0 { height: 2px; opacity: .35; }
/* Max/Min values inside the bar a translucent dark pill (reads on any tier color) /* The tier's lowhigh span, directly under the average and above the bar. It used
holding two rows: key on the left, value on the right. */ 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 { .ct-range {
display: flex; flex-direction: column; gap: 1px; min-height: 1.1em; margin-bottom: 3px;
max-width: 100%; overflow: hidden; white-space: nowrap; font-size: 9px; font-weight: 600; line-height: 1;
font-size: 9px; font-weight: 700; line-height: 1.15; font-variant-numeric: tabular-nums;
color: #fff; background: rgba(12, 15, 19, .55); border-radius: 3px; padding: 2px 3px; color: var(--muted); opacity: .9;
} white-space: nowrap; overflow: hidden;
.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 keyvalue 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; }
} }
/* Share + category name, below the bar. */ /* Share + category name, below the bar. */
.ct-cap { display: flex; flex-direction: column; align-items: center; gap: 1px; margin-top: 5px; } .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 { .ct-label {
min-height: 2.2em; font-size: 10px; line-height: 1.1; font-weight: 600; min-height: 2.2em; font-size: 10px; line-height: 1.1; font-weight: 600;
color: var(--muted); overflow: hidden; color: var(--muted); overflow: hidden;
/* The strip's columns sit 1px apart, so long neighbouring names ("LightMod"
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. */ /* 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; } .guide-metrics dt { margin-top: 10px; }
/* Tighter distribution columns so a 9-tier temp strip fits a phone without /* 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 lowhigh line shrinks to match: at 34px a column has room for "1762"
comfortably, and for precip's ".04.46" once the leading zero is dropped. */
.ct-col { min-width: 34px; } .ct-col { min-width: 34px; }
.ct-range { font-size: 8px; }
.cmp-params { grid-template-columns: 1fr; } .cmp-params { grid-template-columns: 1fr; }
.cmp-basis-block .metric-toggle button { flex: 1 0 22%; padding: 10px 4px; } .cmp-basis-block .metric-toggle button { flex: 1 0 22%; padding: 10px 4px; }

View file

@ -4,7 +4,7 @@
// `onUnitChange`, so stored and plotted values stay imperial and only the numbers // `onUnitChange`, so stored and plotted values stay imperial and only the numbers
// shown flip. // 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 // 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 // three toggles in a header that has room for one. The button still shows °F/°C
// because temperature is what people look for. // because temperature is what people look for.
@ -67,25 +67,25 @@ export function fmtDelta(dF, withLetter) {
} }
// --- precipitation (stored in inches) ---------------------------------------- // --- precipitation (stored in inches) ----------------------------------------
const CM_PER_IN = 2.54; const MM_PER_IN = 25.4;
// Inches as a number in the active unit. // 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 // Millimetres are whole numbers — that is how rainfall is reported, and a tenth
// resolution inches have — at one decimal every light-rain day would collapse to // of a millimetre is below what the source resolves. Inches keep two decimals,
// 0.0 or 0.1 cm, and the source itself is hundredths of an inch. // since a whole inch of rain is a lot to round to.
// The suffix is " in"/" cm" to match what content.py renders server-side, so a // 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 // 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. // the glyph. Compact surfaces (the tier bar) pass withUnit=false and add their own.
export function fmtPrecip(vIn, withUnit = true) { export function fmtPrecip(vIn, withUnit = true) {
if (vIn == null || isNaN(vIn)) return "—"; if (vIn == null || isNaN(vIn)) return "—";
return _unit === "C" return _unit === "C"
? `${(vIn * CM_PER_IN).toFixed(2)}${withUnit ? " cm" : ""}` ? `${Math.round(vIn * MM_PER_IN)}${withUnit ? " mm" : ""}`
: `${vIn.toFixed(2)}${withUnit ? " in" : ""}`; : `${vIn.toFixed(2)}${withUnit ? " in" : ""}`;
} }
export const precipUnit = () => (_unit === "C" ? "cm" : "in"); export const precipUnit = () => (_unit === "C" ? "mm" : "in");
// --- wind (stored in mph) ---------------------------------------------------- // --- wind (stored in mph) ----------------------------------------------------
const KMH_PER_MPH = 1.609344; 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. // known. Runs on load and on every toggle, wherever units.js is imported.
const UNIT_LABEL = { const UNIT_LABEL = {
temp: () => `°${_unit}`, temp: () => `°${_unit}`,
precip: () => (_unit === "C" ? "cm" : "inches"), precip: () => (_unit === "C" ? "mm" : "inches"),
wind: () => windUnit(), wind: () => windUnit(),
humid: () => "g/m³", humid: () => "g/m³",
}; };