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).
159 lines
8.7 KiB
JavaScript
159 lines
8.7 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.
|
||
// ---- 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, "<").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 };
|
||
}
|