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; +};