diff --git a/app-starter/static/app-conf-projected.json b/app-starter/static/app-conf-projected.json index 571294f1..36c7a98a 100644 --- a/app-starter/static/app-conf-projected.json +++ b/app-starter/static/app-conf-projected.json @@ -27,6 +27,15 @@ ] ], + "permalink": { + "location": "search", + "layers": true, + "extent": true, + "paramPrefix": "pl_", + "history": true, + "precision": 6 + }, + "tileGridDefs": { "dutch_rd": { "extent": [-285401.920, 22598.080, 595401.920, 903401.920], diff --git a/app-starter/static/app-conf.json b/app-starter/static/app-conf.json index c29bee22..5d28ed76 100644 --- a/app-starter/static/app-conf.json +++ b/app-starter/static/app-conf.json @@ -14,6 +14,15 @@ "mapZoom": 2, "mapCenter": [0, 0], + "permalink": { + "location": "hash", + "layers": true, + "extent": false, + "projection": "EPSG:4326", + "paramPrefix": "", + "history": true + }, + "mapLayers": [ { diff --git a/src/components/ol/Map.vue b/src/components/ol/Map.vue index 9d1f371c..66be689f 100644 --- a/src/components/ol/Map.vue +++ b/src/components/ol/Map.vue @@ -19,6 +19,7 @@ import Overlay from 'ol/Overlay'; import { WguEventBus } from '../../WguEventBus.js'; import { LayerFactory } from '../../factory/Layer.js'; import ColorUtil from '../../util/Color'; +import PermalinkController from './PermalinkController'; export default { name: 'wgu-map', @@ -32,9 +33,11 @@ export default { zoom: this.$appConfig.mapZoom, center: this.$appConfig.mapCenter, projection: this.$appConfig.mapProjection, + projectionObj: null, projectionDefs: this.$appConfig.projectionDefs, tileGridDefs: this.$appConfig.tileGridDefs || {}, - tileGrids: {} + tileGrids: {}, + permalink: this.$appConfig.permalink } }, mounted () { @@ -58,57 +61,63 @@ export default { }, 200); }, created () { - var me = this; - // make map rotateable according to property const interactions = defaultInteractions({ - altShiftDragRotate: me.rotateableMap, - pinchRotate: me.rotateableMap + altShiftDragRotate: this.rotateableMap, + pinchRotate: this.rotateableMap }); let controls = [ new Zoom(), new Attribution({ - collapsible: me.collapsibleAttribution + collapsible: this.collapsibleAttribution }) ]; // add a button control to reset rotation to 0, if map is rotateable - if (me.rotateableMap) { + if (this.rotateableMap) { controls.push(new RotateControl()); } // Optional projection (EPSG) definitions for Proj4 - if (me.projectionDefs) { + if (this.projectionDefs) { // Add all (array of array) - proj4.defs(me.projectionDefs); + proj4.defs(this.projectionDefs); // Register with OpenLayers olproj4(proj4); } // Projection for Map, default is Web Mercator - if (!me.projection) { - me.projection = {code: 'EPSG:3857', units: 'm'} + if (!this.projection) { + this.projection = {code: 'EPSG:3857', units: 'm'} } - const projection = new Projection(me.projection); + + const projection = new Projection(this.projection); // Optional TileGrid definitions by name, for ref in Layers - Object.keys(me.tileGridDefs).map(name => { - me.tileGrids[name] = new TileGrid(me.tileGridDefs[name]); + Object.keys(this.tileGridDefs).map(name => { + this.tileGrids[name] = new TileGrid(this.tileGridDefs[name]); }); - me.map = new Map({ + this.map = new Map({ layers: [], controls: controls, interactions: interactions, view: new View({ - center: me.center || [0, 0], - zoom: me.zoom, + center: this.center, + zoom: this.zoom, projection: projection }) }); // create layers from config and add them to map - const layers = me.createLayers(); - me.map.getLayers().extend(layers); + const layers = this.createLayers(); + this.map.getLayers().extend(layers); + + if (this.$appConfig.permalink) { + this.permalinkController = this.createPermalinkController(); + this.map.set('permalinkcontroller', this.permalinkController, true); + this.permalinkController.apply(); + this.permalinkController.setup(); + } }, methods: { @@ -150,6 +159,16 @@ export default { return layers; }, + + /** + * Creates a PermalinkController, override in subclass for specializations. + * + * @return {PermalinkController} PermalinkController instance. + */ + createPermalinkController () { + return new PermalinkController(this.map, this.$appConfig.permalink); + }, + /** * Sets the background color of the OL buttons to the color property. */ @@ -213,7 +232,6 @@ export default { // show tooltip if a hoverable feature gets hit with the mouse map.on('pointermove', me.onPointerMove, me); }, - /** * Shows the hover tooltip on the map if an appropriate feature of a * 'hoverable' layer was hit with the mouse. diff --git a/src/components/ol/PermalinkController.js b/src/components/ol/PermalinkController.js new file mode 100644 index 00000000..7e4d9668 --- /dev/null +++ b/src/components/ol/PermalinkController.js @@ -0,0 +1,294 @@ +import Projection from 'ol/proj/Projection'; +import {getTransform, transform} from 'ol/proj'; +import UrlUtil from '../../util/Url'; +import {applyTransform} from 'ol/extent'; + +/** + * Class holding the logic for permalinks. + */ +export default class PermalinkController { + /* the OL map for permalink */ + map = null; + shouldUpdate = false; + projection = null; + conf = null; + urlParams = null; + + constructor (map, permalinkConf) { + this.map = map; + this.conf = permalinkConf || {}; + this.projection = this.conf.projection ? new Projection({'code': this.conf.projection}) : null; + this.conf.paramPrefix = this.conf.paramPrefix || ''; + this.conf.location = this.conf.location || 'hash'; + this.conf.separator = this.conf.location === 'hash' ? '#' : '?'; + this.conf.history = this.conf.history ? this.conf.history : false; + this.conf.extent = this.conf.extent ? this.conf.extent : false; + this.conf.layers = this.conf.layers ? this.conf.layers : false; + this.conf.precision = this.conf.precision ? this.conf.precision : 4; + this.urlParams = UrlUtil.getParams(this.conf.location); + this.layerListeners = []; + } + + /** + * Initializes the map permalink functionality: + * Registers a 'moveend' event to update the permalink + * 'hoverAttribute' if the layer is configured as 'hoverable' + */ + setup () { + this.shouldUpdate = true; + + // Listen to map state changes (pan, zoom) + this.map.on('moveend', () => { + this.onMapChange(); + }); + + // Listen to visibility changes in Map Layers. + this.subscribeLayers(); + + // Listen to Layer Collection (dynamically Layers added/removed) + this.map.getLayers().on('change:length', () => { + this.subscribeLayers(); + }); + + if (this.conf.history === false) { + return; + } + + // restore the view state when navigating through the history (browser back/forward buttons), see + // https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onpopstate + window.addEventListener('popstate', (event) => { + if (event.state === null) { + return; + } + + const view = this.map.getView(); + const state = event.state; + + this.shouldUpdate = false; + + view.setRotation(state.rotation); + + // Use extent (bbox) or center+zoom based on config + if (this.conf.extent) { + this.applyExtent(state.extent); + } else { + this.applyCenter(state.center); + } + + // somehow we also need zoom (or resolution) for extent: + // See: https://stackoverflow.com/questions/47770782/openlayers-fit-to-current-extent-is-zooming-out + view.setZoom(state.zoom); + + if (this.conf.layers) { + this.applyLayers(new Map(state.layers.map(lid => [lid, lid]))); + } + }); + } + + /** + * Subscribe to Layer visibility changes. + */ + subscribeLayers () { + // First unsubscribe from all + this.unsubscribeLayers(); + + // Listen to each Layer's visibility changes. + this.map.getLayers().forEach((layer) => { + const key = layer.on('change:visible', () => { + this.onMapChange(); + }); + this.layerListeners.push({'key': key, 'layer': layer}); + }); + } + + /** + * Unsubscribe to Layer visibility changes. + */ + unsubscribeLayers () { + // Listen to each Layer's visibility changes. + this.layerListeners.forEach((item) => { + item.layer.un(item.key.type, item.key.listener) + }); + this.layerListeners = []; + } + + /** + * Applies map (View) rotation, center+zoom-level or extent + * from permalink params in current URL 'location' + */ + apply () { + const permalinkParams = UrlUtil.getParams(this.conf.location); + const prefix = this.conf.paramPrefix; + const mapView = this.map.getView(); + const r = `${prefix}r`; + const c = `${prefix}c`; + const e = `${prefix}e`; + const z = `${prefix}z`; + const l = `${prefix}l`; + + if (permalinkParams[r]) { + mapView.setRotation(parseFloat(permalinkParams[r])); + } + + // Both extent (bbox) or center+zoom supported. + if (permalinkParams[e]) { + let extent = permalinkParams[e].split(',').map((n) => { + return parseFloat(n); + }); + this.applyExtent(extent); + } else if (permalinkParams[c]) { + let center = permalinkParams[c].split(',').map((n) => { + return parseFloat(n); + }); + this.applyCenter(center); + } + + // Always set zoom, even with extent + // See: https://stackoverflow.com/questions/47770782/openlayers-fit-to-current-extent-is-zooming-out + if (permalinkParams[z]) { + mapView.setZoom(parseFloat(permalinkParams[z])); + } + + // Set layer(s) visible + if (permalinkParams[l]) { + this.applyLayers(new Map(permalinkParams[l].split(',').map(lid => [lid, lid]))); + } + } + + /** + * Make only the layers for given array of layer ids visible. + */ + applyLayers (layers) { + if (!layers) { + return; + } + this.map.getLayers().forEach((layer) => { + const layerId = layer.get('lid'); + layer.setVisible(layerId === layers.get(layerId)); + }) + } + + /** + * Position map at provided center. + */ + applyCenter (center) { + if (!center) { + return; + } + const mapView = this.map.getView(); + + // Permalink coordinates may have specific Projection like WGS84 + if (this.projection) { + center = transform(center, this.projection, mapView.getProjection()) + } + + mapView.setCenter(center); + } + + /** + * Position map at provided map extent. + */ + applyExtent (extent) { + if (!extent) { + return; + } + const mapView = this.map.getView(); + // Permalink coordinates may have specific Projection like WGS84 + if (this.projection) { + extent = applyTransform(extent, getTransform(this.projection, mapView.getProjection())) + } + + // Fit the map in extent + mapView.fit(extent, this.map.getSize()); + } + + /** + * Get the URL parameter permalink string as query or hash. + */ + getParamStr () { + const round = (num, places) => { + return +(Math.round(num + 'e+' + places) + 'e-' + places); + }; + + const state = this.getState(); + const prefix = this.conf.paramPrefix; + const prec = this.conf.precision; + + // Use extent (bbox) or center+zoom based on config + this.urlParams[`${prefix}z`] = `${round(state.zoom, prec)}`; + if (this.conf.extent) { + this.urlParams[`${prefix}e`] = state.extent.map(n => round(n, prec)).join(','); + } else { + this.urlParams[`${prefix}c`] = state.center.map(n => round(n, prec)).join(','); + } + this.urlParams[`${prefix}r`] = `${round(state.rotation, prec)}`; + + if (this.conf.layers) { + this.urlParams[`${prefix}l`] = state.layers.join(','); + } + + return this.conf.separator + UrlUtil.toQueryString(this.urlParams); + } + + /** + * Get full URL with permalink string for sharing. + */ + getShareUrl () { + return location.href.split(this.conf.separator)[0] + this.getParamStr(); + } + + /** + * Get (IFrame) code fragment for embedding the permalink in an HTML page. + */ + getEmbedHTML () { + const mapSize = this.map.getSize(); + + return ``; + } + + /** + * Get array of visible layer id's. + */ + getLayerIds () { + return this.map.getLayers().getArray().filter(layer => !!layer.get('lid') && layer.getVisible()).map(layer => layer.get('lid')); + } + + /** + * Get total State of the Map. + */ + getState () { + const mapView = this.map.getView(); + let center = mapView.getCenter(); + let extent = mapView.calculateExtent(); + + // Optionally reproject to permalink projection (e.g. WGS84 on WebMerc). + if (this.projection) { + center = transform(center, mapView.getProjection(), this.projection); + extent = applyTransform(extent, getTransform(mapView.getProjection(), this.projection)); + } + return { + zoom: mapView.getZoom(), + center: center, + extent: extent, + rotation: mapView.getRotation(), + layers: this.getLayerIds() + }; + } + + /** + * Callback when Map View has changed, e.g. 'moveend' or a Layer's visibility. + */ + onMapChange () { + // console.log('mapchange'); + if (!this.shouldUpdate) { + // do not update the URL when the view was changed in the 'popstate' handler + this.shouldUpdate = true; + return; + } + if (this.conf.history === false) { + return; + } + // This changes the URL in address bar. + window.history.pushState(this.getState(), 'map', this.getParamStr()); + } +} diff --git a/src/factory/Layer.js b/src/factory/Layer.js index c8634ed6..2b711d85 100644 --- a/src/factory/Layer.js +++ b/src/factory/Layer.js @@ -59,8 +59,9 @@ export const LayerFactory = { getInstance (lConf, olMap) { // apply LID (Layer ID) if not existent if (!lConf.lid) { - var now = new Date(); - lConf.lid = now.getTime(); + // Make a unique layerId from Layer name and URL so contexts + // like permalinks can be reapplied. + lConf.lid = btoa(lConf.url + lConf.name).substr(0, 6); } // create correct layer type diff --git a/src/util/Url.js b/src/util/Url.js index fe03bb64..54b698b1 100644 --- a/src/util/Url.js +++ b/src/util/Url.js @@ -3,28 +3,72 @@ */ const UrlUtil = { + /** + * Parses query string like a=b&c=d&e=f etc of the given search part (querySearch) of an URL + * as JS object (key-value). Uses ES6 coding/parsing as in: + * https://www.arungudelli.com/tutorial/javascript/get-query-string-parameter-values-from-url-using-javascript/ + * + * @param {String} query Search part (queryString) of an URL + * @return {Object} Key-value pairs of the URL parameters + */ + parseQueryString (query) { + return (/^[?#]/.test(query) ? query.slice(1) : query) + .split('&') + .reduce((params, param) => { + let [key, value] = param.split('='); + params[key] = value ? decodeURIComponent(value.replace(/\+/g, ' ')) : ''; + return params; + }, {}); + }, + + /** + * Returns the query params of the current 'location' from 'hash' or 'search'. + * + * @param {String} locationType use query or hash part of URL + * @return {Object} Key-value pairs of the URL parameters + */ + getParams (locationType) { + const querySearch = document.location[locationType].substring(1); + if (!querySearch || querySearch === '') { + return {}; + } + return this.parseQueryString(querySearch); + }, + /** * Returns all query params of the given search part (querySearch) of an URL * as JS object (key-value). * If querySearch is not provided it is derived from the current location. * * @param {String} querySearch Search part (querySearch) of an URL - * @return {Object} Key-value pairs of the URL parameters + * @return {Object} Key-value pairs of the URL parameters, may be empty {}. */ getQueryParams (querySearch) { if (!querySearch) { - querySearch = document.location.search; + querySearch = document.location.search.substring(1); } - querySearch = querySearch.split('+').join(' '); - const re = /[?&]?([^=]+)=([^&]*)/g; - let params = {}; - let tokens; - while ((tokens = re.exec(querySearch))) { - params[decodeURIComponent(tokens[1])] = decodeURIComponent(tokens[2]); + if (!querySearch || querySearch === '') { + return {}; } - return params; + return this.parseQueryString(querySearch); + }, + + /** + * Returns all query params of the given hash part (hash) of an URL + * as JS object (key-value). + * If hash is not provided it is derived from the current location. + * + * @param {String} hash Search part (hash) of an URL + * @return {Object} Key-value pairs of the URL parameters + */ + getHashParams (hash) { + if (!hash) { + hash = document.location.hash.substring(1); + } + + return this.parseQueryString(hash); }, /** @@ -41,11 +85,29 @@ const UrlUtil = { Object.keys(params).forEach(key => { if (key === param) { value = params[key]; - return; } }); return value; + }, + + /** + * Returns a query string from an object of key/value pairs. + * + * @param {String} obj object with key/values to encode. + * @return {String} the encoded query string + */ + toQueryString (obj) { + return Object.keys(obj) + .reduce((a, k) => { + a.push( + typeof obj[k] === 'object' + ? this.toQueryString(obj[k]) + : `${encodeURIComponent(k)}=${encodeURIComponent(obj[k])}` + ); + return a; + }, []) + .join('&'); } }; diff --git a/test/unit/specs/components/ol/Map.spec.js b/test/unit/specs/components/ol/Map.spec.js index 04307b1b..b9db4545 100644 --- a/test/unit/specs/components/ol/Map.spec.js +++ b/test/unit/specs/components/ol/Map.spec.js @@ -57,6 +57,7 @@ describe('ol/Map.vue', () => { }); it('has correct default data', () => { + expect(vm.permalink).to.equal(undefined); expect(vm.zoom).to.equal(undefined); expect(vm.center).to.equal(undefined); expect(vm.tileGridDefs).to.be.empty; diff --git a/test/unit/specs/components/ol/PermalinkController.spec.js b/test/unit/specs/components/ol/PermalinkController.spec.js new file mode 100644 index 00000000..63f8b11e --- /dev/null +++ b/test/unit/specs/components/ol/PermalinkController.spec.js @@ -0,0 +1,172 @@ +import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import Map from '@/components/ol/Map'; +import VectorLayer from 'ol/layer/Vector'; +const permalinkDef = { + mapZoom: 2, + mapCenter: [0, 0], + mapLayers: [{ + 'type': 'OSM', + 'lid': 'osm-bg', + 'name': 'OSM', + 'isBaseLayer': false, + 'visible': true, + 'selectable': false, + 'displayInLayerList': true + }, + { + 'type': 'WMS', + 'lid': 'ahocevar-wms', + 'name': 'WMS (ahocevar)', + 'format': 'image/png', + 'layers': 'topp:states', + 'url': 'https://ahocevar.com/geoserver/wms', + 'transparent': true, + 'singleTile': false, + 'projection': 'EPSG:3857', + 'attribution': '', + 'isBaseLayer': false, + 'visible': false, + 'displayInLayerList': true + }], + permalink: { + 'location': 'hash', + 'layers': true, + 'extent': false, + 'projection': 'EPSG:4326', + 'paramPrefix': '', + 'history': true + } +}; + +describe('ol/Map.vue', () => { + describe('data - Map NOT Provides PermalinkController when NOT defined', () => { + let comp; + let vm; + beforeEach(() => { + Vue.prototype.$appConfig = {}; + comp = shallowMount(Map); + vm = comp.vm; + }); + + it('Map has NOT instantiated permalinkController', () => { + expect(vm.permalinkController).to.equal(undefined); + }); + }); + + describe('data - Map Provides PermalinkController when defined', () => { + let comp; + let vm; + beforeEach(() => { + Vue.prototype.$appConfig = permalinkDef; + comp = shallowMount(Map); + vm = comp.vm; + }); + + it('Map has instantiated permalinkController', () => { + expect(vm.permalinkController).to.not.be.empty; + }); + }); + + describe('data - PermalinkController successfully setup', () => { + let comp; + let vm; + beforeEach(() => { + Vue.prototype.$appConfig = permalinkDef; + comp = shallowMount(Map); + vm = comp.vm; + }); + + it('Setup permalinkController', () => { + vm.permalinkController.setup(); + expect(vm.permalinkController.shouldUpdate).equals(true); + expect(vm.permalinkController.layerListeners.length).to.equal(2); + vm.permalinkController.unsubscribeLayers(); + expect(vm.permalinkController.layerListeners.length).to.equal(0); + vm.permalinkController.subscribeLayers(); + expect(vm.permalinkController.layerListeners.length).to.equal(2); + }); + it('Layer Listeners are (re)created when the layer stack changes', () => { + vm.permalinkController.setup(); + expect(vm.permalinkController.layerListeners.length).to.equal(2); + vm.map.addLayer(new VectorLayer()); + expect(vm.permalinkController.layerListeners.length).to.equal(3); + }); + }); + + describe('data - PermalinkController up to date with Map View', () => { + let comp; + let vm; + beforeEach(() => { + Vue.prototype.$appConfig = permalinkDef; + comp = shallowMount(Map); + vm = comp.vm; + }); + + it('Setup and apply permalinkController - defaults', () => { + vm.permalinkController.setup(); + expect(vm.permalinkController.getState().zoom).to.equal(permalinkDef.mapZoom); + expect(vm.permalinkController.getParamStr()).to.equal('#z=2&c=0%2C0&r=0&l=osm-bg'); + }); + + it('Setup and apply permalinkController - modify Map View', () => { + vm.permalinkController.setup(); + const mapView = vm.map.getView(); + const newZoom = 8; + const newCenter = [1000000, 2000000]; + mapView.setZoom(newZoom); + mapView.setCenter(newCenter); + expect(vm.permalinkController.getState().zoom).to.equal(newZoom); + // Map coordinates in Web Mercator converted to WGS84! + expect(vm.permalinkController.getParamStr()).to.equal('#z=' + newZoom + '&c=8.9832%2C17.6789&r=0&l=osm-bg'); + // Make each Layer visible: must change param string to contain all Layers. + vm.map.getLayers().forEach((layer) => { + if (layer.getVisible() === false) { + layer.setVisible(true); + } + }); + expect(vm.permalinkController.getParamStr()).to.equal('#z=' + newZoom + '&c=8.9832%2C17.6789&r=0&l=ahocevar-wms%2Cosm-bg'); + }); + }); + + describe('data - PermalinkController applied from document.location.hash/search', () => { + let comp; + let vm; + beforeEach(() => { + Vue.prototype.$appConfig = permalinkDef; + comp = shallowMount(Map); + vm = comp.vm; + }); + + it('Setup and apply permalinkController - apply from document.location.hash', () => { + vm.permalinkController.setup(); + document.location.hash = '#z=4&c=4%2C52&r=0&l=osm-bg%2Cahocevar-wms'; + vm.permalinkController.apply(); + expect(vm.permalinkController.getState().zoom).to.equal(4); + expect(vm.permalinkController.getParamStr()).to.equal(document.location.hash); + // Map View should reflect hash string above (in Web Merc projection) + const map = vm.map; + const mapView = map.getView(); + expect(mapView.getZoom()).to.equal(4); + expect(Math.round(mapView.getCenter()[0])).to.equal(445278); + expect(Math.round(mapView.getCenter()[1])).to.equal(6800125); + expect(Math.round(map.getLayers().getLength())).to.equal(2); + }); + // Below gives problems in Karma as the document is reloaded by setting document.locaiton.search! + // it('Setup and apply permalinkController - apply from document.location.search', () => { + // permalinkDef.permalink.location = 'search'; + // vm.permalinkController.setup(); + // document.location.search = '?z=4&c=4%2C52&r=0&l=ahocevar-wms%2Cosm-bg'; + // vm.permalinkController.apply(); + // expect(vm.permalinkController.getState().zoom).to.equal(4); + // expect(vm.permalinkController.getParamStr()).to.equal(document.location.search); + // // Map View should reflect search string above (in Web Merc projection) + // const map = vm.map; + // const mapView = map.getView(); + // expect(mapView.getZoom()).to.equal(4); + // expect(Math.round(mapView.getCenter()[0])).to.equal(445278); + // expect(Math.round(mapView.getCenter()[1])).to.equal(6800125); + // expect(Math.round(map.getLayers().getLength())).to.equal(2); + // }); + }); +});