thermograph/static/shared.js
Emi Griffith 4d3a5a1053 Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).

nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
  special case is gone — pages that want the toggle import units.js;
  compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
  view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
  now takes the calling page's own view names instead of a page-identity
  map (VIEW_OWN) — adding a page no longer means editing this module.
  The slice->URL map stays here as bundle-contract knowledge.

frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.

Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00

159 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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: "110" },
{ c: "cold", label: "Low", range: "1025" },
{ c: "cool", label: "Below Normal", range: "2540" },
{ c: "normal", label: "Normal", range: "4060" },
{ c: "warm", label: "Above Normal", range: "6075" },
{ c: "hot", label: "High", range: "7590" },
{ c: "very-hot", label: "Very High", range: "9099" },
{ 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: "110" },
{ c: "wet-3", label: "Light", range: "1025" },
{ c: "wet-4", label: "LightMod", range: "2540" },
{ c: "wet-5", label: "Moderate", range: "4060" },
{ c: "wet-6", label: "ModHeavy", range: "6075" },
{ c: "wet-7", label: "Heavy", range: "7590" },
{ c: "wet-8", label: "Very Heavy", range: "9099" },
{ 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// 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 };
}