Restructure the map controls from MapLibre corner buttons into a left sidebar of icons with flyouts/panels (radar, GPX, GNSS), matching OsmAnd's layout more closely: - Base map and GPX selection are now full-height side panels docked next to the sidebar (not small dropdown flyouts or a right-docked panel), opening/closing exclusively via a small shared event bus (panels.js) so only one is ever open at a time. - The GPX panel's upload/new-folder controls are now icon buttons in the header instead of an always-visible dropzone and form; dragging a GPX file onto the tree, a specific folder, or straight onto the map itself all upload it. - New: Maptool::GpsdClient (lib/maptool/gpsd_client.rb) streams live GNSS fixes from a local gpsd daemon over Server-Sent Events (/api/gnss/stream) to a marker on the map (public/js/gnss.js) — one background gpsd connection shared by every browser tab. - The online map style (public/js/osmand-style.js) is now styled to resemble OsmAnd's own rendering, applied to both the online and offline (PMTiles) styles. Also adds a GeolocateControl (browser geolocation) next to the zoom controls.
200 lines
6.2 KiB
JavaScript
200 lines
6.2 KiB
JavaScript
import { RADAR_ICON } from "/js/icons.js";
|
|
|
|
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 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 = `
|
|
<button type="button" class="sidebar-icon" title="Radar" aria-label="Radar">${RADAR_ICON}</button>
|
|
<div class="sidebar-flyout" hidden>
|
|
<div class="radar-control__playback">
|
|
<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>
|
|
</div>
|
|
`;
|
|
|
|
this._icon = this._container.querySelector(".sidebar-icon");
|
|
this._flyout = this._container.querySelector(".sidebar-flyout");
|
|
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._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();
|
|
return;
|
|
}
|
|
|
|
this._icon.disabled = true;
|
|
let frames;
|
|
try {
|
|
frames = await fetchFrames();
|
|
} catch (err) {
|
|
this._enabled = false;
|
|
this._icon.classList.remove("active");
|
|
this._icon.disabled = false;
|
|
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._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._flyout.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;
|
|
}
|