thermograph/static/shared.js
Emi Griffith a251b0df23 Extract shared.js: one home for tier colors, scales, formatters, helpers (#47)
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.
2026-07-11 20:21:48 +00:00

170 lines
9.3 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.

"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: "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).
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(",")})`;
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, "&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.
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,
});
})();