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

+45 -23
View File
@@ -6,10 +6,13 @@ import {
} from "/vendor/maplibre-gl/maplibre-gl.mjs";
import { RadarControl } from "/js/radar.js";
import { GpxPanelControl } from "/js/gpx.js";
import { GnssControl } from "/js/gnss.js";
import { onlineStyle, offlineTheme } from "/js/osmand-style.js";
import { LAYERS_ICON } from "/js/icons.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
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);
@@ -17,7 +20,7 @@ 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 theme = offlineTheme(protomaps_themes_base.namedTheme("light"));
const pmtilesUrl = `${window.location.origin}/tiles/offline.pmtiles`;
return {
@@ -33,31 +36,46 @@ function offlineStyle() {
};
}
// A sidebar icon that opens a full-height side panel (like the GPX one)
// with the Online/Offline choice — modelled after OsmAnd's "Configure map"
// panel, rather than a small dropdown flyout.
class LayerToggleControl {
constructor(onSelect) {
this._onSelect = onSelect;
}
onAdd() {
mount(sidebar) {
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);
this._container.className = "sidebar-item";
this._container.innerHTML = `<button type="button" class="sidebar-icon" title="Base map" aria-label="Base map">${LAYERS_ICON}</button>`;
this._icon = this._container.querySelector(".sidebar-icon");
this._icon.addEventListener("click", () => this._togglePanel());
this._panel = document.getElementById("basemap-panel");
this._panel
.querySelector(".basemap-panel__close")
.addEventListener("click", () => this._togglePanel(false));
this._panel.querySelectorAll(".basemap-panel__option").forEach((button) => {
button.addEventListener("click", () => {
this._onSelect(button.dataset.style);
this._togglePanel(false);
});
});
return this._container;
onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active"));
sidebar.appendChild(this._container);
}
onRemove() {
this._container.remove();
_togglePanel(force) {
const open = force === undefined ? this._panel.hidden : force;
this._panel.hidden = !open;
this._icon.classList.toggle("active", open);
if (open) notifySidePanelOpen(this._panel);
}
setActive(name) {
this._container.querySelectorAll("button").forEach((button) => {
this._panel.querySelectorAll(".basemap-panel__option").forEach((button) => {
button.classList.toggle("active", button.dataset.style === name);
});
}
@@ -65,30 +83,34 @@ class LayerToggleControl {
const map = new MaplibreMap({
container: "map",
style: ONLINE_STYLE,
style: await onlineStyle(),
center: CENTER,
zoom: ZOOM,
});
map.addControl(new NavigationControl(), "top-right");
map.addControl(new NavigationControl(), "bottom-right");
map.addControl(
new GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: true,
showAccuracyCircle: true,
}),
"top-right",
"bottom-right",
);
map.addControl(new RadarControl(map), "top-right");
const sidebar = document.getElementById("sidebar");
const layerToggle = new LayerToggleControl(applyStyle);
map.addControl(layerToggle, "top-left");
layerToggle.mount(sidebar);
layerToggle.setActive("online");
map.addControl(new GpxPanelControl(map), "top-left");
function applyStyle(name) {
new GpxPanelControl(map).mount(sidebar);
new RadarControl(map).mount(sidebar);
new GnssControl(map).mount(sidebar);
async function applyStyle(name) {
layerToggle.setActive(name);
map.setStyle(name === "offline" ? offlineStyle() : ONLINE_STYLE);
map.setStyle(name === "offline" ? offlineStyle() : await onlineStyle());
}
// If a local PMTiles file is present, prefer it as the default style — its