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.
This commit is contained in:
Emi Griffith 2026-07-11 13:21:48 -07:00 committed by GitHub
parent a3c22ef6d7
commit a251b0df23
12 changed files with 252 additions and 318 deletions

View file

@ -1,10 +1,12 @@
"use strict";
// Shared tier colors, scales, formatters and helpers (single home: shared.js).
const { TIER_COLORS, SCALE_TEMP, drynessColor, ord, esc, todayISO, placeLabel,
fmtPrecip, fmtWind, fmtHumid, locHash } = window.Thermograph;
let selected = null; // {lat, lon}
const dateInput = document.getElementById("date-input");
const todayBtn = document.getElementById("today-btn");
const todayISO = () => new Date().toISOString().slice(0, 10);
dateInput.value = todayISO();
dateInput.max = dateInput.value;
@ -36,10 +38,8 @@ function selectLocation(lat, lon) {
// The Find button opens the shared modal map picker; choosing a spot loads it.
const findBtn = document.getElementById("find-btn");
const locLabel = document.getElementById("loc-label");
findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Find a location</span>`;
findBtn.addEventListener("click", () => {
window.LocationPicker.open(selected, (lat, lon) => selectLocation(lat, lon));
});
window.LocationPicker.initFindButton(findBtn, "Find a location",
() => selected, (lat, lon) => selectLocation(lat, lon));
dateInput.addEventListener("change", () => { syncTodayBtn(); selected && runGrade(); });
@ -52,7 +52,7 @@ let lastGraded = null; // most recent /api/grade response (for the PNG export)
results.addEventListener("click", (e) => {
const row = e.target.closest(".rd-grid [data-date]");
if (!row || !selected) return;
location.href = `day#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${row.dataset.date}`;
location.href = `day${locHash(selected.lat, selected.lon, row.dataset.date)}`;
});
let gradeSeq = 0; // supersedes an in-flight load when the user picks a new spot/date
@ -95,9 +95,6 @@ async function runGrade() {
// Temps flow through the shared unit formatter (°F from the API, shown in the
// active unit); the others are unit-agnostic.
const fmtTemp = (v) => window.Thermograph.fmtTemp(v);
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³`);
// Compact cell formatters for the dense recent/forecast table (units live in the
// column headers, so values stay short). Dry days label the running dry streak
// (see precipCell) rather than the rain amount.
@ -114,10 +111,6 @@ const IC = {
calendar: `<svg class="ic ic-lg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
};
function placeLabel(data) {
return data.place || `${data.cell.center_lat.toFixed(3)}, ${data.cell.center_lon.toFixed(3)}`;
}
function render(data) {
recentData = data;
// The place name appears once — as the results heading (<h2>) below, which also
@ -126,7 +119,7 @@ function render(data) {
// results have rendered (otherwise the name would show twice).
locLabel.hidden = true;
locLabel.textContent = "";
findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Change location</span>`;
window.LocationPicker.setFindLabel(findBtn, "Change location");
const c = data.climatology;
const cell = data.cell;
const yrs = c.year_range ? `${c.year_range[0]}${c.year_range[1]}` : "—";
@ -137,7 +130,7 @@ function render(data) {
// window now runs past the target into the forecast, so the target is looked up
// by date rather than taken as the newest row.
const targetDay = data.recent.find((d) => d.date === data.target_date) || null;
const dayHref = `day#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${data.target_date}`;
const dayHref = `day${locHash(selected.lat, selected.lon, data.target_date)}`;
const cmpCard = (label, key, clim, fmt) => {
const g = targetDay ? targetDay[key] : null;
const normalVal = clim ? fmt(clim.p50) : "—";
@ -168,7 +161,7 @@ function render(data) {
<button id="btn-png" title="Download the trend chart as an image">${IC.download} Chart</button>
</div>
</div>
<a class="cta-cal" href="calendar#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}">
<a class="cta-cal" href="calendar${locHash(selected.lat, selected.lon)}">
<span class="cta-cal-icon" aria-hidden="true">${IC.calendar}</span>
<span>Check historical data</span>
<span class="cta-cal-arrow" aria-hidden="true"></span>
@ -299,37 +292,12 @@ function scrollTableToTarget(host, target) {
}
// ---- trend chart (self-contained inline SVG) ----
// Tier css class -> literal color (mirrors style.css; needed because the chart is
// rasterized to PNG, where CSS variables aren't available).
const TIER_COLORS = {
"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",
};
// Percentile grade scale, low -> high. Names are deliberately *relative* to each
// place's own climate history (a percentile), not absolute temperature words like
// "hot"/"cold" — see the README design note.
const GRADE_SCALE = [
{ cls: "rec-cold", label: "Near Record", range: "<1" },
{ cls: "very-cold", label: "Very Low", range: "110" },
{ cls: "cold", label: "Low", range: "1025" },
{ cls: "cool", label: "Below Normal", range: "2540" },
{ cls: "normal", label: "Normal", range: "4060" },
{ cls: "warm", label: "Above Normal", range: "6075" },
{ cls: "hot", label: "High", range: "7590" },
{ cls: "very-hot", label: "Very High", range: "9099" },
{ cls: "rec-hot", label: "Near Record", range: ">99" },
];
function tierKeyHtml() {
// Highest tier first (Near Record hot → Near Record cold).
const segs = [...GRADE_SCALE].reverse().map((t) =>
const segs = [...SCALE_TEMP].reverse().map((t) =>
`<div class="tk-seg">
<span class="tk-swatch" style="background:${TIER_COLORS[t.cls]}"></span>
<span class="tk-swatch" style="background:${TIER_COLORS[t.c]}"></span>
<span class="tk-label">${t.label}</span>
</div>`).join("");
return `<div class="section-title">Grade scale · low → high vs local normal</div>
@ -381,10 +349,6 @@ let W = 720;
const H = 340;
const PL = 46, PR = 62, plotTop = 48, plotBot = 288, xLabY = 308;
let PW = W - PL - PR;
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]);
};
// Read a percentile from a normals object, falling back to nearby keys so an
// older/partial API response (missing p1/p30/p70/p99) still renders a chart.
@ -636,17 +600,6 @@ function precipChart(days, n, xFor, C) {
});
}
// "Days since last rain" dry-streak color: rain days are blue; each dry day warms
// from a light base toward dark red, saturating at 14 consecutive dry days.
// (Mirrors the calendar's dry-streak ramp.)
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);
}
// Dry streak as a line: days since last rain, dots warming with the streak; a rain
// day drops the line to 0 with a blue dot. (No climatology fan — it's not graded.)
function dryChart(days, n, xFor, C) {
@ -718,15 +671,13 @@ function attachChartHover(wrap) {
// ---- share ----
function copyShareLink() {
if (!selected) return;
const url = `${location.origin}${location.pathname}#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${dateInput.value}`;
const url = `${location.origin}${location.pathname}${locHash(selected.lat, selected.lon, dateInput.value)}`;
navigator.clipboard.writeText(url).then(
() => flashBtn("btn-link", "✓ Copied!"),
() => { prompt("Copy this link:", url); }
);
}
const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// Build a table of graded days as SVG markup, stacked below the chart.
function exportTableSvg(C) {
const rows = lastGraded.recent; // newest first (matches the on-screen table)
@ -811,7 +762,7 @@ function flashBtn(id, text) {
function updateHash() {
if (!selected) return;
history.replaceState(null, "", `#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${dateInput.value}`);
history.replaceState(null, "", locHash(selected.lat, selected.lon, dateInput.value));
window.Thermograph.saveLastLocation(selected.lat, selected.lon, dateInput.value);
}

View file

@ -94,6 +94,7 @@
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="nav.js"></script>
<script src="shared.js"></script>
<script src="mappicker.js"></script>
<script src="calendar.js"></script>
</body>

View file

@ -1,63 +1,15 @@
"use strict";
// Thermograph calendar subpage — two years of daily grades as a month-grid heatmap.
// Talks to the v2 API (/api/v2/calendar, /api/v2/geocode). Self-contained; it
// intentionally re-declares the small shared tier constants so it stays independent
// of app.js (the map page).
// Talks to the v2 API (/api/v2/calendar, /api/v2/geocode). Shared tier colors,
// scales, formatters, the weather summary and range chunking live in shared.js.
// 9-step diverging temperature scale (cold navy -> green -> hot red).
const TIER_COLORS = {
"rec-hot": "#8f0e20", "very-hot": "#dd6b52", "hot": "#ef9351", "warm": "#f6c18a",
"normal": "#4a9d5b",
"cool": "#92c5de", "cold": "#4393c3", "very-cold": "#2166ac", "rec-cold": "#0a2f6b",
};
// 9-step rain-intensity ramp (rain-day percentile): light green -> teal -> dark navy.
const PRECIP_COLORS = {
"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",
};
// "Days since last rain" dry-streak color: 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);
}
// low -> high percentile scale, per metric (temp vs precip use different classes)
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 are shown via 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" },
];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord,
fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
CHUNK_MONTHS, buildChunks, clickOpensPicker, locHash,
weatherType: wxType } = window.Thermograph;
// Temps go through the shared unit formatter (the day tooltip reads it live, so a
// unit switch shows on the next hover); the others are unit-agnostic.
// unit switch shows on the next hover).
const fmtTemp = (v) => window.Thermograph.fmtTemp(v);
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³`);
// The diverging-temperature-scale metrics (colored exactly like High/Low), with a
// display name for the key + tooltip and the formatter for their values.
@ -69,32 +21,8 @@ const TEMP_METRIC_INFO = {
wind: { name: "Wind", label: "wind speed", fmt: fmtWind },
gust: { name: "Gust", label: "wind gust", fmt: fmtWind },
};
// The server grades at most ~2 years per request; longer spans are split into
// successive 2-year chunks the frontend requests one at a time, filling the grid
// as each arrives.
const CHUNK_MONTHS = 24;
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]);
};
// Monochrome line icons (currentColor) — no emoji, so the summary matches the
// site's dark/official 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 IC_POINTER = `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/></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
// (from precipitation, inches — falling as snow when it's at/below freezing).
// The filterable day-type vocabulary (the weather filter's two checkbox groups).
// Keys match what weatherType() reports for each day.
const FEEL_WORDS = [
@ -106,28 +34,10 @@ const SKY_WORDS = [
["rain", "Rain"], ["downpour", "Downpour"], ["snow", "Snow"],
];
function weatherType(rec) {
const t = rec.tmax ? rec.tmax.v : null; // daily high, °F
const p = rec.precip ? rec.precip.v : null; // precipitation, inches
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 = rec.dsr != null && rec.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 };
}
// The shared weather summary, adapted to the calendar's compact day records
// (dsr included, so a long-dry no-rain day reads "dry" rather than "clear").
const weatherType = (rec) =>
wxType(rec.tmax && rec.tmax.v, rec.precip && rec.precip.v, rec.dsr);
let selected = null; // {lat, lon}
let data = null; // last /api/v2/calendar response
@ -220,10 +130,8 @@ calEl.addEventListener("pointerleave", (e) => {
// The Find button opens the shared modal map picker; choosing a spot loads it.
const findBtn = document.getElementById("find-btn");
const locLabel = document.getElementById("loc-label");
findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Find a location</span>`;
findBtn.addEventListener("click", () => {
window.LocationPicker.open(selected, (lat, lon) => selectLocation(lat, lon));
});
window.LocationPicker.initFindButton(findBtn, "Find a location",
() => selected, (lat, lon) => selectLocation(lat, lon));
// ---- metric toggle ----
// Reflect the restored metric on the toggle (the HTML defaults to High).
@ -388,44 +296,13 @@ const refreshBtn = document.getElementById("range-refresh");
// ~1980 and ends a few days ago, and out-of-range picks are clamped server-side.
startInput.min = endInput.min = "1970-01";
startInput.max = endInput.max = `${new Date().getFullYear()}-12`;
const isoOfDate = (d) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const monthStart = (ym) => `${ym}-01`; // 1st of the month
const monthEnd = (ym) => { // last day of the month
const d = new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0);
return isoOfDate(d);
};
// Split [startIso, endIso] into consecutive ≤2-year windows (oldest first) so each
// stays within the server's per-request cap. The grid fills chunk by chunk.
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;
}
// Editing a date doesn't fetch — it reveals the Refresh button so the user
// confirms before a (possibly multi-request) load kicks off.
function markRangeDirty() { refreshBtn.hidden = false; }
startInput.addEventListener("change", markRangeDirty);
endInput.addEventListener("change", markRangeDirty);
// Clicking anywhere in a From/To field opens the native month/year picker.
// Desktop Chrome otherwise only opens it from the tiny calendar glyph and just
// focuses a segment. showPicker isn't everywhere (Safari/Firefox) — ignore there.
for (const el of [startInput, endInput]) {
el.addEventListener("click", () => { try { el.showPicker(); } catch (e) {} });
}
clickOpensPicker(startInput, endInput); // whole-field tap opens the month picker
// Confirm: read the pickers into the active range and (re)load.
function applyRange() {
@ -454,7 +331,7 @@ function syncDayToOtherViews() {
// ---- fetch + render ----
function selectLocation(lat, lon) {
selected = { lat, lon };
history.replaceState(null, "", `#lat=${lat.toFixed(5)}&lon=${lon.toFixed(5)}`);
history.replaceState(null, "", locHash(lat, lon));
window.Thermograph.saveLastLocation(lat, lon); // keep date for the other views
// Show the raw coordinates immediately; they're replaced with the resolved
// neighborhood + city name once the first calendar chunk lands.
@ -838,7 +715,7 @@ function attachHover(byDate) {
// navigates (the tooltip is just a hover preview). On touch there's no hover, so
// the first tap only raises the tooltip and a second tap on the same day confirms.
const openDay = (cell) => {
location.href = `day#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${cell.dataset.date}`;
location.href = `day${locHash(selected.lat, selected.lon, cell.dataset.date)}`;
};
calEl.querySelectorAll(".cal-cell.filled").forEach((c) => {
c.addEventListener("pointerenter", (e) => { if (e.pointerType === "mouse") show(c, e); });

View file

@ -103,6 +103,7 @@
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="nav.js"></script>
<script src="shared.js"></script>
<script src="mappicker.js"></script>
<script src="compare.js"></script>
</body>

View file

@ -12,6 +12,10 @@
// hand). Editing the date range or the location set doesn't refetch on its own:
// it arms the Refresh button, and the load runs when the user taps it.
// Shared date helpers + range chunking live in shared.js.
const { MONTHS, pad, isoOfDate, monthStart, monthEnd, buildChunks,
clickOpensPicker } = window.Thermograph;
const CMP = {
comfort: "thermograph:cmpComfort",
tol: "thermograph:cmpTol",
@ -39,7 +43,6 @@ let loadToken = 0;
const BASIS_LABEL = { tmax: "daytime high", mean: "daily mean", tmin: "overnight low", feels: "feels-like" };
// ---- date range (month granularity) ----
const pad = (n) => String(n).padStart(2, "0");
const isYM = (s) => typeof s === "string" && /^\d{4}-\d{2}$/.test(s);
function defaultRange() {
const now = new Date();
@ -52,32 +55,8 @@ function loadRange() {
}
let range = loadRange(); // the APPLIED range (what loaded data reflects), "YYYY-MM"
const monthStart = (ym) => `${ym}-01`;
const isoOfDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const monthEnd = (ym) => isoOfDate(new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0));
// Pretty "Jul 2025 Jun 2026" for a YYYY-MM range.
const MON = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const monthLabel = (ym) => `${MON[+ym.slice(5, 7) - 1]} ${ym.slice(0, 4)}`;
// Split a [startIso, endIso] span into consecutive ≤2-year windows so each stays
// within the calendar endpoint's per-request cap (mirrors the Calendar view).
const CHUNK_MONTHS = 24;
function buildChunks(startIso, endIso) {
const out = [];
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);
let e = isoOfDate(ed);
if (e > endIso) e = endIso;
out.push({ start: s, end: e });
const ns = new Date(e + "T00:00:00"); ns.setDate(ns.getDate() + 1);
s = isoOfDate(ns);
}
return out;
}
const monthLabel = (ym) => `${MONTHS[+ym.slice(5, 7) - 1]} ${ym.slice(0, 4)}`;
// ---- shareable URL state ----
// The hash carries the full comparison: c=comfort, t=band, b=basis, s/e=range,
@ -383,11 +362,7 @@ basisToggle.addEventListener("click", (e) => {
startInput.max = endInput.max = `${new Date().getFullYear()}-12`;
startInput.addEventListener("change", renderAll);
endInput.addEventListener("change", renderAll);
// Clicking anywhere in a From/To field opens the native month/year picker
// (mirrors the Calendar range; showPicker is missing in some browsers — ignore).
for (const el of [startInput, endInput]) {
el.addEventListener("click", () => { try { el.showPicker(); } catch (e) {} });
}
clickOpensPicker(startInput, endInput); // whole-field tap opens the month picker
// ---- init ----
(function restore() {

View file

@ -66,6 +66,7 @@
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="nav.js"></script>
<script src="shared.js"></script>
<script src="mappicker.js"></script>
<script src="day.js"></script>
</body>

View file

@ -2,67 +2,18 @@
// Thermograph single-day detail subpage — for one day + location, shows the value
// at every percentile tier boundary in that day-of-year's ±7-day climate window,
// and where the observed values land. Talks to /api/v2/day + /api/v2/geocode.
// Self-contained; re-declares the small shared tier constants (like calendar.js)
// so the page stays independent of app.js.
// Shared tier colors, formatters, weather summary and helpers live in shared.js.
const TIER_COLORS = {
"rec-hot": "#8f0e20", "very-hot": "#dd6b52", "hot": "#ef9351", "warm": "#f6c18a",
"normal": "#4a9d5b",
"cool": "#92c5de", "cold": "#4393c3", "very-cold": "#2166ac", "rec-cold": "#0a2f6b",
};
const PRECIP_COLORS = {
"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 DRY_COLOR = "#c9a24a";
// Temps go through the shared unit formatter; the rest are unit-agnostic.
const { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, ord, fmtPrecip, fmtWind, fmtHumid,
todayISO, weatherType, placeLabel, locHash } = window.Thermograph;
const fmtTemp = (v) => window.Thermograph.fmtTemp(v);
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]);
};
// Color a tier the same way the calendar cell would be colored for it.
const tierColor = (c) => (c === "dry" ? DRY_COLOR : TIER_COLORS[c] || PRECIP_COLORS[c] || "");
// Monochrome line icons (currentColor) — no emoji, matching the site's dark look.
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 the day was like" descriptor from the observed values —
// mirrors the calendar tooltip. (No dry-streak here, so no-rain days read "clear".)
function weatherType(t, p) {
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, icon;
if (wet && freezing) { sky = p >= 0.3 ? "heavy snow" : "snow"; icon = WX_ICONS.snow; }
else if (wet && p >= 1.0) { sky = "downpour"; icon = WX_ICONS.storm; }
else if (wet && p >= 0.3) { sky = "rain"; icon = WX_ICONS.rain; }
else if (wet) { sky = "light rain"; icon = WX_ICONS.drizzle; }
else { sky = "clear"; icon = WX_ICONS.sun; }
const text = !temp ? sky : wet ? `${temp}, ${sky}` : `${temp} & ${sky}`;
return { icon, text: text.charAt(0).toUpperCase() + text.slice(1) };
}
let selected = null; // {lat, lon}
let curDate = null; // "YYYY-MM-DD" currently shown
let latest = null; // newest date available in the record
const todayISO = () => new Date().toISOString().slice(0, 10);
const placeholder = document.getElementById("day-placeholder");
const dayHead = document.getElementById("day-head");
@ -73,9 +24,8 @@ const dateInput = document.getElementById("date-input");
// The Find button opens the shared modal map picker; choosing a spot loads it.
const findBtn = document.getElementById("find-btn");
const locLabel = document.getElementById("loc-label");
findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Find a location</span>`;
findBtn.addEventListener("click", () => {
window.LocationPicker.open(selected, (lat, lon) => {
window.LocationPicker.initFindButton(findBtn, "Find a location", () => selected,
(lat, lon) => {
selected = { lat, lon };
// Show the raw coordinates immediately; render() replaces them with the
// resolved neighborhood + city name once the day fetch returns.
@ -83,7 +33,6 @@ findBtn.addEventListener("click", () => {
locLabel.hidden = false;
fetchDay();
});
});
// ---- date navigation ----
dateInput.addEventListener("change", () => { if (dateInput.value) { curDate = dateInput.value; fetchDay(); } });
@ -106,7 +55,7 @@ let daySeq = 0; // supersedes an in-flight load when the user picks a new spot
async function fetchDay() {
if (!selected) return;
const dateQ = curDate ? `&date=${curDate}` : "";
history.replaceState(null, "", `#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}${curDate ? `&date=${curDate}` : ""}`);
history.replaceState(null, "", locHash(selected.lat, selected.lon, curDate));
placeholder.hidden = true;
dayHead.hidden = false;
const seq = ++daySeq;
@ -155,11 +104,10 @@ window.Thermograph.onUnitChange(() => { if (lastData) render(lastData); });
function render(data) {
lastData = data;
const d = data.detail;
const cell = data.cell;
const place = data.place || `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`;
const place = placeLabel(data);
locLabel.textContent = place;
locLabel.hidden = false;
findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Change location</span>`;
window.LocationPicker.setFindLabel(findBtn, "Change location");
const dt = new Date(d.date + "T00:00:00");
const nice = dt.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" });
const yrs = d.year_range ? `${d.year_range[0]}${d.year_range[1]}` : "—";

View file

@ -69,6 +69,7 @@
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="nav.js"></script>
<script src="shared.js"></script>
<script src="mappicker.js"></script>
<script src="app.js"></script>
</body>

View file

@ -85,25 +85,16 @@
</main>
<script src="nav.js"></script>
<script src="shared.js"></script>
<script>
// The scale rows mirror the tiers used across the app (see calendar.js / app.js).
const TEMP = [
["rec-cold", "Near Record", "<1"], ["very-cold", "Very Low", "110"],
["cold", "Low", "1025"], ["cool", "Below Normal", "2540"],
["normal", "Normal", "4060"], ["warm", "Above Normal", "6075"],
["hot", "High", "7590"], ["very-hot", "Very High", "9099"],
["rec-hot", "Near Record", ">99"],
];
const RAIN = [
["wet-1", "Trace", "<1"], ["wet-2", "Very Light", "110"], ["wet-3", "Light", "1025"],
["wet-4", "LightMod", "2540"], ["wet-5", "Moderate", "4060"], ["wet-6", "ModHeavy", "6075"],
["wet-7", "Heavy", "7590"], ["wet-8", "Very Heavy", "9099"], ["wet-9", "Extreme", ">99"],
];
const row = ([cls, label, range]) =>
`<div class="guide-seg"><span class="guide-sw" style="background:var(--${cls})"></span>` +
// The scale rows come from the shared tier tables, so this guide can't
// drift from what the app actually shows.
const { SCALE_TEMP, SCALE_RAIN } = window.Thermograph;
const row = ({ c, label, range }) =>
`<div class="guide-seg"><span class="guide-sw" style="background:var(--${c})"></span>` +
`<span class="guide-lbl">${label}</span><span class="guide-rng">${range}</span></div>`;
document.getElementById("temp-scale").innerHTML = TEMP.map(row).join("");
document.getElementById("rain-scale").innerHTML = RAIN.map(row).join("");
document.getElementById("temp-scale").innerHTML = SCALE_TEMP.map(row).join("");
document.getElementById("rain-scale").innerHTML = SCALE_RAIN.map(row).join("");
</script>
</body>
</html>

View file

@ -210,5 +210,16 @@
function close() { if (overlay) overlay.hidden = true; }
window.LocationPicker = { open, PIN_ICON };
// Wire a page's Find/Add button: pin icon + label, and open the picker on
// click seeded with the page's current spot. `setFindLabel` swaps the label
// (e.g. "Find a location" → "Change location") keeping the icon markup.
function initFindButton(btn, label, getCurrent, onPick) {
setFindLabel(btn, label);
btn.addEventListener("click", () => open(getCurrent(), onPick));
}
function setFindLabel(btn, label) {
btn.innerHTML = `${PIN_ICON} <span>${label}</span>`;
}
window.LocationPicker = { open, PIN_ICON, initFindButton, setFindLabel };
})();

View file

@ -1,10 +1,15 @@
"use strict";
// Shared cross-view navigation + last-location memory. Loaded on all three pages
// (map, calendar, day) before that page's own script. It remembers the most
// recently selected spot in localStorage and keeps the header's view links
// (Map · Calendar · Day) pointing at that spot — so switching views, or coming
// back to the map, lands you where you were instead of an empty world map.
// Shared cross-view navigation + last-location memory. Loaded on all pages
// before that page's own script. It remembers the most recently selected spot
// in localStorage and keeps the header's view links (Map · Calendar · Day)
// pointing at that spot — so switching views, or coming back to the map, lands
// you where you were instead of an empty world map.
//
// Wrapped in an IIFE so nothing here becomes a global — every classic script
// shares one global scope, and a leaked `function locHash` collides with a
// page's `const { locHash } = window.Thermograph`. The namespace object at
// the bottom is the only export.
(function () {
const LOC_KEY = "thermograph:loc";
function readStored() {
@ -347,5 +352,7 @@ function prefetchNeighbors(lat, lon) {
}
}
window.Thermograph = { loadLastLocation, saveLastLocation, getJSON, prefetchViews, hasFreshCache, TTL,
getUnit, toUnit, fmtTemp: fmtTempU, onUnitChange, setUnit };
window.Thermograph = { loadLastLocation, saveLastLocation, locHash, getJSON, prefetchViews,
hasFreshCache, TTL, getUnit, toUnit, fmtTemp: fmtTempU, onUnitChange, setUnit };
})();

170
static/shared.js Normal file
View file

@ -0,0 +1,170 @@
"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,
});
})();