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

geosolutions-it / MapStore2 / 15829819958

23 Jun 2025 04:29PM UTC coverage: 76.979% (+0.03%) from 76.95%
15829819958

Pull #11183

github

web-flow
Merge 124f321fe into 7cde38ac9
Pull Request #11183: #11165: Option to deny app context for normal users

31124 of 48441 branches covered (64.25%)

14 of 16 new or added lines in 5 files covered. (87.5%)

1401 existing lines in 128 files now uncovered.

38752 of 50341 relevant lines covered (76.98%)

36.22 hits per line

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

90.0
/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

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

28
import uuidv1 from 'uuid/v1';
29

30
import { getUnits, normalizeSRS, reproject } from './CoordinatesUtils';
31

32
import { getProjection } from './ProjectionUtils';
33

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

45
export const DEFAULT_SCREEN_DPI = 96;
1✔
46

47
export const METERS_PER_UNIT = {
1✔
48
    'm': 1,
49
    'degrees': 111194.87428468118,
50
    'ft': 0.3048,
51
    'us-ft': 1200 / 3937
52
};
53

54
export const GOOGLE_MERCATOR = {
1✔
55
    RADIUS: 6378137,
56
    TILE_WIDTH: 256,
57
    ZOOM_FACTOR: 2
58
};
59

60
export const EMPTY_MAP = 'EMPTY_MAP';
1✔
61

62
import proj4 from "proj4";
63

64
export const EXTENT_TO_ZOOM_HOOK = 'EXTENT_TO_ZOOM_HOOK';
1✔
65

66
/**
67
 * `ZOOM_TO_EXTENT_HOOK` hook takes 2 arguments:
68
 * - `extent`: array of the extent [minx, miny, maxx, maxy]
69
 * - `options` object, with the following attributes:
70
 *   - `crs`: crs of the extent
71
 *   - `maxZoom`: max zoom for the zoom to functionality.
72
 *   - `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
73
 */
74
export const ZOOM_TO_EXTENT_HOOK = 'ZOOM_TO_EXTENT_HOOK';
1✔
75
export const RESOLUTIONS_HOOK = 'RESOLUTIONS_HOOK';
1✔
76
export const RESOLUTION_HOOK = 'RESOLUTION_HOOK';
1✔
77
export const COMPUTE_BBOX_HOOK = 'COMPUTE_BBOX_HOOK';
1✔
78
export const GET_PIXEL_FROM_COORDINATES_HOOK = 'GET_PIXEL_FROM_COORDINATES_HOOK';
1✔
79
export const GET_COORDINATES_FROM_PIXEL_HOOK = 'GET_COORDINATES_FROM_PIXEL_HOOK';
1✔
80
export const CLICK_ON_MAP_HOOK = 'CLICK_ON_MAP_HOOK';
1✔
81

82
let hooks = {};
1✔
83

84

85
export function registerHook(name, hook) {
86
    hooks[name] = hook;
1,126✔
87
}
88

89
export function getHook(name) {
90
    return hooks[name];
944✔
91
}
92

93
export function executeHook(hookName, existCallback, dontExistCallback) {
94
    const hook = getHook(hookName);
21✔
95
    if (hook) {
21✔
96
        return existCallback(hook);
14✔
97
    }
98
    if (dontExistCallback) {
7!
UNCOV
99
        return dontExistCallback();
×
100
    }
101
    return null;
7✔
102
}
103

104
export function clearHooks() {
105
    hooks = {};
47✔
106
}
107

108
/**
109
 * @param dpi {number} dot per inch resolution
110
 * @return {number} dot per meter resolution
111
 */
112
export function dpi2dpm(dpi) {
113
    return dpi * (100 / 2.54);
7,194✔
114
}
115

116
/**
117
 * @param dpi {number} screen resolution in dots per inch.
118
 * @param projection {string} map projection.
119
 * @return {number} dots per map unit.
120
 */
121
export function dpi2dpu(dpi, projection) {
122
    const units = getUnits(projection || "EPSG:3857");
545✔
123
    return METERS_PER_UNIT[units] * dpi2dpm(dpi || DEFAULT_SCREEN_DPI);
545✔
124
}
125

126
/**
127
 * @param radius {number} Earth's radius of the model in meters.
128
 * @param tileWidth {number} width of the tiles used to draw the map.
129
 * @param zoomFactor {number} zoom factor.
130
 * @param zoomLvl {number} target zoom level.
131
 * @param dpi {number} screen resolution in dot per inch.
132
 * @return {number} the scale of the showed map.
133
 */
134
export function getSphericalMercatorScale(radius, tileWidth, zoomFactor, zoomLvl, dpi) {
135
    return 2 * Math.PI * radius / (tileWidth * Math.pow(zoomFactor, zoomLvl) / dpi2dpm(dpi || DEFAULT_SCREEN_DPI));
6,646✔
136
}
137

138
/**
139
 * @param zoomLvl {number} target zoom level.
140
 * @param dpi {number} screen resolution in dot per inch.
141
 * @return {number} the scale of the showed map.
142
 */
143
export function getGoogleMercatorScale(zoomLvl, dpi) {
144
    return getSphericalMercatorScale(GOOGLE_MERCATOR.RADIUS, GOOGLE_MERCATOR.TILE_WIDTH, GOOGLE_MERCATOR.ZOOM_FACTOR, zoomLvl, dpi);
31✔
145
}
146

147
/**
148
 * @param radius {number} Earth's radius of the model in meters.
149
 * @param tileWidth {number} width of the tiles used to draw the map.
150
 * @param zoomFactor {number} zoom factor.
151
 * @param minZoom {number} min zoom level.
152
 * @param maxZoom {number} max zoom level.
153
 * @param dpi {number} screen resolution in dot per inch.
154
 * @return {array} a list of scale for each zoom level in the given interval.
155
 */
156
export function getSphericalMercatorScales(radius, tileWidth, zoomFactor, minZoom, maxZoom, dpi) {
157
    var retval = [];
302✔
158
    for (let l = minZoom; l <= maxZoom; l++) {
302✔
159
        retval.push(
6,611✔
160
            getSphericalMercatorScale(
161
                radius,
162
                tileWidth,
163
                zoomFactor,
164
                l,
165
                dpi
166
            )
167
        );
168
    }
169
    return retval;
302✔
170
}
171

172
/**
173
 * Get a list of scales for each zoom level of the Google Mercator.
174
 * @param minZoom {number} min zoom level.
175
 * @param maxZoom {number} max zoom level.
176
 * @return {array} a list of scale for each zoom level in the given interval.
177
 */
178
export function getGoogleMercatorScales(minZoom, maxZoom, dpi) {
179
    return getSphericalMercatorScales(
301✔
180
        GOOGLE_MERCATOR.RADIUS,
181
        GOOGLE_MERCATOR.TILE_WIDTH,
182
        GOOGLE_MERCATOR.ZOOM_FACTOR,
183
        minZoom,
184
        maxZoom,
185
        dpi
186
    );
187
}
188

189
/**
190
 * @param scales {array} list of scales.
191
 * @param projection {string} map projection.
192
 * @param dpi {number} screen resolution in dots per inch.
193
 * @return {array} a list of resolutions corresponding to the given scales, projection and dpi.
194
 */
195
export function getResolutionsForScales(scales, projection, dpi) {
196
    const dpu = dpi2dpu(dpi, projection);
309✔
197
    const resolutions = scales.map((scale) => {
309✔
198
        return scale / dpu;
6,595✔
199
    });
200
    return resolutions;
309✔
201
}
202

203
export function getGoogleMercatorResolutions(minZoom, maxZoom, dpi) {
204
    return getResolutionsForScales(getGoogleMercatorScales(minZoom, maxZoom, dpi), "EPSG:3857", dpi);
291✔
205
}
206

207
/**
208
 * Calculates resolutions accordingly with default algorithm in GeoWebCache.
209
 * See this: https://github.com/GeoWebCache/geowebcache/blob/5e913193ff50a61ef9dd63a87887189352fa6b21/geowebcache/core/src/main/java/org/geowebcache/grid/GridSetFactory.java#L196
210
 * It allows to have the resolutions aligned to the default generated grid sets on server side.
211
 * **NOTES**: this solution doesn't support:
212
 * - custom grid sets with `alignTopLeft=true` (e.g. GlobalCRS84Pixel). Custom resolutions will need to be configured as `mapOptions.view.resolutions`
213
 * - custom grid set with custom extent. You need to customize the projection definition extent to make it work.
214
 * - custom grid set is partially supported by mapOptions.view.resolutions but this is not managed by projection change yet
215
 * - custom tile sizes
216
 * @param {string} srs projection code
217
 * @param {object} options optional configuration
218
 * @param {number} options.minResolution minimum resolution of the tile grid pyramid, default computed based on minimum zoom
219
 * @param {number} options.maxResolution maximum resolution of the tile grid pyramid, default computed based on maximum zoom
220
 * @param {number} options.minZoom minimum zoom of the tile grid pyramid, default 0
221
 * @param {number} options.maxZoom maximum zoom of the tile grid pyramid, default 30
222
 * @param {number} options.zoomFactor zoom factor, default 2
223
 * @param {array} options.extent extent of the tile grid pyramid in the projection coordinates, [minx, miny, maxx, maxy], default maximum extent of the projection
224
 * @param {number} options.tileWidth tile width, default 256
225
 * @param {number} options.tileHeight tile height, default 256
226
 * @return {array} a list of resolution based on the selected projection
227
 */
228
export function getResolutionsForProjection(srs, {
72✔
229
    minResolution: minRes,
230
    maxResolution: maxRes,
231
    minZoom: minZ,
232
    maxZoom: maxZ,
233
    zoomFactor: zoomF,
234
    extent: ext,
235
    tileWidth = 256,
534✔
236
    tileHeight = 256
534✔
237
} = {}) {
238
    const defaultMaxZoom = 30;
580✔
239
    const defaultZoomFactor = 2;
580✔
240

241
    let minZoom = minZ ?? 0;
580✔
242

243
    let maxZoom = maxZ ?? defaultMaxZoom;
580✔
244

245
    let zoomFactor = zoomF ?? defaultZoomFactor;
580✔
246

247
    const projection = proj4.defs(srs);
580✔
248

249
    const extent = ext ?? getProjection(srs)?.extent;
580✔
250

251
    const extentWidth = !extent ? 360 * METERS_PER_UNIT.degrees /
580!
252
        METERS_PER_UNIT[projection.getUnits()] :
253
        extent[2] - extent[0];
254
    const extentHeight = !extent ? 360 * METERS_PER_UNIT.degrees /
580!
255
        METERS_PER_UNIT[projection.getUnits()] :
256
        extent[3] - extent[1];
257

258
    let resX = extentWidth / tileWidth;
580✔
259
    let resY = extentHeight / tileHeight;
580✔
260
    let tilesWide;
261
    let tilesHigh;
262
    if (resX <= resY) {
580✔
263
        // use one tile wide by N tiles high
264
        tilesWide = 1;
496✔
265
        tilesHigh = Math.round(resY / resX);
496✔
266
        // previous resY was assuming 1 tile high, recompute with the actual number of tiles
267
        // high
268
        resY = resY / tilesHigh;
496✔
269
    } else {
270
        // use one tile high by N tiles wide
271
        tilesHigh = 1;
84✔
272
        tilesWide = Math.round(resX / resY);
84✔
273
        // previous resX was assuming 1 tile wide, recompute with the actual number of tiles
274
        // wide
275
        resX = resX / tilesWide;
84✔
276
    }
277
    // the maximum of resX and resY is the one that adjusts better
278
    const res = Math.max(resX, resY);
580✔
279

280
    /*
281
        // TODO: this is how GWC creates the bbox adjusted.
282
        // We should calculate it to have the correct extent for a grid set
283
        const adjustedExtentWidth = tilesWide * tileWidth * res;
284
        const adjustedExtentHeight = tilesHigh * tileHeight * res;
285
        BoundingBox adjExtent = new BoundingBox(extent);
286
        adjExtent.setMaxX(adjExtent.getMinX() + adjustedExtentWidth);
287
        // Do we keep the top or the bottom fixed?
288
        if (alignTopLeft) {
289
            adjExtent.setMinY(adjExtent.getMaxY() - adjustedExtentHeight);
290
        } else {
291
            adjExtent.setMaxY(adjExtent.getMinY() + adjustedExtentHeight);
292

293
     */
294

295
    const defaultMaxResolution = res;
580✔
296

297
    const defaultMinResolution = defaultMaxResolution / Math.pow(
580✔
298
        defaultZoomFactor, defaultMaxZoom - 0);
299

300
    // user provided maxResolution takes precedence
301
    let maxResolution = maxRes;
580✔
302
    if (maxResolution !== undefined) {
580!
UNCOV
303
        minZoom = 0;
×
304
    } else {
305
        maxResolution = defaultMaxResolution / Math.pow(zoomFactor, minZoom);
580✔
306
    }
307

308
    // user provided minResolution takes precedence
309
    let minResolution = minRes;
580✔
310
    if (minResolution === undefined) {
580!
311
        if (maxZoom !== undefined) {
580!
312
            if (maxRes !== undefined) {
580!
UNCOV
313
                minResolution = maxResolution / Math.pow(zoomFactor, maxZoom);
×
314
            } else {
315
                minResolution = defaultMaxResolution / Math.pow(zoomFactor, maxZoom);
580✔
316
            }
317
        } else {
UNCOV
318
            minResolution = defaultMinResolution;
×
319
        }
320
    }
321

322
    // given discrete zoom levels, minResolution may be different than provided
323
    maxZoom = minZoom + Math.floor(
580✔
324
        Math.log(maxResolution / minResolution) / Math.log(zoomFactor));
325
    return Array.apply(0, Array(maxZoom - minZoom + 1)).map((x, y) => maxResolution / Math.pow(zoomFactor, y));
17,980✔
326
}
327

328
export function getResolutions(projection) {
329
    if (getHook('RESOLUTIONS_HOOK')) {
545✔
330
        return getHook('RESOLUTIONS_HOOK')(projection);
253✔
331
    }
332
    return projection && normalizeSRS(projection) !== "EPSG:3857" ? getResolutionsForProjection(projection) :
292✔
333
        getGoogleMercatorResolutions(0, 21, DEFAULT_SCREEN_DPI);
334
}
335

336
export function getScales(projection, dpi) {
337
    const dpu = dpi2dpu(dpi, projection);
105✔
338
    return getResolutions(projection).map((resolution) => resolution * dpu);
2,661✔
339
}
340

341
export function getScale(projection, dpi, resolution) {
UNCOV
342
    const dpu = dpi2dpu(dpi, projection);
×
343
    return resolution * dpu;
×
344
}
345
/**
346
 * get random coordinates within CRS extent
347
 * @param {string} crs the code of the projection for example EPSG:4346
348
 * @returns {number[]} the point in [x,y] [lon,lat]
349
 */
350
export function getRandomPointInCRS(crs) {
351
    const extent = getExtent(crs); // Get the projection's extent
6✔
352
    if (!extent) {
6!
UNCOV
353
        throw new Error(`Extent not available for CRS: ${crs}`);
×
354
    }
355
    const [minX, minY, maxX, maxY] = extent.extent_;
6✔
356

357
    // Check if the equator (latitude = 0) is within the CRS extent
358
    const isEquatorWithinExtent = minY <= 0 && maxY >= 0;
6✔
359

360
    // Generate a random X coordinate within the valid longitude range
361
    const randomX = Math.random() * (maxX - minX) + minX;
6✔
362

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

366
    return [randomX, randomY];
6✔
367
}
368

369
/**
370
 * convert resolution between CRSs
371
 * @param {string} sourceCRS the code of a projection
372
 * @param {string} targetCRS the code of a projection
373
 * @param {number} sourceResolution the resolution to convert
374
 * @returns the converted resolution
375
 */
376
export function convertResolution(sourceCRS, targetCRS, sourceResolution) {
377
    const sourceProjection = getProjectionOL(sourceCRS);
4✔
378
    const targetProjection = getProjectionOL(targetCRS);
4✔
379

380
    if (!sourceProjection || !targetProjection) {
4!
UNCOV
381
        throw new Error(`Invalid CRS: ${sourceCRS} or ${targetCRS}`);
×
382
    }
383

384
    // Get a random point in the extent of the source CRS
385
    const randomPoint = getRandomPointInCRS(sourceCRS);
4✔
386

387
    // Transform the resolution
388
    const transformedResolution = getPointResolution(
4✔
389
        sourceProjection,
390
        sourceResolution,
391
        transform(randomPoint, sourceCRS, targetCRS),
392
        targetProjection.getUnits()
393
    );
394

395
    return { randomPoint, transformedResolution };
4✔
396
}
397

398
/**
399
 * Convert a resolution to the nearest zoom
400
 * @param {number} targetResolution resolution to be converted in zoom
401
 * @param {array} resolutions list of all available resolutions
402
 */
403
export function getZoomFromResolution(targetResolution, resolutions = getResolutions()) {
1✔
404
    // compute the absolute difference for all resolutions
405
    // and store the idx as zoom
406
    const diffs = resolutions.map((resolution, zoom) => ({ diff: Math.abs(resolution - targetResolution), zoom }));
910✔
407
    // the minimum difference represents the nearest zoom to the target resolution
408
    const { zoom } = minBy(diffs, 'diff');
37✔
409
    return zoom;
37✔
410
}
411

412
export function defaultGetZoomForExtent(extent, mapSize, minZoom, maxZoom, dpi, mapResolutions) {
413
    const wExtent = extent[2] - extent[0];
10✔
414
    const hExtent = extent[3] - extent[1];
10✔
415

416
    const xResolution = Math.abs(wExtent / mapSize.width);
10✔
417
    const yResolution = Math.abs(hExtent / mapSize.height);
10✔
418
    const extentResolution = Math.max(xResolution, yResolution);
10✔
419

420
    const resolutions = mapResolutions || getResolutionsForScales(getGoogleMercatorScales(
10✔
421
        minZoom, maxZoom, dpi || DEFAULT_SCREEN_DPI), "EPSG:3857", dpi);
11✔
422

423
    const {zoom} = resolutions.reduce((previous, resolution, index) => {
10✔
424
        const diff = Math.abs(resolution - extentResolution);
228✔
425
        return diff > previous.diff ? previous : {diff: diff, zoom: index};
228✔
426
    }, {diff: Number.POSITIVE_INFINITY, zoom: 0});
427

428
    return Math.max(0, Math.min(zoom, maxZoom));
10✔
429
}
430

431
/**
432
 * Calculates the best fitting zoom level for the given extent.
433
 *
434
 * @param extent {Array} [minx, miny, maxx, maxy]
435
 * @param mapSize {Object} current size of the map.
436
 * @param minZoom {number} min zoom level.
437
 * @param maxZoom {number} max zoom level.
438
 * @param dpi {number} screen resolution in dot per inch.
439
 * @return {Number} the zoom level fitting th extent
440
 */
441
export function getZoomForExtent(extent, mapSize, minZoom, maxZoom, dpi) {
442
    if (getHook("EXTENT_TO_ZOOM_HOOK")) {
11✔
443
        return getHook("EXTENT_TO_ZOOM_HOOK")(extent, mapSize, minZoom, maxZoom, dpi);
1✔
444
    }
445
    const resolutions = getHook("RESOLUTIONS_HOOK") ?
10✔
446
        getHook("RESOLUTIONS_HOOK")() : null;
447
    return defaultGetZoomForExtent(extent, mapSize, minZoom, maxZoom, dpi, resolutions);
10✔
448
}
449

450
/**
451
* It returns the current resolution.
452
*
453
* @param currentZoom {number} the current zoom
454
* @param minZoom {number} min zoom level.
455
* @param maxZoom {number} max zoom level.
456
* @param dpi {number} screen resolution in dot per inch.
457
* @return {Number} the actual resolution
458
*/
459
export function getCurrentResolution(currentZoom, minZoom, maxZoom, dpi) {
460
    if (getHook("RESOLUTION_HOOK")) {
63✔
461
        return getHook("RESOLUTION_HOOK")(currentZoom, minZoom, maxZoom, dpi);
2✔
462
    }
463
    /* if no hook is registered (leaflet) it is used the GoogleMercatorResolutions in
464
       in order to get the list of resolutions */
465
    return getGoogleMercatorResolutions(minZoom, maxZoom, dpi)[currentZoom];
61✔
466
}
467

468
/**
469
 * Calculates the center for for the given extent.
470
 *
471
 * @param  {Array} extent [minx, miny, maxx, maxy]
472
 * @param  {String} projection projection of the extent
473
 * @return {object} center object
474
 */
475
export function getCenterForExtent(extent, projection) {
476

477
    var wExtent = extent[2] - extent[0];
10✔
478
    var hExtent = extent[3] - extent[1];
10✔
479

480
    var w = wExtent / 2;
10✔
481
    var h = hExtent / 2;
10✔
482

483
    return {
10✔
484
        x: extent[0] + w,
485
        y: extent[1] + h,
486
        crs: projection
487
    };
488
}
489

490
/**
491
 * Calculates the bounding box for the given center and zoom.
492
 *
493
 * @param  {object} center object
494
 * @param  {number} zoom level
495
 */
496
export function getBbox(center, zoom) {
497
    return executeHook("COMPUTE_BBOX_HOOK",
21✔
498
        (hook) => {
499
            return hook(center, zoom);
14✔
500
        }
501
    );
502
}
503

504
export const isNearlyEqual = function(a, b) {
1✔
505
    if (a === undefined || b === undefined) {
14!
UNCOV
506
        return false;
×
507
    }
508
    return a.toFixed(12) - b.toFixed(12) === 0;
14✔
509
};
510

511
/**
512
 * checks if maps has changed by looking at center or zoom
513
 * @param {object} oldMap map object
514
 * @param {object} newMap map object
515
 */
516
export function mapUpdated(oldMap, newMap) {
517
    if (oldMap && !isEmpty(oldMap) &&
8✔
518
        newMap && !isEmpty(newMap)) {
519
        const centersEqual = isNearlyEqual(newMap?.center?.x, oldMap?.center?.x) &&
3✔
520
                              isNearlyEqual(newMap?.center?.y, oldMap?.center?.y);
521
        return !centersEqual || newMap?.zoom !== oldMap?.zoom;
3✔
522
    }
523
    return false;
5✔
524
}
525

526
/* Transform width and height specified in meters to the units of the specified projection */
527
export function transformExtent(projection, center, width, height) {
UNCOV
528
    let units = getUnits(projection);
×
529
    if (units === 'ft') {
×
530
        return {width: width / METERS_PER_UNIT.ft, height: height / METERS_PER_UNIT.ft};
×
531
    } else if (units === 'us-ft') {
×
532
        return {width: width / METERS_PER_UNIT['us-ft'], height: height / METERS_PER_UNIT['us-ft']};
×
533
    } else if (units === 'degrees') {
×
534
        return {
×
535
            width: width / (111132.92 - 559.82 * Math.cos(2 * center.y) + 1.175 * Math.cos(4 * center.y)),
536
            height: height / (111412.84 * Math.cos(center.y) - 93.5 * Math.cos(3 * center.y))
537
        };
538
    }
UNCOV
539
    return {width, height};
×
540
}
541

542
export const groupSaveFormatted = (node) => {
1✔
543
    return {
42✔
544
        id: node.id,
545
        title: node.title,
546
        description: node.description,
547
        tooltipOptions: node.tooltipOptions,
548
        tooltipPlacement: node.tooltipPlacement,
549
        expanded: node.expanded,
550
        visibility: node.visibility,
551
        nodesMutuallyExclusive: node.nodesMutuallyExclusive
552
    };
553
};
554

555

556
export function saveMapConfiguration(currentMap, currentLayers, currentGroups, currentBackgrounds, textSearchConfig, bookmarkSearchConfig, additionalOptions) {
557

558
    const map = {
41✔
559
        center: currentMap.center,
560
        maxExtent: currentMap.maxExtent,
561
        projection: currentMap.projection,
562
        units: currentMap.units,
563
        mapInfoControl: currentMap.mapInfoControl,
564
        zoom: currentMap.zoom,
565
        mapOptions: currentMap.mapOptions || {},
76✔
566
        ...(currentMap.visualizationMode && { visualizationMode: currentMap.visualizationMode }),
41✔
567
        ...(currentMap.viewerOptions && { viewerOptions: currentMap.viewerOptions })
41✔
568
    };
569

570
    const layers = currentLayers.map((layer) => {
40✔
571
        return saveLayer(layer);
68✔
572
    });
573

574
    const flatGroupId = currentGroups.reduce((a, b) => {
40✔
575
        const flatGroups = a.concat(getGroupNodes(b));
34✔
576
        return flatGroups;
34✔
577
    }, [].concat(currentGroups.map(g => g.id)));
34✔
578

579
    const groups = flatGroupId.map(g => {
40✔
580
        const node = getNode(currentGroups, g);
93✔
581
        return node && node.nodes ? groupSaveFormatted(node) : null;
93✔
582
    }).filter(g => g);
93✔
583

584
    const backgrounds = currentBackgrounds.filter(background => !!background.thumbnail);
40✔
585

586
    // extract sources map
587
    const sources = extractSourcesFromLayers(layers);
40✔
588

589
    // removes tile matrix set from layers and replace it with a link if available in sources
590
    const formattedLayers = layers.map(layer => {
40✔
591
        const { availableTileMatrixSets, ...updatedLayer } = updateAvailableTileMatrixSetsOptions(layer);
68✔
592
        return availableTileMatrixSets
68✔
593
            ? {
594
                ...updatedLayer,
595
                availableTileMatrixSets: Object.keys(availableTileMatrixSets)
596
                    .reduce((acc, tileMatrixSetId) => {
597
                        const tileMatrixSetLink = getTileMatrixSetLink(layer, tileMatrixSetId);
3✔
598
                        if (get({ sources }, tileMatrixSetLink)) {
3!
599
                            return {
3✔
600
                                ...acc,
601
                                [tileMatrixSetId]: {
602
                                    ...omit(availableTileMatrixSets[tileMatrixSetId], 'tileMatrixSet'),
603
                                    tileMatrixSetLink
604
                                }
605
                            };
606
                        }
UNCOV
607
                        return {
×
608
                            ...acc,
609
                            [tileMatrixSetId]: availableTileMatrixSets[tileMatrixSetId]
610
                        };
611
                    }, {})
612
            }
613
            : updatedLayer;
614
    });
615

616
    /* removes the geometryGeodesic property from the features in the annotations layer*/
617
    let annotationsLayerIndex = findIndex(formattedLayers, layer => layer.id === "annotations");
68✔
618
    if (annotationsLayerIndex !== -1) {
40✔
619
        let featuresLayer = formattedLayers[annotationsLayerIndex].features.map(feature => {
1✔
620
            if (feature.type === "FeatureCollection") {
1!
621
                return {
1✔
622
                    ...feature,
623
                    features: feature.features.map(f => {
624
                        if (f.properties.geometryGeodesic) {
1!
625
                            return set("properties.geometryGeodesic", null, f);
1✔
626
                        }
UNCOV
627
                        return f;
×
628
                    })
629
                };
630
            }
UNCOV
631
            if (feature.properties.geometryGeodesic) {
×
632
                return set("properties.geometryGeodesic", null, feature);
×
633
            }
UNCOV
634
            return {};
×
635
        });
636
        formattedLayers[annotationsLayerIndex] = set("features", featuresLayer, formattedLayers[annotationsLayerIndex]);
1✔
637
    }
638

639
    return {
40✔
640
        version: 2,
641
        // layers are defined inside the map object
642
        map: Object.assign({}, map, {layers: formattedLayers, groups, backgrounds, text_search_config: textSearchConfig, bookmark_search_config: bookmarkSearchConfig},
643
            !isEmpty(sources) && {sources} || {}),
80✔
644
        ...additionalOptions
645
    };
646
}
647

648
export const generateNewUUIDs = (mapConfig = {}) => {
1!
649
    const newMapConfig = cloneDeep(mapConfig);
2✔
650

651
    const oldIdToNew = {
2✔
652
        ...get(mapConfig, 'map.layers', []).reduce((result, layer) => ({
4✔
653
            ...result,
654
            [layer.id]: layer.id === 'annotations' ? layer.id : uuidv1()
4!
655
        }), {}),
656
        ...get(mapConfig, 'widgetsConfig.widgets', []).reduce((result, widget) => ({...result, [widget.id]: uuidv1()}), {})
1✔
657
    };
658

659
    return set('map.backgrounds', get(mapConfig, 'map.backgrounds', []).map(background => ({...background, id: oldIdToNew[background.id]})),
2✔
660
        set('widgetsConfig', {
661
            collapsed: mapValues(mapKeys(get(mapConfig, 'widgetsConfig.collapsed', {}), (value, key) => oldIdToNew[key]), (value) =>
1✔
662
                ({...value, layouts: mapValues(value.layouts, (layout) => ({...layout, i: oldIdToNew[layout.i]}))})),
2✔
663
            layouts: mapValues(get(mapConfig, 'widgetsConfig.layouts', {}), (value) =>
664
                value.map(layout => ({...layout, i: oldIdToNew[layout.i]}))),
2✔
665
            widgets: get(mapConfig, 'widgetsConfig.widgets', [])
666
                .map(widget => ({
1✔
667
                    ...widget,
668
                    id: oldIdToNew[widget.id],
669
                    layer: ({...get(widget, 'layer', {}), id: oldIdToNew[get(widget, 'layer.id')]})
670
                }))
671
        },
672
        set('map.layers', get(mapConfig, 'map.layers', [])
673
            .map(layer => ({...layer, id: oldIdToNew[layer.id]})), newMapConfig)));
4✔
674
};
675

676
export const mergeMapConfigs = (cfg1 = {}, cfg2 = {}) => {
1!
677
    // removes empty props from layer as it can cause bugs
678
    const fixLayers = (layers = []) => layers.map(layer => pick(layer, keys(layer).filter(key => layer[key] !== undefined)));
72!
679

680
    const cfg2Fixed = generateNewUUIDs(cfg2);
2✔
681

682
    const backgrounds = [...get(cfg1, 'map.backgrounds', []), ...get(cfg2Fixed, 'map.backgrounds', [])];
2✔
683

684
    const layers1 = fixLayers(get(cfg1, 'map.layers', []));
2✔
685
    const layers2 = fixLayers(get(cfg2Fixed, 'map.layers', []));
2✔
686

687
    const annotationsLayer1 = find(layers1, layer => layer.id === 'annotations');
5✔
688
    const annotationsLayer2 = find(layers2, layer => layer.id === 'annotations');
4✔
689

690
    const layers = [
2✔
691
        ...layers2.filter(layer => layer.id !== 'annotations'),
4✔
692
        ...layers1.filter(layer => layer.id !== 'annotations'),
6✔
693
        ...(annotationsLayer1 || annotationsLayer2 ? [{
5✔
694
            ...(annotationsLayer1 || {}),
1!
695
            ...(annotationsLayer2 || {}),
2✔
696
            features: [
697
                ...get(annotationsLayer1, 'features', []), ...get(annotationsLayer2, 'features', [])
698
            ]
699
        }] : [])
700
    ];
701
    const toleratedFields = ['id', 'visibility'];
2✔
702
    const backgroundLayers = layers.filter(layer => layer.group === 'background')
10✔
703
        // remove duplication by comparing all fields with some level of tolerance
704
        .filter((l1, i, a) => findIndex(a, (l2) => isEqual(omit(l1, toleratedFields), omit(l2, toleratedFields))) === i);
5✔
705
    const firstVisible = findIndex(backgroundLayers, layer => layer.visibility);
2✔
706

707
    const sources1 = get(cfg1, 'map.sources', {});
2✔
708
    const sources2 = get(cfg2Fixed, 'map.sources', {});
2✔
709
    const sources = {...sources1, ...sources2};
2✔
710

711
    const widgetsConfig1 = get(cfg1, 'widgetsConfig', {});
2✔
712
    const widgetsConfig2 = get(cfg2Fixed, 'widgetsConfig', {});
2✔
713

714
    return {
2✔
715
        ...cfg2Fixed,
716
        ...cfg1,
717
        catalogServices: {
718
            ...get(cfg1, 'catalogServices', {}),
719
            services: {
720
                ...get(cfg1, 'catalogServices.services', {}),
721
                ...get(cfg2Fixed, 'catalogServices.services', {})
722
            }
723
        },
724
        map: {
725
            ...cfg2Fixed.map,
726
            ...cfg1.map,
727
            backgrounds,
728
            groups: uniqWith([...get(cfg1, 'map.groups', []), ...get(cfg2Fixed, 'map.groups', [])],
729
                (group1, group2) => group1.id === group2.id),
4✔
730
            layers: [
731
                ...backgroundLayers.slice(0, firstVisible + 1),
732
                ...backgroundLayers.slice(firstVisible + 1).map(layer => ({...layer, visibility: false})),
2✔
733
                ...layers.filter(layer => layer.group !== 'background')
10✔
734
            ],
735
            sources: !isEmpty(sources) ? sources : undefined
2!
736
        },
737
        widgetsConfig: {
738
            collapsed: {...widgetsConfig1.collapsed, ...widgetsConfig2.collapsed},
739
            layouts: uniq([...keys(widgetsConfig1.layouts), ...keys(widgetsConfig2.layouts)])
740
                .reduce((result, key) => ({
2✔
741
                    ...result,
742
                    [key]: [
743
                        ...get(widgetsConfig1, `layouts.${key}`, []),
744
                        ...get(widgetsConfig2, `layouts.${key}`, [])
745
                    ]
746
                }), {}),
747
            widgets: [...get(widgetsConfig1, 'widgets', []), ...get(widgetsConfig2, 'widgets', [])]
748
        },
749
        timelineData: {
750
            ...get(cfg1, 'timelineData', {}),
751
            ...get(cfg2Fixed, 'timelineData', {})
752
        },
753
        dimensionData: {
754
            ...get(cfg1, 'dimensionData', {}),
755
            ...get(cfg2Fixed, 'dimensionData', {})
756
        }
757
    };
758
};
759

760
export const addRootParentGroup = (cfg = {}, groupTitle = 'RootGroup') => {
1!
761
    const groups = get(cfg, 'map.groups', []);
2✔
762
    const groupsWithoutDefault = groups.filter(({id}) => id !== DEFAULT_GROUP_ID);
3✔
763
    const defaultGroup = find(groups, ({id}) => id === DEFAULT_GROUP_ID);
2✔
764
    const fixedDefaultGroup = defaultGroup && {
2✔
765
        id: uuidv1(),
766
        title: groupTitle,
767
        expanded: defaultGroup.expanded
768
    };
769
    const groupsWithFixedDefault = defaultGroup ?
2✔
770
        [
771
            ...groupsWithoutDefault.map(({id, ...other}) => ({
2✔
772
                id: `${fixedDefaultGroup.id}.${id}`,
773
                ...other
774
            })),
775
            fixedDefaultGroup
776
        ] :
777
        groupsWithoutDefault;
778

779
    return {
2✔
780
        ...cfg,
781
        map: {
782
            ...cfg.map,
783
            groups: groupsWithFixedDefault,
784
            layers: get(cfg, 'map.layers', []).map(({group, ...other}) => ({
6✔
785
                ...other,
786
                group: defaultGroup && group !== 'background' && (group === DEFAULT_GROUP_ID || !group) ? fixedDefaultGroup.id :
28✔
787
                    defaultGroup && find(groupsWithFixedDefault, ({id}) => id.slice(id.indexOf('.') + 1) === group)?.id || group
6✔
788
            }))
789
        }
790
    };
791
};
792

793
export function isSimpleGeomType(geomType) {
794
    switch (geomType) {
48✔
795
    case "MultiPoint": case "MultiLineString": case "MultiPolygon": case "GeometryCollection": case "Text": return false;
13✔
796
    case "Point": case "Circle": case "LineString": case "Polygon": default: return true;
35✔
797
    }
798
}
799
export function getSimpleGeomType(geomType = "Point") {
×
800
    switch (geomType) {
37✔
801
    case "Point": case "LineString": case "Polygon": case "Circle": return geomType;
22✔
802
    case "MultiPoint": case "Marker": return "Point";
4✔
803
    case "MultiLineString": return "LineString";
3✔
804
    case "MultiPolygon": return "Polygon";
3✔
805
    case "GeometryCollection": return "GeometryCollection";
3✔
806
    case "Text": return "Point";
1✔
807
    default: return geomType;
1✔
808
    }
809
}
810

811
export const getIdFromUri = (uri, regex = /data\/(\d+)/) => {
1✔
812
    // 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`
813
    const decodedUri = decodeURIComponent(uri);
9✔
814
    const findDataDigit = regex.exec(decodedUri);
9✔
815
    return findDataDigit && findDataDigit.length && findDataDigit.length > 1 ? findDataDigit[1] : null;
9✔
816
};
817

818
/**
819
 * Method for cleanup map object from uneseccary fields which
820
 * updated map contains and were set on map render
821
 * @param {object} obj
822
 */
823

824
export const prepareMapObjectToCompare = obj => {
1✔
825
    const skippedKeys = ['apiKey', 'time', 'args', 'fixed'];
156✔
826
    const shouldBeSkipped = (key) => skippedKeys.reduce((p, n) => p || key === n, false);
1,012✔
827
    Object.keys(obj).forEach(key => {
156✔
828
        const value = obj[key];
484✔
829
        const type = typeof value;
484✔
830
        if (type === "object" && value !== null && !shouldBeSkipped(key)) {
484✔
831
            prepareMapObjectToCompare(value);
136✔
832
            if (!Object.keys(value).length) {
136✔
833
                delete obj[key];
74✔
834
            }
835
        } else if (type === "undefined" || !value || shouldBeSkipped(key)) {
348✔
836
            delete obj[key];
235✔
837
        }
838
    });
839
};
840

841
/**
842
 * Method added for support old key with objects provided for compareMapChanges feature
843
 * like text_serch_config
844
 * @param {object} obj
845
 * @param {string} oldKey
846
 * @param {string} newKey
847
 */
848
export const updateObjectFieldKey = (obj, oldKey, newKey) => {
1✔
849
    if (obj[oldKey]) {
15✔
850
        Object.defineProperty(obj, newKey, Object.getOwnPropertyDescriptor(obj, oldKey));
1✔
851
        delete obj[oldKey];
1✔
852
    }
853
};
854

855
/**
856
 * Feature for map change recognition. Returns value of isEqual method from lodash
857
 * @param {object} map1 original map before changes
858
 * @param {object} map2 updated map
859
 * @returns {boolean}
860
 */
861
export const compareMapChanges = (map1 = {}, map2 = {}) => {
1!
862
    const pickedFields = [
6✔
863
        'map.layers',
864
        'map.backgrounds',
865
        'map.text_search_config',
866
        'map.bookmark_search_config',
867
        'map.text_serch_config',
868
        'map.zoom',
869
        'widgetsConfig',
870
        'swipe'
871
    ];
872
    const filteredMap1 = pick(cloneDeep(map1), pickedFields);
6✔
873
    const filteredMap2 = pick(cloneDeep(map2), pickedFields);
6✔
874
    // ABOUT: used for support text_serch_config field in old maps
875
    updateObjectFieldKey(filteredMap1.map, 'text_serch_config', 'text_search_config');
6✔
876
    updateObjectFieldKey(filteredMap2.map, 'text_serch_config', 'text_search_config');
6✔
877

878
    prepareMapObjectToCompare(filteredMap1);
6✔
879
    prepareMapObjectToCompare(filteredMap2);
6✔
880
    return isEqual(filteredMap1, filteredMap2);
6✔
881
};
882

883
/**
884
 * creates utilities for registering, fetching, executing hooks
885
 * used to override default ones in order to have a local hooks object
886
 * one for each map widget
887
 */
888
export const createRegisterHooks = (id) => {
1✔
889
    let hooksCustom = {};
5✔
890
    return {
5✔
891
        registerHook: (name, hook) => {
892
            hooksCustom[name] = hook;
17✔
893
        },
894
        getHook: (name) => hooksCustom[name],
7✔
895
        executeHook: (hookName, existCallback, dontExistCallback) => {
UNCOV
896
            const hook = hooksCustom[hookName];
×
897
            if (hook) {
×
898
                return existCallback(hook);
×
899
            }
UNCOV
900
            if (dontExistCallback) {
×
901
                return dontExistCallback();
×
902
            }
UNCOV
903
            return null;
×
904
        },
905
        id
906
    };
907
};
908

909
/**
910
 * Detects if state has enabled Identify plugin for mapPopUps
911
 * @param {object} state
912
 * @returns {boolean}
913
 */
914
export const detectIdentifyInMapPopUp = (state)=>{
1✔
915
    if (state.mapPopups?.popups) {
2!
916
        let hasIdentify = state.mapPopups.popups.filter(plugin =>plugin?.component?.toLowerCase() === 'identify');
2✔
917
        return hasIdentify && hasIdentify.length > 0 ? true : false;
2✔
918
    }
UNCOV
919
    return false;
×
920
};
921

922
/**
923
 * Derive resolution object with scale and zoom info
924
 * based on visibility limit's type
925
 * @param value {number} computed with dots per map unit to get resolution
926
 * @param type {string} of visibility limit ex. scale
927
 * @param projection {string} map projection
928
 * @param resolutions {array} map resolutions
929
 * @return {object} resolution object
930
 */
931
export const getResolutionObject = (value, type, {projection, resolutions} = {}) => {
1!
932
    const dpu = dpi2dpu(DEFAULT_SCREEN_DPI, projection);
4✔
933
    if (type === 'scale') {
4✔
934
        const resolution = value / dpu;
3✔
935
        return {
3✔
936
            resolution: resolution,
937
            scale: value,
938
            zoom: getZoomFromResolution(resolution, resolutions)
939
        };
940
    }
941
    return {
1✔
942
        resolution: value,
943
        scale: value * dpu,
944
        zoom: getZoomFromResolution(value, resolutions)
945
    };
946
};
947

948
export function calculateExtent(center = {x: 0, y: 0, crs: "EPSG:3857"}, resolution, size = {width: 100, height: 100}, projection = "EPSG:3857") {
12!
949
    const {x, y} = reproject(center, center.crs ?? projection, projection);
7!
950
    const dx = resolution * size.width / 2;
7✔
951
    const dy = resolution * size.height / 2;
7✔
952
    return [x - dx, y - dy, x + dx, y + dy];
7✔
953

954
}
955

956

957
export const reprojectZoom = (zoom, mapProjection, printProjection) => {
1✔
958
    const multiplier = METERS_PER_UNIT[getUnits(mapProjection)] / METERS_PER_UNIT[getUnits(printProjection)];
20✔
959
    const mapResolution = getResolutions(mapProjection)[Math.round(zoom)] * multiplier;
20✔
960
    const printResolutions = getResolutions(printProjection);
20✔
961

962
    const printResolution = printResolutions.reduce((nearest, current) => {
20✔
963
        return Math.abs(current - mapResolution) < Math.abs(nearest - mapResolution) ? current : nearest;
503✔
964
    }, printResolutions[0]);
965
    return printResolutions.indexOf(printResolution);
20✔
966
};
967

968

969
export default {
970
    createRegisterHooks,
971
    EXTENT_TO_ZOOM_HOOK,
972
    RESOLUTIONS_HOOK,
973
    RESOLUTION_HOOK,
974
    COMPUTE_BBOX_HOOK,
975
    GET_PIXEL_FROM_COORDINATES_HOOK,
976
    GET_COORDINATES_FROM_PIXEL_HOOK,
977
    DEFAULT_SCREEN_DPI,
978
    ZOOM_TO_EXTENT_HOOK,
979
    CLICK_ON_MAP_HOOK,
980
    EMPTY_MAP,
981
    registerHook,
982
    getHook,
983
    dpi2dpm,
984
    getSphericalMercatorScales,
985
    getSphericalMercatorScale,
986
    getGoogleMercatorScales,
987
    getGoogleMercatorResolutions,
988
    getGoogleMercatorScale,
989
    getResolutionsForScales,
990
    getZoomForExtent,
991
    defaultGetZoomForExtent,
992
    getCenterForExtent,
993
    getResolutions,
994
    getScales,
995
    getBbox,
996
    mapUpdated,
997
    getCurrentResolution,
998
    transformExtent,
999
    saveMapConfiguration,
1000
    generateNewUUIDs,
1001
    mergeMapConfigs,
1002
    addRootParentGroup,
1003
    isSimpleGeomType,
1004
    getSimpleGeomType,
1005
    getIdFromUri,
1006
    prepareMapObjectToCompare,
1007
    updateObjectFieldKey,
1008
    compareMapChanges,
1009
    clearHooks,
1010
    getResolutionObject,
1011
    calculateExtent,
1012
    reprojectZoom
1013
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc