The page scripts each re-declared the shared presentation layer — the tier color table existed in four places (app.js, calendar.js, day.js, style.css) and the scale label tables in three (plus legend.html's own drifting copy), alongside per-page copies of the dryness ramp, formatters, ord, todayISO, esc, the weather icons/summary, month helpers and the 2-year range chunker. ~240 duplicated lines deleted (net -236 with the new module included). - frontend/shared.js (IIFE, extends window.Thermograph): tier colors read from style.css's :root custom properties at load — the CSS is now the single source of truth; the JS map exists only because inline-SVG work (chart + PNG export) needs literal values, with hex fallbacks for a missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord, todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker, and the weatherType summary (dsr-aware; the day page just omits dsr). - nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in the shared classic-script scope, and a leaked locHash collided with page destructuring (caught by the browser smoke, not by node --check). locHash is now exported and used by all 8 former hand-built hash sites. - mappicker.js: initFindButton/setFindLabel replace the Find-button block each page rebuilt. - legend.html renders its scales from the shared tables, so the guide can no longer drift from what the app shows. Verified: node --check on all JS; 108 backend tests; headless-Chromium smoke over all five pages against live data — no console/page errors, legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle, calendar grid + key + metric switch, day 7 ladders + weather icon, compare seeded rank card.
170 lines
9.3 KiB
JavaScript
170 lines
9.3 KiB
JavaScript
"use strict";
|
||
// Shared presentation constants + small helpers for every page script. Loaded on
|
||
// all pages right after nav.js (which creates window.Thermograph) and extends
|
||
// that namespace — each constant and helper here previously existed as a copy in
|
||
// app.js, calendar.js, day.js, compare.js and/or legend.html; pages destructure
|
||
// what they need so a tier color, scale label or formatter has exactly one home.
|
||
(function () {
|
||
// ---- 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);
|
||
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.
|
||
const PRECIP_COLORS = Object.fromEntries(
|
||
Object.entries(TIER_COLORS).filter(([c]) => c.startsWith("wet-")));
|
||
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.
|
||
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).
|
||
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(",")})`;
|
||
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.)
|
||
const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`);
|
||
const fmtWind = (v) => (v == null ? "—" : `${Math.round(v)} mph`);
|
||
const fmtHumid = (v) => (v == null ? "—" : `${v.toFixed(1)} g/m³`);
|
||
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]);
|
||
};
|
||
const todayISO = () => new Date().toISOString().slice(0, 10);
|
||
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.
|
||
const placeLabel = (data) =>
|
||
data.place || `${data.cell.center_lat.toFixed(3)}, ${data.cell.center_lon.toFixed(3)}`;
|
||
|
||
// ---- dates & range chunking ----
|
||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||
const pad = (n) => String(n).padStart(2, "0");
|
||
const isoOfDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||
const monthStart = (ym) => `${ym}-01`; // 1st of a "YYYY-MM"
|
||
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.
|
||
const CHUNK_MONTHS = 24;
|
||
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.
|
||
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"/>`;
|
||
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).
|
||
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 };
|
||
}
|
||
|
||
Object.assign(window.Thermograph, {
|
||
TIER_COLORS, PRECIP_COLORS, DRY_COLOR, SCALE_TEMP, SCALE_RAIN,
|
||
drynessColor, fmtPrecip, fmtWind, fmtHumid, ord, todayISO, esc, placeLabel,
|
||
MONTHS, pad, isoOfDate, monthStart, monthEnd, CHUNK_MONTHS, buildChunks,
|
||
clickOpensPicker, WX_ICONS, weatherType,
|
||
});
|
||
})();
|