Prefer this app's own GNSS receiver for "find my location"
CI / check (push) Successful in 41s
CI / deploy (push) Successful in 22s

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.
This commit is contained in:
randy committed 2026-09-08 00:46:14 +02:00
1 parent 64a85afe6a
commit aec665179f
3 files changed
+176 -17

No files matched your search

+31 -7
View File
@@ -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
+137
View File
@@ -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;
}
}
}
+8 -10
View File
@@ -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