"use strict"; // Shared tier colors, scales, formatters and helpers (single home: shared.js). const { TIER_COLORS, SCALE_TEMP, drynessColor, ord, esc, todayISO, placeLabel, fmtPrecip, fmtWind, fmtHumid, locHash } = window.Thermograph; let selected = null; // {lat, lon} const dateInput = document.getElementById("date-input"); const todayBtn = document.getElementById("today-btn"); 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"); window.LocationPicker.initFindButton(findBtn, "Find a location", () => 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${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; const seq = ++gradeSeq; // Delay the spinner: a cache-served render lands in a few ms, so blanking the // panel immediately would just flash. Only a genuinely slow (network) load // ever shows it. const spin = setTimeout(() => { if (seq === gradeSeq) results.innerHTML = `

Fetching decades of climate history…

`; }, 150); // 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 { // Stale-while-revalidate: a stale cached copy renders immediately and the // update callback repaints if the background revalidation finds new data. data = await window.Thermograph.getJSON(url, window.Thermograph.TTL.grade, false, (upd) => { if (seq === gradeSeq) render(upd); }); } catch (err) { clearTimeout(spin); if (seq !== gradeSeq) return; results.innerHTML = `

${err.message}

`; return; } clearTimeout(spin); if (seq !== gradeSeq) return; render(data); } // Temps flow through the shared unit formatter (°F from the API, shown in the // active unit); the others are unit-agnostic. const fmtTemp = (v) => window.Thermograph.fmtTemp(v); // 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) => window.Thermograph.fmtTemp(v); // active unit, bare degree 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: ``, download: ``, calendar: ``, }; function render(data) { recentData = 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 = ""; window.LocationPicker.setFindLabel(findBtn, "Change location"); const c = data.climatology; const cell = data.cell; const yrs = c.year_range ? `${c.year_range[0]}–${c.year_range[1]}` : "—"; 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
${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 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 ``; 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 ``; if (g.value === 0 && d.dsr != null && d.dsr > 0) { return `${d.dsr}d`; } const col = TIER_COLORS[g.class] || ""; return `${cRain(g.value)}`; } // 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, 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. window.Thermograph.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 = [...SCALE_TEMP].reverse().map((t) => `
${t.label}
`).join(""); return `
Grade scale · low → high vs local normal
${segs}

Full guide to metrics & grades →

`; } // #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), }; } // W/PW are recomputed per build from the container width (see buildChart), so a // wide monitor gets a genuinely wider drawing — more room between days — instead // of the same 720-unit canvas magnified. let W = 720; const H = 340; const PL = 46, PR = 62, plotTop = 48, plotBot = 288, xLabY = 308; let PW = W - PL - PR; // 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(); // Drawing width tracks the on-screen box at a steady ~1.5 CSS px per SVG unit, // so text renders the same size everywhere while wide monitors gain plot room. // Phones keep the 720-unit floor (the SVG just scales down). W = Math.max(720, Math.min(1400, Math.round((wrap.clientWidth || 720) / 1.5))); PW = W - PL - PR; 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); } // 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 += ``; grid += `${fmtAxis(v)}`; } // 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 ? `` : ""; }; 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 ? `` : ""; const dots = days.map((d, i) => { const v = getVal(d); if (v == null || isNaN(v)) return ""; return ``; }).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 ? "" : `${ord(pv)}`; return `${fmtLabel(v)}${pct}`; }).join(""); const content = grid + fanSvg + medianSvg + `` + 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); // Temperature series (° suffix) show in the active unit; wind/gust stay in mph. // The plot keeps its °F domain — only the labels convert — so positions hold. const isTemp = s.sfx === "°"; const fmtV = (v) => `${Math.round(isTemp ? window.Thermograph.toUnit(v) : v)}${s.sfx}`; 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: fmtV, fmtLabel: fmtV, legend: `${s.label} (dashed = median)` + `Band shaded by grade tier` + `Points labeled value · percentile — grade from the band tier (scale below)`, 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: `Precipitation (dashed = median)` + `Band shaded by rain-intensity tier` + `Rain days labeled amount · percentile (rain scale below)`, subtitle: "Precipitation — daily totals vs normal", aria: "Daily precipitation totals versus the normal range.", }); } // 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: `Days since last rain — dry streak` + `Rain that day (streak = 0)`, 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 `
${label}
`; const pct = g.percentile == null ? "" : ` · ${ord(g.percentile)} pct`; const gc = TIER_COLORS[g.class] || ""; // "°" marks a temperature line — show it in the active unit; others are as-is. const val = unit === "°" ? Math.round(window.Thermograph.toUnit(g.value)) : g.value; return `
${label} ${val}${unit}${pct} ${g.grade}
`; }; // 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 = `
${dt.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })}
${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)}
Dry ${d.dsr == null ? "—" : d.dsr === 0 ? "rain today" : `${d.dsr} d since rain`}
normal ${nt ? fmtTemp(nt.p50) : "—"} / ${ni ? fmtTemp(ni.p50) : "—"}
`; 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}${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)); 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 world 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();