Files
randy 141b7a08ce Show accuracy circle around the browser-location fallback marker
Re-adds the gray radius MapLibre's own GeolocateControl used to draw,
using the same element/class and pixel-radius calculation.
2026-09-08 14:16:16 +02:00

187 lines
7.5 KiB
JavaScript

import { Marker } from "/vendor/maplibre-gl/maplibre-gl.mjs";
// Replaces MapLibre's own GeolocateControl (browser Geolocation API only)
// 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(gnssControl) {
this._gnssControl = gnssControl;
this._active = false;
this._marker = null;
this._circleMarker = null;
this._accuracy = null;
this._watchId = null;
this._onMapChange = () => this._updateCircleRadius();
}
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._click());
this._container = document.createElement("div");
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group";
this._container.appendChild(this._button);
// GNSS turning on mid-track (this control's own browser-geolocation
// fallback, not GnssControl's — that one manages its own tracking)
// should switch over immediately, not just the next time this button
// gets clicked; the browser's own position has no business still
// being used once the app's own receiver has a fix. No alertIfNoFix
// here (unlike a deliberate click, below) — GnssControl only just
// turned on and almost certainly hasn't gotten its first fix back yet
// (its own poll is async, still in flight at the moment this fires),
// so this would otherwise pop a blocking "waiting for a fix" dialog on
// every single enable; GnssControl's own next poll will show/recenter
// once it actually has something, same as it would either way.
this._gnssControl.onEnabledChange((enabled) => {
if (enabled && this._active) {
this._stop();
this._jumpToGnss();
}
});
return this._container;
}
onRemove() {
this._stop();
this._container.remove();
}
getDefaultPosition() {
return "top-left";
}
_click() {
if (this._gnssControl.isEnabled()) {
this._jumpToGnss({ alertIfNoFix: true });
return;
}
if (this._active) {
this._stop();
} else {
this._start();
}
}
// 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, for the same instant feedback clicking this button has
// always given. alertIfNoFix is opt-in (a deliberate click wants to know
// why nothing happened; the automatic mid-track handoff above doesn't —
// see its own comment) since a JS alert() blocks the whole page until
// dismissed, not something to pop up on a path that isn't a direct user
// action.
_jumpToGnss({ alertIfNoFix = false } = {}) {
const position = this._gnssControl.getPosition();
if (position) {
this._map.flyTo({ center: position, zoom: Math.max(this._map.getZoom(), 15) });
} else if (alertIfNoFix) {
window.alert("Waiting for a GNSS fix…");
}
}
_start() {
this._active = true;
this._button.classList.add("maplibregl-ctrl-geolocate-active");
this._watchId = navigator.geolocation.watchPosition(
(position) => this._setPosition(position.coords.longitude, position.coords.latitude, position.coords.accuracy),
() => {
window.alert("Could not determine your location.");
this._stop();
},
{ enableHighAccuracy: true },
);
this._map.on("zoom", this._onMapChange);
this._map.on("move", this._onMapChange);
}
// 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.
//
// MapLibre's own blue pulsing "user location" dot styling
// (.maplibregl-user-location-dot, maplibre-gl.css — the exact look
// GeolocateControl itself used here before), not GnssControl's green
// .gnss-marker — reusing that green for a browser-estimated position too
// looked like this control was somehow still using GNSS data even when
// it very much wasn't; distinct colors for distinct sources.
_setPosition(lon, lat, accuracy) {
this._accuracy = accuracy;
if (!this._marker) {
const el = document.createElement("div");
el.className = "maplibregl-user-location-dot";
this._marker = new Marker({ element: el }).setLngLat([lon, lat]).addTo(this._map);
const circleEl = document.createElement("div");
circleEl.className = "maplibregl-user-location-accuracy-circle";
this._circleMarker = new Marker({ element: circleEl, pitchAlignment: "map" }).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]);
this._circleMarker.setLngLat([lon, lat]);
this._map.easeTo({ center: [lon, lat] });
}
this._updateCircleRadius();
}
// Mirrors MapLibre's own GeolocateControl._updateCircleRadiusIfNeeded:
// measures real-world meters per pixel at the marker's position (rather
// than assuming a flat conversion, which breaks away from the equator)
// by projecting a 100px offset back to lng/lat and measuring that
// distance, then sizes the circle element to the accuracy radius in
// that many pixels.
_updateCircleRadius() {
if (!this._circleMarker || !this._accuracy) return;
const lngLat = this._marker.getLngLat();
const point = this._map.project(lngLat);
const offsetLngLat = this._map.unproject([point.x + 100, point.y]);
const metersPerPixel = lngLat.distanceTo(offsetLngLat) / 100;
const diameter = (2 * this._accuracy) / metersPerPixel;
this._circleMarker.getElement().style.width = `${diameter.toFixed(2)}px`;
this._circleMarker.getElement().style.height = `${diameter.toFixed(2)}px`;
}
_stop() {
this._active = false;
this._button.classList.remove("maplibregl-ctrl-geolocate-active");
if (this._watchId !== null) {
navigator.geolocation.clearWatch(this._watchId);
this._watchId = null;
}
this._map.off("zoom", this._onMapChange);
this._map.off("move", this._onMapChange);
if (this._marker) {
this._marker.remove();
this._marker = null;
}
if (this._circleMarker) {
this._circleMarker.remove();
this._circleMarker = null;
}
this._accuracy = null;
}
}