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/components/map/leaflet/Layer.jsx b/web/client/components/map/leaflet/Layer.jsx
index f819c9fa00c..6efc12c025c 100644
--- a/web/client/components/map/leaflet/Layer.jsx
+++ b/web/client/components/map/leaflet/Layer.jsx
@@ -147,12 +147,15 @@ class LeafletLayer extends React.Component {
};
generateOpts = (options, position, securityToken) => {
+ const zoom = Math.round(this.props?.map?.getZoom() || 0);
+
return Object.assign({}, options, position ? {zIndex: position, srs: this.props.srs } : null, {
zoomOffset: -this.props.zoomOffset,
onError: () => {
this.props.onCreationError(options);
},
- securityToken
+ securityToken,
+ 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 4ecdfc640f5..3f34785db77 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 })
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/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/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..be79284829b 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,7 +865,7 @@ export const specCreators = {
}
},
wfs: {
- map: (layer) => ({
+ map: (layer, spec, state) => ({
type: 'Vector',
name: layer.name,
"opacity": getOpacity(layer),
@@ -855,7 +881,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);
+ });
+ });
});