diff --git a/static/calendar.js b/static/calendar.js
index 79c9c2c..3843df7 100644
--- a/static/calendar.js
+++ b/static/calendar.js
@@ -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}:`
diff --git a/static/compare.js b/static/compare.js
index 967f70b..ab9504f 100644
--- a/static/compare.js
+++ b/static/compare.js
@@ -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) : `
No data for this metric.
`;
return `` +
@@ -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) => {
diff --git a/static/shared.js b/static/shared.js
index c12f3ae..3c0417e 100644
--- a/static/shared.js
+++ b/static/shared.js
@@ -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 "Light–Mod"/"Mod–Heavy" — 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 + min–max 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 min–max 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 min–max 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 ? `
` : "";
- // `cls` (a rain-tier key on precip) becomes a ct-
hook for label nudging.
- return `` +
- `${label}` +
- `${valText(n, group)}${line}
`;
+ 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
+ ? `${lo != null ? `${fmtMetricRange(b.unit, lo, hi)}` : ""}
`
+ : ``;
+ return `` +
+ `${avg != null ? fmtMetricVal(b.unit, avg) : ""}` +
+ bar +
+ `${valText(b.n, b.group)}` +
+ `${b.label}
`;
}).join("");
- return `${cols}
`;
+ return `${cols}
`;
}
// ---- formatters & tiny utils ----
diff --git a/static/style.css b/static/style.css
index 34373cb..b1f5960 100644
--- a/static/style.css
+++ b/static/style.css
@@ -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, low→high, sitting on a shared
+ baseline and nearly touching so they read as one chart. The tier's average value
+ sits above its bar, the min–max 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
- "Light–Mod"/"Mod–Heavy" 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; }
+/* Min–max 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. */