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

geosolutions-it / MapStore2 / 17125135958

21 Aug 2025 11:08AM UTC coverage: 77.014% (+0.02%) from 76.998%
17125135958

Pull #11393

github

web-flow
Merge 658892e06 into cde8a025a
Pull Request #11393: [Backport c125-2025.01.xx] Performace Lag when there is a layer with very large geomerty #11358

31045 of 48188 branches covered (64.42%)

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

1 existing line in 1 file now uncovered.

38439 of 49912 relevant lines covered (77.01%)

36.7 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
import assign from 'object-assign';
45

46
export const DEFAULT_SCREEN_DPI = 96;
1✔
47

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

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

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

63
import proj4 from "proj4";
64

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

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

83
let hooks = {};
1✔
84

85

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

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

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

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

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

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

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

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

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

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

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

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

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

242
    let minZoom = minZ ?? 0;
581✔
243

244
    let maxZoom = maxZ ?? defaultMaxZoom;
581✔
245

246
    let zoomFactor = zoomF ?? defaultZoomFactor;
581✔
247

248
    const projection = proj4.defs(srs);
581✔
249

250
    const extent = ext ?? getProjection(srs)?.extent;
581✔
251

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

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

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

294
     */
295

296
    const defaultMaxResolution = res;
581✔
297

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

450
/**
451
 * Calculates the best fitting zoom level for the given extent.
452
 *
453
 * @param extent {Array} [minx, miny, maxx, maxy]
454
 * @param mapSize {Object} current size of the map.
455
 * @param minZoom {number} min zoom level.
456
 * @param maxZoom {number} max zoom level.
457
 * @param dpi {number} screen resolution in dot per inch.
458
 * @return {Number} the zoom level fitting th extent
459
 */
460
export function getZoomForExtent(extent, mapSize, minZoom, maxZoom, dpi) {
461
    if (getHook("EXTENT_TO_ZOOM_HOOK")) {
11✔
462
        return getHook("EXTENT_TO_ZOOM_HOOK")(extent, mapSize, minZoom, maxZoom, dpi);
1✔
463
    }
464
    const resolutions = getHook("RESOLUTIONS_HOOK") ?
10✔
465
        getHook("RESOLUTIONS_HOOK")() : null;
466
    return defaultGetZoomForExtent(extent, mapSize, minZoom, maxZoom, dpi, resolutions);
10✔
467
}
468

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

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

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

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

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

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

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

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

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

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

574

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

837

838
/**
839
 * Determines if a field should be included in the comparison based on picked fields and exclusion rules.
840
 * @param {string} path - The full path to the field (e.g., 'root.obj.key').
841
 * @param {string} key - The key of the field being checked.
842
 * @param {*} value - The value of the field.
843
 * @param {object} rules - The rules object containing pickedFields and excludes.
844
 * @param {string[]} rules.pickedFields - Array of field paths to include in the comparison.
845
 * @param {object} rules.excludes - Object mapping parent paths to arrays of keys to exclude.
846
 * @returns {boolean} True if the field should be included, false otherwise.
847
 */
848
export const filterFieldByRules = (path, key, value, { pickedFields = [], excludes = {} }) => {
1!
849
    if (value === undefined) {
364✔
850
        return false;
80✔
851
    }
852
    if (pickedFields.some((field) => field.includes(path) || path.includes(field))) {
915✔
853
        // Fix: check parent path for excludes
854
        const parentPath = path.substring(0, path.lastIndexOf('.'));
207✔
855
        if (excludes[parentPath] === undefined) {
207✔
856
            return true;
80✔
857
        }
858
        if (excludes[parentPath] && excludes[parentPath].includes(key)) {
127✔
859
            return false;
8✔
860
        }
861
        return true;
119✔
862
    }
863
    return false;
77✔
864
};
865

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

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

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

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

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

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

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

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

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

1024
}
1025

1026

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

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

1038

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