Calendar totals: label each bar, share/count toggle, plot Normal

Caption every category column with its name (two reserved lines so the
value figures stay aligned whether a name wraps or not), and add a
"Show count" checkbox in the strip header that swaps every share figure
for the raw day count (state persists in localStorage, re-renders in
place via a delegated listener on #cal-totals).

Also plot the neutral "Normal" median category as a real bar instead of
a bare baseline tick, so the strip reads as a full distribution; bar
heights now scale to the tallest category (Normal included).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Emi Griffith 2026-07-10 18:58:40 -07:00
parent 7aeb32d135
commit 8ff352e236
2 changed files with 68 additions and 29 deletions

View file

@ -260,6 +260,21 @@ document.getElementById("metric-toggle").addEventListener("click", (e) => {
renderKey(); renderKey();
}); });
// ---- category totals: share vs. count toggle ----
// The strip normally prints each category's share; ticking "Show count" swaps every
// figure for the raw day count. State is persisted and applied by renderTotals; the
// checkbox lives inside the (re-rendered) totals block, so the listener is delegated
// on the stable #cal-totals container and re-renders from the last shown days.
let showCount = false;
try { showCount = localStorage.getItem("thermograph:calShowCount") === "1"; } catch (e) {}
let lastVisible = null;
document.getElementById("cal-totals").addEventListener("change", (e) => {
if (!e.target.closest("#ct-count-cb")) return;
showCount = e.target.checked;
try { localStorage.setItem("thermograph:calShowCount", showCount ? "1" : "0"); } catch (err) {}
if (lastVisible) renderTotals(lastVisible);
});
// ---- season / year filters ---- // ---- season / year filters ----
const filtersEl = document.getElementById("cal-filters"); const filtersEl = document.getElementById("cal-filters");
const seasonYearEl = document.getElementById("cal-seasonyear"); const seasonYearEl = document.getElementById("cal-seasonyear");
@ -645,6 +660,7 @@ function renderCalendar() {
// title says how many matched) — so it doubles as a "days you'd love" counter. // title says how many matched) — so it doubles as a "days you'd love" counter.
function renderTotals(visible) { function renderTotals(visible) {
const totEl = document.getElementById("cal-totals"); const totEl = document.getElementById("cal-totals");
lastVisible = visible; // remembered so the "Show count" toggle can re-render in place
if (!visible.length) { totEl.hidden = true; totEl.innerHTML = ""; return; } if (!visible.length) { totEl.hidden = true; totEl.innerHTML = ""; return; }
const wActive = weatherFilterActive(); const wActive = weatherFilterActive();
const days = wActive ? visible.filter((v) => v.pass).map((v) => v.rec) : visible.map((v) => v.rec); const days = wActive ? visible.filter((v) => v.pass).map((v) => v.rec) : visible.map((v) => v.rec);
@ -679,33 +695,41 @@ function renderTotals(visible) {
const total = buckets.reduce((n, b) => n + b[2], 0); const total = buckets.reduce((n, b) => n + b[2], 0);
const pctText = (n) => { const p = 100 * n / total, r = Math.round(p); return n > 0 && r === 0 ? `${p.toFixed(1)}%` : `${r}%`; }; const pctText = (n) => { const p = 100 * n / total, r = Math.round(p); return n > 0 && r === 0 ? `${p.toFixed(1)}%` : `${r}%`; };
// The strip prints either each category's share (default) or its raw day count,
// toggled by the "Show count" checkbox rendered in the header (state: showCount).
const valText = (n) => !n ? "0" : (showCount ? n.toLocaleString() : pctText(n));
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}:`
: `${visible.length.toLocaleString()} days shown — by ${title}:`; : `${visible.length.toLocaleString()} days shown — by ${title}:`;
const countToggle =
`<label class="ct-count-toggle"><input type="checkbox" id="ct-count-cb"` +
`${showCount ? " checked" : ""} /> Show count</label>`;
const header = `<div class="ct-head"><div class="section-title">${head}</div>${countToggle}</div>`;
if (!total) { if (!total) {
totEl.innerHTML = `<div class="section-title">${head}</div><p class="muted">No matching days.</p>`; totEl.innerHTML = `${header}<p class="muted">No matching days.</p>`;
totEl.hidden = false; totEl.hidden = false;
return; return;
} }
// A compact deviation strip: one thin status-colored line per category, ordered // A compact distribution strip: one thin status-colored line per category, ordered
// low→high like the color scale, with each share printed above it in that // low→high like the color scale, with each value printed above it in that category's
// category's own color. The median tier (the neutral "Normal" center of the // own color. Every category — including the neutral "Normal" center of the diverging
// diverging temperature scale) is the reference — its share is shown but it draws // temperature scale — draws a line, so the strip reads as a full distribution.
// no line, just a faint baseline tick. Precip/dry-streak have no median, so every // Category names live in the color key below the grid too, so this stays terse.
// category there gets a line. Category names live in the color key below the grid, // Line heights scale to the tallest category.
// so this stays terse. Line heights scale to the tallest non-median category. const maxLine = Math.max(1, ...buckets.map((b) => b[2]));
const maxLine = Math.max(1, ...buckets.filter((b) => !b[3]).map((b) => b[2])); const LINE_MAX = 30; // px — height of the most common category
const LINE_MAX = 30; // px — height of the most common (non-median) category const cols = buckets.map(([label, color, n]) => {
const cols = buckets.map(([label, color, n, isMed]) => { const h = !n ? 0 : Math.max(2, Math.round(LINE_MAX * n / maxLine));
const h = isMed || !n ? 0 : Math.max(2, Math.round(LINE_MAX * n / maxLine));
const num = n ? pctText(n) : "0";
const line = h ? `<span class="ct-line" style="height:${h}px"></span>` : ""; const line = h ? `<span class="ct-line" style="height:${h}px"></span>` : "";
const mid = isMed ? `<span class="ct-mid" aria-hidden="true"></span>` : ""; // Caption stacks the category name over its value; the name reserves two lines
return `<div class="ct-col${isMed ? " ct-col-median" : ""}" style="--c:${color}" ` + // so the value figures stay aligned across columns whether a name wraps or not.
return `<div class="ct-col" style="--c:${color}" ` +
`title="${label} · ${pctText(n)} (${n})">` + `title="${label} · ${pctText(n)} (${n})">` +
`<span class="ct-num${n ? "" : " ct-num-zero"}">${num}</span>${line}${mid}</div>`; `<span class="ct-cap"><span class="ct-label">${label}</span>` +
`<span class="ct-num${n ? "" : " ct-num-zero"}">${valText(n)}</span></span>` +
`${line}</div>`;
}).join(""); }).join("");
totEl.innerHTML = `<div class="section-title">${head}</div> totEl.innerHTML = `${header}
<div class="ct-strip" style="--line-max:${LINE_MAX}px">${cols}</div>`; <div class="ct-strip" style="--line-max:${LINE_MAX}px">${cols}</div>`;
totEl.hidden = false; totEl.hidden = false;
} }

View file

@ -736,19 +736,39 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
} }
.comfort-row .hint { flex: 1 1 100%; margin: 0; font-size: 12px; color: var(--muted); } .comfort-row .hint { flex: 1 1 100%; margin: 0; font-size: 12px; color: var(--muted); }
/* Category totals above the grid: a compact deviation strip. Each category is a /* Category totals above the grid: a compact distribution strip. Each category is a
column with its share printed at the top (in that category's own status color) column captioned with its name over its value (share by default, raw count when
and a thin status-colored line rising from a shared baseline (height share). "Show count" is ticked, in that category's own status color) above a thin status-
The median tier draws no line just a faint baseline tick as the neutral anchor. */ colored line rising from a shared baseline (height share). Every category draws
a line including the neutral "Normal" center of the temperature scale. */
.cal-totals { max-width: 560px; margin: 0 0 18px; } .cal-totals { max-width: 560px; margin: 0 0 18px; }
/* Header row: the "N days shown — by X" title, with the share/count toggle at the
far end. Wraps to its own line on narrow screens rather than crushing the title. */
.ct-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px 16px; flex-wrap: wrap; }
.ct-head .section-title { margin-bottom: 4px; }
.ct-count-toggle {
display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0;
font-size: 12.5px; color: var(--muted); cursor: pointer; user-select: none;
margin-bottom: 4px;
}
.ct-count-toggle input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; cursor: pointer; }
.ct-strip { .ct-strip {
display: flex; align-items: flex-end; gap: 6px; display: flex; align-items: flex-end; gap: 6px;
height: calc(var(--line-max, 30px) + 22px); margin: 8px 0 6px; 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 { 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-label {
min-height: 2.2em; font-size: 10px; line-height: 1.1; font-weight: 600;
color: var(--muted); text-align: center; overflow: hidden;
}
.ct-num { .ct-num {
position: absolute; top: 0; left: 0; right: 0; text-align: center; font-size: 12px; font-weight: 700; line-height: 1;
font-size: 11.5px; 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; }
@ -756,11 +776,6 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); position: absolute; bottom: 0; left: 50%; transform: translateX(-50%);
width: 10px; border-radius: 3px 3px 0 0; background: var(--c); width: 10px; border-radius: 3px 3px 0 0; background: var(--c);
} }
.ct-col-median .ct-num { color: var(--muted); font-weight: 800; }
.ct-mid {
position: absolute; bottom: 0; left: 50%; transform: translateX(-50%);
width: 7px; height: 3px; border-radius: 2px; background: var(--border);
}
/* Days filtered out by the weather filter fade back so matches pop. */ /* Days filtered out by the weather filter fade back so matches pop. */
.cal-cell.dim { opacity: .15; } .cal-cell.dim { opacity: .15; }