From 8d090b07745cb76060ad4d20467598cd2af673fa Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Wed, 9 Sep 2026 09:42:10 +0200 Subject: [PATCH] Cache the weather forecast for offline fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- TODO.adoc | 3 +- public/css/style.css | 30 ++++++++ public/js/radar.js | 158 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 173 insertions(+), 18 deletions(-) diff --git a/TODO.adoc b/TODO.adoc index 35f0bef..32fec5c 100644 --- a/TODO.adoc +++ b/TODO.adoc @@ -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 diff --git a/public/css/style.css b/public/css/style.css index a9b265d..01fe5e3 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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; diff --git a/public/js/radar.js b/public/js/radar.js index d3f8fb9..6c7c506 100644 --- a/public/js/radar.js +++ b/public/js/radar.js @@ -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 { +
`; 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) => ` +
+ ${new Date(day.date).toLocaleDateString([], { weekday: "short" })} + ${weatherEmoji(day.code)} + ${day.max}° / ${day.min}° + ${day.precip}% +
`, + ) + .join(""); + const staleNote = cachedAt + ? `

Cached from ${new Date(cachedAt).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}

` + : ""; + 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");