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

geosolutions-it / MapStore2 / 16596363367

29 Jul 2025 12:40PM UTC coverage: 76.893% (+0.006%) from 76.887%
16596363367

push

github

web-flow
Performace Lag when there is a layer with very large geomerty #11358 (#11359)

---------

Co-authored-by: allyoucanmap <stefano.bovio@geosolutionsgroup.com>

31373 of 48818 branches covered (64.27%)

42 of 44 new or added lines in 1 file covered. (95.45%)

1 existing line in 1 file now uncovered.

38874 of 50556 relevant lines covered (76.89%)

36.38 hits per line

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

90.13
/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,134✔
87
}
88

89
export function getHook(name) {
90
    return hooks[name];
962✔
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,
540✔
236
    tileHeight = 256
540✔
237
} = {}) {
238
    const defaultMaxZoom = 30;
586✔
239
    const defaultZoomFactor = 2;
586✔
240

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

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

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

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

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

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

258
    let resX = extentWidth / tileWidth;
586✔
259
    let resY = extentHeight / tileHeight;
586✔
260
    let tilesWide;
261
    let tilesHigh;
262
    if (resX <= resY) {
586✔
263
        // use one tile wide by N tiles high
264
        tilesWide = 1;
498✔
265
        tilesHigh = Math.round(resY / resX);
498✔
266
        // previous resY was assuming 1 tile high, recompute with the actual number of tiles
267
        // high
268
        resY = resY / tilesHigh;
498✔
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);
586✔
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;
586✔
296

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

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

308
    // user provided minResolution takes precedence
309
    let minResolution = minRes;
586✔
310
    if (minResolution === undefined) {
586!
311
        if (maxZoom !== undefined) {
586!
312
            if (maxRes !== undefined) {
586!
313
                minResolution = maxResolution / Math.pow(zoomFactor, maxZoom);
×
314
            } else {
315
                minResolution = defaultMaxResolution / Math.pow(zoomFactor, maxZoom);
586✔
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(
586✔
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,166✔
326
}
327

328
export function getResolutions(projection) {
329
    if (getHook('RESOLUTIONS_HOOK')) {
567✔
330
        return getHook('RESOLUTIONS_HOOK')(projection);
249✔
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!
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) {
546
    let units = getUnits(projection);
×
547
    if (units === 'ft') {
×
548
        return {width: width / METERS_PER_UNIT.ft, height: height / METERS_PER_UNIT.ft};
×
549
    } else if (units === 'us-ft') {
×
550
        return {width: width / METERS_PER_UNIT['us-ft'], height: height / METERS_PER_UNIT['us-ft']};
×
551
    } else if (units === 'degrees') {
×
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
    }
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
                        }
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
                        }
645
                        return f;
×
646
                    })
647
                };
648
            }
649
            if (feature.properties.geometryGeodesic) {
×
650
                return set("properties.geometryGeodesic", null, feature);
×
651
            }
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
/**
838
 * Determines if a field should be included in the comparison based on picked fields and exclusion rules.
839
 * @param {string} path - The full path to the field (e.g., 'root.obj.key').
840
 * @param {string} key - The key of the field being checked.
841
 * @param {*} value - The value of the field.
842
 * @param {object} rules - The rules object containing pickedFields and excludes.
843
 * @param {string[]} rules.pickedFields - Array of field paths to include in the comparison.
844
 * @param {object} rules.excludes - Object mapping parent paths to arrays of keys to exclude.
845
 * @returns {boolean} True if the field should be included, false otherwise.
846
 */
847
export const filterFieldByRules = (path, key, value, { pickedFields = [], excludes = {} }) => {
1!
848
    if (value === undefined) {
364✔
849
        return false;
80✔
850
    }
851
    if (pickedFields.some((field) => field.includes(path) || path.includes(field))) {
915✔
852
        // Fix: check parent path for excludes
853
        const parentPath = path.substring(0, path.lastIndexOf('.'));
207✔
854
        if (excludes[parentPath] === undefined) {
207✔
855
            return true;
80✔
856
        }
857
        if (excludes[parentPath] && excludes[parentPath].includes(key)) {
127✔
858
            return false;
8✔
859
        }
860
        return true;
119✔
861
    }
862
    return false;
77✔
863
};
864

865
/**
866
 * Prepares object entries for comparison by applying aliasing, filtering, and sorting.
867
 * @param {object} obj - The object whose entries are to be prepared.
868
 * @param {object} rules - The rules object containing aliases, pickedFields, and excludes.
869
 * @param {string} parentKey - The parent key path for the current object.
870
 * @returns {Array} Array of [key, value] pairs, filtered and sorted for comparison.
871
 */
872
export const prepareObjectEntries = (obj, rules, parentKey) => {
1✔
873
    const safeObj = obj || {};
81!
874
    // First filter using the original keys, then apply aliasing
875
    return Object.entries(safeObj)
81✔
876
        .filter(([key, value]) => filterFieldByRules(`${parentKey}.${key}`, key, value, rules))
360✔
877
        .map(([key, value]) => [rules.aliases && rules.aliases[key] || key, value])
198✔
878
        .sort((a, b) => {
879
            if (a[0] < b[0]) { return -1; }
376✔
880
            if (a[0] > b[0]) { return 1; }
179!
NEW
881
            return 0;
×
882
        });
883
};
884

885
// function that checks if a field has changed ( also includes the rules to prepare object for comparision)
886
export const recursiveIsChangedWithRules = (a, b, rules, parentKey = 'root') => {
1!
887
    // strictly equal
888
    if (a === b) {
95✔
889
        return false;
43✔
890
    }
891

892
    // Handle arrays
893
    if (Array.isArray(a)) {
52✔
894
        if (!Array.isArray(b) || a.length !== b.length) {
11!
NEW
895
            return true;
×
896
        }
897
        // same reference
898
        if (a === b) return false;
11!
899
        for (let i = 0; i < a.length; i++) {
11✔
900
            if (recursiveIsChangedWithRules(a[i], b[i], rules, `${parentKey}[]`)) return true;
8✔
901
        }
902
        return false;
9✔
903
    }
904

905
    // Handle objects
906
    if (typeof a === 'object' && a !== null) {
41✔
907
        // Prepare entries only if needed
908
        const aEntries = prepareObjectEntries(a, rules, parentKey);
39✔
909
        const bEntries = prepareObjectEntries(b || {}, rules, parentKey);
39!
910
        if (aEntries.length !== bEntries.length) {
39✔
911
            return true;
3✔
912
        }
913
        for (let i = 0; i < aEntries.length; i++) {
36✔
914
            const [key, value] = aEntries[i];
76✔
915
            if (recursiveIsChangedWithRules(value, bEntries[i]?.[1], rules, `${parentKey}.${key}`)) {
76✔
916
                return true;
8✔
917
            }
918
        }
919
        return false;
28✔
920
    }
921
    // Fallback for primitives
922
    return a !== b;
2✔
923
};
924

925
/**
926
 * @param {object} map1 - The original map configuration object.
927
 * @param {object} map2 - The updated map configuration object.
928
 * @returns {boolean} True if the considered fields are equal, false otherwise.
929
 */
930
export const compareMapChanges = (map1 = {}, map2 = {}) => {
1!
931
    const pickedFields = [
6✔
932
        'root.map.layers',
933
        'root.map.backgrounds',
934
        'root.map.text_search_config',
935
        'root.map.bookmark_search_config',
936
        'root.map.text_serch_config',
937
        'root.map.zoom',
938
        'root.widgetsConfig',
939
        'root.swipe'
940
    ];
941
    const aliases = {
6✔
942
        text_serch_config: 'text_search_config'
943
    };
944
    const excludes = {
6✔
945
        'root.map.layers[]': ['apiKey', 'time', 'args', 'fixed']
946
    };
947

948
    const isSame = !recursiveIsChangedWithRules(map1, map2, { pickedFields, aliases, excludes }, 'root');
6✔
949
    return isSame;
6✔
950
};
951
/**
952
 * creates utilities for registering, fetching, executing hooks
953
 * used to override default ones in order to have a local hooks object
954
 * one for each map widget
955
 */
956
export const createRegisterHooks = (id) => {
1✔
957
    let hooksCustom = {};
5✔
958
    return {
5✔
959
        registerHook: (name, hook) => {
960
            hooksCustom[name] = hook;
17✔
961
        },
962
        getHook: (name) => hooksCustom[name],
7✔
963
        executeHook: (hookName, existCallback, dontExistCallback) => {
964
            const hook = hooksCustom[hookName];
×
965
            if (hook) {
×
966
                return existCallback(hook);
×
967
            }
968
            if (dontExistCallback) {
×
969
                return dontExistCallback();
×
970
            }
971
            return null;
×
972
        },
973
        id
974
    };
975
};
976

977
/**
978
 * Detects if state has enabled Identify plugin for mapPopUps
979
 * @param {object} state
980
 * @returns {boolean}
981
 */
982
export const detectIdentifyInMapPopUp = (state)=>{
1✔
983
    if (state.mapPopups?.popups) {
2!
984
        let hasIdentify = state.mapPopups.popups.filter(plugin =>plugin?.component?.toLowerCase() === 'identify');
2✔
985
        return hasIdentify && hasIdentify.length > 0 ? true : false;
2✔
986
    }
987
    return false;
×
988
};
989

990
/**
991
 * Derive resolution object with scale and zoom info
992
 * based on visibility limit's type
993
 * @param value {number} computed with dots per map unit to get resolution
994
 * @param type {string} of visibility limit ex. scale
995
 * @param projection {string} map projection
996
 * @param resolutions {array} map resolutions
997
 * @return {object} resolution object
998
 */
999
export const getResolutionObject = (value, type, {projection, resolutions} = {}) => {
1!
1000
    const dpu = dpi2dpu(DEFAULT_SCREEN_DPI, projection);
4✔
1001
    if (type === 'scale') {
4✔
1002
        const resolution = value / dpu;
3✔
1003
        return {
3✔
1004
            resolution: resolution,
1005
            scale: value,
1006
            zoom: getZoomFromResolution(resolution, resolutions)
1007
        };
1008
    }
1009
    return {
1✔
1010
        resolution: value,
1011
        scale: value * dpu,
1012
        zoom: getZoomFromResolution(value, resolutions)
1013
    };
1014
};
1015
window.__ = getResolutionObject;
1✔
1016

1017
export function calculateExtent(center = {x: 0, y: 0, crs: "EPSG:3857"}, resolution, size = {width: 100, height: 100}, projection = "EPSG:3857") {
12!
1018
    const {x, y} = reproject(center, center.crs ?? projection, projection);
7!
1019
    const dx = resolution * size.width / 2;
7✔
1020
    const dy = resolution * size.height / 2;
7✔
1021
    return [x - dx, y - dy, x + dx, y + dy];
7✔
1022

1023
}
1024

1025

1026
export const reprojectZoom = (zoom, mapProjection, printProjection) => {
1✔
1027
    const multiplier = METERS_PER_UNIT[getUnits(mapProjection)] / METERS_PER_UNIT[getUnits(printProjection)];
21✔
1028
    const mapResolution = getResolutions(mapProjection)[Math.round(zoom)] * multiplier;
21✔
1029
    const printResolutions = getResolutions(printProjection);
21✔
1030

1031
    const printResolution = printResolutions.reduce((nearest, current) => {
21✔
1032
        return Math.abs(current - mapResolution) < Math.abs(nearest - mapResolution) ? current : nearest;
525✔
1033
    }, printResolutions[0]);
1034
    return printResolutions.indexOf(printResolution);
21✔
1035
};
1036

1037

1038
export default {
1039
    createRegisterHooks,
1040
    EXTENT_TO_ZOOM_HOOK,
1041
    RESOLUTIONS_HOOK,
1042
    RESOLUTION_HOOK,
1043
    COMPUTE_BBOX_HOOK,
1044
    GET_PIXEL_FROM_COORDINATES_HOOK,
1045
    GET_COORDINATES_FROM_PIXEL_HOOK,
1046
    DEFAULT_SCREEN_DPI,
1047
    ZOOM_TO_EXTENT_HOOK,
1048
    CLICK_ON_MAP_HOOK,
1049
    EMPTY_MAP,
1050
    registerHook,
1051
    getHook,
1052
    dpi2dpm,
1053
    getSphericalMercatorScales,
1054
    getSphericalMercatorScale,
1055
    getGoogleMercatorScales,
1056
    getGoogleMercatorResolutions,
1057
    getGoogleMercatorScale,
1058
    getResolutionsForScales,
1059
    getZoomForExtent,
1060
    defaultGetZoomForExtent,
1061
    getCenterForExtent,
1062
    getResolutions,
1063
    getScales,
1064
    getBbox,
1065
    mapUpdated,
1066
    getCurrentResolution,
1067
    transformExtent,
1068
    saveMapConfiguration,
1069
    generateNewUUIDs,
1070
    mergeMapConfigs,
1071
    addRootParentGroup,
1072
    isSimpleGeomType,
1073
    getSimpleGeomType,
1074
    getIdFromUri,
1075
    compareMapChanges,
1076
    clearHooks,
1077
    getResolutionObject,
1078
    calculateExtent,
1079
    reprojectZoom
1080
};
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