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:
1 parent
008e034932
commit
87df90f75c
14 files changed
+939
-200
No files matched your search
@@ -0,0 +1,101 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user