diff --git a/docs/architecture.adoc b/docs/architecture.adoc index 4fc9f68..fdfa8ff 100644 --- a/docs/architecture.adoc +++ b/docs/architecture.adoc @@ -229,20 +229,28 @@ may not even read the same way there. drop-in — but sourced differently. This app has its own GNSS receiver (gpsd, possibly `bin/gnss_simulator.rb`'s simulated one — see `docs/development.adoc`/`docs/deployment.adoc`), and "find my -location" should mean that receiver first, not a device's separate -(and often less accurate, e.g. wifi-based) location service, when the -receiver actually has a fix. Clicking it checks -`GET /api/gnss/position` once: if `available`, it polls that -endpoint (same 1.5s cadence as `GnssControl`'s own sidebar polling) -and shows the fix with the same `.gnss-marker` styling that control -uses; if not, it falls back to the browser's own -`navigator.geolocation.watchPosition`, GeolocateControl's own -mechanism. Either way, only the *first* fix recenters the map — -later updates move the marker without fighting a pan/zoom the visitor -made in the meantime, unlike GeolocateControl's own default of -recentering on every update. Clicking again stops tracking (clears -the poll timer or browser watch, whichever was active) and removes -the marker. +location" should mean that receiver, not a device's separate (and +often less accurate, e.g. wifi-based) location service — but only +once it's actually turned on, via the sidebar's "GNSS" icon +(`GnssControl`, `public/js/gnss.js`), not just whenever a receiver +happens to have a fix. `GnssControl` itself now recenters the map on +every fix while enabled (`flyTo` for the first one, `easeTo` — a +plainer follow, no zoom swoop — for each update after), the same way +LocateControl's own browser-geolocation fallback follows below; +clicking Locate while GNSS is already on just jumps to +`GnssControl#getPosition()`'s current fix for the same instant +feedback the button has always given, reusing its existing +`.gnss-marker` rather than placing a second one — GNSS following the +map is `GnssControl`'s job for as long as it's enabled, regardless of +whether Locate was ever clicked. Both controls are constructed +together in `map.js` (`GnssControl` first, `LocateControl` holding +that same instance) specifically so `LocateControl` can call +`isEnabled()`/`getPosition()` on it. Only when GNSS is off does +Locate fall back to the browser's own +`navigator.geolocation.watchPosition` — GeolocateControl's own +mechanism — with its own toggle/marker/recentering-per-fix, since +that's the one case this control does own the tracking itself; +clicking again in that case stops the watch and removes the marker. === Debug inspector (Alt+click) @@ -665,9 +673,10 @@ toggling is a cheap re-render of just that strip. A "GNSS" map control (`public/js/gnss.js`) shows the fix from a local GNSS receiver (a real one, plugged into the machine running the -server — distinct from `NavigationControl`'s browser -`navigator.geolocation`, which the OS may or may not derive from such -a receiver at all). +server, or `bin/gnss_simulator.rb`'s simulated one — distinct from +`LocateControl`'s browser-geolocation fallback, `navigator.geolocation`, +which the OS may or may not derive from such a receiver at all — +see "Finding your location" above). `lib/maptool/gpsd_client.rb` is a small client for https://gpsd.io[gpsd]'s JSON protocol over TCP (port 2947 by default): diff --git a/public/js/gnss.js b/public/js/gnss.js index 772a3a5..186fd14 100644 --- a/public/js/gnss.js +++ b/public/js/gnss.js @@ -74,6 +74,12 @@ export class GnssControl { 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"; @@ -82,8 +88,10 @@ export class GnssControl { // 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 }); } } @@ -93,6 +101,21 @@ export class GnssControl { 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 }) { diff --git a/public/js/locate.js b/public/js/locate.js index 4f13ab5..ff382aa 100644 --- a/public/js/locate.js +++ b/public/js/locate.js @@ -1,24 +1,24 @@ import { Marker } from "/vendor/maplibre-gl/maplibre-gl.mjs"; -const GNSS_URL = "/api/gnss/position"; -const POLL_INTERVAL_MS = 1500; // matches gnss.js's own polling cadence - // Replaces MapLibre's own GeolocateControl (browser Geolocation API only) -// with one that prefers this app's own GNSS receiver (gpsd, via -// /api/gnss/position) when it has a fix, falling back to the browser's own -// geolocation otherwise — "find my location" should mean this app's own -// receiver first, not a phone/laptop's separate (and often less accurate, -// e.g. wifi-based) location service, when both are available. +// with one that prefers this app's own GNSS receiver when it's turned on +// (the sidebar "GNSS" icon, GnssControl) over a phone/laptop's separate +// (and often less accurate, e.g. wifi-based) location service. GNSS itself +// is only ever used here when explicitly enabled via that sidebar +// control — not just whenever a receiver happens to have a fix — so this +// doesn't do any polling of its own for that case; GnssControl already +// does, and already keeps the map following it once enabled, the same way +// this control's own browser-geolocation fallback follows below. // // Reuses MapLibre's own geolocate button styling (same classes/icon // GeolocateControl itself uses, maplibre-gl.css) rather than a new icon — // visually a drop-in replacement, just sourced differently. export class LocateControl { - constructor() { + constructor(gnssControl) { + this._gnssControl = gnssControl; this._active = false; this._marker = null; this._watchId = null; - this._pollTimer = null; } onAdd(map) { @@ -32,7 +32,7 @@ export class LocateControl { icon.className = "maplibregl-ctrl-icon"; icon.setAttribute("aria-hidden", "true"); this._button.appendChild(icon); - this._button.addEventListener("click", () => this._toggle()); + this._button.addEventListener("click", () => this._click()); this._container = document.createElement("div"); this._container.className = "maplibregl-ctrl maplibregl-ctrl-group"; @@ -49,7 +49,21 @@ export class LocateControl { return "top-left"; } - _toggle() { + _click() { + if (this._gnssControl.isEnabled()) { + // GNSS already follows the map on its own once enabled (GnssControl) + // and has no separate "off" state to toggle from here — just jump to + // its current fix now, for the same instant feedback clicking this + // button has always given. + const position = this._gnssControl.getPosition(); + if (position) { + this._map.flyTo({ center: position, zoom: Math.max(this._map.getZoom(), 15) }); + } else { + window.alert("Waiting for a GNSS fix…"); + } + return; + } + if (this._active) { this._stop(); } else { @@ -57,46 +71,9 @@ export class LocateControl { } } - async _start() { + _start() { this._active = true; this._button.classList.add("maplibregl-ctrl-geolocate-active"); - - let data; - try { - const response = await fetch(GNSS_URL); - data = await response.json(); - } catch (err) { - data = { available: false }; - } - - if (data.available) { - this._trackGnss(); - } else { - this._trackBrowser(); - } - } - - // Keeps the marker updated on every poll, but only recenters the map on - // the very first fix — GeolocateControl's own default behavior keeps - // recentering on every update too, but replicating its "stop fighting - // the user once they've panned away" state machine isn't worth it here; - // once is enough for what this button is for. - _trackGnss() { - const poll = async () => { - let data; - try { - const response = await fetch(GNSS_URL); - data = await response.json(); - } catch (err) { - return; // transient — try again next poll rather than falling back mid-track - } - if (data.available) this._setPosition(data.lon, data.lat); - }; - poll(); - this._pollTimer = setInterval(poll, POLL_INTERVAL_MS); - } - - _trackBrowser() { this._watchId = navigator.geolocation.watchPosition( (position) => this._setPosition(position.coords.longitude, position.coords.latitude), () => { @@ -107,6 +84,11 @@ export class LocateControl { ); } + // Recenters on every fix, following the position the way GeolocateControl + // itself does while tracking — 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 pan/zoom-preserving follow + // rather than repeating that same swoop on every update. _setPosition(lon, lat) { if (!this._marker) { const el = document.createElement("div"); @@ -115,16 +97,13 @@ export class LocateControl { this._map.flyTo({ center: [lon, lat], zoom: Math.max(this._map.getZoom(), 15) }); } else { this._marker.setLngLat([lon, lat]); + this._map.easeTo({ center: [lon, lat] }); } } _stop() { this._active = false; this._button.classList.remove("maplibregl-ctrl-geolocate-active"); - if (this._pollTimer) { - clearInterval(this._pollTimer); - this._pollTimer = null; - } if (this._watchId !== null) { navigator.geolocation.clearWatch(this._watchId); this._watchId = null; diff --git a/public/js/map.js b/public/js/map.js index 872bf4f..ea34d07 100644 --- a/public/js/map.js +++ b/public/js/map.js @@ -360,12 +360,18 @@ map.addControl(new HomeButton({ bounds: SLOVAKIA_BOUNDS }), "top-left"); map.addControl(new NavigationControl({ showCompass: false }), "top-left"); map.addControl(new ZoomLevelControl(), "top-left"); map.addControl(new MeasureControl(), "top-left"); +// Constructed here (rather than down where it's mounted, alongside the +// other sidebar controls) so LocateControl can hold the same instance — +// its constructor only touches `map`, mount() (which needs the sidebar +// element) can still happen later. +const gnssControl = new GnssControl(map); // Not MapLibre's own GeolocateControl (browser Geolocation API only) — // this app has its own GNSS receiver (gpsd, possibly a simulated one, see -// bin/gnss_simulator.rb) and "find my location" should prefer that over a -// device's separate, often less accurate location service when it has a -// fix. See locate.js. -map.addControl(new LocateControl(), "top-left"); +// bin/gnss_simulator.rb), and "find my location" should prefer that over a +// device's separate, often less accurate location service, but only once +// the "GNSS" sidebar icon actually turns it on — not just whenever a +// receiver happens to have a fix. See locate.js. +map.addControl(new LocateControl(gnssControl), "top-left"); // A small inset map showing where the current view sits in context — NPS's // own overview plugin (docs/nps-resources.adoc's "overview" entry, still on @@ -398,7 +404,7 @@ const profilePanel = new TrackProfilePanel(map); profilePanel.mount(); new GpxPanelControl(map, profilePanel).mount(sidebar); new RadarControl(map).mount(sidebar); -new GnssControl(map).mount(sidebar); +gnssControl.mount(sidebar); new LiveTrackPanelControl(map).mount(sidebar); function applyStyle(name) {