Source

client/ux/map/map.js

/**
 * 
 * The Map derives from [Component](kiss.ui.Component.html).
 * 
 * Encapsulates original OpenLayers inside a KissJS UI component:
 * https://openlayers.org/
 * 
 * The field has the following features:
 * - can be initialized with a geolocation (longitude and latitude) or an address
 * - can show a marker in the initial center of the map
 * - can define a set of markers to display on the map
 * - can select between default OpenStreetMap and ESRI satellite view
 * - can use CDN or local version of OpenLayers
 * 
 * @param {object} config
 * @param {float} [config.longitude] - Longitude
 * @param {float} [config.latitude] - Latitude
 * @param {string} [config.address] - Address
 * @param {object[]} [config.markers] - Array of markers to display on the map, where heach marker is an object like: {longitude, latitude, label}. Do not use this if you set the `address` or the `longitude` and `latitude` properties.
 * @param {integer} [config.zoom] - Zoom level (default 10)
 * @param {integer} [config.width] - Width in pixels
 * @param {integer} [config.height] - Height in pixels
 * @param {boolean} [config.showMarker] - Set false to hide the marker at the center of the default location. Default is true.
 * @param {boolean} [config.canSelectLayer] - Set true to add a button to switch between default map and ESRI satellite view. Default is true.
 * @param {boolean} [config.useCDN] - Set to false to use the local version of OpenLayers. Default is true.
 * @returns this
 * 
 * ## Generated markup
 * ```
 * <a-map class="a-map">
 *  <div class="ol-viewport"></div>
 * </a-map>
 * ```
 */
kiss.ux.Map = class Map extends kiss.ui.Component {
    /**
     * Its a Custom Web Component. Do not use the constructor directly with the **new** keyword.
     * Instead, use one of the 3 following methods:
     * 
     * Create the Web Component and call its **init** method:
     * ```
     * const myMap = document.createElement("a-map").init(config)
     * ```
     * 
     * Or use the shorthand for it:
     * ```
     * const myMap = createMap({
     *  width: 300,
     *  height: 200,
     *  longitude: 2.3483915,
     *  latitude: 48.8534951,
     *  zoom: 15
     * })
     * 
     * myMap.render()
     * ```
     * 
     * Or directly declare the config inside a container component:
     * ```
     * const myPanel = createPanel({
     *   title: "My panel",
     *   items: [
     *       {
     *          type: "map",
     *          width: 300,
     *          height: 200,
     *          longitude: 2.3483915,
     *          latitude: 48.8534951,
     *          zoom: 15
     *       }
     *   ]
     * })
     * myPanel.render()
     * ```
     * 
     * You can define a map from a geolocation or an address:
     * ```
     * const myMapFromGeoloc = createMap({
     *  longitude: 2.3483915,
     *  latitude: 48.8534951,
     * })
     * 
     * const myMapFromAddress = createMap({
     *  address: "10 Downing Street, London",
     * })
     * ```
     * 
     * For now, the geoencoding is done with Nominatim, which is a free service but has limitations when it comes to the accuracy of the address street number.
     */
    constructor() {
        super()
    }

    /**
     * Generates a map from a JSON config
     * 
     * @ignore
     * @param {object} config - JSON config
     * @returns {HTMLElement}
     */
    init(config = {}) {

        // Set default values
        config.width = config.width || 300
        config.height = config.height || 225
        this.zoom = config.zoom || 10
        this.longitude = config.longitude
        this.latitude = config.latitude
        this.address = config.address
        this.markers = config.markers || []
        this.showMarker = (config.showMarker === false) ? false : true
        this.canSelectLayer = (config.canSelectLayer === false) ? false : true
        this.useCDN = (config.useCDN === false) ? false : true
        this.clickCallBack = config.clickCallback || null

        super.init(config)

        this._setProperties(config, [
            [
                ["display", "flex", "position", "top", "left", "width", "height", "margin", "padding", "background", "backgroundColor", "borderColor", "borderRadius", "borderStyle", "borderWidth", "boxShadow"],
                [this.style]
            ]
        ])

        return this
    }

    /**
     * Check if the OpenLayers (ol) library is loaded, and initialize the map
     * 
     * @ignore
     */
    async _afterRender() {
        if (window.ol) {
            this._initMap()
        } else {
            await this._initOpenLayers()
            this._initMap()
        }
    }

    /**
     * Load the OpenLayers library
     * 
     * @private
     * @ignore
     */
    async _initOpenLayers() {
        if (this.useCDN === false) {
            // Local
            await kiss.loader.loadScript("../../kissjs/client/ux/map/map_ol")
            await kiss.loader.loadStyle("../../kissjs/client/ux/map/map_ol")
        } else {
            // CDN
            await kiss.loader.loadScript("https://cdn.jsdelivr.net/npm/ol@v10.0.0/dist/ol")
            await kiss.loader.loadStyle("https://cdn.jsdelivr.net/npm/ol@v10.0.0/ol")
        }
    }

    /**
     * Initialize the OpenLayers map
     * - Create the map
     * - Set the target
     * - Add a click event to store the click coordinates in the "clicked" property
     * 
     * @private
     * @ignore
     */
    _initMap() {
        // Create the map
        this.map = new ol.Map({
            layers: [
                new ol.layer.Tile({
                    // Default OpenStreetMap layer
                    source: new ol.source.OSM(),
                }),
                new ol.layer.Tile({
                    visible: false,
                    source: new ol.source.XYZ({
                        // ESRI Satellite view
                        url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
                    })
                })
            ],

            view: new ol.View({
                zoom: this.zoom
            })
        })

        // Insert the map inside the KissJS component
        this.map.setTarget(this.id)

        // Init icon style
        this._initIconStyle()

        if (this.longitude && this.latitude) {
            // Priority to longitude and latitude
            this.setGeolocation({
                longitude: this.longitude,
                latitude: this.latitude
            })

        } else if (this.address) {

            // Then try to geocode the address
            this.setAddress(this.address)

        } else if (this.markers.length > 0) {

            // If no geolocation or address, but markers are defined, set the first marker as the center
            const firstMarker = this.markers[0]

            // Disable single marker display
            this.showMarker = false

            this.setGeolocation({
                longitude: firstMarker.longitude,
                latitude: firstMarker.latitude
            })

            // Add remaining markers
            this.addMarkers(this.markers)
        }

        // Update the bounding box propery of the map when the map is moved or zoomed
        this._observeBoundingBox()

        // Add a click event to the map
        // This will store the last clicked coordinates in the "clicked" property
        // and call the click callback if defined
        this.clicked
        this.map.on("click", (evt) => {
            // Store the last clicked coordinates
            const coordinate = evt.coordinate
            const lonLat = ol.proj.toLonLat(coordinate)
            this.clicked = {
                longitude: lonLat[0],
                latitude: lonLat[1]
            }

            console.log("kiss.ux - Map clicked at:", this.clicked)

            this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {
                if (this.clickCallBack) {
                    // Call the click callback if defined
                    this.clickCallBack(feature, this.clicked)
                }
            })
        })

        if (this.canSelectLayer) this._addLayerSelectionButton()
    }

    /**
     * Add a button to switch between default map and satellite view
     * 
     * @private
     * @ignore
     */
    _addLayerSelectionButton() {
        setTimeout(() => {
            const buttonSelectLayer = createButton({
                class: "mapview-button",
                width: "2rem",
                height: "2rem",
                icon: "fas fa-map",
                iconSize: "0.9rem",
                action: () => this.selectMapLayer()
            }).render()

            this.map.getViewport().appendChild(buttonSelectLayer)
        }, 500)
    }

    /**
     * Open a panel to select the map layer
     */
    selectMapLayer() {
        const _this = this
        createPanel({
            title: txtTitleCase("select map layer"),
            draggable: true,
            modal: true,
            align: "center",
            verticalAlign: "center",
            layout: "vertical",
            animation: {
                name: "zoomIn",
                speed: "faster"
            },
            defaultConfig: {
                type: "button",
                margin: "0.5rem",
            },
            items: [
                {
                    text: txtTitleCase("default map"),
                    action: () => _this.switchToDefaultView(),
                    icon: "far fa-map",
                },
                {
                    text: txtTitleCase("satellite view"),
                    icon: "fas fa-space-shuttle",
                    action: () => _this.switchToSatteliteView()
                }
            ]
        }).render()
    }

    /**
     * Switch to the satellite view of the map
     */
    switchToSatteliteView() {
        this.map.getLayers().forEach((layer) => {
            if (layer.getSource() instanceof ol.source.OSM) {
                layer.setVisible(false)
            } else if (layer.getSource() instanceof ol.source.XYZ) {
                layer.setVisible(true)
            }
        })
    }

    /**
     * Switch to the default OpenStreetMap view of the map
     */
    switchToDefaultView() {
        this.map.getLayers().forEach((layer) => {
            if (layer.getSource() instanceof ol.source.OSM) {
                layer.setVisible(true)
            } else if (layer.getSource() instanceof ol.source.XYZ) {
                layer.setVisible(false)
            }
        })
    }

    /**
     * Set a new address on the map
     * 
     * IMPORTANT: this methods uses Nominatim for geocoding, which is a free service but has limitations when it comes to the accuracy address street number.
     * 
     * @async
     * @param {string} address 
     * @returns {object} The geolocation object: {longitude, latitude}
     * 
     * @example
     * myMap.setAddress("10 Downing Street, London")
     */
    async setAddress(address) {
        const geoloc = await kiss.tools.getGeolocationFromAddress(address)
        if (!geoloc) return

        this.longitude = geoloc.longitude
        this.latitude = geoloc.latitude

        this.setGeolocation({
            longitude: this.longitude,
            latitude: this.latitude
        })

        return {
            longitude: this.longitude,
            latitude: this.latitude
        }
    }

    /**
     * Set a new geolocation on the map
     * 
     * @param {object} geoloc
     * @param {number} geoloc.longitude
     * @param {number} geoloc.latitude
     * @returns this
     * 
     * @example
     * myMap.setGeolocation({
     *  longitude: 2.3483915,
     *  latitude: 48.8534951
     * })
     */
    setGeolocation(geoloc) {
        try {
            this.longitude = geoloc.longitude
            this.latitude = geoloc.latitude

            const newLonLat = [this.longitude, this.latitude]
            const newCenter = ol.proj.fromLonLat(newLonLat)
            this.map.getView().setCenter(newCenter)

            if (this.showMarker) this.addGeoMarker(this.longitude, this.latitude)

            return this

        } catch (err) {
            // Map is not loaded yet
            return this
        }
    }

    /**
     * Add a marker on the map at the current geolocation
     * 
     * @async
     * @param {number} longitude - Longitude of the marker
     * @param {number} latitude - Latitude of the marker
     * @returns this
     */
    async addGeoMarker(longitude, latitude) {
        await this._waitForMap()

        const position = ol.proj.fromLonLat([longitude, latitude])
        const iconFeature = new ol.Feature({
            geometry: new ol.geom.Point(position)
        })

        iconFeature.setStyle(this.iconStyle)

        const vectorLayer = new ol.layer.Vector({
            source: new ol.source.Vector({
                features: [iconFeature]
            })
        })

        this.map.addLayer(vectorLayer)
        return this
    }

    /**
     * Add multiple markers on the map.
     * The first marker will be used to set the center of the map.
     * 
     * @async
     * @param {object[]} markers - Array of markers to display on the map, where each marker is an object like: {longitude, latitude, label}
     * @returns this
     */
    async addMarkers(markers = []) {
        await this._waitForMap()

        const features = markers.map(marker => {
            const coord = ol.proj.fromLonLat([marker.longitude, marker.latitude])
            const feature = new ol.Feature({
                geometry: new ol.geom.Point(coord)
            })

            if (marker.label) {
                feature.setStyle([
                    this.iconStyle,
                    this._getMarkerLabel(marker.label)
                ])

                // If the marker has a recordId, set it as a property on the feature
                if (marker.recordId) {
                    feature.set("recordId", marker.recordId)
                }
            } else {
                feature.setStyle(this.iconStyle)
            }
            return feature
        })

        this.markerLayer = new ol.layer.Vector({
            source: new ol.source.Vector({
                features: features
            })
        })

        this.map.addLayer(this.markerLayer)
        return this
    }

    /**
     * Update the markers on the map.
     * The first marker will be used to set the center of the map.
     * 
     * @param {object[]} markers - Array of markers to display on the map, where each marker is an object like: {longitude, latitude, label}
     * @returns this
     */
    updateMarkers(markers = []) {
        if (this.markerLayer) {
            this.map.removeLayer(this.markerLayer)
        }

        this.addMarkers(markers)
        return this
    }

    /**
     * Set a new zoom level on the map
     * 
     * @param {number} zoom
     * @returns this
     * 
     * @example
     * myMap.setZoom(15)
     */
    setZoom(zoom) {
        this.zoom = zoom
        this.map.getView().setZoom(zoom)
        return this
    }

    /**
     * Set the width of the map
     * 
     * @param {number} width 
     * @returns this
     */
    setWidth(width) {
        this.style.width = width
        return this
    }

    /**
     * Set the height of the map
     * 
     * @param {number} height 
     * @returns this
     */
    setHeight(height) {
        this.style.height = height
        return this
    }

    /**
     * Get the current bounding box of the map
     * 
     * @async
     * @returns {object} The bounding box object with the following properties: {minLongitude, minLatitude, maxLongitude, maxLatitude}
     */
    async getBounds() {
        await this._waitForMap()

        const extent = this.map.getView().calculateExtent(this.map.getSize())
        const bounds = ol.proj.transformExtent(extent, "EPSG:3857", "EPSG:4326")

        this.boundingBox = {
            minLongitude: bounds[0],
            minLatitude: bounds[1],
            maxLongitude: bounds[2],
            maxLatitude: bounds[3]
        }
        return this.boundingBox
    }

    /**
     * Initialize the icon style used for markers
     * 
     * @private
     * @ignore
     */
    _initIconStyle() {
        this.iconStyle = new ol.style.Style({
            text: new ol.style.Text({
                font: '900 24px "Font Awesome 5 Free"',
                text: "\uf3c5", // FontAwesome map marker icon
                fill: new ol.style.Fill({
                    color: "#ff0000"
                }),
                offsetY: -12
            })
        })
    }

    /**
     * Initialize the text style used for marker labels
     * 
     * @private
     * @ignore
     * @param {string} label - The label text to display on the marker
     */
    _getMarkerLabel(label) {
        return new ol.style.Style({
            text: new ol.style.Text({
                font: "14px sans-serif",
                text: label || "",
                fill: new ol.style.Fill({
                    color: "#ffffff"
                }),
                stroke: new ol.style.Stroke({
                    color: "#000000",
                    width: 2
                }),
                offsetY: -30,
                textAlign: "center"
            })
        })
    }

    /**
     * Observe the map bounding box and update the `boundingBox` property
     * 
     * @private
     * @ignore
     */
    _observeBoundingBox() {
        // Update the bounding box of the map after panning or zooming
        this.map.on("moveend", async () => {
            await this.getBounds()

            // Broadcast the bounding box change event
            // This is useful to update other components that depend on the map bounds
            kiss.pubsub.publish("EVT_MAP_BOUNDS_CHANGED", {
                mapId: this.id,
                boundingBox: this.boundingBox
            })
        })
    }

    /**
     * Wait for the OpenLayers library to be loaded
     * 
     * @private
     * @ignore
     */
    async _waitForMap() {
        await kiss.tools.waitUntil(() => this.map !== undefined, 100, 5000)
    }
}

// Create a Custom Element and add a shortcut to create it
customElements.define("a-map", kiss.ux.Map)

/**
 * Shorthand to create a new Map component. See [kiss.ux.Map](kiss.ux.Map.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createMap = (config) => document.createElement("a-map").init(config)

;