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