Files
maptool/public/js/gnss.js
T
randy 87df90f75c 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.
2026-08-25 19:39:13 +02:00

102 lines
3.0 KiB
JavaScript

import { Marker } from "/vendor/maplibre-gl/maplibre-gl.mjs";
import { GNSS_ICON } from "/js/icons.js";
const POSITION_URL = "/api/gnss/position";
const POLL_INTERVAL_MS = 1500;
// A sidebar icon that polls /api/gnss/position (backed server-side by a
// gpsd connection, see lib/maptool/gpsd_client.rb) and shows the fix as a
// marker, with fix status in a flyout. Polling rather than a push channel
// is deliberate — see the comment in app.rb next to GNSS_LATEST — and fits
// gpsd's own ~1Hz report rate fine.
export class GnssControl {
constructor(map) {
this._map = map;
this._marker = null;
this._timer = null;
this._enabled = false;
}
mount(sidebar) {
this._container = document.createElement("div");
this._container.className = "sidebar-item";
this._container.innerHTML = `
<button type="button" class="sidebar-icon" title="GNSS" aria-label="GNSS">${GNSS_ICON}</button>
<div class="sidebar-flyout" hidden>
<span class="gnss-control__status"></span>
</div>
`;
this._icon = this._container.querySelector(".sidebar-icon");
this._flyout = this._container.querySelector(".sidebar-flyout");
this._status = this._container.querySelector(".gnss-control__status");
this._icon.addEventListener("click", () => this._setEnabled(!this._enabled));
sidebar.appendChild(this._container);
}
_setEnabled(enabled) {
this._enabled = enabled;
this._icon.classList.toggle("active", enabled);
this._flyout.hidden = !enabled;
if (enabled) {
this._poll();
this._timer = setInterval(() => this._poll(), POLL_INTERVAL_MS);
} else {
this._stopPolling();
this._removeMarker();
this._status.textContent = "";
}
}
_stopPolling() {
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
}
async _poll() {
let data;
try {
const response = await fetch(POSITION_URL);
data = await response.json();
} catch (err) {
this._status.textContent = "unreachable";
return;
}
if (!data.available) {
this._removeMarker();
this._status.textContent = "no fix";
return;
}
this._status.textContent = fixLabel(data);
const lngLat = [data.lon, data.lat];
if (!this._marker) {
const el = document.createElement("div");
el.className = "gnss-marker";
// MapLibre markers are plain positioned DOM elements, not tied to the
// style's sources/layers, so — unlike the radar/GPX layers — this
// survives map.setStyle() (switching Online/Offline) with no extra
// re-add handling needed.
this._marker = new Marker({ element: el }).setLngLat(lngLat).addTo(this._map);
} else {
this._marker.setLngLat(lngLat);
}
}
_removeMarker() {
if (this._marker) {
this._marker.remove();
this._marker = null;
}
}
}
function fixLabel({ mode, eph }) {
const kind = mode === 3 ? "3D" : mode === 2 ? "2D" : "no fix";
return eph ? `${kind} (±${Math.round(eph)}m)` : kind;
}