From 6c7bd20a143bcfd59acacf152295727247b46641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Corbin?= Date: Wed, 16 Oct 2019 20:24:00 +0200 Subject: [PATCH] Allow customizable hash fragment for map hash #8596 (#8603) --- src/ui/hash.js | 46 ++++++++++++-- src/ui/map.js | 10 ++- test/unit/ui/hash.test.js | 129 +++++++++++++++++++++++++++++++++++++- 3 files changed, 174 insertions(+), 11 deletions(-) diff --git a/src/ui/hash.js b/src/ui/hash.js index eb49227496f..b375c6b0226 100644 --- a/src/ui/hash.js +++ b/src/ui/hash.js @@ -15,9 +15,12 @@ import type Map from './map'; class Hash { _map: Map; _updateHash: () => ?TimeoutID; + _hashName: ?string; - constructor() { + constructor(hashName: ?string) { + this._hashName = hashName && encodeURIComponent(hashName); bindAll([ + '_getCurrentHash', '_onHashChange', '_updateHash' ], this); @@ -67,19 +70,50 @@ class 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}`; + hash += `/${lng}/${lat}/${zoom}`; } else { - hash += `#${zoom}/${lat}/${lng}`; + hash += `${zoom}/${lat}/${lng}`; } if (bearing || pitch) hash += (`/${Math.round(bearing * 10) / 10}`); if (pitch) hash += (`/${Math.round(pitch)}`); - return hash; + + if (this._hashName) { + const hashName = this._hashName; + let found = false; + const parts = window.location.hash.slice(1).split('&').map(part => { + const key = part.split('=')[0]; + if (key === hashName) { + found = true; + return `${key}=${hash}`; + } + return part; + }).filter(a => a); + if (!found) { + parts.push(`${hashName}=${hash}`); + } + return `#${parts.join('&')}`; + } + + return `#${hash}`; + } + + _getCurrentHash() { + // Get the current hash from location, stripped from its number sign + const hash = window.location.hash.replace('#', ''); + if (this._hashName) { + // Split the parameter-styled hash into parts and find the value we need + const keyval = hash.split('&').map( + part => part.split('=') + ).find(part => part[0] === this._hashName); + return (keyval ? keyval[1] || '' : '').split('/'); + } + return hash.split('/'); } _onHashChange() { - const loc = window.location.hash.replace('#', '').split('/'); - if (loc.length >= 3) { + const loc = this._getCurrentHash(); + if (loc.length >= 3 && !loc.some(v => isNaN(v))) { this._map.jumpTo({ center: [+loc[2], +loc[1]], zoom: +loc[0], diff --git a/src/ui/map.js b/src/ui/map.js index e803bbbb65c..f758fd08d07 100755 --- a/src/ui/map.js +++ b/src/ui/map.js @@ -65,7 +65,7 @@ type IControl = { /* eslint-enable no-use-before-define */ type MapOptions = { - hash?: boolean, + hash?: boolean | string, interactive?: boolean, container: HTMLElement | string, bearingSnap?: number, @@ -172,8 +172,11 @@ const defaultOptions = { * Tilesets hosted with Mapbox can be style-optimized if you append `?optimize=true` to the end of your style URL, like `mapbox://styles/mapbox/streets-v9?optimize=true`. * Learn more about style-optimized vector tiles in our [API documentation](https://www.mapbox.com/api-documentation/maps/#retrieve-tiles). * - * @param {boolean} [options.hash=false] If `true`, the map's position (zoom, center latitude, center longitude, bearing, and pitch) will be synced with the hash fragment of the page's URL. + * @param {(boolean|string)} [options.hash=false] If `true`, the map's position (zoom, center latitude, center longitude, bearing, and pitch) will be synced with the hash fragment of the page's URL. * For example, `http://path/to/my/page.html#2.59/39.26/53.07/-24.1/60`. + * An additional string may optionally be provided to indicate a parameter-styled hash, + * e.g. http://path/to/my/page.html#map=2.59/39.26/53.07/-24.1/60&foo=bar, where foo + * is a custom parameter and bar is an arbitrary hash distinct from the map hash. * @param {boolean} [options.interactive=true] If `false`, no mouse, touch, or keyboard listeners will be attached to the map, so it will not respond to interaction. * @param {number} [options.bearingSnap=7] The threshold, measured in degrees, that determines when the map's * bearing will snap to north. For example, with a `bearingSnap` of 7, if the user rotates @@ -389,7 +392,8 @@ class Map extends Camera { bindHandlers(this, options); - this._hash = options.hash && (new Hash()).addTo(this); + const hashName = (typeof options.hash === 'string' && options.hash) || undefined; + this._hash = options.hash && (new Hash(hashName)).addTo(this); // don't set position from options if set through hash if (!this._hash || !this._hash._onHashChange()) { this.jumpTo({ diff --git a/test/unit/ui/hash.test.js b/test/unit/ui/hash.test.js index 6cbe190a7ae..e81c1d14d33 100644 --- a/test/unit/ui/hash.test.js +++ b/test/unit/ui/hash.test.js @@ -4,8 +4,8 @@ import window from '../../../src/util/window'; import {createMap as globalCreateMap} from '../../util'; test('hash', (t) => { - function createHash() { - const hash = new Hash(); + function createHash(name) { + const hash = new Hash(name); hash._updateHash = hash._updateHashUnthrottled.bind(hash); return hash; } @@ -67,6 +67,14 @@ test('hash', (t) => { t.equal(map.getBearing(), 30); t.equal(map.getPitch(), 60); + window.location.hash = '#4/wrongly/formed/hash'; + + t.false(hash._onHashChange()); + + window.location.hash = '#map=10/3.00/-1.00&foo=bar'; + + t.false(hash._onHashChange()); + window.location.hash = ''; t.end(); @@ -100,6 +108,82 @@ test('hash', (t) => { t.end(); }); + t.test('#_onHashChange named', (t) => { + const map = createMap(t); + const hash = createHash('map') + .addTo(map); + + window.location.hash = '#map=10/3.00/-1.00&foo=bar'; + + hash._onHashChange(); + + t.equal(map.getCenter().lng, -1); + t.equal(map.getCenter().lat, 3); + t.equal(map.getZoom(), 10); + t.equal(map.getBearing(), 0); + t.equal(map.getPitch(), 0); + + window.location.hash = '#map&foo=bar'; + + t.false(hash._onHashChange()); + + window.location.hash = '#map=4/5/baz&foo=bar'; + + t.false(hash._onHashChange()); + + window.location.hash = '#5/1.00/0.50/30/60'; + + t.false(hash._onHashChange()); + + window.location.hash = ''; + + t.end(); + }); + + t.test('#_getCurrentHash', (t) => { + const map = createMap(t); + const hash = createHash() + .addTo(map); + + window.location.hash = '#10/3.00/-1.00'; + + const currentHash = hash._getCurrentHash(); + + t.equal(currentHash[0], '10'); + t.equal(currentHash[1], '3.00'); + t.equal(currentHash[2], '-1.00'); + + window.location.hash = ''; + + t.end(); + }); + + t.test('#_getCurrentHash named', (t) => { + const map = createMap(t); + const hash = createHash('map') + .addTo(map); + + window.location.hash = '#map=10/3.00/-1.00&foo=bar'; + + let currentHash = hash._getCurrentHash(); + + t.equal(currentHash[0], '10'); + t.equal(currentHash[1], '3.00'); + t.equal(currentHash[2], '-1.00'); + + window.location.hash = '#baz&map=10/3.00/-1.00'; + + currentHash = hash._getCurrentHash(); + + t.equal(currentHash[0], '10'); + t.equal(currentHash[1], '3.00'); + t.equal(currentHash[2], '-1.00'); + + window.location.hash = ''; + + t.end(); + }); + t.test('#_updateHash', (t) => { function getHash() { return window.location.hash.split('/'); @@ -145,6 +229,47 @@ test('hash', (t) => { t.equal(newHash[3], '135'); t.equal(newHash[4], '60'); + window.location.hash = ''; + + t.end(); + }); + + t.test('#_updateHash named', (t) => { + const map = createMap(t); + createHash('map') + .addTo(map); + + t.notok(window.location.hash); + + map.setZoom(3); + map.setCenter([1.0, 2.0]); + + t.ok(window.location.hash); + + t.equal(window.location.hash, '#map=3/2/1'); + + map.setPitch(60); + + t.equal(window.location.hash, '#map=3/2/1/0/60'); + + map.setBearing(135); + + t.equal(window.location.hash, '#map=3/2/1/135/60'); + + window.location.hash += '&foo=bar'; + + map.setZoom(7); + + t.equal(window.location.hash, '#map=7/2/1/135/60&foo=bar'); + + window.location.hash = '#baz&map=7/2/1/135/60&foo=bar'; + + map.setCenter([2.0, 1.0]); + + t.equal(window.location.hash, '#baz&map=7/1/2/135/60&foo=bar'); + + window.location.hash = ''; + t.end(); });