import {throttle} from '../util/throttle.ts'; import {LngLat} from '../geo/lng_lat.ts'; import type {Map} from './map.ts'; /** * Adds the map's position to its page's location hash. * Passed as an option to the map object. * * @group Markers and Controls */ export class Hash { _map: Map; _hashName: string; constructor(hashName?: string | null) { this._hashName = hashName && encodeURIComponent(hashName); } /** * Map element to listen for coordinate changes * * @param map - The map object */ addTo(map: Map): this { this._map = map; addEventListener('hashchange', this._onHashChange, false); this._map.on('moveend', this._updateHash); return this; } /** * Removes hash */ remove(): this { removeEventListener('hashchange', this._onHashChange, false); this._map.off('moveend', this._updateHash); clearTimeout(this._updateHash()); this._removeHash(); delete this._map; return this; } getHashString(mapFeedback?: boolean): string { const center = this._map.getCenter(), zoom = Math.round(this._map.getZoom() * 100) / 100, // derived from equation: 512px * 2^z / 360 / 10^d < 0.5px precision = Math.ceil((zoom * Math.LN2 + Math.log(512 / 360 / 0.5)) / Math.LN10), m = Math.pow(10, precision), lng = Math.round(center.lng * m) / m, lat = Math.round(center.lat * m) / m, bearing = this._map.getBearing(), pitch = this._map.getPitch(); let hash = ''; if (mapFeedback) { // new map feedback site has some constraints that don't allow // us to use the same hash format as we do for the Map hash option. hash += `/${lng}/${lat}/${zoom}`; } else { hash += `${zoom}/${lat}/${lng}`; } if (bearing || pitch) hash += (`/${Math.round(bearing * 10) / 10}`); if (pitch) hash += (`/${Math.round(pitch)}`); if (this._hashName) { const params = this._getHashParams(); params.set(this._hashName, hash); return `#${decodeURIComponent(params.toString()).replace(/=&/g, '&').replace(/=$/g, '')}`; } return `#${hash}`; } _getHashParams = (): URLSearchParams => { return new URLSearchParams(window.location.hash.replace('#', '')); }; _getCurrentHash = (): string[] => { const params = this._getHashParams(); if (this._hashName) { return (params.get(this._hashName) || '').split('/'); } // For unnamed hashes, get the first key const hash = [...params.keys()][0] ?? ''; return hash.split('/'); }; _onHashChange = (): boolean => { const hash = this._getCurrentHash(); if (!this._isValidHash(hash)) { return false; } const bearing = this._map.dragRotate.isEnabled() && this._map.touchZoomRotate.isEnabled() ? +(hash[3] || 0) : this._map.getBearing(); this._map.jumpTo({ center: [+hash[2], +hash[1]], zoom: +hash[0], bearing, pitch: +(hash[4] || 0) }); return true; }; _updateHashUnthrottled = (): void => { const location = window.location.href.replace(/(#.*)?$/, this.getHashString()); window.history.replaceState(window.history.state, null, location); }; _removeHash = (): void => { const params = this._getHashParams(); if (this._hashName) { params.delete(this._hashName); } else { // For unnamed hash (#zoom/lat/lng&other=params), remove first entry const keys = Array.from(params.keys()); if (keys.length > 0) { params.delete(keys[0]); } } const newHash = decodeURIComponent(params.toString()).replace(/=&/g, '&').replace(/=$/g, ''); const replaceString = newHash ? `#${newHash}` : ''; let location = window.location.href.replace(/(#.+)?$/, replaceString); location = location.replace('&&', '&'); window.history.replaceState(window.history.state, null, location); }; /** * Mobile Safari doesn't allow updating the hash more than 100 times per 30 seconds. */ _updateHash: () => ReturnType = throttle(this._updateHashUnthrottled, 30 * 1000 / 100); _isValidHash(hash: string[]): boolean { if (hash.length < 3 || hash.some(h => isNaN(+h))) { return false; } // LngLat() throws error if latitude is out of range, and it's valid if it succeeds. try { new LngLat(+hash[2], +hash[1]); } catch { return false; } const zoom = +hash[0]; const bearing = +(hash[3] || 0); const pitch = +(hash[4] || 0); return zoom >= this._map.getMinZoom() && zoom <= this._map.getMaxZoom() && bearing >= -180 && bearing <= 180 && pitch >= this._map.getMinPitch() && pitch <= this._map.getMaxPitch(); }; }