From a88d8b061d52dfda580df09fe7dbe5459a829c04 Mon Sep 17 00:00:00 2001 From: Felix Schmenger Date: Mon, 18 Mar 2024 10:50:18 +0100 Subject: [PATCH 1/7] First implementation of an OL layer and layer collection proxy to overcome problems with layer- properties reactivity. --- src/util/Layer.js | 187 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) diff --git a/src/util/Layer.js b/src/util/Layer.js index 0c86dbbd..4a468d58 100644 --- a/src/util/Layer.js +++ b/src/util/Layer.js @@ -57,3 +57,190 @@ const LayerUtil = { } export default LayerUtil; + +/** + * Transparent proxy around an OpenLayers layer to be used in Vue components + * when reactive layer properties are required. + */ +export class LayerProxy { + /** + * @param {ol.layer.Base} layer OL layer + * @param {Array} properties An array of property key names which need to be + * accessed on the layer. + */ + constructor (layer, properties) { + this.layer = layer; + this.properties = {}; + this.changeListeners = {}; + properties.forEach(property => { + this.properties[property] = layer.get(property); + this.changeListeners[property] = () => { + this.properties[property] = layer.get(property); + }; + layer.on(`change:${property}`, this.changeListeners[property]); + }); + + // Forward everything transparently to the underlying OL layer. The get() + // and getProperties() methods are trapped and handled by the proxy. + // Remarks: Neither set() nor setProperties() have to be handled. Property + // setters operate on the OL layer and then get synced into the proxy via + // observables. + return new Proxy(this, { + get: function (target, prop, receiver) { + if (prop in target.layer && !['get', 'getProperties'].includes(prop)) { + const p = target.layer[prop]; + return (typeof p === 'function') ? p.bind(target.layer) : p; + } + return Reflect.get(target, prop, receiver); + } + }); + } + + /** + * Gets a value. The property name must be registered in the constructor. + * @param {String} property Key name. + * @returns {String} Value. + */ + get (property) { + return this.properties[property]; + } + + /** + * Get an object of all property names and values registered for in the + * constructor. + * @returns {Object} Object. + */ + getProperties () { + return this.properties; + } + + /** + * Get the OL layer object wrapped by this proxy. + * @returns {ol.layer.Base} OL layer + */ + getLayer () { + return this.layer; + } + + /** + * Destroy the proxy object. This must be invoked before the proxy goes out + * of scope to prevent dangling change notifications. + */ + destroy () { + Object.keys(this.changeListeners).forEach(property => { + this.layer.un(`change:${property}`, this.changeListeners[property]); + }); + } +} + +/** + * Transparent proxy around an OpenLayers collection for layers to be used in + * Vue components when reactive layer properties are required. + */ +export class LayerCollectionProxy { + /** + * @param {ol.Collection} collection OL collection of layers + * @param {Array} properties An array of property key names which need to be + * accessed on the layers. + */ + constructor (collection, properties) { + this.collection = collection; + this.layerProxies = []; + + const createLayerProxy = (layer) => new LayerProxy(layer, properties); + + // Sync against the underlying collection while retaining the order of + // elements. + // Remarks: A layer proxy must be destroyed before it goes out of scope. + // To support reactivity the instance of layerProxies must be preserved and + // the length property may not be invoked - see + // https://v2.vuejs.org/v2/guide/reactivity.html#For-Arrays + this.syncLayers = () => { + const newLayerProxies = []; + this.collection.forEach(layer => { + let layerProxy = this.layerProxies.find(proxy => proxy.getLayer() === layer); + if (!layerProxy) { + layerProxy = createLayerProxy(layer); + } + newLayerProxies.push(layerProxy); + }); + + this.layerProxies.forEach(layerProxy => { + if (!newLayerProxies.includes(layerProxy)) { + layerProxy.destroy(); + } + }); + + this.layerProxies.splice(0); + this.layerProxies.push(...newLayerProxies); + }; + + this.syncLayers(); + + this.collection.on('change:length', this.syncLayers); + + // Forward everything transparently to the underlying OL collection. + // The forEach() and getArray() and item() methods are trapped and handled + // by the proxy. + // Remarks: Methods which alter the collection are not handled. + // These operate on the OL collection and changes get synced into the + // proxy via observables. The methods pop(), push(), remove(), + // removeAt(), setAt() will operate on OL base layer arguments. Therefore + // returned objects by these methods will not properly support reactivity. + return new Proxy(this, { + get: function (target, prop, receiver) { + if (prop in target.collection && + !['forEach', 'getArray', 'item'].includes(prop)) { + const p = target.collection[prop]; + return (typeof p === 'function') ? p.bind(target.collection) : p; + } + return Reflect.get(target, prop, receiver); + } + }); + } + + /** + * Iterate over each element, calling the provided callback. + * @param {function} f The function to call for every element. This function + * takes 3 arguments (the element, the index and the array). The return value + * is ignored. + */ + forEach (f) { + this.layerProxies.forEach(f) + } + + /** + * Get an array of LayerProxy objects for all layers in the collection. + * @returns {Array} Array of LayerProxy objects. + */ + getArray () { + return this.layerProxies; + } + + /** + * Get the LayerProxy at the provided index. + * @param {Number} index Index. + * @returns Element. + */ + item (index) { + return this.layerProxies[index]; + } + + /** + * Get the OL collection object wrapped by this proxy. + * @returns {ol.Collection} OL collection of layers + */ + getCollection () { + return this.collection; + } + + /** + * Destroy the proxy object. This must be invoked before the proxy goes out of scope. + */ + destroy () { + this.collection.un('change:length', this.syncLayers); + this.layerProxies.forEach(layerProxy => { + layerProxy.destroy(); + }); + } +} From 234ed8b47624a0a15ade56a018f846e50b805c1f Mon Sep 17 00:00:00 2001 From: Felix Schmenger Date: Wed, 27 Aug 2025 10:58:28 +0200 Subject: [PATCH 2/7] Make LayerProxy and LayerCollectionProxy Vue3 compatible. Track all properties of the layer, no more need to register for specific ones. --- src/util/Layer.js | 90 +++++++++++++++++++++++++++-------------------- 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/src/util/Layer.js b/src/util/Layer.js index 4a468d58..7d71ba9d 100644 --- a/src/util/Layer.js +++ b/src/util/Layer.js @@ -1,4 +1,5 @@ import ViewAnimationUtil from './ViewAnimation'; +import { reactive, toRaw, markRaw } from 'vue' /** * Util class for OL layers @@ -65,19 +66,29 @@ export default LayerUtil; export class LayerProxy { /** * @param {ol.layer.Base} layer OL layer - * @param {Array} properties An array of property key names which need to be - * accessed on the layer. */ - constructor (layer, properties) { - this.layer = layer; - this.properties = {}; - this.changeListeners = {}; - properties.forEach(property => { - this.properties[property] = layer.get(property); - this.changeListeners[property] = () => { - this.properties[property] = layer.get(property); - }; - layer.on(`change:${property}`, this.changeListeners[property]); + constructor (layer) { + this.layer = reactive(layer); + this.properties = reactive({}); + + // Set up listener to detect any property changes + this.propertyChangeListener = (event) => { + const key = event.key; + const value = this.layer.get(key); + + if (value === undefined) { + if (key in this.properties) { + delete this.properties[key]; + } + } else { + this.properties[key] = value; + } + } + this.layer.on('propertychange', this.propertyChangeListener); + + // Track existing properties + Object.keys(layer.getProperties()).forEach(key => { + this.properties[key] = layer.get(key); }); // Forward everything transparently to the underlying OL layer. The get() @@ -85,7 +96,7 @@ export class LayerProxy { // Remarks: Neither set() nor setProperties() have to be handled. Property // setters operate on the OL layer and then get synced into the proxy via // observables. - return new Proxy(this, { + const proxy = new Proxy(this, { get: function (target, prop, receiver) { if (prop in target.layer && !['get', 'getProperties'].includes(prop)) { const p = target.layer[prop]; @@ -94,20 +105,22 @@ export class LayerProxy { return Reflect.get(target, prop, receiver); } }); + + // Avoid Vue wrapping the proxy again. + return markRaw(proxy); } /** - * Gets a value. The property name must be registered in the constructor. - * @param {String} property Key name. + * Gets a value of the layer. + * @param {String} key Key name. * @returns {String} Value. */ - get (property) { - return this.properties[property]; + get (key) { + return this.properties[key]; } /** - * Get an object of all property names and values registered for in the - * constructor. + * Get all property names and values of the layer. * @returns {Object} Object. */ getProperties () { @@ -115,11 +128,11 @@ export class LayerProxy { } /** - * Get the OL layer object wrapped by this proxy. + * Get the raw OL layer object wrapped by this proxy. * @returns {ol.layer.Base} OL layer */ - getLayer () { - return this.layer; + toRaw () { + return toRaw(this.layer); } /** @@ -127,9 +140,7 @@ export class LayerProxy { * of scope to prevent dangling change notifications. */ destroy () { - Object.keys(this.changeListeners).forEach(property => { - this.layer.un(`change:${property}`, this.changeListeners[property]); - }); + this.layer.un('propertychange', this.propertyChangeListener); } } @@ -140,25 +151,23 @@ export class LayerProxy { export class LayerCollectionProxy { /** * @param {ol.Collection} collection OL collection of layers - * @param {Array} properties An array of property key names which need to be - * accessed on the layers. */ - constructor (collection, properties) { - this.collection = collection; - this.layerProxies = []; + constructor (collection) { + this.collection = reactive(collection); + this.layerProxies = reactive([]); - const createLayerProxy = (layer) => new LayerProxy(layer, properties); + const createLayerProxy = (layer) => new LayerProxy(layer); // Sync against the underlying collection while retaining the order of // elements. // Remarks: A layer proxy must be destroyed before it goes out of scope. - // To support reactivity the instance of layerProxies must be preserved and - // the length property may not be invoked - see - // https://v2.vuejs.org/v2/guide/reactivity.html#For-Arrays + // To minimize the overhead of creating layerProxies preserve existing + // instances by merging. this.syncLayers = () => { const newLayerProxies = []; this.collection.forEach(layer => { - let layerProxy = this.layerProxies.find(proxy => proxy.getLayer() === layer); + let layerProxy = this.layerProxies.find( + proxy => proxy.toRaw() === toRaw(layer)); if (!layerProxy) { layerProxy = createLayerProxy(layer); } @@ -187,7 +196,7 @@ export class LayerCollectionProxy { // proxy via observables. The methods pop(), push(), remove(), // removeAt(), setAt() will operate on OL base layer arguments. Therefore // returned objects by these methods will not properly support reactivity. - return new Proxy(this, { + const proxy = new Proxy(this, { get: function (target, prop, receiver) { if (prop in target.collection && !['forEach', 'getArray', 'item'].includes(prop)) { @@ -197,6 +206,9 @@ export class LayerCollectionProxy { return Reflect.get(target, prop, receiver); } }); + + // Avoid Vue wrapping the proxy again. + return markRaw(proxy); } /** @@ -227,11 +239,11 @@ export class LayerCollectionProxy { } /** - * Get the OL collection object wrapped by this proxy. + * Get the raw collection object wrapped by this proxy. * @returns {ol.Collection} OL collection of layers */ - getCollection () { - return this.collection; + toRaw () { + return toRaw(this.collection); } /** From e7bf23fa0450358670a5407af9bdbff62da6e644 Mon Sep 17 00:00:00 2001 From: Felix Schmenger Date: Wed, 27 Aug 2025 14:10:34 +0200 Subject: [PATCH 3/7] In map composable use LayerCollectionProxy to provide the layer list. Minor adjustments in various components to correctly pass the raw layer into APIs and deal with initial case when layers is undefined. --- .../attributeTable/AttributeTableWin.vue | 7 +++++-- .../bglayerswitcher/BgLayerList.vue | 5 ++++- .../bglayerswitcher/BgLayerSwitcher.vue | 5 ++++- .../bglayerswitcher/LayerPreviewImage.vue | 2 +- src/components/layerlist/LayerLegendImage.vue | 3 ++- src/components/layerlist/LayerList.vue | 13 +++++++----- .../overviewmap/OverviewMapPanel.vue | 10 +++++++--- src/composables/Map.js | 20 +++++++++---------- 8 files changed, 40 insertions(+), 25 deletions(-) diff --git a/src/components/attributeTable/AttributeTableWin.vue b/src/components/attributeTable/AttributeTableWin.vue index e05efbb3..6f8d9fa9 100644 --- a/src/components/attributeTable/AttributeTableWin.vue +++ b/src/components/attributeTable/AttributeTableWin.vue @@ -69,9 +69,12 @@ export default { * Reactive property to return the OpenLayers vector layers to be shown in the selection menu. */ displayedLayers () { - return this.layers + if (!this.layers) { + return []; + } + return this.layers.getArray() .filter(layer => - layer instanceof VectorLayer && + layer.toRaw() instanceof VectorLayer && layer.get('lid') !== 'wgu-measure-layer' && layer.get('lid') !== 'wgu-geolocator-layer' ) diff --git a/src/components/bglayerswitcher/BgLayerList.vue b/src/components/bglayerswitcher/BgLayerList.vue index 454f5e50..4d89469f 100644 --- a/src/components/bglayerswitcher/BgLayerList.vue +++ b/src/components/bglayerswitcher/BgLayerList.vue @@ -78,7 +78,10 @@ export default { * Reactive property to return the OpenLayers layers marked as 'isBaseLayer'. */ displayedLayers () { - return this.layers + if (!this.layers) { + return []; + } + return this.layers.getArray() .filter(layer => layer.get('isBaseLayer')) .reverse(); }, diff --git a/src/components/bglayerswitcher/BgLayerSwitcher.vue b/src/components/bglayerswitcher/BgLayerSwitcher.vue index c1dd7e9b..1d13a22f 100644 --- a/src/components/bglayerswitcher/BgLayerSwitcher.vue +++ b/src/components/bglayerswitcher/BgLayerSwitcher.vue @@ -59,7 +59,10 @@ export default { * which is marked as 'isBaseLayer'. */ show () { - return this.layers + if (!this.layers) { + return false; + } + return this.layers.getArray() .filter(layer => layer.get('isBaseLayer')) .length > 1; } diff --git a/src/components/bglayerswitcher/LayerPreviewImage.vue b/src/components/bglayerswitcher/LayerPreviewImage.vue index 4d520320..66c7d785 100644 --- a/src/components/bglayerswitcher/LayerPreviewImage.vue +++ b/src/components/bglayerswitcher/LayerPreviewImage.vue @@ -37,7 +37,7 @@ export default { */ previewURL () { return this.layer.get('previewImage') || LayerPreview.getUrl( - this.layer, + this.layer.toRaw(), this.mapView.getCenter(), this.mapView.getResolution(), this.mapView.getProjection() diff --git a/src/components/layerlist/LayerLegendImage.vue b/src/components/layerlist/LayerLegendImage.vue index 0fab5836..904c2b04 100644 --- a/src/components/layerlist/LayerLegendImage.vue +++ b/src/components/layerlist/LayerLegendImage.vue @@ -53,7 +53,8 @@ export default { ...this.layer.get('legendOptions') }; return LayerLegend.getUrl( - this.layer, this.resolution, options, this.layer.get('legendUrl')); + this.layer.toRaw(), this.resolution, options, + this.layer.get('legendUrl')); } } }; diff --git a/src/components/layerlist/LayerList.vue b/src/components/layerlist/LayerList.vue index db890142..6ac53e06 100644 --- a/src/components/layerlist/LayerList.vue +++ b/src/components/layerlist/LayerList.vue @@ -35,12 +35,15 @@ export default { } }, computed: { - /** - * Reactive property to return the OpenLayers layers to be shown in the control. - * Remarks: The 'displayInLayerList' attribute should default to true per convention. - */ + /** + * Reactive property to return the OpenLayers layers to be shown in the control. + * Remarks: The 'displayInLayerList' attribute should default to true per convention. + */ displayedLayers () { - return this.layers + if (!this.layers) { + return []; + } + return this.layers.getArray() .filter(layer => layer.get('displayInLayerList') !== false && !layer.get('isBaseLayer')) .reverse(); } diff --git a/src/components/overviewmap/OverviewMapPanel.vue b/src/components/overviewmap/OverviewMapPanel.vue index 8197bc21..1628f24c 100644 --- a/src/components/overviewmap/OverviewMapPanel.vue +++ b/src/components/overviewmap/OverviewMapPanel.vue @@ -45,6 +45,7 @@ export default { const panel = this.$refs.overviewmapPanel; if (this.map && panel && !this.overviewMap) { this.overviewMap = new OverviewMapController(this.map, panel.$el, this.$props); + this.overviewMap.setLayer(this.selectedBgLayer.toRaw()); } }, /** @@ -64,7 +65,10 @@ export default { * this returns the first in the list of background layers. */ selectedBgLayer () { - return this.layers + if (!this.layers) { + return {}; + } + return this.layers.getArray() .filter(layer => layer.get('isBaseLayer')) .reverse() .find(layer => layer.getVisible()); @@ -86,9 +90,9 @@ export default { /** * Watch for background layer selection change. */ - selectedBgLayer () { + selectedBgLayer (newLayer) { if (this.overviewMap) { - this.overviewMap.setLayer(this.selectedBgLayer); + this.overviewMap.setLayer(newLayer.toRaw()); } } } diff --git a/src/composables/Map.js b/src/composables/Map.js index af74b17c..43fb91c2 100644 --- a/src/composables/Map.js +++ b/src/composables/Map.js @@ -1,10 +1,11 @@ -import { ref, shallowRef, computed } from 'vue'; +import { shallowRef, computed } from 'vue'; +import { LayerCollectionProxy } from '@/util/Layer'; /** * Composable which encapsulate OL map management. */ const map = shallowRef(); -const layers = ref([]); +const layers = shallowRef(); /** * Init function of the composable. @@ -13,11 +14,7 @@ const layers = ref([]); */ export function bindMap (olMap) { map.value = olMap; - layers.value = map.value.getLayers().getArray(); - - map.value.getLayers().on('change:length', (event) => { - layers.value = [...event.target.getArray()]; - }); + layers.value = new LayerCollectionProxy(olMap.getLayers()); }; /** @@ -26,7 +23,10 @@ export function bindMap (olMap) { * disable map and layers reactivity. */ export function unbindMap () { - layers.value = []; + if (layers.value) { + layers.value.destroy(); + layers.value = undefined; + } map.value = undefined; }; @@ -37,8 +37,6 @@ export function unbindMap () { export function useMap () { return { map: computed(() => map.value), - layers: computed(() => { - return layers.value; - }) + layers: computed(() => layers.value) }; }; From 248537d2c506006fab31e37e0c8d3664d1957efe Mon Sep 17 00:00:00 2001 From: Felix Schmenger Date: Wed, 27 Aug 2025 14:16:20 +0200 Subject: [PATCH 4/7] Adjust various unit tests to pass in LayerProxy classes to the respective components and handle raw conversions. --- .../bglayerswitcher/BgLayerList.spec.js | 3 +- .../bglayerswitcher/LayerPreviewImage.spec.js | 25 ++++--- .../layerlist/LayerLegendImage.spec.js | 67 +++++++++-------- .../components/layerlist/LayerList.spec.js | 2 +- .../layerlist/LayerListItem.spec.js | 71 +++++++++++-------- .../layerlist/LayerOpacityControl.spec.js | 14 ++-- .../overviewmap/OverviewMapPanel.spec.js | 7 +- 7 files changed, 108 insertions(+), 81 deletions(-) diff --git a/tests/unit/specs/components/bglayerswitcher/BgLayerList.spec.js b/tests/unit/specs/components/bglayerswitcher/BgLayerList.spec.js index b78737a3..9be96682 100644 --- a/tests/unit/specs/components/bglayerswitcher/BgLayerList.spec.js +++ b/tests/unit/specs/components/bglayerswitcher/BgLayerList.spec.js @@ -1,4 +1,3 @@ -import { toRaw } from 'vue'; import { shallowMount } from '@vue/test-utils'; import { bindMap, unbindMap } from '@/composables/Map'; import BgLayerList from '@/components/bglayerswitcher/BgLayerList'; @@ -74,7 +73,7 @@ describe('bglayerswitcher/BgLayerList.vue', () => { expect(vm.displayedLayers).to.have.lengthOf(1); const li = vm.displayedLayers[0]; - expect(toRaw(li)).to.equal(layerIn); + expect(li.toRaw()).to.equal(layerIn); expect(li.getVisible()).to.equal(true); expect(vm.selectedLid).to.equal(layerIn.get('lid')); }); diff --git a/tests/unit/specs/components/bglayerswitcher/LayerPreviewImage.spec.js b/tests/unit/specs/components/bglayerswitcher/LayerPreviewImage.spec.js index b5485ad9..f0520290 100644 --- a/tests/unit/specs/components/bglayerswitcher/LayerPreviewImage.spec.js +++ b/tests/unit/specs/components/bglayerswitcher/LayerPreviewImage.spec.js @@ -1,14 +1,17 @@ import { toRaw } from 'vue'; import { shallowMount } from '@vue/test-utils'; import LayerPreviewImage from '@/components/bglayerswitcher/LayerPreviewImage'; +import { LayerProxy } from '@/util/Layer'; import TileLayer from 'ol/layer/Tile'; import OSM from 'ol/source/OSM'; import View from 'ol/View'; -const osmLayer = new TileLayer({ - lid: 'osm', - source: new OSM() -}); +const osmLayer = new LayerProxy( + new TileLayer({ + lid: 'osm', + source: new OSM() + }) +) const view = new View({ projection: 'EPSG:3857', @@ -46,7 +49,7 @@ describe('bglayerswitcher/LayerPreviewImage.vue', () => { it('has correct props', () => { expect(toRaw(vm.mapView)).to.equal(view); - expect(toRaw(vm.layer)).to.equal(osmLayer); + expect(vm.layer).to.equal(osmLayer); expect(vm.width).to.equal(152); expect(vm.height).to.equal(114); expect(vm.previewIcon).to.equal('md:map'); @@ -83,11 +86,13 @@ describe('bglayerswitcher/LayerPreviewImage.vue', () => { }); it('has correct previewURL for static layer image', async () => { - const osmLayer2 = new TileLayer({ - lid: 'osm2', - source: new OSM(), - previewImage: 'http://my-image.png' - }); + const osmLayer2 = new LayerProxy( + new TileLayer({ + lid: 'osm2', + source: new OSM(), + previewImage: 'http://my-image.png' + }) + ); await comp.setProps({ layer: osmLayer2 }); expect(vm.previewURL).to.equal('http://my-image.png'); diff --git a/tests/unit/specs/components/layerlist/LayerLegendImage.spec.js b/tests/unit/specs/components/layerlist/LayerLegendImage.spec.js index 6d6eddab..8aaaf39c 100644 --- a/tests/unit/specs/components/layerlist/LayerLegendImage.spec.js +++ b/tests/unit/specs/components/layerlist/LayerLegendImage.spec.js @@ -3,26 +3,31 @@ import { shallowMount } from '@vue/test-utils'; import { createI18n } from 'vue-i18n'; import LayerLegendImage from '@/components/layerlist/LayerLegendImage'; import i18nMessages from '@/locales/en.json'; +import { LayerProxy } from '@/util/Layer'; import TileLayer from 'ol/layer/Tile'; import OSM from 'ol/source/OSM'; import TileWmsSource from 'ol/source/TileWMS'; import View from 'ol/View'; -const osmLayer = new TileLayer({ - lid: 'osm', - source: new OSM() -}); - -const wmsLayer = new TileLayer({ - lid: 'ahocevar-wms', - source: new TileWmsSource({ - url: 'https://ahocevar.com/geoserver/wms', - params: { - LAYERS: 'topp:states', - TILED: true - } +const osmLayer = new LayerProxy( + new TileLayer({ + lid: 'osm', + source: new OSM() }) -}); +); + +const wmsLayer = new LayerProxy( + new TileLayer({ + lid: 'ahocevar-wms', + source: new TileWmsSource({ + url: 'https://ahocevar.com/geoserver/wms', + params: { + LAYERS: 'topp:states', + TILED: true + } + }) + }) +) const view = new View({ projection: 'EPSG:3857', @@ -80,7 +85,7 @@ describe('layerlist/LayerLegendImage.vue', () => { it('has correct props', () => { expect(toRaw(vm.mapView)).to.equal(view); - expect(toRaw(vm.layer)).to.equal(osmLayer); + expect(vm.layer).to.equal(osmLayer); }); afterEach(() => { @@ -115,26 +120,30 @@ describe('layerlist/LayerLegendImage.vue', () => { }); it('has correct legendURL for static legend URL', async () => { - const layer = new TileLayer({ - lid: 'osm2', - source: new OSM(), - legendUrl: 'http://my-image.png' - }); + const layer = new LayerProxy( + new TileLayer({ + lid: 'osm2', + source: new OSM(), + legendUrl: 'http://my-image.png' + }) + ); await comp.setProps({ layer }); expect(vm.legendURL).to.equal('http://my-image.png'); }); it('has correct legendURL for legend format URL', async () => { - const layer = new TileLayer({ - lid: 'osm2', - source: new OSM(), - legendUrl: 'http://my-image.png?transparent={{TRANSPARENT}}&width={{WIDTH}}&SCALE={{SCALE}}&language={{LANGUAGE}}', - legendOptions: { - transparent: true, - width: 14 - } - }); + const layer = new LayerProxy( + new TileLayer({ + lid: 'osm2', + source: new OSM(), + legendUrl: 'http://my-image.png?transparent={{TRANSPARENT}}&width={{WIDTH}}&SCALE={{SCALE}}&language={{LANGUAGE}}', + legendOptions: { + transparent: true, + width: 14 + } + }) + ); await comp.setProps({ layer }); expect(vm.legendURL).to.equal('http://my-image.png?transparent=true&width=14&SCALE=139770566.00717944&language=en'); diff --git a/tests/unit/specs/components/layerlist/LayerList.spec.js b/tests/unit/specs/components/layerlist/LayerList.spec.js index 09d3fa5e..b82dcb4c 100644 --- a/tests/unit/specs/components/layerlist/LayerList.spec.js +++ b/tests/unit/specs/components/layerlist/LayerList.spec.js @@ -88,7 +88,7 @@ describe('layerlist/LayerList.vue', () => { expect(vm.displayedLayers).to.have.lengthOf(1); const li = vm.displayedLayers[0]; - expect(toRaw(li)).to.equal(layerIn); + expect(li.toRaw()).to.equal(layerIn); expect(li.getVisible()).to.be.true; }); diff --git a/tests/unit/specs/components/layerlist/LayerListItem.spec.js b/tests/unit/specs/components/layerlist/LayerListItem.spec.js index 77d0c3b6..b1b06cb7 100644 --- a/tests/unit/specs/components/layerlist/LayerListItem.spec.js +++ b/tests/unit/specs/components/layerlist/LayerListItem.spec.js @@ -1,14 +1,17 @@ import { toRaw } from 'vue'; import { shallowMount } from '@vue/test-utils'; import LayerListItem from '@/components/layerlist/LayerListItem'; +import { LayerProxy } from '@/util/Layer'; import TileLayer from 'ol/layer/Tile'; import OSM from 'ol/source/OSM'; import View from 'ol/View'; -const osmLayer = new TileLayer({ - lid: 'osm', - source: new OSM() -}); +const osmLayer = new LayerProxy( + new TileLayer({ + lid: 'osm', + source: new OSM() + }) +); const view = new View({ projection: 'EPSG:3857', @@ -104,11 +107,13 @@ describe('layerlist/LayerListItem.vue', () => { it('has correct showLegend property for layer', async () => { expect(vm.showLegend).to.be.false; - const osmLayer2 = new TileLayer({ - lid: 'osm2', - source: new OSM(), - legend: true - }); + const osmLayer2 = new LayerProxy( + new TileLayer({ + lid: 'osm2', + source: new OSM(), + legend: true + }) + ); await comp.setProps({ layer: osmLayer2 }); expect(vm.showLegend).to.be.true; @@ -117,11 +122,13 @@ describe('layerlist/LayerListItem.vue', () => { it('has correct showOpacityControl property for layer', async () => { expect(vm.showOpacityControl).to.be.false; - const osmLayer2 = new TileLayer({ - lid: 'osm2', - source: new OSM(), - opacityControl: true - }); + const osmLayer2 = new LayerProxy( + new TileLayer({ + lid: 'osm2', + source: new OSM(), + opacityControl: true + }) + ); await comp.setProps({ layer: osmLayer2 }); expect(vm.showOpacityControl).to.be.true; @@ -130,20 +137,24 @@ describe('layerlist/LayerListItem.vue', () => { it('has correct showDetails property for layer', async () => { expect(vm.showDetails).to.be.false; - const osmLayer2 = new TileLayer({ - lid: 'osm2', - source: new OSM(), - legend: true - }); + const osmLayer2 = new LayerProxy( + new TileLayer({ + lid: 'osm2', + source: new OSM(), + legend: true + }) + ); await comp.setProps({ layer: osmLayer2 }); expect(vm.showDetails).to.be.true; - const osmLayer3 = new TileLayer({ - lid: 'osm3', - source: new OSM(), - opacityControl: true - }); + const osmLayer3 = new LayerProxy( + new TileLayer({ + lid: 'osm3', + source: new OSM(), + opacityControl: true + }) + ); comp.setProps({ layer: osmLayer3 }); expect(vm.showDetails).to.be.true; @@ -152,11 +163,13 @@ describe('layerlist/LayerListItem.vue', () => { it('has correct layerLid property for layer', async () => { expect(vm.layerLid).to.equal('osm'); - const osmLayer2 = new TileLayer({ - lid: 'osm2', - source: new OSM(), - legend: true - }); + const osmLayer2 = new LayerProxy( + new TileLayer({ + lid: 'osm2', + source: new OSM(), + legend: true + }) + ); await comp.setProps({ layer: osmLayer2 }); expect(vm.layerLid).to.equal('osm2'); diff --git a/tests/unit/specs/components/layerlist/LayerOpacityControl.spec.js b/tests/unit/specs/components/layerlist/LayerOpacityControl.spec.js index 035bab35..a13f0ffa 100644 --- a/tests/unit/specs/components/layerlist/LayerOpacityControl.spec.js +++ b/tests/unit/specs/components/layerlist/LayerOpacityControl.spec.js @@ -1,13 +1,15 @@ -import { toRaw } from 'vue'; import { shallowMount } from '@vue/test-utils'; import LayerOpacityControl from '@/components/layerlist/LayerOpacityControl'; +import { LayerProxy } from '@/util/Layer'; import TileLayer from 'ol/layer/Tile'; import OSM from 'ol/source/OSM'; -const osmLayer = new TileLayer({ - lid: 'osm', - source: new OSM() -}); +const osmLayer = new LayerProxy( + new TileLayer({ + lid: 'osm', + source: new OSM() + }) +) const moduleProps = { layer: osmLayer @@ -34,7 +36,7 @@ describe('layerlist/LayerOpacityControl.vue', () => { }); it('has correct props', () => { - expect(toRaw(vm.layer)).to.equal(osmLayer); + expect(vm.layer).to.equal(osmLayer); }); afterEach(() => { diff --git a/tests/unit/specs/components/overviewmap/OverviewMapPanel.spec.js b/tests/unit/specs/components/overviewmap/OverviewMapPanel.spec.js index 70bbc8eb..99eb4a1b 100644 --- a/tests/unit/specs/components/overviewmap/OverviewMapPanel.spec.js +++ b/tests/unit/specs/components/overviewmap/OverviewMapPanel.spec.js @@ -1,4 +1,3 @@ -import { toRaw } from 'vue'; import { shallowMount } from '@vue/test-utils'; import { bindMap, unbindMap } from '@/composables/Map'; import OverviewMapPanel from '@/components/overviewmap/OverviewMapPanel'; @@ -81,7 +80,7 @@ describe('overviewmap/OverviewMapPanel.vue', () => { }); bindMap(map); - expect(toRaw(vm.selectedBgLayer)).to.equal(layerIn); + expect(vm.selectedBgLayer.toRaw()).to.equal(layerIn); }); it('selectedBgLayer is synced with the layer stack', async () => { @@ -102,12 +101,12 @@ describe('overviewmap/OverviewMapPanel.vue', () => { }); bindMap(map); - expect(toRaw(vm.selectedBgLayer)).to.equal(layerIn); + expect(vm.selectedBgLayer.toRaw()).to.equal(layerIn); layerIn.setVisible(false); map.addLayer(layerOut); - expect(toRaw(vm.selectedBgLayer)).to.equal(layerOut); + expect(vm.selectedBgLayer.toRaw()).to.equal(layerOut); }); afterEach(() => { From 75a9fc7903378460ad950af8bf6d21c2897bc4c0 Mon Sep 17 00:00:00 2001 From: Felix Schmenger Date: Wed, 27 Aug 2025 14:16:57 +0200 Subject: [PATCH 5/7] Introduce units test for LayerCollectionProxy and LayerProxy. --- tests/unit/specs/util/Layer.spec.js | 157 +++++++++++++++++++++++++++- 1 file changed, 156 insertions(+), 1 deletion(-) diff --git a/tests/unit/specs/util/Layer.spec.js b/tests/unit/specs/util/Layer.spec.js index c1ed1a9d..f0a127e2 100644 --- a/tests/unit/specs/util/Layer.spec.js +++ b/tests/unit/specs/util/Layer.spec.js @@ -1,4 +1,5 @@ -import LayerUtil from '@/util/Layer'; +import LayerUtil, { LayerProxy, LayerCollectionProxy } from '@/util/Layer'; + import Map from 'ol/Map'; import View from 'ol/View'; import TileLayer from 'ol/layer/Tile'; @@ -179,3 +180,157 @@ describe('LayerUtil', () => { expect(olMap.getView().getCenter()[1]).to.equal(0); }); }); + +describe('LayerProxy', () => { + it('is defined', () => { + expect(LayerProxy).to.not.be.an('undefined'); + }); + + it('has the correct functions', () => { + expect(LayerProxy.prototype.get).to.be.a('function'); + expect(LayerProxy.prototype.getProperties).to.be.a('function'); + expect(LayerProxy.prototype.toRaw).to.be.a('function'); + expect(LayerProxy.prototype.destroy).to.be.a('function'); + }); + + describe('methods', () => { + let layer; + let proxy; + + beforeEach(() => { + layer = new TileLayer({ + foo: 'bar', + source: new OSM() + }) + proxy = new LayerProxy(layer); + }); + + it('get reflects layer properties', () => { + expect(proxy.get('foo')).to.eql('bar'); + + layer.set('foo', 'bar2'); + expect(proxy.get('foo')).to.eql('bar2'); + }); + + it('getProperties reflects layer properties', () => { + expect(proxy.getProperties()).to.deep.equal(layer.getProperties()); + + layer.set('foo', 'bar2'); + expect(proxy.getProperties()).to.deep.equal(layer.getProperties()); + }); + + it('toRaw returns the raw layer', () => { + expect(proxy.toRaw()).to.equal(layer); + }); + + afterEach(() => { + proxy.destroy(); + }); + }); +}); + +describe('LayerCollectionProxy', () => { + it('is defined', () => { + expect(LayerCollectionProxy).to.not.be.an('undefined'); + }); + + it('has the correct functions', () => { + expect(LayerCollectionProxy.prototype.forEach).to.be.a('function'); + expect(LayerCollectionProxy.prototype.getArray).to.be.a('function'); + expect(LayerCollectionProxy.prototype.item).to.be.a('function'); + expect(LayerCollectionProxy.prototype.toRaw).to.be.a('function'); + expect(LayerCollectionProxy.prototype.destroy).to.be.a('function'); + }); + + describe('methods', () => { + let collection; + let proxy; + + const layer1 = new TileLayer({ + foo: 'bar', + source: new OSM() + }); + const layer2 = new TileLayer({ + foo: 'bar2', + source: new OSM() + }) + const layer3 = new TileLayer({ + foo: 'bar3', + source: new OSM() + }); + + beforeEach(() => { + const map = new Map({ + layers: [layer1, layer2] + }); + + collection = map.getLayers(); + proxy = new LayerCollectionProxy(collection); + }); + + it('getArray reflects layer collection', () => { + const layerProxies = proxy.getArray(); + const rawLayers = collection.getArray(); + + expect(layerProxies.length).to.equal(rawLayers.length); + for (let i = 0; i < rawLayers.length; i++) { + expect(layerProxies[i].toRaw()).to.equal(rawLayers[i]); + } + + collection.push(layer3); + expect(layerProxies.length).to.equal(rawLayers.length); + for (let i = 0; i < rawLayers.length; i++) { + expect(layerProxies[i].toRaw()).to.equal(rawLayers[i]); + } + }); + + it('forEach reflects layer collection', () => { + const rawLayers = collection.getArray(); + let layerProxies = []; + + proxy.forEach(layerProxy => { + layerProxies.push(layerProxy); + }); + expect(layerProxies.length).to.equal(rawLayers.length); + + for (let i = 0; i < rawLayers.length; i++) { + expect(layerProxies[i].toRaw()).to.equal(rawLayers[i]); + } + + layerProxies = []; + collection.push(layer3); + + proxy.forEach(layerProxy => { + layerProxies.push(layerProxy); + }); + expect(layerProxies.length).to.equal(rawLayers.length); + + for (let i = 0; i < rawLayers.length; i++) { + expect(layerProxies[i].toRaw()).to.equal(rawLayers[i]); + } + }); + + it('item reflects layer collection', () => { + const rawLayers = collection.getArray(); + + for (let i = 0; i < rawLayers.length; i++) { + const layerProxy = proxy.item(i); + expect(layerProxy.toRaw()).to.equal(rawLayers[i]); + } + + collection.push(layer3); + for (let i = 0; i < rawLayers.length; i++) { + const layerProxy = proxy.item(i); + expect(layerProxy.toRaw()).to.equal(rawLayers[i]); + } + }); + + it('toRaw returns the raw collection', () => { + expect(proxy.toRaw()).to.equal(collection); + }); + + afterEach(() => { + proxy.destroy(); + }); + }); +}); From 5298ed3f32dc2e7d37d36a8ea864ca560daf0a26 Mon Sep 17 00:00:00 2001 From: Felix Schmenger Date: Thu, 23 Oct 2025 16:24:57 +0200 Subject: [PATCH 6/7] Extend LayerProxy to make various getter functions reactive. --- src/util/Layer.js | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/util/Layer.js b/src/util/Layer.js index 47bcca58..d9cecc37 100644 --- a/src/util/Layer.js +++ b/src/util/Layer.js @@ -68,9 +68,22 @@ export class LayerProxy { * @param {ol.layer.Base} layer OL layer */ constructor (layer) { - this.layer = reactive(layer); + this.layer = layer; this.properties = reactive({}); + // Map getXXX methods declared in ol/layer/Base to property keys. + // For mappings see ol/layer/Property.js. + this.getterMap = { + getOpacity: 'opacity', + getVisible: 'visible', + getExtent: 'extent', + getZIndex: 'zIndex', + getMaxResolution: 'maxResolution', + getMinResolution: 'minResolution', + getMaxZoom: 'maxZoom', + getMinZoom: 'minZoom' + }; + // Set up listener to detect any property changes this.propertyChangeListener = (event) => { const key = event.key; @@ -83,7 +96,7 @@ export class LayerProxy { } else { this.properties[key] = value; } - } + }; this.layer.on('propertychange', this.propertyChangeListener); // Track existing properties @@ -98,6 +111,13 @@ export class LayerProxy { // observables. const proxy = new Proxy(this, { get: function (target, prop, receiver) { + // Intercept getXXX() calls and map to reactive properties + if (prop in target.getterMap) { + const key = target.getterMap[prop]; + return () => target.get(key); + } + + // Forward OL layer API calls except get/getProperties if (prop in target.layer && !['get', 'getProperties'].includes(prop)) { const p = target.layer[prop]; return (typeof p === 'function') ? p.bind(target.layer) : p; @@ -113,7 +133,7 @@ export class LayerProxy { /** * Gets a value of the layer. * @param {String} key Key name. - * @returns {String} Value. + * @returns {any} Value. */ get (key) { return this.properties[key]; From 75f24ef7ec2f953cb73f1464f07b4615d0e880f8 Mon Sep 17 00:00:00 2001 From: Felix Schmenger Date: Mon, 27 Oct 2025 10:41:25 +0100 Subject: [PATCH 7/7] Added unit tests for LayerProxy to verify results of getter methods. --- tests/unit/specs/util/Layer.spec.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/unit/specs/util/Layer.spec.js b/tests/unit/specs/util/Layer.spec.js index f0a127e2..2fe293f9 100644 --- a/tests/unit/specs/util/Layer.spec.js +++ b/tests/unit/specs/util/Layer.spec.js @@ -219,6 +219,35 @@ describe('LayerProxy', () => { expect(proxy.getProperties()).to.deep.equal(layer.getProperties()); }); + it('getter methods reflect layer getter methods', () => { + expect(proxy.getOpacity()).to.eql(layer.getOpacity()); + expect(proxy.getVisible()).to.eql(layer.getVisible()); + expect(proxy.getExtent()).to.eql(layer.getExtent()); + expect(proxy.getZIndex()).to.eql(layer.getZIndex()); + expect(proxy.getMaxResolution()).to.eql(layer.getMaxResolution()); + expect(proxy.getMinResolution()).to.eql(layer.getMinResolution()); + expect(proxy.getMaxZoom()).to.eql(layer.getMaxZoom()); + expect(proxy.getMinZoom()).to.eql(layer.getMinZoom()); + + layer.setOpacity(0.5); + layer.setVisible(false); + layer.setExtent([0, 0, 100, 100]); + layer.setZIndex(15); + layer.setMaxResolution(30); + layer.setMinResolution(5); + layer.setMaxZoom(10); + layer.setMinZoom(1); + + expect(proxy.getOpacity()).to.eql(layer.getOpacity()); + expect(proxy.getVisible()).to.eql(layer.getVisible()); + expect(proxy.getExtent()).to.eql(layer.getExtent()); + expect(proxy.getZIndex()).to.eql(layer.getZIndex()); + expect(proxy.getMaxResolution()).to.eql(layer.getMaxResolution()); + expect(proxy.getMinResolution()).to.eql(layer.getMinResolution()); + expect(proxy.getMaxZoom()).to.eql(layer.getMaxZoom()); + expect(proxy.getMinZoom()).to.eql(layer.getMinZoom()); + }); + it('toRaw returns the raw layer', () => { expect(proxy.toRaw()).to.equal(layer); });