import { L } from "./leaflet/index.js";
import proj4 from "proj4";
import { cloneDeep } from "lodash";
import { Map } from './Map.js';
import { Layer } from './layer/Layer/Layer.js';
import { GeoJSONLayer } from './layer/GeoJSONLayer/GeoJSONLayer.js';
import { roundNumber } from '../utils/utils.js';
import { ContextMenu } from '../ui/ContextMenu.js';
import { EventManager } from '../events/EventManager.js';
import { currentColor } from "../utils/openColorPicker.js";
import "./Map2D.css";
export { Map2D };
/**
* 2D Map Implementation based on leaflet
*
* @author rhess <robin.hess@awi.de>
* @author sjaswal <shahzeib.jaswal@awi.de>
* @author rkoppe <roland.koppe@awi.de>
*
* @memberof vef.map
*/
class Map2D extends Map {
static leafletInitialized_ = false;
/**
* @param {string/HTMLElement} target id or HTMLElement
* @param {Object} options options object
*/
constructor(target, options) {
super(null, options);
this.leafletMap_ = null;
this.activeArea_ = null;
this.resizeObserver_ = null;
this.contextMenu_ = null;
this.getElement().classList.add("map-2d");
this.initMap_();
this.initDrawEvents_();
if (this.options.ctrlToZoom) this.initCtrlToZoom_();
this.setActiveArea("default-active-area", false);
this.initResizeObserver_();
this.initContextMenu_();
if (target) this.appendTo(target);
}
/**
* @private
*/
initResizeObserver_() {
this.resizeObserver_ = new ResizeObserver(() => this.resetViewport());
this.resizeObserver_.observe(this.mapContainer_);
}
/**
* @private
*/
calculateResponsiveMinZoom_() {
this.leafletMap_.setMinZoom(null);
const bbox = Map.getFitToScreenBounds(this.options.crs);
let zoom = this.leafletMap_.getBoundsZoom([[bbox.min.y, bbox.min.x], [bbox.max.y, bbox.max.x]]);
if (
!Number.isNaN(this.options.minZoom) &&
this.options.minZoom !== null &&
(zoom < this.options.minZoom)
) zoom = this.options.minZoom;
// push to end of event loop to wait for the maps projection switch to be completed
setTimeout(() => this.leafletMap_.setMinZoom(zoom), 0);
}
/**
* @private
*/
setResponsiveMaxBounds_() {
if (["EPSG:3857", "EPSG:4326"].includes(this.options.crs)) {
let bbox = this.options.maxBounds || cloneDeep(Map.getFitToScreenBounds(this.options.crs));
if (!this.options.maxBounds) {
bbox.min.x = -Infinity;
bbox.max.x = Infinity;
}
this.leafletMap_.setMaxBounds([[bbox.min.y, bbox.min.x], [bbox.max.y, bbox.max.x]]);
} else {
this.leafletMap_.setMaxBounds(null);
}
}
/**
* reset viewport of the map.
* necessary after resizing the div or switching the projection
*/
resetViewport() {
this.leafletMap_.invalidateSize();
this.calculateResponsiveMinZoom_();
}
/**
* Deprecated. Replaced by public function.
*
* @private
*/
resetViewport_() { this.resetViewport(); }
initContextMenu_() {
let clickedCoordinates = "";
const htmlElement = this.leafletMap_.getContainer();
const getLatLng = (node, x, y) => {
const rect = htmlElement.getBoundingClientRect();
x = x - rect.left;
y = y - rect.top;
const latLng = this.leafletMap_.containerPointToLatLng([x, y]).wrap();
const lat = roundNumber(latLng.lat, 3, 3);
const lng = roundNumber(latLng.lng, 3, 3);
clickedCoordinates = lat + ", " + lng;
return clickedCoordinates;
};
this.contextMenu_ = new ContextMenu();
this.contextMenu_.registerElement(htmlElement, [
{
icon: "fas fa-map-marked-alt",
text: getLatLng,
callback: () => {
if (navigator.clipboard) {
navigator.clipboard.writeText(clickedCoordinates);
console.warn("copied coordinates:" + clickedCoordinates);
} else {
console.warn("navigator.clipboard not available");
}
}
}
]);
}
/**
* @private
*/
initCtrlToZoom_() {
const element = this.getElement();
let resetScrollTimeout = null;
this.leafletMap_.scrollWheelZoom.disable();
const eventCallback = (event) => {
event.stopPropagation();
if (event.ctrlKey == true) {
event.preventDefault();
this.leafletMap_.scrollWheelZoom.enable();
element.classList.remove('ctrl-To-zoom');
} else {
this.leafletMap_.scrollWheelZoom.disable();
element.classList.add('ctrl-To-zoom');
}
clearTimeout(resetScrollTimeout);
resetScrollTimeout = setTimeout(() => {
this.leafletMap_.scrollWheelZoom.disable();
element.classList.remove('ctrl-To-zoom');
}, 2000);
}
element.addEventListener('mousewheel', eventCallback, false);
element.addEventListener('DOMMouseScroll', eventCallback, false);
}
/**
* initialize div containers
*/
initMap_() {
this.leafletMap_ = new L.map(this.mapContainer_, {
minZoom: this.options.minZoom,
maxZoom: this.options.maxZoom,
zoomSnap: 0,
zoomControl: false,
doubleClickZoom: this.options.doubleClickZoom,
crs: L.CRS[this.options.crs.replace(":", "")],
// disable feature for safari bug in leaflet 1.7.1
tap: false,
renderer: new L.CanvasMod()
});
// build wrapper for events
this.leafletMap_.on("moveend", () => this.fire("move_end", this));
this.leafletMap_.on("zoomend", () => this.fire("zoom_end", this));
this.leafletMap_.on("click", e => {
this.fire("click", {
lat: e.latlng.lat,
lng: e.latlng.lng
})
});
this.leafletMap_.on("mousemove", (e) => {
const latLng = e.latlng.wrap();
this.fire("mousemove", { lat: latLng.lat, lng: latLng.lng })
});
// close popups when certain events happen
if (this.options.autoClosePopups) {
this.leafletMap_.on('zoomend', () => this.leafletMap_.closePopup());
this.on("map_add_layer", () => this.leafletMap_.closePopup());
this.on("map_remove_layer", () => this.leafletMap_.closePopup());
this.on("projection_change", () => this.leafletMap_.closePopup());
EventManager.on("close_map_popup", () => this.leafletMap_.closePopup());
}
this.setView(this.options.initialCenter, this.options.initialZoom);
this.setMaxBounds(this.options.maxBounds);
}
initDrawEvents_() {
this.leafletMap_.on(L.Draw.Event.DRAWSTART, () => {
this.editing = true;
});
this.leafletMap_.on(L.Draw.Event.DRAWSTOP, () => {
// delay to prevent directly clicking on the map
setTimeout(() => { this.editing = false; }, 300);
});
this.leafletMap_.on(L.Draw.Event.CREATED, e => {
this.stopDrawing();
const leafletLayer = e.layer;
leafletLayer.feature = leafletLayer.feature || {};
leafletLayer.feature.type = leafletLayer.feature.type || "Feature";
leafletLayer.feature.properties = leafletLayer.feature.properties || {};
leafletLayer.feature.properties.color = currentColor
// temporarily add layer to avaoid flicker
this.leafletMap_.addLayer(leafletLayer);
// add acutual layer with a delay to prevent clicking
setTimeout(() => {
leafletLayer.remove();
const layer = new GeoJSONLayer({
title: e.layerType,
geoJSON: leafletLayer.toGeoJSON()
});
this.fire("create_layer", layer);
}, 300);
})
}
/**
* Append the map to a target element.
* {@code target} can be an "#id" with with or without a hash in the beginning
* or an instance of HTMLElement.
*
* @param {string | HTMLElement} target id or HTMLElement
* @param {string | HTMLElement} position (optional, default=beforeend) beforebegin, afterbegin, beforeend, afterend
*
* @override
*/
appendTo(target, position) {
super.appendTo(target, position);
// push to end of event loop to wait for the current
// execution stack to finish (necessary when appended to new element)
setTimeout(() => this.resetViewport(), 0);
}
/**
* Wrapper method to zoom in one step
*/
zoomIn() {
this.leafletMap_.zoomIn();
}
/**
* Wrapper method to zoom out one step
*/
zoomOut() {
this.leafletMap_.zoomOut();
}
/**
* Wrapper method to get the current zoom of the map
*
* @returns {number} zoom level
*/
getZoom() {
return this.leafletMap_.getZoom();
}
/**
* Wrapper method to get the min zoom of the map
*
* @returns {number} min zoom level
*/
getMinZoom() {
return this.leafletMap_.getMinZoom();
}
/**
* Wrapper method to get the max zoom of the map
*
* @returns {number} max zoom level
*/
getMaxZoom() {
return this.leafletMap_.getMaxZoom();
}
/**
* Wrapper method for showing a popup on the map
* @param {number} lat
* @param {number} lng
* @param {string} content
*/
showPopup(lat, lng, content) {
let popup = null;
if (this.options.responsivePopup) {
popup = new L.ResponsivePopupMod({ maxWidth: "auto", });
} else {
popup = L.popup({ maxWidth: "auto", autoPanPaddingTopLeft: L.point(10, 30), autoPanPaddingBottomRight: L.point(10, 10) });
}
popup.setLatLng({ lat: lat, lng: lng });
popup.setContent(content);
popup.openOn(this.leafletMap_);
popup.on("remove", () => {
this.fire("hide_popup");
});
this.fire("show_popup", {
lat: lat,
lng: lng,
content: content
});
}
/**
* Close the currently opened popup
*/
closePopup() {
this.leafletMap_.closePopup()
}
/**
* @param {number[]} latLng
* @param {number} zoom
*/
setView(latLng, zoom) {
this.leafletMap_.setView(latLng, zoom);
}
/**
* @param {object} bbox { min: {x, y}, max: {x, y}, crs: "CRS:84" }
*/
setMaxBounds(bbox) {
if (bbox && (bbox.crs == "CRS:84")) {
this.leafletMap_.setMaxBounds([[bbox.min.y, bbox.min.x], [bbox.max.y, bbox.max.x]]);
}
}
/**
* @param {object} bbox { min: {x, y}, max: {x, y}, crs: "CRS:84" }
*/
fitBounds(bbox) {
if (bbox.crs == "CRS:84") {
this.leafletMap_.fitBounds([[bbox.min.y, bbox.min.x], [bbox.max.y, bbox.max.x]], {
animate: false,
duration: 0
});
}
}
/**
* Get the current bounds of the map
*
* @returns {object} { min: {x, y}, max: {x, y}, crs: "CRS:84" }
*/
getBounds() {
const bounds = this.leafletMap_.getBounds();
const nw = bounds.getNorthWest();
const se = bounds.getSouthEast();
return {
min: {
x: (nw.lng < se.lng) ? nw.lng : se.lng,
y: (nw.lat < se.lat) ? nw.lat : se.lat
},
max: {
x: (nw.lng > se.lng) ? nw.lng : se.lng,
y: (nw.lat > se.lat) ? nw.lat : se.lat
},
crs: "CRS:84"
};
}
/**
* Generic Add Layer method. Only adds a layer if the map crs code matches
*
* @param {Layer} layer
*/
addLayer(layer) {
if ((layer instanceof Layer) && !this.layers_.includes(layer) && layer.setProjection(this.options.crs)) {
const proxy = layer.getLayerProxy("leaflet");
if (proxy) {
layer.enable("leaflet");
this.layers_.push(layer);
proxy.addToMap(this.leafletMap_);
this.fire("map_add_layer", layer);
}
}
}
/**
* Generic Remove Layer method
*
* @param {Layer} layer
*/
removeLayer(layer) {
const index = this.layers_.indexOf(layer);
if (index >= 0) {
this.layers_.splice(index, 1);
layer.getLayerProxy("leaflet").removeFromMap();
layer.disable();
this.fire("map_remove_layer", layer);
}
}
/**
* Build a bounding box from a click location
* for getFeatureInfo requests
*
* @param {number} lat click lat
* @param {number} lng click lng
*/
getFeatureInfoOptions(lat, lng) {
const container = this.leafletMap_.getContainer();
const click = this.leafletMap_.latLngToContainerPoint({ lat: lat, lng: lng });
const bounds = this.leafletMap_.getPixelBounds();
let northWest = this.leafletMap_.unproject(bounds.getBottomLeft());
let southEast = this.leafletMap_.unproject(bounds.getTopRight());
// reproject points to current projection
const crs = this.options.crs;
const projectedClick = proj4("EPSG:4326", crs, { x: lng, y: lat });
northWest = proj4("EPSG:4326", crs, { x: northWest.lng, y: northWest.lat });
southEast = proj4("EPSG:4326", crs, { x: southEast.lng, y: southEast.lat });
return {
container: {
width: container.offsetWidth,
height: container.offsetHeight,
x: roundNumber(click.x),
y: roundNumber(click.y)
},
click: {
x: projectedClick.x,
y: projectedClick.y
},
min: {
x: (northWest.x < southEast.x) ? northWest.x : southEast.x,
y: (northWest.y < southEast.y) ? northWest.y : southEast.y
},
max: {
x: (northWest.x >= southEast.x) ? northWest.x : southEast.x,
y: (northWest.y >= southEast.y) ? northWest.y : southEast.y
},
crs: crs
};
}
/**
* sets the active area div
*
* @param {string} className css class name
* @param {boolean} centerMap automatically re-center the map (default = false)
*/
setActiveArea(className, centerMap) {
if (this.leafletMap_._viewport) delete this.leafletMap_._viewport;
if (this.activeArea_) {
this.toolOverlay_.classList.remove(this.activeArea_);
const viewport = this.mapContainer_.querySelector("." + this.activeArea_);
if (viewport) viewport.remove();
}
this.activeArea_ = className;
this.leafletMap_.setActiveArea(className, centerMap);
this.toolOverlay_.classList.add(className);
}
/**
* Set baselayer and projection. Crs and layer need to match.
* Either option can be ommited, if the current baselayer/crs
* matches the changed property
*
* @param {Layer} layer
* @param {string} crs
*/
setBaseLayer(layer, crs) {
if (!crs) crs = this.options.crs;
if (!layer) layer = this.baseLayer_;
const code = crs.replace(":", "");
if (!(code in L.CRS)) throw new Error("Invalid CRS:", crs);
const oldBaseLayer = this.baseLayer_;
const oldCrs = this.options.crs;
// remove old baselayer
if (this.baseLayer_) {
this.baseLayer_.getLayerProxy("leaflet").removeFromMap();
this.baseLayer_.disable();
this.baseLayer_ = null;
this.options.baseLayer = null;
}
// apply crs to map
this.options.crs = crs;
this.leafletMap_.options.crs = L.CRS[code];
// set baselayer
if (layer && layer.availableCrs.includes(crs)) {
this.baseLayer_ = layer;
this.options.baseLayer = layer.title;
if (oldBaseLayer != layer) {
this.fire("baselayer_change", { crs: crs, baseLayer: layer });
}
}
// reset maxbounds before changing the projection
this.leafletMap_.setMaxBounds(null);
// apply changed projection
if (crs != oldCrs) {
this.leafletMap_.invalidateSize();
// reload the layers. only layers with matched crs are re-added
for (let i = this.layers_.length - 1; i >= 0; --i) {
const layer = this.layers_[i]
this.removeLayer(layer);
this.addLayer(layer);
}
this.resetViewport();
this.fitToScreen();
this.fire("projection_change", { crs: this.options.crs, baseLayer: this.baseLayer_ });
}
// update maxbounds after changing the projection. Updating it before would result
// in a weird panning animation after the projection was changed
this.setResponsiveMaxBounds_();
// apply baselayer with correct projection
if (this.baseLayer_) {
const proxy = this.baseLayer_.getLayerProxy("leaflet");
layer.enable("leaflet");
layer.setProjection(this.options.crs);
if (proxy) proxy.addToMap(this.leafletMap_);
}
}
/**
* Get the current map options
*
* @returns {object} options
*/
getOptions() {
// temporarily disable active area to get the correct center and zoom
const activeArea = this.activeArea_;
this.setActiveArea("full-active-area", false);
const center = this.leafletMap_.getCenter();
const zoom = this.leafletMap_.getZoom();
// re-enable active area (timeout to push to end of event loop to prevent flicker)
setTimeout(() => this.setActiveArea(activeArea, false), 0);
return {
options: Object.assign({}, this.options, {
initialZoom: zoom,
initialCenter: { lat: center.lat, lng: center.lng },
})
};
}
/**
* Start drawing shapes on the map
*
* Uses Leaflet.Draw version 0.4.12. Version 0.4.14 has a bug in the edit mode
* https://github.com/Leaflet/Leaflet.draw/issues/804
*
* @param {string} shape rectangle, polygon, line, point
*/
startDrawing(shape) {
this.stopDrawing();
let options = { shapeOptions: { color: currentColor } };
let DrawingClass = null;
switch (shape) {
case "polygon":
DrawingClass = L.Draw.Polygon;
break;
case "rectangle":
DrawingClass = L.Draw.Rectangle;
break;
case "line":
DrawingClass = L.Draw.Polyline;
break;
case "point":
options = {
shapeOptions: {
radius: 6,
weight: 4
},
color: currentColor
};
DrawingClass = L.Draw.CircleMarker;
break;
default:
return;
}
this.shapeDrawer_ = new DrawingClass(this.leafletMap_, options);
this.shapeDrawer_.enable();
}
/**
* Abort drawing
*/
stopDrawing() {
if (this.shapeDrawer_) this.shapeDrawer_.disable();
this.shapeDrawer_ = null;
}
/**
* remove the map instance
*
* @override
*/
dispose() {
this.leafletMap_.off();
this.leafletMap_.remove();
this.contextMenu_.dispose();
this.resizeObserver_.unobserve(this.mapContainer_);
super.dispose();
}
}