diff --git a/assets/js/app.js b/assets/js/app.js index cf5f746..16c8ffb 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1649,8 +1649,8 @@ export function UTCIForecast() { const hasFeltAnchor = glanceSummary.some(i => i.label === 'Peak felt temp'); const comfortItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Comfortable window') : null; const drivingItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Good driving time') : null; - const climateItem = hasFeltAnchor ? glanceSummary.find(i => i.label === '1991-2020 Average') : null; - const restSummary = glanceSummary.filter(i => !(hasFeltAnchor && (i.label === 'Comfortable window' || i.label === 'Good driving time' || i.label === '1991-2020 Average'))); + const climateRows = hasFeltAnchor ? glanceSummary.filter(i => i.label === '1991-2020 High' || i.label === '1991-2020 Average') : []; + const restSummary = glanceSummary.filter(i => !(hasFeltAnchor && (i.label === 'Comfortable window' || i.label === 'Good driving time' || i.label === '1991-2020 High' || i.label === '1991-2020 Average'))); return [ ...restSummary.flatMap(item => [ renderRow(item), @@ -1659,7 +1659,7 @@ export function UTCIForecast() { ? [ ...heatEvents.map(e => renderRow(e, 'ev-')), ...(drivingItem ? [renderRow(drivingItem)] : []), - ...(climateItem ? [renderRow(climateItem)] : []), + ...climateRows.map(item => renderRow(item)), ...(comfortItem ? [renderRow(comfortItem)] : []), ] : []), diff --git a/assets/js/compute.js b/assets/js/compute.js index 7728588..10b9809 100644 --- a/assets/js/compute.js +++ b/assets/js/compute.js @@ -491,31 +491,39 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols alert: maxRainProb >= 60, }; - // ── Vs seasonal average (climate anomaly) ────────────────────────────── - // Shown across every profile: how far this day's mean air temp sits above or - // below the 1991-2020 normal for the date. A small, honest climate-change cue. + // ── Vs seasonal norms (climate anomalies) ────────────────────────────── + // Shown across every profile: two rows comparing this day against the + // 1991-2020 norm for the date — the day's HIGH vs the normal high, and the + // day's 24h AVERAGE vs the normal mean. High tracks the daytime peak people + // notice; the average is the standard climate anomaly. An honest climate cue. const climateItem = (() => { if (!normals) return []; const key = dayKey || todayRows[0]?.iso?.slice(0, 10); if (!key) return []; const LEAP_CUM = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335]; const doy = LEAP_CUM[parseInt(key.slice(5, 7), 10) - 1] + (parseInt(key.slice(8, 10), 10) - 1); - const normal = normals[doy]; - if (normal == null) return []; const taVals = todayRows.map(r => r.Ta).filter(v => v != null); if (taVals.length === 0) return []; + const dayHigh = Math.max(...taVals); const dayMean = taVals.reduce((a, b) => a + b, 0) / taVals.length; - const anomaly = dayMean - normal; - const mag = Math.abs(anomaly); - const value = mag < 0.5 - ? 'About average' - : `${anomaly >= 0 ? '+' : '−'}${mag.toFixed(1)}° ${anomaly >= 0 ? 'warmer' : 'cooler'}`; - return [{ - icon: '🌡', - label: '1991-2020 Average', - value, - alert: anomaly >= 5, - }]; + const fmt = (anom) => { + const mag = Math.abs(anom); + return mag < 0.5 + ? 'About average' + : `${anom >= 0 ? '+' : '−'}${mag.toFixed(1)}° ${anom >= 0 ? 'warmer' : 'cooler'}`; + }; + const out = []; + const nHigh = normals.high?.[doy]; + if (nHigh != null) { + const a = dayHigh - nHigh; + out.push({ icon: '🌡', label: '1991-2020 High', value: fmt(a), alert: a >= 5 }); + } + const nMean = normals.mean?.[doy]; + if (nMean != null) { + const a = dayMean - nMean; + out.push({ icon: '🌡', label: '1991-2020 Average', value: fmt(a), alert: a >= 5 }); + } + return out; })(); // ── Good Driving Time ────────────────────────────────────────────────── diff --git a/assets/js/hooks/useForecast.js b/assets/js/hooks/useForecast.js index 6baffae..5971524 100644 --- a/assets/js/hooks/useForecast.js +++ b/assets/js/hooks/useForecast.js @@ -87,22 +87,24 @@ function soilCacheKey(loc) { } // ─── CLIMATE NORMALS (ERA5 archive) ─────────────────────────────────────── -// Open-Meteo's Historical Archive gives daily mean temps back to 1940. We pull -// the WMO reference period (1991-2020) once per location and reduce it into a -// 366-entry day-of-year table of "normal" daily-mean temperature, so the app -// can show how far the forecast for a day sits above/below its seasonal norm. -// Normals don't change, so this caches for a long time. +// Open-Meteo's Historical Archive gives daily temps back to 1940. We pull the +// WMO reference period (1991-2020) once per location and reduce it into two +// 366-entry day-of-year tables of "normal" daily HIGH and daily MEAN temperature, +// so the app can show how far a day's forecast high AND daily average each sit +// above/below their seasonal norm. Normals don't change, so this caches long. function buildClimateUrl(loc) { return `https://archive-api.open-meteo.com/v1/archive` + `?latitude=${loc.lat}&longitude=${loc.lon}` + `&start_date=1991-01-01&end_date=2020-12-31` + - `&daily=temperature_2m_mean&timezone=auto`; + `&daily=temperature_2m_max,temperature_2m_mean&timezone=auto`; } // Coarser key (~2 dp ≈ 1 km) so nearby lookups share one cached climatology. +// The `_hm` marks the { high, mean } shape so it won't collide with any +// earlier single-array cache from a previous build. function climateCacheKey(loc) { - return `sunscope_normals_${loc.lat.toFixed(2)}_${loc.lon.toFixed(2)}`; + return `sunscope_normals_hm_${loc.lat.toFixed(2)}_${loc.lon.toFixed(2)}`; } // Cumulative days before each month on a leap-year calendar, so every date @@ -114,15 +116,13 @@ function doyFromKey(key) { return LEAP_CUM[m - 1] + (d - 1); // 0..365 } -// Reduce raw daily means into a smoothed 366-entry day-of-year normals table. -function reduceNormals(daily) { - const times = daily?.time; - const means = daily?.temperature_2m_mean; - if (!times || !means) return null; +// Reduce one raw daily series into a smoothed 366-entry day-of-year table. +function reduceSeries(times, values) { + if (!times || !values) return null; const sums = new Array(366).fill(0); const counts = new Array(366).fill(0); for (let i = 0; i < times.length; i++) { - const v = means[i]; + const v = values[i]; if (v == null) continue; const doy = doyFromKey(times[i]); sums[doy] += v; @@ -143,8 +143,18 @@ function reduceNormals(daily) { return smooth; } -// Fetches (or reads cached) climate normals for a location. Returns a -// 366-entry array of daily-mean °C, or null on any failure (feature hides). +// Build both the normal-high and normal-mean day-of-year tables from the +// archive response. Returns { high, mean } or null. +function reduceNormals(daily) { + const times = daily?.time; + const high = reduceSeries(times, daily?.temperature_2m_max); + const mean = reduceSeries(times, daily?.temperature_2m_mean); + if (!high && !mean) return null; + return { high, mean }; +} + +// Fetches (or reads cached) climate normals for a location. Returns +// { high, mean } day-of-year tables (°C), or null on any failure (feature hides). async function fetchClimateNormals(loc) { const key = climateCacheKey(loc); try {