// 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 = `

Fetching decades of climate history…

`; }, onData: render, onError: (err) => { results.innerHTML = `

${err.message}

`; }, }); } // 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: ``, download: ``, calendar: ``, }; 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(`
  • A typical day ${esc(around)} peaks near ${fmtTemp(hi, true)} and dips to ${fmtTemp(lo, true)}.
  • `); } 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(`
  • ${tone}sunny about ${pct}% of days ${esc(around)}.
  • `); } 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(`
  • Rain on about ${pct}% of days ${esc(around)} — ${advice}
  • `); } if (!lines.length) return ""; return `

    What to expect ${esc(around)}

    `; } function render(data) { recentData = data; updateHero(data); // The place name appears once — as the results heading (

    ) 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 ? `${g.grade}` : ""; return `

    ${label}

    ${grade}
    ${big}
    normal: ${normalVal}
    `; }; results.innerHTML = `

    ${placeLabel(data)}

      ${data.place ? `
    • ${coords}
    • ` : ""}
    • Climatology from ${yrs}
    • ${c.n_samples.toLocaleString()} days sample size (day ±7)
    Check historical data ${insightsHTML(data)}
    ${data.target_date} vs a typical day. Tap a metric for the full breakdown
    ${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)}
    `; 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 ``; const col = TIER_COLORS[g.class] || ""; return `${fmt(g.value)}`; } // 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 ``; // 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 `${d.dsr}d`; } const col = TIER_COLORS[g.class] || ""; const amt = g.class && g.class !== "dry" ? fmtPrecipTier(g.value, g.class, false) : cRain(g.value); return `${amt}`; } // 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 ${windUnit()}`, cWind], ["gust", `Gust ${windUnit()}`, cWind], ["precip", `Rain ${precipUnit()}`, 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 = `
    ` + 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 `
    ${dow}${md}
    `; }).join(""); const body = RD_METRICS().map(([key, label, fmt]) => `
    ${label}
    ` + rows.map((d) => key === "precip" ? precipCell(d, isFc(d), isTarget(d)) : rdCell(d[key], fmt, d.date, isFc(d), isTarget(d))).join("") ).join(""); return `
    ${header}${body}
    `; } // ---- 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 = `
    Trend vs normal${hasFc ? " · recent → forecast" : ""}
    ${tierKeyHtml()}
    Daily, graded${hasFc ? ": recent & 7-day forecast" : ""}
    ← OlderNewer →
    ${daysTable([...days].reverse(), target) || '

    Nothing to grade.

    '} `; 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 `
    Grade scale · low → high vs local normal
    ${segs}
    ${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 += `${dt.getMonth() + 1}/${dt.getDate()}`; }); // shared: hover hit targets over the plot let hits = ""; days.forEach((_, i) => { const w = n === 1 ? PW : PW / (n - 1); hits += ``; }); 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 += `` + `${td.getMonth() + 1}/${td.getDate()}`; } 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 += `` + `forecast →`; } const title = `${placeLabel(data)} · ${data.target_date}`; const svg = ` ${built.subtitle} ${title} ${built.content} ${marks} ${xlab} ${hits} `; const btn = (m, label) => ``; wrap.innerHTML = `
    ${btn("tmax", "High")}${btn("tmin", "Low")}${btn("feels", "Feels")}${btn("humid", "Humid")}${btn("wind", "Wind")}${btn("gust", "Gust")}${btn("precip", "Precip")}${btn("dry", "Dry")}
    ${built.legend}
    ${svg} `; 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 ``; const color = TIER_COLORS[g.class] || C.ink; const v = isPcp ? `${g.value.toFixed(2)}"` : fmtTemp(g.value); return `${v}` + `${esc(g.grade)}`; }; const th = (x, t) => `${t}`; 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 += `` + (i ? `` : "") + `${esc(dstr)}` + cell(cPcp, d.precip, true) + cell(cHigh, d.tmax, false) + cell(cLow, d.tmin, false) + ``; }); const markup = ` Recent days, graded: ${esc(placeLabel(lastGraded))} ${th(cDate, "DATE")}${th(cPcp, "PRECIP")}${th(cHigh, "HIGH")}${th(cLow, "LOW")} ${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 = `` + `` + chartInner + markup + ``; 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();