Summaries added — every column in COL_DESCRIPTIONS got a short (1–2 sentences) and a link to its reference row. Full desc text kept intact for the day-tab metric labels. Popup rewired — renders the summary plus a "More about this column →" link, opening in a new tab. Link styling — .col-info-more pinned below the summary so it stays visible if the text scrolls; centred on mobile with the rest. Anchors on the reference page — 35 row ids in columns.html, one per column key. New "Rows" row — the hour/interval header had a popup but no matching row on the reference page; added. Landing highlight — tr:target gets a gold bar and tint, with scroll offset so the nav doesn't cover it.
406 lines
17 KiB
JavaScript
406 lines
17 KiB
JavaScript
// ------------------------------------------------------------------------
|
|
// useForecast - fetches the weather forecast and air quality for the
|
|
// given location and keeps them fresh.
|
|
//
|
|
// Responsibilities:
|
|
// 1. Fetch /v1/forecast on location change, with a localStorage cache
|
|
// (15 min Pro / 30 min free). fetchedAt reflects the cache timestamp.
|
|
// 2. Soil data (soil_temperature, soil_moisture) waterfall:
|
|
// a. Main auto API — works if the regional model includes soil.
|
|
// b. ICON Global fallback — used when the auto model returns nulls
|
|
// (e.g. UK Met Office / MetNo don't expose soil variables).
|
|
// c. Stale soil cache — used if ICON fetch fails.
|
|
// 3. Fetch /v1/air-quality on location change, with a 6-hour
|
|
// localStorage cache (AQI / pollen update slowly).
|
|
// 4. Auto-refresh forecast every 15 minutes for Pro, 30 minutes for
|
|
// free (Open-Meteo updates ~every 15 min). Air quality only
|
|
// refetches if cache is stale.
|
|
// 5. Tick `now` every minute to keep the current-row highlight
|
|
// accurate without any API cost.
|
|
//
|
|
// Inputs:
|
|
// location - { lat, lon, name, country }
|
|
// isPro - boolean; true tightens auto-refresh to 15 min
|
|
//
|
|
// Outputs:
|
|
// forecast - raw /v1/forecast response, or null
|
|
// airQuality - raw /v1/air-quality response, or null
|
|
// aqHorizon - last date ('YYYY-MM-DD') air quality covers, or null
|
|
// loading - true while the forecast fetch is in flight
|
|
// error - fetch error message, or null
|
|
// now - Date that ticks every minute (drives the current row)
|
|
// fetchedAt - Date the forecast data was last fetched or read from cache
|
|
// retry - force a fresh fetch of both, ignoring cache age
|
|
// ------------------------------------------------------------------------
|
|
|
|
import { useState, useEffect, useRef } from '../../vendor/preact-hooks.js';
|
|
import { solarElevationDeg } from '../physics.js';
|
|
|
|
const FIVE_MIN_MS = 5 * 60 * 1000;
|
|
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` +
|
|
`?latitude=${loc.lat}&longitude=${loc.lon}` +
|
|
`&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,` +
|
|
`wind_speed_10m,wind_direction_10m,wind_gusts_10m,` +
|
|
`direct_radiation,diffuse_radiation,shortwave_radiation,` +
|
|
`cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` +
|
|
`uv_index,precipitation,precipitation_probability,lightning_potential,cape,snowfall,visibility,` +
|
|
`soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` +
|
|
`&wind_speed_unit=ms&timezone=auto&forecast_days=14`;
|
|
}
|
|
|
|
// CAMS only publishes 7 days of air quality against the forecast's 14, so the
|
|
// AQI and pollen columns run out early. The UI reads the real horizon back off
|
|
// the returned time array (see aqHorizon below) rather than trusting this
|
|
// number, so raising it later needs no other change.
|
|
function buildAirQualityUrl(loc) {
|
|
return `https://air-quality-api.open-meteo.com/v1/air-quality` +
|
|
`?latitude=${loc.lat}&longitude=${loc.lon}` +
|
|
`&hourly=european_aqi,` +
|
|
`grass_pollen,birch_pollen,alder_pollen,` +
|
|
`mugwort_pollen,olive_pollen,ragweed_pollen` +
|
|
`&timezone=auto&forecast_days=7`;
|
|
}
|
|
|
|
function forecastCacheKey(loc) {
|
|
return `sunscope_fc_${loc.lat.toFixed(4)}_${loc.lon.toFixed(4)}`;
|
|
}
|
|
|
|
function airQualityCacheKey(loc) {
|
|
return `sunscope_aq_${loc.lat.toFixed(4)}_${loc.lon.toFixed(4)}`;
|
|
}
|
|
|
|
// ─── SOIL (ECMWF) ─────────────────────────────────────────────────────────
|
|
// Open-Meteo's auto-selected regional models (UK Met Office, MetNo, etc.)
|
|
// don't include soil variables — they return all-null arrays. ECMWF IFS
|
|
// covers soil globally so we fetch soil fields separately and merge them in.
|
|
|
|
function buildSoilUrl(loc) {
|
|
return `https://api.open-meteo.com/v1/forecast` +
|
|
`?latitude=${loc.lat}&longitude=${loc.lon}` +
|
|
`&hourly=soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` +
|
|
`&timezone=auto&forecast_days=14&models=icon_global`;
|
|
}
|
|
|
|
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 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_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_hm_${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 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 = values[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;
|
|
}
|
|
|
|
// 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 {
|
|
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) {
|
|
const key = soilCacheKey(loc);
|
|
let stale = null;
|
|
try {
|
|
const cached = localStorage.getItem(key);
|
|
if (cached) {
|
|
const { ts, data } = JSON.parse(cached);
|
|
if (Date.now() - ts < FIVE_MIN_MS) return data; // fresh — skip fetch
|
|
stale = data; // stale — keep as fallback
|
|
}
|
|
} catch (e) { /* ignore bad cache */ }
|
|
try {
|
|
const r = await fetch(buildSoilUrl(loc));
|
|
if (!r.ok) return stale;
|
|
const json = await r.json();
|
|
const h = json.hourly;
|
|
try { localStorage.setItem(key, JSON.stringify({ ts: Date.now(), data: h })); } catch (e) {}
|
|
return h;
|
|
} catch (e) { return stale; } // network failure — return stale if available
|
|
}
|
|
|
|
// Merges soil arrays into forecast.hourly, aligned by time string.
|
|
function mergeSoil(forecastData, soilHourly) {
|
|
if (!soilHourly) return forecastData;
|
|
const timeToIdx = {};
|
|
soilHourly.time.forEach((t, i) => { timeToIdx[t] = i; });
|
|
const h = { ...forecastData.hourly };
|
|
['soil_temperature_0cm', 'soil_temperature_6cm', 'soil_moisture_0_to_1cm'].forEach(field => {
|
|
h[field] = forecastData.hourly.time.map(t => {
|
|
const i = timeToIdx[t];
|
|
return i !== undefined ? (soilHourly[field]?.[i] ?? null) : null;
|
|
});
|
|
});
|
|
return { ...forecastData, hourly: h };
|
|
}
|
|
|
|
export function useForecast(location, isPro = false) {
|
|
const [forecast, setForecast] = useState(null);
|
|
const [airQuality, setAirQuality] = useState(null);
|
|
const [loading, setLoading] = useState(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.
|
|
const forecastRef = useRef(null);
|
|
useEffect(() => { forecastRef.current = forecast; }, [forecast]);
|
|
|
|
// ─── FORECAST LOADER ──────────────────────────────────────────────
|
|
//
|
|
// Waterfall:
|
|
// 1. Cache fresh (< refresh interval) → serve immediately, no fetch needed
|
|
// 2. Cache stale / missing → fetch main auto API
|
|
// 3. Main API soil is null → try ICON for soil data
|
|
// 4. ICON fails → fall back to stale cache (any age)
|
|
// 5. No cache but data shown → keep existing data (don't show error)
|
|
// 6. Nothing loaded at all → show error
|
|
//
|
|
// `force` is the manual Retry path: it still paints the cache first (better
|
|
// than a blank screen) but never takes the "fresh enough, skip the fetch"
|
|
// exit, so pressing Retry always goes to the network.
|
|
async function loadForecast(loc, force = false) {
|
|
const key = forecastCacheKey(loc);
|
|
let staleCache = null;
|
|
|
|
// Step 1 — stale-while-revalidate: serve any cached data immediately,
|
|
// then continue to fetch fresh data silently in the background.
|
|
// Only show the loading spinner if there is nothing cached at all.
|
|
const cacheMaxAge = isPro ? FIFTEEN_MIN_MS : THIRTY_MIN_MS;
|
|
try {
|
|
const cached = localStorage.getItem(key);
|
|
if (cached) {
|
|
const { ts, data } = JSON.parse(cached);
|
|
staleCache = { ts, data };
|
|
// Always show cached data immediately - no spinner
|
|
setForecast(data);
|
|
setFetchedAt(new Date(ts));
|
|
// If fresh enough, no need to re-fetch
|
|
if (!force && Date.now() - ts < cacheMaxAge) return;
|
|
}
|
|
} catch (e) { /* ignore bad cache */ }
|
|
|
|
// Only show spinner if nothing could be served from cache. A manual retry
|
|
// always spins, so the button visibly does something.
|
|
if (!staleCache || force) { setLoading(true); }
|
|
setError(null);
|
|
try {
|
|
// Step 2 — fetch main forecast
|
|
const r = await fetch(buildForecastUrl(loc));
|
|
if (!r.ok) throw new Error(`Open-Meteo HTTP ${r.status}`);
|
|
const data = await r.json();
|
|
|
|
// Step 3 — if main API has no soil data, fetch ICON
|
|
const mainHasSoil = data.hourly?.soil_moisture_0_to_1cm?.some(v => v != null);
|
|
const soilH = mainHasSoil ? null : await fetchSoilHourly(loc);
|
|
const merged = mergeSoil(data, soilH);
|
|
|
|
const ts = Date.now();
|
|
setForecast(merged);
|
|
setFetchedAt(new Date(ts));
|
|
try {
|
|
localStorage.setItem(key, JSON.stringify({ ts, data: merged }));
|
|
} catch (e) { /* ignore storage errors */ }
|
|
|
|
} catch (e) {
|
|
// Step 4 — API failed, serve stale cache so users still see something
|
|
if (staleCache) {
|
|
setForecast(staleCache.data);
|
|
setFetchedAt(new Date(staleCache.ts)); // old timestamp signals stale data
|
|
} else if (forecastRef.current) {
|
|
// Step 5 — no cache (e.g. an auto-refresh cleared it first), but data
|
|
// is already on screen. Keep showing it rather than replacing the page
|
|
// with a transient network error.
|
|
} else {
|
|
setError(e.message); // Step 6 — nothing loaded yet, surface the error
|
|
}
|
|
}
|
|
finally { setLoading(false); }
|
|
}
|
|
|
|
// ─── AIR QUALITY LOADER ───────────────────────────────────────────
|
|
async function loadAirQuality(loc, force = false) {
|
|
const key = airQualityCacheKey(loc);
|
|
let stale = null;
|
|
try {
|
|
const cached = localStorage.getItem(key);
|
|
if (cached) {
|
|
const { ts, data } = JSON.parse(cached);
|
|
if (!force && Date.now() - ts < SIX_HOURS_MS) {
|
|
setAirQuality(data);
|
|
return;
|
|
}
|
|
stale = data; // keep stale for fallback
|
|
}
|
|
} catch (e) { /* ignore bad cache */ }
|
|
try {
|
|
const r = await fetch(buildAirQualityUrl(loc));
|
|
if (!r.ok) { if (stale) setAirQuality(stale); return; }
|
|
const data = await r.json();
|
|
setAirQuality(data);
|
|
try {
|
|
localStorage.setItem(key, JSON.stringify({ ts: Date.now(), data }));
|
|
} catch (e) { /* ignore storage errors */ }
|
|
} catch (_) {
|
|
if (stale) setAirQuality(stale); // network failure — use stale silently
|
|
}
|
|
}
|
|
|
|
// ─── MANUAL RETRY ─────────────────────────────────────────────────
|
|
// Wired to the Retry button in the error banner. Forces past the cache-age
|
|
// check on both loaders so a user who taps it always gets a real attempt.
|
|
function retry() {
|
|
if (!location) return;
|
|
loadForecast(location, true);
|
|
loadAirQuality(location, true);
|
|
}
|
|
|
|
// ─── INITIAL FETCH on location change ─────────────────────────────
|
|
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) ─────
|
|
useEffect(() => {
|
|
const id = setInterval(() => setNow(new Date()), ONE_MIN_MS);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
// ─── LIVE SOLAR ELEVATION (updates every minute with now tick) ─────
|
|
const [liveElev, setLiveElev] = useState(0);
|
|
useEffect(() => {
|
|
if (location?.lat == null) return;
|
|
setLiveElev(solarElevationDeg(location.lat, location.lon, now));
|
|
}, [now, location]);
|
|
|
|
// ─── AUTO REFRESH (Pro: 15 min, free: 30 min) ─────────────────────
|
|
// Open-Meteo updates roughly every 15 min; Pro gets tighter polling.
|
|
useEffect(() => {
|
|
const refreshMs = isPro ? FIFTEEN_MIN_MS : THIRTY_MIN_MS;
|
|
const id = setInterval(() => {
|
|
async function refresh() {
|
|
const key = forecastCacheKey(location);
|
|
try { localStorage.removeItem(key); } catch (e) { /* ignore */ }
|
|
await loadForecast(location);
|
|
|
|
const aqKey = airQualityCacheKey(location);
|
|
try {
|
|
const cached = localStorage.getItem(aqKey);
|
|
if (cached) {
|
|
const { ts } = JSON.parse(cached);
|
|
if (Date.now() - ts < SIX_HOURS_MS) return;
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
loadAirQuality(location);
|
|
}
|
|
refresh();
|
|
}, refreshMs);
|
|
return () => clearInterval(id);
|
|
}, [location, isPro]);
|
|
|
|
// ─── AIR QUALITY HORIZON ──────────────────────────────────────────
|
|
// CAMS runs out well before the 14-day forecast does. Read the last date it
|
|
// actually returned so the table can say "not forecast this far ahead"
|
|
// instead of rendering blank AQI and pollen cells, which read as a bug.
|
|
const aqHorizon = (() => {
|
|
const times = airQuality?.hourly?.time;
|
|
if (!times || times.length === 0) return null;
|
|
return times[times.length - 1].slice(0, 10); // 'YYYY-MM-DD'
|
|
})();
|
|
|
|
return { forecast, airQuality, aqHorizon, loading, error, now, fetchedAt, liveElev, normals, retry };
|
|
} |