Files
maptool/public/js/gnss.js
T
randy d9879bad60
CI / check (push) Successful in 47s
CI / deploy (push) Successful in 18s
Switch to GNSS immediately if it turns on mid-track
If Locate was already tracking via the browser-geolocation fallback
(GNSS was off when clicked) and GNSS then gets turned on via the
sidebar, it kept using the browser position until the button was
clicked again -- the browser's own (often less accurate) position had
no business still being used once the app's own receiver had a fix.

GnssControl#onEnabledChange (a small internal callback list) notifies
LocateControl the moment the sidebar toggle changes; if it's actively
tracking via the browser at that point, it stops that and jumps to
GNSS's position instead.

Caught a real bug while verifying this: the automatic handoff reused
the same jump-to-GNSS helper a deliberate click uses, which alerts if
GNSS has no fix yet -- but GNSS only just turned on and almost
certainly hasn't gotten its first poll response back yet at the exact
moment the handoff fires, so this popped a blocking window.alert()
(which halts the whole page until dismissed) on every single enable.
A test hung on exactly this before I noticed. Fixed with an
alertIfNoFix option, true only for the deliberate-click path.

Verified end to end (simulating the "already tracking" state directly
since headless geolocation isn't reliable enough to trust as the
trigger): toggling GNSS mid-track removes the blue browser marker,
shows the green GNSS one once its poll lands, and no dialog appears.
2026-09-08 08:50:43 +02:00

136 lines
4.6 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;
this._enabledListeners = [];
}
// Read by LocateControl: if it's mid-tracking on the browser-geolocation
// fallback when GNSS turns on, GNSS should take over there and then, not
// only the next time the button happens to get clicked — the browser's
// own (often less accurate) position has no business still being used
// once the app's own receiver has a fix.
onEnabledChange(callback) {
this._enabledListeners.push(callback);
}
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 = "";
}
this._enabledListeners.forEach((callback) => callback(enabled));
}
_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];
// Recenters on every fix while enabled — this is the app's own
// receiver, so "GNSS on" means "follow it", the same way LocateControl
// (public/js/locate.js) follows it when GNSS is what it's using too.
// flyTo (with its zoom swoop) only for the first fix, since that's the
// one jumping from wherever the map already was; easeTo for later
// updates, a plain follow rather than repeating that swoop every poll.
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);
this._map.flyTo({ center: lngLat, zoom: Math.max(this._map.getZoom(), 15) });
} else {
this._marker.setLngLat(lngLat);
this._map.easeTo({ center: lngLat });
}
}
_removeMarker() {
if (this._marker) {
this._marker.remove();
this._marker = null;
}
}
// Read by LocateControl: GNSS is only ever used for "find my location"
// when explicitly turned on here — clicking Locate while this is off
// falls back to the browser's own geolocation instead, rather than
// silently switching sources on whatever GET /api/gnss/position happens
// to report at that moment.
isEnabled() {
return this._enabled;
}
// The current fix, if any — null both when disabled and when enabled
// but not fixed yet (no marker placed either way).
getPosition() {
return this._marker ? this._marker.getLngLat() : null;
}
}
function fixLabel({ mode, eph }) {
const kind = mode === 3 ? "3D" : mode === 2 ? "2D" : "no fix";
return eph ? `${kind} (±${Math.round(eph)}m)` : kind;
}