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:
1 parent
7329497e95
commit
5afb130c45
19 files changed
+1378
-14
No files matched your search
+91
-5
@@ -1,6 +1,92 @@
|
||||
const map = L.map("map").setView([48.1486, 17.1077], 13);
|
||||
import {
|
||||
Map as MaplibreMap,
|
||||
NavigationControl,
|
||||
addProtocol,
|
||||
} from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
||||
import { RadarControl } from "/js/radar.js";
|
||||
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
attribution: "© OpenStreetMap contributors",
|
||||
}).addTo(map);
|
||||
const CENTER = [17.1077, 48.1486];
|
||||
const ZOOM = 13;
|
||||
const ONLINE_STYLE = "https://tiles.openfreemap.org/styles/liberty";
|
||||
|
||||
const protocol = new pmtiles.Protocol();
|
||||
addProtocol("pmtiles", protocol.tile);
|
||||
|
||||
// No label layers here (see docs/architecture.adoc): rendering text needs a
|
||||
// glyphs server, which the offline style deliberately does not depend on.
|
||||
function offlineStyle() {
|
||||
const theme = protomaps_themes_base.namedTheme("light");
|
||||
const pmtilesUrl = `${window.location.origin}/tiles/offline.pmtiles`;
|
||||
|
||||
return {
|
||||
version: 8,
|
||||
sources: {
|
||||
offline: {
|
||||
type: "vector",
|
||||
url: `pmtiles://${pmtilesUrl}`,
|
||||
attribution: "© OpenStreetMap contributors",
|
||||
},
|
||||
},
|
||||
layers: protomaps_themes_base.layers("offline", theme),
|
||||
};
|
||||
}
|
||||
|
||||
class LayerToggleControl {
|
||||
constructor(onSelect) {
|
||||
this._onSelect = onSelect;
|
||||
}
|
||||
|
||||
onAdd() {
|
||||
this._container = document.createElement("div");
|
||||
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group layer-toggle";
|
||||
this._container.innerHTML = `
|
||||
<button type="button" data-style="online">Online</button>
|
||||
<button type="button" data-style="offline">Offline</button>
|
||||
`;
|
||||
this._container.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button[data-style]");
|
||||
if (button) this._onSelect(button.dataset.style);
|
||||
});
|
||||
return this._container;
|
||||
}
|
||||
|
||||
onRemove() {
|
||||
this._container.remove();
|
||||
}
|
||||
|
||||
setActive(name) {
|
||||
this._container.querySelectorAll("button").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.style === name);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const map = new MaplibreMap({
|
||||
container: "map",
|
||||
style: ONLINE_STYLE,
|
||||
center: CENTER,
|
||||
zoom: ZOOM,
|
||||
});
|
||||
|
||||
map.addControl(new NavigationControl(), "top-right");
|
||||
map.addControl(new RadarControl(map), "top-right");
|
||||
|
||||
const layerToggle = new LayerToggleControl(applyStyle);
|
||||
map.addControl(layerToggle, "top-left");
|
||||
layerToggle.setActive("online");
|
||||
|
||||
function applyStyle(name) {
|
||||
layerToggle.setActive(name);
|
||||
map.setStyle(name === "offline" ? offlineStyle() : ONLINE_STYLE);
|
||||
}
|
||||
|
||||
// If a local PMTiles file is present, prefer it as the default style — its
|
||||
// presence signals the app is meant to be used offline here.
|
||||
fetch("/api/offline-tiles")
|
||||
.then((response) => response.json())
|
||||
.then(({ available }) => {
|
||||
if (available) applyStyle("offline");
|
||||
})
|
||||
.catch(() => {
|
||||
// Backend check failed; keep the online style.
|
||||
});
|
||||
@@ -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 © <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">▶</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;
|
||||
}
|
||||
Reference in new issue
Block a user