/**
*
* 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)
;
Source