diff --git a/docs/developer-guide/mapstore-migration-guide.md b/docs/developer-guide/mapstore-migration-guide.md
index a996a09b08..710decb5a4 100644
--- a/docs/developer-guide/mapstore-migration-guide.md
+++ b/docs/developer-guide/mapstore-migration-guide.md
@@ -22,6 +22,31 @@ This is a list of things to check if you want to update from a previous version
## Migration from 2026.01.02 to 2026.02.00
+### Custom map resolutions moved from `new.json` to the `CRSSelector` plugin
+
+Custom map resolutions used to be declared in the default map configuration (`new.json`, or the project-level equivalent) under `mapOptions.view.resolutions`. That location was tied to a single projection and was not kept in sync when the CRS was changed at runtime, which could leave saved maps with a projection that did not match the persisted resolutions.
+
+Custom resolutions are declared **per CRS** in the `CRSSelector` plugin configuration in `localConfig.json`, keyed by SRS code:
+
+```json
+{
+ "name": "CRSSelector",
+ "cfg": {
+ "customResolutions": {
+ "EPSG:3003": [2366, 1183, 591, 295, 147, 73, 36, 18, 9, 4, 2, 1, 0.5, 0.28, 0.14, 0.07, 0.035, 0.018],
+ "EPSG:4326": [0.7, 0.35, 0.175, 0.0875, 0.04375, 0.021875, 0.010986, 0.0054931]
+ }
+ }
+}
+```
+
+When the user switches the map CRS, the matching list of resolutions is applied to the map. If a CRS has no entry, the resolutions are computed from the projection extent. In either case the chosen list is saved together with the map so that, on reload, the CRS and the resolutions remain aligned.
+
+#### Migration steps
+
+1. Remove `mapOptions.view.resolutions` from `new.json` (and from any custom default map config used by the project). Any value left there is dropped on the next save.
+2. Add an equivalent `customResolutions` block to the `CRSSelector` plugin entry in `localConfig.json`, keyed by the SRS the resolutions were originally designed for.
+
### print-lib updated to 2.3.5
In your project, you should update the `print-lib.version` property from version `2.3.4` to version `2.3.5` in the root `pom.xml`.
diff --git a/web/client/components/map/openlayers/Map.jsx b/web/client/components/map/openlayers/Map.jsx
index 7bdc2dd913..69b06fb329 100644
--- a/web/client/components/map/openlayers/Map.jsx
+++ b/web/client/components/map/openlayers/Map.jsx
@@ -314,8 +314,8 @@ class OpenlayersMap extends React.Component {
}, 0);
}
}
- this.map.setView(this.createView(center, closestMatchedZoom, newProps.projection, newProps.mapOptions && newProps.mapOptions.view, newProps.limits));
- this.props.onResolutionsChange(this.getResolutions());
+ this.map.setView(this.createView(center, closestMatchedZoom, newProps.projection, newProps.mapOptions && newProps.mapOptions.view, newProps.limits, newProps.mapOptions));
+ this.props.onResolutionsChange(this.getResolutions(newProps.projection, newProps.mapOptions));
}
// We have to force ol to drop tile and reload
this.map.getLayers().forEach((l) => {
@@ -361,22 +361,38 @@ class OpenlayersMap extends React.Component {
* - custom grid set with custom extent. You need to customize the projection definition extent to make it work.
* - custom grid set is partially supported by mapOptions.view.resolutions but this is not managed by projection change yet
* - custom tile sizes
- *
*/
- getResolutions = (srs) => {
- if (this.props.mapOptions && this.props.mapOptions.view && this.props.mapOptions.view.resolutions) {
- return this.props.mapOptions.view.resolutions;
+ getResolutions = (srs, mapOptions = this.props.mapOptions) => {
+ // Resolve requested projection
+ const requestedProj = srs
+ ? msGetProjection(srs)
+ : (this.map?.getView()?.getProjection());
+ const requestedSRS = normalizeSRS(srs || requestedProj.getCode());
+
+ // Check for explicitly configured resolutions + matching projection
+ const viewOptions = mapOptions?.view || {};
+ const configuredResolutions = viewOptions.resolutions;
+ const configuredResProjection = viewOptions.projection && normalizeSRS(viewOptions.projection);
+
+ // If resolutions are explicitly configured *and* tied to a projection that matches our target,
+ // return them directly — avoids recomputation and ensures consistency with custom tile sources.
+ if (
+ configuredResolutions &&
+ Array.isArray(configuredResolutions) &&
+ (!configuredResProjection || requestedSRS === configuredResProjection)
+ ) {
+ return configuredResolutions;
}
- const projection = srs ? msGetProjection(srs) : this.map.getView().getProjection();
- const extent = projection.extent || projection?.getExtent(); // get from registry crs item or ol map
+ // Otherwise compute dynamically
+ const extent = requestedProj.extent || requestedProj.getExtent();
return getResolutionsForProjection(
srs ?? this.map.getView().getProjection().getCode(),
{
- minResolution: this.props.mapOptions.minResolution,
- maxResolution: this.props.mapOptions.maxResolution,
- minZoom: this.props.mapOptions.minZoom,
- maxZoom: this.props.mapOptions.maxZoom,
- zoomFactor: this.props.mapOptions.zoomFactor,
+ minResolution: mapOptions.minResolution,
+ maxResolution: mapOptions.maxResolution,
+ minZoom: mapOptions.minZoom,
+ maxZoom: mapOptions.maxZoom,
+ zoomFactor: mapOptions.zoomFactor,
extent
}
);
@@ -597,16 +613,35 @@ class OpenlayersMap extends React.Component {
return !isEqual(rotation, newRotation);
};
- createView = (center, zoom, projection, options, limits = {}) => {
+ createView = (center, zoom, projection, options, limits = {}, mapOptions) => {
+ const srs = normalizeSRS(projection);
// limit has a crs defined
const extent = limits.restrictedExtent && limits.crs && reprojectBbox(limits.restrictedExtent, limits.crs, normalizeSRS(projection));
- const newOptions = !options || (options && !options.view) ? Object.assign({}, options, { extent }) : Object.assign({}, options);
+
+ // Determine whether to use configured resolutions
+ const configuredResolutions = options?.resolutions;
+ const configuredProj = normalizeSRS(options?.projection);
+
+ let resolutionsToUse;
+ if (configuredResolutions && (!configuredProj || configuredProj === srs)) {
+ // use provided resolutions (keep backward compatibility)
+ resolutionsToUse = configuredResolutions;
+ } else {
+ // compute resolutions dynamically (e.g., EPSG:4326)
+ resolutionsToUse = this.getResolutions(srs, mapOptions ? { ...mapOptions, view: options } : undefined);
+ }
+ const newOptions = {
+ ...options,
+ projection: srs,
+ resolutions: resolutionsToUse,
+ extent: options?.extent !== undefined ? options.extent : extent
+ };
/*
* setting the zoom level in the localConfig file is co-related to the projection extent(size)
* it is recommended to use projections with the same coverage area (extent). If you want to have the same restricted zoom level (minZoom)
*/
const viewOptions = Object.assign({}, {
- projection: normalizeSRS(projection),
+ projection: srs,
center: [center?.x || 0, center?.y || 0],
zoom: zoom,
minZoom: limits.minZoom,
@@ -615,7 +650,7 @@ class OpenlayersMap extends React.Component {
// does not allow intermediary zoom levels
// we need this at true to set correctly the scale box
constrainResolution: true,
- resolutions: this.getResolutions(normalizeSRS(projection))
+ resolutions: this.getResolutions(srs, mapOptions ? { ...mapOptions, view: options } : undefined)
}, newOptions || {});
return new View(viewOptions);
};
diff --git a/web/client/components/map/openlayers/__tests__/Map-test.jsx b/web/client/components/map/openlayers/__tests__/Map-test.jsx
index d664fc8a96..cb56233241 100644
--- a/web/client/components/map/openlayers/__tests__/Map-test.jsx
+++ b/web/client/components/map/openlayers/__tests__/Map-test.jsx
@@ -1711,4 +1711,41 @@ describe('OpenlayersMap', () => {
// center is modified
expect(map.map.getView().getCenter()).toEqual([10.3346773790, 43.9323234388]);
});
+ it('should correctly apply view resolutions without requiring mapOptions.view.projection', () => {
+ const resolutions = [0.0005, 0.0004, 0.0003, 0.0002];
+ const map = ReactDOM.render(
+ ,
+ document.getElementById("map")
+ );
+
+ const view = map.map.getView();
+ expect(view.getProjection().getCode()).toBe('EPSG:4326');
+ expect(view.getResolutions()).toEqual(resolutions); // Custom resolutions applied
+
+ // Simulate a zoom change
+ view.setZoom(3);
+ expect(view.getProjection().getCode()).toBe('EPSG:4326');
+
+ // Simulate receiving new props with a different projection
+ ReactDOM.render(
+ ,
+ document.getElementById("map")
+ );
+
+ const updatedView = map.map.getView();
+ updatedView.setZoom(5);
+ expect(updatedView.getProjection().getCode()).toBe('EPSG:3857');
+ expect(updatedView.getResolutions()).toNotEqual(resolutions);
+ });
+
});
diff --git a/web/client/components/print/MapPreview.jsx b/web/client/components/print/MapPreview.jsx
index 49a5d44b90..0861f58de1 100644
--- a/web/client/components/print/MapPreview.jsx
+++ b/web/client/components/print/MapPreview.jsx
@@ -149,7 +149,8 @@ class MapPreview extends React.Component {
let mapOptions = !isEmpty(resolutions) || !isNil(this.props.rotation) ? {
view: {
...(!isEmpty(resolutions) && {resolutions}),
- rotation: !isNil(this.props.rotation) ? Number(this.props.rotation) : 0
+ rotation: !isNil(this.props.rotation) ? Number(this.props.rotation) : 0,
+ projection
}
} : {};
diff --git a/web/client/plugins/CRSSelector/__tests__/CRSSelector-test.jsx b/web/client/plugins/CRSSelector/__tests__/CRSSelector-test.jsx
index 452ff83029..abd17580c0 100644
--- a/web/client/plugins/CRSSelector/__tests__/CRSSelector-test.jsx
+++ b/web/client/plugins/CRSSelector/__tests__/CRSSelector-test.jsx
@@ -6,6 +6,7 @@ import CRSSelectorPlugin from '../index';
import { getPluginForTest } from '../../__tests__/pluginsTestUtils';
import security from '../../../reducers/security';
import ReactTestUtils from 'react-dom/test-utils';
+import { SET_PROJECTIONS_CONFIG } from '../actions/crsselector';
const defaultAvailableProjections = [
{ value: "EPSG:4326", label: "EPSG:4326" },
@@ -490,4 +491,65 @@ describe('CRSSelector Plugin', () => {
}
}, 100);
});
+
+ it('bridges pluginCfg.customResolutions into state.crsselector.config on mount', (done) => {
+ const customResolutions = {
+ 'EPSG:4326': [1, 0.5, 0.25, 0.125]
+ };
+ const { Plugin, store } = getPluginForTest(CRSPluginCustomized, {
+ map: { projection: 'EPSG:4326' },
+ localConfig: { projectionDefs: [] },
+ security: { user: { role: 'USER' } }
+ });
+
+ ReactDOM.render(, document.getElementById('container'));
+
+ // The useEffect runs after the first paint, so wait a tick.
+ setTimeout(() => {
+ const config = store.getState().crsselector?.config;
+ expect(config).toExist();
+ expect(config.customResolutions).toEqual(customResolutions);
+ done();
+ }, 0);
+ });
+
+ it('does NOT bridge pluginCfg.customResolutions when state already has them (saved-map wins)', (done) => {
+ const savedCustomResolutions = { 'EPSG:4326': [9, 8, 7] };
+ const pluginCfgCustomResolutions = { 'EPSG:4326': [1, 0.5, 0.25] };
+ const { Plugin, store, actions } = getPluginForTest(CRSPluginCustomized, {
+ map: { projection: 'EPSG:4326' },
+ localConfig: { projectionDefs: [] },
+ security: { user: { role: 'USER' } },
+ crsselector: {
+ config: { customResolutions: savedCustomResolutions }
+ }
+ });
+
+ ReactDOM.render(, document.getElementById('container'));
+
+ setTimeout(() => {
+ const config = store.getState().crsselector?.config;
+ expect(config.customResolutions).toEqual(savedCustomResolutions);
+ // The bridge should not have emitted a SET_PROJECTIONS_CONFIG to
+ // overwrite the saved-map customResolutions with the pluginCfg ones.
+ const bridgeWrites = actions.filter(a =>
+ a?.type === SET_PROJECTIONS_CONFIG
+ && a?.config?.customResolutions === pluginCfgCustomResolutions
+ );
+ expect(bridgeWrites.length).toBe(0);
+ done();
+ }, 0);
+ });
});
diff --git a/web/client/plugins/CRSSelector/containers/CRSSelector.jsx b/web/client/plugins/CRSSelector/containers/CRSSelector.jsx
index eb4de916c1..bde33637b4 100644
--- a/web/client/plugins/CRSSelector/containers/CRSSelector.jsx
+++ b/web/client/plugins/CRSSelector/containers/CRSSelector.jsx
@@ -6,9 +6,9 @@
* LICENSE file in the root directory of this source tree.
*/
-import { has, includes, indexOf } from 'lodash';
+import { has, includes, indexOf, isEmpty } from 'lodash';
import PropTypes from 'prop-types';
-import React, { useMemo, useState, lazy, Suspense } from 'react';
+import React, { useEffect, useMemo, useState, lazy, Suspense } from 'react';
import { Dropdown, FormControl, Glyphicon } from 'react-bootstrap';
import { connect } from '../../../utils/PluginsUtils';
import { createSelector } from 'reselect';
@@ -105,7 +105,8 @@ const Selector = ({
currentBackground,
onError = () => {},
canEditProjection = true,
- unregisteredProjections = []
+ unregisteredProjections = [],
+ customResolutions
}) => {
const [toggled, setToggled] = useState(false);
const [openAvailableProjections, setOpenAvailableProjections] = useState(false);
@@ -116,6 +117,18 @@ const Selector = ({
}
}, toggled);
+ // Make the configured `customResolutions` available to the rest of the
+ // plugin so that, when the user switches CRS or saves the map, the correct
+ // per-CRS resolutions are applied and persisted.
+ useEffect(() => {
+ if (
+ !isEmpty(customResolutions)
+ && isEmpty(projectionsConfig?.customResolutions)
+ ) {
+ setConfig({ ...projectionsConfig, customResolutions });
+ }
+ }, [customResolutions, projectionsConfig?.customResolutions]);
+
const changeCrs = (crs) => {
const allowedLayerTypes = ["wms", "osm", "tileprovider", "terrain", "empty"];
if (indexOf(allowedLayerTypes, currentBackground?.type) > -1
@@ -311,7 +324,8 @@ Selector.propTypes = {
projectionsConfig: PropTypes.object,
setConfig: PropTypes.func,
canEditProjection: PropTypes.bool,
- searchResultsRemote: PropTypes.array
+ searchResultsRemote: PropTypes.array,
+ customResolutions: PropTypes.object
};
const CRSSelector = connect(
diff --git a/web/client/plugins/CRSSelector/epics/__tests__/crsselector-test.js b/web/client/plugins/CRSSelector/epics/__tests__/crsselector-test.js
index ed4ebd96c6..7ff7c014c9 100644
--- a/web/client/plugins/CRSSelector/epics/__tests__/crsselector-test.js
+++ b/web/client/plugins/CRSSelector/epics/__tests__/crsselector-test.js
@@ -6,52 +6,252 @@
* LICENSE file in the root directory of this source tree.
*/
import expect from 'expect';
-import { updateCrsSelectorConfigEpic } from '../crsselector';
+import {
+ updateCrsSelectorConfigEpic,
+ updateMapResolutionsOnCrsChangeEpic
+} from '../crsselector';
import { configureMap } from '../../../../actions/config';
import { SET_PROJECTIONS_CONFIG } from '../../actions/crsselector';
+import {
+ CHANGE_MAP_CRS,
+ SET_MAP_RESOLUTIONS,
+ UPDATE_MAP_OPTIONS,
+ changeCRS
+} from '../../../../actions/map';
import { testEpic, addTimeoutEpic } from '../../../../epics/__tests__/epicTestUtils';
describe('crsselector epics', () => {
- it('should dispatch setProjectionsConfig when MAP_CONFIG_LOADED has crsSelector config', (done) => {
- const action = configureMap({
- crsSelector: {
- projectionList: [
- { value: 'EPSG:4326', label: 'EPSG:4326' },
- { value: 'EPSG:3857', label: 'EPSG:3857' }
- ]
- }
- });
- testEpic(
- updateCrsSelectorConfigEpic,
- 1,
- action,
- (actions) => {
- expect(actions.length).toBe(1);
- expect(actions[0].type).toBe(SET_PROJECTIONS_CONFIG);
- expect(actions[0].config.projectionList).toEqual([
- { value: 'EPSG:4326', label: 'EPSG:4326' },
- { value: 'EPSG:3857', label: 'EPSG:3857' }
- ]);
- },
- {},
- done
- );
+ describe('updateCrsSelectorConfigEpic', () => {
+ it('should dispatch setProjectionsConfig when MAP_CONFIG_LOADED has crsSelector config', (done) => {
+ const action = configureMap({
+ crsSelector: {
+ projectionList: [
+ { value: 'EPSG:4326', label: 'EPSG:4326' },
+ { value: 'EPSG:3857', label: 'EPSG:3857' }
+ ]
+ }
+ });
+ testEpic(
+ updateCrsSelectorConfigEpic,
+ 1,
+ action,
+ (actions) => {
+ expect(actions.length).toBe(1);
+ expect(actions[0].type).toBe(SET_PROJECTIONS_CONFIG);
+ expect(actions[0].config.projectionList).toEqual([
+ { value: 'EPSG:4326', label: 'EPSG:4326' },
+ { value: 'EPSG:3857', label: 'EPSG:3857' }
+ ]);
+ },
+ {},
+ done
+ );
+ });
+
+ it('should dispatch setProjectionsConfig(undefined) when MAP_CONFIG_LOADED has no crsSelector config', (done) => {
+ const action = configureMap({});
+ testEpic(
+ addTimeoutEpic(updateCrsSelectorConfigEpic),
+ 1,
+ action,
+ (actions) => {
+ expect(actions.length).toBe(1);
+ expect(actions[0].type).toBe(SET_PROJECTIONS_CONFIG);
+ // The undefined payload is the explicit "no persisted list" signal
+ expect(actions[0].config).toBe(undefined);
+ },
+ {},
+ done
+ );
+ });
+
+ it('should also restore customResolutions when present in the persisted crsSelector config', (done) => {
+ const action = configureMap({
+ crsSelector: {
+ projectionList: [{ value: 'EPSG:4326', label: 'EPSG:4326' }],
+ customResolutions: {
+ 'EPSG:4326': [1, 0.5, 0.25]
+ }
+ },
+ map: {
+ projection: 'EPSG:4326',
+ mapOptions: { view: { projection: 'EPSG:4326', resolutions: [1, 0.5, 0.25] } }
+ }
+ });
+ testEpic(
+ updateCrsSelectorConfigEpic,
+ 1,
+ action,
+ (actions) => {
+ expect(actions.length).toBe(1);
+ expect(actions[0].type).toBe(SET_PROJECTIONS_CONFIG);
+ expect(actions[0].config.customResolutions).toEqual({
+ 'EPSG:4326': [1, 0.5, 0.25]
+ });
+ },
+ {},
+ done
+ );
+ });
+
+ it('should hydrate map.mapOptions.view + map.resolutions for legacy maps where customResolutions[map.projection] exists but mapOptions.view.resolutions is missing', (done) => {
+ const action = configureMap({
+ crsSelector: {
+ customResolutions: {
+ 'EPSG:4326': [1, 0.5, 0.25]
+ }
+ },
+ map: {
+ projection: 'EPSG:4326'
+ }
+ });
+ testEpic(
+ updateCrsSelectorConfigEpic,
+ 3,
+ action,
+ (actions) => {
+ expect(actions.length).toBe(3);
+ expect(actions[0].type).toBe(SET_PROJECTIONS_CONFIG);
+ expect(actions[1].type).toBe(UPDATE_MAP_OPTIONS);
+ expect(actions[1].configUpdate.view.projection).toBe(undefined);
+ expect(actions[1].configUpdate.view.resolutions).toEqual([1, 0.5, 0.25]);
+ expect(actions[2].type).toBe(SET_MAP_RESOLUTIONS);
+ expect(actions[2].resolutions).toEqual([1, 0.5, 0.25]);
+ },
+ {},
+ done
+ );
+ });
+
+ it('should NOT hydrate when mapOptions.view.resolutions already match custom resolutions for the current CRS', (done) => {
+ const action = configureMap({
+ crsSelector: {
+ customResolutions: {
+ 'EPSG:4326': [1, 0.5, 0.25]
+ }
+ },
+ map: {
+ projection: 'EPSG:4326',
+ mapOptions: {
+ view: { projection: 'EPSG:4326', resolutions: [1, 0.5, 0.25] }
+ }
+ }
+ });
+ testEpic(
+ addTimeoutEpic(updateCrsSelectorConfigEpic),
+ 1,
+ action,
+ (actions) => {
+ expect(actions.length).toBe(1);
+ expect(actions[0].type).toBe(SET_PROJECTIONS_CONFIG);
+ },
+ {},
+ done
+ );
+ });
+
+ it('should hydrate and remove stale mapOptions.view.projection when custom resolutions differ', (done) => {
+ const action = configureMap({
+ crsSelector: {
+ customResolutions: {
+ 'EPSG:4326': [1, 0.5, 0.25]
+ }
+ },
+ map: {
+ projection: 'EPSG:4326',
+ mapOptions: {
+ view: { projection: 'EPSG:3857', resolutions: [200, 100, 50] }
+ }
+ }
+ });
+ testEpic(
+ updateCrsSelectorConfigEpic,
+ 3,
+ action,
+ (actions) => {
+ expect(actions.length).toBe(3);
+ expect(actions[1].type).toBe(UPDATE_MAP_OPTIONS);
+ expect(actions[1].configUpdate.view.projection).toBe(undefined);
+ expect(actions[1].configUpdate.view.resolutions).toEqual([1, 0.5, 0.25]);
+ expect(actions[2].type).toBe(SET_MAP_RESOLUTIONS);
+ },
+ {},
+ done
+ );
+ });
});
- it('should dispatch setProjectionsConfig(undefined) when MAP_CONFIG_LOADED has no crsSelector config', (done) => {
- const action = configureMap({});
- testEpic(
- addTimeoutEpic(updateCrsSelectorConfigEpic),
- 1,
- action,
- (actions) => {
- expect(actions.length).toBe(1);
- expect(actions[0].type).toBe(SET_PROJECTIONS_CONFIG);
- // The undefined payload is the explicit "no persisted list" signal
- expect(actions[0].config).toBe(undefined);
- },
- {},
- done
- );
+ describe('updateMapResolutionsOnCrsChangeEpic', () => {
+ it('should push customResolutions[newCrs] into the map state when the CRSSelector cfg has an entry for the new CRS', (done) => {
+ const state = {
+ crsselector: {
+ config: {
+ customResolutions: {
+ 'EPSG:4326': [10, 5, 2.5, 1.25]
+ }
+ }
+ },
+ map: {
+ present: {
+ projection: 'EPSG:3857',
+ mapOptions: { view: { projection: 'EPSG:3857', rotation: 0.5, resolutions: [100, 50] } }
+ }
+ }
+ };
+ testEpic(
+ updateMapResolutionsOnCrsChangeEpic,
+ 2,
+ changeCRS('EPSG:4326'),
+ (actions) => {
+ expect(actions.length).toBe(2);
+ expect(actions[0].type).toBe(UPDATE_MAP_OPTIONS);
+ expect(actions[0].configUpdate.view.projection).toBe(undefined);
+ expect(actions[0].configUpdate.view.resolutions).toEqual([10, 5, 2.5, 1.25]);
+ // preserves unrelated view options (rotation, ...)
+ expect(actions[0].configUpdate.view.rotation).toBe(0.5);
+ expect(actions[1].type).toBe(SET_MAP_RESOLUTIONS);
+ expect(actions[1].resolutions).toEqual([10, 5, 2.5, 1.25]);
+ },
+ state,
+ done
+ );
+ });
+
+ it('should recompute resolutions from the projection extent when no customResolutions entry exists for the new CRS', (done) => {
+ const state = {
+ crsselector: { config: { customResolutions: {} } },
+ map: { present: { projection: 'EPSG:3857' } }
+ };
+ testEpic(
+ updateMapResolutionsOnCrsChangeEpic,
+ 2,
+ changeCRS('EPSG:4326'),
+ (actions) => {
+ expect(actions.length).toBe(2);
+ expect(actions[0].type).toBe(UPDATE_MAP_OPTIONS);
+ expect(actions[0].configUpdate.view.projection).toBe(undefined);
+ expect(Array.isArray(actions[0].configUpdate.view.resolutions)).toBe(true);
+ expect(actions[0].configUpdate.view.resolutions.length).toBeGreaterThan(0);
+ expect(actions[1].type).toBe(SET_MAP_RESOLUTIONS);
+ expect(actions[1].resolutions).toEqual(actions[0].configUpdate.view.resolutions);
+ },
+ state,
+ done
+ );
+ });
+
+ it('should not emit any action when the action carries no crs', (done) => {
+ testEpic(
+ addTimeoutEpic(updateMapResolutionsOnCrsChangeEpic),
+ 1,
+ { type: CHANGE_MAP_CRS },
+ (actions) => {
+ expect(actions.length).toBe(1);
+ expect(actions[0].type).toBe('EPICTEST:TIMEOUT');
+ },
+ {},
+ done
+ );
+ });
});
});
diff --git a/web/client/plugins/CRSSelector/epics/crsselector.js b/web/client/plugins/CRSSelector/epics/crsselector.js
index 994144b415..06a52b9441 100644
--- a/web/client/plugins/CRSSelector/epics/crsselector.js
+++ b/web/client/plugins/CRSSelector/epics/crsselector.js
@@ -1,19 +1,88 @@
import { MAP_CONFIG_LOADED } from '../../../actions/config';
import { setProjectionsConfig } from '../actions/crsselector';
+import Rx from 'rxjs';
+import isEqual from 'lodash/isEqual';
+import {
+ CHANGE_MAP_CRS,
+ setMapResolutions,
+ updateMapOptions
+} from '../../../actions/map';
+import { mapSelector } from '../../../selectors/map';
+import { getResolutionsForProjection } from '../../../utils/MapUtils';
+import { customResolutionsForCrsSelector } from '../selectors/crsselector';
-// On MAP_CONFIG_LOADED, restore the plugin's quick-switch projectionList from
-// the persisted crsSelector config. Dynamic projection defs themselves are
-// restored at framework level by restoreDynamicProjectionDefsEpic.
+/**
+ * Returns the resolutions to apply for a given CRS, using the custom resolutions configured for that CRS when available, and falling back
+ * to the resolutions derived from the projection extent otherwise.
+ */
+const resolveResolutionsForCrs = (state, crs) => {
+ const custom = customResolutionsForCrsSelector(state, crs);
+ if (custom && custom.length) {
+ return custom;
+ }
+ return getResolutionsForProjection(crs);
+};
+
+const omitProjectionFromView = (view = {}) => {
+ const viewOptions = { ...view };
+ delete viewOptions.projection;
+ return viewOptions;
+};
+
+/**
+ * When a map is loaded, restore the plugin configuration that was saved with it (the available projection list and any per-CRS custom
+ * resolutions). For maps saved before per-CRS custom resolutions were supported, align the active map resolutions with the configured ones
+ * so the map stays consistent on reload and on the next save.
+ */
export const updateCrsSelectorConfigEpic = (action$) =>
action$.ofType(MAP_CONFIG_LOADED)
- .map((action) => {
+ .switchMap((action) => {
const csConfig = action?.config?.crsSelector;
- if (csConfig?.projectionList) {
- return setProjectionsConfig({ projectionList: csConfig.projectionList });
+ const mapConfig = action?.config?.map;
+ const projection = mapConfig?.projection;
+ const view = mapConfig?.mapOptions?.view || {};
+ const customResolutions = csConfig?.customResolutions || {};
+
+ const setConfigAction = csConfig?.projectionList || csConfig?.customResolutions
+ ? setProjectionsConfig({ ...csConfig })
+ : setProjectionsConfig(undefined);
+
+ const customForCrs = projection ? customResolutions[projection] : undefined;
+ const resolutionsAreAlignedToCrs = !!view.resolutions
+ && isEqual(view.resolutions, customForCrs);
+ const canUpdateMapOptions = !!customForCrs && customForCrs.length > 0 && !resolutionsAreAlignedToCrs;
+
+ if (canUpdateMapOptions) {
+ return Rx.Observable.of(
+ setConfigAction,
+ updateMapOptions({ view: { ...omitProjectionFromView(view), resolutions: customForCrs } }),
+ setMapResolutions(customForCrs)
+ );
+ }
+ return Rx.Observable.of(setConfigAction);
+ });
+
+/**
+ * When the user changes the map CRS, update the map's resolutions to match: use the custom resolutions configured for the new CRS when
+ * available, otherwise compute them from the projection extent. The resolutions applied here are kept in sync with the active CRS so the
+ * map can be saved and reopened without misalignment.
+ */
+export const updateMapResolutionsOnCrsChangeEpic = (action$, store) =>
+ action$.ofType(CHANGE_MAP_CRS)
+ .switchMap(({ crs }) => {
+ if (!crs) {
+ return Rx.Observable.empty();
}
- return setProjectionsConfig(undefined);
+ const state = store.getState();
+ const resolutions = resolveResolutionsForCrs(state, crs);
+ const currentView = mapSelector(state)?.mapOptions?.view || {};
+ return Rx.Observable.of(
+ updateMapOptions({ view: { ...omitProjectionFromView(currentView), resolutions } }),
+ setMapResolutions(resolutions)
+ );
});
export default {
- updateCrsSelectorConfigEpic
+ updateCrsSelectorConfigEpic,
+ updateMapResolutionsOnCrsChangeEpic
};
diff --git a/web/client/plugins/CRSSelector/index.js b/web/client/plugins/CRSSelector/index.js
index 3e5a1d8bad..7f17bc173e 100644
--- a/web/client/plugins/CRSSelector/index.js
+++ b/web/client/plugins/CRSSelector/index.js
@@ -22,34 +22,42 @@ import { createPlugin } from '../../utils/PluginsUtils';
* @prop {object[]} projectionDefs list of additional project definitions
* @prop {array} cfg.allowedRoles list of the authorized roles that can use the plugin, if you want all users to access the plugin, add a "ALL" element to the array.
* @prop {array} cfg.availableProjections list of the available projections to be displayed in the combobox.
- * @prop {string[]} cfg.filterAllowedCRS (deprecated) list of allowed crs in the combobox list to used as filter for the one of retrieved proj4.defs()
- * @prop {object} cfg.additionalCRS (deprecated) additional crs added to the list. The label param is used after in the combobox.
- * @prop {string} cfg.projectionDefsEndpoint (optional) if provided, the plugin will fetch available projections from this endpoint (only supported the GeoServer REST provider).
- *
- * @example
- * // If you want to add some crs you need to provide a definition and adding it in the additionalCRS property
- * // Put the following lines at the first level of the localconfig
- * {
- * "projectionDefs": [{
- * "code": "EPSG:3003",
- * "def": "+proj=tmerc +lat_0=0 +lon_0=9 +k=0.9996 +x_0=1500000 +y_0=0 +ellps=intl+towgs84=-104.1,-49.1,-9.9,0.971,-2.917,0.714,-11.68 +units=m +no_defs",
- * "extent": [1241482.0019, 973563.1609, 1830078.9331, 5215189.0853],
- * "worldExtent": [6.6500, 8.8000, 12.0000, 47.0500]
- * }]
- * }
- * @example
- * // And configure the new projection for the plugin as below:
- * { "name": "CRSSelector",
- * "cfg": {
- * "projectionDefsEndpoint": "https://example.com/geoserver",
- * "availableProjections": [
- * { "value": "EPSG:4326", "label": "EPSG:4326" },
- * { "value": "EPSG:3857", "label": "EPSG:3857" },
- * { "value": "EPSG:3003", "label": "EPSG:3003" }
- * ],
- * "allowedRoles" : ["ADMIN", "USER", "ALL"]
- * }
- * }
+ * @prop {string[]} cfg.filterAllowedCRS (deprecated) list of allowed crs in the combobox list to used as filter for the one of retrieved proj4.defs()
+ * @prop {object} cfg.additionalCRS (deprecated) additional crs added to the list. The label param is used after in the combobox.
+ * @prop {string} cfg.projectionDefsEndpoint (optional) if provided, the plugin will fetch available projections from this endpoint (only supported the GeoServer REST provider).
+ * @prop {object} cfg.customResolutions (optional) map of per-CRS resolution arrays, keyed by SRS code. When the user switches the map CRS to one of the configured codes,
+ * the matching list of resolutions is applied to the map. The applied list is saved together with the map so that, when the map is reopened, the CRS and the resolutions
+ * remain aligned. If a CRS has no entry here, the resolutions are computed from the projection extent. See the migration guide for upgrading from earlier versions where
+ * custom resolutions were declared in the default map configuration.
+ *
+ * @example
+ * // If you want to add some crs you need to provide a definition and adding it in the additionalCRS property
+ * // Put the following lines at the first level of the localconfig
+ * {
+ * "projectionDefs": [{
+ * "code": "EPSG:3003",
+ * "def": "+proj=tmerc +lat_0=0 +lon_0=9 +k=0.9996 +x_0=1500000 +y_0=0 +ellps=intl+towgs84=-104.1,-49.1,-9.9,0.971,-2.917,0.714,-11.68 +units=m +no_defs",
+ * "extent": [1241482.0019, 973563.1609, 1830078.9331, 5215189.0853],
+ * "worldExtent": [6.6500, 8.8000, 12.0000, 47.0500]
+ * }]
+ * }
+ * @example
+ * // And configure the new projection for the plugin as below:
+ * { "name": "CRSSelector",
+ * "cfg": {
+ * "projectionDefsEndpoint": "https://example.com/geoserver",
+ * "availableProjections": [
+ * { "value": "EPSG:4326", "label": "EPSG:4326" },
+ * { "value": "EPSG:3857", "label": "EPSG:3857" },
+ * { "value": "EPSG:3003", "label": "EPSG:3003" }
+ * ],
+ * "customResolutions": {
+ * "EPSG:3003": [2366, 1183, 591, 295, 147, 73, 36, 18, 9, 4, 2, 1, 0.5, 0.28, 0.14, 0.07, 0.035, 0.018],
+ * "EPSG:4326": [0.7, 0.35, 0.175, 0.0875, 0.04375, 0.021875, 0.010986, 0.0054931]
+ * }
+ * "allowedRoles" : ["ADMIN", "USER", "ALL"]
+ * }
+ * }
*/
export default createPlugin('CRSSelector', {
component: CRSSelector,
diff --git a/web/client/plugins/CRSSelector/selectors/__tests__/crsselector-test.js b/web/client/plugins/CRSSelector/selectors/__tests__/crsselector-test.js
index 7b5f343108..9f25ca9a7d 100644
--- a/web/client/plugins/CRSSelector/selectors/__tests__/crsselector-test.js
+++ b/web/client/plugins/CRSSelector/selectors/__tests__/crsselector-test.js
@@ -7,7 +7,12 @@
*/
import expect from 'expect';
-import { crsInputValueSelector, canEditProjectionSelector } from '../crsselector';
+import {
+ crsInputValueSelector,
+ canEditProjectionSelector,
+ customResolutionsByCrsSelector,
+ customResolutionsForCrsSelector
+} from '../crsselector';
describe('Test crsselector selectors', () => {
it('test crsInputValueSelector', () => {
@@ -39,4 +44,40 @@ describe('Test crsselector selectors', () => {
};
expect(canEditProjectionSelector(state)).toBe(false);
});
+
+ describe('customResolutions selectors', () => {
+ const stateWithCustom = {
+ crsselector: {
+ config: {
+ customResolutions: {
+ 'EPSG:3003': [8000, 4000, 2000],
+ 'EPSG:4326': [0.7, 0.35, 0.175]
+ }
+ }
+ }
+ };
+
+ it('customResolutionsByCrsSelector returns the whole map', () => {
+ expect(customResolutionsByCrsSelector(stateWithCustom)).toEqual({
+ 'EPSG:3003': [8000, 4000, 2000],
+ 'EPSG:4326': [0.7, 0.35, 0.175]
+ });
+ });
+
+ it('customResolutionsByCrsSelector returns empty object when no config', () => {
+ expect(customResolutionsByCrsSelector({})).toEqual({});
+ expect(customResolutionsByCrsSelector({ crsselector: { config: {} } })).toEqual({});
+ });
+
+ it('customResolutionsForCrsSelector returns the array for the requested CRS', () => {
+ expect(customResolutionsForCrsSelector(stateWithCustom, 'EPSG:3003')).toEqual([8000, 4000, 2000]);
+ expect(customResolutionsForCrsSelector(stateWithCustom, 'EPSG:4326')).toEqual([0.7, 0.35, 0.175]);
+ });
+
+ it('customResolutionsForCrsSelector returns undefined for unknown CRS / no crs / no config', () => {
+ expect(customResolutionsForCrsSelector(stateWithCustom, 'EPSG:3857')).toBe(undefined);
+ expect(customResolutionsForCrsSelector(stateWithCustom, undefined)).toBe(undefined);
+ expect(customResolutionsForCrsSelector({}, 'EPSG:4326')).toBe(undefined);
+ });
+ });
});
diff --git a/web/client/plugins/CRSSelector/selectors/crsselector.js b/web/client/plugins/CRSSelector/selectors/crsselector.js
index 86eea74ad0..926b80f406 100644
--- a/web/client/plugins/CRSSelector/selectors/crsselector.js
+++ b/web/client/plugins/CRSSelector/selectors/crsselector.js
@@ -12,6 +12,26 @@ export const crsInputValueSelector = state => state && state.crsselector && stat
export const crsProjectionsConfigSelector = state => state && state.crsselector && state.crsselector.config;
export const crsCanEditSelector = state => state && state.crsselector && state.crsselector.canEdit;
+/**
+ * Returns the configured per-CRS resolutions, keyed by SRS code.
+ * Returns an empty object when no custom resolutions are configured.
+ * @memberof selectors.crsselector
+ * @param {object} state the application state
+ * @returns {object}
+ */
+export const customResolutionsByCrsSelector = (state) =>
+ state?.crsselector?.config?.customResolutions || {};
+
+/**
+ * Returns the array of custom resolutions configured for the given CRS, or `undefined` when no custom resolutions are configured for it.
+ * @memberof selectors.crsselector
+ * @param {object} state the application state
+ * @param {string} crs the target SRS code (e.g. "EPSG:4326")
+ * @returns {number[]|undefined}
+ */
+export const customResolutionsForCrsSelector = (state, crs) =>
+ crs ? customResolutionsByCrsSelector(state)[crs] : undefined;
+
/**
* Selects canEdit projection configuration value if any
* @memberof selectors.crsselector