import { RADAR_CLOUD_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"). const FRAME_COUNT = 7; const FRAME_INTERVAL_MS = 600; const OPACITY = 0.7; const ATTRIBUTION = 'Weather data © RainViewer'; // An Overlays panel item (see overlays.js) that fetches RainViewer's recent // precipitation frames on demand and animates them as a raster overlay, // with play/pause and a scrub slider revealed by expanding the item. No API // key needed — see docs/architecture.adoc for RainViewer's usage terms. export class RadarControl { constructor(map) { this._map = map; this._frames = []; this._frameIndex = 0; this._enabled = false; this._playing = false; this._timer = null; // setStyle() (switching the Online/Offline base map) tears down all // custom sources and layers — put the radar overlay back afterwards. map.on("style.load", () => { if (this._enabled && this._frames.length) this._addLayers(); }); } // li: the
Cached from ${new Date(cachedAt).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}
` : ""; this._forecastEl.innerHTML = rows + staleNote; } _addLayers() { const beforeId = firstSymbolLayerId(this._map); this._frames.forEach((frame, i) => { const id = layerId(i); if (this._map.getSource(id)) return; this._map.addSource(id, { type: "raster", tiles: [tileUrl(frame)], tileSize: 256, maxzoom: 7, attribution: ATTRIBUTION, }); this._map.addLayer( { id, type: "raster", source: id, paint: { "raster-opacity": i === this._frameIndex ? OPACITY : 0 }, }, beforeId, ); }); } _removeLayers() { this._frames.forEach((_, i) => { const id = layerId(i); if (this._map.getLayer(id)) this._map.removeLayer(id); if (this._map.getSource(id)) this._map.removeSource(id); }); } _showFrame(index) { this._frameIndex = index; this._frames.forEach((_, i) => { const id = layerId(i); if (this._map.getLayer(id)) { this._map.setPaintProperty(id, "raster-opacity", i === index ? OPACITY : 0); } }); this._updateFrameUI(); } _startAnimation() { if (this._frames.length < 2) return; this._playing = true; this._playButton.textContent = "⏸"; this._playButton.setAttribute("aria-label", "Pause"); this._timer = setInterval(() => { this._showFrame((this._frameIndex + 1) % this._frames.length); }, FRAME_INTERVAL_MS); } _stopAnimation() { this._playing = false; this._playButton.textContent = "▶"; this._playButton.setAttribute("aria-label", "Play"); if (this._timer) { clearInterval(this._timer); this._timer = null; } } _togglePlayback() { if (this._playing) this._stopAnimation(); else this._startAnimation(); } _updateFrameUI() { 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]; this._time.textContent = frame ? new Date(frame.time * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""; } } function layerId(i) { return `radar-frame-${i}`; } function tileUrl(frame) { // Color scheme 2 (Universal Blue), smoothing on, no snow overlay — see // docs/architecture.adoc for the full RainViewer tile URL reference. return `${frame.host}${frame.path}/256/{z}/{x}/{y}/2/1_0.png`; } async function fetchFrames() { const response = await fetch(RAINVIEWER_API); const data = await response.json(); const host = data.host || "https://tilecache.rainviewer.com"; const past = (data.radar && data.radar.past) || []; 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"); return symbolLayer ? symbolLayer.id : undefined; }