Cache the weather forecast for offline fallback

Radar flyout now also shows a 3-day Open-Meteo forecast (free, no API
key) for the map's current center. Radar frames and the forecast fetch
independently — one being unreachable doesn't hide the other. The last
successful forecast is cached in localStorage and shown (with a
"cached from" note) when the live fetch fails, so there's still
something useful once actually offline.

Radar tile images themselves aren't cached — that needs a Service
Worker, left as a separate follow-up (see TODO.adoc).
This commit is contained in:
randy committed 2026-09-09 09:42:10 +02:00
1 parent 707e71caf1
commit 8d090b0774
3 files changed
+173 -18

No files matched your search

+2 -1
View File
@@ -15,7 +15,8 @@ independence from connectivity.
* [ ] Overpass/OSM scripts to extract POIs (drinking water, dump stations, LPG, public toilets, laundromats, shops, springs)
* [ ] Import Park4Night / iOverlander / Freecampsites data
* [ ] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates
* [ ] Cache weather and precipitation radar when temporarily connected (4G/5G/Wi-Fi)
* [x] Cache weather forecast when temporarily connected — Open-Meteo 3-day forecast in the Radar flyout, last successful fetch cached in localStorage as a fallback when offline
** [ ] Cache the radar tile *images* too, not just the forecast text — needs a Service Worker (own bigger task: registration, cache strategy, invalidation); app has HTTPS everywhere (reverse proxy, incl. the in-van local server) so it's viable, just not done yet
== NPS visual style
+30
View File
@@ -193,6 +193,36 @@ html, body {
white-space: nowrap;
}
.radar-control__forecast {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid #eee;
}
.radar-control__forecast-day {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 0;
white-space: nowrap;
}
.radar-control__forecast-day span:first-child {
width: 2.2em;
color: #888;
}
.radar-control__forecast-day span:nth-child(3) {
margin-left: auto;
font-variant-numeric: tabular-nums;
}
.radar-control__forecast-stale {
margin: 4px 0 0;
color: #888;
font-size: 11px;
}
.basemap-panel {
position: fixed;
top: 0;
+141 -17
View File
@@ -2,6 +2,34 @@ import { RADAR_ICON } from "/js/icons.js";
const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json";
// Open-Meteo: free, no API key. Only the forecast (small JSON) is cached
// here, in localStorage — the radar tile images above aren't: actually
// serving those without a live network needs a Service Worker (intercepting
// the tile requests and answering from a cache), which is its own separate
// piece of infrastructure (registration, cache strategy, invalidation) and
// deliberately left for later rather than folded into this. A cached
// forecast still gives *something* useful once genuinely offline, which the
// radar animation above can't.
const FORECAST_API = "https://api.open-meteo.com/v1/forecast";
const FORECAST_DAYS = 3;
const FORECAST_CACHE_KEY = "maptool:forecast";
// A small subset of WMO weather codes (open-meteo's `weather_code`) mapped
// to one glyph each — not the full table, just enough to tell clear/cloudy/
// rain/snow/storm apart at a glance.
const WEATHER_EMOJI = [
[[0], "☀️"],
[[1, 2, 3], "⛅"],
[[45, 48], "🌫️"],
[[51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82], "🌧️"],
[[71, 73, 75, 77, 85, 86], "❄️"],
[[95, 96, 99], "⛈️"],
];
function weatherEmoji(code) {
return WEATHER_EMOJI.find(([codes]) => codes.includes(code))?.[1] ?? "🌡️";
}
// Matches the default window used by github.com/jpettitt/weather-radar-card
// (60 minutes of history at RainViewer's native 10-minute cadence = 7
// frames, the last one being "now").
@@ -42,14 +70,17 @@ export class RadarControl {
<input type="range" class="radar-control__scrub" min="0" max="0" step="1" value="0">
<span class="radar-control__time"></span>
</div>
<div class="radar-control__forecast"></div>
</div>
`;
this._icon = this._container.querySelector(".sidebar-icon");
this._flyout = this._container.querySelector(".sidebar-flyout");
this._playback = this._container.querySelector(".radar-control__playback");
this._playButton = this._container.querySelector(".radar-control__play");
this._scrub = this._container.querySelector(".radar-control__scrub");
this._time = this._container.querySelector(".radar-control__time");
this._forecastEl = this._container.querySelector(".radar-control__forecast");
this._icon.addEventListener("click", () => this._setEnabled(!this._enabled));
this._playButton.addEventListener("click", () => this._togglePlayback());
@@ -69,30 +100,87 @@ export class RadarControl {
this._removeLayers();
this._frames = [];
this._updateFrameUI();
this._renderForecast(null);
return;
}
this._icon.disabled = true;
let frames;
try {
frames = await fetchFrames();
} catch (err) {
this._enabled = false;
this._icon.classList.remove("active");
this._icon.disabled = false;
return;
}
// Independent fetches: RainViewer being unreachable shouldn't hide a
// forecast that Open-Meteo still answered, or vice versa — especially
// since only the forecast has a cached fallback to fall back to.
const [framesResult, forecastResult] = await Promise.allSettled([fetchFrames(), this._loadForecast()]);
this._icon.disabled = false;
// The icon may have been clicked again (disabling) while the fetch was
// in flight.
if (!this._enabled || !frames.length) return;
// The icon may have been clicked again (disabling) while the fetches
// were in flight.
if (!this._enabled) return;
const frames = framesResult.status === "fulfilled" ? framesResult.value : [];
const forecast = forecastResult.status === "fulfilled" ? forecastResult.value : null;
// Nothing at all came back (both offline, no cached forecast either) —
// same "turn back off" story the radar-only fetch used to have on its
// own failure.
if (frames.length === 0 && !forecast) {
this._enabled = false;
this._icon.classList.remove("active");
} else if (frames.length > 0) {
this._frames = frames;
this._frameIndex = frames.length - 1;
this._addLayers();
this._startAnimation();
}
this._frames = frames;
this._frameIndex = frames.length - 1;
this._addLayers();
this._updateFrameUI();
this._startAnimation();
this._renderForecast(forecast);
}
// cachedAt is null for a fresh fetch, or the cache's own timestamp when
// falling back to it — _renderForecast uses that to show a "cached" note
// instead of claiming this is live data.
async _loadForecast() {
const { lat, lng } = this._map.getCenter();
try {
const data = await fetchForecast(lat, lng);
writeCachedForecast(data);
return { data, cachedAt: null };
} catch (err) {
const cached = readCachedForecast();
if (!cached) throw err;
return cached;
}
}
_renderForecast(result) {
if (!result) {
this._forecastEl.innerHTML = "";
return;
}
const { data, cachedAt } = result;
const days = data.daily.time.map((date, i) => ({
date,
code: data.daily.weather_code[i],
max: Math.round(data.daily.temperature_2m_max[i]),
min: Math.round(data.daily.temperature_2m_min[i]),
precip: data.daily.precipitation_probability_max[i],
}));
const rows = days
.map(
(day) => `
<div class="radar-control__forecast-day">
<span>${new Date(day.date).toLocaleDateString([], { weekday: "short" })}</span>
<span>${weatherEmoji(day.code)}</span>
<span>${day.max}° / ${day.min}°</span>
<span>${day.precip}%</span>
</div>`,
)
.join("");
const staleNote = cachedAt
? `<p class="radar-control__forecast-stale">Cached from ${new Date(cachedAt).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}</p>`
: "";
this._forecastEl.innerHTML = rows + staleNote;
}
_addLayers() {
@@ -164,7 +252,10 @@ export class RadarControl {
}
_updateFrameUI() {
this._flyout.hidden = this._frames.length === 0;
// Shown whenever there's radar frames, a forecast, or both — either
// one on its own is still worth showing the flyout for.
this._flyout.hidden = !this._enabled;
this._playback.hidden = this._frames.length === 0;
this._scrub.max = String(Math.max(0, this._frames.length - 1));
this._scrub.value = String(this._frameIndex);
const frame = this._frames[this._frameIndex];
@@ -192,6 +283,39 @@ async function fetchFrames() {
return past.slice(-FRAME_COUNT).map((frame) => ({ ...frame, host }));
}
async function fetchForecast(lat, lon) {
const url = new URL(FORECAST_API);
url.searchParams.set("latitude", lat);
url.searchParams.set("longitude", lon);
url.searchParams.set("daily", "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max");
url.searchParams.set("timezone", "auto");
url.searchParams.set("forecast_days", String(FORECAST_DAYS));
const response = await fetch(url);
if (!response.ok) throw new Error(`Open-Meteo request failed: ${response.status}`);
return response.json();
}
// localStorage, not IndexedDB: this is one small JSON object (a few days of
// daily figures), not worth a heavier async storage API for. Wrapped since
// private browsing / storage-blocked contexts can throw on access, not just
// on being full — a forecast is a nice-to-have, never worth crashing over.
function writeCachedForecast(data) {
try {
localStorage.setItem(FORECAST_CACHE_KEY, JSON.stringify({ data, cachedAt: Date.now() }));
} catch (err) {
// Ignore — see above.
}
}
function readCachedForecast() {
try {
const raw = localStorage.getItem(FORECAST_CACHE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (err) {
return null;
}
}
function firstSymbolLayerId(map) {
const layers = map.getStyle()?.layers || [];
const symbolLayer = layers.find((layer) => layer.type === "symbol");