diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 000000000..a5b051b99
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,22 @@
+{
+ "rules": {},
+ "env": {
+ "es6": true,
+ "browser": true
+ },
+ "parserOptions": {
+ "ecmaVersion": 2018,
+ "sourceType": "module",
+ "ecmaFeatures": {
+ "jsx": true
+ }
+ },
+ "extends": "eslint:recommended",
+ "globals": {
+ "Atomics": "readonly",
+ "SharedArrayBuffer": "readonly"
+ },
+ "plugins": [
+ "react"
+ ]
+}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index aa1c83173..4582c7497 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,23 @@
node_modules/
npm-debug.log
-# On a fresh install with yarn 3.3.0 these extra files were generated.
.yarn
.pnp.cjs
.pnp.loader.mjs
yarn.lock
+
+documentation/
+.cache/
+coverage/
+dist/*
+dist-dev/*
+*.log
+
+# OS generated files
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 000000000..c6d13e221
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,17 @@
+{
+ "cSpell.words": [
+ "bbox",
+ "EPSG",
+ "extwmsparams",
+ "isempty",
+ "isequal",
+ "maxx",
+ "maxy",
+ "miny",
+ "reproject",
+ "reprojecting",
+ "Sublayer",
+ "sublayers",
+ "USERLAYER"
+ ]
+}
\ No newline at end of file
diff --git a/.yarnrc.yml b/.yarnrc.yml
new file mode 100644
index 000000000..3186f3f07
--- /dev/null
+++ b/.yarnrc.yml
@@ -0,0 +1 @@
+nodeLinker: node-modules
diff --git a/README.md b/README.md
index 4e78d9036..fc7b7a4eb 100644
--- a/README.md
+++ b/README.md
@@ -11,3 +11,54 @@ See:
* [this presentation](https://blog.sourcepole.ch/assets/2019/qwc2-foss4g19.pdf) for an overview and architecture of QWC2.
Please report QWC2 issues at [qwc2-demo-app/issues](https://github.com/qgis/qwc2-demo-app/issues).
+
+Development
+-----------
+
+Start by installing the development dependencies:
+
+ yarn install
+
+All the output generated by the commands below is deposited directly
+in the root directory of the repository. To clean up the output:
+
+ yarn clean
+
+### Building
+
+We use webpack to build the QWC2 library. Check out its configuration
+in `webpack.config.js`.
+
+To build the library in production mode:
+
+ yarn build-prod
+
+To build the library in development mode:
+
+ yarn build-dev
+
+The results are deposited in `dist/` for production
+and `dist-dev/` for development.
+
+### Testing
+
+We use Jest to run unit tests. It is configured in `jest.config.js`
+and coverage data is collected in `coverage/`.
+
+The tests can be run with:
+
+ yarn test
+
+To run the tests in watch mode:
+
+ yarn test-watch
+
+### Documentation
+
+We use TypeDoc to document the QWC2 library. It is configured in `typedoc.json`
+and the resulting documentation is deposited in `documentation/`.
+
+JsDoc documentation can be generated with one of the following commands:
+
+ yarn doc
+ yarn doc --watch
diff --git a/__mocks__/axios.js b/__mocks__/axios.js
new file mode 100644
index 000000000..261464e13
--- /dev/null
+++ b/__mocks__/axios.js
@@ -0,0 +1,2 @@
+import mockAxios from 'jest-mock-axios';
+export default mockAxios;
diff --git a/__mocks__/fileMock.js b/__mocks__/fileMock.js
new file mode 100644
index 000000000..602eb23ee
--- /dev/null
+++ b/__mocks__/fileMock.js
@@ -0,0 +1 @@
+export default 'test-file-stub';
diff --git a/__mocks__/styleMock.js b/__mocks__/styleMock.js
new file mode 100644
index 000000000..ff8b4c563
--- /dev/null
+++ b/__mocks__/styleMock.js
@@ -0,0 +1 @@
+export default {};
diff --git a/actions/browser.js b/actions/browser.js
index d4c75c480..3556515fb 100644
--- a/actions/browser.js
+++ b/actions/browser.js
@@ -13,7 +13,13 @@ ReducerIndex.register("browser", browserReducer);
export const CHANGE_BROWSER_PROPERTIES = 'CHANGE_BROWSER_PROPERTIES';
-
+/**
+ * Update browser properties.
+ *
+ * @param {import("qwc2/typings").BrowserData} properties - information
+ * retrieved by {@link ConfigUtils.getBrowserProperties}.
+ * @group Redux Store.Actions
+ */
export function changeBrowserProperties(properties) {
return {
type: CHANGE_BROWSER_PROPERTIES,
diff --git a/actions/display.js b/actions/display.js
index 0818ad5d8..ded4968c9 100644
--- a/actions/display.js
+++ b/actions/display.js
@@ -12,6 +12,10 @@ ReducerIndex.register("display", displayReducer);
export const TOGGLE_FULLSCREEN = 'TOGGLE_FULLSCREEN';
+/**
+ * Issues a request to the browser to
+ * enter full screen mode.
+ */
export function requestFullscreen() {
if (document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen();
@@ -24,6 +28,10 @@ export function requestFullscreen() {
}
}
+/**
+ * Issues a request to the browser to
+ * exit full screen mode.
+ */
export function endFullscreen() {
if (document.exitFullscreen) {
document.exitFullscreen();
@@ -36,6 +44,14 @@ export function endFullscreen() {
}
}
+
+/**
+ * Change full screen mode.
+ *
+ * @param {boolean} fullscreen - true to enter full
+ * screen mode, false to exit it.
+ * @group Redux Store.Actions
+ */
export function toggleFullscreen(fullscreen) {
if (fullscreen) {
requestFullscreen();
diff --git a/actions/editing.js b/actions/editing.js
index 6f115af5b..5b12d5561 100644
--- a/actions/editing.js
+++ b/actions/editing.js
@@ -13,6 +13,18 @@ ReducerIndex.register("editing", editingReducer);
export const SET_EDIT_CONTEXT = 'SET_EDIT_CONTEXT';
export const CLEAR_EDIT_CONTEXT = 'CLEAR_EDIT_CONTEXT';
+
+/**
+ * Set current edit context.
+ *
+ * The `contextId` is set in the store as the `currentContext` and the
+ * `editContext` is saved into the context identified
+ * by the `contextId` in the store's `contexts` members.
+ *
+ * @param {string} contextId - the ID of this context.
+ * @param {object} editContext - the context.
+ * @group Redux Store.Actions
+ */
export function setEditContext(contextId, editContext) {
return {
type: SET_EDIT_CONTEXT,
@@ -21,6 +33,18 @@ export function setEditContext(contextId, editContext) {
};
}
+
+/**
+ * Clear current edit context.
+ *
+ * The context identified by the `contextId` is removed from the store's
+ * `contexts` members and the `newActiveContextId` is set as the new
+ * `currentContext` (but only if `contextId` is currently `currentContext`).
+ *
+ * @param {string} contextId - the ID of this context.
+ * @param {object} newActiveContextId - the context.
+ * @group Redux Store.Actions
+ */
export function clearEditContext(contextId, newActiveContextId = null) {
return {
type: CLEAR_EDIT_CONTEXT,
@@ -29,12 +53,55 @@ export function clearEditContext(contextId, newActiveContextId = null) {
};
}
+
+// This is where we keep the feature template factories
+// for each dataset. It maps dataset names to functions
+// that transform a feature in `getFeatureTemplate()`.
const FeatureTemplateFactories = {};
+/**
+ * @callback FeatureTemplate
+ *
+ * The factory function takes a feature as input and
+ * returns a feature as output. The feature is then
+ * used as the template for the feature form.
+ *
+ * @param {object} feature - the input feature
+ * @returns {object} the output feature
+ */
+
+
+/**
+ * Set the feature template factory for a dataset.
+ *
+ * The factory function takes a feature as input and
+ * returns a feature as output. The feature is then
+ * used as the template for the feature form.
+ *
+ * @param {string} dataset - the dataset name.
+ * @param {FeatureTemplate} factory - the factory function.
+ */
export function setFeatureTemplateFactory(dataset, factory) {
FeatureTemplateFactories[dataset] = factory;
}
+/**
+ * Compute a defaukt value for a field.
+ *
+ * The default value is evaluated as follows:
+ * - if it starts with `expr:` then the rest of the
+ * string is evaluated as follows:
+ * - `expr:now()` returns the current date/time
+ * - `expr:true` returns `true`
+ * - `expr:false` returns `false`
+ * - otherwise it returns an empty string.
+ * - otherwise the string is used as is.
+ *
+ * @param {object} field - the field to evaluate
+ *
+ * @returns {any} the default value
+ * @private
+ */
function evaluateDefaultValue(field) {
if (field.defaultValue.startsWith("expr:")) {
const expr = field.defaultValue.slice(5);
@@ -55,6 +122,24 @@ function evaluateDefaultValue(field) {
}
}
+
+/**
+ * Get a feature template for a dataset.
+ *
+ * The feature template is computed as follows:
+ * - if the dataset has a feature template factory
+ * registered, then the factory is called with the
+ * feature as input and the result is then used in next step;
+ * - each field of the feature is checked for a
+ * `defaultValue` property and if it exists, the
+ * default value is evaluated and assigned to the
+ * feature's `properties` attribute under the `id` of the field.
+ *
+ * @param {object} editConfig - the edit configuration
+ * @param {object} feature - the input feature
+ *
+ * @returns {object} the output feature
+ */
export function getFeatureTemplate(editConfig, feature) {
if (editConfig.editDataset in FeatureTemplateFactories) {
feature = FeatureTemplateFactories[editConfig.editDataset](feature);
diff --git a/actions/identify.js b/actions/identify.js
index e322f5f94..2bc13e4b0 100644
--- a/actions/identify.js
+++ b/actions/identify.js
@@ -14,10 +14,31 @@ import ConfigUtils from '../utils/ConfigUtils';
export const SET_IDENTIFY_TOOL = 'SET_IDENTIFY_TOOL';
+
+/**
+ * Sets the current identify tool.
+ *
+ * The function will use the `identifyTool` property from the
+ * `theme` argument or from current theme if `theme` is not specified.
+ *
+ * If the theme does not specify an `identifyTool` property,
+ * the default `Identify` is used.
+ *
+ * If the `enabled` argument is `false`, the current tool is set to `null`.
+ *
+ * @param {boolean} enabled - whether identify tool is enabled.
+ * @param {string} theme - optional theme name to use for identify tool.
+ *
+ * @group Redux Store.Actions
+ */
export function setIdentifyEnabled(enabled, theme = null) {
return (dispatch, getState) => {
- let identifyTool = ConfigUtils.getConfigProp("identifyTool", theme || getState().theme.current);
- identifyTool = identifyTool !== undefined ? identifyTool : "Identify";
+ let identifyTool = ConfigUtils.getConfigProp(
+ "identifyTool", theme || getState().theme.current
+ );
+ identifyTool = identifyTool !== undefined
+ ? identifyTool
+ : "Identify";
dispatch({
type: SET_IDENTIFY_TOOL,
tool: enabled ? identifyTool : null
diff --git a/actions/index.js b/actions/index.js
new file mode 100644
index 000000000..dedfd9d6b
--- /dev/null
+++ b/actions/index.js
@@ -0,0 +1,73 @@
+/**
+ * Actions for changing the global store.
+ *
+ * @namespace Redux Store.Actions
+ */
+export {
+ CHANGE_BROWSER_PROPERTIES,
+ changeBrowserProperties
+} from './browser';
+
+export {
+ TOGGLE_FULLSCREEN,
+ requestFullscreen,
+ endFullscreen,
+ toggleFullscreen,
+} from './display';
+
+export {
+ SET_EDIT_CONTEXT,
+ CLEAR_EDIT_CONTEXT,
+ setEditContext,
+ clearEditContext,
+ setFeatureTemplateFactory,
+ getFeatureTemplate
+} from './editing';
+
+export {
+ SET_IDENTIFY_TOOL,
+ setIdentifyEnabled
+} from './identify';
+
+export {
+ SET_ACTIVE_LAYERINFO,
+ setActiveLayerInfo
+} from './layerinfo';
+
+export {
+ SET_LAYER_LOADING,
+ ADD_LAYER,
+ ADD_LAYER_SEPARATOR,
+ REMOVE_LAYER,
+ REORDER_LAYER,
+ ADD_LAYER_FEATURES,
+ ADD_THEME_SUBLAYER,
+ REMOVE_LAYER_FEATURES,
+ CLEAR_LAYER,
+ CHANGE_LAYER_PROPERTY,
+ SET_LAYER_DIMENSIONS,
+ REFRESH_LAYER,
+ REMOVE_ALL_LAYERS,
+ REPLACE_PLACEHOLDER_LAYER,
+ SET_SWIPE,
+ SET_LAYERS,
+ LayerRole,
+ addLayer,
+ addLayerSeparator,
+ removeLayer,
+ reorderLayer,
+ addLayerFeatures,
+ removeLayerFeatures,
+ clearLayer,
+ addThemeSublayer,
+ changeLayerProperty,
+ setLayerDimensions,
+ setLayerLoading,
+ addMarker,
+ removeMarker,
+ refreshLayer,
+ removeAllLayers,
+ replacePlaceholderLayer,
+ setSwipe,
+ setLayers
+} from './layers';
diff --git a/actions/layerinfo.js b/actions/layerinfo.js
index a9a2d1873..95de61973 100644
--- a/actions/layerinfo.js
+++ b/actions/layerinfo.js
@@ -12,6 +12,18 @@ ReducerIndex.register("layerinfo", layerinfoReducer);
export const SET_ACTIVE_LAYERINFO = 'SET_ACTIVE_LAYERINFO';
+
+/**
+ * Sets the current layer and sub-layer in the store.
+ *
+ * It changes the `layer` and `sublayer` properties of the store.
+ *
+ * @param {string} layer - the layer to mark as being the active one.
+ * @param {string} sublayer - a sublayer of the active layer
+ * to mark as the active one.
+ *
+ * @group Redux Store.Actions
+ */
export function setActiveLayerInfo(layer, sublayer) {
return {
type: SET_ACTIVE_LAYERINFO,
diff --git a/actions/layers.js b/actions/layers.js
index 9b6bc9eca..d11a57417 100644
--- a/actions/layers.js
+++ b/actions/layers.js
@@ -29,16 +29,54 @@ export const REPLACE_PLACEHOLDER_LAYER = 'REPLACE_PLACEHOLDER_LAYER';
export const SET_SWIPE = 'SET_SWIPE';
export const SET_LAYERS = 'SET_LAYERS';
+/**
+ * @typedef {import('qwc2/typings').Layer} Layer
+ */
+/**
+ * Layer role constants.
+ *
+ * The order is important (e.g. the types below user
+ * layer are hidden by default).
+ * @enum
+ */
export const LayerRole = {
+ /**
+ * Background layer.
+ */
BACKGROUND: 1,
+
+ /**
+ * The layer belongs to a theme.
+ */
THEME: 2,
+
+ /**
+ * The user provided this layer manually.
+ */
USERLAYER: 3,
+
+ /**
+ * Selection layer.
+ */
SELECTION: 4,
+
+ /**
+ * Marker layer.
+ */
MARKER: 5
};
+/**
+ * Add a layer to the map.
+ *
+ * @param {Layer} layer - The layer to add.
+ * @param {number|null} pos - The position to add the layer at.
+ * @param {string|null} beforename - The name of the layer to
+ * insert the new layer before.
+ * @group Redux Store.Actions
+ */
export function addLayer(layer, pos = null, beforename = null) {
return {
type: ADD_LAYER,
@@ -48,6 +86,17 @@ export function addLayer(layer, pos = null, beforename = null) {
};
}
+
+/**
+ * Add a layer separator to the map.
+ *
+ * @param {string} title - The title of the separator.
+ * @param {string|null} afterLayerId - The id of the layer to
+ * insert the separator after.
+ * @param {string[]} afterSublayerPath - The sublayer path of the layer to
+ * insert the separator after.
+ * @group Redux Store.Actions
+ */
export function addLayerSeparator(title, afterLayerId, afterSublayerPath) {
return {
type: ADD_LAYER_SEPARATOR,
@@ -57,6 +106,13 @@ export function addLayerSeparator(title, afterLayerId, afterSublayerPath) {
};
}
+/**
+ * Remove a layer from the map.
+ *
+ * @param {string} layerId - The id of the layer to remove.
+* @param {string[]} sublayerpath - The sublayer path of the layer to remove.
+ * @group Redux Store.Actions
+ */
export function removeLayer(layerId, sublayerpath = []) {
return {
type: REMOVE_LAYER,
@@ -65,6 +121,15 @@ export function removeLayer(layerId, sublayerpath = []) {
};
}
+
+/**
+ * Change the position of a layer in the map.
+ *
+ * @param {Layer} layer - The layer to reorder.
+ * @param {string[]} sublayerpath - The sublayer path of the layer to reorder.
+ * @param {number} direction - The direction to move the layer in.
+ * @group Redux Store.Actions
+ */
export function reorderLayer(layer, sublayerpath, direction) {
return (dispatch, getState) => {
dispatch({
@@ -72,11 +137,23 @@ export function reorderLayer(layer, sublayerpath, direction) {
layer,
sublayerpath,
direction,
- preventSplittingGroups: ConfigUtils.getConfigProp("preventSplittingGroupsWhenReordering", getState().theme.current)
+ preventSplittingGroups: ConfigUtils.getConfigProp(
+ "preventSplittingGroupsWhenReordering",
+ getState().theme.current
+ )
});
};
}
+
+/**
+ * Add features to a layer.
+ *
+ * @param {Layer} layer - The layer to add the features to.
+ * @param {object[]} features - The features to add.
+ * @param {boolean} clear - Whether to clear the layer first.
+ * @group Redux Store.Actions
+ */
export function addLayerFeatures(layer, features, clear = false) {
return {
type: ADD_LAYER_FEATURES,
@@ -86,7 +163,20 @@ export function addLayerFeatures(layer, features, clear = false) {
};
}
-export function removeLayerFeatures(layerId, featureIds, keepEmptyLayer = false) {
+
+/**
+ * Remove features from a layer.
+ *
+ * @param {string} layerId - The id of the layer to remove
+ * the features from.
+ * @param {string[]} featureIds - The ids of the features to remove.
+ * @param {boolean} keepEmptyLayer - Whether to keep the
+ * layer if it becomes empty.
+ * @group Redux Store.Actions
+ */
+export function removeLayerFeatures(
+ layerId, featureIds, keepEmptyLayer = false
+) {
return {
type: REMOVE_LAYER_FEATURES,
layerId,
@@ -95,6 +185,13 @@ export function removeLayerFeatures(layerId, featureIds, keepEmptyLayer = false)
};
}
+
+/**
+ * Remove all features from a layer and clear its bounding box.
+ *
+ * @param {string} layerId - The id of the layer to clear.
+ * @group Redux Store.Actions
+ */
export function clearLayer(layerId) {
return {
type: CLEAR_LAYER,
@@ -102,6 +199,13 @@ export function clearLayer(layerId) {
};
}
+
+/**
+ * Add a sublayer to a theme layer.
+ *
+ * @param {Layer} layer - The layer to add the sublayer to.
+ * @group Redux Store.Actions
+ */
export function addThemeSublayer(layer) {
return {
type: ADD_THEME_SUBLAYER,
@@ -109,8 +213,20 @@ export function addThemeSublayer(layer) {
};
}
-// recurseDirection: null (don't recurse), 'parents', 'children', 'both'
-export function changeLayerProperty(layerUuid, property, newvalue, sublayerpath = [], recurseDirection = null) {
+/**
+ * Change a property of a layer.
+ *
+ * @param {string} layerUuid - The uuid of the layer to change.
+ * @param {string} property - The property to change.
+ * @param {*} newvalue - The new value of the property.
+ * @param {string[]} sublayerpath - The sublayer path of the layer to change.
+ * @param {"parents"|"children"|"both"|null} recurseDirection - The
+ * direction to recurse in (null means don't recurse).
+ * @group Redux Store.Actions
+ */
+export function changeLayerProperty(
+ layerUuid, property, newvalue, sublayerpath = [], recurseDirection = null
+) {
return {
type: CHANGE_LAYER_PROPERTY,
layerUuid,
@@ -121,6 +237,15 @@ export function changeLayerProperty(layerUuid, property, newvalue, sublayerpath
};
}
+
+/**
+ * Set the dimensions of a layer.
+ *
+ * @param {string} layerId - The id of the layer to change.
+ * @param {{width: number, height: number}} dimensions - The new
+ * dimensions of the layer.
+ * @group Redux Store.Actions
+ */
export function setLayerDimensions(layerId, dimensions) {
return {
type: SET_LAYER_DIMENSIONS,
@@ -129,6 +254,14 @@ export function setLayerDimensions(layerId, dimensions) {
};
}
+
+/**
+ * Set the loading state of a layer.
+ *
+ * @param {string} layerId - The id of the layer to change.
+ * @param {boolean} loading - The new loading state of the layer.
+ * @group Redux Store.Actions
+ */
export function setLayerLoading(layerId, loading) {
return {
type: SET_LAYER_LOADING,
@@ -137,7 +270,20 @@ export function setLayerLoading(layerId, loading) {
};
}
-export function addMarker(id, point, label = '', crs = 'EPSG:4326', zIndex = null) {
+
+/**
+ * Add a marker layer and adds a feature to it.
+ *
+ * @param {string} id - The id of the layer.
+ * @param {string} point - The position.
+ * @param {string} label - The label of the layer.
+ * @param {string} crs - The CRS of the layer.
+ * @param {number} zIndex - The z-index of the layer.
+ * @group Redux Store.Actions
+ */
+export function addMarker(
+ id, point, label = '', crs = 'EPSG:4326', zIndex = null
+) {
const layer = {
id: "markers",
role: LayerRole.MARKER,
@@ -156,10 +302,27 @@ export function addMarker(id, point, label = '', crs = 'EPSG:4326', zIndex = nul
return addLayerFeatures(layer, [feature]);
}
+
+/**
+ * Removes a marker feature.
+ *
+ * @param {string} id - The id of the feature to remove.
+ *
+ * @group Redux Store.Actions
+ */
export function removeMarker(id) {
return removeLayerFeatures("markers", [id]);
}
+
+/**
+ * Sets the rev(ision) to current time for all layers that pass the filter.
+ *
+ * @param {(layer: any) => boolean} filter - The filter
+ * callback that decides which layers to refresh.
+ *
+ * @group Redux Store.Actions
+ */
export function refreshLayer(filter) {
return {
type: REFRESH_LAYER,
@@ -167,12 +330,27 @@ export function refreshLayer(filter) {
};
}
+
+/**
+ * Removes all layers from the map.
+ *
+ * @group Redux Store.Actions
+ */
export function removeAllLayers() {
return {
type: REMOVE_ALL_LAYERS
};
}
+
+/**
+ * Replaces a placeholder layer with a real layer.
+ *
+ * @param {string} id - The id of the placeholder layer.
+ * @param {Layer} layer - The layer to replace the placeholder with.
+ *
+ * @group Redux Store.Actions
+ */
export function replacePlaceholderLayer(id, layer) {
return {
type: REPLACE_PLACEHOLDER_LAYER,
@@ -181,6 +359,14 @@ export function replacePlaceholderLayer(id, layer) {
};
}
+
+/**
+ * Set the swipe state.
+ *
+ * @param {object|null} swipe - The new swipe state.
+ *
+ * @group Redux Store.Actions
+ */
export function setSwipe(swipe) {
return {
type: SET_SWIPE,
@@ -188,6 +374,14 @@ export function setSwipe(swipe) {
};
}
+
+/**
+ * Set the flat list of layers.
+ *
+ * @param {Layer[]} layers - The new layers.
+ *
+ * @group Redux Store.Actions
+ */
export function setLayers(layers) {
return {
type: SET_LAYERS,
diff --git a/actions/localConfig.js b/actions/localConfig.js
index 7159c262b..2ebe6215d 100644
--- a/actions/localConfig.js
+++ b/actions/localConfig.js
@@ -14,6 +14,13 @@ export const LOCAL_CONFIG_LOADED = 'LOCAL_CONFIG_LOADED';
export const SET_STARTUP_PARAMETERS = 'SET_STARTUP_PARAMETERS';
export const SET_COLOR_SCHEME = 'SET_COLOR_SCHEME';
+
+/**
+ * Load local config.
+ *
+ * @param {object} config - The local config.
+ * @group Redux Store.Actions
+ */
export function localConfigLoaded(config) {
return {
type: LOCAL_CONFIG_LOADED,
@@ -21,6 +28,14 @@ export function localConfigLoaded(config) {
};
}
+
+/**
+ * Change startup parameters.
+ *
+ * @param {object} params - The new parameters.
+ *
+ * @group Redux Store.Actions
+ */
export function setStartupParameters(params) {
return {
type: SET_STARTUP_PARAMETERS,
@@ -28,6 +43,16 @@ export function setStartupParameters(params) {
};
}
+
+/**
+ * Change the color scheme.
+ *
+ * @param {string} colorScheme - The new color scheme.
+ * @param {boolean} storeInLocalStorage - Whether to store the color
+ * scheme in local storage.
+ *
+ * @group Redux Store.Actions
+ */
export function setColorScheme(colorScheme, storeInLocalStorage = false) {
return {
type: SET_COLOR_SCHEME,
diff --git a/actions/locale.js b/actions/locale.js
index cbfac582b..1685bf055 100644
--- a/actions/locale.js
+++ b/actions/locale.js
@@ -11,26 +11,59 @@ import ReducerIndex from '../reducers/index';
import localeReducer from '../reducers/locale';
ReducerIndex.register("locale", localeReducer);
+import deepmerge from 'deepmerge';
import axios from 'axios';
-import {getLanguageCountries} from 'country-language';
+import { getLanguageCountries } from 'country-language';
import ConfigUtils from '../utils/ConfigUtils';
-import {UrlParams} from '../utils/PermaLinkUtils';
-import deepmerge from 'deepmerge';
+import { UrlParams } from '../utils/PermaLinkUtils';
export const CHANGE_LOCALE = 'CHANGE_LOCALE';
+
+/**
+ * Retrieve the language from the URL or the browser and
+ * save the corresponding locale data in the redux store.
+ *
+ * The order of precedence is:
+ * - `defaultLang` parameter
+ * - `lang` URL parameter
+ * - `navigator.language` or `navigator.browserLanguage`
+ * - `en-US`.
+ *
+ * The language code is then used as it is to download the locale data.
+ * If it was not found the language code is truncated to the first
+ * two characters (so `en-GB` becomes `en`) and used to
+ * download the locale data.
+ *
+ * If the locale data is not found for the language code, the
+ * default language data is used instead.
+ *
+ * @param {object} defaultLangData - The default language data.
+ * @param {string} defaultLang - The default language.
+ *
+ * @group Redux Store.Actions
+ */
export function loadLocale(defaultLangData, defaultLang = "") {
return dispatch => {
- let lang = defaultLang || UrlParams.getParam("lang") || (navigator ? (navigator.language || navigator.browserLanguage) : "en-US");
+ let lang = defaultLang || UrlParams.getParam("lang") || (
+ navigator
+ ? (navigator.language || navigator.browserLanguage)
+ : "en-US"
+ );
const config = {
- headers: {'Content-Type': 'application/json'},
+ headers: { 'Content-Type': 'application/json' },
data: {}
};
+
const translationsPath = ConfigUtils.getTranslationsPath();
- axios.get(translationsPath + '/' + lang + '.json', config).then(response => {
+ axios.get(
+ translationsPath + '/' + lang + '.json', config
+ ).then(response => {
const messages = response.data.messages;
if (ConfigUtils.getConfigProp("loadTranslationOverrides")) {
- axios.get(translationsPath + '/' + lang + '_overrides.json', config).then(response2 => {
+ axios.get(
+ translationsPath + '/' + lang + '_overrides.json', config
+ ).then(response2 => {
const overrideMessages = response2.data.messages;
dispatch({
type: CHANGE_LOCALE,
@@ -54,14 +87,23 @@ export function loadLocale(defaultLangData, defaultLang = "") {
}).catch((e) => {
const langCode = lang.slice(0, 2).toLowerCase();
const countries = getLanguageCountries(langCode);
- const country = countries.find(entry => entry.code_2 === langCode.toUpperCase()) ? langCode.toUpperCase() : ((countries[0] || {}).code_2 || "");
+ const country = countries.find(
+ entry => entry.code_2 === langCode.toUpperCase()
+ ) ? langCode.toUpperCase() : ((countries[0] || {}).code_2 || "");
// eslint-disable-next-line
- console.warn("Failed to load locale for " + lang + " (" + e + "), trying " + langCode + "-" + country);
+ console.warn(
+ "Failed to load locale for " + lang +
+ " (" + e + "), trying " + langCode + "-" + country
+ );
lang = langCode + "-" + country;
- axios.get(translationsPath + '/' + lang + '.json', config).then(response => {
+ axios.get(
+ translationsPath + '/' + lang + '.json', config
+ ).then(response => {
const messages = response.data.messages;
if (ConfigUtils.getConfigProp("loadTranslationOverrides")) {
- axios.get(translationsPath + '/' + lang + '_overrides.json', config).then(response2 => {
+ axios.get(
+ translationsPath + '/' + lang + '_overrides.json', config
+ ).then(response2 => {
const overrideMessages = response2.data.messages;
dispatch({
type: CHANGE_LOCALE,
@@ -84,7 +126,10 @@ export function loadLocale(defaultLangData, defaultLang = "") {
}
}).catch((e2) => {
// eslint-disable-next-line
- console.warn("Failed to load locale for " + lang + " (" + e2 + "), defaulting to " + defaultLangData.locale);
+ console.warn(
+ "Failed to load locale for " + lang +
+ " (" + e2 + "), defaulting to " + defaultLangData.locale
+ );
dispatch({
type: CHANGE_LOCALE,
locale: defaultLangData.locale,
diff --git a/actions/locate.js b/actions/locate.js
index 3c16d1d16..122156ae4 100644
--- a/actions/locate.js
+++ b/actions/locate.js
@@ -15,13 +15,28 @@ export const CHANGE_LOCATE_STATE = 'CHANGE_LOCATE_STATE';
export const CHANGE_LOCATE_POSITION = 'CHANGE_LOCATE_POSITION';
export const LOCATE_ERROR = 'LOCATE_ERROR';
+
+/**
+ * Change locate state.
+ *
+ * @param {string} state - The new state.
+ *
+ * @memberof Redux Store.Actions
+ */
export function changeLocateState(state) {
return {
type: CHANGE_LOCATE_STATE,
- state: state
+ state
};
}
+/**
+ * Change locate position.
+ *
+ * @param {object} position - The new position.
+ *
+ * @group Redux Store.Actions
+ */
export function changeLocatePosition(position) {
return {
type: CHANGE_LOCATE_POSITION,
diff --git a/actions/logging.js b/actions/logging.js
index bada7d5a9..b22054c2c 100644
--- a/actions/logging.js
+++ b/actions/logging.js
@@ -8,6 +8,17 @@
export const LOG_ACTION = 'LOG_ACTION';
+
+/**
+ * Log an action.
+ *
+ * @param {string} actionType - The type of the action.
+ * @param {object} data - The data of the action.
+ *
+ * TODO: This is not used anywhere.
+ *
+ * @memberof Redux Store.Actions
+ */
export function logAction(actionType, data) {
return {
type: LOG_ACTION,
diff --git a/actions/map.js b/actions/map.js
index 7c36cb962..3a2454f4d 100644
--- a/actions/map.js
+++ b/actions/map.js
@@ -36,12 +36,13 @@ export function changeMapView(center, zoom, bbox, size, mapStateSource, projecti
}
/**
- * @param crs {string} The map projection
- * @param scales {Array} List of map scales
- * @param view {Object} The map view, as follows:
- * {center: [x, y], zoom: ..., crs: ...}
- * or
- * {bounds: [xmin, ymin, xmax, ymax], crs: ...}
+ * Configure the map.
+ *
+ * @param {string} crs - The map projection
+ * @param {Array} scales - List of map scales
+ * @param {Object} view - The map view, as follows:
+ * {center: [x, y], zoom: ..., crs: ...} or
+ * {bounds: [xmin, ymin, xmax, ymax], crs: ...}
*/
export function configureMap(crs, scales, view) {
return {
diff --git a/actions/search.js b/actions/search.js
index caedeba28..5d9cdb29d 100644
--- a/actions/search.js
+++ b/actions/search.js
@@ -10,7 +10,7 @@ import ReducerIndex from '../reducers/index';
import searchReducer from '../reducers/search';
ReducerIndex.register("search", searchReducer);
-import {v1 as uuidv1} from 'uuid';
+import { v1 as uuidv1 } from 'uuid';
import axios from 'axios';
export const SEARCH_CHANGE = 'SEARCH_CHANGE';
@@ -51,7 +51,7 @@ export function startSearch(text, searchParams, providers, startup = false) {
startup: startup
});
Object.keys(providers).map(provider => {
- providers[provider].onSearch(text, {...searchParams, cfgParams: providers[provider].params}, (response) => {
+ providers[provider].onSearch(text, { ...searchParams, cfgParams: providers[provider].params }, (response) => {
dispatch({
type: SEARCH_ADD_RESULTS,
reqId: reqId,
diff --git a/actions/task.js b/actions/task.js
index b50975a0f..5ebe9ee58 100644
--- a/actions/task.js
+++ b/actions/task.js
@@ -10,11 +10,11 @@ import ReducerIndex from '../reducers/index';
import taskReducer from '../reducers/task';
ReducerIndex.register("task", taskReducer);
-import {setIdentifyEnabled} from './identify';
+import { setIdentifyEnabled } from './identify';
import ConfigUtils from '../utils/ConfigUtils';
import CoordinatesUtils from '../utils/CoordinatesUtils';
import MapUtils from '../utils/MapUtils';
-import {UrlParams} from '../utils/PermaLinkUtils';
+import { UrlParams } from '../utils/PermaLinkUtils';
export const SET_CURRENT_TASK = 'SET_CURRENT_TASK';
export const SET_CURRENT_TASK_BLOCKED = 'SET_CURRENT_TASK_BLOCKED';
diff --git a/actions/theme.js b/actions/theme.js
index c89e119cf..2e4215af2 100644
--- a/actions/theme.js
+++ b/actions/theme.js
@@ -11,23 +11,31 @@ import themeReducer from '../reducers/theme';
ReducerIndex.register("theme", themeReducer);
import isEmpty from 'lodash.isempty';
-import {setIdentifyEnabled} from '../actions/identify';
+import { setIdentifyEnabled } from '../actions/identify';
import ConfigUtils from '../utils/ConfigUtils';
import CoordinatesUtils from '../utils/CoordinatesUtils';
import MapUtils from '../utils/MapUtils';
+import LocaleUtils from '../utils/LocaleUtils';
import LayerUtils from '../utils/LayerUtils';
-import {UrlParams} from '../utils/PermaLinkUtils';
+import { UrlParams } from '../utils/PermaLinkUtils';
import ServiceLayerUtils from '../utils/ServiceLayerUtils';
import ThemeUtils from '../utils/ThemeUtils';
-import {LayerRole, addLayer, removeLayer, removeAllLayers, replacePlaceholderLayer, setSwipe} from './layers';
-import {configureMap} from './map';
+import {
+ LayerRole, addLayer, removeLayer,
+ removeAllLayers, replacePlaceholderLayer, setSwipe
+} from './layers';
+import { configureMap } from './map';
+import {showNotification, NotificationType} from './windows';
export const THEMES_LOADED = 'THEMES_LOADED';
export const SET_THEME_LAYERS_LIST = 'SET_THEME_LAYERS_LIST';
export const SET_CURRENT_THEME = 'SET_CURRENT_THEME';
export const SWITCHING_THEME = 'SWITCHING_THEME';
-
+/**
+ * Sets the list of themes in redux store.
+ * @param {Theme[]} themes - The themes to set
+ */
export function themesLoaded(themes) {
return {
type: THEMES_LOADED,
@@ -35,6 +43,7 @@ export function themesLoaded(themes) {
};
}
+
export function setThemeLayersList(theme) {
return {
type: SET_THEME_LAYERS_LIST,
@@ -60,16 +69,16 @@ export function finishThemeSetup(dispatch, theme, themes, layerConfigs, insertPo
}
}
if (isEmpty(layers)) {
- layers = [{...themeLayer, sublayers: []}];
+ layers = [{ ...themeLayer, sublayers: [] }];
}
}
// Add background layers for theme
- for (const bgLayer of ThemeUtils.createThemeBackgroundLayers(theme, themes, visibleBgLayer, externalLayers)) {
+ for (const bgLayer of ThemeUtils.createThemeBackgroundLayers(theme, themes, visibleBgLayer, externalLayers, dispatch)) {
dispatch(addLayer(bgLayer));
}
if (visibleBgLayer === "") {
- UrlParams.updateParams({bl: ""});
+ UrlParams.updateParams({ bl: "" });
}
for (const layer of layers.reverse()) {
@@ -113,7 +122,7 @@ export function setCurrentTheme(theme, themes, preserve = true, initialView = nu
// Get current background layer if it needs to be preserved
if (preserve && visibleBgLayer === null && ConfigUtils.getConfigProp("preserveBackgroundOnThemeSwitch", theme) === true) {
- const curBgLayer = getState().layers.flat.find(layer => layer.role === LayerRole.BACKGROUND && layer.visibility === true);
+ const curBgLayer = getState().layers.flat.find(layer => layer.role === LayerRole.BACKGROUND && layer.visibility !== false);
visibleBgLayer = curBgLayer ? curBgLayer.name : null;
}
@@ -160,10 +169,10 @@ export function setCurrentTheme(theme, themes, preserve = true, initialView = nu
const b2 = getState().map.bbox.bounds;
if (b2[0] >= b1[0] && b2[1] >= b1[1] && b2[2] <= b1[2] && b2[3] <= b1[3]) {
// theme bbox (b1) includes current bbox (b2)
- initialView = {bounds: getState().map.bbox.bounds, crs: getState().map.projection};
+ initialView = { bounds: getState().map.bbox.bounds, crs: getState().map.projection };
}
} else if (ConfigUtils.getConfigProp("preserveExtentOnThemeSwitch", theme) === "force") {
- initialView = {bounds: getState().map.bbox.bounds, crs: getState().map.projection};
+ initialView = { bounds: getState().map.bbox.bounds, crs: getState().map.projection };
}
}
@@ -181,7 +190,7 @@ export function setCurrentTheme(theme, themes, preserve = true, initialView = nu
const layerNames = LayerUtils.getSublayerNames(theme);
missingThemeLayers = layerConfigs.reduce((missing, layerConfig) => {
if (layerConfig.type === 'theme' && !layerNames.includes(layerConfig.name)) {
- return {...missing, [layerConfig.name]: layerConfig};
+ return { ...missing, [layerConfig.name]: layerConfig };
} else {
return missing;
}
@@ -189,19 +198,26 @@ export function setCurrentTheme(theme, themes, preserve = true, initialView = nu
}
if (themeLayerRestorer && !isEmpty(missingThemeLayers)) {
themeLayerRestorer(Object.keys(missingThemeLayers), theme, (newLayers, newLayerNames) => {
- const newTheme = LayerUtils.mergeSubLayers(theme, {sublayers: newLayers});
+ const newTheme = LayerUtils.mergeSubLayers(theme, { sublayers: newLayers });
if (newLayerNames) {
layerConfigs = layerConfigs.reduce((res, layerConfig) => {
if (layerConfig.name in newLayerNames) {
- return [...res, ...newLayerNames[layerConfig.name].map(name => ({...layerConfig, name}))];
+ return [...res, ...newLayerNames[layerConfig.name].map(name => ({ ...layerConfig, name }))];
} else {
return [...res, layerConfig];
}
}, []);
}
+ const diff = Object.keys(missingThemeLayers).filter(entry => !(entry in newLayerNames));
+ if (!isEmpty(diff)) {
+ dispatch(showNotification("missinglayers", LocaleUtils.tr("app.missinglayers", diff.join(", ")), NotificationType.WARN, true));
+ }
finishThemeSetup(dispatch, newTheme, themes, layerConfigs, insertPos, permalinkLayers, externalLayerRestorer, visibleBgLayer);
});
} else {
+ if (!isEmpty(missingThemeLayers)) {
+ dispatch(showNotification("missinglayer", LocaleUtils.tr("app.missinglayers", Object.keys(missingThemeLayers).join(", ")), NotificationType.WARN, true));
+ }
finishThemeSetup(dispatch, theme, themes, layerConfigs, insertPos, permalinkLayers, externalLayerRestorer, visibleBgLayer);
}
};
diff --git a/actions/windows.js b/actions/windows.js
index 0ac40c9c1..cdb4fd9b8 100644
--- a/actions/windows.js
+++ b/actions/windows.js
@@ -19,6 +19,11 @@ export const UNREGISTER_WINDOW = 'UNREGISTER_WINDOW';
export const RAISE_WINDOW = 'RAISE_WINDOW';
export const SET_SPLIT_SCREEN = 'SET_SPLIT_SCREEN';
+export const NotificationType = {
+ INFO: 1,
+ WARN: 2,
+ ERROR: 3
+};
export function showIframeDialog(name, url, options) {
return {
@@ -29,11 +34,13 @@ export function showIframeDialog(name, url, options) {
};
}
-export function showNotification(name, text) {
+export function showNotification(name, text, type = NotificationType.INFO, sticky = false) {
return {
type: SHOW_NOTIFICATION,
name: name,
- text: text
+ text: text,
+ notificationType: type,
+ sticky: sticky
};
}
diff --git a/babel.config.json b/babel.config.json
new file mode 100644
index 000000000..46685834d
--- /dev/null
+++ b/babel.config.json
@@ -0,0 +1,9 @@
+{
+ "presets": [
+ "@babel/preset-env",
+ "@babel/preset-react"
+ ],
+ "plugins": [
+ "react-hot-loader/babel"
+ ]
+}
diff --git a/components/AppMenu.jsx b/components/AppMenu.jsx
index 3645954a7..7688f1b4e 100644
--- a/components/AppMenu.jsx
+++ b/components/AppMenu.jsx
@@ -22,7 +22,10 @@ import Icon from './Icon';
import './style/AppMenu.css';
import isEqual from 'lodash.isequal';
-
+/**
+ * A menu component for the main menu.
+ *
+ */
class AppMenu extends React.Component {
static propTypes = {
appMenuClearsTask: PropTypes.bool,
diff --git a/components/MessageBar.jsx b/components/MessageBar.jsx
index 586f6acf6..a066bd900 100644
--- a/components/MessageBar.jsx
+++ b/components/MessageBar.jsx
@@ -35,15 +35,13 @@ class MessageBar extends React.Component {
const contents = (typeof this.props.children === "function") ? this.props.children() : null;
return (
-
-
-
- {contents ? contents.body || null : this.renderRole("body")}
-
-
-
-
+
+
+ {contents ? contents.body || null : this.renderRole("body")}
+
+
+
{contents ? contents.extra || null : this.renderRole("extra")}
diff --git a/components/StandardApp.jsx b/components/StandardApp.jsx
index 0fd54e58b..fdc01109b 100644
--- a/components/StandardApp.jsx
+++ b/components/StandardApp.jsx
@@ -31,9 +31,11 @@ import {addLayer} from '../actions/layers';
import {changeSearch} from '../actions/search';
import {themesLoaded, setCurrentTheme} from '../actions/theme';
import {setCurrentTask} from '../actions/task';
+import {NotificationType, showNotification} from '../actions/windows';
import ConfigUtils from '../utils/ConfigUtils';
import CoordinatesUtils from '../utils/CoordinatesUtils';
+import LocaleUtils from '../utils/LocaleUtils';
import MapUtils from '../utils/MapUtils';
import MiscUtils from '../utils/MiscUtils';
import {UrlParams, resolvePermaLink} from '../utils/PermaLinkUtils';
@@ -68,6 +70,7 @@ class AppInitComponent extends React.Component {
setCurrentTask: PropTypes.func,
setCurrentTheme: PropTypes.func,
setStartupParameters: PropTypes.func,
+ showNotification: PropTypes.func,
themesLoaded: PropTypes.func
};
constructor(props) {
@@ -101,13 +104,17 @@ class AppInitComponent extends React.Component {
// Resolve permalink and restore settings
resolvePermaLink(this.props.initialParams, (params, state) => {
- this.props.setStartupParameters(params);
+ this.props.setStartupParameters({...params});
let theme = ThemeUtils.getThemeById(themes, params.t);
if (!theme || theme.restricted) {
if (ConfigUtils.getConfigProp("dontLoadDefaultTheme")) {
return;
}
+ if (params.t) {
+ this.props.showNotification("missingtheme", LocaleUtils.tr("app.missingtheme", params.t), NotificationType.WARN, true);
+ }
theme = ThemeUtils.getThemeById(themes, themes.defaultTheme);
+ params.l = undefined;
}
const layerParams = params.l !== undefined ? params.l.split(",").filter(entry => entry) : null;
if (layerParams && ConfigUtils.getConfigProp("urlReverseLayerOrder")) {
@@ -173,7 +180,8 @@ const AppInit = connect(state => ({
setColorScheme: setColorScheme,
setCurrentTheme: setCurrentTheme,
setStartupParameters: setStartupParameters,
- addLayer: addLayer
+ addLayer: addLayer,
+ showNotification: showNotification
})(AppInitComponent);
diff --git a/components/WindowManager.jsx b/components/WindowManager.jsx
index 4557a4de9..be152ae8d 100644
--- a/components/WindowManager.jsx
+++ b/components/WindowManager.jsx
@@ -9,9 +9,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
+import Icon from './Icon';
import ResizeableWindow from './ResizeableWindow';
-import MessageBar from './MessageBar';
-import {closeWindow, closeAllWindows} from '../actions/windows';
+import {closeWindow, closeAllWindows, NotificationType} from '../actions/windows';
import './style/WindowManager.css';
@@ -28,15 +28,24 @@ class WindowManager extends React.Component {
}
}
render() {
- return Object.entries(this.props.windows).map(([key, data]) => {
- if (data.type === "iframedialog") {
- return this.renderIframeDialog(key, data);
- } else if (data.type === "notification") {
- return this.renderNotification(key, data);
- } else {
+ let notificationIndex = 0;
+ return [
+ ...Object.entries(this.props.windows).map(([key, data]) => {
+ if (data.type === "iframedialog") {
+ return this.renderIframeDialog(key, data);
+ }
return null;
- }
- });
+ }),
+ (
+ {Object.entries(this.props.windows).map(([key, data]) => {
+ if (data.type === "notification") {
+ return this.renderNotification(key, data, notificationIndex++);
+ } else {
+ return null;
+ }
+ })}
+
)
+ ];
}
renderIframeDialog = (key, data) => {
const extraControls = [];
@@ -57,10 +66,19 @@ class WindowManager extends React.Component {
);
};
renderNotification = (key, data) => {
+ let className = 'windows-notification-info';
+ if (data.notificationType === NotificationType.WARN) {
+ className = 'windows-notification-warn';
+ } else if (data.notificationType === NotificationType.ERROR) {
+ className = 'windows-notification-error';
+ }
return (
-
this.closeWindow(key)}>
- {data.text}
-
+
+
{data.text}
+
+ this.closeWindow(key)} size="large"/>
+
+
);
};
closeWindow = (key) => {
diff --git a/components/style/MessageBar.css b/components/style/MessageBar.css
index 86bb7e719..d4035fd0c 100644
--- a/components/style/MessageBar.css
+++ b/components/style/MessageBar.css
@@ -1,30 +1,23 @@
-#MessageBar {
+div.messagebar {
position: absolute;
top: 3.5em;
- left: 0;
- right: 0;
- text-align: center;
- pointer-events: none;
- z-index: 3;
-}
-
-#MessageBar div.messagebar {
+ left: 50%;
+ transform: translateX(-50%);
display: inline-block;
- position: relative;
max-width: calc(100% - 0.5em);
- pointer-events: all;
padding: 0 1.25em 0 0.25em;
background-color: var(--container-bg-color);
box-shadow: 0px 2px 4px rgba(136, 136, 136, 0.5);
border-top: 1px solid rgba(136, 136, 136, 0.5);
+ z-index: 3;
}
-#MessageBar div.body {
+div.messagebar > div.body {
padding: 0.25em 0.5em 0.25em 0.25em;
display: inline-block;
}
-#MessageBar span.closewrapper {
+div.messagebar > span.closewrapper {
position: absolute;
right: 0;
top: 0;
diff --git a/components/style/WindowManager.css b/components/style/WindowManager.css
index 3eca3180a..be8503e79 100644
--- a/components/style/WindowManager.css
+++ b/components/style/WindowManager.css
@@ -7,3 +7,43 @@ iframe.windows-iframe-dialog-body {
div.dock-window iframe.windows-iframe-dialog-body {
height: calc(100vh - 5.75em);
}
+
+div.windows-notification-container {
+ position: absolute;
+ top: 3.5em;
+ left: 50%;
+ transform: translateX(-50%);
+ display: inline-block;
+ max-width: calc(100% - 0.5em);
+ background-color: var(--container-bg-color);
+ box-shadow: 0px 2px 4px rgba(136, 136, 136, 0.5);
+ border-top: 1px solid rgba(136, 136, 136, 0.5);
+ z-index: 3;
+}
+
+div.windows-notification-container > div {
+ padding: 0 1.25em 0 0.25em;
+ position: relative;
+ border-bottom: 1px solid var(--border-color);
+}
+
+div.windows-notification-container > div > div {
+ padding: 0.25em 0.5em 0.25em 0.25em;
+ display: inline-block;
+}
+
+div.windows-notification-container > div > span {
+ position: absolute;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ padding: 0.125em 0.125em 0 0;
+}
+
+div.windows-notification-warn {
+ background-color: orange;
+}
+
+div.windows-notification-error {
+ background-color: red;
+}
diff --git a/config/setupTests.js b/config/setupTests.js
new file mode 100644
index 000000000..eb0d4bcf9
--- /dev/null
+++ b/config/setupTests.js
@@ -0,0 +1,4 @@
+// This is required because of the following error:
+// TextEncoder is not defined in jsdom/whatwg-url
+import { TextEncoder, TextDecoder } from 'util';
+Object.assign(global, { TextDecoder, TextEncoder });
diff --git a/config/setupTestsAfterEnv.js b/config/setupTestsAfterEnv.js
new file mode 100644
index 000000000..9ddd88e69
--- /dev/null
+++ b/config/setupTestsAfterEnv.js
@@ -0,0 +1,52 @@
+import Proj4js from 'proj4';
+import { register as olProj4Register } from 'ol/proj/proj4';
+import {
+ toBeDeepCloseTo, toMatchCloseTo
+} from 'jest-matcher-deep-close-to';
+import "jest-location-mock";
+
+import { JSDOM } from "jsdom";
+
+expect.extend({
+ toBeDeepCloseTo, toMatchCloseTo
+});
+
+export const feetCRS = "EPSG:2225";
+
+/**
+ * By default only a handful of transformations are registered in Proj4js.
+ * This function registers the CRS used by the tests.
+ * @private
+ */
+export function registerFeetCrs() {
+ if (Proj4js.defs(feetCRS) === undefined) {
+ Proj4js.defs(
+ feetCRS,
+ "+proj=lcc +lat_0=39.3333333333333 +lon_0=-122 " +
+ "+lat_1=41.6666666666667 +lat_2=40 " +
+ "+x_0=2000000.0001016 +y_0=500000.0001016 " +
+ "+ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=us-ft " +
+ "+no_defs +type=crs"
+ );
+ olProj4Register(Proj4js);
+ }
+};
+
+registerFeetCrs();
+
+// Window and document mocking.
+const dom = new JSDOM();
+global.document = dom.window.document;
+global.window = dom.window;
+global.navigator = dom.window.navigator;
+global.location = dom.window.location;
+
+beforeAll(() => {
+ // We need this so that we can test new Date() in the code.
+ jest.useFakeTimers('modern');
+ jest.setSystemTime(new Date(2020, 3, 1));
+});
+
+afterAll(() => {
+ jest.useRealTimers();
+});
diff --git a/config/typedoc.json b/config/typedoc.json
new file mode 100644
index 000000000..daf57a5e5
--- /dev/null
+++ b/config/typedoc.json
@@ -0,0 +1,45 @@
+{
+ "$schema": "https://typedoc.org/schema.json",
+ "out": "../documentation",
+ "basePath": "..",
+ "entryPoints": [
+ "../actions",
+ "../components",
+ "../plugins",
+ "../reducers",
+ "../selectors",
+ "../stores",
+ "../typings",
+ "../utils"
+ ],
+ "entryPointStrategy": "resolve",
+ "theme": "hierarchy",
+ "plugin": [
+ "typedoc-theme-hierarchy"
+ ],
+ "excludeInternal": true,
+ "excludePrivate": true,
+ "excludeNotDocumented": true,
+ "excludeReferences": true,
+ "excludeNotDocumentedKinds": [
+ // "Module",
+ // "Namespace",
+ // "Enum",
+ // "EnumMember",
+ "Variable",
+ "Function",
+ "Class",
+ // "Interface",
+ // "Constructor",
+ // "Property",
+ // "Method",
+ "CallSignature",
+ "IndexSignature",
+ "ConstructorSignature",
+ "Accessor",
+ "GetSignature",
+ "SetSignature",
+ "TypeAlias",
+ "Reference"
+ ]
+}
diff --git a/doc/plugins.md b/doc/plugins.md
index 57c0688d6..a4ead5372 100644
--- a/doc/plugins.md
+++ b/doc/plugins.md
@@ -1,48 +1,49 @@
Plugin reference
================
-* [API](#api)
-* [AttributeTable](#attributetable)
-* [Authentication](#authentication)
-* [BackgroundSwitcher](#backgroundswitcher)
-* [Bookmark](#bookmark)
-* [BottomBar](#bottombar)
-* [Cyclomedia](#cyclomedia)
-* [DxfExport](#dxfexport)
-* [Editing](#editing)
-* [FeatureForm](#featureform)
-* [FeatureSearch](#featuresearch)
-* [HeightProfile](#heightprofile)
-* [Help](#help)
-* [HomeButton](#homebutton)
-* [Identify](#identify)
-* [LayerCatalog](#layercatalog)
-* [LayerTree](#layertree)
-* [LocateButton](#locatebutton)
-* [LoginUser](#loginuser)
-* [MapPlugin](#mapplugin)
-* [MapComparePlugin](#mapcompareplugin)
-* [MapCopyright](#mapcopyright)
-* [MapExport](#mapexport)
-* [MapInfoTooltip](#mapinfotooltip)
-* [MapLegend](#maplegend)
-* [MapTip](#maptip)
-* [Measure](#measure)
-* [NewsPopup](#newspopup)
-* [Print](#print)
-* [ProcessNotifications](#processnotifications)
-* [RasterExport](#rasterexport)
-* [Redlining](#redlining)
-* [Routing](#routing)
-* [ScratchDrawing](#scratchdrawing)
-* [Settings](#settings)
-* [Share](#share)
-* [StartupMarker](#startupmarker)
-* [TaskButton](#taskbutton)
-* [ThemeSwitcher](#themeswitcher)
-* [TimeManager](#timemanager)
-* [TopBar](#topbar)
-* [ZoomButton](#zoombutton)
+- [Plugin reference](#plugin-reference)
+ - [API](#api)
+ - [AttributeTable](#attributetable)
+ - [Authentication](#authentication)
+ - [BackgroundSwitcher](#backgroundswitcher)
+ - [Bookmark](#bookmark)
+ - [BottomBar](#bottombar)
+ - [Cyclomedia](#cyclomedia)
+ - [DxfExport](#dxfexport)
+ - [Editing](#editing)
+ - [FeatureForm](#featureform)
+ - [FeatureSearch](#featuresearch)
+ - [HeightProfile](#heightprofile)
+ - [Help](#help)
+ - [HomeButton](#homebutton)
+ - [Identify](#identify)
+ - [LayerCatalog](#layercatalog)
+ - [LayerTree](#layertree)
+ - [LocateButton](#locatebutton)
+ - [LoginUser](#loginuser)
+ - [MapPlugin](#mapplugin)
+ - [MapComparePlugin](#mapcompareplugin)
+ - [MapCopyright](#mapcopyright)
+ - [MapExport](#mapexport)
+ - [MapInfoTooltip](#mapinfotooltip)
+ - [MapLegend](#maplegend)
+ - [MapTip](#maptip)
+ - [Measure](#measure)
+ - [NewsPopup](#newspopup)
+ - [Print](#print)
+ - [ProcessNotifications](#processnotifications)
+ - [RasterExport](#rasterexport)
+ - [Redlining](#redlining)
+ - [Routing](#routing)
+ - [ScratchDrawing](#scratchdrawing)
+ - [Settings](#settings)
+ - [Share](#share)
+ - [StartupMarker](#startupmarker)
+ - [TaskButton](#taskbutton)
+ - [ThemeSwitcher](#themeswitcher)
+ - [TimeManager](#timemanager)
+ - [TopBar](#topbar)
+ - [ZoomButton](#zoombutton)
---
API
@@ -414,9 +415,9 @@ Allows exporting a selected portion of the map to a variety of formats.
| Property | Type | Description | Default value |
|----------|------|-------------|---------------|
| allowedFormats | `[string]` | Whitelist of allowed export format mimetypes. If empty, supported formats are listed. | `undefined` |
-| allowedScales | `[number]` | List of scales at which to export the map. | `undefined` |
+| allowedScales | `{[number], bool}` | List of scales at which to export the map. If empty, scale can be freely specified. If `false`, the map can only be exported at the current scale. | `undefined` |
| defaultFormat | `string` | Default export format mimetype. If empty, first available format is used. | `undefined` |
-| defaultScaleFactor | `number` | The factor to apply to the map scale to determine the initial export map scale. | `0.5` |
+| defaultScaleFactor | `number` | The factor to apply to the map scale to determine the initial export map scale (if `allowedScales` is not `false`). | `0.5` |
| dpis | `[number]` | List of dpis at which to export the map. If empty, the default server dpi is used. | `undefined` |
| exportExternalLayers | `bool` | Whether to include external layers in the image. Requires QGIS Server 3.x! | `true` |
| formatConfiguration | `{`
` format: [{`
` name: string,`
` extraQuery: string,`
` formatOptions: string,`
` baseLayer: string,`
`}],`
`}` | Custom export configuration per format.
If more than one configuration per format is provided, a selection combo will be displayed.
`query` will be appended to the query string (replacing any existing parameters).
`formatOptions` will be passed as FORMAT_OPTIONS.
`baseLayer` will be appended to the LAYERS. | `undefined` |
@@ -597,6 +598,7 @@ Allows configuring language and color scheme.
|----------|------|-------------|---------------|
| colorSchemes | `[{`
` title: string,`
` titleMsgId: string,`
` value: string,`
`}]` | List of available color schemes. Value is the css class name, title/titleMsgId the display name. | `[]` |
| languages | `array` | List of available languages. Value is the lang code, title/titleMsgId the display name. | `[]` |
+| showDefaultThemeSelector | `bool` | Whether to show a selector to set the default theme/bookmark (of a logged in user). | `true` |
| side | `string` | The side of the application on which to display the sidebar. | `'right'` |
Share
diff --git a/index.js b/index.js
new file mode 100644
index 000000000..593e3e07d
--- /dev/null
+++ b/index.js
@@ -0,0 +1,8 @@
+// The module property should point to a script that utilizes ES2015 module
+// syntax but no other syntax features that aren't yet supported by browsers or
+// node. This enables webpack to parse the module syntax itself, allowing for
+// lighter bundles via tree shaking if users are only consuming certain parts of
+// the library.
+
+export * from './actions';
+export * from './utils';
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 000000000..0ef3438e8
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,34 @@
+
+/** @type {import('jest').Config} */
+const config = {
+ verbose: true,
+ clearMocks: true,
+ collectCoverage: true,
+ coverageDirectory: "coverage",
+ coverageReporters: ["text", "html"],
+ moduleFileExtensions: ["js", "jsx"],
+ moduleDirectories: ["node_modules"],
+ moduleNameMapper: {
+ "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
+ "
/__mocks__/fileMock.js",
+ "\\.(css|less)$": "identity-obj-proxy",
+ "^openlayers$": "/libs/openlayers.js"
+ },
+ transform: {
+ "^.+\\.jsx?$": "babel-jest",
+ },
+ transformIgnorePatterns: [
+ "node_modules/(?!(ol|ol-ext)/)",
+ ],
+ testEnvironment: "jsdom",
+ setupFiles: [
+ "jest-canvas-mock",
+ "./config/setupTests.js",
+ ],
+ setupFilesAfterEnv: [
+ "jest-expect-message",
+ "./config/setupTestsAfterEnv.js",
+ ]
+};
+
+export default config;
diff --git a/libs/openlayers.test.js b/libs/openlayers.test.js
new file mode 100644
index 000000000..01695f996
--- /dev/null
+++ b/libs/openlayers.test.js
@@ -0,0 +1,7 @@
+import ol from 'openlayers';
+
+describe("content of libs/openlayers", () => {
+ it("should import ol", () => {
+ expect(ol).not.toBe(undefined);
+ });
+});
diff --git a/package.json b/package.json
index 712602355..8e15889ca 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,32 @@
{
"name": "qwc2",
- "version": "2023.10.17-master",
+ "version": "2023.10.23-master",
"description": "QGIS Web Client 2 core",
"author": "Sourcepole AG",
"license": "BSD-2-Clause",
"repository": "git@github.com:qgis/qwc2.git",
- "dependencies": {
+ "keywords": [
+ "qgis",
+ "map",
+ "mapping",
+ "gis"
+ ],
+ "main": "index.js",
+ "types": "dist/qwc2.d.ts",
+ "type": "module",
+ "sideEffects": [
+ "*.css"
+ ],
+ "scripts": {
+ "clean": "rimraf dist && rimraf dist-dev && rimraf documentation && rimraf coverage",
+ "build-dev": "webpack --mode development --config webpack.dev.js",
+ "build-prod": "webpack --mode production --config webpack.prod.js",
+ "test": "jest --config ./jest.config.js",
+ "test-watch": "jest --watch --config ./config/jest.config.js",
+ "start": "webpack serve --hot --mode development --config webpack.dev.js",
+ "doc": "typedoc --logLevel Verbose --options ./config/typedoc.json"
+ },
+ "peerDependencies": {
"@turf/buffer": "^6.5.0",
"@turf/helpers": "^6.5.0",
"any-date-parser": "^1.5.3",
@@ -36,8 +57,8 @@
"lodash.sortby": "^4.7.0",
"mime-to-extensions": "^1.0.2",
"mousetrap": "^1.6.5",
- "ol": "^7.2.2",
- "ol-ext": "^4.0.4",
+ "ol": "^7.5.2",
+ "ol-ext": "^4.0.11",
"painterro": "^1.2.78",
"path-browserify": "^1.0.1",
"proj4": "^2.8.1",
@@ -65,7 +86,7 @@
"stream-browserify": "^3.0.0",
"timers-browserify": "^2.0.12",
"url": "^0.11.0",
- "uuid": "^8.3.2"
+ "uuid": "^9.0.1"
},
"devDependencies": {
"@babel/core": "^7.20.12",
@@ -74,29 +95,110 @@
"@babel/plugin-proposal-object-rest-spread": "^7.20.7",
"@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6",
+ "@hot-loader/react-dom": "^17.0.2+4.13.0",
+ "@jest/types": "^29.6.3",
"@redux-devtools/core": "^3.13.1",
"@redux-devtools/dock-monitor": "^3.0.1",
"@redux-devtools/log-monitor": "^4.0.2",
- "@types/react": "^18.0.27",
+ "@turf/buffer": "^6.5.0",
+ "@turf/helpers": "^6.5.0",
+ "@types/lodash": "^4.14.200",
+ "@types/proj4": "^2.5.3",
+ "@types/react": "^18.2.28",
+ "@types/react-dom": "^18.2.13",
+ "@types/uuid": "^9.0.5",
"@vusion/webfonts-generator": "^0.8.0",
+ "any-date-parser": "^1.5.3",
+ "axios": "^1.2.3",
+ "babel-jest": "^29.7.0",
+ "babel-loader": "^9.1.3",
+ "buffer": "^6.0.3",
+ "chartist": "^0.11.4",
+ "chartist-plugin-axistitle": "^0.0.7",
+ "classnames": "^2.3.2",
+ "clone": "^2.1.2",
+ "core-js": "^3.27.2",
+ "country-language": "^0.1.7",
+ "css-loader": "^6.8.1",
+ "dayjs": "^1.11.7",
+ "deepcopy": "^2.1.0",
+ "deepmerge": "^4.2.2",
+ "diacritics": "^1.3.0",
+ "eslint": "^8.51.0",
+ "eslint-plugin-react": "^7.33.2",
+ "fast-xml-parser": "^4.0.14",
+ "file-loader": "^6.2.0",
+ "file-saver": "^2.0.5",
+ "flat": "^5.0.2",
+ "formdata-json": "^1.0.0",
+ "geojson-bounding-box": "^0.2.0",
+ "html-react-parser": "^3.0.8",
+ "html-webpack-plugin": "^5.5.3",
+ "identity-obj-proxy": "^3.0.0",
+ "ismobilejs": "^1.1.1",
+ "jest": "^29.7.0",
+ "jest-canvas-mock": "^2.5.2",
+ "jest-environment-jsdom": "^29.7.0",
+ "jest-expect-message": "^1.1.3",
+ "jest-location-mock": "^2.0.0",
+ "jest-matcher-deep-close-to": "^3.0.2",
+ "jest-mock-axios": "^4.7.3",
+ "jsdoc": "^4.0.2",
+ "jszip": "^3.10.1",
+ "lodash.isempty": "^4.4.0",
+ "lodash.isequal": "^4.5.0",
+ "lodash.omit": "^4.5.0",
+ "lodash.pickby": "^4.6.0",
+ "lodash.sortby": "^4.7.0",
+ "mime-to-extensions": "^1.0.2",
"mkdirp": "^2.1.3",
+ "mousetrap": "^1.6.5",
"object-path": "^0.11.8",
+ "ol": "^7.5.2",
+ "ol-ext": "^4.0.11",
+ "painterro": "^1.2.78",
+ "path-browserify": "^1.0.1",
+ "proj4": "^2.8.1",
+ "prop-types": "^15.8.1",
+ "qrcode.react": "^3.1.0",
+ "randomcolor": "^0.6.2",
+ "react": "^18.2.0",
+ "react-chartist": "^0.14.4",
+ "react-contenteditable": "^3.3.6",
+ "react-copy-to-clipboard": "^5.1.0",
+ "react-dom": "^18.2.0",
+ "react-hot-loader": "^4.13.1",
+ "react-intl": "^6.2.5",
+ "react-numeric-input2": "^3.1.0",
+ "react-redux": "^8.0.5",
+ "react-rnd": "^10.4.1",
+ "react-share": "^4.4.1",
+ "react-sortablejs": "^1.5.1",
+ "react-swipeable": "^7.0.0",
+ "redux": "^4.2.0",
"redux-immutable-state-invariant": "^2.1.0",
+ "redux-logger": "^3.0.6",
+ "redux-thunk": "^2.4.2",
+ "regenerator-runtime": "^0.13.11",
+ "reselect": "^4.1.7",
+ "rimraf": "^5.0.5",
+ "sortablejs": "^1.14.0",
+ "stream-browserify": "^3.0.0",
+ "style-loader": "^3.3.3",
+ "timers-browserify": "^2.0.12",
+ "ts-loader": "^9.5.0",
+ "typedoc": "^0.25.2",
+ "typedoc-theme-hierarchy": "^4.1.2",
+ "typescript": "^5.2.2",
+ "url": "^0.11.0",
+ "url-loader": "^4.1.1",
+ "uuid": "^9.0.1",
+ "webpack": "^5.88.2",
+ "webpack-bundle-analyzer": "^4.9.1",
+ "webpack-cli": "^5.1.4",
+ "webpack-dev-server": "^4.15.1",
+ "webpack-merge": "^5.9.0",
+ "webpack-node-externals": "^3.0.0",
"xml2js": "^0.4.23"
- },
- "babel": {
- "plugins": [
- "@babel/plugin-proposal-class-properties",
- "@babel/plugin-proposal-object-rest-spread"
- ],
- "presets": [
- [
- "@babel/preset-env",
- {
- "modules": false
- }
- ],
- "@babel/preset-react"
- ]
}
}
diff --git a/plugins/FeatureSearch.jsx b/plugins/FeatureSearch.jsx
index 80c1f7d83..98b570cff 100644
--- a/plugins/FeatureSearch.jsx
+++ b/plugins/FeatureSearch.jsx
@@ -13,12 +13,13 @@ import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {v1 as uuidv1} from 'uuid';
import isEmpty from 'lodash.isempty';
+import IdentifyViewer from '../components/IdentifyViewer';
import SideBar from '../components/SideBar';
+import Spinner from '../components/Spinner';
import CoordinatesUtils from '../utils/CoordinatesUtils';
import IdentifyUtils from '../utils/IdentifyUtils';
import LocaleUtils from '../utils/LocaleUtils';
import "./style/FeatureSearch.css";
-import IdentifyViewer from '../components/IdentifyViewer';
class FeatureSearch extends React.Component {
static propTypes = {
@@ -101,7 +102,10 @@ class FeatureSearch extends React.Component {
-
+
);
@@ -128,7 +132,7 @@ class FeatureSearch extends React.Component {
{isEmpty(this.state.searchResults) ? (
{LocaleUtils.tr("featuresearch.noresults")}
) : (
-
+
)}
);
@@ -172,7 +176,7 @@ class FeatureSearch extends React.Component {
});
params.LAYERS = params.LAYERS.join(",");
params.FILTER = params.FILTER.join(";");
- this.setState({busy: true});
+ this.setState({busy: true, searchResults: null});
axios.get(this.props.theme.featureInfoUrl, {params}).then(response => {
const results = IdentifyUtils.parseResponse(response.data, null, 'text/xml', null, this.props.map.projection);
this.setState({busy: false, searchResults: results});
diff --git a/plugins/LocateButton.jsx b/plugins/LocateButton.jsx
index 87bd6cf4a..ac2497b00 100644
--- a/plugins/LocateButton.jsx
+++ b/plugins/LocateButton.jsx
@@ -32,21 +32,6 @@ class LocateButton extends React.Component {
static defaultProps = {
position: 2
};
- constructor(props) {
- super(props);
-
- if (!navigator.geolocation) {
- props.changeLocateState("PERMISSION_DENIED");
- } else {
- navigator.geolocation.getCurrentPosition(() => {
- // OK!
- }, (err) => {
- if (err.code === 1) {
- props.changeLocateState("PERMISSION_DENIED");
- }
- });
- }
- }
onClick = () => {
if (this.props.locateState === "DISABLED") {
this.props.changeLocateState("ENABLED");
diff --git a/plugins/MapExport.jsx b/plugins/MapExport.jsx
index 1a49c1b33..e1adc3018 100644
--- a/plugins/MapExport.jsx
+++ b/plugins/MapExport.jsx
@@ -37,11 +37,11 @@ class MapExport extends React.Component {
static propTypes = {
/** Whitelist of allowed export format mimetypes. If empty, supported formats are listed. */
allowedFormats: PropTypes.arrayOf(PropTypes.string),
- /** List of scales at which to export the map. */
- allowedScales: PropTypes.arrayOf(PropTypes.number),
+ /** List of scales at which to export the map. If empty, scale can be freely specified. If `false`, the map can only be exported at the current scale. */
+ allowedScales: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.bool]),
/** Default export format mimetype. If empty, first available format is used. */
defaultFormat: PropTypes.string,
- /** The factor to apply to the map scale to determine the initial export map scale. */
+ /** The factor to apply to the map scale to determine the initial export map scale (if `allowedScales` is not `false`). */
defaultScaleFactor: PropTypes.number,
/** List of dpis at which to export the map. If empty, the default server dpi is used. */
dpis: PropTypes.arrayOf(PropTypes.number),
@@ -107,11 +107,12 @@ class MapExport extends React.Component {
) {
if (this.state.pageSize !== null) {
this.setState((state) => {
+ const scale = this.getExportScale(state);
const center = this.props.map.center;
const mapCrs = this.props.map.projection;
const pageSize = this.props.pageSizes[state.pageSize];
- const widthm = state.scale * pageSize.width / 1000;
- const heightm = state.scale * pageSize.height / 1000;
+ const widthm = scale * pageSize.width / 1000;
+ const heightm = scale * pageSize.height / 1000;
const {width, height} = MapUtils.transformExtent(mapCrs, center, widthm, heightm);
let extent = [center[0] - 0.5 * width, center[1] - 0.5 * height, center[0] + 0.5 * width, center[1] + 0.5 * height];
extent = (CoordinatesUtils.getAxisOrder(mapCrs).substr(0, 2) === 'ne' && this.props.theme.version === '1.3.0') ?
@@ -156,7 +157,7 @@ class MapExport extends React.Component {
);
- } else {
+ } else if (this.props.allowedScales !== false) {
scaleChooser = (
this.setState({scale: ev.target.value})} role="input" type="number" value={this.state.scale} />
);
@@ -167,7 +168,7 @@ class MapExport extends React.Component {
const mapScale = MapUtils.computeForZoom(this.props.map.scales, this.props.map.zoom);
let scaleFactor = 1;
- if (this.state.pageSize === null) {
+ if (this.state.pageSize === null && this.props.allowedScales !== false) {
scaleFactor = mapScale / this.state.scale;
}
const exportParams = LayerUtils.collectPrintParams(this.props.layers, this.props.theme, mapScale, this.props.map.projection, exportExternalLayers);
@@ -213,15 +214,17 @@ class MapExport extends React.Component {
) : null}
-
- {LocaleUtils.tr("mapexport.scale")} |
-
-
- 1 :
- {scaleChooser}
-
- |
-
+ {scaleChooser ? (
+
+ {LocaleUtils.tr("mapexport.scale")} |
+
+
+ 1 :
+ {scaleChooser}
+
+ |
+
+ ) : null}
{this.props.dpis ? (
{LocaleUtils.tr("mapexport.resolution")} |
@@ -272,7 +275,7 @@ class MapExport extends React.Component {
};
renderFrame = () => {
if (this.state.pageSize !== null) {
- const px2m = 1 / (this.state.dpi * 39.3701) * this.state.scale;
+ const px2m = 1 / (this.state.dpi * 39.3701) * this.getExportScale(this.state);
const frame = {
width: this.state.width * px2m,
height: this.state.height * px2m
@@ -332,6 +335,13 @@ class MapExport extends React.Component {
height: ''
});
};
+ getExportScale = (state) => {
+ if (this.props.allowedScales === false) {
+ return Math.round(MapUtils.computeForZoom(this.props.map.scales, this.props.map.zoom));
+ } else {
+ return state.scale;
+ }
+ };
bboxSelected = (bbox, crs, pixelsize) => {
const version = this.props.theme.version;
let extent = '';
@@ -368,7 +378,7 @@ class MapExport extends React.Component {
if (formatConfiguration) {
const keyCaseMap = Object.keys(params).reduce((res, key) => ({...res, [key.toLowerCase()]: key}), {});
- (formatConfiguration.extraQuery || "").split(/[?&]/).forEach(entry => {
+ (formatConfiguration.extraQuery || "").split(/[?&]/).filter(Boolean).forEach(entry => {
const [key, value] = entry.split("=");
const caseKey = keyCaseMap[key.toLowerCase()] || key;
params[caseKey] = (value ?? "");
@@ -392,7 +402,8 @@ class MapExport extends React.Component {
axios.post(this.props.theme.url, data, config).then(response => {
this.setState({exporting: false});
const contentType = response.headers["content-type"];
- FileSaver.saveAs(new Blob([response.data], {type: contentType}), this.props.theme.name + '.pdf');
+ const ext = this.state.selectedFormat.split(";")[0].split("/").pop();
+ FileSaver.saveAs(new Blob([response.data], {type: contentType}), this.props.theme.name + '.' + ext);
}).catch(e => {
this.setState({exporting: false});
if (e.response) {
diff --git a/plugins/Redlining.jsx b/plugins/Redlining.jsx
index fb1e4a661..b34e1725f 100644
--- a/plugins/Redlining.jsx
+++ b/plugins/Redlining.jsx
@@ -67,8 +67,13 @@ class Redlining extends React.Component {
if (prevProps.redlining.geomType !== this.props.redlining.geomType && this.props.redlining.geomType === 'Text' && !this.state.selectText) {
this.setState({selectText: true});
}
- if (!this.props.layers.find(layer => layer.id === this.props.redlining.layer) && this.props.redlining.layer !== 'redlining') {
- this.props.changeRedliningState({layer: 'redlining', layerTitle: 'Redlining'});
+ if (!this.props.layers.find(layer => layer.id === this.props.redlining.layer)) {
+ const vectorLayers = this.props.layers.filter(layer => layer.type === "vector" && layer.role === LayerRole.USERLAYER && !layer.readonly);
+ if (vectorLayers.length >= 1) {
+ this.props.changeRedliningState({layer: vectorLayers[0].id, layerTitle: vectorLayers[0].title});
+ } else if (this.props.redlining.layer !== 'redlining') {
+ this.props.changeRedliningState({layer: 'redlining', layerTitle: 'Redlining'});
+ }
}
}
componentWillUnmount() {
@@ -139,8 +144,8 @@ class Redlining extends React.Component {
editButtons.push(plugin.cfg);
}
let vectorLayers = this.props.layers.filter(layer => layer.type === "vector" && layer.role === LayerRole.USERLAYER && !layer.readonly);
- // Ensure list always contains "Redlining" layer
- if (!vectorLayers.find(layer => layer.id === 'redlining')) {
+ // Ensure list always contains at least a "Redlining" layer
+ if (vectorLayers.length === 0) {
vectorLayers = [{id: 'redlining', title: 'Redlining'}, ...vectorLayers];
}
diff --git a/plugins/Settings.jsx b/plugins/Settings.jsx
index 2d2050867..c5beb3baf 100644
--- a/plugins/Settings.jsx
+++ b/plugins/Settings.jsx
@@ -9,11 +9,15 @@
import React from 'react';
import {connect} from 'react-redux';
import PropTypes from 'prop-types';
+import axios from 'axios';
import isEmpty from 'lodash.isempty';
import url from 'url';
-import {setColorScheme} from '../actions/localConfig';
+import {setColorScheme, setUserInfoFields} from '../actions/localConfig';
import SideBar from '../components/SideBar';
+import ConfigUtils from '../utils/ConfigUtils';
+import {getUserBookmarks} from '../utils/PermaLinkUtils';
import LocaleUtils from '../utils/LocaleUtils';
+import ThemeUtils from '../utils/ThemeUtils';
import './style/Settings.css';
@@ -31,20 +35,38 @@ class Settings extends React.Component {
titleMsgId: PropTypes.string,
value: PropTypes.string
})),
+ defaultUrlParams: PropTypes.string,
/** List of available languages. Value is the lang code, title/titleMsgId the display name. */
languages: PropTypes.array,
setColorScheme: PropTypes.func,
+ setUserInfoFields: PropTypes.func,
+ /** Whether to show a selector to set the default theme/bookmark (of a logged in user). */
+ showDefaultThemeSelector: PropTypes.bool,
/** The side of the application on which to display the sidebar. */
- side: PropTypes.string
+ side: PropTypes.string,
+ themes: PropTypes.object
};
static defaultProps = {
colorSchemes: [],
languages: [],
- side: 'right'
+ side: 'right',
+ showDefaultThemeSelector: true
+ };
+ state = {
+ bookmarks: {}
+ };
+ onShow = () => {
+ const username = ConfigUtils.getConfigProp("username");
+ if (this.props.showDefaultThemeSelector && username) {
+ getUserBookmarks(username, (bookmarks) => {
+ const bookmarkKeys = bookmarks.reduce((res, entry) => ({...res, [entry.key]: entry.description}), {});
+ this.setState({bookmarks: bookmarkKeys});
+ });
+ }
};
render() {
return (
-
+
{() => ({
body: this.renderBody()
})}
@@ -58,6 +80,7 @@ class Settings extends React.Component {
{this.renderLanguageSelector()}
{this.renderColorSchemeSelector()}
+ {this.renderDefaultThemeSelector()}
@@ -99,6 +122,49 @@ class Settings extends React.Component {
);
};
+ renderDefaultThemeSelector = () => {
+ if (!this.props.showDefaultThemeSelector || !ConfigUtils.getConfigProp("username")) {
+ return null;
+ }
+ const themeNames = ThemeUtils.getThemeNames(this.props.themes);
+ return (
+
+ {LocaleUtils.tr("settings.defaulttheme")} |
+
+
+ |
+
+ );
+ };
+ changeDefaultUrlParams = (ev) => {
+ const params = {
+ default_url_params: ev.target.value
+ };
+ const baseurl = location.href.split("?")[0].replace(/\/$/, '');
+ axios.get(baseurl + "/setuserinfo", {params}).then(response => {
+ if (!response.data.success) {
+ /* eslint-disable-next-line */
+ alert(LocaleUtils.tr("settings.defaultthemefailed", response.data.error));
+ ev.target.value = this.props.defaultUrlParams;
+ } else {
+ this.props.setUserInfoFields(response.data.fields);
+ }
+ }).catch((e) => {
+ /* eslint-disable-next-line */
+ alert(LocaleUtils.tr("settings.defaultthemefailed", String(e)));
+ ev.target.value = this.props.defaultUrlParams;
+ });
+ }
changeLocale = (ev) => {
// eslint-disable-next-line
if (confirm(LocaleUtils.tr("settings.confirmlang"))) {
@@ -122,7 +188,10 @@ class Settings extends React.Component {
}
export default connect((state) => ({
- colorScheme: state.localConfig.colorScheme
+ colorScheme: state.localConfig.colorScheme,
+ defaultUrlParams: state.localConfig.user_infos?.default_url_params || "",
+ themes: state.theme.themes
}), {
- setColorScheme: setColorScheme
+ setColorScheme: setColorScheme,
+ setUserInfoFields: setUserInfoFields
})(Settings);
diff --git a/plugins/map/LocateSupport.jsx b/plugins/map/LocateSupport.jsx
index 0e73cd343..d18ae20c1 100644
--- a/plugins/map/LocateSupport.jsx
+++ b/plugins/map/LocateSupport.jsx
@@ -85,7 +85,12 @@ class LocateSupport extends React.Component {
};
onLocationError = (err) => {
this.props.onLocateError(err.message);
- this.props.changeLocateState("DISABLED");
+ // User denied geolocation prompt
+ if (err.code === 1) {
+ this.props.changeLocateState("PERMISSION_DENIED");
+ } else {
+ this.props.changeLocateState("DISABLED");
+ }
};
render() {
return null;
diff --git a/plugins/style/Buttons.css b/plugins/style/Buttons.css
index 44ef01c2a..8e08b64bf 100644
--- a/plugins/style/Buttons.css
+++ b/plugins/style/Buttons.css
@@ -16,11 +16,6 @@ button.map-button {
cursor: pointer;
}
-button.map-button.locate-button-DISABLED {
- opacity: 0.7;
- cursor: default;
-}
-
button.map-button:hover {
background-color: var(--map-button-hover-bg-color);
color: var(--map-button-hover-text-color);
@@ -31,6 +26,16 @@ button.map-button-active {
color: var(--map-button-active-text-color);
}
+button.map-button.locate-button-PERMISSION_DENIED {
+ opacity: 0.7;
+ cursor: default;
+}
+
+button.map-button.locate-button-PERMISSION_DENIED:hover {
+ background-color: var(--map-button-bg-color);
+ color: var(--map-button-text-color);
+}
+
button.map-button.locate-button-LOCATING,
button.map-button.locate-button-ENABLED {
background-color: var(--map-button-text-color);
diff --git a/plugins/style/FeatureSearch.css b/plugins/style/FeatureSearch.css
index 8162b5085..311ffae08 100644
--- a/plugins/style/FeatureSearch.css
+++ b/plugins/style/FeatureSearch.css
@@ -47,6 +47,12 @@ div.feature-search-bar > button {
width: 100%;
}
+div.feature-search-bar > button > div.Spinner {
+ width: 2em;
+ height: 2em;
+ margin-right: 1em;
+}
+
div.feature-search-results {
flex: 1 1 auto;
overflow-y: auto;
diff --git a/reducers/browser.js b/reducers/browser.js
index 577b0ed84..74afe0ffd 100644
--- a/reducers/browser.js
+++ b/reducers/browser.js
@@ -7,16 +7,24 @@
* LICENSE file in the root directory of this source tree.
*/
-import {CHANGE_BROWSER_PROPERTIES} from '../actions/browser';
+import { CHANGE_BROWSER_PROPERTIES } from '../actions/browser';
+
+/**
+ * @type {import("qwc2/typings").BrowserData}
+ * @private
+ */
const defaultState = {};
-export default function browser(state = defaultState, action) {
+
+export default function browser(
+ state = defaultState, action
+) {
switch (action.type) {
- case CHANGE_BROWSER_PROPERTIES: {
- return {...state, ...action.newProperties};
- }
- default:
- return state;
+ case CHANGE_BROWSER_PROPERTIES: {
+ return { ...state, ...action.newProperties };
+ }
+ default:
+ return state;
}
}
diff --git a/reducers/display.js b/reducers/display.js
index 6474d961e..c43121094 100644
--- a/reducers/display.js
+++ b/reducers/display.js
@@ -6,18 +6,32 @@
* LICENSE file in the root directory of this source tree.
*/
-import {TOGGLE_FULLSCREEN} from '../actions/display';
+import { TOGGLE_FULLSCREEN } from '../actions/display';
+/**
+ * @typedef {object} DisplayState
+ * @property {boolean} fullscreen - Whether the application is in fullscreen mode
+ */
+
+/**
+ * @type {DisplayState}
+ * @private
+ */
const defaultState = {
fullscreen: false
};
-export default function toggleFullscreen(state = defaultState, action) {
+export default function toggleFullscreen(
+ state = defaultState, action
+) {
switch (action.type) {
- case TOGGLE_FULLSCREEN: {
- return {...state, fullscreen: action.fullscreen};
- }
- default:
- return state;
+ case TOGGLE_FULLSCREEN: {
+ return {
+ ...state,
+ fullscreen: action.fullscreen
+ };
+ }
+ default:
+ return state;
}
}
diff --git a/reducers/editing.js b/reducers/editing.js
index 11235b4ba..feb4a8f4a 100644
--- a/reducers/editing.js
+++ b/reducers/editing.js
@@ -6,15 +6,23 @@
* LICENSE file in the root directory of this source tree.
*/
-import {SET_EDIT_CONTEXT, CLEAR_EDIT_CONTEXT} from '../actions/editing';
+import { SET_EDIT_CONTEXT, CLEAR_EDIT_CONTEXT } from '../actions/editing';
+/**
+ * @type {import("qwc2/typings").QwcContextState}
+ * @private
+ */
const defaultState = {
contexts: {},
currentContext: null
};
const nonZeroZCoordinate = (coordinates) => {
- return coordinates.find(entry => Array.isArray(entry[0]) ? nonZeroZCoordinate(entry) : entry.length >= 3 && entry[2] !== 0);
+ return coordinates.find(
+ entry => Array.isArray(entry[0])
+ ? nonZeroZCoordinate(entry)
+ : entry.length >= 3 && entry[2] !== 0
+ );
};
const checkGeomReadOnly = (oldState, newFeature) => {
@@ -22,42 +30,45 @@ const checkGeomReadOnly = (oldState, newFeature) => {
if (!newFeature) {
return false;
} else if (newFeature.id !== ((oldState || {}).feature || {}).id) {
- return nonZeroZCoordinate([newFeature.geometry?.coordinates || []]) !== undefined;
+ return nonZeroZCoordinate(
+ [newFeature.geometry?.coordinates || []]
+ ) !== undefined;
}
return (oldState || {}).geomReadOnly || false;
};
+
export default function editing(state = defaultState, action) {
switch (action.type) {
- case SET_EDIT_CONTEXT: {
- return {
- contexts: {
- ...state.contexts,
- [action.contextId]: {
- action: null,
- feature: null,
- geomType: null,
- changed: false,
- ...state.contexts[action.contextId],
- ...action.editContext,
- geomReadOnly: action.editContext.geomReadOnly === true || checkGeomReadOnly(state.contexts[action.contextId], action.editContext.feature),
- id: action.contextId
- }
- },
- currentContext: action.contextId
- };
- }
- case CLEAR_EDIT_CONTEXT: {
- const newState = {
- contexts: {
- ...state.contexts
- },
- currentContext: state.currentContext === action.contextId ? action.newActiveContextId : state.currentContext
- };
- delete newState.contexts[action.contextId];
- return newState;
- }
- default:
- return state;
+ case SET_EDIT_CONTEXT: {
+ return {
+ contexts: {
+ ...state.contexts,
+ [action.contextId]: {
+ action: null,
+ feature: null,
+ geomType: null,
+ changed: false,
+ ...state.contexts[action.contextId],
+ ...action.editContext,
+ geomReadOnly: action.editContext.geomReadOnly === true || checkGeomReadOnly(state.contexts[action.contextId], action.editContext.feature),
+ id: action.contextId
+ }
+ },
+ currentContext: action.contextId
+ };
+ }
+ case CLEAR_EDIT_CONTEXT: {
+ const newState = {
+ contexts: {
+ ...state.contexts
+ },
+ currentContext: state.currentContext === action.contextId ? action.newActiveContextId : state.currentContext
+ };
+ delete newState.contexts[action.contextId];
+ return newState;
+ }
+ default:
+ return state;
}
}
diff --git a/reducers/identify.js b/reducers/identify.js
index f7c2b8d8d..b5211a45d 100644
--- a/reducers/identify.js
+++ b/reducers/identify.js
@@ -8,10 +8,20 @@
import {SET_IDENTIFY_TOOL} from '../actions/identify';
+/**
+ * @typedef {object} IdentifyState
+ * @property {string|null} tool the active tool
+ */
+
+/**
+ * @type {IdentifyState}
+ * @private
+ */
const defaultState = {
tool: null
};
+
export default function identify(state = defaultState, action) {
switch (action.type) {
case SET_IDENTIFY_TOOL: {
diff --git a/reducers/index.js b/reducers/index.js
index c1b966050..75a9e459a 100644
--- a/reducers/index.js
+++ b/reducers/index.js
@@ -6,8 +6,24 @@
* LICENSE file in the root directory of this source tree.
*/
+
+/**
+ * The place where we accumulate all reducers.
+ *
+ * @memberof Redux Store
+ */
const ReducerIndex = {
+ /**
+ * The reducers.
+ */
reducers: {},
+
+ /**
+ * Register a reducer.
+ *
+ * @param {string} name - The name of the reducer.
+ * @param {function} reducer - The reducer function.
+ */
register(name, reducer) {
ReducerIndex.reducers[name] = reducer;
}
diff --git a/reducers/layerinfo.js b/reducers/layerinfo.js
index d135ff9e5..1a0013594 100644
--- a/reducers/layerinfo.js
+++ b/reducers/layerinfo.js
@@ -6,16 +6,37 @@
* LICENSE file in the root directory of this source tree.
*/
-import {SET_ACTIVE_LAYERINFO} from '../actions/layerinfo';
+import { SET_ACTIVE_LAYERINFO } from '../actions/layerinfo';
-const defaultState = {};
+/**
+ * @typedef {object} LayerInfoState
+ * @property {string|null} layer - the active layer
+ * @property {string[]|null} sublayer - the active sublayer
+ */
+
+
+/**
+ * @type {LayerInfoState}
+ * @private
+ */
+const defaultState = {
+ layer: null,
+ sublayer: null
+};
-export default function layerInfo(state = defaultState, action) {
+
+export default function layerInfo(
+ state = defaultState, action
+) {
switch (action.type) {
- case SET_ACTIVE_LAYERINFO: {
- return {...state, layer: action.layer, sublayer: action.sublayer};
- }
- default:
- return state;
+ case SET_ACTIVE_LAYERINFO: {
+ return {
+ ...state,
+ layer: action.layer,
+ sublayer: action.sublayer
+ };
+ }
+ default:
+ return state;
}
}
diff --git a/reducers/layers.js b/reducers/layers.js
index b17195884..0dc93a4dc 100644
--- a/reducers/layers.js
+++ b/reducers/layers.js
@@ -8,10 +8,10 @@
*/
import isEmpty from 'lodash.isempty';
-import {UrlParams} from '../utils/PermaLinkUtils';
+import { UrlParams } from '../utils/PermaLinkUtils';
import LayerUtils from '../utils/LayerUtils';
import VectorLayerUtils from '../utils/VectorLayerUtils';
-import {v4 as uuidv4} from 'uuid';
+import { v4 as uuidv4 } from 'uuid';
import {
LayerRole,
SET_LAYER_LOADING,
@@ -34,12 +34,12 @@ import {
function propagateLayerProperty(newlayer, property, value, path = null) {
- Object.assign(newlayer, {[property]: value});
+ Object.assign(newlayer, { [property]: value });
// Don't propagate visibility for mutually exclusive groups
if (newlayer.sublayers && !(property === "visibility" && newlayer.mutuallyExclusive)) {
newlayer.sublayers = newlayer.sublayers.map((sublayer, idx) => {
if (path === null || (!isEmpty(path) && path[0] === idx)) {
- const newsublayer = {...sublayer};
+ const newsublayer = { ...sublayer };
propagateLayerProperty(newsublayer, property, value, path ? path.slice(1) : null);
return newsublayer;
} else {
@@ -49,267 +49,427 @@ function propagateLayerProperty(newlayer, property, value, path = null) {
}
}
+
+/**
+ * @typedef {import('qwc2/typings').LayerData} LayerData
+ */
+
+
+/**
+ * @typedef LayerState
+ * @property {LayerData[]} flat - The flat list of layers.
+ * @property {string|null} swipe - The id of the layer to swipe.
+ */
+
+
+/**
+ * Default state for the layers reducer.
+ * @type {LayerState}
+ * @private
+ */
const defaultState = {
flat: [],
swipe: null
};
+
export default function layers(state = defaultState, action) {
switch (action.type) {
- case SET_LAYER_LOADING: {
- const newLayers = (state.flat || []).map((layer) => {
- return layer.id === action.layerId ? {...layer, loading: action.loading} : layer;
- });
- return {...state, flat: newLayers};
- }
- case CHANGE_LAYER_PROPERTY: {
- const targetLayer = state.flat.find((layer) => {return layer.uuid === action.layerUuid; });
- if (!targetLayer) {
- return state;
+ case SET_LAYER_LOADING: {
+ const newLayers = (state.flat || []).map((layer) => {
+ return layer.id === action.layerId ? {
+ ...layer,
+ loading: action.loading
+ } : layer;
+ });
+ return { ...state, flat: newLayers };
}
- const backgroundVisibilityChanged = targetLayer.role === LayerRole.BACKGROUND && action.property === "visibility";
+ case CHANGE_LAYER_PROPERTY: {
+ const targetLayer = state.flat.find((layer) => {
+ return layer.uuid === action.layerUuid;
+ });
+ if (!targetLayer) {
+ // Silently ignore change requests for non-existing layers.
+ return state;
+ }
- let parent = targetLayer;
- const parentPath = action.sublayerpath.slice(0, action.sublayerpath.length - 1);
- parentPath.forEach(idx => { parent = parent.sublayers[idx]; });
- const mutexVisibilityChanged = parent.mutuallyExclusive && action.property === "visibility";
- if (mutexVisibilityChanged && action.newvalue === false) {
- // Don't allow explicitly hiding item in mutex group - need to toggle other item
- return state;
- }
+ const backgroundVisibilityChanged = (
+ targetLayer.role === LayerRole.BACKGROUND &&
+ action.property === "visibility"
+ );
- const newLayers = (state.flat || []).map((layer) => {
- if (layer.uuid === action.layerUuid) {
+ // Find the layer based on the path.
+ let parent = targetLayer;
+ const parentPath = action.sublayerpath.slice(
+ 0, action.sublayerpath.length - 1
+ );
+ parentPath.forEach(idx => {
+ parent = parent.sublayers[idx];
+ });
- const {newlayer, newsublayer} = LayerUtils.cloneLayer(layer, action.sublayerpath || []);
- newsublayer[action.property] = action.newvalue;
- const recurseDirection = action.recurseDirection;
+ const mutexVisibilityChanged = (
+ parent.mutuallyExclusive &&
+ action.property === "visibility"
+ );
+ if (mutexVisibilityChanged && action.newvalue === false) {
+ // Don't allow explicitly hiding item in
+ // mutex group - need to toggle other item
+ return state;
+ }
- // Handle mutually exclusive groups
- if (mutexVisibilityChanged) {
- let newParent = newlayer;
- parentPath.forEach(index => { newParent = newParent.sublayers[index]; });
- const targetIdx = action.sublayerpath[action.sublayerpath.length - 1];
- newParent.sublayers = newParent.sublayers.map((l, idx) => ({...l, visibility: idx === targetIdx}));
- }
+ const newLayers = (state.flat || []).map((layer) => {
+ if (layer.uuid === action.layerUuid) {
- if (["children", "both"].includes(recurseDirection)) { // recurse to children (except visibility to children in mutex case)
- propagateLayerProperty(newsublayer, action.property, action.newvalue);
- }
- if (["parents", "both"].includes(recurseDirection)) { // recurse to parents
- propagateLayerProperty(newlayer, action.property, action.newvalue, action.sublayerpath);
- }
+ const {
+ newlayer, newsublayer
+ } = LayerUtils.cloneLayer(layer, action.sublayerpath || []);
+ newsublayer[action.property] = action.newvalue;
+ const recurseDirection = action.recurseDirection;
- if (newlayer.type === "wms") {
- Object.assign(newlayer, LayerUtils.buildWMSLayerParams(newlayer));
- }
- if (newlayer.role === LayerRole.BACKGROUND) {
- UrlParams.updateParams({bl: newlayer.visibility ? newlayer.name : ''});
+ // Handle mutually exclusive groups
+ if (mutexVisibilityChanged) {
+ let newParent = newlayer;
+ parentPath.forEach(index => {
+ newParent = newParent.sublayers[index];
+ });
+ const targetIdx = action.sublayerpath[action.sublayerpath.length - 1];
+ newParent.sublayers = newParent.sublayers.map(
+ (l, idx) => ({ ...l, visibility: idx === targetIdx })
+ );
+ }
+
+ if (["children", "both"].includes(recurseDirection)) {
+ // recurse to children (except visibility to
+ // children in mutex case)
+ propagateLayerProperty(
+ newsublayer, action.property, action.newvalue
+ );
+ }
+ if (["parents", "both"].includes(recurseDirection)) {
+ // recurse to parents
+ propagateLayerProperty(
+ newlayer, action.property,
+ action.newvalue, action.sublayerpath
+ );
+ }
+
+ if (newlayer.type === "wms") {
+ Object.assign(
+ newlayer,
+ LayerUtils.buildWMSLayerParams(newlayer)
+ );
+ }
+ if (newlayer.role === LayerRole.BACKGROUND) {
+ UrlParams.updateParams({
+ bl: newlayer.visibility !== false ? newlayer.name : ''
+ });
+ }
+ return newlayer;
+ } else if (
+ layer.role === LayerRole.BACKGROUND &&
+ backgroundVisibilityChanged
+ ) {
+ return { ...layer, visibility: false };
}
- return newlayer;
- } else if (layer.role === LayerRole.BACKGROUND && backgroundVisibilityChanged) {
- return {...layer, visibility: false};
- }
- return layer;
- });
- UrlParams.updateParams({l: LayerUtils.buildWMSLayerUrlParam(newLayers)});
- return {...state, flat: newLayers};
- }
- case SET_LAYER_DIMENSIONS: {
- const newLayers = (state.flat || []).map((layer) => {
- if (layer.id === action.layerId) {
- const newLayer = {...layer, dimensionValues: action.dimensions};
- Object.assign(newLayer, LayerUtils.buildWMSLayerParams(newLayer));
- return newLayer;
- }
- return layer;
- });
- return {...state, flat: newLayers};
- }
- case ADD_LAYER: {
- let newLayers = (state.flat || []).concat();
- const layerId = action.layer.id || uuidv4();
- const newLayer = {
- ...action.layer,
- id: layerId,
- name: action.layer.name || layerId,
- role: action.layer.role || LayerRole.USERLAYER,
- queryable: action.layer.queryable || false,
- visibility: action.layer.visibility !== undefined ? action.layer.visibility : true,
- opacity: action.layer.opacity || 255,
- layertreehidden: action.layer.layertreehidden || action.layer.role > LayerRole.USERLAYER
- };
- LayerUtils.addUUIDs(newLayer);
- if (newLayer.type === "wms") {
- Object.assign(newLayer, LayerUtils.buildWMSLayerParams(newLayer));
- }
- if (action.beforename) {
- newLayers = LayerUtils.insertLayer(newLayers, newLayer, "name", action.beforename);
- } else {
- let inspos = 0;
- if (action.pos === null) {
- for (; inspos < newLayers.length && newLayer.role < newLayers[inspos].role; ++inspos);
- } else {
- inspos = action.pos;
- }
- newLayers.splice(inspos, 0, newLayer);
- }
- UrlParams.updateParams({l: LayerUtils.buildWMSLayerUrlParam(newLayers)});
- if (newLayer.role === LayerRole.BACKGROUND && newLayer.visibility) {
- UrlParams.updateParams({bl: newLayer.name});
- }
- return {...state, flat: newLayers};
- }
- case ADD_LAYER_SEPARATOR: {
- const newLayers = LayerUtils.insertSeparator(state.flat, action.title, action.afterLayerId, action.afterSublayerPath);
- UrlParams.updateParams({l: LayerUtils.buildWMSLayerUrlParam(newLayers)});
- return {...state, flat: newLayers};
- }
- case REMOVE_LAYER: {
- const layer = state.flat.find(l => l.id === action.layerId);
- if (!layer) {
- return state;
- }
- let newLayers = state.flat;
- if (layer.role === LayerRole.BACKGROUND || isEmpty(action.sublayerpath)) {
- newLayers = state.flat.filter(l => l.id !== action.layerId);
- } else {
- newLayers = LayerUtils.removeLayer(state.flat, layer, action.sublayerpath);
+ return layer;
+ });
+ UrlParams.updateParams({
+ l: LayerUtils.buildWMSLayerUrlParam(newLayers)
+ });
+ return { ...state, flat: newLayers };
}
- UrlParams.updateParams({l: LayerUtils.buildWMSLayerUrlParam(newLayers)});
- return {...state, flat: newLayers};
- }
- case ADD_LAYER_FEATURES: {
- const layerId = action.layer.id || uuidv4();
- const newLayers = (state.flat || []).concat();
- const idx = newLayers.findIndex(layer => layer.id === layerId);
- if (idx === -1) {
- const newFeatures = action.features.map(function(f) {
- return {...f, id: f.id || (f.properties || {}).id || uuidv4()};
+
+ case SET_LAYER_DIMENSIONS: {
+ const newLayers = (state.flat || []).map((layer) => {
+ if (layer.id === action.layerId) {
+ const newLayer = {
+ ...layer,
+ dimensionValues: action.dimensions
+ };
+ Object.assign(
+ newLayer, LayerUtils.buildWMSLayerParams(newLayer)
+ );
+ return newLayer;
+ }
+ return layer;
});
+ return { ...state, flat: newLayers };
+ }
+
+ case ADD_LAYER: {
+ let newLayers = (state.flat || []).concat();
+ const layerId = action.layer.id || uuidv4();
const newLayer = {
...action.layer,
id: layerId,
- type: 'vector',
name: action.layer.name || layerId,
- uuid: uuidv4(),
- features: newFeatures,
role: action.layer.role || LayerRole.USERLAYER,
queryable: action.layer.queryable || false,
- visibility: action.layer.visibility || true,
+ visibility: action.layer.visibility !== false,
opacity: action.layer.opacity || 255,
- layertreehidden: action.layer.layertreehidden || action.layer.role > LayerRole.USERLAYER,
- bbox: VectorLayerUtils.computeFeaturesBBox(action.features)
+ layertreehidden: (
+ action.layer.layertreehidden ||
+ action.layer.role > LayerRole.USERLAYER
+ )
};
- let inspos = 0;
- for (; inspos < newLayers.length && newLayer.role < newLayers[inspos].role; ++inspos);
- newLayers.splice(inspos, 0, newLayer);
- } else {
- const addFeatures = action.features.map(f => ({
- ...f, id: f.id || (f.properties || {}).id || uuidv4()
- }));
- const newFeatures = action.clear ? addFeatures : [
- ...(newLayers[idx].features || []).filter(f => !addFeatures.find(g => g.id === f.id)),
- ...addFeatures
- ];
- newLayers[idx] = {...newLayers[idx], features: newFeatures, bbox: VectorLayerUtils.computeFeaturesBBox(newFeatures), rev: action.layer.rev};
- }
- return {...state, flat: newLayers};
- }
- case REMOVE_LAYER_FEATURES: {
- let changed = false;
- const newLayers = (state.flat || []).reduce((result, layer) => {
- if (layer.id === action.layerId) {
- const newFeatures = (layer.features || []).filter(f => action.featureIds.includes(f.id) === false);
- if (!isEmpty(newFeatures) || action.keepEmptyLayer) {
- result.push({...layer, features: newFeatures, bbox: VectorLayerUtils.computeFeaturesBBox(newFeatures)});
- }
- changed = true;
+ LayerUtils.addUUIDs(newLayer);
+ if (newLayer.type === "wms") {
+ Object.assign(
+ newLayer,
+ LayerUtils.buildWMSLayerParams(newLayer)
+ );
+ }
+ if (action.beforename) {
+ newLayers = LayerUtils.insertLayer(
+ newLayers, newLayer, "name", action.beforename
+ );
} else {
- result.push(layer);
+ let inspos = 0;
+ if (action.pos === null) {
+ for (; (
+ inspos < newLayers.length &&
+ newLayer.role < newLayers[inspos].role
+ ); ++inspos);
+ } else {
+ inspos = action.pos;
+ }
+ newLayers.splice(inspos, 0, newLayer);
}
- return result;
- }, []);
- if (changed) {
- return {...state, flat: newLayers};
- } else {
- return state;
+ UrlParams.updateParams({
+ l: LayerUtils.buildWMSLayerUrlParam(newLayers)
+ });
+ if (
+ newLayer.role === LayerRole.BACKGROUND &&
+ newLayer.visibility !== false
+ ) {
+ UrlParams.updateParams({ bl: newLayer.name });
+ }
+ return { ...state, flat: newLayers };
}
- }
- case CLEAR_LAYER: {
- const newLayers = (state.flat || []).map(layer => {
- if (layer.id === action.layerId) {
- return {...layer, features: [], bbox: null};
+
+ case ADD_LAYER_SEPARATOR: {
+ const newLayers = LayerUtils.insertSeparator(
+ state.flat,
+ action.title,
+ action.afterLayerId,
+ action.afterSublayerPath
+ );
+ UrlParams.updateParams({
+ l: LayerUtils.buildWMSLayerUrlParam(newLayers)
+ });
+ return { ...state, flat: newLayers };
+ }
+
+ case REMOVE_LAYER: {
+ const layer = state.flat.find(l => l.id === action.layerId);
+ if (!layer) {
+ return state;
+ }
+ let newLayers = state.flat;
+ if (
+ layer.role === LayerRole.BACKGROUND ||
+ isEmpty(action.sublayerpath)
+ ) {
+ newLayers = state.flat.filter(l => l.id !== action.layerId);
} else {
- return layer;
+ newLayers = LayerUtils.removeLayer(
+ state.flat, layer, action.sublayerpath
+ );
}
- });
- return {...state, flat: newLayers};
- }
- case ADD_THEME_SUBLAYER: {
- const themeLayerIdx = state.flat.findIndex(layer => layer.role === LayerRole.THEME);
- if (themeLayerIdx >= 0) {
- const newLayers = state.flat.slice(0);
- newLayers[themeLayerIdx] = LayerUtils.mergeSubLayers(state.flat[themeLayerIdx], action.layer);
- newLayers[themeLayerIdx].visibility = true;
- Object.assign(newLayers[themeLayerIdx], LayerUtils.buildWMSLayerParams(newLayers[themeLayerIdx]));
- UrlParams.updateParams({l: LayerUtils.buildWMSLayerUrlParam(newLayers)});
- return {...state, flat: newLayers};
+ UrlParams.updateParams({
+ l: LayerUtils.buildWMSLayerUrlParam(newLayers)
+ });
+ return { ...state, flat: newLayers };
}
- return state;
- }
- case REFRESH_LAYER: {
- const newLayers = (state.flat || []).map((layer) => {
- if (action.filter(layer)) {
- return {...layer, rev: +new Date()};
+
+ case ADD_LAYER_FEATURES: {
+ const layerId = action.layer.id || uuidv4();
+ const newLayers = (state.flat || []).concat();
+ const idx = newLayers.findIndex(layer => layer.id === layerId);
+ if (idx === -1) {
+ const newFeatures = action.features.map(function (f) {
+ return { ...f, id: f.id || (f.properties || {}).id || uuidv4() };
+ });
+ const newLayer = {
+ ...action.layer,
+ id: layerId,
+ type: 'vector',
+ name: action.layer.name || layerId,
+ uuid: uuidv4(),
+ features: newFeatures,
+ role: action.layer.role || LayerRole.USERLAYER,
+ queryable: action.layer.queryable || false,
+ visibility: action.layer.visibility !== false,
+ opacity: action.layer.opacity || 255,
+ layertreehidden: (
+ action.layer.layertreehidden ||
+ action.layer.role > LayerRole.USERLAYER
+ ),
+ bbox: VectorLayerUtils.computeFeaturesBBox(
+ action.features
+ )
+ };
+ let inspos = 0;
+ for (; (
+ inspos < newLayers.length &&
+ newLayer.role < newLayers[inspos].role
+ ); ++inspos);
+ newLayers.splice(inspos, 0, newLayer);
+ } else {
+ const addFeatures = action.features.map(f => ({
+ ...f, id: f.id || (f.properties || {}).id || uuidv4()
+ }));
+ const newFeatures = action.clear ? addFeatures : [
+ ...(newLayers[idx].features || []).filter(
+ f => !addFeatures.find(g => g.id === f.id)
+ ),
+ ...addFeatures
+ ];
+ newLayers[idx] = {
+ ...newLayers[idx],
+ features: newFeatures,
+ bbox: VectorLayerUtils.computeFeaturesBBox(newFeatures),
+ rev: action.layer.rev
+ };
}
- return layer;
- });
- return {...state, flat: newLayers};
- }
- case REMOVE_ALL_LAYERS: {
- return {...state, flat: [], swipe: null};
- }
- case REORDER_LAYER: {
- const newLayers = LayerUtils.reorderLayer(state.flat, action.layer, action.sublayerpath, action.direction, action.preventSplittingGroups);
- UrlParams.updateParams({l: LayerUtils.buildWMSLayerUrlParam(newLayers)});
- return {...state, flat: newLayers};
- }
- case REPLACE_PLACEHOLDER_LAYER: {
- let newLayers = state.flat || [];
- if (action.layer) {
- newLayers = newLayers.map(layer => {
- if (layer.type === 'placeholder' && layer.id === action.id) {
- const newLayer = {
- ...layer,
- ...action.layer,
- role: layer.role,
- id: layer.id,
- uuid: layer.uuid
- };
- delete newLayer.loading;
- LayerUtils.addUUIDs(newLayer);
- if (newLayer.type === "wms") {
- Object.assign(newLayer, LayerUtils.buildWMSLayerParams(newLayer));
+ return { ...state, flat: newLayers };
+ }
+ case REMOVE_LAYER_FEATURES: {
+ let changed = false;
+ const newLayers = (state.flat || []).reduce((result, layer) => {
+ if (layer.id === action.layerId) {
+ const newFeatures = (layer.features || []).filter(
+ f => action.featureIds.includes(f.id) === false
+ );
+ if (!isEmpty(newFeatures) || action.keepEmptyLayer) {
+ result.push({
+ ...layer,
+ features: newFeatures,
+ bbox: VectorLayerUtils.computeFeaturesBBox(newFeatures)
+ });
}
- return newLayer;
+ changed = true;
+ } else {
+ result.push(layer);
+ }
+ return result;
+ }, []);
+ if (changed) {
+ return { ...state, flat: newLayers };
+ } else {
+ return state;
+ }
+ }
+
+ case CLEAR_LAYER: {
+ const newLayers = (state.flat || []).map(layer => {
+ if (layer.id === action.layerId) {
+ return { ...layer, features: [], bbox: null };
} else {
return layer;
}
});
- } else {
- newLayers = newLayers.filter(layer => !(layer.type === 'placeholder' && layer.id === action.id));
+ return { ...state, flat: newLayers };
}
- UrlParams.updateParams({l: LayerUtils.buildWMSLayerUrlParam(newLayers)});
- return {...state, flat: newLayers};
- }
- case SET_SWIPE: {
- return {...state, swipe: action.swipe};
- }
- case SET_LAYERS: {
- return {...state, flat: action.layers};
- }
- default:
- return state;
+
+ case ADD_THEME_SUBLAYER: {
+ const themeLayerIdx = state.flat.findIndex(
+ layer => layer.role === LayerRole.THEME
+ );
+ if (themeLayerIdx >= 0) {
+ const newLayers = state.flat.slice(0);
+ newLayers[themeLayerIdx] = LayerUtils.mergeSubLayers(
+ state.flat[themeLayerIdx], action.layer
+ );
+ newLayers[themeLayerIdx].visibility = true;
+ Object.assign(
+ newLayers[themeLayerIdx],
+ LayerUtils.buildWMSLayerParams(newLayers[themeLayerIdx])
+ );
+ UrlParams.updateParams({
+ l: LayerUtils.buildWMSLayerUrlParam(newLayers)
+ });
+ return { ...state, flat: newLayers };
+ }
+ return state;
+ }
+
+ case REFRESH_LAYER: {
+ const newLayers = (state.flat || []).map((layer) => {
+ if (action.filter(layer)) {
+ return { ...layer, rev: +new Date() };
+ }
+ return layer;
+ });
+ return { ...state, flat: newLayers };
+ }
+
+ case REMOVE_ALL_LAYERS: {
+ return { ...state, flat: [], swipe: null };
+ }
+
+ case REORDER_LAYER: {
+ const newLayers = LayerUtils.reorderLayer(
+ state.flat,
+ action.layer,
+ action.sublayerpath,
+ action.direction,
+ action.preventSplittingGroups
+ );
+ UrlParams.updateParams({
+ l: LayerUtils.buildWMSLayerUrlParam(newLayers)
+ });
+ return { ...state, flat: newLayers };
+ }
+
+ case REPLACE_PLACEHOLDER_LAYER: {
+ let newLayers = state.flat || [];
+ if (action.layer) {
+ newLayers = newLayers.map(layer => {
+ if (
+ layer.type === 'placeholder' &&
+ layer.id === action.id
+ ) {
+ const newLayer = {
+ ...layer,
+ ...action.layer,
+ role: layer.role,
+ id: layer.id,
+ uuid: layer.uuid
+ };
+ delete newLayer.loading;
+ LayerUtils.addUUIDs(newLayer);
+ if (newLayer.type === "wms") {
+ Object.assign(
+ newLayer,
+ LayerUtils.buildWMSLayerParams(newLayer)
+ );
+ }
+ return newLayer;
+ } else {
+ return layer;
+ }
+ });
+ } else {
+ newLayers = newLayers.filter(
+ layer => !(
+ layer.type === 'placeholder' &&
+ layer.id === action.id
+ )
+ );
+ }
+ UrlParams.updateParams({
+ l: LayerUtils.buildWMSLayerUrlParam(newLayers)
+ });
+ return { ...state, flat: newLayers };
+ }
+ case SET_SWIPE: {
+ return { ...state, swipe: action.swipe };
+ }
+ case SET_LAYERS: {
+ return { ...state, flat: action.layers };
+ }
+ default:
+ return state;
}
}
diff --git a/reducers/localConfig.js b/reducers/localConfig.js
index e400d6b12..74ebda61c 100644
--- a/reducers/localConfig.js
+++ b/reducers/localConfig.js
@@ -7,43 +7,68 @@
* LICENSE file in the root directory of this source tree.
*/
-import {LOCAL_CONFIG_LOADED, SET_STARTUP_PARAMETERS, SET_COLOR_SCHEME} from '../actions/localConfig';
+import {
+ LOCAL_CONFIG_LOADED,
+ SET_STARTUP_PARAMETERS,
+ SET_COLOR_SCHEME,
+ SET_USER_INFO_FIELDS
+} from '../actions/localConfig';
import ConfigUtils from '../utils/ConfigUtils';
-import {UrlParams} from '../utils/PermaLinkUtils';
+import { UrlParams } from '../utils/PermaLinkUtils';
+
+/**
+ * @typedef {import("qwc2/typings").ConfigState} ConfigState
+ */
+
+
+/**
+ * @type {ConfigState}
+ * @private
+ */
const defaultState = {
...ConfigUtils.getDefaults(),
startupParams: {},
colorScheme: 'default'
};
+
export default function localConfig(state = defaultState, action) {
switch (action.type) {
- case LOCAL_CONFIG_LOADED: {
- return {...state, ...action.config};
- }
- case SET_STARTUP_PARAMETERS: {
- return {...state, startupParams: action.params};
- }
- case SET_COLOR_SCHEME: {
- const root = document.querySelector(':root');
- if (state.colorScheme) {
- root.classList.remove(state.colorScheme);
+ case LOCAL_CONFIG_LOADED: {
+ return { ...state, ...action.config };
}
- const newColorScheme = action.colorScheme || state.defaultColorScheme || "default";
- if (newColorScheme) {
- root.classList.add(newColorScheme);
+ case SET_STARTUP_PARAMETERS: {
+ return { ...state, startupParams: action.params };
}
- if (UrlParams.getParam("style")) {
- UrlParams.updateParams({style: newColorScheme});
+ case SET_COLOR_SCHEME: {
+ const root = document.querySelector(':root');
+ if (state.colorScheme) {
+ root.classList.remove(state.colorScheme);
+ }
+ const newColorScheme = action.colorScheme || state.defaultColorScheme || "default";
+ if (newColorScheme) {
+ root.classList.add(newColorScheme);
+ }
+ if (UrlParams.getParam("style")) {
+ UrlParams.updateParams({ style: newColorScheme });
+ }
+ if (action.storeInLocalStorage) {
+ localStorage.setItem('qwc2-color-scheme', newColorScheme);
+ }
+ return { ...state, colorScheme: newColorScheme };
}
- if (action.storeInLocalStorage) {
- localStorage.setItem('qwc2-color-scheme', newColorScheme);
+ case SET_USER_INFO_FIELDS: {
+ return {
+ ...state,
+ user_infos: {
+ ...state.user_infos,
+ ...action.fields
+ }
+ };
}
- return {...state, colorScheme: newColorScheme};
- }
- default:
- return state;
+ default:
+ return state;
}
}
diff --git a/reducers/locale.js b/reducers/locale.js
index 09be89b1f..0cd77f177 100644
--- a/reducers/locale.js
+++ b/reducers/locale.js
@@ -7,9 +7,21 @@
* LICENSE file in the root directory of this source tree.
*/
-import {CHANGE_LOCALE} from '../actions/locale';
+import { CHANGE_LOCALE } from '../actions/locale';
import flatten from 'flat';
+
+/**
+ * @typedef {object} LocaleState
+ * @property {Record
} messages - the current locale messages
+ * @property {string} current - the identifier for current locale
+ */
+
+
+/**
+ * @type {LocaleState}
+ * @private
+ */
const defaultState = {
messages: {},
current: ''
@@ -17,13 +29,13 @@ const defaultState = {
export default function locale(state = defaultState, action) {
switch (action.type) {
- case CHANGE_LOCALE: {
- return {
- messages: flatten(action.messages),
- current: action.locale
- };
- }
- default:
- return state;
+ case CHANGE_LOCALE: {
+ return {
+ messages: flatten(action.messages),
+ current: action.locale
+ };
+ }
+ default:
+ return state;
}
}
diff --git a/reducers/locate.js b/reducers/locate.js
index 499bc70f3..ffc2c698c 100644
--- a/reducers/locate.js
+++ b/reducers/locate.js
@@ -7,8 +7,24 @@
* LICENSE file in the root directory of this source tree.
*/
-import {CHANGE_LOCATE_STATE, CHANGE_LOCATE_POSITION, LOCATE_ERROR} from '../actions/locate';
+import {
+ CHANGE_LOCATE_STATE,
+ CHANGE_LOCATE_POSITION,
+ LOCATE_ERROR
+} from '../actions/locate';
+
+/**
+ * @typedef {object} LocateState
+ * @property {string} state - the current locate state
+ * @property {object} position - the current locate position
+ */
+
+
+/**
+ * @type {LocateState}
+ * @private
+ */
const defaultState = {
state: "DISABLED",
position: null
@@ -16,16 +32,16 @@ const defaultState = {
export default function locate(state = defaultState, action) {
switch (action.type) {
- case CHANGE_LOCATE_STATE: {
- return {...state, state: action.state};
- }
- case CHANGE_LOCATE_POSITION: {
- return {...state, position: action.position};
- }
- case LOCATE_ERROR: {
- return {...state, error: action.error};
- }
- default:
- return state;
+ case CHANGE_LOCATE_STATE: {
+ return { ...state, state: action.state };
+ }
+ case CHANGE_LOCATE_POSITION: {
+ return { ...state, position: action.position };
+ }
+ case LOCATE_ERROR: {
+ return { ...state, error: action.error };
+ }
+ default:
+ return state;
}
}
diff --git a/reducers/map.js b/reducers/map.js
index 4617ffc0b..243e7906c 100644
--- a/reducers/map.js
+++ b/reducers/map.js
@@ -13,12 +13,27 @@ import {
} from '../actions/map';
import isEmpty from 'lodash.isempty';
import MapUtils from '../utils/MapUtils';
-import {UrlParams} from '../utils/PermaLinkUtils';
+import { UrlParams } from '../utils/PermaLinkUtils';
import ConfigUtils from '../utils/ConfigUtils';
import CoordinatesUtils from '../utils/CoordinatesUtils';
+/**
+ * @typedef {import('qwc2/typings').MapState} MapState
+ */
+
+
+/**
+ * State of map in the redux store.
+ *
+ * TODO: This assumes that there is only one map in the application;
+ * either use a key to identify the map or offer the ability to
+ * host this data in a context component.
+ *
+ * @type {MapState}
+ * @private
+ */
const defaultState = {
- bbox: {bounds: [0, 0, 0, 0], rotation: 0},
+ bbox: { bounds: [0, 0, 0, 0], rotation: 0 },
center: [0, 0],
dpi: MapUtils.DEFAULT_SCREEN_DPI,
projection: "EPSG:3857",
@@ -33,113 +48,176 @@ const defaultState = {
}
};
+
export default function map(state = defaultState, action) {
// Always reset mapStateSource, CHANGE_MAP_VIEW will set it if necessary
if (state.mapStateSource) {
- state = {...state, mapStateSource: null};
+ state = { ...state, mapStateSource: null };
}
switch (action.type) {
- case CHANGE_MAP_VIEW: {
- const {type, ...params} = action;
- const newState = {...state, ...params};
+ case CHANGE_MAP_VIEW: {
+ const { type, ...params } = action;
+ const newState = { ...state, ...params };
- const newParams = {};
- const positionFormat = ConfigUtils.getConfigProp("urlPositionFormat");
- const positionCrs = ConfigUtils.getConfigProp("urlPositionCrs") || newState.projection;
- const prec = CoordinatesUtils.getUnits(positionCrs) === 'degrees' ? 4 : 0;
- if (positionFormat === "centerAndZoom") {
- const center = CoordinatesUtils.reproject(newState.center, newState.projection, positionCrs);
- const scale = Math.round(MapUtils.computeForZoom(newState.scales, newState.zoom));
- Object.assign(newParams, {c: center.map(x => x.toFixed(prec)).join(","), s: scale});
- } else {
- const bounds = CoordinatesUtils.reprojectBbox(newState.bbox.bounds, newState.projection, positionCrs);
- Object.assign(newParams, {e: bounds.map(x => x.toFixed(prec)).join(",")});
- }
- if (positionCrs !== newState.projection) {
- Object.assign(newParams, {crs: positionCrs});
+ const newParams = {};
+ const positionFormat = ConfigUtils.getConfigProp(
+ "urlPositionFormat"
+ );
+ const positionCrs = ConfigUtils.getConfigProp(
+ "urlPositionCrs"
+ ) || newState.projection;
+ const prec = CoordinatesUtils.getUnits(
+ positionCrs
+ ) === 'degrees' ? 4 : 0;
+ if (positionFormat === "centerAndZoom") {
+ const center = CoordinatesUtils.reproject(
+ newState.center, newState.projection, positionCrs
+ );
+ const scale = Math.round(
+ MapUtils.computeForZoom(newState.scales, newState.zoom)
+ );
+ Object.assign(newParams, {
+ c: center.map(x => x.toFixed(prec)).join(","),
+ s: scale
+ });
+ } else {
+ const bounds = CoordinatesUtils.reprojectBbox(
+ newState.bbox.bounds, newState.projection, positionCrs
+ );
+ Object.assign(newParams, {
+ e: bounds.map(x => x.toFixed(prec)).join(",")
+ });
+ }
+ if (positionCrs !== newState.projection) {
+ Object.assign(newParams, { crs: positionCrs });
+ }
+ if (!isEmpty(newParams)) {
+ UrlParams.updateParams(newParams);
+ }
+
+ return newState;
}
- if (!isEmpty(newParams)) {
- UrlParams.updateParams(newParams);
+
+ case CONFIGURE_MAP: {
+ const resolutions = MapUtils.getResolutionsForScales(
+ action.scales, action.crs, state.dpi
+ );
+ let bounds;
+ let center;
+ let zoom;
+ if (action.view.center) {
+ center = CoordinatesUtils.reproject(
+ action.view.center,
+ action.view.crs || action.crs,
+ action.crs
+ );
+ zoom = action.view.zoom;
+ bounds = MapUtils.getExtentForCenterAndZoom(
+ center, zoom, resolutions, state.size
+ );
+ } else {
+ bounds = CoordinatesUtils.reprojectBbox(
+ action.view.bounds,
+ action.view.crs || state.projection,
+ action.crs
+ );
+ center = [
+ 0.5 * (bounds[0] + bounds[2]),
+ 0.5 * (bounds[1] + bounds[3])
+ ];
+ zoom = MapUtils.getZoomForExtent(
+ bounds, resolutions,
+ state.size, 0,
+ action.scales.length - 1
+ );
+ }
+ return {
+ ...state,
+ bbox: { ...state.bbox, bounds: bounds },
+ center: center,
+ zoom: zoom,
+ projection: action.crs,
+ scales: action.scales,
+ resolutions: resolutions
+ };
}
- return newState;
- }
- case CONFIGURE_MAP: {
- const resolutions = MapUtils.getResolutionsForScales(action.scales, action.crs, state.dpi);
- let bounds;
- let center;
- let zoom;
- if (action.view.center) {
- center = CoordinatesUtils.reproject(action.view.center, action.view.crs || action.crs, action.crs);
- zoom = action.view.zoom;
- bounds = MapUtils.getExtentForCenterAndZoom(center, zoom, resolutions, state.size);
- } else {
- bounds = CoordinatesUtils.reprojectBbox(action.view.bounds, action.view.crs || state.projection, action.crs);
- center = [0.5 * (bounds[0] + bounds[2]), 0.5 * (bounds[1] + bounds[3])];
- zoom = MapUtils.getZoomForExtent(bounds, resolutions, state.size, 0, action.scales.length - 1);
+ case CHANGE_ZOOM_LVL: {
+ return { ...state, zoom: action.zoom };
}
- return {
- ...state,
- bbox: {...state.bbox, bounds: bounds},
- center: center,
- zoom: zoom,
- projection: action.crs,
- scales: action.scales,
- resolutions: resolutions
- };
- }
- case CHANGE_ZOOM_LVL: {
- return {...state, zoom: action.zoom};
- }
- case ZOOM_TO_EXTENT: {
- const bounds = CoordinatesUtils.reprojectBbox(action.extent, action.crs || state.projection, state.projection);
- const padding = (state.topbarHeight + 10) / state.size.height;
- const width = bounds[2] - bounds[0];
- const height = bounds[3] - bounds[1];
- bounds[0] -= padding * width;
- bounds[2] += padding * width;
- bounds[1] -= padding * height;
- bounds[3] += padding * height;
- return {
- ...state,
- center: [0.5 * (bounds[0] + bounds[2]), 0.5 * (bounds[1] + bounds[3])],
- zoom: MapUtils.getZoomForExtent(bounds, state.resolutions, state.size, 0, state.scales.length - 1) + action.zoomOffset,
- bbox: {...state.bbox, bounds: bounds}
- };
- }
- case ZOOM_TO_POINT: {
- return {
- ...state,
- center: CoordinatesUtils.reproject(action.pos, action.crs || state.projection, state.projection),
- zoom: action.zoom
- };
- }
- case PAN_TO: {
- return {
- ...state,
- center: CoordinatesUtils.reproject(action.pos, action.crs || state.projection, state.projection)
- };
- }
- case CHANGE_ROTATION: {
- return {
- ...state,
- bbox: {...state.bbox, rotation: action.rotation}
- };
- }
- case CLICK_ON_MAP: {
- return {...state, click: action.click};
- }
- case TOGGLE_MAPTIPS: {
- return {...state, maptips: action.active};
- }
- case SET_TOPBAR_HEIGHT: {
- return {...state, topbarHeight: action.height};
- }
- case SET_SNAPPING_CONFIG: {
- return {...state, snapping: {enabled: action.enabled, active: action.active}};
- }
- default:
- return state;
+ case ZOOM_TO_EXTENT: {
+ const bounds = CoordinatesUtils.reprojectBbox(
+ action.extent,
+ action.crs || state.projection,
+ state.projection
+ );
+ const padding = (state.topbarHeight + 10) / state.size.height;
+ const width = bounds[2] - bounds[0];
+ const height = bounds[3] - bounds[1];
+ bounds[0] -= padding * width;
+ bounds[2] += padding * width;
+ bounds[1] -= padding * height;
+ bounds[3] += padding * height;
+ return {
+ ...state,
+ center: [
+ 0.5 * (bounds[0] + bounds[2]),
+ 0.5 * (bounds[1] + bounds[3])
+ ],
+ zoom: MapUtils.getZoomForExtent(
+ bounds, state.resolutions, state.size,
+ 0, state.scales.length - 1
+ ) + action.zoomOffset,
+ bbox: { ...state.bbox, bounds: bounds }
+ };
+ }
+ case ZOOM_TO_POINT: {
+ return {
+ ...state,
+ center: CoordinatesUtils.reproject(
+ action.pos,
+ action.crs || state.projection,
+ state.projection
+ ),
+ zoom: action.zoom
+ };
+ }
+ case PAN_TO: {
+ return {
+ ...state,
+ center: CoordinatesUtils.reproject(
+ action.pos,
+ action.crs || state.projection,
+ state.projection
+ )
+ };
+ }
+ case CHANGE_ROTATION: {
+ return {
+ ...state,
+ bbox: { ...state.bbox, rotation: action.rotation }
+ };
+ }
+ case CLICK_ON_MAP: {
+ return { ...state, click: action.click };
+ }
+ case TOGGLE_MAPTIPS: {
+ return { ...state, maptips: action.active };
+ }
+ case SET_TOPBAR_HEIGHT: {
+ return { ...state, topbarHeight: action.height };
+ }
+ case SET_SNAPPING_CONFIG: {
+ return {
+ ...state,
+ snapping: {
+ enabled: action.enabled,
+ active: action.active
+ }
+ };
+ }
+ default:
+ return state;
}
}
diff --git a/reducers/measurement.js b/reducers/measurement.js
index 401b17080..084e5b295 100644
--- a/reducers/measurement.js
+++ b/reducers/measurement.js
@@ -23,7 +23,11 @@ const defaultState = {
export default function measurement(state = defaultState, action) {
switch (action.type) {
case CHANGE_MEASUREMENT_STATE: {
- return {lenUnit: state.lenUnit, areaUnit: state.areaUnit, ...action.data};
+ return {
+ lenUnit: state.lenUnit,
+ areaUnit: state.areaUnit,
+ ...action.data
+ };
}
default:
return state;
diff --git a/reducers/mousePosition.js b/reducers/mousePosition.js
index e02d6757d..a6a744943 100644
--- a/reducers/mousePosition.js
+++ b/reducers/mousePosition.js
@@ -7,7 +7,7 @@
* LICENSE file in the root directory of this source tree.
*/
-import {CHANGE_MOUSE_POSITION_STATE} from '../actions/mousePosition';
+import { CHANGE_MOUSE_POSITION_STATE } from '../actions/mousePosition';
const defaultState = {
enabled: true
@@ -15,10 +15,10 @@ const defaultState = {
export default function mousePosition(state = defaultState, action) {
switch (action.type) {
- case CHANGE_MOUSE_POSITION_STATE: {
- return {...state, ...action.data};
- }
- default:
- return state;
+ case CHANGE_MOUSE_POSITION_STATE: {
+ return { ...state, ...action.data };
+ }
+ default:
+ return state;
}
}
diff --git a/reducers/processNotifications.js b/reducers/processNotifications.js
index 5c7f2c2c0..c4992c784 100644
--- a/reducers/processNotifications.js
+++ b/reducers/processNotifications.js
@@ -19,41 +19,43 @@ const defaultState = {
export default function processNotifications(state = defaultState, action) {
switch (action.type) {
- case PROCESS_STARTED: {
- return {
- ...state,
- processes: {
- ...state.processes,
- [action.id]: {
- id: action.id,
- name: action.name,
- status: ProcessStatus.BUSY
+ case PROCESS_STARTED: {
+ return {
+ ...state,
+ processes: {
+ ...state.processes,
+ [action.id]: {
+ id: action.id,
+ name: action.name,
+ status: ProcessStatus.BUSY
+ }
}
- }
- };
- }
- case PROCESS_FINISHED: {
- return {
- ...state,
- processes: {
- ...state.processes,
- [action.id]: {
- ...state.processes[action.id],
- status: action.success ? ProcessStatus.SUCCESS : ProcessStatus.FAILURE,
- message: action.message
+ };
+ }
+ case PROCESS_FINISHED: {
+ return {
+ ...state,
+ processes: {
+ ...state.processes,
+ [action.id]: {
+ ...state.processes[action.id],
+ status: action.success
+ ? ProcessStatus.SUCCESS
+ : ProcessStatus.FAILURE,
+ message: action.message
+ }
}
- }
- };
- }
- case CLEAR_PROCESS: {
- const newState = {
- ...state,
- processes: {...state.processes}
- };
- delete newState.processes[action.id];
- return newState;
- }
- default:
- return state;
+ };
+ }
+ case CLEAR_PROCESS: {
+ const newState = {
+ ...state,
+ processes: { ...state.processes }
+ };
+ delete newState.processes[action.id];
+ return newState;
+ }
+ default:
+ return state;
}
}
diff --git a/reducers/redlining.js b/reducers/redlining.js
index 121f635ec..720f70289 100644
--- a/reducers/redlining.js
+++ b/reducers/redlining.js
@@ -6,7 +6,7 @@
* LICENSE file in the root directory of this source tree.
*/
-import {CHANGE_REDLINING_STATE} from '../actions/redlining';
+import { CHANGE_REDLINING_STATE } from '../actions/redlining';
const defaultState = {
action: null,
@@ -29,10 +29,10 @@ const defaultState = {
export default function redlining(state = defaultState, action) {
switch (action.type) {
- case CHANGE_REDLINING_STATE: {
- return {...state, ...action.data};
- }
- default:
- return state;
+ case CHANGE_REDLINING_STATE: {
+ return { ...state, ...action.data };
+ }
+ default:
+ return state;
}
}
diff --git a/reducers/redliningPick.js b/reducers/redliningPick.js
index 1de3fce39..1b59138a8 100644
--- a/reducers/redliningPick.js
+++ b/reducers/redliningPick.js
@@ -6,7 +6,7 @@
* LICENSE file in the root directory of this source tree.
*/
-import {CHANGE_REDLINING_PICK_STATE} from '../actions/redliningPick';
+import { CHANGE_REDLINING_PICK_STATE } from '../actions/redliningPick';
const defaultState = {
active: false,
@@ -16,10 +16,10 @@ const defaultState = {
export default function redliningPick(state = defaultState, action) {
switch (action.type) {
- case CHANGE_REDLINING_PICK_STATE: {
- return {...state, ...action.data};
- }
- default:
- return state;
+ case CHANGE_REDLINING_PICK_STATE: {
+ return { ...state, ...action.data };
+ }
+ default:
+ return state;
}
}
diff --git a/reducers/search.js b/reducers/search.js
index 031bcd455..9d5843962 100644
--- a/reducers/search.js
+++ b/reducers/search.js
@@ -13,7 +13,7 @@ import {
CLEAR_SEARCH,
SEARCH_SET_CURRENT_RESULT
} from '../actions/search';
-import {UrlParams} from '../utils/PermaLinkUtils';
+import { UrlParams } from '../utils/PermaLinkUtils';
const defaultState = {
text: '',
@@ -22,32 +22,52 @@ const defaultState = {
export default function search(state = defaultState, action) {
switch (action.type) {
- case CLEAR_SEARCH: {
- return {...state, text: '', currentResult: null};
- }
- case SEARCH_CHANGE: {
- UrlParams.updateParams({st: action.text || undefined, sp: action.providers ? action.providers.join(",") : undefined});
- return {text: action.text, providers: action.providers};
- }
- case SEARCH_SET_REQUEST: {
- return {...state, requestId: action.id, pendingProviders: action.providers, startup: action.startup, results: []};
- }
- case SEARCH_ADD_RESULTS: {
- if (state.requestId !== action.reqId || !(state.pendingProviders || []).includes(action.provider)) {
- return state;
+ case CLEAR_SEARCH: {
+ return { ...state, text: '', currentResult: null };
}
- const results = [...state.results, ...action.results];
- results.sort((a, b) => {
- return (b.priority || 0) - (a.priority || 0);
- });
- const pendingProviders = state.pendingProviders.slice(0);
- pendingProviders.splice(pendingProviders.indexOf(action.provider), 1);
- return {...state, results: results, pendingProviders: pendingProviders};
- }
- case SEARCH_SET_CURRENT_RESULT: {
- return {...state, currentResult: action.result};
- }
- default:
- return state;
+ case SEARCH_CHANGE: {
+ UrlParams.updateParams({
+ st: action.text || undefined,
+ sp: action.providers
+ ? action.providers.join(",")
+ : undefined
+ });
+ return { text: action.text, providers: action.providers };
+ }
+ case SEARCH_SET_REQUEST: {
+ return {
+ ...state,
+ requestId: action.id,
+ pendingProviders: action.providers,
+ startup: action.startup,
+ results: []
+ };
+ }
+ case SEARCH_ADD_RESULTS: {
+ if (
+ state.requestId !== action.reqId ||
+ !(state.pendingProviders || []).includes(action.provider)
+ ) {
+ return state;
+ }
+ const results = [...state.results, ...action.results];
+ results.sort((a, b) => {
+ return (b.priority || 0) - (a.priority || 0);
+ });
+ const pendingProviders = state.pendingProviders.slice(0);
+ pendingProviders.splice(
+ pendingProviders.indexOf(action.provider), 1
+ );
+ return {
+ ...state,
+ results: results,
+ pendingProviders: pendingProviders
+ };
+ }
+ case SEARCH_SET_CURRENT_RESULT: {
+ return { ...state, currentResult: action.result };
+ }
+ default:
+ return state;
}
}
diff --git a/reducers/selection.js b/reducers/selection.js
index b883c9958..14162c6f4 100644
--- a/reducers/selection.js
+++ b/reducers/selection.js
@@ -6,7 +6,7 @@
* LICENSE file in the root directory of this source tree.
*/
-import {CHANGE_SELECTION_STATE} from '../actions/selection';
+import { CHANGE_SELECTION_STATE } from '../actions/selection';
const defaultState = {
geomType: null,
@@ -17,22 +17,22 @@ const defaultState = {
export default function selection(state = defaultState, action) {
switch (action.type) {
- case CHANGE_SELECTION_STATE: {
- return {
- ...state,
- geomType: action.geomType,
- box: action.box,
- circle: action.circle,
- point: action.point,
- line: action.line,
- polygon: action.polygon,
- style: action.style || 'default',
- styleOptions: action.styleOptions || {},
- cursor: action.cursor || null,
- reset: action.reset || false
- };
- }
- default:
- return state;
+ case CHANGE_SELECTION_STATE: {
+ return {
+ ...state,
+ geomType: action.geomType,
+ box: action.box,
+ circle: action.circle,
+ point: action.point,
+ line: action.line,
+ polygon: action.polygon,
+ style: action.style || 'default',
+ styleOptions: action.styleOptions || {},
+ cursor: action.cursor || null,
+ reset: action.reset || false
+ };
+ }
+ default:
+ return state;
}
}
diff --git a/reducers/serviceinfo.js b/reducers/serviceinfo.js
index cb9aaff1e..6849c847f 100644
--- a/reducers/serviceinfo.js
+++ b/reducers/serviceinfo.js
@@ -6,16 +6,16 @@
* LICENSE file in the root directory of this source tree.
*/
-import {SET_ACTIVE_SERVICEINFO} from '../actions/serviceinfo';
+import { SET_ACTIVE_SERVICEINFO } from '../actions/serviceinfo';
const defaultState = {};
export default function serviceInfo(state = defaultState, action) {
switch (action.type) {
- case SET_ACTIVE_SERVICEINFO: {
- return {...state, service: action.service};
- }
- default:
- return state;
+ case SET_ACTIVE_SERVICEINFO: {
+ return { ...state, service: action.service };
+ }
+ default:
+ return state;
}
}
diff --git a/reducers/task.js b/reducers/task.js
index 57b5707ad..e5809b2a9 100644
--- a/reducers/task.js
+++ b/reducers/task.js
@@ -6,7 +6,7 @@
* LICENSE file in the root directory of this source tree.
*/
-import {SET_CURRENT_TASK, SET_CURRENT_TASK_BLOCKED} from '../actions/task';
+import { SET_CURRENT_TASK, SET_CURRENT_TASK_BLOCKED } from '../actions/task';
const defaultState = {
id: null,
@@ -18,16 +18,22 @@ const defaultState = {
export default function task(state = defaultState, action) {
switch (action.type) {
- case SET_CURRENT_TASK: {
- if (state.blocked) {
- return state;
+ case SET_CURRENT_TASK: {
+ if (state.blocked) {
+ return state;
+ }
+ return {
+ ...state,
+ id: action.id,
+ mode: action.mode,
+ data: action.data,
+ unsetOnMapClick: action.unsetOnMapClick
+ };
}
- return {...state, id: action.id, mode: action.mode, data: action.data, unsetOnMapClick: action.unsetOnMapClick};
- }
- case SET_CURRENT_TASK_BLOCKED: {
- return {...state, blocked: action.blocked};
- }
- default:
- return state;
+ case SET_CURRENT_TASK_BLOCKED: {
+ return { ...state, blocked: action.blocked };
+ }
+ default:
+ return state;
}
}
diff --git a/reducers/theme.js b/reducers/theme.js
index f95ce9388..286901083 100644
--- a/reducers/theme.js
+++ b/reducers/theme.js
@@ -12,26 +12,35 @@ import {
SET_CURRENT_THEME,
SWITCHING_THEME
} from '../actions/theme';
-import {UrlParams} from '../utils/PermaLinkUtils';
+import { UrlParams } from '../utils/PermaLinkUtils';
-const defaultState = {};
+/**
+ * @typedef {object} ThemeState
+ */
+
+const defaultState = {
+ switching: false,
+ themes: {},
+ themelist: {},
+ current: null
+};
export default function theme(state = defaultState, action) {
switch (action.type) {
- case SWITCHING_THEME: {
- return {...state, switching: action.switching};
- }
- case THEMES_LOADED: {
- return {...state, themes: action.themes};
- }
- case SET_THEME_LAYERS_LIST: {
- return {...state, themelist: action.themelist};
- }
- case SET_CURRENT_THEME: {
- UrlParams.updateParams({t: action.theme.id});
- return {...state, current: action.theme};
- }
- default:
- return state;
+ case SWITCHING_THEME: {
+ return { ...state, switching: action.switching };
+ }
+ case THEMES_LOADED: {
+ return { ...state, themes: action.themes };
+ }
+ case SET_THEME_LAYERS_LIST: {
+ return { ...state, themelist: action.themelist };
+ }
+ case SET_CURRENT_THEME: {
+ UrlParams.updateParams({ t: action.theme.id });
+ return { ...state, current: action.theme };
+ }
+ default:
+ return state;
}
}
diff --git a/reducers/windows.js b/reducers/windows.js
index fa9b372e5..6f335eea2 100644
--- a/reducers/windows.js
+++ b/reducers/windows.js
@@ -25,78 +25,96 @@ const defaultState = {
export default function windows(state = defaultState, action) {
switch (action.type) {
- case SHOW_IFRAME_DIALOG: {
- return {
- ...state,
- entries: {
- ...state.entries,
- [action.name]: {type: 'iframedialog', url: action.url, options: action.options || {}}
- }
- };
- }
- case SHOW_NOTIFICATION: {
- return {
- ...state,
- entries: {
- ...state.entries,
- [action.name]: {type: 'notification', text: action.text}
- }
- };
- }
- case CLOSE_WINDOW: {
- const newState = {
- ...state,
- entries: {...state.entries}
- };
- delete newState.entries[action.name];
- return newState;
- }
- case CLOSE_ALL_WINDOWS: {
- return {
- ...state,
- entries: {}
- };
- }
- case REGISTER_WINDOW: {
- return {
- ...state,
- stacking: [...state.stacking, action.id]
- };
- }
- case UNREGISTER_WINDOW: {
- return {
- ...state,
- stacking: state.stacking.filter(x => x !== action.id)
- };
- }
- case RAISE_WINDOW: {
- return {
- ...state,
- stacking: [...state.stacking.filter(x => x !== action.id), action.id]
- };
- }
- case SET_SPLIT_SCREEN: {
- if (action.side === null) {
- const newSplitScreen = {...state.splitScreen};
- delete newSplitScreen[action.windowId];
+ case SHOW_IFRAME_DIALOG: {
return {
...state,
- splitScreen: newSplitScreen
+ entries: {
+ ...state.entries,
+ [action.name]: {
+ type: 'iframedialog',
+ url: action.url,
+ options: action.options || {}
+ }
+ }
};
- } else {
+ }
+ case SHOW_NOTIFICATION: {
return {
...state,
- splitScreen: {
- ...state.splitScreen,
- [action.windowId]: {
- side: action.side,
- size: action.size
+ entries: {
+ ...state.entries,
+ [action.name]: {
+ type: 'notification',
+ text: action.text,
+ notificationType: action.notificationType,
+ sticky: action.sticky
}
}
};
}
- }
- default:
- return state;
+ case CLOSE_WINDOW: {
+ const newState = {
+ ...state,
+ entries: { ...state.entries }
+ };
+ delete newState.entries[action.name];
+ return newState;
+ }
+ case CLOSE_ALL_WINDOWS: {
+ return {
+ ...state,
+ entries: Object.entries(state.entries)
+ .reduce((res, [name, entry]) => {
+ if (entry.sticky) {
+ res[name] = entry;
+ }
+ return res;
+ }, {})
+ };
+ }
+ case REGISTER_WINDOW: {
+ return {
+ ...state,
+ stacking: [...state.stacking, action.id]
+ };
+ }
+ case UNREGISTER_WINDOW: {
+ return {
+ ...state,
+ stacking: state.stacking.filter(x => x !== action.id)
+ };
+ }
+ case RAISE_WINDOW: {
+ return {
+ ...state,
+ stacking: [
+ ...state.stacking.filter(x => x !== action.id),
+ action.id
+ ]
+ };
+ }
+ case SET_SPLIT_SCREEN: {
+ if (action.side === null) {
+ const newSplitScreen = { ...state.splitScreen };
+ delete newSplitScreen[action.windowId];
+ return {
+ ...state,
+ splitScreen: newSplitScreen
+ };
+ } else {
+ return {
+ ...state,
+ splitScreen: {
+ ...state.splitScreen,
+ [action.windowId]: {
+ side: action.side,
+ size: action.size
+ }
+ }
+ };
+ }
+ }
+ default:
+ return state;
}
}
diff --git a/scripts/gen-plugin-docs.js b/scripts/gen-plugin-docs.js
index 0c238b238..d735741f1 100644
--- a/scripts/gen-plugin-docs.js
+++ b/scripts/gen-plugin-docs.js
@@ -1,6 +1,6 @@
-const fs = require("fs");
-const path = require("path");
-const reactDocs = require("react-docgen");
+import fs from 'fs';
+import path from 'path';
+import reactDocs from 'react-docgen';
const qwcPluginDir = './qwc2/plugins';
let pluginData = [];
diff --git a/scripts/makeIconkit.js b/scripts/makeIconkit.js
index 2f24e92ba..9952ed7a3 100644
--- a/scripts/makeIconkit.js
+++ b/scripts/makeIconkit.js
@@ -1,8 +1,9 @@
-const webfontsGenerator = require('@vusion/webfonts-generator');
-const glob = require('glob');
-const mkdirp = require('mkdirp');
-const fs = require('fs');
-const path = require('path');
+import webfontsGenerator from '@vusion/webfonts-generator';
+import glob from 'glob';
+import mkdirp from 'mkdirp';
+import fs from 'fs';
+import path from 'path';
+
const readJSON = (filename) => {
try {
diff --git a/scripts/themesConfig.js b/scripts/themesConfig.js
index e3ca4e86a..031e2e4b0 100644
--- a/scripts/themesConfig.js
+++ b/scripts/themesConfig.js
@@ -5,18 +5,19 @@
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree.
*/
-
-const urlUtil = require('url');
-const axios = require('axios');
-const xml2js = require('xml2js');
-const fs = require('fs');
-const path = require('path');
-const objectPath = require('object-path');
-const isEmpty = require('lodash.isempty');
-const uuidv1 = require('uuid').v1;
-const os = require('os');
-const dns = require('dns');
-
+import urlUtil from 'url';
+import axios from 'axios';
+import xml2js from 'xml2js';
+import fs from 'fs';
+import path from 'path';
+import objectPath from 'object-path';
+import isEmpty from 'lodash.isempty';
+import { v1 as uuidv1 } from 'uuid';
+import os from 'os';
+import dns from 'dns';
+import { createRequire } from 'node:module';
+
+const require = createRequire(import.meta.url);
const { lookup, lookupService } = dns.promises;
let hostFqdn = "";
diff --git a/scripts/updateTranslations.js b/scripts/updateTranslations.js
index 644f4e47b..6fd20a92f 100644
--- a/scripts/updateTranslations.js
+++ b/scripts/updateTranslations.js
@@ -6,9 +6,10 @@
* LICENSE file in the root directory of this source tree.
*/
-const fs = require('fs');
-const merge = require('deepmerge');
-const objectPath = require('object-path');
+import fs from 'fs';
+import merge from 'deepmerge';
+import objectPath from 'object-path';
+
const readJSON = (path) => {
try {
diff --git a/selectors/displaycrs.js b/selectors/displaycrs.js
index 6606af180..44b14bebd 100644
--- a/selectors/displaycrs.js
+++ b/selectors/displaycrs.js
@@ -6,11 +6,17 @@
* LICENSE file in the root directory of this source tree.
*/
-import {createSelector} from 'reselect';
+import { createSelector } from 'reselect';
-export default createSelector([
+/**
+ * Selects the current display CRS.
+ * @memberof Redux Store.Selectors
+ */
+const displayCrs = createSelector([
state => state.map && state.map.projection || undefined,
state => state.mousePosition && state.mousePosition.crs || undefined
], (mapcrs, mousecrs) => {
return mousecrs || mapcrs || "EPSG:4326";
});
+
+export default displayCrs;
diff --git a/selectors/index.js b/selectors/index.js
new file mode 100644
index 000000000..4161d7a36
--- /dev/null
+++ b/selectors/index.js
@@ -0,0 +1,3 @@
+/**
+ * @namespace Redux Store.Selectors
+ */
diff --git a/selectors/searchproviders.js b/selectors/searchproviders.js
index 5e8ce3d96..fa37e01e3 100644
--- a/selectors/searchproviders.js
+++ b/selectors/searchproviders.js
@@ -6,15 +6,25 @@
* LICENSE file in the root directory of this source tree.
*/
-import {createSelector} from 'reselect';
-import {LayerRole} from '../actions/layers';
+import { createSelector } from 'reselect';
+import { LayerRole } from '../actions/layers';
import ConfigUtils from '../utils/ConfigUtils';
import LocaleUtils from '../utils/LocaleUtils';
import ThemeUtils from '../utils/ThemeUtils';
-export default (searchProviders) => createSelector(
- [state => state.theme, state => state.layers && state.layers.flat || null], (theme, layers) => {
- searchProviders = {...searchProviders, ...window.QWC2SearchProviders || {}};
+/**
+ * Retrieve search providers.
+ * @memberof Redux Store.Selectors
+ */
+const getSearchProviders = (searchProviders) => createSelector(
+ [
+ state => state.theme,
+ state => state.layers && state.layers.flat || null
+ ], (theme, layers) => {
+ searchProviders = {
+ ...searchProviders,
+ ...window.QWC2SearchProviders || {}
+ };
const availableProviders = {};
const themeLayerNames = layers.map(layer => layer.role === LayerRole.THEME ? layer.params.LAYERS : "").join(",").split(",").filter(entry => entry);
const themeProviders = theme && theme.current && theme.current.searchProviders ? theme.current.searchProviders : [];
@@ -27,7 +37,10 @@ export default (searchProviders) => createSelector(
// "key" is the legacy name for "provider"
const provider = searchProviders[entry.provider ?? entry.key ?? entry];
if (provider) {
- if (provider.requiresLayer && !themeLayerNames.includes(provider.requiresLayer)) {
+ if (
+ provider.requiresLayer &&
+ !themeLayerNames.includes(provider.requiresLayer)
+ ) {
continue;
}
let key = entry.provider ?? entry.key ?? entry;
@@ -47,10 +60,13 @@ export default (searchProviders) => createSelector(
availableProviders.themes = {
labelmsgid: LocaleUtils.trmsg("search.themes"),
onSearch: (text, options, callback) => {
- setTimeout(() => callback({results: ThemeUtils.searchThemes(theme.themes, text)}), 50);
+ setTimeout(() => callback({
+ results: ThemeUtils.searchThemes(theme.themes, text)
+ }), 50);
}
};
}
return availableProviders;
}
);
+export default getSearchProviders;
diff --git a/stores/StandardStore.js b/stores/StandardStore.js
index 28638b2bb..d81d3cc9a 100644
--- a/stores/StandardStore.js
+++ b/stores/StandardStore.js
@@ -19,6 +19,10 @@ import url from 'url';
import {CHANGE_BROWSER_PROPERTIES} from '../actions/browser';
import ReducerIndex from '../reducers/index';
+/**
+ * The library uses a global store.
+ * @namespace Redux Store
+ */
const DevTools = createDevTools(
@@ -26,8 +30,21 @@ const DevTools = createDevTools(
);
-export default class StandardStore {
+
+/**
+ * The global store interface.
+ *
+ * @memberof Redux Store
+ */
+class StandardStore {
static store = null;
+
+ /**
+ * Initializes the global store.
+ *
+ * @param {object} initialState - the initial state of the store
+ * @param {function} actionLogger - a function to log actions
+ */
static init = (initialState, actionLogger) => {
const allReducers = combineReducers(ReducerIndex.reducers);
@@ -63,7 +80,15 @@ export default class StandardStore {
}
StandardStore.store = finalCreateStore(rootReducer, defaultState);
}
+
+ /**
+ * Retrieve the global store.
+ *
+ * @return {object} the global store
+ */
static get = () => {
return StandardStore.store;
}
}
+
+export default StandardStore;
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 000000000..186732950
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,44 @@
+{
+ "rootDir": ".",
+ "compilerOptions": {
+ "moduleResolution": "node",
+ "sourceMap": true,
+ "jsx": "react",
+ "module": "amd",
+ "target": "es2019",
+ "allowJs": true,
+ // "checkJs": true,
+ "esModuleInterop": true,
+ "outDir": "./build",
+ "rootDir": "./",
+ "declaration": true,
+ "declarationMap": true,
+ "composite": true,
+ // "declarationDir": "dist",
+ "emitDeclarationOnly": true,
+ "outFile": "dist/qwc2.js",
+ "paths": {
+ "qwc2/*": ["./*"]
+ }
+ },
+ "include": [
+ "./typings/**/*",
+ "./actions/**/*",
+ "./components/**/*",
+ "./plugins/**/*",
+ "./reducers/**/*",
+ "./selectors/**/*",
+ "./stores/**/*",
+ "./utils/**/*"
+ ],
+ "exclude": [
+ "node_modules/**/*",
+ "build/**/*",
+ "dist/**/*",
+ "config/**/*",
+ "*/**/*.test.*",
+ ],
+ "lib": [
+ "dom", "es2015.iterable", "es5", "es2019"
+ ]
+}
diff --git a/typings/browser.ts b/typings/browser.ts
new file mode 100644
index 000000000..6520ff4ee
--- /dev/null
+++ b/typings/browser.ts
@@ -0,0 +1,41 @@
+
+/**
+ * Information about current environment.
+ */
+export interface BrowserData {
+ ie: boolean;
+ ie11: boolean;
+ ielt9: boolean;
+ webkit: boolean;
+ gecko: boolean;
+
+ android: boolean;
+ android23: boolean;
+
+ chrome: boolean;
+
+ ie3d: boolean;
+ webkit3d: boolean;
+ gecko3d: boolean;
+ opera3d: boolean;
+ any3d: boolean;
+
+ mobile: boolean;
+ mobileWebkit: boolean;
+ mobileWebkit3d: boolean;
+ mobileOpera: boolean;
+
+ touch: boolean;
+ msPointer: boolean;
+ pointer: boolean;
+
+ retina: boolean;
+
+ /**
+ * A string identifying the platform on which
+ * the user's browser is running.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/platform
+ */
+ platform: string;
+}
diff --git a/typings/config.ts b/typings/config.ts
new file mode 100644
index 000000000..a18ea7146
--- /dev/null
+++ b/typings/config.ts
@@ -0,0 +1,88 @@
+
+export type Color = [number, number, number, number];
+
+
+/**
+ * The default style for features.
+ */
+export interface DefaultFeatureStyle {
+ /**
+ * The color of the stroke.
+ */
+ strokeColor: Color,
+
+ /**
+ * The width of the stroke.
+ */
+ strokeWidth: number,
+
+ /**
+ * TODO: ?.
+ */
+ strokeDash: number[],
+
+ /**
+ * The color used to fill the interior of the feature.
+ */
+ fillColor: Color,
+
+ /**
+ * The radius of the circle.
+ */
+ circleRadius: number,
+
+ /**
+ * The color used for filling the letters.
+ */
+ textFill: string,
+
+ /**
+ * The color used for the stroke of the letters.
+ */
+ textStroke: string
+}
+
+
+/**
+ * The configuration data for the application.
+ */
+export interface ConfigData {
+ /**
+ * The path where the translation files are served.
+ */
+ translationsPath: string;
+
+ /**
+ * The path where the asset files are served.
+ * @default "/assets/"
+ */
+ assetsPath: string;
+
+ /**
+ * The default style for features.
+ */
+ defaultFeatureStyle: DefaultFeatureStyle
+
+ /**
+ * TODO: ?
+ * @see {@link MeasureUtils.updateFeatureMeasurements}
+ */
+ geodesicMeasurements: boolean;
+}
+
+
+/**
+ * The configuration state in redux store.
+ */
+export interface ConfigState extends ConfigData {
+ /**
+ * TODO: ?
+ */
+ startupParams: object;
+
+ /**
+ * The color scheme for the application.
+ * @default "default"
+ */
+ colorScheme: string;
+}
diff --git a/typings/context.ts b/typings/context.ts
new file mode 100644
index 000000000..dc6016b72
--- /dev/null
+++ b/typings/context.ts
@@ -0,0 +1,33 @@
+/**
+ * The ID of a context.
+ */
+export type QwcContextId = string;
+
+
+/**
+ * A context.
+ */
+export interface QwcContext {
+ id: QwcContextId;
+ action: any;
+ feature: any;
+ geomType: any;
+ changed: boolean;
+ geomReadOnly: boolean;
+}
+
+
+/**
+ * The context state.
+ */
+export interface QwcContextState {
+ /**
+ * The list of contexts.
+ */
+ contexts: Record;
+
+ /**
+ * The ID of the current context.
+ */
+ currentContext: QwcContextId;
+}
diff --git a/typings/custom.d.ts b/typings/custom.d.ts
new file mode 100644
index 000000000..60bd434c6
--- /dev/null
+++ b/typings/custom.d.ts
@@ -0,0 +1,4 @@
+declare module '*.svg' {
+ const content: any;
+ export default content;
+}
diff --git a/typings/index.ts b/typings/index.ts
new file mode 100644
index 000000000..d8d525f26
--- /dev/null
+++ b/typings/index.ts
@@ -0,0 +1,8 @@
+export type * from './browser';
+export type * from './config';
+export type * from './context';
+export type * from './layers';
+export type * from './map';
+export type * from './measurements';
+export type * from './plugin';
+export type * from './theme';
diff --git a/typings/layers.ts b/typings/layers.ts
new file mode 100644
index 000000000..4a3f5fc0f
--- /dev/null
+++ b/typings/layers.ts
@@ -0,0 +1,354 @@
+import { Size } from "ol/size";
+
+/**
+ * The unique identifier of a layer.
+ */
+export type LayerId = string;
+
+/**
+ * The size of the tile.
+ */
+export type TileSize = [number, number];
+
+/**
+ * Information about the source of the layer.
+ */
+export interface LayerAttribution {
+ Title: string;
+ OnlineResource: string;
+}
+
+/**
+ * The bounding box for a layer which includes the CRS.
+ */
+export interface LayerBox {
+ crs: string;
+ bounds: any;
+}
+
+/**
+ * Common interface for all layers.
+ */
+export interface BaseLayer {
+ /**
+ * TODO: What is this? Unique identifier?
+ */
+ name: string;
+
+ /**
+ * The label for the UI.
+ */
+ title: string;
+
+ /**
+ * TODO?
+ */
+ abstract: boolean;
+
+ /**
+ * The source of the layer.
+ */
+ attribution: LayerAttribution;
+
+ /**
+ * The location of the layer.
+ */
+ url: string;
+
+ /**
+ * The extends of this layer and the CRS it is in
+ * (e.g. `EPSG:4326`).
+ */
+ bbox: LayerBox;
+}
+
+
+/**
+ * A WMTS (tiled) layer.
+ */
+export interface WmstLayer extends BaseLayer {
+ type: "wmts";
+ capabilitiesUrl: string;
+ tileMatrixPrefix: string;
+ tileMatrixSet: string;
+ originX: number;
+ originY: number;
+ projection: string;
+ tileSize: Size;
+ style: object;
+ format: string;
+ requestEncoding: string;
+ resolutions: number[];
+}
+
+
+/**
+ * The parameters accepted by the WNS service.
+ */
+export interface WmsParams {
+
+ /**
+ * A comma-separated list of layers.
+ */
+ LAYERS: string;
+
+ /**
+ * A comma-separated list of opacities.
+ */
+ OPACITIES: string;
+
+ /**
+ * A comma-separated list of styles.
+ */
+ STYLES: string;
+}
+
+
+/**
+ * A WMS (un-tiled) layer.
+ *
+ * @see https://docs.geoserver.org/stable/en/user/services/wms/reference.html
+ */
+export interface WmsLayer extends BaseLayer {
+ type: "wms";
+
+ /**
+ * The URL for retrieving detailed information about a feature.
+ */
+ featureInfoUrl: string;
+
+ /**
+ * The legend URL.
+ */
+ legendUrl: string;
+
+ /**
+ * The version of the WMS protocol.
+ *
+ * Note that GeoServer supports WMS 1.1.1, the most widely used version
+ * of WMS, as well as WMS 1.3.0.
+ */
+ version: string;
+
+ /**
+ * TODO?
+ *
+ * @see ConfigUtils, externalLayerFeatureInfoFormats
+ */
+ infoFormats: string[];
+
+ /**
+ * Can this layer be queried?
+ */
+ queryable: boolean;
+
+ /**
+ * The list of sub-layers.
+ */
+ sublayers?: null | WmsLayer[];
+
+ /**
+ * TODO Misplaced?
+ */
+ expanded: boolean;
+
+ /**
+ * TODO Misplaced?
+ */
+ visibility: true;
+
+ /**
+ * TODO Misplaced?
+ */
+ opacity: 255;
+
+ /**
+ * TODO?
+ */
+ extwmsparams: any;
+
+ /**
+ * The map scale below which the layer should became invisible.
+ */
+ minScale?: number;
+
+ /**
+ * The map scale above which the layer should became invisible.
+ */
+ maxScale?: number;
+
+ /**
+ * The parameters accepted by the WMS service.
+ */
+ params: WmsParams;
+}
+
+
+/**
+ * An external layer.
+ */
+export type ExternalLayer = WmsLayer | WmstLayer;
+
+
+
+/**
+ * The configuration part of the layer.
+ */
+export interface LayerConfig {
+
+ /**
+ * The type of the layer.
+ */
+ type: "vector" | "wms" | "wmts" | "placeholder" | "separator";
+
+ /**
+ * The source URL of this layer.
+ */
+ url?: string;
+
+ /**
+ * TODO: What is the difference to `title` and `id`?
+ */
+ name: string;
+
+ /**
+ * The label for the label in the UI.
+ */
+ title: string;
+
+ /**
+ * Is this layer visible?
+ *
+ * Note that the layers are assumed to be visible (`undefined` === `true`)
+ * and are only considered invisible if this attribute is `false`.
+ */
+ visibility?: boolean;
+
+ /**
+ * The opacity of the layer [0-255].
+ */
+ opacity: number;
+
+ /**
+ * Parameters for the layer.
+ * @todo specifically?
+ */
+ params: any;
+};
+
+
+/**
+ * The key used to index external layers consists
+ * of two parts separated by a column: the type and the url
+ */
+export type ExternalLayerKey = string;
+
+export type ExternalLayerList = Record;
+
+
+
+/**
+ * The data for a layer in the state.
+ */
+export interface LayerData extends LayerConfig {
+ /**
+ * The ID of the layer.
+ */
+ id: LayerId;
+
+ /**
+ * The UUID of the layer.
+ */
+ uuid: string;
+
+ /**
+ * The list of features for the layer.
+ */
+ features: Record;
+
+ /**
+ * The role of the layer.
+ * @see {@link LayerRole}
+ */
+ role: number;
+
+ /**
+ * Can this layer be queried?
+ */
+ queryable: boolean;
+
+ /**
+ * TODO ?
+ */
+ tristate?: boolean;
+
+ /**
+ * If true identifies this layer as a group in which only a single
+ * sub-layer can be visible at any given time.
+ */
+ mutuallyExclusive?: boolean;
+
+ /**
+ * Is the layer tree hidden?
+ */
+ layertreehidden: boolean;
+
+ /**
+ * The bounding box for this layer.
+ */
+ bbox: [number, number, number, number];
+
+ /**
+ * TODO: Time-related?
+ */
+ dimensionValues: Record;
+
+ /**
+ * The date and time of the last revision.
+ */
+ rev: Date;
+
+ /**
+ * Is the layer loading?
+ */
+ loading: boolean;
+
+ /**
+ * The list of sub-layers.
+ */
+ sublayers?: LayerData[];
+
+ /**
+ * The map scale below which the layer should became visible
+ * (inclusive).
+ *
+ * This is the actual scale, not the denominator.
+ * If `undefined` the layer has no minimum scale.
+ */
+ minScale?: number;
+
+ /**
+ * The map scale above which the layer should became visible
+ * (exclusive).
+ *
+ * This is the actual scale, not the denominator.
+ * If `undefined` the layer has no maximum scale.
+ */
+ maxScale?: number;
+
+ /**
+ * The list of external layers.
+ */
+ externalLayerMap?: ExternalLayerList;
+
+ /**
+ * The external layer data.
+ */
+ externalLayer?: ExternalLayer;
+
+ /**
+ * The drawing order of the sub-layers; each item is a sub-layer name.
+ */
+ drawingOrder?: string[];
+}
+
diff --git a/typings/map.ts b/typings/map.ts
new file mode 100644
index 000000000..eac1bf18d
--- /dev/null
+++ b/typings/map.ts
@@ -0,0 +1,87 @@
+
+/**
+ * The bounds of the map and current rotation.
+ */
+export interface MapBox {
+ /**
+ * The bounds of the map.
+ */
+ bounds: [number, number, number, number];
+
+ /**
+ * The current rotation in radians.
+ */
+ rotation: number;
+}
+
+
+/**
+ * Snapping status.
+ */
+export interface MapSnapping {
+ /**
+ * Whether snapping is enabled.
+ */
+ enabled: boolean;
+
+ /**
+ * Whether snapping is active.
+ */
+ active: boolean;
+}
+
+
+/**
+ * The data for the map that is kept in redux store.
+ */
+export interface MapState {
+ /**
+ * The bounds of the map and current rotation.
+ */
+ bbox: MapBox;
+
+ /**
+ * The center of the map.
+ */
+ center: [number, number];
+
+ /**
+ * The current resolution in dots-per-inch.
+ */
+ dpi: number;
+
+ /**
+ * The current projection.
+ */
+ projection: string;
+
+ /**
+ * The current zoom level.
+ */
+ zoom: number;
+
+ /**
+ * The list of scales.
+ */
+ scales: number[];
+
+ /**
+ * The list of resolutions.
+ */
+ resolutions: number[];
+
+ /**
+ * The size of the top bar in pixels.
+ */
+ topbarHeight: number;
+
+ /**
+ * TODO: ?
+ */
+ click: any;
+
+ /**
+ * Snapping status.
+ */
+ snapping: MapSnapping;
+}
diff --git a/typings/measurements.ts b/typings/measurements.ts
new file mode 100644
index 000000000..bee0122da
--- /dev/null
+++ b/typings/measurements.ts
@@ -0,0 +1,122 @@
+/**
+ * The geometry types supported by the measurement tool.
+ *
+ * @see {@link MeasureUtils.MeasGeomTypes}
+ * TODO: remove MeasureUtils.MeasGeomTypes when
+ * the project is migrated to TypeScript
+ */
+export enum MeasGeomTypes {
+ POINT ='Point',
+ LINE_STRING ='LineString',
+ POLYGON ='Polygon',
+ ELLIPSE ='Ellipse',
+ SQUARE ='Square',
+ BOX ='Box',
+ CIRCLE ='Circle',
+ BEARING ='Bearing',
+}
+
+
+/**
+ * Length units used for measurements on the map.
+ */
+export enum LengthUnits {
+ FEET ="ft",
+ METRES ="m",
+ KILOMETRES ="km",
+ MILES ="mi",
+};
+
+
+/**
+ * Area units used for measurements on the map.
+ */
+export enum AreaUnits {
+ SQUARE_FEET ="sqft",
+ SQUARE_METRES ="sqm",
+ SQUARE_KILOMETRES ="sqkm",
+ SQUARE_MILES ="sqmi",
+ HECTARES ="ha",
+ ACRES ="acre",
+};
+
+
+/**
+ * The state of the measurements in the redux store.
+ */
+export interface MeasurementsState {
+ /**
+ * The type of the geometry.
+ */
+ geomType: MeasGeomTypes;
+
+ /**
+ * The coordinates of the geometry.
+ *
+ * This is a list of coordinates, where each coordinate
+ * is a list of two numbers.
+ */
+ coordinates: number[][];
+
+ /**
+ * The length of the geometry defined by `coordinates`.
+ */
+ length: number;
+
+ /**
+ * The area of the geometry defined by `coordinates`.
+ */
+ area: number;
+
+ /**
+ * The bearing of the geometry defined by `coordinates`.
+ */
+ bearing: number;
+
+ /**
+ * The unit used for the length.
+ */
+ lenUnit: LengthUnits;
+
+ /**
+ * The unit used for the area.
+ */
+ areaUnit: AreaUnits;
+
+ /**
+ * The number of decimals to use when displaying the measurements.
+ */
+ decimals: number;
+}
+
+
+/**
+ * The settings expected by the `updateFeatureMeasurements` function.
+ */
+export interface UpdateFeatMeasSetting {
+ /**
+ * The unit used for the length.
+ */
+ lenUnit?: LengthUnits;
+
+ /**
+ * The unit used for the area.
+ */
+ areaUnit?: AreaUnits;
+
+ /**
+ * The number of decimals to use when displaying the measurements.
+ */
+ decimals?: number;
+
+ /**
+ * The coordinate system of the coordinates.
+ */
+ mapCrs: string;
+
+ /**
+ * The coordinate system used for presenting
+ * coordinates to the user.
+ */
+ displayCrs: string;
+}
diff --git a/typings/plugin.ts b/typings/plugin.ts
new file mode 100644
index 000000000..d3aeca27e
--- /dev/null
+++ b/typings/plugin.ts
@@ -0,0 +1,3 @@
+export interface Plugin {
+
+}
diff --git a/typings/theme.ts b/typings/theme.ts
new file mode 100644
index 000000000..ad1d12d6e
--- /dev/null
+++ b/typings/theme.ts
@@ -0,0 +1,3 @@
+export interface Theme {
+
+}
diff --git a/utils/ConfigUtils.js b/utils/ConfigUtils.js
index a81e4d0b1..1d09a4444 100644
--- a/utils/ConfigUtils.js
+++ b/utils/ConfigUtils.js
@@ -12,7 +12,24 @@ import url from 'url';
import isMobile from 'ismobilejs';
import StandardStore from '../stores/StandardStore';
-let defaultConfig = {
+/**
+ * @typedef {import("qwc2/typings").ConfigData} ConfigData
+ */
+
+/**
+ * @typedef {import("qwc2/typings").Theme} Theme
+ */
+
+/**
+ * @typedef {import("qwc2/typings").Plugin} Plugin
+ */
+
+
+/**
+ * Default configuration.
+ * @type {ConfigData}
+ */
+let initialConfig = {
translationsPath: "translations",
defaultFeatureStyle: {
strokeColor: [0, 0, 255, 1],
@@ -25,37 +42,96 @@ let defaultConfig = {
}
};
+
+/**
+ * Default configuration.
+ * @type {ConfigData}
+ */
+let defaultConfig = {
+ ...initialConfig
+};
+
+
+/**
+ * Utility functions for configuration handling.
+ *
+ * @namespace
+ */
const ConfigUtils = {
+ /**
+ * Get the default configuration.
+ *
+ * @returns {ConfigData} the default configuration
+ */
getDefaults() {
return defaultConfig;
},
+
+ /**
+ * Reset the configuration to library defaults.
+ * @returns {ConfigData} the default configuration
+ */
+ resetDefaults() {
+ defaultConfig = { ...initialConfig };
+ return defaultConfig;
+ },
+
+ /**
+ * Load the configuration from the remote config.json file.
+ *
+ * Uppon success, the internal configuration is merged with the
+ * default configuration and the merged result will be
+ * available through `getDefaults()`.
+ *
+ * @param {object} configParams additional parameters to pass
+ * to the config.json request
+ *
+ * @returns {Promise} a promise resolving to
+ * the configuration
+ */
loadConfiguration(configParams = {}) {
let configFile = 'config.json';
- const urlQuery = url.parse(window.location.href, true).query;
+ const urlQuery = url.parse(
+ window.location.href, true
+ ).query;
if (urlQuery.localConfig) {
configFile = urlQuery.localConfig + '.json';
}
- return axios.get(configFile, {params: configParams}).then(response => {
+ return axios.get(configFile, {
+ params: configParams
+ }).then(response => {
if (typeof response.data === 'object') {
- defaultConfig = {...defaultConfig, ...response.data};
+ defaultConfig = {
+ ...defaultConfig,
+ ...response.data
+ };
} else {
/* eslint-disable-next-line */
- console.warn("Broken configuration file " + configFile + "!");
+ console.warn(
+ "Broken configuration file " + configFile + "!"
+ );
}
return defaultConfig;
});
},
+
/**
* Utility to detect browser properties.
* Code from leaflet-src.js
+ *
+ * @returns {import("qwc2/typings").BrowserData}
*/
getBrowserProperties() {
-
const ie = 'ActiveXObject' in window;
const ielt9 = ie && !document.addEventListener;
- const ie11 = ie && (window.location.hash === !!window.MSInputMethodContext && !!document.documentMode);
+ const ie11 = (
+ ie &&
+ window.location.hash === !!window.MSInputMethodContext &&
+ !!document.documentMode
+ );
- // terrible browser detection to work around Safari / iOS / Android browser bugs
+ // terrible browser detection to work around
+ // Safari / iOS / Android browser bugs
const ua = navigator.userAgent.toLowerCase();
const webkit = ua.indexOf('webkit') !== -1;
const chrome = ua.indexOf('chrome') !== -1;
@@ -66,72 +142,172 @@ const ConfigUtils = {
const mobile = isMobile(window.navigator).any;
const msPointer = !window.PointerEvent && window.MSPointerEvent;
- const pointer = (window.PointerEvent && window.navigator.pointerEnabled && window.navigator.maxTouchPoints) || msPointer;
- const retina = ('devicePixelRatio' in window && window.devicePixelRatio > 1) ||
- ('matchMedia' in window && window.matchMedia('(min-resolution:144dpi)') &&
- window.matchMedia('(min-resolution:144dpi)').matches);
+ const pointer = (
+ window.PointerEvent &&
+ window.navigator.pointerEnabled &&
+ window.navigator.maxTouchPoints
+ ) || msPointer;
+ const retina = (
+ (
+ 'devicePixelRatio' in window &&
+ window.devicePixelRatio > 1
+ ) || (
+ 'matchMedia' in window &&
+ window.matchMedia('(min-resolution:144dpi)') &&
+ window.matchMedia('(min-resolution:144dpi)').matches
+ )
+ );
const doc = document.documentElement;
const ie3d = ie && ('transition' in doc.style);
- const webkit3d = ('WebKitCSSMatrix' in window) && ('m11' in new window.WebKitCSSMatrix()) && !android23;
+ const webkit3d = (
+ ('WebKitCSSMatrix' in window) &&
+ ('m11' in new window.WebKitCSSMatrix()) &&
+ !android23
+ );
const gecko3d = 'MozPerspective' in doc.style;
const opera3d = 'OTransition' in doc.style;
- const any3d = !window.L_DISABLE_3D && (ie3d || webkit3d || gecko3d || opera3d) && !phantomjs;
+ const any3d = (
+ !window.L_DISABLE_3D &&
+ (ie3d || webkit3d || gecko3d || opera3d) &&
+ !phantomjs
+ );
- const touch = !window.L_NO_TOUCH && !phantomjs && (pointer || 'ontouchstart' in window ||
- (window.DocumentTouch && document instanceof window.DocumentTouch));
+ const touch = (
+ !window.L_NO_TOUCH &&
+ !phantomjs && (
+ pointer ||
+ 'ontouchstart' in window ||
+ (
+ window.DocumentTouch &&
+ document instanceof window.DocumentTouch
+ )
+ )
+ );
return {
- ie: ie,
- ie11: ie11,
- ielt9: ielt9,
- webkit: webkit,
+ ie,
+ ie11,
+ ielt9,
+ webkit,
gecko: gecko && !webkit && !window.opera && !ie,
- android: android,
- android23: android23,
+ android,
+ android23,
chrome: chrome,
- ie3d: ie3d,
- webkit3d: webkit3d,
- gecko3d: gecko3d,
- opera3d: opera3d,
- any3d: any3d,
+ ie3d,
+ webkit3d,
+ gecko3d,
+ opera3d,
+ any3d,
- mobile: mobile,
+ mobile,
mobileWebkit: mobile && webkit,
mobileWebkit3d: mobile && webkit3d,
mobileOpera: mobile && window.opera,
- touch: touch,
- msPointer: msPointer,
- pointer: pointer,
+ touch,
+ msPointer,
+ pointer,
- retina: retina,
+ retina,
platform: navigator.platform
};
},
+
+ /**
+ * Get a configuration property from the theme or
+ * default configuration.
+ *
+ * If there is a theme and the property is defined
+ * in the theme configuration, return that value.
+ * If the property is defined in the default
+ * configuration and not `undefined` or `null`, return that value.
+ * Otherwise return the default value.
+ *
+ * @param {keyof Config} prop - the property name
+ * @param {Theme|null} theme - the theme to get
+ * the property from
+ * @param {*} defval - the default value
+ */
getConfigProp(prop, theme, defval = undefined) {
- if (theme && theme.config && theme.config[prop] !== undefined) {
+ if (
+ theme &&
+ theme.config &&
+ theme.config[prop] !== undefined
+ ) {
return theme.config[prop];
}
return defaultConfig[prop] ?? defval;
},
+
+ /**
+ * Get the assets path from default configuration.
+ *
+ * If not set in the default configuration, return
+ * "assets".
+ *
+ * @returns {string} the assets path
+ */
getAssetsPath() {
- return (ConfigUtils.getConfigProp("assetsPath") || "assets").replace(/\/$/g, "");
+ return (
+ ConfigUtils.getConfigProp("assetsPath") ||
+ "assets"
+ ).replace(/\/$/g, "");
},
+
+ /**
+ * Get the translations path from default configuration.
+ *
+ * If not set in the default configuration, return
+ * "translations".
+ *
+ * @returns {string} the assets path
+ */
getTranslationsPath() {
- return (ConfigUtils.getConfigProp("translationsPath") || "translations").replace(/\/$/g, "");
+ return (
+ ConfigUtils.getConfigProp("translationsPath") ||
+ "translations"
+ ).replace(/\/$/g, "");
},
+
+ /**
+ * See if we have a plugin configuration by this name.
+ *
+ * The function looks into the store to determine if the
+ * browser is mobile or desktop and searches in
+ * that list of plugins for the `name`.
+ *
+ * @param {string} name - the plugin name
+ * @returns {Plugin} the plugin
+ */
havePlugin(name) {
const state = StandardStore.get().getState();
- return defaultConfig.plugins[state.browser.mobile ? "mobile" : "desktop"].find(entry => entry.name === name);
+ return defaultConfig.plugins[
+ state.browser.mobile
+ ? "mobile"
+ : "desktop"
+ ].find(
+ entry => entry.name === name
+ );
},
+
+ /**
+ * Get a plugin configuration from default (library)
+ * configuration.
+ *
+ * The function looks into the store to determine if the
+ * browser is mobile or desktop and searches in
+ * that list of plugins for the `name`.
+ *
+ * @param {string} name - the plugin name
+ * @returns {Plugin} the plugin
+ */
getPluginConfig(name) {
- const state = StandardStore.get().getState();
- return defaultConfig.plugins[state.browser.mobile ? "mobile" : "desktop"].find(entry => entry.name === name) || {};
+ return ConfigUtils.havePlugin(name) || {};
}
};
diff --git a/utils/ConfigUtils.test.js b/utils/ConfigUtils.test.js
new file mode 100644
index 000000000..41894f74b
--- /dev/null
+++ b/utils/ConfigUtils.test.js
@@ -0,0 +1,970 @@
+import mockAxios from 'jest-mock-axios';
+import ConfigUtils from "./ConfigUtils";
+
+let mockIsMobile = false;
+jest.mock('ismobilejs', () => ({
+ __esModule: true,
+ default: jest.fn(secret => {
+ return {
+ any: mockIsMobile,
+ };
+ }),
+}));
+
+const navigatorSpy = jest.spyOn(global, 'navigator', 'get');
+let mockUserAgent = "foo";
+let mockPlatform = "bar";
+let mockPointerEnabled = undefined;
+let mockMaxTouchPoints = undefined;
+navigatorSpy.mockImplementation(() => ({
+ userAgent: mockUserAgent,
+ platform: mockPlatform,
+ pointerEnabled: mockPointerEnabled,
+ maxTouchPoints: mockMaxTouchPoints
+}));
+
+const documentSpy = jest.spyOn(global, 'document', 'get');
+let mockDocumentElement = {
+ style: {}
+};
+let mockDocumentMode = undefined;
+let mockAddEventListener = undefined;
+documentSpy.mockImplementation(() => ({
+ documentElement: mockDocumentElement,
+ documentMode: mockDocumentMode,
+ addEventListener: mockAddEventListener,
+}));
+
+const locationSpy = jest.spyOn(global.window, 'location', 'get');
+let mockLocationHash = undefined;
+locationSpy.mockImplementation(() => ({
+ hash: mockLocationHash
+}));
+
+
+const expectedDefault = {
+ translationsPath: "translations",
+ defaultFeatureStyle: {
+ strokeColor: [0, 0, 255, 1],
+ strokeWidth: 2,
+ strokeDash: [4],
+ fillColor: [0, 0, 255, 0.33],
+ circleRadius: 10,
+ textFill: "black",
+ textStroke: "white",
+ },
+}
+
+function setInternalConfig(data) {
+ const responseObj = {
+ data
+ };
+
+ const catchFn = jest.fn(), thenFn = jest.fn();
+ ConfigUtils.loadConfiguration({})
+ .then(thenFn)
+ .catch(catchFn);
+
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'config.json', { params: {} }
+ );
+ mockAxios.mockResponse(responseObj);
+}
+
+let mockStateMobile = undefined;
+jest.mock('../stores/StandardStore', () => ({
+ get: jest.fn(() => ({
+ getState: jest.fn(() => ({
+ browser: {
+ mobile: mockStateMobile,
+ }
+ })),
+ })),
+}));
+
+
+describe("getDefaults", () => {
+ it("should return the default configuration", () => {
+ const defaults = ConfigUtils.getDefaults();
+ expect(defaults).toEqual({ ...expectedDefault });
+ });
+});
+
+describe("loadConfiguration", () => {
+ const configParams = {
+ foo: "bar",
+ };
+
+ afterEach(() => {
+ mockAxios.reset();
+ });
+
+ it("should load the configuration", () => {
+ const responseObj = {
+ data: {
+ abc: "defg",
+ }
+ };
+
+ const catchFn = jest.fn(), thenFn = jest.fn();
+ ConfigUtils.loadConfiguration(configParams)
+ .then(thenFn)
+ .catch(catchFn);
+
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'config.json', { params: configParams }
+ );
+ mockAxios.mockResponse(responseObj);
+ expect(thenFn).toHaveBeenCalledWith({
+ ...expectedDefault,
+ abc: "defg",
+ });
+ expect(catchFn).not.toHaveBeenCalled();
+ });
+ it("should use localConfig URL parameter", () => {
+ location.href = "http://example.com/?localConfig=foo";
+ const catchFn = jest.fn(), thenFn = jest.fn();
+ ConfigUtils.loadConfiguration(configParams)
+ .then(thenFn)
+ .catch(catchFn);
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'foo.json', { params: configParams }
+ );
+ });
+ it("should ignore non-object replies", () => {
+ ConfigUtils.resetDefaults();
+ const catchFn = jest.fn(), thenFn = jest.fn();
+ ConfigUtils.loadConfiguration(configParams)
+ .then(thenFn)
+ .catch(catchFn);
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'config.json', { params: configParams }
+ );
+ mockAxios.mockResponse({ data: "foo" });
+ expect(thenFn).toHaveBeenCalledWith({
+ ...expectedDefault
+ });
+ expect(catchFn).not.toHaveBeenCalled();
+ });
+});
+
+describe("resetDefaults", () => {
+ it("should reset the defaults", () => {
+ const defaults = ConfigUtils.resetDefaults();
+ expect(defaults).toEqual({ ...expectedDefault });
+ });
+});
+
+describe("getBrowserProperties", () => {
+ beforeEach(() => {
+ global.window.location = {
+ href: "http://example.com/"
+ };
+ Object.assign(navigator, () => ({
+ userAgent: "foo",
+ platform: "bar",
+ }))
+ });
+
+ describe("ie", () => {
+ it("should detect IE when ActiveXObject is present", () => {
+ global.window.ActiveXObject = "foo";
+ expect(ConfigUtils.getBrowserProperties().ie).toBe(true);
+ });
+ it("should not detect IE when ActiveXObject is not present", () => {
+ delete global.window.ActiveXObject;
+ expect(ConfigUtils.getBrowserProperties().ie).toBe(false);
+ });
+ });
+
+ describe("ie11", () => {
+ it.skip("should detect IE11 when hash is true", () => {
+ // This one is failing because the mockLocationHash is not
+ // picked up by the ConfigUtils.getBrowserProperties() call.
+ // As set right now the window.location.hash will show up as ''
+ // (empty string).
+ // If set with global.window.location.hash`true`
+ // it will show up as '#true'.
+ global.window.ActiveXObject = "foo";
+ global.window.MSInputMethodContext = "foo";
+ mockDocumentMode = "foo";
+ mockLocationHash = true;
+ expect(ConfigUtils.getBrowserProperties().ie11).toBe(true);
+ });
+ });
+
+ describe("ielt9", () => {
+ it("should detect ielt9 when addEventListener is not present", () => {
+ global.window.ActiveXObject = "foo";
+ mockAddEventListener = undefined;
+ expect(ConfigUtils.getBrowserProperties().ielt9).toBe(true);
+ });
+ it("should not detect ielt9 when addEventListener is present", () => {
+ global.window.ActiveXObject = "foo";
+ mockAddEventListener = "foo";
+ expect(ConfigUtils.getBrowserProperties().ielt9).toBe(false);
+ });
+ });
+
+ describe("webkit", () => {
+ it("should detect webkit", () => {
+ mockUserAgent = "fOo";
+ expect(
+ ConfigUtils.getBrowserProperties().webkit
+ ).toBe(false);
+ mockUserAgent = "fOoWEBKIT";
+ expect(
+ ConfigUtils.getBrowserProperties().webkit
+ ).toBe(true);
+ });
+ });
+
+ describe("gecko", () => {
+ it("should not detect gecko", () => {
+ mockUserAgent = "fOo";
+ expect(
+ ConfigUtils.getBrowserProperties().gecko
+ ).toBe(false);
+ });
+ it("should detect gecko if opera was found", () => {
+ mockUserAgent = "fOoGECKO";
+ global.window.opera = true;
+ delete global.window.ActiveXObject;
+ expect(
+ ConfigUtils.getBrowserProperties().gecko
+ ).toBeFalsy();
+ });
+ it("should detect gecko if IE was found", () => {
+ mockUserAgent = "fOoGECKO";
+ global.window.opera = false;
+ global.window.ActiveXObject = true;
+ expect(
+ ConfigUtils.getBrowserProperties().gecko
+ ).toBeFalsy();
+ });
+ it("should not detect gecko if webkit was found", () => {
+ mockUserAgent = "GECKO-webkit";
+ delete global.window.opera;
+ delete global.window.ActiveXObject;
+ expect(
+ ConfigUtils.getBrowserProperties().gecko
+ ).toBeFalsy();
+ });
+ it("should detect gecko", () => {
+ mockUserAgent = "fOoGECKO";
+ delete global.window.opera;
+ delete global.window.ActiveXObject;
+ expect(
+ ConfigUtils.getBrowserProperties().gecko
+ ).toBe(true);
+ });
+ });
+
+ describe("android", () => {
+ it("should detect android", () => {
+ mockUserAgent = "fOo";
+ expect(
+ ConfigUtils.getBrowserProperties().android
+ ).toBe(false);
+ mockUserAgent = "fOoANDROID";
+ expect(
+ ConfigUtils.getBrowserProperties().android
+ ).toBe(true);
+ });
+ });
+
+ describe("android23", () => {
+ it("should not detect android23", () => {
+ mockUserAgent = "fOo";
+ expect(
+ ConfigUtils.getBrowserProperties().android23
+ ).toBe(false);
+ mockUserAgent = "fOoANDROID";
+ expect(
+ ConfigUtils.getBrowserProperties().android23
+ ).toBe(false);
+ });
+ it("should detect android23", () => {
+ mockUserAgent = "fOoANDROID 2";
+ expect(
+ ConfigUtils.getBrowserProperties().android
+ ).toBe(true);
+ expect(
+ ConfigUtils.getBrowserProperties().android23
+ ).toBe(true);
+ mockUserAgent = "fOoANDROID 3";
+ expect(
+ ConfigUtils.getBrowserProperties().android
+ ).toBe(true);
+ expect(
+ ConfigUtils.getBrowserProperties().android23
+ ).toBe(true);
+ });
+ });
+
+ describe("chrome", () => {
+ it("should detect chrome", () => {
+ mockUserAgent = "fOo";
+ expect(
+ ConfigUtils.getBrowserProperties().chrome
+ ).toBe(false);
+ mockUserAgent = "fOoCHROME";
+ expect(
+ ConfigUtils.getBrowserProperties().chrome
+ ).toBe(true);
+ });
+ });
+
+ describe("ie3d", () => {
+ it("should detect it", () => {
+ global.window.ActiveXObject = "foo";
+ mockDocumentElement = {
+ style: {
+ transition: "foo"
+ }
+ };
+ const bp = ConfigUtils.getBrowserProperties();
+ expect(bp.ie3d).toBeTruthy();
+ expect(bp.any3d).toBeTruthy();
+ });
+ it("should not detect it if transition is missing", () => {
+ mockDocumentElement = {
+ style: {}
+ };
+ global.window.ActiveXObject = "foo";
+ expect(
+ ConfigUtils.getBrowserProperties().ie3d
+ ).toBeFalsy();
+ });
+ it("should not detect it if ActiveX is missing", () => {
+ mockDocumentElement = {
+ style: {
+ transition: "foo"
+ }
+ };
+ delete global.window.ActiveXObject;
+ expect(
+ ConfigUtils.getBrowserProperties().ie3d
+ ).toBeFalsy();
+ });
+ });
+
+ describe("webkit3d", () => {
+ it("should not detect it if WebKitCSSMatrix is missing", () => {
+ delete global.window.WebKitCSSMatrix;
+ expect(
+ ConfigUtils.getBrowserProperties().webkit3d
+ ).toBeFalsy();
+ });
+ it("should not detect it if m11 is missing", () => {
+ class Xyz { }
+ global.window.WebKitCSSMatrix = Xyz;
+ expect(
+ ConfigUtils.getBrowserProperties().webkit3d
+ ).toBeFalsy();
+ });
+ it("should not detect it if android23", () => {
+ class Xyz {
+ m11 = 1;
+ }
+ mockUserAgent = "android 3";
+ global.window.WebKitCSSMatrix = Xyz;
+ expect(
+ ConfigUtils.getBrowserProperties().webkit3d
+ ).toBeFalsy();
+ });
+ it("should detect it", () => {
+ class Xyz {
+ m11 = 1;
+ }
+ mockUserAgent = "xxx";
+ global.window.WebKitCSSMatrix = Xyz;
+ const bp = ConfigUtils.getBrowserProperties();
+ expect(bp.webkit3d).toBeTruthy();
+ expect(bp.any3d).toBeTruthy();
+ });
+ });
+
+ describe("gecko3d", () => {
+ it("should detect it", () => {
+ mockDocumentElement = {
+ style: {
+ MozPerspective: "foo"
+ }
+ };
+ const bp = ConfigUtils.getBrowserProperties();
+ expect(bp.gecko3d).toBeTruthy();
+ expect(bp.any3d).toBeTruthy();
+ });
+ it("should not detect it", () => {
+ mockDocumentElement = {
+ style: {}
+ };
+ expect(
+ ConfigUtils.getBrowserProperties().gecko3d
+ ).toBeFalsy();
+ });
+ });
+
+ describe("opera3d", () => {
+ it("should detect it", () => {
+ mockDocumentElement = {
+ style: {
+ OTransition: "foo"
+ }
+ };
+ const bp = ConfigUtils.getBrowserProperties();
+ expect(bp.opera3d).toBeTruthy();
+ expect(bp.any3d).toBeTruthy();
+ });
+ it("should not detect it", () => {
+ mockDocumentElement = {
+ style: {}
+ };
+ expect(
+ ConfigUtils.getBrowserProperties().opera3d
+ ).toBeFalsy();
+ });
+ });
+
+ describe("any3d", () => {
+ it("should detect it", () => {
+ global.window.L_DISABLE_3D = false;
+ mockDocumentElement = {
+ style: {
+ OTransition: "foo"
+ }
+ };
+ expect(
+ ConfigUtils.getBrowserProperties().any3d
+ ).toBeTruthy();
+ });
+ it("should not detect it", () => {
+ global.window.L_DISABLE_3D = true;
+ mockDocumentElement = {
+ style: {
+ OTransition: "foo"
+ }
+ };
+ expect(
+ ConfigUtils.getBrowserProperties().any3d
+ ).toBeFalsy();
+ });
+ });
+
+ describe("mobile", () => {
+ it("should detect it", () => {
+ mockIsMobile = true;
+ expect(
+ ConfigUtils.getBrowserProperties().mobile
+ ).toBe(true);
+ });
+ it("should not detect it", () => {
+ mockIsMobile = false;
+ expect(
+ ConfigUtils.getBrowserProperties().mobile
+ ).toBe(false);
+ });
+ });
+
+ describe("mobileWebkit", () => {
+ it("should detect it if user agent is webkit", () => {
+ mockIsMobile = true;
+ mockUserAgent = 'webkit';
+ expect(
+ ConfigUtils.getBrowserProperties().mobileWebkit
+ ).toBeTruthy();
+ });
+ it("should not detect it not on mobile", () => {
+ mockIsMobile = false;
+ expect(
+ ConfigUtils.getBrowserProperties().mobileWebkit
+ ).toBe(false);
+ });
+ it("should not detect it not on webkit", () => {
+ mockIsMobile = true;
+ mockUserAgent = 'otherkit';
+ expect(
+ ConfigUtils.getBrowserProperties().mobileWebkit
+ ).toBe(false);
+ });
+ });
+
+ describe("mobileWebkit3d", () => {
+ it("should not detect it if not on mobile", () => {
+ mockIsMobile = false;
+ expect(
+ ConfigUtils.getBrowserProperties().mobileWebkit3d
+ ).toBeFalsy();
+ });
+ it("should not detect it if WebKitCSSMatrix is missing", () => {
+ mockIsMobile = true;
+ mockUserAgent = "xxx";
+ delete global.window.WebKitCSSMatrix;
+ expect(
+ ConfigUtils.getBrowserProperties().mobileWebkit3d
+ ).toBeFalsy();
+ });
+ it("should not detect it if m11 is missing", () => {
+ class Xyz { }
+ mockIsMobile = true;
+ mockUserAgent = "xxx";
+ global.window.WebKitCSSMatrix = Xyz;
+ expect(
+ ConfigUtils.getBrowserProperties().mobileWebkit3d
+ ).toBeFalsy();
+ });
+ it("should not detect it if android23", () => {
+ class Xyz {
+ m11 = 1;
+ }
+ mockIsMobile = true;
+ mockUserAgent = "android 2";
+ global.window.WebKitCSSMatrix = Xyz;
+ expect(
+ ConfigUtils.getBrowserProperties().mobileWebkit3d
+ ).toBeFalsy();
+ });
+ it("should detect it", () => {
+ class Xyz {
+ m11 = 1;
+ }
+ mockIsMobile = true;
+ mockUserAgent = "xxx";
+ global.window.WebKitCSSMatrix = Xyz;
+ expect(
+ ConfigUtils.getBrowserProperties().mobileWebkit3d
+ ).toBeTruthy();
+ });
+ });
+
+ describe("mobileOpera", () => {
+ it("should detect it if opera is in window object", () => {
+ mockIsMobile = true;
+ global.window.opera = true;
+ expect(
+ ConfigUtils.getBrowserProperties().mobileOpera
+ ).toBeTruthy();
+ });
+ it("should not detect it if mobile or opera are missing", () => {
+ mockIsMobile = false;
+ expect(
+ ConfigUtils.getBrowserProperties().mobileOpera
+ ).toBeFalsy();
+ mockIsMobile = true;
+ global.window.opera = false;
+ expect(
+ ConfigUtils.getBrowserProperties().mobileOpera
+ ).toBeFalsy();
+ });
+ });
+
+ describe("touch", () => {
+ class Xyz { }
+
+ it("should not detect it if L_NO_TOUCH", () => {
+ global.window.L_NO_TOUCH = true;
+ expect(
+ ConfigUtils.getBrowserProperties().touch
+ ).toBeFalsy();
+ });
+ it("should not detect it if user-agent is phantom", () => {
+ global.window.L_NO_TOUCH = false;
+ mockUserAgent = "PHANTOM";
+ expect(
+ ConfigUtils.getBrowserProperties().touch
+ ).toBeFalsy();
+ });
+ it("should detect it if there is a pointer", () => {
+ global.window.L_NO_TOUCH = false;
+ mockUserAgent = "non-ph";
+ global.window.PointerEvent = true;
+ mockPointerEnabled = true;
+ mockMaxTouchPoints = 1;
+ delete global.window.ontouchstart;
+ delete global.window.DocumentTouch;
+ expect(
+ ConfigUtils.getBrowserProperties().touch
+ ).toBeTruthy();
+ });
+ it("should detect it if ontouchstart is found", () => {
+ global.window.L_NO_TOUCH = false;
+ mockUserAgent = "non-ph";
+ global.window.PointerEvent = false;
+ global.window.ontouchstart = true;
+ global.window.DocumentTouch = true;
+ expect(
+ ConfigUtils.getBrowserProperties().touch
+ ).toBeTruthy();
+ });
+ it("should not detect it if DocumentTouch is of wrong type", () => {
+ global.window.L_NO_TOUCH = false;
+ mockUserAgent = "non-ph";
+ global.window.PointerEvent = false;
+ delete global.window.ontouchstart;
+ global.window.DocumentTouch = Xyz;
+ expect(
+ ConfigUtils.getBrowserProperties().touch
+ ).toBeFalsy();
+ });
+ it("should not detect it if DocumentTouch is of right type", () => {
+ global.window.L_NO_TOUCH = false;
+ mockUserAgent = "non-ph";
+ global.window.PointerEvent = false;
+ delete global.window.ontouchstart;
+ global.window.DocumentTouch = Xyz;
+ global.document = new Xyz();
+ expect(
+ ConfigUtils.getBrowserProperties().touch
+ ).toBeFalsy();
+ });
+ });
+
+ describe("msPointer", () => {
+ it("should not detect it if PointerEvent is present", () => {
+ global.window.PointerEvent = true;
+ expect(
+ ConfigUtils.getBrowserProperties().msPointer
+ ).toBeFalsy();
+ });
+ it("should not detect it if MSPointerEvent is missing", () => {
+ global.window.PointerEvent = true;
+ delete global.window.MSPointerEvent;
+ expect(
+ ConfigUtils.getBrowserProperties().msPointer
+ ).toBeFalsy();
+ });
+ it("should detect it all are true", () => {
+ global.window.PointerEvent = true;
+ global.window.MSPointerEvent = true;
+ expect(
+ ConfigUtils.getBrowserProperties().msPointer
+ ).toBeFalsy();
+ });
+ });
+
+ describe("pointer", () => {
+ describe("using msPointer", () => {
+ it("should not detect it if MSPointerEvent is missing", () => {
+ delete global.window.PointerEvent;
+ delete global.window.MSPointerEvent;
+ expect(
+ ConfigUtils.getBrowserProperties().pointer
+ ).toBeFalsy();
+ });
+ it("should detect it all are true", () => {
+ delete global.window.PointerEvent;
+ global.window.MSPointerEvent = true;
+ expect(
+ ConfigUtils.getBrowserProperties().pointer
+ ).toBeTruthy();
+ });
+ });
+ describe("using plain Pointer", () => {
+ it("should not detect it if PointerEvent is missing", () => {
+ delete global.window.MSPointerEvent;
+ delete global.window.PointerEvent;
+
+ expect(
+ ConfigUtils.getBrowserProperties().pointer
+ ).toBeFalsy();
+ });
+ it("should not detect it if pointerEnabled is falsly", () => {
+ delete global.window.MSPointerEvent;
+ global.window.PointerEvent = true;
+ mockPointerEnabled = false;
+ expect(
+ ConfigUtils.getBrowserProperties().pointer
+ ).toBeFalsy();
+ });
+ it("should not detect it if maxTouchPoints is falsly", () => {
+ delete global.window.MSPointerEvent;
+ global.window.PointerEvent = true;
+ mockPointerEnabled = true;
+ mockMaxTouchPoints = 0;
+ expect(
+ ConfigUtils.getBrowserProperties().pointer
+ ).toBeFalsy();
+ });
+ it("should detect it if iff all are true", () => {
+ delete global.window.MSPointerEvent;
+ global.window.PointerEvent = true;
+ mockPointerEnabled = true;
+ mockMaxTouchPoints = 1;
+ expect(
+ ConfigUtils.getBrowserProperties().pointer
+ ).toBeTruthy();
+ });
+ });
+ });
+
+ describe("retina", () => {
+ describe("using devicePixelRatio", () => {
+ beforeEach(() => { delete global.window.matchMedia; });
+ it("should detect it if devicePixelRatio > 1", () => {
+ global.window.devicePixelRatio = 2;
+ expect(
+ ConfigUtils.getBrowserProperties().retina
+ ).toBe(true);
+ });
+ it("should not detect it if devicePixelRatio is missing", () => {
+ delete global.window.devicePixelRatio;
+ expect(
+ ConfigUtils.getBrowserProperties().retina
+ ).toBe(false);
+ });
+ it("should not detect it if devicePixelRatio is 1", () => {
+ global.window.devicePixelRatio = 1;
+ expect(
+ ConfigUtils.getBrowserProperties().retina
+ ).toBe(false);
+ });
+ });
+ describe("using matchMedia", () => {
+ beforeEach(() => { delete global.window.devicePixelRatio; });
+ it("should not detect it if matchMedia is missing", () => {
+ delete global.window.matchMedia;
+ expect(
+ ConfigUtils.getBrowserProperties().retina
+ ).toBeFalsy();
+ });
+ it("should not detect it if matchMedia returns falsly", () => {
+ global.window.matchMedia = () => undefined;
+ expect(
+ ConfigUtils.getBrowserProperties().retina
+ ).toBeFalsy();
+ });
+ it("should not detect it if matches returns falsly", () => {
+ global.window.matchMedia = () => ({
+ matches: undefined
+ });
+ expect(
+ ConfigUtils.getBrowserProperties().retina
+ ).toBeFalsy();
+ });
+ it("should detect it if matches returns truthy", () => {
+ global.window.matchMedia = () => ({
+ matches: []
+ });
+ expect(
+ ConfigUtils.getBrowserProperties().retina
+ ).toBeTruthy();
+ });
+ });
+ });
+
+ describe("platform", () => {
+ it("should detect it", () => {
+ expect(
+ ConfigUtils.getBrowserProperties().platform
+ ).toBe("bar");
+ });
+ });
+
+});
+
+describe("getConfigProp", () => {
+ it("should return the default value if not in theme", () => {
+ expect(
+ ConfigUtils.getConfigProp("foo", null, "bar")
+ ).toBe("bar");
+ });
+ it("should return the theme value if in theme", () => {
+ expect(
+ ConfigUtils.getConfigProp("foo", { config: { foo: "bar" } }, "baz")
+ ).toBe("bar");
+ });
+ it("should return the default value if in theme but undefined", () => {
+ expect(
+ ConfigUtils.getConfigProp(
+ "foo", { config: { foo: undefined } }, "baz"
+ )
+ ).toBe("baz");
+ });
+ it("should return the library default if not in theme", () => {
+ ConfigUtils.resetDefaults();
+ expect(
+ ConfigUtils.getConfigProp("translationsPath")
+ ).toBe("translations");
+ });
+});
+
+describe("getAssetsPath", () => {
+ beforeEach(() => {
+ ConfigUtils.resetDefaults();
+ });
+ it("should return the default value if not set", () => {
+ expect(
+ ConfigUtils.getAssetsPath()
+ ).toBe("assets");
+ });
+ it("should return the library value value if set", () => {
+ setInternalConfig({ assetsPath: "foo/" })
+ expect(
+ ConfigUtils.getAssetsPath()
+ ).toBe("foo");
+ });
+});
+
+describe("getTranslationsPath", () => {
+ beforeEach(() => {
+ ConfigUtils.resetDefaults();
+ });
+ it("should return the default value if not set", () => {
+ expect(
+ ConfigUtils.getTranslationsPath()
+ ).toBe("translations");
+ });
+ it("should return the library value value if set", () => {
+ setInternalConfig({ translationsPath: "foo/" })
+ expect(
+ ConfigUtils.getTranslationsPath()
+ ).toBe("foo");
+ });
+});
+
+describe("havePlugin", () => {
+ beforeEach(() => {
+ ConfigUtils.resetDefaults();
+ });
+ it("should return false if no plugins", () => {
+ setInternalConfig({
+ plugins: {
+ mobile: [],
+ desktop: []
+ }
+ });
+ expect(
+ ConfigUtils.havePlugin("foo")
+ ).toBeFalsy();
+ });
+ it("should return false if no plugins of right type", () => {
+ mockStateMobile = true;
+ setInternalConfig({
+ plugins: {
+ mobile: [],
+ desktop: [{
+ name: "foo"
+ }]
+ }
+ });
+ expect(
+ ConfigUtils.havePlugin("foo")
+ ).toBeFalsy();
+
+ mockStateMobile = false;
+ setInternalConfig({
+ plugins: {
+ mobile: [{
+ name: "foo"
+ }],
+ desktop: []
+ }
+ });
+ expect(
+ ConfigUtils.havePlugin("foo")
+ ).toBeFalsy();
+ });
+ it("should return true if plugin found", () => {
+ mockStateMobile = true;
+ setInternalConfig({
+ plugins: {
+ mobile: [{
+ name: "foo"
+ }],
+ desktop: []
+ }
+ });
+ expect(
+ ConfigUtils.havePlugin("foo")
+ ).toBeTruthy();
+
+ mockStateMobile = false;
+ setInternalConfig({
+ plugins: {
+ mobile: [],
+ desktop: [{
+ name: "foo"
+ }]
+ }
+ });
+ expect(
+ ConfigUtils.havePlugin("foo")
+ ).toBeTruthy();
+ });
+});
+
+describe("getPluginConfig", () => {
+ beforeEach(() => {
+ ConfigUtils.resetDefaults();
+ });
+ it("should return an empty object if no plugins", () => {
+ setInternalConfig({
+ plugins: {
+ mobile: [],
+ desktop: []
+ }
+ });
+ expect(
+ ConfigUtils.getPluginConfig("foo")
+ ).toEqual({});
+ });
+ it("should return an empty object if no plugins of right type", () => {
+ mockStateMobile = true;
+ setInternalConfig({
+ plugins: {
+ mobile: [],
+ desktop: [{
+ name: "foo"
+ }]
+ }
+ });
+ expect(
+ ConfigUtils.getPluginConfig("foo")
+ ).toEqual({});
+
+ mockStateMobile = false;
+ setInternalConfig({
+ plugins: {
+ mobile: [{
+ name: "foo"
+ }],
+ desktop: []
+ }
+ });
+ expect(
+ ConfigUtils.getPluginConfig("foo")
+ ).toEqual({});
+ });
+ it("should return true if plugin found", () => {
+ mockStateMobile = true;
+ setInternalConfig({
+ plugins: {
+ mobile: [{
+ name: "foo"
+ }],
+ desktop: []
+ }
+ });
+ expect(
+ ConfigUtils.getPluginConfig("foo")
+ ).toEqual({
+ name: "foo"
+ });
+
+ mockStateMobile = false;
+ setInternalConfig({
+ plugins: {
+ mobile: [],
+ desktop: [{
+ name: "foo"
+ }]
+ }
+ });
+ expect(
+ ConfigUtils.getPluginConfig("foo")
+ ).toEqual({
+ name: "foo"
+ });
+ });
+});
\ No newline at end of file
diff --git a/utils/CoordinatesUtils.js b/utils/CoordinatesUtils.js
index 5b3646067..bfa3856b7 100644
--- a/utils/CoordinatesUtils.js
+++ b/utils/CoordinatesUtils.js
@@ -9,32 +9,122 @@
import Proj4js from 'proj4';
import ol from 'openlayers';
+/**
+ * Internal singleton that maps CRS codes to human readable labels.
+ * @private
+ */
const crsLabels = {
"EPSG:4326": "WGS 84",
"EPSG:3857": "WGS 84 / Pseudo Mercator"
};
+/**
+ * Utility functions for coordinate handling and transformations.
+ *
+ * @namespace
+ */
const CoordinatesUtils = {
+ /**
+ * Register names for CRS codes.
+ *
+ * @param {Object.} labels - object with
+ * key/value pairs with CRS code and label
+ * @see {@link CoordinatesUtils.getCrsLabel}
+ * @see {@link CoordinatesUtils.getAvailableCRS}
+ */
setCrsLabels(labels) {
Object.assign(crsLabels, labels);
},
+
+
+ /**
+ * Return the label for a given CRS code. If no label is found, the CRS
+ * code is returned.
+ *
+ * @param {string} crs - the CRS code
+ *
+ * @returns {string} the label for the given CRS code
+ * @see {@link CoordinatesUtils.setCrsLabels}
+ * @see {@link CoordinatesUtils.getAvailableCRS}
+ */
+ getCrsLabel(crs) {
+ return crsLabels[crs] || crs;
+ },
+
+
+ /**
+ * Return the list of available CRS codes.
+ *
+ * The `label` property of each CRS code is set to the
+ * previously registered label of the CRS code, if available.
+ *
+ * @returns {Object.} the list of available
+ * CRS codes
+ * @see {@link CoordinatesUtils.setCrsLabels}
+ * @see {@link CoordinatesUtils.getCrsLabel}
+ */
getAvailableCRS() {
const crsList = {};
for (const a in Proj4js.defs) {
if (Object.prototype.hasOwnProperty.call(Proj4js.defs, a)) {
- crsList[a] = {label: crsLabels[a] || a};
+ crsList[a] = { label: crsLabels[a] || a };
}
}
return crsList;
},
+
+
+ /**
+ * Return the string representing the units of a projection.
+ *
+ * @param {string} projection - the projection code, e.g. 'EPSG:3857'
+ *
+ * @returns {import("ol/proj/Units").Units} the units of the projection
+ * (e.g. 'degrees' or 'm')
+ * @throws {Error} if the projection is unknown
+ */
getUnits(projection) {
const proj = ol.proj.get(projection);
+ if (!proj) {
+ throw new Error(`Invalid projection: ${projection}`);
+ }
return proj.getUnits() || 'degrees';
},
+
+
+ /**
+ * Return the string representing the orientation of the axis.
+ *
+ * @param {string} projection - the projection code, e.g. 'EPSG:3857'
+ *
+ * @returns {string} the string indicating the orientation
+ * (e.g. 'enu' or 'neu')
+ * @throws {Error} if the projection is unknown
+ * @see {@link https://openlayers.org/en/v7.5.2/apidoc/module-ol_proj_Projection-Projection.html#getAxisOrientation}
+ */
getAxisOrder(projection) {
- const axis = ol.proj.get(projection).getAxisOrientation();
- return axis || 'enu';
+ const proj = ol.proj.get(projection);
+ if (!proj) {
+ throw new Error("Invalid projection: " + projection);
+ }
+ return proj.getAxisOrientation() || 'enu';
},
+
+
+ /**
+ * Convert coodinates between different projections.
+ *
+ * If the projections are the same a new point (array) is returned with
+ * same coordinates as the input point.
+ * If the conversion cannot be performed a [0, 0] result is returned.
+ * If either source or destination are not set (empty string,
+ * undefined, null) then `null` is returned.
+ *
+ * @param {ol.Coordinate} point - the point to project
+ * @param {ol.ProjectionLike} source - projection of the source point
+ * @param {ol.ProjectionLike} dest - the destination CRS code
+ * @returns {ol.Coordinate} the transformed point
+ */
reproject(point, source, dest) {
if (source === dest) {
return [...point];
@@ -50,41 +140,78 @@ const CoordinatesUtils = {
}
return null;
},
+
+
/**
* Reprojects a bounding box.
+ *
+ * The function simply converts the two corners of
+ * the bounding box.
*
- * @param bbox {array} [minx, miny, maxx, maxy]
- * @param source {string} SRS of the given bbox
- * @param dest {string} SRS of the returned bbox
+ * @param {number[]} bbox - The box to transform
+ * as an array of [minx, miny, maxx, maxy]
+ * @param {string} source - SRS of the given bbox
+ * @param {string} dest - SRS of the returned bbox
*
- * @return {array} [minx, miny, maxx, maxy]
+ * @returns {number[]} The result as an array
+ * of [minx, miny, maxx, maxy]
*/
reprojectBbox(bbox, source, dest) {
const sw = CoordinatesUtils.reproject([bbox[0], bbox[1]], source, dest);
const ne = CoordinatesUtils.reproject([bbox[2], bbox[3]], source, dest);
return [...sw, ...ne];
},
+
+
+ /**
+ * Calculate the direction (azimuth) between two points in degrees.
+ *
+ * @param {ol.Coordinate} p1 - the first point
+ * @param {ol.Coordinate} p2 - the second point
+ * @param {string} pj - the projection of the points
+ *
+ * @returns {number} the direction in degrees, in interval [0..360)
+ */
calculateAzimuth(p1, p2, pj) {
+ // Convert both points to WGS 84. The result is in degrees.
const p1proj = CoordinatesUtils.reproject(p1, pj, 'EPSG:4326');
const p2proj = CoordinatesUtils.reproject(p2, pj, 'EPSG:4326');
+
+ // Convert to radians.
const lon1 = p1proj[0] * Math.PI / 180.0;
const lat1 = p1proj[1] * Math.PI / 180.0;
const lon2 = p2proj[0] * Math.PI / 180.0;
const lat2 = p2proj[1] * Math.PI / 180.0;
+
+ // Intermediate values.
const dLon = lon2 - lon1;
const y = Math.sin(dLon) * Math.cos(lat2);
- const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon);
- const azimuth = (((Math.atan2(y, x) * 180.0 / Math.PI) + 360 ) % 360 );
+ const x = (
+ Math.cos(lat1) * Math.sin(lat2) -
+ Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon)
+ );
+ // Compute the azimuth in radians and convert it in degrees.
+ const azimuth = (((Math.atan2(y, x) * 180.0 / Math.PI) + 360) % 360);
return azimuth;
},
+
+
/**
- * Extend an extent given another one
- *
- * @param extent1 {array} [minx, miny, maxx, maxy]
- * @param extent2 {array} [minx, miny, maxx, maxy]
+ * Extend an extent given another one.
*
- * @return {array} [minx, miny, maxx, maxy]
+ * The function assumes (but does not check) that the
+ * two extents are in the same projection and the given extents
+ * are valid (minx <= maxx, miny <= maxy).
+ *
+ * @param {number[]} extent1 - First bounding
+ * box as an array of [minx, miny, maxx, maxy]
+ * @param {number[]} extent2 - Second bounding
+ * box as an array of [minx, miny, maxx, maxy]
+ *
+ * @returns {number[]} The common bounding
+ * box as an array of [minx, miny, maxx, maxy]
+ * @see {@link CoordinatesUtils.isValidExtent}
*/
extendExtent(extent1, extent2) {
return [
@@ -94,28 +221,69 @@ const CoordinatesUtils = {
Math.max(extent1[3], extent2[3])
];
},
+
+
/**
- * Check extent validity
+ * Check extent validity.
+ *
+ * A valid extent is an array of four numbers with the following
+ * constraints:
+ * - minx <= maxx
+ * - miny <= maxy
+ * - no Infinity or -Infinity in any of its members.
*
- * @param extent {array} [minx, miny, maxx, maxy]
+ * @param {number[]} extent - The bounding
+ * box as an array of [minx, miny, maxx, maxy]
*
- * @return {bool}
+ * @returns {bool} True if the extent is valid, false otherwise.
*/
isValidExtent(extent) {
return !(
+ !Array.isArray(extent) ||
+ extent.length < 4 ||
extent.indexOf(Infinity) !== -1 || extent.indexOf(-Infinity) !== -1 ||
extent[1] >= extent[2] || extent[1] >= extent[3]
);
},
+
+
+ /**
+ * Convert a CRS string from OGC notation to EPSG notation.
+ *
+ * @param {string} crsStr - The CRS string in OGC notation
+ *
+ * @returns {string} The CRS string in EPSG notation.
+ * @see {@link https://epsg.io/}
+ * @see {@link https://www.ogc.org/about-ogc/policies/ogc-urn-policy/}
+ */
fromOgcUrnCrs(crsStr) {
- if (crsStr.endsWith(":CRS84")) {
+ const parts = crsStr.split(":");
+ if (parts.length < 2) {
+ throw new Error("Invalid OGC CRS: " + crsStr);
+ }
+ const last = parts.slice(-1)[0];
+ if (last === "CRS84") {
return "EPSG:4326";
}
- const parts = crsStr.split(":");
- return "EPSG:" + parts.slice(-1);
+ return "EPSG:" + last;
},
+
+
+ /**
+ * Convert a CRS string from EPSG notation to OGC notation.
+ *
+ * @param {string} crsStr - The CRS string in EPSG notation
+ *
+ * @returns {string} The CRS string in OGC notation
+ * @throws {Error} if the CRS string is invalid
+ * @see {@link https://epsg.io/}
+ * @see {@link https://www.ogc.org/about-ogc/policies/ogc-urn-policy/}
+ */
toOgcUrnCrs(crsStr) {
const parts = crsStr.split(":");
+ if (parts.length !== 2) {
+ throw new Error("Invalid CRS: " + crsStr);
+ }
return "urn:ogc:def:crs:" + parts[0] + "::" + parts[1];
}
};
diff --git a/utils/CoordinatesUtils.test.js b/utils/CoordinatesUtils.test.js
new file mode 100644
index 000000000..0ae5186bf
--- /dev/null
+++ b/utils/CoordinatesUtils.test.js
@@ -0,0 +1,338 @@
+import CoordinatesUtils from './CoordinatesUtils';
+
+const invalidProjections = ['XXYY', 'ABCD', 'EPSG'];
+
+
+describe('setCrsLabels', () => {
+ it('should set crsLabels', () => {
+ CoordinatesUtils.setCrsLabels({ testIn: 'testOut' });
+ expect(
+ CoordinatesUtils.getCrsLabel("testIn")
+ ).toBe('testOut');
+ });
+});
+
+
+describe('getCrsLabel', () => {
+ it('should retrieve default values', () => {
+ expect(
+ CoordinatesUtils.getCrsLabel("EPSG:4326")
+ ).toBe('WGS 84');
+ expect(
+ CoordinatesUtils.getCrsLabel("EPSG:3857")
+ ).toBe('WGS 84 / Pseudo Mercator');
+ });
+});
+
+
+describe('getAvailableCRS', () => {
+ it('should retrieve default values', () => {
+ const available = CoordinatesUtils.getAvailableCRS();
+ expect(available["EPSG:4326"].label).toBe('WGS 84');
+ expect(available["EPSG:3857"].label).toBe('WGS 84 / Pseudo Mercator');
+ expect(available["GOOGLE"].label).toBe('GOOGLE');
+ expect(available["WGS84"].label).toBe('WGS84');
+ });
+});
+
+
+describe('getUnits', () => {
+ it('should throw if the projection is unknown', () => {
+ invalidProjections.forEach((projection) => {
+ expect(() => {
+ CoordinatesUtils.getUnits(projection)
+ }).toThrow(/^Invalid projection.+/);
+ });
+ });
+
+ it('should return proper units for known projections', () => {
+ expect(
+ CoordinatesUtils.getUnits('EPSG:4326')
+ ).toBe('degrees');
+ expect(
+ CoordinatesUtils.getUnits('EPSG:3857')
+ ).toBe('m');
+ });
+});
+
+
+describe('getAxisOrder', () => {
+ it('should throw if the projection is unknown', () => {
+ invalidProjections.forEach((projection) => {
+ expect(() => {
+ CoordinatesUtils.getAxisOrder(projection)
+ }).toThrow(/^Invalid projection.+/);
+ });
+ });
+
+ it('should return proper order for known projections', () => {
+ expect(
+ CoordinatesUtils.getAxisOrder('EPSG:4326')
+ ).toBe('neu');
+ expect(
+ CoordinatesUtils.getAxisOrder('EPSG:3857')
+ ).toBe('enu');
+ });
+});
+
+
+describe('reproject', () => {
+ it("should return null if no src or no dest", () => {
+ expect(
+ CoordinatesUtils.reproject([1, 2], 'EPSG:4326', null)
+ ).toBe(null);
+ expect(
+ CoordinatesUtils.reproject([1, 2], null, 'EPSG:4326')
+ ).toBe(null);
+ expect(
+ CoordinatesUtils.reproject([1, 2], 'EPSG:4326', undefined)
+ ).toBe(null);
+ expect(
+ CoordinatesUtils.reproject([1, 2], undefined, 'EPSG:4326')
+ ).toBe(null);
+ expect(
+ CoordinatesUtils.reproject([1, 2], 'EPSG:4326', '')
+ ).toBe(null);
+ expect(
+ CoordinatesUtils.reproject([1, 2], '', 'EPSG:4326')
+ ).toBe(null);
+ });
+ it("should return same point if same CRS", () => {
+ expect(
+ CoordinatesUtils.reproject([1, 2], undefined, undefined)
+ ).not.toBe([1, 2]);
+ expect(
+ CoordinatesUtils.reproject([1, 2], undefined, undefined)
+ ).toStrictEqual([1, 2]);
+ expect(
+ CoordinatesUtils.reproject([1, 2], 'EPSG:4326', 'EPSG:4326')
+ ).not.toBe([1, 2]);
+ expect(
+ CoordinatesUtils.reproject([1, 2], 'EPSG:4326', 'EPSG:4326')
+ ).toStrictEqual([1, 2]);
+ });
+ it("should convert from EPSG:4326 to EPSG:3857", () => {
+ const converted = CoordinatesUtils.reproject(
+ [0, 0], 'EPSG:4326', 'EPSG:3857'
+ );
+ expect(converted[0]).toBeCloseTo(0, 4);
+ expect(converted[1]).toBeCloseTo(0, 4);
+ });
+ it("should convert from EPSG:3857 to EPSG:4326", () => {
+ const converted = CoordinatesUtils.reproject(
+ [0, 0], 'EPSG:3857', 'EPSG:4326'
+ );
+ expect(converted[0]).toBeCloseTo(0, 4);
+ expect(converted[1]).toBeCloseTo(0, 4);
+ });
+});
+
+
+describe('reprojectBbox', () => {
+ it("should compute the bbox of a point", () => {
+ const result = CoordinatesUtils.reprojectBbox(
+ [1, 2, 1, 2], 'EPSG:4326', 'EPSG:3857'
+ );
+ expect(result[0]).toBeCloseTo(111319.49, 1);
+ expect(result[1]).toBeCloseTo(222684.20, 1);
+ expect(result[2]).toBeCloseTo(111319.49, 1);
+ expect(result[3]).toBeCloseTo(222684.20, 1);
+ });
+ it("should compute the bbox of a line", () => {
+ const result = CoordinatesUtils.reprojectBbox(
+ [1, 2, 3, 4], 'EPSG:4326', 'EPSG:3857'
+ );
+ expect(result[0]).toBeCloseTo(111319.49, 1);
+ expect(result[1]).toBeCloseTo(222684.20, 1);
+ expect(result[2]).toBeCloseTo(333958.47, 1);
+ expect(result[3]).toBeCloseTo(445640.10, 1);
+ });
+ it("should silently ignore the invalid CRS", () => {
+ expect(
+ CoordinatesUtils.reprojectBbox([1, 2, 3, 4], 'EPSG', 'EPSG')
+ ).toStrictEqual([1, 2, 3, 4]);
+ expect(
+ CoordinatesUtils.reprojectBbox([1, 2, 3, 4], 'EPSG', 'EPSG:3857')
+ ).toStrictEqual([0, 0, 0, 0]);
+ expect(
+ CoordinatesUtils.reprojectBbox([1, 2, 3, 4], 'EPSG:4326', 'EPSG')
+ ).toStrictEqual([0, 0, 0, 0]);
+ });
+ it("should throw on empty CRS", () => {
+ expect(() => {
+ CoordinatesUtils.reprojectBbox([1, 2, 3, 4], null)
+ }).toThrow(/^object null is not iterable+/);
+ });
+});
+
+
+describe('calculateAzimuth', () => {
+ it("should silently ignore the invalid CRS", () => {
+ expect(
+ CoordinatesUtils.calculateAzimuth([1, 2], [3, 4], 'EPSG')
+ ).toBe(0);
+ expect(
+ CoordinatesUtils.calculateAzimuth([1, 2], [3, 4], '3857')
+ ).toBe(0);
+ });
+ it("should throw on empty CRS", () => {
+ expect(() => {
+ CoordinatesUtils.calculateAzimuth([1, 2], [3, 4], null)
+ }).toThrow(/^Cannot read properties of null+/);
+ expect(() => {
+ CoordinatesUtils.calculateAzimuth([1, 2], [3, 4], undefined)
+ }).toThrow(/^Cannot read properties of null+/);
+ expect(() => {
+ CoordinatesUtils.calculateAzimuth([1, 2], [3, 4], '')
+ }).toThrow(/^Cannot read properties of null+/);
+ });
+ it("should return 0 if the two points are the same", () => {
+ expect(
+ CoordinatesUtils.calculateAzimuth([1, 2], [1, 2], 'EPSG:4326')
+ ).toBe(0);
+ expect(
+ CoordinatesUtils.calculateAzimuth([1, 2], [1, 2], 'EPSG:3857')
+ ).toBe(0);
+ });
+ it("should return the azimuth between two points", () => {
+ expect(
+ CoordinatesUtils.calculateAzimuth([1, 2], [3, 4], 'EPSG:4326')
+ ).toBeCloseTo(45, 0);
+ expect(
+ CoordinatesUtils.calculateAzimuth([1, 2], [3, 4], 'EPSG:3857')
+ ).toBeCloseTo(45, 0);
+ });
+});
+
+
+describe('extendExtent', () => {
+ it("should return a good result", () => {
+ expect(
+ CoordinatesUtils.extendExtent([1, 2, 3, 4], [5, 6, 7, 8])
+ ).toStrictEqual([1, 2, 7, 8]);
+ expect(
+ CoordinatesUtils.extendExtent([0, 0, 0, 0], [0, 0, 0, 0])
+ ).toStrictEqual([0, 0, 0, 0]);
+ });
+});
+
+
+describe('isValidExtent', () => {
+ it("should return false if the extent is not an array", () => {
+ expect(
+ CoordinatesUtils.isValidExtent(null)
+ ).toBe(false);
+ expect(
+ CoordinatesUtils.isValidExtent(undefined)
+ ).toBe(false);
+ expect(
+ CoordinatesUtils.isValidExtent("")
+ ).toBe(false);
+ expect(
+ CoordinatesUtils.isValidExtent(1)
+ ).toBe(false);
+ expect(
+ CoordinatesUtils.isValidExtent({})
+ ).toBe(false);
+ });
+ it("should return false if the extent is not an array of 4 numbers", () => {
+ expect(
+ CoordinatesUtils.isValidExtent([1, 2, 3])
+ ).toBe(false);
+ expect(
+ CoordinatesUtils.isValidExtent([1, 2])
+ ).toBe(false);
+ expect(
+ CoordinatesUtils.isValidExtent([1])
+ ).toBe(false);
+ });
+ it("should return false if the extent contains Infinity or -Infinity", () => {
+ expect(
+ CoordinatesUtils.isValidExtent([1, 2, 3, Infinity])
+ ).toBe(false);
+ expect(
+ CoordinatesUtils.isValidExtent([1, 2, 3, -Infinity])
+ ).toBe(false);
+ expect(
+ CoordinatesUtils.isValidExtent([1, 2, Infinity, 4])
+ ).toBe(false);
+ expect(
+ CoordinatesUtils.isValidExtent([1, 2, -Infinity, 4])
+ ).toBe(false);
+ expect(
+ CoordinatesUtils.isValidExtent([1, Infinity, 3, 4])
+ ).toBe(false);
+ expect(
+ CoordinatesUtils.isValidExtent([1, -Infinity, 3, 4])
+ ).toBe(false);
+ expect(
+ CoordinatesUtils.isValidExtent([Infinity, 2, 3, 4])
+ ).toBe(false);
+ expect(
+ CoordinatesUtils.isValidExtent([-Infinity, 2, 3, 4])
+ ).toBe(false);
+ });
+ it("should return false if the extent is not valid", () => {
+ expect(
+ CoordinatesUtils.isValidExtent([1, 2, 0, 4])
+ ).toBe(false);
+ expect(
+ CoordinatesUtils.isValidExtent([1, 2, 3, 2])
+ ).toBe(false);
+ });
+ it("should return true if the extent is valid", () => {
+ expect(
+ CoordinatesUtils.isValidExtent([1, 2, 3, 4])
+ ).toBe(true);
+ });
+});
+
+
+describe('fromOgcUrnCrs', () => {
+ it("should throw if the CRS is invalid", () => {
+ expect(() => {
+ CoordinatesUtils.fromOgcUrnCrs("urn")
+ }).toThrow(/^Invalid OGC CRS.+/);
+ expect(() => {
+ CoordinatesUtils.fromOgcUrnCrs("EPSG")
+ }).toThrow(/^Invalid OGC CRS.+/);
+ expect(() => {
+ CoordinatesUtils.fromOgcUrnCrs("")
+ }).toThrow(/^Invalid OGC CRS.+/);
+ });
+ it("should convert from urn:ogc:def:crs:EPSG::4326 to EPSG:4326", () => {
+ expect(
+ CoordinatesUtils.fromOgcUrnCrs("urn:ogc:def:crs:EPSG::4326")
+ ).toBe("EPSG:4326");
+ });
+ it("Just sticks the last part of the URN onto EPSG:", () => {
+ expect(
+ CoordinatesUtils.fromOgcUrnCrs("have:fun")
+ ).toBe("EPSG:fun");
+ });
+ it("deals with one special case", () => {
+ expect(
+ CoordinatesUtils.fromOgcUrnCrs("urn:ogc:def:crs:OGC:1.3:CRS84")
+ ).toBe("EPSG:4326");
+ });
+});
+
+
+describe('toOgcUrnCrs', () => {
+ it("should throw if the CRS is invalid", () => {
+ expect(() => {
+ CoordinatesUtils.toOgcUrnCrs("EPSG:4326:4326")
+ }).toThrow(/^Invalid CRS.+/);
+ expect(() => {
+ CoordinatesUtils.toOgcUrnCrs("EPSG")
+ }).toThrow(/^Invalid CRS.+/);
+ expect(() => {
+ CoordinatesUtils.toOgcUrnCrs("4326")
+ }).toThrow(/^Invalid CRS.+/);
+ });
+ it("should convert from EPSG:4326 to urn:ogc:def:crs:EPSG::4326", () => {
+ expect(
+ CoordinatesUtils.toOgcUrnCrs("EPSG:4326")
+ ).toBe("urn:ogc:def:crs:EPSG::4326");
+ });
+});
diff --git a/utils/EditingInterface.js b/utils/EditingInterface.js
index 8e2391363..fc2d57761 100644
--- a/utils/EditingInterface.js
+++ b/utils/EditingInterface.js
@@ -7,11 +7,12 @@
*/
/**
- * NOTE: This sample editing interface is designed to work with the counterpart at
+ * NOTE: This sample editing interface is designed to work with
+ * the counterpart at
* https://github.com/qwc-services/qwc-data-service
*
- * You can use any other editing backend by implementing the getFeature, addFeature,
- * editFeature and deleteFeature methods as necessary.
+ * You can use any other editing backend by implementing the
+ * getFeature, addFeature, editFeature and deleteFeature methods as necessary.
*/
import axios from 'axios';
import isEmpty from 'lodash.isempty';
@@ -19,257 +20,386 @@ import ConfigUtils from './ConfigUtils';
import LocaleUtils from './LocaleUtils';
+/**
+ * @callback FeatureCallback
+ *
+ * The methods that deal with single features take a callback function as
+ * parameter. The callback function is called with the result of the operation.
+ * The result is either a feature or null, depending on whether the operation
+ * was successful or not.
+ *
+ * @param {object|null} feature - the feature returned
+ */
+
+
+/**
+ * @callback FeaturesCallback
+ *
+ * The methods that deal with multiple features take a callback function as
+ * parameter. The callback function is called with the result of the operation.
+ * The result is either an array of features or null, depending on whether
+ * the operation was successful or not.
+ *
+ * @param {object[]|null} features - the features returned
+ */
+
+
+/**
+ * Build an error message from an axios error response.
+ *
+ * @param {object} err - the axios error response
+ *
+ * @return {string} the error message
+ * @private
+ */
function buildErrMsg(err) {
- let message = LocaleUtils.tr("editing.commitfailed");
+ let message;
if (err.response && err.response.data && err.response.data.message) {
message = err.response.data.message;
if (!isEmpty(err.response.data.geometry_errors)) {
message += ":\n";
- message += err.response.data.geometry_errors.map(entry => {
- let entrymsg = " - " + entry.reason;
+ message += err.response.data.geometry_errors.map((entry, index) => {
+ let entryMsg = " - " + entry.reason;
if (entry.location) {
- entrymsg += " at " + entry.location;
+ entryMsg += " at " + entry.location;
}
- return entrymsg;
+ if (index) {
+ entryMsg = "\n" + entryMsg;
+ }
+ return entryMsg;
});
}
if (!isEmpty(err.response.data.data_errors)) {
- message += ":\n - " + err.response.data.data_errors.join("\n - ");
+ message += (
+ ":\n - " +
+ err.response.data.data_errors.join("\n - ")
+ );
}
if (!isEmpty(err.response.data.validation_errors)) {
- message += ":\n - " + err.response.data.validation_errors.join("\n - ");
+ message += (
+ ":\n - " +
+ err.response.data.validation_errors.join("\n - ")
+ );
}
if (!isEmpty(err.response.data.attachment_errors)) {
- message += ":\n - " + err.response.data.attachment_errors.join("\n - ");
+ message += (
+ ":\n - " +
+ err.response.data.attachment_errors.join("\n - ")
+ );
+ }
+ } else {
+ message = LocaleUtils.tr("editing.commitfailed");
+ if (err.response && err.response.statusText) {
+ message += ": " + err.response.statusText;
}
- } else if (err.response && err.response.statusText) {
- message += ": " + err.response.statusText;
}
return message;
}
-/*
- layerId: The edit layer id
- mapPos: the map position
- mapCrs: the map crs
- mapScale: the map scale denominator
- dpi: the map resolution
- callback: function(result), on success result is a collection of features, on failure, result is null
- filter: the filter expression as [["", "", ],"and|or",["","",],...], or null
-*/
-function getFeature(layerId, mapPos, mapCrs, mapScale, dpi, callback, filter = null) {
- const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
- const req = SERVICE_URL + layerId + '/';
- // 10px tolerance
- const tol = (10.0 / dpi) * 0.0254 * mapScale;
- const bbox = (mapPos[0] - tol) + "," + (mapPos[1] - tol) + "," + (mapPos[0] + tol) + "," + (mapPos[1] + tol);
+/**
+ * Utility functions for editing layers in the map.
+ *
+ * NOTE: This sample editing interface is designed to work with
+ * the counterpart at
+ * https://github.com/qwc-services/qwc-data-service
+ *
+ * You can use any other editing backend by implementing the
+ * getFeature, addFeature, editFeature and deleteFeature methods as necessary.
+ *
+ * @namespace
+ */
+const EditingInterface = {
- const params = {
- bbox: bbox,
- crs: mapCrs,
- filter: filter ? JSON.stringify(filter) : undefined
- };
- const headers = {
- "Accept-Language": LocaleUtils.lang()
- };
- axios.get(req, {headers, params}).then(response => {
- if (response.data && !isEmpty(response.data.features)) {
- const version = +new Date();
- response.data.features.forEach(feature => {
- feature.__version__ = version;
- });
- callback(response.data);
- } else {
- callback(null);
- }
- }).catch(() => callback(null));
-}
+ /**
+ * Get a feature at a given map position.
+ *
+ * @param {string} layerId - the edit layer id
+ * @param {number[]} mapPos - the map position as `[x, y]`
+ * @param {string} mapCrs - the map crs
+ * @param {number} mapScale - the map scale denominator
+ * @param {number} dpi - the map resolution in dots per inch
+ * @param {FeatureCallback} callback - function(result), on success
+ * result is a feature, on failure, result is null
+ * @param {object} filter - the filter expression as
+ * `[["", "", ],"and|or",["","",],...],`
+ * or null
+ */
+ getFeature(
+ layerId, mapPos, mapCrs, mapScale, dpi, callback, filter = null
+ ) {
+ const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
+ const req = SERVICE_URL + layerId + '/';
-/*
- layerId: The edit layer id
- featureId: The feature id
- mapCrs: the map crs
- callback: function(result), on success result is a feature, on failure, result is null
-*/
-function getFeatureById(layerId, featureId, mapCrs, callback) {
- const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
- const req = SERVICE_URL + layerId + '/' + featureId;
- const params = {
- crs: mapCrs
- };
- const headers = {
- "Accept-Language": LocaleUtils.lang()
- };
- axios.get(req, {headers, params}).then(response => {
- response.data.__version__ = +new Date();
- callback(response.data);
- }).catch(() => callback(null));
-}
+ // 10px tolerance
+ const tol = (10.0 / dpi) * 0.0254 * mapScale;
+ const bbox = (
+ (mapPos[0] - tol) + "," +
+ (mapPos[1] - tol) + "," +
+ (mapPos[0] + tol) + "," +
+ (mapPos[1] + tol)
+ );
-/*
- layerId: The edit layer id
- mapCrs: the map crs
- callback: function(result), on success result is a collection of features, on failure, result is null
- bbox: the filter bounding box as [xmin, xmax, ymin, xmax], or null
- filter: the filter expression as [["", "", ],"and|or",["","",],...], or null
-*/
-function getFeatures(layerId, mapCrs, callback, bbox = null, filter = null) {
- const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
- const req = SERVICE_URL + layerId + '/';
- const params = {
- crs: mapCrs,
- bbox: bbox ? bbox.join(",") : undefined,
- filter: filter ? JSON.stringify(filter) : undefined
- };
- const headers = {
- "Accept-Language": LocaleUtils.lang()
- };
- axios.get(req, {headers, params}).then(response => {
- if (response.data && !isEmpty(response.data.features)) {
- const version = +new Date();
- response.data.features.forEach(feature => {
- feature.__version__ = version;
- });
+ const params = {
+ bbox,
+ crs: mapCrs,
+ filter: filter ? JSON.stringify(filter) : undefined
+ };
+ const headers = {
+ "Accept-Language": LocaleUtils.lang()
+ };
+ axios.get(req, { headers, params }).then(response => {
+ if (response.data && !isEmpty(response.data.features)) {
+ const version = +new Date();
+ response.data.features.forEach(feature => {
+ feature.__version__ = version;
+ });
+ callback(response.data);
+ } else {
+ callback(null);
+ }
+ }).catch(() => callback(null));
+ },
+
+ /**
+ * Get a feature by id.
+ *
+ * @param {string} layerId - the edit layer id
+ * @param {string} featureId - the feature id
+ * @param {string} mapCrs - the map crs
+ * @param {FeatureCallback} callback - function(result), on success
+ * result is a feature, on failure, result is null
+ */
+ getFeatureById(layerId, featureId, mapCrs, callback) {
+ const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
+ const req = SERVICE_URL + layerId + '/' + featureId;
+ const params = {
+ crs: mapCrs
+ };
+ const headers = {
+ "Accept-Language": LocaleUtils.lang()
+ };
+ axios.get(req, { headers, params }).then(response => {
+ response.data.__version__ = +new Date();
callback(response.data);
- } else {
+ }).catch(() => {
+ console.log("getFeatureById failed");
callback(null);
- }
- }).catch(() => callback(null));
-}
+ });
+ },
-/*
- layerId: The edit layer id
- mapCrs: the map crs
- callback: function(result), on success result is a {"bbox": [xmin, ymin, xmax, ymax]} object, on failure, result is null
- filter: the filter expression as [["", "", ],"and|or",["","",],...], or null
-*/
-function getExtent(layerId, mapCrs, callback, filter = null) {
- const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
- const req = SERVICE_URL + layerId + "/extent";
- const params = {
- crs: mapCrs,
- filter: filter ? JSON.stringify(filter) : undefined
- };
- const headers = {
- "Accept-Language": LocaleUtils.lang()
- };
- axios.get(req, {headers, params}).then(response => {
- callback(response.data);
- }).catch(() => callback(null));
-}
+ /**
+ * Get features.
+ *
+ * @param {string} layerId - the edit layer id
+ * @param {string} mapCrs - the map crs
+ * @param {FeaturesCallback} callback - on success
+ * result is a collection of features, on failure, result is null
+ * @param {number[]} bbox - the filter bounding box as
+ * `[xmin, xmax, ymin, xmax]`, or null
+ * @param {object} filter - the filter expression as
+ * `[["", "", ],"and|or",["","",],...],`
+ * or null
+ */
+ getFeatures(layerId, mapCrs, callback, bbox = null, filter = null) {
+ const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
+ const req = SERVICE_URL + layerId + '/';
+ const params = {
+ crs: mapCrs,
+ bbox: bbox ? bbox.join(",") : undefined,
+ filter: filter ? JSON.stringify(filter) : undefined
+ };
+ const headers = {
+ "Accept-Language": LocaleUtils.lang()
+ };
+ axios.get(req, { headers, params }).then(response => {
+ if (response.data && !isEmpty(response.data.features)) {
+ const version = +new Date();
+ response.data.features.forEach(feature => {
+ feature.__version__ = version;
+ });
+ callback(response.data);
+ } else {
+ callback(null);
+ }
+ }).catch(() => callback(null));
+ },
-/*
- layerId: The edit layer id
- featureData: a FormData instance, with a 'feature' entry containing the GeoJSON serialized feature and optionally one or more 'file:' prefixed file upload entries
- callback: function(success, result), if success = true, result is the committed GeoJSON feature, if success = false, result is an error message
-*/
-function addFeatureMultipart(layerId, featureData, callback) {
- const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
- const req = SERVICE_URL + layerId + '/multipart';
- const headers = {
- "Content-Type": "multipart/form-data",
- "Accept-Language": LocaleUtils.lang()
- };
- axios.post(req, featureData, {headers}).then(response => {
- response.data.__version__ = +new Date();
- callback(true, response.data);
- }).catch(err => callback(false, buildErrMsg(err)));
-}
+ /**
+ * Get the extent of a layer.
+ *
+ * @param {string} layerId - the edit layer id
+ * @param {string} mapCrs - the map crs
+ * @param {function} callback - function(result), on success result is
+ * a {"bbox": [xmin, ymin, xmax, ymax]} object, on failure, result is null
+ * @param {object} filter - the filter expression as
+ * `[["", "", ],"and|or",["","",],...],`
+ * or null
+ */
+ getExtent(layerId, mapCrs, callback, filter = null) {
+ const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
+ const req = SERVICE_URL + layerId + "/extent";
+ const params = {
+ crs: mapCrs,
+ filter: filter ? JSON.stringify(filter) : undefined
+ };
+ const headers = {
+ "Accept-Language": LocaleUtils.lang()
+ };
+ axios.get(req, { headers, params }).then(response => {
+ callback(response.data);
+ }).catch(() => callback(null));
+ },
-/*
- layerId: The edit layer id
- featureId: The id of the feature to edit
- featureData: a FormData instance, with a 'feature' entry containing the GeoJSON serialized feature and optionally one or more 'file:' prefixed file upload entries
- callback: function(success, result), if success = true, result is the committed GeoJSON feature, if success = false, result is an error message
-*/
-function editFeatureMultipart(layerId, featureId, featureData, callback) {
- const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
- const req = SERVICE_URL + layerId + '/multipart/' + featureId;
- const headers = {
- "Content-Type": "multipart/form-data",
- "Accept-Language": LocaleUtils.lang()
- };
- axios.put(req, featureData, {headers}).then(response => {
- response.data.__version__ = +new Date();
- callback(true, response.data);
- }).catch(err => callback(false, buildErrMsg(err)));
-}
+ /**
+ * Add a feature.
+ *
+ * @param {string} layerId - the edit layer id
+ * @param {FormData} featureData - contains a 'feature' entry
+ * containing the GeoJSON serialized feature and optionally one or more
+ * 'file:' prefixed file upload entries
+ * @param {function} callback - function(success, result),
+ * if success = true, result is the committed GeoJSON feature,
+ * if success = false, result is an error message
+ */
+ addFeatureMultipart(layerId, featureData, callback) {
+ const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
+ const req = SERVICE_URL + layerId + '/multipart';
+ const headers = {
+ "Content-Type": "multipart/form-data",
+ "Accept-Language": LocaleUtils.lang()
+ };
+ axios.post(req, featureData, { headers }).then(response => {
+ response.data.__version__ = +new Date();
+ callback(true, response.data);
+ }).catch(err => callback(false, buildErrMsg(err)));
+ },
-/*
- layerId: The edit layer id
- featureId: The id of the feature to delete
- callback: function(success, result), if success = true, result is null, if success = false, result is an error message
-*/
-function deleteFeature(layerId, featureId, callback) {
- const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
- const req = SERVICE_URL + layerId + '/' + featureId;
- const headers = {
- "Accept-Language": LocaleUtils.lang()
- };
- axios.delete(req, {headers}).then(() => {
- callback(true, featureId);
- }).catch(err => callback(false, buildErrMsg(err)));
-}
+ /**
+ * Edit a feature.
+ *
+ * @param {string} layerId - the edit layer id
+ * @param {string} featureId - the id of the feature to edit
+ * @param {FormData} featureData - a FormData instance, with a 'feature'
+ * entry containing the GeoJSON serialized feature and optionally one
+ * or more 'file:' prefixed file upload entries
+ * @param {function} callback - function(success, result),
+ * if success = true, result is the committed GeoJSON feature,
+ * if success = false, result is an error message
+ */
+ editFeatureMultipart(layerId, featureId, featureData, callback) {
+ const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
+ const req = SERVICE_URL + layerId + '/multipart/' + featureId;
+ const headers = {
+ "Content-Type": "multipart/form-data",
+ "Accept-Language": LocaleUtils.lang()
+ };
+ axios.put(req, featureData, { headers }).then(response => {
+ response.data.__version__ = +new Date();
+ callback(true, response.data);
+ }).catch(err => callback(false, buildErrMsg(err)));
+ },
-function getRelations(layerId, featureId, tables, mapCrs, callback) {
- const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
- const req = SERVICE_URL + layerId + '/' + featureId + "/relations";
- const params = {
- tables: tables,
- crs: mapCrs
- };
- const headers = {
- "Accept-Language": LocaleUtils.lang()
- };
- axios.get(req, {headers, params}).then(response => {
- callback(response.data);
- }).catch(() => callback({}));
-}
+ /**
+ * Delete a feature.
+ *
+ * @param {string} layerId - the edit layer id
+ * @param {string} featureId - the id of the feature to delete
+ * @param {function} callback - function(success, result),
+ * if success = true, result is null,
+ * if success = false, result is an error message
+ */
+ deleteFeature(layerId, featureId, callback) {
+ const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
+ const req = SERVICE_URL + layerId + '/' + featureId;
+ const headers = {
+ "Accept-Language": LocaleUtils.lang()
+ };
+ axios.delete(req, { headers }).then(() => {
+ callback(true, featureId);
+ }).catch(err => callback(false, buildErrMsg(err)));
+ },
-function writeRelations(layerId, featureId, relationData, mapCrs, callback) {
- const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
- const req = SERVICE_URL + layerId + '/' + featureId + "/relations";
- const params = {
- crs: mapCrs
- };
- const headers = {
- "Content-Type": "multipart/form-data",
- "Accept-Language": LocaleUtils.lang()
- };
- axios.post(req, relationData, {headers, params}).then(response => {
- callback(response.data);
- }).catch(err => callback(false, buildErrMsg(err)));
-}
+ /**
+ * Get relations for a feature.
+ *
+ * @param {string} layerId - the edit layer id
+ * @param {string} featureId - the id of the feature to get relations for
+ * @param {string[]} tables - the list of tables to get relations for
+ * @param {string} mapCrs - the map crs
+ * @param {function} callback - function(result), on success result is
+ * a {"relations": {"": [{"id": , "feature": }, ...]}},
+ * on failure, result is an empty object
+ */
+ getRelations(layerId, featureId, tables, mapCrs, callback) {
+ const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
+ const req = SERVICE_URL + layerId + '/' + featureId + "/relations";
+ const params = {
+ tables: tables,
+ crs: mapCrs
+ };
+ const headers = {
+ "Accept-Language": LocaleUtils.lang()
+ };
+ axios.get(req, { headers, params }).then(response => {
+ callback(response.data);
+ }).catch(() => callback({}));
+ },
-/*
- keyvalues: ::,::,...
- callback: function(result), result is a {"keyvalues": {"": [{"key": , "value": ", "", ],"and|or",["","",],...]] (one filter expr per keyvalue entry), or null
-*/
-function getKeyValues(keyvalues, callback, filter = null) {
- const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
- const req = SERVICE_URL + "keyvals?tables=" + keyvalues;
- const params = {
- filter: filter ? JSON.stringify(filter) : undefined
- };
- const headers = {
- "Accept-Language": LocaleUtils.lang()
- };
- axios.get(req, {headers, params}).then(response => {
- callback(response.data);
- }).catch(() => callback({}));
-}
+ /**
+ * Write relations for a feature.
+ *
+ * @param {string} layerId - the edit layer id
+ * @param {string} featureId - the id of the feature to write relations for
+ * @param {FormData} relationData - the relation data as a FormData instance,
+ * with a 'relations' entry containing the relation data
+ * @param {string} mapCrs - the map crs
+ * @param {function} callback - function(result), on success result is
+ * a {"relations": {"": [{"id": , "feature": }, ...]}},
+ * on failure, result is false and an error message
+ */
+ writeRelations(layerId, featureId, relationData, mapCrs, callback) {
+ const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
+ const req = SERVICE_URL + layerId + '/' + featureId + "/relations";
+ const params = {
+ crs: mapCrs
+ };
+ const headers = {
+ "Content-Type": "multipart/form-data",
+ "Accept-Language": LocaleUtils.lang()
+ };
+ axios.post(req, relationData, { headers, params }).then(response => {
+ callback(response.data);
+ }).catch(err => callback(false, buildErrMsg(err)));
+ },
-export default {
- getFeature,
- getFeatureById,
- getFeatures,
- getExtent,
- addFeatureMultipart,
- editFeatureMultipart,
- deleteFeature,
- writeRelations,
- getRelations,
- getKeyValues
+ /**
+ * Get key values.
+ *
+ * @param {string} keyvalues - the key-values to get as
+ * `::,::,...`
+ * @param {function} callback - function(result), on success result is
+ * a {"keyvalues": {"": [{"key": , "value": ", "", ],"and|or",["","",],...],`
+ * or null
+ *
+ * @namespace EditingInterface
+ */
+ getKeyValues(keyvalues, callback, filter = null) {
+ const SERVICE_URL = ConfigUtils.getConfigProp("editServiceUrl");
+ const req = SERVICE_URL + "keyvals?tables=" + keyvalues;
+ const params = {
+ filter: filter ? JSON.stringify(filter) : undefined
+ };
+ const headers = {
+ "Accept-Language": LocaleUtils.lang()
+ };
+ axios.get(req, { headers, params }).then(response => {
+ callback(response.data);
+ }).catch(() => callback({}));
+ },
};
+
+export default EditingInterface;
diff --git a/utils/EditingInterface.test.js b/utils/EditingInterface.test.js
new file mode 100644
index 000000000..90c80ef00
--- /dev/null
+++ b/utils/EditingInterface.test.js
@@ -0,0 +1,656 @@
+import mockAxios from 'jest-mock-axios';
+import EdIface from './EditingInterface';
+
+let mockEditServiceUrl = "url-foo/";
+jest.mock("./ConfigUtils", () => ({
+ __esModule: true,
+ default: {
+ getConfigProp: (name) => {
+ if (name === 'editServiceUrl') {
+ return mockEditServiceUrl;
+ }
+ },
+ },
+}));
+
+let mockLocale = "xy";
+jest.mock("./LocaleUtils", () => ({
+ __esModule: true,
+ default: {
+ lang: () => mockLocale,
+ tr: (msg) => msg,
+ },
+}));
+
+
+afterEach(() => {
+ mockAxios.reset();
+});
+
+describe("addFeatureMultipart", () => {
+ const formData = new FormData();
+ formData.append('feature', 'Fred');
+ formData.append('file', 'XX-YY');
+ const args = ["layer-x", formData];
+ const postArgs = {
+ "headers": {
+ "Accept-Language": "xy",
+ "Content-Type": "multipart/form-data",
+ },
+ };
+ const response = {
+ data: {
+ abc: "def"
+ }
+ };
+ const result = {
+ "__version__": 1585688400000,
+ abc: "def"
+ };
+ it("should return response data", () => {
+ const callback = jest.fn();
+ EdIface.addFeatureMultipart(...args, callback);
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ 'url-foo/layer-x/multipart', formData, postArgs
+ );
+ mockAxios.mockResponse({ ...response });
+ expect(callback).toHaveBeenCalledWith(true, result);
+ });
+ it("should return an error if connection fails", () => {
+ mockAxios.post.mockRejectedValueOnce({
+ response: {
+ data: {
+ abcd: "efg"
+ }
+ }
+ });
+ const callback = jest.fn((result, error) => {
+ expect(result).toBe(false);
+ expect(error).toBe("editing.commitfailed");
+ });
+ EdIface.addFeatureMultipart(...args, callback);
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ 'url-foo/layer-x/multipart', formData, postArgs
+ );
+ });
+ it("should return the error in the message", () => {
+ mockAxios.post.mockRejectedValueOnce({
+ response: {
+ data: {
+ message: "foo-err"
+ }
+ }
+ });
+ const callback = jest.fn((result, error) => {
+ expect(result).toBe(false);
+ expect(error).toBe("foo-err");
+ });
+ EdIface.addFeatureMultipart(...args, callback);
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ 'url-foo/layer-x/multipart', formData, postArgs
+ );
+ });
+ it("should return the error in statusText", () => {
+ mockAxios.post.mockRejectedValueOnce({
+ response: {
+ statusText: "foo-err"
+ }
+ });
+ const callback = jest.fn((result, error) => {
+ expect(result).toBe(false);
+ expect(error).toBe("editing.commitfailed: foo-err");
+ });
+ EdIface.addFeatureMultipart(...args, callback);
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ 'url-foo/layer-x/multipart', formData, postArgs
+ );
+ });
+ it("should return the various errors", () => {
+ mockAxios.post.mockRejectedValueOnce({
+ response: {
+ data: {
+ message: "foo-err",
+ geometry_errors: [{
+ reason: "lorem",
+ location: 123
+ }, {
+ reason: "ipsum",
+ location: 456
+ }],
+ data_errors: [
+ "dolor", "sit", "amet"
+ ],
+ validation_errors: [
+ "consectetur", "adipiscing", "elit"
+ ],
+ attachment_errors: [
+ "sed", "do", "eiusmod", "tempor"
+ ]
+ }
+ }
+ });
+ const callback = jest.fn((result, error) => {
+ expect(result).toBe(false);
+ expect(error).toBe(
+ "foo-err:\n" +
+ " - lorem at 123,\n" +
+ " - ipsum at 456:\n" +
+ " - dolor\n" +
+ " - sit\n" +
+ " - amet:\n" +
+ " - consectetur\n" +
+ " - adipiscing\n" +
+ " - elit:\n" +
+ " - sed\n" +
+ " - do\n" +
+ " - eiusmod\n" +
+ " - tempor"
+ );
+ });
+ EdIface.addFeatureMultipart(...args, callback);
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ 'url-foo/layer-x/multipart', formData, postArgs
+ );
+ });
+});
+
+describe("deleteFeature", () => {
+ const args = ["layer-x", "123456"];
+ const postArgs = {
+ "headers": {
+ "Accept-Language": "xy",
+ },
+ };
+ it("should return response data", () => {
+ const callback = jest.fn();
+ EdIface.deleteFeature(...args, callback);
+ expect(mockAxios.delete).toHaveBeenCalledWith(
+ 'url-foo/layer-x/123456', postArgs
+ );
+ mockAxios.mockResponse({});
+ expect(callback).toHaveBeenCalledWith(true, "123456");
+ });
+ it("should return an error if connection fails", () => {
+ mockAxios.delete.mockRejectedValueOnce({
+ response: {
+ data: {
+ abcd: "efg"
+ }
+ }
+ });
+ const callback = jest.fn((result, error) => {
+ expect(result).toBe(false);
+ expect(error).toBe("editing.commitfailed");
+ });
+ EdIface.deleteFeature(...args, callback);
+ expect(mockAxios.delete).toHaveBeenCalledWith(
+ 'url-foo/layer-x/123456', postArgs
+ );
+ });
+});
+
+describe("editFeatureMultipart", () => {
+ const formData = new FormData();
+ formData.append('feature', 'Fred');
+ formData.append('file', 'XX-YY');
+ const args = ["layer-x", "123456", formData];
+ const postArgs = {
+ "headers": {
+ "Accept-Language": "xy",
+ "Content-Type": "multipart/form-data",
+ },
+ };
+ const response = {
+ data: {
+ abc: "def"
+ }
+ };
+ const result = {
+ "__version__": 1585688400000,
+ abc: "def"
+ };
+ it("should return response data", () => {
+ const callback = jest.fn();
+ EdIface.editFeatureMultipart(...args, callback);
+ expect(mockAxios.put).toHaveBeenCalledWith(
+ 'url-foo/layer-x/multipart/123456', formData, postArgs
+ );
+ mockAxios.mockResponse({ ...response });
+ expect(callback).toHaveBeenCalledWith(true, result);
+ });
+ it("should return an error if connection fails", () => {
+ mockAxios.put.mockRejectedValueOnce({
+ response: {
+ data: {
+ abcd: "efg"
+ }
+ }
+ });
+ const callback = jest.fn((result, error) => {
+ expect(result).toBe(false);
+ expect(error).toBe("editing.commitfailed");
+ });
+ EdIface.editFeatureMultipart(...args, callback);
+ expect(mockAxios.put).toHaveBeenCalledWith(
+ 'url-foo/layer-x/multipart/123456', formData, postArgs
+ );
+ });
+});
+
+describe("getExtent", () => {
+ const args = ["layer-x", "EPSG:3857"];
+ const getArgs = {
+ "headers": {
+ "Accept-Language": "xy",
+ },
+ "params": {
+ "crs": "EPSG:3857",
+ filter: undefined,
+ },
+ };
+ const response = {
+ data: "foo-data",
+ };
+ it("should return an extent", () => {
+ const callback = jest.fn();
+ EdIface.getExtent(...args, callback);
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/layer-x/extent', getArgs
+ );
+ mockAxios.mockResponse({ ...response });
+ expect(callback).toHaveBeenCalledWith("foo-data");
+ });
+ it("should return null if connection fails", () => {
+ mockAxios.get.mockRejectedValueOnce(new Error("foo-err"));
+ const callback = jest.fn((result) => {
+ expect(result).toBe(null);
+ });
+ EdIface.getExtent(...args, callback);
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/layer-x/extent', getArgs
+ );
+ });
+ it("should forward filters", () => {
+ const callback = jest.fn();
+ EdIface.getExtent(
+ ...args, callback, [["", "", ""]]
+ );
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/layer-x/extent', {
+ ...getArgs,
+ params: {
+ ...getArgs.params,
+ filter: '[["","",""]]',
+ },
+ });
+ });
+});
+
+describe("getFeature", () => {
+ const args = ["layer-x", [0, 0], "EPSG:3857", 1 / 0.0254, 10];
+ const getArgs = {
+ "headers": {
+ "Accept-Language": "xy",
+ },
+ "params": {
+ "bbox": "-1,-1,1,1",
+ "crs": "EPSG:3857",
+ "filter": undefined,
+ },
+ };
+ const response = {
+ data: {
+ features: [
+ {
+ geometry: {
+ coordinates: [0, 0],
+ type: "Point",
+ },
+ id: "id-foo",
+ properties: {},
+ type: "Feature",
+ },
+ ],
+ type: "FeatureCollection",
+ },
+ };
+ const result = {
+ features: [
+ {
+ "__version__": 1585688400000,
+ geometry: {
+ coordinates: [0, 0],
+ type: "Point",
+ },
+ id: "id-foo",
+ properties: {},
+ type: "Feature",
+ },
+ ],
+ type: "FeatureCollection",
+ };
+ it("should return a feature", () => {
+ const callback = jest.fn();
+ EdIface.getFeature(...args, callback, null);
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/layer-x/', getArgs
+ );
+ mockAxios.mockResponse({ ...response });
+ expect(callback).toHaveBeenCalledWith(result);
+ });
+ it("should forward filters", () => {
+ const callback = jest.fn();
+ EdIface.getFeature(
+ ...args, callback, [["", "", ""]]
+ );
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/layer-x/', {
+ ...getArgs,
+ params: {
+ ...getArgs.params,
+ filter: '[["","",""]]',
+ },
+ }
+ );
+ });
+ it("should return null if connection fails", () => {
+ mockAxios.get.mockRejectedValueOnce(new Error("foo-err"));
+ const callback = jest.fn((result) => {
+ expect(result).toBe(null);
+ });
+ EdIface.getFeature(...args, callback, null);
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/layer-x/', getArgs
+ );
+ });
+ it("should return null if data is missing", () => {
+ const callback = jest.fn((result) => {
+ expect(result).toBe(null);
+ });
+ EdIface.getFeature(...args, callback, null);
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/layer-x/', getArgs
+ );
+ mockAxios.mockResponse({ a: "b" });
+ expect(callback).toHaveBeenCalledWith(null);
+ });
+ it("should return null if the array is empty", () => {
+ const callback = jest.fn((result) => {
+ expect(result).toBe(null);
+ });
+ EdIface.getFeature(...args, callback, null);
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/layer-x/', getArgs
+ );
+ mockAxios.mockResponse({ data: { features: [] } });
+ expect(callback).toHaveBeenCalledWith(null);
+ });
+});
+
+describe("getFeatureById", () => {
+ const args = ["layer-x", "1234", "EPSG:3857"];
+ const getArgs = {
+ "headers": {
+ "Accept-Language": "xy",
+ },
+ "params": {
+ "crs": "EPSG:3857",
+ "filter": undefined,
+ },
+ };
+ const response = {
+ data: {
+ geometry: {
+ coordinates: [0, 0],
+ type: "Point",
+ },
+ id: "id-foo",
+ properties: {},
+ },
+ type: "Feature",
+ };
+ const result = {
+ "__version__": 1585688400000,
+ geometry: {
+ coordinates: [0, 0],
+ type: "Point",
+ },
+ id: "id-foo",
+ properties: {},
+ };
+ it("should return a feature", () => {
+ const callback = jest.fn();
+ EdIface.getFeatureById(...args, callback);
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/layer-x/1234', getArgs
+ );
+ mockAxios.mockResponse({ ...response });
+ expect(callback).toHaveBeenCalledWith(result);
+ });
+ it("should return null if connection fails", () => {
+ mockAxios.get.mockRejectedValueOnce(new Error("foo-err"));
+ const callback = jest.fn((result) => {
+ expect(result).toBe(null);
+ });
+ EdIface.getFeatureById(...args, callback);
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/layer-x/1234', getArgs
+ );
+ });
+});
+
+describe("getFeatures", () => {
+ const args = ["layer-x", "EPSG:3857"];
+ const getArgs = {
+ "headers": {
+ "Accept-Language": "xy",
+ },
+ "params": {
+ "bbox": undefined,
+ "crs": "EPSG:3857",
+ "filter": undefined,
+ },
+ };
+ const response = {
+ data: {
+ features: [
+ {
+ geometry: {
+ coordinates: [0, 0],
+ type: "Point",
+ },
+ id: "id-foo",
+ properties: {},
+ type: "Feature",
+ },
+ ],
+ type: "FeatureCollection",
+ },
+ };
+ const result = {
+ features: [
+ {
+ "__version__": 1585688400000,
+ geometry: {
+ coordinates: [0, 0],
+ type: "Point",
+ },
+ id: "id-foo",
+ properties: {},
+ type: "Feature",
+ },
+ ],
+ type: "FeatureCollection",
+ };
+ it("should return a list of features", () => {
+ const callback = jest.fn();
+ EdIface.getFeatures(...args, callback);
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/layer-x/', getArgs
+ );
+ mockAxios.mockResponse({ ...response });
+ expect(callback).toHaveBeenCalledWith(result);
+ });
+ it("should forward filters", () => {
+ const callback = jest.fn();
+ EdIface.getFeatures(
+ ...args, callback, null, [["", "", ""]]
+ );
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/layer-x/', {
+ ...getArgs,
+ params: {
+ ...getArgs.params,
+ filter: '[["","",""]]',
+ },
+ }
+ );
+ });
+ it("should forward bounding box", () => {
+ const callback = jest.fn();
+ EdIface.getFeatures(
+ ...args, callback, [0, 0, 1, 1]
+ );
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/layer-x/', {
+ ...getArgs,
+ params: {
+ ...getArgs.params,
+ bbox: "0,0,1,1",
+ },
+ });
+ });
+ it("should return null if connection fails", () => {
+ mockAxios.get.mockRejectedValueOnce(new Error("foo-err"));
+ const callback = jest.fn((result) => {
+ expect(result).toBe(null);
+ });
+ EdIface.getFeatures(...args, callback);
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/layer-x/', getArgs
+ );
+ });
+ it("should return null if data is missing", () => {
+ const callback = jest.fn((result) => {
+ expect(result).toBe(null);
+ });
+ EdIface.getFeatures(...args, callback);
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/layer-x/', getArgs
+ );
+ mockAxios.mockResponse({ a: "b" });
+ expect(callback).toHaveBeenCalledWith(null);
+ });
+});
+
+describe("getKeyValues", () => {
+ const getArgs = {
+ "headers": {
+ "Accept-Language": "xy",
+ },
+ "params": {
+ "filter": undefined
+ },
+ };
+ const response = {
+ data: "foo"
+ };
+ it("should return the data", () => {
+ const callback = jest.fn();
+ EdIface.getKeyValues("getKeyValues", callback);
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/keyvals?tables=getKeyValues', getArgs
+ );
+ mockAxios.mockResponse({ ...response });
+ expect(callback).toHaveBeenCalledWith("foo");
+ });
+ it("should return empty object in case of error", () => {
+ mockAxios.get.mockRejectedValueOnce(new Error("foo-err"));
+ const callback = jest.fn((result) => {
+ expect(result).toEqual({});
+ });
+ EdIface.getKeyValues("getKeyValues", callback);
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/keyvals?tables=getKeyValues', getArgs
+ );
+ });
+});
+
+describe("getRelations", () => {
+ const args = ["layer-x", "56789", "XYZ", "EPSG:3857"];
+ const getArgs = {
+ "headers": {
+ "Accept-Language": "xy",
+ },
+ "params": {
+ "crs": "EPSG:3857",
+ "tables": "XYZ",
+ },
+ };
+ const response = {
+ data: "foo"
+ };
+ it("should return the data", () => {
+ const callback = jest.fn();
+ EdIface.getRelations(...args, callback);
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/layer-x/56789/relations', getArgs
+ );
+ mockAxios.mockResponse({ ...response });
+ expect(callback).toHaveBeenCalledWith("foo");
+ });
+ it("should return empty object in case of error", () => {
+ mockAxios.get.mockRejectedValueOnce(new Error("foo-err"));
+ const callback = jest.fn((result) => {
+ expect(result).toEqual({});
+ });
+ EdIface.getRelations(...args, callback);
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ 'url-foo/layer-x/56789/relations', getArgs
+ );
+ });
+});
+
+describe("writeRelations", () => {
+ const formData = new FormData();
+ formData.append('feature', 'Fred');
+ const args = ["layer-x", "56789", formData, "EPSG:3857"];
+ const getArgs = {
+ "headers": {
+ "Content-Type": "multipart/form-data",
+ "Accept-Language": "xy",
+ },
+ "params": {
+ "crs": "EPSG:3857",
+ },
+ };
+ const response = {
+ data: "foo"
+ };
+ it("should return the data", () => {
+ const callback = jest.fn();
+ EdIface.writeRelations(...args, callback);
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ 'url-foo/layer-x/56789/relations', formData, getArgs
+ );
+ mockAxios.mockResponse({ ...response });
+ expect(callback).toHaveBeenCalledWith("foo");
+ });
+ it("should return an error if connection fails", () => {
+ mockAxios.post.mockRejectedValueOnce({
+ response: {
+ data: {
+ abcd: "efg"
+ }
+ }
+ });
+ const callback = jest.fn((result, error) => {
+ expect(result).toBe(false);
+ expect(error).toBe("editing.commitfailed");
+ });
+ EdIface.writeRelations(...args, callback);
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ 'url-foo/layer-x/56789/relations', formData, getArgs
+ );
+ });
+});
diff --git a/utils/FeatureStyles.js b/utils/FeatureStyles.js
index a94aba032..ce30f3cb7 100644
--- a/utils/FeatureStyles.js
+++ b/utils/FeatureStyles.js
@@ -55,83 +55,138 @@ const DEFAULT_INTERACTION_STYLE = {
sketchPointRadius: 6,
}
-const defaultStyle = (feature, options) => {
- const opts = {...DEFAULT_FEATURE_STYLE, ...ConfigUtils.getConfigProp("defaultFeatureStyle"), ...options};
- const styles = [];
- styles.push(
- new ol.style.Style({
- fill: new ol.style.Fill({
- color: opts.fillColor
- }),
- stroke: new ol.style.Stroke({
- color: opts.strokeColor,
- width: opts.strokeWidth,
- lineDash: opts.strokeDash
- }),
- image: opts.circleRadius > 0 ? new ol.style.Circle({
- radius: opts.circleRadius,
- fill: new ol.style.Fill({ color: opts.fillColor }),
- stroke: new ol.style.Stroke({color: opts.strokeColor, width: opts.strokeWidth})
- }) : null
- })
- );
- if (feature.getProperties().label) {
+
+/**
+ * Utility functions for styling features in the map.
+ *
+ * @namespace
+ */
+const FeatureStyles = {
+ /**
+ * Returns the default style for features.
+ *
+ * @param {import("ol").Feature} feature - the feature to style
+ * @param {object} options - additional options to override the
+ * default style
+ *
+ * @returns {import("ol/style").Style[]} the list of styles to apply
+ */
+ default(feature, options) {
+ const opts = {
+ ...DEFAULT_FEATURE_STYLE,
+ ...ConfigUtils.getConfigProp("defaultFeatureStyle"),
+ ...options
+ };
+ const styles = [];
styles.push(
new ol.style.Style({
- geometry: (f) => {
- if (f.getGeometry().getType().startsWith("Multi")) {
- // Only label middle point
- const extent = f.getGeometry().getExtent();
- return new ol.geom.Point(f.getGeometry().getClosestPoint(ol.extent.getCenter(extent)));
- }
- return f.getGeometry();
- },
- text: new ol.style.Text({
- font: opts.textFont || '11pt sans-serif',
- text: feature.getProperties().label || "",
- overflow: true,
- fill: new ol.style.Fill({color: opts.textFill}),
- stroke: new ol.style.Stroke({color: opts.textStroke, width: 3}),
- textAlign: feature.getGeometry().getType() === "Point" ? 'left' : 'center',
- textBaseline: feature.getGeometry().getType() === "Point" ? 'bottom' : 'middle',
- offsetX: feature.getGeometry().getType() === "Point" ? (5 + opts.circleRadius) : 0
- })
+ fill: new ol.style.Fill({
+ color: opts.fillColor
+ }),
+ stroke: new ol.style.Stroke({
+ color: opts.strokeColor,
+ width: opts.strokeWidth,
+ lineDash: opts.strokeDash
+ }),
+ image: opts.circleRadius > 0
+ ? new ol.style.Circle({
+ radius: opts.circleRadius,
+ fill: new ol.style.Fill({ color: opts.fillColor }),
+ stroke: new ol.style.Stroke({
+ color: opts.strokeColor,
+ width: opts.strokeWidth
+ })
+ })
+ : null
})
);
- }
- if (feature.getProperties().segment_labels) {
- const segmentLabels = feature.getProperties().segment_labels;
- const coo = feature.getGeometry().getCoordinates();
- for (let i = 0; i < coo.length - 1; ++i) {
- const p1 = coo[i];
- const p2 = coo[i + 1];
- let angle = -Math.atan2(p2[1] - p1[1], p2[0] - p1[0]);
- while (angle < -0.5 * Math.PI) {
- angle += Math.PI;
- }
- while (angle > 0.5 * Math.PI) {
- angle -= Math.PI;
- }
- styles.push(new ol.style.Style({
- geometry: new ol.geom.Point([0.5 * (p1[0] + p2[0]), 0.5 * (p1[1] + p2[1])]),
- text: new ol.style.Text({
- font: opts.textFont || '11pt sans-serif',
- text: segmentLabels[i],
- fill: new ol.style.Fill({color: opts.textFill}),
- stroke: new ol.style.Stroke({color: opts.textStroke, width: 3}),
- rotation: angle,
- offsetY: 10
+ if (feature.getProperties().label) {
+ styles.push(
+ new ol.style.Style({
+ geometry: (f) => {
+ if (f.getGeometry().getType().startsWith("Multi")) {
+ // Only label middle point
+ const extent = f.getGeometry().getExtent();
+ return new ol.geom.Point(
+ f.getGeometry().getClosestPoint(
+ ol.extent.getCenter(extent)
+ )
+ );
+ }
+ return f.getGeometry();
+ },
+ text: new ol.style.Text({
+ font: opts.textFont || '11pt sans-serif',
+ text: feature.getProperties().label || "",
+ overflow: true,
+ fill: new ol.style.Fill({ color: opts.textFill }),
+ stroke: new ol.style.Stroke({
+ color: opts.textStroke,
+ width: 3
+ }),
+ textAlign: feature.getGeometry().getType() === "Point"
+ ? 'left'
+ : 'center',
+ textBaseline: feature.getGeometry().getType() === "Point"
+ ? 'bottom'
+ : 'middle',
+ offsetX: feature.getGeometry().getType() === "Point"
+ ? (5 + opts.circleRadius)
+ : 0
+ })
})
- }));
+ );
}
- }
- return styles;
-};
+ if (feature.getProperties().segment_labels) {
+ const segmentLabels = feature.getProperties().segment_labels;
+ const coo = feature.getGeometry().getCoordinates();
+ for (let i = 0; i < coo.length - 1; ++i) {
+ const p1 = coo[i];
+ const p2 = coo[i + 1];
+ let angle = -Math.atan2(p2[1] - p1[1], p2[0] - p1[0]);
+ while (angle < -0.5 * Math.PI) {
+ angle += Math.PI;
+ }
+ while (angle > 0.5 * Math.PI) {
+ angle -= Math.PI;
+ }
+ styles.push(new ol.style.Style({
+ geometry: new ol.geom.Point([
+ 0.5 * (p1[0] + p2[0]),
+ 0.5 * (p1[1] + p2[1])
+ ]),
+ text: new ol.style.Text({
+ font: opts.textFont || '11pt sans-serif',
+ text: segmentLabels[i],
+ fill: new ol.style.Fill({ color: opts.textFill }),
+ stroke: new ol.style.Stroke({
+ color: opts.textStroke,
+ width: 3
+ }),
+ rotation: angle,
+ offsetY: 10
+ })
+ }));
+ }
+ }
+ return styles;
+ },
-export default {
- default: defaultStyle,
+ /**
+ * Returns the style for markers.
+ *
+ * @param {import("ol").Feature} feature - the feature to style
+ * @param {object} options - additional options to override the
+ * default style
+ *
+ * @returns {import("ol/style").Style[]} the list of styles to apply
+ */
marker: (feature, options) => {
- const opts = {...DEFAULT_MARKER_STYLE, ...ConfigUtils.getConfigProp("defaultMarkerStyle"), ...options};
+ const opts = {
+ ...DEFAULT_MARKER_STYLE,
+ ...ConfigUtils.getConfigProp("defaultMarkerStyle"),
+ ...options
+ };
return [
new ol.style.Style({
image: new ol.style.Icon({
@@ -148,14 +203,30 @@ export default {
font: opts.textFont || '11pt sans-serif',
text: feature.getProperties().label || "",
offsetY: 8,
- fill: new ol.style.Fill({color: opts.textColor}),
- stroke: new ol.style.Stroke({color: opts.textStroke, width: 3})
+ fill: new ol.style.Fill({ color: opts.textColor }),
+ stroke: new ol.style.Stroke({
+ color: opts.textStroke,
+ width: 3
+ })
})
})
];
},
+
+ /**
+ * Returns the style for interaction features.
+ *
+ * @param {object} options - additional options to override the
+ * default style
+ *
+ * @returns {import("ol/style").Style} the style to apply
+ */
interaction: (options, isSnap) => {
- const opts = {...DEFAULT_INTERACTION_STYLE, ...ConfigUtils.getConfigProp("defaultInteractionStyle"), ...options};
+ const opts = {
+ ...DEFAULT_INTERACTION_STYLE,
+ ...ConfigUtils.getConfigProp("defaultInteractionStyle"),
+ ...options
+ };
let fillColor = opts.fillColor;
let strokeColor = opts.strokeColor;
let strokeWidth = opts.strokeWidth;
@@ -166,11 +237,27 @@ export default {
}
return new ol.style.Style({
fill: new ol.style.Fill({ color: fillColor }),
- stroke: new ol.style.Stroke({ color: strokeColor, width: strokeWidth})
+ stroke: new ol.style.Stroke({
+ color: strokeColor,
+ width: strokeWidth
+ })
});
},
+
+ /**
+ * Returns the style for interaction vertices.
+ *
+ * @param {object} options - additional options to override the
+ * default style
+ *
+ * @returns {import("ol/style").Style} the style to apply
+ */
interactionVertex: (options, isSnap) => {
- const opts = {...DEFAULT_INTERACTION_STYLE, ...ConfigUtils.getConfigProp("defaultInteractionStyle"), ...options};
+ const opts = {
+ ...DEFAULT_INTERACTION_STYLE,
+ ...ConfigUtils.getConfigProp("defaultInteractionStyle"),
+ ...options
+ };
let strokeWidth = opts.strokeWidth;
let vertexFill = opts.vertexFillColor;
let vertexStroke = opts.vertexStrokeColor;
@@ -182,7 +269,10 @@ export default {
return new ol.style.Style({
image: new ol.style.RegularShape({
fill: new ol.style.Fill({ color: vertexFill }),
- stroke: new ol.style.Stroke({ color: vertexStroke, width: strokeWidth }),
+ stroke: new ol.style.Stroke({
+ color: vertexStroke,
+ width: strokeWidth
+ }),
points: 4,
radius: 5,
angle: Math.PI / 4
@@ -190,37 +280,95 @@ export default {
geometry: opts.geometryFunction,
});
},
+
+ /**
+ * Returns the style for measure interactions.
+ *
+ * @param {import("ol").Feature} feature - the feature to style
+ * @param {object} options - additional options to override the
+ * default style
+ *
+ * @returns {import("ol/style").Style[]} the list of styles to apply
+ */
measureInteraction: (feature, options) => {
- const opts = {...DEFAULT_INTERACTION_STYLE, ...ConfigUtils.getConfigProp("defaultInteractionStyle"), ...options};
+ const opts = {
+ ...DEFAULT_INTERACTION_STYLE,
+ ...ConfigUtils.getConfigProp("defaultInteractionStyle"),
+ ...options
+ };
const styleOptions = {
strokeColor: opts.measureStrokeColor,
strokeWidth: opts.measureStrokeWidth,
fillColor: opts.measureFillColor,
strokeDash: []
};
- return defaultStyle(feature, styleOptions);
+ return FeatureStyles.default(feature, styleOptions);
},
+
+ /**
+ * Returns the style for measure interaction vertices.
+ *
+ * @param {object} options - additional options to override the
+ * default style
+ *
+ * @returns {import("ol/style").Style} the style to apply
+ */
measureInteractionVertex: (options) => {
- const opts = {...DEFAULT_INTERACTION_STYLE, ...ConfigUtils.getConfigProp("defaultInteractionStyle"), ...options};
+ const opts = {
+ ...DEFAULT_INTERACTION_STYLE,
+ ...ConfigUtils.getConfigProp("defaultInteractionStyle"),
+ ...options
+ };
return new ol.style.Style({
image: new ol.style.Circle({
radius: opts.measurePointRadius,
- fill: new ol.style.Fill({color: opts.measureVertexFillColor}),
- stroke: new ol.style.Stroke({ color: opts.measureVertexStrokeColor, width: opts.measureVertexStrokeWidth })
+ fill: new ol.style.Fill({
+ color: opts.measureVertexFillColor
+ }),
+ stroke: new ol.style.Stroke({
+ color: opts.measureVertexStrokeColor,
+ width: opts.measureVertexStrokeWidth
+ })
}),
geometry: opts.geometryFunction,
});
},
+
+ /**
+ * Returns the style for sketch features.
+ *
+ * @param {object} options - additional options to override the
+ * default style
+ *
+ * @returns {import("ol/style").Style} the style to apply
+ */
sketchInteraction: (options) => {
- const opts = {...DEFAULT_INTERACTION_STYLE, ...ConfigUtils.getConfigProp("defaultInteractionStyle"), ...options};
+ const opts = {
+ ...DEFAULT_INTERACTION_STYLE,
+ ...ConfigUtils.getConfigProp("defaultInteractionStyle"),
+ ...options
+ };
return new ol.style.Style({
image: new ol.style.Circle({
- fill: new ol.style.Fill({color: opts.sketchPointFillColor}),
- stroke: new ol.style.Stroke({color: opts.sketchPointStrokeColor, width: opts.strokeWidth}),
+ fill: new ol.style.Fill({ color: opts.sketchPointFillColor }),
+ stroke: new ol.style.Stroke({
+ color: opts.sketchPointStrokeColor,
+ width: opts.strokeWidth
+ }),
radius: opts.sketchPointRadius
})
});
},
+
+ /**
+ * Returns the style for images.
+ *
+ * @param {import("ol").Feature} feature - the feature to style
+ * @param {object} options - additional options to override the
+ * default style
+ *
+ * @returns {import("ol/style").Style} the style to apply
+ */
image: (feature, options) => {
return new ol.style.Style({
image: new ol.style.Icon({
@@ -232,6 +380,15 @@ export default {
})
});
},
+
+ /**
+ * Returns the style for text.
+ *
+ * @param {import("ol").Feature} feature - the feature to style
+ * @param {object} options - additional options to override the
+ *
+ * @returns {import("ol/style").Style[]} the list of styles to apply
+ */
text: (feature, options) => {
return [
new ol.style.Style({
@@ -239,10 +396,15 @@ export default {
font: '10pt sans-serif',
text: feature.getProperties().label || "",
scale: options.strokeWidth,
- fill: new ol.style.Fill({color: options.fillColor}),
- stroke: new ol.style.Stroke({color: options.strokeColor, width: 2})
+ fill: new ol.style.Fill({ color: options.fillColor }),
+ stroke: new ol.style.Stroke({
+ color: options.strokeColor,
+ width: 2
+ })
})
})
];
}
};
+
+export default FeatureStyles;
diff --git a/utils/FeatureStyles.test.js b/utils/FeatureStyles.test.js
new file mode 100644
index 000000000..2704c894a
--- /dev/null
+++ b/utils/FeatureStyles.test.js
@@ -0,0 +1,146 @@
+import { Point } from 'ol/geom';
+import { Feature } from 'ol';
+import { Style } from 'ol/style';
+
+import FeatureStyles from './FeatureStyles';
+
+
+let mockDefaultFeatureStyle = {
+ "foo": "bar",
+};
+jest.mock("./ConfigUtils", () => ({
+ __esModule: true,
+ default: {
+ getConfigProp: (name) => {
+ if (name === 'defaultFeatureStyle') {
+ return mockDefaultFeatureStyle;
+ }
+ },
+ },
+}));
+
+let mockFeatureProps = {};
+const getPropertiesMock = jest
+ .spyOn(Feature.prototype, 'getProperties')
+ .mockImplementation(() => mockFeatureProps);
+
+
+describe("default", () => {
+ it('returns default style', () => {
+ const feature = new Feature(new Point([1, 2]));
+ const styles = FeatureStyles.default(feature);
+ expect(styles.length).toBe(1);
+ expect(styles[0]).toBeInstanceOf(Style);
+ });
+ it('adds a layer style', () => {
+ const feature = new Feature(new Point([1, 2]));
+ feature.set("geometry", new Point([1, 2]));
+ mockFeatureProps = {
+ label: "abcd",
+ }
+ const styles = FeatureStyles.default(feature);
+ expect(styles.length).toBe(2);
+ expect(styles[0]).toBeInstanceOf(Style);
+ expect(styles[1]).toBeInstanceOf(Style);
+ });
+ it('adds a segment labels style', () => {
+ const feature = new Feature(new Point([1, 2]));
+ mockFeatureProps = {
+ segment_labels: [
+ "abcd",
+ "def"
+ ]
+ }
+ const styles = FeatureStyles.default(feature);
+ expect(styles.length).toBe(2);
+ expect(styles[0]).toBeInstanceOf(Style);
+ expect(styles[1]).toBeInstanceOf(Style);
+ });
+ it('adds both', () => {
+ const feature = new Feature(new Point([1, 2]));
+ mockFeatureProps = {
+ label: "abcd",
+ segment_labels: [
+ "abcd",
+ "def"
+ ]
+ }
+ const styles = FeatureStyles.default(feature);
+ expect(styles.length).toBe(3);
+ expect(styles[0]).toBeInstanceOf(Style);
+ expect(styles[1]).toBeInstanceOf(Style);
+ expect(styles[2]).toBeInstanceOf(Style);
+ });
+});
+
+describe("image", () => {
+ it("returns default style", () => {
+ const feature = new Feature(new Point([1, 2]));
+ const style = FeatureStyles.image(feature, {
+ img: new Image(),
+ rotation: 0,
+ size: 10,
+ });
+ expect(style).toBeInstanceOf(Style);
+ });
+});
+
+describe("interaction", () => {
+ it('returns the style', () => {
+ const feature = new Feature(new Point([1, 2]));
+ const style = FeatureStyles.interaction(feature, false);
+ expect(style).toBeInstanceOf(Style);
+ });
+});
+
+describe("interactionVertex", () => {
+ it('returns the style', () => {
+ const style = FeatureStyles.interactionVertex({}, false);
+ expect(style).toBeInstanceOf(Style);
+ });
+});
+
+describe("marker", () => {
+ it('returns the style', () => {
+ const feature = new Feature(new Point([1, 2]));
+ const styles = FeatureStyles.marker(feature);
+ expect(styles.length).toBe(1);
+ expect(styles[0]).toBeInstanceOf(Style);
+ });
+});
+
+describe("measureInteraction", () => {
+ it('returns the style', () => {
+ const feature = new Feature(new Point([1, 2]));
+ const styles = FeatureStyles.measureInteraction(feature);
+ expect(styles.length).toBe(3);
+ expect(styles[0]).toBeInstanceOf(Style);
+ expect(styles[1]).toBeInstanceOf(Style);
+ expect(styles[2]).toBeInstanceOf(Style);
+ });
+});
+
+
+describe("measureInteractionVertex", () => {
+ it('returns the style', () => {
+ const style = FeatureStyles.measureInteractionVertex({});
+ expect(style).toBeInstanceOf(Style);
+ });
+});
+
+describe("sketchInteraction", () => {
+ it('returns the style', () => {
+ const style = FeatureStyles.sketchInteraction({});
+ expect(style).toBeInstanceOf(Style);
+ });
+});
+
+describe("text", () => {
+ it('returns the style', () => {
+ const feature = new Feature(new Point([1, 2]));
+ const styles = FeatureStyles.measureInteraction(feature);
+ expect(styles.length).toBe(3);
+ expect(styles[0]).toBeInstanceOf(Style);
+ });
+});
+
diff --git a/utils/IdentifyUtils.js b/utils/IdentifyUtils.js
index 41ce3b665..375a71a36 100644
--- a/utils/IdentifyUtils.js
+++ b/utils/IdentifyUtils.js
@@ -11,8 +11,8 @@ import geojsonBbox from 'geojson-bounding-box';
import ol from 'openlayers';
import url from 'url';
import axios from 'axios';
-import {v1 as uuidv1} from 'uuid';
-import {LayerRole} from '../actions/layers';
+import { v1 as uuidv1 } from 'uuid';
+import { LayerRole } from '../actions/layers';
import CoordinatesUtils from '../utils/CoordinatesUtils';
import ConfigUtils from '../utils/ConfigUtils';
import LayerUtils from '../utils/LayerUtils';
@@ -23,7 +23,10 @@ import VectorLayerUtils from './VectorLayerUtils';
function identifyRequestParams(layer, queryLayers, projection, params) {
let format = 'text/plain';
const infoFormats = layer.infoFormats || [];
- if (infoFormats.includes('text/xml') && (layer.serverType === 'qgis' || infoFormats.length === 1)) {
+ if (
+ infoFormats.includes('text/xml') &&
+ (layer.serverType === 'qgis' || infoFormats.length === 1)
+ ) {
format = 'text/xml';
} else if (infoFormats.includes('application/geojson')) {
format = 'application/geojson';
@@ -39,7 +42,7 @@ function identifyRequestParams(layer, queryLayers, projection, params) {
return {
url: layer.featureInfoUrl.split("?")[0],
params: {
- ...url.parse(layer.featureInfoUrl, true).query,
+ ...url.parse(layer.featureInfoUrl, true).query,
service: 'WMS',
version: layer.version,
request: 'GetFeatureInfo',
@@ -58,43 +61,97 @@ function identifyRequestParams(layer, queryLayers, projection, params) {
};
}
+/**
+ * Utility functions for identifying features on the map.
+ *
+ * @namespace
+ */
const IdentifyUtils = {
- getQueryLayers(maplayers, map) {
- const queryableLayers = maplayers.filter((l) => {
- // All non-background WMS layers with a non-empty queryLayers list
- return l.visibility && l.type === 'wms' && l.role !== LayerRole.BACKGROUND && (l.queryLayers || []).length > 0;
- });
+ /**
+ * Retrieve a list of layers that should be queried.
+ *
+ * @param {object[]} mapLayers - the list of layers to investigate
+ * @param {object} map - the map object
+ *
+ * @return {object[]} the list of layers to query
+ * @todo As shown in the test, this function returns mixed results.
+ */
+ getQueryLayers(mapLayers, map) {
const mapScale = MapUtils.computeForZoom(map.scales, map.zoom);
let result = [];
- queryableLayers.forEach((layer) => {
+ // All non-background WMS layers with a non-empty queryLayers list
+ mapLayers.filter((l) => (
+ l.visibility !== false &&
+ l.type === 'wms' &&
+ l.role !== LayerRole.BACKGROUND &&
+ (l.queryLayers || []).length > 0
+ )).forEach((layer) => {
const layers = [];
const queryLayers = layer.queryLayers;
for (let i = 0; i < queryLayers.length; ++i) {
- if (layer.externalLayerMap && layer.externalLayerMap[queryLayers[i]]) {
- const sublayer = LayerUtils.searchSubLayer(layer, "name", queryLayers[i]);
- const sublayerVisible = LayerUtils.layerScaleInRange(sublayer, mapScale);
- if (!isEmpty(layer.externalLayerMap[queryLayers[i]].queryLayers) && sublayerVisible) {
+ if (
+ layer.externalLayerMap &&
+ layer.externalLayerMap[queryLayers[i]]
+ ) {
+ const subLayer = LayerUtils.searchSubLayer(
+ layer, "name", queryLayers[i]
+ );
+ const subLayerVisible = LayerUtils.layerScaleInRange(
+ subLayer, mapScale
+ );
+ if (
+ !isEmpty(
+ layer.externalLayerMap[queryLayers[i]].queryLayers
+ ) && subLayerVisible
+ ) {
layers.push(layer.externalLayerMap[queryLayers[i]]);
}
- } else if (layers.length > 0 && layers[layers.length - 1].id === layer.id) {
+ } else if (
+ layers.length > 0 &&
+ layers[layers.length - 1].id === layer.id
+ ) {
layers[layers.length - 1].queryLayers.push(queryLayers[i]);
} else {
- layers.push({...layer, queryLayers: [queryLayers[i]]});
+ layers.push({ ...layer, queryLayers: [queryLayers[i]] });
}
}
result = result.concat(layers);
});
return result;
},
+
+ /**
+ * Build a GetFeatureInfo request for a given layer.
+ *
+ * @param {object} layer - the layer to query
+ * @param {string[]} queryLayers - the list of sub-layers to query
+ * (usually the same as layer.queryLayers)
+ * @param {number[]} center - the map coordinates to query
+ * @param {object} map - the map object
+ * @param {object} options - additional options to pass to the request
+ *
+ * @return {object} the request object
+ */
buildRequest(layer, queryLayers, center, map, options = {}) {
const size = [101, 101];
const resolution = MapUtils.computeForZoom(map.resolutions, map.zoom);
const dx = 0.5 * resolution * size[0];
const dy = 0.5 * resolution * size[1];
const version = layer.version;
- let bbox = [center[0] - dx, center[1] - dy, center[0] + dx, center[1] + dy];
- if (CoordinatesUtils.getAxisOrder(map.projection).substr(0, 2) === 'ne' && version === '1.3.0') {
- bbox = [center[1] - dx, center[0] - dy, center[1] + dx, center[0] + dy];
+ let bbox = [
+ center[0] - dx, center[1] - dy,
+ center[0] + dx, center[1] + dy
+ ];
+ if (
+ CoordinatesUtils.getAxisOrder(
+ map.projection
+ ).substr(0, 2) === 'ne' &&
+ version === '1.3.0'
+ ) {
+ bbox = [
+ center[1] - dx, center[0] - dy,
+ center[1] + dx, center[0] + dy
+ ];
}
if (layer.params.FILTER) {
options.filter = layer.params.FILTER;
@@ -110,8 +167,23 @@ const IdentifyUtils = {
bbox: bbox.join(","),
...options
};
- return identifyRequestParams(layer, queryLayers, map.projection, params);
+ return identifyRequestParams(
+ layer, queryLayers, map.projection, params
+ );
},
+
+ /**
+ * Build a GetFeatureInfo request for a given layer.
+ *
+ * @param {object} layer - the layer to query
+ * @param {string[]} queryLayers - the list of sub-layers to query
+ * (usually the same as layer.queryLayers)
+ * @param {string} filterGeom - the filter geometry to use
+ * @param {object} map - the map object
+ * @param {object} options - additional options to pass to the request
+ *
+ * @return {object} the request object
+ */
buildFilterRequest(layer, queryLayers, filterGeom, map, options = {}) {
const size = [101, 101];
const params = {
@@ -121,8 +193,18 @@ const IdentifyUtils = {
FILTER_GEOM: filterGeom,
...options
};
- return identifyRequestParams(layer, queryLayers, map.projection, params);
+ return identifyRequestParams(
+ layer, queryLayers, map.projection, params
+ );
},
+
+ /**
+ * Send a request to a WMS server and return the response.
+ *
+ * @param {object} request - the request object
+ * @param {function} responseHandler - the callback to call with
+ * the response or null if the request failed
+ */
sendRequest(request, responseHandler) {
const urlParts = url.parse(request.url, true);
urlParts.query = {
@@ -131,86 +213,151 @@ const IdentifyUtils = {
};
delete urlParts.search;
const requestUrl = url.format(urlParts);
- const maxUrlLength = ConfigUtils.getConfigProp("wmsMaxGetUrlLength", null, 2048);
+ const maxUrlLength = ConfigUtils.getConfigProp(
+ "wmsMaxGetUrlLength", null, 2048
+ );
if (requestUrl.length > maxUrlLength) {
// Switch to POST if url is too long
const reqUrlParts = requestUrl.split("?");
const options = {
- headers: {'content-type': 'application/x-www-form-urlencoded'}
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded'
+ }
};
- axios.post(reqUrlParts[0], reqUrlParts[1], options).then(postResp => {
+ axios.post(
+ reqUrlParts[0], reqUrlParts[1], options
+ ).then(postResp => {
responseHandler(postResp.data);
}).catch(() => {
- axios.get(request.url, {params: request.params}).then(getResp => {
+ axios.get(
+ request.url, { params: request.params }
+ ).then(getResp => {
responseHandler(getResp.data);
}).catch(() => {
responseHandler(null);
});
});
} else {
- axios.get(request.url, {params: request.params}).then(getResp => {
+ axios.get(
+ request.url, { params: request.params }
+ ).then(getResp => {
responseHandler(getResp.data);
}).catch(() => {
responseHandler(null);
});
}
},
- parseResponse(response, layer, format, clickPoint, projection, featureInfoReturnsLayerName, layers) {
- const digits = CoordinatesUtils.getUnits(projection).units === 'degrees' ? 4 : 0;
- const posstr = clickPoint ? clickPoint[0].toFixed(digits) + ", " + clickPoint[1].toFixed(digits) : "";
+ parseResponse(
+ response, layer, format, clickPoint,
+ projection, featureInfoReturnsLayerName, layers
+ ) {
+ const digits = (
+ CoordinatesUtils.getUnits(projection) === 'degrees'
+ ? 4
+ : 0
+ );
+ const posStr = clickPoint
+ ? (
+ clickPoint[0].toFixed(digits) +
+ ", " +
+ clickPoint[1].toFixed(digits)
+ ) : "";
let results = {};
- if (["application/json", "application/geojson", "application/geo+json", "GeoJSON"].includes(format)) {
- results = IdentifyUtils.parseGeoJSONResponse(response, projection, layer);
+ if ([
+ "application/json",
+ "application/geojson",
+ "application/geo+json",
+ "GeoJSON"
+ ].includes(format)) {
+ results = IdentifyUtils.parseGeoJSONResponse(
+ response, projection, layer
+ );
} else if (format === "text/xml") {
- results = IdentifyUtils.parseXmlResponse(response, projection, posstr, featureInfoReturnsLayerName, layers);
+ results = IdentifyUtils.parseXmlResponse(
+ response, projection, posStr,
+ featureInfoReturnsLayerName, layers
+ );
} else if (format === "application/vnd.ogc.gml") {
- results = IdentifyUtils.parseGmlResponse(response, projection, posstr, layer);
+ results = IdentifyUtils.parseGmlResponse(
+ response, projection, posStr, layer
+ );
} else if (format === "text/plain") {
- results[layer.name] = [{type: "text", text: response, id: posstr, layername: layer.name, layertitle: layer.title}];
+ results[layer.name] = [{
+ type: "text",
+ text: response,
+ id: posStr,
+ layername: layer.name,
+ layertitle: layer.title
+ }];
} else if (format === "text/html") {
- results[layer.name] = [{type: "html", text: response, id: posstr, layername: layer.name, layertitle: layer.title}];
+ results[layer.name] = [{
+ type: "html",
+ text: response,
+ id: posStr,
+ layername: layer.name,
+ layertitle: layer.title
+ }];
+ } else {
+ // TODO LTS: Throw an exception here?
+ console.warn("[parseResponse] Unsupported format: " + format);
}
// Add clickPos, bounding box, displayname and layer name / title
- for (const layername of Object.keys(results)) {
- for (const item of results[layername]) {
+ for (const layerName of Object.keys(results)) {
+ for (const item of results[layerName]) {
if (item.type === "Feature" && !item.bbox && item.geometry) {
item.crs = projection;
item.bbox = geojsonBbox(item);
}
item.clickPos = clickPoint;
- item.displayname = IdentifyUtils.determineDisplayName(layer, layername, item);
+ item.displayname = IdentifyUtils.determineDisplayName(
+ layer, layerName, item
+ );
}
}
return results;
},
- determineDisplayName(layer, layername, item) {
+ determineDisplayName(layer, layerName, item) {
const properties = item.properties || {};
if (item.displayfield) {
- if (properties[item.displayfield] && (properties[item.displayfield][0] !== "<")) {
+ if (
+ properties[item.displayfield] &&
+ (properties[item.displayfield][0] !== "<")
+ ) {
return properties[item.displayfield];
}
}
- const sublayer = LayerUtils.searchSubLayer(layer, 'name', layername);
- if (sublayer && sublayer.displayField) {
- if (properties[sublayer.displayField] && (properties[sublayer.displayField][0] !== "<")) {
- return properties[sublayer.displayField];
+ const subLayer = LayerUtils.searchSubLayer(layer, 'name', layerName);
+ if (subLayer && subLayer.displayField) {
+ if (
+ properties[subLayer.displayField] &&
+ (properties[subLayer.displayField][0] !== "<")
+ ) {
+ return properties[subLayer.displayField];
}
}
- return properties.name || properties.Name || properties.NAME || item.id;
+ return (
+ properties.name || properties.Name ||
+ properties.NAME || item.id
+ );
},
- parseXmlFeature(feature, geometrycrs, id, featurereport, displayfield, layername, layertitle, layerinfo) {
+ parseXmlFeature(
+ feature, geometryCrs, id, featureReport, displayField,
+ layerName, layerTitle, layerInfo
+ ) {
const featureResult = {};
featureResult.type = "Feature";
featureResult.id = id;
- featureResult.featurereport = featurereport;
- featureResult.displayfield = displayfield;
- featureResult.layername = layername;
- featureResult.layertitle = layertitle;
- featureResult.layerinfo = layerinfo;
- const bboxes = feature.getElementsByTagName("BoundingBox");
- if (bboxes.length > 0) {
- const bbox = bboxes[0];
- const crs = bbox.attributes.CRS ? bbox.attributes.CRS.value : bbox.attributes.SRS.value;
+ featureResult.featurereport = featureReport;
+ featureResult.displayfield = displayField;
+ featureResult.layername = layerName;
+ featureResult.layertitle = layerTitle;
+ featureResult.layerinfo = layerInfo;
+ const bBoxes = feature.getElementsByTagName("BoundingBox");
+ if (bBoxes.length > 0) {
+ const bbox = bBoxes[0];
+ const crs = bbox.attributes.CRS
+ ? bbox.attributes.CRS.value
+ : bbox.attributes.SRS.value;
featureResult.bbox = [
parseFloat(bbox.attributes.minx.value),
parseFloat(bbox.attributes.miny.value),
@@ -220,79 +367,124 @@ const IdentifyUtils = {
featureResult.crs = crs;
}
featureResult.properties = {};
- const attrmapping = {};
+ const attrMapping = {};
const attributes = feature.getElementsByTagName("Attribute");
for (let i = 0; i < attributes.length; ++i) {
const attribute = attributes[i];
if (attribute.attributes.name.value === "geometry") {
const wkt = attribute.attributes.value.value;
- const geoJsonFeature = VectorLayerUtils.wktToGeoJSON(wkt, geometrycrs, featureResult.crs);
+ const geoJsonFeature = VectorLayerUtils.wktToGeoJSON(
+ wkt, geometryCrs, featureResult.crs
+ );
if (geoJsonFeature) {
featureResult.geometry = geoJsonFeature.geometry;
}
} else {
- featureResult.properties[attribute.attributes.name.value] = attribute.attributes.value.value;
+ featureResult.properties[
+ attribute.attributes.name.value
+ ] = attribute.attributes.value.value;
if (attribute.attributes.attrname) {
- attrmapping[attribute.attributes.name.value] = attribute.attributes.attrname.value;
+ attrMapping[
+ attribute.attributes.name.value
+ ] = attribute.attributes.attrname.value;
}
}
}
const htmlContent = feature.getElementsByTagName("HtmlContent");
if (htmlContent.length > 0) {
featureResult.properties.htmlContent = htmlContent[0].textContent;
- featureResult.properties.htmlContentInline = (htmlContent[0].getAttribute("inline") === "1" || htmlContent[0].getAttribute("inline") === "true");
+ featureResult.properties.htmlContentInline = (
+ htmlContent[0].getAttribute("inline") === "1" ||
+ htmlContent[0].getAttribute("inline") === "true"
+ );
}
- if (!isEmpty(attrmapping)) {
- featureResult.attribnames = attrmapping;
+ if (!isEmpty(attrMapping)) {
+ featureResult.attribnames = attrMapping;
}
return featureResult;
},
- parseXmlResponse(response, geometrycrs, posstr = null, featureInfoReturnsLayerName = false, mapLayers = null) {
+ parseXmlResponse(
+ response, geometryCrs, posStr = null,
+ featureInfoReturnsLayerName = false, mapLayers = null
+ ) {
const parser = new DOMParser();
const doc = parser.parseFromString(response, "text/xml");
- const layers = [].slice.call(doc.firstChild.getElementsByTagName("Layer"));
+ const layers = [].slice.call(
+ doc.firstChild.getElementsByTagName("Layer")
+ );
const result = {};
- let idcounter = 0;
+ let idCounter = 0;
for (const layer of layers) {
- const featurereport = layer.attributes.featurereport ? layer.attributes.featurereport.value : null;
- const displayfield = layer.attributes.displayfield ? layer.attributes.displayfield.value : null;
- let layername = "";
- let layertitle = "";
+ const featureReport = layer.attributes.featurereport
+ ? layer.attributes.featurereport.value
+ : null;
+ const displayField = layer.attributes.displayfield
+ ? layer.attributes.displayfield.value
+ : null;
+ let layerName = "";
+ let layerTitle = "";
if (featureInfoReturnsLayerName) {
- layername = layer.attributes.name.value;
- const match = LayerUtils.searchLayer(mapLayers, 'name', layername);
- layertitle = match ? match.sublayer.title : layername;
+ layerName = layer.attributes.name.value;
+ const match = LayerUtils.searchLayer(
+ mapLayers, 'name', layerName
+ );
+ layerTitle = match ? match.sublayer.title : layerName;
} else {
- layertitle = layer.attributes.name.value;
- layername = layer.attributes.layername ? layer.attributes.layername.value : layertitle;
+ layerTitle = layer.attributes.name.value;
+ layerName = layer.attributes.layername
+ ? layer.attributes.layername.value
+ : layerTitle;
}
- const layerinfo = layer.attributes.layerinfo ? layer.attributes.layerinfo.value : null;
- const features = [].slice.call(layer.getElementsByTagName("Feature"));
+ const layerInfo = layer.attributes.layerinfo
+ ? layer.attributes.layerinfo.value
+ : null;
+ const features = [].slice.call(
+ layer.getElementsByTagName("Feature")
+ );
if (features.length > 0) {
- result[layername] = features.map(feature => this.parseXmlFeature(feature, geometrycrs, feature.attributes.id.value, featurereport, displayfield, layername, layertitle, layerinfo));
+ result[layerName] = features.map(
+ feature => this.parseXmlFeature(
+ feature, geometryCrs, feature.attributes.id.value,
+ featureReport, displayField, layerName,
+ layerTitle, layerInfo
+ )
+ );
} else {
- const attributes = [].slice.call(layer.getElementsByTagName("Attribute"));
+ const attributes = [].slice.call(
+ layer.getElementsByTagName("Attribute")
+ );
if (attributes.length > 0) {
- const id = posstr || "" + (idcounter++);
- result[layername] = [this.parseXmlFeature(layer, geometrycrs, id, featurereport, displayfield, layername, layertitle, layerinfo)];
+ const id = posStr || "" + (idCounter++);
+ result[layerName] = [
+ this.parseXmlFeature(
+ layer, geometryCrs, id, featureReport,
+ displayField, layerName, layerTitle, layerInfo
+ )
+ ];
}
}
}
return result;
},
- parseGeoJSONResponse(response, geometrycrs, layer) {
+ parseGeoJSONResponse(response, geometryCrs, layer) {
const result = {};
(response.features || []).map(feature => {
// Deduce layer name as far as possible from feature id
- const id = feature.id || (feature.properties || {}).OBJECTID || uuidv1();
+ const id = (
+ feature.id ||
+ (feature.properties || {}).OBJECTID ||
+ uuidv1()
+ );
if (result[layer.name] === undefined) {
result[layer.name] = [];
}
let geometry = feature.geometry;
if (geometry) {
- geometry = VectorLayerUtils.reprojectGeometry(geometry, "EPSG:4326", geometrycrs); // GeoJSON always wgs84
+ geometry = VectorLayerUtils.reprojectGeometry(
+ geometry, "EPSG:4326", geometryCrs
+ ); // GeoJSON always wgs84
}
result[layer.name].push({
...feature,
@@ -304,7 +496,7 @@ const IdentifyUtils = {
});
return result;
},
- parseGmlResponse(response, geometrycrs, posstr, layer) {
+ parseGmlResponse(response, geometryCrs, posStr, layer) {
const parser = new DOMParser();
const doc = parser.parseFromString(response, "text/xml");
const result = {};
@@ -317,12 +509,19 @@ const IdentifyUtils = {
const featureName = layerName + "_feature";
result[layerName] = [];
- for (const featureEl of [].slice.call(layerEl.getElementsByTagName(featureName))) {
-
+ for (
+ const featureEl of [].slice.call(
+ layerEl.getElementsByTagName(featureName)
+ )
+ ) {
const context = [{
featureType: featureName
}];
- const feature = new ol.format.GeoJSON().writeFeatureObject(new ol.format.GML2().readFeatureElement(featureEl, context));
+ const feature = new ol.format.GeoJSON().writeFeatureObject(
+ new ol.format.GML2().readFeatureElement(
+ featureEl, context
+ )
+ );
feature.id = count++;
feature.layername = layer.name;
feature.layertitle = layer.title;
@@ -331,7 +530,9 @@ const IdentifyUtils = {
}
}
} else {
- result[layer.name] = [{type: "text", text: response, id: posstr}];
+ result[layer.name] = [{
+ type: "text", text: response, id: posStr
+ }];
}
return result;
}
diff --git a/utils/IdentifyUtils.test.js b/utils/IdentifyUtils.test.js
new file mode 100644
index 000000000..2a3d4a705
--- /dev/null
+++ b/utils/IdentifyUtils.test.js
@@ -0,0 +1,349 @@
+import mockAxios from 'jest-mock-axios';
+import IdUtil from "./IdentifyUtils";
+import { LayerRole } from '../actions/layers';
+
+
+let mockComputeForZoom = 5;
+jest.mock("./MapUtils", () => ({
+ __esModule: true,
+ default: {
+ computeForZoom: () => mockComputeForZoom,
+ },
+}));
+
+let mockSearchSubLayer = true;
+let mockLayerScaleInRange = true;
+jest.mock("./LayerUtils", () => ({
+ __esModule: true,
+ default: {
+ searchSubLayer: () => mockSearchSubLayer,
+ layerScaleInRange: () => mockLayerScaleInRange,
+ },
+}));
+
+let mockWmsMaxGetUrlLength = 500;
+jest.mock("./ConfigUtils", () => ({
+ __esModule: true,
+ default: {
+ getConfigProp: (name) => {
+ if (name === 'wmsMaxGetUrlLength')
+ return mockWmsMaxGetUrlLength;
+ }
+ },
+}));
+
+let mockGetUnits = 'm';
+let mockAxisOrder = 'ne';
+jest.mock("./CoordinatesUtils", () => ({
+ __esModule: true,
+ default: {
+ getUnits: () => mockGetUnits,
+ getAxisOrder: () => mockAxisOrder,
+ },
+}));
+
+
+describe("buildFilterRequest", () => {
+ it("should build a request", () => {
+ expect(IdUtil.buildFilterRequest(
+ {
+ id: "abcd",
+ version: "ipsum",
+ styles: "sit",
+ dimensionValues: {},
+ params: {},
+ featureInfoUrl: "dolor?lorem=dolor",
+ },
+ ["a", "b", "c"],
+ "filterGeom",
+ {
+ resolutions: [1, 2, 3],
+ zoom: 0,
+ projection: "EPSG:4326",
+ },
+ {
+ "lorem": "dolor",
+ }
+ )).toEqual({
+ "params": {
+ "FILTER_GEOM": "filterGeom",
+ "crs": "EPSG:4326",
+ "feature_count": 100,
+ "height": 101,
+ "id": "abcd",
+ "info_format": "text/plain",
+ "layers": [
+ "a",
+ "b",
+ "c",
+ ],
+ "lorem": "dolor",
+ "query_layers": [
+ "a",
+ "b",
+ "c",
+ ],
+ "request": "GetFeatureInfo",
+ "service": "WMS",
+ "srs": "EPSG:4326",
+ "styles": undefined,
+ "version": "ipsum",
+ "width": 101,
+ "with_geometry": true,
+ "with_maptip": false,
+ },
+ "url": "dolor",
+ });
+ });
+});
+
+describe("buildRequest", () => {
+ it("should build a request", () => {
+ expect(IdUtil.buildRequest(
+ {
+ id: "abcd",
+ version: "ipsum",
+ styles: "sit",
+ dimensionValues: {},
+ params: {},
+ featureInfoUrl: "dolor?lorem=dolor",
+ },
+ ["a", "b", "c"],
+ [12, 13],
+ {
+ resolutions: [1, 2, 3],
+ zoom: 0,
+ projection: "EPSG:4326",
+ }
+ )).toEqual({
+ "params": {
+ "bbox": "-240.5,-239.5,264.5,265.5",
+ "crs": "EPSG:4326",
+ "feature_count": 100,
+ "height": 101,
+ "i": 51,
+ "id": "abcd",
+ "info_format": "text/plain",
+ "j": 51,
+ "layers": [
+ "a",
+ "b",
+ "c",
+ ],
+ "lorem": "dolor",
+ "query_layers": [
+ "a",
+ "b",
+ "c",
+ ],
+ "request": "GetFeatureInfo",
+ "service": "WMS",
+ "srs": "EPSG:4326",
+ "styles": undefined,
+ "version": "ipsum",
+ "width": 101,
+ "with_geometry": true,
+ "with_maptip": false,
+ "x": 51,
+ "y": 51,
+ },
+ "url": "dolor",
+ });
+ });
+});
+
+describe("determineDisplayName", () => {
+
+});
+
+describe("getQueryLayers", () => {
+ const map = {
+ scales: [250000, 100000, 50000, 25000, 10000, 5000],
+ zoom: 0,
+ }
+ it("should return an empty array if no layers are passed", () => {
+ expect(IdUtil.getQueryLayers([], map)).toEqual([]);
+ });
+ it("should filter out invisible layers", () => {
+ expect(IdUtil.getQueryLayers([{
+ visibility: false
+ }], map)).toEqual([]);
+ });
+ it("should filter out non-wms layers", () => {
+ expect(IdUtil.getQueryLayers([{
+ visibility: true,
+ type: "xyz"
+ }], map)).toEqual([]);
+ });
+ it("should filter out background layers", () => {
+ expect(IdUtil.getQueryLayers([{
+ visibility: true,
+ type: "wms",
+ role: LayerRole.BACKGROUND
+ }], map)).toEqual([]);
+ });
+ it("should filter out layers with no query layers", () => {
+ expect(IdUtil.getQueryLayers([{
+ visibility: true,
+ type: "wms",
+ role: LayerRole.THEME,
+ queryLayers: []
+ }], map)).toEqual([]);
+ });
+ it("should return a simple layer", () => {
+ expect(IdUtil.getQueryLayers([{
+ visibility: true,
+ type: "wms",
+ role: LayerRole.THEME,
+ queryLayers: [
+ "lorem"
+ ],
+ }], map)).toEqual([
+ {
+ visibility: true,
+ type: "wms",
+ role: LayerRole.THEME,
+ queryLayers: [
+ "lorem"
+ ]
+ }
+ ]);
+ });
+ it("should concatenate query layers for same ID", () => {
+ expect(IdUtil.getQueryLayers([{
+ id: "abcd",
+ visibility: true,
+ type: "wms",
+ role: LayerRole.THEME,
+ queryLayers: [
+ "lorem",
+ "ipsum"
+ ]
+ }], map)).toEqual([
+ {
+ id: "abcd",
+ visibility: true,
+ type: "wms",
+ role: LayerRole.THEME,
+ queryLayers: [
+ "lorem",
+ "ipsum"
+ ]
+ }
+ ]);
+ });
+ it("should work with external layer map", () => {
+ expect(IdUtil.getQueryLayers([{
+ id: "abcd",
+ visibility: true,
+ type: "wms",
+ role: LayerRole.THEME,
+ queryLayers: [
+ "lorem"
+ ],
+ externalLayerMap: {
+ "lorem": {
+ queryLayers: [
+ "ipsum"
+ ]
+ }
+ }
+ }], map)).toEqual([
+ {
+ queryLayers: [
+ "ipsum"
+ ]
+ }
+ ]);
+ });
+ it("should exclude invisible external layer", () => {
+ mockLayerScaleInRange = false;
+ expect(IdUtil.getQueryLayers([{
+ id: "abcd",
+ visibility: true,
+ type: "wms",
+ role: LayerRole.THEME,
+ queryLayers: [
+ "lorem"
+ ],
+ externalLayerMap: {
+ "lorem": {
+ queryLayers: [
+ "ipsum"
+ ]
+ }
+ }
+ }], map)).toEqual([]);
+ });
+});
+
+describe("parseGeoJSONResponse", () => {
+
+});
+
+describe("parseGmlResponse", () => {
+
+});
+
+describe("parseResponse", () => {
+ it("should parse the response", () => {
+ mockGetUnits = 'm';
+ IdUtil.parseResponse(
+ {}, {},
+ )
+ });
+});
+
+describe("parseXmlFeature", () => {
+
+});
+
+describe("parseXmlResponse", () => {
+
+});
+
+describe("sendRequest", () => {
+ it("should send a get request", () => {
+ const callback = jest.fn();
+ const request = {
+ url: "url-foo",
+ params: {
+ foo: "bar",
+ },
+ };
+ const result = "lorem ipsum";
+ const response = {
+ data: result,
+ };
+ IdUtil.sendRequest(request, callback);
+ expect(mockAxios.get).toHaveBeenCalledWith(
+ "url-foo", { "params": { "foo": "bar" } }
+ );
+ mockAxios.mockResponse({ ...response });
+ expect(callback).toHaveBeenCalledWith(result);
+ });
+ it("should send a post request", () => {
+ mockWmsMaxGetUrlLength = 0;
+ const callback = jest.fn();
+ const request = {
+ url: "url-foo",
+ params: {
+ foo: "bar",
+ },
+ };
+ const result = "lorem ipsum";
+ const response = {
+ data: result,
+ };
+ IdUtil.sendRequest(request, callback);
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ "url-foo", "foo=bar", {
+ "headers": {
+ "content-type": "application/x-www-form-urlencoded"
+ }
+ });
+ mockAxios.mockResponse({ ...response });
+ expect(callback).toHaveBeenCalledWith(result);
+ });
+});
+
diff --git a/utils/ImageEditor.js b/utils/ImageEditor.js
index 09ab5cc89..ae870a5f8 100644
--- a/utils/ImageEditor.js
+++ b/utils/ImageEditor.js
@@ -12,7 +12,8 @@ import StandardStore from '../stores/StandardStore';
import '../components/style/ModalDialog.css';
export function showImageEditor(imageData, imageDataCallback) {
- // Do old-school JS rather than react portal as portal event bubbling messes up Painterro
+ // Do old-school JS rather than react portal as portal
+ // event bubbling messes up Painterro
const modalDialogContainer = document.createElement("div");
modalDialogContainer.className = "modal-dialog-container";
@@ -54,7 +55,8 @@ export function showImageEditor(imageData, imageDataCallback) {
window.ptro = Painterro({
id: 'painterro',
hiddenTools: ['open'],
- language: StandardStore.get().getState().locale.current.slice(0, 2).toLowerCase(),
+ language: StandardStore.get().getState()
+ .locale.current.slice(0, 2).toLowerCase(),
onBeforeClose: (hasUnsaved, doClose) => {
if (hasUnsaved) {
// eslint-disable-next-line
@@ -76,6 +78,7 @@ export function showImageEditor(imageData, imageDataCallback) {
document.body.removeChild(modalDialogContainer);
}
}).show(imageData);
+ console.log(window.ptro);
closeIcon.addEventListener('click', () => {
// eslint-disable-next-line
diff --git a/utils/ImageEditor.test.js b/utils/ImageEditor.test.js
new file mode 100644
index 000000000..b0893664b
--- /dev/null
+++ b/utils/ImageEditor.test.js
@@ -0,0 +1,38 @@
+import { showImageEditor } from "./ImageEditor";
+
+let mockPainterroShow;
+let mockPainterro;
+
+jest.mock("painterro", () => ({
+ __esModule: true,
+ default: (args) => mockPainterro(args),
+}));
+
+jest.mock('../stores/StandardStore', () => ({
+ get: jest.fn(() => ({
+ getState: jest.fn(() => ({
+ locale: {
+ current: "xy",
+ messages: {
+ lorem: "ipsum"
+ }
+ }
+ })),
+ })),
+}));
+
+
+describe("showImageEditor", () => {
+ beforeEach(() => {
+ mockPainterroShow = jest.fn();
+ mockPainterro = jest.fn(() => ({
+ show: mockPainterroShow
+ }));
+ })
+ it("should construct the image editor", () => {
+ const callback = jest.fn();
+ showImageEditor("imageData", callback);
+ expect(mockPainterro).toHaveBeenCalled();
+ expect(mockPainterroShow).toHaveBeenCalledWith("imageData");
+ });
+});
diff --git a/utils/LayerUtils.js b/utils/LayerUtils.js
index b6a40a454..e3040887c 100644
--- a/utils/LayerUtils.js
+++ b/utils/LayerUtils.js
@@ -8,22 +8,65 @@
import isEmpty from 'lodash.isempty';
import isEqual from 'lodash.isequal';
-import {v4 as uuidv4} from 'uuid';
+import { v4 as uuidv4 } from 'uuid';
import url from 'url';
import ConfigUtils from './ConfigUtils';
import CoordinatesUtils from './CoordinatesUtils';
import MapUtils from './MapUtils';
-import {LayerRole} from '../actions/layers';
+import { LayerRole } from '../actions/layers';
+/** @typedef {import("qwc2/typings/layers").ExternalLayerKey} ExternalLayerKey */
+/** @typedef {import("qwc2/typings/layers").ExternalLayer} ExternalLayer */
+/** @typedef {import("qwc2/typings/layers").LayerConfig} LayerConfig */
+/** @typedef {import("qwc2/typings/layers").LayerData} LayerData */
+/** @typedef {import("qwc2/typings/layers").ExternalLayerList} ExternalLayerList */
+/** @typedef {import("qwc2/typings/map").MapState} MapState */
+
+/**
+ * A structure that contains a leaf layer and the path to it
+ * from the top level layer.
+ * @typedef ExplodedLayer
+ * @property {LayerData} layer - the top level layer
+ * @property {number[]} path - the 0-based index of each sub-layer in parent, up
+ * until the leaf (last one in this list is the index of the leaf)
+ * @property {LayerData} sublayer - the leaf layer
+ */
+
+
+/**
+ * Utility functions for working with layers.
+ *
+ * @namespace
+ */
const LayerUtils = {
- restoreLayerParams(themeLayer, layerConfigs, permalinkLayers, externalLayers) {
+
+ /**
+ * Restores the parameters of a theme layer and external layers.
+ *
+ * @param {LayerData} themeLayer - the theme layer to restore
+ * @param {LayerConfig[]} layerConfigs - an array of layer configurations
+ * @param {LayerData[]} permalinkLayers - an array of permalink layers
+ * @param {ExternalLayerList} externalLayers - the list of external layers
+ *
+ * @returns {LayerData[]} - the restored layers
+ */
+ restoreLayerParams(
+ themeLayer, layerConfigs, permalinkLayers, externalLayers
+ ) {
let exploded = LayerUtils.explodeLayers([themeLayer]);
// Restore theme layer configuration
for (const entry of exploded) {
- const layerConfig = layerConfigs.find(layer => layer.type === 'theme' && layer.name === entry.sublayer.name);
+ const layerConfig = layerConfigs.find(
+ cfg => (
+ cfg.type === 'theme' &&
+ cfg.name === entry.sublayer.name
+ )
+ );
if (layerConfig) {
entry.sublayer.opacity = layerConfig.opacity;
- entry.sublayer.visibility = layerConfig.visibility || layerConfig.tristate;
+ entry.sublayer.visibility = (
+ layerConfig.visibility || layerConfig.tristate
+ );
entry.sublayer.tristate = layerConfig.tristate;
} else {
entry.sublayer.visibility = false;
@@ -35,52 +78,122 @@ const LayerUtils = {
if (layerConfig.type === 'separator') {
// No point restoring separators
} else if (layerConfig.type !== 'theme') {
- external = external.concat(LayerUtils.createExternalLayerPlaceholder(layerConfig, externalLayers, layerConfig.id));
+ external = external.concat(
+ LayerUtils.createExternalLayerPlaceholder(
+ layerConfig, externalLayers, layerConfig.id
+ )
+ );
}
}
exploded = [...external, ...exploded];
LayerUtils.insertPermalinkLayers(exploded, permalinkLayers);
const layers = LayerUtils.implodeLayers(exploded);
- LayerUtils.setGroupVisiblities(layers);
+ LayerUtils.setGroupVisibilities(layers);
return layers;
},
- restoreOrderedLayerParams(themeLayer, layerConfigs, permalinkLayers, externalLayers) {
+
+
+ /**
+ * Restores the ordered layer parameters based on the given theme layer,
+ * layer configurations, permalink layers, and external layers.
+ *
+ * @param {LayerData} themeLayer - the theme layer to use as a base for
+ * restoring the ordered layer parameters
+ * @param {LayerConfig[]} layerConfigs - the layer configurations to use
+ * for reordering the layers
+ * @param {LayerData[]} permalinkLayers - the permalink layers to insert
+ * into the reordered layers
+ * @param {LayerData[]} externalLayers - the external layers to use
+ * for creating placeholders
+ *
+ * @returns {LayerData[]} the reordered layers with the permalink
+ * layers inserted and the group visibilities set
+ */
+ restoreOrderedLayerParams(
+ themeLayer, layerConfigs, permalinkLayers, externalLayers
+ ) {
const exploded = LayerUtils.explodeLayers([themeLayer]);
let reordered = [];
- // Iterate over layer configs and reorder items accordingly, create external layer placeholders as neccessary
+ // Iterate over layer configs and reorder items accordingly, create
+ // external layer placeholders as necessary
for (const layerConfig of layerConfigs) {
if (layerConfig.type === 'theme') {
- const entry = exploded.find(e => e.sublayer.name === layerConfig.name);
+ const entry = exploded.find(
+ e => e.sublayer.name === layerConfig.name
+ );
if (entry) {
entry.sublayer.opacity = layerConfig.opacity;
- entry.sublayer.visibility = layerConfig.visibility || layerConfig.tristate;
+ entry.sublayer.visibility = (
+ layerConfig.visibility || layerConfig.tristate
+ );
entry.sublayer.tristate = layerConfig.tristate;
reordered.push(entry);
}
} else if (layerConfig.type === 'separator') {
- reordered = reordered.concat(LayerUtils.createSeparatorLayer(layerConfig.name));
+ reordered = reordered.concat(
+ LayerUtils.createSeparatorLayer(layerConfig.name)
+ );
} else {
- reordered = reordered.concat(LayerUtils.createExternalLayerPlaceholder(layerConfig, externalLayers, layerConfig.id));
+ reordered = reordered.concat(
+ LayerUtils.createExternalLayerPlaceholder(
+ layerConfig, externalLayers, layerConfig.id
+ )
+ );
}
}
LayerUtils.insertPermalinkLayers(reordered, permalinkLayers);
const layers = LayerUtils.implodeLayers(reordered);
- LayerUtils.setGroupVisiblities(layers);
+ LayerUtils.setGroupVisibilities(layers);
return layers;
},
- setGroupVisiblities(layers) {
+
+
+ /**
+ * Determines and sets the visibility of a tree of layers based on
+ * the visibilities of each layer members.
+ *
+ * For each layer in the list (either the one the user provided or
+ * the list of sub-layers for group layers) the function determines a
+ * layer to be visible if:
+ * - any of its sub-layers are visible and
+ * - none of its sub-layers are in tri-state.
+ *
+ * While walking the tree of layers the function will remove the
+ * `tristate` property from all layers and - for group layers -
+ * the `visibility` property will be set to the result of
+ * running this function over its sub-layers.
+ *
+ * @param {LayerData[]} layers - the tree of layers
+ *
+ * @returns {boolean}
+ */
+ setGroupVisibilities(layers) {
let parentVisible = false;
let parentInvisible = false;
for (const layer of layers) {
if (!isEmpty(layer.sublayers)) {
- layer.visibility = LayerUtils.setGroupVisiblities(layer.sublayers);
+ layer.visibility = LayerUtils.setGroupVisibilities(
+ layer.sublayers
+ );
}
parentInvisible = parentInvisible || layer.tristate;
delete layer.tristate;
- parentVisible = parentVisible || layer.visibility;
+ parentVisible = parentVisible || layer.visibility !== false;
}
return parentVisible && !parentInvisible;
},
+
+
+ /**
+ * Creates a new *exploded* layer to act as a separator.
+ *
+ * @param {string} title - the title to assign to this layer
+ *
+ * @returns {ExplodedLayer[]} the array that contains a single
+ * structure suitable to be merged with other exploded layers
+ * and reconstructed into a tree via {@link LayerUtils.implodeLayers}.
+ * @see {@link LayerUtils.explodeLayers}
+ */
createSeparatorLayer(title) {
return LayerUtils.explodeLayers([{
type: "separator",
@@ -90,6 +203,21 @@ const LayerUtils = {
id: uuidv4()
}]);
},
+
+
+ /**
+ * Creates a layer from external layer configuration.
+ *
+ * @param {LayerConfig} layerConfig - the configuration of the
+ * external layer
+ * @param {ExternalLayerList} externalLayers - the list of external layers
+ * @param {LayerId} id - unique identifier for the layer
+ *
+ * @returns {ExplodedLayer[]} the array that contains a single
+ * structure suitable to be merged with other exploded layers
+ * and reconstructed into a tree via {@link LayerUtils.implodeLayers}.
+ * @see {@link LayerUtils.explodeLayers}
+ */
createExternalLayerPlaceholder(layerConfig, externalLayers, id) {
const key = layerConfig.type + ":" + layerConfig.url;
(externalLayers[key] = externalLayers[key] || []).push({
@@ -109,50 +237,116 @@ const LayerUtils = {
uuid: uuidv4()
}]);
},
+
+
+ /**
+ * Inserts permalink layers into the exploded layer array.
+ *
+ * @param {ExplodedLayer[]} exploded - the exploded layer array
+ * @param {LayerData[]} layers - the permalink layers to insert
+ */
insertPermalinkLayers(exploded, layers) {
for (const layer of layers || []) {
const insLayer = LayerUtils.explodeLayers([layer])[0];
- if (insLayer.layer.role !== LayerRole.USERLAYER || insLayer.layer.type !== 'vector') {
+ if (
+ insLayer.layer.role !== LayerRole.USERLAYER ||
+ insLayer.layer.type !== 'vector'
+ ) {
continue;
}
delete insLayer.layer.pos;
exploded.splice(layer.pos, 0, insLayer);
}
},
- collectWMSSublayerParams(sublayer, layerNames, opacities, styles, queryable, visibilities, parentVisibility) {
- const layerVisibility = (sublayer.visibility === undefined ? true : sublayer.visibility);
+
+
+ /**
+ * Collects parameters for WMS sub-layers recursively.
+ *
+ * @param {LayerData} sublayer - the sublayer object to collect
+ * parameters for
+ * @param {string[]} layerNames - the array to push the layer names to
+ * @param {number[]} opacities - the array to push the layer opacities to
+ * @param {string[]} styles - the array to push the layer styles to
+ * @param {string[]} queryable - the array to push the queryable layer
+ * names to
+ * @param {number[]} visibilities - the array to push the layer
+ * visibilities to; if you set this argument to a *falsy value*,
+ * only layers that are either implicitly (`undefined`)
+ * or explicitly (`true`) visible will be considered
+ * @param {boolean} parentVisibility - the visibility of the parent layer
+ *
+ * @see {@link LayerUtils.buildWMSLayerParams},
+ * {@link LayerUtils.buildWMSLayerUrlParam},
+ */
+ collectWMSSublayerParams(
+ sublayer, layerNames, opacities, styles, queryable,
+ visibilities, parentVisibility
+ ) {
+ const layerVisibility = (
+ sublayer.visibility === undefined ? true : sublayer.visibility
+ );
const visibility = layerVisibility && parentVisibility;
if (visibility || visibilities) {
if (!isEmpty(sublayer.sublayers)) {
// Is group
- sublayer.sublayers.map(sublyr => {
- LayerUtils.collectWMSSublayerParams(sublyr, layerNames, opacities, styles, queryable, visibilities, visibility);
+ sublayer.sublayers.map(subLayer => {
+ LayerUtils.collectWMSSublayerParams(
+ subLayer, layerNames, opacities, styles,
+ queryable, visibilities, visibility
+ );
});
} else {
layerNames.push(sublayer.name);
- opacities.push(Number.isInteger(sublayer.opacity) ? sublayer.opacity : 255);
+ opacities.push(
+ Number.isInteger(sublayer.opacity) ? sublayer.opacity : 255
+ );
styles.push(sublayer.style || "");
if (sublayer.queryable) {
queryable.push(sublayer.name);
}
if (visibilities) {
- visibilities.push(layerVisibility ? (parentVisibility ? 1 : 0.5) : 0);
+ visibilities.push(
+ layerVisibility ? (parentVisibility ? 1 : 0.5) : 0
+ );
}
}
}
},
+
+
+ /**
+ * Build WMS layer parameters.
+ *
+ * @param {LayerData} layer - the layer to build parameters for
+ *
+ * @return {{
+ * params: {
+ * LAYERS: string,
+ * OPACITIES: string,
+ * STYLES: string
+ * },
+ * queryLayers: string[]
+ * }} the parameters and the list of queryable layer names
+ */
buildWMSLayerParams(layer) {
const params = layer.params || {};
let newParams = {};
let queryLayers = [];
if (!Array.isArray(layer.sublayers)) {
- const layers = (params.LAYERS || layer.name).split(",").filter(Boolean);
- const opacities = (params.OPACITIES || "").split(",").filter(Boolean);
- const opacityMult = (layer.opacity ?? 255) / 255;
+ const layers = (
+ params.LAYERS || layer.name
+ ).split(",").filter(Boolean);
+ const opacities = (
+ params.OPACITIES || ""
+ ).split(",").filter(Boolean);
+ const opacityFactor = (layer.opacity ?? 255) / 255;
newParams = {
LAYERS: layers.join(","),
- OPACITIES: layers.map((x, i) => (opacities[i] ?? "255") * opacityMult).join(","),
+ OPACITIES: layers.map(
+ (x, i) => ((opacities[i] ?? "255") * opacityFactor)
+ ).join(","),
STYLES: params.STYLES ?? "",
...layer.dimensionValues
};
@@ -162,13 +356,19 @@ const LayerUtils = {
let opacities = [];
let styles = [];
layer.sublayers.map(sublayer => {
- LayerUtils.collectWMSSublayerParams(sublayer, layerNames, opacities, styles, queryLayers, null, layer.visibility);
+ LayerUtils.collectWMSSublayerParams(
+ sublayer, layerNames, opacities, styles,
+ queryLayers, null, layer.visibility
+ );
});
layerNames.reverse();
opacities.reverse();
styles.reverse();
if (layer.drawingOrder && layer.drawingOrder.length > 0) {
- const indices = layer.drawingOrder.map(lyr => layerNames.indexOf(lyr)).filter(idx => idx >= 0);
+ const indices = layer.drawingOrder.map(
+ lyr => layerNames.indexOf(lyr)
+ ).filter(idx => idx >= 0);
+
layerNames = indices.map(idx => layerNames[idx]);
opacities = indices.map(idx => opacities[idx]);
styles = indices.map(idx => styles[idx]);
@@ -182,53 +382,114 @@ const LayerUtils = {
}
return {
params: newParams,
- queryLayers: queryLayers
+ queryLayers
};
},
+
+
+ /**
+ * Add UUIDs to layers that don't have one and to all of their sublayers.
+ *
+ * Note that this function will create a deep clone of the `sublayers`
+ * property.
+ *
+ * @param {LayerData} group - the layer that will be equipped with an uuid
+ * @param {Set} usedUUIDs - a set of UUIDs to avoid; new UUIDs
+ * will be added to this set, if provided
+ */
addUUIDs(group, usedUUIDs = new Set()) {
- group.uuid = !group.uuid || usedUUIDs.has(group.uuid) ? uuidv4() : group.uuid;
+ group.uuid = (
+ !group.uuid || usedUUIDs.has(group.uuid)
+ ? uuidv4()
+ : group.uuid
+ );
usedUUIDs.add(group.uuid);
if (!isEmpty(group.sublayers)) {
- Object.assign(group, {sublayers: group.sublayers.slice(0)});
+ Object.assign(group, { sublayers: group.sublayers.slice(0) });
for (let i = 0; i < group.sublayers.length; ++i) {
- group.sublayers[i] = {...group.sublayers[i]};
+ group.sublayers[i] = { ...group.sublayers[i] };
LayerUtils.addUUIDs(group.sublayers[i], usedUUIDs);
}
}
},
+
+
+ /**
+ * Builds a comma-separated string of layer names and parameters
+ * for a list of layers.
+ *
+ * @param {LayerData[]} layers - an array of layer objects
+ *
+ * @returns {string} A comma-separated string of layer names and parameters
+ */
buildWMSLayerUrlParam(layers) {
- const layernames = [];
+ const layerNames = [];
const opacities = [];
const styles = [];
const visibilities = [];
const queryable = [];
for (const layer of layers) {
if (layer.role === LayerRole.THEME) {
- LayerUtils.collectWMSSublayerParams(layer, layernames, opacities, styles, queryable, visibilities, layer.visibility);
- } else if (layer.role === LayerRole.USERLAYER && layer.type === "wms") {
- const sublayernames = [];
- LayerUtils.collectWMSSublayerParams(layer, sublayernames, opacities, styles, queryable, visibilities, layer.visibility);
- let layerurl = layer.url;
+ LayerUtils.collectWMSSublayerParams(
+ layer, layerNames, opacities, styles,
+ queryable, visibilities, layer.visibility
+ );
+ } else if (
+ layer.role === LayerRole.USERLAYER &&
+ layer.type === "wms"
+ ) {
+ const subLayerNames = [];
+ LayerUtils.collectWMSSublayerParams(
+ layer, subLayerNames, opacities, styles, queryable,
+ visibilities, layer.visibility
+ );
+ let layerUrl = layer.url;
if (layer.extwmsparams) {
- layerurl += (layerurl.includes('?') ? '&' : '?') + Object.entries(layer.extwmsparams || {}).map(([key, value]) => 'extwms.' + key + "=" + value).join('&');
+ layerUrl += (
+ (layerUrl.includes('?') ? '&' : '?') +
+ Object.entries(layer.extwmsparams || {}).map(
+ ([key, value]) => 'extwms.' + key + "=" + value
+ ).join('&')
+ );
}
- layernames.push(...sublayernames.map(name => "wms:" + layerurl + "#" + name));
- } else if (layer.role === LayerRole.USERLAYER && (layer.type === "wfs" || layer.type === "wmts")) {
- layernames.push(layer.type + ':' + (layer.capabilitiesUrl || layer.url) + "#" + layer.name);
+ layerNames.push(
+ ...subLayerNames.map(
+ name => "wms:" + layerUrl + "#" + name
+ )
+ );
+ } else if (
+ layer.role === LayerRole.USERLAYER &&
+ (layer.type === "wfs" || layer.type === "wmts")
+ ) {
+ layerNames.push(
+ layer.type + ':' +
+ (layer.capabilitiesUrl || layer.url) + "#" +
+ layer.name
+ );
opacities.push(layer.opacity);
styles.push(layer.style);
- visibilities.push(layer.visibility);
- } else if (layer.role === LayerRole.USERLAYER && layer.type === "separator") {
- layernames.push("sep:" + layer.title);
+ visibilities.push(
+ layer.visibility === undefined ? 1 : (
+ layer.visibility ? 1 : 0
+ )
+ );
+ } else if (
+ layer.role === LayerRole.USERLAYER &&
+ layer.type === "separator"
+ ) {
+ layerNames.push("sep:" + layer.title);
opacities.push(255);
styles.push('');
visibilities.push(true);
}
}
- const result = layernames.map((layername, idx) => {
- let param = layername;
+ // TODO: styles are not used.
+ const result = layerNames.map((layerName, idx) => {
+ let param = layerName;
if (opacities[idx] < 255) {
- param += "[" + (100 - Math.round(opacities[idx] / 255 * 100)) + "]";
+ param += "[" + (
+ 100 - Math.round(opacities[idx] / 255 * 100)
+ ) + "]";
}
if (visibilities[idx] === 0) {
param += '!';
@@ -241,8 +502,27 @@ const LayerUtils = {
result.reverse();
}
return result.join(",");
-
},
+
+
+ /**
+ * Splits a layer URL parameter into its components.
+ *
+ * Parameters consist of:
+ * - an optional prefix that indicate the type of the
+ * layer and the url (e.g. `foo:bar` indicates layer of type `foo` with
+ * URL `bar`),
+ * - a layer name, an optional opacity (e.g. `foo[50]` indicates a foo
+ * layer with 50% opacity) and
+ * - an optional visibility indicator (e.g. `foo!` indicates
+ * a foo layer that is not visible, `foo~` indicates a group
+ * layer with some sub-layers visible and some invisible).
+ *
+ * @param {string} entry - the layer URL parameter to split
+ *
+ * @returns {Object} An object containing the ID, type, URL, name,
+ * opacity, visibility, and tristate of the layer.
+ */
splitLayerUrlParam(entry) {
const nameOpacityPattern = /([^[]+)\[(\d+)]/;
const id = uuidv4();
@@ -273,73 +553,178 @@ const LayerUtils = {
type = 'separator';
name = name.slice(4);
}
- return {id, type, url: layerUrl, name, opacity, visibility, tristate};
+ return { id, type, url: layerUrl, name, opacity, visibility, tristate };
},
+
+
+ /**
+ * Checks if the parent array is a prefix for the child array.
+ *
+ * @param {number[]} parent - the shorter path
+ * @param {number[]} child - the longer path
+ *
+ * @returns {boolean} - true if the parent is a prefix for the child
+ */
pathEqualOrBelow(parent, child) {
return isEqual(child.slice(0, parent.length), parent);
},
- removeLayer(layers, layer, sublayerpath) {
+
+
+ /**
+ * Removes a foreground layer from the list of layers.
+ *
+ * To remove a top level layer call this function with `layer` being the
+ * layer to remove and `subLayerPath` being an empty array.
+ *
+ * To remove a sub-layer call this function with `layer` being the
+ * top level layer and `subLayerPath` being the path to the sub-layer.
+ *
+ * The function silently ignores layers that are not found in the list.
+ *
+ * @param {LayerData[]} layers - the array of layers to remove the
+ * layer from
+ * @param {LayerData} layer - the top layer
+ * @param {number[]} subLayerPath - the path to the sub-layer to be removed
+ * relative to the top level layer or an empty array if the top level
+ * layer itself is to be removed
+ *
+ * @returns {LayerData[]} - the new array of layers with the
+ * layer removed
+ */
+ removeLayer(layers, layer, subLayerPath) {
// Extract foreground layers
- const fglayers = layers.filter(lyr => lyr.role !== LayerRole.BACKGROUND);
+ const fgLayers = layers.filter(
+ lyr => lyr.role !== LayerRole.BACKGROUND
+ );
// Explode layers (one entry for every single sublayer)
- let exploded = LayerUtils.explodeLayers(fglayers);
+ let exploded = LayerUtils.explodeLayers(fgLayers);
// Remove matching entries
- exploded = exploded.filter(entry => entry.layer.uuid !== layer.uuid || !LayerUtils.pathEqualOrBelow(sublayerpath, entry.path));
+ exploded = exploded.filter(
+ entry => (
+ entry.layer.uuid !== layer.uuid ||
+ !LayerUtils.pathEqualOrBelow(subLayerPath, entry.path)
+ )
+ );
// Re-assemble layers
- const newlayers = LayerUtils.implodeLayers(exploded);
- for (const lyr of newlayers) {
+ const newLayers = LayerUtils.implodeLayers(exploded);
+ for (const lyr of newLayers) {
if (lyr.type === "wms") {
Object.assign(lyr, LayerUtils.buildWMSLayerParams(lyr));
}
}
// Ensure theme layer is never removed
- if (!newlayers.find(lyr => lyr.role === LayerRole.THEME)) {
- const oldThemeLayer = layers.find(lyr => lyr.role === LayerRole.THEME);
+ if (!newLayers.find(lyr => lyr.role === LayerRole.THEME)) {
+ const oldThemeLayer = layers.find(
+ lyr => lyr.role === LayerRole.THEME
+ );
if (oldThemeLayer) {
- const newThemeLayer = {...oldThemeLayer, sublayers: []};
- Object.assign(newThemeLayer, LayerUtils.buildWMSLayerParams(newThemeLayer));
- newlayers.push(newThemeLayer);
+ const newThemeLayer = { ...oldThemeLayer, sublayers: [] };
+ Object.assign(
+ newThemeLayer,
+ LayerUtils.buildWMSLayerParams(newThemeLayer)
+ );
+ newLayers.push(newThemeLayer);
}
}
// Re-add background layers
return [
- ...newlayers,
+ ...newLayers,
...layers.filter(lyr => lyr.role === LayerRole.BACKGROUND)
];
},
- insertSeparator(layers, title, beforelayerId, beforesublayerpath) {
+
+
+ /**
+ * Inserts a separator layer with the given title before another layer.
+ *
+ * @param {LayerData[]} layers - the array of layers to insert
+ * the separator into
+ * @param {string} title - the title of the separator layer to insert
+ * @param {string} beforeLayerId - The ID of the top level layer used
+ * with `beforeSubLayerPath` for locating the layer to insert
+ * the separator before.
+ * @param {Array} beforeSubLayerPath - the path of the sublayer to insert
+ * the separator before relative to the top level layer specified
+ * by `beforeLayerId`
+ *
+ * @returns {LayerData[]} - the new array of layers with the
+ * separator layer inserted
+ * @throws {Error} - if the layer specified by `beforeLayerId` and
+ * `beforeSubLayerPath` is not found in the `layers` array
+ */
+ insertSeparator(layers, title, beforeLayerId, beforeSubLayerPath) {
// Extract foreground layers
- const fglayers = layers.filter(layer => layer.role !== LayerRole.BACKGROUND);
+ const fgLayers = layers.filter(
+ layer => layer.role !== LayerRole.BACKGROUND
+ );
+
// Explode layers (one entry for every single sublayer)
- const exploded = LayerUtils.explodeLayers(fglayers);
+ const exploded = LayerUtils.explodeLayers(fgLayers);
+
// Remove matching entries
- const pos = exploded.findIndex(entry => entry.layer.id === beforelayerId && isEqual(beforesublayerpath, entry.path));
+ const pos = exploded.findIndex(
+ entry => (
+ entry.layer.id === beforeLayerId &&
+ isEqual(beforeSubLayerPath, entry.path)
+ )
+ );
if (pos !== -1) {
// Add separator
exploded.splice(pos, 0, LayerUtils.createSeparatorLayer(title)[0]);
+ } else {
+ throw new Error(
+ "Failed to find 'before' layer item with " +
+ `ID '${beforeLayerId}' and path ${beforeSubLayerPath}`
+ );
}
+
// Re-assemble layers
- const newlayers = LayerUtils.implodeLayers(exploded);
- for (const layer of newlayers) {
+ const newLayers = LayerUtils.implodeLayers(exploded);
+ for (const layer of newLayers) {
if (layer.type === "wms") {
Object.assign(layer, LayerUtils.buildWMSLayerParams(layer));
}
}
+
// Re-add background layers
return [
- ...newlayers,
+ ...newLayers,
...layers.filter(layer => layer.role === LayerRole.BACKGROUND)
];
},
- reorderLayer(layers, movelayer, sublayerpath, delta, preventSplittingGroups) {
+
+
+ /**
+ * Reorders the given layers by moving the specified layer by the
+ * given delta.
+ *
+ * @param {LayerData[]} layers - the array of layers to reorder
+ * @param {LayerData} moveLayer - the top layer
+ * @param {number[]} subLayerPath - the path of the sublayer to move
+ * relative to the top level layer or an empty array if the top level
+ * layer itself is to be moved
+ * @param {number} delta - the amount to move the layer by
+ * @param {boolean} preventSplittingGroups - whether to prevent
+ * splitting sibling groups when reordering
+ *
+ * @returns {LayerData[]} - the reordered array of layers
+ */
+ reorderLayer(
+ layers, moveLayer, subLayerPath, delta, preventSplittingGroups
+ ) {
// Extract foreground layers
- const fglayers = layers.filter(layer => layer.role !== LayerRole.BACKGROUND);
+ const fgLayers = layers.filter(
+ layer => layer.role !== LayerRole.BACKGROUND
+ );
// Explode layers (one entry for every single sublayer)
- const exploded = LayerUtils.explodeLayers(fglayers);
+ const exploded = LayerUtils.explodeLayers(fgLayers);
// Find entry to move
- if (movelayer) {
+ if (moveLayer) {
const indices = exploded.reduce((result, entry, index) => {
- if (entry.layer.uuid === movelayer.uuid && LayerUtils.pathEqualOrBelow(sublayerpath, entry.path)) {
+ if (
+ entry.layer.uuid === moveLayer.uuid &&
+ LayerUtils.pathEqualOrBelow(subLayerPath, entry.path)
+ ) {
return [...result, index];
}
return result;
@@ -348,25 +733,59 @@ const LayerUtils = {
return layers;
}
indices.sort((a, b) => a - b);
- if ((delta < 0 && indices[0] <= 0) || (delta > 0 && indices[indices.length - 1] >= exploded.length - 1)) {
+ if (
+ (delta < 0 && indices[0] <= 0) ||
+ (
+ delta > 0 &&
+ indices[indices.length - 1] >= exploded.length - 1
+ )
+ ) {
return layers;
}
if (preventSplittingGroups) {
// Prevent moving an entry out of a containing group
- const idx = delta < 0 ? indices[0] : indices[indices.length - 1];
- const level = sublayerpath.length;
- if (level > exploded[idx + delta].path.length || !isEqual(exploded[idx + delta].path.slice(0, level - 1), sublayerpath.slice(0, -1))) {
+ const idx = delta < 0
+ ? indices[0]
+ : indices[indices.length - 1];
+ const level = subLayerPath.length;
+ if (
+ level > exploded[idx + delta].path.length ||
+ !isEqual(
+ exploded[idx + delta].path.slice(0, level - 1),
+ subLayerPath.slice(0, -1)
+ )
+ ) {
return layers;
}
// Avoid splitting sibling groups when reordering
- if (exploded[idx + delta].path.length > level || !isEqual(exploded[idx + delta].path.slice(0, -1), sublayerpath.slice(0, -1))) {
+ if (
+ exploded[idx + delta].path.length > level ||
+ !isEqual(
+ exploded[idx + delta].path.slice(0, -1),
+ subLayerPath.slice(0, -1)
+ )
+ ) {
// Find next slot
- const siblinggrouppath = exploded[idx + delta].path.slice(0, level);
- siblinggrouppath[siblinggrouppath.length - 1] += delta;
- while (idx + delta >= 0 && idx + delta < exploded.length && (exploded[idx + delta].path.length > level || !isEqual(exploded[idx + delta].path.slice(0, level), siblinggrouppath))) {
+ const siblingGroupPath = (
+ exploded[idx + delta].path.slice(0, level)
+ );
+ siblingGroupPath[siblingGroupPath.length - 1] += delta;
+ while (
+ idx + delta >= 0 &&
+ idx + delta < exploded.length &&
+ (
+ exploded[idx + delta].path.length > level ||
+ !isEqual(
+ exploded[idx + delta].path.slice(0, level),
+ siblingGroupPath
+ )
+ )
+ ) {
delta += delta > 0 ? 1 : -1;
}
- // The above logic adds the number of items to skip to the delta which is already -1 or +1, so we need to decrease delta by one accordingly
+ // The above logic adds the number of items to skip to
+ // the delta which is already -1 or +1, so we need
+ // to decrease delta by one accordingly
if (Math.abs(delta) > 1) {
delta += delta > 0 ? -1 : 1;
}
@@ -387,98 +806,230 @@ const LayerUtils = {
}
}
// Re-assemble layers
- const newlayers = LayerUtils.implodeLayers(exploded);
+ const newLayers = LayerUtils.implodeLayers(exploded);
// Re-add background layers
return [
- ...newlayers,
+ ...newLayers,
...layers.filter(layer => layer.role === LayerRole.BACKGROUND)
];
},
+
+
+ /**
+ * Return array with one entry for every single leaf sublayer.
+ *
+ * The result is a list of records, each of which contains a copy of the top
+ * level layer, the path to the leaf layer as indices inside parent
+ * and a copy of the leaf layer.
+ *
+ * @param {LayerData[]} layers - the list of top level layers
+ *
+ * @returns {ExplodedLayer[]} - an array that includes only
+ * leaf layers, with their paths and the root layer where they belong
+ * @see {LayerUtils.explodeSublayers}
+ */
explodeLayers(layers) {
- // Return array with one entry for every single sublayer)
const exploded = [];
for (const layer of layers) {
if (!isEmpty(layer.sublayers)) {
this.explodeSublayers(layer, layer, exploded);
} else {
- const newLayer = {...layer};
+ const newLayer = { ...layer };
+ // This check is true only if layer.sublayers = []
if (newLayer.sublayers) {
newLayer.sublayers = [...newLayer.sublayers];
}
- exploded.push({layer: newLayer, path: [], sublayer: newLayer});
+ exploded.push({
+ layer: newLayer,
+ path: [],
+ sublayer: newLayer
+ });
}
}
return exploded;
},
- explodeSublayers(layer, parent, exploded, parentpath = []) {
+
+
+ /**
+ * Go through all sublayers and create an array with one entry for every
+ * single leaf sublayer.
+ *
+ * This method calls itself recursively to create the list with `layer`
+ * unchanged and `parent` being the current layer that is being explored.
+ *
+ * @param {LayerData} layer - the top level layer
+ * @param {LayerData} parent - the current layer
+ * @param {ExplodedLayer[]} exploded - an array that includes only
+ * leaf layers, with their paths and the root layer where they belong
+ * @param {number[]} parentPath - the list of parent layers expresses as
+ * the 0-based index of that layer inside its parent
+ *
+ * @see {LayerUtils.explodeLayers}
+ */
+ explodeSublayers(layer, parent, exploded, parentPath = []) {
+ // We go through teach sublayer in parent.
for (let idx = 0; idx < parent.sublayers.length; ++idx) {
- const path = [...parentpath, idx];
- if (parent.sublayers[idx].sublayers) {
- LayerUtils.explodeSublayers(layer, parent.sublayers[idx], exploded, path);
+ // The path for this item is the path of the parent
+ // and its own index.
+ const path = [...parentPath, idx];
+
+ // Get the sub-layer at his index.
+ const subitem = parent.sublayers[idx];
+
+ // See if this layer has its own sublayers.
+ if (subitem.sublayers) {
+ // If it does simply go through those.
+ LayerUtils.explodeSublayers(
+ layer, subitem, exploded, path
+ );
} else {
+ // This is a leaf layer (has no sublayers).
// Reduced layer with one single sublayer per level, up to leaf
- const redLayer = {...layer};
+
+ // Make a copy of the top level layer.
+ const redLayer = { ...layer };
+
+ // Start from the top level and create a clone of this
+ // branch. Each node in the branch has a single sublayer
+ // except the last one (the leaf) which nas none.
let group = redLayer;
for (const jdx of path) {
- group.sublayers = [{...group.sublayers[jdx]}];
+ group.sublayers = [{ ...group.sublayers[jdx] }];
group = group.sublayers[0];
}
- exploded.push({layer: redLayer, path: path, sublayer: group});
+ exploded.push({
+ layer: redLayer,
+ path: path,
+ sublayer: group
+ });
}
}
},
+
+
+ /**
+ * Creates a tree structure from an array of layers.
+ *
+ * @param {ExplodedLayer[]} exploded - the flat list of layers
+ *
+ * @returns {LayerData[]} the reconstructed layer tree
+ */
implodeLayers(exploded) {
- const newlayers = [];
+ const newLayers = [];
const usedLayerUUids = new Set();
// Merge all possible items of an exploded layer array
for (const entry of exploded) {
+ // Get the top level layer.
const layer = entry.layer;
// Attempt to merge with previous if possible
- let target = newlayers.length > 0 ? newlayers[newlayers.length - 1] : null;
+ let target = newLayers.length > 0
+ ? newLayers[newLayers.length - 1]
+ : null;
let source = layer;
if (target && target.sublayers && target.id === layer.id) {
- let innertarget = target.sublayers[target.sublayers.length - 1];
- let innersource = source.sublayers[0]; // Exploded entries have only one entry per sublayer level
- while (innertarget && innertarget.sublayers && innertarget.name === innersource.name) {
- target = innertarget;
- source = innersource;
- innertarget = target.sublayers[target.sublayers.length - 1];
- innersource = source.sublayers[0]; // Exploded entries have only one entry per sublayer level
+ let innerTarget = target.sublayers[target.sublayers.length - 1];
+
+ // Exploded entries have only one entry per sublayer level
+ let innerSource = source.sublayers[0];
+
+ while (
+ innerTarget &&
+ innerTarget.sublayers &&
+ innerTarget.id === innerSource.id
+ ) {
+ target = innerTarget;
+ source = innerSource;
+
+ innerTarget = target.sublayers[target.sublayers.length - 1];
+ // Exploded entries have only one entry per sublayer level
+ innerSource = source.sublayers[0];
}
+
target.sublayers.push(source.sublayers[0]);
LayerUtils.addUUIDs(source.sublayers[0], usedLayerUUids);
} else {
- newlayers.push(layer);
+ newLayers.push(layer);
LayerUtils.addUUIDs(layer, usedLayerUUids);
}
}
// Ensure mutually exclusive groups have exactly one visible layer
- for (const layer of newlayers) {
+ for (const layer of newLayers) {
LayerUtils.ensureMutuallyExclusive(layer);
}
- for (const layer of newlayers) {
+ for (const layer of newLayers) {
if (layer.type === "wms") {
Object.assign(layer, LayerUtils.buildWMSLayerParams(layer));
}
}
- return newlayers;
+ return newLayers;
},
- insertLayer(layers, newlayer, beforeattr, beforeval) {
+
+
+ /**
+ * Inserts a layer into a tree.
+ *
+ * The function creates a linear representation of the tree of layers
+ * through {@link LayerUtils.explodeLayers}, inserts the layer
+ * then it recreates the tree through {@link LayerUtils.implodeLayers}.
+ *
+ * To determine the position of the insertion the function compares the
+ * value of the `beforeAttr` property of each leaf layer with the
+ * `beforeVal` argument.
+ *
+ * @param {LayerData[]} layers - the list of layers to change
+ * @param {LayerData} newLayer - the layer to insert
+ * @param {string} beforeAttr - the attribute to examine (e.g.
+ * `name` or `id`)
+ * @param {*} beforeVal - the value to examine
+ *
+ * @throws {Error} if the reference leaf layer is not found
+ * @returns {LayerData[]} a new list that includes the `newLayer`
+ */
+ insertLayer(layers, newLayer, beforeAttr, beforeVal) {
const exploded = LayerUtils.explodeLayers(layers);
- const explodedAdd = LayerUtils.explodeLayers([newlayer]);
- const index = exploded.findIndex(entry => entry.sublayer[beforeattr] === beforeval);
+ const explodedAdd = LayerUtils.explodeLayers([newLayer]);
+ const index = exploded.findIndex(
+ entry => entry.sublayer[beforeAttr] === beforeVal
+ );
if (index !== -1) {
exploded.splice(index, 0, ...explodedAdd);
+ } else {
+ throw new Error(
+ "Failed to find 'before' layer item with " +
+ `'${beforeAttr}'=${beforeVal}`
+ );
}
return LayerUtils.implodeLayers(exploded);
},
+
+
+ /**
+ * Changes the visibility attribute of all sub-layers in the entire tree
+ * for layers that represents mutually-exclusive groups.
+ *
+ * At each level the function will set exactly one visible sub-layer
+ * in a mutually-exclusive group based on following rules:
+ * - the first tri-state sub-layer or
+ * - the first visible sub-layer or
+ * - the first sub-layer in the group.
+ *
+ * @param {LayerData} group - the group to edit
+ */
ensureMutuallyExclusive(group) {
if (!isEmpty(group.sublayers)) {
if (group.mutuallyExclusive) {
- const tristateSublayer = group.sublayers.find(sublayer => sublayer.tristate === true);
- const visibleSublayer = tristateSublayer || group.sublayers.find(sublayer => sublayer.visibility === true) || group.sublayers[0];
+ const tristateSublayer = group.sublayers.find(
+ sublayer => sublayer.tristate === true
+ );
+ const visibleSublayer = (
+ tristateSublayer ||
+ group.sublayers.find(
+ sublayer => sublayer.visibility !== false
+ ) ||
+ group.sublayers[0]
+ );
for (const sublayer of group.sublayers) {
sublayer.visibility = sublayer === visibleSublayer;
}
@@ -488,33 +1039,74 @@ const LayerUtils = {
}
}
},
+
+
+ /**
+ * Returns an array of sublayer names for the given layer, including
+ * the layer's own name.
+ *
+ * @param {object} layer - the layer object to get sublayer names for.
+ *
+ * @returns {string[]} An array of sublayer names.
+ */
getSublayerNames(layer) {
- return [layer.name].concat((layer.sublayers || []).reduce((list, sublayer) => {
- return list.concat([...this.getSublayerNames(sublayer)]);
- }, [])).filter(x => x);
- },
- mergeSubLayers(baselayer, addlayer) {
- addlayer = {...baselayer, sublayers: addlayer.sublayers};
- addlayer.externalLayerMap = addlayer.externalLayerMap || {};
- LayerUtils.extractExternalLayersFromSublayers(addlayer, addlayer);
- LayerUtils.addUUIDs(addlayer);
- if (isEmpty(addlayer.sublayers)) {
- return {...baselayer};
- }
- if (isEmpty(baselayer.sublayers)) {
- return addlayer;
- }
- const explodedBase = LayerUtils.explodeLayers([baselayer]);
+ return [layer.name].concat(
+ (layer.sublayers || []).reduce((list, sublayer) => {
+ return list.concat([...this.getSublayerNames(sublayer)]);
+ }, [])
+ ).filter(x => x);
+ },
+
+
+ /**
+ * Merges the sub-layers of two layers into a single layer.
+ *
+ * @param {LayerData} baseLayer - the base layer to merge into.
+ * @param {LayerData} addLayer - the layer to merge into the base layer.
+ * @returns {LayerData} the merged layer.
+ */
+ mergeSubLayers(baseLayer, addLayer) {
+ addLayer = { ...baseLayer, sublayers: addLayer.sublayers };
+ addLayer.externalLayerMap = addLayer.externalLayerMap || {};
+ LayerUtils.extractExternalLayersFromSublayers(addLayer, addLayer);
+ LayerUtils.addUUIDs(addLayer);
+ if (isEmpty(addLayer.sublayers)) {
+ return { ...baseLayer };
+ }
+ if (isEmpty(baseLayer.sublayers)) {
+ return addLayer;
+ }
+ const explodedBase = LayerUtils.explodeLayers([baseLayer]);
const existing = explodedBase.map(entry => entry.sublayer.name);
- let explodedAdd = LayerUtils.explodeLayers([addlayer]);
- explodedAdd = explodedAdd.filter(entry => !existing.includes(entry.sublayer.name));
+ let explodedAdd = LayerUtils.explodeLayers([addLayer]);
+ explodedAdd = explodedAdd.filter(
+ entry => !existing.includes(entry.sublayer.name)
+ );
return LayerUtils.implodeLayers(explodedAdd.concat(explodedBase))[0];
},
+
+
+ /**
+ * Recursively searches for a sublayer with the given attribute
+ * and value.
+ *
+ * @param {LayerData} layer - the layer to search in
+ * @param {keyof LayerData} attr - the attribute to search for
+ * @param {*} value - the value to search for
+ * @param {number[]} path - the path to the sublayer that was located
+ *
+ * @returns {object|null} the sublayer with the given attribute
+ * and value, or null if not found.
+ */
searchSubLayer(layer, attr, value, path = []) {
if (layer.sublayers) {
let idx = 0;
for (const sublayer of layer.sublayers) {
- const match = sublayer[attr] === value ? sublayer : LayerUtils.searchSubLayer(sublayer, attr, value, path);
+ const match = (
+ sublayer[attr] === value
+ ? sublayer
+ : LayerUtils.searchSubLayer(sublayer, attr, value, path)
+ );
if (match) {
path.unshift(idx);
return match;
@@ -528,21 +1120,59 @@ const LayerUtils = {
}
return null;
},
- searchLayer(layers, key, value, roles = [LayerRole.THEME, LayerRole.USERLAYER]) {
+
+
+
+ /**
+ * Recursively searches for a sublayer with the given attribute.
+ *
+ * Searches for a layer in the given array of layers that matches
+ * the specified key-value pair, and returns an object containing
+ * the matching layer and its matching sublayer (if any).
+ *
+ * @param {LayerData[]} layers - the array of layers to search in
+ * @param {keyof LayerData} key - the key to search for in the
+ * layer properties
+ * @param {*} value - the value to search for in the layer properties
+ * @param {LayerRole[]} roles - only layers that have the roles specified
+ * in this array will be included in the search
+ *
+ * @returns {Object|null} an object containing the matching layer
+ * and its matching sublayer (if any), or null if no matching
+ * layer was found.
+ */
+ searchLayer(
+ layers, key, value, roles = [LayerRole.THEME, LayerRole.USERLAYER]
+ ) {
for (const layer of layers) {
if (roles.includes(layer.role)) {
- const matchsublayer = LayerUtils.searchSubLayer(layer, key, value);
- if (matchsublayer) {
- return {layer: layer, sublayer: matchsublayer};
+ const matchSubLayer = LayerUtils.searchSubLayer(
+ layer, key, value
+ );
+ if (matchSubLayer) {
+ return { layer: layer, sublayer: matchSubLayer };
}
}
}
return null;
},
- sublayerVisible(layer, sublayerpath) {
+
+
+ /**
+ * Check if a sub-layer and all of its parents are visible.
+ *
+ * @param {LayerData} layer - the layer to query
+ * @param {number[]} subLayerPath - path to the sub-layer as a list of
+ * 0-based indices; each number is the index of a child in its parent's
+ * list of `sublayers`
+ *
+ * @returns {boolean} true if sub-layer and all of its parents are visible
+ * (either explicitly or implicitly).
+ */
+ sublayerVisible(layer, subLayerPath) {
let visible = layer.visibility !== false;
let sublayer = layer;
- for (const index of sublayerpath) {
+ for (const index of subLayerPath) {
sublayer = sublayer.sublayers[index];
visible &= sublayer.visibility !== false;
if (!visible) {
@@ -551,95 +1181,280 @@ const LayerUtils = {
}
return true;
},
+
+
+ /**
+ * Computes the visibility of the layer based on the visibility of
+ * sub-layers.
+ *
+ * Layers that have no `visibility` attribute are assumed to be visible.
+ *
+ * @param {LayerData} layer - the layer to query
+ *
+ * @returns {number} - the visibility of this layer in the `[0..1]` interval.
+ */
computeLayerVisibility(layer) {
if (isEmpty(layer.sublayers) || layer.visibility === false) {
return layer.visibility ? 1 : 0;
}
let visible = 0;
layer.sublayers.map(sublayer => {
- const sublayervisibility = sublayer.visibility === undefined ? true : sublayer.visibility;
- if (sublayer.sublayers && sublayervisibility) {
+ const subLayerVisibility = sublayer.visibility !== false;
+ if (sublayer.sublayers && subLayerVisibility) {
visible += LayerUtils.computeLayerVisibility(sublayer);
} else {
- visible += sublayervisibility ? 1 : 0;
+ visible += subLayerVisibility ? 1 : 0;
}
});
return visible / layer.sublayers.length;
},
- cloneLayer(layer, sublayerpath) {
- const newlayer = {...layer};
- let cur = newlayer;
- for (let i = 0; i < sublayerpath.length; ++i) {
- const idx = sublayerpath[i];
+
+
+ /**
+ * Create a layer duplicate.
+ *
+ * @param {LayerData} layer - the layer to clone
+ * @param {number[]} subLayerPath - the path to the sub-layer to clone
+ * as a list of 0-based indices; each number is the index of a child in
+ * its parent's list of `sublayers`
+ *
+ * @returns {{newlayer: LayerData, newsublayer: LayerData}} the
+ * cloned top level layer and the cloned leaf sub-layer; the top layer
+ * will have all the sub-layers leading down to the lead sub-layer
+ * cloned as well but other layers sill be simply copied.
+ */
+ cloneLayer(layer, subLayerPath) {
+ const newLayer = { ...layer };
+ let cur = newLayer;
+ for (let i = 0; i < subLayerPath.length; ++i) {
+ const idx = subLayerPath[i];
cur.sublayers = [
...cur.sublayers.slice(0, idx),
- {...cur.sublayers[idx]},
+ { ...cur.sublayers[idx] },
...cur.sublayers.slice(idx + 1)
];
cur = cur.sublayers[idx];
}
- return {newlayer, newsublayer: cur};
+ return { newlayer: newLayer, newsublayer: cur };
},
+
+
+ /**
+ * Creates a map of group names to the list of layer names that
+ * belong to that group.
+ *
+ * The layers that contain sub-layers are groups.
+ * Usually you call the function with an empty `parentGroups` array.
+ * If the `layer` has no sub-layers, the function will return the
+ * `groupLayers` unchanged.
+ * If the layer does have sub-layers, the function will call itself
+ * recursively for each sub-layer, passing the `parentGroups` array
+ * with the name of the current layer added to it, thus each leaf
+ * layer will have a list of all the group names that it belongs to,
+ * all the way tot the top level layer.
+ *
+ * When the function encounters a leaf layer, it will add the layer
+ * name to the `groupLayers` map for each group name in the
+ * `parentGroups` array, so each leaf layers will show up once for
+ * each group it belongs to at every depth level.
+ *
+ * @param {LayerData} layer - the layer to start the query from
+ * @param {string[]} parentGroups - the initial list of group names;
+ * the function
+ * @param {Object} groupLayers - the map of group names to the list of
+ * layer names that belong to that group
+ *
+ * @todo this function assumes that the names of the groups are unique
+ * across the tree of layers, no matter the depth level. Is this true?
+ * If not true a group can eat up layers from different parts of the
+ * tree.
+ */
collectGroupLayers(layer, parentGroups, groupLayers) {
if (!isEmpty(layer.sublayers)) {
for (const sublayer of layer.sublayers) {
- LayerUtils.collectGroupLayers(sublayer, parentGroups.concat(layer.name), groupLayers);
+ LayerUtils.collectGroupLayers(
+ sublayer, parentGroups.concat(layer.name), groupLayers
+ );
}
} else {
for (const group of parentGroups) {
- groupLayers[group] = (groupLayers[group] || []).concat(layer.name);
+ groupLayers[group] = (
+ groupLayers[group] || []
+ ).concat(layer.name);
}
}
},
+
+
+ /**
+ * Replaces the configuration for groups with one configuration for
+ * each leaf layer in the group.
+ *
+ * A shallow copy of each configuration is made and the `name` property
+ * is replaced with the name of the leaf layer in the group.
+ *
+ * @param {LayerConfig[]} layerConfigs - the list of layer configurations
+ * @param {LayerData} layer - the layer from which to extract the groups
+ *
+ * @returns {LayerConfig[]} the list of layer configurations with
+ * the group configurations replaced with configurations for each
+ * leaf layer in the group.
+ *
+ * @todo for a leaf layer at depth level 2 (has a parent and a grand-parent)
+ * there will be two copies of the configuration with same name.
+ */
replaceLayerGroups(layerConfigs, layer) {
+ // We accumulate here the list of all group names that
+ // contain sub-layers associated with all their leaf layers.
const groupLayers = {};
LayerUtils.collectGroupLayers(layer, [], groupLayers);
+
const newLayerConfigs = [];
for (const layerConfig of layerConfigs) {
+ // TODO: this assumes that the group names are unique across the
+ // tree of layers.
if (layerConfig.name in groupLayers) {
- newLayerConfigs.push(...groupLayers[layerConfig.name].map(name => ({...layerConfig, name})));
+ // We have now determined that this layer config belongs
+ // to a group.
+
+ // Here we expand the single layer config into multiple layer
+ // configs, one for each leaf layer in the group.
+ newLayerConfigs.push(
+ ...groupLayers[layerConfig.name].map(
+ name => ({ ...layerConfig, name })
+ )
+ );
} else {
+ // If this is not a group, we simply copy the layer config.
newLayerConfigs.push(layerConfig);
}
}
return newLayerConfigs;
},
- extractExternalLayersFromSublayers(toplayer, layer) {
+
+
+ /**
+ * Create a new list of sublayers with external layer information
+ * stripped from them.
+ *
+ * The information about the external layers is collected in the
+ * `externalLayerMap` property of the top level layer.
+ *
+ * @param {LayerData} topLayer - the top level layer
+ * @param {LayerData} layer - the current layer
+ */
+ extractExternalLayersFromSublayers(topLayer, layer) {
if (layer.sublayers) {
+ // Create a new list of sublayers that does not contain
+ // the external layers.
layer.sublayers = layer.sublayers.map(sublayer => {
if (sublayer.externalLayer) {
- const externalLayer = {...sublayer.externalLayer};
+ // Take the data from sublayer, enhance it and save it
+ // in the externalLayerMap of the top level layer under the
+ // name of this layer. The enhance part will make sure
+ // that the external layer has a title, an uuid and
+ // wms properties.
+ const externalLayer = { ...sublayer.externalLayer };
LayerUtils.completeExternalLayer(externalLayer, sublayer);
- toplayer.externalLayerMap[sublayer.name] = externalLayer;
- sublayer = {...sublayer};
+ topLayer.externalLayerMap[sublayer.name] = externalLayer;
+
+ // Remove the external data from the sublayer.
+ sublayer = { ...sublayer };
delete sublayer.externalLayer;
}
if (sublayer.sublayers) {
- LayerUtils.extractExternalLayersFromSublayers(toplayer, sublayer);
+ LayerUtils.extractExternalLayersFromSublayers(
+ topLayer, sublayer
+ );
}
return sublayer;
});
}
},
+
+
+ /**
+ * Ensure that the layer has an uuid, a title and WMS properties.
+ *
+ * The title is taken from the layer itself, or from the sublayer
+ * or from the `name` of the external layer.
+ * The `uuid` is always generated with `uuidv4`.
+ *
+ * For WMS layers the `version` defaults to `1.3.0` if not set,
+ * the `featureInfoUrl` and `legendUrl` default to `url` if not set,
+ * the `queryLayers` are extracted from `LAYER` parameter if not set.
+ * If the `externalLayerFeatureInfoFormats` configuration is set,
+ * the `infoFormats` are set based on the it and the `featureInfoUrl`
+ * content.
+ *
+ * @param {ExternalLayer} externalLayer - the external layer data to enhance
+ * @param {LayerData} sublayer - the sublayer that contains the
+ * external layer data
+ */
completeExternalLayer(externalLayer, sublayer) {
- externalLayer.title = externalLayer.title || (sublayer || {}).title || externalLayer.name;
+ externalLayer.title = (
+ externalLayer.title ||
+ (sublayer || {}).title ||
+ externalLayer.name
+ );
externalLayer.uuid = uuidv4();
if (externalLayer.type === "wms" || externalLayer.params) {
externalLayer.version = externalLayer.version || "1.3.0";
- externalLayer.featureInfoUrl = externalLayer.featureInfoUrl || externalLayer.url;
- externalLayer.legendUrl = externalLayer.legendUrl || externalLayer.url;
- externalLayer.queryLayers = externalLayer.queryLayers || externalLayer.params.LAYERS.split(",");
+ externalLayer.featureInfoUrl = (
+ externalLayer.featureInfoUrl || externalLayer.url
+ );
+ externalLayer.legendUrl = (
+ externalLayer.legendUrl || externalLayer.url
+ );
+ externalLayer.queryLayers = (
+ externalLayer.queryLayers ||
+ externalLayer.params.LAYERS.split(",")
+ );
- const externalLayerFeatureInfoFormats = ConfigUtils.getConfigProp("externalLayerFeatureInfoFormats") || {};
+ const externalLayerFeatureInfoFormats = ConfigUtils.getConfigProp(
+ "externalLayerFeatureInfoFormats"
+ ) || {};
+ const featureInfoUrl = externalLayer.featureInfoUrl.toLowerCase();
for (const entry of Object.keys(externalLayerFeatureInfoFormats)) {
- if (externalLayer.featureInfoUrl.toLowerCase().includes(entry.toLowerCase())) {
- externalLayer.infoFormats = [externalLayerFeatureInfoFormats[entry]];
+ // TODO: this is a very simplistic check, we should
+ // probably parse the url and check the query parameters.
+ if (featureInfoUrl.includes(entry.toLowerCase())) {
+ externalLayer.infoFormats = [
+ externalLayerFeatureInfoFormats[entry]
+ ];
break;
}
}
}
},
- getLegendUrl(layer, sublayer, scale, map, bboxDependentLegend, scaleDependentLegend, extraLegendParameters) {
+
+
+ /**
+ * Returns the legend URL for a given layer and sublayer.
+ *
+ * For non-WMS layers the function simply returns `legendUrl`
+ * layer property.
+ *
+ * @param {LayerData} layer - the layer object
+ * @param {LayerData} sublayer - the sublayer object
+ * @param {number} scale - the scale of the map used to compute
+ * the `SCALE` parameter, but only when `scaleDependentLegend` is set
+ * @param {MapState} map - the map object
+ * @param {boolean|"theme"} bboxDependentLegend - whether the legend
+ * is dependent on the map's bounding box (`true`); if `theme` is used
+ * then this only applies to theme layers
+ * @param {boolean|"theme"} scaleDependentLegend - whether the legend
+ * is dependent on the map's scale (`true`); if `theme` is used
+ * then this only applies to theme layers
+ * @param {string} extraLegendParameters - extra parameters to add
+ * to the legend URL after the `VERSION` parameter
+ *
+ * @returns {string} the legend URL
+ */
+ getLegendUrl(
+ layer, sublayer, scale, map, bboxDependentLegend,
+ scaleDependentLegend, extraLegendParameters
+ ) {
if (layer.type !== "wms") {
return layer.legendUrl || "";
}
@@ -651,59 +1466,124 @@ const LayerUtils = {
SLD_VERSION: "1.1.0"
};
if (extraLegendParameters) {
- Object.assign(requestParams, Object.fromEntries(extraLegendParameters.split("&").map(entry => entry.split("="))));
+ Object.assign(
+ requestParams,
+ Object.fromEntries(
+ extraLegendParameters.split("&").map(
+ entry => entry.split("=")
+ )
+ )
+ );
}
- if (scaleDependentLegend === true || (scaleDependentLegend === "theme" && layer.role === LayerRole.THEME)) {
+ if (
+ scaleDependentLegend === true ||
+ (
+ scaleDependentLegend === "theme" &&
+ layer.role === LayerRole.THEME
+ )
+ ) {
requestParams.SCALE = Math.round(scale);
}
- if (bboxDependentLegend === true || (bboxDependentLegend === "theme" && layer.role === LayerRole.THEME)) {
+ if (
+ bboxDependentLegend === true ||
+ (
+ bboxDependentLegend === "theme" &&
+ layer.role === LayerRole.THEME
+ )
+ ) {
requestParams.WIDTH = map.size.width;
requestParams.HEIGHT = map.size.height;
const bounds = map.bbox.bounds;
- if (CoordinatesUtils.getAxisOrder(map.projection).substr(0, 2) === 'ne' && layer.version === '1.3.0') {
- requestParams.BBOX = [bounds[1], bounds[0], bounds[3], bounds[2]].join(",");
+ if (
+ CoordinatesUtils.getAxisOrder(
+ map.projection
+ ).substr(0, 2) === 'ne' &&
+ layer.version === '1.3.0'
+ ) {
+ requestParams.BBOX = [
+ bounds[1], bounds[0], bounds[3], bounds[2]
+ ].join(",");
} else {
requestParams.BBOX = bounds.join(",");
}
}
+ let urlParts;
if (layer.externalLayerMap && layer.externalLayerMap[sublayer.name]) {
const externalLayer = layer.externalLayerMap[sublayer.name];
if (externalLayer.type !== "wms") {
return externalLayer.legendUrl || "";
}
- const urlParts = url.parse(externalLayer.legendUrl, true);
+ urlParts = url.parse(externalLayer.legendUrl, true);
urlParts.query = {
VERSION: layer.version,
...urlParts.query,
...requestParams,
LAYER: externalLayer.params.LAYERS
};
- delete urlParts.search;
- return url.format(urlParts);
} else {
- const layername = layer === sublayer ? layer.name.replace(/.*\//, '') : sublayer.name;
- const urlParts = url.parse(layer.legendUrl, true);
+ const layerName = layer === sublayer
+ ? layer.name.replace(/.*\//, '')
+ : sublayer.name;
+ urlParts = url.parse(layer.legendUrl, true);
urlParts.query = {
VERSION: layer.version,
...urlParts.query,
...requestParams,
- LAYER: layername
+ LAYER: layerName
};
- delete urlParts.search;
- return url.format(urlParts);
}
+ delete urlParts.search;
+ return url.format(urlParts);
},
+
+
+ /**
+ * Checks if the the layer should be visible given a map scale.
+ *
+ * @param {LayerData} layer - the layer to investigate
+ * @param {number} mapScale - current scale of the map
+ *
+ * @returns {boolean} true if the layer should be visible
+ * @todo throw an error in degenerate cases (`minScale` >= `maxScale`)
+ */
layerScaleInRange(layer, mapScale) {
- return (layer.minScale === undefined || mapScale >= layer.minScale) && (layer.maxScale === undefined || mapScale < layer.maxScale);
+ return (
+ (
+ layer.minScale === undefined ||
+ mapScale >= layer.minScale
+ ) && (
+ layer.maxScale === undefined ||
+ mapScale < layer.maxScale
+ )
+ );
},
+
+
+ /**
+ * Adds external layer print parameters to the given params
+ * object for the specified layer.
+ *
+ * @param {LayerData} layer - the layer object to add print parameters for
+ * @param {object} params - the params object to add the print parameters to
+ * @param {string} printCrs - the CRS to use for printing
+ * @param {[number]} counterRef - an array reference to generate new
+ * unique identifiers for each layer
+ *
+ * @throws {Error} Unsupported value in `qgisServerVersion` configuration
+ * variable.
+ */
addExternalLayerPrintParams(layer, params, printCrs, counterRef) {
- const qgisServerVersion = (ConfigUtils.getConfigProp("qgisServerVersion") || 3);
+ const qgisServerVersion = (
+ ConfigUtils.getConfigProp("qgisServerVersion") || 3
+ );
if (qgisServerVersion >= 3) {
if (layer.type === "wms") {
const names = layer.params.LAYERS.split(",");
const opacities = layer.params.OPACITIES.split(",");
for (let idx = 0; idx < names.length; ++idx) {
- const identifier = String.fromCharCode(65 + (counterRef[0]++));
+ const identifier = String.fromCharCode(
+ 65 + (counterRef[0]++)
+ );
params.LAYERS.push("EXTERNAL_WMS:" + identifier);
params.OPACITIES.push(opacities[idx]);
params.COLORS.push("");
@@ -727,7 +1607,9 @@ const LayerUtils = {
if (layer.url.includes("?")) {
params[identifier + ":IgnoreGetMapUrl"] = "1";
}
- Object.entries(layer.extwmsparams || {}).forEach(([key, value]) => {
+ Object.entries(
+ layer.extwmsparams || {}
+ ).forEach(([key, value]) => {
params[identifier + ":" + key] = value;
});
}
@@ -748,31 +1630,73 @@ const LayerUtils = {
params.OPACITIES.push(layer.opacity);
params.COLORS.push(layer.color);
}
+ } else {
+ throw new Error(
+ `Unsupported qgisServerVersion: ${qgisServerVersion}`
+ );
}
},
- collectPrintParams(layers, theme, printScale, printCrs, printExternalLayers) {
+
+
+ /**
+ * Collects print parameters for the given layers, theme, print
+ * scale, print CRS, and print external layers.
+ *
+ * @param {Array} layers - the layers to collect print parameters for.
+ * @param {Object} theme - the theme to use for printing.
+ * @param {number} printScale - the print scale.
+ * @param {string} printCrs - the print CRS.
+ * @param {boolean} printExternalLayers - whether to print
+ * external layers.
+ *
+ * @return {{
+ * LAYERS: string,
+ * OPACITIES: string,
+ * COLORS: string
+ * }} the parameters
+ */
+ collectPrintParams(
+ layers, theme, printScale, printCrs, printExternalLayers
+ ) {
const params = {
LAYERS: [],
OPACITIES: [],
COLORS: []
};
const counterRef = [0];
-
for (const layer of layers) {
if (layer.role === LayerRole.THEME && layer.params.LAYERS) {
params.LAYERS.push(layer.params.LAYERS);
params.OPACITIES.push(layer.params.OPACITIES);
- params.COLORS.push(layer.params.LAYERS.split(",").map(() => "").join(","));
- } else if (printExternalLayers && layer.role === LayerRole.USERLAYER && layer.visibility !== false && LayerUtils.layerScaleInRange(layer, printScale)) {
- LayerUtils.addExternalLayerPrintParams(layer, params, printCrs, counterRef);
+ params.COLORS.push(
+ layer.params.LAYERS.split(",").map(() => "").join(",")
+ );
+ } else if (
+ printExternalLayers &&
+ layer.role === LayerRole.USERLAYER &&
+ layer.visibility !== false &&
+ LayerUtils.layerScaleInRange(layer, printScale)
+ ) {
+ LayerUtils.addExternalLayerPrintParams(
+ layer, params, printCrs, counterRef
+ );
}
}
- const backgroundLayer = layers.find(layer => layer.role === LayerRole.BACKGROUND && layer.visibility === true);
+ const backgroundLayer = layers.find(
+ layer => (
+ layer.role === LayerRole.BACKGROUND &&
+ layer.visibility !== false
+ )
+ );
if (backgroundLayer) {
const backgroundLayerName = backgroundLayer.name;
- const themeBackgroundLayer = theme.backgroundLayers.find(entry => entry.name === backgroundLayerName);
- const printBackgroundLayer = themeBackgroundLayer ? themeBackgroundLayer.printLayer : null;
+ const themeBackgroundLayer = theme.backgroundLayers.find(
+ entry => entry.name === backgroundLayerName
+ );
+ const printBackgroundLayer = themeBackgroundLayer
+ ? themeBackgroundLayer.printLayer
+ : null;
if (printBackgroundLayer) {
// Use printLayer defined in qgis project
let printBgLayerName = printBackgroundLayer;
@@ -786,16 +1710,35 @@ const LayerUtils = {
}
}
if (printBgLayerName) {
- params.LAYERS.push(printBgLayerName);
- params.OPACITIES.push("255");
- params.COLORS.push("");
+ let match = null;
+ if (
+ (match = printBgLayerName.match(/^(\w+):(.*)#([^#]+)$/)) &&
+ match[1] === "wms"
+ ) {
+ const layer = {
+ type: 'wms',
+ params: { LAYERS: match[3], OPACITIES: '255' },
+ url: match[2]
+ };
+ LayerUtils.addExternalLayerPrintParams(
+ layer, params, printCrs, counterRef
+ );
+ } else {
+ params.LAYERS.push(printBgLayerName);
+ params.OPACITIES.push("255");
+ params.COLORS.push("");
+ }
}
} else if (printExternalLayers) {
// Inject client-side wms as external layer for print
- const items = backgroundLayer.type === "group" ? backgroundLayer.items : [backgroundLayer];
+ const items = backgroundLayer.type === "group"
+ ? backgroundLayer.items
+ : [backgroundLayer];
items.slice(0).reverse().forEach(layer => {
if (LayerUtils.layerScaleInRange(layer, printScale)) {
- LayerUtils.addExternalLayerPrintParams(layer, params, printCrs, counterRef);
+ LayerUtils.addExternalLayerPrintParams(
+ layer, params, printCrs, counterRef
+ );
}
});
}
@@ -805,18 +1748,43 @@ const LayerUtils = {
params.COLORS = params.COLORS.reverse().join(",");
return params;
},
+
+
+ /**
+ * Returns an object containing the time dimension values of
+ * the given layer and its sublayers.
+ *
+ * @param {LayerData} layer - the layer to get the time
+ * dimension values from.
+ * @returns {{
+ * names: Set,
+ * values: Set,
+ * attributes: {
+ * [string]: [string, string]
+ * }
+ * }} - an object with the following properties:
+ * - names: a Set of the names of the time dimensions;
+ * - values: a Set of the values of the time dimensions;
+ * - attributes: An object mapping layer names to arrays of field names
+ * for the time dimensions.
+ */
getTimeDimensionValues(layer) {
const result = {
names: new Set(),
values: new Set(),
attributes: {}
};
- if (layer.visibility) {
+ if (layer.visibility !== false) {
(layer.dimensions || []).forEach(dimension => {
if (dimension.units === "ISO8601" && dimension.value) {
result.names.add(dimension.name);
- dimension.value.split(/,\s+/).filter(x => x).forEach(x => result.values.add(x));
- result.attributes[layer.name] = [dimension.fieldName, dimension.endFieldName];
+ dimension.value
+ .split(/,\s+/)
+ .filter(x => x)
+ .forEach(x => result.values.add(x));
+ result.attributes[layer.name] = [
+ dimension.fieldName, dimension.endFieldName
+ ];
}
});
}
@@ -824,12 +1792,58 @@ const LayerUtils = {
const sublayerResult = LayerUtils.getTimeDimensionValues(sublayer);
sublayerResult.names.forEach(x => result.names.add(x));
sublayerResult.values.forEach(x => result.values.add(x));
- result.attributes = {...result.attributes, ...sublayerResult.attributes};
+ result.attributes = {
+ ...result.attributes,
+ ...sublayerResult.attributes
+ };
});
return result;
},
- getAttribution(layer, map, showThemeAttributionOnly = false, transformedMapBBoxes = {}) {
- if (layer.visibility === false || (showThemeAttributionOnly && layer.role !== LayerRole.THEME)) {
+
+
+ /**
+ * Retrieve the attribution for a layer.
+ *
+ * If the layer has a valid range the function will use it to check
+ * if the layer is visible at the current map scale and omit this
+ * layer if it is not.
+ *
+ * If the layer has a bounding box the function will use it to check
+ * if the layer is visible in the current map extent and omit this
+ * layer if it is not.
+ *
+ * The function calls itself recursively for each sub-layer and
+ * group item.
+ *
+ * @param {LayerData} layer - the layer to get the attribution for
+ * @param {MapState} map - the map state
+ * @param {boolean} showThemeAttributionOnly - whether to show only the
+ * attribution for theme layers (the function will return an empty
+ * object for other types of layers)
+ * @param {{
+ * [string]: [number, number, number, number]
+ * }} transformedMapBBoxes - the bounds of the map transformed in the
+ * CRS of the layers; the function uses this trick to avoid reprojecting
+ * the map bounds for each layer and sub-layer
+ * @returns {{
+ * [string]: {
+ * title: string,
+ * layers: LayerData[]
+ * }
+ * }} - the attribution for the layer and its sublayers as an associative
+ * array that maps the attribution title to the list of layers
+ */
+ getAttribution(
+ layer, map,
+ showThemeAttributionOnly = false,
+ transformedMapBBoxes = {}
+ ) {
+ if (
+ layer.visibility === false || (
+ showThemeAttributionOnly &&
+ layer.role !== LayerRole.THEME
+ )
+ ) {
return {};
}
@@ -841,13 +1855,15 @@ const LayerUtils = {
if (layer.bbox && layer.bbox.bounds) {
const layerCrs = layer.bbox.crs || map.projection;
if (!transformedMapBBoxes[layerCrs]) {
- transformedMapBBoxes[layerCrs] = CoordinatesUtils.reprojectBbox(map.bbox.bounds, map.projection, layerCrs);
+ transformedMapBBoxes[layerCrs] = CoordinatesUtils.reprojectBbox(
+ map.bbox.bounds, map.projection, layerCrs
+ );
}
- const mapbbox = transformedMapBBoxes[layerCrs];
- const laybbox = layer.bbox.bounds;
+ const mapBbox = transformedMapBBoxes[layerCrs];
+ const layBbox = layer.bbox.bounds;
if (
- mapbbox[0] > laybbox[2] || mapbbox[2] < laybbox[0] ||
- mapbbox[1] > laybbox[3] || mapbbox[3] < laybbox[1]
+ mapBbox[0] > layBbox[2] || mapBbox[2] < layBbox[0] ||
+ mapBbox[1] > layBbox[3] || mapBbox[3] < layBbox[1]
) {
// Extents don't overlap
return {};
@@ -855,23 +1871,37 @@ const LayerUtils = {
}
const copyrights = {};
-
if (layer.sublayers) {
Object.assign(
copyrights,
- layer.sublayers.reduce((res, sublayer) => ({...res, ...LayerUtils.getAttribution(sublayer, map, false, transformedMapBBoxes)}), {})
+ layer.sublayers.reduce((res, sublayer) => ({
+ ...res,
+ ...LayerUtils.getAttribution(
+ sublayer, map, false, transformedMapBBoxes
+ )
+ }), {})
);
} else if (layer.type === "group" && layer.items) {
Object.assign(
copyrights,
- layer.items.reduce((res, sublayer) => ({...res, ...LayerUtils.getAttribution(sublayer, map, false, transformedMapBBoxes)}), {})
+ layer.items.reduce((res, sublayer) => ({
+ ...res,
+ ...LayerUtils.getAttribution(
+ sublayer, map, false, transformedMapBBoxes
+ )
+ }), {})
);
}
if (layer.attribution && layer.attribution.Title) {
- const key = layer.attribution.OnlineResource || layer.attribution.Title;
+ const key = (
+ layer.attribution.OnlineResource ||
+ layer.attribution.Title
+ );
copyrights[key] = {
- title: layer.attribution.OnlineResource ? layer.attribution.Title : null,
- layers: [ ...((copyrights[key] || {}).layers || []), layer]
+ title: layer.attribution.OnlineResource
+ ? layer.attribution.Title
+ : null,
+ layers: [...((copyrights[key] || {}).layers || []), layer]
};
}
return copyrights;
diff --git a/utils/LayerUtils.test.js b/utils/LayerUtils.test.js
new file mode 100644
index 000000000..22cdcabb1
--- /dev/null
+++ b/utils/LayerUtils.test.js
@@ -0,0 +1,4015 @@
+import LayerUtils from './LayerUtils';
+import { LayerRole } from '../actions/layers';
+
+const uuidRegex = /[a-z0-9]+-[a-z0-9]+-[a-z0-9]+-[a-z0-9]+-[a-z0-9]+/;
+
+let mockUrlReverseLayerOrder = false;
+let mockExternalLayerFeatureInfoFormats = undefined;
+let mockQgisServerVersion = 3;
+let mockAllowFractionalZoom = false;
+jest.mock("./ConfigUtils", () => ({
+ __esModule: true,
+ default: {
+ getConfigProp: (name) => {
+ if (name === 'urlReverseLayerOrder') {
+ return mockUrlReverseLayerOrder;
+ } else if (name === 'externalLayerFeatureInfoFormats') {
+ return mockExternalLayerFeatureInfoFormats;
+ } else if (name === 'qgisServerVersion') {
+ return mockQgisServerVersion;
+ } else if (name === 'allowFractionalZoom') {
+ return mockAllowFractionalZoom;
+ } else {
+ throw new Error(`Unknown config prop: ${name}`);
+ }
+ },
+ },
+}));
+
+let mockGetUnits = 'm';
+let mockAxisOrder = 'ne';
+jest.mock("./CoordinatesUtils", () => ({
+ __esModule: true,
+ default: {
+ getUnits: () => mockGetUnits,
+ getAxisOrder: () => mockAxisOrder,
+ },
+}));
+
+
+beforeEach(() => {
+ mockUrlReverseLayerOrder = false;
+ mockExternalLayerFeatureInfoFormats = undefined;
+ mockQgisServerVersion = 3;
+ mockGetUnits = 'm';
+ mockAxisOrder = 'ne';
+ mockAllowFractionalZoom = false;
+});
+
+
+describe("addExternalLayerPrintParams", () => {
+ const printCrs = "EPSG:3857";
+ let params;
+ let counterRef;
+ beforeEach(() => {
+ params = {
+ LAYERS: [],
+ OPACITIES: [],
+ COLORS: [],
+ };
+ counterRef = [0];
+ });
+ it("ignores git versions other than 2 and 3", () => {
+ mockQgisServerVersion = 1;
+ expect(() => {
+ LayerUtils.addExternalLayerPrintParams({
+ name: "lorem",
+ role: LayerRole.USERLAYER,
+ type: "wms",
+ }, params, printCrs, counterRef);
+ }).toThrow("Unsupported qgisServerVersion: 1");
+ expect(params).toEqual(params);
+ expect(counterRef[0]).toBe(0);
+ });
+ it("ignores non-WMS layers", () => {
+ mockQgisServerVersion = 3;
+ LayerUtils.addExternalLayerPrintParams({
+ name: "lorem",
+ role: LayerRole.USERLAYER,
+ type: "xyz",
+ }, params, printCrs, counterRef);
+ expect(params).toEqual(params);
+ expect(counterRef[0]).toBe(0);
+ });
+ it("deals with WMS, QGis 3 layers", () => {
+ mockQgisServerVersion = 3;
+ LayerUtils.addExternalLayerPrintParams({
+ name: "lorem",
+ role: LayerRole.USERLAYER,
+ type: "wms",
+ url: "ipsum",
+ params: {
+ LAYERS: "dolor",
+ OPACITIES: "255",
+ COLORS: "",
+ }
+ }, params, printCrs, counterRef);
+ expect(params).toEqual({
+ LAYERS: ["EXTERNAL_WMS:A"],
+ OPACITIES: ["255"],
+ COLORS: [""],
+ "A:contextualWMSLegend": "0",
+ "A:crs": "EPSG:3857",
+ "A:dpiMode": "7",
+ "A:format": "image/png",
+ "A:layers": "dolor",
+ "A:styles": "",
+ "A:url": "http://localhost/ipsum",
+ });
+ expect(counterRef[0]).toBe(1);
+ });
+ it("deals with WMS, QGis 2 layers", () => {
+ mockQgisServerVersion = 2;
+ LayerUtils.addExternalLayerPrintParams({
+ name: "lorem",
+ role: LayerRole.USERLAYER,
+ type: "wms",
+ url: "ipsum",
+ params: {
+ LAYERS: "dolor",
+ OPACITIES: "255",
+ COLORS: "",
+ }
+ }, params, printCrs, counterRef);
+ expect(params).toEqual({
+ LAYERS: ["wms:ipsum#dolor"],
+ OPACITIES: ["255"],
+ COLORS: [""],
+ });
+ expect(counterRef[0]).toBe(0);
+ });
+ it("deals with WFS, QGis 2 layers", () => {
+ mockQgisServerVersion = 2;
+ LayerUtils.addExternalLayerPrintParams({
+ name: "lorem",
+ role: LayerRole.USERLAYER,
+ type: "wfs",
+ url: "ipsum",
+ opacity: 127,
+ color: "#123456",
+ }, params, printCrs, counterRef);
+ expect(params).toEqual({
+ LAYERS: ["wfs:ipsum#lorem"],
+ OPACITIES: [127],
+ COLORS: ["#123456"],
+ });
+ expect(counterRef[0]).toBe(0);
+ });
+});
+
+
+describe("addUUIDs", () => {
+ it("should assign a new uuid if one is missing", () => {
+ const layer = {};
+ LayerUtils.addUUIDs(layer);
+ expect(layer.uuid).toMatch(uuidRegex);
+ });
+ it("should keep the old uuid if present", () => {
+ const uuid = "lorem";
+ const layer = { uuid };
+ LayerUtils.addUUIDs(layer);
+ expect(layer.uuid).toBe(uuid);
+ });
+ it("should allocate a new uuid if already present", () => {
+ const uuid = "lorem";
+ const layer = { uuid };
+ const used = new Set();
+ used.add(uuid, used);
+ LayerUtils.addUUIDs(layer, used);
+ expect(layer.uuid).not.toBe(uuid);
+ expect(layer.uuid).toMatch(uuidRegex);
+ });
+ it("should deal with sub-layers", () => {
+ const used = new Set();
+ const subLayers = [{
+ sublayers: [{}]
+ }]
+ const layer = {
+ sublayers: subLayers
+ };
+ LayerUtils.addUUIDs(layer, used);
+ expect(layer.uuid).toMatch(uuidRegex);
+ expect(layer.sublayers).not.toBe(subLayers);
+ expect(layer.sublayers[0].uuid).toMatch(uuidRegex);
+ expect(layer.sublayers[0].sublayers[0].uuid).toMatch(uuidRegex);
+ });
+});
+
+
+describe("buildWMSLayerParams", () => {
+ describe("without sublayers", () => {
+ it("should work with a simple layer", () => {
+ expect(LayerUtils.buildWMSLayerParams({
+ name: "lorem"
+ })).toEqual({
+ params: {
+ LAYERS: "lorem",
+ OPACITIES: "255",
+ STYLES: "",
+ },
+ queryLayers: []
+ });
+ });
+ it("should use layer opacity", () => {
+ expect(LayerUtils.buildWMSLayerParams({
+ name: "lorem",
+ opacity: 191
+ })).toEqual({
+ params: {
+ LAYERS: "lorem",
+ OPACITIES: "191",
+ STYLES: "",
+ },
+ queryLayers: []
+ });
+ });
+ it("should filter out empty strings", () => {
+ expect(LayerUtils.buildWMSLayerParams({
+ name: "lorem",
+ opacity: 0,
+ params: {
+ LAYERS: "ipsum,,dolor",
+ OPACITIES: "191,,255",
+ }
+ })).toEqual({
+ params: {
+ LAYERS: "ipsum,dolor",
+ OPACITIES: "0,0",
+ STYLES: "",
+ },
+ queryLayers: []
+ });
+ });
+ it("should copy the style", () => {
+ expect(LayerUtils.buildWMSLayerParams({
+ name: "lorem",
+ params: {
+ STYLES: "ipsum,dolor"
+ }
+ })).toEqual({
+ params: {
+ LAYERS: "lorem",
+ OPACITIES: "255",
+ STYLES: "ipsum,dolor",
+ },
+ queryLayers: []
+ });
+ });
+ it("should include dimensionValues content", () => {
+ expect(LayerUtils.buildWMSLayerParams({
+ name: "lorem",
+ dimensionValues: {
+ "ipsum": "dolor",
+ "sit": "amet",
+ }
+ })).toEqual({
+ params: {
+ LAYERS: "lorem",
+ OPACITIES: "255",
+ STYLES: "",
+ ipsum: "dolor",
+ sit: "amet",
+ },
+ queryLayers: []
+ });
+ });
+ it("should add the layer to queryLayers", () => {
+ expect(LayerUtils.buildWMSLayerParams({
+ name: "lorem",
+ queryable: true
+ })).toEqual({
+ params: {
+ LAYERS: "lorem",
+ OPACITIES: "255",
+ STYLES: "",
+ },
+ queryLayers: ["lorem"]
+ });
+ });
+ });
+ describe("with sublayers", () => {
+ it("excludes invisible layers", () => {
+ expect(LayerUtils.buildWMSLayerParams({
+ sublayers: [{
+ name: "lorem"
+ }]
+ })).toEqual({
+ params: {
+ LAYERS: "",
+ OPACITIES: "",
+ STYLES: "",
+ },
+ queryLayers: []
+ });
+ });
+ it("includes visible sublayers", () => {
+ expect(LayerUtils.buildWMSLayerParams({
+ name: "lorem",
+ visibility: true,
+ sublayers: [{
+ name: "ipsum"
+ }]
+ })).toEqual({
+ params: {
+ LAYERS: "ipsum",
+ OPACITIES: "255",
+ STYLES: "",
+ },
+ queryLayers: []
+ });
+ });
+ it("adds the styles", () => {
+ expect(LayerUtils.buildWMSLayerParams({
+ name: "lorem",
+ visibility: true,
+ sublayers: [{
+ name: "ipsum",
+ style: "dolor",
+ opacity: 191
+ }]
+ })).toEqual({
+ params: {
+ LAYERS: "ipsum",
+ OPACITIES: "191",
+ STYLES: "dolor",
+ },
+ queryLayers: []
+ });
+ });
+ it("adds queryable layers", () => {
+ expect(LayerUtils.buildWMSLayerParams({
+ name: "lorem",
+ visibility: true,
+ sublayers: [{
+ name: "ipsum",
+ queryable: true
+ }]
+ })).toEqual({
+ params: {
+ LAYERS: "ipsum",
+ OPACITIES: "255",
+ STYLES: "",
+ },
+ queryLayers: ["ipsum"]
+ });
+ });
+ it("includes multiple visible sublayers", () => {
+ expect(LayerUtils.buildWMSLayerParams({
+ name: "lorem",
+ visibility: true,
+ sublayers: [{
+ name: "ipsum",
+ }, {
+ name: "dolor",
+ }]
+ })).toEqual({
+ params: {
+ LAYERS: "dolor,ipsum",
+ OPACITIES: "255,255",
+ STYLES: ",",
+ },
+ queryLayers: []
+ });
+ });
+ it("adds the styles from multiple layers", () => {
+ expect(LayerUtils.buildWMSLayerParams({
+ name: "lorem",
+ visibility: true,
+ sublayers: [{
+ name: "ipsum",
+ style: "dolor",
+ opacity: 191
+ }, {
+ name: "sit",
+ style: "amet",
+ opacity: 215
+ }]
+ })).toEqual({
+ params: {
+ LAYERS: "sit,ipsum",
+ OPACITIES: "215,191",
+ STYLES: "amet,dolor",
+ },
+ queryLayers: []
+ });
+ });
+ it("adds multiple queryable layers", () => {
+ expect(LayerUtils.buildWMSLayerParams({
+ name: "lorem",
+ visibility: true,
+ sublayers: [{
+ name: "ipsum",
+ queryable: true
+ }, {
+ name: "dolor",
+ queryable: true
+ }]
+ })).toEqual({
+ params: {
+ LAYERS: "dolor,ipsum",
+ OPACITIES: "255,255",
+ STYLES: ",",
+ },
+ queryLayers: ["ipsum", "dolor"]
+ });
+ });
+ it("follows the drawing order", () => {
+ expect(LayerUtils.buildWMSLayerParams({
+ name: "lorem",
+ visibility: true,
+ drawingOrder: ["dolor", "ipsum", "sit"],
+ sublayers: [{
+ name: "ipsum",
+ opacity: 191
+ }, {
+ name: "dolor",
+ style: "sit",
+ }, {
+ name: "amet",
+ queryable: true
+ }]
+ })).toEqual({
+ params: {
+ LAYERS: "dolor,ipsum",
+ OPACITIES: "255,191",
+ STYLES: "sit,",
+ },
+ queryLayers: ["amet"]
+ });
+ });
+ });
+});
+
+
+describe("buildWMSLayerUrlParam", () => {
+ it("should return an empty string if no params are passed", () => {
+ expect(LayerUtils.buildWMSLayerUrlParam([])).toBe("");
+ });
+ it("should ignore layer types other than theme and user", () => {
+ expect(LayerUtils.buildWMSLayerUrlParam([{
+ role: LayerRole.BACKGROUND
+ }, {
+ role: LayerRole.SELECTION
+ }, {
+ role: LayerRole.MARKER
+ }, {
+ role: LayerRole.USERLAYER,
+ type: "xyz"
+ }])).toBe("");
+ });
+ describe("with theme layers", () => {
+ it("should accept theme layers", () => {
+ expect(LayerUtils.buildWMSLayerUrlParam([{
+ role: LayerRole.THEME,
+ name: "lorem"
+ }])).toBe("lorem~");
+ });
+ it("should use opacity", () => {
+ expect(LayerUtils.buildWMSLayerUrlParam([{
+ role: LayerRole.THEME,
+ name: "lorem",
+ opacity: 123
+ }])).toBe("lorem[52]~");
+ });
+ it("should use visibility", () => {
+ expect(LayerUtils.buildWMSLayerUrlParam([{
+ role: LayerRole.THEME,
+ name: "lorem",
+ visibility: false
+ }])).toBe("lorem!");
+ });
+ it("should work with sublayers", () => {
+ expect(LayerUtils.buildWMSLayerUrlParam([{
+ role: LayerRole.THEME,
+ name: "lorem",
+ sublayers: [{
+ name: "ipsum"
+ }, {
+ name: "dolor",
+ sublayers: [{
+ name: "sit",
+ opacity: 73
+ }, {
+ name: "amet",
+ visibility: false
+ }]
+ }]
+ }])).toBe("ipsum~,sit[71]~,amet!");
+ });
+ it("should reverse the layers", () => {
+ mockUrlReverseLayerOrder = true;
+ expect(LayerUtils.buildWMSLayerUrlParam([{
+ role: LayerRole.THEME,
+ name: "lorem",
+ sublayers: [{
+ name: "ipsum"
+ }, {
+ name: "dolor",
+ sublayers: [{
+ name: "sit",
+ opacity: 73
+ }, {
+ name: "amet",
+ visibility: false
+ }]
+ }]
+ }])).toBe("amet!,sit[71]~,ipsum~");
+ });
+ });
+ describe("with WMS user layers", () => {
+ it("should accept WMS user layers", () => {
+ expect(LayerUtils.buildWMSLayerUrlParam([{
+ role: LayerRole.USERLAYER,
+ type: "wms",
+ url: "ipsum",
+ name: "lorem"
+ }])).toBe("wms:ipsum#lorem~");
+ });
+ it("should use opacity", () => {
+ expect(LayerUtils.buildWMSLayerUrlParam([{
+ role: LayerRole.USERLAYER,
+ type: "wms",
+ url: "ipsum",
+ name: "lorem",
+ opacity: 123
+ }])).toBe("wms:ipsum#lorem[52]~");
+ });
+ it("should use visibility", () => {
+ expect(LayerUtils.buildWMSLayerUrlParam([{
+ role: LayerRole.USERLAYER,
+ type: "wms",
+ url: "ipsum",
+ name: "lorem",
+ visibility: false
+ }])).toBe("wms:ipsum#lorem!");
+ });
+ it("should use extended wms params", () => {
+ expect(LayerUtils.buildWMSLayerUrlParam([{
+ role: LayerRole.USERLAYER,
+ type: "wms",
+ url: "ipsum",
+ name: "lorem",
+ visibility: false,
+ extwmsparams: {
+ "tempor": "incididunt",
+ "ut": "labore"
+ }
+ }])).toBe(
+ "wms:ipsum?extwms.tempor=incididunt&extwms.ut=labore#lorem!"
+ );
+ });
+ it("should work with sublayers", () => {
+ expect(LayerUtils.buildWMSLayerUrlParam([{
+ role: LayerRole.USERLAYER,
+ type: "wms",
+ url: "ipsum",
+ name: "lorem",
+ sublayers: [{
+ name: "dolor"
+ }, {
+ name: "sit",
+ sublayers: [{
+ name: "amet",
+ opacity: 73
+ }, {
+ name: "consectetur",
+ visibility: false
+ }]
+ }]
+ }])).toBe(
+ "wms:ipsum#dolor~,wms:ipsum#amet[71]~,wms:ipsum#consectetur!"
+ );
+ });
+ });
+ describe("with WFS and WMTS user layers", () => {
+ it("should accept the layers", () => {
+ expect(LayerUtils.buildWMSLayerUrlParam([{
+ role: LayerRole.USERLAYER,
+ type: "wfs",
+ url: "ipsum",
+ name: "lorem"
+ }, {
+ role: LayerRole.USERLAYER,
+ type: "wmts",
+ capabilitiesUrl: "ipsum",
+ name: "lorem"
+ }])).toBe("wfs:ipsum#lorem,wmts:ipsum#lorem");
+ });
+ it("should use opacity", () => {
+ expect(LayerUtils.buildWMSLayerUrlParam([{
+ role: LayerRole.USERLAYER,
+ type: "wfs",
+ url: "ipsum",
+ name: "lorem",
+ opacity: 123
+ }, {
+ role: LayerRole.USERLAYER,
+ type: "wmts",
+ capabilitiesUrl: "sit",
+ name: "dolor",
+ opacity: 10
+ }])).toBe("wfs:ipsum#lorem[52],wmts:sit#dolor[96]");
+ });
+ it("should use visibility", () => {
+ expect(LayerUtils.buildWMSLayerUrlParam([{
+ role: LayerRole.USERLAYER,
+ type: "wfs",
+ capabilitiesUrl: "ipsum",
+ name: "lorem",
+ visibility: false
+ }, {
+ role: LayerRole.USERLAYER,
+ type: "wmts",
+ url: "sit",
+ name: "dolor",
+ visibility: false
+ }])).toBe("wfs:ipsum#lorem!,wmts:sit#dolor!");
+ });
+ });
+ describe("with separator layers", () => {
+ it("should accept separator layers", () => {
+ expect(LayerUtils.buildWMSLayerUrlParam([{
+ role: LayerRole.USERLAYER,
+ type: "separator",
+ name: "lorem",
+ title: "ipsum"
+ }])).toBe("sep:ipsum");
+ });
+ it("should ignore opacity", () => {
+ expect(LayerUtils.buildWMSLayerUrlParam([{
+ role: LayerRole.USERLAYER,
+ opacity: 123,
+ type: "separator",
+ name: "lorem",
+ title: "ipsum"
+ }])).toBe("sep:ipsum");
+ });
+ it("should ignore visibility", () => {
+ expect(LayerUtils.buildWMSLayerUrlParam([{
+ role: LayerRole.USERLAYER,
+ visibility: false,
+ type: "separator",
+ name: "lorem",
+ title: "ipsum"
+ }])).toBe("sep:ipsum");
+ });
+ it("should ignore sublayers", () => {
+ expect(LayerUtils.buildWMSLayerUrlParam([{
+ role: LayerRole.USERLAYER,
+ type: "separator",
+ name: "lorem",
+ title: "ipsum",
+ sublayers: [{
+ name: "dolor"
+ }]
+ }])).toBe("sep:ipsum");
+ });
+ });
+});
+
+
+describe("cloneLayer", () => {
+ it("should clone a layer without sublayers", () => {
+ const layer = {
+ id: "lorem",
+ };
+ const clone = LayerUtils.cloneLayer(layer, []);
+ const newLayer = clone.newlayer;
+ const newSubLayer = clone.newsublayer;
+ expect(newLayer).not.toBe(layer);
+ expect(newLayer).toEqual(layer);
+ expect(newSubLayer).not.toBe(layer);
+ expect(newSubLayer).toEqual(layer);
+ });
+ it("should clone a layer with a sub-layer", () => {
+ const subLayer = {
+ id: "ipsum",
+ };
+ const layer = {
+ id: "lorem",
+ sublayers: [subLayer]
+ };
+ const clone = LayerUtils.cloneLayer(layer, [0]);
+ const newLayer = clone.newlayer;
+ const newSubLayer = clone.newsublayer;
+ expect(newLayer).not.toBe(layer);
+ expect(newLayer).toEqual(layer);
+ expect(newSubLayer).not.toBe(subLayer);
+ expect(newSubLayer).toEqual(subLayer);
+ });
+});
+
+
+describe("collectGroupLayers", () => {
+ it("should return an empty list", () => {
+ const groupLayers = {};
+ LayerUtils.collectGroupLayers({
+ name: "lorem"
+ }, [], groupLayers);
+ expect(groupLayers).toEqual({});
+ });
+ it("should add the layer to a single group", () => {
+ const groupLayers = {};
+ LayerUtils.collectGroupLayers({
+ name: "lorem"
+ }, ['ipsum'], groupLayers);
+ expect(groupLayers).toEqual({
+ ipsum: ["lorem"]
+ });
+ });
+ it("should add the layer to multiple groups", () => {
+ const groupLayers = {};
+ LayerUtils.collectGroupLayers({
+ name: "lorem"
+ }, ['ipsum', 'dolor'], groupLayers);
+ expect(groupLayers).toEqual({
+ ipsum: ["lorem"],
+ dolor: ["lorem"]
+ });
+ });
+ it("should add the layer and sub-layer to a single group", () => {
+ const groupLayers = {};
+ LayerUtils.collectGroupLayers({
+ name: "lorem",
+ sublayers: [{
+ name: "consectetur"
+ }, {
+ name: "adipiscing"
+ }]
+ }, ['ipsum'], groupLayers);
+ expect(groupLayers).toEqual({
+ ipsum: ["consectetur", "adipiscing"],
+ lorem: ["consectetur", "adipiscing"]
+ });
+ });
+ it("should add the layer and sub-layer to multiple groups", () => {
+ const groupLayers = {};
+ LayerUtils.collectGroupLayers({
+ name: "lorem",
+ sublayers: [{
+ name: "consectetur"
+ }, {
+ name: "adipiscing"
+ }]
+ }, ['ipsum', 'dolor'], groupLayers);
+ expect(groupLayers).toEqual({
+ ipsum: ["consectetur", "adipiscing"],
+ dolor: ["consectetur", "adipiscing"],
+ lorem: ["consectetur", "adipiscing"]
+ });
+ });
+});
+
+
+describe("collectPrintParams", () => {
+ const emptyResponse = {
+ LAYERS: "",
+ OPACITIES: "",
+ COLORS: "",
+ };
+ it("should return an empty list if no params are passed", () => {
+ expect(
+ LayerUtils.collectPrintParams([], {}, 1, "EPSG:3857", true)
+ ).toEqual(emptyResponse);
+ });
+ it("should ignore layer types other than theme and user", () => {
+ expect(LayerUtils.collectPrintParams([{
+ role: LayerRole.BACKGROUND,
+ visibility: false
+ }, {
+ role: LayerRole.SELECTION
+ }, {
+ role: LayerRole.MARKER
+ }, {
+ role: LayerRole.USERLAYER,
+ type: "xyz"
+ }], {}, 1, "EPSG:3857", true)).toEqual(emptyResponse);
+ });
+ it("should accept theme layers", () => {
+ expect(LayerUtils.collectPrintParams([{
+ role: LayerRole.THEME,
+ visibility: false,
+ params: {
+ LAYERS: "lorem",
+ OPACITIES: "255",
+ COLORS: "",
+ }
+ }], {}, 1, "EPSG:3857", true)).toEqual({
+ LAYERS: "lorem",
+ OPACITIES: "255",
+ COLORS: "",
+ });
+ });
+ it("should accept user layers", () => {
+ expect(LayerUtils.collectPrintParams([{
+ role: LayerRole.USERLAYER,
+ visibility: true,
+ type: "wms",
+ url: "ipsum",
+ params: {
+ LAYERS: "lorem",
+ OPACITIES: "255",
+ COLORS: "",
+ }
+ }], {}, 1, "EPSG:3857", true)).toEqual({
+ LAYERS: "EXTERNAL_WMS:A",
+ OPACITIES: "255",
+ COLORS: "",
+ "A:contextualWMSLegend": "0",
+ "A:crs": "EPSG:3857",
+ "A:dpiMode": "7",
+ "A:format": "image/png",
+ "A:layers": "lorem",
+ "A:styles": "",
+ "A:url": "http://localhost/ipsum",
+ });
+ });
+ it("should accept background layers", () => {
+ expect(LayerUtils.collectPrintParams([{
+ name: "ipsum",
+ role: LayerRole.BACKGROUND,
+ visibility: true,
+ params: {
+ LAYERS: "lorem",
+ OPACITIES: "255",
+ COLORS: "",
+ }
+ }, {
+ name: "dolor",
+ role: LayerRole.USERLAYER,
+ }], {
+ backgroundLayers: [{
+ name: "ipsum",
+ printLayer: "dolor"
+ }]
+ }, 1, "EPSG:3857", true)).toEqual({
+ LAYERS: "dolor",
+ OPACITIES: "255",
+ COLORS: "",
+ });
+ });
+});
+
+
+describe("collectWMSSubLayerParams", () => {
+ it("should return an empty list if visibilities is not set", () => {
+ const subLayer = {
+ name: "lorem"
+ };
+ const layerNames = [];
+ const opacities = [];
+ const styles = [];
+ const queryable = [];
+ const visibilities = null;
+ LayerUtils.collectWMSSublayerParams(
+ subLayer, layerNames, opacities, styles,
+ queryable, visibilities, false
+ )
+ expect(layerNames).toEqual([]);
+ expect(opacities).toEqual([]);
+ expect(styles).toEqual([]);
+ expect(queryable).toEqual([]);
+ });
+ it("should return an empty list if layer visibility is off", () => {
+ const subLayer = {
+ name: "lorem",
+ visibility: false
+ };
+ const layerNames = [];
+ const opacities = [];
+ const styles = [];
+ const queryable = [];
+ const visibilities = null;
+ LayerUtils.collectWMSSublayerParams(
+ subLayer, layerNames, opacities, styles,
+ queryable, visibilities, false
+ )
+ expect(layerNames).toEqual([]);
+ expect(opacities).toEqual([]);
+ expect(styles).toEqual([]);
+ expect(queryable).toEqual([]);
+ });
+ it("should return the layer if it is visible", () => {
+ const subLayer = {
+ name: "lorem"
+ };
+ const layerNames = [];
+ const opacities = [];
+ const styles = [];
+ const queryable = [];
+ const visibilities = [];
+ LayerUtils.collectWMSSublayerParams(
+ subLayer, layerNames, opacities, styles,
+ queryable, visibilities, true
+ )
+ expect(layerNames).toEqual(["lorem"]);
+ expect(opacities).toEqual([255]);
+ expect(styles).toEqual([""]);
+ expect(queryable).toEqual([]);
+ expect(visibilities).toEqual([1]);
+ });
+ it("should take into account the visibility of its parent", () => {
+ const subLayer = {
+ name: "lorem"
+ };
+ const layerNames = [];
+ const opacities = [];
+ const styles = [];
+ const queryable = [];
+ const visibilities = [];
+ LayerUtils.collectWMSSublayerParams(
+ subLayer, layerNames, opacities, styles,
+ queryable, visibilities, false
+ )
+ expect(layerNames).toEqual(["lorem"]);
+ expect(opacities).toEqual([255]);
+ expect(styles).toEqual([""]);
+ expect(queryable).toEqual([]);
+ expect(visibilities).toEqual([0.5]);
+ });
+ it("should not use a non-integer opacity", () => {
+ const subLayer = {
+ name: "lorem",
+ opacity: "123"
+ };
+ const layerNames = [];
+ const opacities = [];
+ const styles = [];
+ const queryable = [];
+ const visibilities = [];
+ LayerUtils.collectWMSSublayerParams(
+ subLayer, layerNames, opacities, styles,
+ queryable, visibilities, false
+ )
+ expect(layerNames).toEqual(["lorem"]);
+ expect(opacities).toEqual([255]);
+ expect(styles).toEqual([""]);
+ expect(queryable).toEqual([]);
+ expect(visibilities).toEqual([0.5]);
+ });
+ it("should use an integer opacity", () => {
+ const subLayer = {
+ name: "lorem",
+ opacity: 123
+ };
+ const layerNames = [];
+ const opacities = [];
+ const styles = [];
+ const queryable = [];
+ const visibilities = [];
+ LayerUtils.collectWMSSublayerParams(
+ subLayer, layerNames, opacities, styles,
+ queryable, visibilities, false
+ )
+ expect(layerNames).toEqual(["lorem"]);
+ expect(opacities).toEqual([123]);
+ expect(styles).toEqual([""]);
+ expect(queryable).toEqual([]);
+ expect(visibilities).toEqual([0.5]);
+ });
+ it("should use layer style", () => {
+ const subLayer = {
+ name: "lorem",
+ style: "ipsum"
+ };
+ const layerNames = [];
+ const opacities = [];
+ const styles = [];
+ const queryable = [];
+ const visibilities = [];
+ LayerUtils.collectWMSSublayerParams(
+ subLayer, layerNames, opacities, styles,
+ queryable, visibilities, false
+ )
+ expect(layerNames).toEqual(["lorem"]);
+ expect(opacities).toEqual([255]);
+ expect(styles).toEqual(["ipsum"]);
+ expect(queryable).toEqual([]);
+ expect(visibilities).toEqual([0.5]);
+ });
+ it("should add the layer to the list of queryable layers", () => {
+ const subLayer = {
+ name: "lorem",
+ queryable: true
+ };
+ const layerNames = [];
+ const opacities = [];
+ const styles = [];
+ const queryable = [];
+ const visibilities = [];
+ LayerUtils.collectWMSSublayerParams(
+ subLayer, layerNames, opacities, styles,
+ queryable, visibilities, false
+ )
+ expect(layerNames).toEqual(["lorem"]);
+ expect(opacities).toEqual([255]);
+ expect(styles).toEqual([""]);
+ expect(queryable).toEqual(["lorem"]);
+ expect(visibilities).toEqual([0.5]);
+ });
+ it("should work with one sub-layer", () => {
+ const subLayer = {
+ name: "lorem",
+ queryable: true,
+ style: "ipsum",
+ opacity: 12,
+ sublayers: [{
+ name: "dolor",
+ opacity: 123,
+ style: "sit",
+ queryable: true
+ }]
+ };
+ const layerNames = [];
+ const opacities = [];
+ const styles = [];
+ const queryable = [];
+ const visibilities = [];
+ LayerUtils.collectWMSSublayerParams(
+ subLayer, layerNames, opacities, styles,
+ queryable, visibilities, false
+ )
+ expect(layerNames).toEqual(["dolor"]);
+ expect(opacities).toEqual([123]);
+ expect(styles).toEqual(["sit"]);
+ expect(queryable).toEqual(["dolor"]);
+ expect(visibilities).toEqual([0.5]);
+ });
+ it("should work with multiple sub-layers", () => {
+ const subLayer = {
+ name: "lorem",
+ queryable: true,
+ style: "ipsum",
+ opacity: 12,
+ sublayers: [{
+ name: "dolor",
+ opacity: 123,
+ style: "sit",
+ queryable: true
+ }, {
+ name: "amet",
+ visibility: false,
+ }, {
+ name: "consectetur",
+ opacity: 0,
+ style: "",
+ queryable: false
+ }, {
+ name: "adipiscing",
+ opacity: 0,
+ style: "",
+ queryable: true,
+ sublayers: [{
+ name: "elit",
+ opacity: 75,
+ style: "sed",
+ queryable: true
+ }, {
+ name: "do",
+ visibility: false,
+ }, {
+ name: "eiusmod",
+ visibility: false,
+ sublayers: [{
+ name: "tempor",
+ style: "incididunt",
+ }]
+ }]
+ }]
+ };
+ const layerNames = [];
+ const opacities = [];
+ const styles = [];
+ const queryable = [];
+ const visibilities = [];
+ LayerUtils.collectWMSSublayerParams(
+ subLayer, layerNames, opacities, styles,
+ queryable, visibilities, false
+ )
+ expect(layerNames).toEqual([
+ "dolor", "amet", "consectetur", "elit", "do", "tempor"
+ ]);
+ expect(opacities).toEqual([123, 255, 0, 75, 255, 255]);
+ expect(styles).toEqual(["sit", "", "", "sed", "", "incididunt"]);
+ expect(queryable).toEqual(["dolor", "elit"]);
+ expect(visibilities).toEqual([
+ 0.5, 0, 0.5, 0.5, 0, 0.5
+ ]);
+ });
+});
+
+
+describe("completeExternalLayer", () => {
+ describe("non-WMS layers", () => {
+ it("adds an uuid", () => {
+ const externalLayer = {};
+ LayerUtils.completeExternalLayer(externalLayer, undefined);
+ expect(externalLayer).toEqual({
+ title: undefined,
+ uuid: expect.stringMatching(uuidRegex)
+ });
+ });
+ it("keeps the title of the layer", () => {
+ const externalLayer = {
+ title: "lorem"
+ };
+ LayerUtils.completeExternalLayer(externalLayer, undefined);
+ expect(externalLayer).toEqual({
+ title: "lorem",
+ uuid: expect.stringMatching(uuidRegex)
+ });
+ });
+ it("uses the name if there's no title", () => {
+ const externalLayer = {
+ name: "lorem"
+ };
+ LayerUtils.completeExternalLayer(externalLayer, undefined);
+ expect(externalLayer).toEqual({
+ title: "lorem",
+ name: "lorem",
+ uuid: expect.stringMatching(uuidRegex)
+ });
+ });
+ it("uses the title of the sublayer", () => {
+ const externalLayer = {};
+ const sublayer = {
+ title: "lorem"
+ };
+ LayerUtils.completeExternalLayer(externalLayer, sublayer);
+ expect(externalLayer).toEqual({
+ title: "lorem",
+ uuid: expect.stringMatching(uuidRegex)
+ });
+ });
+ });
+ describe("WMS layers", () => {
+ const baseInput = {
+ type: "wms",
+ featureInfoUrl: "ipsum",
+ params: {
+ LAYERS: "lorem",
+ OPACITIES: "255",
+ STYLES: "",
+ }
+ };
+ const baseOutput = {
+ title: undefined,
+ uuid: expect.stringMatching(uuidRegex),
+ type: "wms",
+ legendUrl: undefined,
+ featureInfoUrl: "ipsum",
+ params: {
+ LAYERS: "lorem",
+ OPACITIES: "255",
+ STYLES: "",
+ },
+ queryLayers: ["lorem",],
+ version: "1.3.0",
+ }
+ beforeEach(() => {
+ mockExternalLayerFeatureInfoFormats = {};
+ });
+ it("adds the defaults", () => {
+ const externalLayer = { ...baseInput };
+ LayerUtils.completeExternalLayer(externalLayer, undefined);
+ expect(externalLayer).toEqual(baseOutput);
+ });
+ it("uses the version from the layer", () => {
+ const externalLayer = {
+ ...baseInput,
+ version: "1.1.1"
+ };
+ LayerUtils.completeExternalLayer(externalLayer, undefined);
+ expect(externalLayer).toEqual({
+ ...baseOutput,
+ version: "1.1.1"
+ });
+ });
+ it("uses the url property if featureInfoUrl is missing", () => {
+ const externalLayer = {
+ ...baseInput,
+ featureInfoUrl: undefined,
+ url: "ipsum"
+ };
+ LayerUtils.completeExternalLayer(externalLayer, undefined);
+ expect(externalLayer).toEqual({
+ ...baseOutput,
+ featureInfoUrl: "ipsum",
+ url: "ipsum",
+ legendUrl: "ipsum"
+ });
+ });
+ it("uses the legendUrl property", () => {
+ const externalLayer = {
+ ...baseInput,
+ legendUrl: "dolor"
+ };
+ LayerUtils.completeExternalLayer(externalLayer, undefined);
+ expect(externalLayer).toEqual({
+ ...baseOutput,
+ legendUrl: "dolor"
+ });
+ });
+ it("uses the queryLayers property", () => {
+ const externalLayer = {
+ ...baseInput,
+ queryLayers: ["lorem", "ipsum", "dolor"],
+ };
+ LayerUtils.completeExternalLayer(externalLayer, undefined);
+ expect(externalLayer).toEqual({
+ ...baseOutput,
+ queryLayers: ["lorem", "ipsum", "dolor"],
+ });
+ });
+ it("uses the LAYERS in parameters if no queryLayers property", () => {
+ const externalLayer = {
+ ...baseInput,
+ params: {
+ LAYERS: "lorem,ipsum,dolor"
+ }
+ };
+ LayerUtils.completeExternalLayer(externalLayer, undefined);
+ expect(externalLayer).toEqual({
+ ...baseOutput,
+ params: {
+ LAYERS: "lorem,ipsum,dolor"
+ },
+ queryLayers: ["lorem", "ipsum", "dolor"],
+ });
+ });
+ it("provides info formats", () => {
+ mockExternalLayerFeatureInfoFormats = {
+ "ipsum": "dolor",
+ "lorem": "sit"
+ }
+ const externalLayer = {
+ ...baseInput,
+ featureInfoUrl: "ipsum+lorem"
+ };
+ LayerUtils.completeExternalLayer(externalLayer, undefined);
+ expect(externalLayer).toEqual({
+ ...baseOutput,
+ featureInfoUrl: "ipsum+lorem",
+ infoFormats: ["dolor"]
+ });
+ });
+ });
+});
+
+
+describe("computeLayerVisibility", () => {
+ it("should be simple if there are no sub-layers", () => {
+ expect(LayerUtils.computeLayerVisibility({
+ visibility: true
+ })).toBe(1);
+ expect(LayerUtils.computeLayerVisibility({
+ visibility: false
+ })).toBe(0);
+ });
+ it("should return 0 if visibility is false", () => {
+ expect(LayerUtils.computeLayerVisibility({
+ visibility: false,
+ sublayers: [{
+ visibility: true,
+ }]
+ })).toBe(0);
+ });
+ it("should use immediate sublayers", () => {
+ expect(LayerUtils.computeLayerVisibility({
+ sublayers: [{
+ visibility: true,
+ }, {
+ visibility: false,
+ }]
+ })).toBe(0.5);
+ });
+ it("should use deep sublayers", () => {
+ expect(LayerUtils.computeLayerVisibility({
+ sublayers: [{
+ visibility: true,
+ }, {
+ sublayers: [{
+ visibility: true,
+ }, {
+ sublayers: [{
+ visibility: true,
+ }, {
+ visibility: false,
+ }]
+ }]
+ }]
+ })).toBe(0.875);
+ });
+ it("should assume visibility=true", () => {
+ expect(LayerUtils.computeLayerVisibility({
+ sublayers: [{}, {
+ sublayers: [{}, {
+ sublayers: [{}, {
+ visibility: false,
+ }]
+ }]
+ }]
+ })).toBe(0.875);
+ });
+});
+
+
+describe("createExternalLayerPlaceholder", () => {
+ it("should create the layer and add it to ", () => {
+ const externalLayers = {};
+ expect(LayerUtils.createExternalLayerPlaceholder({
+ name: "ipsum",
+ opacity: 0.75,
+ visibility: false,
+ params: "dolor"
+ }, externalLayers, "lorem")).toEqual([{
+ layer: {
+ id: "lorem",
+ type: "placeholder",
+ loading: true,
+ name: "ipsum",
+ title: "ipsum",
+ role: LayerRole.USERLAYER,
+ uuid: expect.stringMatching(uuidRegex)
+ },
+ path: [],
+ sublayer: {
+ id: "lorem",
+ type: "placeholder",
+ loading: true,
+ name: "ipsum",
+ title: "ipsum",
+ role: LayerRole.USERLAYER,
+ uuid: expect.stringMatching(uuidRegex)
+ }
+ }])
+ });
+});
+
+
+describe("createSeparatorLayer", () => {
+ it("should create a new layer", () => {
+ expect(LayerUtils.createSeparatorLayer("lorem")).toEqual([{
+ layer: {
+ type: "separator",
+ title: "lorem",
+ role: LayerRole.USERLAYER,
+ uuid: expect.stringMatching(uuidRegex),
+ id: expect.stringMatching(uuidRegex),
+ },
+ path: [],
+ sublayer: {
+ type: "separator",
+ title: "lorem",
+ role: LayerRole.USERLAYER,
+ uuid: expect.stringMatching(uuidRegex),
+ id: expect.stringMatching(uuidRegex),
+ }
+ }]);
+ });
+});
+
+
+describe("ensureMutuallyExclusive", () => {
+ it("should not throw if the groups is empty", () => {
+ expect(() => LayerUtils.ensureMutuallyExclusive({
+ mutuallyExclusive: true,
+ sublayers: []
+ })).not.toThrow();
+ expect(() => LayerUtils.ensureMutuallyExclusive({
+ mutuallyExclusive: true,
+ })).not.toThrow();
+ });
+ it("should set visible the only sub-layer", () => {
+ const layer = {
+ visibility: false
+ };
+ LayerUtils.ensureMutuallyExclusive({
+ mutuallyExclusive: true,
+ sublayers: [layer]
+ });
+ expect(layer.visibility).toBeTruthy();
+ });
+ it("should set visible the first sub-layer", () => {
+ const layer1 = {
+ visibility: false
+ };
+ const layer2 = {
+ visibility: false
+ };
+ LayerUtils.ensureMutuallyExclusive({
+ mutuallyExclusive: true,
+ sublayers: [layer1, layer2]
+ });
+ expect(layer1.visibility).toBeTruthy();
+ expect(layer2.visibility).toBeFalsy();
+ });
+ it("should set visible the visible sub-layer", () => {
+ const layer1 = {
+ visibility: false
+ };
+ const layer2 = {
+ visibility: true
+ };
+ LayerUtils.ensureMutuallyExclusive({
+ mutuallyExclusive: true,
+ sublayers: [layer1, layer2]
+ });
+ expect(layer1.visibility).toBeFalsy();
+ expect(layer2.visibility).toBeTruthy();
+ });
+ it("should set visible the tristate sub-layer", () => {
+ const layer1 = {
+ visibility: false,
+ tristate: false
+ };
+ const layer2 = {
+ visibility: false,
+ tristate: true
+ };
+ LayerUtils.ensureMutuallyExclusive({
+ mutuallyExclusive: true,
+ sublayers: [layer1, layer2]
+ });
+ expect(layer1.visibility).toBeFalsy();
+ expect(layer2.visibility).toBeTruthy();
+ });
+ it("should work inside a non mutually-exclusive group", () => {
+ const layer1 = {
+ visibility: false,
+ tristate: false
+ };
+ const layer2 = {
+ visibility: false,
+ tristate: true
+ };
+ LayerUtils.ensureMutuallyExclusive({
+ sublayers: [{
+ mutuallyExclusive: true,
+ sublayers: [layer1, layer2]
+ }]
+ });
+ expect(layer1.visibility).toBeFalsy();
+ expect(layer2.visibility).toBeTruthy();
+ });
+ it("should allow nested mutually-exclusive groups", () => {
+ const layer1 = {
+ visibility: false,
+ tristate: false
+ };
+ const layer2 = {
+ visibility: false,
+ tristate: true
+ };
+ const layer3 = {
+ visibility: false
+ };
+ const layer4 = {
+ visibility: false
+ };
+ LayerUtils.ensureMutuallyExclusive({
+ mutuallyExclusive: true,
+ sublayers: [
+ layer1,
+ layer2,
+ {
+ mutuallyExclusive: true,
+ sublayers: [layer3, layer4]
+ }
+ ]
+ });
+ expect(layer1.visibility).toBeFalsy();
+ expect(layer2.visibility).toBeTruthy();
+ expect(layer3.visibility).toBeTruthy();
+ expect(layer4.visibility).toBeFalsy();
+ });
+});
+
+
+describe("explodeLayers", () => {
+ it("should work with an empty list", () => {
+ expect(LayerUtils.explodeLayers([])).toEqual([]);
+ });
+ it("should work with a single layer", () => {
+ expect(LayerUtils.explodeLayers([{
+ id: "one"
+ }])).toEqual([{
+ layer: { id: "one" },
+ path: [],
+ sublayer: { id: "one" }
+ }]);
+ });
+ it("should create the list for a 2-item tree", () => {
+ expect(LayerUtils.explodeLayers([{
+ id: "one",
+ sublayers: [{
+ id: "one-one"
+ }]
+ }])).toEqual([{
+ layer: {
+ id: "one",
+ sublayers: [{
+ id: "one-one"
+ }]
+ },
+ path: [0],
+ sublayer: { id: "one-one" }
+ }]);
+ });
+ it("should create the list for a 3-item tree", () => {
+ expect(LayerUtils.explodeLayers([{
+ id: "one",
+ sublayers: [{
+ id: "one-one"
+ }, {
+ id: "one-two"
+ }]
+ }])).toEqual([{
+ layer: {
+ id: "one",
+ sublayers: [{
+ id: "one-one"
+ }]
+ },
+ path: [0],
+ sublayer: { id: "one-one" }
+ }, {
+ layer: {
+ id: "one",
+ sublayers: [{
+ id: "one-two"
+ }]
+ },
+ path: [1],
+ sublayer: { id: "one-two" }
+ }]);
+ });
+ it("should create the list for a 4-item tree", () => {
+ expect(LayerUtils.explodeLayers([{
+ id: "one",
+ sublayers: [{
+ id: "one-one",
+ sublayers: [{
+ id: "one-one-one",
+ }]
+ }, {
+ id: "one-two"
+ }]
+ }])).toEqual([{
+ layer: {
+ id: "one",
+ sublayers: [{
+ id: "one-one",
+ sublayers: [{ id: "one-one-one" }]
+ }]
+ },
+ path: [0, 0],
+ sublayer: { id: "one-one-one" }
+ }, {
+ layer: {
+ id: "one",
+ sublayers: [{
+ id: "one-two"
+ }]
+ },
+ path: [1],
+ sublayer: { id: "one-two" }
+ }]);
+ });
+
+});
+
+
+describe("explodeSublayers", () => {
+ it("should ignore a leaf layer (empty subitems property)", () => {
+ const layer = { sublayers: [] };
+ const exploded = [];
+ const path = [];
+ LayerUtils.explodeSublayers(layer, layer, exploded, path);
+ expect(exploded).toEqual([]);
+ expect(path).toEqual([]);
+ });
+ it("throws if a layer without subitems member is passed", () => {
+ expect(() => {
+ LayerUtils.explodeSublayers({}, {}, [], []);
+ }).toThrow();
+ });
+ it("should create the list for a 2-item tree", () => {
+ const layer = {
+ id: "one",
+ sublayers: [{
+ id: "one-one"
+ }]
+ };
+ const exploded = [];
+ const path = [];
+ LayerUtils.explodeSublayers(layer, layer, exploded, path);
+ expect(exploded).toEqual([{
+ "layer": {
+ "id": "one",
+ "sublayers": [
+ { "id": "one-one", },
+ ],
+ },
+ "path": [0],
+ "sublayer": { "id": "one-one" },
+ }]);
+ expect(path).toEqual([]);
+ });
+ it("should create the list for a 3-item tree", () => {
+ const layer = {
+ id: "one",
+ sublayers: [{
+ id: "one-one"
+ }, {
+ id: "one-two"
+ }]
+ };
+ const exploded = [];
+ const path = [];
+ LayerUtils.explodeSublayers(layer, layer, exploded, path);
+ expect(exploded).toEqual([{
+ layer: {
+ id: "one",
+ sublayers: [
+ { id: "one-one", },
+ ],
+ },
+ path: [0],
+ sublayer: { id: "one-one" },
+ }, {
+ layer: {
+ id: "one",
+ sublayers: [
+ { id: "one-two", },
+ ],
+ },
+ path: [1],
+ sublayer: { id: "one-two" },
+ }]);
+ expect(path).toEqual([]);
+ expect(exploded[0]).not.toBe(layer);
+ expect(exploded[1]).not.toBe(layer);
+ });
+ it("should create the list for a 4-item tree", () => {
+ const layer = {
+ id: "one",
+ sublayers: [{
+ id: "one-one",
+ sublayers: [{
+ id: "one-one-one",
+ }]
+ }, {
+ id: "one-two"
+ }]
+ };
+ const exploded = [];
+ const path = [];
+ LayerUtils.explodeSublayers(layer, layer, exploded, path);
+ expect(exploded).toEqual([{
+ layer: {
+ id: "one",
+ sublayers: [{
+ id: "one-one",
+ sublayers: [{
+ id: "one-one-one",
+ }]
+ }],
+ },
+ path: [0, 0],
+ sublayer: { id: "one-one-one" },
+ }, {
+ layer: {
+ id: "one",
+ sublayers: [
+ { "id": "one-two", },
+ ],
+ },
+ path: [1],
+ sublayer: { id: "one-two" },
+ }]);
+ expect(path).toEqual([]);
+ });
+
+});
+
+
+describe("extractExternalLayersFromSublayers", () => {
+ it("should clone an empty list", () => {
+ const sublayers = [];
+ const layer = { sublayers };
+ const topLayer = {};
+ LayerUtils.extractExternalLayersFromSublayers(
+ topLayer, layer
+ );
+ expect(layer.sublayers).toEqual(sublayers);
+ expect(layer.sublayers).not.toBe(sublayers);
+ expect(topLayer).toEqual({});
+ });
+ it("should clone a list with a single layer without external data", () => {
+ const sublayers = [{
+ name: "one"
+ }];
+ const layer = { sublayers };
+ const topLayer = {};
+ LayerUtils.extractExternalLayersFromSublayers(
+ topLayer, layer
+ );
+ expect(layer.sublayers).toEqual(sublayers);
+ expect(layer.sublayers).not.toBe(sublayers);
+ expect(topLayer).toEqual({});
+ });
+ it("should add external data", () => {
+ const sublayers = [{
+ name: "lorem",
+ externalLayer: {
+ name: "ipsum",
+ }
+ }];
+ const layer = { sublayers };
+ const topLayer = {
+ externalLayerMap: {}
+ };
+ LayerUtils.extractExternalLayersFromSublayers(
+ topLayer, layer
+ );
+ expect(layer.sublayers).toEqual([{ name: "lorem" }]);
+ expect(layer.sublayers).not.toBe(sublayers);
+ expect(topLayer).toEqual({
+ externalLayerMap: {
+ "lorem": {
+ name: "ipsum",
+ title: "ipsum",
+ uuid: expect.stringMatching(uuidRegex),
+ }
+ }
+ });
+ });
+ it("should add WMS data", () => {
+ mockExternalLayerFeatureInfoFormats = undefined;
+ const sublayers = [{
+ name: "lorem",
+ externalLayer: {
+ name: "ipsum",
+ type: "wms",
+ url: "dolor",
+ params: {
+ LAYERS: "sit,amet",
+ }
+ }
+ }];
+ const layer = { sublayers };
+ const topLayer = {
+ externalLayerMap: {}
+ };
+ LayerUtils.extractExternalLayersFromSublayers(
+ topLayer, layer
+ );
+ expect(layer.sublayers).toEqual([{ name: "lorem" }]);
+ expect(layer.sublayers).not.toBe(sublayers);
+ expect(topLayer).toEqual({
+ externalLayerMap: {
+ lorem: {
+ name: "ipsum",
+ type: "wms",
+ url: "dolor",
+ title: "ipsum",
+ uuid: expect.stringMatching(uuidRegex),
+ featureInfoUrl: "dolor",
+ legendUrl: "dolor",
+ params: {
+ LAYERS: "sit,amet",
+ },
+ version: "1.3.0",
+ queryLayers: [
+ "sit",
+ "amet",
+ ],
+ }
+ }
+ });
+ });
+ it("should work with nested data", () => {
+ const sublayers = [{
+ name: "lorem",
+ externalLayer: {
+ name: "ipsum",
+ },
+ sublayers: [{
+ name: "dolor",
+ externalLayer: {
+ name: "sit",
+ },
+ }],
+ }];
+ const layer = { sublayers };
+ const topLayer = {
+ externalLayerMap: {}
+ };
+ LayerUtils.extractExternalLayersFromSublayers(
+ topLayer, layer
+ );
+ expect(layer.sublayers).toEqual([
+ { name: "lorem", sublayers: [{ name: "dolor" }] },
+ ]);
+ expect(layer.sublayers).not.toBe(sublayers);
+ expect(topLayer).toEqual({
+ externalLayerMap: {
+ lorem: {
+ name: "ipsum",
+ title: "ipsum",
+ uuid: expect.stringMatching(uuidRegex),
+ },
+ dolor: {
+ name: "sit",
+ title: "sit",
+ uuid: expect.stringMatching(uuidRegex),
+ }
+ }
+ });
+ });
+});
+
+
+describe("getAttribution", () => {
+ const map = {
+ scales: [
+ 500,
+ 250,
+ 100,
+ ],
+ zoom: 100
+ };
+ it("should ignore invisible layers", () => {
+ const layer = {
+ visibility: false,
+ attribution: {
+ Title: "lorem",
+ OnlineResource: true
+ }
+ };
+ const showThemeAttributionOnly = false;
+ const transformedMapBBoxes = {};
+ expect(LayerUtils.getAttribution(
+ layer, map, showThemeAttributionOnly, transformedMapBBoxes
+ )).toEqual({});
+ });
+ it("should ignore non-theme layers", () => {
+ const layer = {
+ visibility: true,
+ role: LayerRole.USERLAYER,
+ attribution: {
+ Title: "lorem",
+ OnlineResource: true
+ }
+ };
+ const showThemeAttributionOnly = true;
+ const transformedMapBBoxes = {};
+ expect(LayerUtils.getAttribution(
+ layer, map, showThemeAttributionOnly, transformedMapBBoxes
+ )).toEqual({});
+ });
+ it("should ignore layers that are outside visible scale range", () => {
+ const layer = {
+ visibility: true,
+ minScale: 10,
+ maxScale: 90,
+ attribution: {
+ Title: "lorem",
+ OnlineResource: true
+ }
+ };
+ const showThemeAttributionOnly = false;
+ const transformedMapBBoxes = {};
+ expect(LayerUtils.getAttribution(
+ layer, map, showThemeAttributionOnly, transformedMapBBoxes
+ )).toEqual({});
+ });
+ it("should add visible layers", () => {
+ const layer = {
+ visibility: true,
+ attribution: {
+ Title: "lorem",
+ OnlineResource: "ipsum"
+ }
+ };
+ const showThemeAttributionOnly = false;
+ const transformedMapBBoxes = {};
+ expect(LayerUtils.getAttribution(
+ layer, map, showThemeAttributionOnly, transformedMapBBoxes
+ )).toEqual({
+ ipsum: {
+ title: "lorem",
+ layers: [{
+ visibility: true,
+ attribution: {
+ Title: "lorem",
+ OnlineResource: "ipsum"
+ }
+ }]
+ }
+ });
+ });
+ it("should add visible layers and sublayers", () => {
+ const layer = {
+ visibility: true,
+ attribution: {
+ Title: "lorem",
+ OnlineResource: "ipsum"
+ },
+ sublayers: [{
+ visibility: true,
+ attribution: {
+ Title: "dolor",
+ OnlineResource: "sit"
+ }
+ }]
+ };
+ const showThemeAttributionOnly = false;
+ const transformedMapBBoxes = {};
+ expect(LayerUtils.getAttribution(
+ layer, map, showThemeAttributionOnly, transformedMapBBoxes
+ )).toEqual({
+ ipsum: {
+ layers: [{
+ attribution: {
+ OnlineResource: "ipsum",
+ Title: "lorem",
+ },
+ sublayers: [{
+ attribution: {
+ OnlineResource: "sit",
+ Title: "dolor",
+ },
+ visibility: true,
+ },
+ ],
+ visibility: true,
+ }],
+ title: "lorem",
+ },
+ sit: {
+ layers: [{
+ attribution: {
+ OnlineResource: "sit",
+ Title: "dolor",
+ },
+ visibility: true,
+ }],
+ title: "dolor",
+ }
+ });
+ });
+});
+
+
+describe("getLegendUrl", () => {
+ it("simply returns the legendUrl property if not a WMS layer", () => {
+ expect(LayerUtils.getLegendUrl({})).toBe("");
+ expect(LayerUtils.getLegendUrl({
+ legendUrl: "lorem"
+ })).toBe("lorem");
+ expect(LayerUtils.getLegendUrl({
+ legendUrl: "lorem",
+ type: "xxx"
+ })).toBe("lorem");
+ });
+ it("should create an url for a simple layer", () => {
+ const layer = {
+ type: "wms",
+ legendUrl: "http://www.example.com/lorem",
+ name: "ipsum",
+ };
+ const sublayer = layer;
+ const map = {};
+ expect(LayerUtils.getLegendUrl(
+ layer, sublayer, undefined, map,
+ undefined, undefined, undefined
+ )).toBe(
+ "http://www.example.com/lorem?" +
+ "VERSION=&" +
+ "SERVICE=WMS&" +
+ "REQUEST=GetLegendGraphic&" +
+ "FORMAT=image%2Fpng&" +
+ "CRS=&" +
+ "SLD_VERSION=1.1.0&" +
+ "LAYER=ipsum"
+ );
+ });
+ it("should use the name from the sublayer", () => {
+ const layer = {
+ type: "wms",
+ legendUrl: "http://www.example.com/lorem",
+ name: "ipsum",
+ };
+ const sublayer = {
+ name: "dolor"
+ };
+ const map = {};
+ expect(LayerUtils.getLegendUrl(
+ layer, sublayer, undefined, map,
+ undefined, undefined, undefined
+ )).toBe(
+ "http://www.example.com/lorem?" +
+ "VERSION=&" +
+ "SERVICE=WMS&" +
+ "REQUEST=GetLegendGraphic&" +
+ "FORMAT=image%2Fpng&" +
+ "CRS=&" +
+ "SLD_VERSION=1.1.0&" +
+ "LAYER=dolor"
+ );
+ });
+ it("should kep query parameters in the url", () => {
+ const layer = {
+ type: "wms",
+ legendUrl: "http://www.example.com/lorem?a=b&c=d&q=1&search=sit",
+ name: "ipsum",
+ };
+ const sublayer = {
+ name: "dolor"
+ };
+ const map = {};
+ expect(LayerUtils.getLegendUrl(
+ layer, sublayer, undefined, map,
+ undefined, undefined, undefined
+ )).toBe(
+ "http://www.example.com/lorem?" +
+ "VERSION=&" +
+ "a=b&c=d&q=1&search=sit&" +
+ "SERVICE=WMS&" +
+ "REQUEST=GetLegendGraphic&" +
+ "FORMAT=image%2Fpng&" +
+ "CRS=&" +
+ "SLD_VERSION=1.1.0&" +
+ "LAYER=dolor"
+ );
+ });
+ it("should use extra legend parameters", () => {
+ const layer = {
+ type: "wms",
+ legendUrl: "http://www.example.com/lorem",
+ name: "ipsum",
+ };
+ const sublayer = layer;
+ const map = {};
+ expect(LayerUtils.getLegendUrl(
+ layer, sublayer, undefined, map,
+ undefined, undefined, "a=b1&c=d2&q=1&search=sit"
+ )).toBe(
+ "http://www.example.com/lorem?" +
+ "VERSION=&" +
+ "SERVICE=WMS&" +
+ "REQUEST=GetLegendGraphic&" +
+ "FORMAT=image%2Fpng&" +
+ "CRS=&" +
+ "SLD_VERSION=1.1.0&" +
+ "a=b1&c=d2&q=1&search=sit&" +
+ "LAYER=ipsum"
+ );
+ });
+ describe("scaleDependentLegend", () => {
+ const scale = 15.5;
+ const map = {};
+
+ it("should accept boolean", () => {
+ const layer = {
+ type: "wms",
+ legendUrl: "http://www.example.com/lorem",
+ name: "ipsum",
+ };
+ const sublayer = layer;
+ const scaleDependentLegend = true;
+ expect(LayerUtils.getLegendUrl(
+ layer, sublayer, scale, map,
+ undefined, scaleDependentLegend, undefined
+ )).toBe(
+ "http://www.example.com/lorem?" +
+ "VERSION=&" +
+ "SERVICE=WMS&" +
+ "REQUEST=GetLegendGraphic&" +
+ "FORMAT=image%2Fpng&" +
+ "CRS=&" +
+ "SLD_VERSION=1.1.0&" +
+ "SCALE=16&" +
+ "LAYER=ipsum"
+ );
+ });
+ it("should accept theme and ignore non-theme layers", () => {
+ const layer = {
+ type: "wms",
+ legendUrl: "http://www.example.com/lorem",
+ name: "ipsum",
+ role: LayerRole.USERLAYER,
+ };
+ const sublayer = layer;
+ const scaleDependentLegend = "theme";
+ expect(LayerUtils.getLegendUrl(
+ layer, sublayer, scale, map,
+ undefined, scaleDependentLegend, undefined
+ )).toBe(
+ "http://www.example.com/lorem?" +
+ "VERSION=&" +
+ "SERVICE=WMS&" +
+ "REQUEST=GetLegendGraphic&" +
+ "FORMAT=image%2Fpng&" +
+ "CRS=&" +
+ "SLD_VERSION=1.1.0&" +
+ "LAYER=ipsum"
+ );
+ });
+ it("should accept theme and use it for theme layers", () => {
+ const layer = {
+ type: "wms",
+ legendUrl: "http://www.example.com/lorem",
+ name: "ipsum",
+ role: LayerRole.THEME,
+ };
+ const sublayer = layer;
+ const scaleDependentLegend = "theme";
+ expect(LayerUtils.getLegendUrl(
+ layer, sublayer, scale, map,
+ undefined, scaleDependentLegend, undefined
+ )).toBe(
+ "http://www.example.com/lorem?" +
+ "VERSION=&" +
+ "SERVICE=WMS&" +
+ "REQUEST=GetLegendGraphic&" +
+ "FORMAT=image%2Fpng&" +
+ "CRS=&" +
+ "SLD_VERSION=1.1.0&" +
+ "SCALE=16&" +
+ "LAYER=ipsum"
+ );
+ });
+ });
+ describe("bboxDependentLegend", () => {
+ const map = {
+ size: {
+ width: 100,
+ height: 200
+ },
+ bbox: {
+ bounds: [0, 0, 100, 200]
+ }
+ };
+ it("should accept boolean", () => {
+ mockAxisOrder = "yx";
+ const layer = {
+ type: "wms",
+ legendUrl: "http://www.example.com/lorem",
+ name: "ipsum",
+ };
+ const sublayer = layer;
+ const bboxDependentLegend = true;
+ expect(LayerUtils.getLegendUrl(
+ layer, sublayer, undefined, map,
+ bboxDependentLegend, undefined, undefined
+ )).toBe(
+ "http://www.example.com/lorem?" +
+ "VERSION=&" +
+ "SERVICE=WMS&" +
+ "REQUEST=GetLegendGraphic&" +
+ "FORMAT=image%2Fpng&" +
+ "CRS=&" +
+ "SLD_VERSION=1.1.0&" +
+ "WIDTH=100&" +
+ "HEIGHT=200&" +
+ "BBOX=0%2C0%2C100%2C200&" +
+ "LAYER=ipsum"
+ );
+ });
+ it("should account for version 1.3.0", () => {
+ mockAxisOrder = "ne";
+ const layer = {
+ type: "wms",
+ legendUrl: "http://www.example.com/lorem",
+ name: "ipsum",
+ version: "1.3.0"
+ };
+ const sublayer = layer;
+ const bboxDependentLegend = true;
+ expect(LayerUtils.getLegendUrl(
+ layer, sublayer, undefined, map,
+ bboxDependentLegend, undefined, undefined
+ )).toBe(
+ "http://www.example.com/lorem?" +
+ "VERSION=1.3.0&" +
+ "SERVICE=WMS&" +
+ "REQUEST=GetLegendGraphic&" +
+ "FORMAT=image%2Fpng&" +
+ "CRS=&" +
+ "SLD_VERSION=1.1.0&" +
+ "WIDTH=100&" +
+ "HEIGHT=200&" +
+ "BBOX=0%2C0%2C200%2C100&" +
+ "LAYER=ipsum"
+ );
+ });
+ });
+ describe("with external layer map", () => {
+ it("should create an url for a simple layer", () => {
+ const externalLayerMap = {
+ "ipsum": {
+ type: "xyz",
+ legendUrl: "http://www.lorem.com/ipsum",
+ name: "ipsum",
+ }
+ };
+ const layer = {
+ type: "wms",
+ legendUrl: "http://www.example.com/lorem",
+ name: "ipsum",
+ externalLayerMap
+ };
+ const sublayer = layer;
+ const map = {};
+ expect(LayerUtils.getLegendUrl(
+ layer, sublayer, undefined, map,
+ undefined, undefined, undefined
+ )).toBe("http://www.lorem.com/ipsum");
+ });
+ it("should deal with a wms sub-layer", () => {
+ const externalLayerMap = {
+ "ipsum": {
+ type: "wms",
+ legendUrl: "http://www.lorem.com/ipsum?a=b&c=d&q=1&search=sit",
+ name: "ipsum",
+ params: {
+ LAYERS: "sit,amet",
+ }
+ }
+ };
+ const layer = {
+ type: "wms",
+ legendUrl: "http://www.example.com/lorem",
+ name: "ipsum",
+ externalLayerMap,
+ version: "1.2.3",
+ };
+ const sublayer = layer;
+ const map = {
+ projection: "EPSG:3857"
+ };
+ expect(LayerUtils.getLegendUrl(
+ layer, sublayer, undefined, map,
+ undefined, undefined, undefined
+ )).toBe(
+ "http://www.lorem.com/ipsum?" +
+ "VERSION=1.2.3&" +
+ "a=b&c=d&q=1&search=sit&" +
+ "SERVICE=WMS&" +
+ "REQUEST=GetLegendGraphic&" +
+ "FORMAT=image%2Fpng&" +
+ "CRS=EPSG%3A3857&" +
+ "SLD_VERSION=1.1.0&" +
+ "LAYER=sit%2Camet"
+ );
+ });
+ });
+});
+
+
+describe("getSublayerNames", () => {
+ it("should deal with no sublayers", () => {
+ const layer = { name: "Layer 1" };
+ const result = LayerUtils.getSublayerNames(layer);
+ expect(result).toEqual(["Layer 1"]);
+ });
+
+ it("should use all sublayer names", () => {
+ const layer = {
+ name: "Layer 1",
+ sublayers: [
+ { name: "Sublayer 1.1" },
+ {
+ name: "Sublayer 1.2",
+ sublayers: [{ name: "Sublayer 1.2.1" }]
+ },
+ ],
+ };
+ const result = LayerUtils.getSublayerNames(layer);
+ expect(result).toEqual([
+ "Layer 1",
+ "Sublayer 1.1",
+ "Sublayer 1.2",
+ "Sublayer 1.2.1",
+ ]);
+ });
+
+ it("should filter out falsy sublayer names", () => {
+ const layer = {
+ name: "Layer 1",
+ sublayers: [{
+ name: "Sublayer 1.1"
+ }, {
+ name: ""
+ }, {
+ name: "Sublayer 1.2"
+ }],
+ };
+ const result = LayerUtils.getSublayerNames(layer);
+ expect(result).toEqual(["Layer 1", "Sublayer 1.1", "Sublayer 1.2"]);
+ });
+});
+
+
+describe("getTimeDimensionValues", () => {
+ it("should return an empty object if the layer is invisible", () => {
+ expect(LayerUtils.getTimeDimensionValues({
+ visibility: false
+ })).toEqual({
+ names: new Set(),
+ values: new Set(),
+ attributes: {}
+ });
+ });
+ it("should return an empty object if the layer has no time dimension", () => {
+ expect(LayerUtils.getTimeDimensionValues({
+ visibility: true,
+ dimensions: []
+ })).toEqual({
+ names: new Set(),
+ values: new Set(),
+ attributes: {}
+ });
+ });
+ it("should return an empty object if the units are wrong", () => {
+ expect(LayerUtils.getTimeDimensionValues({
+ visibility: true,
+ dimensions: [{
+ units: "lorem"
+ }]
+ })).toEqual({
+ names: new Set(),
+ values: new Set(),
+ attributes: {}
+ });
+ });
+ it("should return an empty object if there is no value", () => {
+ expect(LayerUtils.getTimeDimensionValues({
+ visibility: true,
+ dimensions: [{
+ units: "ISO8601",
+ value: undefined
+ }]
+ })).toEqual({
+ names: new Set(),
+ values: new Set(),
+ attributes: {}
+ });
+ });
+ it("should add records with values", () => {
+ expect(LayerUtils.getTimeDimensionValues({
+ name: "amet",
+ visibility: true,
+ dimensions: [{
+ name: "sit",
+ units: "ISO8601",
+ value: "one, two, three",
+ fieldName: "lorem",
+ endFieldName: "ipsum"
+ }]
+ })).toEqual({
+ names: new Set(["sit"]),
+ values: new Set(["one", "two", "three"]),
+ attributes: {
+ amet: ["lorem", "ipsum"]
+ }
+ });
+ });
+ it("should descend into sub-layers", () => {
+ expect(LayerUtils.getTimeDimensionValues({
+ name: "amet",
+ visibility: true,
+ dimensions: [{
+ name: "sit",
+ units: "ISO8601",
+ value: "one, two, three",
+ fieldName: "lorem1",
+ endFieldName: "ipsum1"
+ }],
+ sublayers: [{
+ name: "dolor",
+ dimensions: [{
+ name: "adipiscing",
+ units: "ISO8601",
+ value: "four, five, six",
+ fieldName: "lorem2",
+ endFieldName: "ipsum2"
+ }]
+ }]
+ })).toEqual({
+ names: new Set(["sit", "adipiscing"]),
+ values: new Set(["one", "two", "three", "four", "five", "six"]),
+ attributes: {
+ amet: ["lorem1", "ipsum1"],
+ dolor: ["lorem2", "ipsum2"]
+ }
+ });
+ });
+});
+
+
+describe("implodeLayers", () => {
+ it("should return an empty list", () => {
+ expect(LayerUtils.implodeLayers([])).toEqual([]);
+ });
+ it("should work with one item", () => {
+ expect(LayerUtils.implodeLayers([{
+ layer: {
+ id: "one"
+ }
+ }])).toEqual([{
+ id: "one",
+ uuid: expect.stringMatching(uuidRegex)
+ }]);
+ });
+ it("should work with two items", () => {
+ expect(LayerUtils.implodeLayers([{
+ layer: {
+ id: "one"
+ }
+ }, {
+ layer: {
+ id: "two"
+ }
+ }])).toEqual([{
+ id: "one",
+ uuid: expect.stringMatching(uuidRegex)
+ }, {
+ id: "two",
+ uuid: expect.stringMatching(uuidRegex)
+ }]);
+ });
+ it("should work with sub-layers", () => {
+ expect(LayerUtils.implodeLayers([{
+ layer: {
+ id: "one",
+ sublayers: [{
+ id: "one-one",
+ sublayers: [{
+ id: "one-one-one",
+ }]
+ }],
+ },
+ path: [0, 0],
+ sublayer: { id: "one-one-one" },
+ }, {
+ layer: {
+ id: "one",
+ sublayers: [
+ { "id": "one-two", },
+ ],
+ },
+ path: [1],
+ sublayer: { id: "one-two" },
+ }])).toEqual([{
+ id: "one",
+ uuid: expect.stringMatching(uuidRegex),
+ sublayers: [{
+ id: "one-one",
+ uuid: expect.stringMatching(uuidRegex),
+ sublayers: [{
+ id: "one-one-one",
+ uuid: expect.stringMatching(uuidRegex)
+ }]
+ }, {
+ id: "one-two",
+ uuid: expect.stringMatching(uuidRegex),
+ }]
+ }]);
+ });
+});
+
+
+describe("insertLayer", () => {
+ it("should throw an error with an empty list", () => {
+ expect(() => {
+ LayerUtils.insertLayer([], { id: "one" }, "xxx", null)
+ }).toThrow("Failed to find");
+ });
+ it("should throw an error if before item is not found", () => {
+ expect(() => {
+ LayerUtils.insertLayer([{
+ id: "lorem"
+ }], {
+ id: "one"
+ }, "xxx", null)
+ }).toThrow("Failed to find");
+ });
+ it("should insert the layer before another top layer", () => {
+ expect(
+ LayerUtils.insertLayer([{
+ id: "lorem"
+ }], {
+ id: "ipsum"
+ }, "id", "lorem")
+ ).toEqual([{
+ id: "ipsum",
+ uuid: expect.stringMatching(uuidRegex)
+ }, {
+ id: "lorem",
+ uuid: expect.stringMatching(uuidRegex)
+ }]);
+ });
+ it("should insert the layer before another sub-layer", () => {
+ expect(
+ LayerUtils.insertLayer([{
+ id: "lorem",
+ sublayers: [{
+ id: "ipsum"
+ }, {
+ id: "dolor",
+ sublayers: [{
+ id: "amet"
+ }, {
+ id: "consectetur"
+ }]
+ }]
+ }], {
+ id: "lorem",
+ sublayers: [{
+ id: "dolor",
+ sublayers: [{
+ id: "sit",
+ }]
+ }]
+ }, "id", "amet")
+ ).toEqual([{
+ id: "lorem",
+ uuid: expect.stringMatching(uuidRegex),
+ sublayers: [
+ {
+ id: "ipsum",
+ uuid: expect.stringMatching(uuidRegex)
+ },
+ {
+ id: "dolor",
+ uuid: expect.stringMatching(uuidRegex),
+ sublayers: [
+ {
+ id: "sit",
+ uuid: expect.stringMatching(uuidRegex)
+ },
+ {
+ id: "amet",
+ uuid: expect.stringMatching(uuidRegex)
+ },
+ {
+ id: "consectetur",
+ uuid: expect.stringMatching(uuidRegex)
+ },
+ ],
+ },
+ ],
+ }]);
+ });
+});
+
+
+describe("insertPermalinkLayers", () => {
+ it("should ignore an empty exploded list", () => {
+ const exploded = [];
+ LayerUtils.insertPermalinkLayers(exploded, []);
+ expect(exploded).toEqual([]);
+ });
+ it("should ignore an empty input list", () => {
+ const exploded = [{
+ layer: {},
+ path: [],
+ sublayer: {}
+ }];
+ LayerUtils.insertPermalinkLayers(exploded, []);
+ expect(exploded).toEqual(exploded);
+ });
+ it("should insert a top-level layer in an empty list", () => {
+ const exploded = [];
+ LayerUtils.insertPermalinkLayers(exploded, [{
+ id: "lorem",
+ uuid: "ipsum",
+ role: LayerRole.USERLAYER,
+ type: "vector",
+ pos: 0
+ }]);
+ expect(exploded).toEqual([{
+ layer: {
+ id: "lorem",
+ uuid: "ipsum",
+ role: LayerRole.USERLAYER,
+ type: "vector"
+ },
+ path: [],
+ sublayer: {
+ id: "lorem",
+ uuid: "ipsum",
+ role: LayerRole.USERLAYER,
+ type: "vector"
+ }
+ }]);
+ });
+ it("should insert a top-level layer", () => {
+ const someLayer = {
+ id: "sit",
+ uuid: "dolor",
+ }
+ const exploded = [{
+ layer: someLayer,
+ path: [],
+ sublayer: someLayer
+ }];
+ LayerUtils.insertPermalinkLayers(exploded, [{
+ id: "lorem",
+ uuid: "ipsum",
+ role: LayerRole.USERLAYER,
+ type: "vector",
+ pos: 0
+ }]);
+ expect(exploded).toEqual([{
+ layer: {
+ id: "lorem",
+ uuid: "ipsum",
+ role: LayerRole.USERLAYER,
+ type: "vector"
+ },
+ path: [],
+ sublayer: {
+ id: "lorem",
+ uuid: "ipsum",
+ role: LayerRole.USERLAYER,
+ type: "vector"
+ }
+ }, {
+ layer: someLayer,
+ path: [],
+ sublayer: someLayer
+ }]);
+ });
+});
+
+
+describe("insertSeparator", () => {
+ it("inserts into an empty list throws an error", () => {
+ expect(() => {
+ LayerUtils.insertSeparator([], "lorem");
+ }).toThrow("Failed to find");
+ });
+ it("inserts into a list with unknown ID throws an error", () => {
+ expect(() => {
+ LayerUtils.insertSeparator([{
+ id: "ipsum",
+ role: LayerRole.USERLAYER
+ }], "lorem", "xxx", [0, 1, 2]);
+ }).toThrow("Failed to find");
+ });
+ it("inserts before a top-level layer", () => {
+ expect(LayerUtils.insertSeparator([{
+ id: "ipsum",
+ role: LayerRole.USERLAYER
+ }], "lorem", "ipsum", [])).toEqual([{
+ id: expect.stringMatching(uuidRegex),
+ uuid: expect.stringMatching(uuidRegex),
+ role: LayerRole.USERLAYER,
+ title: "lorem",
+ type: "separator",
+ }, {
+ id: "ipsum",
+ role: LayerRole.USERLAYER,
+ uuid: expect.stringMatching(uuidRegex)
+ }]);
+ });
+ it("inserts before a sub-layer", () => {
+ expect(LayerUtils.insertSeparator([{
+ id: "ipsum",
+ role: LayerRole.USERLAYER,
+ sublayers: [{
+ id: "dolor",
+ role: LayerRole.USERLAYER
+ }, {
+ id: "sit",
+ role: LayerRole.USERLAYER
+ }]
+ }], "lorem", "ipsum", [1])).toEqual([{
+ "id": "ipsum",
+ "role": 3,
+ "sublayers": [
+ {
+ "id": "dolor",
+ "role": 3,
+ "uuid": expect.stringMatching(uuidRegex),
+ },
+ ],
+ "uuid": expect.stringMatching(uuidRegex),
+ },
+ {
+ "id": expect.stringMatching(uuidRegex),
+ "role": 3,
+ "title": "lorem",
+ "type": "separator",
+ "uuid": expect.stringMatching(uuidRegex),
+ },
+ {
+ "id": "ipsum",
+ "role": 3,
+ "sublayers": [
+ {
+ "id": "sit",
+ "role": 3,
+ "uuid": expect.stringMatching(uuidRegex),
+ },
+ ],
+ "uuid": expect.stringMatching(uuidRegex),
+ }]);
+ });
+});
+
+
+describe("layerScaleInRange", () => {
+ it("should be true if no constraints are explicitly set", () => {
+ expect(LayerUtils.layerScaleInRange({}, 0)).toBeTruthy();
+ expect(LayerUtils.layerScaleInRange({}, 10000)).toBeTruthy();
+ expect(LayerUtils.layerScaleInRange({}, "ignored")).toBeTruthy();
+ });
+ it("should use the lower limit", () => {
+ expect(LayerUtils.layerScaleInRange({
+ minScale: 1
+ }, 0)).toBeFalsy();
+ expect(LayerUtils.layerScaleInRange({
+ minScale: 1
+ }, 1)).toBeTruthy();
+ expect(LayerUtils.layerScaleInRange({
+ minScale: 1
+ }, 2)).toBeTruthy();
+ });
+ it("should use the upper limit", () => {
+ expect(LayerUtils.layerScaleInRange({
+ maxScale: 1
+ }, 2)).toBeFalsy();
+ expect(LayerUtils.layerScaleInRange({
+ maxScale: 1
+ }, 1)).toBeFalsy();
+ expect(LayerUtils.layerScaleInRange({
+ maxScale: 1
+ }, 0)).toBeTruthy();
+ });
+ it("should use the both limit", () => {
+ expect(LayerUtils.layerScaleInRange({
+ minScale: 1,
+ maxScale: 2
+ }, 0)).toBeFalsy();
+ expect(LayerUtils.layerScaleInRange({
+ minScale: 1,
+ maxScale: 2
+ }, 1)).toBeTruthy();
+ expect(LayerUtils.layerScaleInRange({
+ minScale: 1,
+ maxScale: 2
+ }, 2)).toBeFalsy();
+ expect(LayerUtils.layerScaleInRange({
+ minScale: 1,
+ maxScale: 2
+ }, 3)).toBeFalsy();
+ });
+ it("should return false (!) in degenerate cases", () => {
+ expect(LayerUtils.layerScaleInRange({
+ minScale: 1,
+ maxScale: 1
+ }, 1)).toBeFalsy();
+ expect(LayerUtils.layerScaleInRange({
+ minScale: 2,
+ maxScale: 1
+ }, 1)).toBeFalsy();
+ expect(LayerUtils.layerScaleInRange({
+ minScale: 2,
+ maxScale: 1
+ }, 0)).toBeFalsy();
+ expect(LayerUtils.layerScaleInRange({
+ minScale: 2,
+ maxScale: 1
+ }, 3)).toBeFalsy();
+ });
+});
+
+
+describe("mergeSubLayers", () => {
+ it("should ignore two layers without sublayers", () => {
+ const baseLayer = {
+ id: "lorem",
+ };
+ const addLayer = {
+ id: "ipsum",
+ };
+ expect(
+ LayerUtils.mergeSubLayers(baseLayer, addLayer)
+ ).toEqual({
+ id: "lorem",
+ });
+ });
+ it("should add layers from the second layer", () => {
+ const baseLayer = {
+ id: "lorem",
+ };
+ const addLayer = {
+ id: "ipsum",
+ sublayers: [{
+ id: "dolor",
+ }],
+ };
+ expect(
+ LayerUtils.mergeSubLayers(baseLayer, addLayer)
+ ).toEqual({
+ id: "lorem",
+ externalLayerMap: {},
+ sublayers: [{
+ id: "dolor",
+ uuid: expect.stringMatching(uuidRegex),
+ }],
+ uuid: expect.stringMatching(uuidRegex),
+ });
+ });
+ it("should keep existing layers", () => {
+ const baseLayer = {
+ id: "lorem",
+ sublayers: [{
+ id: "dolor",
+ }],
+ };
+ const addLayer = {
+ id: "ipsum",
+
+ };
+ expect(
+ LayerUtils.mergeSubLayers(baseLayer, addLayer)
+ ).toEqual({
+ id: "lorem",
+ sublayers: [{
+ id: "dolor",
+ }],
+ });
+ });
+ it("should merge the layers", () => {
+ const baseLayer = {
+ id: "lorem",
+ name: "lorem",
+ sublayers: [{
+ id: "dolor",
+ name: "dolor",
+ }],
+ };
+ const addLayer = {
+ id: "ipsum",
+ name: "ipsum",
+ sublayers: [{
+ id: "sit",
+ name: "sit",
+ }],
+ };
+ expect(
+ LayerUtils.mergeSubLayers(baseLayer, addLayer)
+ ).toEqual({
+ id: "lorem",
+ name: "lorem",
+ externalLayerMap: {},
+ sublayers: [{
+ id: "sit",
+ name: "sit",
+ uuid: expect.stringMatching(uuidRegex),
+ }, {
+ id: "dolor",
+ name: "dolor",
+ uuid: expect.stringMatching(uuidRegex),
+ }],
+ uuid: expect.stringMatching(uuidRegex),
+ });
+ });
+ it("should merge layers with same name", () => {
+ const baseLayer = {
+ id: "lorem",
+ name: "lorem",
+ sublayers: [{
+ id: "one",
+ name: "dolor",
+ }],
+ };
+ const addLayer = {
+ id: "ipsum",
+ name: "ipsum",
+ sublayers: [{
+ id: "two",
+ name: "dolor",
+ }],
+ };
+ expect(
+ LayerUtils.mergeSubLayers(baseLayer, addLayer)
+ ).toEqual({
+ id: "lorem",
+ name: "lorem",
+ sublayers: [{
+ id: "one",
+ name: "dolor",
+ uuid: expect.stringMatching(uuidRegex),
+ }],
+ uuid: expect.stringMatching(uuidRegex),
+ });
+ });
+ it("should merge deeply nested layers", () => {
+ const baseLayer = {
+ id: "lorem",
+ name: "lorem",
+ sublayers: [{
+ id: "dolor",
+ name: "dolor",
+ sublayers: [{
+ id: "sit",
+ name: "sit",
+ sublayers: [{
+ id: "amet",
+ name: "amet",
+ sublayers: [{
+ id: "consectetur",
+ name: "consectetur",
+ }, {
+ id: "adipiscing",
+ name: "adipiscing",
+ }],
+ }, {
+ id: "elit",
+ name: "elit",
+ }, {
+ id: "sed",
+ name: "sed",
+ }],
+ }, {
+ id: "eiusmod",
+ name: "eiusmod",
+ }],
+ }],
+ };
+ const addLayer = {
+ id: "ipsum",
+ name: "ipsum",
+ sublayers: [{
+ id: "amet2",
+ name: "amet",
+ }, {
+ id: "adipiscing2",
+ name: "adipiscing",
+ }, {
+ id: "tempor",
+ name: "tempor",
+ sublayers: [{
+ id: "incididunt",
+ name: "incididunt",
+ }, {
+ id: "labore",
+ name: "labore",
+ }]
+ }],
+ };
+ expect(
+ LayerUtils.mergeSubLayers(baseLayer, addLayer)
+ ).toEqual({
+ externalLayerMap: {},
+ id: "lorem",
+ name: "lorem",
+ sublayers: [{
+ id: "amet2",
+ name: "amet",
+ uuid: expect.stringMatching(uuidRegex),
+ }, {
+ id: "tempor",
+ name: "tempor",
+ sublayers: [{
+ id: "incididunt",
+ name: "incididunt",
+ uuid: expect.stringMatching(uuidRegex),
+ }, {
+ id: "labore",
+ name: "labore",
+ uuid: expect.stringMatching(uuidRegex),
+ }],
+ uuid: expect.stringMatching(uuidRegex),
+ }, {
+ id: "dolor",
+ name: "dolor",
+ sublayers: [{
+ id: "sit",
+ name: "sit",
+ sublayers: [{
+ id: "amet",
+ name: "amet",
+ sublayers: [{
+ id: "consectetur",
+ name: "consectetur",
+ uuid: expect.stringMatching(uuidRegex),
+ }, {
+ id: "adipiscing",
+ name: "adipiscing",
+ uuid: expect.stringMatching(uuidRegex),
+ }],
+ uuid: expect.stringMatching(uuidRegex),
+ }, {
+ id: "elit",
+ name: "elit",
+ uuid: expect.stringMatching(uuidRegex),
+ }, {
+ id: "sed",
+ name: "sed",
+ uuid: expect.stringMatching(uuidRegex),
+ }],
+ uuid: expect.stringMatching(uuidRegex),
+ }, {
+ id: "eiusmod",
+ name: "eiusmod",
+ uuid: expect.stringMatching(uuidRegex),
+ }],
+ uuid: expect.stringMatching(uuidRegex),
+ }],
+ uuid: expect.stringMatching(uuidRegex),
+ });
+ });
+});
+
+
+describe("pathEqualOrBelow", () => {
+ it("should consider two empty arrays equal", () => {
+ expect(LayerUtils.pathEqualOrBelow([], [])).toBeTruthy();
+ });
+ it("should accept equal arrays", () => {
+ expect(LayerUtils.pathEqualOrBelow([1], [1])).toBeTruthy();
+ });
+ it("should reject different arrays", () => {
+ expect(LayerUtils.pathEqualOrBelow([1], [2])).toBeFalsy();
+ });
+ it("should accept a goos array", () => {
+ expect(LayerUtils.pathEqualOrBelow([1], [1, 2])).toBeTruthy();
+ });
+ it("should reject a longer array", () => {
+ expect(LayerUtils.pathEqualOrBelow([1, 2], [1])).toBeFalsy();
+ });
+ it("should reject the empty array", () => {
+ expect(LayerUtils.pathEqualOrBelow([1], [])).toBeFalsy();
+ });
+});
+
+
+describe("removeLayer", () => {
+ it("should silently ignore an empty list", () => {
+ expect(LayerUtils.removeLayer([], {}, [])).toEqual([]);
+ });
+ it("should silently ignore a list with an unknown layer", () => {
+ expect(LayerUtils.removeLayer([{
+ id: "lorem",
+ uuid: "ipsum"
+ }], {
+ uuid: "dolor"
+ }, [])).toEqual([{
+ id: "lorem",
+ uuid: "ipsum"
+ }]);
+ });
+ it("should remove a top-level layer", () => {
+ expect(LayerUtils.removeLayer([{
+ id: "lorem",
+ uuid: "ipsum"
+ }, {
+ id: "dolor",
+ uuid: "sit"
+ }, {
+ id: "amet",
+ uuid: "consectetur"
+ }], {
+ uuid: "sit"
+ }, [])).toEqual([{
+ id: "lorem",
+ uuid: "ipsum"
+ }, {
+ id: "amet",
+ uuid: "consectetur"
+ }]);
+ });
+ it("should remove a sub-layer", () => {
+ expect(LayerUtils.removeLayer([{
+ id: "lorem",
+ uuid: "ipsum",
+ sublayers: [{
+ id: "dolor",
+ uuid: "sit"
+ }, {
+ id: "amet",
+ uuid: "consectetur"
+ }]
+ }], {
+ uuid: "ipsum"
+ }, [0])).toEqual([{
+ id: "lorem",
+ uuid: "ipsum",
+ sublayers: [{
+ id: "amet",
+ uuid: "consectetur"
+ }]
+ }]);
+ });
+ it("should move background layer to the back of the list", () => {
+ expect(LayerUtils.removeLayer([{
+ id: "lorem",
+ uuid: "ipsum"
+ }, {
+ id: "dolor",
+ uuid: "sit",
+ role: LayerRole.BACKGROUND
+ }, {
+ id: "amet",
+ uuid: "consectetur"
+ }], {
+ uuid: "xxx"
+ }, [])).toEqual([{
+ id: "lorem",
+ uuid: "ipsum"
+ }, {
+ id: "amet",
+ uuid: "consectetur"
+ }, {
+ id: "dolor",
+ uuid: "sit",
+ role: LayerRole.BACKGROUND
+ }]);
+ });
+ it("should not remove a top-level background layer", () => {
+ expect(LayerUtils.removeLayer([{
+ id: "lorem",
+ uuid: "ipsum"
+ }, {
+ id: "dolor",
+ uuid: "sit",
+ role: LayerRole.BACKGROUND
+ }, {
+ id: "amet",
+ uuid: "consectetur"
+ }], {
+ uuid: "sit"
+ }, [])).toEqual([{
+ id: "lorem",
+ uuid: "ipsum"
+ }, {
+ id: "amet",
+ uuid: "consectetur"
+ }, {
+ id: "dolor",
+ uuid: "sit",
+ role: LayerRole.BACKGROUND
+ }]);
+ });
+});
+
+
+describe("reorderLayer", () => {
+ it("should silently ignore an empty list", () => {
+ expect(LayerUtils.reorderLayer([], {}, [], 1, true)).toEqual([]);
+ });
+ it("should silently ignore a list with an unknown layer", () => {
+ expect(LayerUtils.reorderLayer([{
+ id: "lorem",
+ uuid: "ipsum"
+ }], {
+ uuid: "dolor"
+ }, [])).toEqual([{
+ id: "lorem",
+ uuid: "ipsum"
+ }]);
+ });
+ it("should move a top-level layer one position to the back", () => {
+ expect(LayerUtils.reorderLayer([{
+ id: "lorem",
+ uuid: "ipsum"
+ }, {
+ id: "dolor",
+ uuid: "sit"
+ }, {
+ id: "amet",
+ uuid: "consectetur"
+ }], {
+ uuid: "sit"
+ }, [], 1, true)).toEqual([{
+ id: "lorem",
+ uuid: "ipsum"
+ }, {
+ id: "amet",
+ uuid: "consectetur"
+ }, {
+ id: "dolor",
+ uuid: "sit"
+ }]);
+ });
+ it("should move background layers to the back", () => {
+ expect(LayerUtils.reorderLayer([{
+ id: "background",
+ uuid: "background",
+ role: LayerRole.BACKGROUND
+ }, {
+ id: "lorem",
+ uuid: "ipsum"
+ }, {
+ id: "dolor",
+ uuid: "sit"
+ }, {
+ id: "amet",
+ uuid: "consectetur"
+ }], {
+ uuid: "sit"
+ }, [], 1, true)).toEqual([{
+ id: "lorem",
+ uuid: "ipsum"
+ }, {
+ id: "amet",
+ uuid: "consectetur"
+ }, {
+ id: "dolor",
+ uuid: "sit"
+ }, {
+ id: "background",
+ uuid: "background",
+ role: LayerRole.BACKGROUND
+ }]);
+ });
+ it("should move a top-level layer one position to the front", () => {
+ expect(LayerUtils.reorderLayer([{
+ id: "lorem",
+ uuid: "ipsum"
+ }, {
+ id: "dolor",
+ uuid: "sit"
+ }, {
+ id: "amet",
+ uuid: "consectetur"
+ }], {
+ uuid: "sit"
+ }, [], -1, true)).toEqual([{
+ id: "dolor",
+ uuid: "sit"
+ }, {
+ id: "lorem",
+ uuid: "ipsum"
+ }, {
+ id: "amet",
+ uuid: "consectetur"
+ }]);
+ });
+ it("should move a sub-layer one position to the back", () => {
+ expect(LayerUtils.reorderLayer([{
+ id: "lorem",
+ uuid: "ipsum",
+ sublayers: [{
+ id: "dolor",
+ uuid: "sit"
+ }, {
+ id: "amet",
+ uuid: "consectetur"
+ }]
+ }], {
+ uuid: "ipsum"
+ }, [0], 1, true)).toEqual([{
+ id: "lorem",
+ uuid: "ipsum",
+ sublayers: [{
+ id: "amet",
+ uuid: "consectetur"
+ }, {
+ id: "dolor",
+ uuid: "sit"
+ }]
+ }]);
+ });
+});
+
+
+describe("replaceLayerGroups", () => {
+ it("deals with an empty array of configurations", () => {
+ expect(LayerUtils.replaceLayerGroups([], {})).toEqual([]);
+ });
+ it("deals with a layer without sublayers", () => {
+ expect(LayerUtils.replaceLayerGroups([{
+ name: "lorem"
+ }], {})).toEqual([{
+ name: "lorem"
+ }]);
+ });
+ it("deals with a layer with sublayers", () => {
+ expect(LayerUtils.replaceLayerGroups([{
+ name: "lorem",
+ }], {
+ sublayers: [{
+ name: "ipsum"
+ }]
+ })).toEqual([{
+ name: "lorem",
+ }]);
+ });
+ it("deals with a layer with sublayers", () => {
+ expect(LayerUtils.replaceLayerGroups([{
+ name: "lorem",
+ }], {
+ name: "lorem",
+ sublayers: [{
+ name: "ipsum"
+ }]
+ })).toEqual([{
+ name: "ipsum",
+ }]);
+ });
+ it("makes the world a better place", () => {
+ expect(LayerUtils.replaceLayerGroups([{
+ name: "lorem",
+ params: "ipsum",
+ }], {
+ name: "lorem",
+ sublayers: [{
+ name: "consectetur"
+ }, {
+ name: "adipiscing"
+ }]
+ })).toEqual([{
+ name: "consectetur",
+ params: "ipsum",
+ }, {
+ name: "adipiscing",
+ params: "ipsum",
+ }]);
+ });
+ it("makes the world a better place again", () => {
+ expect(LayerUtils.replaceLayerGroups([{
+ name: "lorem",
+ params: "ipsum",
+ }], {
+ name: "lorem",
+ sublayers: [{
+ name: "consectetur",
+ sublayers: [{
+ name: "sed"
+ }, {
+ name: "eiusmod"
+ }]
+ }, {
+ name: "adipiscing",
+ sublayers: [{
+ name: "tempor"
+ }, {
+ name: "incididunt"
+ }]
+ }]
+ })).toEqual([{
+ name: "sed",
+ params: "ipsum",
+ }, {
+ name: "eiusmod",
+ params: "ipsum",
+ }, {
+ name: "tempor",
+ params: "ipsum",
+ }, {
+ name: "incididunt",
+ params: "ipsum",
+ }]);
+ });
+});
+
+
+describe("restoreLayerParams", () => {
+ it("should create a top level layer", () => {
+ expect(LayerUtils.restoreLayerParams({
+ id: "lorem",
+ uuid: "ipsum",
+ }, [], [], {})).toEqual([{
+ id: "lorem",
+ uuid: "ipsum",
+ visibility: false
+ }]);
+ });
+ it("should use theme layer configuration", () => {
+ expect(LayerUtils.restoreLayerParams({
+ id: "lorem",
+ uuid: "ipsum",
+ name: "dolor",
+ }, [{
+ name: "dolor",
+ type: "theme",
+ opacity: 122,
+ tristate: true,
+ }], [], {})).toEqual([{
+ id: "lorem",
+ uuid: "ipsum",
+ visibility: true,
+ name: "dolor",
+ opacity: 122
+ }]);
+ });
+ it("should add external layers from config", () => {
+ const externalLayers = {};
+ expect(LayerUtils.restoreLayerParams({
+ id: "lorem",
+ uuid: "ipsum",
+ }, [{
+ name: "dolor",
+ id: "sit",
+ type: "wms",
+ url: "amet",
+ opacity: 122,
+ visibility: true,
+ params: {
+ LAYERS: "consectetur",
+ }
+ }], [], externalLayers)).toEqual([{
+ id: "sit",
+ loading: true,
+ name: "dolor",
+ role: LayerRole.USERLAYER,
+ title: "dolor",
+ type: "placeholder",
+ uuid: expect.stringMatching(uuidRegex),
+ }, {
+ id: "lorem",
+ uuid: "ipsum",
+ visibility: false
+ }]);
+ expect(externalLayers).toEqual({
+ "wms:amet": [{
+ id: "sit",
+ name: "dolor",
+ opacity: 122,
+ params: {
+ LAYERS: "consectetur"
+ },
+ visibility: true
+ }]
+ });
+ });
+ it("should add permalink layers", () => {
+ const externalLayers = {};
+ expect(LayerUtils.restoreLayerParams({
+ id: "lorem",
+ uuid: "ipsum",
+ }, [], [{
+ id: "sit",
+ uuid: "amet",
+ role: LayerRole.USERLAYER,
+ type: "vector",
+ pos: 0
+ }], {})).toEqual([{
+ id: "sit",
+ uuid: "amet",
+ role: LayerRole.USERLAYER,
+ type: "vector",
+ }, {
+ id: "lorem",
+ uuid: "ipsum",
+ visibility: false
+ }]);
+ expect(externalLayers).toEqual({});
+ });
+});
+
+
+describe("restoreOrderedLayerParams", () => {
+ it("should return an empty array for no configs", () => {
+ expect(
+ LayerUtils.restoreOrderedLayerParams([{
+ id: "lorem",
+ }, {}], [], [])
+ ).toEqual([]);
+ });
+ it("should work with a theme layer", () => {
+ const themeLayer = {
+ name: "lorem"
+ };
+ const layerConfigs = [{
+ type: "theme",
+ name: "lorem",
+ opacity: 127,
+ visibility: true
+ }];
+ const permalinkLayers = [];
+ const externalLayers = [];
+ expect(
+ LayerUtils.restoreOrderedLayerParams(
+ themeLayer, layerConfigs, permalinkLayers,
+ externalLayers
+ )
+ ).toEqual([{
+ name: "lorem",
+ opacity: 127,
+ visibility: true,
+ uuid: expect.stringMatching(uuidRegex),
+ }]);
+ });
+ it("should work with a separator layer", () => {
+ const themeLayer = {};
+ const layerConfigs = [{
+ type: "separator",
+ name: "lorem",
+ }];
+ const permalinkLayers = [];
+ const externalLayers = [];
+ expect(
+ LayerUtils.restoreOrderedLayerParams(
+ themeLayer, layerConfigs, permalinkLayers,
+ externalLayers
+ )
+ ).toEqual([{
+ title: "lorem",
+ type: "separator",
+ role: LayerRole.USERLAYER,
+ uuid: expect.stringMatching(uuidRegex),
+ id: expect.stringMatching(uuidRegex),
+ }]);
+ });
+ it("should work with a external layer", () => {
+ const themeLayer = {};
+ const layerConfigs = [{
+ id: "ipsum",
+ type: "other",
+ url: "url",
+ name: "lorem",
+ }];
+ const permalinkLayers = [];
+ const externalLayers = [];
+ expect(
+ LayerUtils.restoreOrderedLayerParams(
+ themeLayer, layerConfigs, permalinkLayers,
+ externalLayers
+ )
+ ).toEqual([{
+ id: "ipsum",
+ title: "lorem",
+ type: "placeholder",
+ loading: true,
+ name: "lorem",
+ role: LayerRole.USERLAYER,
+ uuid: expect.stringMatching(uuidRegex),
+ }]);
+ });
+ it("should insert permalink layers", () => {
+ const themeLayer = {};
+ const layerConfigs = [{
+ id: "ipsum",
+ type: "other",
+ url: "url",
+ name: "lorem",
+ }];
+ const permalinkLayers = [{
+ id: "sit",
+ uuid: "amet",
+ role: LayerRole.USERLAYER,
+ type: "vector",
+ pos: 0
+ }];
+ const externalLayers = [];
+ expect(
+ LayerUtils.restoreOrderedLayerParams(
+ themeLayer, layerConfigs, permalinkLayers,
+ externalLayers
+ )
+ ).toEqual([{
+ id: "sit",
+ uuid: "amet",
+ role: LayerRole.USERLAYER,
+ type: "vector",
+ }, {
+ id: "ipsum",
+ title: "lorem",
+ type: "placeholder",
+ loading: true,
+ name: "lorem",
+ role: LayerRole.USERLAYER,
+ uuid: expect.stringMatching(uuidRegex),
+ }]);
+ });
+});
+
+
+describe("searchLayer", () => {
+ it("should look into layer's attributes", () => {
+ const layers = [{
+ name: "lorem",
+ role: LayerRole.BACKGROUND
+ }, {
+ name: "ipsum",
+ role: LayerRole.BACKGROUND
+ }, {
+ name: "dolor",
+ role: LayerRole.BACKGROUND
+ }];
+ expect(
+ LayerUtils.searchLayer(layers, "name", "lorem")
+ ).toBeNull();
+ expect(
+ LayerUtils.searchLayer(layers, "name", "xyz")
+ ).toBeNull();
+ expect(
+ LayerUtils.searchLayer(layers, "name", "lorem", [LayerRole.BACKGROUND])
+ ).toEqual({
+ layer: layers[0],
+ sublayer: layers[0]
+ });
+ expect(
+ LayerUtils.searchLayer(layers, "name", "xyz", [LayerRole.BACKGROUND])
+ ).toBeNull();
+ });
+ it("should look into layer's sublayers", () => {
+ const roles = [LayerRole.BACKGROUND];
+ const layers = [{
+ name: "lorem",
+ role: LayerRole.BACKGROUND,
+ sublayers: [{
+ name: "ipsum",
+ role: LayerRole.BACKGROUND
+ }, {
+ name: "dolor",
+ role: LayerRole.BACKGROUND
+ }],
+ }, {
+ name: "sit",
+ role: LayerRole.BACKGROUND,
+ sublayers: [{
+ name: "amet",
+ role: LayerRole.BACKGROUND
+ }, {
+ name: "consectetur",
+ role: LayerRole.BACKGROUND
+ }],
+ }, {
+ name: "adipiscing",
+ role: LayerRole.BACKGROUND,
+ sublayers: [{
+ name: "elit",
+ role: LayerRole.BACKGROUND
+ }, {
+ name: "sed",
+ role: LayerRole.BACKGROUND
+ }],
+ }];
+ expect(
+ LayerUtils.searchLayer(layers, "name", "consectetur", roles)
+ ).toEqual({
+ "layer": {
+ name: "sit",
+ role: 1,
+ sublayers: [
+ {
+ name: "amet",
+ role: 1,
+ },
+ {
+ name: "consectetur",
+ role: 1,
+ },
+ ],
+ },
+ sublayer: {
+ name: "consectetur",
+ role: 1,
+ },
+ });
+ });
+});
+
+
+describe("searchSubLayer", () => {
+ it("should look into layer's attributes", () => {
+ const layer = {
+ name: "lorem"
+ };
+ expect(LayerUtils.searchSubLayer(layer, "name", "lorem")).toEqual(layer);
+ expect(LayerUtils.searchSubLayer(layer, "name", "xyz")).toBeNull();
+ });
+ it("should look into layer's sublayers", () => {
+ const path = [];
+ const layer = {
+ sublayers: [{
+ name: "lorem"
+ }]
+ };
+ expect(
+ LayerUtils.searchSubLayer(layer, "name", "lorem")
+ ).toEqual(layer.sublayers[0]);
+ expect(
+ LayerUtils.searchSubLayer(layer, "name", "xyz")
+ ).toBeNull();
+ });
+ it("should set the path to an empty array for top level layer", () => {
+ const path = [];
+ const layer = {
+ name: "lorem"
+ };
+ LayerUtils.searchSubLayer(layer, "name", "lorem", path);
+ expect(path).toEqual([]);
+ });
+ it("should set the path to an array with the index for sublayers", () => {
+ const path = [];
+ const layer = {
+ sublayers: [{
+ name: "lorem"
+ }]
+ };
+ LayerUtils.searchSubLayer(layer, "name", "lorem", path);
+ expect(path).toEqual([0]);
+ });
+ it("should work with deep nested layers", () => {
+ let path = [];
+ const layer = {
+ sublayers: [{
+ name: "lorem"
+ }, {
+ name: "ipsum",
+ sublayers: [{
+ name: "dolor",
+ sublayers: [{
+ name: "sit"
+ }, {
+ name: "amet",
+ sublayers: [{
+ name: "consectetur"
+ }]
+ }]
+ }, {
+ name: "adipiscing"
+ }]
+ }]
+ };
+ LayerUtils.searchSubLayer(layer, "name", "consectetur", path);
+ expect(path).toEqual([1, 0, 1, 0]);
+
+ path = [];
+ LayerUtils.searchSubLayer(layer, "name", "ipsum", path);
+ expect(path).toEqual([1]);
+
+ path = [];
+ LayerUtils.searchSubLayer(layer, "name", "lorem", path);
+ expect(path).toEqual([0]);
+
+ path = [];
+ LayerUtils.searchSubLayer(layer, "name", "adipiscing", path);
+ expect(path).toEqual([1, 1]);
+ });
+});
+
+
+describe("setGroupVisibilities", () => {
+ it("should accept an empty list", () => {
+ expect(LayerUtils.setGroupVisibilities([])).toBe(false);
+ });
+ it("should work with a single top layer", () => {
+ let layer = {};
+ expect(LayerUtils.setGroupVisibilities([layer])).toBeTruthy();
+
+ layer = { visibility: true };
+ expect(LayerUtils.setGroupVisibilities([layer])).toBeTruthy();
+
+ const parts = [
+ [true, false, true],
+ [false, false, false],
+ [true, true, false],
+ [false, true, false],
+ ];
+ for (let part of parts) {
+ layer = { visibility: part[0], tristate: part[1] };
+ const x = expect(LayerUtils.setGroupVisibilities([layer]));
+ if (part[2]) {
+ x.toBeTruthy();
+ } else {
+ x.toBeFalsy();
+ }
+ expect(layer.tristate).toBeUndefined();
+ }
+
+ layer = { visibility: true, tristate: false };
+ expect(LayerUtils.setGroupVisibilities([layer])).toBeTruthy();
+ expect(layer.tristate).toBeUndefined();
+
+ layer = { visibility: false, tristate: false };
+ expect(LayerUtils.setGroupVisibilities([layer])).toBeFalsy();
+ expect(layer.tristate).toBeUndefined();
+
+ layer = { visibility: false, tristate: true };
+ expect(LayerUtils.setGroupVisibilities([layer])).toBeFalsy();
+ expect(layer.tristate).toBeUndefined();
+
+ layer = { visibility: true, tristate: true };
+ expect(LayerUtils.setGroupVisibilities([layer])).toBeFalsy();
+ expect(layer.tristate).toBeUndefined();
+ });
+ it("should work with a multiple top layer", () => {
+ let layer1 = {};
+ let layer2 = {};
+ expect(LayerUtils.setGroupVisibilities([layer1, layer2])).toBeTruthy();
+
+ layer1 = { visibility: true };
+ layer2 = { visibility: true };
+ expect(LayerUtils.setGroupVisibilities([layer1, layer2])).toBeTruthy();
+
+ layer1 = { visibility: true };
+ layer2 = { visibility: false };
+ expect(LayerUtils.setGroupVisibilities([layer1, layer2])).toBeTruthy();
+
+ // L1V, L1Tr, L2V, L2Tr, result
+ const parts = [
+ [true, true, true, true, false],
+ [true, true, true, false, false],
+ [true, true, false, true, false],
+ [true, true, false, false, false],
+ [true, false, true, true, false],
+ [true, false, true, false, true],
+ [true, false, false, true, false],
+ [true, false, false, false, true],
+ [false, true, true, true, false],
+ [false, true, true, false, false],
+ [false, true, false, true, false],
+ [false, true, false, false, false],
+ [false, false, true, true, false],
+ [false, false, true, false, true],
+ [false, false, false, true, false],
+ [false, false, false, false, false],
+ ];
+ for (let part of parts) {
+ layer1 = { visibility: part[0], tristate: part[1] };
+ layer2 = { visibility: part[2], tristate: part[3] };
+ const x = expect(
+ LayerUtils.setGroupVisibilities([layer1, layer2]),
+ `L1V=${part[0]}, L1Tr=${part[1]}, L2V=${part[2]}, ` +
+ `L2Tr=${part[3]}, result=${part[4]}`
+ );
+ if (part[4]) {
+ x.toBeTruthy();
+ } else {
+ x.toBeFalsy();
+ }
+ expect(layer1.tristate).toBeUndefined();
+ expect(layer2.tristate).toBeUndefined();
+ }
+ });
+});
+
+
+describe("splitLayerUrlParam", () => {
+ it("should return defaults with an empty string", () => {
+ expect(LayerUtils.splitLayerUrlParam("")).toEqual({
+ id: expect.stringMatching(uuidRegex),
+ name: "",
+ opacity: 255,
+ tristate: false,
+ type: "theme",
+ url: null,
+ visibility: true,
+ });
+ });
+ it("should pick up the layer name", () => {
+ expect(LayerUtils.splitLayerUrlParam("lorem")).toEqual({
+ id: expect.stringMatching(uuidRegex),
+ name: "lorem",
+ opacity: 255,
+ tristate: false,
+ type: "theme",
+ url: null,
+ visibility: true,
+ });
+ });
+ it("should read visibility = false", () => {
+ expect(LayerUtils.splitLayerUrlParam("lorem!")).toEqual({
+ id: expect.stringMatching(uuidRegex),
+ name: "lorem",
+ opacity: 255,
+ tristate: false,
+ type: "theme",
+ url: null,
+ visibility: false,
+ });
+ });
+ it("should read tristate", () => {
+ expect(LayerUtils.splitLayerUrlParam("lorem~")).toEqual({
+ id: expect.stringMatching(uuidRegex),
+ name: "lorem",
+ opacity: 255,
+ tristate: true,
+ type: "theme",
+ url: null,
+ visibility: false,
+ });
+ });
+ it("should read opacity", () => {
+ expect(LayerUtils.splitLayerUrlParam("lorem[10]")).toEqual({
+ id: expect.stringMatching(uuidRegex),
+ name: "lorem",
+ opacity: 230,
+ tristate: false,
+ type: "theme",
+ url: null,
+ visibility: true,
+ });
+ });
+ it("should parse type and url", () => {
+ expect(LayerUtils.splitLayerUrlParam("foo:ipsum#lorem")).toEqual({
+ id: expect.stringMatching(uuidRegex),
+ name: "lorem",
+ opacity: 255,
+ tristate: false,
+ type: "foo",
+ url: "ipsum",
+ visibility: true,
+ });
+ });
+ it("should parse a separator", () => {
+ expect(LayerUtils.splitLayerUrlParam("sep:ipsum")).toEqual({
+ id: expect.stringMatching(uuidRegex),
+ name: "ipsum",
+ opacity: 255,
+ tristate: false,
+ type: "separator",
+ url: null,
+ visibility: true,
+ });
+ });
+});
+
+
+describe("sublayerVisible", () => {
+ it("should throw an error if the index is out of bounds", () => {
+ expect(() => { LayerUtils.sublayerVisible({}, [0]) }).toThrow(TypeError);
+ });
+ it("should assume is visible if attribute is missing", () => {
+ expect(LayerUtils.sublayerVisible({
+ sublayers: [{}]
+ }, [0])).toBeTruthy();
+ });
+ it("should return the value of visible attribute", () => {
+ expect(LayerUtils.sublayerVisible({
+ sublayers: [{
+ visibility: true
+ }]
+ }, [0])).toBeTruthy();
+ expect(LayerUtils.sublayerVisible({
+ sublayers: [{
+ visibility: false
+ }]
+ }, [0])).toBeFalsy();
+ });
+ it("should work with deep trees", () => {
+ expect(LayerUtils.sublayerVisible({
+ sublayers: [{
+ visibility: true,
+ sublayers: [{
+ visibility: true,
+ sublayers: [{
+ visibility: true,
+ sublayers: [{
+ visibility: true,
+ sublayers: [{
+ visibility: true,
+ }]
+ }]
+ }]
+ }]
+ }]
+ }, [0, 0, 0, 0, 0])).toBeTruthy();
+ expect(LayerUtils.sublayerVisible({
+ sublayers: [{
+ visibility: true,
+ sublayers: [{
+ visibility: true,
+ sublayers: [{
+ visibility: true,
+ sublayers: [{
+ visibility: true,
+ sublayers: [{
+ visibility: false,
+ }]
+ }]
+ }]
+ }]
+ }]
+ }, [0, 0, 0, 0, 0])).toBeFalsy();
+ expect(LayerUtils.sublayerVisible({
+ sublayers: [{
+ visibility: true,
+ sublayers: [{
+ visibility: false,
+ sublayers: [{
+ visibility: true,
+ sublayers: [{
+ visibility: true,
+ sublayers: [{
+ visibility: true,
+ }]
+ }]
+ }]
+ }]
+ }]
+ }, [0, 0, 0, 0, 0])).toBeFalsy();
+ expect(LayerUtils.sublayerVisible({
+ visibility: false,
+ sublayers: [{
+ visibility: true,
+ sublayers: [{
+ visibility: true,
+ sublayers: [{
+ visibility: true,
+ sublayers: [{
+ visibility: true,
+ sublayers: [{
+ visibility: true,
+ }]
+ }]
+ }]
+ }]
+ }]
+ }, [0, 0, 0, 0, 0])).toBeFalsy();
+ });
+});
diff --git a/utils/LocaleUtils.js b/utils/LocaleUtils.js
index d65fb58a2..9398ba82e 100644
--- a/utils/LocaleUtils.js
+++ b/utils/LocaleUtils.js
@@ -10,35 +10,104 @@
import StandardStore from '../stores/StandardStore';
import ConfigUtils from './ConfigUtils';
+
+/**
+ * Translation and localization.
+ *
+ * The library relies on the `scripts/updateTranslations.js` script to extract
+ * translation keys from the source code.
+ *
+ * @namespace
+ */
const LocaleUtils = {
- tr(key) {
+ /**
+ * Return the translation for the given key.
+ *
+ * The function expects the string templates for current locale to be
+ * present in the Redux store under `state.locale.messages`. If the
+ * template is not found, the key itself is returned.
+ *
+ * If the template contains placeholders, they are replaced by the
+ * corresponding arguments. The placeholders are numbered starting from 0,
+ * so the second argument to this function replaces `{0}`,
+ * the third `{1}` etc.
+ *
+ * @param {string} key - the translation key used to locate the
+ * string template
+ * @param {...string} args - the arguments to the string template
+ *
+ * @return {string} the translated string
+ */
+ tr(key, ...args) {
const state = StandardStore.get().getState();
- const text = key in state.locale.messages ? (state.locale.messages[key] ?? key) : key;
+ const text = key in state.locale.messages
+ ? (state.locale.messages[key] ?? key)
+ : key;
- const args = Array.prototype.slice.call(arguments, 1);
if (args.length > 0) {
return text.replace(/{(\d+)}/g, (match, number) => {
- return typeof args[number] !== 'undefined' ? args[number] : match;
+ return typeof args[number] !== 'undefined'
+ ? args[number]
+ : match;
});
} else {
return text;
}
},
- // Just a stub to make updateTranslations pick up the msgId
+
+ /**
+ * A stub for marking strings as translation keys.
+ *
+ * This function is used by the `scripts/updateTranslations.js` script to
+ * extract translation keys from the source code.
+ *
+ * @param {string} key - the translation key
+ * @return {string} the translation key
+ */
trmsg(key) {
return key;
},
- trWithFallback(key, fallback) {
+
+ /**
+ * Return the translation for the given key, or the fallback if the key is
+ * not found.
+ *
+ * The function expects the string templates for current locale to be
+ * present in the Redux store under `state.locale.messages`. If the
+ * template is not found, the fallback is returned.
+ *
+ * Placeholders are not supported by this function.
+ *
+ * @param {string} key - the translation key used to locate the
+ * string template
+ * @param {string} fallback - the fallback string to return if the key is
+ * not found
+ *
+ * @return {string} the translated string
+ */
+ trWithFallback(key, fallback, ...args) {
const state = StandardStore.get().getState();
return state.locale.messages[key] || fallback;
},
+
+ /**
+ * Return the current locale
+ *
+ * The function expects the current locale to be present in the Redux store
+ * under `state.locale.current`.
+ */
lang() {
const state = StandardStore.get().getState();
return state.locale.current;
},
+
toLocaleFixed(number, digits) {
if (ConfigUtils.getConfigProp("localeAwareNumbers")) {
- return number.toLocaleString(LocaleUtils.lang(), { minimumFractionDigits: digits, maximumFractionDigits: digits });
+ return number.toLocaleString(
+ LocaleUtils.lang(), {
+ minimumFractionDigits: digits,
+ maximumFractionDigits: digits
+ });
} else {
return number.toFixed(digits);
}
diff --git a/utils/LocaleUtils.test.js b/utils/LocaleUtils.test.js
new file mode 100644
index 000000000..329521a59
--- /dev/null
+++ b/utils/LocaleUtils.test.js
@@ -0,0 +1,115 @@
+import LocaleUtils from './LocaleUtils';
+
+let mockGetConfigPropValue = false;
+
+jest.mock("./ConfigUtils", () => {
+ return {
+ __esModule: true,
+ default: {
+ getConfigProp: () => mockGetConfigPropValue,
+ },
+ }
+});
+
+jest.mock('../stores/StandardStore', () => ({
+ get: jest.fn(() => ({
+ getState: jest.fn(() => ({
+ locale: {
+ messages: {
+ 'test.key': 'test value',
+ 'test.key3': '{0} {1}',
+ 'test.key4': undefined,
+ 'test.key5': null,
+ 'test.key6': "",
+ },
+ current: 'some-locale',
+ }
+ })),
+ })),
+}));
+
+
+afterEach(() => {
+ jest.resetModules();
+});
+
+
+describe("tr", () => {
+ it("should return the template string for a key", () => {
+ expect(LocaleUtils.tr('test.key')).toEqual('test value');
+ });
+ it("should return the key if the template is not found", () => {
+ expect(LocaleUtils.tr('test.key2')).toEqual('test.key2');
+ });
+ it("should replace placeholders with arguments", () => {
+ expect(
+ LocaleUtils.tr('test.key3', 'arg1', 'arg2')
+ ).toEqual('arg1 arg2');
+ });
+ it("should print the placeholder if not provided", () => {
+ expect(LocaleUtils.tr('test.key3', 'arg1')).toEqual('arg1 {1}');
+ expect(
+ LocaleUtils.tr('test.key3', undefined, undefined)
+ ).toEqual('{0} {1}');
+ });
+ it("should return the key if the string is empty", () => {
+ expect(LocaleUtils.tr('test.key4')).toEqual('test.key4');
+ expect(LocaleUtils.tr('test.key5')).toEqual('test.key5');
+ });
+ it("should return the empty string if that is the value", () => {
+ expect(LocaleUtils.tr('test.key6')).toEqual('');
+ });
+});
+
+
+describe("trmsg", () => {
+ it("should return the argument unchanged", () => {
+ expect(LocaleUtils.trmsg('test.key')).toEqual('test.key');
+ });
+});
+
+
+describe("trWithFallback", () => {
+ it("should return the template string for a key", () => {
+ expect(
+ LocaleUtils.trWithFallback('test.key', 'Fallback')
+ ).toEqual('test value');
+ });
+ it("should return the fallback if the template is not found", () => {
+ expect(
+ LocaleUtils.trWithFallback('test.key2', 'Fallback')
+ ).toEqual('Fallback');
+ });
+});
+
+
+describe("lang", () => {
+ it("should return the current language", () => {
+ expect(LocaleUtils.lang()).toEqual('some-locale');
+ });
+});
+
+
+describe("toLocaleFixed", () => {
+ describe("with localeAwareNumbers", () => {
+ const value = {
+ toLocaleString: jest.fn()
+ };
+ it("should call toLocaleString with the correct arguments", () => {
+ mockGetConfigPropValue = true;
+ LocaleUtils.toLocaleFixed(value, 5);
+ expect(value.toLocaleString).toHaveBeenCalledWith('some-locale', {
+ minimumFractionDigits: 5,
+ maximumFractionDigits: 5,
+ });
+ });
+ });
+ describe("without localeAwareNumbers", () => {
+ it("should call toLocaleString with the correct arguments", () => {
+ mockGetConfigPropValue = false;
+ expect(
+ LocaleUtils.toLocaleFixed(1.2345678, 5)
+ ).toBe("1.23457");
+ });
+ });
+});
diff --git a/utils/MapUtils.js b/utils/MapUtils.js
index c8c1e5880..0ab295fcd 100644
--- a/utils/MapUtils.js
+++ b/utils/MapUtils.js
@@ -6,54 +6,119 @@
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree.
*/
-
import ConfigUtils from './ConfigUtils';
import CoordinatesUtils from './CoordinatesUtils';
const DEFAULT_SCREEN_DPI = 96;
+
+// TODO: any reason ro not using the ol defaults?
+// ol/proj/Units.js -> METERS_PER_UNIT
const METERS_PER_UNIT = {
'm': 1,
'degrees': 111194.87428468118,
'ft': 0.3048,
- 'us-ft': 1200 / 3937
+ 'us-ft': 1200 / 3937 // 0.3048006096
};
const hooks = {};
+
+/**
+ * Utility functions for working with the map.
+ *
+ * @namespace
+ */
const MapUtils = {
GET_PIXEL_FROM_COORDINATES_HOOK: 'GET_PIXEL_FROM_COORDINATES_HOOK',
GET_COORDINATES_FROM_PIXEL_HOOK: 'GET_COORDINATES_FROM_PIXEL_HOOK',
GET_NATIVE_LAYER: 'GET_NATIVE_LAYER',
+ /**
+ * Save a hook in internal registry (library-wide)
+ *
+ * This mechanism imposes no constraints on the hook function. See
+ * the documentation for the specific hook key to understand the
+ * expected function signature.
+ *
+ * @param {string} name - the unique identifier of the hook
+ * @param {function} hook - the hook function
+ */
registerHook(name, hook) {
hooks[name] = hook;
},
+
+ /**
+ * Retrieve a hook from internal registry (library-wide).
+ *
+ * This mechanism imposes no constraints on the hook function. See
+ * the documentation for the specific hook key to understand the
+ * expected function signature.
+ *
+ * @param {string} name - the unique identifier of the hook
+ * @return {function} the hook function or undefined if
+ * the identifier was not found
+ */
getHook(name) {
return hooks[name];
},
+
/**
- * @param dpi {number} dot per inch resolution
- * @return {number} dot per meter resolution
+ * Convert a resolution expreseed in dots per inch to a resolution
+ * expressed in dots per meter.
+ *
+ * @param {number?} dpi - dot per inch resolution (default is 96).
+ * @return {number} dot per meter resolution.
*/
- dpi2dpm(dpi) {
+ dpi2dpm(dpi = null) {
return (dpi || DEFAULT_SCREEN_DPI) * (100 / 2.54);
},
+
/**
- * @param dpi {number} screen resolution in dots per inch.
- * @param projection {string} map projection.
+ * Convert a resolution expreseed in dots per meter to a resolution
+ * expressed in dots per units in a particular projection.
+ *
+ * The function supports projections with following units:
+ * - meters;
+ * - degrees (default if the projection does not define units);
+ * - feet;
+ * - us-feet.
+ *
+ * @param {number} dpi - screen resolution in dots per
+ * inch (default is 96).
+ * @param {string} projection - the projection used to retrieve
+ * the units.
+ *
+ * @throws {Error} if the projection units are not supported.
+ * @throws {Error} if the projection is not supported.
* @return {number} dots per map unit.
*/
dpi2dpu(dpi, projection) {
const units = CoordinatesUtils.getUnits(projection);
- return METERS_PER_UNIT[units] * MapUtils.dpi2dpm(dpi);
+ const mpu = METERS_PER_UNIT[units];
+ if (!mpu) {
+ throw new Error(
+ `Unsupported projection ${projection} units: ${units}`
+ );
+ }
+ return mpu * MapUtils.dpi2dpm(dpi);
},
+
/**
- * Get a list of scales for each zoom level of the Google Mercator.
- * @param minZoom {number} min zoom level.
- * @param maxZoom {number} max zoom level.
- * @return {array} a list of scale for each zoom level in the given interval.
+ * Get a list of scales, one for each zoom level
+ * of the Google Mercator.
+ *
+ * @param {number} minZoom - the first zoom level to compute
+ * the scale for (integer >= 0).
+ * @param {number} maxZoom - the last zoom level to compute
+ * the scale for (integer).
+ * @param {number} dpi - screen resolution in dots per
+ * inch (96 by default).
+ *
+ * @return {number[]} a list of scales, one for each zoom level
+ * in the given interval (the lower the zoom level, the
+ * larger the scale).
*/
getGoogleMercatorScales(minZoom, maxZoom, dpi = DEFAULT_SCREEN_DPI) {
// Google mercator params
@@ -61,35 +126,51 @@ const MapUtils = {
const TILE_WIDTH = 256;
const ZOOM_FACTOR = 2;
+ const dpm = MapUtils.dpi2dpm(dpi);
+ const twoPiRad = 2 * Math.PI * RADIUS;
const retval = [];
for (let l = minZoom; l <= maxZoom; l++) {
- retval.push(2 * Math.PI * RADIUS / (TILE_WIDTH * Math.pow(ZOOM_FACTOR, l) / MapUtils.dpi2dpm(dpi)));
+ retval.push(
+ twoPiRad / (
+ TILE_WIDTH * Math.pow(ZOOM_FACTOR, l) / dpm
+ )
+ );
}
return retval;
},
+
/**
- * @param scales {array} list of scales.
- * @param projection {string} map projection.
- * @param dpi {number} screen resolution in dots per inch.
- * @return {array} a list of resolutions corresponding to the given scales, projection and dpi.
+ * Compute resolution for scale.
+ *
+ * @param {number[]} scales - the list of scales.
+ * @param {string} projection - the map projection.
+ * @param {number} dpi - the screen resolution in dots per
+ * inch (96 by default).
+ *
+ * @return {number[]} a list of resolutions corresponding to
+ * the given scales, projection and dpi.
*/
getResolutionsForScales(scales, projection, dpi = DEFAULT_SCREEN_DPI) {
const dpu = MapUtils.dpi2dpu(dpi, projection);
- const resolutions = scales.map((scale) => {
- return scale / dpu;
- });
- return resolutions;
+ return scales.map((scale) => scale / dpu);
},
+
/**
* Calculates the best fitting zoom level for the given extent.
*
- * @param extent {Array} [minx, miny, maxx, maxy]
- * @param resolutions {Array} The list of available map resolutions
- * @param mapSize {Object} current size of the map.
- * @param minZoom {number} min zoom level.
- * @param maxZoom {number} max zoom level.
- * @param dpi {number} screen resolution in dot per inch.
- * @return {Number} the zoom level fitting th extent
+ * Depending on the `allowFractionalZoom` configuration, the returned
+ * zoom level can be fractional (a real number) or it will be one that
+ * matches one of the `resolutions` (an integer).
+ *
+ * @param {[number, number, number, number]} extent - the
+ * bounding box as an array of `[minx, miny, maxx, maxy]`.
+ * @param {number[]} resolutions - the list of available map resolutions.
+ * @param {{width: number, height: number}} mapSize - current size
+ * of the map in pixels.
+ * @param {number} minZoom - minimum allowed zoom level (integer >= 0).
+ * @param {number} maxZoom - maximum allowed zoom level (integer).
+ *
+ * @return {number} the zoom level fitting the extent
*/
getZoomForExtent(extent, resolutions, mapSize, minZoom, maxZoom) {
const wExtent = extent[2] - extent[0];
@@ -100,28 +181,50 @@ const MapUtils = {
const extentResolution = Math.max(xResolution, yResolution);
if (ConfigUtils.getConfigProp("allowFractionalZoom") === true) {
- return Math.max(minZoom, Math.min(this.computeZoom(resolutions, extentResolution), maxZoom));
+ return Math.max(
+ minZoom,
+ Math.min(
+ this.computeZoom(resolutions, extentResolution),
+ maxZoom
+ )
+ );
} else {
const calc = resolutions.reduce((previous, resolution, index) => {
const diff = Math.abs(resolution - extentResolution);
- return diff > previous.diff ? previous : {diff: diff, zoom: index};
- }, {diff: Number.POSITIVE_INFINITY, zoom: 0});
+ return diff > previous.diff
+ ? previous
+ : { diff: diff, zoom: index };
+ }, {
+ diff: Number.POSITIVE_INFINITY,
+ zoom: 0
+ });
return Math.max(minZoom, Math.min(calc.zoom, maxZoom));
}
},
+
/**
- * Calculates the extent for the provided center and zoom level
- * @param center {Array} [x, y]
- * @param zoom {number} The zoom level
- * @param resolutions {Array} The list of map resolutions
- * @param mapSize {Object} The current size of the map
+ * Calculates the extent in map units for the provided
+ * center and zoom level.
+ *
+ * @param {[number, number]} center - the position of the center as an
+ * `[x, y]` array in map units.
+ * @param {number} zoom - the (potentially fractional) zoom level. If
+ * `allowFractionalZoom` library configuration is false, the zoom
+ * level will be rounded to the nearest integer.
+ * @param {number[]} resolutions - the list of map resolutions.
+ * @param {{width: number, height: number}} mapSize - current size
+ * of the map in pixels.
+ *
+ * @return {[number, number, number, number]} the bounding box as an
+ * array of `[minx, miny, maxx, maxy]` coordinates in map units.
*/
getExtentForCenterAndZoom(center, zoom, resolutions, mapSize) {
if (ConfigUtils.getConfigProp("allowFractionalZoom") !== true) {
zoom = Math.round(zoom);
}
- const width = this.computeForZoom(resolutions, zoom) * mapSize.width;
- const height = this.computeForZoom(resolutions, zoom) * mapSize.height;
+ const res = this.computeForZoom(resolutions, zoom);
+ const width = res * mapSize.width;
+ const height = res * mapSize.height;
return [
center[0] - 0.5 * width,
center[1] - 0.5 * height,
@@ -129,35 +232,66 @@ const MapUtils = {
center[1] + 0.5 * height
];
},
+
/**
- * Transform width and height specified in meters to the units of the specified projection
+ * Transform width and height specified in meters to the units
+ * of the specified projection
*
- * @param projection {string} projection.
- * @param center {Array} Center of extent in EPSG:4326 coordinates.
- * @param width {number} Width in meters.
- * @param height {number} Height in meters.
+ * The function supports projections with following units:
+ * - meters;
+ * - degrees (default if the projection does not define units);
+ * - feet;
+ * - us-feet.
+ *
+ * @param {string} projection - the proj4 identifier of the projection.
+ * @param {[number, number]} center - center of extent in
+ * `EPSG:4326` (WGS 84) coordinates (degrees).
+ * @param {number} width - the width of the extent in meters.
+ * @param {number} height - the height of the extent in meters.
+ *
+ * @throws {Error} if the projection is not supported.
+ * @return {{width: number, height: number}} the width and height
+ * in the units of the specified projection.
*/
transformExtent(projection, center, width, height) {
const units = CoordinatesUtils.getUnits(projection);
if (units === 'ft') {
- return {width: width / METERS_PER_UNIT.ft, height: height / METERS_PER_UNIT.ft};
+ return {
+ width: width / METERS_PER_UNIT.ft,
+ height: height / METERS_PER_UNIT.ft
+ };
} else if (units === 'us-ft') {
- return {width: width / METERS_PER_UNIT['us-ft'], height: height / METERS_PER_UNIT['us-ft']};
+ return {
+ width: width / METERS_PER_UNIT['us-ft'],
+ height: height / METERS_PER_UNIT['us-ft']
+ };
} else if (units === 'degrees') {
// https://en.wikipedia.org/wiki/Geographic_coordinate_system#Length_of_a_degree
const phi = center[1] / 180 * Math.PI;
- const latPerM = 111132.92 - 559.82 * Math.cos(2 * phi) + 1.175 * Math.cos(4 * phi) - 0.0023 * Math.cos(6 * phi);
- const lonPerM = 111412.84 * Math.cos(phi) - 93.5 * Math.cos(3 * phi) + 0.118 * Math.cos(5 * phi);
- return {width: width / lonPerM, height: height / latPerM};
+ const latPerM = (
+ 111132.92 -
+ 559.82 * Math.cos(2 * phi) +
+ 1.175 * Math.cos(4 * phi) -
+ 0.0023 * Math.cos(6 * phi)
+ );
+ const lonPerM = (
+ 111412.84 * Math.cos(phi) -
+ 93.5 * Math.cos(3 * phi) +
+ 0.118 * Math.cos(5 * phi)
+ );
+ return { width: width / lonPerM, height: height / latPerM };
}
- return {width, height};
+ return { width, height };
},
+
/**
- * Compute the scale or resolution matching a (possibly fractional) zoom level.
+ * Compute the scale or resolution matching a(possibly fractional)
+ * zoom level.
*
- * @param list {Array} List of scales or resolutions.
- * @param zoomLevel (number) Zoom level (integer or fractional).
- * @return Scale of resolution matching zoomLevel
+ * @param {number[]} list - The list of scales or resolutions.
+ * @param {number} zoomLevel - The zoom level (integer or fractional).
+ *
+ * @return Scale of resolution matching `zoomLevel`
*/
computeForZoom(list, zoomLevel) {
if (ConfigUtils.getConfigProp("allowFractionalZoom") !== true) {
@@ -172,11 +306,14 @@ const MapUtils = {
const frac = zoomLevel - lower;
return list[lower] * (1 - frac) + list[upper] * frac;
},
+
/**
- * Compute the (possibly fractional) zoom level matching the specified scale or resolution.
+ * Compute the (possibly fractional) zoom level matching the
+ * specified scale or resolution.
*
- * @param list {Array} List of scales or resolutions.
- * @param value (number) Scale or resolution.
+ * @param {number[]} list - The list of scales or resolutions.
+ * @param {number} value - The scale or resolution.
+ *
* @return Zoom level matching the specified scale or resolution.
*/
computeZoom(list, value) {
@@ -204,7 +341,8 @@ const MapUtils = {
/**
* Convert degrees to radians
- * @param degrees {number}
+ *
+ * @param {number} degrees - the value to convert
* @return {number} in radians
*/
degreesToRadians(degrees) {
diff --git a/utils/MapUtils.test.js b/utils/MapUtils.test.js
new file mode 100644
index 000000000..10b4d90a9
--- /dev/null
+++ b/utils/MapUtils.test.js
@@ -0,0 +1,465 @@
+import MapUtils from './MapUtils';
+import { feetCRS } from '../config/setupTestsAfterEnv';
+
+let mockAllowFractionalZoom = false;
+
+jest.mock("./ConfigUtils", () => {
+ return {
+ __esModule: true,
+ default: {
+ getConfigProp: (key) => {
+ if (key === "allowFractionalZoom") {
+ return mockAllowFractionalZoom;
+ }
+ throw new Error("Unknown key");
+ },
+ },
+ }
+});
+
+let mockProjectionUnits = undefined;
+
+jest.mock("./CoordinatesUtils", () => {
+ return {
+ __esModule: true,
+ default: {
+ getUnits: (projection) => {
+ if (projection === "EPSG:4326") {
+ return "degrees";
+ } else if (projection === "EPSG:3857") {
+ return "m";
+ } else if (projection === "EPSG:2225" /* feetCRS */) {
+ return "us-ft";
+ } else {
+ return mockProjectionUnits;
+ }
+ },
+ },
+ }
+});
+
+
+describe("registerHook", () => {
+ it("should register the hook", () => {
+ const hook = () => { };
+ MapUtils.registerHook("test", hook);
+ expect(MapUtils.getHook("test")).toBe(hook);
+ const hook2 = () => { };
+ MapUtils.registerHook("test", hook2);
+ expect(MapUtils.getHook("test")).toBe(hook2);
+ })
+});
+
+
+describe("getHook", () => {
+ it("should return undefined if the name is not known", () => {
+ expect(MapUtils.getHook("test-unknown")).toBeUndefined();
+ })
+});
+
+
+describe("dpi2dpm", () => {
+ it("should convert", () => {
+ expect(MapUtils.dpi2dpm(1)).toBeCloseTo(39.3700, 3);
+ expect(MapUtils.dpi2dpm(-1)).toBeCloseTo(-39.3700, 3);
+ expect(MapUtils.dpi2dpm(72)).toBeCloseTo(2834.6457, 3);
+ });
+ it("should use default DPI", () => {
+ expect(MapUtils.dpi2dpm()).toBeCloseTo(3779.5275, 3);
+ expect(MapUtils.dpi2dpm(undefined)).toBeCloseTo(3779.5275, 3);
+ expect(MapUtils.dpi2dpm(null)).toBeCloseTo(3779.5275, 3);
+ expect(MapUtils.dpi2dpm(0)).toBeCloseTo(3779.5275, 3);
+ });
+});
+
+
+describe("dpi2dpu", () => {
+ it("should convert to Pseudo-Mercator map", () => {
+ expect(
+ MapUtils.dpi2dpu(1, "EPSG:3857")
+ ).toBeCloseTo(39.3700, 3);
+ });
+ it("should convert to WGS map", () => {
+ expect(
+ MapUtils.dpi2dpu(1, "EPSG:4326")
+ ).toBeCloseTo(4377750.9560, 3);
+ });
+ it(`should convert to ${feetCRS} map`, () => {
+ expect(
+ MapUtils.dpi2dpu(1, feetCRS)
+ ).toBeCloseTo(12.000, 3);
+ });
+});
+
+
+describe("getGoogleMercatorScales", () => {
+ it("should compute the scales with default DPI", () => {
+ expect(MapUtils.getGoogleMercatorScales(
+ 0, 1
+ )).toBeDeepCloseTo([
+ 591658710.90, 295829355.45
+ ], 1);
+ expect(MapUtils.getGoogleMercatorScales(
+ 5, 10
+ )).toBeDeepCloseTo([
+ 18489334.71,
+ 9244667.35,
+ 4622333.67,
+ 2311166.83,
+ 1155583.42,
+ 577791.71,
+ ], 1);
+ });
+ it("should compute the scales with DPI = 72", () => {
+ expect(MapUtils.getGoogleMercatorScales(
+ 0, 1, 72
+ )).toBeDeepCloseTo([
+ 443744033.18, 221872016.59
+ ], 1);
+ });
+});
+
+
+describe("getResolutionsForScales", () => {
+ it("should get the resolutions for default DPI", () => {
+ expect(
+ MapUtils.getResolutionsForScales([
+ 18489334.71,
+ 9244667.35,
+ 4622333.67,
+ 2311166.83,
+ 1155583.42,
+ 577791.71,
+ 1,
+ 0
+ ], "EPSG:3857")
+ ).toBeDeepCloseTo([
+ 4891.96,
+ 2445.98,
+ 1222.99,
+ 611.49,
+ 305.75,
+ 152.87,
+ 0.00,
+ 0.00
+ ], 1)
+ });
+ it("should get the resolutions for DPI = 72", () => {
+ expect(
+ MapUtils.getResolutionsForScales([
+ 18489334.71,
+ 9244667.35,
+ 4622333.67,
+ 2311166.83,
+ 1155583.42,
+ 577791.71,
+ 1,
+ 0
+ ], "EPSG:3857", 72)
+ ).toBeDeepCloseTo([
+ 6522.62,
+ 3261.31,
+ 1630.65,
+ 815.32,
+ 407.66,
+ 203.83,
+ 0.00,
+ 0.00,
+ ], 1)
+ });
+});
+
+
+describe("getZoomForExtent", () => {
+ const commonArgs = [
+ [-180, -90, 180, 90],
+ [
+ 4891.96,
+ 2445.98,
+ 1222.99,
+ 611.49,
+ 305.75,
+ 152.87,
+ ],
+ { width: 256, height: 256 },
+ ];
+ describe("fractional zoom", () => {
+ beforeEach(() => { mockAllowFractionalZoom = true; });
+ it("should compute zoom withing allowed range", () => {
+ expect(MapUtils.getZoomForExtent(
+ ...commonArgs, 0, 21
+ )).toBeCloseTo(5.990, 2)
+ });
+ it("should use minimum value", () => {
+ expect(MapUtils.getZoomForExtent(
+ ...commonArgs, 7, 21
+ )).toBe(7)
+ });
+ it("should use maximum value", () => {
+ expect(MapUtils.getZoomForExtent(
+ ...commonArgs, 0, 3
+ )).toBe(3)
+ });
+ });
+ describe("integer zoom", () => {
+ beforeEach(() => { mockAllowFractionalZoom = false; });
+ it("should compute zoom withing allowed range", () => {
+ expect(MapUtils.getZoomForExtent(
+ ...commonArgs, 0, 21
+ )).toBe(5)
+ });
+ it("should use minimum value", () => {
+ expect(MapUtils.getZoomForExtent(
+ ...commonArgs, 7, 21
+ )).toBe(7)
+ });
+ it("should use maximum value", () => {
+ expect(MapUtils.getZoomForExtent(
+ ...commonArgs, 0, 3
+ )).toBe(3)
+ });
+ });
+});
+
+
+describe("getExtentForCenterAndZoom", () => {
+ const resSize = [
+ [
+ 4891.96,
+ 2445.98,
+ 1222.99,
+ 611.49,
+ 305.75,
+ 152.87,
+ ], {
+ width: 256, height: 256
+ }
+ ]
+ describe("fractional zoom", () => {
+ beforeEach(() => { mockAllowFractionalZoom = true; });
+ it("should do a good job", () => {
+ expect(MapUtils.getExtentForCenterAndZoom(
+ [0, 0], 0.12, ...resSize,
+ )).toBeDeepCloseTo([
+ -588600.62, -588600.62,
+ 588600.62, 588600.62
+ ], 1);
+ expect(MapUtils.getExtentForCenterAndZoom(
+ [1, 1], 0.12, ...resSize,
+ )).toBeDeepCloseTo([
+ -588599.62, -588599.62,
+ 588601.62, 588601.62
+ ], 1);
+ expect(MapUtils.getExtentForCenterAndZoom(
+ [1, 1], 10.12, ...resSize,
+ )).toBeDeepCloseTo([
+ -19566.36, -19566.36,
+ 19568.36, 19568.36
+ ], 1);
+ });
+ });
+ describe("integer zoom", () => {
+ beforeEach(() => { mockAllowFractionalZoom = false; });
+ it("should do a good job", () => {
+ expect(MapUtils.getExtentForCenterAndZoom(
+ [0, 0], 0.12, ...resSize,
+ )).toBeDeepCloseTo([
+ -626170.88, -626170.88,
+ 626170.88, 626170.88
+ ], 1);
+ expect(MapUtils.getExtentForCenterAndZoom(
+ [1, 1], 0.12, ...resSize,
+ )).toBeDeepCloseTo([
+ -626169.88, -626169.88,
+ 626171.88, 626171.88
+ ], 1);
+ expect(MapUtils.getExtentForCenterAndZoom(
+ [1, 1], 10.12, ...resSize,
+ )).toBeDeepCloseTo([
+ -19566.36, -19566.36,
+ 19568.36, 19568.36
+ ], 1);
+ });
+ });
+});
+
+
+describe("transformExtent", () => {
+ // NOTE: in these tests we use undefined as the projection
+ // so that our mock implementation of getUnits is used
+ // and picks up the mockProjectionUnits value.
+ const projection = undefined;
+
+ describe("feets", () => {
+ beforeEach(() => { mockProjectionUnits = "ft"; });
+ it("should transform the extent", () => {
+ expect(MapUtils.transformExtent(
+ projection, [0, 0], 100, 100
+ )).toBeDeepCloseTo({
+ width: 328.084,
+ height: 328.084
+ }, 2);
+ });
+ });
+ describe("us-feets", () => {
+ beforeEach(() => { mockProjectionUnits = "us-ft"; });
+ it("should transform the extent", () => {
+ expect(MapUtils.transformExtent(
+ projection, [0, 0], 100, 100
+ )).toBeDeepCloseTo({
+ width: 328.084,
+ height: 328.084
+ }, 2);
+ });
+ });
+ describe("meters", () => {
+ beforeEach(() => { mockProjectionUnits = "m"; });
+ it("should transform the extent", () => {
+ expect(MapUtils.transformExtent(
+ projection, [0, 0], 100, 100
+ )).toEqual({
+ width: 100,
+ height: 100
+ });
+ });
+ });
+ describe("degrees", () => {
+ beforeEach(() => { mockProjectionUnits = "degrees"; });
+ it("should transform the extent", () => {
+ expect(MapUtils.transformExtent(
+ projection, [0, 0], 100, 100
+ )).toBeDeepCloseTo({
+ width: 0.00089831,
+ height: 0.00090436
+ }, 7);
+ });
+ });
+});
+
+
+describe("computeForZoom", () => {
+ const scaleLists = [
+ 4891.96,
+ 2445.98,
+ 1222.99,
+ 611.49,
+ 305.75,
+ 152.87,
+ ]
+
+ describe("fractional zoom", () => {
+ beforeEach(() => { mockAllowFractionalZoom = true; });
+
+ it("computes the value", () => {
+ expect(
+ MapUtils.computeForZoom(scaleLists, 0.12)
+ ).toBeCloseTo(4598.4424, 3);
+ expect(
+ MapUtils.computeForZoom(scaleLists, 3.14)
+ ).toBeCloseTo(568.6864, 3);
+ expect(
+ MapUtils.computeForZoom(scaleLists, 4.2)
+ ).toBeCloseTo(275.174, 3);
+ expect(
+ MapUtils.computeForZoom(scaleLists, 5.2)
+ ).toBeCloseTo(152.87, 3);
+ expect(
+ MapUtils.computeForZoom(scaleLists, 100.2)
+ ).toBeCloseTo(152.87, 3);
+ })
+ });
+ describe("integer zoom", () => {
+ beforeEach(() => { mockAllowFractionalZoom = false; });
+
+ it("computes the value", () => {
+ expect(
+ MapUtils.computeForZoom(scaleLists, 0.12)
+ ).toBeCloseTo(4891.96, 3);
+ expect(
+ MapUtils.computeForZoom(scaleLists, 3.14)
+ ).toBeCloseTo(611.49, 3);
+ expect(
+ MapUtils.computeForZoom(scaleLists, 4.2)
+ ).toBeCloseTo(305.75, 3);
+ expect(
+ MapUtils.computeForZoom(scaleLists, 5.2)
+ ).toBeCloseTo(152.87, 3);
+ expect(
+ MapUtils.computeForZoom(scaleLists, 100.2)
+ ).toBeCloseTo(152.87, 3);
+ })
+ });
+});
+
+
+describe("computeZoom", () => {
+ const scaleLists = [
+ 4891.96,
+ 2445.98,
+ 1222.99,
+ 611.49,
+ 305.75,
+ 152.87,
+ ]
+
+ describe("fractional zoom", () => {
+ beforeEach(() => { mockAllowFractionalZoom = true; });
+
+ it("computes the value", () => {
+ expect(
+ MapUtils.computeZoom(scaleLists, 0.12)
+ ).toBeCloseTo(5.9991, 3);
+ expect(
+ MapUtils.computeZoom(scaleLists, 3.14)
+ ).toBeCloseTo(5.9793, 3);
+ expect(
+ MapUtils.computeZoom(scaleLists, 100)
+ ).toBeCloseTo(5.3458, 3);
+ expect(
+ MapUtils.computeZoom(scaleLists, 1000)
+ ).toBeCloseTo(2.3646, 3);
+ expect(
+ MapUtils.computeZoom(scaleLists, 5000)
+ ).toBeCloseTo(-0.0441, 3);
+ })
+ });
+ describe("integer zoom", () => {
+ beforeEach(() => { mockAllowFractionalZoom = false; });
+
+ it("computes the value", () => {
+ expect(
+ MapUtils.computeZoom(scaleLists, 0.12)
+ ).toBe(5);
+ expect(
+ MapUtils.computeZoom(scaleLists, 3.14)
+ ).toBe(5);
+ expect(
+ MapUtils.computeZoom(scaleLists, 100)
+ ).toBe(5);
+ expect(
+ MapUtils.computeZoom(scaleLists, 1000)
+ ).toBe(2);
+ expect(
+ MapUtils.computeZoom(scaleLists, 5000)
+ ).toBe(0);
+ })
+ });
+});
+
+
+describe("degreesToRadians", () => {
+ it("should convert the degrees", () => {
+ expect(MapUtils.degreesToRadians(0)).toBe(0);
+ expect(MapUtils.degreesToRadians(1)).toBeCloseTo(0.0174532, 6);
+ expect(MapUtils.degreesToRadians(45)).toBeCloseTo(0.7853981, 6);
+ expect(MapUtils.degreesToRadians(90)).toBeCloseTo(1.5707963, 6);
+ expect(MapUtils.degreesToRadians(135)).toBeCloseTo(2.3561944, 6);
+ expect(MapUtils.degreesToRadians(180)).toBeCloseTo(3.14159265, 6);
+ expect(MapUtils.degreesToRadians(225)).toBeCloseTo(3.92699081, 6);
+ expect(MapUtils.degreesToRadians(270)).toBeCloseTo(4.71238898, 6);
+ expect(MapUtils.degreesToRadians(315)).toBeCloseTo(5.49778714, 6);
+ expect(MapUtils.degreesToRadians(360)).toBeCloseTo(6.28318530, 6);
+ expect(MapUtils.degreesToRadians(-45)).toBeCloseTo(-0.7853981, 6);
+ expect(MapUtils.degreesToRadians(450)).toBeCloseTo(7.85398163, 6);
+ })
+});
diff --git a/utils/MeasureUtils.js b/utils/MeasureUtils.js
index 2e46d76d5..2e6c8ce10 100644
--- a/utils/MeasureUtils.js
+++ b/utils/MeasureUtils.js
@@ -12,20 +12,125 @@ import ConfigUtils from './ConfigUtils';
import CoordinatesUtils from "./CoordinatesUtils";
import LocaleUtils from './LocaleUtils';
+
+/**
+ * The geometry types supported by the measurement tool.
+ * @enum {import('qwc2/typings').MeasGeomTypes}
+ */
+export const MeasGeomTypes = {
+ POINT: 'Point',
+ LINE_STRING: 'LineString',
+ POLYGON: 'Polygon',
+ ELLIPSE: 'Ellipse',
+ SQUARE: 'Square',
+ BOX: 'Box',
+ CIRCLE: 'Circle',
+ BEARING: 'Bearing',
+}
+
+
+/**
+ * Length units used for measurements on the map.
+ * @enum {string}
+ */
+export const LengthUnits = {
+ FEET: "ft",
+ METRES: "m",
+ KILOMETRES: "km",
+ MILES: "mi",
+};
+
+
+/**
+ * Area units used for measurements on the map.
+ * @enum {string}
+ */
+export const AreaUnits = {
+ SQUARE_FEET: "sqft",
+ SQUARE_METRES: "sqm",
+ SQUARE_KILOMETRES: "sqkm",
+ SQUARE_MILES: "sqmi",
+ HECTARES: "ha",
+ ACRES: "acre",
+};
+
+
+/**
+ * All units used for measurements on the map,
+ * including those for length and area.
+ *
+ * @enum {string}
+ */
+export const MeasUnits = {
+ ...LengthUnits,
+ ...AreaUnits,
+
+ /**
+ * The metric unit appropriate for the given context.
+ */
+ METRIC: 'metric',
+
+ /**
+ * The imperial unit appropriate for the given context.
+ */
+ IMPERIAL: 'imperial',
+};
+
+
+/**
+ * Utility functions for measurements on the map.
+ *
+ * @namespace
+ */
const MeasureUtils = {
+
+ /**
+ * Computes a `N/S DD° MM' SS'' E/W` formatted bearing value from an
+ * azimuth value.
+ *
+ * The azimuth's 0 value is north oriented and increases clockwise.
+ * For negative values the function returns "N/A". Values above
+ * 360 are wrapped around.
+ *
+ * @param {number} azimuth - the azimuth value in degrees
+ *
+ * @returns {string} formatted bearing value
+ */
getFormattedBearingValue(azimuth) {
let bearing = "";
+ azimuth = azimuth % 360;
if (azimuth >= 0 && azimuth < 90) {
- bearing = "N " + this.degToDms(azimuth) + " E";
- } else if (azimuth > 90 && azimuth <= 180) {
- bearing = "S " + this.degToDms(180.0 - azimuth) + " E";
- } else if (azimuth > 180 && azimuth < 270) {
- bearing = "S " + this.degToDms(azimuth - 180.0 ) + " W";
+ bearing = "N " + this.degToDms(azimuth) + "E";
+ } else if (azimuth >= 90 && azimuth <= 180) {
+ bearing = "S " + this.degToDms(180.0 - azimuth) + "E";
+ } else if (azimuth >= 180 && azimuth < 270) {
+ bearing = "S " + this.degToDms(azimuth - 180.0) + "W";
} else if (azimuth >= 270 && azimuth <= 360) {
- bearing = "N " + this.degToDms(360 - azimuth ) + " W";
+ bearing = "N " + this.degToDms(360 - azimuth) + "W";
+ } else {
+ bearing = "N/A";
}
return bearing;
},
+
+ /**
+ * Pretty-print coordinates.
+ *
+ * The function transforms the coordinates to the given CRS and
+ * formats them according to the given number of decimals
+ * and the current locale.
+ *
+ * @param {number[]} coo - the list of coordinates to format (
+ * can be any length if `srcCrs` == `dstCrs`, otherwise a
+ * two-element list of x and y coordinates is expected).
+ * @param {string} srcCrs - the CRS of the `coo` parameter
+ * @param {string} dstCrs - the CRS to use for formatting
+ * @param {number} decimals - the number of decimals to use
+ * (-1 to automatically set the number of decimals)
+ *
+ * @returns {string} the formatted coordinates as a list of
+ * comma-separated values
+ */
getFormattedCoordinate(coo, srcCrs = null, dstCrs = null, decimals = -1) {
if (srcCrs && dstCrs && srcCrs !== dstCrs) {
coo = CoordinatesUtils.reproject(coo, srcCrs, dstCrs);
@@ -38,132 +143,316 @@ const MeasureUtils = {
decimals = 0;
}
}
- return coo.map(ord => LocaleUtils.toLocaleFixed(ord, decimals)).join(", ");
+ return coo.map(
+ ord => LocaleUtils.toLocaleFixed(ord, decimals)
+ ).join(", ");
},
+
+ /**
+ * Pretty-print a time interval.
+ *
+ * Note that time intervals longer than 23:59:59 are going to
+ * be wrapped back to [0..86400) range and fractional part
+ * is simply ignored (so 66.99 will be returned as 00:01:06).
+ *
+ * @param {number} valueSeconds - the time interval in seconds (integer)
+ *
+ * @returns {string} the time interval formatted as HH:MM:SS
+ */
formatDuration(valueSeconds) {
return new Date(valueSeconds * 1000).toISOString().slice(11, 19);
},
- formatMeasurement(valueMetric, isArea, unit = 'metric', decimals = 2, withUnit = true) {
+
+ /**
+ * Pretty-print a measurement value.
+ *
+ * If the unit is among the predefined ones, an appropriate suffix
+ * is appended to the formatted value.
+ *
+ * If the unit is not among the predefined ones, the
+ * value is formatted using the given number of decimals and
+ * the current locale, with `unit` appended to it.
+ *
+ * @param {number} valueMetric - the measurement value in the
+ * metric system
+ * @param {boolean} isArea - whether the measurement is an area
+ * or a length
+ * @param {MeasUnits} unit - the unit to use for formatting
+ * (default: metric)
+ * @param {number} decimals - the number of decimals to use
+ * (2 by default)
+ * @param {boolean} withUnit - whether to append the unit to the
+ * formatted value (true by default)
+ *
+ * @returns {string} the formatted measurement value
+ */
+ formatMeasurement(
+ valueMetric, isArea, unit = 'metric', decimals = 2, withUnit = true
+ ) {
let result = '';
let unitlabel = unit;
switch (unit) {
- case 'metric':
- if (isArea) {
- if (valueMetric > 1000000) {
- result = LocaleUtils.toLocaleFixed(valueMetric / 1000000, decimals);
- unitlabel = 'km²';
- } else if ( valueMetric > 10000) {
- result = LocaleUtils.toLocaleFixed(valueMetric / 10000, decimals);
- unitlabel = 'ha';
- } else {
- result = LocaleUtils.toLocaleFixed(valueMetric, decimals);
- unitlabel = 'm²';
- }
- } else {
- if (valueMetric > 1000) {
- result = LocaleUtils.toLocaleFixed(valueMetric / 1000, decimals);
- unitlabel = 'km';
- } else {
- result = LocaleUtils.toLocaleFixed(valueMetric, decimals);
- unitlabel = 'm';
- }
- }
- break;
- case 'imperial':
- if (isArea) {
- if (valueMetric > 2.58999 * 1000000) {
- result = LocaleUtils.toLocaleFixed(valueMetric * 0.000000386102159, decimals);
- unitlabel = 'mi²';
- } else if (valueMetric > 4046.86) {
- result = LocaleUtils.toLocaleFixed(valueMetric * 0.0001, decimals);
- unitlabel = 'acre';
+ case MeasUnits.METRIC:
+ if (isArea) {
+ if (valueMetric > 1000000) {
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric / 1000000, decimals
+ );
+ unitlabel = 'km²';
+ } else if (valueMetric > 10000) {
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric / 10000, decimals
+ );
+ unitlabel = 'ha';
+ } else {
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric, decimals
+ );
+ unitlabel = 'm²';
+ }
} else {
- result = LocaleUtils.toLocaleFixed(valueMetric * 10.7639, decimals);
- unitlabel = 'ft²';
+ if (valueMetric > 1000) {
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric / 1000, decimals
+ );
+ unitlabel = 'km';
+ } else {
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric, decimals
+ );
+ unitlabel = 'm';
+ }
}
- } else {
- if (valueMetric > 1609.34) {
- result = LocaleUtils.toLocaleFixed(valueMetric * 0.000621371, decimals);
- unitlabel = 'mi';
+ break;
+ case MeasUnits.IMPERIAL:
+ if (isArea) {
+ if (valueMetric > 2.58999 * 1000000) {
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric * 0.000000386102159, decimals
+ );
+ unitlabel = 'mi²';
+ } else if (valueMetric > 4046.86) {
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric * 0.000247105381467, decimals
+ );
+ unitlabel = 'acre';
+ } else {
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric * 10.7639, decimals
+ );
+ unitlabel = 'ft²';
+ }
} else {
- result = LocaleUtils.toLocaleFixed(valueMetric * 3.28084, decimals);
- unitlabel = 'ft';
+ if (valueMetric > 1609.34) {
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric * 0.000621371, decimals
+ );
+ unitlabel = 'mi';
+ } else {
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric * 3.28084, decimals
+ );
+ unitlabel = 'ft';
+ }
}
- }
- break;
- case 'm':
- result = LocaleUtils.toLocaleFixed(valueMetric, decimals); break;
- case 'ft':
- result = LocaleUtils.toLocaleFixed(valueMetric * 3.28084, decimals); break;
- case 'km':
- result = LocaleUtils.toLocaleFixed(valueMetric * 0.001, decimals); break;
- case 'mi':
- result = LocaleUtils.toLocaleFixed(valueMetric * 0.000621371, decimals); break;
- case 'sqm':
- result = LocaleUtils.toLocaleFixed(valueMetric, decimals); unitlabel = 'm²'; break;
- case 'sqft':
- result = LocaleUtils.toLocaleFixed(valueMetric * 10.7639, decimals); unitlabel = 'ft²'; break;
- case 'sqkm':
- result = LocaleUtils.toLocaleFixed(valueMetric * 0.000001, decimals); unitlabel = 'km²'; break;
- case 'sqmi':
- result = LocaleUtils.toLocaleFixed(valueMetric * 0.000000386102159, decimals); unitlabel = 'mi²'; break;
- case 'ha':
- result = LocaleUtils.toLocaleFixed(valueMetric * 0.0001, decimals); break;
- case 'acre':
- result = LocaleUtils.toLocaleFixed(valueMetric * 0.000247105381467, decimals); break;
- default:
- result = LocaleUtils.toLocaleFixed(valueMetric, decimals); break;
+ break;
+ case MeasUnits.METRES:
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric, decimals
+ );
+ break;
+ case MeasUnits.FEET:
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric * 3.28084, decimals
+ );
+ break;
+ case MeasUnits.KILOMETRES:
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric * 0.001, decimals
+ );
+ break;
+ case MeasUnits.MILES:
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric * 0.000621371, decimals
+ );
+ break;
+ case MeasUnits.SQUARE_METRES:
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric, decimals);
+ unitlabel = 'm²';
+ break;
+ case MeasUnits.SQUARE_FEET:
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric * 10.7639, decimals);
+ unitlabel = 'ft²';
+ break;
+ case MeasUnits.SQUARE_KILOMETRES:
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric * 0.000001, decimals);
+ unitlabel = 'km²';
+ break;
+ case MeasUnits.SQUARE_MILES:
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric * 0.000000386102159, decimals
+ );
+ unitlabel = 'mi²';
+ break;
+ case MeasUnits.HECTARES:
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric * 0.0001, decimals
+ );
+ break;
+ case MeasUnits.ACRES:
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric * 0.000247105381467, decimals
+ );
+ break;
+ default:
+ result = LocaleUtils.toLocaleFixed(
+ valueMetric, decimals
+ );
+ break;
}
if (withUnit) {
result += ' ' + unitlabel;
}
return result;
},
+
+ /**
+ * Pretty-print a length value.
+ *
+ * If the unit is among the predefined ones, an appropriate suffix
+ * is appended to the formatted value.
+ *
+ * In cases when the unit is not among the predefined ones, the
+ * value is formatted using the given number of decimals and
+ * the current locale, with `unit` appended to it.
+ *
+ * @param {string} unit - the unit to use for formatting
+ * @param {LengthUnits} length - the length in meters
+ * @param {number} decimals - the number of decimals to use
+ * (2 by default)
+ * @param {boolean} withUnit - whether to append the unit to the
+ * formatted value (true by default)
+ *
+ * @returns {string} the formatted length value
+ */
getFormattedLength(unit, length, decimals = 2, withUnit = true) {
let result = '';
switch (unit) {
- case 'm':
- result = LocaleUtils.toLocaleFixed(length, decimals); break;
- case 'ft':
- result = LocaleUtils.toLocaleFixed(length * 3.28084, decimals); break;
- case 'km':
- result = LocaleUtils.toLocaleFixed(length * 0.001, decimals); break;
- case 'mi':
- result = LocaleUtils.toLocaleFixed(length * 0.000621371, decimals); break;
- default:
- result = LocaleUtils.toLocaleFixed(length, decimals); break;
+ case LengthUnits.METRES:
+ result = LocaleUtils.toLocaleFixed(
+ length, decimals
+ );
+ break;
+ case LengthUnits.FEET:
+ result = LocaleUtils.toLocaleFixed(
+ length * 3.28084, decimals
+ );
+ break;
+ case LengthUnits.KILOMETRES:
+ result = LocaleUtils.toLocaleFixed(
+ length * 0.001, decimals
+ );
+ break;
+ case LengthUnits.MILES:
+ result = LocaleUtils.toLocaleFixed(
+ length * 0.000621371, decimals
+ );
+ break;
+ default:
+ result = LocaleUtils.toLocaleFixed(
+ length, decimals
+ );
+ break;
}
if (withUnit) {
result += ' ' + unit;
}
return result;
},
+
+ /**
+ * Pretty-print an area value.
+ *
+ * If the unit is among the predefined ones, an appropriate suffix
+ * is appended to the formatted value.
+ *
+ * In cases when the unit is not among the predefined ones, the
+ * value is formatted using the given number of decimals and
+ * the current locale, with `unit` appended to it.
+ *
+ * @param {string} unit - the unit to use for formatting
+ * @param {AreaUnits} area - the measurement value in square meters
+ * @param {number} decimals - the number of decimals to use
+ * (2 by default)
+ * @param {boolean} withUnit - whether to append the unit to the
+ * formatted value (true by default)
+ *
+ * @returns {string} the formatted area value
+ */
getFormattedArea(unit, area, decimals = 2, withUnit = true) {
let result = '';
let unitlabel = unit;
switch (unit) {
- case 'sqm':
- result = LocaleUtils.toLocaleFixed(area, decimals); unitlabel = 'm²'; break;
- case 'sqft':
- result = LocaleUtils.toLocaleFixed(area * 10.7639, decimals); unitlabel = 'ft²'; break;
- case 'sqkm':
- result = LocaleUtils.toLocaleFixed(area * 0.000001, decimals); unitlabel = 'km²'; break;
- case 'sqmi':
- result = LocaleUtils.toLocaleFixed(area * 0.000000386102159, decimals); unitlabel = 'mi²'; break;
- case 'ha':
- result = LocaleUtils.toLocaleFixed(area * 0.0001, decimals); break;
- case 'acre':
- result = LocaleUtils.toLocaleFixed(area * 0.000247105381467, decimals); break;
- default:
- result = LocaleUtils.toLocaleFixed(area, decimals); break;
+ case AreaUnits.SQUARE_METRES:
+ result = LocaleUtils.toLocaleFixed(
+ area, decimals
+ );
+ unitlabel = 'm²';
+ break;
+ case AreaUnits.SQUARE_FEET:
+ result = LocaleUtils.toLocaleFixed(
+ area * 10.7639, decimals
+ );
+ unitlabel = 'ft²';
+ break;
+ case AreaUnits.SQUARE_KILOMETRES:
+ result = LocaleUtils.toLocaleFixed(
+ area * 0.000001, decimals
+ );
+ unitlabel = 'km²';
+ break;
+ case AreaUnits.SQUARE_MILES:
+ result = LocaleUtils.toLocaleFixed(
+ area * 0.000000386102159, decimals
+ );
+ unitlabel = 'mi²';
+ break;
+ case AreaUnits.HECTARES:
+ result = LocaleUtils.toLocaleFixed(
+ area * 0.0001, decimals
+ );
+ break;
+ case AreaUnits.ACRES:
+ result = LocaleUtils.toLocaleFixed(
+ area * 0.000247105381467, decimals
+ );
+ break;
+ default:
+ result = LocaleUtils.toLocaleFixed(
+ area, decimals
+ );
+ break;
}
if (withUnit) {
result += ' ' + unitlabel;
}
return result;
},
+
+ /**
+ * Converts a decimal degree value to a DD° MM' SS'' formatted
+ * string.
+ *
+ * Note that the string has an extra space at the end.
+ *
+ * @param {number} deg - the decimal degree value to convert
+ *
+ * @returns {string} the degrees-minutes-seconds formatted value
+ */
degToDms(deg) {
- // convert decimal deg to minutes and seconds
const d = Math.floor(deg);
const minfloat = (deg - d) * 60;
const m = Math.floor(minfloat);
@@ -172,6 +461,27 @@ const MeasureUtils = {
return ("" + d + "° " + m + "' " + s + "'' ");
},
+
+ /**
+ * Changes the properties of a feature to reflect the current
+ * measurement settings.
+ *
+ * The function changes following properties of the feature:
+ * - `measurements` - an object containing the measurement values
+ * - `label` - a string containing the formatted measurement value
+ * - `segment_labels` - an array of strings containing the formatted
+ * measurement values for each segment of the line
+ *
+ * Note that the function silently ignores an unknown
+ * geometry type.
+ *
+ * @param {import('ol').Feature} feature - the feature to update
+ * @param {import('qwc2/typings').MeasGeomTypes} geomType - the type
+ * of the feature's geometry
+ * @param {string} featureCrs - the CRS of the feature's geometry
+ * @param {import('qwc2/typings').UpdateFeatMeasSetting} settings - the
+ * settings to use for updating the feature
+ */
updateFeatureMeasurements(feature, geomType, featureCrs, settings) {
const geodesic = ConfigUtils.getConfigProp("geodesicMeasurements");
const measurements = {
@@ -181,38 +491,94 @@ const MeasureUtils = {
feature.set('label', '');
feature.set('segment_labels', undefined);
const geom = feature.getGeometry();
- if (geomType === 'Point') {
- feature.set('label', MeasureUtils.getFormattedCoordinate(geom.getCoordinates(), settings.mapCrs, settings.displayCrs));
- } else if (geomType === 'LineString') {
- const lengths = MeasureUtils.computeSegmentLengths(geom.getCoordinates(), featureCrs, geodesic);
+
+ if (geomType === MeasGeomTypes.POINT) {
+ // TODO: is this right? The CRS of the feature is not used.
+ // The coordinates of the feature are stored in map CRS?
+ // This is the only instance where the mapCrs is used in
+ // this function.
+ feature.set('label', MeasureUtils.getFormattedCoordinate(
+ geom.getCoordinates(), settings.mapCrs, settings.displayCrs)
+ );
+
+ } else if (geomType === MeasGeomTypes.LINE_STRING) {
+ const lengths = MeasureUtils.computeSegmentLengths(
+ geom.getCoordinates(), featureCrs, geodesic
+ );
measurements.segment_lengths = lengths;
measurements.length = lengths.reduce((sum, len) => sum + len, 0);
- feature.set('segment_labels', lengths.map(length => MeasureUtils.formatMeasurement(length, false, settings.lenUnit, settings.decimals)));
- } else if (["Ellipse", "Polygon", "Square", "Box"].includes(geomType)) {
+ feature.set('segment_labels', lengths.map(
+ length => MeasureUtils.formatMeasurement(
+ length, false, settings.lenUnit, settings.decimals
+ )
+ ));
+
+ } else if (
+ [
+ MeasGeomTypes.ELLIPSE,
+ MeasGeomTypes.POLYGON,
+ MeasGeomTypes.SQUARE,
+ MeasGeomTypes.BOX
+ ].includes(geomType)
+ ) {
const area = MeasureUtils.computeArea(geom, featureCrs, geodesic);
measurements.area = area;
- feature.set('label', MeasureUtils.formatMeasurement(area, true, settings.areaUnit, settings.decimals));
- } else if (geomType === 'Circle') {
+ feature.set('label', MeasureUtils.formatMeasurement(
+ area, true, settings.areaUnit, settings.decimals
+ ));
+
+ } else if (geomType === MeasGeomTypes.CIRCLE) {
const radius = geom.getRadius();
measurements.radius = radius;
- feature.set('label', "r = " + MeasureUtils.formatMeasurement(radius, false, settings.lenUnit, settings.decimals));
- } else if (geomType === 'Bearing') {
+ feature.set('label', "r = " + MeasureUtils.formatMeasurement(
+ radius, false, settings.lenUnit, settings.decimals
+ ));
+
+ } else if (geomType === MeasGeomTypes.BEARING) {
const coo = geom.getCoordinates();
- measurements.bearing = CoordinatesUtils.calculateAzimuth(coo[0], coo[1], featureCrs);
- feature.set('label', MeasureUtils.getFormattedBearingValue(measurements.bearing));
+ measurements.bearing = CoordinatesUtils.calculateAzimuth(
+ coo[0], coo[1], featureCrs
+ );
+ feature.set('label', MeasureUtils.getFormattedBearingValue(
+ measurements.bearing
+ ));
}
feature.set('measurements', measurements);
},
+
+ /**
+ * Compute the lengths of the segments of a line string.
+ *
+ * The function relies on {@link CoordinatesUtils.getUnits} to
+ * retrieve the units of the line's coordinates from the CRS.
+ * It deals with three cases:
+ * - degrees (geodesic calculations are used),
+ * - feet (the coordinates are converted to meters),
+ * - meters (which is assumed if the units are not degrees or feet).
+ *
+ * @param {number[][]} coordinates - the coordinates of the line
+ * @param {string} featureCrs - the CRS of the line's coordinates
+ * @param {boolean} geodesic - whether to use geodesic calculations
+ *
+ * @returns {number[]} the lengths of the line's segments in meters
+ * (or degrees if `geodesic` is true)
+ */
computeSegmentLengths(coordinates, featureCrs, geodesic) {
const lengths = [];
const units = CoordinatesUtils.getUnits(featureCrs);
if (geodesic || units === 'degrees') {
- const wgsCoo = coordinates.map(coo => CoordinatesUtils.reproject(coo, featureCrs, "EPSG:4326"));
+ const wgsCoo = coordinates.map(
+ coo => CoordinatesUtils.reproject(
+ coo, featureCrs, "EPSG:4326"
+ )
+ );
for (let i = 0; i < wgsCoo.length - 1; ++i) {
- lengths.push(ol.sphere.getDistance(wgsCoo[i], wgsCoo[i + 1]));
+ lengths.push(
+ ol.sphere.getDistance(wgsCoo[i], wgsCoo[i + 1])
+ );
}
} else {
- const conv = units === 'feet' ? 0.3048 : 1;
+ const conv = (units === 'feet' || units === 'us-ft') ? 0.3048 : 1;
for (let i = 0; i < coordinates.length - 1; ++i) {
const dx = coordinates[i + 1][0] - coordinates[i][0];
const dy = coordinates[i + 1][1] - coordinates[i][1];
@@ -221,12 +587,32 @@ const MeasureUtils = {
}
return lengths;
},
+
+ /**
+ * Compute the area of a polygon.
+ *
+ * The function relies on {@link CoordinatesUtils.getUnits} to
+ * retrieve the units of the polygon's coordinates from the CRS.
+ * It deals with three cases:
+ * - degrees (geodesic calculations are used),
+ * - feet (the coordinates are converted to meters),
+ * - meters (which is assumed if the units are not degrees or feet).
+ *
+ * @param {import('ol/geom').Geometry} geometry - the polygon's geometry
+ * @param {string} featureCrs - the CRS of the polygon's coordinates
+ * @param {boolean} geodesic - whether to use geodesic calculations
+ *
+ * @returns {number} the area of the polygon in square meters
+ * (or square degrees if `geodesic` is true)
+ */
computeArea(geometry, featureCrs, geodesic) {
const units = CoordinatesUtils.getUnits(featureCrs);
if (geodesic || units === 'degrees') {
- return ol.sphere.getArea(geometry, {projection: featureCrs});
+ return ol.sphere.getArea(geometry, {
+ projection: featureCrs
+ });
} else {
- const conv = units === 'feet' ? 0.3048 : 1;
+ const conv = (units === 'feet' || units === 'us-ft') ? 0.3048 : 1;
return geometry.getArea() * conv * conv;
}
}
diff --git a/utils/MeasureUtils.test.js b/utils/MeasureUtils.test.js
new file mode 100644
index 000000000..f1e5fd77b
--- /dev/null
+++ b/utils/MeasureUtils.test.js
@@ -0,0 +1,1274 @@
+import { Polygon, Point, LineString, Circle } from 'ol/geom';
+import { Feature } from 'ol';
+
+import MeasureUtils, {
+ LengthUnits, MeasUnits, AreaUnits, MeasGeomTypes
+} from './MeasureUtils';
+import LayerUtils from './LocaleUtils';
+import { feetCRS } from '../config/setupTestsAfterEnv';
+
+
+jest.mock('./LocaleUtils');
+
+beforeEach(() => {
+ LayerUtils.toLocaleFixed.mockImplementation((number, digits) => {
+ return number.toFixed(digits);
+ });
+});
+
+const coords = [
+ [0, 0],
+ [0, 1],
+ [1, 1],
+ [1, 0],
+ [0, 0]
+];
+
+/**
+ * A polygon with no hole.
+ */
+const somePolygon = new Polygon([coords]);
+
+
+describe("getFormattedBearingValue", () => {
+ it("should return a formatted bearing value", () => {
+ expect(
+ MeasureUtils.getFormattedBearingValue(0)
+ ).toEqual("N 0° 0' 0'' E");
+ expect(
+ MeasureUtils.getFormattedBearingValue(45)
+ ).toEqual("N 45° 0' 0'' E");
+ expect(
+ MeasureUtils.getFormattedBearingValue(90)
+ ).toEqual("S 90° 0' 0'' E");
+ expect(
+ MeasureUtils.getFormattedBearingValue(135)
+ ).toEqual("S 45° 0' 0'' E");
+ expect(
+ MeasureUtils.getFormattedBearingValue(180)
+ ).toEqual("S 0° 0' 0'' E");
+ expect(
+ MeasureUtils.getFormattedBearingValue(225)
+ ).toEqual("S 45° 0' 0'' W");
+ expect(
+ MeasureUtils.getFormattedBearingValue(270)
+ ).toEqual("N 90° 0' 0'' W");
+ expect(
+ MeasureUtils.getFormattedBearingValue(315)
+ ).toEqual("N 45° 0' 0'' W");
+ expect(
+ MeasureUtils.getFormattedBearingValue(360)
+ ).toEqual("N 0° 0' 0'' E");
+ });
+ it("should return N/A if the value is negative", () => {
+ expect(
+ MeasureUtils.getFormattedBearingValue(-1)
+ ).toEqual("N/A");
+ });
+});
+
+
+describe("getFormattedCoordinate", () => {
+ it("should format a single value in array", () => {
+ expect(
+ MeasureUtils.getFormattedCoordinate([0.123], null, null, 0)
+ ).toEqual("0");
+ expect(
+ MeasureUtils.getFormattedCoordinate([0.126], null, null, 1)
+ ).toEqual("0.1");
+ expect(
+ MeasureUtils.getFormattedCoordinate([0.126], null, null, 2)
+ ).toEqual("0.13");
+ });
+ it("should format multiple values in array", () => {
+ expect(
+ MeasureUtils.getFormattedCoordinate([0.123, 1, -1], null, null, 0)
+ ).toEqual("0, 1, -1");
+ expect(
+ MeasureUtils.getFormattedCoordinate([0.123, 1, -1], null, null, 1)
+ ).toEqual("0.1, 1.0, -1.0");
+ expect(
+ MeasureUtils.getFormattedCoordinate([0.123, 1, -1], null, null, 2)
+ ).toEqual("0.12, 1.00, -1.00");
+ });
+ it("should work with same CRS in source and destination", () => {
+ expect(
+ MeasureUtils.getFormattedCoordinate(
+ [0.123, 1, -1], "EPSG:4326", "EPSG:4326", 0
+ )
+ ).toEqual("0, 1, -1");
+
+ });
+ it("determines the number of decimals from CRS", () => {
+ expect(
+ MeasureUtils.getFormattedCoordinate(
+ [0.123, 1, -1], "EPSG:4326", "EPSG:4326"
+ )
+ ).toEqual("0.1230, 1.0000, -1.0000");
+ expect(
+ MeasureUtils.getFormattedCoordinate(
+ [0.123, 1, -1], "EPSG:3857", "EPSG:3857"
+ )
+ ).toEqual("0, 1, -1");
+ });
+ it("converts the coordinates", () => {
+ expect(
+ MeasureUtils.getFormattedCoordinate(
+ [500000, 500000], "EPSG:3857", "EPSG:4326"
+ )
+ ).toEqual("4.4916, 4.4870");
+ expect(
+ MeasureUtils.getFormattedCoordinate(
+ [0, 0], "EPSG:3857", "EPSG:4326"
+ )
+ ).toEqual("0.0000, 0.0000");
+ expect(
+ MeasureUtils.getFormattedCoordinate(
+ [45, 45], "EPSG:4326", "EPSG:3857"
+ )
+ ).toEqual("5009377, 5621521");
+ });
+});
+
+
+describe("formatDuration", () => {
+ it("should format values", () => {
+ expect(
+ MeasureUtils.formatDuration(0)
+ ).toEqual("00:00:00");
+ expect(
+ MeasureUtils.formatDuration(60)
+ ).toEqual("00:01:00");
+ expect(
+ MeasureUtils.formatDuration(61)
+ ).toEqual("00:01:01");
+ expect(
+ MeasureUtils.formatDuration(60 * 60)
+ ).toEqual("01:00:00");
+ });
+ it("should wrap around at 24 hours", () => {
+ expect(
+ MeasureUtils.formatDuration(60 * 60 * 24)
+ ).toEqual("00:00:00");
+ expect(
+ MeasureUtils.formatDuration(60 * 60 * 24 + 61)
+ ).toEqual("00:01:01");
+ });
+ it("should handle negative values", () => {
+ expect(
+ MeasureUtils.formatDuration(-1)
+ ).toEqual("23:59:59");
+ });
+ it("should ignore fractional part", () => {
+ expect(
+ MeasureUtils.formatDuration(61.123)
+ ).toEqual("00:01:01");
+ expect(
+ MeasureUtils.formatDuration(61.99)
+ ).toEqual("00:01:01");
+ });
+});
+
+describe("formatMeasurement", () => {
+ describe("metric", () => {
+ describe("area", () => {
+ describe("square-kilometers", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1000001, true, 'metric', 0, true
+ )
+ ).toEqual("1 km²");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1120000, true, 'metric', 2, true
+ )
+ ).toEqual("1.12 km²");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1000001, true, 'metric', 0, false
+ )
+ ).toEqual("1");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1120000, true, 'metric', 2, false
+ )
+ ).toEqual("1.12");
+ });
+ });
+ describe("hectares", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 10001, true, 'metric', 0, true
+ )
+ ).toEqual("1 ha");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 11200, true, 'metric', 2, true
+ )
+ ).toEqual("1.12 ha");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 10001, true, 'metric', 0, false
+ )
+ ).toEqual("1");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 11200, true, 'metric', 2, false
+ )
+ ).toEqual("1.12");
+ });
+ });
+ describe("square meters", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1001.12, true, 'metric', 0, true
+ )
+ ).toEqual("1001 m²");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1001.12, true, 'metric', 2, true
+ )
+ ).toEqual("1001.12 m²");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1001.12, true, 'metric', 0, false
+ )
+ ).toEqual("1001");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1001.12, true, 'metric', 2, false
+ )
+ ).toEqual("1001.12");
+ });
+ });
+ });
+ describe("distance", () => {
+ describe("kilometers", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1001, false, 'metric', 0, true
+ )
+ ).toEqual("1 km");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1120, false, 'metric', 2, true
+ )
+ ).toEqual("1.12 km");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1001, false, 'metric', 0, false
+ )
+ ).toEqual("1");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1120, false, 'metric', 2, false
+ )
+ ).toEqual("1.12");
+ });
+ });
+ describe("meters", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 101.12, false, 'metric', 0, true
+ )
+ ).toEqual("101 m");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 101.12, false, 'metric', 2, true
+ )
+ ).toEqual("101.12 m");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 101.12, false, 'metric', 0, false
+ )
+ ).toEqual("101");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 101.12, false, 'metric', 2, false
+ )
+ ).toEqual("101.12");
+ });
+ });
+ });
+ });
+ describe("imperial", () => {
+ describe("area", () => {
+ describe("square-miles", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 1000000, true, 'imperial', 0, true
+ )
+ ).toEqual("1 mi²");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 1120000, true, 'imperial', 2, true
+ )
+ ).toEqual("1.12 mi²");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 1000000, true, 'imperial', 0, false
+ )
+ ).toEqual("1");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 1120000, true, 'imperial', 2, false
+ )
+ ).toEqual("1.12");
+ });
+ });
+ describe("acres", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 26000, true, 'imperial', 0, true
+ )
+ ).toEqual("6 acre");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 11200, true, 'imperial', 2, true
+ )
+ ).toEqual("7.20 acre");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 10000, true, 'imperial', 0, false
+ )
+ ).toEqual("6");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 11200, true, 'imperial', 2, false
+ )
+ ).toEqual("7.20");
+ });
+ });
+ describe("square-feets", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 260, true, 'imperial', 0, true
+ )
+ ).toEqual("2799 ft²");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 112, true, 'imperial', 2, true
+ )
+ ).toEqual("3134.45 ft²");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 260, true, 'imperial', 0, false
+ )
+ ).toEqual("2799");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 112, true, 'imperial', 2, false
+ )
+ ).toEqual("3134.45");
+ });
+ });
+ });
+ describe("distance", () => {
+ describe("miles", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 1000, false, 'imperial', 0, true
+ )
+ ).toEqual("2 mi");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 1120, false, 'imperial', 2, true
+ )
+ ).toEqual("1.81 mi");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 1000, false, 'imperial', 0, false
+ )
+ ).toEqual("2");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 1120, false, 'imperial', 2, false
+ )
+ ).toEqual("1.81");
+ });
+ });
+ describe("feets", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 260, false, 'imperial', 0, true
+ )
+ ).toEqual("853 ft");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 112, false, 'imperial', 2, true
+ )
+ ).toEqual("955.38 ft");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 260, false, 'imperial', 0, false
+ )
+ ).toEqual("853");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 112, false, 'imperial', 2, false
+ )
+ ).toEqual("955.38");
+ });
+ });
+ });
+ });
+ describe("acres", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 26000, true, MeasUnits.ACRES, 0, true
+ )
+ ).toEqual("6 acre");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 11200, true, MeasUnits.ACRES, 2, true
+ )
+ ).toEqual("7.20 acre");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 10000, true, MeasUnits.ACRES, 0, false
+ )
+ ).toEqual("6");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 11200, true, MeasUnits.ACRES, 2, false
+ )
+ ).toEqual("7.20");
+ });
+ });
+ describe("feet", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 260, false, MeasUnits.FEET, 0, true
+ )
+ ).toEqual("853 ft");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 112, false, MeasUnits.FEET, 2, true
+ )
+ ).toEqual("955.38 ft");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 260, false, MeasUnits.FEET, 0, false
+ )
+ ).toEqual("853");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 112, false, MeasUnits.FEET, 2, false
+ )
+ ).toEqual("955.38");
+ });
+ });
+ describe("hectares", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 10001, true, MeasUnits.HECTARES, 0, true
+ )
+ ).toEqual("1 ha");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 11200, true, MeasUnits.HECTARES, 2, true
+ )
+ ).toEqual("1.12 ha");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 10001, true, MeasUnits.HECTARES, 0, false
+ )
+ ).toEqual("1");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 11200, true, MeasUnits.HECTARES, 2, false
+ )
+ ).toEqual("1.12");
+ });
+ });
+ describe("kilometres", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1001, false, MeasUnits.KILOMETRES, 0, true
+ )
+ ).toEqual("1 km");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1120, false, MeasUnits.KILOMETRES, 2, true
+ )
+ ).toEqual("1.12 km");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1001, false, MeasUnits.KILOMETRES, 0, false
+ )
+ ).toEqual("1");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1120, false, MeasUnits.KILOMETRES, 2, false
+ )
+ ).toEqual("1.12");
+ });
+ });
+ describe("metres", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 101.12, false, MeasUnits.METRES, 0, true
+ )
+ ).toEqual("101 m");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 101.12, false, MeasUnits.METRES, 2, true
+ )
+ ).toEqual("101.12 m");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 101.12, false, MeasUnits.METRES, 0, false
+ )
+ ).toEqual("101");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 101.12, false, MeasUnits.METRES, 2, false
+ )
+ ).toEqual("101.12");
+ });
+ });
+ describe("miles", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 1000, false, MeasUnits.MILES, 0, true
+ )
+ ).toEqual("2 mi");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 1120, false, MeasUnits.MILES, 2, true
+ )
+ ).toEqual("1.81 mi");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 1000, false, MeasUnits.MILES, 0, false
+ )
+ ).toEqual("2");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 1120, false, MeasUnits.MILES, 2, false
+ )
+ ).toEqual("1.81");
+ });
+ });
+ describe("square_feet", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 260, true, MeasUnits.SQUARE_FEET, 0, true
+ )
+ ).toEqual("2799 ft²");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 112, true, MeasUnits.SQUARE_FEET, 2, true
+ )
+ ).toEqual("3134.45 ft²");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 260, true, MeasUnits.SQUARE_FEET, 0, false
+ )
+ ).toEqual("2799");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 112, true, MeasUnits.SQUARE_FEET, 2, false
+ )
+ ).toEqual("3134.45");
+ });
+ });
+ describe("square_kilometres", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1000001, true, MeasUnits.SQUARE_KILOMETRES, 0, true
+ )
+ ).toEqual("1 km²");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1120000, true, MeasUnits.SQUARE_KILOMETRES, 2, true
+ )
+ ).toEqual("1.12 km²");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1000001, true, MeasUnits.SQUARE_KILOMETRES, 0, false
+ )
+ ).toEqual("1");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1120000, true, MeasUnits.SQUARE_KILOMETRES, 2, false
+ )
+ ).toEqual("1.12");
+ });
+ });
+ describe("square_metres", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1001.12, true, MeasUnits.SQUARE_METRES, 0, true
+ )
+ ).toEqual("1001 m²");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1001.12, true, MeasUnits.SQUARE_METRES, 2, true
+ )
+ ).toEqual("1001.12 m²");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1001.12, true, MeasUnits.SQUARE_METRES, 0, false
+ )
+ ).toEqual("1001");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 1001.12, true, MeasUnits.SQUARE_METRES, 2, false
+ )
+ ).toEqual("1001.12");
+ });
+ });
+ describe("square_miles", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 1000000, true, MeasUnits.SQUARE_MILES, 0, true
+ )
+ ).toEqual("1 mi²");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 1120000, true, MeasUnits.SQUARE_MILES, 2, true
+ )
+ ).toEqual("1.12 mi²");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 1000000, true, MeasUnits.SQUARE_MILES, 0, false
+ )
+ ).toEqual("1");
+ expect(
+ MeasureUtils.formatMeasurement(
+ 2.6 * 1120000, true, MeasUnits.SQUARE_MILES, 2, false
+ )
+ ).toEqual("1.12");
+ });
+ });
+ describe("default", () => {
+ it("should append whatever unit we provide", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 260, true, "whatever", 0, true
+ )
+ ).toEqual("260 whatever");
+ });
+ it("should print the numbers when the units are supresses", () => {
+ expect(
+ MeasureUtils.formatMeasurement(
+ 260, true, "whatever", 0, false
+ )
+ ).toEqual("260");
+ });
+ });
+});
+
+
+describe("getFormattedLength", () => {
+ describe("meters", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.getFormattedLength(
+ LengthUnits.METRES, 1.12, 0, true
+ )
+ ).toEqual("1 m");
+ expect(
+ MeasureUtils.getFormattedLength(
+ LengthUnits.METRES, 1.12, 2, true
+ )
+ ).toEqual("1.12 m");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.getFormattedLength(
+ LengthUnits.METRES, 1.12, 0, false
+ )
+ ).toEqual("1");
+ expect(
+ MeasureUtils.getFormattedLength(
+ LengthUnits.METRES, 1.12, 2, false
+ )
+ ).toEqual("1.12");
+ });
+ });
+ describe("kilometers", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.getFormattedLength(
+ LengthUnits.KILOMETRES, 1120, 0, true
+ )
+ ).toEqual("1 km");
+ expect(
+ MeasureUtils.getFormattedLength(
+ LengthUnits.KILOMETRES, 1120, 2, true
+ )
+ ).toEqual("1.12 km");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.getFormattedLength(
+ LengthUnits.KILOMETRES, 1120, 0, false
+ )
+ ).toEqual("1");
+ expect(
+ MeasureUtils.getFormattedLength(
+ LengthUnits.KILOMETRES, 1120, 2, false
+ )
+ ).toEqual("1.12");
+ });
+ });
+ describe("feet", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.getFormattedLength(
+ LengthUnits.FEET, 112, 0, true
+ )
+ ).toEqual("367 ft");
+ expect(
+ MeasureUtils.getFormattedLength(
+ LengthUnits.FEET, 112, 2, true
+ )
+ ).toEqual("367.45 ft");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.getFormattedLength(
+ LengthUnits.FEET, 112, 0, false
+ )
+ ).toEqual("367");
+ expect(
+ MeasureUtils.getFormattedLength(
+ LengthUnits.FEET, 112, 2, false
+ )
+ ).toEqual("367.45");
+ });
+ });
+ describe("miles", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.getFormattedLength(
+ LengthUnits.MILES, 5000, 0, true
+ )
+ ).toEqual("3 mi");
+ expect(
+ MeasureUtils.getFormattedLength(
+ LengthUnits.MILES, 5000, 2, true
+ )
+ ).toEqual("3.11 mi");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.getFormattedLength(
+ LengthUnits.MILES, 5000, 0, false
+ )
+ ).toEqual("3");
+ expect(
+ MeasureUtils.getFormattedLength(
+ LengthUnits.MILES, 5000, 2, false
+ )
+ ).toEqual("3.11");
+ });
+ });
+ describe("default", () => {
+ it("should append whatever unit we provide", () => {
+ expect(
+ MeasureUtils.getFormattedLength(
+ "whatever", 260, 0, true
+ )
+ ).toEqual("260 whatever");
+ });
+ it("should print the numbers when the units are supresses", () => {
+ expect(
+ MeasureUtils.getFormattedLength(
+ "whatever", 260, 0, false
+ )
+ ).toEqual("260");
+ });
+ });
+});
+
+
+describe("getFormattedArea", () => {
+ describe("square-meters", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.SQUARE_METRES, 1.12, 0, true
+ )
+ ).toEqual("1 m²");
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.SQUARE_METRES, 1.12, 2, true
+ )
+ ).toEqual("1.12 m²");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.SQUARE_METRES, 1.12, 0, false
+ )
+ ).toEqual("1");
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.SQUARE_METRES, 1.12, 2, false
+ )
+ ).toEqual("1.12");
+ });
+ });
+ describe("square-feets", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.SQUARE_FEET, 112, 0, true
+ )
+ ).toEqual("1206 ft²");
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.SQUARE_FEET, 112.73, 2, true
+ )
+ ).toEqual("1213.41 ft²");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.SQUARE_FEET, 112, 0, false
+ )
+ ).toEqual("1206");
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.SQUARE_FEET, 112.73, 2, false
+ )
+ ).toEqual("1213.41");
+ });
+ });
+ describe("square-kilometers", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.SQUARE_KILOMETRES, 1120000, 0, true
+ )
+ ).toEqual("1 km²");
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.SQUARE_KILOMETRES, 1120000, 2, true
+ )
+ ).toEqual("1.12 km²");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.SQUARE_KILOMETRES, 1120000, 0, false
+ )
+ ).toEqual("1");
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.SQUARE_KILOMETRES, 1120000, 2, false
+ )
+ ).toEqual("1.12");
+ });
+ });
+ describe("square-miles", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.SQUARE_MILES, 1500000, 0, true
+ )
+ ).toEqual("1 mi²");
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.SQUARE_MILES, 1500000, 2, true
+ )
+ ).toEqual("0.58 mi²");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.SQUARE_MILES, 1500000, 0, false
+ )
+ ).toEqual("1");
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.SQUARE_MILES, 1500000, 2, false
+ )
+ ).toEqual("0.58");
+ });
+ });
+ describe("acres", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.ACRES, 11234, 0, true
+ )
+ ).toEqual("3 acre");
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.ACRES, 11234, 2, true
+ )
+ ).toEqual("2.78 acre");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.ACRES, 11234, 0, false
+ )
+ ).toEqual("3");
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.ACRES, 11234, 2, false
+ )
+ ).toEqual("2.78");
+ });
+ });
+ describe("hectares", () => {
+ it("should format values with units", () => {
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.HECTARES, 11200, 0, true
+ )
+ ).toEqual("1 ha");
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.HECTARES, 11200, 2, true
+ )
+ ).toEqual("1.12 ha");
+ });
+ it("should format values without units", () => {
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.HECTARES, 11200, 0, false
+ )
+ ).toEqual("1");
+ expect(
+ MeasureUtils.getFormattedArea(
+ AreaUnits.HECTARES, 11200, 2, false
+ )
+ ).toEqual("1.12");
+ });
+ });
+ describe("default", () => {
+ it("should append whatever unit we provide", () => {
+ expect(
+ MeasureUtils.getFormattedArea(
+ "whatever", 260, 0, true
+ )
+ ).toEqual("260 whatever");
+ });
+ it("should print the numbers when the units are supresses", () => {
+ expect(
+ MeasureUtils.getFormattedArea(
+ "whatever", 260, 0, false
+ )
+ ).toEqual("260");
+ });
+ });
+});
+
+
+describe("degToDms", () => {
+ it("should print a nice string", () => {
+ expect(MeasureUtils.degToDms(0)).toEqual("0° 0' 0'' ");
+ expect(MeasureUtils.degToDms(1)).toEqual("1° 0' 0'' ");
+ expect(MeasureUtils.degToDms(1.23456789)).toEqual("1° 14' 4'' ");
+ expect(MeasureUtils.degToDms(180.23456789)).toEqual("180° 14' 4'' ");
+ });
+});
+
+
+describe("updateFeatureMeasurements", () => {
+ let feature;
+ const settings = {
+ lenUnit: "meters",
+ areaUnit: "square_meters",
+ mapCrs: "EPSG:3857",
+ displayCrs: "EPSG:3857",
+ decimals: 2,
+ };
+ beforeEach(() => {
+ feature = new Feature();
+ });
+
+ describe("Point", () => {
+ it("should update the feature with Pseudo-Mercator", () => {
+ [
+ [0, 0, "0, 0"],
+ [1, 1, "1, 1"],
+ [1.23456789, 1.23456789, "1, 1"],
+ ].forEach(([x, y, label]) => {
+ feature.set("geometry", new Point([x, y]));
+ MeasureUtils.updateFeatureMeasurements(
+ feature, MeasGeomTypes.POINT, "EPSG:3857", settings
+ );
+ expect(feature.get("label")).toBe(label);
+ expect(feature.get("segment_labels")).toBeUndefined();
+ expect(feature.get("measurements")).toEqual({
+ lenUnit: "meters",
+ areaUnit: "square_meters",
+ });
+ });
+ });
+ it("should ignore (!) feature's CRS", () => {
+ [
+ [0, 0, "0, 0"],
+ [45, 25, "45, 25"],
+ [180, 90, "180, 90"],
+ ].forEach(([x, y, label]) => {
+ feature.set("geometry", new Point([x, y]));
+ MeasureUtils.updateFeatureMeasurements(
+ feature, MeasGeomTypes.POINT, "EPSG:4326", settings
+ );
+ expect(feature.get("label")).toBe(label);
+ expect(feature.get("segment_labels")).toBeUndefined();
+ expect(feature.get("measurements")).toEqual({
+ lenUnit: "meters",
+ areaUnit: "square_meters",
+ });
+ });
+ });
+ it("should convert from map CRS to display CRS", () => {
+ [
+ [0, 0, "0, -0"],
+ [45, 25, "5009377, 2875745"],
+ [180, 90, "20037508, 238107693"],
+ ].forEach(([x, y, label]) => {
+ feature.set("geometry", new Point([x, y]));
+ MeasureUtils.updateFeatureMeasurements(
+ feature, MeasGeomTypes.POINT, "ignored", {
+ ...settings,
+ mapCrs: "EPSG:4326",
+ }
+ );
+ expect(feature.get("label")).toBe(label);
+ expect(feature.get("segment_labels")).toBeUndefined();
+ expect(feature.get("measurements")).toEqual({
+ lenUnit: "meters",
+ areaUnit: "square_meters",
+ });
+ });
+ });
+ });
+
+ describe("LineString", () => {
+ beforeEach(() => {
+ feature.set("geometry", new LineString(coords));
+ });
+ it("should update the feature", () => {
+ MeasureUtils.updateFeatureMeasurements(
+ feature, MeasGeomTypes.LINE_STRING, "EPSG:4326", settings
+ );
+ expect(feature.get("label")).toBe('');
+ expect(feature.get("segment_labels")).toEqual([
+ "111195.08 meters",
+ "111178.14 meters",
+ "111195.08 meters",
+ "111195.08 meters",
+ ]);
+ expect(feature.get("measurements")).toMatchCloseTo({
+ lenUnit: "meters",
+ areaUnit: "square_meters",
+ segment_lengths: [
+ 111195.08,
+ 111178.14,
+ 111195.08,
+ 111195.08,
+ ],
+ length: 444763.38,
+ }, 1);
+ });
+ });
+
+ describe("Polygon", () => {
+ beforeEach(() => {
+ feature.set("geometry", new Polygon([coords]));
+ });
+ it("should update the feature", () => {
+ MeasureUtils.updateFeatureMeasurements(
+ feature, MeasGeomTypes.POLYGON, "EPSG:4326", settings
+ );
+ expect(feature.get("label")).toBe('12363718145.18 square_meters');
+ expect(feature.get("measurements")).toMatchCloseTo({
+ lenUnit: "meters",
+ areaUnit: "square_meters",
+ area: 12363718145.179
+ }, 1);
+ });
+ });
+
+ describe('Circle', () => {
+ beforeEach(() => {
+ feature.set("geometry", new Circle([1, 1], 1));
+ });
+ it("should update the feature", () => {
+ MeasureUtils.updateFeatureMeasurements(
+ feature, MeasGeomTypes.CIRCLE, "EPSG:4326", settings
+ );
+ expect(feature.get("label")).toBe('r = 1.00 meters');
+ expect(feature.get("measurements")).toMatchCloseTo({
+ lenUnit: "meters",
+ areaUnit: "square_meters",
+ radius: 1
+ }, 1);
+ });
+ });
+
+ describe('Bearing', () => {
+ beforeEach(() => {
+ feature.set("geometry", new LineString([[0, 0], [1, 1]]));
+ });
+ it("should update the feature", () => {
+ MeasureUtils.updateFeatureMeasurements(
+ feature, MeasGeomTypes.BEARING, "EPSG:4326", settings
+ );
+ expect(feature.get("label")).toBe("N 44° 59' 44'' E");
+ expect(feature.get("measurements")).toMatchCloseTo({
+ lenUnit: "meters",
+ areaUnit: "square_meters",
+ bearing: 44.9956
+ }, 3);
+ });
+ });
+});
+
+
+describe("computeSegmentLengths", () => {
+ describe("geodesic", () => {
+ expect(MeasureUtils.computeSegmentLengths(
+ coords, "EPSG:4326", true
+ )).toBeDeepCloseTo([
+ 111195.0802,
+ 111178.1442,
+ 111195.0802,
+ 111195.0802,
+ ], 3);
+ expect(MeasureUtils.computeSegmentLengths(
+ coords, "EPSG:3857", true
+ )).toBeDeepCloseTo([
+ 0.99888,
+ 0.99888,
+ 0.99888,
+ 0.99888,
+ ], 4);
+ });
+ describe("degrees", () => {
+ expect(MeasureUtils.computeSegmentLengths(
+ coords, "EPSG:4326", false
+ )).toBeDeepCloseTo([
+ 111195.0802,
+ 111178.1442,
+ 111195.0802,
+ 111195.0802,
+ ], 3);
+
+ });
+ describe("feets", () => {
+ expect(MeasureUtils.computeSegmentLengths(
+ coords, feetCRS, false
+ )).toBeDeepCloseTo([
+ 0.3048,
+ 0.3048,
+ 0.3048,
+ 0.3048,
+ ], 4);
+ });
+ describe("meters", () => {
+ expect(MeasureUtils.computeSegmentLengths(
+ coords, "EPSG:3857", false
+ )).toBeDeepCloseTo([
+ 1,
+ 1,
+ 1,
+ 1,
+ ], 4);
+ });
+});
+
+
+describe("computeArea", () => {
+ describe("geodesic", () => {
+ it("should compute the area", () => {
+ expect(
+ MeasureUtils.computeArea(somePolygon, "EPSG:4326", true)
+ ).toBeCloseTo(12363718145.18, 2);
+ expect(
+ MeasureUtils.computeArea(somePolygon, "EPSG:3857", true)
+ ).toBeCloseTo(0.997766, 6);
+ });
+ });
+ describe("degrees", () => {
+ it("should compute the area", () => {
+ expect(
+ MeasureUtils.computeArea(somePolygon, "EPSG:4326", false)
+ ).toBeCloseTo(12363718145.18, 2);
+ });
+ });
+ describe("feets", () => {
+ it("should compute the area in feets", () => {
+ expect(
+ MeasureUtils.computeArea(somePolygon, feetCRS, false)
+ ).toBeCloseTo(0.09290, 5);
+ });
+ });
+ describe("meters", () => {
+ it("should compute the area in meters", () => {
+ expect(
+ MeasureUtils.computeArea(somePolygon, "EPSG:3857", false)
+ ).toBe(1);
+ });
+ });
+});
diff --git a/utils/MiscUtils.js b/utils/MiscUtils.js
index 2e57ae599..83d6c23d1 100644
--- a/utils/MiscUtils.js
+++ b/utils/MiscUtils.js
@@ -8,7 +8,20 @@
import ConfigUtils from './ConfigUtils';
+
+/**
+ * Various other utility functions.
+ *
+ * @namespace
+ */
const MiscUtils = {
+ /**
+ * Add anchor tags to URLs in text.
+ *
+ * @param {string} text - the text to process.
+ *
+ * @return {string} The processed text.
+ */
addLinkAnchors(text) {
// If text already contains tags, do nothing
const tagRegEx = /(<.[^(><.)]+>)/;
@@ -30,7 +43,11 @@ const MiscUtils = {
let match = null;
while ((match = urlRegEx.exec(value))) {
// If URL is part of a HTML attribute, don't add anchor
- if (value.substring(match.index - 2, match.index).match(/^=['"]$/) === null) {
+ if (
+ value.substring(
+ match.index - 2, match.index
+ ).match(/^=['"]$/) === null
+ ) {
const url = match[0].substr(match[1].length);
let protoUrl = url;
if (match[2] === undefined) {
@@ -41,8 +58,18 @@ const MiscUtils = {
}
}
const pos = match.index + match[1].length;
- const anchor = "" + MiscUtils.htmlEncode(url) + "";
- value = value.substring(0, pos) + anchor + value.substring(pos + url.length);
+ const anchor = (
+ "" +
+ MiscUtils.htmlEncode(url) +
+ ""
+ );
+ value = (
+ value.substring(0, pos) +
+ anchor +
+ value.substring(pos + url.length)
+ );
urlRegEx.lastIndex = pos + anchor.length;
}
}
@@ -50,6 +77,14 @@ const MiscUtils = {
urlRegEx.lastIndex = 0;
return value;
},
+
+ /**
+ * Encode HTML special characters in text.
+ *
+ * @param {string} text - the text to process.
+ *
+ * @return {string} The processed text.
+ */
htmlEncode(text) {
return text
.replace(/&/g, "&")
@@ -58,25 +93,76 @@ const MiscUtils = {
.replace(/"/g, """)
.replace(/'/g, "'");
},
+
+ /**
+ * Retrieve the CSRF token from the page.
+ *
+ * The function looks for a `meta` tag with name `csrf-token`
+ * and returns its `content` attribute.
+ *
+ * @return {string} The CSRF token if it exists or an
+ * empty string otherwise.
+ */
getCsrfToken() {
- const csrfTag = Array.from(document.getElementsByTagName('meta')).find(tag => tag.getAttribute('name') === "csrf-token");
+ const csrfTag = Array.from(
+ document.getElementsByTagName('meta')
+ ).find(
+ tag => tag.getAttribute('name') === "csrf-token"
+ );
return csrfTag ? csrfTag.getAttribute('content') : "";
},
+
+ /**
+ * Install a `touchmove` event handler on the given element
+ * to stop the event from propagating to the parent.
+ *
+ * We need to do this to stop touchmove propagating to
+ * parent which can trigger a swipe.
+ *
+ * @param {Element} el - the element to install the handler on.
+ */
setupKillTouchEvents(el) {
if (el) {
- // To stop touchmove propagating to parent which can trigger a swipe
- el.addEventListener('touchmove', (ev) => { ev.stopPropagation(); }, {passive: false});
+ el.addEventListener(
+ 'touchmove', (ev) => {
+ ev.stopPropagation();
+ }, {
+ passive: false
+ });
}
},
+
+ /**
+ * Stop the given event from propagating and prevent its default action.
+ */
killEvent(ev) {
if (ev.cancelable) {
ev.stopPropagation();
ev.preventDefault();
}
},
+
+ /**
+ * Average two colors.
+ *
+ * @param {string} color1 - the first color as a #RRGGBB string.
+ * @param {string} color2 - the second color as a #RRGGBB string.
+ * @param {number} ratio - the ratio of the first color
+ * relative to the second.
+ *
+ * @return {string} The average color as a #RRGGBB string.
+ */
blendColors(color1, color2, ratio) {
- color1 = [parseInt(color1[1] + color1[2], 16), parseInt(color1[3] + color1[4], 16), parseInt(color1[5] + color1[6], 16)];
- color2 = [parseInt(color2[1] + color2[2], 16), parseInt(color2[3] + color2[4], 16), parseInt(color2[5] + color2[6], 16)];
+ color1 = [
+ parseInt(color1[1] + color1[2], 16),
+ parseInt(color1[3] + color1[4], 16),
+ parseInt(color1[5] + color1[6], 16)
+ ];
+ color2 = [
+ parseInt(color2[1] + color2[2], 16),
+ parseInt(color2[3] + color2[4], 16),
+ parseInt(color2[5] + color2[6], 16)
+ ];
const color3 = [
(1 - ratio) * color1[0] + ratio * color2[0],
(1 - ratio) * color1[1] + ratio * color2[1],
@@ -85,6 +171,15 @@ const MiscUtils = {
const toHex = (num) => ("0" + Math.round(num).toString(16)).slice(-2);
return '#' + toHex(color3[0]) + toHex(color3[1]) + toHex(color3[2]);
},
+
+ /**
+ * Ensure that the given element is an array.
+ *
+ * @param {*} el - the element to ensure.
+ *
+ * @return {Array} an array containing the element or
+ * an empty array if the element is undefined.
+ */
ensureArray(el) {
if (el === undefined) {
return [];
@@ -93,18 +188,44 @@ const MiscUtils = {
}
return [el];
},
+
+ /**
+ * Capitalize the first letter of the given text.
+ *
+ * @param {string} text - the text to process.
+ *
+ * @return {string} The processed text.
+ */
capitalizeFirst(text) {
- return text.slice(0, 1).toUpperCase() + text.slice(1).toLowerCase();
+ return (
+ text.slice(0, 1).toUpperCase() +
+ text.slice(1).toLowerCase()
+ );
},
+
+ /**
+ * Check if the given color is bright.
+ */
isBrightColor(hex) {
- const color = +("0x" + hex.slice(1).replace(hex.length < 5 && /./g, '$&$&'));
+ const color = +(
+ "0x" + hex.slice(1).replace(hex.length < 5 && /./g, '$&$&')
+ );
const r = color >> 16;
const g = color >> 8 & 255;
const b = color & 255;
-
- const hsp = Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b));
+ const hsp = Math.sqrt(
+ 0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b)
+ );
return hsp > 127.5;
},
+
+ /**
+ * Adjust the protocol of the given URL to the current protocol.
+ *
+ * @param {string} url - the URL to adjust.
+ *
+ * @return {string} The adjusted URL.
+ */
adjustProtocol(url) {
if (location.protocol === 'https:' && url.startsWith('http:')) {
return 'https:' + url.substr(5);
diff --git a/utils/MiscUtils.test.js b/utils/MiscUtils.test.js
new file mode 100644
index 000000000..687fedf62
--- /dev/null
+++ b/utils/MiscUtils.test.js
@@ -0,0 +1,235 @@
+import MiscUtils from './MiscUtils';
+
+describe('addLinkAnchors', () => {
+ it("should do nothing if text already contains tags", () => {
+ const text = (
+ 'Test string with a link to https://www.google.com
'
+ );
+ expect(MiscUtils.addLinkAnchors(text)).toBe(text);
+ });
+
+ it("should only deal with anchors", () => {
+ const text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.";
+ expect(MiscUtils.addLinkAnchors(text)).toBe(text);
+ });
+
+ it('should return text with anchor tags', () => {
+ expect(MiscUtils.addLinkAnchors(
+ 'Test string with a link to https://www.google.com'
+ )).toBe(
+ 'Test string with a link to ' +
+ '' +
+ 'https://www.google.com' +
+ ''
+ );
+ });
+});
+
+
+describe('htmlEncode', () => {
+ it('should return text with HTML special characters encoded', () => {
+ expect(MiscUtils.htmlEncode(
+ 'Test string
'
+ )).toBe(
+ '<p>Test string</p>'
+ );
+ expect(MiscUtils.htmlEncode(
+ '"Test string"'
+ )).toBe(
+ '"Test string"'
+ );
+ expect(MiscUtils.htmlEncode(
+ "'Test string'"
+ )).toBe(
+ ''Test string''
+ );
+ expect(MiscUtils.htmlEncode(
+ "a & b < c > d \" e ' f"
+ )).toBe(
+ 'a & b < c > d " e ' f'
+ );
+ });
+});
+
+
+describe('getCsrfToken', () => {
+ describe('if there is a token', () => {
+ it('should find it', () => {
+ document.getElementsByTagName = jest.fn().mockReturnValue([
+ {
+ getAttribute: (name) => {
+ if (name === "name") {
+ return "csrf-token";
+ } else if (name === "content") {
+ return "test";
+ }
+ }
+ }
+ ]);
+ expect(MiscUtils.getCsrfToken()).toBe("test");
+ });
+ });
+ describe('if there no token', () => {
+ it('should return an empty string', () => {
+ document.getElementsByTagName = jest.fn().mockReturnValue([
+ {
+ getAttribute: (name) => {
+ if (name === "name") {
+ return "xxx";
+ } else if (name === "content") {
+ return "test";
+ }
+ }
+ }
+ ]);
+ expect(MiscUtils.getCsrfToken()).toBe("");
+ });
+ });
+});
+
+
+describe('setupKillTouchEvents', () => {
+ it('should install a touchmove handler', () => {
+ const el = {
+ addEventListener: jest.fn()
+ };
+ MiscUtils.setupKillTouchEvents(el);
+ expect(el.addEventListener).toHaveBeenCalledWith(
+ 'touchmove', expect.anything(), { passive: false }
+ );
+ });
+ it('should do nothing if element is null', () => {
+ MiscUtils.setupKillTouchEvents(null);
+ });
+});
+
+
+describe('killEvent', () => {
+ it('should stop event propagation and prevent default action', () => {
+ const ev = {
+ cancelable: true,
+ stopPropagation: jest.fn(),
+ preventDefault: jest.fn()
+ };
+ MiscUtils.killEvent(ev);
+ expect(ev.stopPropagation).toHaveBeenCalled();
+ expect(ev.preventDefault).toHaveBeenCalled();
+ });
+ it('should do nothing if event is not cancelable', () => {
+ const ev = {
+ cancelable: false,
+ stopPropagation: jest.fn(),
+ preventDefault: jest.fn()
+ };
+ MiscUtils.killEvent(ev);
+ expect(ev.stopPropagation).not.toHaveBeenCalled();
+ expect(ev.preventDefault).not.toHaveBeenCalled();
+ });
+});
+
+
+describe('blendColors', () => {
+ it('should blend two colors', () => {
+ expect(
+ MiscUtils.blendColors("#000000", "#ffffff", 0.5)
+ ).toBe("#808080");
+ expect(
+ MiscUtils.blendColors("#000000", "#ffffff", 0)
+ ).toBe("#000000");
+ expect(
+ MiscUtils.blendColors("#000000", "#ffffff", 1)
+ ).toBe("#ffffff");
+ });
+});
+
+
+describe('ensureArray', () => {
+ it('should create an empty array for undefined', () => {
+ expect(
+ MiscUtils.ensureArray(undefined)
+ ).toBeDeepCloseTo([]);
+
+ });
+ it('should create an array for a number', () => {
+ expect(
+ MiscUtils.ensureArray(1)
+ ).toBeDeepCloseTo([1]);
+ });
+ it('should return the array', () => {
+ const ary = [1, 2, 3];
+ expect(
+ MiscUtils.ensureArray(ary)
+ ).toBe(ary);
+ });
+});
+
+
+describe('capitalizeFirst', () => {
+ it('should capitalize the first letter', () => {
+ expect(
+ MiscUtils.capitalizeFirst("test")
+ ).toBe("Test");
+ });
+ it('should do nothing if already capitalized', () => {
+ expect(
+ MiscUtils.capitalizeFirst("Test")
+ ).toBe("Test");
+ });
+ it('should do nothing if empty', () => {
+ expect(
+ MiscUtils.capitalizeFirst("")
+ ).toBe("");
+ });
+});
+
+
+describe('isBrightColor', () => {
+ it('should return true for bright colors', () => {
+ expect(
+ MiscUtils.isBrightColor("#ffffff")
+ ).toBe(true);
+ expect(
+ MiscUtils.isBrightColor("#ff0000")
+ ).toBe(true);
+ expect(
+ MiscUtils.isBrightColor("#00ff00")
+ ).toBe(true);
+ });
+ it('should return false for dark colors', () => {
+ expect(
+ MiscUtils.isBrightColor("#0000ff")
+ ).toBe(false);
+ expect(
+ MiscUtils.isBrightColor("#000000")
+ ).toBe(false);
+ expect(
+ MiscUtils.isBrightColor("#800000")
+ ).toBe(false);
+ expect(
+ MiscUtils.isBrightColor("#008000")
+ ).toBe(false);
+ expect(
+ MiscUtils.isBrightColor("#000080")
+ ).toBe(false);
+ });
+});
+
+describe('adjustProtocol', () => {
+ it('should return the URL unchanged if it has no protocol', () => {
+ expect(
+ MiscUtils.adjustProtocol("test")
+ ).toBe("test");
+ });
+ it('should return the URL unchanged if it has the same protocol', () => {
+ location.protocol = 'http:';
+ expect(
+ MiscUtils.adjustProtocol("http://test")
+ ).toBe("http://test");
+ });
+ it('should return the URL with the current protocol', () => {
+ location.protocol = 'https:';
+ expect(
+ MiscUtils.adjustProtocol("http://test")
+ ).toBe("https://test");
+ });
+});
diff --git a/utils/PermaLinkUtils.js b/utils/PermaLinkUtils.js
index 151d2af09..6acec0a2d 100644
--- a/utils/PermaLinkUtils.js
+++ b/utils/PermaLinkUtils.js
@@ -8,12 +8,17 @@
import url from 'url';
import axios from 'axios';
-import {LayerRole} from '../actions/layers';
+import { LayerRole } from '../actions/layers';
import ConfigUtils from '../utils/ConfigUtils';
import LayerUtils from '../utils/LayerUtils';
let UrlQuery = {};
+
+/**
+ * Utilities for working with URL parameters.
+ * @namespace
+ */
export const UrlParams = {
updateParams(dict, forceLocationUrl = false) {
if (ConfigUtils.getConfigProp("omitUrlParameterUpdates") === true) {
@@ -29,7 +34,8 @@ export const UrlParams = {
return;
}
}
- // Timeout: avoid wierd issue where Firefox triggers a full reload when invoking history-replaceState directly
+ // Timeout: avoid weird issue where Firefox triggers a full
+ // reload when invoking history-replaceState directly
setTimeout(() => {
const urlObj = url.parse(window.location.href, true);
urlObj.query = Object.assign(urlObj.query, dict);
@@ -41,7 +47,7 @@ export const UrlParams = {
}
}
delete urlObj.search;
- history.replaceState({id: urlObj.host}, '', url.format(urlObj));
+ history.replaceState({ id: urlObj.host }, '', url.format(urlObj));
}, 0);
},
getParam(key) {
@@ -55,13 +61,17 @@ export const UrlParams = {
getParams() {
const query = url.parse(window.location.href, true).query;
if (ConfigUtils.getConfigProp("omitUrlParameterUpdates") === true) {
- return {...UrlQuery, ...query};
+ return { ...UrlQuery, ...query };
} else {
return query;
}
},
clear() {
- this.updateParams({k: undefined, t: undefined, l: undefined, bl: undefined, bk: undefined, c: undefined, s: undefined, e: undefined, crs: undefined, st: undefined, sp: undefined}, true);
+ this.updateParams({
+ k: undefined, t: undefined, l: undefined, bl: undefined,
+ bk: undefined, c: undefined, s: undefined, e: undefined,
+ crs: undefined, st: undefined, sp: undefined
+ }, true);
},
getFullUrl() {
if (ConfigUtils.getConfigProp("omitUrlParameterUpdates") === true) {
@@ -83,18 +93,31 @@ export function generatePermaLink(state, callback, user = false) {
}
const permalinkState = {};
if (ConfigUtils.getConfigProp("storeAllLayersInPermalink")) {
- permalinkState.layers = state.layers.flat.filter(layer => layer.role !== LayerRole.BACKGROUND);
+ permalinkState.layers = state.layers.flat.filter(
+ layer => layer.role !== LayerRole.BACKGROUND
+ );
} else {
// Only store redlining layers
- const exploded = LayerUtils.explodeLayers(state.layers.flat.filter(layer => layer.role !== LayerRole.BACKGROUND));
- const redliningLayers = exploded.map((entry, idx) => ({...entry, pos: idx}))
- .filter(entry => entry.layer.role === LayerRole.USERLAYER && entry.layer.type === 'vector')
- .map(entry => ({...entry.layer, pos: entry.pos}));
+ const exploded = LayerUtils.explodeLayers(
+ state.layers.flat.filter(
+ layer => layer.role !== LayerRole.BACKGROUND
+ )
+ );
+ const redliningLayers = exploded
+ .map((entry, idx) => ({ ...entry, pos: idx }))
+ .filter(entry => (
+ entry.layer.role === LayerRole.USERLAYER &&
+ entry.layer.type === 'vector'
+ ))
+ .map(entry => ({ ...entry.layer, pos: entry.pos }));
permalinkState.layers = redliningLayers;
}
permalinkState.url = fullUrl;
const route = user ? "userpermalink" : "createpermalink";
- axios.post(ConfigUtils.getConfigProp("permalinkServiceUrl").replace(/\/$/, '') + "/" + route, permalinkState)
+ const url = ConfigUtils.getConfigProp(
+ "permalinkServiceUrl"
+ ).replace(/\/$/, '') + "/" + route;
+ axios.post(url, permalinkState)
.then(response => callback(response.data.permalink || fullUrl))
.catch(() => callback(fullUrl));
}
@@ -102,18 +125,27 @@ export function generatePermaLink(state, callback, user = false) {
export function resolvePermaLink(initialParams, callback) {
const key = UrlParams.getParam('k');
const bkey = UrlParams.getParam('bk');
+ const url = ConfigUtils.getConfigProp(
+ "permalinkServiceUrl"
+ ).replace(/\/$/, '');
if (key) {
- axios.get(ConfigUtils.getConfigProp("permalinkServiceUrl").replace(/\/$/, '') + "/resolvepermalink?key=" + key)
+ axios.get(url + "/resolvepermalink?key=" + key)
.then(response => {
- callback({...initialParams, ...(response.data.query || {})}, response.data.state || {});
+ callback({
+ ...initialParams,
+ ...(response.data.query || {})
+ }, response.data.state || {});
})
.catch(() => {
callback(initialParams, {});
});
} else if (bkey) {
- axios.get(ConfigUtils.getConfigProp("permalinkServiceUrl").replace(/\/$/, '') + "/bookmarks/" + bkey)
+ axios.get(url + "/bookmarks/" + bkey)
.then(response => {
- callback({...initialParams, ...(response.data.query || {})}, response.data.state || {});
+ callback({
+ ...initialParams,
+ ...(response.data.query || {})
+ }, response.data.state || {});
})
.catch(() => {
callback(initialParams, {});
@@ -125,7 +157,10 @@ export function resolvePermaLink(initialParams, callback) {
export function getUserBookmarks(user, callback) {
if (user) {
- axios.get(ConfigUtils.getConfigProp("permalinkServiceUrl").replace(/\/$/, '') + "/bookmarks/")
+ const url = ConfigUtils.getConfigProp(
+ "permalinkServiceUrl"
+ ).replace(/\/$/, '');
+ axios.get(url + "/bookmarks/")
.then(response => {
callback(response.data || []);
})
@@ -137,7 +172,10 @@ export function getUserBookmarks(user, callback) {
export function removeBookmark(bkey, callback) {
if (bkey) {
- axios.delete(ConfigUtils.getConfigProp("permalinkServiceUrl").replace(/\/$/, '') + "/bookmarks/" + bkey)
+ const url = ConfigUtils.getConfigProp(
+ "permalinkServiceUrl"
+ ).replace(/\/$/, '');
+ axios.delete(url + "/bookmarks/" + bkey)
.then(() => {
callback(true);
}).catch(() => callback(false));
@@ -150,19 +188,29 @@ export function createBookmark(state, description, callback) {
return;
}
// Only store redlining layers
- const exploded = LayerUtils.explodeLayers(state.layers.flat.filter(layer => layer.role !== LayerRole.BACKGROUND));
+ const exploded = LayerUtils.explodeLayers(
+ state.layers.flat.filter(layer => layer.role !== LayerRole.BACKGROUND)
+ );
const bookmarkState = {};
if (ConfigUtils.getConfigProp("storeAllLayersInPermalink")) {
- bookmarkState.layers = state.layers.flat.filter(layer => layer.role !== LayerRole.BACKGROUND);
+ bookmarkState.layers = state.layers.flat.filter(
+ layer => layer.role !== LayerRole.BACKGROUND
+ );
} else {
- const redliningLayers = exploded.map((entry, idx) => ({...entry, pos: idx}))
- .filter(entry => entry.layer.role === LayerRole.USERLAYER && entry.layer.type === 'vector')
- .map(entry => ({...entry.layer, pos: entry.pos}));
+ const redliningLayers = exploded
+ .map((entry, idx) => ({ ...entry, pos: idx }))
+ .filter(entry => (
+ entry.layer.role === LayerRole.USERLAYER &&
+ entry.layer.type === 'vector'
+ ))
+ .map(entry => ({ ...entry.layer, pos: entry.pos }));
bookmarkState.layers = redliningLayers;
}
bookmarkState.url = UrlParams.getFullUrl();
- axios.post(ConfigUtils.getConfigProp("permalinkServiceUrl").replace(/\/$/, '') + "/bookmarks/" +
- "?description=" + description, bookmarkState)
+ const url = ConfigUtils.getConfigProp(
+ "permalinkServiceUrl"
+ ).replace(/\/$/, '');
+ axios.post(url + "/bookmarks/?description=" + description, bookmarkState)
.then(() => callback(true))
.catch(() => callback(false));
}
@@ -173,19 +221,48 @@ export function updateBookmark(state, bkey, description, callback) {
return;
}
// Only store redlining layers
- const exploded = LayerUtils.explodeLayers(state.layers.flat.filter(layer => layer.role !== LayerRole.BACKGROUND));
+ const exploded = LayerUtils.explodeLayers(
+ state.layers.flat.filter(layer => layer.role !== LayerRole.BACKGROUND)
+ );
const bookmarkState = {};
if (ConfigUtils.getConfigProp("storeAllLayersInPermalink")) {
- bookmarkState.layers = state.layers.flat.filter(layer => layer.role !== LayerRole.BACKGROUND);
+ bookmarkState.layers = state.layers.flat.filter(
+ layer => layer.role !== LayerRole.BACKGROUND
+ );
} else {
- const redliningLayers = exploded.map((entry, idx) => ({...entry, pos: idx}))
- .filter(entry => entry.layer.role === LayerRole.USERLAYER && entry.layer.type === 'vector')
- .map(entry => ({...entry.layer, pos: entry.pos}));
+ const redliningLayers = exploded
+ .map((entry, idx) => ({ ...entry, pos: idx }))
+ .filter(entry => (
+ entry.layer.role === LayerRole.USERLAYER &&
+ entry.layer.type === 'vector'
+ ))
+ .map(entry => ({ ...entry.layer, pos: entry.pos }));
bookmarkState.layers = redliningLayers;
}
bookmarkState.url = UrlParams.getFullUrl();
- axios.put(ConfigUtils.getConfigProp("permalinkServiceUrl").replace(/\/$/, '') + "/bookmarks/" + bkey +
+ const url = ConfigUtils.getConfigProp(
+ "permalinkServiceUrl"
+ ).replace(/\/$/, '');
+ axios.put(url + "/bookmarks/" + bkey +
"?description=" + description, bookmarkState)
.then(() => callback(true))
.catch(() => callback(false));
}
+
+
+/**
+ * Utility functions for working with permanent links.
+ *
+ * @namespace
+ */
+const PermaLinkUtils = {
+ UrlParams,
+ generatePermaLink,
+ resolvePermaLink,
+ getUserBookmarks,
+ removeBookmark,
+ createBookmark,
+ updateBookmark
+};
+
+export default PermaLinkUtils;
diff --git a/utils/PermaLinkUtils.test.js b/utils/PermaLinkUtils.test.js
new file mode 100644
index 000000000..5d3189aad
--- /dev/null
+++ b/utils/PermaLinkUtils.test.js
@@ -0,0 +1,121 @@
+import mockAxios from 'jest-mock-axios';
+
+import { UrlParams, generatePermaLink } from "./PermaLinkUtils";
+
+let mockPermalinkServiceUrl = '';
+let mockStoreAllLayersInPermalink = false;
+let mockOmitUrlParameterUpdates = false;
+jest.mock("./ConfigUtils", () => ({
+ __esModule: true,
+ default: {
+ getConfigProp: (name) => {
+ if (name === 'permalinkServiceUrl') {
+ return mockPermalinkServiceUrl;
+ } else if (name === 'storeAllLayersInPermalink') {
+ return mockStoreAllLayersInPermalink;
+ } else if (name === 'omitUrlParameterUpdates') {
+ return mockOmitUrlParameterUpdates;
+ }
+ },
+ },
+}));
+
+
+afterEach(() => {
+ mockAxios.reset();
+});
+
+
+describe("generatePermaLink", () => {
+ beforeEach(() => {
+ jest.spyOn(UrlParams, 'getFullUrl').mockReturnValue('bar');
+ });
+ it("calls the callback if permalinkServiceUrl is not set", () => {
+ mockPermalinkServiceUrl = '';
+ const callback = jest.fn();
+ generatePermaLink({}, callback);
+ expect(callback).toHaveBeenCalledWith("bar");
+ });
+ it("should generate the link", () => {
+ mockPermalinkServiceUrl = "permalink-service-url";
+ mockStoreAllLayersInPermalink = true;
+ const callback = jest.fn();
+ generatePermaLink({
+ layers: {
+ flat: []
+ }
+ }, callback);
+
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ "permalink-service-url/createpermalink", {
+ "layers": [],
+ "url": "bar",
+ });
+ mockAxios.mockResponse({
+ data: {
+ permalink: "foo-bar"
+ }
+ });
+ expect(callback).toHaveBeenCalledWith("foo-bar");
+ });
+ it("should silently ignore network errors", () => {
+ mockAxios.post.mockRejectedValueOnce({});
+ mockPermalinkServiceUrl = "permalink-service-url";
+ mockStoreAllLayersInPermalink = true;
+ const callback = jest.fn((value) => {
+ expect(value).toBe("bar");
+ });
+ generatePermaLink({
+ layers: {
+ flat: []
+ }
+ }, callback);
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ "permalink-service-url/createpermalink", {
+ "layers": [],
+ "url": "bar-bar",
+ });
+ });
+});
+
+describe("resolvePermaLink", () => {
+
+});
+
+describe("getUserBookmarks", () => {
+
+});
+
+describe("removeBookmark", () => {
+
+});
+
+describe("createBookmark", () => {
+
+});
+
+describe("updateBookmar", () => {
+
+});
+
+describe("UrlParams", () => {
+ describe("clear", () => {
+
+ });
+
+ describe("getFullUrl", () => {
+
+ });
+
+ describe("getParam", () => {
+
+ });
+
+ describe("getParams", () => {
+
+ });
+
+ describe("updateParams", () => {
+
+ });
+});
diff --git a/utils/RoutingInterface.js b/utils/RoutingInterface.js
index a162fccba..ee6addb5d 100644
--- a/utils/RoutingInterface.js
+++ b/utils/RoutingInterface.js
@@ -8,7 +8,7 @@
import axios from 'axios';
-import {v1 as uuidv1} from 'uuid';
+import { v1 as uuidv1 } from 'uuid';
import ConfigUtils from './ConfigUtils';
import LocaleUtils from './LocaleUtils';
import VectorLayerUtils from './VectorLayerUtils';
@@ -120,8 +120,8 @@ function getValhallaParams(costing, locations, options, extraOptions) {
costing: costing,
costing_options: costingOptions,
exclude_polygons: options.exclude_polygons || [],
- locations: locations.map(loc => ({lon: loc[0], lat: loc[1]})),
- directions_options: {units: "kilometers", language: LocaleUtils.lang()},
+ locations: locations.map(loc => ({ lon: loc[0], lat: loc[1] })),
+ directions_options: { units: "kilometers", language: LocaleUtils.lang() },
...extraOptions
};
return {
@@ -129,107 +129,70 @@ function getValhallaParams(costing, locations, options, extraOptions) {
};
}
-function computeRoute(costing, locations, options, callback) {
- const extraOptions = {
- id: "valhalla_directions"
- };
- const params = getValhallaParams(costing, locations, options, extraOptions);
- const serviceUrl = ConfigUtils.getConfigProp("routingServiceUrl").replace(/\/$/, '');
- const endpoint = options.optimized_route ? 'optimized_route' : 'route';
- axios.get(serviceUrl + '/' + endpoint, {params}).then(response => {
- if (!response.data || !response.data.trip) {
- callback(false, {errorMsgId: LocaleUtils.trmsg("routing.computefailed")});
- return;
- }
- const trip = response.data.trip;
- if (trip.status !== 0) {
- callback(false, response.data.trip.status_message);
- }
- // https://valhalla.github.io/valhalla/api/turn-by-turn/api-reference/
- const travelTypeMap = {
- car: {icon: "routing-car", color: [0, 0, 255, 1]},
- tractor_trailer: {icon: "routing-truck", color: [0, 0, 255, 1]},
- foot: {icon: "routing-walking", color: [127, 127, 255, 1]},
- road: {icon: "routing-bicycle", color: [0, 127, 0, 1]},
- tram: {icon: "routing-tram", color: [255, 0, 0, 1]},
- metro: {icon: "routing-tram", color: [255, 0, 0, 1]},
- rail: {icon: "routing-train", color: [255, 0, 0, 1]},
- bus: {icon: "routing-bus", color: [255, 0, 0, 1]},
- ferry: {icon: "routing-ship", color: [0, 0, 200, 1]},
- cable_car: {icon: "routing-cablecar", color: [255, 0, 0, 1]},
- gondola: {icon: "routing-cablecar", color: [255, 0, 0, 1]},
- funicular: {icon: "routing-cablecar", color: [255, 0, 0, 1]}
- };
- const result = {
- legs: trip.legs.map(leg => {
- return {
- coordinates: decodeShape(leg.shape),
- time: leg.summary.time,
- length: leg.summary.length * 1000,
- maneuvers: leg.maneuvers.map(entry => ({
- instruction: entry.instruction,
- post_instruction: entry.verbal_post_transition_instruction,
- geom_indices: [entry.begin_shape_index, entry.end_shape_index],
- icon: (travelTypeMap[entry.travel_type] || {}).icon || "routing",
- color: (travelTypeMap[entry.travel_type] || {}).color || "#0000FF",
- time: entry.time,
- length: entry.length * 1000
- }))
- };
- }),
- summary: {
- bounds: [trip.summary.min_lon, trip.summary.min_lat, trip.summary.max_lon, trip.summary.max_lat],
- time: trip.summary.time,
- length: trip.summary.length * 1000
- }
- };
- callback(true, result);
- }).catch((e) => {
- const error = ((e.response || {}).data || {}).error;
- const data = {};
- if (error) {
- data.error = error;
- } else {
- data.errorMsgId = LocaleUtils.trmsg("routing.computefailed");
- }
- callback(false, data);
- });
-}
-function computeIsochrone(costing, locations, contourOptions, options, callback) {
- const extraOptions = {
- contours: contourOptions.intervals.map(entry => ({[contourOptions.mode]: entry})),
- id: "valhalla_isochrone"
- };
- const serviceUrl = ConfigUtils.getConfigProp("routingServiceUrl").replace(/\/$/, '');
- const reqId = uuidv1();
- ValhallaSession.reqId = reqId;
- ValhallaSession.pending = locations.length;
- locations.forEach(location => {
- const params = getValhallaParams(costing, [location], options, extraOptions);
-
- axios.get(serviceUrl + '/isochrone', {params}).then(response => {
- if (reqId !== ValhallaSession.reqId) {
- return;
- }
- if (!response.data || !response.data.features) {
- ValhallaSession.clear();
- callback(false, {errorMsgId: LocaleUtils.trmsg("routing.computefailed")});
+/**
+ * Utility functions for routing.
+ *
+ * @namespace
+ */
+const RoutingInterface = {
+
+ computeRoute(costing, locations, options, callback) {
+ const extraOptions = {
+ id: "valhalla_directions"
+ };
+ const params = getValhallaParams(costing, locations, options, extraOptions);
+ const serviceUrl = ConfigUtils.getConfigProp("routingServiceUrl").replace(/\/$/, '');
+ const endpoint = options.optimized_route ? 'optimized_route' : 'route';
+ axios.get(serviceUrl + '/' + endpoint, { params }).then(response => {
+ if (!response.data || !response.data.trip) {
+ callback(false, { errorMsgId: LocaleUtils.trmsg("routing.computefailed") });
return;
}
- ValhallaSession.pending -= 1;
- if (!ValhallaSession.result) {
- ValhallaSession.result = response.data;
- } else {
- ValhallaSession.result.features.push(...response.data.features);
- }
- if (ValhallaSession.pending === 0) {
- const areas = ValhallaSession.result.features.map(feature => feature.geometry.coordinates);
- callback(true, {areas: areas, bounds: VectorLayerUtils.computeFeatureBBox(ValhallaSession.result)});
- ValhallaSession.clear();
+ const trip = response.data.trip;
+ if (trip.status !== 0) {
+ callback(false, response.data.trip.status_message);
}
+ // https://valhalla.github.io/valhalla/api/turn-by-turn/api-reference/
+ const travelTypeMap = {
+ car: { icon: "routing-car", color: [0, 0, 255, 1] },
+ tractor_trailer: { icon: "routing-truck", color: [0, 0, 255, 1] },
+ foot: { icon: "routing-walking", color: [127, 127, 255, 1] },
+ road: { icon: "routing-bicycle", color: [0, 127, 0, 1] },
+ tram: { icon: "routing-tram", color: [255, 0, 0, 1] },
+ metro: { icon: "routing-tram", color: [255, 0, 0, 1] },
+ rail: { icon: "routing-train", color: [255, 0, 0, 1] },
+ bus: { icon: "routing-bus", color: [255, 0, 0, 1] },
+ ferry: { icon: "routing-ship", color: [0, 0, 200, 1] },
+ cable_car: { icon: "routing-cablecar", color: [255, 0, 0, 1] },
+ gondola: { icon: "routing-cablecar", color: [255, 0, 0, 1] },
+ funicular: { icon: "routing-cablecar", color: [255, 0, 0, 1] }
+ };
+ const result = {
+ legs: trip.legs.map(leg => {
+ return {
+ coordinates: decodeShape(leg.shape),
+ time: leg.summary.time,
+ length: leg.summary.length * 1000,
+ maneuvers: leg.maneuvers.map(entry => ({
+ instruction: entry.instruction,
+ post_instruction: entry.verbal_post_transition_instruction,
+ geom_indices: [entry.begin_shape_index, entry.end_shape_index],
+ icon: (travelTypeMap[entry.travel_type] || {}).icon || "routing",
+ color: (travelTypeMap[entry.travel_type] || {}).color || "#0000FF",
+ time: entry.time,
+ length: entry.length * 1000
+ }))
+ };
+ }),
+ summary: {
+ bounds: [trip.summary.min_lon, trip.summary.min_lat, trip.summary.max_lon, trip.summary.max_lat],
+ time: trip.summary.time,
+ length: trip.summary.length * 1000
+ }
+ };
+ callback(true, result);
}).catch((e) => {
- ValhallaSession.clear();
const error = ((e.response || {}).data || {}).error;
const data = {};
if (error) {
@@ -239,10 +202,53 @@ function computeIsochrone(costing, locations, contourOptions, options, callback)
}
callback(false, data);
});
- });
-}
+ },
-export default {
- computeRoute,
- computeIsochrone
+ computeIsochrone(costing, locations, contourOptions, options, callback) {
+ const extraOptions = {
+ contours: contourOptions.intervals.map(entry => ({ [contourOptions.mode]: entry })),
+ id: "valhalla_isochrone"
+ };
+ const serviceUrl = ConfigUtils.getConfigProp("routingServiceUrl").replace(/\/$/, '');
+ const reqId = uuidv1();
+ ValhallaSession.reqId = reqId;
+ ValhallaSession.pending = locations.length;
+ locations.forEach(location => {
+ const params = getValhallaParams(costing, [location], options, extraOptions);
+
+ axios.get(serviceUrl + '/isochrone', { params }).then(response => {
+ if (reqId !== ValhallaSession.reqId) {
+ return;
+ }
+ if (!response.data || !response.data.features) {
+ ValhallaSession.clear();
+ callback(false, { errorMsgId: LocaleUtils.trmsg("routing.computefailed") });
+ return;
+ }
+ ValhallaSession.pending -= 1;
+ if (!ValhallaSession.result) {
+ ValhallaSession.result = response.data;
+ } else {
+ ValhallaSession.result.features.push(...response.data.features);
+ }
+ if (ValhallaSession.pending === 0) {
+ const areas = ValhallaSession.result.features.map(feature => feature.geometry.coordinates);
+ callback(true, { areas: areas, bounds: VectorLayerUtils.computeFeatureBBox(ValhallaSession.result) });
+ ValhallaSession.clear();
+ }
+ }).catch((e) => {
+ ValhallaSession.clear();
+ const error = ((e.response || {}).data || {}).error;
+ const data = {};
+ if (error) {
+ data.error = error;
+ } else {
+ data.errorMsgId = LocaleUtils.trmsg("routing.computefailed");
+ }
+ callback(false, data);
+ });
+ });
+ }
};
+
+export default RoutingInterface;
diff --git a/utils/RoutingInterface.test.js b/utils/RoutingInterface.test.js
new file mode 100644
index 000000000..f9e921360
--- /dev/null
+++ b/utils/RoutingInterface.test.js
@@ -0,0 +1,10 @@
+import RoutingInterface from './RoutingInterface';
+
+
+describe("computeRoute", () => {
+
+});
+
+describe("computeIsochron", () => {
+
+});
diff --git a/utils/ServiceLayerUtils.js b/utils/ServiceLayerUtils.js
index a3a8bc2a0..10b2fb26c 100644
--- a/utils/ServiceLayerUtils.js
+++ b/utils/ServiceLayerUtils.js
@@ -10,14 +10,14 @@ import ol from 'openlayers';
import axios from 'axios';
import deepmerge from 'deepmerge';
import isEmpty from 'lodash.isempty';
-import {XMLParser} from 'fast-xml-parser';
+import { XMLParser } from 'fast-xml-parser';
import randomColor from 'randomcolor';
import url from 'url';
import ConfigUtils from './ConfigUtils';
import CoordinatesUtils from './CoordinatesUtils';
import LayerUtils from './LayerUtils';
import MiscUtils from './MiscUtils';
-import {LayerRole} from '../actions/layers';
+import { LayerRole } from '../actions/layers';
function strcmp(a, b) {
const al = a.toLowerCase();
@@ -34,6 +34,12 @@ function array(obj) {
return Array.isArray(obj) ? obj : [obj];
}
+
+/**
+ * Utility functions for service layers.
+ *
+ * @namespace
+ */
const ServiceLayerUtils = {
getDCPTypes(dcpTypes) {
let result = {};
@@ -45,7 +51,8 @@ const ServiceLayerUtils = {
getWMTSLayers(capabilitiesXml, capabilitiesUrl, mapCrs) {
const wmtsFormat = new ol.format.WMTSCapabilities();
const capabilities = wmtsFormat.read(capabilitiesXml);
- const tileMatrices = capabilities.Contents.TileMatrixSet.reduce((res, entry) => {
+ const TileMatrixSet = capabilities.Contents.TileMatrixSet;
+ const tileMatrices = TileMatrixSet.reduce((res, entry) => {
const crsMatch = entry.SupportedCRS.match(/(EPSG).*:(\d+)/i);
res[entry.Identifier] = {
crs: crsMatch ? "EPSG:" + crsMatch[2] : entry.SupportedCRS,
@@ -54,18 +61,33 @@ const ServiceLayerUtils = {
return res;
}, {});
const layers = capabilities.Contents.Layer.map(layer => {
- const matchingMatrix = layer.TileMatrixSetLink.find(link => tileMatrices[link.TileMatrixSet].crs === mapCrs);
- const tileMatrixSet = matchingMatrix ? matchingMatrix.TileMatrixSet : layer.TileMatrixSetLink[0].TileMatrixSet;
+ const matchingMatrix = layer.TileMatrixSetLink.find(
+ link => tileMatrices[link.TileMatrixSet].crs === mapCrs
+ );
+ const tileMatrixSet = matchingMatrix
+ ? matchingMatrix.TileMatrixSet
+ : layer.TileMatrixSetLink[0].TileMatrixSet;
const topMatrix = tileMatrices[tileMatrixSet].matrix[0];
- let origin = [topMatrix.TopLeftCorner[0], topMatrix.TopLeftCorner[1]];
+ let origin = [
+ topMatrix.TopLeftCorner[0],
+ topMatrix.TopLeftCorner[1]
+ ];
try {
- const axisOrder = CoordinatesUtils.getAxisOrder(tileMatrices[tileMatrixSet].crs).substr(0, 2);
- if (axisOrder === 'ne') {
- origin = [topMatrix.TopLeftCorner[1], topMatrix.TopLeftCorner[0]];
+ const axisOrder = CoordinatesUtils.getAxisOrder(
+ tileMatrices[tileMatrixSet].crs
+ ).substr(0, 2);
+ if (axisOrder === 'ne') {
+ origin = [
+ topMatrix.TopLeftCorner[1],
+ topMatrix.TopLeftCorner[0]
+ ];
}
} catch (e) {
// eslint-disable-next-line
- console.warn("Could not determine axis order for projection " + tileMatrices[tileMatrixSet].crs);
+ console.warn(
+ "Could not determine axis order for projection " +
+ tileMatrices[tileMatrixSet].crs
+ );
return null;
}
const resolutions = tileMatrices[tileMatrixSet].matrix.map(entry => {
@@ -73,17 +95,31 @@ const ServiceLayerUtils = {
return entry.ScaleDenominator * 0.00028;
});
const styles = MiscUtils.ensureArray(layer.Style || []);
- const style = (styles.find(entry => entry.isDefault) || styles[0] || {Identifier: ""}).Identifier;
- const getTile = MiscUtils.ensureArray(capabilities.OperationsMetadata.GetTile.DCP.HTTP.Get)[0];
- const getEncoding = MiscUtils.ensureArray(getTile.Constraint).find(c => c.name === "GetEncoding");
- const requestEncoding = MiscUtils.ensureArray(getEncoding.AllowedValues.Value)[0];
+ const style = (
+ styles.find(entry => entry.isDefault) ||
+ styles[0] ||
+ { Identifier: "" }
+ ).Identifier;
+ const getTile = MiscUtils.ensureArray(
+ capabilities.OperationsMetadata.GetTile.DCP.HTTP.Get
+ )[0];
+ const getEncoding = MiscUtils.ensureArray(
+ getTile.Constraint
+ ).find(c => c.name === "GetEncoding");
+ const requestEncoding = MiscUtils.ensureArray(
+ getEncoding.AllowedValues.Value
+ )[0];
let serviceUrl = null;
if (requestEncoding === 'KVP') {
serviceUrl = getTile.href;
} else {
- serviceUrl = layer.ResourceURL.find(u => u.resourceType === "tile").template;
+ serviceUrl = layer.ResourceURL.find(
+ u => u.resourceType === "tile"
+ ).template;
(layer.Dimension || []).forEach(dim => {
- serviceUrl = serviceUrl.replace("{" + dim.Identifier + "}", dim.Default);
+ serviceUrl = serviceUrl.replace(
+ "{" + dim.Identifier + "}", dim.Default
+ );
});
}
return {
@@ -111,8 +147,15 @@ const ServiceLayerUtils = {
resolutions: resolutions,
abstract: layer.Abstract,
attribution: {
- Title: capabilities.ServiceProvider?.ProviderName || capabilities.ServiceIdentification?.Title || "",
- OnlineResource: capabilities.ServiceProvider?.ProviderSite || ""
+ Title: (
+ capabilities.ServiceProvider?.ProviderName ||
+ capabilities.ServiceIdentification?.Title ||
+ ""
+ ),
+ OnlineResource: (
+ capabilities.ServiceProvider?.ProviderSite
+ || ""
+ )
}
};
}).filter(Boolean);
@@ -126,21 +169,32 @@ const ServiceLayerUtils = {
const extwmsparams = {};
calledUrlParts.query = Object.keys(calledUrlParts.query).filter(key => {
// Extract extwms params
- if ( key.startsWith("extwms.") ) {
+ if (key.startsWith("extwms.")) {
extwmsparams[key.substring(7)] = calledUrlParts.query[key];
return false;
}
- // Filter service and request from calledServiceUrl, but keep other parameters (i.e. MAP)
+ // Filter service and request from calledServiceUrl,
+ // but keep other parameters (i.e. MAP)
return !["service", "request"].includes(key.toLowerCase());
- }).reduce((res, key) => ({...res, [key]: calledUrlParts.query[key]}), {});
+ }).reduce((res, key) => ({
+ ...res,
+ [key]: calledUrlParts.query[key]
+ }), {});
delete calledUrlParts.search;
const topLayer = capabilities.Capability.Layer;
- const getMapUrl = this.mergeCalledServiceUrlQuery(ServiceLayerUtils.getDCPTypes(capabilities.Capability.Request.GetMap.DCPType).HTTP.Get.OnlineResource, calledUrlParts);
+ const getMapUrl = this.mergeCalledServiceUrlQuery(
+ ServiceLayerUtils.getDCPTypes(
+ capabilities.Capability.Request.GetMap.DCPType
+ ).HTTP.Get.OnlineResource, calledUrlParts);
let featureInfoUrl = getMapUrl;
try {
- featureInfoUrl = this.mergeCalledServiceUrlQuery(ServiceLayerUtils.getDCPTypes(capabilities.Capability.Request.GetFeatureInfo.DCPType).HTTP.Get.OnlineResource, calledUrlParts);
+ featureInfoUrl = this.mergeCalledServiceUrlQuery(
+ ServiceLayerUtils.getDCPTypes(
+ capabilities.Capability.Request.GetFeatureInfo.DCPType
+ ).HTTP.Get.OnlineResource, calledUrlParts
+ );
} catch (e) {
// pass
}
@@ -150,7 +204,9 @@ const ServiceLayerUtils = {
} catch (e) {
infoFormats = ['text/plain'];
}
- const externalLayerFeatureInfoFormats = ConfigUtils.getConfigProp("externalLayerFeatureInfoFormats") || {};
+ const externalLayerFeatureInfoFormats = ConfigUtils.getConfigProp(
+ "externalLayerFeatureInfoFormats"
+ ) || {};
for (const entry of Object.keys(externalLayerFeatureInfoFormats)) {
if (featureInfoUrl.toLowerCase().includes(entry.toLowerCase())) {
infoFormats = [externalLayerFeatureInfoFormats[entry]];
@@ -159,13 +215,26 @@ const ServiceLayerUtils = {
}
const version = capabilities.version;
if (!topLayer.Layer || asGroup) {
- return [this.getWMSLayerParams(topLayer, topLayer.CRS, calledUrlParts, version, getMapUrl, featureInfoUrl, infoFormats, extwmsparams)].filter(entry => entry);
+ return [
+ this.getWMSLayerParams(
+ topLayer, topLayer.CRS, calledUrlParts, version,
+ getMapUrl, featureInfoUrl, infoFormats, extwmsparams
+ )
+ ].filter(entry => entry);
} else {
- const entries = topLayer.Layer.map(layer => this.getWMSLayerParams(layer, topLayer.CRS, calledUrlParts, version, getMapUrl, featureInfoUrl, infoFormats, extwmsparams)).filter(entry => entry);
+ const entries = topLayer.Layer.map(
+ layer => this.getWMSLayerParams(
+ layer, topLayer.CRS, calledUrlParts, version,
+ getMapUrl, featureInfoUrl, infoFormats, extwmsparams
+ )
+ ).filter(entry => entry);
return entries.sort((a, b) => strcmp(a.title, b.title));
}
},
- getWMSLayerParams(layer, parentCrs, calledUrlParts, version, getMapUrl, featureInfoUrl, infoFormats, extwmsparams, groupbbox = null) {
+ getWMSLayerParams(
+ layer, parentCrs, calledUrlParts, version, getMapUrl,
+ featureInfoUrl, infoFormats, extwmsparams, groupbbox = null
+ ) {
let supportedCrs = layer.CRS;
if (isEmpty(supportedCrs)) {
supportedCrs = [...(parentCrs || [])];
@@ -175,7 +244,13 @@ const ServiceLayerUtils = {
let sublayers = [];
const sublayerbounds = {};
if (!isEmpty(layer.Layer)) {
- sublayers = layer.Layer.map(sublayer => this.getWMSLayerParams(sublayer, supportedCrs, calledUrlParts, version, getMapUrl, featureInfoUrl, infoFormats, extwmsparams, sublayerbounds)).filter(entry => entry);
+ sublayers = layer.Layer.map(
+ sublayer => this.getWMSLayerParams(
+ sublayer, supportedCrs, calledUrlParts, version,
+ getMapUrl, featureInfoUrl, infoFormats, extwmsparams,
+ sublayerbounds
+ )
+ ).filter(entry => entry);
}
let bbox = null;
if (isEmpty(layer.BoundingBox)) {
@@ -194,15 +269,25 @@ const ServiceLayerUtils = {
if (isEmpty(groupbbox)) {
Object.assign(groupbbox, bbox);
} else if (bbox.crs === groupbbox.crs) {
- groupbbox.bounds[0] = Math.min(bbox.bounds[0], groupbbox.bounds[0]);
- groupbbox.bounds[1] = Math.min(bbox.bounds[1], groupbbox.bounds[1]);
- groupbbox.bounds[2] = Math.max(bbox.bounds[2], groupbbox.bounds[2]);
- groupbbox.bounds[3] = Math.max(bbox.bounds[3], groupbbox.bounds[3]);
+ groupbbox.bounds[0] = Math.min(
+ bbox.bounds[0], groupbbox.bounds[0]
+ );
+ groupbbox.bounds[1] = Math.min(
+ bbox.bounds[1], groupbbox.bounds[1]
+ );
+ groupbbox.bounds[2] = Math.max(
+ bbox.bounds[2], groupbbox.bounds[2]
+ );
+ groupbbox.bounds[3] = Math.max(
+ bbox.bounds[3], groupbbox.bounds[3]
+ );
}
}
let legendUrl = getMapUrl;
try {
- legendUrl = this.mergeCalledServiceUrlQuery(layer.Style[0].LegendURL[0].OnlineResource, calledUrlParts);
+ legendUrl = this.mergeCalledServiceUrlQuery(
+ layer.Style[0].LegendURL[0].OnlineResource, calledUrlParts
+ );
} catch (e) {
/* pass */
}
@@ -235,8 +320,13 @@ const ServiceLayerUtils = {
try {
const urlParts = url.parse(capabilityUrl, true);
urlParts.host = calledServiceUrlParts.host;
- urlParts.protocol = calledServiceUrlParts.protocol ?? location.protocol;
- urlParts.query = {...calledServiceUrlParts.query, ...urlParts.query};
+ urlParts.protocol = (
+ calledServiceUrlParts.protocol ?? location.protocol
+ );
+ urlParts.query = {
+ ...calledServiceUrlParts.query,
+ ...urlParts.query
+ };
delete urlParts.search;
return url.format(urlParts);
} catch (e) {
@@ -245,10 +335,15 @@ const ServiceLayerUtils = {
},
getWFSLayers(capabilitiesXml, calledServiceUrl, mapCrs) {
const calledUrlParts = url.parse(calledServiceUrl, true);
- // Filter service and request from calledServiceUrl, but keep other parameters (i.e. MAP)
- calledUrlParts.query = Object.keys(calledUrlParts.query).filter(key => {
+ // Filter service and request from calledServiceUrl,
+ // but keep other parameters (i.e. MAP)
+ calledUrlParts.query = Object.keys(
+ calledUrlParts.query
+ ).filter(key => {
return !["service", "request"].includes(key.toLowerCase());
- }).reduce((res, key) => ({...res, [key]: calledUrlParts.query[key]}), {});
+ }).reduce((res, key) => ({
+ ...res, [key]: calledUrlParts.query[key]
+ }), {});
delete calledUrlParts.search;
const options = {
@@ -259,12 +354,20 @@ const ServiceLayerUtils = {
removeNSPrefix: true
};
const capabilities = (new XMLParser(options)).parse(capabilitiesXml);
- if (!capabilities || !capabilities.WFS_Capabilities || !capabilities.WFS_Capabilities.version) {
+ if (
+ !capabilities ||
+ !capabilities.WFS_Capabilities ||
+ !capabilities.WFS_Capabilities.version
+ ) {
return [];
} else if (capabilities.WFS_Capabilities.version < "1.1.0") {
- return ServiceLayerUtils.getWFS10Layers(capabilities.WFS_Capabilities, calledUrlParts);
+ return ServiceLayerUtils.getWFS10Layers(
+ capabilities.WFS_Capabilities, calledUrlParts
+ );
} else {
- return ServiceLayerUtils.getWFS11_20Layers(capabilities.WFS_Capabilities, calledUrlParts, mapCrs);
+ return ServiceLayerUtils.getWFS11_20Layers(
+ capabilities.WFS_Capabilities, calledUrlParts, mapCrs
+ );
}
},
getWFS10Layers(capabilities, calledUrlParts) {
@@ -272,10 +375,17 @@ const ServiceLayerUtils = {
const version = capabilities.version;
let formats = null;
try {
- serviceUrl = ServiceLayerUtils.getDCPTypes(array(capabilities.Capability.Request.GetFeature.DCPType)).HTTP.Get.onlineResource;
- serviceUrl = this.mergeCalledServiceUrlQuery(serviceUrl, calledUrlParts);
- formats = Object.keys(capabilities.Capability.Request.GetFeature.ResultFormat);
- if (typeof(formats) === 'string') {
+ serviceUrl = ServiceLayerUtils.getDCPTypes(
+ array(
+ capabilities.Capability.Request.GetFeature.DCPType)
+ ).HTTP.Get.onlineResource;
+ serviceUrl = this.mergeCalledServiceUrlQuery(
+ serviceUrl, calledUrlParts
+ );
+ formats = Object.keys(
+ capabilities.Capability.Request.GetFeature.ResultFormat
+ );
+ if (typeof (formats) === 'string') {
// convert to list if single entry
formats = [formats];
}
@@ -284,7 +394,9 @@ const ServiceLayerUtils = {
}
const layers = [];
- for (const featureType of array(capabilities.FeatureTypeList.FeatureType)) {
+ for (
+ const featureType of array(capabilities.FeatureTypeList.FeatureType)
+ ) {
let name;
let bbox;
try {
@@ -321,23 +433,38 @@ const ServiceLayerUtils = {
const version = capabilities.version;
let formats = null;
try {
- const getFeatureOp = array(capabilities.OperationsMetadata.Operation).find(el => el.name === "GetFeature");
- serviceUrl = ServiceLayerUtils.getDCPTypes(array(getFeatureOp.DCP)).HTTP.Get.href;
- serviceUrl = this.mergeCalledServiceUrlQuery(serviceUrl, calledUrlParts);
- const outputFormat = array(getFeatureOp.Parameter).find(el => el.name === "outputFormat");
- formats = MiscUtils.ensureArray(outputFormat.AllowedValues ? outputFormat.AllowedValues.Value : outputFormat.Value);
+ const getFeatureOp = array(
+ capabilities.OperationsMetadata.Operation
+ ).find(el => el.name === "GetFeature");
+ serviceUrl = ServiceLayerUtils.getDCPTypes(
+ array(getFeatureOp.DCP)
+ ).HTTP.Get.href;
+ serviceUrl = this.mergeCalledServiceUrlQuery(
+ serviceUrl, calledUrlParts
+ );
+ const outputFormat = array(
+ getFeatureOp.Parameter
+ ).find(el => el.name === "outputFormat");
+ formats = MiscUtils.ensureArray(
+ outputFormat.AllowedValues
+ ? outputFormat.AllowedValues.Value
+ : outputFormat.Value
+ );
} catch (e) {
return [];
}
const layers = [];
- for (const featureType of array(capabilities.FeatureTypeList.FeatureType)) {
+ for (
+ const featureType of array(capabilities.FeatureTypeList.FeatureType)
+ ) {
let name;
let bbox;
try {
name = featureType.Name;
- const lc = featureType.WGS84BoundingBox.LowerCorner.split(/\s+/);
- const uc = featureType.WGS84BoundingBox.UpperCorner.split(/\s+/);
+ const featBox = featureType.WGS84BoundingBox;
+ const lc = featBox.LowerCorner.split(/\s+/);
+ const uc = featBox.UpperCorner.split(/\s+/);
bbox = {
crs: "EPSG:4326",
bounds: [lc[0], lc[1], uc[0], uc[1]]
@@ -348,10 +475,16 @@ const ServiceLayerUtils = {
const title = featureType.Title || name;
const abstract = featureType.Abstract || "";
const projections = [
- CoordinatesUtils.fromOgcUrnCrs(featureType.DefaultCRS || featureType.DefaultSRS),
- ...MiscUtils.ensureArray(featureType.OtherCRS || featureType.OtherSRS || []).map(crs => CoordinatesUtils.fromOgcUrnCrs(crs))
+ CoordinatesUtils.fromOgcUrnCrs(
+ featureType.DefaultCRS || featureType.DefaultSRS
+ ),
+ ...MiscUtils.ensureArray(
+ featureType.OtherCRS || featureType.OtherSRS || []
+ ).map(crs => CoordinatesUtils.fromOgcUrnCrs(crs))
];
- const projection = projections.includes(mapCrs) ? mapCrs : projections[0];
+ const projection = projections.includes(mapCrs)
+ ? mapCrs
+ : projections[0];
layers.push({
type: "wfs",
@@ -370,28 +503,40 @@ const ServiceLayerUtils = {
return layers;
},
findLayers(type, serviceUrl, layerConfigs, mapCrs, callback) {
- // Scan the capabilities of the specified service for the specified layers
+ // Scan the capabilities of the specified service for
+ // the specified layers
serviceUrl = MiscUtils.adjustProtocol(serviceUrl).replace(/\?$/, '');
- if (type === "wmts") {
- // Do nothing
- } else if (serviceUrl.includes('?')) {
- serviceUrl += "&service=" + type.toUpperCase() + "&request=GetCapabilities";
- } else {
- serviceUrl += "?service=" + type.toUpperCase() + "&request=GetCapabilities";
+ if (type !== "wmts") {
+ serviceUrl += (
+ serviceUrl.includes('?') ? "?" : "&" +
+ "service=" + type.toUpperCase() +
+ "&request=GetCapabilities"
+ );
}
axios.get(serviceUrl).then(response => {
for (const layerConfig of layerConfigs) {
let result = null;
if (type === "wms") {
- result = ServiceLayerUtils.getWMSLayers(response.data, serviceUrl, true);
+ result = ServiceLayerUtils.getWMSLayers(
+ response.data, serviceUrl, true
+ );
} else if (type === "wfs") {
- result = ServiceLayerUtils.getWFSLayers(response.data, serviceUrl, mapCrs);
+ result = ServiceLayerUtils.getWFSLayers(
+ response.data, serviceUrl, mapCrs
+ );
} else if (type === "wmts") {
- result = ServiceLayerUtils.getWMTSLayers(response.data, serviceUrl, mapCrs);
+ result = ServiceLayerUtils.getWMTSLayers(
+ response.data, serviceUrl, mapCrs
+ );
}
- let layer = LayerUtils.searchSubLayer({sublayers: result}, "name", layerConfig.name);
- // Some services (i.e. wms.geo.admin.ch) have same-named sublayers
- layer = LayerUtils.searchSubLayer(layer, "name", layerConfig.name) ?? layer;
+ let layer = LayerUtils.searchSubLayer(
+ { sublayers: result }, "name", layerConfig.name
+ );
+ // Some services (i.e. wms.geo.admin.ch) have
+ // same-named sublayers
+ layer = LayerUtils.searchSubLayer(
+ layer, "name", layerConfig.name
+ ) ?? layer;
if (layer) {
layer = {
...layer,
diff --git a/utils/ServiceLayerUtils.test.js b/utils/ServiceLayerUtils.test.js
new file mode 100644
index 000000000..750938f9a
--- /dev/null
+++ b/utils/ServiceLayerUtils.test.js
@@ -0,0 +1,38 @@
+import ServiceLayerUtils from './ServiceLayerUtils';
+
+
+describe("findLayers", () => {
+
+});
+
+describe("getDCPTypes", () => {
+
+});
+
+describe("getWFS", () => {
+
+});
+
+describe("getWFS", () => {
+
+});
+
+describe("getWFSLayers", () => {
+
+});
+
+describe("getWMSLayerParams", () => {
+
+});
+
+describe("getWMSLayers", () => {
+
+});
+
+describe("getWMTSLayers", () => {
+
+});
+
+describe("mergeCalledServiceUrlQuery", () => {
+
+});
diff --git a/utils/Signal.js b/utils/Signal.js
index fcf6d0d4e..4d973a01b 100644
--- a/utils/Signal.js
+++ b/utils/Signal.js
@@ -1,17 +1,54 @@
+/**
+ * A callback used with the `Signal` class.
+ * @callback Callback
+ * @param {*} data - The data passed to the callback.
+ * @returns {boolean} - If the callback returns `true`,
+ * it will be removed from the signal.
+ */
+
+
+/**
+ * A simple event system that allows you to connect and disconnect callbacks
+ * and notify them of events.
+ * @constructor
+ */
export default class Signal {
constructor() {
+ /**
+ * The list of callbacks to execute when the signal is notified.
+ * @type {Callback[]}
+ */
this.callbacks = [];
}
+
+ /**
+ * Install a callback to be executed when the signal is notified.
+ *
+ * @param {Callback} callback - The callback to connect to the signal.
+ */
connect = (callback) => {
this.callbacks.push(callback);
}
+
+ /**
+ * Remove a callback from the signal.
+ *
+ * @param {Callback} callback - The callback to remove from the signal.
+ */
disconnect = (callback) => {
this.callbacks = this.callbacks.filter(cb => cb !== callback);
}
+
+ /**
+ * Trigger all callbacks connected to the signal.
+ *
+ * If a callback returns `true`, it will be removed from the signal.
+ *
+ * @param {*} data - The data to pass to the callbacks.
+ */
notify = (data) => {
const newcallbacks = [];
this.callbacks.forEach(callback => {
- // If a callback returns true, don't re-execute it
const result = callback(data);
if (!result) {
newcallbacks.push(callback);
diff --git a/utils/Signal.test.js b/utils/Signal.test.js
new file mode 100644
index 000000000..5ef096fa8
--- /dev/null
+++ b/utils/Signal.test.js
@@ -0,0 +1,41 @@
+import Signal from './Signal';
+
+describe('connect', () => {
+ it('should add a callback to the list of callbacks', () => {
+ const signal = new Signal();
+ const callback = jest.fn();
+ signal.connect(callback);
+ expect(signal.callbacks).toContain(callback);
+ });
+});
+
+describe('disconnect', () => {
+ it('should remove a callback from the list of callbacks', () => {
+ const signal = new Signal();
+ const callback = jest.fn();
+ signal.connect(callback);
+ signal.disconnect(callback);
+ expect(signal.callbacks).not.toContain(callback);
+ });
+});
+
+describe('notify', () => {
+ it('should call all callbacks with the data', () => {
+ const signal = new Signal();
+ const callback = jest.fn();
+ signal.connect(callback);
+ signal.notify('data');
+ expect(callback).toHaveBeenCalledWith('data');
+ });
+
+ it('should remove callbacks that return true', () => {
+ const signal = new Signal();
+ const callback1 = jest.fn(() => true);
+ const callback2 = jest.fn(() => false);
+ signal.connect(callback1);
+ signal.connect(callback2);
+ signal.notify('data');
+ expect(signal.callbacks).not.toContain(callback1);
+ expect(signal.callbacks).toContain(callback2);
+ });
+});
diff --git a/utils/ThemeUtils.js b/utils/ThemeUtils.js
index 0ef02bc51..7152022c3 100644
--- a/utils/ThemeUtils.js
+++ b/utils/ThemeUtils.js
@@ -8,15 +8,31 @@
import isEmpty from 'lodash.isempty';
import url from 'url';
-import {v4 as uuidv4} from 'uuid';
-import {remove as removeDiacritics} from 'diacritics';
+import { v4 as uuidv4 } from 'uuid';
+import { remove as removeDiacritics } from 'diacritics';
-import {SearchResultType} from '../actions/search';
-import {LayerRole} from '../actions/layers';
+import { SearchResultType } from '../actions/search';
+import { LayerRole } from '../actions/layers';
+import { NotificationType, showNotification } from '../actions/windows';
import ConfigUtils from './ConfigUtils';
import LayerUtils from './LayerUtils';
+import LocaleUtils from './LocaleUtils';
+/**
+ * Utility functions for working with themes.
+ *
+ * @namespace
+ */
const ThemeUtils = {
+ /**
+ * Retrieve a theme definition by its ID.
+ *
+ * @param {object} themes - the themes collection
+ * @param {string} id - the unique identifier of the theme
+ *
+ * @returns {object|null} the theme that was found or `null` if the theme
+ * could not be found
+ */
getThemeById(themes, id) {
for (let i = 0, n = themes.items.length; i < n; ++i) {
if (themes.items[i].id === id) {
@@ -31,7 +47,28 @@ const ThemeUtils = {
}
return null;
},
- createThemeBackgroundLayers(theme, themes, visibleLayer, externalLayers) {
+
+ /**
+ * Create background layers from a theme definition.
+ *
+ * The theme definition only contains in its `backgroundLayers` property
+ * a list of background layer names and their visibility status.
+ * This function looks up the background layers in the themes collection
+ * and returns a list of background layers with all their properties.
+ *
+ * @param {object} theme - the theme definition to create the
+ * background layers for
+ * @param {object} themes - the themes collection
+ * @param {string} visibleLayer - the name of the background layer to
+ * make visible
+ * @param {object} externalLayers - the external layers collection
+ * @param {*} dispatch - redux store dispatcher
+ *
+ * @return {object[]} the background layers list.
+ */
+ createThemeBackgroundLayers(
+ theme, themes, visibleLayer, externalLayers, dispatch
+ ) {
const bgLayers = [];
let visibleIdx = -1;
let defaultVisibleIdx = -1;
@@ -39,9 +76,11 @@ const ThemeUtils = {
if (!entry.name) {
continue;
}
- let bgLayer = themes.backgroundLayers.find(lyr => lyr.name === entry.name);
+ let bgLayer = themes.backgroundLayers.find(
+ lyr => lyr.name === entry.name
+ );
if (bgLayer) {
- if (entry.visibility === true) {
+ if (entry.visibility !== false) {
defaultVisibleIdx = bgLayers.length;
}
if (bgLayer.name === visibleLayer) {
@@ -52,26 +91,48 @@ const ThemeUtils = {
role: LayerRole.BACKGROUND,
thumbnail: bgLayer.thumbnail || "img/mapthumbs/default.jpg",
visibility: false,
- opacity: bgLayer.opacity !== undefined ? bgLayer.opacity : 255
+ opacity: bgLayer.opacity !== undefined
+ ? bgLayer.opacity
+ : 255
};
if (bgLayer.resource) {
bgLayer.id = uuidv4();
bgLayer.type = "placeholder";
- const params = LayerUtils.splitLayerUrlParam(bgLayer.resource);
+ const params = LayerUtils.splitLayerUrlParam(
+ bgLayer.resource
+ );
params.id = bgLayer.id;
const key = params.type + ":" + params.url;
- (externalLayers[key] = externalLayers[key] || []).push(params);
+ (
+ externalLayers[key] = externalLayers[key] || []
+ ).push(params);
delete bgLayer.resource;
} else if (bgLayer.type === "wms") {
- bgLayer.version = bgLayer.params.VERSION || bgLayer.version || themes.defaultWMSVersion || "1.3.0";
+ bgLayer.version = (
+ bgLayer.params.VERSION ||
+ bgLayer.version ||
+ themes.defaultWMSVersion ||
+ "1.3.0"
+ );
} else if (bgLayer.type === "group") {
bgLayer.items = bgLayer.items.map(item => {
if (item.ref) {
- const sublayer = themes.backgroundLayers.find(l => l.name === item.ref);
- if (sublayer) {
- item = {...item, ...sublayer, ...LayerUtils.buildWMSLayerParams(sublayer)};
+ const subLayer = themes.backgroundLayers.find(
+ l => l.name === item.ref
+ );
+ if (subLayer) {
+ item = {
+ ...item,
+ ...subLayer,
+ ...LayerUtils.buildWMSLayerParams(subLayer)
+ };
if (item.type === "wms") {
- item.version = item.params.VERSION || item.version || themes.defaultWMSVersion || "1.3.0";
+ item.version = (
+ item.params.VERSION ||
+ item.version ||
+ themes.defaultWMSVersion ||
+ "1.3.0"
+ );
}
delete item.ref;
} else {
@@ -89,11 +150,30 @@ const ThemeUtils = {
}
if (visibleIdx >= 0) {
bgLayers[visibleIdx].visibility = true;
- } else if (defaultVisibleIdx >= 0 && visibleLayer !== "") {
+ } else if (defaultVisibleIdx >= 0 && visibleLayer) {
+ dispatch(
+ showNotification(
+ "missingbglayer",
+ LocaleUtils.tr("app.missingbg", visibleLayer),
+ NotificationType.WARN, true
+ )
+ );
bgLayers[defaultVisibleIdx].visibility = true;
}
return bgLayers;
},
+
+ /**
+ * Create a theme layer.
+ *
+ * @param {object} theme - the theme definition to create the
+ * background layers for
+ * @param {object} themes - the themes collection
+ * @param {LayerRole} role - the role to assign to the new layer
+ * @param {object[]} subLayers - the sub-layers to assign to the new layer
+ *
+ * @returns {object} the new layer object
+ */
createThemeLayer(theme, themes, role = LayerRole.THEME, subLayers = []) {
const urlParts = url.parse(theme.url, true);
// Resolve relative urls
@@ -112,7 +192,9 @@ const ThemeUtils = {
name: theme.name,
title: theme.title,
bbox: theme.bbox,
- sublayers: (Array.isArray(subLayers) && subLayers.length) ? subLayers : theme.sublayers,
+ sublayers: (
+ Array.isArray(subLayers) && subLayers.length
+ ) ? subLayers : theme.sublayers,
tiled: theme.tiled,
tileSize: theme.tileSize,
ratio: !theme.tiled ? 1 : undefined,
@@ -121,40 +203,84 @@ const ThemeUtils = {
rev: +new Date(),
role: role,
attribution: theme.attribution,
- legendUrl: ThemeUtils.inheritBaseUrlParams(theme.legendUrl, theme.url, baseParams),
- printUrl: ThemeUtils.inheritBaseUrlParams(theme.printUrl, theme.url, baseParams),
- featureInfoUrl: ThemeUtils.inheritBaseUrlParams(theme.featureInfoUrl, theme.url, baseParams),
+ legendUrl: ThemeUtils.inheritBaseUrlParams(
+ theme.legendUrl, theme.url, baseParams
+ ),
+ printUrl: ThemeUtils.inheritBaseUrlParams(
+ theme.printUrl, theme.url, baseParams
+ ),
+ featureInfoUrl: ThemeUtils.inheritBaseUrlParams(
+ theme.featureInfoUrl, theme.url, baseParams
+ ),
infoFormats: theme.infoFormats,
externalLayerMap: {
...theme.externalLayerMap,
...(theme.externalLayers || []).reduce((res, cur) => {
res[cur.internalLayer] = {
- ...themes.externalLayers.find(entry => entry.name === cur.name)
+ ...themes.externalLayers.find(
+ entry => entry.name === cur.name
+ )
};
- LayerUtils.completeExternalLayer(res[cur.internalLayer], LayerUtils.searchSubLayer(theme, 'name', cur.internalLayer));
+ LayerUtils.completeExternalLayer(
+ res[cur.internalLayer],
+ LayerUtils.searchSubLayer(
+ theme, 'name', cur.internalLayer
+ )
+ );
return res;
}, {})
}
};
// Drawing order only makes sense if layer reordering is disabled
- if (ConfigUtils.getConfigProp("allowReorderingLayers", theme) !== true) {
+ if (
+ ConfigUtils.getConfigProp("allowReorderingLayers", theme) !== true
+ ) {
layer.drawingOrder = theme.drawingOrder;
}
return layer;
},
+
+ /**
+ * Compute the parameters from capability and base urls.
+ *
+ * @param {string} capabilityUrl - the URL for GetCapabilities
+ * @param {string} baseUrl - the base URL
+ * @param {object} baseParams - extra parameters to include in the query
+ *
+ * @returns {string} the computed URL
+ */
inheritBaseUrlParams(capabilityUrl, baseUrl, baseParams) {
if (!capabilityUrl) {
return baseUrl;
}
if (capabilityUrl.split("?")[0] === baseUrl.split("?")[0]) {
const parts = url.parse(capabilityUrl, true);
- parts.query = {...baseParams, ...parts.query};
+ parts.query = { ...baseParams, ...parts.query };
+
+ // If we don't do this the search parameter is used to
+ // construct the url. We want `query` to be used.
+ delete parts.search;
+
return url.format(parts);
}
return capabilityUrl;
},
- searchThemes(themes, searchtext) {
- const filter = new RegExp(removeDiacritics(searchtext).replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&"), "i");
+
+ /**
+ * Find themes by text.
+ *
+ * @param {themes} - the theme collection to search in
+ * @param {searchText} - the string to search
+ *
+ * @returns {object[]} an empty array if there is no match
+ * or a list of item that matched (these can be sub-items)
+ */
+ searchThemes(themes, searchText) {
+ const filter = new RegExp(
+ removeDiacritics(searchText).replace(
+ /[-[\]/{}()*+?.\\^$|]/g, "\\$&"
+ ), "i"
+ );
const matches = ThemeUtils.searchThemeGroup(themes, filter);
return isEmpty(matches) ? [] : [{
id: "themes",
@@ -169,11 +295,31 @@ const ThemeUtils = {
}))
}];
},
+
+ /**
+ * Finds the theme items that matches given pattern.
+ *
+ * The function looks into `title`, `keywords` and `abstract` fields
+ * of the `themeGroup` and the child themes.
+ *
+ * @param {object} themeGroup - the theme to search
+ * @param {RegExp} filter - the filter to apply
+ * @returns {object[]} the themes that were found to match the
+ * pattern
+ */
searchThemeGroup(themeGroup, filter) {
const matches = [];
- (themeGroup.subdirs || []).map(subdir => matches.push(...ThemeUtils.searchThemeGroup(subdir, filter)));
+ (themeGroup.subdirs || []).map(
+ subdir => matches.push(
+ ...ThemeUtils.searchThemeGroup(subdir, filter)
+ )
+ );
matches.push(...(themeGroup.items || []).filter(item => {
- return removeDiacritics(item.title).match(filter) || removeDiacritics(item.keywords || "").match(filter) || removeDiacritics(item.abstract || "").match(filter);
+ return (
+ removeDiacritics(item.title).match(filter) ||
+ removeDiacritics(item.keywords || "").match(filter) ||
+ removeDiacritics(item.abstract || "").match(filter)
+ );
}));
return matches;
}
diff --git a/utils/ThemeUtils.test.js b/utils/ThemeUtils.test.js
new file mode 100644
index 000000000..adc929717
--- /dev/null
+++ b/utils/ThemeUtils.test.js
@@ -0,0 +1,513 @@
+import ThemeUtils from './ThemeUtils';
+import { LayerRole } from '../actions/layers';
+
+let mockLocale = "xy";
+jest.mock("./LocaleUtils", () => ({
+ __esModule: true,
+ default: {
+ lang: () => mockLocale,
+ tr: (msg) => msg,
+ },
+}));
+
+let mockAllowReorderingLayers = true;
+let mockAssetsPath = '';
+jest.mock("./ConfigUtils", () => ({
+ __esModule: true,
+ default: {
+ getConfigProp: (name) => {
+ if (name === 'allowReorderingLayers') {
+ return mockAllowReorderingLayers;
+ }
+ },
+ getAssetsPath: () => mockAssetsPath,
+ },
+}));
+
+
+describe("createThemeBackgroundLayers", () => {
+ it("should return empty array if theme has no backgroundLayers", () => {
+ const theme = {};
+ const themes = {};
+ const visibleLayer = "test";
+ const externalLayers = {};
+ const result = ThemeUtils.createThemeBackgroundLayers(
+ theme, themes, visibleLayer, externalLayers
+ );
+ expect(result).toEqual([]);
+ });
+ it("should return empty array if theme has no named layers", () => {
+ const theme = {
+ backgroundLayers: [
+ { noName: "test" }
+ ]
+ };
+ const themes = {};
+ const visibleLayer = "test";
+ const externalLayers = {};
+ const result = ThemeUtils.createThemeBackgroundLayers(
+ theme, themes, visibleLayer, externalLayers
+ );
+ expect(result).toEqual([]);
+ });
+ it("should return empty array if theme has no matching layers", () => {
+ const theme = {
+ backgroundLayers: [
+ { name: "test" }
+ ]
+ };
+ const themes = { backgroundLayers: [] };
+ const visibleLayer = "test";
+ const externalLayers = {};
+ const result = ThemeUtils.createThemeBackgroundLayers(
+ theme, themes, visibleLayer, externalLayers
+ );
+ expect(result).toEqual([]);
+ });
+ it("should work in simple cases", () => {
+ const theme = {
+ backgroundLayers: [
+ { name: "test" }
+ ]
+ };
+ const themes = {
+ backgroundLayers: [
+ { name: "test" }
+ ]
+ };
+ const visibleLayer = "test";
+ const externalLayers = {};
+ const result = ThemeUtils.createThemeBackgroundLayers(
+ theme, themes, visibleLayer, externalLayers
+ );
+ expect(result).toEqual([
+ {
+ "name": "test",
+ "opacity": 255,
+ "role": LayerRole.BACKGROUND,
+ "thumbnail": "img/mapthumbs/default.jpg",
+ "visibility": true,
+ },
+ ]);
+ });
+ it("should work with resource marker", () => {
+ const theme = {
+ backgroundLayers: [
+ { name: "test" }
+ ]
+ };
+ const themes = {
+ backgroundLayers: [
+ {
+ name: "test",
+ resource: 'http://www.example.com/test.png'
+ }
+ ]
+ };
+ const visibleLayer = "test";
+ const externalLayers = {};
+ const result = ThemeUtils.createThemeBackgroundLayers(
+ theme, themes, visibleLayer, externalLayers
+ );
+ expect(result).toEqual([
+ {
+ "id": expect.stringMatching(/.+-.+-.+-.+-.+/),
+ "name": "test",
+ "opacity": 255,
+ "role": LayerRole.BACKGROUND,
+ "thumbnail": "img/mapthumbs/default.jpg",
+ "visibility": true,
+ "type": "placeholder",
+ },
+ ]);
+ });
+ it("should work with wms layers", () => {
+ const theme = {
+ backgroundLayers: [
+ { name: "test" }
+ ]
+ };
+ const themes = {
+ backgroundLayers: [
+ {
+ name: "test",
+ type: "wms",
+ params: {},
+ version: "1.2.3",
+ }
+ ]
+ };
+ const visibleLayer = "test";
+ const externalLayers = {};
+ const result = ThemeUtils.createThemeBackgroundLayers(
+ theme, themes, visibleLayer, externalLayers
+ );
+ expect(result).toEqual([
+ {
+ "name": "test",
+ "opacity": 255,
+ "role": LayerRole.BACKGROUND,
+ "thumbnail": "img/mapthumbs/default.jpg",
+ "visibility": true,
+ "params": {},
+ "type": "wms",
+ "version": "1.2.3",
+ },
+ ]);
+ });
+ it("should work with groups", () => {
+ const theme = {
+ backgroundLayers: [
+ { name: "test" }
+ ]
+ };
+ const themes = {
+ backgroundLayers: [
+ {
+ name: "group",
+ type: "group",
+ items: [
+ {
+ name: "test",
+ ref: "test",
+ }
+ ]
+ },
+ {
+ name: "test",
+ type: "wms",
+ params: {},
+ version: "1.2.3",
+ }
+ ]
+ };
+ const visibleLayer = "test";
+ const externalLayers = {};
+ const result = ThemeUtils.createThemeBackgroundLayers(
+ theme, themes, visibleLayer, externalLayers
+ );
+ expect(result).toEqual([
+ {
+ name: "test",
+ opacity: 255,
+ role: LayerRole.BACKGROUND,
+ thumbnail: "img/mapthumbs/default.jpg",
+ visibility: true,
+ params: {},
+ type: "wms",
+ version: "1.2.3",
+ visibility: true,
+ },
+ ]);
+ });
+ it("should notify the user that background layer is missing", () => {
+ const theme = {
+ backgroundLayers: [
+ { name: "test" },
+ {
+ name: "lorem",
+ visibility: true
+ },
+ ]
+ };
+ const themes = {
+ backgroundLayers: [
+ {
+ name: "test",
+ type: "wms",
+ params: {},
+ version: "1.2.3",
+ },
+ {
+ name: "lorem",
+ type: "wms",
+ params: {},
+ version: "3.2.1"
+ },
+ ]
+ };
+ const visibleLayer = "not-found";
+ const externalLayers = {};
+ const dispatch = jest.fn();
+ ThemeUtils.createThemeBackgroundLayers(
+ theme, themes, visibleLayer, externalLayers, dispatch
+ );
+ expect(dispatch).toHaveBeenCalledWith({
+ "name": "missingbglayer",
+ "notificationType": 2,
+ "sticky": true,
+ "text": "app.missingbg",
+ "type": "SHOW_NOTIFICATION"
+ });
+ });
+ it(
+ "should not (!) notify the user that background layer is " +
+ "missing but there are no visible layers", () => {
+ const theme = {
+ backgroundLayers: [
+ { name: "test" },
+ {
+ name: "lorem",
+ visibility: false
+ },
+ ]
+ };
+ const themes = {
+ backgroundLayers: [
+ {
+ name: "test",
+ type: "wms",
+ params: {},
+ version: "1.2.3",
+ },
+ {
+ name: "lorem",
+ type: "wms",
+ params: {},
+ version: "3.2.1"
+ },
+ ]
+ };
+ const dispatch = jest.fn();
+ const visibleLayer = "not-found";
+ const externalLayers = {};
+ ThemeUtils.createThemeBackgroundLayers(
+ theme, themes, visibleLayer, externalLayers, dispatch
+ );
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+});
+
+describe("createThemeLayer", () => {
+ it("creates a layer", () => {
+ expect(
+ ThemeUtils.createThemeLayer({
+ id: "lorem",
+ url: "http://example.com",
+ legendUrl: "http://example.com/legend",
+ }, {
+ items: [],
+ subdirs: []
+ })
+ ).toEqual({
+ "attribution": undefined,
+ "bbox": undefined,
+ "drawingOrder": undefined,
+ "expanded": undefined,
+ "externalLayerMap": {},
+ "featureInfoUrl": "http://example.com",
+ "format": undefined,
+ "infoFormats": undefined,
+ "legendUrl": "http://example.com/legend",
+ "name": undefined,
+ "printUrl": "http://example.com",
+ "ratio": 1,
+ "rev": 1585688400000,
+ "role": 2,
+ "serverType": "qgis",
+ "sublayers": undefined,
+ "tileSize": undefined,
+ "tiled": undefined,
+ "title": undefined,
+ "type": "wms",
+ "url": "http://example.com/",
+ "version": "1.3.0",
+ "visibility": true,
+ })
+ });
+});
+
+describe("getThemeById", () => {
+ it("should return null if no themes exist", () => {
+ const themes = {
+ items: [],
+ subdirs: []
+ };
+ const result = ThemeUtils.getThemeById(themes, "test");
+ expect(result).toBeNull();
+ });
+ it("should return the theme", () => {
+ const themes = {
+ items: [{
+ id: "test"
+ }],
+ subdirs: []
+ };
+ const result = ThemeUtils.getThemeById(themes, "test");
+ expect(result).toEqual({
+ id: "test"
+ })
+ });
+ it("should find the theme in sub-dirs", () => {
+ const themes = {
+ items: [],
+ subdirs: [{
+ items: [{
+ id: "test"
+ }],
+ subdirs: []
+ }]
+ };
+ const result = ThemeUtils.getThemeById(themes, "test");
+ expect(result).toEqual({
+ id: "test"
+ })
+ });
+});
+
+describe("inheritBaseUrlParams", () => {
+ it("should return base url if there is no capability url", () => {
+ expect(ThemeUtils.inheritBaseUrlParams("", "xxx", {})).toBe("xxx");
+ });
+ it("should return capability url if base does not match", () => {
+ expect(ThemeUtils.inheritBaseUrlParams("yyy", "xxx", {})).toBe("yyy");
+ });
+ it("should merge capability url and base url", () => {
+ expect(ThemeUtils.inheritBaseUrlParams(
+ "http://example.com?a=b&c=d",
+ "http://example.com?x=y&z=t",
+ {}
+ )).toBe("http://example.com/?a=b&c=d");
+ });
+ it("should use parameters", () => {
+ expect(ThemeUtils.inheritBaseUrlParams(
+ "http://example.com?a=b&c=d",
+ "http://example.com?x=y&z=t",
+ {
+ lorem: "lorem"
+ }
+ )).toBe("http://example.com/?lorem=lorem&a=b&c=d");
+ });
+});
+
+describe("searchThemeGroup", () => {
+ const themeItem = {
+ title: "abcd",
+ keywords: "keywords",
+ abstract: "abstract"
+ };
+ it("should return an empty list if there are no themes", () => {
+ expect(ThemeUtils.searchThemeGroup({
+ subdirs: []
+ }, "xyz")).toEqual([]);
+ });
+ it("should return an empty list if there is no match", () => {
+ expect(ThemeUtils.searchThemeGroup({
+ items: [themeItem]
+ }, "xyz")).toEqual([]);
+ });
+ it("should return an item if the title matches", () => {
+ expect(ThemeUtils.searchThemeGroup({
+ items: [themeItem]
+ }, "abcd")).toEqual([themeItem]);
+ });
+ it("should return an item if the keywords match", () => {
+ expect(ThemeUtils.searchThemeGroup({
+ items: [themeItem]
+ }, "keywords")).toEqual([themeItem]);
+ });
+ it("should return an item if the abstract matches", () => {
+ expect(ThemeUtils.searchThemeGroup({
+ items: [themeItem]
+ }, "abstract")).toEqual([themeItem]);
+ });
+ it("should return an item if the title matches in subdirs", () => {
+ expect(ThemeUtils.searchThemeGroup({
+ subdirs: [{
+ items: [themeItem]
+ }],
+ items: []
+ }, "abcd")).toEqual([themeItem]);
+ });
+ it("should return an item if the keywords match in subdirs", () => {
+ expect(ThemeUtils.searchThemeGroup({
+ subdirs: [{
+ items: [themeItem]
+ }],
+ items: []
+ }, "keywords")).toEqual([themeItem]);
+ });
+ it("should return an item if the abstract matches in subdirs", () => {
+ expect(ThemeUtils.searchThemeGroup({
+ subdirs: [{
+ items: [themeItem]
+ }],
+ items: []
+ }, "abstract")).toEqual([themeItem]);
+ });
+});
+
+describe("searchThemes", () => {
+ const themeItem = {
+ title: "abcd",
+ keywords: "keywords",
+ abstract: "abstract"
+ };
+ const goodReply = [{
+ "id": "themes",
+ "items": [
+ {
+ "id": undefined,
+ "text": "abcd",
+ "theme": {
+ "abstract": "abstract",
+ "keywords": "keywords",
+ "title": "abcd",
+ },
+ "thumbnail": "/undefined",
+ "type": 2,
+ },
+ ],
+ "priority": -1,
+ "titlemsgid": "search.themes",
+ }];
+ it("should return an empty list if there are no themes", () => {
+ expect(ThemeUtils.searchThemes({
+ subdirs: []
+ }, "xyz")).toEqual([]);
+ });
+ it("should return an empty list if there is no match", () => {
+ expect(ThemeUtils.searchThemes({
+ items: [themeItem]
+ }, "xyz")).toEqual([]);
+ });
+ it("should return an item if the title matches", () => {
+ expect(ThemeUtils.searchThemes({
+ items: [themeItem]
+ }, "abcd")).toEqual(goodReply);
+ });
+ it("should return an item if the keywords match", () => {
+ expect(ThemeUtils.searchThemes({
+ items: [themeItem]
+ }, "keywords")).toEqual(goodReply);
+ });
+ it("should return an item if the abstract matches", () => {
+ expect(ThemeUtils.searchThemes({
+ items: [themeItem]
+ }, "abstract")).toEqual(goodReply);
+ });
+ it("should return an item if the title matches in subdirs", () => {
+ expect(ThemeUtils.searchThemes({
+ subdirs: [{
+ items: [themeItem]
+ }],
+ items: []
+ }, "abcd")).toEqual(goodReply);
+ });
+ it("should return an item if the keywords match in subdirs", () => {
+ expect(ThemeUtils.searchThemes({
+ subdirs: [{
+ items: [themeItem]
+ }],
+ items: []
+ }, "keywords")).toEqual(goodReply);
+ });
+ it("should return an item if the abstract matches in subdirs", () => {
+ expect(ThemeUtils.searchThemes({
+ subdirs: [{
+ items: [themeItem]
+ }],
+ items: []
+ }, "abstract")).toEqual(goodReply);
+ });
+});
diff --git a/utils/VectorLayerUtils.js b/utils/VectorLayerUtils.js
index a7b9327a6..b4ec2d536 100644
--- a/utils/VectorLayerUtils.js
+++ b/utils/VectorLayerUtils.js
@@ -6,37 +6,77 @@
* LICENSE file in the root directory of this source tree.
*/
-import {v1 as uuidv1} from 'uuid';
+import { v1 as uuidv1 } from 'uuid';
import ol from 'openlayers';
import isEmpty from 'lodash.isempty';
import geojsonBbox from 'geojson-bounding-box';
import CoordinatesUtils from '../utils/CoordinatesUtils';
import ConfigUtils from '../utils/ConfigUtils';
-import {getDefaultImageStyle} from 'ol/format/KML';
-
+import { getDefaultImageStyle } from 'ol/format/KML';
+/**
+ * Utility functions for working with vector layers.
+ *
+ * @namespace
+ */
const VectorLayerUtils = {
+ /**
+ * Create a print highlight set of parameters for the given layers.
+ *
+ * @param {object[]} layers - the layers to create the parameters for
+ * @param {string} printCrs - the CRS to use for the print
+ * @param {number} dpi - the print DPI
+ * @param {number} scaleFactor - the print scale factor
+ *
+ * @return {object} the print highlight parameters
+ */
createPrintHighlighParams(layers, printCrs, dpi = 96, scaleFactor = 1.0) {
- const qgisServerVersion = ConfigUtils.getConfigProp("qgisServerVersion") || 3;
+ const qgisServerVersion = ConfigUtils.getConfigProp(
+ "qgisServerVersion"
+ ) || 3;
const params = {
geoms: [],
styles: [],
labels: [],
labelFillColors: [],
- labelOultineColors: [],
+ labelOutlineColors: [],
labelOutlineSizes: [],
labelSizes: []
};
- const defaultFeatureStyle = ConfigUtils.getConfigProp("defaultFeatureStyle");
- let ensureHex = null;
+ const defaultFeatureStyle = ConfigUtils.getConfigProp(
+ "defaultFeatureStyle"
+ );
+ let ensureHex;
if (qgisServerVersion >= 3) {
- ensureHex = (rgb) => (!Array.isArray(rgb) ? rgb : '#' + [255 - (rgb.length > 3 ? rgb[3] : 1) * 255, ...rgb.slice(0, 3)].map(v => v.toString(16).padStart(2, '0')).join(''));
+ ensureHex = (rgb) => (
+ !Array.isArray(rgb)
+ ? rgb
+ : '#' + [
+ 255 - (rgb.length > 3 ? rgb[3] : 1) * 255,
+ ...rgb.slice(0, 3)
+ ].map(v => v.toString(16).padStart(2, '0')).join('')
+ );
} else {
- ensureHex = (rgb) => (!Array.isArray(rgb) ? rgb : ('#' + (0x1000000 + (rgb[2] | (rgb[1] << 8) | (rgb[0] << 16))).toString(16).slice(1)));
+ ensureHex = (rgb) => (
+ !Array.isArray(rgb)
+ ? rgb :
+ (
+ '#' + (
+ 0x1000000 + (
+ rgb[2] | (rgb[1] << 8) | (rgb[0] << 16)
+ )
+ ).toString(16).slice(1)
+ )
+ );
}
for (const layer of layers.slice(0).reverse()) {
- if (layer.type !== 'vector' || (layer.features || []).length === 0 || layer.visibility === false || layer.skipPrint === true) {
+ if (
+ layer.type !== 'vector' ||
+ (layer.features || []).length === 0 ||
+ layer.visibility === false ||
+ layer.skipPrint === true
+ ) {
continue;
}
for (const feature of layer.features) {
@@ -44,28 +84,59 @@ const VectorLayerUtils = {
continue;
}
const properties = feature.properties || {};
- let geometry = VectorLayerUtils.reprojectGeometry(feature.geometry, feature.crs || printCrs, printCrs);
- if (feature.geometry.type === "LineString" && !isEmpty(properties.segment_labels)) {
- // Split line into single segments and label them individually
+ let geometry = VectorLayerUtils.reprojectGeometry(
+ feature.geometry, feature.crs || printCrs, printCrs
+ );
+ // Filter degenerate geometries coordinates
+ if (feature.geometry.type === "LineString") {
+ const filteredCoordinates = geometry.coordinates.filter((item, pos, arr) => {
+ return pos === 0 || item[0] !== arr[pos - 1][0] || item[1] !== arr[pos - 1][1];
+ });
+ if (filteredCoordinates.length < 2) {
+ continue;
+ }
+ }
+ if (
+ feature.geometry.type === "LineString" &&
+ !isEmpty(properties.segment_labels)
+ ) {
+ // Split line into single segments and label
+ // them individually
const coords = geometry.coordinates;
for (let i = 0; i < coords.length - 1; ++i) {
const segment = {
type: "LineString",
coordinates: [coords[i], coords[i + 1]]
};
- params.styles.push(VectorLayerUtils.createSld(segment.type, feature.styleName, feature.styleOptions, layer.opacity, dpi, scaleFactor));
+ params.styles.push(
+ VectorLayerUtils.createSld(
+ segment.type, feature.styleName,
+ feature.styleOptions,
+ layer.opacity, dpi, scaleFactor
+ ));
params.labels.push(properties.segment_labels[i] || " ");
- params.geoms.push(VectorLayerUtils.geoJSONGeomToWkt(segment, printCrs === "EPSG:4326" ? 4 : 2));
- params.labelFillColors.push(defaultFeatureStyle.textFill);
- params.labelOultineColors.push(defaultFeatureStyle.textStroke);
+ params.geoms.push(VectorLayerUtils.geoJSONGeomToWkt(
+ segment, printCrs === "EPSG:4326" ? 4 : 2
+ ));
+ params.labelFillColors.push(
+ defaultFeatureStyle.textFill
+ );
+ params.labelOutlineColors.push(
+ defaultFeatureStyle.textStroke
+ );
params.labelOutlineSizes.push(scaleFactor);
params.labelSizes.push(Math.round(10 * scaleFactor));
}
} else {
- params.styles.push(VectorLayerUtils.createSld(geometry.type, feature.styleName, feature.styleOptions, layer.opacity, dpi, scaleFactor));
+ params.styles.push(VectorLayerUtils.createSld(
+ geometry.type, feature.styleName,
+ feature.styleOptions, layer.opacity,
+ dpi, scaleFactor
+ ));
params.labels.push(properties.label || " ");
if (feature.styleName === "text") {
- // Make point a tiny square, so that QGIS server centers the text inside the polygon when labelling
+ // Make point a tiny square, so that QGIS server
+ // centers the text inside the polygon when labelling
const x = geometry.coordinates[0];
const y = geometry.coordinates[1];
geometry = {
@@ -78,15 +149,33 @@ const VectorLayerUtils = {
[x - 0.01, y - 0.01]
]]
};
- params.geoms.push(VectorLayerUtils.geoJSONGeomToWkt(geometry, printCrs === "EPSG:4326" ? 4 : 2));
- params.labelFillColors.push(ensureHex(feature.styleOptions.fillColor));
- params.labelOultineColors.push(ensureHex(feature.styleOptions.strokeColor));
- params.labelOutlineSizes.push(scaleFactor * feature.styleOptions.strokeWidth * 0.5);
- params.labelSizes.push(Math.round(10 * feature.styleOptions.strokeWidth * scaleFactor));
+ params.geoms.push(VectorLayerUtils.geoJSONGeomToWkt(
+ geometry, printCrs === "EPSG:4326" ? 4 : 2
+ ));
+ params.labelFillColors.push(ensureHex(
+ feature.styleOptions.fillColor
+ ));
+ params.labelOutlineColors.push(ensureHex(
+ feature.styleOptions.strokeColor
+ ));
+ params.labelOutlineSizes.push(
+ scaleFactor * feature.styleOptions.strokeWidth * 0.5
+ );
+ params.labelSizes.push(Math.round(
+ 10 * feature.styleOptions.strokeWidth * scaleFactor
+ ));
} else {
- params.geoms.push(VectorLayerUtils.geoJSONGeomToWkt(geometry, printCrs === "EPSG:4326" ? 4 : 2));
- params.labelFillColors.push(defaultFeatureStyle.textFill);
- params.labelOultineColors.push(defaultFeatureStyle.textStroke);
+ params.geoms.push(
+ VectorLayerUtils.geoJSONGeomToWkt(
+ geometry, printCrs === "EPSG:4326" ? 4 : 2
+ )
+ );
+ params.labelFillColors.push(
+ defaultFeatureStyle.textFill
+ );
+ params.labelOutlineColors.push(
+ defaultFeatureStyle.textStroke
+ );
params.labelOutlineSizes.push(scaleFactor);
params.labelSizes.push(Math.round(10 * scaleFactor));
}
@@ -95,7 +184,23 @@ const VectorLayerUtils = {
}
return params;
},
- createSld(geometrytype, styleName, styleOptions, layerOpacity, dpi = 96, scaleFactor = 1.0) {
+
+ /**
+ * Create a SLD style for the given geometry type and style options.
+ *
+ * @param {string} geometryType - the geometry type
+ * @param {string} styleName - the style name
+ * @param {object} styleOptions - the style options
+ * @param {number} layerOpacity - the layer opacity
+ * @param {number} dpi - the print DPI
+ * @param {number} scaleFactor - the print scale factor
+ *
+ * @return {string} the SLD style
+ */
+ createSld(
+ geometryType, styleName, styleOptions,
+ layerOpacity, dpi = 96, scaleFactor = 1.0
+ ) {
let opts = {};
// Special cases
if (styleName === 'text') {
@@ -113,12 +218,23 @@ const VectorLayerUtils = {
};
} else {
// Default style
- opts = {...ConfigUtils.getConfigProp("defaultFeatureStyle"), ...styleOptions};
+ opts = {
+ ...ConfigUtils.getConfigProp("defaultFeatureStyle"),
+ ...styleOptions
+ };
}
const dpiScale = dpi / 96 * scaleFactor;
- const ensureHex = (rgb) => (!Array.isArray(rgb) ? rgb : ('#' + (0x1000000 + (rgb[2] | (rgb[1] << 8) | (rgb[0] << 16))).toString(16).slice(1)));
- const opacity = (rgb) => {
+ const ensureHex = (rgb) => (
+ !Array.isArray(rgb)
+ ? rgb
+ : (
+ '#' + (
+ 0x1000000 + (rgb[2] | (rgb[1] << 8) | (rgb[0] << 16))
+ ).toString(16).slice(1)
+ )
+ );
+ const opacity = (rgb) => {
if (Array.isArray(rgb) && rgb.length > 3) {
return rgb[3] * layerOpacity / 255;
}
@@ -126,80 +242,120 @@ const VectorLayerUtils = {
};
const stroke = '' +
- '' + ensureHex(opts.strokeColor) + '' +
- '' + opacity(opts.strokeColor) + '' +
- '' + (opts.strokeWidth * dpiScale) + '' +
- 'round' +
- (!isEmpty(opts.strokeDash) ? '' + opts.strokeDash.join(' ') + '' : '') +
- '';
+ '' +
+ ensureHex(opts.strokeColor) +
+ '' +
+ '' +
+ opacity(opts.strokeColor) +
+ '' +
+ '' +
+ (opts.strokeWidth * dpiScale) +
+ '' +
+ 'round' +
+ (
+ !isEmpty(opts.strokeDash)
+ ? (
+ '' +
+ opts.strokeDash.join(' ') +
+ ''
+ ) : ''
+ ) +
+ '';
const fill = '' +
- '' + ensureHex(opts.fillColor) + '' +
- '' + opacity(opts.fillColor) + '' +
- '';
+ '' +
+ ensureHex(opts.fillColor) +
+ '' +
+ '' +
+ opacity(opts.fillColor) +
+ '' +
+ '';
let rule = null;
- if (geometrytype.endsWith("Point")) {
+ if (geometryType.endsWith("Point")) {
rule = '' +
- '' +
- '' +
- 'circle' +
- '' +
- '' + ensureHex(opts.strokeColor) + '' +
- '' + opacity(opts.strokeColor) + '' +
- '' + (opts.strokeWidth * dpiScale) + '' +
- '' +
- fill +
- '' +
- '' + (2 * opts.circleRadius * dpiScale) + '' +
- '' +
- '';
- } else if (geometrytype.endsWith("LineString")) {
+ '' +
+ '' +
+ 'circle' +
+ '' +
+ '' +
+ ensureHex(opts.strokeColor) +
+ '' +
+ '' +
+ opacity(opts.strokeColor) +
+ '' +
+ '' +
+ (opts.strokeWidth * dpiScale) +
+ '' +
+ '' +
+ fill +
+ '' +
+ '' + (2 * opts.circleRadius * dpiScale) + '' +
+ '' +
+ '';
+ } else if (geometryType.endsWith("LineString")) {
rule = '' +
- stroke +
- '';
- } else if (geometrytype.endsWith("Polygon")) {
+ stroke +
+ '';
+ } else if (geometryType.endsWith("Polygon")) {
rule = '' +
- stroke +
- fill +
- '';
+ stroke +
+ fill +
+ '';
}
if (rule) {
return '' +
- '' +
- '' +
- '' +
- '' +
- rule +
- '' +
- '' +
- '' +
- '';
+ '' +
+ '' +
+ '' +
+ '' +
+ rule +
+ '' +
+ '' +
+ '' +
+ '';
}
return null;
},
- reprojectGeometry(geometry, srccrs, dstcrs) {
- if (srccrs === dstcrs || !srccrs || !dstcrs) {
+ reprojectGeometry(geometry, srcCrs, dstCrs) {
+ if (srcCrs === dstCrs || !srcCrs || !dstCrs) {
return geometry;
}
if (geometry.type === "Point") {
- const wgscoo = CoordinatesUtils.reproject(geometry.coordinates, srccrs, dstcrs);
return {
type: geometry.type,
- coordinates: wgscoo
+ coordinates: CoordinatesUtils.reproject(
+ geometry.coordinates, srcCrs, dstCrs
+ )
};
- } else if (geometry.type === "LineString" || geometry.type === "MultiPoint") {
+ } else if (
+ geometry.type === "LineString" ||
+ geometry.type === "MultiPoint"
+ ) {
return {
type: geometry.type,
coordinates: geometry.coordinates.map(tuple => {
- return CoordinatesUtils.reproject(tuple, srccrs, dstcrs);
+ return CoordinatesUtils.reproject(tuple, srcCrs, dstCrs);
})
};
- } else if (geometry.type === "Polygon" || geometry.type === "MultiLineString") {
+ } else if (
+ geometry.type === "Polygon" ||
+ geometry.type === "MultiLineString"
+ ) {
return {
type: geometry.type,
coordinates: geometry.coordinates.map(ring => {
return ring.map(tuple => {
- return CoordinatesUtils.reproject(tuple, srccrs, dstcrs);
+ return CoordinatesUtils.reproject(
+ tuple, srcCrs, dstCrs
+ );
});
})
};
@@ -209,7 +365,9 @@ const VectorLayerUtils = {
coordinates: geometry.coordinates.map(part => {
return part.map(ring => {
return ring.map(tuple => {
- return CoordinatesUtils.reproject(tuple, srccrs, dstcrs);
+ return CoordinatesUtils.reproject(
+ tuple, srcCrs, dstCrs
+ );
});
});
})
@@ -218,7 +376,7 @@ const VectorLayerUtils = {
return geometry;
}
},
- wktToGeoJSON(wkt, srccrs, dstcrs, id = uuidv1()) {
+ wktToGeoJSON(wkt, srcCrs, dstCrs, id = uuidv1()) {
wkt = wkt
.replace(/Point(\w+)/i, "Point $1")
.replace(/LineString(\w+)/i, "LineString $1")
@@ -226,10 +384,12 @@ const VectorLayerUtils = {
.replace(/MultiSurface(\w*)/i, "GeometryCollection $1");
try {
const feature = new ol.format.WKT().readFeature(wkt, {
- dataProjection: srccrs,
- featureProjection: dstcrs
+ dataProjection: srcCrs,
+ featureProjection: dstCrs
});
- const featureObj = new ol.format.GeoJSON().writeFeatureObject(feature);
+ const featureObj = new ol.format.GeoJSON().writeFeatureObject(
+ feature
+ );
featureObj.id = id;
return featureObj;
} catch (e) {
@@ -238,40 +398,69 @@ const VectorLayerUtils = {
return null;
}
},
+
+ /**
+ * Convert a GeoJSON geometry to WKT.
+ *
+ * @param {object} gj - the GeoJSON geometry
+ * @param {number} precision - the number of decimal places to use
+ *
+ * @return {string} the WKT representation of the geometry
+ */
geoJSONGeomToWkt(gj, precision = 4) {
if (gj.type === 'Feature') {
gj = gj.geometry;
}
- const wrapParens = (s) => { return '(' + s + ')'; };
- const pairWKT = (c) => { return c.map(x => x.toFixed(precision)).join(' '); };
- const ringWKT = (r) => { return r.map(pairWKT).join(', '); };
- const ringsWKT = (r) => { return r.map(ringWKT).map(wrapParens).join(', '); };
- const multiRingsWKT = (r) => { return r.map(ringsWKT).map(wrapParens).join(', '); };
+ const wrapParens = (s) => {
+ return '(' + s + ')';
+ };
+ const pairWKT = (c) => {
+ return c.map(x => x.toFixed(precision)).join(' ');
+ };
+ const ringWKT = (r) => {
+ return r.map(pairWKT).join(', ');
+ };
+ const ringsWKT = (r) => {
+ return r.map(ringWKT).map(wrapParens).join(', ');
+ };
+ const multiRingsWKT = (r) => {
+ return r.map(ringsWKT).map(wrapParens).join(', ');
+ };
switch (gj.type) {
- case 'Point':
- return 'POINT (' + pairWKT(gj.coordinates) + ')';
- case 'LineString':
- return 'LINESTRING (' + ringWKT(gj.coordinates) + ')';
- case 'Polygon':
- return 'POLYGON (' + ringsWKT(gj.coordinates) + ')';
- case 'MultiPoint':
- return 'MULTIPOINT (' + ringWKT(gj.coordinates) + ')';
- case 'MultiPolygon':
- return 'MULTIPOLYGON (' + multiRingsWKT(gj.coordinates) + ')';
- case 'MultiLineString':
- return 'MULTILINESTRING (' + ringsWKT(gj.coordinates) + ')';
- case 'GeometryCollection':
- return 'GEOMETRYCOLLECTION (' + gj.geometries.map(
- (x) => VectorLayerUtils.geoJSONGeomToWkt(x, precision)
- ).join(', ') + ')';
- default:
- throw new Error('Invalid geometry object');
+ case 'Point':
+ return 'POINT (' + pairWKT(gj.coordinates) + ')';
+ case 'LineString':
+ return 'LINESTRING (' + ringWKT(gj.coordinates) + ')';
+ case 'Polygon':
+ return 'POLYGON (' + ringsWKT(gj.coordinates) + ')';
+ case 'MultiPoint':
+ return 'MULTIPOINT (' + ringWKT(gj.coordinates) + ')';
+ case 'MultiPolygon':
+ return 'MULTIPOLYGON (' + multiRingsWKT(gj.coordinates) + ')';
+ case 'MultiLineString':
+ return 'MULTILINESTRING (' + ringsWKT(gj.coordinates) + ')';
+ case 'GeometryCollection':
+ return 'GEOMETRYCOLLECTION (' + gj.geometries.map(
+ (x) => VectorLayerUtils.geoJSONGeomToWkt(x, precision)
+ ).join(', ') + ')';
+ default:
+ throw new Error('Invalid geometry object');
}
},
+
+ /**
+ * Convert WKT geometry to GeoJSON.
+ *
+ * @param {string} kml - the WKT geometry
+ *
+ * @return {object} the GeoJSON representation of the geometry
+ */
kmlToGeoJSON(kml) {
- const kmlFormat = new ol.format.KML({defaultStyle: [new ol.style.Style()]});
+ const kmlFormat = new ol.format.KML({
+ defaultStyle: [new ol.style.Style()]
+ });
const geojsonFormat = new ol.format.GeoJSON();
const features = [];
let fid = 0;
@@ -280,17 +469,38 @@ const VectorLayerUtils = {
style = style[0] || style;
const styleOptions = {
- strokeColor: style.getStroke() ? style.getStroke().getColor() : [0, 0, 0, 1],
- strokeWidth: style.getStroke() ? style.getStroke().getWidth() : 1,
- strokeDash: style.getStroke() ? style.getStroke().getLineDash() : [],
- fillColor: style.getFill() ? style.getFill().getColor() : [255, 255, 255, 1],
- textFill: style.getText() && style.getText().getFill() ? style.getText().getFill().getColor() : [0, 0, 0, 1],
- textStroke: style.getText() && style.getText().getStroke() ? style.getText().getStroke().getColor() : [255, 255, 255, 1]
+ strokeColor: style.getStroke()
+ ? style.getStroke().getColor()
+ : [0, 0, 0, 1],
+ strokeWidth: style.getStroke()
+ ? style.getStroke().getWidth()
+ : 1,
+ strokeDash: style.getStroke()
+ ? style.getStroke().getLineDash()
+ : [],
+ fillColor: style.getFill()
+ ? style.getFill().getColor()
+ : [255, 255, 255, 1],
+ textFill: style.getText() && style.getText().getFill()
+ ? style.getText().getFill().getColor()
+ : [0, 0, 0, 1],
+ textStroke: style.getText() && style.getText().getStroke()
+ ? style.getText().getStroke().getColor()
+ : [255, 255, 255, 1]
};
- if (style.getImage() && style.getImage() !== getDefaultImageStyle() && style.getImage().getSrc()) {
- // FIXME: Uses private members of ol.style.Icon, style.getImage().getAnchor() returns null because style.getImage.getSize() is null because the the image is not yet loaded
+ if (
+ style.getImage() &&
+ style.getImage() !== getDefaultImageStyle() &&
+ style.getImage().getSrc()
+ ) {
+ // FIXME: Uses private members of ol.style.Icon,
+ // style.getImage().getAnchor() returns null because
+ // style.getImage.getSize() is null because the the image
+ // is not yet loaded
const anchor = style.getImage().anchor_ || [0.5, 0.5];
- const anchorOrigin = (style.getImage().anchorOrigin_ || "").split("-");
+ const anchorOrigin = (
+ style.getImage().anchorOrigin_ || ""
+ ).split("-");
if (anchorOrigin.includes("right")) {
anchor[0] = 1 - anchor[0];
}
@@ -309,7 +519,9 @@ const VectorLayerUtils = {
properties: {}
});
const properties = olFeature.getProperties();
- const excludedProperties = ['visibility', olFeature.getGeometryName()];
+ const excludedProperties = [
+ 'visibility', olFeature.getGeometryName()
+ ];
for (const key of Object.keys(properties)) {
if (!excludedProperties.includes(key)) {
feature.properties[key] = properties[key];
@@ -322,6 +534,14 @@ const VectorLayerUtils = {
}
return features;
},
+
+ /**
+ * Convert an array of coordinates to an array of 2D coordinates.
+ *
+ * @param {number[]|number[][]} entry - the array of coordinates
+ *
+ * @return {number[]|number[][]} the array of 2D coordinates
+ */
convert3dto2d(entry) {
if (!Array.isArray(entry)) {
return entry;
@@ -332,6 +552,14 @@ const VectorLayerUtils = {
}
return entry;
},
+
+ /**
+ * Compute the bounding box of the given features.
+ *
+ * @param {object[]} features - the features to compute the bounding box for
+ *
+ * @return {{crs: string, bounds: object}} the bounding box of the features
+ */
computeFeaturesBBox(features) {
const featureCrs = new Set();
features.forEach(feature => {
@@ -339,12 +567,19 @@ const VectorLayerUtils = {
featureCrs.add(feature.crs);
}
});
- const bboxCrs = featureCrs.size === 1 ? [...featureCrs.keys()][0] : "EPSG:4326";
+ const bboxCrs = featureCrs.size === 1
+ ? [...featureCrs.keys()][0]
+ : "EPSG:4326";
let bounds = geojsonBbox({
type: "FeatureCollection",
- features: features.filter(feature => feature.geometry).map(feature => ({
+ features: features.filter(
+ feature => feature.geometry
+ ).map(feature => ({
...feature,
- geometry: feature.crs ? VectorLayerUtils.reprojectGeometry(feature.geometry, feature.crs, bboxCrs) : feature.geometry
+ geometry: feature.crs
+ ? VectorLayerUtils.reprojectGeometry(
+ feature.geometry, feature.crs, bboxCrs
+ ) : feature.geometry
}))
});
// Discard z component
@@ -356,6 +591,15 @@ const VectorLayerUtils = {
bounds: bounds
};
},
+
+ /**
+ * Compute the bounding box of the given feature.
+ *
+ * @param {object} feature - the feature to compute the bounding box for
+ *
+ * @return {number[]} the bounding box of the feature as
+ * `[minx, miny, maxx, maxy]`
+ */
computeFeatureBBox(feature) {
let bounds = geojsonBbox(feature);
// Discard z component
@@ -364,35 +608,50 @@ const VectorLayerUtils = {
}
return bounds;
},
+
+ /**
+ * Compute the center of the given feature.
+ *
+ * @param {object} feature - the feature to compute the center for
+ *
+ * @return {number[]} the center of the feature as `[x, y]`
+ */
getFeatureCenter(feature) {
const geojson = new ol.format.GeoJSON().readFeature(feature);
const geometry = geojson.getGeometry();
const type = geometry.getType();
let center = null;
switch (type) {
- case "Polygon":
- center = geometry.getInteriorPoint().getCoordinates();
- break;
- case "MultiPolygon":
- center = geometry.getInteriorPoints().getClosestPoint(ol.extent.getCenter(geometry.getExtent()));
- break;
- case "Point":
- center = geometry.getCoordinates();
- break;
- case "MultiPoint":
- center = geometry.getClosestPoint(ol.extent.getCenter(geometry.getExtent()));
- break;
- case "LineString":
- center = geometry.getCoordinateAt(0.5);
- break;
- case "MultiLineString":
- center = geometry.getClosestPoint(ol.extent.getCenter(geometry.getExtent()));
- break;
- case "Circle":
- center = geometry.getCenter();
- break;
- default:
- break;
+ case "Polygon":
+ center = geometry.getInteriorPoint()
+ .getCoordinates().splice(0, 2);
+ break;
+ case "MultiPolygon":
+ center = geometry.getInteriorPoints().getClosestPoint(
+ ol.extent.getCenter(geometry.getExtent())
+ ).splice(0, 2);
+ break;
+ case "Point":
+ center = geometry.getCoordinates();
+ break;
+ case "MultiPoint":
+ center = geometry.getClosestPoint(
+ ol.extent.getCenter(geometry.getExtent())
+ );
+ break;
+ case "LineString":
+ center = geometry.getCoordinateAt(0.5);
+ break;
+ case "MultiLineString":
+ center = geometry.getClosestPoint(
+ ol.extent.getCenter(geometry.getExtent())
+ );
+ break;
+ case "Circle":
+ center = geometry.getCenter();
+ break;
+ default:
+ break;
}
return center;
}
diff --git a/utils/VectorLayerUtils.test.js b/utils/VectorLayerUtils.test.js
new file mode 100644
index 000000000..6d3c3f0cd
--- /dev/null
+++ b/utils/VectorLayerUtils.test.js
@@ -0,0 +1,563 @@
+import VecLyUt from './VectorLayerUtils';
+
+
+let mockQgisServerVersion = 3;
+let mockDefaultFeatureStyle = {
+ textFill: "#000123",
+ textStroke: "#fff456",
+};
+jest.mock("./ConfigUtils", () => ({
+ __esModule: true,
+ default: {
+ getConfigProp: (name) => {
+ if (name === 'qgisServerVersion') {
+ return mockQgisServerVersion;
+ } else if (name === 'defaultFeatureStyle') {
+ return mockDefaultFeatureStyle;
+ }
+ },
+ },
+}));
+
+
+describe("computeFeatureBBox", () => {
+ it("should work with a point", () => {
+ expect(VecLyUt.computeFeatureBBox({
+ type: "Feature",
+ geometry: {
+ type: "Point",
+ coordinates: [1, 2],
+ },
+ })).toEqual([1, 2, 1, 2]);
+ });
+ it("should work with a line-string", () => {
+ expect(VecLyUt.computeFeatureBBox({
+ type: "Feature",
+ geometry: {
+ type: "LineString",
+ coordinates: [[1, 2], [3, 4]],
+ },
+ })).toEqual([1, 2, 3, 4]);
+ });
+});
+
+describe("computeFeaturesBBox", () => {
+ it("should work with a point", () => {
+ expect(VecLyUt.computeFeaturesBBox([{
+ type: "Feature",
+ geometry: {
+ type: "Point",
+ coordinates: [1, 2],
+ },
+ }])).toEqual({
+ crs: "EPSG:4326",
+ bounds: [1, 2, 1, 2],
+ });
+ });
+ it("should work with two points", () => {
+ expect(VecLyUt.computeFeaturesBBox([{
+ type: "Feature",
+ geometry: {
+ type: "Point",
+ coordinates: [1, 2],
+ },
+ }, {
+ type: "Feature",
+ geometry: {
+ type: "Point",
+ coordinates: [4, 5],
+ },
+ }])).toEqual({
+ crs: "EPSG:4326",
+ bounds: [1, 2, 4, 5],
+ });
+ });
+});
+
+describe("convert3dto2d", () => {
+ it("should be transparent to anything that is not an array", () => {
+ expect(VecLyUt.convert3dto2d(null)).toBeNull();
+ expect(VecLyUt.convert3dto2d(undefined)).toBeUndefined();
+ expect(VecLyUt.convert3dto2d("")).toBe("");
+ expect(VecLyUt.convert3dto2d(123)).toBe(123);
+ expect(VecLyUt.convert3dto2d({})).toEqual({});
+ });
+ it("should work with a 2d array", () => {
+ expect(VecLyUt.convert3dto2d(
+ [[1, 2], [3, 4]]
+ )).toEqual([[1, 2], [3, 4]]);
+ });
+ it("should work with a 3d array", () => {
+ expect(VecLyUt.convert3dto2d(
+ [[1, 2, 3], [4, 5, 6]]
+ )).toEqual([[1, 2], [4, 5]]);
+ });
+ it("should work with nested 2d array", () => {
+ expect(VecLyUt.convert3dto2d([
+ [[1, 2], [3, 4]],
+ [[5, 6], [7, 8]],
+ ])).toEqual([
+ [[1, 2], [3, 4]],
+ [[5, 6], [7, 8]],
+ ]);
+ });
+ it("should work with nested 3d array", () => {
+ expect(VecLyUt.convert3dto2d([
+ [[1, 2, 3], [4, 5, 6]],
+ [[7, 8, 9], [10, 11, 12]],
+ ])).toEqual([
+ [[1, 2], [4, 5]],
+ [[7, 8], [10, 11]],
+ ]);
+ });
+});
+
+describe("createPrintHighlighParams", () => {
+ const emptyParams = {
+ "geoms": [],
+ "labelFillColors": [],
+ "labelOutlineColors": [],
+ "labelOutlineSizes": [],
+ "labelSizes": [],
+ "labels": [],
+ "styles": [],
+ };
+ const goodLayer = {
+ type: "vector",
+ features: [{
+ styleName: "styleName",
+ styleOptions: {
+ fillColor: "#456000",
+ strokeColor: "#123fff",
+ strokeWidth: 3,
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [[
+ [0, 0],
+ [0, 1],
+ [1, 1],
+ [1, 0],
+ [0, 0],
+ ]],
+ },
+ }],
+ visibility: true,
+ skipPrint: false
+ };
+ it("should return empty when the list is empty", () => {
+ expect(VecLyUt.createPrintHighlighParams(
+ [], "EPSG:4326"
+ )).toEqual(emptyParams);
+ });
+ it("should return empty when the list has no vector", () => {
+ expect(VecLyUt.createPrintHighlighParams(
+ [{
+ ...goodLayer,
+ type: "xxx",
+ }], "EPSG:4326"
+ )).toEqual(emptyParams);
+ });
+ it("should return empty when the list has no features", () => {
+ expect(VecLyUt.createPrintHighlighParams(
+ [{
+ ...goodLayer,
+ features: [],
+ }], "EPSG:4326"
+ )).toEqual(emptyParams);
+ });
+ it("should return empty when the list has no geometries", () => {
+ expect(VecLyUt.createPrintHighlighParams(
+ [{
+ ...goodLayer,
+ features: [
+ {
+ ...goodLayer.features[0],
+ geometry: null,
+ },
+ ],
+ }], "EPSG:4326"
+ )).toEqual(emptyParams);
+ });
+ it("should return empty when the list has nothing visible", () => {
+ expect(VecLyUt.createPrintHighlighParams(
+ [{
+ ...goodLayer,
+ visibility: false,
+ }], "EPSG:4326"
+ )).toEqual(emptyParams);
+ });
+ it("should return empty when the list has nothing printable", () => {
+ expect(VecLyUt.createPrintHighlighParams(
+ [{
+ ...goodLayer,
+ skipPrint: true
+ }], "EPSG:4326"
+ )).toEqual(emptyParams);
+ });
+ it("should return the parameters", () => {
+ expect(VecLyUt.createPrintHighlighParams(
+ [goodLayer], "EPSG:4326"
+ )).toEqual({
+ "geoms": [
+ "POLYGON ((" +
+ "0.0000 0.0000, 0.0000 1.0000, " +
+ "1.0000 1.0000, 1.0000 0.0000, " +
+ "0.0000 0.0000" +
+ "))"
+ ],
+ "labelFillColors": ["#000123"],
+ "labelOutlineColors": ["#fff456"],
+ "labelOutlineSizes": [1],
+ "labelSizes": [10],
+ "labels": [" "],
+ "styles": [
+ expect.stringContaining('xml')
+ ],
+ });
+ });
+});
+
+describe("createSld", () => {
+ it("should work with points", () => {
+ const sld = VecLyUt.createSld(
+ "MultiPoint", "someStyle", {}, 0.5
+ )
+ expect(sld).toMatch(/PointSymbolizer/);
+ });
+ it("should work with lines", () => {
+ const sld = VecLyUt.createSld(
+ "LineString", "someStyle", {}, 0.5
+ )
+ expect(sld).toMatch(/LineSymbolizer/);
+ });
+ it("should work with points", () => {
+ const sld = VecLyUt.createSld(
+ "MultiPolygon", "someStyle", {}, 0.5
+ )
+ expect(sld).toMatch(/PolygonSymbolizer/);
+ });
+ it("should return empty if unknown geometry type", () => {
+ const sld = VecLyUt.createSld(
+ "Lorem", "someStyle", {}, 0.5
+ )
+ expect(sld).toBeNull();;
+ });
+});
+
+describe("geoJSONGeomToWkt", () => {
+ describe("Point", () => {
+ const goodGeometry = {
+ type: "Point",
+ coordinates: [1, 2],
+ };
+ it("should work with plain object", () => {
+ expect(
+ VecLyUt.geoJSONGeomToWkt(goodGeometry)
+ ).toEqual("POINT (1.0000 2.0000)");
+ });
+ it("should work with feature", () => {
+ expect(VecLyUt.geoJSONGeomToWkt({
+ type: "Feature",
+ geometry: goodGeometry
+ })).toEqual("POINT (1.0000 2.0000)");
+ });
+ });
+ describe("LineString", () => {
+ const goodGeometry = {
+ type: "LineString",
+ coordinates: [[1, 2], [3, 4]],
+ };
+ const goodResult = "LINESTRING (1.0000 2.0000, 3.0000 4.0000)";
+ it("should work with plain object", () => {
+ expect(
+ VecLyUt.geoJSONGeomToWkt(goodGeometry)
+ ).toEqual(goodResult);
+ });
+ it("should work with feature", () => {
+ expect(VecLyUt.geoJSONGeomToWkt({
+ type: "Feature",
+ geometry: goodGeometry
+ })).toEqual(goodResult);
+ });
+ });
+ describe("Polygon", () => {
+ const goodGeometry = {
+ type: "Polygon",
+ coordinates: [[[1, 2], [3, 4], [5, 6], [1, 2]]],
+ };
+ const goodResult = (
+ "POLYGON ((" +
+ "1.0000 2.0000, " +
+ "3.0000 4.0000, " +
+ "5.0000 6.0000, " +
+ "1.0000 2.0000" +
+ "))"
+ );
+ it("should work with plain object", () => {
+ expect(
+ VecLyUt.geoJSONGeomToWkt(goodGeometry)
+ ).toEqual(goodResult);
+ });
+ it("should work with feature", () => {
+ expect(VecLyUt.geoJSONGeomToWkt({
+ type: "Feature",
+ geometry: goodGeometry
+ })).toEqual(goodResult);
+ });
+ });
+ describe("MultiPoint", () => {
+ const goodGeometry = {
+ type: "MultiPoint",
+ coordinates: [[1, 2], [3, 4]],
+ };
+ const goodResult = (
+ "MULTIPOINT (" +
+ "1.0000 2.0000, " +
+ "3.0000 4.0000" +
+ ")"
+ );
+ it("should work with plain object", () => {
+ expect(
+ VecLyUt.geoJSONGeomToWkt(goodGeometry)
+ ).toEqual(goodResult);
+ });
+ it("should work with feature", () => {
+ expect(VecLyUt.geoJSONGeomToWkt({
+ type: "Feature",
+ geometry: goodGeometry
+ })).toEqual(goodResult);
+ });
+ });
+ describe("MultiPolygon", () => {
+ const goodGeometry = {
+ type: "MultiPolygon",
+ coordinates: [
+ [[[1, 2], [3, 4], [5, 6], [1, 2]]],
+ [[[7, 8], [9, 10], [11, 12], [7, 8]]],
+ ],
+ };
+ const goodResult = (
+ "MULTIPOLYGON (" +
+ "((" +
+ "1.0000 2.0000, " +
+ "3.0000 4.0000, " +
+ "5.0000 6.0000, " +
+ "1.0000 2.0000" +
+ ")), ((" +
+ "7.0000 8.0000, " +
+ "9.0000 10.0000, " +
+ "11.0000 12.0000, " +
+ "7.0000 8.0000" +
+ ")))"
+ );
+ it("should work with plain object", () => {
+ expect(
+ VecLyUt.geoJSONGeomToWkt(goodGeometry)
+ ).toEqual(goodResult);
+ });
+ it("should work with feature", () => {
+ expect(VecLyUt.geoJSONGeomToWkt({
+ type: "Feature",
+ geometry: goodGeometry
+ })).toEqual(goodResult);
+ });
+ });
+ describe("MultiLineString", () => {
+ const goodGeometry = {
+ type: "MultiLineString",
+ coordinates: [
+ [[1, 2], [3, 4]],
+ [[5, 6], [7, 8]],
+ ],
+ };
+ const goodResult = (
+ "MULTILINESTRING (" +
+ "(1.0000 2.0000, 3.0000 4.0000), " +
+ "(5.0000 6.0000, 7.0000 8.0000)" +
+ ")"
+ );
+ it("should work with plain object", () => {
+ expect(
+ VecLyUt.geoJSONGeomToWkt(goodGeometry)
+ ).toEqual(goodResult);
+ });
+ it("should work with feature", () => {
+ expect(VecLyUt.geoJSONGeomToWkt({
+ type: "Feature",
+ geometry: goodGeometry
+ })).toEqual(goodResult);
+ });
+ });
+ describe("GeometryCollection", () => {
+ const goodGeometry = {
+ type: "GeometryCollection",
+ geometries: [
+ {
+ type: "Point",
+ coordinates: [1, 2],
+ },
+ {
+ type: "LineString",
+ coordinates: [[3, 4], [5, 6]],
+ },
+ ],
+ };
+ const goodResult = (
+ "GEOMETRYCOLLECTION (" +
+ "POINT (1.0000 2.0000), " +
+ "LINESTRING (3.0000 4.0000, 5.0000 6.0000)" +
+ ")"
+ );
+ it("should work with plain object", () => {
+ expect(
+ VecLyUt.geoJSONGeomToWkt(goodGeometry)
+ ).toEqual(goodResult);
+ });
+ it("should work with feature", () => {
+ expect(VecLyUt.geoJSONGeomToWkt({
+ type: "Feature",
+ geometry: goodGeometry
+ })).toEqual(goodResult);
+ });
+ });
+ describe("Others", () => {
+ const goodGeometry = {
+ type: "Point",
+ coordinates: [1, 2],
+ };
+ it("should throw an error if unknown geometry type", () => {
+ expect(() => VecLyUt.geoJSONGeomToWkt({
+ ...goodGeometry,
+ type: "Lorem",
+ })).toThrow();
+ });
+ });
+});
+
+describe("getFeatureCenter", () => {
+ it("should work with a point", () => {
+ expect(VecLyUt.getFeatureCenter({
+ type: "Feature",
+ geometry: {
+ type: "Point",
+ coordinates: [1, 2],
+ },
+ })).toEqual([1, 2]);
+ });
+ it("should work with a line-string", () => {
+ expect(VecLyUt.getFeatureCenter({
+ type: "Feature",
+ geometry: {
+ type: "LineString",
+ coordinates: [[1, 2], [3, 4]],
+ },
+ })).toEqual([2, 3]);
+ });
+ it("should work with a polygon", () => {
+ expect(VecLyUt.getFeatureCenter({
+ type: "Feature",
+ geometry: {
+ type: "Polygon",
+ coordinates: [[[0, 1], [1, 1], [1, 0], [0, 0]]],
+ },
+ })).toEqual([0.5, 0.5]);
+ });
+ it("should work with a multi-point", () => {
+ expect(VecLyUt.getFeatureCenter({
+ type: "Feature",
+ geometry: {
+ type: "MultiPoint",
+ coordinates: [[1, 2], [3, 4]],
+ },
+ })).toEqual([1, 2]);
+ });
+ it("should work with a multi-line-string", () => {
+ expect(VecLyUt.getFeatureCenter({
+ type: "Feature",
+ geometry: {
+ type: "MultiLineString",
+ coordinates: [[[1, 2], [3, 4]], [[5, 6], [7, 8]]],
+ },
+ })).toEqual([3, 4]);
+ });
+
+ it("should work with a multi-polygon", () => {
+ expect(VecLyUt.getFeatureCenter({
+ type: "Feature",
+ geometry: {
+ type: "MultiPolygon",
+ coordinates: [
+ [[[0, 1], [1, 1], [1, 0], [0, 0]]],
+ [[[2, 3], [3, 3], [3, 2], [2, 2]]],
+ ],
+ },
+ })).toEqual([0.5, 0.5]);
+ });
+ it("should not work with anything else", () => {
+ expect(VecLyUt.getFeatureCenter({
+ type: "Feature",
+ geometry: {
+ type: "GeometryCollection",
+ geometries: [{
+ type: "Point",
+ coordinates: [1, 2],
+ }, {
+ type: "LineString",
+ coordinates: [[3, 4], [5, 6]],
+ }],
+ },
+ })).toBeNull();
+ });
+});
+
+describe("kmlToGeoJSON", () => {
+ it("should work with a simple KML", () => {
+ const kml = (
+ '' +
+ '' +
+ '' +
+ '' +
+ 'Simple placemark' +
+ 'A description.' +
+ '' +
+ '-122.0822035425683,37.42228990140251,0' +
+ '' +
+ '' +
+ '' +
+ ''
+ );
+ expect(VecLyUt.kmlToGeoJSON(kml)).toEqual([{
+ "crs": "EPSG:4326",
+ "geometry": {
+ "coordinates": [
+ -122.0822035425683, 37.42228990140251, 0
+ ],
+ "type": "Point"
+ },
+ "id": 0,
+ "properties": {
+ "description": "A description.",
+ "name": "Simple placemark"
+ },
+ "styleName": "default",
+ "styleOptions": {
+ "fillColor": [255, 255, 255, 1],
+ "strokeColor": [0, 0, 0, 1],
+ "strokeDash": [],
+ "strokeWidth": 1,
+ "textFill": [255, 255, 255, 1],
+ "textStroke": [51, 51, 51, 1]
+ },
+ "type": "Feature"
+ }]);
+ });
+});
+
+describe("reprojectGeometry", () => {
+
+});
+
+describe("wktToGeoJSON", () => {
+
+});
diff --git a/utils/index.js b/utils/index.js
new file mode 100644
index 000000000..546f4f939
--- /dev/null
+++ b/utils/index.js
@@ -0,0 +1,22 @@
+export { default as ConfigUtils } from './ConfigUtils';
+export { default as CoordinatesUtils } from './CoordinatesUtils';
+export { default as EditingInterface } from './EditingInterface';
+export { default as FeatureStyles } from './FeatureStyles';
+export { default as IdentifyUtils } from './IdentifyUtils';
+export { showImageEditor } from './ImageEditor';
+export { default as LayerUtils } from './LayerUtils';
+export { default as LocaleUtils } from './LocaleUtils';
+export { default as MapUtils } from './MapUtils';
+export {
+ default as MeasureUtils,
+ LengthUnits,
+ AreaUnits,
+ MeasUnits
+} from './MeasureUtils';
+export { default as MiscUtils } from './MiscUtils';
+export { default as PermaLinkUtils } from './PermaLinkUtils';
+export { default as RoutingInterface } from './RoutingInterface';
+export { default as ServiceLayerUtils } from './ServiceLayerUtils';
+export { default as Signal } from './Signal';
+export { default as ThemeUtils } from './ThemeUtils';
+export { default as VectorLayerUtils } from './VectorLayerUtils';
diff --git a/webpack.common.js b/webpack.common.js
new file mode 100644
index 000000000..c77ca8772
--- /dev/null
+++ b/webpack.common.js
@@ -0,0 +1,113 @@
+
+import HtmlWebpackPlugin from 'html-webpack-plugin';
+import webpack from 'webpack';
+import { fileURLToPath } from 'url';
+import { dirname, join } from 'path';
+
+const __filename = fileURLToPath(import.meta.url);
+
+// Absolute path to the root directory of this library.
+export const lib_root_dir = dirname(__filename);
+
+/**
+ * Common configuration for webpack in all environments.
+ */
+export default (env, argv) => {
+ return {
+ plugins: [
+ // This plugin generates an HTML file with
+ //
+ // 'umd' means the library can be used as:
+ // - AMD module
+ // - CommonJS module
+ // - Global variable
+ // https://webpack.js.org/guides/author-libraries/
+ library: {
+ name: 'qwc2',
+ type: 'umd'
+ },
+
+ // Indicates what global object will be used to mount the library.
+ // To make UMD build available on both browsers and Node.js
+ // we need to set the globalObject option to this.
+ globalObject: 'this',
+ },
+ resolve: {
+ extensions: [".mjs", ".js", ".jsx", '.ts', '.tsx'],
+ symlinks: false,
+ mainFiles: ['index'],
+ },
+
+ module: {
+ rules: [
+ {
+ test: /\.tsx?$/,
+ loader: 'ts-loader',
+ exclude: /node_modules/,
+ },
+ {
+ test: /\.(js|jsx)$/,
+ use: 'babel-loader',
+ exclude: /node_modules/,
+ resolve: {
+ fullySpecified: false,
+ }
+ },
+ {
+ test: /\.css$/,
+ use: [
+ 'style-loader',
+ 'css-loader'
+ ]
+ },
+ {
+ test: /\.svg$/,
+ use: 'file-loader'
+ },
+ {
+ test: /\.png$/,
+ use: [
+ {
+ loader: 'url-loader',
+ options: {
+ mimetype: 'image/png'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ };
+}
diff --git a/webpack.dev.js b/webpack.dev.js
new file mode 100644
index 000000000..cbdde64d3
--- /dev/null
+++ b/webpack.dev.js
@@ -0,0 +1,66 @@
+import { merge } from 'webpack-merge';
+import { resolve } from 'path';
+
+import common, { lib_root_dir } from './webpack.common.js';
+
+// Set this to true to print the full configuration
+// after merging with the common configuration.
+const printWebpackConfig = false;
+
+// Notes on plugins used in demo app:
+// - DefinePlugin:
+// - process.env.NODE_ENV is set by webpack
+// https://webpack.js.org/configuration/mode/#root
+// - __DEVTOOLS__ is not set in production, it is only needed if
+// one needs to disable Redux Devtools in development
+// https://github.com/erikras/react-redux-universal-hot-example/blob/master/README.md
+// - NamedModulesPlugin: not needed in webpack5
+// https://github.com/webpack/webpack/issues/11637#issuecomment-706718119
+// - NoEmitOnErrorsPlugin: no longer needed
+// https://stackoverflow.com/questions/40080501/webpack-when-to-use-noerrorsplugin
+// - LoaderOptionsPlugin is for ancient loaders, page no longer
+// present in webpack 5 docs
+// https://v4.webpack.js.org/plugins/loader-options-plugin/
+// - HotModuleReplacementPlugin: enabled via devServer.hot
+
+/**
+ * Development configuration for webpack.
+ */
+export default (env, argv) => {
+ const merged = merge(common(env, argv), {
+ // Available in code as process.env.NODE_ENV
+ mode: 'development',
+ entry: [
+ 'react-hot-loader/patch',
+ './index.js'
+ ],
+
+ // Each module is executed with eval() and a SourceMap
+ // is added as a DataUrl, Source Maps from Loaders are
+ // processed for better results. Line numbers are correctly
+ // mapped since it gets mapped to the original code
+ devtool: 'eval-cheap-module-source-map',
+
+ output: {
+ // Generated files are placed in a distinct directory
+ // so that `clean` from production does not vipe
+ // out development output and vice-versa.
+ path: resolve(lib_root_dir, 'dist-dev'),
+ // under a distinct name.
+ filename: 'qwc2-dev.js',
+ },
+ devServer: {
+ 'static': {
+ directory: './dist-dev'
+ },
+ port: 7771,
+ hot: true,
+ },
+ });
+ if (printWebpackConfig) {
+ console.log('****************[ WEBPACK CONFIG ]********************');
+ console.log(JSON.stringify(merged, null, 2));
+ console.log('******************************************************');
+ }
+ return merged;
+};
diff --git a/webpack.prod.js b/webpack.prod.js
new file mode 100644
index 000000000..bc6ce5fdf
--- /dev/null
+++ b/webpack.prod.js
@@ -0,0 +1,57 @@
+import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
+import { merge } from 'webpack-merge';
+import common from './webpack.common.js';
+import nodeExternals from 'webpack-node-externals';
+
+// Set this to true to print the full configuration
+// after merging with the common configuration.
+const printWebpackConfig = false;
+
+/**
+ * Production configuration for webpack.
+ */
+export default (env, argv) => {
+ const commonConfig = common(env, argv);
+ const merged = merge(commonConfig, {
+ // Available in code as process.env.NODE_ENV
+ mode: 'production',
+
+ // We have a single entry point for the library where we
+ // collect all exports.
+ entry: './index.js',
+
+ plugins: [
+ new BundleAnalyzerPlugin({
+ analyzerMode: 'static',
+ openAnalyzer: false,
+ reportFilename: 'qwc2-bundle-report.html',
+ })
+ ],
+
+ // A full SourceMap is emitted as a separate file. It adds a
+ // reference comment to the bundle so development tools
+ // know where to find it.
+ devtool: 'source-map',
+
+ output: {
+ filename: 'qwc2.js',
+ sourceMapFilename: 'qwc2.map',
+ },
+
+ // In order not to bundle built-in modules like path, fs, etc.
+ // see https://github.com/liady/webpack-node-externals
+ target: 'node',
+ externalsPresets: { node: true },
+
+ // In order to ignore all modules in node_modules folder.
+ externals: [
+ nodeExternals(),
+ ],
+ });
+ if (printWebpackConfig) {
+ console.log('****************[ WEBPACK CONFIG ]********************');
+ console.log(JSON.stringify(merged, null, 2));
+ console.log('******************************************************');
+ }
+ return merged;
+};