Source

client/ui/data/mapview.js

/** 
 * 
 * The **Map view** derives from [DataComponent](kiss.ui.DataComponent.html).
 * 
 * It's a [map view](https://kissjs.net/#ui=start&section=mapview) with the following features:
 * - default coordinates to center the map when the map is first loaded
 * - default coordinates can be an address, which will be converted to GPS coordinates
 * - default zoom level to use when the map is first displayed
 * - display markers based on a field containing GPS coordinates
 * - display labels for the markers based on a field
 * - can limit the number of markers displayed on the map (for performances reason)
 * - handle coordinates in the format "longitude,latitude" or "latitude,longitude"
 * - toolbar with buttons to create new records, filter, search, setup the map view, and custom actions
 * - custom click callback to handle marker clicks (e.g. open a record)
 * 
 * @param {object} config
 * @param {Collection} config.collection - The data source collection
 * @param {string} [config.coordinatesField] - The field to use as the GPS coordinates. If not set, the map won't display any marker.
 * @param {string} [config.coordinatesFormat] - The format of the coordinates field. Default is "longitude,latitude".
 * @param {string} [config.defaultCoordinates] - The default coordinates to use when the map is first displayed. Ex: "55.3895,-20.9906"
 * @param {number} [config.defaultZoom] - The default zoom level to use when the map is first displayed. Between 1 and 19.
 * @param {string} [config.labelField] - The field to use as the label for the markers.
 * @param {number} [config.maxMarkers] - The maximum number of markers to display on the map (for performances reason). Default is 100.
 * @param {function} [config.clickCallback] - Callback function to call when a marker is clicked. The function receives the clicked feature and the clicked coordinates.
 * @param {object} [config.record] - Record to persist the view configuration into the db
 * @param {string} [config.color] - Hexa color code. Ex: #00aaee
 * @param {boolean} [config.showToolbar] - false to hide the toolbar (default = true)
 * @param {boolean} [config.showActions] - false to hide the custom actions menu (default = true)
 * @param {boolean} [config.canFilter] - false to hide the filter button (default = true)
 * @param {boolean} [config.canSearch] - false to hide the search button (default = true)
 * @param {boolean} [config.canCreateRecord] - Can we create new records from the map view?
 * @param {boolean} [config.createRecordText] - Optional text to insert in the button to create a new record, instead of the default model's name
 * @param {object[]} [config.actions] - Array of menu actions, where each menu entry is: {text: "abc", icon: "fas fa-check", action: function() {}}
 * @param {number|string} [config.width]
 * @param {number|string} [config.height]
 * @returns this
 * 
 * ## Generated markup
 * ```
 * <a-mapview class="a-mapview">
 *      <div class="mapview-toolbar">
 *          <!-- MapView toolbar items -->
 *      </div>
 *      <div class="mapview-body-container">
 *          <div class="mapview-body">
 *              <!-- Body columns -->
 *          </div>
 *      </div>
 * </a-mapview>
 * ```
 */
kiss.ui.MapView = class MapView extends kiss.ui.DataComponent {
    /**
     * Its a Custom Web Component. Do not use the constructor directly with the **new** keyword.
     * Instead, use one of the following methods:
     * 
     * Create the Web Component and call its **init** method:
     * ```
     * const myMapView = document.createElement("a-mapview").init(config)
     * ```
     * 
     * Or use the shorthand for it:
     * ```
     * const myMapView = createMapView({
     *  id: "my-mapview",
     *  collection: kiss.app.collections["contact"],
     *  coordinatesField: "gpsCoordinates",
     *  coordinatesFormat: "longitude,latitude",
     *  defaultCoordinates: "55.3895,-20.9906",
     *  defaultZoom: 10,
     *  labelField: "name"
     * })
     * 
     * myMapView.render()
     * ```
     */
    constructor() {
        super()
    }

    /**
     * Generates a Map View from a JSON config
     * 
     * @ignore
     * @param {object} config - JSON config
     * @returns {HTMLElement}
     */
    init(config) {
        // This component must be resized with its parent container
        config.autoSize = true

        // Init the parent DataComponent
        super.init(config)

        // Options
        this.showToolbar = (config.showToolbar !== false)
        this.showActions = (config.showActions !== false)
        this.showSetup = (config.showSetup !== false)
        this.canSearch = (config.canSearch !== false)
        this.canFilter = (config.canFilter !== false)
        this.actions = config.actions || []
        this.buttons = config.buttons || []
        this.color = config.color || "#00aaee"
        this.defaultColumnWidth = 20 // in rem

        // Manage groups state
        this.collapsedGroups = new Set()

        // Build map view skeletton markup
        let id = this.id
        this.innerHTML = /*html*/
            `<div class="mapview">
                <div id="mapview-toolbar:${id}" class="mapview-toolbar">
                    <div id="create:${id}"></div>
                    <div id="actions:${id}"></div>
                    <div id="setup:${id}"></div>
                    <div id="filter:${id}"></div>
                    <div id="refresh:${id}"></div>
                    <div id="search-field:${id}"></div>
                    <div id="search:${id}"></div>
                </div>

                <div class="mapview-body-container">
                    <div id="mapview-body:${id}" class="mapview-body"></div>
                </div>
            </div>`.removeExtraSpaces()

        // Set map view components
        this.mapView = this.querySelector(".mapview")
        this.mapViewToolbar = this.querySelector(".mapview-toolbar")
        this.mapViewBodyContainer = this.querySelector(".mapview-body-container")
        this.mapViewBody = this.querySelector(".mapview-body")

        this._initMapViewParams(config)
            ._initSize(config)
            ._initElementsVisibility()
            ._initSubscriptions()

        return this
    }

    /**
     * 
     * MAP VIEW METHODS
     * 
     */

    /**
     * Load data into the map view.
     * 
     * Remark:
     * - rendering time is proportional to the number of cards and visible fields (cards x fields)
     * - rendering takes an average of 0.03 millisecond per card on an Intel i7-4790K
     * 
     * @ignore
     */
    async load() {
        try {
            log(`kiss.ui - Map view ${this.id} - Loading collection <${this.collection.id} (changed: ${this.collection.hasChanged})>`)

            // Add the search filter if needed
            let currentFilter = this.filter
            if (this.currentSearchTerm) {
                currentFilter = this.createSearchFilter(this.currentSearchTerm)
            }

            // Load records
            await this.collection.find({
                filterSyntax: this.filterSyntax,
                filter: currentFilter
            })

            // Render the map view toolbar
            this._renderToolbar()

        } catch (err) {
            log(err)
            log(`kiss.ui - Map view ${this.id} - Couldn't load data properly`)
        }
    }

    /**
     * Filter the markers based on the given bounds.
     * 
     * This method is called when the map bounds change, to update the markers displayed on the map.
     * 
     * @async
     * @param {object} [bounds] - The bounding box to filter the markers. Ex: {maxLatitude: 50, minLatitude: 40, maxLongitude: 10, minLongitude: 0}. If not provided, it will use the current map bounds.
     */
    async filterMarkers(bounds) {
        if (!this.coordinatesField || !this.labelField) return

        this.bounds = bounds || this.bounds || await this.map.getBounds()
        const result = []
        const maxResults = this.maxMarkers

        for (const record of this.collection.records) {
            const coords = record[this.coordinatesField]?.split(",")
            if (!coords || coords.length < 2) continue

            let latitude, longitude
            if (this.coordinatesFormat === "longitude,latitude") {
                longitude = parseFloat(coords[0])
                latitude = parseFloat(coords[1])
            }
            else {
                latitude = parseFloat(coords[0])
                longitude = parseFloat(coords[1])
            }

            if (isNaN(latitude) || isNaN(longitude)) continue

            if (
                longitude >= this.bounds.minLongitude &&
                longitude <= this.bounds.maxLongitude &&
                latitude >= this.bounds.minLatitude &&
                latitude <= this.bounds.maxLatitude
            ) {
                result.push({
                    latitude,
                    longitude,
                    label: record[this.labelField],
                    recordId: record.id
                })

                if (result.length >= maxResults) break
            }
        }

        this.markers = result
        this.map.updateMarkers(this.markers)
    }

    /**
     * Switch to search mode
     * 
     * Show/hide only the necessary buttons in this mode.
     */
    switchToSearchMode() {
        if (kiss.screen.isMobile) {
            $("create:" + this.id).hide()
            $("search:" + this.id).hide()
            $("expand:" + this.id).hide()
            $("collapse:" + this.id).hide()
        }
    }

    /**
     * Reset search mode
     */
    resetSearchMode() {
        if (kiss.screen.isMobile) {
            $("create:" + this.id).show()
            $("search:" + this.id).show()
            $("expand:" + this.id).show()
            $("collapse:" + this.id).show()
        }
    }

    /**
     * Update the map view color (toolbar buttons + modal windows)
     * 
     * @param {string} newColor
     */
    async setColor(newColor) {
        this.color = newColor
        Array.from(this.mapViewToolbar.children).forEach(item => {
            if (item && item.firstChild && item.firstChild.type == "button") item.firstChild.setIconColor(newColor)
        })
    }

    /**
     * Show the filter window
     */
    showFilterWindow() {
        super.showFilterWindow(null, null, this.color)
    }

    /**
     * Update the map size (recomputes its width and height)
     */
    updateLayout() {
        if (this.isConnected) {
            this._setWidth()
            this._setHeight()
        }
    }

    /**
     * Show the window to setup the map view:
     * - field used to display the image
     */
    showSetupWindow() {
        let textFields = this.model.getFieldsByType(["text", "mapField"])
            .filter(field => !field.deleted)
            .map(field => {
                return {
                    value: field.id,
                    label: field.label.toTitleCase()
                }
            })

        createPanel({
            icon: "fas fa-map",
            title: txtTitleCase("setup the map"),
            headerBackgroundColor: this.color,
            modal: true,
            backdropFilter: true,
            draggable: true,
            closable: true,
            align: "center",
            verticalAlign: "center",
            width: "40rem",

            defaultConfig: {
                labelPosition: "top",
                optionsColor: this.color,
                width: "100%"
            },

            items: [
                // Source coordinates field
                {
                    type: "select",
                    id: "map-coordinates-field:" + this.id,
                    label: txtTitleCase("#coordinates field"),
                    options: textFields,
                    maxHeight: () => kiss.screen.current.height - 200,
                    value: this.coordinatesField,
                    events: {
                        change: async function () {
                            let coordinatesField = this.getValue()
                            let viewId = this.id.split(":")[1]
                            publish("EVT_VIEW_SETUP:" + viewId, {
                                coordinatesField
                            })
                        }
                    }
                },
                // Default coordinates format
                {
                    type: "select",
                    id: "map-default-coordinates:" + this.id,
                    label: txtTitleCase("coordinates format"),
                    options: [{
                            value: "longitude,latitude",
                            label: txtTitleCase("longitude") + ", " + txtTitleCase("latitude")
                        },
                        {
                            value: "latitude,longitude",
                            label: txtTitleCase("latitude") + ", " + txtTitleCase("longitude")
                        }
                    ],
                    value: this.coordinatesFormat || "longitude,latitude",
                    events: {
                        change: async function () {
                            let coordinatesFormat = this.getValue()
                            let viewId = this.id.split(":")[1]
                            publish("EVT_VIEW_SETUP:" + viewId, {
                                coordinatesFormat
                            })
                        }
                    }
                },
                // Default GPS coordinates
                {
                    type: "text",
                    id: "map-default-coordinates:" + this.id,
                    label: txtTitleCase("default coordinates"),
                    tip: txtTitleCase("#default coordinates help"),
                    value: this.defaultCoordinates || "",
                    events: {
                        change: async function () {
                            let defaultCoordinates = this.getValue()
                            let viewId = this.id.split(":")[1]
                            publish("EVT_VIEW_SETUP:" + viewId, {
                                defaultCoordinates
                            })
                        }
                    }
                },
                // Source label field
                {
                    type: "select",
                    id: "map-label-field:" + this.id,
                    label: txtTitleCase("#label field"),
                    options: textFields,
                    maxHeight: () => kiss.screen.current.height - 200,
                    value: this.labelField,
                    events: {
                        change: async function () {
                            let labelField = this.getValue()
                            let viewId = this.id.split(":")[1]
                            publish("EVT_VIEW_SETUP:" + viewId, {
                                labelField
                            })
                        }
                    }
                },
                // Default zoom level
                {
                    type: "slider",
                    id: "map-default-zoom:" + this.id,
                    label: txtTitleCase("default zoom level"),
                    min: 1,
                    max: 19,
                    step: 1,
                    value: this.defaultZoom || 10,
                    events: {
                        change: async function () {
                            let defaultZoom = this.getValue()
                            let viewId = this.id.split(":")[1]
                            publish("EVT_VIEW_SETUP:" + viewId, {
                                defaultZoom
                            })
                        }
                    }
                },
                // Max number of markers
                {
                    type: "slider",
                    id: "map-max-markers:" + this.id,
                    label: txtTitleCase("#max markers"),
                    min: 0,
                    max: 1000,
                    step: 5,
                    value: this.maxMarkers || 100,
                    events: {
                        change: async function () {
                            let maxMarkers = this.getValue()
                            let viewId = this.id.split(":")[1]
                            publish("EVT_VIEW_SETUP:" + viewId, {
                                maxMarkers
                            })
                        }
                    }
                }
            ]
        }).render()
    }

    /**
     * re-render the markers on the map view.
     * 
     * @private
     * @ignore
     */
    _render() {
        this.filterMarkers() // Filter markers based on the current bounds
    }

    /**
     * Automatically called after the map view is rendered, to insert the map component.
     * 
     * @private
     * @ignore
     */
    async _afterRender() {
        this._createMap()
    }

    /**
     * Create the map component and insert it into the map view body.
     * 
     * @private
     * @ignore
     * @returns this
     */
    async _createMap() {
        let zoom = this.defaultZoom || 10
        if (zoom > 19) zoom = 19
        if (zoom < 1) zoom = 1

        let coordinates = this.defaultCoordinates
        let longitude, latitude

        // If the default coordinates are an address, we try to convert it to GPS coordinates first
        // To test this, we must use a regex that match a GPS coordinates format, like "55.5,-21.0" or "55.5, 21.0"
        const regex = /^-?\d+(\.\d+)?,\s*-?\d+(\.\d+)?$/
        
        if (!regex.test(coordinates)) {
            // log(`kiss.ui - Map view ${this.id} - Default coordinates is an address: ${this.defaultCoordinates}. Converting to GPS coordinates...`)
            const geoloc = await kiss.tools.getGeolocationFromAddress(coordinates)
            if (!geoloc) return

            longitude = geoloc.longitude
            latitude = geoloc.latitude
        } else {
            coordinates = coordinates.split(",")
            if (this.coordinatesFormat === "longitude,latitude") {
                longitude = parseFloat(coordinates[0])
                latitude = parseFloat(coordinates[1])
            } else {
                longitude = parseFloat(coordinates[1])
                latitude = parseFloat(coordinates[0])
            }
        }

        this.map = createMap({
            id: "map-for:" + this.id,
            zoom,
            longitude,
            latitude,
            width: "100%",
            height: "100%",

            // Open a record when a marker is clicked
            clickCallback: async (feature, clicked) => {
                const recordId = feature.get("recordId")
                const record = await this.collection.getRecord(recordId)
                await this.selectRecord(record)
            }
        })

        this.map.style.order = 2
        this.map.style.flex = "1 1 100%"
        this.mapViewBody.style.width = "100%"
        this.mapViewBody.style.height = "100%"
        this.mapViewBody.appendChild(this.map)
        this.map.render()

        return this
    }

    /**
     * Define the specific map params
     * 
     * @private
     * @ignore
     * @param {object} config
     * @param {string} config.coordinatesField - The field to use as the GPS coordinates. If not set, the map won't display any marker.
     * @param {string} config.labelField - The field to use as the label for
     * @param {string} config.defaultCoordinates - The default coordinates to use when the map is first displayed. Ex: "55.3895,-20.9906"
     * @param {number} config.defaultZoom - The default zoom level to use when the map is first displayed. Between 1 and 19.
     * @returns this
     */
    _initMapViewParams(config) {
        if (this.record) {
            this.coordinatesField = config.coordinatesField || this.record.config.coordinatesField
            this.labelField = config.labelField || this.record.config.labelField
            this.defaultCoordinates = config.defaultCoordinates || this.record.config.defaultCoordinates || "55.3895,-20.9906"
            this.coordinatesFormat = config.coordinatesFormat || this.record.config.coordinatesFormat || "longitude,latitude"
            this.defaultZoom = config.defaultZoom || this.record.config.defaultZoom
            this.maxMarkers = config.maxMarkers || this.record.config.maxMarkers || 100
        } else {
            this.coordinatesField = config.coordinatesField || this.config.coordinatesField
            this.labelField = config.labelField || this.config.labelField
            this.defaultCoordinates = config.defaultCoordinates || this.config.defaultCoordinates || "55.3895,-20.9906"
            this.coordinatesFormat = config.coordinatesFormat || this.config.coordinatesFormat || "longitude,latitude"
            this.defaultZoom = config.defaultZoom || this.config.defaultZoom
            this.maxMarkers = config.maxMarkers || this.config.maxMarkers || 100
        }
        return this
    }

    /**
     * Update the map view configuration
     * 
     * @private
     * @ignore
     * @param {object} newConfig 
     */
    async _updateConfig(newConfig) {
        if (newConfig.hasOwnProperty("coordinatesField")) this.coordinatesField = newConfig.coordinatesField
        if (newConfig.hasOwnProperty("labelField")) this.labelField = newConfig.labelField
        if (newConfig.hasOwnProperty("defaultCoordinates")) this.defaultCoordinates = newConfig.defaultCoordinates
        if (newConfig.hasOwnProperty("coordinatesFormat")) this.coordinatesFormat = newConfig.coordinatesFormat
        if (newConfig.hasOwnProperty("defaultZoom")) this.defaultZoom = newConfig.defaultZoom
        if (newConfig.hasOwnProperty("maxMarkers")) this.maxMarkers = newConfig.maxMarkers

        this.filterMarkers()

        let currentConfig
        if (this.record) {
            currentConfig = this.record.config
        } else {
            currentConfig = {
                coordinatesField: this.coordinatesField,
                labelField: this.labelField,
                defaultCoordinates: this.defaultCoordinates,
                coordinatesFormat: this.coordinatesFormat,
                defaultZoom: this.defaultZoom,
                maxMarkers: this.maxMarkers
            }
        }

        let config = Object.assign(currentConfig, newConfig)
        await this.updateConfig({
            config
        })
    }

    /**
     * Set toolbar visibility
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initElementsVisibility() {
        if (this.showToolbar === false) this.mapViewToolbar.style.display = "none"
        return this
    }

    /**
     * Initialize map sizes
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initSize(config) {
        if (config.width) {
            this._setWidth()
        } else {
            this.style.width = this.config.width = "100%"
        }

        if (config.height) {
            this._setHeight()
        } else {
            this.style.height = this.config.height = "100%"
        }
        return this
    }

    /**
     * Initialize subscriptions to PubSub
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initSubscriptions() {
        super._initSubscriptions()

        // React to database mutations
        this.subscriptions = this.subscriptions.concat([
            // Local events (not coming from websocket)
            subscribe("EVT_VIEW_SETUP:" + this.id, (msgData) => this._updateConfig(msgData)),

            // Update the markers when the map bounds change
            subscribe("EVT_MAP_BOUNDS_CHANGED", (msgData) => {
                if (msgData.mapId.split(":")[1] !== this.id) return
                this.filterMarkers(msgData.boundingBox)
            })
        ])

        return this
    }

    /**
     * Adjust the component width
     * 
     * @ignore
     * @param {(number|string|function)} [width] - The width to set
     */
    _setWidth() {
        let newWidth = this._computeSize("width")

        setTimeout(() => {
            this.style.width = newWidth
            this.mapView.style.width = this.clientWidth.toString() + "px"
        }, 50)
    }

    /**
     * Adjust the components height
     * 
     * @private
     * @ignore
     * @param {(number|string|function)} [height] - The height to set
     */
    _setHeight() {
        let newHeight = this._computeSize("height")
        this.style.height = this.mapView.style.height = newHeight
    }

    /**
     * Render the toolbar
     * 
     * @private
     * @ignore
     */
    _renderToolbar() {
        // If the toolbar is already rendered, we just update it
        if (this.isToolbarRendered) {
            return
        }

        // New record creation button
        createButton({
            hidden: !this.canCreateRecord,
            class: "map-create-record",
            target: "create:" + this.id,
            text: this.config.createRecordText || this.model.name.toTitleCase(),
            icon: "fas fa-plus",
            iconColor: this.color,
            borderWidth: 3,
            borderRadius: "3.2rem",
            maxWidth: (kiss.screen.isMobile && kiss.screen.isVertical()) ? "16rem" : null,
            action: async () => this.createRecord(this.model)
        }).render()

        // Actions button
        createButton({
            hidden: this.showActions === false,
            target: "actions:" + this.id,
            tip: txtTitleCase("actions"),
            icon: "fas fa-bolt",
            iconColor: this.color,
            width: "3.2rem",
            action: () => this._buildActionMenu()
        }).render()

        // Setup the map
        createButton({
            hidden: !this.showSetup,
            target: "setup:" + this.id,
            tip: txtTitleCase("setup the map"),
            icon: "fas fa-cog",
            iconColor: this.color,
            width: "3.2rem",
            action: () => this.showSetupWindow()
        }).render()

        // Filtering button
        createButton({
            hidden: !this.canFilter,
            target: "filter:" + this.id,
            tip: txtTitleCase("to filter"),
            icon: "fas fa-filter",
            iconColor: this.color,
            width: "3.2rem",
            action: () => this.showFilterWindow()
        }).render()

        // View refresh button
        if (!kiss.screen.isMobile) {
            createButton({
                target: "refresh:" + this.id,
                tip: txtTitleCase("refresh"),
                icon: "fas fa-undo-alt",
                iconColor: this.color,
                width: "3.2rem",
                events: {
                    click: () => this.reload()
                }
            }).render()
        }

        // Search button
        createButton({
            hidden: !this.canSearch,
            target: "search:" + this.id,
            icon: "fas fa-search",
            iconColor: this.color,
            width: "3.2rem",
            events: {
                click: () => this.showSearchBar()
            }
        }).render()

        // Flag the toolbar as "rendered", so that the method _renderToolbar() is idempotent
        this.isToolbarRendered = true
    }
}

// Create a Custom Element and add a shortcut to create it
customElements.define("a-mapview", kiss.ui.MapView)

/**
 * Shorthand to create a new Map View. See [kiss.ui.MapView](kiss.ui.MapView.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createMapView = (config) => document.createElement("a-mapview").init(config)

;