diff --git a/examples/link.html b/examples/link.html new file mode 100644 index 0000000000..28b28bff89 --- /dev/null +++ b/examples/link.html @@ -0,0 +1,12 @@ +--- +layout: example.html +title: Map Link +shortdesc: Synchronizing map state with the URL. +docs: > + The `Link` interaction allows you to synchronize the map state with the URL. + The view center, zoom level, and rotation will be reflected in the URL as you + navigate around the map. Layer visibility is also reflected in the URL. + Reloading the page restores the map view state. +tags: "link, permalink, url, query, search, params" +--- +
diff --git a/examples/link.js b/examples/link.js new file mode 100644 index 0000000000..b96c82829b --- /dev/null +++ b/examples/link.js @@ -0,0 +1,20 @@ +import Link from '../src/ol/interaction/Link.js'; +import Map from '../src/ol/Map.js'; +import OSM from '../src/ol/source/OSM.js'; +import TileLayer from '../src/ol/layer/Tile.js'; +import View from '../src/ol/View.js'; + +const map = new Map({ + layers: [ + new TileLayer({ + source: new OSM(), + }), + ], + target: 'map', + view: new View({ + center: [0, 0], + zoom: 2, + }), +}); + +map.addInteraction(new Link()); diff --git a/src/ol/PluggableMap.js b/src/ol/PluggableMap.js index 4e94f03736..3c8aa39df1 100644 --- a/src/ol/PluggableMap.js +++ b/src/ol/PluggableMap.js @@ -605,6 +605,9 @@ class PluggableMap extends BaseObject { * Clean up. */ disposeInternal() { + this.controls.clear(); + this.interactions.clear(); + this.overlays_.clear(); this.setTarget(null); super.disposeInternal(); } diff --git a/src/ol/interaction/Link.js b/src/ol/interaction/Link.js new file mode 100644 index 0000000000..af0dfeef75 --- /dev/null +++ b/src/ol/interaction/Link.js @@ -0,0 +1,359 @@ +/** + * @module ol/interaction/Link + */ +import EventType from '../events/EventType.js'; +import Interaction from './Interaction.js'; +import MapEventType from '../MapEventType.js'; +import {assign} from '../obj.js'; +import {listen, unlistenByKey} from '../events.js'; +import {toFixed} from '../math.js'; + +/** + * @param {number} number A number. + * @return {number} A number with at most 5 decimal places. + */ +function to5(number) { + return toFixed(number, 5); +} + +/** + * @param {string} string A string. + * @return {number} A number representing the string. + */ +function readNumber(string) { + return parseFloat(string); +} + +/** + * @param {number} number A number. + * @return {string} A string representing the number. + */ +function writeNumber(number) { + return to5(number).toString(); +} + +/** + * @param {number} a A number. + * @param {number} b A number. + * @return {boolean} The numbers are different. + */ +function differentNumber(a, b) { + if (isNaN(a)) { + return false; + } + return a !== readNumber(writeNumber(b)); +} + +/** + * @param {Array} a An array of two numbers. + * @param {Array} b An array of two numbers. + * @return {boolean} The arrays are different. + */ +function differentArray(a, b) { + return differentNumber(a[0], b[0]) || differentNumber(a[1], b[1]); +} + +/** + * @typedef {Object} Options + * @property {boolean|import('../View.js').AnimationOptions} [animate=true] Animate view transitions. + * @property {boolean} [replace=false] Replace the current URL without creating the new entry in browser history. + * By default, changes in the map state result in a new entry being added to the browser history. + * @property {string} [prefix=''] By default, the URL will be updated with search parameters x, y, z, and r. To + * avoid collisions with existing search parameters that your application uses, you can supply a custom prefix for + * the ones used by this interaction (e.g. 'ol:'). + */ + +/** + * @classdesc + * An interaction that synchronizes the map state with the URL. + * + * @api + */ +class Link extends Interaction { + /** + * @param {Options} [opt_options] Link options. + */ + constructor(opt_options) { + super(); + + const options = assign( + {animate: true, replace: false, prefix: ''}, + opt_options || {} + ); + + let animationOptions; + if (options.animate === true) { + animationOptions = {duration: 250}; + } else if (!options.animate) { + animationOptions = null; + } else { + animationOptions = options.animate; + } + + /** + * @type {import('../View.js').AnimationOptions|null} + * @private + */ + this.animationOptions_ = animationOptions; + + /** + * @private + * @type {boolean} + */ + this.replace_ = options.replace; + + /** + * @private + * @type {string} + */ + this.prefix_ = options.prefix; + + /** + * @private + * @type {!Array} + */ + this.listenerKeys_ = []; + + /** + * @private + * @type {boolean} + */ + this.initial_ = true; + + this.updateState_ = this.updateState_.bind(this); + } + + /** + * @private + * @param {string} name A parameter name. + * @return {string} A name with the prefix applied. + */ + getParamName_(name) { + if (!this.prefix_) { + return name; + } + return this.prefix_ + name; + } + + /** + * @private + * @param {URLSearchParams} params The search params. + * @param {string} name The unprefixed parameter name. + * @return {string|null} The parameter value. + */ + get_(params, name) { + return params.get(this.getParamName_(name)); + } + + /** + * @private + * @param {URLSearchParams} params The search params. + * @param {string} name The unprefixed parameter name. + * @param {string} value The param value. + */ + set_(params, name, value) { + params.set(this.getParamName_(name), value); + } + + /** + * @private + * @param {URLSearchParams} params The search params. + * @param {string} name The unprefixed parameter name. + */ + delete_(params, name) { + params.delete(this.getParamName_(name)); + } + + /** + * @param {import("../PluggableMap.js").default|null} map Map. + */ + setMap(map) { + const oldMap = this.getMap(); + super.setMap(map); + if (map === oldMap) { + return; + } + if (oldMap) { + this.unregisterListeners_(oldMap); + } + if (map) { + this.initial_ = true; + this.updateState_(); + this.registerListeners_(map); + } + } + + /** + * @param {import("../PluggableMap.js").default} map Map. + * @private + */ + registerListeners_(map) { + this.listenerKeys_.push( + listen(map, MapEventType.MOVEEND, this.updateUrl_, this), + listen(map.getLayerGroup(), EventType.CHANGE, this.updateUrl_, this), + listen(map, 'change:layergroup', this.handleChangeLayerGroup_, this) + ); + + if (!this.replace_) { + addEventListener('popstate', this.updateState_); + } + } + + /** + * @param {import("../PluggableMap.js").default} map Map. + * @private + */ + unregisterListeners_(map) { + for (let i = 0, ii = this.listenerKeys_.length; i < ii; ++i) { + unlistenByKey(this.listenerKeys_[i]); + } + this.listenerKeys_.length = 0; + + if (!this.replace_) { + removeEventListener('popstate', this.updateState_); + } + + const url = new URL(window.location.href); + const params = url.searchParams; + this.delete_(params, 'x'); + this.delete_(params, 'y'); + this.delete_(params, 'z'); + this.delete_(params, 'r'); + this.delete_(params, 'l'); + window.history.replaceState(null, '', url); + } + + /** + * @private + */ + handleChangeLayerGroup_() { + const map = this.getMap(); + if (!map) { + return; + } + this.unregisterListeners_(map); + this.registerListeners_(map); + this.initial_ = true; + this.updateUrl_(); + } + + /** + * @private + */ + updateState_() { + const map = this.getMap(); + if (!map) { + return; + } + const view = map.getView(); + if (!view) { + return; + } + const url = new URL(window.location.href); + const params = url.searchParams; + + let updateView = false; + + /** + * @type {import('../View.js').AnimationOptions} + */ + const viewProperties = {}; + + const zoom = readNumber(this.get_(params, 'z')); + if (differentNumber(zoom, view.getZoom())) { + updateView = true; + viewProperties.zoom = zoom; + } + + const rotation = readNumber(this.get_(params, 'r')); + if (differentNumber(rotation, view.getRotation())) { + updateView = true; + viewProperties.rotation = rotation; + } + + const center = [ + readNumber(this.get_(params, 'x')), + readNumber(this.get_(params, 'y')), + ]; + if (differentArray(center, view.getCenter())) { + updateView = true; + viewProperties.center = center; + } + + if (updateView) { + if (!this.initial_ && this.animationOptions_) { + view.animate(assign(viewProperties, this.animationOptions_)); + } else { + if (viewProperties.center) { + view.setCenter(viewProperties.center); + } + if ('zoom' in viewProperties) { + view.setZoom(viewProperties.zoom); + } + if ('rotation' in viewProperties) { + view.setRotation(viewProperties.rotation); + } + } + } + + const layers = map.getAllLayers(); + const layersParam = this.get_(params, 'l'); + if (layersParam && layersParam.length === layers.length) { + for (let i = 0, ii = layers.length; i < ii; ++i) { + const value = parseInt(layersParam[i]); + if (!isNaN(value)) { + const visible = Boolean(value); + const layer = layers[i]; + if (layer.getVisible() !== visible) { + layer.setVisible(visible); + } + } + } + } + } + + /** + * @private + */ + updateUrl_() { + const map = this.getMap(); + if (!map) { + return; + } + const view = map.getView(); + if (!view) { + return; + } + const initial = this.initial_; + this.initial_ = false; + + const center = view.getCenter(); + const zoom = view.getZoom(); + const rotation = view.getRotation(); + + const layers = map.getAllLayers(); + const visibilities = new Array(layers.length); + for (let i = 0, ii = layers.length; i < ii; ++i) { + visibilities[i] = layers[i].getVisible() ? '1' : '0'; + } + + const url = new URL(window.location.href); + const params = url.searchParams; + + this.set_(params, 'x', writeNumber(center[0])); + this.set_(params, 'y', writeNumber(center[1])); + this.set_(params, 'z', writeNumber(zoom)); + this.set_(params, 'r', writeNumber(rotation)); + this.set_(params, 'l', visibilities.join('')); + + if (url.href !== window.location.href) { + if (initial || this.replace_) { + window.history.replaceState(null, '', url); + } else { + window.history.pushState(null, '', url); + } + } + } +} + +export default Link; diff --git a/src/ol/interaction/MouseWheelZoom.js b/src/ol/interaction/MouseWheelZoom.js index d427ee1146..6fcc526230 100644 --- a/src/ol/interaction/MouseWheelZoom.js +++ b/src/ol/interaction/MouseWheelZoom.js @@ -155,7 +155,11 @@ class MouseWheelZoom extends Interaction { */ endInteraction_() { this.trackpadTimeoutId_ = undefined; - const view = this.getMap().getView(); + const map = this.getMap(); + if (!map) { + return; + } + const view = map.getView(); view.endInteraction( undefined, this.lastDelta_ ? (this.lastDelta_ > 0 ? 1 : -1) : 0, diff --git a/test/browser/spec/ol/interaction/Link.test.js b/test/browser/spec/ol/interaction/Link.test.js new file mode 100644 index 0000000000..ba8d3b6337 --- /dev/null +++ b/test/browser/spec/ol/interaction/Link.test.js @@ -0,0 +1,71 @@ +import Layer from '../../../../../src/ol/layer/Tile.js'; +import Link from '../../../../../src/ol/interaction/Link.js'; +import Map from '../../../../../src/ol/Map.js'; +import View from '../../../../../src/ol/View.js'; + +describe('ol/interaction/Link', () => { + let map; + + beforeEach(function () { + map = new Map({ + target: createMapDiv(100, 100), + view: new View({ + center: [0, 0], + resolutions: [4, 2, 1], + zoom: 1, + }), + layers: [ + new Layer({visible: true}), + new Layer({visible: false}), + new Layer({visible: true}), + ], + }); + map.renderSync(); + }); + + afterEach(function () { + disposeMap(map); + }); + + describe('constructor', () => { + it('addds view state to the url', (done) => { + map.addInteraction(new Link()); + + map.once('moveend', () => { + const url = new URL(window.location.href); + const params = url.searchParams; + expect(params.get('z')).to.be('2'); + expect(params.get('x')).to.be('3'); + expect(params.get('y')).to.be('4'); + expect(params.get('r')).to.be('0.5'); + expect(params.get('l')).to.be('101'); + done(); + }); + + const view = map.getView(); + view.setZoom(2); + view.setCenter([3, 4]); + view.setRotation(0.5); + }); + + it('accepts a prefix', (done) => { + map.addInteraction(new Link({prefix: 'ol:'})); + + map.once('moveend', () => { + const url = new URL(window.location.href); + const params = url.searchParams; + expect(params.get('ol:z')).to.be('2'); + expect(params.get('ol:x')).to.be('3'); + expect(params.get('ol:y')).to.be('4'); + expect(params.get('ol:r')).to.be('0.5'); + expect(params.get('ol:l')).to.be('101'); + done(); + }); + + const view = map.getView(); + view.setZoom(2); + view.setCenter([3, 4]); + view.setRotation(0.5); + }); + }); +});