Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
ext_map.js 88.86 KiB
/*
 * ** 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.4.1
 *
 * 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.4.1";
    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.
     */

    /**
     * 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 &copy; 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.
     */

    /**
     * @typedef {object} EntityLayerConfig
     * @property {string} id
     * @property {string} name
     * @property {string} description
     * @property {DivIcon_options} icon_options
     * @property {number} zIndexOffset
     * @property {mapEntityGetter} get_entities
     * @property {mapEntityPopupGenerator} make_popup
     */

    /**
     * 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&lt;lat&gt;">${lat_val}</Property>`;
                var lng_val = `<Value>${latlong[rec_id][1].join("</Value><Value>")}</Value>`;
                lng = `<Property name="${datamodel.lng}" datatype="LIST&lt;lng&gt;">${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 {EntityLayerConfig[]}
     */
    this._default_entity_layer_config = {
        "current_page_entities": {
            "name": "Entities on the current page.",
            "description": "Show all entities on the current page.",
            "icon": {
                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": {
                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"]);
                    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 {EntityLayerConfig[]} configs
         * @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);

            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.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("pull-right")
                .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);
});