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

geosolutions-it / MapStore2 / 12831531306

17 Jan 2025 03:01PM UTC coverage: 77.182% (+0.07%) from 77.115%
12831531306

Pull #10746

github

web-flow
Merge 501dbaeea into 4e4dabc03
Pull Request #10746: Fix #10739 Changing correctly resolutions limits when switching map CRS

30373 of 47156 branches covered (64.41%)

34 of 43 new or added lines in 2 files covered. (79.07%)

126 existing lines in 15 files now uncovered.

37769 of 48935 relevant lines covered (77.18%)

35.14 hits per line

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

90.17
/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
    isString,
11
    trim,
12
    isNumber,
13
    pick,
14
    get,
15
    find,
16
    mapKeys,
17
    mapValues,
18
    keys,
19
    uniq,
20
    uniqWith,
21
    isEqual,
22
    isEmpty,
23
    findIndex,
24
    cloneDeep,
25
    minBy,
26
    omit
27
} from 'lodash';
28
import { get as getProjectionOL, getPointResolution, transform } from 'ol/proj';
29
import { get as getExtent } from 'ol/proj/projections';
30

31
import uuidv1 from 'uuid/v1';
32

33
import { getUnits, normalizeSRS, reproject } from './CoordinatesUtils';
34

35
import { getProjection } from './ProjectionUtils';
36

37
import { set } from './ImmutableUtils';
38
import {
39
    saveLayer,
40
    getGroupNodes,
41
    getNode,
42
    extractSourcesFromLayers,
43
    updateAvailableTileMatrixSetsOptions,
44
    getTileMatrixSetLink,
45
    DEFAULT_GROUP_ID
46
} from './LayersUtils';
47
import assign from 'object-assign';
48

49
export const DEFAULT_MAP_LAYOUT = {left: {sm: 300, md: 500, lg: 600}, right: {md: 548}, bottom: {sm: 30}};
1✔
50

51
export const DEFAULT_SCREEN_DPI = 96;
1✔
52

53
export const METERS_PER_UNIT = {
1✔
54
    'm': 1,
55
    'degrees': 111194.87428468118,
56
    'ft': 0.3048,
57
    'us-ft': 1200 / 3937
58
};
59

60
export const GOOGLE_MERCATOR = {
1✔
61
    RADIUS: 6378137,
62
    TILE_WIDTH: 256,
63
    ZOOM_FACTOR: 2
64
};
65

66
export const EMPTY_MAP = 'EMPTY_MAP';
1✔
67

68
import proj4 from "proj4";
69

70
export const EXTENT_TO_ZOOM_HOOK = 'EXTENT_TO_ZOOM_HOOK';
1✔
71

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

88
let hooks = {};
1✔
89

90

91
export function registerHook(name, hook) {
92
    hooks[name] = hook;
1,113✔
93
}
94

95
export function getHook(name) {
96
    return hooks[name];
974✔
97
}
98

99
export function executeHook(hookName, existCallback, dontExistCallback) {
100
    const hook = getHook(hookName);
18✔
101
    if (hook) {
18✔
102
        return existCallback(hook);
11✔
103
    }
104
    if (dontExistCallback) {
7!
105
        return dontExistCallback();
×
106
    }
107
    return null;
7✔
108
}
109

110
export function clearHooks() {
111
    hooks = {};
47✔
112
}
113

114
/**
115
 * @param dpi {number} dot per inch resolution
116
 * @return {number} dot per meter resolution
117
 */
118
export function dpi2dpm(dpi) {
119
    return dpi * (100 / 2.54);
7,659✔
120
}
121

122
/**
123
 * @param dpi {number} screen resolution in dots per inch.
124
 * @param projection {string} map projection.
125
 * @return {number} dots per map unit.
126
 */
127
export function dpi2dpu(dpi, projection) {
128
    const units = getUnits(projection || "EPSG:3857");
570✔
129
    return METERS_PER_UNIT[units] * dpi2dpm(dpi || DEFAULT_SCREEN_DPI);
570✔
130
}
131

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

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

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

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

195
/**
196
 * @param scales {array} list of scales.
197
 * @param projection {string} map projection.
198
 * @param dpi {number} screen resolution in dots per inch.
199
 * @return {array} a list of resolutions corresponding to the given scales, projection and dpi.
200
 */
201
export function getResolutionsForScales(scales, projection, dpi) {
202
    const dpu = dpi2dpu(dpi, projection);
329✔
203
    const resolutions = scales.map((scale) => {
329✔
204
        return scale / dpu;
7,035✔
205
    });
206
    return resolutions;
329✔
207
}
208

209
export function getGoogleMercatorResolutions(minZoom, maxZoom, dpi) {
210
    return getResolutionsForScales(getGoogleMercatorScales(minZoom, maxZoom, dpi), "EPSG:3857", dpi);
311✔
211
}
212

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

247
    let minZoom = minZ ?? 0;
579✔
248

249
    let maxZoom = maxZ ?? defaultMaxZoom;
579✔
250

251
    let zoomFactor = zoomF ?? defaultZoomFactor;
579✔
252

253
    const projection = proj4.defs(srs);
579✔
254

255
    const extent = ext ?? getProjection(srs)?.extent;
579✔
256

257
    const extentWidth = !extent ? 360 * METERS_PER_UNIT.degrees /
579!
258
        METERS_PER_UNIT[projection.getUnits()] :
259
        extent[2] - extent[0];
260
    const extentHeight = !extent ? 360 * METERS_PER_UNIT.degrees /
579!
261
        METERS_PER_UNIT[projection.getUnits()] :
262
        extent[3] - extent[1];
263

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

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

299
     */
300

301
    const defaultMaxResolution = res;
579✔
302

303
    const defaultMinResolution = defaultMaxResolution / Math.pow(
579✔
304
        defaultZoomFactor, defaultMaxZoom - 0);
305

306
    // user provided maxResolution takes precedence
307
    let maxResolution = maxRes;
579✔
308
    if (maxResolution !== undefined) {
579!
309
        minZoom = 0;
×
310
    } else {
311
        maxResolution = defaultMaxResolution / Math.pow(zoomFactor, minZoom);
579✔
312
    }
313

314
    // user provided minResolution takes precedence
315
    let minResolution = minRes;
579✔
316
    if (minResolution === undefined) {
579!
317
        if (maxZoom !== undefined) {
579!
318
            if (maxRes !== undefined) {
579!
319
                minResolution = maxResolution / Math.pow(zoomFactor, maxZoom);
×
320
            } else {
321
                minResolution = defaultMaxResolution / Math.pow(zoomFactor, maxZoom);
579✔
322
            }
323
        } else {
324
            minResolution = defaultMinResolution;
×
325
        }
326
    }
327

328
    // given discrete zoom levels, minResolution may be different than provided
329
    maxZoom = minZoom + Math.floor(
579✔
330
        Math.log(maxResolution / minResolution) / Math.log(zoomFactor));
331
    return Array.apply(0, Array(maxZoom - minZoom + 1)).map((x, y) => maxResolution / Math.pow(zoomFactor, y));
17,949✔
332
}
333

334
export function getResolutions(projection) {
335
    if (getHook('RESOLUTIONS_HOOK')) {
583✔
336
        return getHook('RESOLUTIONS_HOOK')(projection);
254✔
337
    }
338
    return projection && normalizeSRS(projection) !== "EPSG:3857" ? getResolutionsForProjection(projection) :
329✔
339
        getGoogleMercatorResolutions(0, 21, DEFAULT_SCREEN_DPI);
340
}
341

342
export function getScales(projection, dpi) {
343
    const dpu = dpi2dpu(dpi, projection);
120✔
344
    return getResolutions(projection).map((resolution) => resolution * dpu);
3,018✔
345
}
346

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

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

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

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

372
    return [randomX, randomY];
6✔
373
}
374

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

386
    if (!sourceProjection || !targetProjection) {
4!
NEW
387
        throw new Error(`Invalid CRS: ${sourceCRS} or ${targetCRS}`);
×
388
    }
389

390
    // Get a random point in the extent of the source CRS
391
    const randomPoint = getRandomPointInCRS(sourceCRS);
4✔
392

393
    // Transform the resolution
394
    const transformedResolution = getPointResolution(
4✔
395
        sourceProjection,
396
        sourceResolution,
397
        transform(randomPoint, sourceCRS, targetCRS),
398
        targetProjection.getUnits()
399
    );
400

401
    return { randomPoint, transformedResolution };
4✔
402
}
403

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

418
export function defaultGetZoomForExtent(extent, mapSize, minZoom, maxZoom, dpi, mapResolutions) {
419
    const wExtent = extent[2] - extent[0];
10✔
420
    const hExtent = extent[3] - extent[1];
10✔
421

422
    const xResolution = Math.abs(wExtent / mapSize.width);
10✔
423
    const yResolution = Math.abs(hExtent / mapSize.height);
10✔
424
    const extentResolution = Math.max(xResolution, yResolution);
10✔
425

426
    const resolutions = mapResolutions || getResolutionsForScales(getGoogleMercatorScales(
10✔
427
        minZoom, maxZoom, dpi || DEFAULT_SCREEN_DPI), "EPSG:3857", dpi);
11✔
428

429
    const {zoom} = resolutions.reduce((previous, resolution, index) => {
10✔
430
        const diff = Math.abs(resolution - extentResolution);
228✔
431
        return diff > previous.diff ? previous : {diff: diff, zoom: index};
228✔
432
    }, {diff: Number.POSITIVE_INFINITY, zoom: 0});
433

434
    return Math.max(0, Math.min(zoom, maxZoom));
10✔
435
}
436

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

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

474
/**
475
 * Calculates the center for for the given extent.
476
 *
477
 * @param  {Array} extent [minx, miny, maxx, maxy]
478
 * @param  {String} projection projection of the extent
479
 * @return {object} center object
480
 */
481
export function getCenterForExtent(extent, projection) {
482

483
    var wExtent = extent[2] - extent[0];
10✔
484
    var hExtent = extent[3] - extent[1];
10✔
485

486
    var w = wExtent / 2;
10✔
487
    var h = hExtent / 2;
10✔
488

489
    return {
10✔
490
        x: extent[0] + w,
491
        y: extent[1] + h,
492
        crs: projection
493
    };
494
}
495

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

510
export const isNearlyEqual = function(a, b) {
1✔
511
    if (a === undefined || b === undefined) {
100!
512
        return false;
×
513
    }
514
    return a.toFixed(12) - b.toFixed(12) === 0;
100✔
515
};
516

517
/**
518
 * checks if maps has changed by looking at center or zoom
519
 * @param {object} oldMap map object
520
 * @param {object} newMap map object
521
 */
522
export function mapUpdated(oldMap, newMap) {
523
    if (oldMap && !isEmpty(oldMap) &&
51✔
524
        newMap && !isEmpty(newMap)) {
525
        const centersEqual = isNearlyEqual(newMap?.center?.x, oldMap?.center?.x) &&
46✔
526
                              isNearlyEqual(newMap?.center?.y, oldMap?.center?.y);
527
        return !centersEqual || newMap?.zoom !== oldMap?.zoom;
46✔
528
    }
529
    return false;
5✔
530
}
531

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

548
export const groupSaveFormatted = (node) => {
1✔
549
    return {
42✔
550
        id: node.id,
551
        title: node.title,
552
        description: node.description,
553
        tooltipOptions: node.tooltipOptions,
554
        tooltipPlacement: node.tooltipPlacement,
555
        expanded: node.expanded,
556
        visibility: node.visibility,
557
        nodesMutuallyExclusive: node.nodesMutuallyExclusive
558
    };
559
};
560

561

562
export function saveMapConfiguration(currentMap, currentLayers, currentGroups, currentBackgrounds, textSearchConfig, bookmarkSearchConfig, additionalOptions) {
563

564
    const map = {
32✔
565
        center: currentMap.center,
566
        maxExtent: currentMap.maxExtent,
567
        projection: currentMap.projection,
568
        units: currentMap.units,
569
        mapInfoControl: currentMap.mapInfoControl,
570
        zoom: currentMap.zoom,
571
        mapOptions: currentMap.mapOptions || {},
58✔
572
        ...(currentMap.visualizationMode && { visualizationMode: currentMap.visualizationMode }),
32✔
573
        ...(currentMap.viewerOptions && { viewerOptions: currentMap.viewerOptions })
32✔
574
    };
575

576
    const layers = currentLayers.map((layer) => {
31✔
577
        return saveLayer(layer);
55✔
578
    });
579

580
    const flatGroupId = currentGroups.reduce((a, b) => {
31✔
581
        const flatGroups = a.concat(getGroupNodes(b));
33✔
582
        return flatGroups;
33✔
583
    }, [].concat(currentGroups.map(g => g.id)));
33✔
584

585
    const groups = flatGroupId.map(g => {
31✔
586
        const node = getNode(currentGroups, g);
92✔
587
        return node && node.nodes ? groupSaveFormatted(node) : null;
92✔
588
    }).filter(g => g);
92✔
589

590
    const backgrounds = currentBackgrounds.filter(background => !!background.thumbnail);
31✔
591

592
    // extract sources map
593
    const sources = extractSourcesFromLayers(layers);
31✔
594

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

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

645
    return {
31✔
646
        version: 2,
647
        // layers are defined inside the map object
648
        map: assign({}, map, {layers: formattedLayers, groups, backgrounds, text_search_config: textSearchConfig, bookmark_search_config: bookmarkSearchConfig},
649
            !isEmpty(sources) && {sources} || {}),
62✔
650
        ...additionalOptions
651
    };
652
}
653

654
export const generateNewUUIDs = (mapConfig = {}) => {
1!
655
    const newMapConfig = cloneDeep(mapConfig);
2✔
656

657
    const oldIdToNew = {
2✔
658
        ...get(mapConfig, 'map.layers', []).reduce((result, layer) => ({
4✔
659
            ...result,
660
            [layer.id]: layer.id === 'annotations' ? layer.id : uuidv1()
4!
661
        }), {}),
662
        ...get(mapConfig, 'widgetsConfig.widgets', []).reduce((result, widget) => ({...result, [widget.id]: uuidv1()}), {})
1✔
663
    };
664

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

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

686
    const cfg2Fixed = generateNewUUIDs(cfg2);
2✔
687

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

690
    const layers1 = fixLayers(get(cfg1, 'map.layers', []));
2✔
691
    const layers2 = fixLayers(get(cfg2Fixed, 'map.layers', []));
2✔
692

693
    const annotationsLayer1 = find(layers1, layer => layer.id === 'annotations');
5✔
694
    const annotationsLayer2 = find(layers2, layer => layer.id === 'annotations');
4✔
695

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

713
    const sources1 = get(cfg1, 'map.sources', {});
2✔
714
    const sources2 = get(cfg2Fixed, 'map.sources', {});
2✔
715
    const sources = {...sources1, ...sources2};
2✔
716

717
    const widgetsConfig1 = get(cfg1, 'widgetsConfig', {});
2✔
718
    const widgetsConfig2 = get(cfg2Fixed, 'widgetsConfig', {});
2✔
719

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

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

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

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

817
export const getIdFromUri = (uri, regex = /data\/(\d+)/) => {
1✔
818
    // 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`
819
    const decodedUri = decodeURIComponent(uri);
17✔
820
    const findDataDigit = regex.exec(decodedUri);
17✔
821
    return findDataDigit && findDataDigit.length && findDataDigit.length > 1 ? findDataDigit[1] : null;
17✔
822
};
823

824
/**
825
 * Return parsed number from layout value
826
 * if percentage returns percentage of second argument that should be a number
827
 * eg. 20% of map height parseLayoutValue(20%, map.size.height)
828
 * but if value is stored as number it will return the number
829
 * eg. parseLayoutValue(50, map.size.height) returns 50
830
 * @param value {number|string} number or percentage value string
831
 * @param size {number} only in case of percentage
832
 * @return {number}
833
 */
834
export const parseLayoutValue = (value, size = 0) => {
1✔
835
    if (isString(value) && value.indexOf('%') !== -1) {
35✔
836
        return parseFloat(trim(value)) * size / 100;
6✔
837
    }
838
    return isNumber(value) ? value : 0;
29✔
839
};
840

841
/**
842
 * Method for cleanup map object from uneseccary fields which
843
 * updated map contains and were set on map render
844
 * @param {object} obj
845
 */
846

847
export const prepareMapObjectToCompare = obj => {
1✔
848
    const skippedKeys = ['apiKey', 'time', 'args', 'fixed'];
155✔
849
    const shouldBeSkipped = (key) => skippedKeys.reduce((p, n) => p || key === n, false);
1,008✔
850
    Object.keys(obj).forEach(key => {
155✔
851
        const value = obj[key];
479✔
852
        const type = typeof value;
479✔
853
        if (type === "object" && value !== null && !shouldBeSkipped(key)) {
479✔
854
            prepareMapObjectToCompare(value);
135✔
855
            if (!Object.keys(value).length) {
135✔
856
                delete obj[key];
73✔
857
            }
858
        } else if (type === "undefined" || !value || shouldBeSkipped(key)) {
344✔
859
            delete obj[key];
231✔
860
        }
861
    });
862
};
863

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

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

900
    prepareMapObjectToCompare(filteredMap1);
6✔
901
    prepareMapObjectToCompare(filteredMap2);
6✔
902
    return isEqual(filteredMap1, filteredMap2);
6✔
903
};
904

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

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

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

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

976
}
977

978

979
export const reprojectZoom = (zoom, mapProjection, printProjection) => {
1✔
980
    const multiplier = METERS_PER_UNIT[getUnits(mapProjection)] / METERS_PER_UNIT[getUnits(printProjection)];
36✔
981
    const mapResolution = getResolutions(mapProjection)[Math.round(zoom)] * multiplier;
36✔
982
    const printResolutions = getResolutions(printProjection);
36✔
983

984
    const printResolution = printResolutions.reduce((nearest, current) => {
36✔
985
        return Math.abs(current - mapResolution) < Math.abs(nearest - mapResolution) ? current : nearest;
891✔
986
    }, printResolutions[0]);
987
    return printResolutions.indexOf(printResolution);
36✔
988
};
989

990

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

© 2025 Coveralls, Inc