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

geosolutions-it / MapStore2 / 16061365012

03 Jul 2025 10:03PM UTC coverage: 76.877% (-0.1%) from 76.972%
16061365012

Pull #11087

github

web-flow
Merge f7c173b17 into 01d598372
Pull Request #11087: #8338: Implement a terrain layer selector

31243 of 48671 branches covered (64.19%)

124 of 152 new or added lines in 10 files covered. (81.58%)

240 existing lines in 17 files now uncovered.

38832 of 50512 relevant lines covered (76.88%)

36.55 hits per line

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

90.1
/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];
972✔
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!
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,709✔
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");
576✔
123
    return METERS_PER_UNIT[units] * dpi2dpm(dpi || DEFAULT_SCREEN_DPI);
576✔
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));
7,130✔
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 = [];
324✔
158
    for (let l = minZoom; l <= maxZoom; l++) {
324✔
159
        retval.push(
7,095✔
160
            getSphericalMercatorScale(
161
                radius,
162
                tileWidth,
163
                zoomFactor,
164
                l,
165
                dpi
166
            )
167
        );
168
    }
169
    return retval;
324✔
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(
323✔
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);
331✔
197
    const resolutions = scales.map((scale) => {
331✔
198
        return scale / dpu;
7,079✔
199
    });
200
    return resolutions;
331✔
201
}
202

203
export function getGoogleMercatorResolutions(minZoom, maxZoom, dpi) {
204
    return getResolutionsForScales(getGoogleMercatorScales(minZoom, maxZoom, dpi), "EPSG:3857", dpi);
313✔
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, {
76✔
229
    minResolution: minRes,
230
    maxResolution: maxRes,
231
    minZoom: minZ,
232
    maxZoom: maxZ,
233
    zoomFactor: zoomF,
234
    extent: ext,
235
    tileWidth = 256,
546✔
236
    tileHeight = 256
546✔
237
} = {}) {
238
    const defaultMaxZoom = 30;
592✔
239
    const defaultZoomFactor = 2;
592✔
240

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

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

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

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

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

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

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

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

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

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

322
    // given discrete zoom levels, minResolution may be different than provided
323
    maxZoom = minZoom + Math.floor(
592✔
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));
18,352✔
326
}
327

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

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

341
export function getScale(projection, dpi, resolution) {
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!
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!
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
/**
413
 * Calculate the exact zoom level corresponding to a given resolution
414
 *
415
 * @param {number} targetResolution resolution to be converted in zoom
416
 * @param {number[]} resolutions list of all available resolutions
417
 * @returns {number} - A floating-point number representing the exact zoom level that corresponds
418
 *                   to the provided resolution.
419
 *
420
 * @example
421
 * const resolutions = [2048, 1024, 512, 256];
422
 * const zoom = getExactZoom(600, resolutions);
423
 * console.log(zoom); // e.g., ~1.77
424
 */
425
export function getExactZoomFromResolution(targetResolution, resolutions = getResolutions()) {
×
426
    const maxResolution = resolutions[0]; // zoom level 0
3✔
427
    return Math.log2(maxResolution / targetResolution);
3✔
428
}
429

430
export function defaultGetZoomForExtent(extent, mapSize, minZoom, maxZoom, dpi, mapResolutions) {
431
    const wExtent = extent[2] - extent[0];
10✔
432
    const hExtent = extent[3] - extent[1];
10✔
433

434
    const xResolution = Math.abs(wExtent / mapSize.width);
10✔
435
    const yResolution = Math.abs(hExtent / mapSize.height);
10✔
436
    const extentResolution = Math.max(xResolution, yResolution);
10✔
437

438
    const resolutions = mapResolutions || getResolutionsForScales(getGoogleMercatorScales(
10✔
439
        minZoom, maxZoom, dpi || DEFAULT_SCREEN_DPI), "EPSG:3857", dpi);
11✔
440

441
    const {zoom} = resolutions.reduce((previous, resolution, index) => {
10✔
442
        const diff = Math.abs(resolution - extentResolution);
228✔
443
        return diff > previous.diff ? previous : {diff: diff, zoom: index};
228✔
444
    }, {diff: Number.POSITIVE_INFINITY, zoom: 0});
445

446
    return Math.max(0, Math.min(zoom, maxZoom));
10✔
447
}
448

449
/**
450
 * Calculates the best fitting zoom level for the given extent.
451
 *
452
 * @param extent {Array} [minx, miny, maxx, maxy]
453
 * @param mapSize {Object} current size of the map.
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 zoom level fitting th extent
458
 */
459
export function getZoomForExtent(extent, mapSize, minZoom, maxZoom, dpi) {
460
    if (getHook("EXTENT_TO_ZOOM_HOOK")) {
11✔
461
        return getHook("EXTENT_TO_ZOOM_HOOK")(extent, mapSize, minZoom, maxZoom, dpi);
1✔
462
    }
463
    const resolutions = getHook("RESOLUTIONS_HOOK") ?
10✔
464
        getHook("RESOLUTIONS_HOOK")() : null;
465
    return defaultGetZoomForExtent(extent, mapSize, minZoom, maxZoom, dpi, resolutions);
10✔
466
}
467

468
/**
469
* It returns the current resolution.
470
*
471
* @param currentZoom {number} the current zoom
472
* @param minZoom {number} min zoom level.
473
* @param maxZoom {number} max zoom level.
474
* @param dpi {number} screen resolution in dot per inch.
475
* @return {Number} the actual resolution
476
*/
477
export function getCurrentResolution(currentZoom, minZoom, maxZoom, dpi) {
478
    if (getHook("RESOLUTION_HOOK")) {
63✔
479
        return getHook("RESOLUTION_HOOK")(currentZoom, minZoom, maxZoom, dpi);
2✔
480
    }
481
    /* if no hook is registered (leaflet) it is used the GoogleMercatorResolutions in
482
       in order to get the list of resolutions */
483
    return getGoogleMercatorResolutions(minZoom, maxZoom, dpi)[currentZoom];
61✔
484
}
485

486
/**
487
 * Calculates the center for for the given extent.
488
 *
489
 * @param  {Array} extent [minx, miny, maxx, maxy]
490
 * @param  {String} projection projection of the extent
491
 * @return {object} center object
492
 */
493
export function getCenterForExtent(extent, projection) {
494

495
    var wExtent = extent[2] - extent[0];
10✔
496
    var hExtent = extent[3] - extent[1];
10✔
497

498
    var w = wExtent / 2;
10✔
499
    var h = hExtent / 2;
10✔
500

501
    return {
10✔
502
        x: extent[0] + w,
503
        y: extent[1] + h,
504
        crs: projection
505
    };
506
}
507

508
/**
509
 * Calculates the bounding box for the given center and zoom.
510
 *
511
 * @param  {object} center object
512
 * @param  {number} zoom level
513
 */
514
export function getBbox(center, zoom) {
515
    return executeHook("COMPUTE_BBOX_HOOK",
21✔
516
        (hook) => {
517
            return hook(center, zoom);
14✔
518
        }
519
    );
520
}
521

522
export const isNearlyEqual = function(a, b) {
1✔
523
    if (a === undefined || b === undefined) {
14!
UNCOV
524
        return false;
×
525
    }
526
    return a.toFixed(12) - b.toFixed(12) === 0;
14✔
527
};
528

529
/**
530
 * checks if maps has changed by looking at center or zoom
531
 * @param {object} oldMap map object
532
 * @param {object} newMap map object
533
 */
534
export function mapUpdated(oldMap, newMap) {
535
    if (oldMap && !isEmpty(oldMap) &&
8✔
536
        newMap && !isEmpty(newMap)) {
537
        const centersEqual = isNearlyEqual(newMap?.center?.x, oldMap?.center?.x) &&
3✔
538
                              isNearlyEqual(newMap?.center?.y, oldMap?.center?.y);
539
        return !centersEqual || newMap?.zoom !== oldMap?.zoom;
3✔
540
    }
541
    return false;
5✔
542
}
543

544
/* Transform width and height specified in meters to the units of the specified projection */
545
export function transformExtent(projection, center, width, height) {
UNCOV
546
    let units = getUnits(projection);
×
UNCOV
547
    if (units === 'ft') {
×
UNCOV
548
        return {width: width / METERS_PER_UNIT.ft, height: height / METERS_PER_UNIT.ft};
×
UNCOV
549
    } else if (units === 'us-ft') {
×
UNCOV
550
        return {width: width / METERS_PER_UNIT['us-ft'], height: height / METERS_PER_UNIT['us-ft']};
×
UNCOV
551
    } else if (units === 'degrees') {
×
UNCOV
552
        return {
×
553
            width: width / (111132.92 - 559.82 * Math.cos(2 * center.y) + 1.175 * Math.cos(4 * center.y)),
554
            height: height / (111412.84 * Math.cos(center.y) - 93.5 * Math.cos(3 * center.y))
555
        };
556
    }
UNCOV
557
    return {width, height};
×
558
}
559

560
export const groupSaveFormatted = (node) => {
1✔
561
    return {
42✔
562
        id: node.id,
563
        title: node.title,
564
        description: node.description,
565
        tooltipOptions: node.tooltipOptions,
566
        tooltipPlacement: node.tooltipPlacement,
567
        expanded: node.expanded,
568
        visibility: node.visibility,
569
        nodesMutuallyExclusive: node.nodesMutuallyExclusive
570
    };
571
};
572

573

574
export function saveMapConfiguration(currentMap, currentLayers, currentGroups, currentBackgrounds, textSearchConfig, bookmarkSearchConfig, additionalOptions) {
575

576
    const map = {
41✔
577
        center: currentMap.center,
578
        maxExtent: currentMap.maxExtent,
579
        projection: currentMap.projection,
580
        units: currentMap.units,
581
        mapInfoControl: currentMap.mapInfoControl,
582
        zoom: currentMap.zoom,
583
        mapOptions: currentMap.mapOptions || {},
76✔
584
        ...(currentMap.visualizationMode && { visualizationMode: currentMap.visualizationMode }),
41✔
585
        ...(currentMap.viewerOptions && { viewerOptions: currentMap.viewerOptions })
41✔
586
    };
587

588
    const layers = currentLayers.map((layer) => {
40✔
589
        return saveLayer(layer);
68✔
590
    });
591

592
    const flatGroupId = currentGroups.reduce((a, b) => {
40✔
593
        const flatGroups = a.concat(getGroupNodes(b));
34✔
594
        return flatGroups;
34✔
595
    }, [].concat(currentGroups.map(g => g.id)));
34✔
596

597
    const groups = flatGroupId.map(g => {
40✔
598
        const node = getNode(currentGroups, g);
93✔
599
        return node && node.nodes ? groupSaveFormatted(node) : null;
93✔
600
    }).filter(g => g);
93✔
601

602
    const backgrounds = currentBackgrounds.filter(background => !!background.thumbnail);
40✔
603

604
    // extract sources map
605
    const sources = extractSourcesFromLayers(layers);
40✔
606

607
    // removes tile matrix set from layers and replace it with a link if available in sources
608
    const formattedLayers = layers.map(layer => {
40✔
609
        const { availableTileMatrixSets, ...updatedLayer } = updateAvailableTileMatrixSetsOptions(layer);
68✔
610
        return availableTileMatrixSets
68✔
611
            ? {
612
                ...updatedLayer,
613
                availableTileMatrixSets: Object.keys(availableTileMatrixSets)
614
                    .reduce((acc, tileMatrixSetId) => {
615
                        const tileMatrixSetLink = getTileMatrixSetLink(layer, tileMatrixSetId);
3✔
616
                        if (get({ sources }, tileMatrixSetLink)) {
3!
617
                            return {
3✔
618
                                ...acc,
619
                                [tileMatrixSetId]: {
620
                                    ...omit(availableTileMatrixSets[tileMatrixSetId], 'tileMatrixSet'),
621
                                    tileMatrixSetLink
622
                                }
623
                            };
624
                        }
UNCOV
625
                        return {
×
626
                            ...acc,
627
                            [tileMatrixSetId]: availableTileMatrixSets[tileMatrixSetId]
628
                        };
629
                    }, {})
630
            }
631
            : updatedLayer;
632
    });
633

634
    /* removes the geometryGeodesic property from the features in the annotations layer*/
635
    let annotationsLayerIndex = findIndex(formattedLayers, layer => layer.id === "annotations");
68✔
636
    if (annotationsLayerIndex !== -1) {
40✔
637
        let featuresLayer = formattedLayers[annotationsLayerIndex].features.map(feature => {
1✔
638
            if (feature.type === "FeatureCollection") {
1!
639
                return {
1✔
640
                    ...feature,
641
                    features: feature.features.map(f => {
642
                        if (f.properties.geometryGeodesic) {
1!
643
                            return set("properties.geometryGeodesic", null, f);
1✔
644
                        }
UNCOV
645
                        return f;
×
646
                    })
647
                };
648
            }
UNCOV
649
            if (feature.properties.geometryGeodesic) {
×
UNCOV
650
                return set("properties.geometryGeodesic", null, feature);
×
651
            }
UNCOV
652
            return {};
×
653
        });
654
        formattedLayers[annotationsLayerIndex] = set("features", featuresLayer, formattedLayers[annotationsLayerIndex]);
1✔
655
    }
656

657
    return {
40✔
658
        version: 2,
659
        // layers are defined inside the map object
660
        map: Object.assign({}, map, {layers: formattedLayers, groups, backgrounds, text_search_config: textSearchConfig, bookmark_search_config: bookmarkSearchConfig},
661
            !isEmpty(sources) && {sources} || {}),
80✔
662
        ...additionalOptions
663
    };
664
}
665

666
export const generateNewUUIDs = (mapConfig = {}) => {
1!
667
    const newMapConfig = cloneDeep(mapConfig);
2✔
668

669
    const oldIdToNew = {
2✔
670
        ...get(mapConfig, 'map.layers', []).reduce((result, layer) => ({
4✔
671
            ...result,
672
            [layer.id]: layer.id === 'annotations' ? layer.id : uuidv1()
4!
673
        }), {}),
674
        ...get(mapConfig, 'widgetsConfig.widgets', []).reduce((result, widget) => ({...result, [widget.id]: uuidv1()}), {})
1✔
675
    };
676

677
    return set('map.backgrounds', get(mapConfig, 'map.backgrounds', []).map(background => ({...background, id: oldIdToNew[background.id]})),
2✔
678
        set('widgetsConfig', {
679
            collapsed: mapValues(mapKeys(get(mapConfig, 'widgetsConfig.collapsed', {}), (value, key) => oldIdToNew[key]), (value) =>
1✔
680
                ({...value, layouts: mapValues(value.layouts, (layout) => ({...layout, i: oldIdToNew[layout.i]}))})),
2✔
681
            layouts: mapValues(get(mapConfig, 'widgetsConfig.layouts', {}), (value) =>
682
                value.map(layout => ({...layout, i: oldIdToNew[layout.i]}))),
2✔
683
            widgets: get(mapConfig, 'widgetsConfig.widgets', [])
684
                .map(widget => ({
1✔
685
                    ...widget,
686
                    id: oldIdToNew[widget.id],
687
                    layer: ({...get(widget, 'layer', {}), id: oldIdToNew[get(widget, 'layer.id')]})
688
                }))
689
        },
690
        set('map.layers', get(mapConfig, 'map.layers', [])
691
            .map(layer => ({...layer, id: oldIdToNew[layer.id]})), newMapConfig)));
4✔
692
};
693

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

698
    const cfg2Fixed = generateNewUUIDs(cfg2);
2✔
699

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

702
    const layers1 = fixLayers(get(cfg1, 'map.layers', []));
2✔
703
    const layers2 = fixLayers(get(cfg2Fixed, 'map.layers', []));
2✔
704

705
    const annotationsLayer1 = find(layers1, layer => layer.id === 'annotations');
5✔
706
    const annotationsLayer2 = find(layers2, layer => layer.id === 'annotations');
4✔
707

708
    const layers = [
2✔
709
        ...layers2.filter(layer => layer.id !== 'annotations'),
4✔
710
        ...layers1.filter(layer => layer.id !== 'annotations'),
6✔
711
        ...(annotationsLayer1 || annotationsLayer2 ? [{
5✔
712
            ...(annotationsLayer1 || {}),
1!
713
            ...(annotationsLayer2 || {}),
2✔
714
            features: [
715
                ...get(annotationsLayer1, 'features', []), ...get(annotationsLayer2, 'features', [])
716
            ]
717
        }] : [])
718
    ];
719
    const toleratedFields = ['id', 'visibility'];
2✔
720
    const backgroundLayers = layers.filter(layer => layer.group === 'background')
10✔
721
        // remove duplication by comparing all fields with some level of tolerance
722
        .filter((l1, i, a) => findIndex(a, (l2) => isEqual(omit(l1, toleratedFields), omit(l2, toleratedFields))) === i);
5✔
723
    const firstVisible = findIndex(backgroundLayers, layer => layer.visibility);
2✔
724

725
    const sources1 = get(cfg1, 'map.sources', {});
2✔
726
    const sources2 = get(cfg2Fixed, 'map.sources', {});
2✔
727
    const sources = {...sources1, ...sources2};
2✔
728

729
    const widgetsConfig1 = get(cfg1, 'widgetsConfig', {});
2✔
730
    const widgetsConfig2 = get(cfg2Fixed, 'widgetsConfig', {});
2✔
731

732
    return {
2✔
733
        ...cfg2Fixed,
734
        ...cfg1,
735
        catalogServices: {
736
            ...get(cfg1, 'catalogServices', {}),
737
            services: {
738
                ...get(cfg1, 'catalogServices.services', {}),
739
                ...get(cfg2Fixed, 'catalogServices.services', {})
740
            }
741
        },
742
        map: {
743
            ...cfg2Fixed.map,
744
            ...cfg1.map,
745
            backgrounds,
746
            groups: uniqWith([...get(cfg1, 'map.groups', []), ...get(cfg2Fixed, 'map.groups', [])],
747
                (group1, group2) => group1.id === group2.id),
4✔
748
            layers: [
749
                ...backgroundLayers.slice(0, firstVisible + 1),
750
                ...backgroundLayers.slice(firstVisible + 1).map(layer => ({...layer, visibility: false})),
2✔
751
                ...layers.filter(layer => layer.group !== 'background')
10✔
752
            ],
753
            sources: !isEmpty(sources) ? sources : undefined
2!
754
        },
755
        widgetsConfig: {
756
            collapsed: {...widgetsConfig1.collapsed, ...widgetsConfig2.collapsed},
757
            layouts: uniq([...keys(widgetsConfig1.layouts), ...keys(widgetsConfig2.layouts)])
758
                .reduce((result, key) => ({
2✔
759
                    ...result,
760
                    [key]: [
761
                        ...get(widgetsConfig1, `layouts.${key}`, []),
762
                        ...get(widgetsConfig2, `layouts.${key}`, [])
763
                    ]
764
                }), {}),
765
            widgets: [...get(widgetsConfig1, 'widgets', []), ...get(widgetsConfig2, 'widgets', [])]
766
        },
767
        timelineData: {
768
            ...get(cfg1, 'timelineData', {}),
769
            ...get(cfg2Fixed, 'timelineData', {})
770
        },
771
        dimensionData: {
772
            ...get(cfg1, 'dimensionData', {}),
773
            ...get(cfg2Fixed, 'dimensionData', {})
774
        }
775
    };
776
};
777

778
export const addRootParentGroup = (cfg = {}, groupTitle = 'RootGroup') => {
1!
779
    const groups = get(cfg, 'map.groups', []);
2✔
780
    const groupsWithoutDefault = groups.filter(({id}) => id !== DEFAULT_GROUP_ID);
3✔
781
    const defaultGroup = find(groups, ({id}) => id === DEFAULT_GROUP_ID);
2✔
782
    const fixedDefaultGroup = defaultGroup && {
2✔
783
        id: uuidv1(),
784
        title: groupTitle,
785
        expanded: defaultGroup.expanded
786
    };
787
    const groupsWithFixedDefault = defaultGroup ?
2✔
788
        [
789
            ...groupsWithoutDefault.map(({id, ...other}) => ({
2✔
790
                id: `${fixedDefaultGroup.id}.${id}`,
791
                ...other
792
            })),
793
            fixedDefaultGroup
794
        ] :
795
        groupsWithoutDefault;
796

797
    return {
2✔
798
        ...cfg,
799
        map: {
800
            ...cfg.map,
801
            groups: groupsWithFixedDefault,
802
            layers: get(cfg, 'map.layers', []).map(({group, ...other}) => ({
6✔
803
                ...other,
804
                group: defaultGroup && group !== 'background' && (group === DEFAULT_GROUP_ID || !group) ? fixedDefaultGroup.id :
28✔
805
                    defaultGroup && find(groupsWithFixedDefault, ({id}) => id.slice(id.indexOf('.') + 1) === group)?.id || group
6✔
806
            }))
807
        }
808
    };
809
};
810

811
export function isSimpleGeomType(geomType) {
812
    switch (geomType) {
48✔
813
    case "MultiPoint": case "MultiLineString": case "MultiPolygon": case "GeometryCollection": case "Text": return false;
13✔
814
    case "Point": case "Circle": case "LineString": case "Polygon": default: return true;
35✔
815
    }
816
}
817
export function getSimpleGeomType(geomType = "Point") {
×
818
    switch (geomType) {
37✔
819
    case "Point": case "LineString": case "Polygon": case "Circle": return geomType;
22✔
820
    case "MultiPoint": case "Marker": return "Point";
4✔
821
    case "MultiLineString": return "LineString";
3✔
822
    case "MultiPolygon": return "Polygon";
3✔
823
    case "GeometryCollection": return "GeometryCollection";
3✔
824
    case "Text": return "Point";
1✔
825
    default: return geomType;
1✔
826
    }
827
}
828

829
export const getIdFromUri = (uri, regex = /data\/(\d+)/) => {
1✔
830
    // 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`
831
    const decodedUri = decodeURIComponent(uri);
9✔
832
    const findDataDigit = regex.exec(decodedUri);
9✔
833
    return findDataDigit && findDataDigit.length && findDataDigit.length > 1 ? findDataDigit[1] : null;
9✔
834
};
835

836
/**
837
 * Method for cleanup map object from uneseccary fields which
838
 * updated map contains and were set on map render
839
 * @param {object} obj
840
 */
841

842
export const prepareMapObjectToCompare = obj => {
1✔
843
    const skippedKeys = ['apiKey', 'time', 'args', 'fixed'];
156✔
844
    const shouldBeSkipped = (key) => skippedKeys.reduce((p, n) => p || key === n, false);
1,012✔
845
    Object.keys(obj).forEach(key => {
156✔
846
        const value = obj[key];
484✔
847
        const type = typeof value;
484✔
848
        if (type === "object" && value !== null && !shouldBeSkipped(key)) {
484✔
849
            prepareMapObjectToCompare(value);
136✔
850
            if (!Object.keys(value).length) {
136✔
851
                delete obj[key];
74✔
852
            }
853
        } else if (type === "undefined" || !value || shouldBeSkipped(key)) {
348✔
854
            delete obj[key];
235✔
855
        }
856
    });
857
};
858

859
/**
860
 * Method added for support old key with objects provided for compareMapChanges feature
861
 * like text_serch_config
862
 * @param {object} obj
863
 * @param {string} oldKey
864
 * @param {string} newKey
865
 */
866
export const updateObjectFieldKey = (obj, oldKey, newKey) => {
1✔
867
    if (obj[oldKey]) {
15✔
868
        Object.defineProperty(obj, newKey, Object.getOwnPropertyDescriptor(obj, oldKey));
1✔
869
        delete obj[oldKey];
1✔
870
    }
871
};
872

873
/**
874
 * Feature for map change recognition. Returns value of isEqual method from lodash
875
 * @param {object} map1 original map before changes
876
 * @param {object} map2 updated map
877
 * @returns {boolean}
878
 */
879
export const compareMapChanges = (map1 = {}, map2 = {}) => {
1!
880
    const pickedFields = [
6✔
881
        'map.layers',
882
        'map.backgrounds',
883
        'map.text_search_config',
884
        'map.bookmark_search_config',
885
        'map.text_serch_config',
886
        'map.zoom',
887
        'widgetsConfig',
888
        'swipe'
889
    ];
890
    const filteredMap1 = pick(cloneDeep(map1), pickedFields);
6✔
891
    const filteredMap2 = pick(cloneDeep(map2), pickedFields);
6✔
892
    // ABOUT: used for support text_serch_config field in old maps
893
    updateObjectFieldKey(filteredMap1.map, 'text_serch_config', 'text_search_config');
6✔
894
    updateObjectFieldKey(filteredMap2.map, 'text_serch_config', 'text_search_config');
6✔
895

896
    prepareMapObjectToCompare(filteredMap1);
6✔
897
    prepareMapObjectToCompare(filteredMap2);
6✔
898
    return isEqual(filteredMap1, filteredMap2);
6✔
899
};
900

901
/**
902
 * creates utilities for registering, fetching, executing hooks
903
 * used to override default ones in order to have a local hooks object
904
 * one for each map widget
905
 */
906
export const createRegisterHooks = (id) => {
1✔
907
    let hooksCustom = {};
5✔
908
    return {
5✔
909
        registerHook: (name, hook) => {
910
            hooksCustom[name] = hook;
17✔
911
        },
912
        getHook: (name) => hooksCustom[name],
7✔
913
        executeHook: (hookName, existCallback, dontExistCallback) => {
UNCOV
914
            const hook = hooksCustom[hookName];
×
UNCOV
915
            if (hook) {
×
UNCOV
916
                return existCallback(hook);
×
917
            }
UNCOV
918
            if (dontExistCallback) {
×
919
                return dontExistCallback();
×
920
            }
UNCOV
921
            return null;
×
922
        },
923
        id
924
    };
925
};
926

927
/**
928
 * Detects if state has enabled Identify plugin for mapPopUps
929
 * @param {object} state
930
 * @returns {boolean}
931
 */
932
export const detectIdentifyInMapPopUp = (state)=>{
1✔
933
    if (state.mapPopups?.popups) {
2!
934
        let hasIdentify = state.mapPopups.popups.filter(plugin =>plugin?.component?.toLowerCase() === 'identify');
2✔
935
        return hasIdentify && hasIdentify.length > 0 ? true : false;
2✔
936
    }
UNCOV
937
    return false;
×
938
};
939

940
/**
941
 * Derive resolution object with scale and zoom info
942
 * based on visibility limit's type
943
 * @param value {number} computed with dots per map unit to get resolution
944
 * @param type {string} of visibility limit ex. scale
945
 * @param projection {string} map projection
946
 * @param resolutions {array} map resolutions
947
 * @return {object} resolution object
948
 */
949
export const getResolutionObject = (value, type, {projection, resolutions} = {}) => {
1!
950
    const dpu = dpi2dpu(DEFAULT_SCREEN_DPI, projection);
4✔
951
    if (type === 'scale') {
4✔
952
        const resolution = value / dpu;
3✔
953
        return {
3✔
954
            resolution: resolution,
955
            scale: value,
956
            zoom: getZoomFromResolution(resolution, resolutions)
957
        };
958
    }
959
    return {
1✔
960
        resolution: value,
961
        scale: value * dpu,
962
        zoom: getZoomFromResolution(value, resolutions)
963
    };
964
};
965
window.__ = getResolutionObject;
1✔
966

967
export function calculateExtent(center = {x: 0, y: 0, crs: "EPSG:3857"}, resolution, size = {width: 100, height: 100}, projection = "EPSG:3857") {
12!
968
    const {x, y} = reproject(center, center.crs ?? projection, projection);
7!
969
    const dx = resolution * size.width / 2;
7✔
970
    const dy = resolution * size.height / 2;
7✔
971
    return [x - dx, y - dy, x + dx, y + dy];
7✔
972

973
}
974

975

976
export const reprojectZoom = (zoom, mapProjection, printProjection) => {
1✔
977
    const multiplier = METERS_PER_UNIT[getUnits(mapProjection)] / METERS_PER_UNIT[getUnits(printProjection)];
21✔
978
    const mapResolution = getResolutions(mapProjection)[Math.round(zoom)] * multiplier;
21✔
979
    const printResolutions = getResolutions(printProjection);
21✔
980

981
    const printResolution = printResolutions.reduce((nearest, current) => {
21✔
982
        return Math.abs(current - mapResolution) < Math.abs(nearest - mapResolution) ? current : nearest;
525✔
983
    }, printResolutions[0]);
984
    return printResolutions.indexOf(printResolution);
21✔
985
};
986

987

988
export default {
989
    createRegisterHooks,
990
    EXTENT_TO_ZOOM_HOOK,
991
    RESOLUTIONS_HOOK,
992
    RESOLUTION_HOOK,
993
    COMPUTE_BBOX_HOOK,
994
    GET_PIXEL_FROM_COORDINATES_HOOK,
995
    GET_COORDINATES_FROM_PIXEL_HOOK,
996
    DEFAULT_SCREEN_DPI,
997
    ZOOM_TO_EXTENT_HOOK,
998
    CLICK_ON_MAP_HOOK,
999
    EMPTY_MAP,
1000
    registerHook,
1001
    getHook,
1002
    dpi2dpm,
1003
    getSphericalMercatorScales,
1004
    getSphericalMercatorScale,
1005
    getGoogleMercatorScales,
1006
    getGoogleMercatorResolutions,
1007
    getGoogleMercatorScale,
1008
    getResolutionsForScales,
1009
    getZoomForExtent,
1010
    defaultGetZoomForExtent,
1011
    getCenterForExtent,
1012
    getResolutions,
1013
    getScales,
1014
    getBbox,
1015
    mapUpdated,
1016
    getCurrentResolution,
1017
    transformExtent,
1018
    saveMapConfiguration,
1019
    generateNewUUIDs,
1020
    mergeMapConfigs,
1021
    addRootParentGroup,
1022
    isSimpleGeomType,
1023
    getSimpleGeomType,
1024
    getIdFromUri,
1025
    prepareMapObjectToCompare,
1026
    updateObjectFieldKey,
1027
    compareMapChanges,
1028
    clearHooks,
1029
    getResolutionObject,
1030
    calculateExtent,
1031
    reprojectZoom
1032
};
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