From 44cf71453b6069a503d5fd8ca1c5bbfa62da0b0b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Henrik=20tom=20W=C3=B6rden?= <h.tomwoerden@indiscale.com>
Date: Mon, 14 Dec 2020 15:37:50 +0000
Subject: [PATCH] Show entities at related location

---
 .gitignore                         |   2 +-
 CHANGELOG.md                       |   7 +-
 Makefile                           |   6 +-
 src/core/js/caosdb.js              |  85 +++++
 src/core/js/ext_map.js             | 483 ++++++++++++++++++++++++-----
 src/ext/js/fileupload.js           |   2 -
 test/core/js/modules/caosdb.js.js  |  87 +++++-
 test/core/js/modules/ext_map.js.js | 209 ++++++++++---
 test/docker/Dockerfile             |   7 +-
 9 files changed, 762 insertions(+), 126 deletions(-)

diff --git a/.gitignore b/.gitignore
index ef086126..859153e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,13 +13,13 @@
 /sss_bin
 /node_modules/
 /build
+__pycache__
 
 # screen logs
 screenlog.*
 xerr.log
 
 # extensions
-
 conf/ext
 test/ext
 src/ext
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2c54f43..6a4911ac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,12 +20,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   button (edit_mode).
 * Plotly preview has an additional parameter for a config object,
   e.g., for disabling the plotly logo
+- The map can now show entities that have no geo location but are related to
+  entities that have one. This also effects the search from the map.
+* `getPropertyValues` function which generates a table of property values from
+  a xml representation of entities.
 * After a SELECT statement now also all referenced files can be downloaded.
 * Automated documentation builds: `make doc`
 
 ### Changed (for changes in existing functionality)
-- enabled and enhanced autocompletion
 
+* ext_map version bumped to 0.4
+- enabled and enhanced autocompletion
 * Login form is hidden behind another button.
 
 ### Deprecated (for soon-to-be removed features) 
diff --git a/Makefile b/Makefile
index 92f69dfb..1f182e78 100644
--- a/Makefile
+++ b/Makefile
@@ -139,8 +139,10 @@ install-sss:
 	popd ; \
 	./install-sss.sh $(SRC_SSS_DIR) $(SSS_BIN_DIR)
 
-PYTEST ?= pytest-3
+PYTEST ?= pytest
+PIP ?= pip3
 test-sss: install-sss
+	$(PIP) freeze
 	$(PYTEST) -vv $(TEST_SSS_DIR)
 
 
@@ -303,7 +305,7 @@ unzip:
 	for f in $(LIBS_ZIP); do unzip -q -o -d libs $$f; done
 
 
-PYLINT ?= pylint3
+PYLINT ?= pylint
 PYTHON_FILES = $(subst $(ROOT_DIR)/,,$(shell find $(ROOT_DIR)/ -iname "*.py"))
 pylint: $(PYTHON_FILES)
 	for f in $(PYTHON_FILES); do $(PYLINT) -d all -e E,F $$f || exit 1; done
diff --git a/src/core/js/caosdb.js b/src/core/js/caosdb.js
index e5f875ad..0c63fbe0 100644
--- a/src/core/js/caosdb.js
+++ b/src/core/js/caosdb.js
@@ -697,6 +697,91 @@ function getProperties(element) {
     return list;
 }
 
+
+/**
+ * Construct XPath expression from selectors.
+ *
+ * Used by getPropertyValues.
+ *
+ * @param {String[][]} selectors
+ * @return {String[]} XPath expressions.
+ */
+var _constructXpaths = function (selectors) {
+    const xpaths = [];
+    for (let sel of selectors) {
+        var expr = "Property";
+        if (sel[0] == "id") {
+            expr = "";
+        }
+        for (let i = 0; i < sel.length; i++) {
+            const segment = sel[i];
+            if (segment == "id") {
+                expr += `@id`;
+            } else if (segment) {
+                expr += `[@name='${segment}']`;
+            }
+
+            if (i+1 < sel.length) {
+                expr += "//Property"
+            }
+        }
+        xpaths.push(expr);
+    }
+    return xpaths;
+}
+
+/**
+ * Return a table where each row represents an entity and each column a property.
+ *
+ * This also works for entities from select queries, where the properties
+ * are deeply nested, e.g. when each entity references a "Geo Location"
+ * record which have latitude and longitude properties:
+ *
+ * `getPropertyValues(entities, [["Geo Location", "latitude"], ["Geo Location", "longitude"]])`
+ *
+ * Use empty strings for selector elements when the property name is irrelevant:
+ *
+ * `getPropertyValues(entities, [["", "latitude"], ["", "longitude"]])`
+ *
+ * Limitations:
+ *
+ * 1. Currently, this implementation assumes that properties (and subproperties
+ *    for that matter) have unique names, entity-wide and do have a LIST
+ *    datatype.
+ *
+ * 2. It only handles one of the many special cases, which is "id". Other
+ *    special cases ("name", "description", "unit", etc.) are to be added when
+ *    needed.
+ *
+ * @param {XMLElement[]) entities
+ * @param {String[][]} selectors
+ * @return {String[][]} A table of the property values for each entity.
+ */
+var getPropertyValues = function (entities, selectors) {
+    const entity_iter = entities.evaluate("/Response/Record", entities);
+
+    const table = [];
+    const xpaths = _constructXpaths(selectors)
+
+    var current_entity = entity_iter.iterateNext();
+    while (current_entity) {
+        const row = [];
+        for (let expr of xpaths) {
+            const property = entities.evaluate(expr, current_entity).iterateNext();
+            if (typeof property != "undefined" && property != null) {
+                row.push(property.textContent.trim());
+            } else {
+                row.push(undefined)
+            }
+
+        }
+        table.push(row);
+        current_entity = entity_iter.iterateNext();
+    }
+
+    return table;
+}
+
 /**
  * Sets a property with some basic type checking.
  *
diff --git a/src/core/js/ext_map.js b/src/core/js/ext_map.js
index 67c7bfe7..d71a1d1a 100644
--- a/src/core/js/ext_map.js
+++ b/src/core/js/ext_map.js
@@ -25,7 +25,7 @@
 
 /**
  * @module caosdb_map
- * @version 0.3
+ * @version 0.4
  *
  * For displaying a geographical map which shows entities at their associated
  * geolocation.
@@ -34,7 +34,7 @@
  * `conf/ext/json/ext_map.json` and comply with the {@link MapConfig} type
  * which is described below.
  *
- * The current version is 0.3. It is not considered to be stable because
+ * The current version is 0.4. 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
@@ -43,7 +43,7 @@
 var caosdb_map = new function () {
 
     var logger = log.getLogger("caosdb_map");
-    this.version = "0.3";
+    this.version = "0.4";
     this.dependencies = ["log", {
         "L": ["latlngGraticule", "Proj"]
     }, "navbar", "caosdb_utils"];
@@ -84,10 +84,14 @@ var caosdb_map = new function () {
     /**
      * 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).
+     * searching for Entities in the selected area) and when retrieving
+     * entities to be shown.
      *
-     * The generated query has the pattern <code>FIND {@link query.role} {@link
-     * query.entity} WITH ...<code>. The dots stand for the area filter here.
+     * 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.
@@ -98,6 +102,8 @@ var caosdb_map = new function () {
      *     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.
      */
 
     /**
@@ -332,6 +338,7 @@ var caosdb_map = new function () {
                 "role": "RECORD",
                 "entity": "",
             },
+            "paths": {},
         },
     }
 
@@ -373,21 +380,190 @@ var caosdb_map = new function () {
      */
 
     /**
-     * Implements {@link mapEntityGetter}.
+     * 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_current_page_entities = function (
-        datamodel, north, south, west, east) {
-        const container = $(".caosdb-f-main-entities")[0];
+    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 ${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);
     }
 
     /**
-     * Implements {@link mapEntityGetter}.
+     * 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) {
+        var latlong = caosdb_map._get_leaf_prop(entities, depth, datamodel);
+
+        for (let rec_id in latlong) {
+            let tmp_rec = caosdb_map._get_toplvl_rec_with_id(entities, rec_id);
+            tmp_rec.append(str2xml(`<Property name="${datamodel.lat}">${latlong[rec_id][0]}</Property>`).firstElementChild);
+            tmp_rec.append(str2xml(`<Property name="${datamodel.lng}">${latlong[rec_id][1]}</Property>`).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._query_all_entities = async function (
-        datamodel, north, south, west, east) {
-        const results = await caosdb_map.query(`FIND ENTITY WITH ${datamodel.lat} AND ${datamodel.lng}`);
+    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);
     }
 
@@ -410,7 +586,12 @@ var caosdb_map = new function () {
         const name = caosdb_map.make_entity_name_label(entity);
         const dms_lat = L.NumberFormatter.toDMS(lat);
         const dms_lng = L.NumberFormatter.toDMS(lng);
-        const loc = $(`<div class="small text-muted">
+        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/>')
@@ -421,6 +602,18 @@ var caosdb_map = new function () {
         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:
@@ -430,43 +623,52 @@ var caosdb_map = new function () {
      *
      * @type {EntityLayerConfig[]}
      */
-    this._default_entity_layer_config = [{
-        "id": "current_page_entities",
-        "name": "Entities on the current page.",
-        "description": "Show all entities on the current page.",
-        "icon": {
-            html: '<span style="color: #00F; font-size: 20px" class="glyphicon glyphicon-map-marker"></span>',
-            iconAnchor: [10, 19],
-            className: "",
-        },
-        "zIndexOffset": 1000,
-        "datamodel": {
-            "lat": "latitude",
-            "lng": "longitude",
-            "role": "ENTITY",
-            "entity": "",
-        },
-        "get_entities": this._get_current_page_entities,
-        "make_popup": this._make_map_popup,
-    }, {
-        "id": "all_map_entities",
-        "name": "All entities",
-        "description": "Show all entities with coordinates.",
-        "icon": {
-            html: '<span style="color: #F00; font-size: 20px" class="glyphicon glyphicon-map-marker"></span>',
-            iconAnchor: [10, 19],
-            className: "",
+    this._default_entity_layer_config = {
+        "current_page_entities": {
+            "name": "Entities on the current page.",
+            "description": "Show all entities on the current page.",
+            "icon": {
+                html: '<span style="color: #00F; font-size: 20px" class="glyphicon glyphicon-map-marker"></span>',
+                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,
         },
-        "zIndexOffset": 0,
-        "datamodel": {
-            "lat": "latitude",
-            "lng": "longitude",
-            "role": "ENTITY",
-            "entity": "",
+        "all_map_entities": {
+            "name": "All entities",
+            "description": "Show all entities with coordinates.",
+            "icon": {
+                html: '<span style="color: #F00; font-size: 20px" class="glyphicon glyphicon-map-marker"></span>',
+                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,
         },
-        "get_entities": this._query_all_entities,
-        "make_popup": this._make_map_popup,
-    }, ];
+    };
 
 
     /**
@@ -730,6 +932,22 @@ var caosdb_map = new function () {
             throw new Error("Could not find view " + id);
         }
 
+        /**
+         * Reload layers.
+         */
+        this._reload_layers = function () {
+            caosdb_map._show_load_info()
+            const promises = []
+            for (const layer of caosdb_map.layers) {
+                promises.push(caosdb_map._fill_layer(layer.layer_group,
+                    caosdb_map._default_entity_layer_config[layer.id]));
+            }
+            Promise.all(promises).then((val) => {
+                caosdb_map._hide_load_info()
+            })
+        }
+
+
 
         /** Initialize the caosdb_map module.
          *
@@ -807,16 +1025,36 @@ var caosdb_map = new function () {
                         view_config);
 
                     // init entity layers
-                    var layers = this.init_entity_layers(this._default_entity_layer_config);
+                    this.layers = this.init_entity_layers(this._default_entity_layer_config);
                     var layerControl = L.control.layers();
-                    for (const layer of layers) {
+
+                    const promises = []
+                    for (const layer of this.layers) {
+
+                        promises.push(caosdb_map._fill_layer(layer.layer_group,
+                            this._default_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,
@@ -858,12 +1096,33 @@ var caosdb_map = new function () {
          */
         this.init_entity_layers = function (configs) {
             var ret = []
-            for (const conf of configs) {
-                ret.push(this.init_entity_layer(conf));
+            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);
+
+            for (const marker of markers) {
+                layer_group.addLayer(marker);
+            }
+        };
 
         /**
          * Initialize an entity layer.
@@ -871,24 +1130,11 @@ var caosdb_map = new function () {
          * @param {EntityLayerConfig} config
          * @return {_EntityLayer}
          */
-        this.init_entity_layer = function (config) {
-            logger.trace("enter init_entity_layer", config);
+        this._init_single_entity_layer = function (config) {
+            logger.trace("enter _init_single_entity_layer", config);
 
             var layer_group = L.layerGroup();
 
-            // load all entities into layer group
-            var _load = async function (layer_group, config) {
-                var entities = await config.get_entities(config.datamodel);
-                var markers = caosdb_map.create_entitiy_markers(
-                    entities, config.datamodel, config.make_popup,
-                    config.zIndexOffset, config.icon);
-
-                for (const marker of markers) {
-                    layer_group.addLayer(marker);
-                }
-            };
-            _load(layer_group, config);
-
             var ret = {
                 "id": config.id,
                 "active": typeof config.active === "undefined" || config.active,
@@ -975,7 +1221,6 @@ var caosdb_map = new function () {
                 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
@@ -1048,9 +1293,18 @@ var caosdb_map = new function () {
         this.generate_query_from_bounds = function (north, south, west,
             east) {
             const role = this.config.select.query.role;
-            const entity = this.config.select.query.entity;
+            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 +
@@ -1058,7 +1312,7 @@ var caosdb_map = new function () {
                 "' AND " +
                 lng + " < '" + east + "' ) ";
 
-            const query = "FIND " + role + " " + entity +
+            const query = "FIND " + role + " " + entity + additional_path +
                 " WITH " + query_filter;
             return query
         }
@@ -1201,7 +1455,7 @@ var caosdb_map = new function () {
 
             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 entitiy." : "Browse to this entity.";
+            const link_title = entity_on_page ? "Jump to this entity." : "Browse to this entity.";
             const link = $(`<a title="${link_title}" href="${href}"/>`)
                 .addClass("pull-right")
                 .append(`<span class="glyphicon glyphicon-share-alt"/></a>`);
@@ -1239,8 +1493,8 @@ var caosdb_map = new function () {
          * @param {DivIcon_options} icon_options
          * @returns {L.Marker[]} an array of markers for the map.
          */
-        this.create_entitiy_markers = function (entities, datamodel, make_popup, zIndexOffset, icon_options) {
-            logger.trace("enter create_entitiy_markers", entities, datamodel, zIndexOffset, icon_options);
+        this.create_entity_markers = function (entities, datamodel, make_popup, zIndexOffset, icon_options) {
+            logger.trace("enter create_entity_markers", entities, datamodel, zIndexOffset, icon_options);
 
             var ret = []
             for (const map_entity of entities) {
@@ -1440,6 +1694,83 @@ var caosdb_map = new function () {
             },
         }
 
+        /**
+         * 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
@@ -1898,4 +2229,4 @@ var caosdb_map = new function () {
 
 $(document).ready(function () {
     caosdb_modules.register(caosdb_map);
-});
+});
\ No newline at end of file
diff --git a/src/ext/js/fileupload.js b/src/ext/js/fileupload.js
index e21ccf7f..1c069f2a 100644
--- a/src/ext/js/fileupload.js
+++ b/src/ext/js/fileupload.js
@@ -235,8 +235,6 @@ var fileupload = new function() {
     }
 
     this.init = function() {
-        fileupload.debug("init");
-
         // add global listener for start_edit event
         document.body.addEventListener(edit_mode.start_edit.type, function(e) {
             $(e.target).find(".caosdb-properties .caosdb-f-entity-property").each(function(idx) {
diff --git a/test/core/js/modules/caosdb.js.js b/test/core/js/modules/caosdb.js.js
index d3e9b4a2..20dc4b4e 100644
--- a/test/core/js/modules/caosdb.js.js
+++ b/test/core/js/modules/caosdb.js.js
@@ -17,7 +17,7 @@ QUnit.module("caosdb.js", {
         }, err => {console.log(err);});
 
     },
-    
+
     before: function(assert) {
         var done = assert.async(3);
         this.setTestDocument("x", done, `
@@ -476,6 +476,90 @@ QUnit.test("unset_entity_references", function(assert) {
 });
 
 
+QUnit.test("_constructXpaths", function (assert) {
+    assert.propEqual(
+      _constructXpaths([["id"], ["longitude"], ["latitude"]]),
+      ["@id", "Property[@name='longitude']", "Property[@name='latitude']"]
+    );
+    assert.propEqual(
+      _constructXpaths([["Geo Location", "longitude"], ["latitude"]]),
+      ["Property[@name='Geo Location']//Property[@name='longitude']", "Property[@name='latitude']"]
+    );
+    assert.propEqual(
+      _constructXpaths([["", "longitude"], ["latitude"]]),
+      ["Property//Property[@name='longitude']", "Property[@name='latitude']"]
+    );
+    assert.propEqual(
+      _constructXpaths([["", "Geo Location", "", "longitude"]]),
+      ["Property//Property[@name='Geo Location']//Property//Property[@name='longitude']"]
+    );
+});
+
+
+QUnit.test("getPropertyValues", function (assert) {
+    const test_response = str2xml(`
+<Response srid="851d063d-121b-4b67-98d4-6e5ad4d31e72" timestamp="1606214042274" baseuri="https://localhost:10443" count="8">
+  <Query string="select Campaign.responsible.firstname from icecore" results="8">
+    <ParseTree>(cq select (prop_sel (prop_subsel (selector_txt   C a m p a i g n) . (prop_subsel (selector_txt r e s p o n s i b l e) . (prop_subsel (selector_txt f i r s t n a m e  ))))) from (entity icecore) &lt;EOF&gt;)</ParseTree>
+    <Role/>
+    <Entity>icecore</Entity>
+    <Selection>
+      <Selector name="Campaign.responsible.firstname"/>
+    </Selection>
+  </Query>
+  <Record id="6525" name="Test_IceCore_1">
+    <Permissions/>
+    <Property datatype="Campaign" id="6430" name="Campaign">
+      <Record id="6516" name="Test-2020_Camp1">
+        <Permissions/>
+        <Property datatype="REFERENCE" id="168" name="responsible">
+          <Record id="6515" name="Test_Scientist">
+            <Permissions/>
+            <Property datatype="DOUBLE" id="151" name="latitude" importance="FIX">
+              1.34
+              <Permissions/>
+            </Property>
+            <Property datatype="DOUBLE" id="151" name="longitude" importance="FIX">
+              2
+              <Permissions/>
+            </Property>
+          </Record>
+          <Permissions/>
+        </Property>
+      </Record>
+      <Permissions/>
+    </Property>
+  </Record>
+  <Record id="6526" name="Test_IceCore_2">
+    <Permissions/>
+    <Property datatype="Campaign" id="6430" name="Campaign">
+      <Record id="6516" name="Test-2020_Camp1">
+        <Permissions/>
+        <Property datatype="REFERENCE" id="168" name="responsible">
+          <Record id="6515" name="Test_Scientist">
+            <Permissions/>
+            <Property datatype="DOUBLE" id="151" name="latitude" importance="FIX">
+              3
+              <Permissions/>
+            </Property>
+            <Property datatype="DOUBLE" id="151" name="longitude" importance="FIX">
+              4.8345
+              <Permissions/>
+            </Property>
+          </Record>
+          <Permissions/>
+        </Property>
+      </Record>
+      <Permissions/>
+    </Property>
+  </Record>
+</Response>`);
+
+    assert.propEqual(
+      getPropertyValues(test_response, [["id"], ["", "latitude"],["", "longitude"]]),
+      [["6525" ,"1.34", "2"], ["6526", "3", "4.8345"]]);
+});
+
 // Test for bug 103
 // If role is File when creating XML for entities, checksum, path and size must be given.
 QUnit.test("unset_file_attributes", function(assert) {
@@ -497,4 +581,3 @@ QUnit.test("unset_file_attributes", function(assert) {
                              undefined);
     assert.equal(xml2str(res3), "<File id=\"103\" name=\"test\" path=\"testfile.txt\" checksum=\"blablabla\" size=\"0\"/>");
 });
-          
diff --git a/test/core/js/modules/ext_map.js.js b/test/core/js/modules/ext_map.js.js
index 9d068ad1..d14d7ebf 100644
--- a/test/core/js/modules/ext_map.js.js
+++ b/test/core/js/modules/ext_map.js.js
@@ -23,10 +23,13 @@
 'use strict';
 
 QUnit.module("ext_map.js", {
-    before: function(assert) {
+    before: function (assert) {
         var lat = "latitude";
         var lng = "longitude";
-        this.datamodel = { lat: lat, lng: lng };
+        this.datamodel = {
+            lat: lat,
+            lng: lng
+        };
         this.test_map_entity = `
 <div class="caosdb-entity-panel caosdb-properties">
   <div class="caosdb-id">1234</div>
@@ -42,22 +45,22 @@ QUnit.module("ext_map.js", {
   </div>
 </div>`;
     },
-    beforeEach: function(assert) {
+    beforeEach: function (assert) {
         sessionStorage.removeItem("caosdb_map.view");
     }
 });
 
-QUnit.test("availability", function(assert) {
-    assert.equal(caosdb_map.version, "0.3", "test version");
+QUnit.test("availability", function (assert) {
+    assert.equal(caosdb_map.version, "0.4", "test version");
     assert.ok(caosdb_map.init, "init available");
 });
 
-QUnit.test("default config", function(assert) {
+QUnit.test("default config", function (assert) {
     assert.ok(caosdb_map._default_config);
     assert.equal(caosdb_map._default_config.version, caosdb_map.version, "version");
 });
 
-QUnit.test("load_config", async function(assert) {
+QUnit.test("load_config", async function (assert) {
     assert.ok(caosdb_map.load_config, "available");
     var config = await caosdb_map.load_config("non_existing.json");
     assert.ok(config, "returns something");
@@ -65,39 +68,43 @@ QUnit.test("load_config", async function(assert) {
     assert.equal(config.views[0].id, "UNCONFIGURED", "view has id 'UNCONFIGURED'.");
 });
 
-QUnit.test("check_config", function(assert) {
+QUnit.test("check_config", function (assert) {
     assert.ok(caosdb_map.check_config(caosdb_map._default_config), "default config ok");
-    assert.throws(()=>caosdb_map.check_config({"version": "wrong version",}), /The version of the configuration does not match the version of this implementation of the caosdb_map module. Should be '.*', was 'wrong version'./, "wrong version");
+    assert.throws(() => caosdb_map.check_config({
+        "version": "wrong version",
+    }), /The version of the configuration does not match the version of this implementation of the caosdb_map module. Should be '.*', was 'wrong version'./, "wrong version");
 });
 
-QUnit.test("check dependencies", function(assert) {
+QUnit.test("check dependencies", function (assert) {
     assert.ok(caosdb_map.check_dependencies, "available");
-    assert.propEqual(caosdb_map.dependencies, ["log", {"L": ["latlngGraticule", "Proj"]}, "navbar", "caosdb_utils"]);
+    assert.propEqual(caosdb_map.dependencies, ["log", {
+        "L": ["latlngGraticule", "Proj"]
+    }, "navbar", "caosdb_utils"]);
     assert.ok(caosdb_map.check_dependencies(), "deps available");
 });
 
-QUnit.test("create_toggle_map_button", function(assert) {
+QUnit.test("create_toggle_map_button", function (assert) {
     assert.ok(caosdb_map.create_toggle_map_button, "available");
     var button = caosdb_map.create_toggle_map_button();
     assert.equal(button.tagName, "BUTTON", "is button");
-    assert.ok($(button).hasClass("caosdb-f-toggle-map-button"),"has caosdb-f-toggle-map-button class");
+    assert.ok($(button).hasClass("caosdb-f-toggle-map-button"), "has caosdb-f-toggle-map-button class");
     assert.equal($(button).text(), "Map", "button says 'Map'");
 
     // set other content:
     button = caosdb_map.create_toggle_map_button("Karte");
     assert.equal(button.tagName, "BUTTON", "is button");
-    assert.ok($(button).hasClass("caosdb-f-toggle-map-button"),"has caosdb-f-toggle-map-button class");
+    assert.ok($(button).hasClass("caosdb-f-toggle-map-button"), "has caosdb-f-toggle-map-button class");
     assert.equal($(button).text(), "Karte", "button says 'Karte'");
 
 });
 
-QUnit.test("bind_toggle_map", function(assert) {
+QUnit.test("bind_toggle_map", function (assert) {
     let button = $("<button/>")[0];
     let done = assert.async();
 
     assert.ok(caosdb_map.bind_toggle_map, "available");
-    assert.throws(()=>caosdb_map.bind_toggle_map(button), /parameter 'toggle_cb'.* was undefined/, "no function throws");
-    assert.throws(()=>caosdb_map.bind_toggle_map("test", ()=>{}), /parameter 'button'.* was string/, "string button throws");
+    assert.throws(() => caosdb_map.bind_toggle_map(button), /parameter 'toggle_cb'.* was undefined/, "no function throws");
+    assert.throws(() => caosdb_map.bind_toggle_map("test", () => {}), /parameter 'button'.* was string/, "string button throws");
     assert.equal(caosdb_map.bind_toggle_map(button, done), button, "call returns button");
 
     // button click calls 'done'
@@ -105,12 +112,12 @@ QUnit.test("bind_toggle_map", function(assert) {
 });
 
 
-QUnit.test("create_map", function(assert) {
+QUnit.test("create_map", function (assert) {
     assert.equal(typeof caosdb_map.create_map_view, "function", "function available");
 
 });
 
-QUnit.test("create_map_panel", function(assert) {
+QUnit.test("create_map_panel", function (assert) {
     assert.ok(caosdb_map.create_map_panel, "available");
     let panel = caosdb_map.create_map_panel();
     assert.equal(panel.tagName, "DIV", "is div");
@@ -118,9 +125,11 @@ QUnit.test("create_map_panel", function(assert) {
     assert.ok($(panel).hasClass("container"), "has class container");
 });
 
-QUnit.test("create_map_view", function(assert) {
-    var view_config = $.extend(true, {}, caosdb_map._unconfigured_views[0],
-        {"select": true, "view_change": true});
+QUnit.test("create_map_view", function (assert) {
+    var view_config = $.extend(true, {}, caosdb_map._unconfigured_views[0], {
+        "select": true,
+        "view_change": true
+    });
     var map_panel = $("<div/>");
 
     var map = caosdb_map.create_map_view(map_panel[0], view_config);
@@ -144,7 +153,7 @@ QUnit.test("create_map_view", function(assert) {
     map.remove();
 
     // test with special crs:
-    view_config["crs"] =  {
+    view_config["crs"] = {
         "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": {
@@ -163,7 +172,7 @@ QUnit.test("create_map_view", function(assert) {
 
 });
 
-QUnit.test("get_map_entities", function(assert) {
+QUnit.test("get_map_entities", function (assert) {
     var datamodel = this.datamodel;
     var container = $('<div/>').append(this.test_map_entity);
     var map_objects = caosdb_map.get_map_entities(container[0], datamodel);
@@ -171,12 +180,12 @@ QUnit.test("get_map_entities", function(assert) {
 });
 
 
-QUnit.test("create_entitiy_markers", function(assert) {
+QUnit.test("create_entity_markers", function (assert) {
     var datamodel = this.datamodel;
     var entities = $(this.test_map_entity).toArray();
 
     // w/o popup
-    var markers = caosdb_map.create_entitiy_markers(entities, datamodel);
+    var markers = caosdb_map.create_entity_markers(entities, datamodel);
     assert.equal(markers.length, 1, "has one marker");
     assert.ok(markers[0] instanceof L.Marker, "is marker");
     var latlng = markers[0]._latlng;
@@ -185,30 +194,32 @@ QUnit.test("create_entitiy_markers", function(assert) {
     assert.notOk(markers[0].getPopup(), "no popup");
 
     // with popup
-    var markers = caosdb_map.create_entitiy_markers(entities, datamodel, ()=>"popup");
+    var markers = caosdb_map.create_entity_markers(entities, datamodel, () => "popup");
     assert.ok(markers[0].getPopup(), "has popup");
 });
 
 
-QUnit.test("_add_current_page_entities", function(assert) {
+QUnit.test("_add_current_page_entities", async function (assert) {
     var datamodel = this.datamodel;
     var layerGroup = L.layerGroup();
     var container = $('<div class="caosdb-f-main-entities"/>').append(this.test_map_entity);
     $("body").append(container);
 
     assert.equal(layerGroup.getLayers().length, 0, "no layer");
-    var cpe = caosdb_map._get_current_page_entities(datamodel, undefined, undefined, undefined, undefined);
+    var cpe = await caosdb_map._generic_get_current_page_entities(datamodel, undefined, undefined, undefined, undefined, undefined);
 
     assert.equal(cpe.length, 1, "has one entity");
     container.remove();
 });
 
 
-QUnit.test("make_layer_chooser_html", function(assert) {
-    var test_conf = { "id": "test_id",
+QUnit.test("make_layer_chooser_html", function (assert) {
+    var test_conf = {
+        "id": "test_id",
         "name": "test name",
         "description": "test description",
-        "icon": { "html": "<span>ICON</span>",
+        "icon": {
+            "html": "<span>ICON</span>",
         },
     };
 
@@ -217,19 +228,139 @@ QUnit.test("make_layer_chooser_html", function(assert) {
     assert.equal($(layer_chooser).attr("title"), "test description", "description set as title");
 });
 
-QUnit.test("init_entity_layer", function(assert) {
-    var done = assert.async();
-    var test_conf = { "id": "test_id",
+QUnit.test("_init_single_entity_layer", function (assert) {
+    var test_conf = {
+        "id": "test_id",
         "name": "test name",
         "description": "test description",
-        "get_entities": async function() {done(); return []},
-        "icon": { "html": "<span>ICON</span>",
+        "icon": {
+            "html": "<span>ICON</span>",
         },
     }
 
-    var entityLayer= caosdb_map.init_entity_layer(test_conf);
+    var entityLayer = caosdb_map._init_single_entity_layer(test_conf);
     assert.equal(entityLayer.id, test_conf.id, "id");
     assert.equal(entityLayer.active, true, "is active");
     assert.ok(entityLayer.chooser_html instanceof HTMLElement, "chooser_html is HTMLElement");
-    assert.equal(entityLayer.layer_group.getLayers().length, 0 , "empty layergroup");
+    assert.equal(entityLayer.layer_group.getLayers().length, 0, "empty layergroup");
+});
+
+QUnit.test("_get_with_POV ", function (assert) {
+    assert.equal(caosdb_map._get_with_POV(
+        []), "", "no POV");
+    assert.equal(caosdb_map._get_with_POV(
+        ["lol"]), " WITH lol ", "single POV");
+    assert.equal(caosdb_map._get_with_POV(
+        ["lol", "hi"]), " WITH lol  WITH hi ", "with two POV");
+});
+
+
+QUnit.test("_get_select_with_path  ", function (assert) {
+    assert.throws(() => caosdb_map._get_select_with_path(), /Supply the datamodel./, "missing datamodel");
+    assert.throws(() => caosdb_map._get_select_with_path(this.datamodel, []), /Supply at least a RecordType./, "missing value");
+    assert.equal(caosdb_map._get_select_with_path(
+        this.datamodel,
+        ["RealRT"]), "SELECT latitude,longitude FROM ENTITY RealRT  WITH latitude AND longitude ", "RT only");
+    assert.equal(caosdb_map._get_select_with_path(
+        this.datamodel,
+        ["RealRT", "prop1"]), "SELECT prop1.latitude,prop1.longitude FROM ENTITY RealRT  WITH prop1  WITH latitude AND longitude ", "RT with one prop");
+    assert.equal(caosdb_map._get_select_with_path(
+        this.datamodel,
+        ["RealRT", "prop1", "prop2"]), "SELECT prop1.prop2.latitude,prop1.prop2.longitude FROM ENTITY RealRT  WITH prop1  WITH prop2  WITH latitude AND longitude ", "RT with two props");
+});
+
+
+QUnit.test("_get_leaf_prop", async function (assert) {
+    const test_response = str2xml(`
+<Response srid="851d063d-121b-4b67-98d4-6e5ad4d31e72" timestamp="1606214042274" baseuri="https://localhost:10443" count="8">
+  <Query string="select Campaign.responsible.firstname from icecore" results="8">
+    <ParseTree>(cq select (prop_sel (prop_subsel (selector_txt   C a m p a i g n) . (prop_subsel (selector_txt r e s p o n s i b l e) . (prop_subsel (selector_txt f i r s t n a m e  ))))) from (entity icecore) &lt;EOF&gt;)</ParseTree>
+    <Role/>
+    <Entity>icecore</Entity>
+    <Selection>
+      <Selector name="Campaign.responsible.firstname"/>
+    </Selection>
+  </Query>
+  <Record id="6525" name="Test_IceCore_1">
+    <Permissions/>
+    <Property datatype="Campaign" id="6430" name="Campaign">
+      <Record id="6516" name="Test-2020_Camp1">
+        <Permissions/>
+        <Property datatype="REFERENCE" id="168" name="responsible">
+          <Record id="6515" name="Test_Scientist">
+            <Permissions/>
+            <Property datatype="DOUBLE" id="151" name="latitude" importance="FIX">
+              1.34
+              <Permissions/>
+            </Property>
+            <Property datatype="DOUBLE" id="151" name="longitude" importance="FIX">
+              2
+              <Permissions/>
+            </Property>
+          </Record>
+          <Permissions/>
+        </Property>
+      </Record>
+      <Permissions/>
+    </Property>
+  </Record>
+  <Record id="6526" name="Test_IceCore_2">
+    <Permissions/>
+    <Property datatype="Campaign" id="6430" name="Campaign">
+      <Record id="6516" name="Test-2020_Camp1">
+        <Permissions/>
+        <Property datatype="REFERENCE" id="168" name="responsible">
+          <Record id="6515" name="Test_Scientist">
+            <Permissions/>
+            <Property datatype="DOUBLE" id="151" name="latitude" importance="FIX">
+              3
+              <Permissions/>
+            </Property>
+            <Property datatype="DOUBLE" id="151" name="longitude" importance="FIX">
+              4.8345
+              <Permissions/>
+            </Property>
+          </Record>
+          <Permissions/>
+        </Property>
+      </Record>
+      <Permissions/>
+    </Property>
+  </Record>
+</Response>`);
+    var leaves = caosdb_map._get_leaf_prop(test_response, 2, this.datamodel)
+
+    assert.equal(Object.keys(leaves).length, 2, "number of records");
+    assert.notEqual(typeof leaves["6525"], "undefined", "has entity id");
+    assert.deepEqual(leaves["6525"], ["1.34", "2"]);
+    assert.deepEqual(leaves["6526"], ["3", "4.8345"], "long/lat in second rec");
+
+    assert.equal(
+        caosdb_map._get_toplvl_rec_with_id(test_response, "6526")["id"],
+        "6526",
+        "number of records");
+
+    caosdb_map._set_subprops_at_top(
+        test_response, 2, this.datamodel, {
+            "6526": [1.234, 5.67]
+        })
+    assert.equal($(test_response).find(`[name='longitude']`).length,
+        4,
+        "number lng props");
+    assert.equal($(test_response).find(`[name='latitude']`).length,
+        4,
+        "number lat props");
+    // after transforming, the long/lat props should be accessible
+    var html_ents = await transformation.transformEntities(test_response);
+    assert.equal(
+        getProperty(html_ents[0], "longitude"),
+        "2",
+        "longitude of first rec");
+
+});
+
+QUnit.test("_get_id_POV", function (assert) {
+    assert.equal(caosdb_map._get_id_POV([]), "WITH ", "no POV");
+    assert.equal(caosdb_map._get_id_POV([5]), "WITH id=5", "one id");
+    assert.equal(caosdb_map._get_id_POV([5, 6]), "WITH id=5 or id=6", "two ids");
 });
diff --git a/test/docker/Dockerfile b/test/docker/Dockerfile
index 7340ee5a..8eff9aae 100644
--- a/test/docker/Dockerfile
+++ b/test/docker/Dockerfile
@@ -2,13 +2,14 @@ FROM debian:10
 RUN echo "deb http://deb.debian.org/debian buster-backports main" >> /etc/apt/sources.list \
     && apt-get update \
     && apt-get install -y \
-      firefox-esr gettext-base pylint3 python3-pip \
-      python3-httpbin git curl x11-apps xvfb unzip python3-pytest \
+      firefox-esr gettext-base python3-pip \
+      python3-httpbin git curl x11-apps xvfb unzip \
     && apt-get install -y -t buster-backports \
       npm
 
+RUN pip3 install pylint pytest
 RUN pip3 install caosdb
-RUN pip3 install pandas xlrd
+RUN pip3 install pandas xlrd==1.2.0
 RUN pip3 install git+https://gitlab.com/caosdb/caosdb-advanced-user-tools.git@dev
 # For automatic documentation
 RUN npm install -g jsdoc
-- 
GitLab