Category distribution: connected bar chart with per-tier avg + range (#76)

Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.

- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
  and collects each category's actual values, not just a count, so distStrip
  can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
  unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
  °C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
  toggle: wired in compare.js's onUnitChange and a new onUnitChange in
  calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
  from thin lines to connected bars with an in-bar range pill.
This commit is contained in:
Emi Griffith 2026-07-11 21:31:29 -07:00 committed by GitHub
parent bc1aac3424
commit e11fc73f46
4 changed files with 116 additions and 60 deletions

View file

@ -2,7 +2,7 @@
// Talks to the v2 API (/api/v2/calendar, /api/v2/geocode).
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
// The tooltip reads fmtTemp live, so a unit switch shows on the next hover.
import { fmtTemp } from "./units.js";
import { fmtTemp, onUnitChange } from "./units.js";
import { TTL, chunkedFetch, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord,
@ -161,6 +161,9 @@ document.getElementById("cal-totals").addEventListener("change", (e) => {
try { localStorage.setItem("thermograph:calShowCount", showCount ? "1" : "0"); } catch (err) {}
if (lastVisible) renderTotals(lastVisible);
});
// The totals chart now prints each category's average + range, so it follows the
// °C/°F toggle (the grid itself stays tier-colored and reads temps live in tooltips).
onUnitChange(() => { if (lastVisible) renderTotals(lastVisible); });
// ---- season / year filters ----
const filtersEl = document.getElementById("cal-filters");
@ -530,7 +533,7 @@ function renderTotals(visible) {
// visual — the wet/dry scale-group split and the per-tier label hook — are shared
// with the compare page. Category names live in the color key below the grid too.
const buckets = metricBuckets(days, metric);
const total = buckets.reduce((n, b) => n + b[2], 0);
const total = buckets.reduce((n, b) => n + b.n, 0);
const head = wActive
? `${days.length.toLocaleString()} of ${visible.length.toLocaleString()} shown days match the weather filter — by ${title}:`

View file

@ -474,15 +474,16 @@ function renderResults() {
}
// ---- distribution (independent of the comfort filter) ----
// One calendar-style Record-Low→Record-High strip per loaded location for the
// selected metric; the share/count toggle flips every figure. The categories are
// percentiles, so the strip is unit-agnostic (no re-render on °C/°F).
// One calendar-style Record-Low→Record-High connected bar chart per loaded location
// for the selected metric; the share/count toggle flips every figure. Category names
// are percentile tiers, but each bar now shows the tier's average value + range, so it
// re-renders on the °C/°F toggle (wired below).
function renderDist() {
const withData = locations.filter((l) => l.series && l.series.length);
if (!withData.length) { distSection.hidden = true; distBody.innerHTML = ""; return; }
distBody.innerHTML = withData.map((l) => {
const buckets = metricBuckets(l.series, distMetric);
const total = buckets.reduce((n, b) => n + b[2], 0);
const total = buckets.reduce((n, b) => n + b.n, 0);
const name = l.name || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`;
const strip = total ? distStrip(buckets, distCount) : `<p class="muted">No data for this metric.</p>`;
return `<div class="cmp-dist-row">` +
@ -548,9 +549,9 @@ tloInput.addEventListener("input", () => { tlo = Math.min(+tloInput.value, lo);
thiInput.addEventListener("input", () => { thi = Math.max(+thiInput.value, hi); slicerChanged(); });
// The °F/°C toggle only flips what's shown — the range and series stay in °F (the
// API's unit) — so just re-render the range label and the cards. (The distribution
// strip is percentile categories, so units don't affect it.)
onUnitChange(() => { syncSlicer(); renderResults(); });
// API's unit) — so re-render the range label, the cards, and the distribution chart
// (its per-category averages + ranges are in the active unit).
onUnitChange(() => { syncSlicer(); renderResults(); renderDist(); });
// Distribution: its own metric selector + share/count toggle, re-rendering in place.
distToggle.addEventListener("click", (e) => {

View file

@ -2,6 +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";
// ---- 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
@ -85,37 +86,67 @@ export function metricBuckets(days, metric) {
];
const vals = days.map((r) => r.dsr).filter((d) => d != null);
// The lone "Rain day" (wet) scales apart from the dry-streak buckets (dry).
return bands.map(([label, color, fn], i) => [label, color, vals.filter(fn).length, i === 0 ? "wet" : "dry"]);
return bands.map(([label, color, fn], i) => {
const v = vals.filter(fn);
return { label, color, n: v.length, group: i === 0 ? "wet" : "dry", unit: "dsr", values: v };
});
}
if (metric === "precip") {
const classes = days.map((r) => r.precip && r.precip.c).filter(Boolean);
// Dry days scale independently of the rain-intensity buckets (wet), and vice versa.
// Index 4 carries the rain-tier key (e.g. "wet-5") so a single crowded label —
// "Moderate", wedged between the two-line "LightMod"/"ModHeavy" — can be nudged
// in CSS (.ct-wet-5) without brittle text matching.
return [["Dry", drynessColor(7), classes.filter((c) => c === "dry").length, "dry"]]
.concat(SCALE_RAIN.map((t) => [t.label, PRECIP_COLORS[t.c], classes.filter((c) => c === t.c).length, "wet", t.c]));
const recs = days.map((r) => r.precip).filter(Boolean); // {v, c}
const dry = recs.filter((x) => x.c === "dry");
// Dry days scale independently of the rain-intensity buckets (wet). `cls`
// (e.g. "wet-5") stays a CSS hook for nudging a crowded label (.ct-wet-5).
const out = [{ label: "Dry", color: drynessColor(7), n: dry.length, group: "dry", unit: "precip", values: dry.map((x) => x.v) }];
for (const t of SCALE_RAIN) {
const g = recs.filter((x) => x.c === t.c);
out.push({ label: t.label, color: PRECIP_COLORS[t.c], n: g.length, group: "wet", cls: t.c, unit: "precip", values: g.map((x) => x.v) });
}
return out;
}
const classes = days.map((r) => { const g = r[metric]; return g && g.c; }).filter(Boolean);
// Temperature tiers all share one scale group, so they scale together.
return SCALE_TEMP.map((t) => [t.label, TIER_COLORS[t.c], classes.filter((c) => c === t.c).length, "temp"]);
// Temperature-style metrics: percentile tiers on the class, plus the actual values
// per tier for the average + minmax range. The value's unit follows the metric.
const unit = metric === "humid" ? "humid" : (metric === "wind" || metric === "gust") ? "wind" : "temp";
const recs = days.map((r) => r[metric]).filter(Boolean); // {v, c}
return SCALE_TEMP.map((t) => {
const g = recs.filter((x) => x.c === t.c);
return { label: t.label, color: TIER_COLORS[t.c], n: g.length, group: "temp", unit, values: g.map((x) => x.v) };
});
}
// Render buckets from metricBuckets() as the compact distribution strip: one
// status-colored line per category, low→high, each figure printed above as a share
// (default) or, when showCount is set, the raw day count. Bar heights and shares are
// scoped to each bucket's scale group (index 3): wet and dry days scale apart, and a
// multi-bar group shows each bar's share within that group, while a lone opposing
// column (Dry on precip, Rain day on dry streak) stays a share of all shown days
// (a group-relative percentage there would trivially read 100%). Temperature tiers
// share one group, so they scale together. Assumes ≥1 day (callers guard empty).
// 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.
function fmtMetricVal(unit, v) {
if (v == null || isNaN(v)) return "";
if (unit === "temp") return fmtTemp(v);
if (unit === "humid") return `${Math.round(v)} g/m³`;
if (unit === "wind") return `${Math.round(v)} mph`;
if (unit === "precip") return `${v.toFixed(2)}`;
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
// 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).
export function distStrip(buckets, showCount) {
const total = buckets.reduce((n, b) => n + b[2], 0);
const total = buckets.reduce((s, b) => s + b.n, 0);
const groupTotal = {}, groupCount = {}, maxByGroup = {};
for (const b of buckets) {
groupTotal[b[3]] = (groupTotal[b[3]] || 0) + b[2];
groupCount[b[3]] = (groupCount[b[3]] || 0) + 1;
maxByGroup[b[3]] = Math.max(maxByGroup[b[3]] || 1, b[2]);
groupTotal[b.group] = (groupTotal[b.group] || 0) + b.n;
groupCount[b.group] = (groupCount[b.group] || 0) + 1;
maxByGroup[b.group] = Math.max(maxByGroup[b.group] || 1, b.n);
}
const pctText = (n, group) => {
const d = groupCount[group] > 1 ? (groupTotal[group] || 1) : total;
@ -123,17 +154,26 @@ 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 LINE_MAX = 30; // px — height of the tallest category in its group
const cols = buckets.map(([label, color, n, group, cls]) => {
const h = !n ? 0 : Math.max(2, Math.round(LINE_MAX * n / maxByGroup[group]));
const line = h ? `<span class="ct-line" style="height:${h}px"></span>` : "";
// `cls` (a rain-tier key on precip) becomes a ct-<cls> hook for label nudging.
return `<div class="ct-col${cls ? ` ct-${cls}` : ""}" style="--c:${color}" ` +
`title="${label} · ${pctText(n, group)} (${n})">` +
`<span class="ct-cap"><span class="ct-label">${label}</span>` +
`<span class="ct-num${n ? "" : " ct-num-zero"}">${valText(n, group)}</span></span>${line}</div>`;
const BAR_MIN = 26, BAR_MAX = 74; // px — the floor keeps the in-bar range readable
const cols = buckets.map((b) => {
let avg = null, lo = null, hi = null;
if (b.values && b.values.length) {
let s = 0; lo = Infinity; hi = -Infinity;
for (const v of b.values) { s += v; if (v < lo) lo = v; if (v > hi) hi = v; }
avg = s / b.values.length;
}
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 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)}` : ""}">` +
`<span class="ct-top">${avg != null ? fmtMetricVal(b.unit, avg) : ""}</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>`;
}).join("");
return `<div class="ct-strip" style="--line-max:${LINE_MAX}px">${cols}</div>`;
return `<div class="ct-strip ct-connected">${cols}</div>`;
}
// ---- formatters & tiny utils ----

View file

@ -728,33 +728,45 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
margin-bottom: 4px;
}
.ct-count-toggle input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; cursor: pointer; }
/* Connected category bar chart: one bar per tier, lowhigh, sitting on a shared
baseline and nearly touching so they read as one chart. The tier's average value
sits above its bar, the minmax range inside it, and the share + name below.
Bar height encodes frequency (within the metric's scale group). */
.ct-strip {
display: flex; align-items: flex-end; gap: 6px;
height: calc(var(--line-max, 30px) + 44px); margin: 8px 0 6px;
display: flex; align-items: flex-end; gap: 1px; margin: 10px 0 6px; overflow-x: auto;
}
.ct-col { position: relative; flex: 1 1 0; min-width: 0; height: 100%; }
/* Caption pins to the top of the column: the category name (two reserved lines so
values line up whether the name wraps or not) over the value figure. */
.ct-cap {
position: absolute; top: 0; left: 0; right: 0;
display: flex; flex-direction: column; align-items: center; gap: 3px;
.ct-col {
flex: 1 1 0; min-width: 38px;
display: flex; flex-direction: column; justify-content: flex-end; align-items: stretch;
text-align: center;
}
.ct-label {
min-height: 2.2em; font-size: 10px; line-height: 1.1; font-weight: 600;
color: var(--muted); text-align: center; overflow: hidden;
/* Average value, pinned just above each bar's top. */
.ct-top {
min-height: 1.25em; margin-bottom: 3px; font-size: 10.5px; font-weight: 700; line-height: 1;
color: color-mix(in oklab, var(--c, var(--muted)), var(--text) 45%);
white-space: nowrap; overflow: hidden;
}
/* "Moderate" is a wide single word (can't wrap) sandwiched between the two-line
"LightMod"/"ModHeavy" labels, so on narrow screens its centered box spills over
their first line. Lift it into the empty gap above to clear the collision. */
.ct-wet-5 .ct-label { transform: translateY(-1.4em); }
.ct-bar {
width: 100%; background: var(--c); border-radius: 4px 4px 0 0;
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. */
.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;
}
/* Share + category name, below the bar. */
.ct-cap { display: flex; flex-direction: column; align-items: center; gap: 1px; margin-top: 5px; }
.ct-num {
font-size: 12px; font-weight: 700; line-height: 1;
color: color-mix(in oklab, var(--c, var(--muted)), var(--text) 40%);
}
.ct-num-zero { font-weight: 600; opacity: .38; }
.ct-line {
position: absolute; bottom: 0; left: 50%; transform: translateX(-50%);
width: 10px; border-radius: 3px 3px 0 0; background: var(--c);
.ct-label {
min-height: 2.2em; font-size: 10px; line-height: 1.1; font-weight: 600;
color: var(--muted); overflow: hidden;
}
/* Days filtered out by the weather filter fade back so matches pop. */