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"). const FRAME_COUNT = 7; const FRAME_INTERVAL_MS = 600; const OPACITY = 0.7; const ATTRIBUTION = 'Weather data © RainViewer'; // A sidebar icon that fetches RainViewer's recent precipitation frames on // demand and animates them as a raster overlay, with play/pause and a scrub // slider in a flyout. 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(); }); } mount(sidebar) { this._container = document.createElement("div"); this._container.className = "sidebar-item"; this._container.innerHTML = ` `; 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()); this._scrub.addEventListener("input", () => { this._stopAnimation(); this._showFrame(Number(this._scrub.value)); }); sidebar.appendChild(this._container); } async _setEnabled(enabled) { this._enabled = enabled; this._icon.classList.toggle("active", enabled); if (!enabled) { this._stopAnimation(); this._removeLayers(); this._frames = []; this._updateFrameUI(); this._renderForecast(null); return; } this._icon.disabled = true; // 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 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._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) => `
${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() { 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() { // 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]; 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; }