thermograph/frontend/static/app.js
emi 5b7e30dad9
All checks were successful
Build + push images (Forgejo registry) / build-push (backend) (push) Successful in 6s
secrets-guard / encrypted (push) Successful in 6s
shell-lint / shellcheck (push) Successful in 9s
secrets-guard / encrypted (pull_request) Successful in 17s
shell-lint / shellcheck (pull_request) Successful in 16s
PR build (required check) / changes (pull_request) Successful in 20s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
Build + push images (Forgejo registry) / build-push (frontend) (push) Successful in 59s
PR build (required check) / build-frontend (pull_request) Successful in 35s
PR build (required check) / gate (pull_request) Successful in 2s
fix(frontend): Weekly view must always send an explicit date to /grade
2026-07-26 06:20:19 +00:00

641 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Weekly (map) page. Imports replace the old script-tag ordering contract.
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
import { fmtTemp, onUnitChange, toWind, windUnit, precipUnit } from "./units.js";
import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver
import { loadView, TTL, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette,
tempChart, precipChart, dryChart, attachChartHover } from "./chart.js";
import { track } from "./digest.js";
import { TIER_COLORS, SCALE_TEMP, drynessColor, pctOrd, esc, todayISO, placeLabel,
tierKeySegs, GUIDE_LINK, fmtPrecip, fmtPrecipTier, fmtWind, fmtHumid } from "./shared.js";
let selected = null; // {lat, lon}
const dateInput = document.getElementById("date-input");
const todayBtn = document.getElementById("today-btn");
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 };
// Show the raw coordinates immediately; render() replaces them with the resolved
// neighborhood + city name once the grade fetch returns.
locLabel.textContent = `${lat.toFixed(3)}, ${lon.toFixed(3)}`;
locLabel.hidden = false;
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");
initFindButton(findBtn, "Find a location",
() => selected, (lat, lon) => selectLocation(lat, lon));
// ---- hero ----
// The hero's grade card is server-rendered showing the most unusual city we're
// tracking; once the visitor has a place of their own it re-points at that. The
// frame and its text slots already exist in the HTML, so swapping the numbers in
// shifts nothing on the page.
const hero = document.getElementById("hero");
const heroLocate = document.getElementById("hero-locate");
const heroPick = document.getElementById("hero-pick");
const heroMsg = document.getElementById("hero-locate-msg");
function locateMsg(text) {
if (!heroMsg) return;
heroMsg.textContent = text || "";
heroMsg.hidden = !text;
}
// Browsers expose geolocation ONLY on secure origins, but `navigator.geolocation`
// still exists on plain http:// — getCurrentPosition just fails with a permission
// error. So feature-testing the object is not enough: without the isSecureContext
// check the button is a dead control on the plain-HTTP LAN dev server (and on any
// phone reaching it by IP), which is exactly how it shipped broken.
// Rather than offer a button that cannot work, promote the map picker instead.
const canLocate = !!navigator.geolocation && window.isSecureContext;
if (heroLocate && !canLocate) {
heroLocate.hidden = true;
heroPick?.classList.replace("btn-ghost", "btn-primary");
}
heroLocate?.addEventListener("click", () => {
track("home.locate");
const label = heroLocate.textContent;
const restore = () => {
heroLocate.disabled = false;
heroLocate.textContent = label;
};
// Geolocation can take seconds (or hang until the timeout), so say so —
// a button that looks inert is indistinguishable from a broken one.
heroLocate.disabled = true;
heroLocate.textContent = "Locating…";
locateMsg("");
navigator.geolocation.getCurrentPosition(
(pos) => {
restore();
selectLocation(pos.coords.latitude, pos.coords.longitude);
},
(err) => {
restore();
// Always say something: a declined prompt or a timeout must not look
// like the button did nothing.
locateMsg(
err.code === err.PERMISSION_DENIED
? "Location permission was declined. Pick a spot on the map instead."
: err.code === err.TIMEOUT
? "That took too long. Try again, or pick a spot on the map."
: "Your location isn't available right now. Pick a spot on the map instead.",
);
},
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 },
);
});
heroPick?.addEventListener("click", () => { locateMsg(""); findBtn.click(); });
/** Re-point the hero card at the place the visitor actually chose. */
function updateHero(data) {
if (!hero) return;
const card = document.getElementById("hero-grade");
const targetDay = data.recent.find((d) => d.date === data.target_date);
const g = targetDay && (targetDay.tmax || targetDay.tmin);
if (!card || !g) return;
document.getElementById("hero-where").textContent = `Today in ${placeLabel(data)}`;
document.getElementById("hero-band").textContent = g.grade;
document.getElementById("hero-detail").textContent =
`${targetDay.tmax === g ? "High temp" : "Low temp"} in the ${pctOrd(g.percentile)} percentile`;
// Band colour rides the same token set as everything else; the band word is
// always present too, so colour is never the only signal.
card.style.setProperty("--cat", TIER_COLORS[g.class] || "");
hero.querySelector(".grade-why")?.remove();
}
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${locHash(selected.lat, selected.lon, row.dataset.date)}`;
});
let gradeSeq = 0; // supersedes an in-flight load when the user picks a new spot/date
async function runGrade() {
if (!selected) return;
updateHash();
placeholder.hidden = true;
results.hidden = false;
// Always send the target date, even when it's "today" — the backend only
// falls back to its own UTC today (api_grade's `date` param default; see
// backend/web/app.py) when the param is omitted or empty, a day behind local
// midnight for any viewer east of UTC (reported from Kaunas, UTC+3: asking
// for 7/26 rendered 7/25). The comment this replaces claimed omitting the
// date kept this URL matching the cross-view prefetch — but that match was
// the other half of the bug: cache.js's viewFetches() seeds the grade view's
// cache entry under this exact dateless URL too, tagged fresh for the
// viewer's local day no matter which day the server actually resolved, so a
// same-tab nav that had already prefetched this cell (e.g. via the Day page)
// could serve that stale, server-resolved slice straight out of IndexedDB
// with no live request ever going out. Sending the real date makes every
// load self-consistent with the viewer's own clock instead of aliasing onto
// whatever "today" the server or an earlier prefetch resolved.
// dateInput.value can also come back empty (a cleared native date field —
// day.js guards the same input for the same reason), so fall back to the
// viewer's local today rather than ever send `date=` empty, which hits that
// same server fallback.
const date = dateInput.value || todayISO();
const q = `lat=${selected.lat}&lon=${selected.lon}&date=${date}`;
const url = uv(`grade?${q}`);
await loadView({
url, ttl: TTL.grade, seq: ++gradeSeq, current: () => gradeSeq,
spinner: () => { results.innerHTML = `<p class="spinner">Fetching decades of climate history…</p>`; },
onData: render,
onError: (err) => { results.innerHTML = `<p class="error">${err.message}</p>`; },
});
}
// 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 = fmtTemp; // active unit, bare degree
const cHum = (v) => (v == null ? "—" : `${Math.round(v)}%`); // relative %, unit-invariant
const cWind = (v) => (v == null ? "—" : `${Math.round(toWind(v))}`);
// fmtPrecip carries the precision: whole millimetres, two decimals for inches.
const cRain = (v) => (v == null ? "—" : v > 0 ? fmtPrecip(v, false) : "·");
// 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>`,
};
const MONTH_NAMES = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"];
// Friendly, plain-language "what to expect" sentences for the selected date's
// season, built from the ±7-day climatology frequencies (rain_freq / sun_freq)
// the backend now returns. Rain drives a bit of cute packing advice. Any piece
// with no data (e.g. sunshine on a NASA/MET-sourced cell) is simply omitted.
function insightsHTML(data) {
const c = data.climatology || {};
const parts = data.target_date ? data.target_date.split("-") : [];
const monthName = parts.length === 3 ? MONTH_NAMES[+parts[1] - 1] : null;
const around = monthName ? `around mid-${monthName}` : "this time of year";
const lines = [];
const hi = c.tmax && c.tmax.p50 != null ? c.tmax.p50 : null;
const lo = c.tmin && c.tmin.p50 != null ? c.tmin.p50 : null;
if (hi != null && lo != null) {
lines.push(`<li><span class="ins-emoji" aria-hidden="true">🌡️</span> A typical day ${esc(around)} peaks near <b>${fmtTemp(hi, true)}</b> and dips to <b>${fmtTemp(lo, true)}</b>.</li>`);
}
if (c.sun_freq != null) {
const pct = Math.round(c.sun_freq * 100);
const emoji = c.sun_freq >= 0.6 ? "☀️" : c.sun_freq >= 0.3 ? "⛅" : "☁️";
const tone = c.sun_freq >= 0.6 ? "plenty of sunshine — " : c.sun_freq < 0.3 ? "often cloudy — " : "";
lines.push(`<li><span class="ins-emoji" aria-hidden="true">${emoji}</span> ${tone}sunny about <b>${pct}%</b> of days ${esc(around)}.</li>`);
}
if (c.rain_freq != null) {
const pct = Math.round(c.rain_freq * 100);
let emoji, advice;
if (c.rain_freq < 0.15) {
emoji = "🌤️"; advice = "you can probably leave the umbrella at home.";
} else if (c.rain_freq < 0.4) {
emoji = "🌦️"; advice = "maybe tuck a compact umbrella in your bag just in case.";
} else {
emoji = "🌧️"; advice = "don't forget your favorite umbrella or raincoat!";
}
lines.push(`<li class="ins-rain"><span class="ins-emoji" aria-hidden="true">${emoji}</span> Rain on about <b>${pct}%</b> of days ${esc(around)}${advice}</li>`);
}
if (!lines.length) return "";
return `<div class="insights">
<p class="insight-lead">What to expect ${esc(around)}</p>
<ul class="insight-list">${lines.join("")}</ul>
</div>`;
}
function render(data) {
recentData = data;
updateHero(data);
// The place name appears once — as the results heading (<h2>) below, which also
// carries the coordinates + climatology context. The top label only holds the
// transient coordinates written on selection, so hide it now that the named
// results have rendered (otherwise the name would show twice).
locLabel.hidden = true;
locLabel.textContent = "";
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]}` : "—";
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. The
// 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${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) : "—";
const cat = g ? (TIER_COLORS[g.class] || "") : "";
const big = g ? fmt(g.value) : normalVal;
const grade = g
? `<span class="nc-grade" style="--cat:${cat}">${g.grade}</span>`
: "";
return `<a class="normal-card" href="${dayHref}">
<div class="nc-head"><h3>${label}</h3>${grade}</div>
<div class="big"${g ? ` style="--cat:${cat}"` : ""}>${big}</div>
<div class="range">normal: <b>${normalVal}</b></div>
</a>`;
};
results.innerHTML = `
<div class="loc-head">
<div class="loc-title">
<h2>${placeLabel(data)}</h2>
<ul class="loc-meta">
${data.place ? `<li><b>${coords}</b></li>` : ""}
<li>Climatology from <b>${yrs}</b></li>
<li><b>${c.n_samples.toLocaleString()}</b> days sample size (day ±7)</li>
</ul>
</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${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>
</a>
${insightsHTML(data)}
<div class="section-title">${data.target_date} vs a typical day. 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 the window
prefetchViews(selected.lat, selected.lon, ["grade", "forecast"]); // warm calendar/day
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>`;
// Only a genuinely dry day (no rain at all) labels the streak; any rain day —
// down to sub-0.01" trace that rounds to 0 — shows its depth, tinted by tier.
if (g.class === "dry" && d.dsr != null && d.dsr > 0) {
return `<span class="${cls}"${dd} style="--c:${drynessColor(d.dsr)}" title="${d.dsr} days since rain">${d.dsr}d</span>`;
}
const col = TIER_COLORS[g.class] || "";
const amt = g.class && g.class !== "dry" ? fmtPrecipTier(g.value, g.class, false) : cRain(g.value);
return `<span class="${cls}"${dd} style="--c:${col}">${amt}</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.
// Wind and rain cells are bare numbers to keep the grid narrow, so their row
// label carries the unit — and has to be built per render, since the unit can
// change under us (onUnitChange re-renders).
const RD_METRICS = () => [
["tmax", "Hi", cTemp], ["tmin", "Lo", cTemp], ["feels", "Feel", cTemp],
["humid", "Hum", cHum],
["wind", `Wind <span class="rd-unit">${windUnit()}</span>`, cWind],
["gust", `Gust <span class="rd-unit">${windUnit()}</span>`, cWind],
["precip", `Rain <span class="rd-unit">${precipUnit()}</span>`, cRain],
];
function daysTable(rows, targetDate) {
if (!rows.length) return "";
const todayDate = todayISO();
const isFc = (d) => d.date > todayDate; // future = forecast columns
const isTarget = (d) => d.date === targetDate; // the graded target day (orange marker)
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" + (isTarget(d) ? " rd-today" : isFc(d) ? " rd-fc" : "");
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), isTarget(d))
: rdCell(d[key], fmt, d.date, isFc(d), isTarget(d))).join("")
).join("");
return `<div class="rd-scroll"><div class="rd-grid" style="${cols}">${header}${body}</div></div>`;
}
// ---- graded-days timeline ----
// The header normals stay fixed on the target day; the chart + graded-days table
// below show one continuous window built server-side: two weeks of history before
// the target, the target itself (orange marker), then the days after it — real
// observations when they're in the past, the forward forecast when they run into
// the future (see backend _build_grade).
let recentData = null; // /grade response — the whole window, newest-first
// Repaint everything in the newly-picked unit.
onUnitChange(() => {
if (recentData) render(recentData);
});
// Rebuild the chart when the window is resized so its drawing width keeps
// tracking the container (debounced — resize fires continuously while dragging).
let resizeTimer = null;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => { if (recentData) renderSeries(); }, 160);
});
function renderSeries() {
const host = document.getElementById("series");
if (!host || !recentData) return;
const days = recentData.recent; // newest-first: history · target · after/forecast
const target = recentData.target_date;
const hasFc = days.some((d) => d.date > todayISO()); // window runs into the forecast
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 &amp; 7-day forecast" : ""}</div>
<div class="rd-orient"><span>← Older</span><span>Newer →</span></div>
${daysTable([...days].reverse(), target) || '<p class="muted">Nothing to grade.</p>'}
`;
buildChart(recentData);
scrollTableToTarget(host, target);
}
// Open the timeline centered on the target day's column (the orange marker), so
// the two weeks of history and the days after it are both in view.
function scrollTableToTarget(host, target) {
const scroll = host.querySelector(".rd-scroll");
if (!scroll) return;
requestAnimationFrame(() => {
const col = scroll.querySelector(`.rd-colh[data-date="${target}"]`);
if (!col) { scroll.scrollLeft = scroll.scrollWidth; return; }
scroll.scrollLeft = Math.max(0, col.offsetLeft - (scroll.clientWidth - col.offsetWidth) / 2);
});
}
// ---- trend chart (self-contained inline SVG) ----
function tierKeyHtml() {
// Highest tier first (Near Record hot → Near Record cold).
const segs = tierKeySegs([...SCALE_TEMP].reverse().map((t) =>
({ color: TIER_COLORS[t.c], label: t.label })));
return `<div class="section-title">Grade scale · low → high vs local normal</div>
<div class="tier-key">${segs}</div>${GUIDE_LINK}`;
}
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();
// Drawing width tracks the on-screen box (see chart.js) so wide monitors gain
// plot room while phones keep the 720-unit floor (the SVG just scales down).
setChartWidth(wrap.clientWidth);
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);
// Solid orange marker line ON the target day, plus a dashed "forecast →" divider
// where the window crosses from the past into the future — drawn only when it's
// clearly separated from the target marker (i.e. the target is in the past; when
// the target is today the marker itself already sits at that boundary).
let marks = "";
const tIdx = days.findIndex((d) => d.date === data.target_date);
if (tIdx >= 0) {
const xt = xFor(tIdx).toFixed(1);
const td = new Date(data.target_date + "T00:00:00");
marks += `<line x1="${xt}" y1="${plotTop}" x2="${xt}" y2="${plotBot}" stroke="${C.accent}" stroke-width="1.8" opacity="0.9"/>`
+ `<text x="${xt}" y="${plotTop - 3}" text-anchor="middle" font-size="9" font-weight="700" fill="${C.accent}">${td.getMonth() + 1}/${td.getDate()}</text>`;
}
const fIdx = days.findIndex((d) => d.date > todayISO()); // first forecast (future) day
if (fIdx > tIdx + 1) {
const xd = ((xFor(fIdx - 1) + xFor(fIdx)) / 2).toFixed(1);
marks += `<line x1="${xd}" y1="${plotTop}" x2="${xd}" y2="${plotBot}" stroke="${C.accent}" stroke-width="1.1" stroke-dasharray="4 3" opacity="0.55"/>`
+ `<text x="${xd}" y="${plotTop - 3}" text-anchor="middle" font-size="8.5" font-weight="700" fill="${C.accent}" opacity="0.8">forecast →</text>`;
}
const title = `${placeLabel(data)} · ${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}
${marks}
${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, chartDays);
}
// ---- share ----
function copyShareLink() {
if (!selected) return;
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); }
);
}
// 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)}"` : fmtTemp(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, "", locHash(selected.lat, selected.lon, dateInput.value));
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 world map.
function restoreFromHash() {
const loc = loadLastLocation();
if (!loc) return;
if (loc.date) dateInput.value = loc.date;
syncTodayBtn();
selectLocation(loc.lat, loc.lon);
}
restoreFromHash();