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. */