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:
1 parent
707e71caf1
commit
8d090b0774
3 files changed
+171
-16
No files matched your search
@@ -15,7 +15,8 @@ independence from connectivity.
|
|||||||
* [ ] Overpass/OSM scripts to extract POIs (drinking water, dump stations, LPG, public toilets, laundromats, shops, springs)
|
* [ ] Overpass/OSM scripts to extract POIs (drinking water, dump stations, LPG, public toilets, laundromats, shops, springs)
|
||||||
* [ ] Import Park4Night / iOverlander / Freecampsites data
|
* [ ] Import Park4Night / iOverlander / Freecampsites data
|
||||||
* [ ] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates
|
* [ ] 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
|
== NPS visual style
|
||||||
|
|
||||||
|
|||||||
@@ -193,6 +193,36 @@ html, body {
|
|||||||
white-space: nowrap;
|
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 {
|
.basemap-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
+139
-15
@@ -2,6 +2,34 @@ import { RADAR_ICON } from "/js/icons.js";
|
|||||||
|
|
||||||
const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json";
|
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
|
// Matches the default window used by github.com/jpettitt/weather-radar-card
|
||||||
// (60 minutes of history at RainViewer's native 10-minute cadence = 7
|
// (60 minutes of history at RainViewer's native 10-minute cadence = 7
|
||||||
// frames, the last one being "now").
|
// 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">
|
<input type="range" class="radar-control__scrub" min="0" max="0" step="1" value="0">
|
||||||
<span class="radar-control__time"></span>
|
<span class="radar-control__time"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="radar-control__forecast"></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this._icon = this._container.querySelector(".sidebar-icon");
|
this._icon = this._container.querySelector(".sidebar-icon");
|
||||||
this._flyout = this._container.querySelector(".sidebar-flyout");
|
this._flyout = this._container.querySelector(".sidebar-flyout");
|
||||||
|
this._playback = this._container.querySelector(".radar-control__playback");
|
||||||
this._playButton = this._container.querySelector(".radar-control__play");
|
this._playButton = this._container.querySelector(".radar-control__play");
|
||||||
this._scrub = this._container.querySelector(".radar-control__scrub");
|
this._scrub = this._container.querySelector(".radar-control__scrub");
|
||||||
this._time = this._container.querySelector(".radar-control__time");
|
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._icon.addEventListener("click", () => this._setEnabled(!this._enabled));
|
||||||
this._playButton.addEventListener("click", () => this._togglePlayback());
|
this._playButton.addEventListener("click", () => this._togglePlayback());
|
||||||
@@ -69,32 +100,89 @@ export class RadarControl {
|
|||||||
this._removeLayers();
|
this._removeLayers();
|
||||||
this._frames = [];
|
this._frames = [];
|
||||||
this._updateFrameUI();
|
this._updateFrameUI();
|
||||||
|
this._renderForecast(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._icon.disabled = true;
|
this._icon.disabled = true;
|
||||||
let frames;
|
// Independent fetches: RainViewer being unreachable shouldn't hide a
|
||||||
try {
|
// forecast that Open-Meteo still answered, or vice versa — especially
|
||||||
frames = await fetchFrames();
|
// since only the forecast has a cached fallback to fall back to.
|
||||||
} catch (err) {
|
const [framesResult, forecastResult] = await Promise.allSettled([fetchFrames(), this._loadForecast()]);
|
||||||
|
this._icon.disabled = false;
|
||||||
|
|
||||||
|
// 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._enabled = false;
|
||||||
this._icon.classList.remove("active");
|
this._icon.classList.remove("active");
|
||||||
this._icon.disabled = false;
|
} else if (frames.length > 0) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._icon.disabled = false;
|
|
||||||
|
|
||||||
// The icon may have been clicked again (disabling) while the fetch was
|
|
||||||
// in flight.
|
|
||||||
if (!this._enabled || !frames.length) return;
|
|
||||||
|
|
||||||
this._frames = frames;
|
this._frames = frames;
|
||||||
this._frameIndex = frames.length - 1;
|
this._frameIndex = frames.length - 1;
|
||||||
this._addLayers();
|
this._addLayers();
|
||||||
this._updateFrameUI();
|
|
||||||
this._startAnimation();
|
this._startAnimation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._updateFrameUI();
|
||||||
|
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() {
|
_addLayers() {
|
||||||
const beforeId = firstSymbolLayerId(this._map);
|
const beforeId = firstSymbolLayerId(this._map);
|
||||||
this._frames.forEach((frame, i) => {
|
this._frames.forEach((frame, i) => {
|
||||||
@@ -164,7 +252,10 @@ export class RadarControl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_updateFrameUI() {
|
_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.max = String(Math.max(0, this._frames.length - 1));
|
||||||
this._scrub.value = String(this._frameIndex);
|
this._scrub.value = String(this._frameIndex);
|
||||||
const frame = this._frames[this._frameIndex];
|
const frame = this._frames[this._frameIndex];
|
||||||
@@ -192,6 +283,39 @@ async function fetchFrames() {
|
|||||||
return past.slice(-FRAME_COUNT).map((frame) => ({ ...frame, host }));
|
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) {
|
function firstSymbolLayerId(map) {
|
||||||
const layers = map.getStyle()?.layers || [];
|
const layers = map.getStyle()?.layers || [];
|
||||||
const symbolLayer = layers.find((layer) => layer.type === "symbol");
|
const symbolLayer = layers.find((layer) => layer.type === "symbol");
|
||||||
|
|||||||
Reference in new issue
Block a user