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.
269 lines
15 KiB
JavaScript
269 lines
15 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.
|
||
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
|
||
// 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) => {
|
||
const v = vals.filter(fn);
|
||
return { label, color, n: v.length, group: i === 0 ? "wet" : "dry", unit: "dsr", values: v };
|
||
});
|
||
}
|
||
if (metric === "precip") {
|
||
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;
|
||
}
|
||
// 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) };
|
||
});
|
||
}
|
||
|
||
// 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((s, b) => s + b.n, 0);
|
||
const groupTotal = {}, groupCount = {}, maxByGroup = {};
|
||
for (const b of buckets) {
|
||
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;
|
||
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 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
|
||
? `<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("");
|
||
return `<div class="ct-strip ct-connected">${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 };
|
||
}
|