800 lines
39 KiB
JavaScript
800 lines
39 KiB
JavaScript
"use strict";
|
||
|
||
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;
|
||
|
||
// The "Today" chip snaps the target date back to today; it stays lit while the
|
||
// target already is today, so it doubles as a "you're viewing now" indicator.
|
||
function syncTodayBtn() {
|
||
todayBtn.classList.toggle("active", dateInput.value === todayISO());
|
||
}
|
||
syncTodayBtn();
|
||
todayBtn.addEventListener("click", () => {
|
||
const t = todayISO();
|
||
if (dateInput.value === t) return;
|
||
dateInput.value = t;
|
||
dateInput.max = t;
|
||
syncTodayBtn();
|
||
if (selected) runGrade();
|
||
});
|
||
|
||
function selectLocation(lat, lon) {
|
||
selected = { lat, lon };
|
||
runGrade();
|
||
}
|
||
|
||
// ---- location picker ----
|
||
// 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));
|
||
});
|
||
|
||
dateInput.addEventListener("change", () => { syncTodayBtn(); selected && runGrade(); });
|
||
|
||
// ---- grade request + render ----
|
||
const placeholder = document.getElementById("placeholder");
|
||
const results = document.getElementById("results");
|
||
let lastGraded = null; // most recent /api/grade response (for the PNG export)
|
||
|
||
// Clicking a graded day (any cell or its date header) opens its full breakdown.
|
||
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}`;
|
||
});
|
||
|
||
async function runGrade() {
|
||
if (!selected) return;
|
||
updateHash();
|
||
placeholder.hidden = true;
|
||
results.hidden = false;
|
||
results.innerHTML = `<p class="spinner">Fetching decades of climate history…</p>`;
|
||
|
||
// Omit the date when it's today, so the URL (and cache key) matches the prefetch
|
||
// fired from the other views — a same-tab navigation then reuses the response.
|
||
const q = `lat=${selected.lat}&lon=${selected.lon}`;
|
||
const url = dateInput.value === todayISO() ? `api/grade?${q}` : `api/grade?${q}&date=${dateInput.value}`;
|
||
let data;
|
||
try {
|
||
data = await window.Thermograph.getJSON(url, window.Thermograph.TTL.grade);
|
||
} catch (err) {
|
||
results.innerHTML = `<p class="error">${err.message}</p>`;
|
||
return;
|
||
}
|
||
render(data);
|
||
}
|
||
|
||
const fmtTemp = (v) => (v == null ? "—" : `${Math.round(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.
|
||
const cTemp = (v) => (v == null ? "—" : `${Math.round(v)}°`);
|
||
const cHum = (v) => (v == null ? "—" : `${Math.round(v)}%`);
|
||
const cWind = (v) => (v == null ? "—" : `${Math.round(v)}`);
|
||
const cRain = (v) => (v == null ? "—" : v > 0 ? v.toFixed(2) : "·");
|
||
|
||
// Monochrome inline icons (currentColor) so the UI chrome matches the dark theme
|
||
// instead of using colorful emoji. Feather-style line paths.
|
||
const IC = {
|
||
link: `<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="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
|
||
download: `<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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
|
||
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;
|
||
forecastData = null; // invalidate any forecast from a previous location/date
|
||
// Reflect the chosen place beside the Find button (which becomes "Change").
|
||
locLabel.textContent = placeLabel(data);
|
||
locLabel.hidden = false;
|
||
findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Change location</span>`;
|
||
const c = data.climatology;
|
||
const cell = data.cell;
|
||
const yrs = c.year_range ? `${c.year_range[0]}–${c.year_range[1]}` : "—";
|
||
const coords = `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`;
|
||
|
||
// Each card compares the target day's value to the normal (median) for that
|
||
// metric, tinted by its grade, and links to the full single-day breakdown.
|
||
const today = data.recent[0] || null;
|
||
const dayHref = `day#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${data.target_date}`;
|
||
const cmpCard = (label, key, clim, fmt) => {
|
||
const g = today ? today[key] : null;
|
||
const normalVal = clim ? fmt(clim.p50) : "—";
|
||
const cat = g ? (TIER_COLORS[g.class] || "") : "";
|
||
const big = g ? fmt(g.value) : normalVal;
|
||
const sub = g
|
||
? `<span class="nc-grade" style="--cat:${cat}">${g.grade}</span> · normal ${normalVal}`
|
||
: `normal ${normalVal}`;
|
||
return `<a class="normal-card" href="${dayHref}">
|
||
<h3>${label}</h3>
|
||
<div class="big"${g ? ` style="--cat:${cat}"` : ""}>${big}</div>
|
||
<div class="range">${sub}</div>
|
||
</a>`;
|
||
};
|
||
|
||
results.innerHTML = `
|
||
<div class="loc-head">
|
||
<div class="loc-title">
|
||
<h2>${placeLabel(data)}</h2>
|
||
<p class="meta">${data.place ? coords + " · " : ""}~${cell.area_sq_mi} sq mi cell · climatology from ${yrs}
|
||
(${c.n_samples.toLocaleString()} days in the ±7-day window)</p>
|
||
</div>
|
||
<div class="share">
|
||
<button id="btn-link" title="Copy a link back to this exact view">${IC.link} Copy link</button>
|
||
<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)}">
|
||
<span class="cta-cal-icon" aria-hidden="true">${IC.calendar}</span>
|
||
<span class="cta-cal-text">
|
||
<span class="cta-cal-title">Check historical data</span>
|
||
<span class="cta-cal-sub">The last 2 years, graded day by day</span>
|
||
</span>
|
||
<span class="cta-cal-arrow" aria-hidden="true">→</span>
|
||
</a>
|
||
<div class="section-title">${data.target_date} vs normal (±7 days) — tap a metric for the full breakdown</div>
|
||
<div class="normals">
|
||
${cmpCard("High", "tmax", c.tmax, fmtTemp)}
|
||
${cmpCard("Low", "tmin", c.tmin, fmtTemp)}
|
||
${cmpCard("Feels", "feels", c.feels, fmtTemp)}
|
||
${cmpCard("Humidity", "humid", c.humid, fmtHumid)}
|
||
${cmpCard("Wind", "wind", c.wind, fmtWind)}
|
||
${cmpCard("Gust", "gust", c.gust, fmtWind)}
|
||
${cmpCard("Precip", "precip", c.precip, fmtPrecip)}
|
||
</div>
|
||
<div id="series"></div>
|
||
`;
|
||
|
||
renderSeries(); // fills #series (chart + graded-days timeline) from recent data
|
||
fetchForecast(); // merge the 7-day forecast into the timeline when it arrives
|
||
window.Thermograph.prefetchViews(selected.lat, selected.lon, "grade"); // warm calendar/forecast
|
||
document.getElementById("btn-link").onclick = copyShareLink;
|
||
document.getElementById("btn-png").onclick = downloadChartPng;
|
||
}
|
||
|
||
// One tinted value cell: background + value colored by the day's grade tier for
|
||
// that metric (the same diverging scale as everywhere else). Carries the date so
|
||
// tapping any cell opens that day's full breakdown.
|
||
function rdCell(g, fmt, date, isFc, isToday) {
|
||
const dd = date ? ` data-date="${date}"` : "";
|
||
const cls = "rd-c" + (isFc ? " rd-fc" : isToday ? " rd-today" : "");
|
||
if (!g) return `<span class="${cls}"${dd}>—</span>`;
|
||
const col = TIER_COLORS[g.class] || "";
|
||
return `<span class="${cls}"${dd} style="--c:${col}">${fmt(g.value)}</span>`;
|
||
}
|
||
|
||
// Precip cell: rain days show the amount (tinted by intensity tier); dry days
|
||
// label the running dry streak — "3d" = 3 days since measurable rain, warmed by
|
||
// the same dryness ramp as the chart — instead of a bare dot.
|
||
function precipCell(d, isFc, isToday) {
|
||
const g = d.precip;
|
||
const dd = ` data-date="${d.date}"`;
|
||
const cls = "rd-c" + (isFc ? " rd-fc" : isToday ? " rd-today" : "");
|
||
if (!g) return `<span class="${cls}"${dd}>—</span>`;
|
||
if (g.value === 0 && d.dsr != null && d.dsr > 0) {
|
||
return `<span class="${cls}"${dd} style="--c:${drynessColor(d.dsr)}" title="${d.dsr} days since measurable rain">${d.dsr}d</span>`;
|
||
}
|
||
const col = TIER_COLORS[g.class] || "";
|
||
return `<span class="${cls}"${dd} style="--c:${col}">${cRain(g.value)}</span>`;
|
||
}
|
||
|
||
// Compact "graded days" table — a ROW per metric, a COLUMN per day (newest first).
|
||
// Wide runs (e.g. 2 weeks) scroll horizontally inside their own container; the
|
||
// metric-label column stays pinned. Tapping any cell/day opens that day's detail.
|
||
const RD_METRICS = [
|
||
["tmax", "Hi", cTemp], ["tmin", "Lo", cTemp], ["feels", "Feel", cTemp],
|
||
["humid", "Hum", cHum], ["wind", "Wind", cWind], ["gust", "Gust", cWind], ["precip", "Rain", cRain],
|
||
];
|
||
function daysTable(rows, todayDate) {
|
||
if (!rows.length) return "";
|
||
const isFc = (d) => todayDate && d.date > todayDate; // future = forecast columns
|
||
const cols = `grid-template-columns: 46px repeat(${rows.length}, minmax(42px, 1fr));`;
|
||
const header = `<div class="rd-corner"></div>` + rows.map((d) => {
|
||
const dt = new Date(d.date + "T00:00:00");
|
||
const dow = dt.toLocaleDateString(undefined, { weekday: "short" });
|
||
const md = dt.toLocaleDateString(undefined, { month: "numeric", day: "numeric" });
|
||
const cls = "rd-colh" + (isFc(d) ? " rd-fc" : d.date === todayDate ? " rd-today" : "");
|
||
return `<div class="${cls}" data-date="${d.date}" title="See the full breakdown for this day"><b>${dow}</b><span>${md}</span></div>`;
|
||
}).join("");
|
||
const body = RD_METRICS.map(([key, label, fmt]) =>
|
||
`<div class="rd-mlabel">${label}</div>` +
|
||
rows.map((d) => key === "precip"
|
||
? precipCell(d, isFc(d), d.date === todayDate)
|
||
: rdCell(d[key], fmt, d.date, isFc(d), d.date === todayDate)).join("")
|
||
).join("");
|
||
return `<div class="rd-scroll"><div class="rd-grid" style="${cols}">${header}${body}</div></div>`;
|
||
}
|
||
|
||
// ---- Recent + Forecast timeline ----
|
||
// The header normals stay fixed on the target day; the chart + graded-days table
|
||
// below show one continuous timeline: recent history through the 7-day forecast,
|
||
// chronological, opened with today at the left edge.
|
||
let recentData = null; // /grade response (past) — the base render
|
||
let forecastData = null; // /forecast response (next 7 days), fetched in the background
|
||
|
||
const addDaysISO = (iso, n) => {
|
||
const d = new Date(iso + "T00:00:00Z");
|
||
d.setUTCDate(d.getUTCDate() + n);
|
||
return d.toISOString().slice(0, 10);
|
||
};
|
||
|
||
// Newest-first overall: forecast (furthest→nearest) then past (newest→oldest).
|
||
// The forecast is only merged while it *continues* the observed run with no gap —
|
||
// so viewing a past week (whose recent days end well before today's live forecast)
|
||
// shows just that week instead of a disconnected forecast block after a date gap.
|
||
// Bounded to within 13 days of the anchored date and at most a week of forecast.
|
||
function combinedDays() {
|
||
const past = recentData ? recentData.recent : []; // newest-first; newest = target_date
|
||
const rawFut = forecastData ? forecastData.recent : []; // furthest-first (tomorrow…+7 reversed)
|
||
if (!rawFut.length || !past.length) return [...rawFut, ...past];
|
||
|
||
const limit = addDaysISO(recentData.target_date, 13); // forecast must fall within 13 days
|
||
let prev = past[0].date; // last observed day (the anchor)
|
||
const kept = [];
|
||
for (const f of [...rawFut].reverse()) { // walk oldest→newest
|
||
if (kept.length >= 7 || f.date > limit) break; // cap a week / 13-day window
|
||
if (f.date !== addDaysISO(prev, 1)) break; // a gap — stop merging the forecast
|
||
kept.push(f);
|
||
prev = f.date;
|
||
}
|
||
return [...kept.reverse(), ...past]; // restore furthest-first ordering
|
||
}
|
||
|
||
async function fetchForecast() {
|
||
if (!selected) return;
|
||
const { lat, lon } = selected;
|
||
try {
|
||
const d = await window.Thermograph.getJSON(`api/v2/forecast?lat=${lat}&lon=${lon}`, window.Thermograph.TTL.forecast);
|
||
if (!selected || selected.lat !== lat || selected.lon !== lon) return; // user moved on
|
||
forecastData = d;
|
||
renderSeries();
|
||
} catch (err) { /* forecast is a bonus — keep the recent view on failure */ }
|
||
}
|
||
|
||
function renderSeries() {
|
||
const host = document.getElementById("series");
|
||
if (!host || !recentData) return;
|
||
const combined = combinedDays(); // newest-first
|
||
const chartData = { ...recentData, recent: combined };
|
||
const today = recentData.target_date;
|
||
const hasFc = combined.some((d) => d.date > today); // forecast days actually merged in
|
||
host.innerHTML = `
|
||
<div class="section-title">Trend vs normal${hasFc ? " · recent → forecast" : ""}</div>
|
||
<div id="chart-wrap"></div>
|
||
${tierKeyHtml()}
|
||
<div class="section-title">Daily, graded${hasFc ? " — recent & 7-day forecast" : ""}</div>
|
||
<div class="rd-orient"><span>← Older</span><span>Newer →</span></div>
|
||
${daysTable([...combined].reverse(), today) || '<p class="muted">Nothing to grade.</p>'}
|
||
`;
|
||
buildChart(chartData);
|
||
scrollTableToToday(host, today, hasFc);
|
||
}
|
||
|
||
// Open the timeline with today's column pinned at the left edge (after the sticky
|
||
// metric labels); before the forecast loads, just show the most-recent day.
|
||
function scrollTableToToday(host, today, hasFc) {
|
||
const scroll = host.querySelector(".rd-scroll");
|
||
if (!scroll) return;
|
||
requestAnimationFrame(() => {
|
||
const col = hasFc && scroll.querySelector(`.rd-colh[data-date="${today}"]`);
|
||
scroll.scrollLeft = col ? Math.max(0, col.offsetLeft - 46) : scroll.scrollWidth;
|
||
});
|
||
}
|
||
|
||
// ---- 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) =>
|
||
`<div class="tk-seg">
|
||
<span class="tk-swatch" style="background:${TIER_COLORS[t.cls]}"></span>
|
||
<span class="tk-label">${t.label}</span>
|
||
</div>`).join("");
|
||
return `<div class="section-title">Grade scale · low → high vs local normal</div>
|
||
<div class="tier-key">${segs}</div>
|
||
<p class="muted tk-note"><a href="legend">Full guide to metrics & grades →</a></p>`;
|
||
}
|
||
|
||
// #rrggbb -> rgba() with alpha, so band tints derive from the same site colors.
|
||
const hexA = (hex, a) => {
|
||
let h = (hex || "").trim().replace("#", "");
|
||
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
|
||
const n = parseInt(h || "888888", 16);
|
||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
|
||
};
|
||
|
||
function chartPalette() {
|
||
// Read straight from the site's CSS variables so the chart matches the rest of
|
||
// the UI and follows the light/dark theme automatically. hiBand/loBand are
|
||
// applied once per tier ring (p1-99, p10-90, p25-75, p40-60) and stack, so keep
|
||
// each layer light — the center (Normal) darkens naturally.
|
||
const css = getComputedStyle(document.documentElement);
|
||
const v = (name, f) => (css.getPropertyValue(name).trim() || f);
|
||
const dark = matchMedia("(prefers-color-scheme: dark)").matches;
|
||
const hi = v("--very-hot", "#dd6b52"); // daily-high series = warm tier red
|
||
const lo = v("--cold", "#4393c3"); // daily-low series = cool tier blue
|
||
return {
|
||
surface: v("--surface-2", dark ? "#1e242c" : "#eef1f5"),
|
||
ink: v("--text", dark ? "#e7ecf2" : "#1a2029"),
|
||
muted: v("--muted", dark ? "#9aa6b2" : "#5b6673"),
|
||
grid: v("--border", dark ? "#2a323c" : "#dde3ea"),
|
||
accent: v("--accent", "#f0803c"),
|
||
hi, lo,
|
||
// Distinct line/point accents for the three added series; the graded fan
|
||
// behind them still uses the shared diverging tier colors (same as temps).
|
||
feels: v("--accent", "#f0803c"), // "feels like" = orange accent
|
||
humid: "#2ea9bf", // humidity = cyan
|
||
wind: "#5aa9a3", // wind speed = teal
|
||
gust: "#8f86d8", // wind gust = periwinkle
|
||
precip: v("--wet-6", "#2b8cbe"),
|
||
hiBand: hexA(hi, dark ? 0.15 : 0.11),
|
||
loBand: hexA(lo, dark ? 0.17 : 0.12),
|
||
};
|
||
}
|
||
|
||
const W = 720, H = 340;
|
||
const PL = 46, PR = 62, plotTop = 48, plotBot = 288, xLabY = 308;
|
||
const 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.
|
||
const PCT_FALLBACK = {
|
||
p1: ["p1", "p10", "min", "p50"], p10: ["p10", "p50"],
|
||
p25: ["p25", "p30", "p50"], p40: ["p40", "p30", "p50"],
|
||
p60: ["p60", "p70", "p50"], p75: ["p75", "p70", "p50"],
|
||
p90: ["p90", "p50"], p99: ["p99", "p90", "max", "p50"],
|
||
};
|
||
const pget = (o, k) => {
|
||
for (const kk of (PCT_FALLBACK[k] || [k])) if (o && o[kk] != null) return o[kk];
|
||
return null;
|
||
};
|
||
|
||
let chartDays = [];
|
||
// Which chart category is shown. Persisted so a refresh keeps your selection.
|
||
let chartMetric = localStorage.getItem("thermograph:chartMetric") || "tmax";
|
||
|
||
function buildChart(data) {
|
||
const wrap = document.getElementById("chart-wrap");
|
||
const C = chartPalette();
|
||
lastGraded = data; // the series currently drawn (for PNG export)
|
||
chartDays = [...data.recent].reverse(); // oldest -> newest
|
||
const days = chartDays, n = days.length;
|
||
if (!n) { wrap.innerHTML = ""; return; }
|
||
const xFor = (i) => (n === 1 ? PL + PW / 2 : PL + (i * PW) / (n - 1));
|
||
|
||
// shared: x date labels
|
||
const step = n > 9 ? 2 : 1;
|
||
let xlab = "";
|
||
days.forEach((d, i) => {
|
||
if (i % step && i !== n - 1) return;
|
||
const dt = new Date(d.date + "T00:00:00");
|
||
xlab += `<text x="${xFor(i).toFixed(1)}" y="${xLabY}" text-anchor="middle" font-size="10.5" fill="${C.muted}">${dt.getMonth() + 1}/${dt.getDate()}</text>`;
|
||
});
|
||
// shared: hover hit targets over the plot
|
||
let hits = "";
|
||
days.forEach((_, i) => {
|
||
const w = n === 1 ? PW : PW / (n - 1);
|
||
hits += `<rect class="hit" data-i="${i}" x="${(xFor(i) - w / 2).toFixed(1)}" y="${plotTop}" width="${w.toFixed(1)}" height="${plotBot - plotTop}" fill="transparent"/>`;
|
||
});
|
||
|
||
const built = chartMetric === "precip" ? precipChart(days, n, xFor, C)
|
||
: chartMetric === "dry" ? dryChart(days, n, xFor, C)
|
||
: tempChart(chartMetric, days, n, xFor, C);
|
||
|
||
// Divider between the last observed day and the forecast, when the chart spans both.
|
||
let todayDiv = "";
|
||
const tIdx = days.findIndex((d) => d.date === data.target_date);
|
||
if (tIdx >= 0 && tIdx < n - 1) {
|
||
const xd = ((xFor(tIdx) + xFor(tIdx + 1)) / 2).toFixed(1);
|
||
todayDiv = `<line x1="${xd}" y1="${plotTop}" x2="${xd}" y2="${plotBot}" stroke="${C.accent}" stroke-width="1.2" stroke-dasharray="4 3" opacity="0.85"/>`
|
||
+ `<text x="${xd}" y="${plotTop - 3}" text-anchor="middle" font-size="9" font-weight="700" fill="${C.accent}">forecast →</text>`;
|
||
}
|
||
|
||
const title = `${placeLabel(data)} · through ${data.target_date}`;
|
||
const svg = `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" font-family="system-ui, -apple-system, Segoe UI, sans-serif" role="img" aria-label="${built.aria}">
|
||
<rect x="0" y="0" width="${W}" height="${H}" fill="${C.surface}"/>
|
||
<text x="${PL - 8}" y="24" font-size="12.5" font-weight="700" fill="${built.color}">${built.subtitle}</text>
|
||
<text x="${W - PR}" y="24" text-anchor="end" font-size="11" fill="${C.muted}">${title}</text>
|
||
${built.content}
|
||
${todayDiv}
|
||
${xlab}
|
||
<line id="crosshair" x1="0" y1="${plotTop}" x2="0" y2="${plotBot}" stroke="${C.muted}" stroke-width="1" stroke-dasharray="3 3" opacity="0"/>
|
||
${hits}
|
||
</svg>`;
|
||
|
||
const btn = (m, label) => `<button type="button" data-metric="${m}"${chartMetric === m ? ' class="active"' : ""}>${label}</button>`;
|
||
wrap.innerHTML = `
|
||
<div class="chart-toggle" id="chart-toggle" role="group" aria-label="Chart metric">
|
||
${btn("tmax", "High")}${btn("tmin", "Low")}${btn("feels", "Feels")}${btn("humid", "Humid")}${btn("wind", "Wind")}${btn("gust", "Gust")}${btn("precip", "Precip")}${btn("dry", "Dry")}
|
||
</div>
|
||
<div class="chart-legend">${built.legend}</div>
|
||
${svg}
|
||
<div id="chart-tip" class="chart-tip" hidden></div>`;
|
||
|
||
document.getElementById("chart-toggle").addEventListener("click", (e) => {
|
||
const b = e.target.closest("button[data-metric]");
|
||
if (!b || b.dataset.metric === chartMetric) return;
|
||
chartMetric = b.dataset.metric;
|
||
try { localStorage.setItem("thermograph:chartMetric", chartMetric); } catch (e) {}
|
||
buildChart(data);
|
||
});
|
||
attachChartHover(wrap);
|
||
}
|
||
|
||
// Per-series display meta for the diverging-scale metrics. `sfx` is the unit
|
||
// appended to axis + point labels ("°"); wind carries its unit in the subtitle
|
||
// instead of on every point to keep the plot uncluttered.
|
||
const SERIES = {
|
||
tmax: (C) => ({ color: C.hi, label: "Daily high", sfx: "°" }),
|
||
tmin: (C) => ({ color: C.lo, label: "Daily low", sfx: "°" }),
|
||
feels: (C) => ({ color: C.feels, label: "Feels like", sfx: "°" }),
|
||
humid: (C) => ({ color: C.humid, label: "Absolute humidity (g/m³)", sfx: "" }),
|
||
wind: (C) => ({ color: C.wind, label: "Wind speed (mph)", sfx: "" }),
|
||
gust: (C) => ({ color: C.gust, label: "Wind gust (mph)", sfx: "" }),
|
||
};
|
||
|
||
// Percentile "fan" tiers — bands between successive percentile marks, colored to
|
||
// match the calendar. Temperature is diverging (cold→green→hot); precipitation
|
||
// uses the sequential rain-intensity ramp.
|
||
const TEMP_FAN = [
|
||
["p90", "p99", "very-hot"], ["p75", "p90", "hot"], ["p60", "p75", "warm"],
|
||
["p40", "p60", "normal"], ["p25", "p40", "cool"], ["p10", "p25", "cold"], ["p1", "p10", "very-cold"],
|
||
];
|
||
const RAIN_FAN = [
|
||
["p90", "p99", "wet-8"], ["p75", "p90", "wet-7"], ["p60", "p75", "wet-6"],
|
||
["p40", "p60", "wet-5"], ["p25", "p40", "wet-4"], ["p10", "p25", "wet-3"], ["p1", "p10", "wet-2"],
|
||
];
|
||
|
||
// Shared line-series chart: an optional shaded percentile fan + dashed median, the
|
||
// value line with dots, and labeled points — so temperature, wind, humidity,
|
||
// precipitation and dry streak all read in one consistent style.
|
||
function lineChart(cfg) {
|
||
const { days, n, xFor, C, key, color, fan, hasNormals, baseZero,
|
||
getVal, getPct, dotColor, fmtAxis, fmtLabel, labelOn,
|
||
subtitle, legend, aria } = cfg;
|
||
|
||
// y-range: the plotted values plus the fan extremes (p1/p99) when present.
|
||
const vals = [];
|
||
days.forEach((d) => {
|
||
const v = getVal(d); if (v != null && !isNaN(v)) vals.push(v);
|
||
if (hasNormals && d.normals && d.normals[key]) {
|
||
const u = pget(d.normals[key], "p99"), l = pget(d.normals[key], "p1");
|
||
if (u != null) vals.push(u); if (l != null) vals.push(l);
|
||
}
|
||
});
|
||
let finite = vals.filter((v) => v != null && !isNaN(v));
|
||
if (!finite.length) finite = [0, 1];
|
||
let lo = Math.min(...finite), hi = Math.max(...finite);
|
||
if (baseZero) lo = Math.min(lo, 0);
|
||
if (hi <= lo) hi = lo + 1;
|
||
const pad = baseZero ? Math.max((hi - lo) * 0.12, 0.01) : Math.max((hi - lo) * 0.08, 3);
|
||
hi += pad; if (!baseZero) lo -= pad;
|
||
const yT = (v) => plotBot - ((v - lo) / (hi - lo)) * (plotBot - plotTop);
|
||
|
||
let grid = "";
|
||
for (let t = 0; t <= 4; t++) {
|
||
const v = lo + ((hi - lo) * t) / 4, y = yT(v).toFixed(1);
|
||
grid += `<line x1="${PL}" y1="${y}" x2="${W - PR}" y2="${y}" stroke="${C.grid}" stroke-width="1"/>`;
|
||
grid += `<text x="${PL - 8}" y="${+y + 4}" text-anchor="end" font-size="11" fill="${C.muted}">${fmtAxis(v)}</text>`;
|
||
}
|
||
|
||
// Shaded fan bands between percentile marks (the "highlights"), same as the calendar tiers.
|
||
const band = (dnKey, upKey, cls) => {
|
||
let top = "", bot = "";
|
||
days.forEach((d, i) => { const u = d.normals && pget(d.normals[key], upKey); if (u == null) return;
|
||
top += (top ? "L" : "M") + xFor(i).toFixed(1) + " " + yT(u).toFixed(1) + " "; });
|
||
for (let i = n - 1; i >= 0; i--) { const dd = days[i].normals && pget(days[i].normals[key], dnKey); if (dd == null) continue;
|
||
bot += "L" + xFor(i).toFixed(1) + " " + yT(dd).toFixed(1) + " "; }
|
||
return top ? `<path d="${top + bot}Z" fill="${hexA(TIER_COLORS[cls], 0.4)}"/>` : "";
|
||
};
|
||
const fanSvg = (hasNormals && fan) ? fan.map((t) => band(t[0], t[1], t[2])).join("") : "";
|
||
|
||
const normTrace = (pctKey) => {
|
||
let p = "", st = false;
|
||
days.forEach((d, i) => { const v = d.normals && pget(d.normals[key], pctKey); if (v == null) return;
|
||
p += (st ? "L" : "M") + xFor(i).toFixed(1) + " " + yT(v).toFixed(1) + " "; st = true; });
|
||
return p;
|
||
};
|
||
const valTrace = () => {
|
||
let p = "", st = false;
|
||
days.forEach((d, i) => { const v = getVal(d); if (v == null || isNaN(v)) { st = false; return; }
|
||
p += (st ? "L" : "M") + xFor(i).toFixed(1) + " " + yT(v).toFixed(1) + " "; st = true; });
|
||
return p;
|
||
};
|
||
const medianSvg = hasNormals
|
||
? `<path d="${normTrace("p50")}" fill="none" stroke="${color}" stroke-width="1.2" stroke-dasharray="4 4" opacity="0.55"/>` : "";
|
||
|
||
const dots = days.map((d, i) => {
|
||
const v = getVal(d); if (v == null || isNaN(v)) return "";
|
||
return `<circle cx="${xFor(i).toFixed(1)}" cy="${yT(v).toFixed(1)}" r="3.4" fill="${dotColor ? dotColor(d) : color}" stroke="${C.surface}" stroke-width="1.6"/>`;
|
||
}).join("");
|
||
const labels = days.map((d, i) => {
|
||
const v = getVal(d); if (v == null || isNaN(v)) return "";
|
||
if (labelOn && !labelOn(v, d)) return "";
|
||
const x = xFor(i).toFixed(1), y = yT(v);
|
||
const vy = (y - plotTop < 26 ? y + 15 : y - 15).toFixed(1); // flip below near the top
|
||
const pv = getPct ? getPct(d) : null;
|
||
const pct = pv == null ? "" :
|
||
`<tspan x="${x}" dy="9" font-size="7.5" font-weight="600" fill="${C.muted}">${ord(pv)}</tspan>`;
|
||
return `<text x="${x}" y="${vy}" text-anchor="middle" font-size="8.5" font-weight="700" fill="${C.ink}" stroke="${C.surface}" stroke-width="2.4" paint-order="stroke" stroke-linejoin="round">${fmtLabel(v)}${pct}</text>`;
|
||
}).join("");
|
||
|
||
const content = grid + fanSvg + medianSvg +
|
||
`<path d="${valTrace()}" fill="none" stroke="${color}" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"/>` +
|
||
dots + labels;
|
||
|
||
return { content, legend, color, subtitle, aria };
|
||
}
|
||
|
||
// One temperature-scale series (High/Low/Feels/Wind/Gust).
|
||
function tempChart(key, days, n, xFor, C) {
|
||
const s = SERIES[key](C);
|
||
return lineChart({
|
||
days, n, xFor, C, key, color: s.color, fan: TEMP_FAN, hasNormals: true, baseZero: false,
|
||
getVal: (d) => (d[key] ? d[key].value : null),
|
||
getPct: (d) => (d[key] ? d[key].percentile : null),
|
||
fmtAxis: (v) => `${Math.round(v)}${s.sfx}`,
|
||
fmtLabel: (v) => `${Math.round(v)}${s.sfx}`,
|
||
legend:
|
||
`<span><i style="background:${s.color}"></i>${s.label} (dashed = median)</span>` +
|
||
`<span><i class="swatch-band" style="background:${hexA(TIER_COLORS.normal, 0.5)};border-color:${TIER_COLORS.normal}"></i>Band shaded by grade tier</span>` +
|
||
`<span class="legend-note">Points labeled value · percentile — grade from the band tier (scale below)</span>`,
|
||
subtitle: `${s.label} — recent trend vs normal`,
|
||
aria: `Recent ${s.label.toLowerCase()} versus the normal range.`,
|
||
});
|
||
}
|
||
|
||
// Precipitation as a line vs its normal range — same fan/median/labels style as
|
||
// temperature, so the metrics read consistently. Rain days are labeled; dry days
|
||
// sit on the baseline. The fan is the rain-intensity tier ramp.
|
||
function precipChart(days, n, xFor, C) {
|
||
return lineChart({
|
||
days, n, xFor, C, key: "precip", color: C.precip, fan: RAIN_FAN, hasNormals: true, baseZero: true,
|
||
getVal: (d) => (d.precip ? d.precip.value : null),
|
||
getPct: (d) => (d.precip ? d.precip.percentile : null),
|
||
// Rain days take their intensity-tier color; no-rain days warm with the dry
|
||
// streak (tan→red) so a dry spell reads as dry, matching the Dry chart.
|
||
dotColor: (d) => (d.precip && d.precip.value > 0 ? (TIER_COLORS[d.precip.class] || C.precip) : (drynessColor(d.dsr) || C.precip)),
|
||
fmtAxis: (v) => `${v.toFixed(2)}"`,
|
||
fmtLabel: (v) => `${v.toFixed(2)}"`,
|
||
labelOn: (v) => v > 0, // only label rain days; dry days rest on the baseline
|
||
legend:
|
||
`<span><i style="background:${C.precip}"></i>Precipitation (dashed = median)</span>` +
|
||
`<span><i class="swatch-band" style="background:${hexA(TIER_COLORS["wet-6"], 0.5)};border-color:${TIER_COLORS["wet-6"]}"></i>Band shaded by rain-intensity tier</span>` +
|
||
`<span class="legend-note">Rain days labeled amount · percentile (rain scale below)</span>`,
|
||
subtitle: "Precipitation — daily totals vs normal",
|
||
aria: "Daily precipitation totals versus the normal range.",
|
||
});
|
||
}
|
||
|
||
// "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) {
|
||
return lineChart({
|
||
days, n, xFor, C, key: "dry", color: drynessColor(9), fan: null, hasNormals: false, baseZero: true,
|
||
getVal: (d) => (d.dsr == null ? null : d.dsr),
|
||
getPct: () => null,
|
||
dotColor: (d) => drynessColor(d.dsr),
|
||
fmtAxis: (v) => `${Math.round(v)}d`,
|
||
fmtLabel: (v) => `${Math.round(v)}`,
|
||
labelOn: (v) => v > 0, // label the streak length; rain days (0) just show the dot
|
||
legend:
|
||
`<span><i style="background:${drynessColor(9)}"></i>Days since last rain — dry streak</span>` +
|
||
`<span><i style="background:${drynessColor(0)}"></i>Rain that day (streak = 0)</span>`,
|
||
subtitle: "Dry streak — days since last measurable rain",
|
||
aria: "Days since last measurable rain, as a line.",
|
||
});
|
||
}
|
||
|
||
function attachChartHover(wrap) {
|
||
const svg = wrap.querySelector("svg");
|
||
const tip = wrap.querySelector("#chart-tip");
|
||
const cross = wrap.querySelector("#crosshair");
|
||
const C = chartPalette();
|
||
const pctLine = (label, g, unit, color) => {
|
||
if (!g) return `<div><b style="color:${color}">${label}</b> —</div>`;
|
||
const pct = g.percentile == null ? "" : ` · ${ord(g.percentile)} pct`;
|
||
const gc = TIER_COLORS[g.class] || "";
|
||
return `<div><b style="color:${color}">${label}</b> ${g.value}${unit}${pct} <span class="tt-grade" style="color:${gc}">${g.grade}</span></div>`;
|
||
};
|
||
|
||
// Pointer events cover mouse, touch and pen with one path, so tapping/dragging
|
||
// the chart works on phones (mousemove never fires on touchscreens).
|
||
const showTip = (r, e) => {
|
||
const d = chartDays[+r.dataset.i];
|
||
const x = +r.getAttribute("x") + +r.getAttribute("width") / 2;
|
||
cross.setAttribute("x1", x); cross.setAttribute("x2", x); cross.setAttribute("opacity", "0.7");
|
||
const dt = new Date(d.date + "T00:00:00");
|
||
const nt = d.normals.tmax, ni = d.normals.tmin;
|
||
tip.innerHTML = `<div class="tt-date">${dt.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })}</div>
|
||
${pctLine("High", d.tmax, "°", C.hi)}
|
||
${pctLine("Low", d.tmin, "°", C.lo)}
|
||
${pctLine("Feels", d.feels, "°", C.feels)}
|
||
${pctLine("Humidity", d.humid, " g/m³", C.humid)}
|
||
${pctLine("Wind", d.wind, " mph", C.wind)}
|
||
${pctLine("Gust", d.gust, " mph", C.gust)}
|
||
${pctLine("Precip", d.precip, '"', C.precip)}
|
||
<div><b>Dry</b> ${d.dsr == null ? "—" : d.dsr === 0 ? "rain today" : `${d.dsr} d since rain`}</div>
|
||
<div class="tt-norm">normal ${nt ? Math.round(nt.p50) : "—"}° / ${ni ? Math.round(ni.p50) : "—"}°</div>`;
|
||
tip.hidden = false;
|
||
const box = wrap.getBoundingClientRect();
|
||
let px = e.clientX - box.left + 12;
|
||
if (px > box.width - 160) px = e.clientX - box.left - 160;
|
||
tip.style.left = Math.max(4, px) + "px";
|
||
tip.style.top = Math.max(8, e.clientY - box.top - 10) + "px";
|
||
};
|
||
|
||
wrap.querySelectorAll("rect.hit").forEach((r) => {
|
||
r.addEventListener("pointermove", (e) => showTip(r, e));
|
||
r.addEventListener("pointerdown", (e) => showTip(r, e));
|
||
});
|
||
const hide = () => { tip.hidden = true; cross.setAttribute("opacity", "0"); };
|
||
svg.addEventListener("pointerleave", hide);
|
||
svg.addEventListener("pointerup", hide);
|
||
}
|
||
|
||
// ---- 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}`;
|
||
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)
|
||
const rowH = 27, headH = 42, padB = 16;
|
||
const top = H + 10;
|
||
const cDate = PL, cPcp = PL + 150, cHigh = PL + 322, cLow = PL + 494;
|
||
const totalH = top + headH + rows.length * rowH + padB;
|
||
|
||
const cell = (x, g, isPcp) => {
|
||
if (!g) return `<text x="${x}" y="0" font-size="12" fill="${C.muted}">—</text>`;
|
||
const color = TIER_COLORS[g.class] || C.ink;
|
||
const v = isPcp ? `${g.value.toFixed(2)}"` : `${Math.round(g.value)}°`;
|
||
return `<text x="${x}" y="0"><tspan font-size="13" font-weight="700" fill="${C.ink}">${v}</tspan>` +
|
||
`<tspan dx="7" font-size="11" font-weight="600" fill="${color}">${esc(g.grade)}</tspan></text>`;
|
||
};
|
||
|
||
const th = (x, t) =>
|
||
`<text x="${x}" y="${top + 30}" font-size="10.5" font-weight="700" letter-spacing="0.05em" fill="${C.muted}">${t}</text>`;
|
||
|
||
let body = "";
|
||
rows.forEach((d, i) => {
|
||
const y = top + headH + i * rowH;
|
||
const dt = new Date(d.date + "T00:00:00");
|
||
const dstr = dt.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||
body += `<g transform="translate(0 ${y})">` +
|
||
(i ? `<line x1="${PL}" y1="-7" x2="${W - PR}" y2="-7" stroke="${C.grid}" stroke-width="1"/>` : "") +
|
||
`<text x="${cDate}" y="0" font-size="12.5" fill="${C.ink}">${esc(dstr)}</text>` +
|
||
cell(cPcp, d.precip, true) + cell(cHigh, d.tmax, false) + cell(cLow, d.tmin, false) +
|
||
`</g>`;
|
||
});
|
||
|
||
const markup = `
|
||
<text x="${PL}" y="${top + 8}" font-size="13" font-weight="700" fill="${C.ink}">Recent days, graded — ${esc(placeLabel(lastGraded))}</text>
|
||
${th(cDate, "DATE")}${th(cPcp, "PRECIP")}${th(cHigh, "HIGH")}${th(cLow, "LOW")}
|
||
<line x1="${PL}" y1="${top + headH - 9}" x2="${W - PR}" y2="${top + headH - 9}" stroke="${C.grid}" stroke-width="1.5"/>
|
||
${body}`;
|
||
return { markup, totalH };
|
||
}
|
||
|
||
function downloadChartPng() {
|
||
const chart = document.querySelector("#chart-wrap svg");
|
||
if (!chart || !lastGraded) return;
|
||
const C = chartPalette();
|
||
|
||
// Chart body minus interactive-only layers.
|
||
const clone = chart.cloneNode(true);
|
||
const cross = clone.querySelector("#crosshair"); if (cross) cross.remove();
|
||
clone.querySelectorAll("rect.hit").forEach((r) => r.remove());
|
||
const chartInner = clone.innerHTML;
|
||
|
||
const { markup, totalH } = exportTableSvg(C);
|
||
const xml = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${totalH}" width="${W * 2}" height="${totalH * 2}" font-family="system-ui, -apple-system, Segoe UI, sans-serif">` +
|
||
`<rect x="0" y="0" width="${W}" height="${totalH}" fill="${C.surface}"/>` +
|
||
chartInner + markup + `</svg>`;
|
||
|
||
const url = URL.createObjectURL(new Blob([xml], { type: "image/svg+xml;charset=utf-8" }));
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = W * 2; canvas.height = totalH * 2;
|
||
canvas.getContext("2d").drawImage(img, 0, 0);
|
||
URL.revokeObjectURL(url);
|
||
canvas.toBlob((blob) => {
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `thermograph_${dateInput.value}.png`;
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||
flashBtn("btn-png", "✓ Saved");
|
||
}, "image/png");
|
||
};
|
||
img.src = url;
|
||
}
|
||
|
||
function flashBtn(id, text) {
|
||
const b = document.getElementById(id);
|
||
if (!b) return;
|
||
const orig = b.innerHTML; // preserve the inline icon markup
|
||
b.textContent = text;
|
||
setTimeout(() => (b.innerHTML = orig), 1600);
|
||
}
|
||
|
||
function updateHash() {
|
||
if (!selected) return;
|
||
history.replaceState(null, "", `#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${dateInput.value}`);
|
||
window.Thermograph.saveLastLocation(selected.lat, selected.lon, dateInput.value);
|
||
}
|
||
|
||
// Start where you left off: an explicit link (hash) wins, otherwise the last
|
||
// place you looked at (remembered across views), otherwise the default US map.
|
||
function restoreFromHash() {
|
||
const loc = window.Thermograph.loadLastLocation();
|
||
if (!loc) return;
|
||
if (loc.date) dateInput.value = loc.date;
|
||
syncTodayBtn();
|
||
selectLocation(loc.lat, loc.lon);
|
||
}
|
||
restoreFromHash();
|