3.2.2
Updated logo Added average seasonal temps compare
This commit is contained in:
@@ -39,6 +39,7 @@ const FIFTEEN_MIN_MS = 15 * 60 * 1000;
|
||||
const THIRTY_MIN_MS = 30 * 60 * 1000;
|
||||
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
|
||||
const ONE_MIN_MS = 1 * 60 * 1000;
|
||||
const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function buildForecastUrl(loc) {
|
||||
return `https://api.open-meteo.com/v1/forecast` +
|
||||
@@ -85,6 +86,85 @@ function soilCacheKey(loc) {
|
||||
return `sunscope_soil_${loc.lat.toFixed(4)}_${loc.lon.toFixed(4)}`;
|
||||
}
|
||||
|
||||
// ─── 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.
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
// Coarser key (~2 dp ≈ 1 km) so nearby lookups share one cached climatology.
|
||||
function climateCacheKey(loc) {
|
||||
return `sunscope_normals_${loc.lat.toFixed(2)}_${loc.lon.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// Cumulative days before each month on a leap-year calendar, so every date
|
||||
// maps to a stable day-of-year slot (Mar 1 is always 60, Feb 29 is 59).
|
||||
const LEAP_CUM = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
|
||||
function doyFromKey(key) {
|
||||
const m = parseInt(key.slice(5, 7), 10);
|
||||
const d = parseInt(key.slice(8, 10), 10);
|
||||
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;
|
||||
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];
|
||||
if (v == null) continue;
|
||||
const doy = doyFromKey(times[i]);
|
||||
sums[doy] += v;
|
||||
counts[doy] += 1;
|
||||
}
|
||||
const raw = sums.map((s, i) => (counts[i] > 0 ? s / counts[i] : null));
|
||||
// ±7-day circular smoothing to remove single-day sampling noise.
|
||||
const WIN = 7;
|
||||
const smooth = new Array(366).fill(null);
|
||||
for (let i = 0; i < 366; i++) {
|
||||
let s = 0, n = 0;
|
||||
for (let k = -WIN; k <= WIN; k++) {
|
||||
const v = raw[(i + k + 366) % 366];
|
||||
if (v != null) { s += v; n += 1; }
|
||||
}
|
||||
if (n > 0) smooth[i] = s / n;
|
||||
}
|
||||
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).
|
||||
async function fetchClimateNormals(loc) {
|
||||
const key = climateCacheKey(loc);
|
||||
try {
|
||||
const cached = localStorage.getItem(key);
|
||||
if (cached) {
|
||||
const { ts, normals } = JSON.parse(cached);
|
||||
if (normals && Date.now() - ts < SIXTY_DAYS_MS) return normals;
|
||||
}
|
||||
} catch (e) { /* ignore bad cache */ }
|
||||
try {
|
||||
const r = await fetch(buildClimateUrl(loc));
|
||||
if (!r.ok) return null;
|
||||
const json = await r.json();
|
||||
const normals = reduceNormals(json.daily);
|
||||
if (!normals) return null;
|
||||
try { localStorage.setItem(key, JSON.stringify({ ts: Date.now(), normals })); } catch (e) {}
|
||||
return normals;
|
||||
} catch (e) { return null; } // network failure — feature just hides
|
||||
}
|
||||
|
||||
// Fetches ICON soil hourly data with its own cache.
|
||||
// Returns stale cache of any age if the fetch fails — caller uses this as last resort.
|
||||
async function fetchSoilHourly(loc) {
|
||||
@@ -130,6 +210,7 @@ export function useForecast(location, isPro = false) {
|
||||
const [error, setError] = useState(null);
|
||||
const [now, setNow] = useState(new Date());
|
||||
const [fetchedAt, setFetchedAt] = useState(null);
|
||||
const [normals, setNormals] = useState(null);
|
||||
|
||||
// Mirrors the latest forecast so the loader's catch block can tell whether
|
||||
// data is already on screen, even inside stale setInterval closures.
|
||||
@@ -236,6 +317,12 @@ export function useForecast(location, isPro = false) {
|
||||
useEffect(() => {
|
||||
loadForecast(location);
|
||||
loadAirQuality(location);
|
||||
// Climate normals are supplementary — load them without blocking. Clear
|
||||
// first so a previous location's normals don't linger if this fetch fails.
|
||||
setNormals(null);
|
||||
if (location?.lat != null) {
|
||||
fetchClimateNormals(location).then(n => { if (n) setNormals(n); });
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
// ─── 1-MINUTE CLOCK TICK (current-row highlight + live scope) ─────
|
||||
@@ -276,5 +363,5 @@ export function useForecast(location, isPro = false) {
|
||||
return () => clearInterval(id);
|
||||
}, [location, isPro]);
|
||||
|
||||
return { forecast, airQuality, loading, error, now, fetchedAt, liveElev };
|
||||
return { forecast, airQuality, loading, error, now, fetchedAt, liveElev, normals };
|
||||
}
|
||||
Reference in New Issue
Block a user