Distribution bars: split the in-bar range into Max/Min rows (#84)

The min–max range inside each distribution bar packed onto one line
(e.g. -20–-16°), where the range dash collides with the minus signs and
becomes unreadable for negative temperatures. Split it into two labelled
rows — Max on top, Min below — so each value stands alone. The per-tier
average above each bar is unchanged.

Shared by the Compare distribution and the Calendar totals strip. Bump
the bar-height floor 26→30px so the two-line label never clips.
This commit is contained in:
Emi Griffith 2026-07-12 11:32:38 -07:00 committed by GitHub
parent de02ebcf19
commit 3fbaffd876
2 changed files with 20 additions and 21 deletions

View file

@ -2,7 +2,7 @@
// constant and helper here previously existed as a copy in app.js, calendar.js,
// day.js, compare.js and/or legend.html; pages import what they need so a tier
// color, scale label or formatter has exactly one home.
import { fmtTemp, toUnit } from "./units.js";
import { fmtTemp } from "./units.js";
// ---- tier colors ----
// style.css's :root custom properties are the source of truth (they're not
// re-themed, so reading them once at load is safe). The JS map exists because
@ -113,8 +113,8 @@ export function metricBuckets(days, metric) {
});
}
// Format a single metric value / a minmax range in the metric's own unit — temps
// follow the active °C/°F unit; humidity, wind, precip and dry-streak are fixed-unit.
// Format a single metric value in the metric's own unit — temps follow the active
// °C/°F unit; humidity, wind, precip and dry-streak are fixed-unit.
function fmtMetricVal(unit, v) {
if (v == null || isNaN(v)) return "";
if (unit === "temp") return fmtTemp(v);
@ -124,22 +124,12 @@ function fmtMetricVal(unit, v) {
if (unit === "dsr") return `${Math.round(v)}d`;
return `${Math.round(v)}`;
}
function fmtMetricRange(unit, lo, hi) {
if (lo == null) return "";
if (lo === hi) return fmtMetricVal(unit, lo);
if (unit === "temp") return `${Math.round(toUnit(lo))}${Math.round(toUnit(hi))}°`;
if (unit === "humid") return `${Math.round(lo)}${Math.round(hi)}`;
if (unit === "wind") return `${Math.round(lo)}${Math.round(hi)} mph`;
if (unit === "precip") return `${lo.toFixed(2)}${hi.toFixed(2)}`;
if (unit === "dsr") return `${Math.round(lo)}${Math.round(hi)}d`;
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, the minmax range inside
// 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 range stays legible. Assumes ≥1 day (callers guard).
// apart), floored so the in-bar label stays legible. Assumes ≥1 day (callers guard).
export function distStrip(buckets, showCount) {
const total = buckets.reduce((s, b) => s + b.n, 0);
const groupTotal = {}, groupCount = {}, maxByGroup = {};
@ -154,7 +144,7 @@ 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 = 26, BAR_MAX = 74; // px — the floor keeps the in-bar range readable
const BAR_MIN = 30, BAR_MAX = 74; // px — the floor keeps the two-line Max/Min label readable
const cols = buckets.map((b) => {
let avg = null, lo = null, hi = null;
if (b.values && b.values.length) {
@ -164,7 +154,12 @@ 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">${fmtMetricRange(b.unit, lo, hi)}</span>` : ""}</div>`
? `<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 ct-bar-0"></div>`;
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)}` : ""}">` +

View file

@ -802,12 +802,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; }
/* Minmax range inside the bar — a translucent dark pill so it reads on any tier color. */
/* 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. */
.ct-range {
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 9.5px; font-weight: 700; line-height: 1;
color: #fff; background: rgba(12, 15, 19, .5); border-radius: 3px; padding: 1px 3px;
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; }
/* Share + category name, below the bar. */
.ct-cap { display: flex; flex-direction: column; align-items: center; gap: 1px; margin-top: 5px; }
.ct-num {