* 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
229 lines
13 KiB
JavaScript
229 lines
13 KiB
JavaScript
// Shared presentation constants + small helpers for every page script — each
|
||
// 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.
|
||
// ---- 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
|
||
// inline-SVG work — chart building and the PNG export in particular — needs
|
||
// literal color values, where CSS variables can't resolve. The hex fallbacks
|
||
// only cover a missing/blocked stylesheet.
|
||
const FALLBACK = {
|
||
"rec-hot": "#8f0e20", "very-hot": "#dd6b52", "hot": "#ef9351", "warm": "#f6c18a",
|
||
"normal": "#4a9d5b",
|
||
"cool": "#92c5de", "cold": "#4393c3", "very-cold": "#2166ac", "rec-cold": "#0a2f6b",
|
||
"dry": "#c9a24a",
|
||
"wet-1": "#d9f0d3", "wet-2": "#b7e2b1", "wet-3": "#8fd18f", "wet-4": "#5cba9f",
|
||
"wet-5": "#35a1c0", "wet-6": "#2b8cbe", "wet-7": "#226bb3", "wet-8": "#164a97",
|
||
"wet-9": "#08306b",
|
||
};
|
||
const rootCss = getComputedStyle(document.documentElement);
|
||
export const TIER_COLORS = {};
|
||
for (const [cls, fb] of Object.entries(FALLBACK)) {
|
||
TIER_COLORS[cls] = rootCss.getPropertyValue(`--${cls}`).trim() || fb;
|
||
}
|
||
// The rain-intensity subset + the dry tint, for pages that color them apart.
|
||
export const PRECIP_COLORS = Object.fromEntries(
|
||
Object.entries(TIER_COLORS).filter(([c]) => c.startsWith("wet-")));
|
||
export const DRY_COLOR = TIER_COLORS.dry;
|
||
|
||
// ---- percentile tier scales, low → high ----
|
||
// Names are deliberately *relative* to each place's own climate history (a
|
||
// percentile), not absolute temperature words — see the README design note.
|
||
export const SCALE_TEMP = [
|
||
{ c: "rec-cold", label: "Near Record", range: "<1" },
|
||
{ c: "very-cold", label: "Very Low", range: "1–10" },
|
||
{ c: "cold", label: "Low", range: "10–25" },
|
||
{ c: "cool", label: "Below Normal", range: "25–40" },
|
||
{ c: "normal", label: "Normal", range: "40–60" },
|
||
{ c: "warm", label: "Above Normal", range: "60–75" },
|
||
{ c: "hot", label: "High", range: "75–90" },
|
||
{ c: "very-hot", label: "Very High", range: "90–99" },
|
||
{ c: "rec-hot", label: "Near Record", range: ">99" },
|
||
];
|
||
// Rain-intensity tiers by rain-day percentile (dry days show their dry streak).
|
||
export const SCALE_RAIN = [
|
||
{ c: "wet-1", label: "Trace", range: "<1" },
|
||
{ c: "wet-2", label: "Very Light", range: "1–10" },
|
||
{ c: "wet-3", label: "Light", range: "10–25" },
|
||
{ c: "wet-4", label: "Light–Mod", range: "25–40" },
|
||
{ c: "wet-5", label: "Moderate", range: "40–60" },
|
||
{ c: "wet-6", label: "Mod–Heavy", range: "60–75" },
|
||
{ c: "wet-7", label: "Heavy", range: "75–90" },
|
||
{ c: "wet-8", label: "Very Heavy", range: "90–99" },
|
||
{ c: "wet-9", label: "Extreme", range: ">99" },
|
||
];
|
||
|
||
// ---- dry-streak ramp ----
|
||
// "Days since last rain": rain days are blue; each dry day warms from a light
|
||
// base toward dark red, saturating at 14 consecutive dry days.
|
||
const DRY_BASE = [247, 217, 176], DRY_MAX = [122, 13, 26], DRY_CAP = 14;
|
||
const lerpRgb = (a, b, t) =>
|
||
`rgb(${a.map((x, i) => Math.round(x + (b[i] - x) * t)).join(",")})`;
|
||
export function drynessColor(dsr) {
|
||
if (dsr == null) return "";
|
||
if (dsr === 0) return "#2b7bba"; // measurable rain that day
|
||
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 ----
|
||
// (Temperatures go through nav.js's unit-aware fmtTemp; these are unit-agnostic.)
|
||
export const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`);
|
||
export const fmtWind = (v) => (v == null ? "—" : `${Math.round(v)} mph`);
|
||
export const fmtHumid = (v) => (v == null ? "—" : `${v.toFixed(1)} g/m³`);
|
||
export const ord = (n) => {
|
||
const r = Math.round(n), s = ["th", "st", "nd", "rd"], v = r % 100;
|
||
return r + (s[(v - 20) % 10] || s[v] || s[0]);
|
||
};
|
||
export const todayISO = () => new Date().toISOString().slice(0, 10);
|
||
export const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
// The heading label for a graded response: the resolved place name, or the
|
||
// cell-center coordinates while (or if) no name resolves.
|
||
export const placeLabel = (data) =>
|
||
data.place || `${data.cell.center_lat.toFixed(3)}, ${data.cell.center_lon.toFixed(3)}`;
|
||
|
||
// ---- dates & range chunking ----
|
||
export const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||
export const pad = (n) => String(n).padStart(2, "0");
|
||
export const isoOfDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||
export const monthStart = (ym) => `${ym}-01`; // 1st of a "YYYY-MM"
|
||
export const monthEnd = (ym) => isoOfDate(new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0));
|
||
|
||
// Split [startIso, endIso] into consecutive ≤2-year windows (oldest first) so
|
||
// each stays within the calendar endpoint's per-request cap; the calendar grid
|
||
// and the compare series both fill chunk by chunk.
|
||
export const CHUNK_MONTHS = 24;
|
||
export function buildChunks(startIso, endIso) {
|
||
const chunks = [];
|
||
let s = startIso, guard = 0;
|
||
while (s <= endIso && guard++ < 200) {
|
||
const sd = new Date(s + "T00:00:00");
|
||
const ed = new Date(sd.getFullYear(), sd.getMonth() + CHUNK_MONTHS, sd.getDate());
|
||
ed.setDate(ed.getDate() - 1); // 24 months inclusive
|
||
let e = isoOfDate(ed);
|
||
if (e > endIso) e = endIso;
|
||
chunks.push({ start: s, end: e });
|
||
const ns = new Date(e + "T00:00:00"); ns.setDate(ns.getDate() + 1);
|
||
s = isoOfDate(ns);
|
||
}
|
||
return chunks;
|
||
}
|
||
|
||
// Clicking anywhere in a month/date field opens the native picker. Desktop
|
||
// Chrome otherwise only opens it from the tiny calendar glyph and just focuses
|
||
// a segment; showPicker is missing in some browsers (Safari/Firefox) — ignore.
|
||
export function clickOpensPicker(...els) {
|
||
for (const el of els) {
|
||
el.addEventListener("click", () => { try { el.showPicker(); } catch (e) {} });
|
||
}
|
||
}
|
||
|
||
// ---- weather summary ----
|
||
// Monochrome line icons (currentColor) — no emoji, matching the site's dark
|
||
// look. Feather-style paths.
|
||
const WX = (paths) =>
|
||
`<svg class="wx" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
||
const WX_CLOUD = `<path d="M20 16.6A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25"/>`;
|
||
export const WX_ICONS = {
|
||
sun: WX(`<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.2" y1="4.2" x2="5.6" y2="5.6"/><line x1="18.4" y1="18.4" x2="19.8" y2="19.8"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.2" y1="19.8" x2="5.6" y2="18.4"/><line x1="18.4" y1="5.6" x2="19.8" y2="4.2"/>`),
|
||
drizzle: WX(`${WX_CLOUD}<line x1="8" y1="19" x2="8" y2="21"/><line x1="16" y1="19" x2="16" y2="21"/><line x1="12" y1="20" x2="12" y2="22"/>`),
|
||
rain: WX(`${WX_CLOUD}<line x1="8" y1="19" x2="8" y2="22"/><line x1="16" y1="19" x2="16" y2="22"/><line x1="12" y1="19" x2="12" y2="23"/>`),
|
||
storm: WX(`<path d="M19 16.9A5 5 0 0 0 18 7h-1.26a8 8 0 1 0-11.62 9"/><polyline points="13 11 9 17 15 17 11 23"/>`),
|
||
snow: WX(`<path d="M20 17.6A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25"/><line x1="8" y1="18" x2="8" y2="18"/><line x1="12" y1="20" x2="12" y2="20"/><line x1="16" y1="18" x2="16" y2="18"/><line x1="12" y1="16" x2="12" y2="16"/>`),
|
||
};
|
||
|
||
// Plain-language "what was the day like" descriptor from the raw values: a
|
||
// temperature word (from the daily high, °F) crossed with a sky/precip word
|
||
// (precip in inches — falling as snow at/below freezing). `dsr` is optional:
|
||
// when a long dry streak is known, a no-rain day reads "dry" instead of
|
||
// "clear" (the calendar passes it; the day page doesn't track streaks).
|
||
export function weatherType(t, p, dsr) {
|
||
const wet = p != null && p >= 0.01;
|
||
const freezing = t != null && t <= 34;
|
||
const temp = t == null ? "" :
|
||
t >= 95 ? "Scorching" : t >= 85 ? "Hot" : t >= 72 ? "Warm" :
|
||
t >= 58 ? "Mild" : t >= 45 ? "Cool" : t >= 32 ? "Cold" : "Frigid";
|
||
let sky, skyKey, icon;
|
||
if (wet && freezing) { sky = p >= 0.3 ? "heavy snow" : "snow"; skyKey = "snow"; icon = WX_ICONS.snow; }
|
||
else if (wet && p >= 1.0) { sky = "downpour"; skyKey = "downpour"; icon = WX_ICONS.storm; }
|
||
else if (wet && p >= 0.3) { sky = "rain"; skyKey = "rain"; icon = WX_ICONS.rain; }
|
||
else if (wet) { sky = "light rain"; skyKey = "drizzle"; icon = WX_ICONS.drizzle; }
|
||
else {
|
||
skyKey = dsr != null && dsr >= 10 ? "dry" : "clear";
|
||
sky = skyKey === "dry" ? "dry" : "clear";
|
||
icon = WX_ICONS.sun;
|
||
}
|
||
const text = !temp ? sky : wet ? `${temp}, ${sky}` : `${temp} & ${sky}`;
|
||
return { icon, text: text.charAt(0).toUpperCase() + text.slice(1),
|
||
feel: temp.toLowerCase(), sky: skyKey };
|
||
}
|