diff --git a/src/components/ol/Map.vue b/src/components/ol/Map.vue index 0f778465..966743e3 100644 --- a/src/components/ol/Map.vue +++ b/src/components/ol/Map.vue @@ -35,72 +35,75 @@ export default { } }, mounted () { - var me = this; // Make the OL map accessible for Mapable mixin even 'ol-map-mounted' has // already been fired. Don not use directly in cmps, use Mapable instead. - Vue.prototype.$map = me.map; + Vue.prototype.$map = this.map; // Send the event 'ol-map-mounted' with the OL map as payload - WguEventBus.$emit('ol-map-mounted', me.map); + WguEventBus.$emit('ol-map-mounted', this.map); - // resize the map, so it fits to parent - window.setTimeout(() => { - me.map.setTarget(document.getElementById('ol-map-container')); - me.map.updateSize(); + // resize the map, so it fits to parent, may need to wait + // until map container element is ready. + const timer = setInterval(() => { + const mapTarget = document.getElementById('ol-map-container'); + if (!mapTarget) { + return; + } + clearInterval(timer); + this.map.setTarget(mapTarget); + this.map.updateSize(); // adjust the bg color of the OL buttons (like zoom, rotate north, ...) - me.setOlButtonColor(); + this.setOlButtonColor(); // initialize map hover functionality - me.setupMapHover(); - }, 200); + this.setupMapHover(); + }, 100); }, - created () { - var me = this; - + async created () { // 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); - 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 || [0, 0], + 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 = await this.createLayers(); + this.map.getLayers().extend(layers); }, methods: { @@ -108,35 +111,43 @@ export default { * Creates the OL layers due to the "mapLayers" array in app config. * @return {ol.layer.Base[]} Array of OL layer instances */ - createLayers () { - const me = this; - let layers = []; - const appConfig = this.$appConfig; - const mapLayersConfig = appConfig.mapLayers || []; - mapLayersConfig.reverse().forEach(function (lConf) { - let layer = LayerFactory.getInstance(lConf); - layers.push(layer); - + async createLayers () { + const addInteraction = (layer) => { // if layer is selectable register a select interaction - if (lConf.selectable) { - const selectClick = new SelectInteraction({ - layers: [layer] - }); - // forward an event if feature selection changes - selectClick.on('select', function (evt) { - // TODO use identifier for layer (once its implemented) - WguEventBus.$emit( - 'map-selectionchange', - layer.get('lid'), - evt.selected, - evt.deselected - ); - }); - // register/activate interaction on map - me.map.addInteraction(selectClick); + if (layer.get('selectable') === false) { + return; } - }); + const selectClick = new SelectInteraction({ + layers: [layer] + }); + // forward an event if feature selection changes + selectClick.on('select', function (evt) { + // TODO use identifier for layer (once its implemented) + WguEventBus.$emit( + 'map-selectionchange', + layer.get('lid'), + evt.selected, + evt.deselected + ); + }); + // register/activate interaction on map + this.map.addInteraction(selectClick); + }; + let layers = []; + const mapLayersConfig = this.$appConfig.mapLayers; + await Promise.all(mapLayersConfig.reverse().map(async lConf => { + let layersToAdd = await LayerFactory.getInstance(lConf); + // One layer definition can lead to several layer instances being created + if (Array.isArray(layersToAdd)) { + // Reverse like main config to have Layers added in right stacking order. + layersToAdd = layersToAdd.reverse(); + } else { + layersToAdd = [layersToAdd]; + } + layersToAdd.forEach(layer => addInteraction(layer)); + layers.push(...layersToAdd); + })); return layers; }, /** @@ -178,8 +189,7 @@ export default { * 'hoverAttribute' if the layer is configured as 'hoverable' */ setupMapHover () { - const me = this; - const map = me.map; + const map = this.map; let overlayEl; // create a span to show map tooltip @@ -187,20 +197,20 @@ export default { overlayEl.classList.add('wgu-hover-tooltiptext'); map.getTarget().append(overlayEl); - me.overlayEl = overlayEl; + this.overlayEl = overlayEl; // wrap the tooltip span in a OL overlay and add it to map - me.overlay = new Overlay({ + this.overlay = new Overlay({ element: overlayEl, autoPan: true, autoPanAnimation: { duration: 250 } }); - map.addOverlay(me.overlay); + map.addOverlay(this.overlay); // show tooltip if a hoverable feature gets hit with the mouse - map.on('pointermove', me.onPointerMove, me); + map.on('pointermove', this.onPointerMove, this); }, /** diff --git a/src/factory/Layer.js b/src/factory/Layer.js index bb702aed..aa823515 100644 --- a/src/factory/Layer.js +++ b/src/factory/Layer.js @@ -35,7 +35,7 @@ export const LayerFactory = { * @param {Object} lConf Layer config object * @return {ol.layer.Base} OL layer instance */ - getInstance (lConf) { + async getInstance (lConf) { // apply LID (Layer ID) if not existent if (!lConf.lid) { var now = new Date(); @@ -53,6 +53,8 @@ export const LayerFactory = { return this.createVectorLayer(lConf); } else if (lConf.type === 'VECTORTILE') { return this.createVectorTileLayer(lConf); + } else if (lConf.type === 'LAYERCOLLECTION') { + return this.createLayersFromCollection(lConf); } else { return null; } @@ -69,6 +71,7 @@ export const LayerFactory = { name: lConf.name, lid: lConf.lid, displayInLayerList: lConf.displayInLayerList, + selectable: lConf.selectable || false, extent: lConf.extent, visible: lConf.visible, opacity: lConf.opacity, @@ -97,6 +100,7 @@ export const LayerFactory = { name: lConf.name, lid: lConf.lid, displayInLayerList: lConf.displayInLayerList, + selectable: lConf.selectable || false, visible: lConf.visible, opacity: lConf.opacity, source: new XyzSource({ @@ -119,6 +123,7 @@ export const LayerFactory = { name: lConf.name, lid: lConf.lid, displayInLayerList: lConf.displayInLayerList, + selectable: lConf.selectable || false, visible: lConf.visible, opacity: lConf.opacity, source: new OsmSource() @@ -138,6 +143,7 @@ export const LayerFactory = { name: lConf.name, lid: lConf.lid, displayInLayerList: lConf.displayInLayerList, + selectable: lConf.selectable || false, extent: lConf.extent, visible: lConf.visible, opacity: lConf.opacity, @@ -165,6 +171,7 @@ export const LayerFactory = { name: lConf.name, lid: lConf.lid, displayInLayerList: lConf.displayInLayerList, + selectable: lConf.selectable || false, visible: lConf.visible, opacity: lConf.opacity, source: new VectorTileSource({ @@ -178,6 +185,18 @@ export const LayerFactory = { }); return vtLayer; - } + }, + /** + * Returns an array of Wegue Layer objects from given config. + * + * @param {Object} lConf Wegue Layer list config object + * @return {Array} array of layer instances + */ + async createLayersFromCollection (lConf) { + const response = await (await fetch(lConf.url)).json(); + return Promise.all(response.map(async layerDef => { + return this.getInstance(layerDef); + })); + } } diff --git a/static/app-conf.json b/static/app-conf.json index 7cb85f2f..bb4600f5 100644 --- a/static/app-conf.json +++ b/static/app-conf.json @@ -84,7 +84,10 @@ "fillColor": "rgba(20,20,20,0.1)" } }, - + { + "type": "LAYERCOLLECTION", + "url": "./static/layer-collection.json" + }, { "type": "XYZ", "name": "OpenTopoMap", diff --git a/static/layer-collection.json b/static/layer-collection.json new file mode 100644 index 00000000..38b0bab4 --- /dev/null +++ b/static/layer-collection.json @@ -0,0 +1,37 @@ +[ + { + "type": "VECTOR", + "lid": "portuguese_pois", + "name": "Portuguese POIs", + "url": "https://demo.pygeoapi.io/master/collections/ogr_gpkg_poi/items", + "formatConfig": { + + }, + "format": "GeoJSON", + "visible": true, + "selectable": true, + "displayInLayerList": true, + "style": { + "radius": 6, + "strokeColor": "blue", + "strokeWidth": 2, + "fillColor": "rgba(0,0,100,0.5)" + } + }, + { + "type": "WMS", + "lid": "au_administrativeunit", + "name": "Dutch Administrative Units", + "format": "image/png", + "layers": "AU.AdministrativeUnit", + "url": "https://geodata.nationaalgeoregister.nl/inspire/au/wms", + "transparent": true, + "singleTile": false, + "projection": "EPSG:3857", + "attribution": "Dutch Kadaster", + "isBaseLayer": false, + "visible": false, + "selectable": true, + "displayInLayerList": true + } +] diff --git a/test/unit/specs/components/ol/Map.spec.js b/test/unit/specs/components/ol/Map.spec.js index 4132adc2..5f442e3c 100644 --- a/test/unit/specs/components/ol/Map.spec.js +++ b/test/unit/specs/components/ol/Map.spec.js @@ -65,7 +65,7 @@ describe('ol/Map.vue', () => { vm = comp.vm; }); - it('createLayers returns always an array', () => { + it('createLayers returns always an array', async () => { // mock a map layer config Vue.prototype.$appConfig = {mapLayers: [{ 'type': 'OSM', @@ -76,12 +76,32 @@ describe('ol/Map.vue', () => { 'selectable': false, 'displayInLayerList': true}] }; - const layers = vm.createLayers(); + const layers = await vm.createLayers(); expect(layers).to.be.an('array'); expect(layers.length).to.equal(1); }); - it('createLayers registers a select interaction if configured', () => { + it('createLayers expands LAYERCOLLECTION Layer type', async () => { + // mock a map layer config + Vue.prototype.$appConfig = {mapLayers: [{ + 'type': 'OSM', + 'lid': 'osm-bg', + 'name': 'OSM', + 'isBaseLayer': false, + 'visible': true, + 'selectable': false, + 'displayInLayerList': true}, { + 'type': 'LAYERCOLLECTION', + // should change URL to Wegue GH when ready + 'url': 'https://raw.githubusercontent.com/Geolicious/wegue/111-dynlayers-wegueformat/static/layer-collection.json'}] + }; + const layers = await vm.createLayers(); + expect(layers).to.be.an('array'); + // OSM (1 layer) and LAYERCOLLECTION (2 layers) + expect(layers.length).to.equal(3); + }); + + it('createLayers registers a select interaction if configured', async () => { // mock a map layer config Vue.prototype.$appConfig = {mapLayers: [{ 'type': 'OSM', @@ -92,7 +112,7 @@ describe('ol/Map.vue', () => { 'selectable': true, 'displayInLayerList': true}] }; - vm.createLayers(); + await vm.createLayers(); let selectIa; vm.map.getInteractions().forEach((ia) => { if (ia instanceof SelectInteraction) { diff --git a/test/unit/specs/factory/Layer.spec.js b/test/unit/specs/factory/Layer.spec.js index 6b7ad410..5744d178 100644 --- a/test/unit/specs/factory/Layer.spec.js +++ b/test/unit/specs/factory/Layer.spec.js @@ -26,11 +26,11 @@ describe('LayerFactory', () => { expect(typeof LayerFactory.createVectorTileLayer).to.equal('function'); }); - it('getInstance returns correct instance', () => { + it('getInstance returns correct instance', async () => { let layerConf = { type: 'WMS' }; - const style = LayerFactory.getInstance(layerConf); + const style = await LayerFactory.getInstance(layerConf); expect((style instanceof TileLayer)).to.equal(true); }); diff --git a/test/unit/specs/ol/Map.spec.js b/test/unit/specs/ol/Map.spec.js deleted file mode 100644 index 4132adc2..00000000 --- a/test/unit/specs/ol/Map.spec.js +++ /dev/null @@ -1,236 +0,0 @@ -import Vue from 'vue'; -import { shallowMount } from '@vue/test-utils'; -import Map from '@/components/ol/Map'; -import OlMap from 'ol/Map'; -import Feature from 'ol/Feature'; -import VectorLayer from 'ol/layer/Vector'; -import VectorSource from 'ol/source/Vector'; -import Point from 'ol/geom/Point'; -import SelectInteraction from 'ol/interaction/Select'; - -describe('ol/Map.vue', () => { - // Inspect the raw component options - it('is defined', () => { - expect(typeof Map).to.not.equal('undefined'); - }); - - it('has a mounted hook', () => { - expect(typeof Map.mounted).to.equal('function'); - }); - - it('has a created hook', () => { - expect(typeof Map.created).to.equal('function'); - }); - - describe('props', () => { - let comp; - let vm; - beforeEach(() => { - Vue.prototype.$appConfig = {modules: {}}; - comp = shallowMount(Map); - vm = comp.vm; - }); - - it('has correct default props', () => { - expect(vm.color).to.equal('red darken-3'); - expect(vm.collapsibleAttribution).to.equal(false); - expect(vm.rotateableMap).to.equal(false); - }); - }); - - describe('data', () => { - let comp; - let vm; - beforeEach(() => { - comp = shallowMount(Map); - vm = comp.vm; - }); - - it('has correct default data', () => { - expect(vm.zoom).to.equal(undefined); - expect(vm.center).to.equal(undefined); - }); - - it('has correct default data', () => { - expect(vm.zoom).to.equal(undefined); - expect(vm.center).to.equal(undefined); - }); - }); - - describe('methods', () => { - let comp; - let vm; - beforeEach(() => { - comp = shallowMount(Map); - vm = comp.vm; - }); - - it('createLayers returns always an array', () => { - // mock a map layer config - Vue.prototype.$appConfig = {mapLayers: [{ - 'type': 'OSM', - 'lid': 'osm-bg', - 'name': 'OSM', - 'isBaseLayer': false, - 'visible': true, - 'selectable': false, - 'displayInLayerList': true}] - }; - const layers = vm.createLayers(); - expect(layers).to.be.an('array'); - expect(layers.length).to.equal(1); - }); - - it('createLayers registers a select interaction if configured', () => { - // mock a map layer config - Vue.prototype.$appConfig = {mapLayers: [{ - 'type': 'OSM', - 'lid': 'osm-bg', - 'name': 'OSM', - 'isBaseLayer': false, - 'visible': true, - 'selectable': true, - 'displayInLayerList': true}] - }; - vm.createLayers(); - let selectIa; - vm.map.getInteractions().forEach((ia) => { - if (ia instanceof SelectInteraction) { - selectIa = ia; - } - }); - expect(typeof selectIa).to.not.equal('undefined'); - }); - - it('setOlButtonColor applies CSS color to OL buttons', () => { - // mock a OL zoom button - const mockZoomDiv = document.createElement('div'); - const mockSubZoomInEl = document.createElement('button'); - const mockSubZoomOutEl = document.createElement('button'); - mockZoomDiv.classList.add('ol-zoom'); - mockSubZoomInEl.classList.add('ol-zoom-in'); - mockSubZoomOutEl.classList.add('ol-zoom-out'); - mockZoomDiv.append(mockSubZoomInEl); - mockZoomDiv.append(mockSubZoomOutEl); - document.body.append(mockZoomDiv); - - // mock a OL rotate button - const mockRotDiv = document.createElement('div'); - const mockSubRotDiv = document.createElement('div'); - mockRotDiv.classList.add('ol-rotate'); - mockSubRotDiv.classList.add('ol-rotate-reset'); - mockRotDiv.append(mockSubRotDiv); - document.body.append(mockRotDiv); - - vm.color = 'rgb(0, 0, 0)'; - vm.setOlButtonColor(); - - expect(mockSubZoomInEl.style.backgroundColor).to.equal(vm.color); - expect(mockSubZoomOutEl.style.backgroundColor).to.equal(vm.color); - expect(mockSubRotDiv.style.backgroundColor).to.equal(vm.color); - - // cleanup (otherwise follow up tests fail) - mockZoomDiv.parentNode.removeChild(mockZoomDiv); - mockRotDiv.parentNode.removeChild(mockRotDiv); - }); - - it('setOlButtonColor applies Vuetify color to OL buttons', () => { - // mock a OL zoom button - const mockZoomDiv = document.createElement('div'); - const mockSubZoomInEl = document.createElement('button'); - const mockSubZoomOutEl = document.createElement('button'); - mockZoomDiv.classList.add('ol-zoom'); - mockSubZoomInEl.classList.add('ol-zoom-in'); - mockSubZoomOutEl.classList.add('ol-zoom-out'); - mockZoomDiv.append(mockSubZoomInEl); - mockZoomDiv.append(mockSubZoomOutEl); - document.body.append(mockZoomDiv); - - // mock a OL rotate button - var mockRotDiv = document.createElement('div'); - var mockSubRotEl = document.createElement('button'); - mockRotDiv.classList.add('ol-rotate'); - mockSubRotEl.classList.add('ol-rotate-reset'); - mockRotDiv.append(mockSubRotEl); - document.body.append(mockRotDiv); - - // set a vuetify color definition like 'red darken-3' - const cssCls1 = 'red'; - const cssCls2 = 'darken-3'; - vm.color = cssCls1 + ' ' + cssCls2; - vm.setOlButtonColor(); - - expect(mockSubZoomInEl.classList.contains(cssCls1)).to.equal(true); - expect(mockSubZoomInEl.classList.contains(cssCls2)).to.equal(true); - expect(mockSubZoomOutEl.classList.contains(cssCls1)).to.equal(true); - expect(mockSubZoomOutEl.classList.contains(cssCls2)).to.equal(true); - expect(mockSubRotEl.classList.contains(cssCls1)).to.equal(true); - expect(mockSubRotEl.classList.contains(cssCls2)).to.equal(true); - - // cleanup (otherwise follow up tests fail) - mockZoomDiv.parentNode.removeChild(mockZoomDiv); - mockRotDiv.parentNode.removeChild(mockRotDiv); - }); - - it('setupMapHover registers a tooltip DOM element and OL overlay', () => { - const map = new OlMap({}); - const mockMapDiv = document.createElement('div'); - map.setTarget(mockMapDiv); - vm.map = map; - - vm.setupMapHover(); - - const hoverOverlayEl = vm.map.getTarget().querySelector('.wgu-hover-tooltiptext'); - expect(typeof hoverOverlayEl).not.to.equal('undefined'); - - expect(map.getOverlays().getLength()).to.equal(1); - }); - - it('setupMapHover binds a pointermove and shows no tooltip if no feature is hit', () => { - const map = new OlMap({}); - const mockMapDiv = document.createElement('div'); - map.setTarget(mockMapDiv); - vm.map = map; - - vm.setupMapHover(); - vm.onPointerMove({pixel: [0, 0]}); - - expect(vm.overlayEl.innerHTML).to.equal(''); - expect(vm.overlay.getPosition()).to.equal(undefined); - }); - - it('setupMapHover binds a pointermove and shows correct tooltip', () => { - const feat = new Feature({ - foo: 'bar', - geometry: new Point([0, 0]) - }); - const layer = new VectorLayer({ - hoverable: true, - hoverAttribute: 'foo', - source: new VectorSource({ - features: [feat] - }) - }); - const map = new OlMap({ - layers: [layer] - }); - const mockMapDiv = document.createElement('div'); - map.setTarget(mockMapDiv); - - // overwrite getFeaturesAtPixel to simulate hitting a valid feature - map.getFeaturesAtPixel = (evt, opts) => { - opts.layerFilter(layer); - return [feat]; - }; - - vm.map = map; - - vm.setupMapHover(); - - vm.onPointerMove({pixel: [0, 0]}); - - expect(vm.overlayEl.innerHTML).to.equal('bar'); - expect(vm.overlay.getPosition()).to.equal(undefined); - }); - }); -});