-
Timm Fitschen authoredTimm Fitschen authored
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 © 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<lat>">${lat_val}</Property>`;
var lng_val = `<Value>${latlong[rec_id][1].join("</Value><Value>")}</Value>`;
lng = `<Property name="${datamodel.lng}" datatype="LIST<lng>">${lng_val}</Property>`;
} else {
lat = `<Property name="${datamodel.lat}">${latlong[rec_id][0]}</Property>`;
lng = `<Property name="${datamodel.lng}">${latlong[rec_id][1]}</Property>`;
}
let tmp_rec = caosdb_map._get_toplvl_rec_with_id(entities, rec_id);
tmp_rec.append(str2xml(lat).firstElementChild);
tmp_rec.append(str2xml(lng).firstElementChild);
}
}
/**
* Template for {@link mapEntityGetter}.
*
* This implementation has a single additional parameter which is not
* defined by {@link mapEntityGetter}:
*
* @param {string[]} path - array of strings defining the path to the
* related entity
*/
this._generic_query_all_entities = async function (
datamodel, north, south, west, east, path) {
var results = undefined;
if (typeof path !== "undefined" && path.length) {
const qs = caosdb_map._get_select_with_path(datamodel, path);
let entities = await connection.get("Entity/?query=" + qs);
caosdb_map._set_subprops_at_top(entities, path.length - 1, datamodel);
results = await transformation.transformEntities(entities);
} else {
results = await caosdb_map.query(
`FIND ENTITY WITH ( "${datamodel.lat}" AND "${datamodel.lng}" )`);
}
const container = $('<div>').append(results)[0];
// As soon as the SELECT query can handle subtyping, the results don't
// have to filtered anymore.
return caosdb_map.get_map_entities(container, datamodel);
}
/**
* Create a HTML representation of an entity which is suitable for being
* displayed in the map as a marker's popup.
*
* Implements {@link mapEntityPopupGenerator}
* @param {HTMLElement} entity - an entity in HTML representation.
* @param {DataModelConfig} datamodel - configuration of the properties
* used for the coordinates.
* @param {Number} lat - latitude
* @param {Number} lng - longitude
* @returns {HTMLElement} a popup element.
*/
this._make_map_popup = function (entity, datamodel, lat, lng) {
// @review Florian Spreckelsen 2022-05-06
const role_label = $(entity).find(
".label.caosdb-f-entity-role").first().clone();
const parent_list = caosdb_map.make_parent_labels(entity);
const name = caosdb_map.make_entity_name_label(entity);
const dms_lat = L.NumberFormatter.toDMS(lat);
const dms_lng = L.NumberFormatter.toDMS(lng);
let extra_loc_hint = "";
let path = caosdb_map._get_current_path();
if (path && path.length > 1) {
extra_loc_hint = `<div>Location of related ${path[path.length-1]}<div>`;
}
const loc = $(`<div class="small text-muted">${extra_loc_hint}
Lat: ${dms_lat} Lng: ${dms_lng}</div>`);
const ret = $('<div/>')
.append(role_label)
.append(parent_list)
.append(name)
.append(loc);
return ret[0];
}
/**
* Returns the path from the config corresponding to the value stored in
* the session storage (i.e. the storage should be updated before calling
* this method if the value changes).
*
* @returns {string[]} path - array of strings defining the path to the
* related entity
*/
this._get_current_path = function () {
return caosdb_map.config.select.paths[sessionStorage["caosdb_map.display_path"]];
}
/**
* Default entities layers configuration with two layers:
* "current_page_entities" which shows the entities on the current page and
* "all_map_entities" which shows all entities in the database with
* coordinates.
*
* @type {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);
});