Source: map/Map2D.js

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();
    }

}