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:
parent
a3c22ef6d7
commit
a251b0df23
12 changed files with 252 additions and 318 deletions
|
|
@ -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: "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) =>
|
||||
`<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, "&").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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
`<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); });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]}` : "—";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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", "1–10"],
|
||||
["cold", "Low", "10–25"], ["cool", "Below Normal", "25–40"],
|
||||
["normal", "Normal", "40–60"], ["warm", "Above Normal", "60–75"],
|
||||
["hot", "High", "75–90"], ["very-hot", "Very High", "90–99"],
|
||||
["rec-hot", "Near Record", ">99"],
|
||||
];
|
||||
const RAIN = [
|
||||
["wet-1", "Trace", "<1"], ["wet-2", "Very Light", "1–10"], ["wet-3", "Light", "10–25"],
|
||||
["wet-4", "Light–Mod", "25–40"], ["wet-5", "Moderate", "40–60"], ["wet-6", "Mod–Heavy", "60–75"],
|
||||
["wet-7", "Heavy", "75–90"], ["wet-8", "Very Heavy", "90–99"], ["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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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
170
static/shared.js
Normal 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: "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, "<").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) =>
|
||||
`<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,
|
||||
});
|
||||
})();
|
||||
Loading…
Reference in a new issue