Files
maptool/public/js/gnss.js
T
randy 67585cd99a
CI / check (push) Successful in 48s
CI / deploy (push) Successful in 23s
Gate GNSS (turning it on and its position) behind login
GET /api/gnss/position now requires login (app.rb) -- it reveals
where the receiver physically is, not something to leave open to
anyone with the URL. GnssControl's sidebar icon is hidden entirely
when logged out (same pattern as the tree UI's write controls) rather
than shown and failing to ever get a fix.

LocateControl needs no changes: it only ever reaches GNSS through
GnssControl#isEnabled(), which can't turn true for a logged-out
visitor if the toggle is never on the page to click. Its
browser-geolocation fallback stays open to everyone -- a visitor's
own device asking for their own location isn't something to gate.

Extracted isLoggedIn() out of gpx.js into a shared public/js/auth.js
(gnss.js needed the same check) rather than duplicating it.

Verified: /api/gnss/position returns 401 logged out; the sidebar icon
is actually hidden (computed display: none, not just present-but-
unstyled -- checked properly this time after almost repeating the
same present-vs-visible test mistake the .gpx-panel__action bug
already taught).
2026-09-08 14:06:29 +02:00

143 lines
5.0 KiB
JavaScript

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 = `
<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));
// 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;
}