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; } }