Merge pull request #13689 from tschaub/map-link

Link interaction
This commit is contained in:
Tim Schaub
2022-05-22 07:02:52 -06:00
committed by GitHub
6 changed files with 470 additions and 1 deletions

12
examples/link.html Normal file
View File

@@ -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"
---
<div id="map" class="map"></div>

20
examples/link.js Normal file
View File

@@ -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());

View File

@@ -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();
}

359
src/ol/interaction/Link.js Normal file
View File

@@ -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<number>} a An array of two numbers.
* @param {Array<number>} 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<import("../events.js").EventsKey>}
*/
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;

View File

@@ -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,

View File

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