From 73762829c9450a91155a8449a1c9fd7e980df30a Mon Sep 17 00:00:00 2001 From: mahmoudadel54 Date: Thu, 12 Mar 2026 22:31:09 +0200 Subject: [PATCH 1/4] #12044: Ability to setup scale limits for vector/WFS styling rules - handle functionality of scale limits layer filter for cesium/ol for wfs and vector layers - handle functionality of scale limits layer filter in print - add unit tests --- .../map/cesium/plugins/VectorLayer.js | 8 ++- .../plugins/styleeditor/VectorStyleEditor.jsx | 14 +++- web/client/selectors/__tests__/map-test.js | 14 +++- web/client/selectors/map.js | 4 +- web/client/utils/MapUtils.js | 50 ++++++++++++++ web/client/utils/PrintUtils.js | 35 ++++++++-- web/client/utils/__tests__/PrintUtils-test.js | 30 +++++++++ .../utils/cesium/GeoJSONStyledFeatures.js | 27 ++++++++ .../__tests__/GeoJSONStyledFeatures-test.js | 21 ++++++ .../utils/styleparser/PrintStyleParser.js | 8 ++- .../utils/styleparser/StyleParserUtils.js | 23 +++++++ .../__tests__/PrintStyleParser-test.js | 65 +++++++++++++++++++ .../__tests__/StyleParserUtils-test.js | 47 +++++++++++++- 13 files changed, 330 insertions(+), 16 deletions(-) diff --git a/web/client/components/map/cesium/plugins/VectorLayer.js b/web/client/components/map/cesium/plugins/VectorLayer.js index 86c2aba45f0..f7234adf653 100644 --- a/web/client/components/map/cesium/plugins/VectorLayer.js +++ b/web/client/components/map/cesium/plugins/VectorLayer.js @@ -35,7 +35,8 @@ const createLayer = (options, map) => { map: map, opacity: options.opacity, queryable: options.queryable === undefined || options.queryable, - featureFilter: vectorFeatureFilter // make filter for features if filter is existing + featureFilter: vectorFeatureFilter, // make filter for features if filter is existing + styleRules: options?.style?.body?.rules || [] }); layerToGeoStylerStyle(options) @@ -69,6 +70,11 @@ Layers.registerType('vector', { } if (layer?.styledFeatures && !isEqual(newOptions.style, oldOptions.style)) { + // update style rules here + if (!isEqual(newOptions?.style?.body?.rules, oldOptions?.style?.body?.rules)) { + let styleRules = newOptions?.style?.body?.rules || []; + layer.styledFeatures._setStyleRules(styleRules); + } layerToGeoStylerStyle(newOptions) .then((style) => { getStyle(applyDefaultStyleToVectorLayer({ ...newOptions, style }), 'cesium') diff --git a/web/client/plugins/styleeditor/VectorStyleEditor.jsx b/web/client/plugins/styleeditor/VectorStyleEditor.jsx index 165f2907644..97d9d2323a2 100644 --- a/web/client/plugins/styleeditor/VectorStyleEditor.jsx +++ b/web/client/plugins/styleeditor/VectorStyleEditor.jsx @@ -30,6 +30,9 @@ import { classificationVector } from '../../api/StyleEditor'; import SLDService from '../../api/SLDService'; import { classifyGeoJSON, availableMethods } from '../../api/GeoJSONClassification'; import { getLayerJSONFeature } from '../../observables/wfs'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { scalesSelector } from '../../selectors/map'; const { getColors } = SLDService; @@ -84,7 +87,8 @@ function VectorStyleEditor({ 'Courier New', 'Brush Script MT' ], - onUpdateNode = () => {} + onUpdateNode = () => {}, + scales = [] }) { const request = capabilitiesRequest[layer?.type]; @@ -253,9 +257,13 @@ function VectorStyleEditor({ simple: !['vector', 'wfs'].includes(layer?.type), supportedSymbolizerMenuOptions: ['Simple', 'Extrusion', 'Classification'], fonts, - enableFieldExpression: ['vector', 'wfs'].includes(layer.type) + enableFieldExpression: ['vector', 'wfs'].includes(layer.type), + scales }} /> ); } -export default VectorStyleEditor; +const ConnectedVectorStyleEditor = connect(createSelector([scalesSelector], (scales) => ({ + scales: scales.map(scale => Math.round(scale)) +})))(VectorStyleEditor); +export default ConnectedVectorStyleEditor; diff --git a/web/client/selectors/__tests__/map-test.js b/web/client/selectors/__tests__/map-test.js index 5f8708a7dcf..c416b004df3 100644 --- a/web/client/selectors/__tests__/map-test.js +++ b/web/client/selectors/__tests__/map-test.js @@ -29,7 +29,8 @@ import { mapInfoAttributesSelector, showEditableFeatureCheckboxSelector, mapOptionsSelector, - mapEnableImageryOverlaySelector + mapEnableImageryOverlaySelector, + resolutionsSelector } from '../map'; const center = {x: 1, y: 1}; @@ -78,7 +79,18 @@ describe('Test map selectors', () => { expect(projection).toExist(); expect(projection).toBe(proj); }); + it('test resolutionsSelector from map', () => { + const resolutions = resolutionsSelector({...state, map: {...state.map, resolutions: [1000, 500, 250, 100]}}); + expect(resolutions).toExist(); + expect(resolutions.length).toEqual(4); + }); + it('test resolutionsSelector from map if it there are no resolutions in map state like in case cesium map', () => { + const resolutions = resolutionsSelector(state); + + expect(resolutions).toExist(); + expect(resolutions.length).toEqual(22); + }); it('test mapSelector from map with history', () => { const props = mapSelector({map: {present: {center}}}); diff --git a/web/client/selectors/map.js b/web/client/selectors/map.js index a85b4a21a57..7607dc5f85f 100644 --- a/web/client/selectors/map.js +++ b/web/client/selectors/map.js @@ -10,7 +10,7 @@ import CoordinatesUtils from '../utils/CoordinatesUtils'; import { createSelector } from 'reselect'; import {get, memoize, round} from 'lodash'; -import {detectIdentifyInMapPopUp} from "../utils/MapUtils"; +import {detectIdentifyInMapPopUp, getResolutions} from "../utils/MapUtils"; import { isLoggedIn } from './security'; /** @@ -80,7 +80,7 @@ export const configuredMinZoomSelector = state => { export const mapLimitsSelector = state => get(mapSelector(state), "limits"); export const mapBboxSelector = state => get(mapSelector(state), "bbox"); export const minZoomSelector = state => get(mapLimitsSelector(state), "minZoom"); -export const resolutionsSelector = state => get(mapSelector(state), "resolutions"); +export const resolutionsSelector = state => get(mapSelector(state), "resolutions") || getResolutions(); export const currentZoomLevelSelector = state => get(mapSelector(state), "zoom"); export const currentResolutionSelector = createSelector( resolutionsSelector, diff --git a/web/client/utils/MapUtils.js b/web/client/utils/MapUtils.js index d09a569aecb..950c48bec9e 100644 --- a/web/client/utils/MapUtils.js +++ b/web/client/utils/MapUtils.js @@ -5,6 +5,7 @@ * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. */ +import * as Cesium from 'cesium'; import { pick, @@ -343,6 +344,55 @@ export function getScale(projection, dpi, resolution) { const dpu = dpi2dpu(dpi, projection); return resolution * dpu; } + +/** + * Calculates the map scale denominator at the center of the Cesium viewer's screen. + * + * * @param {Cesium.Viewer} viewer - The Cesium Viewer instance containing the scene and camera. + * @returns {number} The map scale denominator (M in 1:M) at the screen center. + * Returns a fallback scale based on camera height if the camera + * is looking at space or the globe intersection fails. + **/ +export function getMapScaleForCesium(viewer) { + const FALLBACK_EARTH_CIRCUMFERENCE_METERS = 80000000; + const cesiumDefaultProj = "EPSG:3857"; + const scene = viewer.scene; + const camera = scene.camera; + const canvas = scene.canvas; + + // 1. Get two points at the center of the screen, 1 pixel apart horizontally + const centerX = Math.floor(canvas.clientWidth / 2); + const centerY = Math.floor(canvas.clientHeight / 2); + + const leftPoint = new Cesium.Cartesian2(centerX, centerY); + const rightPoint = new Cesium.Cartesian2(centerX + 1, centerY); + + // 2. Convert screen pixels to Globe positions (Cartesian3) + const leftRay = camera.getPickRay(leftPoint); + const rightRay = camera.getPickRay(rightPoint); + + const leftPos = scene.globe.pick(leftRay, scene); + const rightPos = scene.globe.pick(rightRay, scene); + + + if (!Cesium.defined(leftPos) || !Cesium.defined(rightPos)) { + console.warn('Camera is looking at space/sky'); + const cameraPosition = viewer.camera.positionCartographic; + const currentZoom = Math.log2(FALLBACK_EARTH_CIRCUMFERENCE_METERS / (cameraPosition.height)) + 1; + const resolutions = getResolutions(); + const resolution = resolutions[Math.round(currentZoom)]; + const scaleVal = getScale(cesiumDefaultProj, DEFAULT_SCREEN_DPI, resolution); + return scaleVal; + } + + const leftCartographic = scene.globe.ellipsoid.cartesianToCartographic(leftPos); + const rightCartographic = scene.globe.ellipsoid.cartesianToCartographic(rightPos); + + const geodesic = new Cesium.EllipsoidGeodesic(leftCartographic, rightCartographic); + const resolution = geodesic.surfaceDistance; // This is meters per 1 pixel [resolution] + const scaleValue = getScale(cesiumDefaultProj, DEFAULT_SCREEN_DPI, resolution); + return scaleValue; +} /** * get random coordinates within CRS extent * @param {string} crs the code of the projection for example EPSG:4346 diff --git a/web/client/utils/PrintUtils.js b/web/client/utils/PrintUtils.js index dbd94c34023..33df3264717 100644 --- a/web/client/utils/PrintUtils.js +++ b/web/client/utils/PrintUtils.js @@ -396,6 +396,32 @@ export function getScalesByResolutions(resolutions, ratio, projection = "EPSG:38 }); return scales; } +/** + * Calculates the map print scale denominator (1 : X) for a given print specification. + * + * Handles multiple scale sources: + * - Fixed scales from spec.scales + * - Dynamic scales from projection (via getScales) + * - Custom scales from resolutions (via getScalesByResolutions) when editScale is enabled + * + * @param {Object} rawSpec - Raw print specification object + * @returns {number} Rounded scale denominator (e.g., 5000 for 1:5000 scale) + **/ +export const getMapPrintScale = (rawSpec, state) => { + const {params, mergeableParams, excludeLayersFromLegend, ...baseSpec} = rawSpec; + const spec = {...baseSpec, ...params}; + const printMap = state?.print?.map; + // * use [spec.zoom] the actual zoom in case useFixedScales = false else use [spec.scaleZoom] the fixed zoom scale not actual + const projectedZoom = Math.round(printMap?.useFixedScales && !printMap?.editScale ? spec.scaleZoom : spec.zoom); + const layout = head(state?.print?.capabilities?.layouts?.filter((l) => l.name === getLayoutName(spec)) || []); + const ratio = getResolutionMultiplier(layout?.map?.width, 370) ?? 1; + const scales = printMap?.editScale ? + printMap.mapPrintResolutions?.length ? + getScalesByResolutions(printMap.mapPrintResolutions, ratio, spec.projection) : + getScales(spec.projection) : spec.scales || getScales(spec.projection); + const reprojectedScale = printMap?.editScale ? scales[projectedZoom] : scales[projectedZoom] || defaultScales[projectedZoom]; + return Math.round(reprojectedScale); +}; /** * Creates the mapfish print specification from the current configuration @@ -765,7 +791,7 @@ export const specCreators = { } }, vector: { - map: (layer, spec) => ({ + map: (layer, spec, state) => ({ type: 'Vector', name: layer.name, "opacity": getOpacity(layer), @@ -780,7 +806,7 @@ export const specCreators = { geoJson: reprojectGeoJson({ type: "FeatureCollection", features: layer?.style?.format === 'geostyler' && layer?.style?.body - ? printStyleParser.writeStyle(layer.style.body, true)({ layer, spec }) + ? printStyleParser.writeStyle(layer.style.body, true)({ layer, spec, mapPrintScale: getMapPrintScale(spec, state) }) : layer.features.map( f => ({...f, properties: {...f.properties, ms_style: f && f.geometry && f.geometry.type && f.geometry.type.replace("Multi", "") || 1}})) }, "EPSG:4326", @@ -839,10 +865,9 @@ export const specCreators = { } }, wfs: { - map: (layer) => ({ + map: (layer, spec, state) => ({ type: 'Vector', name: layer.name, - "opacity": getOpacity(layer), styleProperty: "ms_style", styles: { 1: PrintUtils.toOpenLayers2Style(layer, layer.style), @@ -855,7 +880,7 @@ export const specCreators = { geoJson: layer.geoJson && { type: "FeatureCollection", features: layer?.style?.format === 'geostyler' && layer?.style?.body - ? printStyleParser.writeStyle(layer.style.body, true)({ layer: { ...layer, features: layer.geoJson.features } }) + ? printStyleParser.writeStyle(layer.style.body, true)({ layer: { ...layer, features: layer.geoJson.features }, spec: {...spec, projection: 'EPSG:3857', mapPrintScale: getMapPrintScale(spec, state)}}) : layer.geoJson.features.map(f => ({ ...f, properties: { ...f.properties, ms_style: f && f.geometry && f.geometry.type && f.geometry.type.replace("Multi", "") || 1 } })) } } diff --git a/web/client/utils/__tests__/PrintUtils-test.js b/web/client/utils/__tests__/PrintUtils-test.js index 72dfdd91201..a1edf770469 100644 --- a/web/client/utils/__tests__/PrintUtils-test.js +++ b/web/client/utils/__tests__/PrintUtils-test.js @@ -15,6 +15,7 @@ import { toAbsoluteURL, getMapSize, getNearestZoom, + getMapPrintScale, getMapfishPrintSpecification, rgbaTorgb, specCreators, @@ -570,6 +571,35 @@ describe('PrintUtils', () => { const scales = [10000000, 1000000, 10000, 1000]; expect(getNearestZoom(18.3, scales)).toBe(2); }); + describe('getMapPrintScale - Core', () => { + it('calculates scale from zoom and projection', () => { + const result = getMapPrintScale( + { zoom: 1, projection: 'EPSG:3857', sheet: "A2" }, + { print: { map: {}, capabilities: { layouts: [{"name": "A2_no_legend", "map": {"width": 837, "height": 1340 }, "rotation": true}] } } } + ); + expect(result).toExist(); + expect(Number.isInteger(result)).toBe(true); + expect(result).toEqual(295829355); + }); + + it('respects useFixedScales flag', () => { + const result = getMapPrintScale( + { zoom: 1, scaleZoom: 2, projection: 'EPSG:3857', sheet: "A2" }, + { print: { map: { useFixedScales: true }, capabilities: { layouts: [{"name": "A2_no_legend", "map": {"width": 837, "height": 1340 }, "rotation": true}] } } } + ); + expect(result).toExist(); + expect(result).toEqual(147914678); + }); + + it('handles editScale mode with resolutions', () => { + const result = getMapPrintScale( + { zoom: 0, projection: 'EPSG:3857', sheet: "A2" }, + { print: { map: { editScale: true, mapPrintResolutions: [0.5] }, capabilities: { layouts: [{"name": "A2_no_legend", "map": {"width": 837, "height": 1340 }, "rotation": true}] } } } + ); + expect(result).toExist(); + expect(result).toEqual(591658711); + }); + }); it('getMapfishPrintSpecification', (done) => { getMapfishPrintSpecification(testSpec).then(printSpec => { expect(printSpec).toExist(); diff --git a/web/client/utils/cesium/GeoJSONStyledFeatures.js b/web/client/utils/cesium/GeoJSONStyledFeatures.js index 16eca71c07d..756b8e2d765 100644 --- a/web/client/utils/cesium/GeoJSONStyledFeatures.js +++ b/web/client/utils/cesium/GeoJSONStyledFeatures.js @@ -3,6 +3,8 @@ import turfFlatten from '@turf/flatten'; import omit from 'lodash/omit'; import isArray from 'lodash/isArray'; import uuid from 'uuid/v1'; +import { getMapScaleForCesium } from '../MapUtils'; +import { geoStylerScaleDenominatorFilter } from '../styleparser/StyleParserUtils'; /** * validate the coordinates and ensure: @@ -73,9 +75,11 @@ class GeoJSONStyledFeatures { this._dataSource = new Cesium.CustomDataSource(options.id); this._primitives = new Cesium.PrimitiveCollection({ destroyPrimitives: true }); this._map = options.map; + this._onCameraMoveEndBound = this._updateScaleVisibility.bind(this); if (this._map) { this._map.scene.primitives.add(this._primitives); this._map.dataSources.add(this._dataSource); + this._map.camera.moveEnd.addEventListener(this._onCameraMoveEndBound); } this._styledFeatures = []; this._entities = []; @@ -90,6 +94,7 @@ class GeoJSONStyledFeatures { this._uuidKey = '__ms_uuid_key__' + uuid(); // needs to be run after this._uuidKey this.setFeatures(options.features); + this._styleRules = options?.styleRules; } _addCustomProperties(obj) { obj._msIsQueryable = () => this._queryable; @@ -380,6 +385,7 @@ class GeoJSONStyledFeatures { this._updateGroundPolygonPrimitive(styledFeatures, forceUpdate); } this._styledFeatures = [...styledFeatures]; + this._updateScaleVisibility(); setTimeout(() => this._map.scene.requestRender()); }); } @@ -419,11 +425,32 @@ class GeoJSONStyledFeatures { this._featureFilter = featureFilter; this._update(); } + _updateScaleVisibility() { + if (!this._map) return; + const currentMapScale = getMapScaleForCesium(this._map); + this._entities.forEach(wrapper => { + const { entity} = wrapper; + // If no custom rules → apply default visibility + if (!this._styleRules || this._styleRules.length === 0) { + entity.show = true; // Keep visible + return; + } + const validRules = this._styleRules.filter(rule => + geoStylerScaleDenominatorFilter(rule, currentMapScale) + ); + entity.show = validRules.length > 0; + }); + this._map.scene.requestRender(); + } + _setStyleRules(rules) { + this._styleRules = rules; + } destroy() { this._primitives.removeAll(); this._map.scene.primitives.remove(this._primitives); this._dataSource.entities.removeAll(); this._map.dataSources.remove(this._dataSource); + this._map.camera.moveEnd.removeEventListener(this._onCameraMoveEndBound); } } diff --git a/web/client/utils/cesium/__tests__/GeoJSONStyledFeatures-test.js b/web/client/utils/cesium/__tests__/GeoJSONStyledFeatures-test.js index 1ec314030d8..ab240610f92 100644 --- a/web/client/utils/cesium/__tests__/GeoJSONStyledFeatures-test.js +++ b/web/client/utils/cesium/__tests__/GeoJSONStyledFeatures-test.js @@ -42,4 +42,25 @@ describe('Test GeoJSONStyledFeatures', () => { }); expect(styledFeatures._features.length).toBe(1); }); + it('test passing styleRules to GeoJSONStyledFeatures', () => { + const styledFeatures = new GeoJSONStyledFeatures({ + features: [{ + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [[[7, 41], [7, 41], [7, 41], [7, 41]]] + }, + properties: {} + }, { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [[[7, 41], [14, 41], [14, 46], [7, 46]]] + }, + properties: {} + }], + styleRules: [{style: {body: { rules: [{ruleId: "123"}]}}}] + }); + expect(styledFeatures._styleRules.length).toBe(1); + }); }); diff --git a/web/client/utils/styleparser/PrintStyleParser.js b/web/client/utils/styleparser/PrintStyleParser.js index 4838cf4eb73..af6acc7e248 100644 --- a/web/client/utils/styleparser/PrintStyleParser.js +++ b/web/client/utils/styleparser/PrintStyleParser.js @@ -13,7 +13,8 @@ import { geoStylerStyleFilter, drawWellKnownNameImageFromSymbolizer, parseSymbolizerExpressions, - getCachedImageById + getCachedImageById, + geoStylerScaleDenominatorFilter } from './StyleParserUtils'; import { drawIcons } from './IconUtils'; @@ -171,7 +172,8 @@ const symbolizerToPrintMSStyle = (symbolizer, feature, layer, originalSymbolizer export const getPrintStyleFuncFromRules = (geoStylerStyle) => { return ({ layer, - spec = { projection: 'EPSG:3857' } + spec = { projection: 'EPSG:3857' }, + mapPrintScale }) => { if (!layer?.features) { return []; @@ -179,7 +181,7 @@ export const getPrintStyleFuncFromRules = (geoStylerStyle) => { const collection = turfFlatten({ type: 'FeatureCollection', features: layer.features}); return flatten(collection.features .map((feature) => { - const validRules = geoStylerStyle?.rules?.filter((rule) => !rule.filter || geoStylerStyleFilter(feature, rule.filter)); + const validRules = geoStylerStyle?.rules?.filter(rule => geoStylerScaleDenominatorFilter(rule, mapPrintScale)).filter((rule) => !rule.filter || geoStylerStyleFilter(feature, rule.filter)); if (validRules.length > 0) { const geometryType = feature.geometry.type; const symbolizers = validRules.reduce((acc, rule) => [...acc, ...rule?.symbolizers], []); diff --git a/web/client/utils/styleparser/StyleParserUtils.js b/web/client/utils/styleparser/StyleParserUtils.js index 5166ad57de2..a10bc345d16 100644 --- a/web/client/utils/styleparser/StyleParserUtils.js +++ b/web/client/utils/styleparser/StyleParserUtils.js @@ -425,6 +425,29 @@ export const geoStylerStyleFilter = (feature, filter) => { } return matchesFilter; }; +/** + * Checks if a GeoStyler rule should be visible at the current map scale. + * + * @param {Object} [rule={}] - GeoStyler rule with optional scaleDenominator + * @param {Object} [rule.scaleDenominator] - Scale constraints { min?, max? } + * @param {number} [mapViewScale] - Current map scale denominator (1 : X) + * @returns {boolean} true if rule should be applied at this scale + * @example + * geoStylerScaleDenominatorFilter( + * { scaleDenominator: { min: 1000, max: 10000 } }, + * 5000 + * ); // → true + */ +export const geoStylerScaleDenominatorFilter = (rule = {}, mapViewScale) => { + if (rule?.scaleDenominator && mapViewScale) { + const {min, max} = rule?.scaleDenominator; + if ((min !== undefined && mapViewScale < min) || + (max !== undefined && mapViewScale > max)) { + return false; + } + } + return true; +}; /** * parse a string template and replace the placeholders with feature properties * @param {object} feature a GeoJSON feature diff --git a/web/client/utils/styleparser/__tests__/PrintStyleParser-test.js b/web/client/utils/styleparser/__tests__/PrintStyleParser-test.js index 54d07d6e1c2..7dcfebd2827 100644 --- a/web/client/utils/styleparser/__tests__/PrintStyleParser-test.js +++ b/web/client/utils/styleparser/__tests__/PrintStyleParser-test.js @@ -465,4 +465,69 @@ describe('PrintStyleParser', () => { }); }); }); + it('should apply rule only when mapPrintScale is within scaleDenominator range', () => { + const style = { + name: 'test', + rules: [ + { + name: 'visible-rule', + scaleDenominator: { min: 1000, max: 10000 }, + symbolizers: [{ kind: 'Fill', color: '#FF0000' }] + }, + { + name: 'hidden-rule', + scaleDenominator: { min: 20000, max: 50000 }, + symbolizers: [{ kind: 'Fill', color: '#00FF00' }] + } + ] + }; + + const layer = { + features: [{ + type: 'Feature', + properties: {}, + geometry: { type: 'Polygon', coordinates: [[[0, 0], [0, 1], [1, 1], [0, 0]]] } + }] + }; + + // Scale 5000 → first rule visible, second hidden + const result1 = parser.writeStyle(style, true)({ layer, mapPrintScale: 5000 }); + expect(result1[0].properties.ms_style.fillColor).toBe('#FF0000'); + + // Scale 30000 → first rule hidden, second visible + const result2 = parser.writeStyle(style, true)({ layer, mapPrintScale: 30000 }); + expect(result2[0].properties.ms_style.fillColor).toBe('#00FF00'); + + // Scale 100 → both rules hidden → empty result + const result3 = parser.writeStyle(style, true)({ layer, mapPrintScale: 100 }); + expect(result3.length).toBe(0); + }); + + it('should show rule when scaleDenominator has only min or max', () => { + const styleMinOnly = { + name: 'test', + rules: [{ + name: 'min-only', + scaleDenominator: { min: 5000 }, + symbolizers: [{ kind: 'Fill', color: '#AAAAAA' }] + }] + }; + + const layer = { + features: [{ + type: 'Feature', + properties: {}, + geometry: { type: 'Polygon', coordinates: [[[0, 0], [0, 1], [1, 1], [0, 0]]] } + }] + }; + + // Scale 10000 >= min 5000 → rule visible + const resultVisible = parser.writeStyle(styleMinOnly, true)({ layer, mapPrintScale: 10000 }); + expect(resultVisible.length).toBe(1); + expect(resultVisible[0].properties.ms_style.fillColor).toBe('#AAAAAA'); + + // Scale 1000 < min 5000 → rule hidden → empty array + const resultHidden = parser.writeStyle(styleMinOnly, true)({ layer, mapPrintScale: 1000 }); + expect(resultHidden.length).toBe(0); + }); }); diff --git a/web/client/utils/styleparser/__tests__/StyleParserUtils-test.js b/web/client/utils/styleparser/__tests__/StyleParserUtils-test.js index 19c885a9ae2..193254a813c 100644 --- a/web/client/utils/styleparser/__tests__/StyleParserUtils-test.js +++ b/web/client/utils/styleparser/__tests__/StyleParserUtils-test.js @@ -12,7 +12,8 @@ import { geoStylerStyleFilter, getWellKnownNameImageFromSymbolizer, parseSymbolizerExpressions, - getCachedImageById + getCachedImageById, + geoStylerScaleDenominatorFilter } from '../StyleParserUtils'; describe("StyleParserUtils ", () => { @@ -167,4 +168,48 @@ describe("StyleParserUtils ", () => { expect(width).toBe(annotationSymbolizer.size); expect(height).toBe(annotationSymbolizer.size); }); + // tests for geoStylerScaleDenominatorFilter + describe('geoStylerScaleDenominatorFilter', () => { + + it('returns true when scale is within [min, max] range', () => { + const rule = { scaleDenominator: { min: 1000, max: 10000 } }; + expect(geoStylerScaleDenominatorFilter(rule, 5000)).toBe(true); + expect(geoStylerScaleDenominatorFilter(rule, 1000)).toBe(true); + expect(geoStylerScaleDenominatorFilter(rule, 10000)).toBe(true); + }); + + it('returns false when scale is outside [min, max] range', () => { + const rule = { scaleDenominator: { min: 1000, max: 10000 } }; + expect(geoStylerScaleDenominatorFilter(rule, 500)).toBe(false); + expect(geoStylerScaleDenominatorFilter(rule, 50000)).toBe(false); + }); + + it('works with only min or only max defined', () => { + const minOnly = { scaleDenominator: { min: 5000 } }; + const maxOnly = { scaleDenominator: { max: 5000 } }; + + expect(geoStylerScaleDenominatorFilter(minOnly, 10000)).toBe(true); + expect(geoStylerScaleDenominatorFilter(minOnly, 1000)).toBe(false); + expect(geoStylerScaleDenominatorFilter(maxOnly, 1000)).toBe(true); + expect(geoStylerScaleDenominatorFilter(maxOnly, 10000)).toBe(false); + }); + + it('returns true when no scale filter or no mapViewScale provided', () => { + expect(geoStylerScaleDenominatorFilter({}, 5000)).toBe(true); + expect(geoStylerScaleDenominatorFilter({ scaleDenominator: {} }, 5000)).toBe(true); + expect(geoStylerScaleDenominatorFilter({ scaleDenominator: { min: 1000 } }, undefined)).toBe(true); + }); + + it('works with real-world rule structure', () => { + const rule = { + symbolizerId: "rule-001", + kind: "Fill", + color: "#3388FF", + scaleDenominator: { min: 2500, max: 25000 } + }; + expect(geoStylerScaleDenominatorFilter(rule, 10000)).toBe(true); + expect(geoStylerScaleDenominatorFilter(rule, 1000)).toBe(false); + expect(geoStylerScaleDenominatorFilter(rule, 50000)).toBe(false); + }); + }); }); From e3982ff6ecccc71fafa1f3163374b9ea4885f580 Mon Sep 17 00:00:00 2001 From: mahmoudadel54 Date: Fri, 13 Mar 2026 11:44:27 +0200 Subject: [PATCH 2/4] - handle functionality of scale limits layer filter for leaflet for wfs and vector layers --- web/client/components/map/leaflet/Layer.jsx | 3 +- .../map/leaflet/plugins/VectorLayer.jsx | 42 +++++++++++++++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/web/client/components/map/leaflet/Layer.jsx b/web/client/components/map/leaflet/Layer.jsx index f819c9fa00c..8e78fbe1a6c 100644 --- a/web/client/components/map/leaflet/Layer.jsx +++ b/web/client/components/map/leaflet/Layer.jsx @@ -152,7 +152,8 @@ class LeafletLayer extends React.Component { onError: () => { this.props.onCreationError(options); }, - securityToken + securityToken, + resolution: this.props.resolutions[Math.round(this.props.map.getZoom())] }); }; diff --git a/web/client/components/map/leaflet/plugins/VectorLayer.jsx b/web/client/components/map/leaflet/plugins/VectorLayer.jsx index 4ecdfc640f5..3d7d9edd205 100644 --- a/web/client/components/map/leaflet/plugins/VectorLayer.jsx +++ b/web/client/components/map/leaflet/plugins/VectorLayer.jsx @@ -14,6 +14,26 @@ import { } from '../../../../utils/VectorStyleUtils'; import { applyDefaultStyleToVectorLayer } from '../../../../utils/StyleUtils'; import { createVectorFeatureFilter } from '../../../../utils/FilterUtils'; +import { DEFAULT_SCREEN_DPI, getScale } from '../../../../utils/MapUtils'; +import { geoStylerScaleDenominatorFilter } from '../../../../utils/styleparser/StyleParserUtils'; + +/** + * Check if layer should be visible at current scale + */ +const checkScaleVisibility = (options) => { + const styleRules = options?.style?.body?.rules || []; + if (styleRules.length === 0) { + return true; // No scale rules means always visible + } + + const scale = getScale(options.srs, DEFAULT_SCREEN_DPI, options.resolution) || 0; + if (!scale || isNaN(scale)) return true; + const validRules = styleRules.filter(rule => + geoStylerScaleDenominatorFilter(rule, Math.round(scale)) + ); + + return validRules.length > 0; +}; const setOpacity = (layer, opacity) => { if (layer.eachLayer) { @@ -56,12 +76,16 @@ const createLayerLegacy = (options) => { const createLayer = (options) => { const { hideLoading } = options; const vectorFeatureFilter = createVectorFeatureFilter(options); - const featuresToRender = options.features.filter(vectorFeatureFilter); // make filter for features if filter is existing - + const isScaleVisible = checkScaleVisibility(options); + let featuresToRender = isScaleVisible + ? options.features.filter(vectorFeatureFilter) + : []; const layer = L.geoJson(featuresToRender, { hideLoading: hideLoading }); - + if (!isScaleVisible) { + layer._filteredWithScaleLimits = true; + } getStyle(applyDefaultStyleToVectorLayer(options), 'leaflet') .then((styleUtils) => { styleUtils({ opacity: options.opacity, layer, features: featuresToRender }) @@ -97,8 +121,18 @@ const updateLayer = (layer, newOptions, oldOptions) => { layer.remove(); return createLayer(newOptions); } + const isScaleVisible = checkScaleVisibility(newOptions); + if (!isScaleVisible) { + if (!layer._filteredWithScaleLimits) { + layer.clearLayers(); + layer._filteredWithScaleLimits = true; + } + return null; + } + if (!isEqual(oldOptions.style, newOptions.style) - || newOptions.opacity !== oldOptions.opacity) { + || newOptions.opacity !== oldOptions.opacity || layer._filteredWithScaleLimits) { + layer._filteredWithScaleLimits = false; getStyle(applyDefaultStyleToVectorLayer(newOptions), 'leaflet') .then((styleUtils) => { styleUtils({ opacity: newOptions.opacity, layer, features: newOptions.features }) From a9d215f60e6351a1a9bb94a00a246ea8ffae1f4b Mon Sep 17 00:00:00 2001 From: mahmoudadel54 Date: Fri, 13 Mar 2026 13:04:33 +0200 Subject: [PATCH 3/4] - fix FE unit test failure --- web/client/components/map/leaflet/Layer.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/client/components/map/leaflet/Layer.jsx b/web/client/components/map/leaflet/Layer.jsx index 8e78fbe1a6c..806b86da8fe 100644 --- a/web/client/components/map/leaflet/Layer.jsx +++ b/web/client/components/map/leaflet/Layer.jsx @@ -147,13 +147,16 @@ class LeafletLayer extends React.Component { }; generateOpts = (options, position, securityToken) => { + const zoom = Math.round(this.props?.map?.getZoom() || 0); + const defaultResolution = 156543.03392804097; // Web Mercator resolution at zoom 0 + return Object.assign({}, options, position ? {zIndex: position, srs: this.props.srs } : null, { zoomOffset: -this.props.zoomOffset, onError: () => { this.props.onCreationError(options); }, securityToken, - resolution: this.props.resolutions[Math.round(this.props.map.getZoom())] + resolution: this.props?.resolutions?.[zoom] ?? defaultResolution }); }; From 22c5170c5e8ac343eb8d42aa88a5b29ae28a8d01 Mon Sep 17 00:00:00 2001 From: mahmoudadel54 Date: Fri, 13 Mar 2026 17:25:24 +0200 Subject: [PATCH 4/4] - edits to fix FE failures - revert a typo remove in PrintUtils --- web/client/components/map/leaflet/Layer.jsx | 3 +-- .../map/leaflet/plugins/VectorLayer.jsx | 2 +- .../__tests__/defaultSettingsTabs-test.js | 15 +++++++++++---- web/client/utils/PrintUtils.js | 1 + 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/web/client/components/map/leaflet/Layer.jsx b/web/client/components/map/leaflet/Layer.jsx index 806b86da8fe..6efc12c025c 100644 --- a/web/client/components/map/leaflet/Layer.jsx +++ b/web/client/components/map/leaflet/Layer.jsx @@ -148,7 +148,6 @@ class LeafletLayer extends React.Component { generateOpts = (options, position, securityToken) => { const zoom = Math.round(this.props?.map?.getZoom() || 0); - const defaultResolution = 156543.03392804097; // Web Mercator resolution at zoom 0 return Object.assign({}, options, position ? {zIndex: position, srs: this.props.srs } : null, { zoomOffset: -this.props.zoomOffset, @@ -156,7 +155,7 @@ class LeafletLayer extends React.Component { this.props.onCreationError(options); }, securityToken, - resolution: this.props?.resolutions?.[zoom] ?? defaultResolution + resolution: this.props?.resolutions?.[zoom] }); }; diff --git a/web/client/components/map/leaflet/plugins/VectorLayer.jsx b/web/client/components/map/leaflet/plugins/VectorLayer.jsx index 3d7d9edd205..3f34785db77 100644 --- a/web/client/components/map/leaflet/plugins/VectorLayer.jsx +++ b/web/client/components/map/leaflet/plugins/VectorLayer.jsx @@ -26,7 +26,7 @@ const checkScaleVisibility = (options) => { return true; // No scale rules means always visible } - const scale = getScale(options.srs, DEFAULT_SCREEN_DPI, options.resolution) || 0; + const scale = getScale(options.srs, DEFAULT_SCREEN_DPI, options?.resolution) || 0; if (!scale || isNaN(scale)) return true; const validRules = styleRules.filter(rule => geoStylerScaleDenominatorFilter(rule, Math.round(scale)) diff --git a/web/client/plugins/tocitemssettings/__tests__/defaultSettingsTabs-test.js b/web/client/plugins/tocitemssettings/__tests__/defaultSettingsTabs-test.js index c7e3580c4b2..262a7c48f3a 100644 --- a/web/client/plugins/tocitemssettings/__tests__/defaultSettingsTabs-test.js +++ b/web/client/plugins/tocitemssettings/__tests__/defaultSettingsTabs-test.js @@ -8,6 +8,7 @@ import expect from 'expect'; import MockAdapter from "axios-mock-adapter"; import axios from "../../../libs/ajax"; +import configureMockStore from 'redux-mock-store'; import defaultSettingsTabs, { getStyleTabPlugin } from '../defaultSettingsTabs'; import React from 'react'; @@ -15,6 +16,7 @@ import ReactDOM from 'react-dom'; import { act } from "react-dom/test-utils"; import VectorStyleEditor from "../../styleeditor/VectorStyleEditor"; +import { Provider } from 'react-redux'; const BASE_STYLE_TEST_DATA = { settings: {}, @@ -178,6 +180,8 @@ describe('TOCItemsSettings - VectorStyleEditor rendered items', () => { }); it("VectorStyleEditor displays an error message if the geometry is type GEOMETRY and the layer is wfs", (done) => { + const mockStore = configureMockStore()({}); + const PROPS = { ...BASE_STYLE_TEST_DATA, element: { @@ -191,7 +195,7 @@ describe('TOCItemsSettings - VectorStyleEditor rendered items', () => { mockFeatureRequestWithGeometryType("Geometry"); act(async() => { - ReactDOM.render(, document.querySelector('#container')); + ReactDOM.render(, document.querySelector('#container')); }); asyncValidation(()=>{ // Check if an error message is rendered @@ -202,6 +206,7 @@ describe('TOCItemsSettings - VectorStyleEditor rendered items', () => { }); it("VectorStyleEditor renders editor if the geometry is not of type GEOMETRY and the layer is wfs", (done) => { + const mockStore = configureMockStore()({}); const PROPS = { ...BASE_STYLE_TEST_DATA, element: { @@ -215,7 +220,7 @@ describe('TOCItemsSettings - VectorStyleEditor rendered items', () => { mockFeatureRequestWithGeometryType("MultiPolygon"); act(async() => { - ReactDOM.render(, document.querySelector('#container')); + ReactDOM.render(, document.querySelector('#container')); }); asyncValidation(()=>{ // Check if the editor is rendered @@ -225,6 +230,7 @@ describe('TOCItemsSettings - VectorStyleEditor rendered items', () => { }); it("VectorStyleEditor renders editor if the geometry is of type GeometryCollection and the layer is wfs", (done) => { + const mockStore = configureMockStore()({}); const PROPS = { ...BASE_STYLE_TEST_DATA, element: { @@ -238,7 +244,7 @@ describe('TOCItemsSettings - VectorStyleEditor rendered items', () => { mockFeatureRequestWithGeometryType("GeometryCollection"); act(async() => { - ReactDOM.render(, document.querySelector('#container')); + ReactDOM.render(, document.querySelector('#container')); }); asyncValidation(()=>{ // Check if the editor is rendered @@ -248,6 +254,7 @@ describe('TOCItemsSettings - VectorStyleEditor rendered items', () => { }); it("VectorStyleEditor renders an empty component if the geometry is not defined and the layer is wfs", (done) => { + const mockStore = configureMockStore()({}); const PROPS = { ...BASE_STYLE_TEST_DATA, element: { @@ -261,7 +268,7 @@ describe('TOCItemsSettings - VectorStyleEditor rendered items', () => { mockFeatureRequestWithGeometryType(""); act(async() => { - ReactDOM.render(, document.querySelector('#container')); + ReactDOM.render(, document.querySelector('#container')); }); asyncValidation(()=>{ // Check if an empty container has been rendered diff --git a/web/client/utils/PrintUtils.js b/web/client/utils/PrintUtils.js index 33df3264717..be79284829b 100644 --- a/web/client/utils/PrintUtils.js +++ b/web/client/utils/PrintUtils.js @@ -868,6 +868,7 @@ export const specCreators = { map: (layer, spec, state) => ({ type: 'Vector', name: layer.name, + "opacity": getOpacity(layer), styleProperty: "ms_style", styles: { 1: PrintUtils.toOpenLayers2Style(layer, layer.style),