// 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); } // ---- 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, ">"); // 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) => ``; const WX_CLOUD = ``; export const WX_ICONS = { sun: WX(``), drizzle: WX(`${WX_CLOUD}`), rain: WX(`${WX_CLOUD}`), storm: WX(``), snow: WX(``), }; // 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 }; }