// 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 ? `
` : ``; return `