From aec665179f996b6998e41d3477396712ae8c296e Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Tue, 8 Sep 2026 00:46:14 +0200 Subject: [PATCH] Prefer this app's own GNSS receiver for "find my location" LocateControl (public/js/locate.js) replaces MapLibre's own GeolocateControl -- same button, reusing its exact CSS/icon so it's a visual drop-in, but sourced differently. Checks GET /api/gnss/position once on click: if a fix is available (gpsd, possibly bin/gnss_simulator.rb's simulated one), tracks that instead of the browser's separate (and often less accurate) location service, which is now only a fallback. Only the first fix recenters the map; later updates just move the marker, unlike GeolocateControl's own default of recentering on every update, which would fight a pan/zoom made in the meantime. Verified end to end with the GNSS simulator running: clicking the button centered on the simulated position and dropped a marker, clicking again cleared both, no console errors. --- docs/architecture.adoc | 38 +++++++++--- public/js/locate.js | 137 +++++++++++++++++++++++++++++++++++++++++ public/js/map.js | 18 +++--- 3 files changed, 176 insertions(+), 17 deletions(-) create mode 100644 public/js/locate.js diff --git a/docs/architecture.adoc b/docs/architecture.adoc index 07d82c2..4fc9f68 100644 --- a/docs/architecture.adoc +++ b/docs/architecture.adoc @@ -166,8 +166,8 @@ sits top-left on the map. It fits to `SLOVAKIA_BOUNDS` (a country-wide bounding box) rather than the plain `CENTER`/`ZOOM` point the map itself starts at — "home" is meant as a full-country overview, not a re-centering on Bratislava. It resets to that fixed default view, not -the visitor's own location — that's `GeolocateControl`, a separate -button in the same stack (below). +the visitor's own location — that's `LocateControl`, a separate button +in the same stack (below). That Home button is the top of a small top-left stack — Home, zoom in/out (`NavigationControl`, `showCompass: false` since this map never @@ -175,11 +175,12 @@ rotates), a passive zoom-level readout (`ZoomLevelControl`, a plain div updated on the map's `zoom` event — not a button, since it has no click behavior of its own), a distance/area measuring tool (`MeasureControl`, `public/js/measure.js`), locate-me -(`GeolocateControl`), and a "Base map" button — matching the National -Park Service's own map control layout (grouped together on the map -itself, rather than split across corners or between the map and a side -panel). The last of those, `MapLayersButton` (`public/js/map.js`), is -a second entry point into the exact same Online/Offline panel +(`LocateControl`, `public/js/locate.js`), and a "Base map" button — +matching the National Park Service's own map control layout (grouped +together on the map itself, rather than split across corners or +between the map and a side panel). The last of those, +`MapLayersButton` (`public/js/map.js`), is a second entry point into +the exact same Online/Offline panel `LayerToggleControl` already manages in the page's own sidebar — both call the same `LayerToggleControl#toggle()`, so there's one source of truth for the panel's open/closed state either way. @@ -220,6 +221,29 @@ long any one leg was on its own. Area mode has no equivalent yet; a polygon's edges are its perimeter, not a path, so the same treatment may not even read the same way there. +=== Finding your location + +`LocateControl` (`public/js/locate.js`) replaces MapLibre's own +`GeolocateControl` — same button, reusing its exact CSS classes/icon +(`.maplibregl-ctrl-geolocate`) rather than a new one, so it's a visual +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. + === Debug inspector (Alt+click) Alt+click anywhere on the map opens a popup listing every rendered diff --git a/public/js/locate.js b/public/js/locate.js new file mode 100644 index 0000000..4f13ab5 --- /dev/null +++ b/public/js/locate.js @@ -0,0 +1,137 @@ +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. +// +// 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() { + this._active = false; + this._marker = null; + this._watchId = null; + this._pollTimer = null; + } + + onAdd(map) { + this._map = map; + this._button = document.createElement("button"); + this._button.type = "button"; + this._button.className = "maplibregl-ctrl-geolocate"; + this._button.title = "Find my location"; + this._button.setAttribute("aria-label", "Find my location"); + const icon = document.createElement("span"); + icon.className = "maplibregl-ctrl-icon"; + icon.setAttribute("aria-hidden", "true"); + this._button.appendChild(icon); + this._button.addEventListener("click", () => this._toggle()); + + this._container = document.createElement("div"); + this._container.className = "maplibregl-ctrl maplibregl-ctrl-group"; + this._container.appendChild(this._button); + return this._container; + } + + onRemove() { + this._stop(); + this._container.remove(); + } + + getDefaultPosition() { + return "top-left"; + } + + _toggle() { + if (this._active) { + this._stop(); + } else { + this._start(); + } + } + + async _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), + () => { + window.alert("Could not determine your location."); + this._stop(); + }, + { enableHighAccuracy: true }, + ); + } + + _setPosition(lon, lat) { + if (!this._marker) { + const el = document.createElement("div"); + el.className = "gnss-marker"; + this._marker = new Marker({ element: el }).setLngLat([lon, lat]).addTo(this._map); + this._map.flyTo({ center: [lon, lat], zoom: Math.max(this._map.getZoom(), 15) }); + } else { + this._marker.setLngLat([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; + } + if (this._marker) { + this._marker.remove(); + this._marker = null; + } + } +} diff --git a/public/js/map.js b/public/js/map.js index a8b01e4..872bf4f 100644 --- a/public/js/map.js +++ b/public/js/map.js @@ -1,7 +1,6 @@ import { Map as MaplibreMap, NavigationControl, - GeolocateControl, Popup, addProtocol, } from "/vendor/maplibre-gl/maplibre-gl.mjs"; @@ -11,6 +10,7 @@ import { GnssControl } from "/js/gnss.js"; import { LiveTrackPanelControl } from "/js/livetrack.js"; import { TrackProfilePanel } from "/js/track_profile.js"; import { MeasureControl } from "/js/measure.js"; +import { LocateControl } from "/js/locate.js"; import { LAYERS_ICON } from "/js/icons.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; import HomeButton from "/vendor/npmap-home-button/home-button.esm.js"; @@ -349,7 +349,7 @@ const map = new MaplibreMap({ // above), "current view" and "the default view" are no longer the same // thing, and a home button that resets to wherever you happened to load // the page wouldn't be a home button. This is *not* "go to my location" — -// that's GeolocateControl, below. +// that's LocateControl, below. const SLOVAKIA_BOUNDS = [ [16.83, 47.73], [22.57, 49.61], @@ -360,14 +360,12 @@ 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"); -map.addControl( - new GeolocateControl({ - positionOptions: { enableHighAccuracy: true }, - trackUserLocation: true, - showAccuracyCircle: true, - }), - "top-left", -); +// 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"); // 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