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:
parent
b2612ab1ca
commit
57dfbe109e
4 changed files with 70 additions and 43 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? `<div class="ct-bar" style="height:${h}px">${lo != null
|
||||
? `<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" style="height:${h}px"></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}" ` +
|
||||
`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-range">${range}</span>` +
|
||||
bar +
|
||||
`<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>`;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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³",
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue