/**
*
* The **Map view** derives from [DataComponent](kiss.ui.DataComponent.html).
*
* It's a [map view](https://kissjs.net/#ui=start§ion=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)
;
Source