• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

geosolutions-it / MapStore2 / 30799522353

03 Aug 2026 08:59AM UTC coverage: 75.784% (+0.006%) from 75.778%
30799522353

push

github

web-flow
CRS change crashes with grid-based projections when layer resolution limits are configured  #12627 (#12669)

* avoid target CRS transform in resolution conversion

* Fix #12627 - convert layer resolution limits by units on CRS change

The converted limits were snapped to a resolution of the target CRS, but
getResolutions returns the resolutions configured in mapOptions.view for any SRS,
which on the way back is the previous CRS ladder: metres ended up snapped onto
degrees. Convert through the meters-per-unit ratio instead, the same one the map
view uses to keep the scale, and drop the snap so the conversion is reversible.

---------

Co-authored-by: Lorenzo Natali <lorenzo.natali@geosolutionsgroup.com>

36218 of 56973 branches covered (63.57%)

22 of 23 new or added lines in 2 files covered. (95.65%)

1 existing line in 1 file now uncovered.

44978 of 59350 relevant lines covered (75.78%)

123.88 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.41
/web/client/utils/MapUtils.js
1
/*
2
 * Copyright 2015-2016, GeoSolutions Sas.
3
 * All rights reserved.
4
 *
5
 * This source code is licensed under the BSD-style license found in the
6
 * LICENSE file in the root directory of this source tree.
7
 */
8
import * as Cesium from 'cesium';
9

10
import {
11
    pick,
12
    get,
13
    find,
14
    mapKeys,
15
    mapValues,
16
    keys,
17
    uniq,
18
    uniqWith,
19
    isEqual,
20
    isEmpty,
21
    findIndex,
22
    cloneDeep,
23
    minBy,
24
    omit,
25
    isObject
26
} from 'lodash';
27
import { get as getProjectionOL, getPointResolution, transform } from 'ol/proj';
28
import { get as getExtent } from 'ol/proj/projections';
29

30
import { v1 as uuidv1 } from 'uuid';
31

32
import { getUnits, normalizeSRS, reproject } from './CoordinatesUtils';
33

34
import { getProjection } from './ProjectionUtils';
35

36
import { set } from './ImmutableUtils';
37
import {
38
    saveLayer,
39
    getGroupNodes,
40
    getNode,
41
    extractSourcesFromLayers,
42
    updateAvailableTileMatrixSetsOptions,
43
    getTileMatrixSetLink,
44
    DEFAULT_GROUP_ID
45
} from './LayersUtils';
46

47
export const DEFAULT_SCREEN_DPI = 96;
3✔
48

49
export const METERS_PER_UNIT = {
3✔
50
    'm': 1,
51
    'degrees': 111194.87428468118,
52
    'ft': 0.3048,
53
    'us-ft': 1200 / 3937,
54
    'foot-gold-coast': 0.30479971018542
55
};
56

57
// Map alternate spellings (post normalization) to canonical METERS_PER_UNIT
58
// keys. Sources differ: proj4 short syntax uses `m`/`ft`/`us-ft`/`degrees`;
59
// WKT1 carries `metre`/`degree`/`foot`/`foot_US`; EPSG names use the long
60
// English form; Esri WKT uses CamelCase with underscores (`Foot_US`,
61
// `Decimal_Degree`). normalizeUnit lowercases, trims, and collapses runs of
62
// underscores or whitespace to a single space before lookup, so each variant
63
// only needs one entry here in its post-normalization form.
64
const UNIT_ALIASES = {
3✔
65
    // degrees
66
    'degree': 'degrees',
67
    'dd': 'degrees',
68
    'decimal degree': 'degrees',
69
    'decimal degrees': 'degrees',
70
    // m
71
    'meter': 'm',
72
    'meters': 'm',
73
    'metre': 'm',
74
    'metres': 'm',
75
    // ft (international)
76
    'foot': 'ft',
77
    'feet': 'ft',
78
    'international foot': 'ft',
79
    'foot international': 'ft',
80
    'intl foot': 'ft',
81
    'intl ft': 'ft',
82
    // us-ft (US survey)
83
    'us ft': 'us-ft',
84
    'usft': 'us-ft',
85
    'ft us': 'us-ft',
86
    'ftus': 'us-ft',
87
    'foot us': 'us-ft',
88
    'us foot': 'us-ft',
89
    'usfoot': 'us-ft',
90
    'us survey foot': 'us-ft',
91
    'survey foot us': 'us-ft',
92
    'foot us survey': 'us-ft',
93
    'ft survey us': 'us-ft',
94
    // Ghana legacy foot, scale 0.30479971018542; proj4 emits 'foot_gold_coast'
95
    'foot gold coast': 'foot-gold-coast'
96
};
97

98
// proj4 emits `m*<scale>` when WKT carries a non-standard `UNIT["x", scale]`
99
// the scale is literally the meters-per-unit ratio. Match after normalize.
100
const M_SCALE_RE = /^m\*(\d+(?:\.\d+)?)$/;
3✔
101

102
/**
103
 * Normalize a unit string to a canonical METERS_PER_UNIT key.
104
 * Lowercases, trims, and collapses runs of underscores or whitespace to a
105
 * single space so that `Foot_US`, `foot us`, `FOOT  US` all match the same
106
 * alias entry.
107
 * @param {string} unit
108
 * @return {string|null}
109
 */
110
export function normalizeUnit(unit) {
111
    if (!unit) {
5,498✔
112
        return null;
12✔
113
    }
114
    const k = String(unit).toLowerCase().trim().replace(/[_\s]+/g, ' ');
5,486✔
115
    return UNIT_ALIASES[k] ?? k;
5,486✔
116
}
117

118
/**
119
 * Resolve a unit name (any spelling) to its meters-per-unit value.
120
 * @param {string} unit unit string from a projection (proj4, WKT or EPSG spelling)
121
 * @param {number} [fallback] returned when the unit is unknown; defaults to undefined
122
 * @return {number|undefined} meters-per-unit value
123
 */
124
export function getMetersPerUnit(unit, fallback) {
125
    const normalized = normalizeUnit(unit);
5,390✔
126
    const direct = METERS_PER_UNIT[normalized];
5,390✔
127
    if (direct !== undefined) return direct;
5,390✔
128
    const scaleMatch = normalized && M_SCALE_RE.exec(normalized);
15✔
129
    if (scaleMatch) return Number(scaleMatch[1]);
15✔
130
    return fallback;
9✔
131
}
132

133
export const GOOGLE_MERCATOR = {
3✔
134
    RADIUS: 6378137,
135
    TILE_WIDTH: 256,
136
    ZOOM_FACTOR: 2
137
};
138

139
export const EMPTY_MAP = 'EMPTY_MAP';
3✔
140

141
import proj4 from "proj4";
142

143
export const EXTENT_TO_ZOOM_HOOK = 'EXTENT_TO_ZOOM_HOOK';
3✔
144

145
// id of the DOM container rendered by the Map plugin for the main map
146
export const MAIN_MAP_CONTAINER_ID = 'map';
3✔
147

148
/**
149
 * `ZOOM_TO_EXTENT_HOOK` hook takes 2 arguments:
150
 * - `extent`: array of the extent [minx, miny, maxx, maxy]
151
 * - `options` object, with the following attributes:
152
 *   - `crs`: crs of the extent
153
 *   - `maxZoom`: max zoom for the zoom to functionality.
154
 *   - `padding`: object with attributes, `top`, `right`, `bottom` and `top` with the size, in pixels of the padding for the visible part of the map. When supported by the mapping lib, it will zoom to visible area
155
 */
156
export const ZOOM_TO_EXTENT_HOOK = 'ZOOM_TO_EXTENT_HOOK';
3✔
157
export const RESOLUTIONS_HOOK = 'RESOLUTIONS_HOOK';
3✔
158
export const RESOLUTION_HOOK = 'RESOLUTION_HOOK';
3✔
159
export const COMPUTE_BBOX_HOOK = 'COMPUTE_BBOX_HOOK';
3✔
160
export const GET_PIXEL_FROM_COORDINATES_HOOK = 'GET_PIXEL_FROM_COORDINATES_HOOK';
3✔
161
export const GET_COORDINATES_FROM_PIXEL_HOOK = 'GET_COORDINATES_FROM_PIXEL_HOOK';
3✔
162
export const CLICK_ON_MAP_HOOK = 'CLICK_ON_MAP_HOOK';
3✔
163

164
let hooks = {};
3✔
165

166

167
export function registerHook(name, hook) {
168
    hooks[name] = hook;
3,831✔
169
}
170

171
export function getHook(name) {
172
    return hooks[name];
3,328✔
173
}
174

175
export function executeHook(hookName, existCallback, dontExistCallback) {
176
    const hook = getHook(hookName);
63✔
177
    if (hook) {
63✔
178
        return existCallback(hook);
42✔
179
    }
180
    if (dontExistCallback) {
21!
181
        return dontExistCallback();
×
182
    }
183
    return null;
21✔
184
}
185

186
export function clearHooks() {
187
    hooks = {};
144✔
188
}
189

190
/**
191
 * @param dpi {number} dot per inch resolution
192
 * @return {number} dot per meter resolution
193
 */
194
export function dpi2dpm(dpi) {
195
    return dpi * (100 / 2.54);
33,245✔
196
}
197

198
/**
199
 * @param dpi {number} screen resolution in dots per inch.
200
 * @param projection {string} map projection.
201
 * @return {number} dots per map unit.
202
 */
203
export function dpi2dpu(dpi, projection) {
204
    const units = getUnits(projection || "EPSG:3857");
5,114✔
205
    return getMetersPerUnit(units) * dpi2dpm(dpi || DEFAULT_SCREEN_DPI);
5,114✔
206
}
207

208
/**
209
 * @param radius {number} Earth's radius of the model in meters.
210
 * @param tileWidth {number} width of the tiles used to draw the map.
211
 * @param zoomFactor {number} zoom factor.
212
 * @param zoomLvl {number} target zoom level.
213
 * @param dpi {number} screen resolution in dot per inch.
214
 * @return {number} the scale of the showed map.
215
 */
216
export function getSphericalMercatorScale(radius, tileWidth, zoomFactor, zoomLvl, dpi) {
217
    return 2 * Math.PI * radius / (tileWidth * Math.pow(zoomFactor, zoomLvl) / dpi2dpm(dpi || DEFAULT_SCREEN_DPI));
28,122✔
218
}
219

220
/**
221
 * @param zoomLvl {number} target zoom level.
222
 * @param dpi {number} screen resolution in dot per inch.
223
 * @return {number} the scale of the showed map.
224
 */
225
export function getGoogleMercatorScale(zoomLvl, dpi) {
226
    return getSphericalMercatorScale(GOOGLE_MERCATOR.RADIUS, GOOGLE_MERCATOR.TILE_WIDTH, GOOGLE_MERCATOR.ZOOM_FACTOR, zoomLvl, dpi);
93✔
227
}
228

229
/**
230
 * @param radius {number} Earth's radius of the model in meters.
231
 * @param tileWidth {number} width of the tiles used to draw the map.
232
 * @param zoomFactor {number} zoom factor.
233
 * @param minZoom {number} min zoom level.
234
 * @param maxZoom {number} max zoom level.
235
 * @param dpi {number} screen resolution in dot per inch.
236
 * @return {array} a list of scale for each zoom level in the given interval.
237
 */
238
export function getSphericalMercatorScales(radius, tileWidth, zoomFactor, minZoom, maxZoom, dpi) {
239
    var retval = [];
1,278✔
240
    for (let l = minZoom; l <= maxZoom; l++) {
1,278✔
241
        retval.push(
28,017✔
242
            getSphericalMercatorScale(
243
                radius,
244
                tileWidth,
245
                zoomFactor,
246
                l,
247
                dpi
248
            )
249
        );
250
    }
251
    return retval;
1,278✔
252
}
253

254
/**
255
 * Get a list of scales for each zoom level of the Google Mercator.
256
 * @param minZoom {number} min zoom level.
257
 * @param maxZoom {number} max zoom level.
258
 * @return {array} a list of scale for each zoom level in the given interval.
259
 */
260
export function getGoogleMercatorScales(minZoom, maxZoom, dpi) {
261
    return getSphericalMercatorScales(
1,275✔
262
        GOOGLE_MERCATOR.RADIUS,
263
        GOOGLE_MERCATOR.TILE_WIDTH,
264
        GOOGLE_MERCATOR.ZOOM_FACTOR,
265
        minZoom,
266
        maxZoom,
267
        dpi
268
    );
269
}
270

271
/**
272
 * @param scales {array} list of scales.
273
 * @param projection {string} map projection.
274
 * @param dpi {number} screen resolution in dots per inch.
275
 * @return {array} a list of resolutions corresponding to the given scales, projection and dpi.
276
 */
277
export function getResolutionsForScales(scales, projection, dpi) {
278
    const dpu = dpi2dpu(dpi, projection);
1,299✔
279
    const resolutions = scales.map((scale) => {
1,299✔
280
        return scale / dpu;
27,969✔
281
    });
282
    return resolutions;
1,299✔
283
}
284

285
export function getGoogleMercatorResolutions(minZoom, maxZoom, dpi) {
286
    return getResolutionsForScales(getGoogleMercatorScales(minZoom, maxZoom, dpi), "EPSG:3857", dpi);
1,245✔
287
}
288

289
/**
290
 * Calculates resolutions accordingly with default algorithm in GeoWebCache.
291
 * See this: https://github.com/GeoWebCache/geowebcache/blob/5e913193ff50a61ef9dd63a87887189352fa6b21/geowebcache/core/src/main/java/org/geowebcache/grid/GridSetFactory.java#L196
292
 * It allows to have the resolutions aligned to the default generated grid sets on server side.
293
 * **NOTES**: this solution doesn't support:
294
 * - custom grid sets with `alignTopLeft=true` (e.g. GlobalCRS84Pixel). Custom resolutions will need to be configured as `mapOptions.view.resolutions`
295
 * - custom grid set with custom extent. You need to customize the projection definition extent to make it work.
296
 * - custom grid set is partially supported by mapOptions.view.resolutions but this is not managed by projection change yet
297
 * - custom tile sizes
298
 * @param {string} srs projection code
299
 * @param {object} options optional configuration
300
 * @param {number} options.minResolution minimum resolution of the tile grid pyramid, default computed based on minimum zoom
301
 * @param {number} options.maxResolution maximum resolution of the tile grid pyramid, default computed based on maximum zoom
302
 * @param {number} options.minZoom minimum zoom of the tile grid pyramid, default 0
303
 * @param {number} options.maxZoom maximum zoom of the tile grid pyramid, default 30
304
 * @param {number} options.zoomFactor zoom factor, default 2
305
 * @param {array} options.extent extent of the tile grid pyramid in the projection coordinates, [minx, miny, maxx, maxy], default maximum extent of the projection
306
 * @param {number} options.tileWidth tile width, default 256
307
 * @param {number} options.tileHeight tile height, default 256
308
 * @return {array} a list of resolution based on the selected projection
309
 */
310
export function getResolutionsForProjection(srs, {
290✔
311
    minResolution: minRes,
312
    maxResolution: maxRes,
313
    minZoom: minZ,
314
    maxZoom: maxZ,
315
    zoomFactor: zoomF,
316
    extent: ext,
317
    tileWidth = 256,
2,480✔
318
    tileHeight = 256
2,480✔
319
} = {}) {
320
    const defaultMaxZoom = 30;
2,654✔
321
    const defaultZoomFactor = 2;
2,654✔
322

323
    let minZoom = minZ ?? 0;
2,654✔
324

325
    let maxZoom = maxZ ?? defaultMaxZoom;
2,654✔
326

327
    let zoomFactor = zoomF ?? defaultZoomFactor;
2,654✔
328

329
    const projection = proj4.defs(srs);
2,654✔
330

331
    const extent = ext ?? getProjection(srs)?.extent;
2,654✔
332

333
    const extentWidth = !extent ? 360 * METERS_PER_UNIT.degrees /
2,654!
334
        getMetersPerUnit(projection.getUnits()) :
335
        extent[2] - extent[0];
336
    const extentHeight = !extent ? 360 * METERS_PER_UNIT.degrees /
2,654!
337
        getMetersPerUnit(projection.getUnits()) :
338
        extent[3] - extent[1];
339

340
    let resX = extentWidth / tileWidth;
2,654✔
341
    let resY = extentHeight / tileHeight;
2,654✔
342
    let tilesWide;
343
    let tilesHigh;
344
    if (resX <= resY) {
2,654✔
345
        // use one tile wide by N tiles high
346
        tilesWide = 1;
2,139✔
347
        tilesHigh = Math.round(resY / resX);
2,139✔
348
        // previous resY was assuming 1 tile high, recompute with the actual number of tiles
349
        // high
350
        resY = resY / tilesHigh;
2,139✔
351
    } else {
352
        // use one tile high by N tiles wide
353
        tilesHigh = 1;
515✔
354
        tilesWide = Math.round(resX / resY);
515✔
355
        // previous resX was assuming 1 tile wide, recompute with the actual number of tiles
356
        // wide
357
        resX = resX / tilesWide;
515✔
358
    }
359
    // the maximum of resX and resY is the one that adjusts better
360
    const res = Math.max(resX, resY);
2,654✔
361

362
    /*
363
        // TODO: this is how GWC creates the bbox adjusted.
364
        // We should calculate it to have the correct extent for a grid set
365
        const adjustedExtentWidth = tilesWide * tileWidth * res;
366
        const adjustedExtentHeight = tilesHigh * tileHeight * res;
367
        BoundingBox adjExtent = new BoundingBox(extent);
368
        adjExtent.setMaxX(adjExtent.getMinX() + adjustedExtentWidth);
369
        // Do we keep the top or the bottom fixed?
370
        if (alignTopLeft) {
371
            adjExtent.setMinY(adjExtent.getMaxY() - adjustedExtentHeight);
372
        } else {
373
            adjExtent.setMaxY(adjExtent.getMinY() + adjustedExtentHeight);
374

375
     */
376

377
    const defaultMaxResolution = res;
2,654✔
378

379
    const defaultMinResolution = defaultMaxResolution / Math.pow(
2,654✔
380
        defaultZoomFactor, defaultMaxZoom - 0);
381

382
    // user provided maxResolution takes precedence
383
    let maxResolution = maxRes;
2,654✔
384
    if (maxResolution !== undefined) {
2,654!
385
        minZoom = 0;
×
386
    } else {
387
        maxResolution = defaultMaxResolution / Math.pow(zoomFactor, minZoom);
2,654✔
388
    }
389

390
    // user provided minResolution takes precedence
391
    let minResolution = minRes;
2,654✔
392
    if (minResolution === undefined) {
2,654!
393
        if (maxZoom !== undefined) {
2,654!
394
            if (maxRes !== undefined) {
2,654!
395
                minResolution = maxResolution / Math.pow(zoomFactor, maxZoom);
×
396
            } else {
397
                minResolution = defaultMaxResolution / Math.pow(zoomFactor, maxZoom);
2,654✔
398
            }
399
        } else {
400
            minResolution = defaultMinResolution;
×
401
        }
402
        // Cap resolutions to avoid inverted scales (< 1:1), since sub-millimeter scales are not meaningful.
403
        const minUsableResolution = 1 / dpi2dpu(DEFAULT_SCREEN_DPI, srs);
2,654✔
404
        minResolution = Math.max(minResolution, minUsableResolution);
2,654✔
405
    }
406

407
    // given discrete zoom levels, minResolution may be different than provided
408
    maxZoom = minZoom + Math.floor(
2,654✔
409
        Math.log(maxResolution / minResolution) / Math.log(zoomFactor));
410
    // Bail out cleanly when intermediate math went non-finite. Causes include
411
    // an extent containing NaN, a zero-width/height extent, or an unknown unit
412
    // returning undefined meters-per-unit. Without this guard `Array(NaN)`
413
    // throws `Invalid array length` and crashes the caller.
414
    if (!Number.isFinite(maxZoom) || !Number.isFinite(minZoom) || maxZoom < minZoom) {
2,654!
415
        // we should at fallback to a set of default resolutions
416
        // to allow the map to render properly
417
        return getGoogleMercatorResolutions(0, 21, DEFAULT_SCREEN_DPI);
×
418
    }
419
    return Array.apply(0, Array(maxZoom - minZoom + 1)).map((x, y) => maxResolution / Math.pow(zoomFactor, y));
78,847✔
420
}
421

422
export function getResolutions(projection) {
423
    if (getHook('RESOLUTIONS_HOOK')) {
2,082✔
424
        return getHook('RESOLUTIONS_HOOK')(projection);
784✔
425
    }
426
    return projection && normalizeSRS(projection) !== "EPSG:3857" ? getResolutionsForProjection(projection) :
1,298✔
427
        getGoogleMercatorResolutions(0, 21, DEFAULT_SCREEN_DPI);
428
}
429

430
export function getScales(projection, dpi) {
431
    const dpu = dpi2dpu(dpi, projection);
628✔
432
    return getResolutions(projection).map((resolution) => resolution * dpu);
15,137✔
433
}
434

435
export function getScale(projection, dpi, resolution) {
436
    const dpu = dpi2dpu(dpi, projection);
76✔
437
    return resolution * dpu;
76✔
438
}
439

440
/**
441
 * Checks if the camera is looking perpendicular (nadir) to the surface
442
 * @param {Cesium.Camera} camera - The Cesium camera
443
 * @param {Cesium.Cartesian3} position - Position on the globe (Cartesian3)
444
 * @param {Cesium.Ellipsoid} ellipsoid - The ellipsoid (usually scene.globe.ellipsoid)
445
 * @param {number} threshold - Cosine threshold (0.95 = ~18°, 0.99 = ~8°)
446
 * @returns {boolean} True if camera is approximately perpendicular
447
 */
448
export function isCameraPerpendicularToSurface(camera, position, ellipsoid, threshold = 0.95) {
×
449
    const surfaceNormal = ellipsoid.geodeticSurfaceNormal(position);
15✔
450
    const cameraDirection = camera.direction;
15✔
451

452
    // Dot product: -1 = exactly opposite (straight down), 0 = parallel to surface
453
    const dot = Cesium.Cartesian3.dot(cameraDirection, surfaceNormal);
15✔
454

455
    // Check if dot product is close to -1 (camera looking straight down)
456
    return dot < -threshold;
15✔
457
}
458

459
/**
460
 * Calculates the map scale denominator at the center of the Cesium viewer's screen.
461
 *
462
 * * @param {Cesium.Viewer} viewer - The Cesium Viewer instance containing the scene and camera.
463
 * @returns {number} The map scale denominator (M in 1:M) at the screen center.
464
 *                   Returns a fallback scale based on camera height if the camera
465
 *                   is looking at space or the globe intersection fails.
466
 **/
467
export function getMapScaleForCesium(viewer) {
468
    const FALLBACK_EARTH_CIRCUMFERENCE_METERS = 80000000;
24✔
469
    const cesiumDefaultProj = "EPSG:3857";
24✔
470
    const scene = viewer.scene;
24✔
471
    const camera = scene.camera;
21✔
472
    const canvas = scene.canvas;
21✔
473
    const ellipsoid = scene.globe.ellipsoid;
21✔
474
    // 1. Get two points at the center of the screen, 1 pixel apart horizontally
475
    const centerX = Math.floor(canvas.clientWidth / 2);
21✔
476
    const centerY = Math.floor(canvas.clientHeight / 2);
21✔
477

478
    const leftPoint = new Cesium.Cartesian2(centerX, centerY);
21✔
479
    const rightPoint = new Cesium.Cartesian2(centerX + 1, centerY);
21✔
480

481
    // 2. Convert screen pixels to Globe positions (Cartesian3)
482
    const leftRay = camera.getPickRay(leftPoint);
21✔
483
    const rightRay = camera.getPickRay(rightPoint);
21✔
484

485
    const leftPos = scene.globe.pick(leftRay, scene);
21✔
486
    const rightPos = scene.globe.pick(rightRay, scene);
9✔
487

488
    // Check if camera is perpendicular (only if we have a valid position to test against)
489
    const isPerpendicular = Cesium.defined(leftPos) ? isCameraPerpendicularToSurface(camera, leftPos, ellipsoid, 0.95) : false;
9✔
490

491
    if (!Cesium.defined(leftPos) || !Cesium.defined(rightPos) || isPerpendicular) {
9✔
492
        console.warn('Camera is looking at space/sky or is perpendicular');
3✔
493
        const cameraPosition = camera.positionCartographic;
3✔
494
        const currentZoom = Math.log2(FALLBACK_EARTH_CIRCUMFERENCE_METERS / (cameraPosition.height)) + 1;
3✔
495
        const resolutions = getResolutions();
3✔
496
        const resolution = resolutions[Math.round(currentZoom)];
3✔
497
        const scaleVal = getScale(cesiumDefaultProj, DEFAULT_SCREEN_DPI, resolution);
3✔
498
        return Math.round(scaleVal ?? 0);
3!
499
    }
500

501
    const leftCartographic = scene.globe.ellipsoid.cartesianToCartographic(leftPos);
6✔
502
    const rightCartographic = scene.globe.ellipsoid.cartesianToCartographic(rightPos);
6✔
503

504
    const geodesic = new Cesium.EllipsoidGeodesic(leftCartographic, rightCartographic);
6✔
505
    const resolution = geodesic.surfaceDistance; // This is meters per 1 pixel [resolution]
6✔
506
    const scaleValue = getScale(cesiumDefaultProj, DEFAULT_SCREEN_DPI, resolution);
6✔
507
    return Math.round(scaleValue ?? 0);
6!
508
}
509
/**
510
 * get random coordinates within CRS extent
511
 * @param {string} crs the code of the projection for example EPSG:4346
512
 * @returns {number[]} the point in [x,y] [lon,lat]
513
 */
514
export function getRandomPointInCRS(crs) {
515
    const extent = getExtent(crs); // Get the projection's extent
12✔
516
    if (!extent) {
12!
517
        throw new Error(`Extent not available for CRS: ${crs}`);
×
518
    }
519
    const [minX, minY, maxX, maxY] = extent.extent_;
12✔
520

521
    // Check if the equator (latitude = 0) is within the CRS extent
522
    const isEquatorWithinExtent = minY <= 0 && maxY >= 0;
12✔
523

524
    // Generate a random X coordinate within the valid longitude range
525
    const randomX = Math.random() * (maxX - minX) + minX;
12✔
526

527
    // Set Y to 0 if the equator is within the extent, otherwise generate a random Y
528
    const randomY = isEquatorWithinExtent ? 0 : Math.random() * (maxY - minY) + minY;
12!
529

530
    return [randomX, randomY];
12✔
531
}
532

533
/**
534
 * convert resolution between CRSs
535
 * @param {string} sourceCRS the code of a projection
536
 * @param {string} targetCRS the code of a projection
537
 * @param {number} sourceResolution the resolution to convert
538
 * @param {number[]} [anchorPoint] point in sourceCRS to anchor the conversion to. Falls back to a random point
539
 *  in the source extent when omitted, for backwards compatibility.
540
 * @returns the converted resolution
541
 */
542
export function convertResolution(sourceCRS, targetCRS, sourceResolution, anchorPoint) {
543
    const sourceProjection = getProjectionOL(sourceCRS);
15✔
544
    const targetProjection = getProjectionOL(targetCRS);
15✔
545

546
    if (!sourceProjection || !targetProjection) {
15!
547
        throw new Error(`Invalid CRS: ${sourceCRS} or ${targetCRS}`);
×
548
    }
549

550
    const point = anchorPoint || getRandomPointInCRS(sourceCRS);
15✔
551

552
    // getPointResolution expects the point in the space of the projection passed as first argument,
553
    // so `point` stays in sourceCRS: measuring the source resolution needs no target transform
554
    const groundResolution = getPointResolution(sourceProjection, sourceResolution, point, 'm');
15✔
555

556
    // ground meters covered by one targetCRS unit at the same location
557
    let metersPerTargetUnit;
558
    try {
15✔
559
        const targetPoint = transform(point, sourceCRS, targetCRS);
15✔
560
        if (targetPoint.every((value) => Number.isFinite(value))) {
27✔
561
            metersPerTargetUnit = getPointResolution(targetProjection, 1, targetPoint, 'm');
12✔
562
        }
563
    } catch (e) {
NEW
564
        metersPerTargetUnit = undefined;
×
565
    }
566

567
    const transformedResolution = metersPerTargetUnit > 0
15✔
568
        ? groundResolution / metersPerTargetUnit
569
        // targetCRS not reachable from this point: convert by units only, ignoring its local distortion
570
        : getPointResolution(sourceProjection, sourceResolution, point, targetProjection.getUnits());
571

572
    return { randomPoint: point, transformedResolution };
15✔
573
}
574

575
/**
576
 * Convert a resolution between two CRSs through the meters-per-unit ratio of their units.
577
 *
578
 * This is the ratio the map view uses as well when it looks for the zoom level that keeps the
579
 * current scale on a projection change: resolutions converted this way keep the same relation
580
 * with the view resolution, which is what preserves layer visibility across a CRS switch.
581
 * Converting through the local point resolution instead would be closer to the real size on the
582
 * ground, but it would drift from the view by the local scale factor of the projection
583
 * (1 / cos(lat) for Mercator, one full zoom level around 45 degrees of latitude).
584
 *
585
 * @param {string} sourceCRS the code of the source projection
586
 * @param {string} targetCRS the code of the target projection
587
 * @param {number} sourceResolution the resolution to convert, in sourceCRS units
588
 * @returns {number} the resolution in targetCRS units
589
 */
590
export function convertResolutionByUnits(sourceCRS, targetCRS, sourceResolution) {
591
    const metersPerCRSUnit = (code) => getMetersPerUnit(getUnits(code), 1);
72✔
592
    return sourceResolution * metersPerCRSUnit(sourceCRS) / metersPerCRSUnit(targetCRS);
36✔
593
}
594

595
/**
596
 * Convert a resolution to the nearest zoom
597
 * @param {number} targetResolution resolution to be converted in zoom
598
 * @param {array} resolutions list of all available resolutions
599
 */
600
export function getZoomFromResolution(targetResolution, resolutions = getResolutions()) {
3✔
601
    // compute the absolute difference for all resolutions
602
    // and store the idx as zoom
603
    const diffs = resolutions
111✔
604
        .map((resolution, zoom) => ({ diff: Math.abs(resolution - targetResolution), zoom }))
2,670✔
605
        .filter(({ diff }) => Number.isFinite(diff));
2,670✔
606
    // the minimum difference represents the nearest zoom to the target resolution
607
    return minBy(diffs, 'diff')?.zoom;
111✔
608
}
609

610
/**
611
 * Calculate the exact zoom level corresponding to a given resolution
612
 *
613
 * @param {number} targetResolution resolution to be converted in zoom
614
 * @param {number[]} resolutions list of all available resolutions
615
 * @returns {number} - A floating-point number representing the exact zoom level that corresponds
616
 *                   to the provided resolution.
617
 *
618
 * @example
619
 * const resolutions = [2048, 1024, 512, 256];
620
 * const zoom = getExactZoom(600, resolutions);
621
 * console.log(zoom); // e.g., ~1.77
622
 */
623
export function getExactZoomFromResolution(targetResolution, resolutions = getResolutions()) {
×
624
    const maxResolution = resolutions[0]; // zoom level 0
84✔
625
    return Math.log2(maxResolution / targetResolution);
84✔
626
}
627

628
export function defaultGetZoomForExtent(extent, mapSize, minZoom, maxZoom, dpi, mapResolutions) {
629
    const wExtent = extent[2] - extent[0];
30✔
630
    const hExtent = extent[3] - extent[1];
30✔
631

632
    const xResolution = Math.abs(wExtent / mapSize.width);
30✔
633
    const yResolution = Math.abs(hExtent / mapSize.height);
30✔
634
    const extentResolution = Math.max(xResolution, yResolution);
30✔
635

636
    const resolutions = mapResolutions || getResolutionsForScales(getGoogleMercatorScales(
30✔
637
        minZoom, maxZoom, dpi || DEFAULT_SCREEN_DPI), "EPSG:3857", dpi);
33✔
638

639
    const {zoom} = resolutions.reduce((previous, resolution, index) => {
30✔
640
        const diff = Math.abs(resolution - extentResolution);
675✔
641
        return diff > previous.diff ? previous : {diff: diff, zoom: index};
675✔
642
    }, {diff: Number.POSITIVE_INFINITY, zoom: 0});
643

644
    return Math.max(0, Math.min(zoom, maxZoom));
30✔
645
}
646

647
/**
648
 * Calculates the best fitting zoom level for the given extent.
649
 *
650
 * @param extent {Array} [minx, miny, maxx, maxy]
651
 * @param mapSize {Object} current size of the map.
652
 * @param minZoom {number} min zoom level.
653
 * @param maxZoom {number} max zoom level.
654
 * @param dpi {number} screen resolution in dot per inch.
655
 * @return {Number} the zoom level fitting th extent
656
 */
657
export function getZoomForExtent(extent, mapSize, minZoom, maxZoom, dpi) {
658
    if (getHook("EXTENT_TO_ZOOM_HOOK")) {
33✔
659
        return getHook("EXTENT_TO_ZOOM_HOOK")(extent, mapSize, minZoom, maxZoom, dpi);
3✔
660
    }
661
    const resolutions = getHook("RESOLUTIONS_HOOK") ?
30✔
662
        getHook("RESOLUTIONS_HOOK")() : null;
663
    return defaultGetZoomForExtent(extent, mapSize, minZoom, maxZoom, dpi, resolutions);
30✔
664
}
665

666
/**
667
* It returns the current resolution.
668
*
669
* @param currentZoom {number} the current zoom
670
* @param minZoom {number} min zoom level.
671
* @param maxZoom {number} max zoom level.
672
* @param dpi {number} screen resolution in dot per inch.
673
* @return {Number} the actual resolution
674
*/
675
export function getCurrentResolution(currentZoom, minZoom, maxZoom, dpi) {
676
    if (getHook("RESOLUTION_HOOK")) {
198✔
677
        return getHook("RESOLUTION_HOOK")(currentZoom, minZoom, maxZoom, dpi);
6✔
678
    }
679
    /* if no hook is registered (leaflet) it is used the GoogleMercatorResolutions in
680
       in order to get the list of resolutions */
681
    return getGoogleMercatorResolutions(minZoom, maxZoom, dpi)[currentZoom];
192✔
682
}
683

684
/**
685
 * Calculates the center for for the given extent.
686
 *
687
 * @param  {Array} extent [minx, miny, maxx, maxy]
688
 * @param  {String} projection projection of the extent
689
 * @return {object} center object
690
 */
691
export function getCenterForExtent(extent, projection) {
692

693
    var wExtent = extent[2] - extent[0];
30✔
694
    var hExtent = extent[3] - extent[1];
30✔
695

696
    var w = wExtent / 2;
30✔
697
    var h = hExtent / 2;
30✔
698

699
    return {
30✔
700
        x: extent[0] + w,
701
        y: extent[1] + h,
702
        crs: projection
703
    };
704
}
705

706
/**
707
 * Calculates the bounding box for the given center and zoom.
708
 *
709
 * @param  {object} center object
710
 * @param  {number} zoom level
711
 */
712
export function getBbox(center, zoom) {
713
    return executeHook("COMPUTE_BBOX_HOOK",
63✔
714
        (hook) => {
715
            return hook(center, zoom);
42✔
716
        }
717
    );
718
}
719

720
function createTinyNumber(num) {
721
    return Math.pow(10, -num);
386✔
722
}
723

724
/**
725
 * current implementation will update the map only if the movement
726
 * between 12 decimals in the reference system to avoid rounded value
727
 * changes due to float mathematic operations.
728
 * avoid errors like 44.40641479 !== 44.40641478999999
729
 * using abs because the difference can be negative, creating a false positive
730
 * @param {*} a first number
731
 * @param {*} b second number
732
 * @param {number} precision
733
 * @returns
734
 */
735
export const isNearlyEqual = function(a, b, numOfDecimals = 12) {
3✔
736
    if (a === undefined || b === undefined) {
386!
737
        return false;
×
738
    }
739
    return Math.abs(Number(a).toFixed(8) - Number(b).toFixed(8)) <= createTinyNumber(numOfDecimals);
386✔
740

741
};
742

743
/**
744
 * checks if maps has changed by looking at center or zoom
745
 * @param {object} oldMap map object
746
 * @param {object} newMap map object
747
 */
748
export function mapUpdated(oldMap, newMap) {
749
    if (oldMap && !isEmpty(oldMap) &&
24✔
750
        newMap && !isEmpty(newMap)) {
751
        const centersEqual = isNearlyEqual(newMap?.center?.x, oldMap?.center?.x, 8) &&
9✔
752
                              isNearlyEqual(newMap?.center?.y, oldMap?.center?.y, 8);
753
        return !centersEqual || newMap?.zoom !== oldMap?.zoom;
9✔
754
    }
755
    return false;
15✔
756
}
757

758
/* Transform width and height specified in meters to the units of the specified projection */
759
export function transformExtent(projection, center, width, height) {
760
    let units = getUnits(projection);
×
761
    if (units === 'ft') {
×
762
        return {width: width / METERS_PER_UNIT.ft, height: height / METERS_PER_UNIT.ft};
×
763
    } else if (units === 'us-ft') {
×
764
        return {width: width / METERS_PER_UNIT['us-ft'], height: height / METERS_PER_UNIT['us-ft']};
×
765
    } else if (units === 'degrees') {
×
766
        return {
×
767
            width: width / (111132.92 - 559.82 * Math.cos(2 * center.y) + 1.175 * Math.cos(4 * center.y)),
768
            height: height / (111412.84 * Math.cos(center.y) - 93.5 * Math.cos(3 * center.y))
769
        };
770
    }
771
    return {width, height};
×
772
}
773

774
export const groupSaveFormatted = (node) => {
3✔
775
    return {
129✔
776
        id: node.id,
777
        title: node.title,
778
        description: node.description,
779
        tooltipOptions: node.tooltipOptions,
780
        tooltipPlacement: node.tooltipPlacement,
781
        expanded: node.expanded,
782
        visibility: node.visibility,
783
        nodesMutuallyExclusive: node.nodesMutuallyExclusive
784
    };
785
};
786

787

788
export function saveMapConfiguration(currentMap, currentLayers, currentGroups, currentBackgrounds, textSearchConfig, bookmarkSearchConfig, additionalOptions, projectionDefs) {
789

790
    const map = {
135✔
791
        center: currentMap.center,
792
        maxExtent: currentMap.maxExtent,
793
        projection: currentMap.projection,
794
        units: currentMap.units,
795
        mapInfoControl: currentMap.mapInfoControl,
796
        zoom: currentMap.zoom,
797
        mapOptions: currentMap.mapOptions || {},
249✔
798
        ...(currentMap.visualizationMode && { visualizationMode: currentMap.visualizationMode }),
135✔
799
        ...(currentMap.viewerOptions && { viewerOptions: currentMap.viewerOptions }),
135✔
800
        ...(projectionDefs?.length && { projections: { defs: projectionDefs } })
135✔
801
    };
802

803
    const layers = currentLayers.map((layer) => {
132✔
804
        return saveLayer(layer);
207✔
805
    });
806

807
    const flatGroupId = currentGroups.reduce((a, b) => {
132✔
808
        const flatGroups = a.concat(getGroupNodes(b));
105✔
809
        return flatGroups;
105✔
810
    }, [].concat(currentGroups.map(g => g.id)));
105✔
811

812
    const groups = flatGroupId.map(g => {
132✔
813
        const node = getNode(currentGroups, g);
285✔
814
        return node && node.nodes ? groupSaveFormatted(node) : null;
285✔
815
    }).filter(g => g);
285✔
816

817
    const backgrounds = currentBackgrounds.filter(background => !!background.thumbnail);
132✔
818

819
    // extract sources map
820
    const sources = extractSourcesFromLayers(layers);
132✔
821

822
    // removes tile matrix set from layers and replace it with a link if available in sources
823
    const formattedLayers = layers.map(layer => {
132✔
824
        const { availableTileMatrixSets, ...updatedLayer } = updateAvailableTileMatrixSetsOptions(layer);
207✔
825
        return availableTileMatrixSets
207✔
826
            ? {
827
                ...updatedLayer,
828
                availableTileMatrixSets: Object.keys(availableTileMatrixSets)
829
                    .reduce((acc, tileMatrixSetId) => {
830
                        const tileMatrixSetLink = getTileMatrixSetLink(layer, tileMatrixSetId);
9✔
831
                        if (get({ sources }, tileMatrixSetLink)) {
9!
832
                            return {
9✔
833
                                ...acc,
834
                                [tileMatrixSetId]: {
835
                                    ...omit(availableTileMatrixSets[tileMatrixSetId], 'tileMatrixSet'),
836
                                    tileMatrixSetLink
837
                                }
838
                            };
839
                        }
840
                        return {
×
841
                            ...acc,
842
                            [tileMatrixSetId]: availableTileMatrixSets[tileMatrixSetId]
843
                        };
844
                    }, {})
845
            }
846
            : updatedLayer;
847
    });
848

849
    /* removes the geometryGeodesic property from the features in the annotations layer*/
850
    let annotationsLayerIndex = findIndex(formattedLayers, layer => layer.id === "annotations");
207✔
851
    if (annotationsLayerIndex !== -1) {
132✔
852
        let featuresLayer = formattedLayers[annotationsLayerIndex].features.map(feature => {
3✔
853
            if (feature.type === "FeatureCollection") {
3!
854
                return {
3✔
855
                    ...feature,
856
                    features: feature.features.map(f => {
857
                        if (f.properties.geometryGeodesic) {
3!
858
                            return set("properties.geometryGeodesic", null, f);
3✔
859
                        }
860
                        return f;
×
861
                    })
862
                };
863
            }
864
            if (feature.properties.geometryGeodesic) {
×
865
                return set("properties.geometryGeodesic", null, feature);
×
866
            }
867
            return {};
×
868
        });
869
        formattedLayers[annotationsLayerIndex] = set("features", featuresLayer, formattedLayers[annotationsLayerIndex]);
3✔
870
    }
871

872
    return {
132✔
873
        version: 2,
874
        // layers are defined inside the map object
875
        map: Object.assign({}, map, {layers: formattedLayers, groups, backgrounds, text_search_config: textSearchConfig, bookmark_search_config: bookmarkSearchConfig},
876
            !isEmpty(sources) && {sources} || {}),
264✔
877
        ...additionalOptions
878
    };
879
}
880

881
export const generateNewUUIDs = (mapConfig = {}) => {
3!
882
    const newMapConfig = cloneDeep(mapConfig);
6✔
883

884
    const oldIdToNew = {
6✔
885
        ...get(mapConfig, 'map.layers', []).reduce((result, layer) => ({
12✔
886
            ...result,
887
            [layer.id]: layer.id === 'annotations' ? layer.id : uuidv1()
12!
888
        }), {}),
889
        ...get(mapConfig, 'widgetsConfig.widgets', []).reduce((result, widget) => ({...result, [widget.id]: uuidv1()}), {})
3✔
890
    };
891

892
    return set('map.backgrounds', get(mapConfig, 'map.backgrounds', []).map(background => ({...background, id: oldIdToNew[background.id]})),
6✔
893
        set('widgetsConfig', {
894
            collapsed: mapValues(mapKeys(get(mapConfig, 'widgetsConfig.collapsed', {}), (value, key) => oldIdToNew[key]), (value) =>
3✔
895
                ({...value, layouts: mapValues(value.layouts, (layout) => ({...layout, i: oldIdToNew[layout.i]}))})),
6✔
896
            layouts: mapValues(get(mapConfig, 'widgetsConfig.layouts', {}), (value) =>
897
                value.map(layout => ({...layout, i: oldIdToNew[layout.i]}))),
6✔
898
            widgets: get(mapConfig, 'widgetsConfig.widgets', [])
899
                .map(widget => ({
3✔
900
                    ...widget,
901
                    id: oldIdToNew[widget.id],
902
                    layer: ({...get(widget, 'layer', {}), id: oldIdToNew[get(widget, 'layer.id')]})
903
                }))
904
        },
905
        set('map.layers', get(mapConfig, 'map.layers', [])
906
            .map(layer => ({...layer, id: oldIdToNew[layer.id]})), newMapConfig)));
12✔
907
};
908

909
export const mergeMapConfigs = (cfg1 = {}, cfg2 = {}) => {
3!
910
    // removes empty props from layer as it can cause bugs
911
    const fixLayers = (layers = []) => layers.map(layer => pick(layer, keys(layer).filter(key => layer[key] !== undefined)));
216!
912

913
    const cfg2Fixed = generateNewUUIDs(cfg2);
6✔
914

915
    const backgrounds = [...get(cfg1, 'map.backgrounds', []), ...get(cfg2Fixed, 'map.backgrounds', [])];
6✔
916

917
    const layers1 = fixLayers(get(cfg1, 'map.layers', []));
6✔
918
    const layers2 = fixLayers(get(cfg2Fixed, 'map.layers', []));
6✔
919

920
    const annotationsLayer1 = find(layers1, layer => layer.id === 'annotations');
15✔
921
    const annotationsLayer2 = find(layers2, layer => layer.id === 'annotations');
12✔
922

923
    const layers = [
6✔
924
        ...layers2.filter(layer => layer.id !== 'annotations'),
12✔
925
        ...layers1.filter(layer => layer.id !== 'annotations'),
18✔
926
        ...(annotationsLayer1 || annotationsLayer2 ? [{
15✔
927
            ...(annotationsLayer1 || {}),
3!
928
            ...(annotationsLayer2 || {}),
6✔
929
            features: [
930
                ...get(annotationsLayer1, 'features', []), ...get(annotationsLayer2, 'features', [])
931
            ]
932
        }] : [])
933
    ];
934
    const toleratedFields = ['id', 'visibility'];
6✔
935
    const backgroundLayers = layers.filter(layer => layer.group === 'background')
30✔
936
        // remove duplication by comparing all fields with some level of tolerance
937
        .filter((l1, i, a) => findIndex(a, (l2) => isEqual(omit(l1, toleratedFields), omit(l2, toleratedFields))) === i);
15✔
938
    const firstVisible = findIndex(backgroundLayers, layer => layer.visibility);
6✔
939

940
    const sources1 = get(cfg1, 'map.sources', {});
6✔
941
    const sources2 = get(cfg2Fixed, 'map.sources', {});
6✔
942
    const sources = {...sources1, ...sources2};
6✔
943

944
    const widgetsConfig1 = get(cfg1, 'widgetsConfig', {});
6✔
945
    const widgetsConfig2 = get(cfg2Fixed, 'widgetsConfig', {});
6✔
946

947
    return {
6✔
948
        ...cfg2Fixed,
949
        ...cfg1,
950
        catalogServices: {
951
            ...get(cfg1, 'catalogServices', {}),
952
            services: {
953
                ...get(cfg1, 'catalogServices.services', {}),
954
                ...get(cfg2Fixed, 'catalogServices.services', {})
955
            }
956
        },
957
        map: {
958
            ...cfg2Fixed.map,
959
            ...cfg1.map,
960
            backgrounds,
961
            groups: uniqWith([...get(cfg1, 'map.groups', []), ...get(cfg2Fixed, 'map.groups', [])],
962
                (group1, group2) => group1.id === group2.id),
12✔
963
            layers: [
964
                ...backgroundLayers.slice(0, firstVisible + 1),
965
                ...backgroundLayers.slice(firstVisible + 1).map(layer => ({...layer, visibility: false})),
6✔
966
                ...layers.filter(layer => layer.group !== 'background')
30✔
967
            ],
968
            sources: !isEmpty(sources) ? sources : undefined
6!
969
        },
970
        widgetsConfig: {
971
            collapsed: {...widgetsConfig1.collapsed, ...widgetsConfig2.collapsed},
972
            layouts: uniq([...keys(widgetsConfig1.layouts), ...keys(widgetsConfig2.layouts)])
973
                .reduce((result, key) => ({
6✔
974
                    ...result,
975
                    [key]: [
976
                        ...get(widgetsConfig1, `layouts.${key}`, []),
977
                        ...get(widgetsConfig2, `layouts.${key}`, [])
978
                    ]
979
                }), {}),
980
            widgets: [...get(widgetsConfig1, 'widgets', []), ...get(widgetsConfig2, 'widgets', [])]
981
        },
982
        timelineData: {
983
            ...get(cfg1, 'timelineData', {}),
984
            ...get(cfg2Fixed, 'timelineData', {})
985
        },
986
        dimensionData: {
987
            ...get(cfg1, 'dimensionData', {}),
988
            ...get(cfg2Fixed, 'dimensionData', {})
989
        }
990
    };
991
};
992

993
export const addRootParentGroup = (cfg = {}, groupTitle = 'RootGroup') => {
3!
994
    const groups = get(cfg, 'map.groups', []);
6✔
995
    const groupsWithoutDefault = groups.filter(({id}) => id !== DEFAULT_GROUP_ID);
9✔
996
    const defaultGroup = find(groups, ({id}) => id === DEFAULT_GROUP_ID);
6✔
997
    const fixedDefaultGroup = defaultGroup && {
6✔
998
        id: uuidv1(),
999
        title: groupTitle,
1000
        expanded: defaultGroup.expanded
1001
    };
1002
    const groupsWithFixedDefault = defaultGroup ?
6✔
1003
        [
1004
            ...groupsWithoutDefault.map(({id, ...other}) => ({
6✔
1005
                id: `${fixedDefaultGroup.id}.${id}`,
1006
                ...other
1007
            })),
1008
            fixedDefaultGroup
1009
        ] :
1010
        groupsWithoutDefault;
1011

1012
    return {
6✔
1013
        ...cfg,
1014
        map: {
1015
            ...cfg.map,
1016
            groups: groupsWithFixedDefault,
1017
            layers: get(cfg, 'map.layers', []).map(({group, ...other}) => ({
18✔
1018
                ...other,
1019
                group: defaultGroup && group !== 'background' && (group === DEFAULT_GROUP_ID || !group) ? fixedDefaultGroup.id :
84✔
1020
                    defaultGroup && find(groupsWithFixedDefault, ({id}) => id.slice(id.indexOf('.') + 1) === group)?.id || group
18✔
1021
            }))
1022
        }
1023
    };
1024
};
1025

1026
export function isSimpleGeomType(geomType) {
1027
    switch (geomType) {
144✔
1028
    case "MultiPoint": case "MultiLineString": case "MultiPolygon": case "GeometryCollection": case "Text": return false;
39✔
1029
    case "Point": case "Circle": case "LineString": case "Polygon": default: return true;
105✔
1030
    }
1031
}
1032
export function getSimpleGeomType(geomType = "Point") {
×
1033
    switch (geomType) {
111✔
1034
    case "Point": case "LineString": case "Polygon": case "Circle": return geomType;
66✔
1035
    case "MultiPoint": case "Marker": return "Point";
12✔
1036
    case "MultiLineString": return "LineString";
9✔
1037
    case "MultiPolygon": return "Polygon";
9✔
1038
    case "GeometryCollection": return "GeometryCollection";
9✔
1039
    case "Text": return "Point";
3✔
1040
    default: return geomType;
3✔
1041
    }
1042
}
1043

1044
export const getIdFromUri = (uri, regex = /data\/(\d+)/) => {
3✔
1045
    // this decode is for backward compatibility with old linked resources`rest%2Fgeostore%2Fdata%2F2%2Fraw%3Fdecode%3Ddatauri` not needed for new ones `rest/geostore/data/2/raw?decode=datauri`
1046
    const decodedUri = decodeURIComponent(uri);
27✔
1047
    const findDataDigit = regex.exec(decodedUri);
27✔
1048
    return findDataDigit && findDataDigit.length && findDataDigit.length > 1 ? findDataDigit[1] : null;
27✔
1049
};
1050

1051

1052
/**
1053
 * Determines if a field should be included in the comparison based on picked fields and exclusion rules.
1054
 * @param {string} path - The full path to the field (e.g., 'root.obj.key').
1055
 * @param {string} key - The key of the field being checked.
1056
 * @param {any} value - The value of the field.
1057
 * @param {object} rules - The rules object containing pickedFields and excludes.
1058
 * @param {string[]} rules.pickedFields - Array of field paths to include in the comparison.
1059
 * @param {object} rules.excludes - Object mapping parent paths to arrays of keys to exclude.
1060
 * @returns {boolean} True if the field should be included, false otherwise.
1061
 */
1062
export const filterFieldByRules = (path, key, value, { pickedFields = [], excludes = {} }) => {
3!
1063
    // remove all empty objects, nill or false value to normalize comparison
1064
    if (
1,182✔
1065
        value === undefined
4,308✔
1066
        || value === null
1067
        || value === false
1068
        || (isObject(value) && isEmpty(value))
1069
    ) {
1070
        return false;
606✔
1071
    }
1072
    if (pickedFields.some((field) => field.includes(path) || path.includes(field))) {
1,515✔
1073
        // Fix: check parent path for excludes
1074
        const parentPath = path.substring(0, path.lastIndexOf('.'));
453✔
1075
        if (excludes[parentPath] === undefined) {
453✔
1076
            return true;
258✔
1077
        }
1078
        if (excludes[parentPath] && excludes[parentPath].includes(key)) {
195✔
1079
            return false;
24✔
1080
        }
1081
        return true;
171✔
1082
    }
1083
    return false;
123✔
1084
};
1085

1086
/**
1087
 * Apply a custom parser to a value based on the path
1088
 * @param {string} path - The full path to the field (e.g., 'root.obj.key').
1089
 * @param {string} key - The key of the field being checked.
1090
 * @param {any} value - The value of the field.
1091
 * @param {object} rules - The rules object containing pickedFields and excludes.
1092
 * @param {object} rules.parsers - parsers configuration
1093
 * @returns {any} parsed value
1094
 */
1095
export const parseFieldValue = (path, key, value, { parsers }) => {
3✔
1096
    return parsers?.[path] ? parsers[path](value, key) : value;
1,182✔
1097
};
1098
/**
1099
 * Prepares object entries for comparison by applying aliasing, filtering, and sorting.
1100
 * @param {object} obj - The object whose entries are to be prepared.
1101
 * @param {object} rules - The rules object containing aliases, pickedFields, and excludes.
1102
 * @param {string} parentKey - The parent key path for the current object.
1103
 * @returns {array} Array of [key, value] pairs, filtered and sorted for comparison.
1104
 */
1105
export const prepareObjectEntries = (obj, rules, parentKey) => {
3✔
1106
    const safeObj = obj || {};
213!
1107
    // First apply aliasing and parsing, then filter using the aliased keys
1108
    return Object.entries(safeObj)
213✔
1109
        .map(([originalKey, value]) => {
1110
            const key = rules?.aliases?.[originalKey] || originalKey;
1,170✔
1111
            return [key, parseFieldValue(`${parentKey}.${key}`, key, value, rules)];
1,170✔
1112
        })
1113
        .filter(([key, value]) => filterFieldByRules(`${parentKey}.${key}`, key, value, rules))
1,170✔
1114
        .sort((a, b) => {
1115
            if (a[0] < b[0]) { return -1; }
363✔
1116
            if (a[0] > b[0]) { return 1; }
219!
1117
            return 0;
×
1118
        });
1119
};
1120

1121
// function that checks if a field has changed ( also includes the rules to prepare object for comparision)
1122
export const recursiveIsChangedWithRules = (a, b, rules, parentKey = 'root') => {
3!
1123
    // strictly equal
1124
    if (a === b) {
240✔
1125
        return false;
96✔
1126
    }
1127

1128
    // Handle arrays
1129
    if (Array.isArray(a)) {
144✔
1130
        if (!Array.isArray(b) || a.length !== b.length) {
30!
1131
            return true;
×
1132
        }
1133
        // same reference
1134
        if (a === b) {
30!
1135
            return false;
×
1136
        }
1137
        for (let i = 0; i < a.length; i++) {
30✔
1138
            if (recursiveIsChangedWithRules(a[i], b[i], rules, `${parentKey}[]`)) {
30✔
1139
                return true;
9✔
1140
            }
1141
        }
1142
        return false;
21✔
1143
    }
1144

1145
    // Handle objects
1146
    if (typeof a === 'object' && a !== null) {
114✔
1147
        // Prepare entries only if needed
1148
        const aEntries = prepareObjectEntries(a, rules, parentKey);
102✔
1149
        const bEntries = prepareObjectEntries(b || {}, rules, parentKey);
102!
1150
        if (aEntries.length !== bEntries.length) {
102✔
1151
            return true;
9✔
1152
        }
1153
        for (let i = 0; i < aEntries.length; i++) {
93✔
1154
            const [key, value] = aEntries[i];
165✔
1155
            if (recursiveIsChangedWithRules(value, bEntries[i]?.[1], rules, `${parentKey}.${key}`)) {
165✔
1156
                return true;
36✔
1157
            }
1158
        }
1159
        return false;
57✔
1160
    }
1161
    // Fallback for primitives
1162
    return a !== b;
12✔
1163
};
1164

1165
/**
1166
 * @param {object} map1 - The original map configuration object.
1167
 * @param {object} map2 - The updated map configuration object.
1168
 * @returns {boolean} True if the considered fields are equal, false otherwise.
1169
 */
1170
export const compareMapChanges = (map1 = {}, map2 = {}) => {
3!
1171
    const pickedFields = [
24✔
1172
        'root.map.layers',
1173
        'root.map.backgrounds',
1174
        'root.map.text_search_config',
1175
        'root.map.bookmark_search_config',
1176
        'root.map.text_serch_config',
1177
        'root.map.zoom',
1178
        'root.widgetsConfig',
1179
        'root.swipe'
1180
    ];
1181
    const aliases = {
24✔
1182
        text_serch_config: 'text_search_config'
1183
    };
1184
    const excludes = {
24✔
1185
        'root.map.layers[]': ['apiKey', 'time', 'args', 'fixed']
1186
    };
1187
    const parsers = {
24✔
1188
        // in some cases widgets have an empty configuration
1189
        // we could exclude them if there are not widgets listed
1190
        'root.widgetsConfig': (value) => {
1191
            if (!value?.widgets?.length) {
36!
1192
                return null;
36✔
1193
            }
1194
            return value;
×
1195
        },
1196
        // the ellipsoid layer is included by default from the background selector
1197
        // we could exclude it because it's not currently configurable
1198
        'root.map.layers': (value) => {
1199
            return (value || []).filter(layer => !(layer.type === 'terrain' && layer.provider === 'ellipsoid'));
36!
1200
        }
1201
    };
1202
    const isSame = !recursiveIsChangedWithRules(map1, map2, { pickedFields, aliases, excludes, parsers }, 'root');
24✔
1203
    return isSame;
24✔
1204
};
1205
/**
1206
 * creates utilities for registering, fetching, executing hooks
1207
 * used to override default ones in order to have a local hooks object
1208
 * one for each map widget
1209
 */
1210
export const createRegisterHooks = (id) => {
3✔
1211
    let hooksCustom = {};
15✔
1212
    return {
15✔
1213
        registerHook: (name, hook) => {
1214
            hooksCustom[name] = hook;
51✔
1215
        },
1216
        getHook: (name) => hooksCustom[name],
21✔
1217
        executeHook: (hookName, existCallback, dontExistCallback) => {
1218
            const hook = hooksCustom[hookName];
×
1219
            if (hook) {
×
1220
                return existCallback(hook);
×
1221
            }
1222
            if (dontExistCallback) {
×
1223
                return dontExistCallback();
×
1224
            }
1225
            return null;
×
1226
        },
1227
        id
1228
    };
1229
};
1230

1231
/**
1232
 * Detects if state has enabled Identify plugin for mapPopUps
1233
 * @param {object} state
1234
 * @returns {boolean}
1235
 */
1236
export const detectIdentifyInMapPopUp = (state)=>{
3✔
1237
    if (state.mapPopups?.popups) {
6!
1238
        let hasIdentify = state.mapPopups.popups.filter(plugin =>plugin?.component?.toLowerCase() === 'identify');
6✔
1239
        return hasIdentify && hasIdentify.length > 0 ? true : false;
6✔
1240
    }
1241
    return false;
×
1242
};
1243

1244
/**
1245
 * Derive resolution object with scale and zoom info
1246
 * based on visibility limit's type
1247
 * @param value {number} computed with dots per map unit to get resolution
1248
 * @param type {string} of visibility limit ex. scale
1249
 * @param projection {string} map projection
1250
 * @param resolutions {array} map resolutions
1251
 * @return {object} resolution object
1252
 */
1253
export const getResolutionObject = (value, type, {projection, resolutions} = {}) => {
3!
1254
    const dpu = dpi2dpu(DEFAULT_SCREEN_DPI, projection);
12✔
1255
    if (type === 'scale') {
12✔
1256
        const resolution = value / dpu;
9✔
1257
        return {
9✔
1258
            resolution: resolution,
1259
            scale: value,
1260
            zoom: getZoomFromResolution(resolution, resolutions)
1261
        };
1262
    }
1263
    return {
3✔
1264
        resolution: value,
1265
        scale: value * dpu,
1266
        zoom: getZoomFromResolution(value, resolutions)
1267
    };
1268
};
1269
window.__ = getResolutionObject;
3✔
1270

1271
export function calculateExtent(center = {x: 0, y: 0, crs: "EPSG:3857"}, resolution, size = {width: 100, height: 100}, projection = "EPSG:3857") {
36!
1272
    const {x, y} = reproject(center, center.crs ?? projection, projection);
21!
1273
    const dx = resolution * size.width / 2;
21✔
1274
    const dy = resolution * size.height / 2;
21✔
1275
    return [x - dx, y - dy, x + dx, y + dy];
21✔
1276

1277
}
1278

1279

1280
export const reprojectZoom = (zoom, mapProjection, printProjection) => {
3✔
1281
    const multiplier = getMetersPerUnit(getUnits(mapProjection)) / getMetersPerUnit(getUnits(printProjection));
72✔
1282
    const mapResolution = getResolutions(mapProjection)[Math.round(zoom)] * multiplier;
72✔
1283
    const printResolutions = getResolutions(printProjection);
72✔
1284

1285
    const printResolution = printResolutions.reduce((nearest, current) => {
72✔
1286
        return Math.abs(current - mapResolution) < Math.abs(nearest - mapResolution) ? current : nearest;
1,776✔
1287
    }, printResolutions[0]);
1288
    return printResolutions.indexOf(printResolution);
72✔
1289
};
1290

1291

1292
export default {
1293
    createRegisterHooks,
1294
    EXTENT_TO_ZOOM_HOOK,
1295
    RESOLUTIONS_HOOK,
1296
    RESOLUTION_HOOK,
1297
    COMPUTE_BBOX_HOOK,
1298
    GET_PIXEL_FROM_COORDINATES_HOOK,
1299
    GET_COORDINATES_FROM_PIXEL_HOOK,
1300
    DEFAULT_SCREEN_DPI,
1301
    ZOOM_TO_EXTENT_HOOK,
1302
    CLICK_ON_MAP_HOOK,
1303
    EMPTY_MAP,
1304
    registerHook,
1305
    getHook,
1306
    dpi2dpm,
1307
    getSphericalMercatorScales,
1308
    getSphericalMercatorScale,
1309
    getGoogleMercatorScales,
1310
    getGoogleMercatorResolutions,
1311
    getGoogleMercatorScale,
1312
    getResolutionsForScales,
1313
    getZoomForExtent,
1314
    defaultGetZoomForExtent,
1315
    getCenterForExtent,
1316
    getResolutions,
1317
    getScales,
1318
    getBbox,
1319
    mapUpdated,
1320
    getCurrentResolution,
1321
    transformExtent,
1322
    saveMapConfiguration,
1323
    generateNewUUIDs,
1324
    mergeMapConfigs,
1325
    addRootParentGroup,
1326
    isSimpleGeomType,
1327
    getSimpleGeomType,
1328
    getIdFromUri,
1329
    compareMapChanges,
1330
    clearHooks,
1331
    getResolutionObject,
1332
    calculateExtent,
1333
    reprojectZoom
1334
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc