Skip to content
Snippets Groups Projects
mapModal.js 26.57 KiB
// mapModal.js
import {
    map,
    divIcon,
    tileLayer,
    geoJSON,
    circleMarker,
    marker,
    GeoJSON,
    DomEvent,
    Control,
    DomUtil
} from '/js/3rdparty/leaflet-src.esm.js';

/**
 * This JavaScript module enables the display of a map modal centered on Norway, with a given set of
 * coordinates representing points of interest. The map is primarily intended for use within VIPSLogic,
 * but can also be imported and used in VIPSWeb or other applications within the VIPS sphere.
 *
 * Follow the steps below in order to set up the map:
 * 1) Include the following stylesheets:
 * <link type="text/css" rel="stylesheet" href="(https://logic.vips.nibio.no)/css/3rdparty/leaflet.css" />
 * <link type="text/css" rel="stylesheet" href="/https://logic.vips.nibio.no)/css/mapModal.css" />
 * 2) Include HTML to display a button, and a div in which the map will be opened:
 *      <i id="open-map-modal-icon" class="fa fa-map-marker" onclick="openLocationMap()"></i>
 *      <div id="location-map" class="map-modal"></div>
 * 3) Include MapModal within a script tag of type 'module', and implement the function openLocationMap(),
 *    which initializes and opens the map modal.
 *
 * Uses css classes from bootstrap 3.4.1
 * Leaflet: https://unpkg.com/leaflet@1.9.4/dist/leaflet-src.esm.js
 */
class MapModal {

    static TRANSLATIONS = {
        nb: {
            selectLocation: 'Velg sted',
            saveLocation: 'Lagre nytt sted',
            createNewLocation: 'Opprett nytt sted',
            name: 'Navn',
            latitude: 'Breddegrad',
            longitude: 'Lengdegrad',
            type: 'Type',
            submitLocation: 'OK',
            zoomToLocation: 'Zoom til meg',
            geolocationNotSupported: 'Geolokalisering støttes ikke av denne nettleseren',
            geolocationFailed: 'Fant ikke din posisjon',
            closeMap: 'Lukk kart'
        },
        en: {
            selectLocation: 'Select location',
            saveLocation: 'Save new location',
            createNewLocation: 'Create New Location',
            name: 'Name',
            latitude: 'Latitude',
            longitude: 'Longitude',
            type: 'Type',
            submitLocation: 'OK',
            zoomToLocation: 'Zoom to My Location',
            geolocationNotSupported: 'Geolocation is not supported by this browser',
            geolocationFailed: 'Unable to retrieve your location',
            closeMap: 'Close Map'
        }
    };

    /**
     * @param mapModalId    The id of the HTML element in which the modal should be opened
     * @param typeNameMap   A mapping from pointOfInterestTypeIds to their localized names - expects names for ids 0,1,2,3,5,6,7
     * @param geoJsonData   GeoJson containing all features which should be displayed on the map
     * @param language  The language in which texts should be displayed, either 'nb' or 'en'
     * @param allowNewPoints    Whether or not the user should be allowed to add new points
     * @param callbackOnClose   Callback function to call when closing the modal
     */
    constructor(mapModalId, typeNameMap, geoJsonData, language = 'nb', allowNewPoints = false, callbackOnClose = null) {
        this.mapModalElement = document.getElementById(mapModalId);
        this.mapContainerId = mapModalId + "-container";
        this.mapContainerElement = this.addMapContainer(this.mapModalElement, this.mapContainerId);

        this.typeNameMap = typeNameMap;
        // Empty or invalid typeNameMap means that type information should not be displayed
        this.includeTypeInformation = Object.keys(typeNameMap).length === 7;

        this.geoJsonData = geoJsonData;
        if(language in MapModal.TRANSLATIONS) {
            this.translations = MapModal.TRANSLATIONS[language];
        } else {
            console.error("'"+ language +"' is not a supported language");
            this.translations = MapModal.TRANSLATIONS['nb'];
        }
        this.allowNewPoints = allowNewPoints;
        this.callbackOnClose = callbackOnClose;

        this.map = null;
        this.isMapInitialized = false;
        this.markersByType = {};
        this.selectedNewPointMarker = null;
        this.selectedExistingPointMarker = null;
        this.coordinatePrecision = 6;

        this.displaySelectedFeatureInfoControl = new DisplaySelectedFeatureInfoControl({
            translations: this.translations,
            typeNameMap: this.typeNameMap,
            onSubmit: (feature) => {this.confirmSelection(feature)},
            coordinatePrecision: this.coordinatePrecision
        });
        this.zoomToLocationControl = new ZoomToLocationControl({
            translations: this.translations
        });
        this.closeMapControl = new CloseMapControl({
            translations: this.translations,
            onClose: () => {this.closeModal()}
        });

        // Colours for the available types of pois
        this.typeColorMap = {
            0: "#5DADE2",  // Bright Blue
            1: "#58D68D",  // Vibrant Green
            2: "#AF7AC5",  // Medium Lavender
            3: "#F5B041",  // Warm Orange
            5: "#F7DC6F",  // Bright Yellow
            6: "#DC7633",   // Rich Brown
            7: "#FF33A6"  // Vivid Magenta
        };
    }

    addMapContainer(parentDiv, id) {
        const mapContainer = document.createElement('div');
        mapContainer.id = id;
        mapContainer.style.height = '100%';
        mapContainer.style.width = '100%';
        mapContainer.style.position = 'relative';
        parentDiv.appendChild(mapContainer);
        return mapContainer;
    }

    styleOfSelectedPointMarker(newPoint) {
        const styleMap = {
            radius: 12,
            color: "#FFFFFF",
            weight: 2,
            opacity: 1,
            fillOpacity: 1,
        };
        if (newPoint) {
            styleMap['fillColor'] = "#FF5733";
        }
        return styleMap
    }

    styleOfPointMarker(pointOfInterestTypeId) {
        const color = this.typeColorMap[pointOfInterestTypeId] || "#3498DB";
        return {
            radius: 8,
            fillColor: color,
            color: "#FFFFFF",
            weight: 2,
            opacity: 1,
            fillOpacity: 0.8
        }
    }

    initMap(latitude= 65, longitude= 14, zoomLevel= 5) {
        if (this.isMapInitialized) {
            console.error("Map is already initialized");
            return;
        }
        // Initialize the map centered on Norway
        this.map = map(this.mapContainerId).setView([latitude, longitude], zoomLevel);
        tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
            maxZoom: 19
        }).addTo(this.map);
        console.info("Create map " + this.mapContainerId + " centered on (" + latitude + "," + longitude + ") with points", this.geoJsonData);

        this.map.addControl(this.displaySelectedFeatureInfoControl);
        this.map.addControl(this.zoomToLocationControl);
        this.map.addControl(this.closeMapControl);

        // Add points to the map
        geoJSON(this.geoJsonData, {
            filter: (feature) => feature.geometry.type === "Point",
            pointToLayer: (feature, latlng) => {
                return circleMarker(latlng, this.styleOfPointMarker(feature.properties.pointOfInterestTypeId));
            },
            onEachFeature: (feature, layer) => {
                const typeId = feature.properties.pointOfInterestTypeId;
                this.bindActionToPoint(layer);
                if (!this.markersByType[typeId]) {
                    this.markersByType[typeId] = [];
                }
                this.markersByType[typeId].push(layer);
            }
        }).addTo(this.map);

        if(this.includeTypeInformation) {
            this.legendControl = new LegendControl({
                typeColorMap: this.typeColorMap,
                typeNameMap: this.typeNameMap,
                markersByType: this.markersByType,
                mapModalInstance: this
            });
            this.map.addControl(this.legendControl);
        }

        // Enable adding new points if allowed
        if (this.allowNewPoints) {
            this.enablePointCreation();
        }
        this.isMapInitialized = true;
    }
    // Function called when point is hidden (by deselecting its location type in legend box)
    // If point is already selected, the popup and info panel is removed.
    unbindActionToPoint(layer) {
        if(this.selectedExistingPointMarker === layer) {
            layer.closePopup();
            this.removeSelectedPointMarkerIfExists();
            this.displaySelectedFeatureInfoControl.updateInfoPanel();
        }
        layer.unbindPopup();
        layer.off('click');
    }

    bindActionToPoint(layer) {
        layer.bindPopup(this.popupContent(layer.feature));
        layer.on('click', () => {
            this.displaySelectedPoint(layer.feature, layer, false);
            this.displaySelectedFeatureInfoControl.updateInfoPanel(layer.feature)
        });
    }

    selectPointById(pointOfInterestId) {
        const selectedFeature = this.getFeatureById(pointOfInterestId);
        const selectedLayer = this.getLayerById(pointOfInterestId);
        this.displaySelectedPoint(selectedFeature, selectedLayer, true);
        selectedLayer.openPopup();
        this.displaySelectedFeatureInfoControl.updateInfoPanel(selectedFeature);
    }

    getFeatureById(pointOfInterestId) {
        return this.geoJsonData.features.find(feature => feature.properties.pointOfInterestId == pointOfInterestId);
    }

    getLayerById(pointOfInterestId) {
        let result = null;
        this.map.eachLayer(layer => {
            if (layer instanceof GeoJSON) {
                layer.eachLayer(l => {
                    if (l.feature && l.feature.properties.pointOfInterestId == pointOfInterestId) {
                        result = l;
                    }
                });
            }
        });
        return result;
    }

    displaySelectedPoint(feature, layer, zoomInToSelected = false) {
        // Deselect previously selected point, if any
        this.removeSelectedPointMarkerIfExists();
        this.removeNewPointMarkerIfExists();

        this.selectedExistingPointMarker = layer;
        this.selectedExistingPointMarker.setStyle(this.styleOfSelectedPointMarker(false));
        if (zoomInToSelected) {
            const latLng = this.selectedExistingPointMarker.getLatLng();
            this.map.setView(latLng, 10);
        }
    }

    confirmSelection(feature) {
        if (typeof this.callbackOnClose === 'function') {
            const pointData = {
                pointOfInterestId: feature.properties.pointOfInterestId,
                name: feature.properties.pointOfInterestName,
                pointOfInterestTypeId: feature.properties.pointOfInterestTypeId,
                longitude: feature.geometry.coordinates[0],
                latitude: feature.geometry.coordinates[1]
            };
            this.callbackOnClose(pointData);
        }
        this.closeModal();
    }

    enablePointCreation() {
        this.map.on('click', (e) => {
            const latlng = e.latlng;

            // Click on the map should remove any previous selections
            this.removeSelectedPointMarkerIfExists();
            this.removeNewPointMarkerIfExists();
            this.closeNewPointFormIfOpen();
            this.displaySelectedFeatureInfoControl.updateInfoPanel(null);

            // Calculate the pixel position from the map's click event
            const containerPoint = this.map.latLngToContainerPoint(latlng);

            this.selectedNewPointMarker = circleMarker(latlng, this.styleOfSelectedPointMarker(true)).addTo(this.map);
            const newPointFormElement = this.addHtmlElementNewPointForm(containerPoint.x, containerPoint.y, latlng.lat, latlng.lng)

            DomEvent.disableClickPropagation(newPointFormElement);
            document.addEventListener('click', this.handleClickOutsidePointForm.bind(this), true);

            const closeButton = newPointFormElement.querySelector("#map-poi-close-button");
            const nameInput = newPointFormElement.querySelector('#map-poi-name');
            const latitudeInput = newPointFormElement.querySelector('#map-poi-latitude');
            const longitudeInput = newPointFormElement.querySelector('#map-poi-longitude');
            const typeInput = newPointFormElement.querySelector('#map-poi-type');
            const submitButton = newPointFormElement.querySelector('#map-poi-submit-button');

            // Add options for the allowed types - or remove the select list altogether
            if(this.includeTypeInformation) {
                ["2", "3", "5"].forEach(value => {
                    const option = document.createElement("option");
                    option.value = value;
                    option.text = this.typeNameMap[value];
                    typeInput.appendChild(option);
                })
            } else {
                const formGroup = typeInput.closest('.form-group');
                if(formGroup){
                    formGroup.remove()
                }
            }

            const validateInputs = () => {
                const isValidLat = !isNaN(parseFloat(latitudeInput.value)) && isFinite(latitudeInput.value);
                const isValidLng = !isNaN(parseFloat(longitudeInput.value)) && isFinite(longitudeInput.value);
                if (isValidLat && isValidLng) {
                    this.updateMarkerPosition(this.selectedNewPointMarker, parseFloat(latitudeInput.value), parseFloat(longitudeInput.value));
                }
                submitButton.disabled = !(isValidLat && isValidLng);
            };
            latitudeInput.addEventListener('blur', validateInputs);
            longitudeInput.addEventListener('blur', validateInputs);

            closeButton.addEventListener('click', () => {
                newPointFormElement.remove();
                this.removeNewPointMarkerIfExists();
            });

            submitButton.addEventListener('click', () => {
                const feature = this.createFeatureForPoint(nameInput.value, parseInt(typeInput.value, 10), parseFloat(longitudeInput.value), parseFloat(latitudeInput.value));
                this.displaySelectedFeatureInfoControl.updateInfoPanel(feature);
                newPointFormElement.remove();
            });
        });
    }

    updateMarkerPosition(marker, lat, lng) {
        if (marker) {
            console.info("Move marker to new position")
            marker.setLatLng([lat, lng]);
            this.map.setView([lat, lng], this.map.getZoom());
        }
    }

    removeNewPointMarkerIfExists() {
        if (this.selectedNewPointMarker) {
            console.info("Remove selected new point marker", this.selectedNewPointMarker);
            this.map.removeLayer(this.selectedNewPointMarker);
        }
    }

    removeSelectedPointMarkerIfExists() {
        if (this.selectedExistingPointMarker) {
            const pointOfInterestTypeId = this.selectedExistingPointMarker.feature.properties.pointOfInterestTypeId;
            this.selectedExistingPointMarker.setStyle(this.styleOfPointMarker(pointOfInterestTypeId));
        }
    }

    handleClickOutsidePointForm(event) {
        const formElement = document.getElementById('new-point-form');
        if (formElement && !formElement.contains(event.target)) {
            this.closeNewPointFormIfOpen();
        }
    }

    closeNewPointFormIfOpen() {
        const formElement = document.getElementById('new-point-form');
        if (formElement) {
            formElement.remove();
        }
        document.removeEventListener('click', this.handleClickOutsidePointForm.bind(this), true);
    }

    createFeatureForPoint(name, type, longitude, latitude) {
        return {
            "type": "Feature",
            "geometry": {
                "type": "Point",
                "coordinates": [longitude, latitude]
            },
            "properties": {
                "pointOfInterestName": name,
                "pointOfInterestTypeId": type
            }
        };
    }

    popupContent(feature) {
        return `<div id='poi-popup'>${feature.properties.pointOfInterestName}</div>`;
    }

    /**
     * Creates the HTML form for adding a new point, and add it to the map container.
     *
     * @param positionX Where to place the form on the x axis
     * @param positionY Where to place the form on the y axis
     * @param latitude  Latitude of the point clicked
     * @param longitude Longitude of the point clicked
     * @returns {Element}
     */
    addHtmlElementNewPointForm(positionX, positionY, latitude, longitude) {
        const form = document.createElement("div");
        form.id="new-point-form"
        form.classList.add("panel");
        form.classList.add("panel-default");

        // Adjust position so that the form is always displayed within the map
        const mapWidth = this.mapModalElement.offsetWidth;
        const mapHeight = this.mapModalElement.offsetHeight;
        const formWidth = 200; // approximately
        const formHeight = 400; // approximately
        if (positionX + formWidth > mapWidth) {
            positionX = mapWidth - formWidth - 10; // 10px padding from the edge
        }
        if (positionY + formHeight > mapHeight) {
            positionY = mapHeight - formHeight - 10; // 10px padding from the edge
        }
        form.style.left = `${positionX}px`;
        form.style.top = `${positionY}px`;

        form.innerHTML = `
            <div class="panel-heading">
                <h4 class="panel-title">${this.translations.createNewLocation}</h4>
                <span id="map-poi-close-button" style="position: absolute; top: 5px; right: 10px; cursor: pointer; font-size: 18px;">&times;</span>
            </div>
            <div class="panel-body">
                <div class="form-group">
                    <label for="map-poi-name">${this.translations.name}:</label>
                    <input type="text" class="form-control" id="map-poi-name" name="name">
                </div>
                <div class="form-group">
                    <label for="map-poi-latitude">${this.translations.latitude}:</label>
                    <input type="text" class="form-control" id="map-poi-latitude" name="latitude" value="${latitude.toFixed(this.coordinatePrecision)}">
                </div>
                <div class="form-group">
                    <label for="map-poi-longitude">${this.translations.longitude}:</label>
                    <input type="text" class="form-control" id="map-poi-longitude" name="longitude" value="${longitude.toFixed(this.coordinatePrecision)}">
                </div>
                <div class="form-group">
                    <label for="map-poi-type">${this.translations.type}:</label>
                    <select class="form-control" id="map-poi-type" name="type">
                    </select>
                </div>
                <div class="form-group text-right">
                    <button id="map-poi-submit-button" class="btn btn-primary">${this.translations.submitLocation}</button>
                </div>                
            </div>`;
        this.mapContainerElement.appendChild(form);
        return form;
    }

    /**
     * Make modal visible. Initialise map with selected point marked, if given.
     *
     * @param selectedPointOfInterestId
     * @param latitude
     * @param longitude
     * @param zoomLevel
     */
    openModal(selectedPointOfInterestId, latitude, longitude, zoomLevel) {
        this.mapModalElement.style.display = 'flex';
        this.mapModalElement.style.justifyContent = 'center';
        this.mapModalElement.style.alignItems = 'center';
        this.initMap(latitude, longitude, zoomLevel);
        if (selectedPointOfInterestId) {
            this.selectPointById(selectedPointOfInterestId);
        }
    }

    /**
     * Hide modal. Remove container element and map.
     */
    closeModal() {
        this.mapModalElement.style.display = 'none';
        this.mapContainerElement.remove();
        this.map.remove();
        this.map = null;
    }

}

const DisplaySelectedFeatureInfoControl = Control.extend({
    options: {
        position: 'bottomleft',
        onSubmit: undefined,
        typeNameMap: {},
        translations: {},
        coordinatePrecision: 5
    },

    onAdd: function (map) {
        const container = DomUtil.create('div', 'leaflet-bar leaflet-control hidden');
        container.id = 'selected-point-info-panel';
        const infoMessageDiv = DomUtil.create('div', 'info-message', container);
        infoMessageDiv.id = 'info-message';
        const button = DomUtil.create('button', 'btn btn-primary', container);
        button.id = 'confirm-button';
        return container;
    },

    updateInfoPanel: function (feature) {
        const container = this._container;
        const infoMessageDiv = container.querySelector('#info-message');
        const confirmButton = container.querySelector('#confirm-button');

        if (feature) {
            const name = feature.properties.pointOfInterestName;
            const type = this.options.typeNameMap[feature.properties.pointOfInterestTypeId];
            const latitude = feature.geometry.coordinates[1].toFixed(this.options.coordinatePrecision);
            const longitude = feature.geometry.coordinates[0].toFixed(this.options.coordinatePrecision);

            infoMessageDiv.innerHTML = `
            <h4>${name}</h4>
            <b>${this.options.translations.latitude}</b> ${latitude}<br>
            <b>${this.options.translations.longitude}</b> ${longitude}`;
            if (type) {
                infoMessageDiv.innerHTML += `<br><b>${this.options.translations.type}</b> ${type}`
            }

            confirmButton.innerHTML = feature.properties.pointOfInterestId
                ? this.options.translations.selectLocation
                : this.options.translations.saveLocation;

            confirmButton.onclick = () => {
                this.options.onSubmit(feature);
            };
            container.classList.remove('hidden');
        } else {
            container.classList.add('hidden');
        }
    }
});

const ZoomToLocationControl = Control.extend({
    options: {
        position: 'topleft',
        translations: {}
    },

    onAdd: function (map) {
        // Create the button element
        const container = DomUtil.create('div', 'leaflet-bar leaflet-control');
        const zoomToLocationButton = DomUtil.create('a', 'map-button', container);
        zoomToLocationButton.innerHTML = '<i class="fa fa-map-marker"></i>';
        zoomToLocationButton.href = '#';
        zoomToLocationButton.title = this.options.translations.zoomToLocation;

        DomEvent
            .on(zoomToLocationButton, 'click', DomEvent.stop)
            .on(zoomToLocationButton, 'click', () => {
                if (navigator.geolocation) {
                    const locationIcon = divIcon({
                        html: '<i class="fa fa-map-marker fa-3x"></i>',
                        iconSize: [40, 60],
                        className: 'location-marker',
                        iconAnchor: [20, 60]
                    });
                    navigator.geolocation.getCurrentPosition((position) => {
                        const latitude = position.coords.latitude;
                        const longitude = position.coords.longitude;

                        map.setView([latitude, longitude], 13);
                        marker([latitude, longitude], {icon: locationIcon}).addTo(map);
                    }, (error) => {
                        console.error('Geolocation failed: ' + error.message);
                        alert(this.options.translations.geolocationFailed);
                    });
                } else {
                    alert(this.options.translations.geolocationNotSupported);
                }
            });
        return container;
    }
});


const CloseMapControl = Control.extend({
    options: {
        position: 'topright',
        translations: {}
    },

    onAdd: function (map) {
        // Create a container for the button
        const container = DomUtil.create('div', 'leaflet-bar leaflet-control');

        // Create the button element
        const closeMapButton = DomUtil.create('a', 'map-button', container);
        closeMapButton.innerHTML = '×'; // Unicode for close symbol
        closeMapButton.href = '#';
        closeMapButton.title = this.options.translations.closeMap;

        DomEvent
            .on(closeMapButton, 'click', DomEvent.stop)
            .on(closeMapButton, 'click', () => {
                if(this.options.onClose) {
                    this.options.onClose();
                }
        });
        return container;
    }
});

const LegendControl = Control.extend({
    options: {
        position: 'bottomright',
        typeColorMap: {},
        typeNameMap: {},
        markersByType: {},
        mapModalInstance: null,
    },
    onAdd: function (map) {
        const legendDiv = DomUtil.create('div', 'info legend');
        const typeColorMap = this.options.typeColorMap;
        const typeNameMap = this.options.typeNameMap;
        const markersByType = this.options.markersByType;

        DomEvent.disableClickPropagation(legendDiv);

        for (const type in typeColorMap) {
            if (!markersByType[type]) {
                continue;
            }
            const count = markersByType[type].length;
            const color = typeColorMap[type];

            const itemDiv = DomUtil.create('div', 'legend-item', legendDiv);
            itemDiv.innerHTML = `<i style="background:${color};"></i>
                    <span>${typeNameMap[type]} (${count})</span>`;

            DomEvent
                .on(itemDiv, 'click', DomEvent.stop)
                .on(itemDiv, 'click', () => {
                    this.toggleMarkers(type, markersByType[type], itemDiv);
                });
    }
        return legendDiv;
    },

    toggleMarkers: function (type, markers, itemDiv) {
        if (!markers) return;
        const wasVisible = markers[0].options.opacity !== 0;
        const isVisible = !wasVisible;

        markers.forEach(marker => {
            const markerElement = marker.getElement();
            if (wasVisible) {
                this.options.mapModalInstance.unbindActionToPoint(marker);
                if (markerElement) {
                    markerElement.style.pointerEvents = 'none';
                }
                marker.setStyle({opacity: 0, fillOpacity: 0});
            } else {
                marker.setStyle({opacity: 1, fillOpacity: 0.8});
                this.options.mapModalInstance.bindActionToPoint(marker);
                if (markerElement) {
                    markerElement.style.pointerEvents = '';
                }
            }
        });
        // Mark hidden with line-through
        itemDiv.style.textDecoration = isVisible ? "none" : "line-through solid black 2px";
    }



});

// Export the module
export default MapModal;