Switch to vector maps (MapLibre GL JS + PMTiles) and add RainViewer radar

Replace Leaflet/raster tiles with MapLibre GL JS: an online OpenFreeMap
style and an offline style built from a local PMTiles archive via
pmtiles.js and protomaps-themes-base (no glyphs/labels offline by
design, see docs/architecture.adoc). Frontend libraries are vendored
locally rather than loaded from a CDN so the app keeps working with no
internet access.

Also add an animated RainViewer precipitation radar overlay (toggle,
play/pause, scrub slider), matching the default time window of the
weather-radar-card Home Assistant integration.
This commit is contained in:
randy committed 2026-08-25 01:18:36 +02:00
1 parent 7329497e95
commit 5afb130c45
19 files changed
+1378 -14

No files matched your search

+203
View File
@@ -0,0 +1,203 @@
const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json";
// 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 &copy; <a href="https://www.rainviewer.com" target="_blank">RainViewer</a>';
// A MapLibre IControl: a "Radar" checkbox that fetches RainViewer's recent
// precipitation frames on demand and animates them as a raster overlay, with
// play/pause and a scrub slider. 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();
});
}
onAdd() {
this._container = document.createElement("div");
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group radar-control";
this._container.innerHTML = `
<label class="radar-control__toggle">
<input type="checkbox" class="radar-control__checkbox">
Radar
</label>
<div class="radar-control__playback" hidden>
<button type="button" class="radar-control__play" aria-label="Play">&#9654;</button>
<input type="range" class="radar-control__scrub" min="0" max="0" step="1" value="0">
<span class="radar-control__time"></span>
</div>
`;
this._checkbox = this._container.querySelector(".radar-control__checkbox");
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._checkbox.addEventListener("change", () => this._setEnabled(this._checkbox.checked));
this._playButton.addEventListener("click", () => this._togglePlayback());
this._scrub.addEventListener("input", () => {
this._stopAnimation();
this._showFrame(Number(this._scrub.value));
});
return this._container;
}
onRemove() {
this._stopAnimation();
this._removeLayers();
this._container.remove();
}
async _setEnabled(enabled) {
this._enabled = enabled;
if (!enabled) {
this._stopAnimation();
this._removeLayers();
this._frames = [];
this._updateFrameUI();
return;
}
this._checkbox.disabled = true;
let frames;
try {
frames = await fetchFrames();
} catch (err) {
this._enabled = false;
this._checkbox.checked = false;
this._checkbox.disabled = false;
return;
}
this._checkbox.disabled = false;
// The checkbox may have been switched off again while the fetch was in
// flight.
if (!this._enabled || !frames.length) return;
this._frames = frames;
this._frameIndex = frames.length - 1;
this._addLayers();
this._updateFrameUI();
this._startAnimation();
}
_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 }));
}
function firstSymbolLayerId(map) {
const layers = map.getStyle()?.layers || [];
const symbolLayer = layers.find((layer) => layer.type === "symbol");
return symbolLayer ? symbolLayer.id : undefined;
}