Updated logo
Added average seasonal temps compare
This commit is contained in:
fraxle
2026-07-07 13:23:08 +01:00
parent fd7ed5dfcc
commit 0b260affd7
7 changed files with 151 additions and 24 deletions
+2 -2
View File
@@ -100,7 +100,7 @@ export function useAppState() {
return localStorage.getItem('sunscope_pro') === '1';
});
const { forecast, airQuality, loading, error, now, fetchedAt, liveElev } = useForecast(location, isPro);
const { forecast, airQuality, loading, error, now, fetchedAt, liveElev, normals } = useForecast(location, isPro);
useEffect(() => { track('visit'); }, []);
@@ -617,7 +617,7 @@ export function useAppState() {
// location
location, setLocationAndSave, recentLocations,
// forecast
forecast, airQuality, loading, error, now, fetchedAt,
forecast, airQuality, loading, error, now, fetchedAt, normals,
// search
searchQuery, setSearchQuery,
searchResults, setSearchResults,
+88 -1
View File
@@ -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 };
}