Compare: comfort range slicer + per-location climate distribution (#62)
* Compare: comfort range slicer + per-location climate distribution Replace the comfort-point + band sliders with a single dual-handle range slicer: a day hits comfort when its judged temperature lands in [lo, hi]; below lo is colder, above hi is warmer. State is the range in °F, so old c/t links and stored prefs migrate to a range. Ranking, average miss, and the typical-miss figure are all computed against the range. Add a climate-distribution section below the ranked cards, independent of the comfort filter: its own metric selector (High/Low/Feels/Humid/Wind/ Gust/Precip/Dry streak) plus a share/count toggle render one Record-Low→ Record-High distribution strip per location, mirroring the calendar. Extract the calendar's category bucketing and distribution strip into shared metricBuckets/distStrip so both pages share one implementation; calendar output is unchanged. * Carry the calendar's wet/dry scale-group logic into the shared distribution helpers * Carry the precip strip's per-tier label hook into the shared distribution helper
This commit is contained in:
parent
c65ee15c0d
commit
2662fa38e5
5 changed files with 298 additions and 172 deletions
|
|
@ -7,7 +7,7 @@ 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,
|
||||||
fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
|
fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
|
||||||
CHUNK_MONTHS, buildChunks, clickOpensPicker,
|
CHUNK_MONTHS, buildChunks, clickOpensPicker, metricBuckets, distStrip,
|
||||||
weatherType as wxType } from "./shared.js";
|
weatherType as wxType } from "./shared.js";
|
||||||
|
|
||||||
// The diverging-temperature-scale metrics (colored exactly like High/Low), with a
|
// The diverging-temperature-scale metrics (colored exactly like High/Low), with a
|
||||||
|
|
@ -523,99 +523,27 @@ function renderTotals(visible) {
|
||||||
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);
|
||||||
|
|
||||||
// Buckets low→high for the metric: [label, color, count-fn].
|
const title = metric === "dsr" ? "dry streak"
|
||||||
let buckets, title;
|
: metric === "precip" ? "rain intensity"
|
||||||
if (metric === "dsr") {
|
: (TEMP_METRIC_INFO[metric] || {}).label || "value";
|
||||||
const B = [
|
// Category distribution (low→high) for the metric; the bucketing and the strip
|
||||||
["Rain day", drynessColor(0), (d) => d === 0],
|
// visual — the wet/dry scale-group split and the per-tier label hook — are shared
|
||||||
["1–3 dry", drynessColor(2), (d) => d >= 1 && d <= 3],
|
// with the compare page. Category names live in the color key below the grid too.
|
||||||
["4–6 dry", drynessColor(5), (d) => d >= 4 && d <= 6],
|
const buckets = metricBuckets(days, metric);
|
||||||
["7–13 dry", drynessColor(10), (d) => d >= 7 && d <= 13],
|
|
||||||
["14+ dry", drynessColor(14), (d) => d >= 14],
|
|
||||||
];
|
|
||||||
const vals = days.map((r) => r.dsr).filter((d) => d != null);
|
|
||||||
// Group (index 3) sets which categories share a height scale: the lone "Rain day"
|
|
||||||
// (wet) is scaled apart from the dry-streak buckets so it can't flatten them.
|
|
||||||
buckets = B.map(([label, color, fn], i) =>
|
|
||||||
[label, color, vals.filter(fn).length, i === 0 ? "wet" : "dry"]);
|
|
||||||
title = "dry streak";
|
|
||||||
} else 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) so a big dry
|
|
||||||
// count doesn't crush the rain bars — 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 without brittle text matching.
|
|
||||||
buckets = [["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]));
|
|
||||||
title = "rain intensity";
|
|
||||||
} else {
|
|
||||||
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 as before.
|
|
||||||
buckets = SCALE_TEMP.map((t) =>
|
|
||||||
[t.label, TIER_COLORS[t.c], classes.filter((c) => c === t.c).length, "temp"]);
|
|
||||||
title = (TEMP_METRIC_INFO[metric] || {}).label || "value";
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = buckets.reduce((n, b) => n + b[2], 0);
|
const total = buckets.reduce((n, b) => n + b[2], 0);
|
||||||
// Per-group subtotals + membership, so a bar's share can be scoped to its own group.
|
|
||||||
const groupTotal = {}, groupCount = {};
|
|
||||||
for (const b of buckets) {
|
|
||||||
groupTotal[b[3]] = (groupTotal[b[3]] || 0) + b[2];
|
|
||||||
groupCount[b[3]] = (groupCount[b[3]] || 0) + 1;
|
|
||||||
}
|
|
||||||
// A multi-bar group (rain intensities on precip, streak lengths on dry streak) shows
|
|
||||||
// each bar's share *within that group* — matching the per-group bar heights. A lone
|
|
||||||
// opposing column (Dry on precip, Rain day on dry streak) stays a share of ALL shown
|
|
||||||
// days, since a group-relative percentage there would trivially read 100%.
|
|
||||||
const pctText = (n, group) => {
|
|
||||||
const d = groupCount[group] > 1 ? (groupTotal[group] || 1) : total;
|
|
||||||
const p = 100 * n / d, 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, group) => !n ? "0" : (showCount ? n.toLocaleString() : pctText(n, group));
|
|
||||||
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}:`;
|
||||||
|
// The strip prints each category's share by default, or the raw day count when the
|
||||||
|
// "Show count" checkbox (state: showCount) is ticked.
|
||||||
const countToggle =
|
const countToggle =
|
||||||
`<label class="ct-count-toggle"><input type="checkbox" id="ct-count-cb"` +
|
`<label class="ct-count-toggle"><input type="checkbox" id="ct-count-cb"` +
|
||||||
`${showCount ? " checked" : ""} /> Show count</label>`;
|
`${showCount ? " checked" : ""} /> Show count</label>`;
|
||||||
const header = `<div class="ct-head"><div class="section-title">${head}</div>${countToggle}</div>`;
|
const header = `<div class="ct-head"><div class="section-title">${head}</div>${countToggle}</div>`;
|
||||||
if (!total) {
|
totEl.innerHTML = total
|
||||||
totEl.innerHTML = `${header}<p class="muted">No matching days.</p>`;
|
? `${header}\n ${distStrip(buckets, showCount)}`
|
||||||
totEl.hidden = false;
|
: `${header}<p class="muted">No matching days.</p>`;
|
||||||
return;
|
|
||||||
}
|
|
||||||
// A compact distribution strip: one thin status-colored line per category, ordered
|
|
||||||
// low→high like the color scale, with each value printed above it in that category's
|
|
||||||
// own color. Every category — including the neutral "Normal" center of the diverging
|
|
||||||
// temperature scale — draws a line, so the strip reads as a full distribution.
|
|
||||||
// Category names live in the color key below the grid too, so this stays terse.
|
|
||||||
// Line heights scale to the tallest category *within each scale group* (index 3):
|
|
||||||
// for precip/dry-streak, wet and dry days scale independently, so a lopsided count
|
|
||||||
// on one side doesn't shrink the opposing side's bars into invisibility. Temperature
|
|
||||||
// tiers all sit in one group, so they still scale against each other as before.
|
|
||||||
const maxByGroup = {};
|
|
||||||
for (const b of buckets) maxByGroup[b[3]] = Math.max(maxByGroup[b[3]] || 1, b[2]);
|
|
||||||
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>` : "";
|
|
||||||
// Caption stacks the category name over its value; the name reserves two lines
|
|
||||||
// so the value figures stay aligned across columns whether a name wraps or not.
|
|
||||||
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>`;
|
|
||||||
}).join("");
|
|
||||||
totEl.innerHTML = `${header}
|
|
||||||
<div class="ct-strip" style="--line-max:${LINE_MAX}px">${cols}</div>`;
|
|
||||||
totEl.hidden = false;
|
totEl.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,14 +59,13 @@
|
||||||
the date range or the location set trigger a data load. -->
|
the date range or the location set trigger a data load. -->
|
||||||
<div id="cmp-params" class="cmp-params" hidden>
|
<div id="cmp-params" class="cmp-params" hidden>
|
||||||
<div class="cmp-param cmp-comfort">
|
<div class="cmp-param cmp-comfort">
|
||||||
<label for="cmp-comfort">Comfort temperature <b id="cmp-comfort-val"></b></label>
|
<label>Comfort range <b id="cmp-range-val"></b></label>
|
||||||
<input id="cmp-comfort" type="range" min="30" max="100" step="1" />
|
<div class="dual-range" id="cmp-slicer">
|
||||||
|
<div class="dual-track"><span class="dual-fill" id="cmp-fill"></span></div>
|
||||||
|
<input id="cmp-lo" type="range" min="30" max="100" step="1" aria-label="Comfort range lower bound" />
|
||||||
|
<input id="cmp-hi" type="range" min="30" max="100" step="1" aria-label="Comfort range upper bound" />
|
||||||
</div>
|
</div>
|
||||||
|
<span class="hint">Days landing in this range hit comfort; below it is colder, above it is warmer.</span>
|
||||||
<div class="cmp-param cmp-band">
|
|
||||||
<label for="cmp-tol">Comfort band <b id="cmp-tol-val"></b></label>
|
|
||||||
<input id="cmp-tol" type="range" min="0" max="15" step="1" />
|
|
||||||
<span class="hint">A day "hits comfort" when it lands within this many degrees.</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cmp-param cmp-basis-block">
|
<div class="cmp-param cmp-basis-block">
|
||||||
|
|
@ -99,6 +98,29 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="cmp-results" class="cmp-results"></div>
|
<div id="cmp-results" class="cmp-results"></div>
|
||||||
|
|
||||||
|
<!-- Climate distribution, independent of the comfort filter: how each place's
|
||||||
|
days spread across a metric's Record-Low→Record-High categories, mirroring
|
||||||
|
the calendar's totals strip. Its own metric + share/count toggles. -->
|
||||||
|
<section id="cmp-dist" class="cmp-dist" hidden>
|
||||||
|
<div class="ct-head">
|
||||||
|
<div class="cmp-dist-controls">
|
||||||
|
<span class="cal-filter-label">Distribution by</span>
|
||||||
|
<div class="metric-toggle" id="cmp-dist-metric" role="group" aria-label="Distribution metric">
|
||||||
|
<button type="button" data-metric="tmax" class="active">High</button>
|
||||||
|
<button type="button" data-metric="tmin">Low</button>
|
||||||
|
<button type="button" data-metric="feels">Feels</button>
|
||||||
|
<button type="button" data-metric="humid">Humid</button>
|
||||||
|
<button type="button" data-metric="wind">Wind</button>
|
||||||
|
<button type="button" data-metric="gust">Gust</button>
|
||||||
|
<button type="button" data-metric="precip">Precip</button>
|
||||||
|
<button type="button" data-metric="dsr">Dry streak</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="ct-count-toggle"><input type="checkbox" id="cmp-dist-count" /> Show count</label>
|
||||||
|
</div>
|
||||||
|
<div id="cmp-dist-body" class="cmp-dist-body"></div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,35 @@
|
||||||
// Compare view: line up several places over one date range and see which best
|
// Compare view: line up several places over one date range and see which best
|
||||||
// matches a comfort temperature. For each location we pull the same daily record
|
// fits a comfort temperature *range*. For each location we pull the same daily
|
||||||
// the Calendar uses (api/v2/calendar) and, per day, take a chosen temperature
|
// record the Calendar uses (api/v2/calendar) and, per day, take a chosen
|
||||||
// (daytime high / daily mean / overnight low / feels-like) and measure it against
|
// temperature (daytime high / daily mean / overnight low / feels-like) and test it
|
||||||
// the comfort target. A day "hits comfort" when it lands within a tolerance band;
|
// against the comfort range [lo, hi]: inside the range "hits comfort"; below lo is
|
||||||
// otherwise it's colder or warmer, and we track by how much.
|
// colder, above hi is warmer, and we track by how much.
|
||||||
//
|
//
|
||||||
// The whole comparison — locations, comfort target, band, judged temperature and
|
// Below the ranked cards a separate climate-distribution section shows how each
|
||||||
// date range — lives in the URL hash, so a link reproduces exactly what you see.
|
// place's days spread across a metric's Record-Low→Record-High categories (its own
|
||||||
// Comfort / band / judged-temperature re-rank instantly (all values are already in
|
// metric + share/count toggles), reusing the calendar's totals strip.
|
||||||
// hand). Editing the date range or the location set doesn't refetch on its own:
|
//
|
||||||
// it arms the Refresh button, and the load runs when the user taps it.
|
// The comparison — locations, comfort range, judged temperature and date range —
|
||||||
|
// lives in the URL hash, so a link reproduces exactly what you see. The range and
|
||||||
|
// judged-temperature re-rank instantly (all values are already in hand). Editing
|
||||||
|
// the date range or the location set doesn't refetch on its own: it arms the
|
||||||
|
// Refresh button, and the load runs when the user taps it.
|
||||||
|
|
||||||
import { loadLastLocation, saveLastLocation } from "./nav.js";
|
import { loadLastLocation, saveLastLocation } from "./nav.js";
|
||||||
import { getJSON, TTL } from "./cache.js";
|
import { getJSON, TTL } from "./cache.js";
|
||||||
import { initFindButton } from "./mappicker.js";
|
import { initFindButton } from "./mappicker.js";
|
||||||
import { MONTHS, pad, isoOfDate, monthStart, monthEnd, buildChunks,
|
import { MONTHS, pad, monthStart, monthEnd, buildChunks,
|
||||||
clickOpensPicker } from "./shared.js";
|
clickOpensPicker, metricBuckets, distStrip } from "./shared.js";
|
||||||
import { fmtTemp, fmtDelta, onUnitChange } from "./units.js";
|
import { fmtTemp, fmtDelta, onUnitChange } from "./units.js";
|
||||||
|
|
||||||
const CMP = {
|
const CMP = {
|
||||||
comfort: "thermograph:cmpComfort",
|
lo: "thermograph:cmpLo",
|
||||||
tol: "thermograph:cmpTol",
|
hi: "thermograph:cmpHi",
|
||||||
basis: "thermograph:cmpBasis",
|
basis: "thermograph:cmpBasis",
|
||||||
range: "thermograph:cmpRange",
|
range: "thermograph:cmpRange",
|
||||||
locs: "thermograph:cmpLocs",
|
locs: "thermograph:cmpLocs",
|
||||||
|
distMetric: "thermograph:cmpDistMetric",
|
||||||
|
distCount: "thermograph:cmpDistCount",
|
||||||
};
|
};
|
||||||
|
|
||||||
const lsGet = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
|
const lsGet = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
|
||||||
|
|
@ -31,13 +37,30 @@ const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch (e) {} };
|
||||||
|
|
||||||
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||||||
const BASES = ["tmax", "tmin", "mean", "feels"];
|
const BASES = ["tmax", "tmin", "mean", "feels"];
|
||||||
let comfort = clamp(+lsGet(CMP.comfort) || 68, 30, 100);
|
const R_MIN = 30, R_MAX = 100; // comfort-range slider bounds (°F)
|
||||||
let tol = clamp(lsGet(CMP.tol) == null ? 5 : +lsGet(CMP.tol), 0, 15);
|
|
||||||
|
// The comfort range [lo, hi] in °F. Restored from the new lo/hi keys, else migrated
|
||||||
|
// from the old comfort-point + band (c ± t), else the default 63–73° (was 68 ± 5).
|
||||||
|
function loadRange2() {
|
||||||
|
let lo = +lsGet(CMP.lo), hi = +lsGet(CMP.hi);
|
||||||
|
if (lo >= R_MIN && hi <= R_MAX && lo <= hi) return { lo, hi };
|
||||||
|
const c = +lsGet("thermograph:cmpComfort"), t = +lsGet("thermograph:cmpTol");
|
||||||
|
if (c >= R_MIN && c <= R_MAX && t >= 0) return { lo: clamp(c - t, R_MIN, R_MAX), hi: clamp(c + t, R_MIN, R_MAX) };
|
||||||
|
return { lo: 63, hi: 73 };
|
||||||
|
}
|
||||||
|
let { lo, hi } = loadRange2();
|
||||||
let basis = BASES.includes(lsGet(CMP.basis)) ? lsGet(CMP.basis) : "tmax";
|
let basis = BASES.includes(lsGet(CMP.basis)) ? lsGet(CMP.basis) : "tmax";
|
||||||
|
|
||||||
|
// Distribution section (independent of comfort): which metric to bucket by, and
|
||||||
|
// whether the strip prints shares or raw day counts.
|
||||||
|
const DIST_METRICS = ["tmax", "tmin", "feels", "humid", "wind", "gust", "precip", "dsr"];
|
||||||
|
let distMetric = DIST_METRICS.includes(lsGet(CMP.distMetric)) ? lsGet(CMP.distMetric) : "tmax";
|
||||||
|
let distCount = lsGet(CMP.distCount) === "1";
|
||||||
|
|
||||||
// Locations being compared: {lat, lon, name, series|null, loading, error}.
|
// Locations being compared: {lat, lon, name, series|null, loading, error}.
|
||||||
// `series` is a compact per-day array of {hi, lo, feels} (°F, any may be null).
|
// `series` is the array of raw daily records from /api/v2/calendar (each metric an
|
||||||
// A location with no series and not loading is "pending" — it needs a Refresh tap.
|
// {v, c, …} object) — the comfort ranking reads the values, the distribution the
|
||||||
|
// category classes. No series and not loading is "pending" — it needs a Refresh tap.
|
||||||
let locations = [];
|
let locations = [];
|
||||||
// Bumped on every (re)load so a superseded in-flight fetch drops its result.
|
// Bumped on every (re)load so a superseded in-flight fetch drops its result.
|
||||||
let loadToken = 0;
|
let loadToken = 0;
|
||||||
|
|
@ -61,23 +84,30 @@ let range = loadRange(); // the APPLIED range (what loaded data reflects), "YY
|
||||||
const monthLabel = (ym) => `${MONTHS[+ym.slice(5, 7) - 1]} ${ym.slice(0, 4)}`;
|
const monthLabel = (ym) => `${MONTHS[+ym.slice(5, 7) - 1]} ${ym.slice(0, 4)}`;
|
||||||
|
|
||||||
// ---- shareable URL state ----
|
// ---- shareable URL state ----
|
||||||
// The hash carries the full comparison: c=comfort, t=band, b=basis, s/e=range,
|
// The hash carries the comparison: lo/hi=comfort range, b=basis, s/e=range,
|
||||||
// loc=lat,lon;lat,lon. Written on every state change; read once on load (a link
|
// loc=lat,lon;lat,lon. Written on every state change; read once on load (a link
|
||||||
// wins over localStorage). A plain lat/lon hash from cross-view nav is ignored
|
// wins over localStorage). Old links used c=comfort + t=band; those still resolve
|
||||||
// here and handled by the seed path instead.
|
// (converted to a range). A plain lat/lon hash from cross-view nav is ignored here
|
||||||
|
// and handled by the seed path instead.
|
||||||
function writeHashState() {
|
function writeHashState() {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
p.set("c", comfort); p.set("t", tol); p.set("b", basis);
|
p.set("lo", lo); p.set("hi", hi); p.set("b", basis);
|
||||||
p.set("s", range.start); p.set("e", range.end);
|
p.set("s", range.start); p.set("e", range.end);
|
||||||
if (locations.length) p.set("loc", locations.map((l) => `${l.lat.toFixed(4)},${l.lon.toFixed(4)}`).join(";"));
|
if (locations.length) p.set("loc", locations.map((l) => `${l.lat.toFixed(4)},${l.lon.toFixed(4)}`).join(";"));
|
||||||
history.replaceState(null, "", "#" + p.toString());
|
history.replaceState(null, "", "#" + p.toString());
|
||||||
}
|
}
|
||||||
function readHashState() {
|
function readHashState() {
|
||||||
const p = new URLSearchParams(location.hash.slice(1));
|
const p = new URLSearchParams(location.hash.slice(1));
|
||||||
if (!["c", "t", "b", "s", "e", "loc"].some((k) => p.has(k))) return null; // not a compare link
|
if (!["lo", "hi", "c", "t", "b", "s", "e", "loc"].some((k) => p.has(k))) return null; // not a compare link
|
||||||
const st = {};
|
const st = {};
|
||||||
if (p.has("c")) st.comfort = clamp(+p.get("c") || 68, 30, 100);
|
if (p.has("lo") || p.has("hi")) {
|
||||||
if (p.has("t") && p.get("t") !== "") st.tol = clamp(+p.get("t"), 0, 15);
|
const l = clamp(+p.get("lo"), R_MIN, R_MAX), h = clamp(+p.get("hi"), R_MIN, R_MAX);
|
||||||
|
if (!isNaN(l) && !isNaN(h)) { st.lo = Math.min(l, h); st.hi = Math.max(l, h); }
|
||||||
|
} else if (p.has("c")) { // legacy comfort-point + band link
|
||||||
|
const c = clamp(+p.get("c") || 68, R_MIN, R_MAX);
|
||||||
|
const t = p.get("t") !== null && p.get("t") !== "" ? clamp(+p.get("t"), 0, 15) : 5;
|
||||||
|
st.lo = clamp(c - t, R_MIN, R_MAX); st.hi = clamp(c + t, R_MIN, R_MAX);
|
||||||
|
}
|
||||||
if (BASES.includes(p.get("b"))) st.basis = p.get("b");
|
if (BASES.includes(p.get("b"))) st.basis = p.get("b");
|
||||||
if (isYM(p.get("s"))) st.start = p.get("s");
|
if (isYM(p.get("s"))) st.start = p.get("s");
|
||||||
if (isYM(p.get("e"))) st.end = p.get("e");
|
if (isYM(p.get("e"))) st.end = p.get("e");
|
||||||
|
|
@ -98,17 +128,21 @@ const params = document.getElementById("cmp-params");
|
||||||
const placeholder = document.getElementById("cmp-placeholder");
|
const placeholder = document.getElementById("cmp-placeholder");
|
||||||
const head = document.getElementById("cmp-head");
|
const head = document.getElementById("cmp-head");
|
||||||
const results = document.getElementById("cmp-results");
|
const results = document.getElementById("cmp-results");
|
||||||
const comfortInput = document.getElementById("cmp-comfort");
|
const loInput = document.getElementById("cmp-lo");
|
||||||
const comfortVal = document.getElementById("cmp-comfort-val");
|
const hiInput = document.getElementById("cmp-hi");
|
||||||
const tolInput = document.getElementById("cmp-tol");
|
const rangeVal = document.getElementById("cmp-range-val");
|
||||||
const tolVal = document.getElementById("cmp-tol-val");
|
const fillEl = document.getElementById("cmp-fill");
|
||||||
const basisToggle = document.getElementById("cmp-basis");
|
const basisToggle = document.getElementById("cmp-basis");
|
||||||
const startInput = document.getElementById("cmp-start");
|
const startInput = document.getElementById("cmp-start");
|
||||||
const endInput = document.getElementById("cmp-end");
|
const endInput = document.getElementById("cmp-end");
|
||||||
|
const distSection = document.getElementById("cmp-dist");
|
||||||
|
const distToggle = document.getElementById("cmp-dist-metric");
|
||||||
|
const distBody = document.getElementById("cmp-dist-body");
|
||||||
|
|
||||||
|
|
||||||
// ---- data ----
|
// ---- data ----
|
||||||
// Fetch one location's range (chunked) and reduce it to the compact per-day series.
|
// Fetch one location's range (chunked), keeping the raw daily records — the comfort
|
||||||
|
// ranking reads their values, the distribution their category classes.
|
||||||
async function fetchSeries(loc, token) {
|
async function fetchSeries(loc, token) {
|
||||||
const chunks = buildChunks(monthStart(range.start), monthEnd(range.end));
|
const chunks = buildChunks(monthStart(range.start), monthEnd(range.end));
|
||||||
const days = [];
|
const days = [];
|
||||||
|
|
@ -120,14 +154,7 @@ async function fetchSeries(loc, token) {
|
||||||
place = place || d.place || `${d.cell.center_lat.toFixed(2)}, ${d.cell.center_lon.toFixed(2)}`;
|
place = place || d.place || `${d.cell.center_lat.toFixed(2)}, ${d.cell.center_lon.toFixed(2)}`;
|
||||||
for (const r of d.days) days.push(r);
|
for (const r of d.days) days.push(r);
|
||||||
}
|
}
|
||||||
return {
|
return { name: place, series: days };
|
||||||
name: place,
|
|
||||||
series: days.map((r) => ({
|
|
||||||
hi: r.tmax ? r.tmax.v : null,
|
|
||||||
lo: r.tmin ? r.tmin.v : null,
|
|
||||||
feels: r.feels ? r.feels.v : null,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistLocations() {
|
function persistLocations() {
|
||||||
|
|
@ -213,23 +240,24 @@ function refresh() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- stats ----
|
// ---- stats ----
|
||||||
function basisTemp(pt) {
|
// The judged temperature for one raw daily record, per the basis toggle.
|
||||||
if (basis === "tmax") return pt.hi;
|
function basisTemp(r) {
|
||||||
if (basis === "tmin") return pt.lo;
|
const hi = r.tmax ? r.tmax.v : null, lo = r.tmin ? r.tmin.v : null;
|
||||||
if (basis === "feels") return pt.feels;
|
if (basis === "tmax") return hi;
|
||||||
return pt.hi != null && pt.lo != null ? (pt.hi + pt.lo) / 2 : null; // mean
|
if (basis === "tmin") return lo;
|
||||||
|
if (basis === "feels") return r.feels ? r.feels.v : null;
|
||||||
|
return hi != null && lo != null ? (hi + lo) / 2 : null; // mean
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeStats(series) {
|
function computeStats(series) {
|
||||||
let n = 0, below = 0, above = 0, comf = 0, sumBelow = 0, sumAbove = 0, sumAbs = 0;
|
let n = 0, below = 0, above = 0, comf = 0, sumBelow = 0, sumAbove = 0, sumMiss = 0;
|
||||||
for (const pt of series) {
|
for (const r of series) {
|
||||||
const t = basisTemp(pt);
|
const t = basisTemp(r);
|
||||||
if (t == null || isNaN(t)) continue;
|
if (t == null || isNaN(t)) continue;
|
||||||
n++;
|
n++;
|
||||||
const d = t - comfort;
|
// Distance outside the comfort range (0 when inside) drives the "typical miss".
|
||||||
sumAbs += Math.abs(d);
|
if (t < lo) { below++; const m = lo - t; sumBelow += m; sumMiss += m; }
|
||||||
if (d < -tol) { below++; sumBelow += -d; }
|
else if (t > hi) { above++; const m = t - hi; sumAbove += m; sumMiss += m; }
|
||||||
else if (d > tol) { above++; sumAbove += d; }
|
|
||||||
else comf++;
|
else comf++;
|
||||||
}
|
}
|
||||||
if (!n) return null;
|
if (!n) return null;
|
||||||
|
|
@ -238,7 +266,7 @@ function computeStats(series) {
|
||||||
comfortPct: 100 * comf / n, belowPct: 100 * below / n, abovePct: 100 * above / n,
|
comfortPct: 100 * comf / n, belowPct: 100 * below / n, abovePct: 100 * above / n,
|
||||||
avgBelow: below ? sumBelow / below : 0,
|
avgBelow: below ? sumBelow / below : 0,
|
||||||
avgAbove: above ? sumAbove / above : 0,
|
avgAbove: above ? sumAbove / above : 0,
|
||||||
mad: sumAbs / n,
|
mad: sumMiss / n,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,7 +299,7 @@ function rankCard(loc, s, rank) {
|
||||||
const stats =
|
const stats =
|
||||||
`<div class="cmp-stats">` +
|
`<div class="cmp-stats">` +
|
||||||
`<div class="cmp-stat cmp-s-below"><span class="cmp-k">Colder</span><span class="cmp-v">${pct(s.belowPct)}</span><span class="cmp-d">${s.below ? `avg ${deg(s.avgBelow)} below` : "—"}</span></div>` +
|
`<div class="cmp-stat cmp-s-below"><span class="cmp-k">Colder</span><span class="cmp-v">${pct(s.belowPct)}</span><span class="cmp-d">${s.below ? `avg ${deg(s.avgBelow)} below` : "—"}</span></div>` +
|
||||||
`<div class="cmp-stat cmp-s-comfort"><span class="cmp-k">In comfort</span><span class="cmp-v">${pct(s.comfortPct)}</span><span class="cmp-d">±${fmtDelta(tol)} of ${fmtTemp(comfort)}</span></div>` +
|
`<div class="cmp-stat cmp-s-comfort"><span class="cmp-k">In comfort</span><span class="cmp-v">${pct(s.comfortPct)}</span><span class="cmp-d">${fmtTemp(lo)}–${fmtTemp(hi)}</span></div>` +
|
||||||
`<div class="cmp-stat cmp-s-above"><span class="cmp-k">Warmer</span><span class="cmp-v">${pct(s.abovePct)}</span><span class="cmp-d">${s.above ? `avg ${deg(s.avgAbove)} above` : "—"}</span></div>` +
|
`<div class="cmp-stat cmp-s-above"><span class="cmp-k">Warmer</span><span class="cmp-v">${pct(s.abovePct)}</span><span class="cmp-d">${s.above ? `avg ${deg(s.avgAbove)} above` : "—"}</span></div>` +
|
||||||
`</div>`;
|
`</div>`;
|
||||||
return `<div class="cmp-card">` +
|
return `<div class="cmp-card">` +
|
||||||
|
|
@ -303,8 +331,8 @@ function renderResults() {
|
||||||
const win = scored[0];
|
const win = scored[0];
|
||||||
const span = `${monthLabel(range.start)} – ${monthLabel(range.end)}`;
|
const span = `${monthLabel(range.start)} – ${monthLabel(range.end)}`;
|
||||||
const lead = scored.length > 1
|
const lead = scored.length > 1
|
||||||
? `<b>${win.loc.name}</b> best matches ${fmtTemp(comfort)} — ${pct(win.s.comfortPct)} of days within ±${fmtDelta(tol)}`
|
? `<b>${win.loc.name}</b> best fits ${fmtTemp(lo)}–${fmtTemp(hi)} — ${pct(win.s.comfortPct)} of days in range`
|
||||||
: `<b>${win.loc.name}</b>: ${pct(win.s.comfortPct)} of days within ±${fmtDelta(tol)} of ${fmtTemp(comfort)}`;
|
: `<b>${win.loc.name}</b>: ${pct(win.s.comfortPct)} of days land in ${fmtTemp(lo)}–${fmtTemp(hi)}`;
|
||||||
head.hidden = false;
|
head.hidden = false;
|
||||||
head.innerHTML = `<h2>${lead}</h2>` +
|
head.innerHTML = `<h2>${lead}</h2>` +
|
||||||
`<p class="meta">By ${BASIS_LABEL[basis]} · ${span}${loading ? ` · loading ${loading} more…` : ""}</p>`;
|
`<p class="meta">By ${BASIS_LABEL[basis]} · ${span}${loading ? ` · loading ${loading} more…` : ""}</p>`;
|
||||||
|
|
@ -312,6 +340,25 @@ function renderResults() {
|
||||||
results.innerHTML = scored.map((x, i) => rankCard(x.loc, x.s, i + 1)).join("");
|
results.innerHTML = scored.map((x, i) => rankCard(x.loc, x.s, i + 1)).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 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).
|
||||||
|
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 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">` +
|
||||||
|
`<div class="cmp-dist-name">${name}<span class="cmp-dist-days">${total.toLocaleString()} days</span></div>` +
|
||||||
|
strip + `</div>`;
|
||||||
|
}).join("");
|
||||||
|
distSection.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
function renderAll() {
|
function renderAll() {
|
||||||
const has = locations.length > 0;
|
const has = locations.length > 0;
|
||||||
params.hidden = !has;
|
params.hidden = !has;
|
||||||
|
|
@ -320,6 +367,7 @@ function renderAll() {
|
||||||
refreshBtn.hidden = !isDirty();
|
refreshBtn.hidden = !isDirty();
|
||||||
renderLocList();
|
renderLocList();
|
||||||
renderResults();
|
renderResults();
|
||||||
|
renderDist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- events ----
|
// ---- events ----
|
||||||
|
|
@ -334,30 +382,47 @@ locList.addEventListener("click", (e) => {
|
||||||
if (x) removeLocation(+x.dataset.i);
|
if (x) removeLocation(+x.dataset.i);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Comfort / band / basis: instant re-rank over data already in hand — no refetch.
|
// ---- comfort range slicer ----
|
||||||
comfortInput.addEventListener("input", () => {
|
// Two range inputs overlaid on a shared track; keep lo ≤ hi and mirror the selection
|
||||||
comfort = +comfortInput.value;
|
// in the label + fill. State stays in °F; the range re-ranks instantly (no refetch).
|
||||||
comfortVal.textContent = fmtTemp(comfort);
|
const pctPos = (v) => (v - R_MIN) / (R_MAX - R_MIN) * 100;
|
||||||
lsSet(CMP.comfort, String(comfort));
|
function syncSlicer() {
|
||||||
|
loInput.value = lo; hiInput.value = hi;
|
||||||
|
rangeVal.textContent = `${fmtTemp(lo)} – ${fmtTemp(hi)}`;
|
||||||
|
fillEl.style.left = `${pctPos(lo)}%`;
|
||||||
|
fillEl.style.right = `${100 - pctPos(hi)}%`;
|
||||||
|
}
|
||||||
|
function slicerChanged() {
|
||||||
|
lsSet(CMP.lo, String(lo)); lsSet(CMP.hi, String(hi));
|
||||||
|
syncSlicer();
|
||||||
writeHashState();
|
writeHashState();
|
||||||
renderResults();
|
renderResults(); // the distribution is comfort-independent — no need to touch it
|
||||||
|
}
|
||||||
|
loInput.addEventListener("input", () => { lo = Math.min(+loInput.value, hi); slicerChanged(); });
|
||||||
|
hiInput.addEventListener("input", () => { hi = Math.max(+hiInput.value, lo); 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(); });
|
||||||
|
|
||||||
|
// Distribution: its own metric selector + share/count toggle, re-rendering in place.
|
||||||
|
distToggle.addEventListener("click", (e) => {
|
||||||
|
const btn = e.target.closest("button[data-metric]");
|
||||||
|
if (!btn) return;
|
||||||
|
distMetric = btn.dataset.metric;
|
||||||
|
lsSet(CMP.distMetric, distMetric);
|
||||||
|
distToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn));
|
||||||
|
renderDist();
|
||||||
});
|
});
|
||||||
tolInput.addEventListener("input", () => {
|
distSection.addEventListener("change", (e) => {
|
||||||
tol = +tolInput.value;
|
if (!e.target.closest("#cmp-dist-count")) return;
|
||||||
tolVal.textContent = `±${fmtDelta(tol)}`;
|
distCount = e.target.checked;
|
||||||
lsSet(CMP.tol, String(tol));
|
lsSet(CMP.distCount, distCount ? "1" : "0");
|
||||||
writeHashState();
|
renderDist();
|
||||||
renderResults();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// The °F/°C toggle only flips what's shown — comfort/tol/series all stay in °F
|
// Basis: instant re-rank over data already in hand — no refetch.
|
||||||
// (the API's unit), so there's nothing to refetch or re-rank, just re-render the
|
|
||||||
// numbers: the two slider labels and every card.
|
|
||||||
onUnitChange(() => {
|
|
||||||
comfortVal.textContent = fmtTemp(comfort);
|
|
||||||
tolVal.textContent = `±${fmtDelta(tol)}`;
|
|
||||||
renderResults();
|
|
||||||
});
|
|
||||||
basisToggle.addEventListener("click", (e) => {
|
basisToggle.addEventListener("click", (e) => {
|
||||||
const btn = e.target.closest("button[data-basis]");
|
const btn = e.target.closest("button[data-basis]");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
|
@ -378,15 +443,16 @@ clickOpensPicker(startInput, endInput); // whole-field tap opens the month pic
|
||||||
(function restore() {
|
(function restore() {
|
||||||
const hash = readHashState();
|
const hash = readHashState();
|
||||||
if (hash) {
|
if (hash) {
|
||||||
if (hash.comfort != null) comfort = hash.comfort;
|
if (hash.lo != null) lo = hash.lo;
|
||||||
if (hash.tol != null) tol = hash.tol;
|
if (hash.hi != null) hi = hash.hi;
|
||||||
if (hash.basis) basis = hash.basis;
|
if (hash.basis) basis = hash.basis;
|
||||||
if (hash.start && hash.end) range = { start: hash.start, end: hash.end };
|
if (hash.start && hash.end) range = { start: hash.start, end: hash.end };
|
||||||
}
|
}
|
||||||
|
|
||||||
comfortInput.value = comfort; comfortVal.textContent = fmtTemp(comfort);
|
syncSlicer();
|
||||||
tolInput.value = tol; tolVal.textContent = `±${fmtDelta(tol)}`;
|
|
||||||
basisToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.basis === basis));
|
basisToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.basis === basis));
|
||||||
|
distToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.metric === distMetric));
|
||||||
|
document.getElementById("cmp-dist-count").checked = distCount;
|
||||||
startInput.value = range.start; endInput.value = range.end;
|
startInput.value = range.start; endInput.value = range.end;
|
||||||
|
|
||||||
// A shared link's locations win; else the last-used set; else seed from the spot
|
// A shared link's locations win; else the last-used set; else seed from the spot
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,76 @@ export function drynessColor(dsr) {
|
||||||
return lerpRgb(DRY_BASE, DRY_MAX, Math.min(dsr, DRY_CAP) / DRY_CAP);
|
return lerpRgb(DRY_BASE, DRY_MAX, Math.min(dsr, DRY_CAP) / DRY_CAP);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- category distribution (shared by the calendar totals + the compare page) ----
|
||||||
|
// Bucket a set of daily records into the chosen metric's ordered categories
|
||||||
|
// (low→high), each entry [label, color, dayCount, scaleGroup]. Temperature-style
|
||||||
|
// metrics (High/Low/Feels/Humid/Wind/Gust) use the diverging Record-Low→Record-High
|
||||||
|
// tiers; precip uses the rain-intensity tiers plus a Dry bucket; dry-streak uses
|
||||||
|
// run-length bands. The scaleGroup ("wet"/"dry"/"temp") lets distStrip scale and
|
||||||
|
// percent wet vs. dry days apart so a lopsided count on one side can't crush the
|
||||||
|
// other. Categories are percentile classes on the records, so they're unit-agnostic.
|
||||||
|
export function metricBuckets(days, metric) {
|
||||||
|
if (metric === "dsr") {
|
||||||
|
const bands = [
|
||||||
|
["Rain day", drynessColor(0), (d) => d === 0],
|
||||||
|
["1–3 dry", drynessColor(2), (d) => d >= 1 && d <= 3],
|
||||||
|
["4–6 dry", drynessColor(5), (d) => d >= 4 && d <= 6],
|
||||||
|
["7–13 dry", drynessColor(10), (d) => d >= 7 && d <= 13],
|
||||||
|
["14+ dry", drynessColor(14), (d) => d >= 14],
|
||||||
|
];
|
||||||
|
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"]);
|
||||||
|
}
|
||||||
|
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 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"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
export function distStrip(buckets, showCount) {
|
||||||
|
const total = buckets.reduce((n, b) => n + b[2], 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]);
|
||||||
|
}
|
||||||
|
const pctText = (n, group) => {
|
||||||
|
const d = groupCount[group] > 1 ? (groupTotal[group] || 1) : total;
|
||||||
|
const p = 100 * n / d, r = Math.round(p);
|
||||||
|
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>`;
|
||||||
|
}).join("");
|
||||||
|
return `<div class="ct-strip" style="--line-max:${LINE_MAX}px">${cols}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- formatters & tiny utils ----
|
// ---- formatters & tiny utils ----
|
||||||
// (Temperatures go through nav.js's unit-aware fmtTemp; these are unit-agnostic.)
|
// (Temperatures go through nav.js's unit-aware fmtTemp; these are unit-agnostic.)
|
||||||
export const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`);
|
export const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`);
|
||||||
|
|
|
||||||
|
|
@ -855,6 +855,46 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
|
||||||
.cmp-mad { margin: 12px 0 0; font-size: 13px; color: var(--muted); }
|
.cmp-mad { margin: 12px 0 0; font-size: 13px; color: var(--muted); }
|
||||||
.cmp-mad b { color: var(--text); }
|
.cmp-mad b { color: var(--text); }
|
||||||
|
|
||||||
|
/* Dual-handle comfort slicer: two range inputs overlaid on a shared track, with a
|
||||||
|
fill marking the selected span. Only the thumbs take pointer events, so both
|
||||||
|
handles stay grabbable even where the inputs overlap. */
|
||||||
|
.dual-range { position: relative; height: 44px; }
|
||||||
|
.dual-range .dual-track {
|
||||||
|
position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-50%);
|
||||||
|
height: 6px; border-radius: 3px; background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.dual-range .dual-fill { position: absolute; top: 0; bottom: 0; border-radius: 3px; background: var(--accent); }
|
||||||
|
.dual-range input[type="range"] {
|
||||||
|
position: absolute; left: 0; top: 0; width: 100%; height: 100%; margin: 0;
|
||||||
|
-webkit-appearance: none; appearance: none; background: transparent; pointer-events: none;
|
||||||
|
}
|
||||||
|
.dual-range input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none; appearance: none; pointer-events: auto;
|
||||||
|
width: 22px; height: 22px; border-radius: 50%; background: var(--accent);
|
||||||
|
border: 2px solid var(--surface); box-shadow: 0 1px 3px rgba(0, 0, 0, .35); cursor: pointer;
|
||||||
|
}
|
||||||
|
.dual-range input[type="range"]::-moz-range-thumb {
|
||||||
|
pointer-events: auto; width: 22px; height: 22px; border-radius: 50%; background: var(--accent);
|
||||||
|
border: 2px solid var(--surface); box-shadow: 0 1px 3px rgba(0, 0, 0, .35); cursor: pointer;
|
||||||
|
}
|
||||||
|
.dual-range input[type="range"]::-webkit-slider-runnable-track { background: transparent; border: none; }
|
||||||
|
.dual-range input[type="range"]::-moz-range-track { background: transparent; border: none; }
|
||||||
|
|
||||||
|
/* Climate distribution below the ranked cards: a metric toggle + share/count toggle,
|
||||||
|
then one calendar-style distribution strip (.ct-strip, shared) per location. */
|
||||||
|
.cmp-dist { margin: 24px 0 8px; }
|
||||||
|
.cmp-dist[hidden] { display: none; }
|
||||||
|
.cmp-dist .ct-head { align-items: center; }
|
||||||
|
.cmp-dist-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; }
|
||||||
|
.cmp-dist-controls .cal-filter-label { margin: 0; }
|
||||||
|
.cmp-dist .metric-toggle { margin: 0; }
|
||||||
|
.cmp-dist-body { display: grid; gap: 12px; margin-top: 14px; }
|
||||||
|
.cmp-dist-row {
|
||||||
|
border: 1px solid var(--border); border-radius: 12px; background: var(--surface); padding: 12px 16px 6px;
|
||||||
|
}
|
||||||
|
.cmp-dist-name { display: flex; align-items: baseline; gap: 8px; font-size: 14px; font-weight: 700; }
|
||||||
|
.cmp-dist-days { font-size: 12px; font-weight: 400; color: var(--muted); }
|
||||||
|
|
||||||
/* --- phones / narrow screens — every mobile override lives in this one
|
/* --- phones / narrow screens — every mobile override lives in this one
|
||||||
block (they used to be scattered across four) --- */
|
block (they used to be scattered across four) --- */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue