Redesign UI as an OsmAnd-style sidebar, add live GNSS position via gpsd

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.
This commit is contained in:
randy committed 2026-08-25 19:39:13 +02:00
1 parent 008e034932
commit 87df90f75c
14 files changed
+939 -200

No files matched your search

+27 -31
View File
@@ -1,3 +1,5 @@
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
@@ -9,10 +11,10 @@ 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.
// 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;
@@ -29,45 +31,39 @@ export class RadarControl {
});
}
onAdd() {
mount(sidebar) {
this._container = document.createElement("div");
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group radar-control";
this._container.className = "sidebar-item";
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>
<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">&#9654;</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._checkbox = this._container.querySelector(".radar-control__checkbox");
this._playback = this._container.querySelector(".radar-control__playback");
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._checkbox.addEventListener("change", () => this._setEnabled(this._checkbox.checked));
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));
});
return this._container;
}
onRemove() {
this._stopAnimation();
this._removeLayers();
this._container.remove();
sidebar.appendChild(this._container);
}
async _setEnabled(enabled) {
this._enabled = enabled;
this._icon.classList.toggle("active", enabled);
if (!enabled) {
this._stopAnimation();
this._removeLayers();
@@ -76,20 +72,20 @@ export class RadarControl {
return;
}
this._checkbox.disabled = true;
this._icon.disabled = true;
let frames;
try {
frames = await fetchFrames();
} catch (err) {
this._enabled = false;
this._checkbox.checked = false;
this._checkbox.disabled = false;
this._icon.classList.remove("active");
this._icon.disabled = false;
return;
}
this._checkbox.disabled = false;
this._icon.disabled = false;
// The checkbox may have been switched off again while the fetch was in
// flight.
// The icon may have been clicked again (disabling) while the fetch was
// in flight.
if (!this._enabled || !frames.length) return;
this._frames = frames;
@@ -168,7 +164,7 @@ export class RadarControl {
}
_updateFrameUI() {
this._playback.hidden = this._frames.length === 0;
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];