From a251b0df230d0fd2a22c887ffb2e066961b3cafa Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sat, 11 Jul 2026 13:21:48 -0700 Subject: [PATCH] Extract shared.js: one home for tier colors, scales, formatters, helpers (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- static/app.js | 75 ++++--------------- static/calendar.html | 1 + static/calendar.js | 155 ++++----------------------------------- static/compare.html | 1 + static/compare.js | 37 ++-------- static/day.html | 1 + static/day.js | 68 ++--------------- static/index.html | 1 + static/legend.html | 25 ++----- static/mappicker.js | 13 +++- static/nav.js | 23 ++++-- static/shared.js | 170 +++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 252 insertions(+), 318 deletions(-) create mode 100644 static/shared.js diff --git a/static/app.js b/static/app.js index 19fc983..0dac510 100644 --- a/static/app.js +++ b/static/app.js @@ -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} Find a location`; -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: ``, }; -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 (

) 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} Change location`; + 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) { - + Check historical data @@ -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: "1–10" }, - { cls: "cold", label: "Low", range: "10–25" }, - { cls: "cool", label: "Below Normal", range: "25–40" }, - { cls: "normal", label: "Normal", range: "40–60" }, - { cls: "warm", label: "Above Normal", range: "60–75" }, - { cls: "hot", label: "High", range: "75–90" }, - { cls: "very-hot", label: "Very High", range: "90–99" }, - { 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) => `
- + ${t.label}
`).join(""); return `
Grade scale · low → high vs local normal
@@ -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, "&").replace(//g, ">"); - // 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); } diff --git a/static/calendar.html b/static/calendar.html index e5827a0..45dc6e7 100644 --- a/static/calendar.html +++ b/static/calendar.html @@ -94,6 +94,7 @@ + diff --git a/static/calendar.js b/static/calendar.js index 1094dff..e0a4d85 100644 --- a/static/calendar.js +++ b/static/calendar.js @@ -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: "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 are shown via 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" }, -]; - -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) => - ``; const IC_POINTER = ``; -const WX_CLOUD = ``; -const WX_ICONS = { - sun: WX(``), - drizzle: WX(`${WX_CLOUD}`), - rain: WX(`${WX_CLOUD}`), - storm: WX(``), - snow: WX(``), -}; -// 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} Find a location`; -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); }); diff --git a/static/compare.html b/static/compare.html index fe0c869..8ccad4e 100644 --- a/static/compare.html +++ b/static/compare.html @@ -103,6 +103,7 @@ + diff --git a/static/compare.js b/static/compare.js index 15b719e..8b0c85c 100644 --- a/static/compare.js +++ b/static/compare.js @@ -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() { diff --git a/static/day.html b/static/day.html index 5c58702..bcfd107 100644 --- a/static/day.html +++ b/static/day.html @@ -66,6 +66,7 @@ + diff --git a/static/day.js b/static/day.js index 004e7f5..78e363b 100644 --- a/static/day.js +++ b/static/day.js @@ -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) => - ``; -const WX_CLOUD = ``; -const WX_ICONS = { - sun: WX(``), - drizzle: WX(`${WX_CLOUD}`), - rain: WX(`${WX_CLOUD}`), - storm: WX(``), - snow: WX(``), -}; - -// 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} Find a location`; -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} Change location`; + 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]}` : "—"; diff --git a/static/index.html b/static/index.html index 9928f92..6ec6935 100644 --- a/static/index.html +++ b/static/index.html @@ -69,6 +69,7 @@ + diff --git a/static/legend.html b/static/legend.html index b1f762d..ff5b00a 100644 --- a/static/legend.html +++ b/static/legend.html @@ -85,25 +85,16 @@ + diff --git a/static/mappicker.js b/static/mappicker.js index cc18792..f2a2833 100644 --- a/static/mappicker.js +++ b/static/mappicker.js @@ -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} ${label}`; + } + + window.LocationPicker = { open, PIN_ICON, initFindButton, setFindLabel }; })(); diff --git a/static/nav.js b/static/nav.js index 781f0a2..ff9269b 100644 --- a/static/nav.js +++ b/static/nav.js @@ -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 }; +})(); diff --git a/static/shared.js b/static/shared.js new file mode 100644 index 0000000..ddd7516 --- /dev/null +++ b/static/shared.js @@ -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: "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, ">"); + // 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) => + ``; + const WX_CLOUD = ``; + const WX_ICONS = { + sun: WX(``), + drizzle: WX(`${WX_CLOUD}`), + rain: WX(`${WX_CLOUD}`), + storm: WX(``), + snow: WX(``), + }; + + // 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, + }); +})();