From 742fc0dfe8a782cb0f139d9f372f095209a324c6 Mon Sep 17 00:00:00 2001 From: sethg Date: Mon, 17 Aug 2026 14:13:19 +0200 Subject: [PATCH 1/3] Allow multiple label items to be configured --- README.md | 7 + app/factory/Layer.js | 15 ++ app/util/SwitchLayer.js | 52 ++++- app/view/layer/StyleSwitcherRadioGroup.js | 10 +- app/view/menuitem/LayerLabels.js | 203 +++++++++++++++++--- test/spec/util/SwitchLayer.spec.js | 38 ++++ test/spec/view/menuitem/LayerLabels.spec.js | 55 ++++++ 7 files changed, 347 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index accd74c06..77f1840d2 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,13 @@ Navigate to the project folder and run the following: npm test ``` +To fix and linting issues: + +``` +npm run lintfix +npm run format +``` + Note - if there are errors such as `BasiGX not defined`, ensure that the submodules have been created using `git submodule update --init --recursive`. diff --git a/app/factory/Layer.js b/app/factory/Layer.js index 8e72376d5..78e4a4855 100644 --- a/app/factory/Layer.js +++ b/app/factory/Layer.js @@ -123,6 +123,21 @@ Ext.define('CpsiMapview.factory.Layer', { mapLayer.set('refreshLayerOption', allowRefresh); // indicator if a label option is drawn in layer context menu for wms layers mapLayer.set('labelClassName', layerConf.labelClassName); + // allow multiple label items + mapLayer.set('labels', layerConf.labels); + + if ( + !Ext.isEmpty(layerConf.labelClassName) && + Ext.isArray(layerConf.labels) && + layerConf.labels.length > 0 + ) { + Ext.Logger.warn( + 'Layer "' + + layerConf.layerKey + + '" has both "labelClassName" and "labels" configured - ' + + '"labels" takes priority and "labelClassName" will be ignored.' + ); + } // indicator if an opacity slider is offered in layer context menu const allowOpacitySlider = layerConf.opacitySlider !== false; mapLayer.set('opacitySlider', allowOpacitySlider); diff --git a/app/util/SwitchLayer.js b/app/util/SwitchLayer.js index c45c1235c..3fed46253 100644 --- a/app/util/SwitchLayer.js +++ b/app/util/SwitchLayer.js @@ -76,6 +76,40 @@ Ext.define('CpsiMapview.util.SwitchLayer', { }); }, + /** + * Get the active WMS label style for a layer + * + * @param {ol.layer.Layer} layer The layer to check + * @return {String|null} The active label style name, or null if + * none is configured + */ + getActiveLabelStyleName: function (layer) { + return ( + layer.get('activeLabelName') || layer.get('labelClassName') || null + ); + }, + + /** + * Builds the WMS STYLES parameter value for a layer, appending the + * label style (if any) + * + * @param {String} activatedStyle The style name + * @param {Boolean} labelsActive Whether a label style should be + * appended + * @param {String} activeLabelName The label style name to append + * @return {String} The combined STYLES parameter value + */ + buildWmsStyleList: function ( + activatedStyle, + labelsActive, + activeLabelName + ) { + if (labelsActive === true && activeLabelName) { + return activatedStyle + ',' + activeLabelName; + } + return activatedStyle; + }, + /** * Changes a switchlayer from one internal layer to the other. * @@ -141,13 +175,19 @@ Ext.define('CpsiMapview.util.SwitchLayer', { newLayer.set('activatedStyle', activatedStyle); if (newLayer.get('isWms')) { - // check if a label STYLES parameter was added --> keep this - // the STYLES value (SLD) for the labels - const labelClassName = newLayer.get('labelClassName'); - let wmsStyleList = activatedStyle; + const labelsActive = switchLayer.get('labelsActive') === true; + const activeLabelName = + staticMe.getActiveLabelStyleName(switchLayer); + const wmsStyleList = staticMe.buildWmsStyleList( + activatedStyle, + labelsActive, + activeLabelName + ); - if (newLayer.get('labelsActive') === true) { - wmsStyleList += ',' + labelClassName; + if (labelsActive && activeLabelName) { + // copy the active label the new layer + newLayer.set('labelsActive', true); + newLayer.set('activeLabelName', activeLabelName); } if (filters && filters.length > 0) { diff --git a/app/view/layer/StyleSwitcherRadioGroup.js b/app/view/layer/StyleSwitcherRadioGroup.js index 2a91456d4..ad50696ff 100644 --- a/app/view/layer/StyleSwitcherRadioGroup.js +++ b/app/view/layer/StyleSwitcherRadioGroup.js @@ -115,9 +115,13 @@ Ext.define('CpsiMapview.view.layer.StyleSwitcherRadioGroup', { if (layer.get('isWms')) { // check if a label STYLES parameter was added --> keep this // the STYLES value (SLD) for the labels - const labelClassName = layer.get('labelClassName'); - if (layer.get('labelsActive') === true) { - newStyle += ',' + labelClassName; + // 'activeLabelName' is set by LayerLabels when multiple label + // options are configured, 'labelClassName' is used for + // the single label-style case. + const activeLabelName = + layer.get('activeLabelName') || layer.get('labelClassName'); + if (layer.get('labelsActive') === true && activeLabelName) { + newStyle += ',' + activeLabelName; } // apply new style parameter and reload layer diff --git a/app/view/menuitem/LayerLabels.js b/app/view/menuitem/LayerLabels.js index 099e0b155..3f17e3bbe 100644 --- a/app/view/menuitem/LayerLabels.js +++ b/app/view/menuitem/LayerLabels.js @@ -2,6 +2,14 @@ * MenuItem showing a checkbox in the layer context menu that allows * a separate label layer to be displayed alongside the feature layer. * + * Also supports a `labels` config array, similar to `styles`: + * + * "labels": [ + * { "name": "LabelsName", "title": "Names" }, + * { "name": "LabelsCode", "title": "Codes" } + * ] + * + * This configuration creates a submenu with a radio-style choice. * @class CpsiMapview.view.menuitem.LayerLabels */ Ext.define('CpsiMapview.view.menuitem.LayerLabels', { @@ -22,12 +30,19 @@ Ext.define('CpsiMapview.view.menuitem.LayerLabels', { text: 'Labels', /** - * The style name for the labels layer. + * The style name for the labels layer. Used for the single labels. * @property {String} * @readonly */ labelClassName: null, + /** + * Configured label style options read from the layer's `labels` config array. + * @property {Object[]} + * @readonly + */ + labelOptions: null, + /** * Switch if the style should be applied * client sided or not @@ -45,14 +60,35 @@ Ext.define('CpsiMapview.view.menuitem.LayerLabels', { me.clientSideStyle = me.layer.getSource() instanceof ol.source.VectorTile || me.layer.getSource() instanceof ol.source.Vector; - // try to detect the 'labelClassName' property of a WMS layer + + // try to detect the label configuration of a WMS layer if ( me.layer.getSource() instanceof ol.source.TileWMS || me.layer.getSource() instanceof ol.source.ImageWMS ) { - me.labelClassName = me.layer.get('labelClassName'); + const labelsCfg = me.layer.get('labels'); + if (Ext.isArray(labelsCfg) && labelsCfg.length > 0) { + me.labelOptions = labelsCfg; + if (labelsCfg.length === 1) { + // a single configured option behaves like the + // single label menu item + me.labelClassName = labelsCfg[0].name; + } + } else { + me.labelClassName = me.layer.get('labelClassName'); + } } } + + // build the submenu + if ( + !me.clientSideStyle && + Ext.isArray(me.labelOptions) && + me.labelOptions.length > 1 + ) { + me.menu = me.buildLabelOptionsMenuCfg(); + } + me.callParent(); if (me.clientSideStyle) { @@ -70,13 +106,96 @@ Ext.define('CpsiMapview.view.menuitem.LayerLabels', { } me.setHidden(hideLabelsCheckbox); } else { - me.setHidden(Ext.isEmpty(me.labelClassName)); + const hasLabelOptions = + Ext.isArray(me.labelOptions) && me.labelOptions.length > 0; + me.setHidden(Ext.isEmpty(me.labelClassName) && !hasLabelOptions); } me.on('afterrender', me.onAfterrender); me.on('checkchange', me.onCheckChange); }, + /** + * Builds the config for the submenu of selectable label styles + * ("None" + one radio-button for each label option). + * + * @return {Object} Config object for an Ext.menu.Menu + * @private + */ + buildLabelOptionsMenuCfg: function () { + const me = this; + // the group name only needs to be unique within this submenu instance + const groupName = 'cmv-labels-group-' + Ext.id(); + + const items = [ + { + xtype: 'menucheckitem', + text: 'None', + group: groupName, + labelName: null, + checked: true, + checkHandler: me.onLabelOptionCheckChange, + scope: me + } + ]; + + Ext.Array.each(me.labelOptions, function (labelCfg) { + items.push({ + xtype: 'menucheckitem', + text: labelCfg.title || labelCfg.name, + group: groupName, + labelName: labelCfg.name, + checked: false, + checkHandler: me.onLabelOptionCheckChange, + scope: me + }); + }); + + return { + xtype: 'menu', + items: items + }; + }, + + /** + * Handles the 'checkchange' event of a submenu radio button. + * + * @param {Ext.menu.CheckItem} checkItem The submenu item itself + * @param {Boolean} checked Current checked state + * @private + */ + onLabelOptionCheckChange: function (checkItem, checked) { + const me = this; + if (!checked) { + return; + } + const labelName = checkItem.labelName; + me.addStyleParameters(!!labelName, labelName); + // update checked property without re-triggering onCheckChange + const suppressEvents = true; + me.setChecked(!!labelName, suppressEvents); + }, + + /** + * Keeps the submenu's radio items in sync with the active + * label name (e.g. after the parent checkbox is toggled directly, + * or after detecting the initial state on render). + * + * @param {String} activeLabelName Currently active label name, or + * null if none is active + * @private + */ + syncLabelOptionsMenu: function (activeLabelName) { + const me = this; + if (!me.menu) { + return; + } + me.menu.items.each(function (item) { + const suppressEvents = true; + item.setChecked(item.labelName === activeLabelName, suppressEvents); + }); + }, + /** * Handles the 'afterrender' event of this menu item. * Checks / unchecks box dependent on if labels are displayed. @@ -118,21 +237,42 @@ Ext.define('CpsiMapview.view.menuitem.LayerLabels', { /** * Handles the 'afterrender' event of this menu item for server * side labeling. - * Checks / unchecks box dependent on if labels are displayed. + * Checks / unchecks box dependent on if labels are displayed, and + * (when multiple label options are configured) syncs the submenu + * to whichever configured label style is currently active. * * @param {Ext.menu.CheckItem} checkItem The menu item itself */ onAfterrenderServerSide: function (checkItem) { const me = this; - if (Ext.isEmpty(me.labelClassName)) { - return; - } const wmsSource = me.layer.getSource(); const wmsParams = wmsSource.getParams(); // set the checkbox value, but no need to call onCheckChange again const suppressEvents = true; + if (Ext.isArray(me.labelOptions) && me.labelOptions.length > 1) { + let activeLabelName = null; + if (wmsParams && !Ext.isEmpty(wmsParams.STYLES)) { + const activeEntry = Ext.Array.findBy( + me.labelOptions, + function (labelCfg) { + return wmsParams.STYLES.indexOf(labelCfg.name) !== -1; + } + ); + activeLabelName = activeEntry ? activeEntry.name : null; + } + me.layer.set('activeLabelName', activeLabelName); + me.layer.set('labelsActive', !!activeLabelName); + checkItem.setChecked(!!activeLabelName, suppressEvents); + me.syncLabelOptionsMenu(activeLabelName); + return; + } + + if (Ext.isEmpty(me.labelClassName)) { + return; + } + if ( wmsParams && !Ext.isEmpty(wmsParams.STYLES) && @@ -154,6 +294,15 @@ Ext.define('CpsiMapview.view.menuitem.LayerLabels', { const me = this; if (me.clientSideStyle) { me.addLabelStyle(checked); + } else if (Ext.isArray(me.labelOptions) && me.labelOptions.length > 1) { + // the top-level menu was toggled directly + // turning it off means "None"; turning it on + // uses the previously active option or the first configured option + const labelName = checked + ? me.layer.get('activeLabelName') || me.labelOptions[0].name + : null; + me.addStyleParameters(checked, labelName); + me.syncLabelOptionsMenu(labelName); } else { me.addStyleParameters(checked); } @@ -183,11 +332,14 @@ Ext.define('CpsiMapview.view.menuitem.LayerLabels', { * WMS LAYERS param with custom style. * * @param {Boolean} addLabel Add or remove the label layer + * @param {String} [labelClassNameOverride] Label style name + * to use instead of `me.labelClassName`. Used when + * multiple `labels` options are configured. */ - addStyleParameters: function (addLabel) { + addStyleParameters: function (addLabel, labelClassNameOverride) { const me = this; const layer = me.layer; - const labelClassName = me.labelClassName; + const labelClassName = labelClassNameOverride || me.labelClassName; const wmsSource = layer.getSource(); const wmsParams = wmsSource.getParams(); const layers = wmsParams.LAYERS || []; @@ -195,24 +347,27 @@ Ext.define('CpsiMapview.view.menuitem.LayerLabels', { let layerList = Ext.isArray(layers) ? layers : layers.split(','); const stylesList = Ext.isArray(styles) ? styles : styles.split(','); - if (addLabel) { - if (layerList.length === 1) { - // add a duplicate of the layer - layerList.push(layerList[0]); - // apply the label style on the duplicated layer - stylesList.push(labelClassName); - - // mark layer that labels are active - layer.set('labelsActive', true); - } - } else { - // remove any duplicate layer names created by adding labels + // if a label style is currently active, remove it first + if (layer.get('labelsActive')) { + const previousLabelClassName = + layer.get('activeLabelName') || me.labelClassName; layerList = Ext.Array.unique(layerList); - // remove any label styles - Ext.Array.remove(stylesList, labelClassName); + Ext.Array.remove(stylesList, previousLabelClassName); + } + if (addLabel && labelClassName) { + // add a duplicate of the layer + layerList.push(layerList[0]); + // apply the label style on the duplicated layer + stylesList.push(labelClassName); + + // mark layer that labels are active, and which style is used + layer.set('labelsActive', true); + layer.set('activeLabelName', labelClassName); + } else { // mark layer that labels are inactive layer.set('labelsActive', false); + layer.set('activeLabelName', null); } wmsSource.getParams().LAYERS = layerList.join(','); diff --git a/test/spec/util/SwitchLayer.spec.js b/test/spec/util/SwitchLayer.spec.js index c4d3c4ae6..7f6c76da3 100644 --- a/test/spec/util/SwitchLayer.spec.js +++ b/test/spec/util/SwitchLayer.spec.js @@ -65,5 +65,43 @@ describe('CpsiMapview.util.SwitchLayer', function () { const fn = cmp.updateLayerTreeForSwitchLayers; expect(fn).not.to.be(undefined); }); + + it('#getActiveLabelStyleName', function () { + const fn = cmp.getActiveLabelStyleName; + expect(fn).not.to.be(undefined); + + const layerWithActiveLabelName = new ol.layer.Image({ + source: new ol.source.ImageWMS() + }); + + layerWithActiveLabelName.set('activeLabelName', 'LabelsRoads'); + layerWithActiveLabelName.set('labelClassName', 'OtherLabels'); + + expect(fn(layerWithActiveLabelName)).to.be('LabelsRoads'); + + const layerWithLabelClassNameOnly = new ol.layer.Image({ + source: new ol.source.ImageWMS() + }); + layerWithLabelClassNameOnly.set('labelClassName', 'OtherLabels'); + expect(fn(layerWithLabelClassNameOnly)).to.be('OtherLabels'); + + const layerWithNoLabelConfig = new ol.layer.Image({ + source: new ol.source.ImageWMS() + }); + expect(fn(layerWithNoLabelConfig)).to.be(null); + }); + + it('#buildWmsStyleList', function () { + const fn = cmp.buildWmsStyleList; + expect(fn).not.to.be(undefined); + + expect(fn('DefaultStyle', true, 'LabelsRoads')).to.be( + 'DefaultStyle,LabelsRoads' + ); + expect(fn('DefaultStyle', false, 'LabelsRoads')).to.be( + 'DefaultStyle' + ); + expect(fn('DefaultStyle', true, null)).to.be('DefaultStyle'); + }); }); }); diff --git a/test/spec/view/menuitem/LayerLabels.spec.js b/test/spec/view/menuitem/LayerLabels.spec.js index b78e45e9d..fb58456a1 100644 --- a/test/spec/view/menuitem/LayerLabels.spec.js +++ b/test/spec/view/menuitem/LayerLabels.spec.js @@ -62,5 +62,60 @@ describe('CpsiMapview.view.menuitem.LayerLabels', function () { expect(inst.clientSideStyle).to.be(false); expect(inst.isHidden()).to.be(true); }); + + describe('labels config', function () { + it('is not hidden with a WMS layer and labelClassName set', function () { + const layer = new ol.layer.Image({ + source: new ol.source.ImageWMS() + }); + layer.set('labelClassName', 'LabelsDefault'); + const inst = Ext.create( + 'CpsiMapview.view.menuitem.LayerLabels', + { + layer: layer + } + ); + expect(inst.labelClassName).to.be('LabelsDefault'); + expect(inst.isHidden()).to.be(false); + }); + + it('treats a single-entry labels array like labelClassName', function () { + const layer = new ol.layer.Image({ + source: new ol.source.ImageWMS() + }); + layer.set('labels', [ + { name: 'LabelsDefault', title: 'Default' } + ]); + const inst = Ext.create( + 'CpsiMapview.view.menuitem.LayerLabels', + { + layer: layer + } + ); + expect(inst.labelClassName).to.be('LabelsDefault'); + expect(inst.menu).to.be(undefined); + expect(inst.isHidden()).to.be(false); + }); + + it('builds a submenu with a multi-entry labels array', function () { + const layer = new ol.layer.Image({ + source: new ol.source.ImageWMS() + }); + layer.set('labels', [ + { name: 'LabelsRoads', title: 'Road names' }, + { name: 'LabelsRivers', title: 'River names' } + ]); + const inst = Ext.create( + 'CpsiMapview.view.menuitem.LayerLabels', + { + layer: layer + } + ); + expect(inst.labelOptions.length).to.be(2); + expect(inst.isHidden()).to.be(false); + // 'None' + 2 configured options + expect(inst.menu.items.length).to.be(3); + }); + }); }); }); From c27801a24911dbe240324ab77950eb5f718e16af Mon Sep 17 00:00:00 2001 From: sethg Date: Mon, 17 Aug 2026 17:25:49 +0200 Subject: [PATCH 2/3] Add guard --- app/view/menuitem/LayerLabels.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/view/menuitem/LayerLabels.js b/app/view/menuitem/LayerLabels.js index 3f17e3bbe..0b4c9d448 100644 --- a/app/view/menuitem/LayerLabels.js +++ b/app/view/menuitem/LayerLabels.js @@ -220,6 +220,11 @@ Ext.define('CpsiMapview.view.menuitem.LayerLabels', { */ onAfterrenderClientSide: function (checkItem) { const me = this; + + if (!me.layer || me.layer instanceof ol.layer.Group) { + return; + } + const activatedStyle = me.layer.get('activatedStyle'); const styles = me.layer.get('styles'); From 8d35773e58c7ff49f110f1e6e2c056c26f17ce4d Mon Sep 17 00:00:00 2001 From: sethg Date: Tue, 18 Aug 2026 12:18:59 +0200 Subject: [PATCH 3/3] Add guards --- app/view/menuitem/LayerLabels.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/view/menuitem/LayerLabels.js b/app/view/menuitem/LayerLabels.js index 0b4c9d448..964197a88 100644 --- a/app/view/menuitem/LayerLabels.js +++ b/app/view/menuitem/LayerLabels.js @@ -204,6 +204,11 @@ Ext.define('CpsiMapview.view.menuitem.LayerLabels', { */ onAfterrender: function (checkItem) { const me = this; + + if (!me.layer || me.layer instanceof ol.layer.Group) { + return; + } + if (me.clientSideStyle) { me.onAfterrenderClientSide(checkItem); } else { @@ -220,11 +225,6 @@ Ext.define('CpsiMapview.view.menuitem.LayerLabels', { */ onAfterrenderClientSide: function (checkItem) { const me = this; - - if (!me.layer || me.layer instanceof ol.layer.Group) { - return; - } - const activatedStyle = me.layer.get('activatedStyle'); const styles = me.layer.get('styles'); @@ -250,6 +250,12 @@ Ext.define('CpsiMapview.view.menuitem.LayerLabels', { */ onAfterrenderServerSide: function (checkItem) { const me = this; + + if (!me.layer.getSource || !me.layer.getSource().getParams) { + // not a WMS layer + return; + } + const wmsSource = me.layer.getSource(); const wmsParams = wmsSource.getParams();