diff --git a/Makefile b/Makefile index 81f13eb7181aeec7067e7789565f2688667b2b40..6eb725f3ac4a8938cf70026a8aead8e26c3257fa 100644 --- a/Makefile +++ b/Makefile @@ -39,12 +39,16 @@ CONF_EXT_DIR = $(abspath conf/ext) SRC_CORE_DIR = $(abspath src/core) SRC_EXT_DIR = $(abspath src/ext) SRC_SSS_DIR = $(abspath src/server_side_scripting) +SRC_LEGACY_DIR = $(abspath src/legacy) LIBS_DIR = $(abspath libs) TEST_CORE_DIR = $(abspath test/core/) TEST_EXT_DIR = $(abspath test/ext) TEST_SSS_DIR =$(abspath test/server_side_scripting) -LIBS = fonts css/fonts css/bootstrap.css css/bootstrap-icons.css js/state-machine.js js/jquery.js js/showdown.js js/dropzone.js css/dropzone.css js/loglevel.js js/leaflet.js css/leaflet.css css/images js/leaflet-latlng-graticule.js js/proj4.js js/proj4leaflet.js js/leaflet-coordinates.js css/leaflet-coordinates.css js/leaflet-graticule.js js/bootstrap-select.js css/bootstrap-select.css js/bootstrap-autocomplete.min.js js/plotly.js js/pako.js js/utif.js js/bootstrap.js js/qrcode.js js/ckeditor.js +LIBS = fonts css/fonts css/bootstrap.css css/bootstrap-icons.css js/state-machine.js js/jquery.js js/showdown.js js/dropzone.js css/dropzone.css js/loglevel.js css/images js/bootstrap-select.js css/bootstrap-select.css js/bootstrap-autocomplete.min.js js/plotly.js js/pako.js js/utif.js js/bootstrap.js js/qrcode.js js/ckeditor.js +LEGACY_MAP_JS = leaflet.js leaflet-latlng-graticule.js proj4.js proj4leaflet.js leaflet-coordinates.js leaflet-graticule.js ext_map.js +LEGACY_MAP_LIBS = $(addprefix js/, $(LEGACY_MAP_JS)) css/leaflet.css css/leaflet-coordinates.css +LEGACY_QUERY_FORM_LIBS = js/query_form.js TEST_LIBS = $(LIBS) js/qunit.js css/qunit.css $(subst $(TEST_CORE_DIR)/,,$(shell find $(TEST_CORE_DIR)/)) @@ -53,9 +57,10 @@ LIBS_SUBDIRS = $(addprefix $(LIBS_DIR)/, js css fonts) ALL: install -install: clean-ignore-zips install-sss cp-src cp-ext cp-conf $(addprefix $(PUBLIC_DIR)/, $(LIBS)) merge_js build_properties merge_xsl +install: clean-ignore-zips install-sss cp-src cp-legacy cp-ext cp-conf $(addprefix $(PUBLIC_DIR)/, $(LIBS)) merge_js build_properties merge_xsl -test: clean-ignore-zips install-sss cp-src cp-ext cp-ext-test cp-conf $(addprefix $(PUBLIC_DIR)/, $(TEST_LIBS)) merge_js build_properties merge_xsl +test: clean-ignore-zips install-sss cp-src cp-legacy cp-ext cp-ext-test cp-conf $(addprefix $(PUBLIC_DIR)/, $(TEST_LIBS)) + $(MAKE) EXTRA_BUILD_FILE="$(TEST_CORE_DIR)/test.properties" merge_js build_properties merge_xsl @for f in $(shell find $(TEST_EXT_DIR) -type f -iname *.js) ; do \ sed -i "/EXTENSIONS/a \<script src=\"$${f#$(TEST_EXT_DIR)/}\" ></script>" $(PUBLIC_DIR)/index.html ; \ echo include $$f; \ @@ -70,15 +75,17 @@ merge_xsl: merge_js: for f in ${BUILDFILELIST} ; do source "$$f" ; done ; \ + if [ "$${BUILD_MODULE_LEGACY_MAP}" = "ENABLED" ] ; then \ + MODULE_DEPENDENCIES+=($(LEGACY_MAP_JS)); \ + fi ; \ JS_DIST_BUNDLE=$${JS_DIST_BUNDLE} AUTO_DISCOVER_MODULES=$$AUTO_DISCOVER_MODULES misc/merge_js.sh $${MODULE_DEPENDENCIES[*]} EXCLUDE_EXPR = %~ %.backup -BUILDFILELIST = $(sort $(filter-out $(EXCLUDE_EXPR),$(wildcard build.properties.d/*))) +EXTRA_BUILD_FILE?= +BUILDFILELIST = $(sort $(filter-out $(EXCLUDE_EXPR),$(wildcard build.properties.d/*))) $(EXTRA_BUILD_FILE) build_properties: @set -a -e ; \ - pushd build.properties.files ; \ - for f in ${BUILDFILELIST} ; do echo "processing ../$$f" && source "../$$f" ; done ; \ - popd ; \ + for f in ${BUILDFILELIST} ; do echo "processing $$f" && source "$$f" ; done ; \ BUILD_NUMBER=$(BUILD_NUMBER) ; \ PROPS=$$(printenv | grep -e "^BUILD_") ; \ echo "SET BUILD PROPERTIES:" ; \ @@ -206,6 +213,25 @@ convert-yaml: misc/yaml_to_json.py $$f > $${f%.yaml}.json ; \ done +cp-legacy: + @for f in ${BUILDFILELIST} ; do source "$$f" ; done ; \ + if [ "$${BUILD_MODULE_LEGACY_MAP}" = "ENABLED" ] ; then \ + $(MAKE) cp-legacy-map ; \ + fi + @for f in ${BUILDFILELIST} ; do source "$$f" ; done ; \ + if [ "$${BUILD_MODULE_LEGACY_QUERY_FORM}" = "ENABLED" ] ; then \ + $(MAKE) cp-legacy-query-form ; \ + fi + +cp-legacy-map: $(addprefix $(PUBLIC_DIR)/, $(LEGACY_MAP_LIBS)) + @echo "/*disabled in favor of legacy ext_map.js*/" > "$(PUBLIC_DIR)/js/map.bundle.js" + +cp-legacy-query-form: $(addprefix $(PUBLIC_DIR)/, $(LEGACY_QUERY_FORM_LIBS)) + @echo "/*disabled in favor of legacy query_form.js*/" > "$(PUBLIC_DIR)/js/query-form.bundle.js" + +$(PUBLIC_DIR)/%: $(SRC_LEGACY_DIR)/% + cp $< $@ + cp-src: cp -r $(SRC_CORE_DIR) $(PUBLIC_DIR) @@ -267,7 +293,7 @@ $(LIBS_DIR)/css/leaflet.css: $(LIBS_DIR)/css/images ln -s $(LIBS_DIR)/leaflet-1.5.1/leaflet.css $@ $(LIBS_DIR)/css/images: unzip $(LIBS_DIR)/css - ln -s $(LIBS_DIR)/leaflet-1.5.1/images $@ + ln -s $(LIBS_DIR)/leaflet-1.5.1/images $@ || true $(LIBS_DIR)/js/leaflet-graticule.js: unzip $(LIBS_DIR)/js ln -s $(LIBS_DIR)/L.Graticule.js $@ @@ -319,7 +345,7 @@ $(LIBS_DIR)/css/fonts: $(LIBS_DIR)/css clean-ignore-zips: $(RM) -r $(SSS_BIN_DIR) $(RM) -r $(PUBLIC_DIR) - for f in $(LIBS_SUBDIRS); do unlink $$f || $(RM) -r $$f || true; done + for f in $(LIBS_SUBDIRS); do $(RM) -r $$f || true; done $(RM) .server_done .PHONY: clean diff --git a/build.properties.d/00_default.properties b/build.properties.d/00_default.properties index 7364f27b3f948209e2082d1b7bd77d236326381b..b912e6c5e0aba594c5520b69525bd541d91e9f58 100644 --- a/build.properties.d/00_default.properties +++ b/build.properties.d/00_default.properties @@ -56,6 +56,7 @@ BUILD_MODULE_EXT_COSMETICS_CUSTOMDATETIME=DISABLED BUILD_MODULE_EXT_QRCODE=ENABLED BUILD_MODULE_SHOW_ID_IN_LABEL=DISABLED BUILD_MODULE_LEGACY_QUERY_FORM=DISABLED +BUILD_MODULE_LEGACY_MAP=DISABLED BUILD_MODULE_USER_MANAGEMENT=ENABLED BUILD_MODULE_USER_MANAGEMENT_CHANGE_OWN_PASSWORD_REALM=CaosDB @@ -174,7 +175,6 @@ MODULE_DEPENDENCIES=( loglevel.js plotly.js webcaosdb.js - query_form.js pako.js utif.js ext_version_history.js @@ -192,12 +192,6 @@ MODULE_DEPENDENCIES=( edit_mode.js ext_entity_state.js ext_file_download.js - leaflet.js - leaflet-graticule.js - leaflet-latlng-graticule.js - leaflet-coordinates.js - proj4.js - proj4leaflet.js tour.js ext_bottom_line.js ext_sss_markdown.js diff --git a/src/legacy/js/ext_map.js b/src/legacy/js/ext_map.js new file mode 100644 index 0000000000000000000000000000000000000000..9d2e1d5d362fb4d24853fd99b81053b0198d8c27 --- /dev/null +++ b/src/legacy/js/ext_map.js @@ -0,0 +1,2377 @@ +/* + * ** header v3.0 + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2019 IndiScale GmbH (info@indiscale.com) + * Copyright (C) 2019 Timm Fitschen (t.fitschen@indiscale.com) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + * ** end header + */ + +'use strict'; + +/** + * @module caosdb_map + * @version 0.5.0 + * + * For displaying a geographical map which shows entities at their associated + * geolocation. + * + * The configuration for this module has to be stored in + * `conf/ext/json/ext_map.json` and comply with the {@link MapConfig} type + * which is described below. + * + * The current version is 0.4.1. It is not considered to be stable because + * implementation of the graticule still is not satisfactory. + * + * Apart from that the specification of the configuration and the + * implementation can be considered stable. + */ +var caosdb_map = new function () { + + var logger = log.getLogger("caosdb_map"); + this.version = "0.5.0"; + this.dependencies = ["log", { + "L": ["latlngGraticule", "Proj"] + }, "navbar", "caosdb_utils"]; + this.logger = logger; + + /** + * Map is initialized, map button is visible in the menu. + * + * @event caosdb_map#map_ready + */ + this.map_ready = new Event("caosdb.caosdb_map.map_ready"); + + /** + * The MapConfig object is used to define all relevant parameters for the + * map including the tiling servers, different views and CRSs of the map, + * the configuration of the graticule(s) and the data model for the + * integrated query generator. + * + * @typedef {object} MapConfig + * @property {boolean} [show=false] - show the map immediately when it is + * ready. This configuration option is being stored locally for keeping + * the map shown/hidden accross page reloads. + * @property {string} version - the version of the map + * @property {string} default_view - the view which is shown when the user + * opens the map and has no stored view from prior visits to the map. + * The view are defined in the `views` property. + * @property {ViewConfig[]} views - array of the configurations for the + * available views of the map. This includes the tiling servers, the + * graticule, the CRS and more. + * @property {DataModelConfig} datamodel - the data model for the + * display of entities in the map (also used by the query generator). + * @property {SelectConfig} select - config for the query generator. + * @property {Object.<string, EntityLayerConfig>} entityLayers - + * configuration for the entity layer which are to be shown on the map. + */ + + /** + * The DataModelConfig object is used to define the CaosDB Properties which + * are interpreted as latitude and longitude in the map. + * + * Note: Both latitude and longitude are expected to be represented in + * decimal format. The latitude should not exceed [-90°,90°] ranges and the + * longitude should not exceed [-180°, 180°] ranges. + * + * @typedef {object} DataModelConfig + * @property {string} [lat=latitude] - the name of the latitude property. + * @property {string} [lng=longitude] - the name of the longitude property. + */ + + /** + * The SelectConfig object configures the custom {@link select_handler} + * plugin for the Leaflet.js module, especially the query generation (for + * searching for Entities in the selected area) and when retrieving + * entities to be shown. + * + * The generated query for a selected area has the pattern + * <code>FIND {@link query.role} {@link query.entity} WITH PATH AREA<code>. + * PATH can be empty or represent a configured path to some entity, e.g. + * `WITH RT1 WITH RT2`. + * AREA stand for the area filter here. + * + * The default values of the {@link query} result in queries for any Record + * in the selected map area. + * + * @typedef {object} SelectConfig + * @property {object} [query] - The configuration of the query. + * @property {string} [query.role=Record] - The role of the entities which + * are to be searched in the selected ares. + * @property {string} [query.entity] The (parent) entity to be searched + * for in the area. Defaults to empty string. + * @property {object} [paths] - A dictionary of paths that define from + * which entities the geographic location shall be taken. + */ + + /** + * The ViewConfig contains the most important parts of the configuration as + * there are no default values set for legal reasons - each person who is + * responsible for a CaosDB Server and WebUI has to answer for the + * configuration of the tiling servers and possibly has to negotiate the + * terms of use with the tiling service providers or provide an own tiling + * server. + * + * It governs most of the actual map functionality including the graticule, + * zoom levels, intial zoom and center of the map, and more. + * + * Furthermore it affects how this view is displayed by the menu of the + * {@link view_change_handler} plugin. + * + * Note: Leaflet comes with a few pre-defined coordinate reference systems + * (cf. {@link https://leafletjs.com/reference-1.5.1.html#crs Defined + * CRSs}). By default, the default CRS of leaflet will be used for the map + * (which is currently EPSG:3857, the Sperical Mercator). If {@link crs} is + * a string, e.g. "EPSG:3395" or "Simple" that matches the pre-defined CRS, + * the pre-defined CRS is used. + * + * @typedef {object} ViewConfig + * @property {string} id - the unique view id, used by the + * {@link default_view} property to identify the default view for + * preserving the active view and view configuration across reloads of + * the page. + * @property {string} name - the name is shown in the views menu. + * @property {string} description - a short discription of the views + * purpose and properties. Also shown in the views menu when mouse + * hovers over the name. + * @property {number} zoom - Initial zoom level. Must be an integer and + * >=0. + * @property {object} center - the coordinates of the initial map center. + * @property {number} center.lat - latitude of the initial map center. + * @property {number} center.lng - longitude of the initial map center. + * @property {TileLayerConfig} tileLayer - configuration of the tiling + * server for the base layer of the map. + * @property {GraticuleConfig} [graticule] - configuration of the graticule + * of the map. + * @property {string|CRSConfig} [crs] - coordinate + * reference system of the map. + */ + + /** + * The TileLayerConfig is a thin extension wrapper around the {@link + * https://leafletjs.com/reference-1.5.1.html#tilelayer-wms-option + * TileLayer.WMS options} and the {@link + * https://leafletjs.com/reference-1.5.1.html#tilelayer-option TileLayer + * options}. + * + * Only three properties are defined by the wrapper {@link type}, {@link + * url} and {@link options}. + * + * If {@link type} is "osm", the tiling layer is configured with an + * OpenStreetMap tile server and the rest of the properties are the ones + * defined by the TileLayer options. + * + * If the {@link type} is "wms", the tiling layer is configured with an + * WebMapService server and the rest of the properties are the ones defined + * by the TileLayer.WMS options. + * + * Other types of tiling layers are not supported as for now. + * + * The {@link url} is of course the url of either the WMS server or the OSM + * tiling server. + * + * The {@link options} is an object which has all properties of the + * respective tileLayer as defined by {@link + * https://leafletjs.com/reference-1.5.1.html#tilelayer-option + * TileLayer options} when {@link type} = "osm" or {@link + * https://leafletjs.com/reference-1.5.1.html#tilelayer-wms-option + * TileLayer.WMS options} when {@link type} = "wms". + * + * @example <caption>Example for a TileLayerConfig with OSM</caption> + * { type: "osm", + * url: "https://example.com/tile/{z}/{y}/{x}", + * options: { + * attribution: "Tiles © Example.com", + * maxZoom: 13 + * } + * } + * + * @example <caption>Example for a TileLayerConfig with WMS</caption> + * { type: "wms", + * url: "https://example.com/wms", + * options: { + * layers: "0", + * format: "image/png", + * attribution: "WMS Server by Example.com", + * version: "1.3.0", + * transparent: true, + * } + * } + * + * @typedef {object} TileLayerConfig + * @property {string} [type="osm"] - the type of the tile layer, "osm" or + * "wms" + * @property {string} url - the url of the OSM or WMS server. + * @property {object} options - the {@link + * https://leafletjs.com/reference-1.5.1.html#tilelayer-option + * TileLayer options} or {@link + * https://leafletjs.com/reference-1.5.1.html#tilelayer-wms-option + * TileLayer.WMS options}. + */ + + /** + * GraticuleConfig for configuring a graticule to be shown on the map. + * + * Experimental Feature! + * + * Currently, two graticule plug-ins are used. {@link + * https://github.com/turban/Leaflet.Graticule L.Graticule.js} handles + * polar maps better but lacks configurability and has no lat/lng labels + * while {@link + * https://github.com/Leaflet/Leaflet.Graticule leaflet.latlng-graticule} + * can show lat/lng labels, comes with a more flexible configurability, but + * cannot handle polar maps sufficiently. + * + * Unfortunately, both plug-ins seem to be unmaintained or at least without + * any progress in the last years. + * + * It is unclear wheather we have to write our own or refactor one of these + * plug-ins It is unclear which plug-in will make it, wheather we have to + * write our own or refactor one of these plug-ins. + * + * For the time being, both can be used and the implementation is set by + * the {@link type} property. If unset or "simple", the L.Graticule.js + * implementation is used. + * + * If {@link type} is "latlngGraticule" the leaflet.latlng-graticule + * implementation is used. + * + * The {@link options} must comply with the configuration options of the + * respective implementation. + * + * @example <caption>Example for the `simple` graticule</caption> + * // this works for polar maps + * { "type": "simple", + * "options": { + * "interval": 45, // draw line every 45th degree + * "style": { + * "color": "#333", + * "weight": 1 + * } + * } + * } + * + * + * @example <caption>Example for the `latlng-graticule`</caption> + * // this does not work for polar maps + * { "type": "latlngGraticule", + * "options": { + * "showLabel": true, + * "dashArray": [5, 5], + * "zoomInterval": { + * // interval configuration for different zoom levels + * // and for lat/lng separately. + * "latitude": [ + * {"start": 2, "end": 2, "interval": 20}, + * {"start": 3, "end": 3, "interval": 10}, + * {"start": 4, "end": 6, "interval": 5}, + * {"start": 7, "end": 10, "interval": 1}, + * {"start": 11, "end": 20, "interval": 1} + * ], + * "longitude": [ + * {"start": 2, "end": 2, "interval": 20}, + * {"start": 3, "end": 3, "interval": 10}, + * {"start": 4, "end": 6, "interval": 5}, + * {"start": 7, "end": 10, "interval": 1}, + * {"start": 11, "end": 20, "interval": 1} + * ] + * } + * } + * } + * + * @typedef {object} GraticuleConfig + * @property {string} type - either "simple" or "latlngGraticule". + * @property {object} options - the options for the graticule + * implementation. + */ + + /** + * CRSConfig for the configuration of the coordinate reference system. + * + * The CRS is managed by the {@link http://kartena.github.io/Proj4Leaflet/ + * Proj4Leaflet} plugin of the Leaflet.js module. + * + * The {@link code} defines the standardized CRS Code, e.g. "EPSG:3857" for + * the widely used Sperical Mercator CRS. + * + * The {@link proj4def} contains the proj4 definition of the CRS. A good + * soure for these definitions is {@link https://epsg.io}, e.g. {@link + * https://epsg.io/3857} for the Sperical Mercartor CRS. + * + * The {@link options} defines the options for the Proj4Leaflet plug-in, + * see {@link http://kartena.github.io/Proj4Leaflet/api/#l-proj-crs-options + * Proj.CRS options}. + * + * @example <caption>Example for CRSConfig</caption> + * { "code": "EPSG:3995", + * "proj4def": `+proj=stere +lat_0=90 +lat_ts=71 +lon_0=0 +k=1 + * +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs`, + * "options": { + * "resolutions": [ "16384", "8192", "4096", "2048", + * "1024", "512", "256", "128", "64", "32", "16", + * "8", "4", "2", "1", "0.5" ] + * } + * } + * + * @typedef {object} CRSConfig + * @property {string} code - the standardized CRS Code. + * @property {string} proj4def - the proj4 definition of the CRS. + * @property {object} options - options according to the + * {@link http://kartena.github.io/Proj4Leaflet/api/#l-proj-crs-options + * Proj.CRS options}. + */ + + /** + * default configuration for the map. + * @type {MapConfig} + */ + this._default_config = { + "version": this.version, + "show": false, + "datamodel": { + "lat": "latitude", + "lng": "longitude", + }, + "select": { + "query": { + "role": "RECORD", + "entity": "", + }, + "paths": {}, + }, + } + + /** + * A function which returns entities which are to be displayed on the map. + * + * The parameters may be used to filter the entities. The caller should not + * expect anything from the entities. + * + * @async + * @callback {mapEntityGetter} + * @param {DataModelConfig} datamodel - datamodel of the entities to be returned. + * @param {number} north - northern bounding latitude + * @param {number} south - southern bounding latitude + * @param {number} west - western bounding longitude + * @param {number} east - eastern bounding longitude + * @returns {HTMLElement[]} array of entities in HTML representation. + */ + + /** + * A function which generates a condensed HTML representation of an entity + * which can be shown as a popup in a map. + * + * @callback {mapEntityPopupGenerator} + * @param {HTMLElement} entity - in HTML representation + * @param {DataModelConfig} datamodel + * @param {Number} lat - latitude + * @param {Number} lng - longitude + * @return {HTMLElement} a popup element. + */ + + /** + * Configuration of the entity layers which are to be shown on the map. + * + * @typedef {object} EntityLayerConfig + * @property {string} id - the id of the entity layer which is used + * internally. + * @property {string} name - a short name which is shown in the entity + * layers menu. + * @property {string} description - a short description which is shown as + * hover-over text in the entity layers menu. + * @property {DivIcon_options} icon_tions - leaflet options for the icon + * (aka the marker) which is shown on the map. These options are + * espcially useful to style the icons (color etc). + * @property {number} zIndexOffset + * @property {mapEntityGetter} get_entities - returns the entities which + * are to be shown on the map. + * @property {mapEntityPopupGenerator} make_popup - returns the popup which + * is to be shown when a user clicks on the map marker. + */ + + /** + * Generates a Property Operator Value (POV) expression by chaining the + * provided arguments with "WITH". + * + * @param {string[]} props - array with the names of RecordTypes + * @returns {string} string with the the filter + */ + this._get_with_POV = function (props) { + var pov = "" + for (let p of props) { + pov = pov + ` WITH "${p}" `; + } + return pov; + } + + /** + * Generates a Property Operator Value (POV) by joining ids with OR. + * + * @param {number[]} ids - array of ids for the filter + * @returns {string} string with the the filter + */ + this._get_id_POV = function (ids) { + ids = ids.map(x => "id=" + x); + return "WITH " + ids.join(" or ") + } + + /** + * Generates a SELECT query string that applies the provided path of + * properties as POV and as selector + * + * If ids is provided, the condition is not created from the path, but + * from ids. + * + * @param {DataModelConfig} datamodel - datamodel of the entities to be returned. + * @param {string[]} path - array with the names of RecordTypes + * @param {number[]} ids - array of ids for the filter + * @returns {string} query string + */ + this._get_select_with_path = function (datamodel, path, ids) { + if (typeof datamodel === "undefined") { + throw new Error("Supply the datamodel.") + } + if (typeof path === "undefined" || path.length == 0) { + throw new Error("Supply at least a RecordType.") + } + const recordtype = path[0]; + const props = path.slice(1, path.length) + var selector = props.join(".") + if (selector != "") { + selector = selector + "." + } + var pov = undefined; + if (typeof ids === "undefined") { + pov = (caosdb_map._get_with_POV(props) + + ` WITH ( "${datamodel.lat}" AND "${datamodel.lng}" )`); + + } else { + pov = caosdb_map._get_id_POV(ids); + } + return `SELECT parent,${selector}${datamodel.lat},${selector}${datamodel.lng} FROM ENTITY "${recordtype}" ${pov} `; + + } + + + /** + * Returns a dictionary where for each top level record in the xmldoc + * The long and lat is assigned. + * + * depth: the depth of the tree including the top level record type: + * e.g. RT1->prop1->prop2->lat/long would be a depth=3 + * + * @param {XMLDocument} xmldoc - xml document containing the entities + * @param {number} depth - the depth at which the properties shall be taken + * @param {DataModelConfig} datamodel - datamodel of the entities to be returned. + * @returns {Object} a dictionary where for each id as key the location ist + * stored as [lat, lng] + */ + this._get_leaf_prop = function (xmldoc, depth, datamodel) { + const paths = [ + ["id"], + // The following creates a list: ["", "", ... (depth times), lat/long] + ("__split__".repeat(depth) + datamodel.lat).split("__split__"), + ("__split__".repeat(depth) + datamodel.lng).split("__split__"), + ]; + const propertyValues = getPropertyValues(xmldoc, paths); + + const leaves = {}; + for (let row of propertyValues) { + leaves[row[0]] = [row[1], row[2]]; + } + return leaves; + } + + /** + * Template for {@link mapEntityGetter}. + * + * This implementation has a single additional parameter which is not + * defined by {@link mapEntityGetter}: + * + * @param {string[]} path - array of strings defining the path to the + * related entity + */ + this._generic_get_current_page_entities = async function ( + datamodel, north, south, west, east, path) { + var container = $(".caosdb-f-main-entities")[0]; + + if (typeof path !== "undefined" && path.length) { + var ids = [] + for (let rec of getEntities(container)) { + ids.push(getEntityID(rec)) + } + if (ids.length) { + const qs = caosdb_map._get_select_with_path(datamodel, path, ids); + let entities = await connection.get("Entity/?query=" + qs); + caosdb_map._set_subprops_at_top(entities, path.length - 1, datamodel); + let results = await transformation.transformEntities(entities); + container = $('<div>').append(results)[0]; + } else { + return []; + } + } + // it is possible, the the page contains entities which do not have + // lat/lng and there doesn't exist any related entity with lat/lng. + return caosdb_map.get_map_entities(container, datamodel); + } + + /** + * Returns a top level record entity from xml. + * + * @param {XMLDocument} entities - xml document containing the entities + * @param {number} rec_id - id of the record to be returned + * @returns {XMLDocument} the corresponding record + */ + this._get_toplvl_rec_with_id = function (entities, rec_id) { + let tmp = $(entities).find(`Response >[id='${rec_id}']`); + if (tmp.length != 1) { + throw new Error("There should be exactly one result record. Not " + + tmp.length) + } + return tmp[0]; + } + + /** + * Set the longitude/latitude from subproperties to the top level + * records in the xml and convert everything to html. + * + * @param {XMLDocument} entities - xml document containing the entities + * @param {number} depth - the depth of the path (full: including the first + * recordtype) + */ + this._set_subprops_at_top = function (entities, depth, datamodel) { + // @review Florian Spreckelsen 2022-05-06 + var latlong = caosdb_map._get_leaf_prop(entities, depth, datamodel); + + for (let rec_id in latlong) { + const is_list_lat = Array.isArray(latlong[rec_id][0]); + const is_list_lng = Array.isArray(latlong[rec_id][0]); + var lat, lng; + if (is_list_lat) { + var lat_val = `<Value>${latlong[rec_id][0].join("</Value><Value>")}</Value>`; + lat = `<Property name="${datamodel.lat}" datatype="LIST<lat>">${lat_val}</Property>`; + var lng_val = `<Value>${latlong[rec_id][1].join("</Value><Value>")}</Value>`; + lng = `<Property name="${datamodel.lng}" datatype="LIST<lng>">${lng_val}</Property>`; + } else { + lat = `<Property name="${datamodel.lat}">${latlong[rec_id][0]}</Property>`; + lng = `<Property name="${datamodel.lng}">${latlong[rec_id][1]}</Property>`; + } + let tmp_rec = caosdb_map._get_toplvl_rec_with_id(entities, rec_id); + tmp_rec.append(str2xml(lat).firstElementChild); + tmp_rec.append(str2xml(lng).firstElementChild); + } + } + + /** + * Template for {@link mapEntityGetter}. + * + * This implementation has a single additional parameter which is not + * defined by {@link mapEntityGetter}: + * + * @param {string[]} path - array of strings defining the path to the + * related entity + */ + this._generic_query_all_entities = async function ( + datamodel, north, south, west, east, path) { + var results = undefined; + if (typeof path !== "undefined" && path.length) { + const qs = caosdb_map._get_select_with_path(datamodel, path); + let entities = await connection.get("Entity/?query=" + qs); + caosdb_map._set_subprops_at_top(entities, path.length - 1, datamodel); + results = await transformation.transformEntities(entities); + } else { + results = await caosdb_map.query( + `FIND ENTITY WITH ( "${datamodel.lat}" AND "${datamodel.lng}" )`); + } + const container = $('<div>').append(results)[0]; + + // As soon as the SELECT query can handle subtyping, the results don't + // have to filtered anymore. + return caosdb_map.get_map_entities(container, datamodel); + } + + /** + * Create a HTML representation of an entity which is suitable for being + * displayed in the map as a marker's popup. + * + * Implements {@link mapEntityPopupGenerator} + * @param {HTMLElement} entity - an entity in HTML representation. + * @param {DataModelConfig} datamodel - configuration of the properties + * used for the coordinates. + * @param {Number} lat - latitude + * @param {Number} lng - longitude + * @returns {HTMLElement} a popup element. + */ + this._make_map_popup = function (entity, datamodel, lat, lng) { + // @review Florian Spreckelsen 2022-05-06 + const role_label = $(entity).find( + ".label.caosdb-f-entity-role").first().clone(); + const parent_list = caosdb_map.make_parent_labels(entity); + const name = caosdb_map.make_entity_name_label(entity); + const dms_lat = L.NumberFormatter.toDMS(lat); + const dms_lng = L.NumberFormatter.toDMS(lng); + let extra_loc_hint = ""; + let path = caosdb_map._get_current_path(); + if (path && path.length > 1) { + extra_loc_hint = `<div>Location of related ${path[path.length-1]}<div>`; + } + const loc = $(`<div class="small text-muted">${extra_loc_hint} + Lat: ${dms_lat} Lng: ${dms_lng}</div>`); + const ret = $('<div/>') + .append(role_label) + .append(parent_list) + .append(name) + .append(loc); + return ret[0]; + } + + /** + * Returns the path from the config corresponding to the value stored in + * the session storage (i.e. the storage should be updated before calling + * this method if the value changes). + * + * @returns {string[]} path - array of strings defining the path to the + * related entity + */ + this._get_current_path = function () { + return caosdb_map.config.select.paths[sessionStorage["caosdb_map.display_path"]]; + } + + + /** + * Default entities layers configuration with two layers: + * "current_page_entities" which shows the entities on the current page and + * "all_map_entities" which shows all entities in the database with + * coordinates. + * + * @type {Object.<string, EntityLayerConfig>} + */ + this._default_entity_layer_config = { + "current_page_entities": { + "name": "Entities on the current page.", + "description": "Show all entities on the current page.", + "icon_options": { + html: '<i class="bi-geo-alt-fill" style="font-size: 20px; color: #00F;"></i>', + iconAnchor: [10, 19], + className: "", + }, + "zIndexOffset": 1000, + "datamodel": { + "lat": "latitude", + "lng": "longitude", + "role": "ENTITY", + "entity": "", + }, + "get_entities": (datamodel, north, south, west, east) => { + let path = caosdb_map._get_current_path() + return caosdb_map._generic_get_current_page_entities( + datamodel, north, south, west, east, path) + }, + "make_popup": this._make_map_popup, + }, + "all_map_entities": { + "name": "All entities", + "description": "Show all entities with coordinates.", + "icon_options": { + html: '<i class="bi-geo-alt-fill" style="font-size: 20px; color: #F00;"></i>', + iconAnchor: [10, 19], + className: "", + }, + "zIndexOffset": 0, + "datamodel": { + "lat": "latitude", + "lng": "longitude", + "role": "ENTITY", + "entity": "", + }, + "get_entities": (datamodel, north, south, west, east) => { + let path = caosdb_map._get_current_path() + return caosdb_map._generic_query_all_entities( + datamodel, north, south, west, east, path) + }, + "make_popup": this._make_map_popup, + }, + }; + + + /** + * If no views are configured this dummy view servers as a placeholder. + * + * @type {ViewConfig} + */ + this._unconfigured_views = [{ + "id": "UNCONFIGURED", + "zoom": 10, + "center": { + "lat": 0, + "lng": 0, + }, + "select": false, + "view_change": false, + "tileLayer": { + "type": "wms", + "url": "/webinterface/${BUILD_NUMBER}/pics/map_tile_caosdb_logo.png" + } + }]; + + /** + * Default configuration for every map view. + * + * This configuration sets a default zoom level and center of the map. + * + * Furthermore, it enables the select handler and the view_change handler. + * + * Leaflets boxZoom handler is disabled, because the select handler uses + * the box for selection. + * + */ + this._default_leaflet_config = { + "center": { + "lat": 53.5332554, + "lng": 8.5783268 + }, + "zoom": 5, + "boxZoom": false, + "select": true, + "view_change": true, + } + + /** (Re-)set this module's functions to standard implementation. + */ + this._init_functions = function () { + logger.trace("enter _init_functions"); + + /** + * Create button with the caosdb-f-toggle-map-button class. + * + * @param {string} content - the button content. Optional, defaults to + * "Map" + */ + this.create_toggle_map_button = function (content = "Map") { + logger.trace("enter create_toggle_map_button"); + let button = $( + `<a class="nav-link" role="button"></a>`); + button.toggleClass("caosdb-f-toggle-map-button", true); + button.text(content); + logger.trace("leave create_toggle_map_button"); + return button[0]; + } + + /** + * Create map panel (div.caosdb-f-map-panel). + */ + this.create_map_panel = function () { + let panel = $("<div>"); + panel.toggleClass("caosdb-f-map-panel", true); + + // for centered and responsive display + panel.toggleClass(["container", "mb-2"], true); + + return panel[0]; + } + + + /** + * Create a Leaflet map in the container and return the map object. + * + * @param {HTMLElement} container - the map container. + * @param {ViewConfig} config - configuration for the map. + * + * @returns {L.Map} the map + */ + this.create_map_view = function (container, config) { + logger.trace("enter create_map_view", container, + config); + caosdb_utils.assert_html_element(container, "param `container`"); + + // crs + // TODO move to separate function + if (typeof config.crs === "string" || config + .crs instanceof String) { + config.crs = L.CRS[config.crs.replace(":", "")]; + logger.debug("use pre-defined CRS ", config.crs); + } else if (typeof config.crs === "object") { + config.crs = new L.Proj.CRS(config.crs.code, + config.crs.proj4def, config.crs.options); + logger.debug("use custom proj4 CRS ", config.crs); + } + + // set tiling server + // TODO move to separate function + var tileLayer; + if (config.tileLayer.type && config.tileLayer.type === + "wms") { + tileLayer = L.tileLayer + .wms(config.tileLayer.url, config.tileLayer + .options); + } else if (config.tileLayer.type === "osm" || + typeof config.tileLayer.type === "undefined") { + tileLayer = L + .tileLayer(config.tileLayer.url, config + .tileLayer.options); + } else { + throw new Error("unknown tileLayer type: " + + config.tileLayer.type); + } + + const wrapped = $("<div/>"); + $(container).append(wrapped); + var map = L.map(wrapped[0], config); + map._crs = config.crs; + tileLayer.addTo(map); + + // add listeners which store the current zoom and center + map.on("zoomend", (e) => { + sessionStorage["caosdb_map.view." + config.id + ".zoom"] = map.getZoom(); + }); + + map.on("moveend", (e) => { + sessionStorage["caosdb_map.view." + config.id + ".center"] = JSON.stringify(map.getCenter()); + }); + + // add mouse coords + // TODO do this outside of this function (one level up) + L.control.coordinates({ + // TODO move to config + "position": "bottomleft", + "decimals": 2, + "enableUserInput": false, + "useDMS": true, + }).addTo(map); + + this._toggle_cb = () => { + // TODO this is shit + // resizing of the jquery toggle() function is the problem + map.invalidateSize(true); + }; + + // TODO move one level up + if (config.graticule) { + this.add_graticule(map, config.graticule); + } + + return map; + } + + + /** + * Initialize the map panel. + * + * The map panel is the HTMLElement which contains the map. Is is + * hidden or shown when the user clicks on the toggle map button. + * + * @param {boolean} show - show this panel immediately. + */ + this.init_map_panel = function (show) { + logger.trace("enter init_map_panel"); + + // remove old + $('.caosdb-f-map-panel').remove(); + + let panel = this.create_map_panel(); + + if (!show) { + $(panel).hide(); + } + $('nav').first().after(panel); + + logger.trace("leave init_map_panel"); + return panel; + } + + + /** + * Toggle the map (show/hide). + */ + this.toggle_map = function () { + logger.trace("enter toggle_map"); + sessionStorage["caosdb_map.show"] = !$(".caosdb-f-map-panel").is(":visible"); + $(".caosdb-f-map-panel").toggle(900, () => this + ._toggle_cb()); + } + + + this.show_map = function () { + logger.trace("enter show_map"); + sessionStorage["caosdb_map.show"] = "true"; + $(".caosdb-f-map-panel").show(900, () => this + ._toggle_cb()); + } + + + /** + * To be called after the map panel has been toggled. + */ + this._toggle_cb = undefined; + + /** + * Bind the toggle_cb function to the click event of the button. + * + * @param {HTMLElement} button + * @param {function} toggle_cb - to be called on click. + * @throws TypeError if the parameters have wrong types. + * @returns {HTMLElement} the button + */ + this.bind_toggle_map = function (button, toggle_cb) { + logger.trace("enter bind_toggle_map"); + caosdb_utils.assert_html_element(button, + "parameter 'button'"); + caosdb_utils.assert_type(toggle_cb, "function", + "parameter 'toggle_cb'"); + $(button).on("click", toggle_cb); + logger.trace("leave bind_toggle_map"); + return button; + } + + /** + * Test if the dependencies are defined in window. + * + * @throws Error - if a dependency is unmet. + * @returns {boolean} true if all dependencies are defined. + */ + this.check_dependencies = function () { + logger.trace("enter check_dependencies"); + for (let dep of this.dependencies) { + if (typeof dep == "string") { + if (typeof window[dep] == "undefined") { + throw new Error("Unmet dependency: " + + dep); + } + } else { + // TODO + logger.warn("could not check dep", dep); + } + } + logger.trace("leave check_dependencies"); + return true; + } + + + /** + * Return the view with the given id from an array of views. + * + * @param {ViewConfig[]} views - array of views + * @param {string} id - the id of the view to be returned. + * @throws {Error} if the view is not in the array. + * @return {ViewConfig} with the given id. + */ + this.get_view_config = function (views, id) { + caosdb_utils.assert_string(id, "param `id`"); + for (const view of views) { + if (view.id === id) + return view; + } + throw new Error("Could not find view " + id); + } + + /** + * Reload layers. + */ + this._reload_layers = function () { + caosdb_map._show_load_info() + const promises = [] + const entity_layer_config = $.extend(true, {}, caosdb_map._default_entity_layer_config, caosdb_map.config["entityLayers"]); + for (const layer of caosdb_map.layers) { + promises.push(caosdb_map._fill_layer(layer.layer_group, + entity_layer_config[layer.id])); + } + Promise.all(promises).then((val) => { + caosdb_map._hide_load_info() + }) + } + + + + /** Initialize the caosdb_map module. + * + * 1) load config + * 2) initialize leaflet plug-ins (select_handler, change-view-handler) + * 3) initialize the container panel + * 4) inttialize the map itself + * 5) add the map toggle button to the navbar + * + * @fires caosdb_map#map_ready + */ + this.init = async function () { + logger.trace("enter init"); + var panel = undefined; + var toggle_button = undefined; + try { + const config = await this.load_config(); + if (config.disabled) { + return; + } + if (!config.views || config.views.length === 0) { + logger.warn("no views in config"); + return; + } + this.config = config; + var show_map = config.show; + if (sessionStorage["caosdb_map.show"]) { + show_map = JSON.parse(sessionStorage["caosdb_map.show"]); + } + this.init_select_handler(); + this.init_view_change_handler(); + panel = this.init_map_panel(show_map); + + // TODO split in smaller pieces and move callback to separate function + this.change_map_view = (view) => { + if (this._map) { + this._map._container.remove(); + this._map.remove(); + } + + var nextview = view || sessionStorage[ + "caosdb_map.view"] || config + .default_view || config.views[0].id; + sessionStorage["caosdb_map.view"] = + nextview; + var nextview_config; + + // try to get the next view config. + try { + nextview_config = this + .get_view_config(config.views, + nextview); + } catch (err) { + logger.warn(err); + + // try the default + nextview = config.default_view || config.views[0].id; + nextview_config = this + .get_view_config(config.views, + nextview); + } + + // remember zoom and center for each view: + var local_conf = {}; + local_conf["zoom"] = sessionStorage["caosdb_map.view." + + nextview + ".zoom"]; + local_conf["center"] = sessionStorage["caosdb_map.view." + + nextview + ".center"]; + if (local_conf["center"]) { + local_conf["center"] = + JSON.parse(local_conf["center"]); + } + + // merge the view config with default values + var view_config = $.extend(true, {}, + this._default_leaflet_config, + nextview_config, + local_conf); + + // create map + this._map = this.create_map_view(panel, + view_config); + + // init entity layers + const entity_layer_config = $.extend(true, {}, this._default_entity_layer_config, config["entityLayers"]); + caosdb_map.entityLayers = entity_layer_config; + this.layers = this.init_entity_layers(entity_layer_config); + var layerControl = L.control.layers(); + + const promises = [] + for (const layer of this.layers) { + + promises.push(caosdb_map._fill_layer(layer.layer_group, + entity_layer_config[layer.id])); + layerControl.addOverlay(layer.layer_group, layer.chooser_html.outerHTML); + layer.layer_group.addTo(this._map); + } + Promise.all(promises).then((val) => { + caosdb_map._hide_load_info() + }) + layerControl.addTo(this._map); + + // initialize handlers + this.add_select_handler(this._map); + + + this.path_ddm = this._get_path_ddm( + (event) => { + sessionStorage["caosdb_map.display_path"] = event.target.value; + caosdb_map._reload_layers(); + }, + this.config.select.paths + ); + this._map.addControl(this.path_ddm); + + + this.add_view_change_handler( + this._map, + config.views, + nextview, + this.change_map_view + ); + }; + this.change_map_view(); + + toggle_button = this.init_toggle_map_button(); + + // indicate that the map is ready: map button is present and + // map is hidden or shown but initialized in either case. + this._map.whenReady(() => { + document.body.dispatchEvent(caosdb_map.map_ready); + }); + } catch (err) { + logger.error("Could not initialize the map.", + err); + if (typeof toggle_button !== "undefined") { + $(toggle_button).remove(); + } + if (typeof panel !== "undefined") { + $(panel).remove(); + } + } + logger.trace("leave init"); + } + + + /** + * The _EntityLayer object is used to pass around map overlay layers + * between functions. It is not part of the public API. + * + * @typedef {object} _EntityLayer + * @property {string} id + * @property {HTMLElement} chooser_html + * @property {L.LayerGroup} layer_group + * @property {boolean} active + */ + + /** + * @param {Object.<string, EntityLayerConfig>} config + * @returns {_EntityLayer[]} + */ + this.init_entity_layers = function (configs) { + var ret = [] + for (let name in configs) { + configs[name]["id"] = name; + ret.push(this._init_single_entity_layer(configs[name])); + } + return ret; + } + + /** + * Initialize an entity layer. + * + * @param {EntityLayerConfig} config + * @return {_EntityLayer} + */ + this._fill_layer = async function (layer_group, config) { + // in case load is called on a filled layer: clear first + layer_group.clearLayers(); + + var entities = await config.get_entities(config.datamodel); + layer_group.entities = entities; + var markers = caosdb_map.create_entity_markers( + entities, config.datamodel, config.make_popup, + config.zIndexOffset, config.icon_options); + + for (const marker of markers) { + layer_group.addLayer(marker); + } + }; + + /** + * Initialize an entity layer. + * + * @param {EntityLayerConfig} config + * @return {_EntityLayer} + */ + this._init_single_entity_layer = function (config) { + logger.trace("enter _init_single_entity_layer", config); + + var layer_group = L.layerGroup(); + + var ret = { + "id": config.id, + "active": typeof config.active === "undefined" || config.active, + "chooser_html": this.make_layer_chooser_html(config), + "layer_group": layer_group + }; + + + return ret; + } + + + /** + * @param {EntityLayerConfig} config + * @return {HTMLElement} + */ + this.make_layer_chooser_html = function (config) { + return $('<span/>') + .attr("title", config.description) + .append(config.icon_options.html) + .append(config.name)[0]; + } + + + /** + * Add the graticule to the current map view. + * + * @parameter {L.Map} map - the leaflet map. + * @parameter {GraticuleConfig} config - the graticule config. + */ + this.add_graticule = function (map, config) { + if (config.type === "latlngGraticule") { + L.latlngGraticule(config.options).addTo(map); + } else if (typeof config.type === "undefined" || + config.type === "simple") { + L.graticule(config.options).addTo(map); + } else { + this.logger.warn("unknown graticule type: ", type); + } + } + + /** + * Add the select_handler to the map + * + * @param {L.Map} map + */ + this.add_select_handler = function (map) { + map.addHandler("select", L.SelectHandler); + } + + /** + * @callback ViewChangeCB + * @param {string} view - the id of the view to be changed to. + */ + + /** + * Add the view_change_handler to the map. + * + * + * @parameter {L.Map} map + * @parameter {ViewConfig[]} views - the available views. + * @parameter {string} active - the id of the currently active view. + * @parameter {ViewChangeCB} view_changer_cb - callback which changes the view. + */ + this.add_view_change_handler = function (map, views, active, + view_changer_cb) { + map.addHandler("view_change", L.ViewChangeHandler); + map.view_change.init(views, active, view_changer_cb); + } + + + /** Initialize the L.ViewChangeHandler as a leaflet extension of + * L.Handler. + */ + this.init_view_change_handler = function () { + L.ViewChangeHandler = + L.Handler.extend(this.view_change_handler); + } + + /** Initialize the L.SelectHandler as a leaflet extension of L.Handler. + */ + this.init_select_handler = function () { + L.SelectHandler = + L.Handler.extend(this.select_handler); + } + + /** + * Show the query panel if not visible, collapse the query shortcuts + * if visible and fill the query string into the text input of the + * query panel. + * + * Also, the query form's paging setting is set to return *all* results + * one page. + * + * The query shortcuts are collapsed because they would otherwise take + * up too much space of the view port and push the map outside of the + * view port. + * + * @param {string} query - the generated query. + */ + this.open_query_panel = function (query) { + var query_panel = $("#caosdb-query-panel"); + + // hide query shortcuts if available and visible + query_panel.find( + ".caosdb-f-shortcuts-panel-toggle-button:not('.caosdb-f-shortcuts-panel-hidden')" + ).click(); + + $("#caosdb-query-panel-collapsible") + .collapse("show"); + + // fill query into text field + query_panel.find("#caosdb-query-textarea").val(query); + + // remove paging for queries generated by the map + query_panel.find("#caosdb-query-paging-input") + .remove(); + } + + + + /** + * Generate a query to search inside a map area bounded by maximum and + * minimum latitudes and longitudes. + * + * This function uses the configuration from {@link SelectConfig} for + * the role and entity, the configuration from {@link DataModelConfig} + * for the latitude and longitude properties, and constructs a query + * filter from the rectangular bounding box such that all entities with + * coordinates inside that area are returned. + * + * The area is specified as follows: + * + * <code> + * north + * +-----------------+ + * | | + * | | + * west| |east + * | | + * | | + * +-----------------+ + * south + * </code> + * + * The horizontal lines (-) mark the maximum and minimum latitude and + * the vertical lines (|) mark the maximum and minimum longitude of the + * area to be searched in. + * + * @param {number} north + * @param {number} south + * @param {number} west + * @param {number} east + * @return {string} a query string. + */ + this.generate_query_from_bounds = function (north, south, west, + east) { + const role = this.config.select.query.role; + var entity = this.config.select.query.entity; + const lat = this.config.datamodel.lat; + const lng = this.config.datamodel.lng; + let path = caosdb_map._get_current_path(); + if (path && path.length > 0 && entity == "") { + entity = path[0]; + } + var additional_path = "" + if (path && path.length > 1) { + additional_path = caosdb_map._get_with_POV( + path.slice(1, path.length)) + } + + const query_filter = " ( " + lat + " < '" + north + + "' AND " + lat + + " > '" + south + "' AND " + lng + " > '" + west + + "' AND " + + lng + " < '" + east + "' ) "; + + const query = "FIND " + role + " " + entity + additional_path + + " WITH " + query_filter; + return query + } + + + /** + * Retrieve the map config from the CaosDBServer. + * + * @param {string} [resource=ext_map.json] - location of the config + * file. + * @returns {MapConfig} config + */ + this.load_config = async function (resource) { + logger.trace("enter load_config"); + + var conf = {}; + try { + resource = resource || "ext_map.json"; + conf = await load_config(resource); + } catch (err) { + logger.error(err); + } + + + var result = $.extend(true, {}, this + ._default_config, conf); + + if (!result.views || result.views.length === 0) { + logger.debug( + "Could not find any view config. using a dummy tiling server." + ); + result.views = this._unconfigured_views; + } + try { + this.check_config(result); + } catch (error) { + logger.error(error.message); + } + logger.trace("leave load_config", result); + return result; + } + + + /** + * Check config (version and all important keys). + * + * @param {MapConfig} config - the config which is to be checked. + * @throws TypeError, if version does not match this modules version. + * @throws Error, if any assertion about the config fails. + * @returns {boolean} true, iff everything is fine. + */ + this.check_config = function (config) { + logger.trace("enter check_config"); + caosdb_utils.assert_type(config, "object", "config"); + if (config.version !== caosdb_map.version) { + throw new TypeError( + "The version of the configuration does not match the version of this implementation of the caosdb_map module. Should be '" + + caosdb_map.version + "', was '" + config + .version + "'."); + } + + logger.trace("enter check_config"); + return true; + } + + + /** + * Create a button, bind the toggle_map function to the on-click event + * and append it to the navbar. + */ + this.init_toggle_map_button = function () { + logger.trace("enter init_toggle_map_button"); + + // remove old + $('.caosdb-f-toggle-map-button').remove(); + + let button = this.create_toggle_map_button(); + this.bind_toggle_map(button, () => this.toggle_map()); + + var ret = navbar.add_button(button); + logger.trace("leave init_toggle_map_button", ret); + return ret; + } + + + /** + * Return all entities which have the properties specified in the + * datamodel configuration. + * + * @param {HTMLElement} container - where the entities in HTML + * representation are kept. + * @param {DataModelConfig} datamodel + * @returns {HTMLElement[]} entities with coordinate properties. + */ + this.get_map_entities = function (container, datamodel) { + var map_entities = $(container) + .find(".caosdb-entity-panel").has( + ".caosdb-property-name:contains('" + datamodel + .lng + "')").has( + ".caosdb-property-name:contains('" + datamodel + .lat + "')"); + return map_entities.toArray(); + } + + + + /** + * Return an array containing spans with the entity's parents' names in + * the pop-up which is shown when the user clicks on an entity marker + * in the map. + * + * @param {HTMLElement} entity - the entity in HTML representation. + * @param {HTMLElement[]} array of spans + */ + this.make_parent_labels = function (entity) { + var parents = getParents(entity); + var ret = []; + for (const par of parents) { + var label = $('<span class="badge">' + par.name + + '</span>') + // TODO move to global css + .css({ + "color": "#333", + "border": "1px solid #333" + }); + ret.push(label[0]); + } + return ret; + } + + + /** + * Create a div which shows the name of the entity and contains a link + * which points to the entity on the page. + * + * This is shown as a part of the pop-up when the user click on an + * entity marker in the map. + * + * @param {HTMLElement} entity - the entity in HTML representation. + * @returns {HTMLElement} a label with the entities name. + */ + this.make_entity_name_label = function (entity) { + const name = getEntityName(entity); + const id = getEntityID(entity); + + const entity_on_page = $(`#${id}`).length > 0; + const href = entity_on_page ? `#${id}` : connection.getBasePath() + `Entity/${id}` + const link_title = entity_on_page ? "Jump to this entity." : "Browse to this entity."; + const link = $(`<a title="${link_title}" href="${href}"/>`) + .addClass("float-end") + .append(`<i class="bi bi-box-arrow-up-right"></i></a>`); + + const name_label = $('<div/>') + // TODO move to global css + .css({ + "margin-top": "4px", + "margin-bottom": "4px" + }) + .text(name) + .append(link); + return name_label[0]; + } + + + /** + * Retrieve entities from the server and return a container. + * + * @param {string} query_str - a CQL query. + * @returns {HTMLElement[]} an array of entities in HTML representation. + */ + this.query = query; + + /* + * Create a single map marker for the given entity. + * + * @param {HTMLElement} map_entity - the entity. + * @param {DataModelConfig} datamodel - specifies the properties for + * coordinates. + * @param {number} lat - latitude + * @param {number} lng - longitude + * @param {DivIcon_options} icon_options + * @param {number} zIndexOffset - zIndexOffset of the marker. + * @param {mapEntityPopupGenerator} [make_popup] - creates popup content. + * @returns {L.Marker} the marker for the map. + */ + var _create_single_entity_marker = function (map_entity, datamodel, lat, + lng, icon_options, zIndexOffset, make_popup) { + // @review Florian Spreckelsen 2022-05-06 + var marker = L.marker([lat, lng], { + icon: L.divIcon(icon_options) + }); + + if (zIndexOffset) { + marker.setZIndexOffset(zIndexOffset); + } + if (make_popup) { + marker.bindPopup(make_popup(map_entity, datamodel, lat, lng)); + } + return marker; + } + + /** + * Create markers for the map for an array of entities. + * + * @param {HTMLElement[]} entities - an array of entities in HTML + * representation. + * @param {DataModelConfig} datamodel - specifies the properties for + * coordinates. + * @param {mapEntityPopupGenerator} [make_popup] - creates popup content. + * @param {number} zIndexOffset - zIndexOffset of the marker. + * @param {DivIcon_options} icon_options + * @returns {L.Marker[]} an array of markers for the map. + */ + this.create_entity_markers = function (entities, datamodel, make_popup, + zIndexOffset, icon_options) { + // @review Florian Spreckelsen 2022-05-06 + logger.trace("enter create_entity_markers", entities, datamodel, + zIndexOffset, icon_options); + + var ret = [] + for (const map_entity of entities) { + var lat_vals = getProperty(map_entity, datamodel.lat); + var lng_vals = getProperty(map_entity, datamodel.lng); + + if (!lng_vals || !lng_vals || lng_vals == "undefined" || lng_vals== "undefined") { + logger.debug("undefined latitude or longitude", + map_entity, lat_vals, lng_vals); + continue; + } + + // we need lat_vals and lng_lavs to be arrays so we make them + // be one + var is_list_lat = true; + if (!Array.isArray(lat_vals)) { + lat_vals = [lat_vals]; + is_list_lat = false; + } + var is_list_lng = true; + if (!Array.isArray(lng_vals)) { + lng_vals = [lng_vals]; + is_list_lng = false; + } + + // both array's length must match + if (is_list_lng !== is_list_lat || + (is_list_lat && is_list_lng && + lat_vals.length !== lng_vals.length)) { + logger.error("Cannot show this entity on the map. " + + "Its lat/long properties have different lenghts: ", + map_entity); + continue; + } + + + // zip both arrays + // [lat1, lat2, ... latN] + // [lng1, lng2, ... lngN] + // into one + // [[lat1,lng1],[lat2,lng2],... [latN,lngN]] + var latlngs = lat_vals.map(function (e, i) { + return [e, lng_vals[i]]; + }); + logger.debug(`create point marker(s) at ${latlngs} for`, + map_entity); + for (let latlng of latlngs) { + var marker = _create_single_entity_marker(map_entity, + datamodel, latlng[0], latlng[1], + icon_options, zIndexOffset, make_popup); + ret.push(marker); + } + + /* Code for showing a PATH on the map. + * Maybe we re-use it later + * + logger.debug(`create path line at ${latlngs} for`, + map_entity); + + var opts = {color:'red', smoothFactor: 10.0, weight: 1.5, opacity: 0.5}; + var opts_2 = {color:'green', smoothFactor: 10.0, weight: 3, opacity: 0.5}; + var path = L.polyline(latlngs, opts); + if (make_popup) { + path.bindPopup(make_popup(map_entity, datamodel, lat, lng)); + } + path.on("mouseover",()=>path.setStyle(opts_2)); + path.on("mouseout",()=>path.setStyle(opts)); + ret.push(path); + * + * + */ + } + return ret; + } + + + /** + * Plug-in for leaflet which adds a menu to the map for changing the view. + * + * A view is a combination of a base map tile layer, a coordinate + * reference system and possibly other properties of the map. + * + * When a new view is activated the old map is being destroyed and a + * new map is contstructed. + */ + this.view_change_handler = { + init: function (views, active, view_changer) { + this._add_view_options(this._view_menu, views, + active, view_changer); + }, + + /** + * Add a all defined views to the menu. + * + * The menu is a set of checkboxes. The currently active view is + * checked initially and when another view is selected a callback + * function intiates change to the new view. + * + * @param {HTMLElement} menu - A form element where the options are + * added. + * @param {ViewConfig[]} view - The view to select from. + * @param {string} active - The id of the currently active view. + * @param {function} view_changer - The callback which initiates + * the reset of the map in the new view. + */ + _add_view_options: function (menu, views, active, + view_changer) { + const click = function (e) { + logger.trace( + "click on view_menu option", e); + var next = e.target.value; + if (next !== active) { + view_changer(e.target.value); + } + } + for (const key of Object.keys(views)) { + const id = views[key].id; + const name = views[key].name || id; + const description = views[key] + .description || name; + const checked = id === active; + const option = $('<div title="' + + description + + '"><input name="view" type="radio" value="' + + id + + '"/><label style="margin-left: 8px">' + + name + '</label></div>') + .css({ + "width": "max-content" + }); + option + .find(":input") + .on("click", click) + .prop("checked", checked) + .prop("name", "nextview"); + $(menu).append(option); + } + }, + + /** + * Initialize the handler after it has been added to the map. + * + * This function is called by the map after the handler has been + * added via {@link L.Map.addHandler} or the handler added itself + * to the map via {@link L.Handler.addTo}. + * + * This method adds the menu button which opens the menu for + * selecting views. + */ + addHooks: function () { + this._view_menu = this._make_view_menu(); + const toggle_view_menu_button = this. + _make_toggle_view_menu_button(this._view_menu); + + const ChangeViewControl = L.Control.extend({ + options: { + position: "bottomleft" + }, + onAdd: function () { + return toggle_view_menu_button; + } + }) + + this._map.addControl(new ChangeViewControl()); + }, + + /** + * Return an empty menu, i.e. a form element where later on the + * view options will be added to. + * + * @returns {HTMLElement} A form element. + */ + _make_view_menu: function () { + var form = $('<form/>') + .hide() + .css({ // TODO move to css + "padding": "4px 10px", + "text-align": "left", + "background-color": "white", + "position": "absolute", + "bottom": "0px", + "left": "34px", + "border": "1px solid black", + "border-radius": "4px", + "min-width": "100px" + }); + + form[0].addEventListener("click", function ( + e) { + // just stop the click here, such that it does not change + // anything else in the map's state. + logger.trace("click on view menu", + e); + e.stopPropagation(); + }); + + return form[0]; + }, + + /** + * Return a button which toggles the view menu. + * + * @param {HTMLElement} the view menu which is to be toggled. + * @returns {HTMLElement} the button. + */ + _make_toggle_view_menu_button: function (view_menu) { + var click = (e) => { + e.stopPropagation(); + logger.trace( + "click on toggle_view_menu_button", + e, view_menu); + $(view_menu).toggle(); + }; + + // TODO refactor and extract function for map controls and + // merge with similar code from the select_handler. + var button = L.DomUtil.create("div", + "leaflet-bar leaflet-control leaflet-control-custom caosdb-f-map-change-view-btn" + ); + button.title = "Change the view"; + // TODO move to css + button.style.backgroundColor = "white"; + button.style.width = "34px"; + button.style.height = "34px"; + button.style.textAlign = "center"; + button.style.marginTop = "2px"; + button.innerHTML = + '<i style="font-size: 20px" class="bi-three-dots-vertical"></i>'; + + button.addEventListener("click", click); + $(button).prepend(view_menu); + + return button; + }, + + /** + * Clean up after the view change handler has been removed from the + * map. + * + * This method removes the menu button from the map. + */ + removeHooks: function () { + $(this._toggle_view_menu_button).remove(); + }, + } + + /** + * Shows the loading information div of the map + */ + this._show_load_info = function () { + $(".caosdb-f-map-loading").attr("style", "display:inherit"); + } + + /** + * Hides the loading information div of the map + */ + this._hide_load_info = function () { + $(".caosdb-f-map-loading").attr("style", "display:None"); + } + + /** + * Return a new leaflet control for setting paths to use for geo location + * + * @param {function} callback - a callback applies the effect of a + * changed path + * @returns {L.Control} the drop down menu button. + */ + this._get_path_ddm = function (callback, paths) { + + // TODO flatten the structure of the code and possibly merge it with the query_button code. + var path_ddm = L.Control.extend({ + options: { + position: "bottomright" + }, + + onAdd: function (m) { + return this.button; + }, + + button: function () { + // TODO refactor to make_map_control function + var button = L.DomUtil + .create("div", + "leaflet-bar leaflet-control leaflet-control-custom" + ); + button.title = `Show the location of related entities. +By default ('Same Entity') entities are shown that have +a geographic location. The other options allow to show +entities on the map using the location of a related +entity.`; + button.style.backgroundColor = "white"; + button.style.textAlign = "center"; + // Distance to zoom buttons: + button.style.marginTop = "10px"; + // TODO implement helper for pictures + let tmp_html = ('<div class="caosdb-f-map-loading" style="display:inherit">Loading Entities...</div><select><option value="same">Same Entity</option>'); + for (let pa in paths) { + tmp_html += `<option value="${pa}">${pa}</option>`; + } + tmp_html += '</select>'; + button.innerHTML = tmp_html; + const select = $(button).find('select'); + select.on("change", callback); + + const current_path = sessionStorage["caosdb_map.display_path"] || "same"; + sessionStorage["caosdb_map.display_path"] = current_path; + select[0].value = current_path + + $(button).on("mousedown", ( + event) => { + event + .stopPropagation(); + }); + $(button).on("mouseup", ( + event) => { + event + .stopPropagation(); + }); + return button; + }(), + }); + return new path_ddm(); + } + + /** + * Plug-in for leaflet which lets the user select an area in the map + * and execute a query using a latitude/longitude filter for entities. + * + * This handler adds a select button as a control to the map. The + * select button toggles and indicates the select mode (on/off). + * + * The selection can be started by either clicking on the map when the + * select mode is on (after enabling it via the select button). + * + * The query button apears as soon as an selection has being finished. + * + * The query button generates a query from for searching inside the + * selected area, opens the query panel and fills the generated query + * into the query panel's text field. + */ + this.select_handler = { + + + /** + * Initialize the handler after it has been added to the map. + * + * This function is called by the map after the handler has been + * added via {@link L.Map.addHandler} or the handler added itself + * to the map via {@link L.Handler.addTo}. + * + * This method + * 1) Adds the select button. + * 2) Adds a listener for the `mousedown` event. + * + * Both are means to start the process of selecting an area in the + * map. + */ + addHooks: function () { + const select_button = this._get_select_button(( + event) => { + logger.trace( + "select button clicked", + this); + event.preventDefault(); + event.stopPropagation(); + this._toggle_select_mode(); + }); + this._select_button = select_button; + this._map.addControl(select_button); + this._map.on('mousedown', this + ._mousedown_listener); + }, + + /** + * Clean up after the select handler has been removed from the map. + * + * This method removes the select button and the `mousedown` + * listener. + */ + removeHooks: function () { + this._select_button.remove(); + this._map.off('mousedown', this + ._mousedown_listener); + }, + + /** + * Change the color of the select button in order to highlight it. + * + * This is used to indicate that the select mode is on and that the + * user can select something by clicking and moving the mouse on + * the map. + */ + _highlight_select_button: function () { + $(this._select_button.button).css({ + backgroundColor: "#90EE90" + }); + }, + + + /** + * Change the color of the select button back to normal. + */ + _unhighlight_select_button: function () { + $(this._select_button.button).css({ + backgroundColor: "white" + }); + }, + + /** + * Toggle the select mode (on/off). + * + * This includes setting the _select_mode_on to true/false, + * highlighting/unhighlighting the select button and + * disabling/enabling the moving of the map center by dragging. + */ + _toggle_select_mode: function () { + logger.trace("toggle select mode", this); + if (this._select_mode_on) { + this._unhighlight_select_button(); + this._map.dragging.enable(); + this._select_mode_on = false; + } else { + this._highlight_select_button(); + this._map.dragging.disable(); + this._select_mode_on = true; + } + }, + + /** + * Return a button for toggling and indicating the select mode. + * + * The select button shows a litte dashed square as its icon. + * + * @param {function} callback - a callback which toggles the select + * mode. + * @returns {L.Control} the select button. + */ + _get_select_button: function (callback) { + + // TODO flatten the structure of the code and possibly merge it with the query_button code. + var select_button = L.Control.extend({ + options: { + position: "topleft" + }, + + onAdd: function (m) { + return this.button; + }, + + button: function () { + // TODO refactor to make_map_control function + var button = L.DomUtil + .create("div", + "leaflet-bar leaflet-control leaflet-control-custom" + ); + button.title = + "Select an area"; + button.style + .backgroundColor = + "white"; + button.style.width = + "34px"; + button.style.height = + "34px"; + button.style.textAlign = + "center"; + // Distance to zoom buttons: + button.style.marginTop = + "10px"; + // TODO implement helper for pictures + button.innerHTML = + '<img width="20px" height="20px" style="margin-top: 5px;" src="/webinterface/${BUILD_NUMBER}/pics/select.svg.png">'; + button.onclick = callback; + + $(button).on("mousedown", ( + event) => { + event + .stopPropagation(); + }); + $(button).on("mouseup", ( + event) => { + event + .stopPropagation(); + }); + return button; + }(), + }); + return new select_button(); + }, + + /** + * Return a button for opening the query panel with a pre-filled query. + * + * The query button has a loupe icon. + * + * The query button is added after an area has been selected and + * only visible as long an area is selected. + * + * @param {function} callback - a callback for opening the query + * panel and fill in the query. + * @returns {L.Control} the query button. + */ + _get_query_button: function (callback) { + + // TODO flatten the structure of the code and possibly merge it with the select_button code. + var query_button = L.Control.extend({ + options: { + position: "topleft" + }, + + onAdd: function (m) { + return this.button; + }, + + button: function () { + var button = L.DomUtil + .create("div", + "leaflet-bar leaflet-control leaflet-control-custom" + ); + button.title = + "Search within this area"; + button.style + .backgroundColor = + "#ff8700"; + button.style.width = + "34px"; + button.style.height = + "34px"; + button.style.textAlign = + "center"; + button.style.marginTop = + "2px"; + button.innerHTML = + '<i style="margin-top: 5px; font-size: 15px" class="bi-search"></i>'; + button.onclick = callback; + + $(button).on("mousedown", ( + event) => { + event + .stopPropagation(); + }); + $(button).on("mouseup", ( + event) => { + event + .stopPropagation(); + }); + return button; + }(), + }); + return new query_button(); + }, + + + /** + * Listens on the mousedown event of the map and calls the + * _startSelect method if either (1) the select mode is on or (2) + * the shift key is pressed during the click on the map. + */ + _mousedown_listener: function (event) { + logger.trace("mousedown", event, "on", this); + if (!event.originalEvent.shiftKey && !this + .select._select_mode_on) { + return; + } + event.originalEvent.preventDefault(); + event.originalEvent.stopPropagation(); + this.select._startSelect(event.latlng); + }, + + /** + * Remove a pre-existing selection and start the process of a new + * selection. + * + * When the user clicks on the map with shift key pressed or + * _select_mode this method is called with the coordinates of the + * click. + * + * This also adds listeners on `mousemove` events (for redrawing + * the selected area) and on `mouseup` events for finishing the + * process of selection. + * + * @param {L.LatLng} start_point - the coordinates where the + * selection begins. + */ + _startSelect: function (start_point) { + this._reset_selection(); + this._point1 = start_point; + logger.trace("point1", this._point1); + + this._map.on("mousemove", this._drawRect); + this._map.on("mouseup", this._endSelect); + }, + + /** + * If present, remove the selected area and the query button from + * the map. + */ + _reset_selection: function () { + this._point1 = undefined; + if (this._rectangle) { + this._rectangle.remove(); + } + this._remove_query_button(); + }, + + /** + * (Re-)draw the rectangle which indicates the currently selected + * area. + * + * This method is added as a listener on the `mousemove` event by + * the _startSelect method. + */ + _drawRect: function (event) { + logger.trace("mousemove", event, "on", this); + event.originalEvent.preventDefault(); + event.originalEvent.stopPropagation(); + + // remove old rectangle + if (this.select._rectangle) { + this.select._rectangle.remove(); + } + + // draw new rectangle + const point2 = event.latlng; + const area = this.select._get_area(this.select + ._point1, point2) + this.select._rectangle = this.select + ._get_select_rectangle(area); + this.select._rectangle.addTo(this); + }, + + + /** + * Return a colored rectangle covering an area. + * + * @param {L.LatLngBounds} area. + * @returns {L.Rectangle} the colored rectangle. + */ + _get_select_rectangle: function (area) { + return L.rectangle(area, { + color: "#ff7800", + weight: 1 + }); + }, + + /** + * Finish the process of selection, add the query button to the map. + * + * This method is a listener on the `mouseup` event and is added by + * the _startSelect method. + * + * It removes itself as a listener and also the _drawRect listener + * on the `mousemove` event. + */ + _endSelect: function (event) { + logger.trace("mouseup", event, "on", this); + event.originalEvent.preventDefault(); + event.originalEvent.stopPropagation(); + + const point2 = event.latlng; + logger.trace("point1", this.select._point1); + logger.trace("point2", point2); + + const area = this.select._get_area(this.select + ._point1, point2) + + this.select._add_query_button(area); + + this.select._point1 = undefined; + this.off("mouseup", this.select._endSelect); + this.off("mousemove", this.select._drawRect); + }, + + /** + * Add a `query` button to the map (showing a loupe icon) which + * opens the query panel with a pre-filled query. + * + * The generated query searches for entities inside the selected + * area `a`. + * + * A pre-existing query button is removed and the new query button + * is stored into this._query_button for later references. + * + * @param {L.LatLngBounds} a - the selected area. + * @return {L.Control} the new query button. + */ + _add_query_button: function (a) { + logger.trace("_add_query_button", a, this); + + // remove older query button + this._remove_query_button(); + + const north = this._round(a.getNorth()); + const south = this._round(a.getSouth()); + const east = this._round(a.getEast()); + const west = this._round(a.getWest()); + const query = caosdb_map + .generate_query_from_bounds(north, south, + west, east) + + + // generate a call-back which opens the query panel with the + // generated query + const callback = (event) => { + logger.trace("click query_button", + this); + event.stopPropagation(); + caosdb_map.open_query_panel(query); + }; + + this._query_button = this._get_query_button( + callback); + this._map.addControl(this._query_button); + return this._query_button; + }, + + /** + * Remove a `query` button if present. + * + * @return {L.Control} the old query button if present, `undefined` + * otherwise. + */ + _remove_query_button: function () { + const old = this._query_button; + if (old) { + old.remove(); + this._query_button = undefined; + return old; + } + }, + + /** + * Return the area specified by two coordinates. + * + * The edges are parallel to the latitude and longitude of the two + * points. + * + * @param {L.LatLng} point1 + * @param {L.LatLng} point2 + * @returns {L.LatLngBounds} the area. + */ + _get_area: function (point1, point2) { + return L.latLngBounds(point1, point2); + }, + + /** + * Round the double value to 3 decimal places. + * + * Note: This function is used to round map coordinates to + * meaningful values. + * + * @param {number} d - a double value + * @returns {number} a rounded double value. + */ + _round: function (d) { + return Math.round(d * 1000) / 1000; + }, + + /** + * The first point of the selected area. + * + * It is stored by the _startSelect method is used by subsequent + * execution of the _drawRect method and eventually the _endSelect + * method. + */ + _point1: undefined, + + /** + * _select_mode_on indicates whether clicks on the map without + * pressing shift will start/end/reset selection. + */ + _select_mode_on: false, + }; + + logger.trace("leave _init_functions"); + } + this._init_functions(); +} + +$(document).ready(function () { + caosdb_modules.register(caosdb_map); +}); diff --git a/src/core/js/query_form.js b/src/legacy/js/query_form.js similarity index 100% rename from src/core/js/query_form.js rename to src/legacy/js/query_form.js diff --git a/test/core/test.properties b/test/core/test.properties new file mode 100644 index 0000000000000000000000000000000000000000..efbe015bc5cbcfa5e01418e8f4841d8658ea3786 --- /dev/null +++ b/test/core/test.properties @@ -0,0 +1,2 @@ +BUILD_MODULE_LEGACY_QUERY_FORM=ENABLED +BUILD_MODULE_LEGACY_MAP=ENABLED