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

geosolutions-it / MapStore2 / 15422327504

03 Jun 2025 04:08PM UTC coverage: 76.952% (-0.04%) from 76.993%
15422327504

Pull #11024

github

web-flow
Merge 2ddc9a6d7 into 2dbe8dab2
Pull Request #11024: Update User Guide - Upload image on Text Widget

31021 of 48282 branches covered (64.25%)

38629 of 50199 relevant lines covered (76.95%)

36.23 hits per line

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

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

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

28
import uuidv1 from 'uuid/v1';
29

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

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

34
import { set } from './ImmutableUtils';
35
import {
36
    saveLayer,
37
    getGroupNodes,
38
    getNode,
39
    extractSourcesFromLayers,
40
    updateAvailableTileMatrixSetsOptions,
41
    getTileMatrixSetLink,
42
    DEFAULT_GROUP_ID
43
} from './LayersUtils';
44
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,122✔
88
}
89

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

94
export function executeHook(hookName, existCallback, dontExistCallback) {
95
    const hook = getHook(hookName);
18✔
96
    if (hook) {
18✔
97
        return existCallback(hook);
11✔
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,058✔
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");
541✔
124
    return METERS_PER_UNIT[units] * dpi2dpm(dpi || DEFAULT_SCREEN_DPI);
541✔
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));
6,514✔
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 = [];
296✔
159
    for (let l = minZoom; l <= maxZoom; l++) {
296✔
160
        retval.push(
6,479✔
161
            getSphericalMercatorScale(
162
                radius,
163
                tileWidth,
164
                zoomFactor,
165
                l,
166
                dpi
167
            )
168
        );
169
    }
170
    return retval;
296✔
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(
295✔
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);
303✔
198
    const resolutions = scales.map((scale) => {
303✔
199
        return scale / dpu;
6,463✔
200
    });
201
    return resolutions;
303✔
202
}
203

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

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

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

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

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

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

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

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

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

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

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

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

337
export function getScales(projection, dpi) {
338
    const dpu = dpi2dpu(dpi, projection);
106✔
339
    return getResolutions(projection).map((resolution) => resolution * dpu);
2,692✔
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
export function defaultGetZoomForExtent(extent, mapSize, minZoom, maxZoom, dpi, mapResolutions) {
414
    const wExtent = extent[2] - extent[0];
10✔
415
    const hExtent = extent[3] - extent[1];
10✔
416

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

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

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

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

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

451
/**
452
* It returns the current resolution.
453
*
454
* @param currentZoom {number} the current zoom
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 actual resolution
459
*/
460
export function getCurrentResolution(currentZoom, minZoom, maxZoom, dpi) {
461
    if (getHook("RESOLUTION_HOOK")) {
57✔
462
        return getHook("RESOLUTION_HOOK")(currentZoom, minZoom, maxZoom, dpi);
2✔
463
    }
464
    /* if no hook is registered (leaflet) it is used the GoogleMercatorResolutions in
465
       in order to get the list of resolutions */
466
    return getGoogleMercatorResolutions(minZoom, maxZoom, dpi)[currentZoom];
55✔
467
}
468

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

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

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

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

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

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

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

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

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

556

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

955
}
956

957

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

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

969

970
export default {
971
    createRegisterHooks,
972
    EXTENT_TO_ZOOM_HOOK,
973
    RESOLUTIONS_HOOK,
974
    RESOLUTION_HOOK,
975
    COMPUTE_BBOX_HOOK,
976
    GET_PIXEL_FROM_COORDINATES_HOOK,
977
    GET_COORDINATES_FROM_PIXEL_HOOK,
978
    DEFAULT_SCREEN_DPI,
979
    ZOOM_TO_EXTENT_HOOK,
980
    CLICK_ON_MAP_HOOK,
981
    EMPTY_MAP,
982
    registerHook,
983
    getHook,
984
    dpi2dpm,
985
    getSphericalMercatorScales,
986
    getSphericalMercatorScale,
987
    getGoogleMercatorScales,
988
    getGoogleMercatorResolutions,
989
    getGoogleMercatorScale,
990
    getResolutionsForScales,
991
    getZoomForExtent,
992
    defaultGetZoomForExtent,
993
    getCenterForExtent,
994
    getResolutions,
995
    getScales,
996
    getBbox,
997
    mapUpdated,
998
    getCurrentResolution,
999
    transformExtent,
1000
    saveMapConfiguration,
1001
    generateNewUUIDs,
1002
    mergeMapConfigs,
1003
    addRootParentGroup,
1004
    isSimpleGeomType,
1005
    getSimpleGeomType,
1006
    getIdFromUri,
1007
    prepareMapObjectToCompare,
1008
    updateObjectFieldKey,
1009
    compareMapChanges,
1010
    clearHooks,
1011
    getResolutionObject,
1012
    calculateExtent,
1013
    reprojectZoom
1014
};
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