import { Marker } from "/vendor/maplibre-gl/maplibre-gl.mjs"; import { GNSS_ICON } from "/js/icons.js"; import { isLoggedIn } from "/js/auth.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 = `
`; 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)); // GET /api/gnss/position itself requires login too (app.rb) — hidden // here rather than shown and failing on click, same reasoning as // gpx.js's write-action controls: it reveals where the receiver // physically is, not something to leave open to anyone with the URL. if (!isLoggedIn()) this._container.hidden = true; 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; }