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:
parent
bc1aac3424
commit
e11fc73f46
4 changed files with 116 additions and 60 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
// Talks to the v2 API (/api/v2/calendar, /api/v2/geocode).
|
// Talks to the v2 API (/api/v2/calendar, /api/v2/geocode).
|
||||||
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
|
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
|
||||||
// The tooltip reads fmtTemp live, so a unit switch shows on the next hover.
|
// 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 { TTL, chunkedFetch, prefetchViews } from "./cache.js";
|
||||||
import { initFindButton, setFindLabel } from "./mappicker.js";
|
import { initFindButton, setFindLabel } from "./mappicker.js";
|
||||||
import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord,
|
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) {}
|
try { localStorage.setItem("thermograph:calShowCount", showCount ? "1" : "0"); } catch (err) {}
|
||||||
if (lastVisible) renderTotals(lastVisible);
|
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 ----
|
// ---- season / year filters ----
|
||||||
const filtersEl = document.getElementById("cal-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
|
// 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.
|
// with the compare page. Category names live in the color key below the grid too.
|
||||||
const buckets = metricBuckets(days, metric);
|
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
|
const head = wActive
|
||||||
? `${days.length.toLocaleString()} of ${visible.length.toLocaleString()} shown days match the weather filter — by ${title}:`
|
? `${days.length.toLocaleString()} of ${visible.length.toLocaleString()} shown days match the weather filter — by ${title}:`
|
||||||
|
|
|
||||||
|
|
@ -474,15 +474,16 @@ function renderResults() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- distribution (independent of the comfort filter) ----
|
// ---- distribution (independent of the comfort filter) ----
|
||||||
// One calendar-style Record-Low→Record-High strip per loaded location for the
|
// One calendar-style Record-Low→Record-High connected bar chart per loaded location
|
||||||
// selected metric; the share/count toggle flips every figure. The categories are
|
// for the selected metric; the share/count toggle flips every figure. Category names
|
||||||
// percentiles, so the strip is unit-agnostic (no re-render on °C/°F).
|
// 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() {
|
function renderDist() {
|
||||||
const withData = locations.filter((l) => l.series && l.series.length);
|
const withData = locations.filter((l) => l.series && l.series.length);
|
||||||
if (!withData.length) { distSection.hidden = true; distBody.innerHTML = ""; return; }
|
if (!withData.length) { distSection.hidden = true; distBody.innerHTML = ""; return; }
|
||||||
distBody.innerHTML = withData.map((l) => {
|
distBody.innerHTML = withData.map((l) => {
|
||||||
const buckets = metricBuckets(l.series, distMetric);
|
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 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>`;
|
const strip = total ? distStrip(buckets, distCount) : `<p class="muted">No data for this metric.</p>`;
|
||||||
return `<div class="cmp-dist-row">` +
|
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(); });
|
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
|
// 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
|
// API's unit) — so re-render the range label, the cards, and the distribution chart
|
||||||
// strip is percentile categories, so units don't affect it.)
|
// (its per-category averages + ranges are in the active unit).
|
||||||
onUnitChange(() => { syncSlicer(); renderResults(); });
|
onUnitChange(() => { syncSlicer(); renderResults(); renderDist(); });
|
||||||
|
|
||||||
// Distribution: its own metric selector + share/count toggle, re-rendering in place.
|
// Distribution: its own metric selector + share/count toggle, re-rendering in place.
|
||||||
distToggle.addEventListener("click", (e) => {
|
distToggle.addEventListener("click", (e) => {
|
||||||
|
|
|
||||||
106
static/shared.js
106
static/shared.js
|
|
@ -2,6 +2,7 @@
|
||||||
// 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, toUnit } 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
|
||||||
|
|
@ -85,37 +86,67 @@ export function metricBuckets(days, metric) {
|
||||||
];
|
];
|
||||||
const vals = days.map((r) => r.dsr).filter((d) => d != null);
|
const vals = days.map((r) => r.dsr).filter((d) => d != null);
|
||||||
// The lone "Rain day" (wet) scales apart from the dry-streak buckets (dry).
|
// 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") {
|
if (metric === "precip") {
|
||||||
const classes = days.map((r) => r.precip && r.precip.c).filter(Boolean);
|
const recs = days.map((r) => r.precip).filter(Boolean); // {v, c}
|
||||||
// Dry days scale independently of the rain-intensity buckets (wet), and vice versa.
|
const dry = recs.filter((x) => x.c === "dry");
|
||||||
// Index 4 carries the rain-tier key (e.g. "wet-5") so a single crowded label —
|
// Dry days scale independently of the rain-intensity buckets (wet). `cls`
|
||||||
// "Moderate", wedged between the two-line "Light–Mod"/"Mod–Heavy" — can be nudged
|
// (e.g. "wet-5") stays a CSS hook for nudging a crowded label (.ct-wet-5).
|
||||||
// in CSS (.ct-wet-5) without brittle text matching.
|
const out = [{ label: "Dry", color: drynessColor(7), n: dry.length, group: "dry", unit: "precip", values: dry.map((x) => x.v) }];
|
||||||
return [["Dry", drynessColor(7), classes.filter((c) => c === "dry").length, "dry"]]
|
for (const t of SCALE_RAIN) {
|
||||||
.concat(SCALE_RAIN.map((t) => [t.label, PRECIP_COLORS[t.c], classes.filter((c) => c === t.c).length, "wet", t.c]));
|
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-style metrics: percentile tiers on the class, plus the actual values
|
||||||
// Temperature tiers all share one scale group, so they scale together.
|
// per tier for the average + min–max range. The value's unit follows the metric.
|
||||||
return SCALE_TEMP.map((t) => [t.label, TIER_COLORS[t.c], classes.filter((c) => c === t.c).length, "temp"]);
|
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
|
// Format a single metric value / a min–max range in the metric's own unit — temps
|
||||||
// status-colored line per category, low→high, each figure printed above as a share
|
// follow the active °C/°F unit; humidity, wind, precip and dry-streak are fixed-unit.
|
||||||
// (default) or, when showCount is set, the raw day count. Bar heights and shares are
|
function fmtMetricVal(unit, v) {
|
||||||
// scoped to each bucket's scale group (index 3): wet and dry days scale apart, and a
|
if (v == null || isNaN(v)) return "";
|
||||||
// multi-bar group shows each bar's share within that group, while a lone opposing
|
if (unit === "temp") return fmtTemp(v);
|
||||||
// column (Dry on precip, Rain day on dry streak) stays a share of all shown days
|
if (unit === "humid") return `${Math.round(v)} g/m³`;
|
||||||
// (a group-relative percentage there would trivially read 100%). Temperature tiers
|
if (unit === "wind") return `${Math.round(v)} mph`;
|
||||||
// share one group, so they scale together. Assumes ≥1 day (callers guard empty).
|
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) {
|
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 = {};
|
const groupTotal = {}, groupCount = {}, maxByGroup = {};
|
||||||
for (const b of buckets) {
|
for (const b of buckets) {
|
||||||
groupTotal[b[3]] = (groupTotal[b[3]] || 0) + b[2];
|
groupTotal[b.group] = (groupTotal[b.group] || 0) + b.n;
|
||||||
groupCount[b[3]] = (groupCount[b[3]] || 0) + 1;
|
groupCount[b.group] = (groupCount[b.group] || 0) + 1;
|
||||||
maxByGroup[b[3]] = Math.max(maxByGroup[b[3]] || 1, b[2]);
|
maxByGroup[b.group] = Math.max(maxByGroup[b.group] || 1, b.n);
|
||||||
}
|
}
|
||||||
const pctText = (n, group) => {
|
const pctText = (n, group) => {
|
||||||
const d = groupCount[group] > 1 ? (groupTotal[group] || 1) : total;
|
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}%`;
|
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 LINE_MAX = 30; // px — height of the tallest category in its group
|
const BAR_MIN = 26, BAR_MAX = 74; // px — the floor keeps the in-bar range readable
|
||||||
const cols = buckets.map(([label, color, n, group, cls]) => {
|
const cols = buckets.map((b) => {
|
||||||
const h = !n ? 0 : Math.max(2, Math.round(LINE_MAX * n / maxByGroup[group]));
|
let avg = null, lo = null, hi = null;
|
||||||
const line = h ? `<span class="ct-line" style="height:${h}px"></span>` : "";
|
if (b.values && b.values.length) {
|
||||||
// `cls` (a rain-tier key on precip) becomes a ct-<cls> hook for label nudging.
|
let s = 0; lo = Infinity; hi = -Infinity;
|
||||||
return `<div class="ct-col${cls ? ` ct-${cls}` : ""}" style="--c:${color}" ` +
|
for (const v of b.values) { s += v; if (v < lo) lo = v; if (v > hi) hi = v; }
|
||||||
`title="${label} · ${pctText(n, group)} (${n})">` +
|
avg = s / b.values.length;
|
||||||
`<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 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("");
|
}).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 ----
|
// ---- formatters & tiny utils ----
|
||||||
|
|
|
||||||
|
|
@ -728,33 +728,45 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
.ct-count-toggle input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; cursor: pointer; }
|
.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 {
|
.ct-strip {
|
||||||
display: flex; align-items: flex-end; gap: 6px;
|
display: flex; align-items: flex-end; gap: 1px; margin: 10px 0 6px; overflow-x: auto;
|
||||||
height: calc(var(--line-max, 30px) + 44px); margin: 8px 0 6px;
|
|
||||||
}
|
}
|
||||||
.ct-col { position: relative; flex: 1 1 0; min-width: 0; height: 100%; }
|
.ct-col {
|
||||||
/* Caption pins to the top of the column: the category name (two reserved lines so
|
flex: 1 1 0; min-width: 38px;
|
||||||
values line up whether the name wraps or not) over the value figure. */
|
display: flex; flex-direction: column; justify-content: flex-end; align-items: stretch;
|
||||||
.ct-cap {
|
text-align: center;
|
||||||
position: absolute; top: 0; left: 0; right: 0;
|
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 3px;
|
|
||||||
}
|
}
|
||||||
.ct-label {
|
/* Average value, pinned just above each bar's top. */
|
||||||
min-height: 2.2em; font-size: 10px; line-height: 1.1; font-weight: 600;
|
.ct-top {
|
||||||
color: var(--muted); text-align: center; overflow: hidden;
|
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
|
.ct-bar {
|
||||||
"Light–Mod"/"Mod–Heavy" labels, so on narrow screens its centered box spills over
|
width: 100%; background: var(--c); border-radius: 4px 4px 0 0;
|
||||||
their first line. Lift it into the empty gap above to clear the collision. */
|
display: flex; align-items: center; justify-content: center;
|
||||||
.ct-wet-5 .ct-label { transform: translateY(-1.4em); }
|
}
|
||||||
|
.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 {
|
.ct-num {
|
||||||
font-size: 12px; font-weight: 700; line-height: 1;
|
font-size: 12px; font-weight: 700; line-height: 1;
|
||||||
color: color-mix(in oklab, var(--c, var(--muted)), var(--text) 40%);
|
color: color-mix(in oklab, var(--c, var(--muted)), var(--text) 40%);
|
||||||
}
|
}
|
||||||
.ct-num-zero { font-weight: 600; opacity: .38; }
|
.ct-num-zero { font-weight: 600; opacity: .38; }
|
||||||
.ct-line {
|
.ct-label {
|
||||||
position: absolute; bottom: 0; left: 50%; transform: translateX(-50%);
|
min-height: 2.2em; font-size: 10px; line-height: 1.1; font-weight: 600;
|
||||||
width: 10px; border-radius: 3px 3px 0 0; background: var(--c);
|
color: var(--muted); overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Days filtered out by the weather filter fade back so matches pop. */
|
/* Days filtered out by the weather filter fade back so matches pop. */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue