/* eslint-disable new-cap */
import L from 'leaflet'
import { OpenStreetMapProvider } from 'leaflet-geosearch'
import { capitalize, sprintf } from './utils'
import Tool from './Tool'
// default options
const _dftOpts = {
center: [45, 7],
zoom: 8,
tools: {},
fullscreen: true,
clearMapCtrl: 'default',
exportMapCtrl: 'default',
exportMapCb: null,
geocoderMapField: true,
tipsMapCtrl: 'default'
}
/**
* Open Street Map maps drawing class, provides tools for drawing over an open street map canvas, and export drawn data.
*
* <p>This class handles the drawing tools used to draw over a map and allows
* data exportation.</p>
* <p>The map manages also some controllers</p>
* <ul>
* <li>clear map controller</li>
* <li>export map controller</li>
* <li>geocoder text field controller</li>
* <li>tips controller</li>
* </ul>
* <p>Moreover every drawing tool has its own controller, which may be specifically set or used in
* its default form.</p>
* <p>Each map controller may be specified as custom, may be removed by setting the
* related option to <code>null</code> or used in its default form.</p>
* <p>Once instantiated the class and set the tools by options or instantiating direclty
* the drawing tool classes and adding them to the map,
* see {@link Map#addTool}, call the render method in order to render the widget.<br />
* It is possible to continue configuring the widget adding or removing tools and
* customize the map instance which is returned by the {@link Map#gmap} method.</p>
* <p>When defining specific map controllers, be sure to make them handle the proper map methods.</p>
*
*
* @example
* var mymap = new osmdrawer.Map('my_map_canvas_id', {
* tools: {
* point: {
* options: {
* max_items: 5
* }
* },
* circle: {}
* }
* });
*
*/
const Map = class {
/**
* @summary Constrcuts a Map
*
* @param {Element|String} canvas
* The map container element as selector or jQuery element
* @param {Object} [options]
* A class options object
* @param {Array} [options.center=new Array(45, 7)]
* The initial map center coordinates, (lat, lng).
* @param {Number} [options.zoom=8]
* The the initial map zoom level.
* @param {Object} [options.tools={}]
* The object containing the tool's names and options to be activated
* when initializing the map.
* It's a shortcut to easily define set and active tools objects.
* @param {Number} [options.fullscreen=true]
* Whether or not to enable fullscreen functionality
* @param {Object} [options.tools.point=undefined]
* The point tool init object
* @param {String|Element} [options.tools.point.ctrl=undefined]
* The selector or jQuery element
* which controls the tool, default the built-in menu voice
* @param {Object} [options.tools.point.options=undefined]
* The tool options object,
* see {@link PointTool} for available properties
* @param {Object} [options.tools.polyline=undefined]
* The polyline tool init object
* @param {String|Element} [options.tools.polyline.ctrl=undefined]
* The selector or jQuery element which controls the tool,
* default the built-in menu voice
* @param {Object} [options.tools.polyline.options=undefined]
* The tool options object, see {@link PolylineTool} for available properties
* @param {Object} [options.tools.polygon=undefined]
* The polygon tool init object
* @param {String|Element} [options.tools.polygon.ctrl=undefined]
* The selector or jQuery element which controls the tool, default the built-in menu voice
* @param {Object} [options.tools.polygon.options=undefined]
* The tool options object, see {@link PolygonTool} for available properties
* @param {Object} [options.tools.circle=undefined]
* The circle tool init object
* @param {String|Element} [options.tools.circle.ctrl=undefined]
* The selctor or jQuery element which controls the tool, default the built-in menu voice
* @param {Object} [options.tools.circle.options=undefined]
* The tool options object, see {@link CircleTool} for available properties
* @param {String|Element} [options.clearMapCtrl='default']
* The clear map controller (clears all drawings over the map).
* If 'default' the built-in controller is used, if <code>null</code> the clear map
* functionality is removed. If id attribute or an element the clear map functionality is attached to the element.
* @param {String|Element} [options.exportMapCtrl='default']
* The export map controller (exports all shapes drawed over the map).
* If 'default' the built-in controller is used, if <code>null</code> the export map
* functionality is removed. If id attribute or an element the clear map functionality is attached to the element.
* @param {Function} [options.exportMapCb=null]
* The callback function to call when the export map button is pressed.
* The callback function receives one argument, the exported data as
* returned by the osmdrawer.Map#exportMap method.
* @param {Boolean} [options.geocoderMapField=true]
* Whether or not to add the gecoder functionality which allows to center the map in a point
* defined through an address, or to pass the lat,lng coordinates found to the map click handlers
* (exactly as click over the map in a lat,lng point).
* @param {String|Element} [options.tipsMapCtrl='default']
* The help tips map controller (shows tips about drawing tools).
* If 'default' the built-in controller is used, if <code>null</code> the tips box is not shown,
* if id attribute or an element the functionality is attached to the element.
*/
constructor (canvas, options) {
this._dom = {}
this._dom.canvas = jQuery(canvas).addClass('osmdrawer-map-canvas')
// check canvas exists
if (!this._dom.canvas.length) {
throw new Error('Canvas container not found!')
}
// wrap canvas inside a container and add controllers container
this._dom.container = jQuery('<div />', {
class: 'osmdrawer-container'
}).css({
width: this._dom.canvas.css('width')
})
this._dom.controllersContainer = jQuery('<div />', {
class: 'osmdrawer-ctrls-container'
})
this._dom.toolsCtrlsContainer = jQuery('<div />', {
class: 'osmdrawer-ctrls-tools-container'
})
this._dom.actionsCtrlsContainer = jQuery('<div />', {
class: 'osmdrawer-ctrls-actions-container'
})
this._dom.geocoderCtrlsContainer = jQuery('<div />', {
class: 'osmdrawer-ctrls-geocoder-container'
})
this._dom.screenCtrlsContainer = jQuery('<div />', {
class: 'osmdrawer-ctrls-screen-container'
})
this._dom.controllersContainer.append(
this._dom.toolsCtrlsContainer,
this._dom.geocoderCtrlsContainer,
this._dom.actionsCtrlsContainer,
this._dom.screenCtrlsContainer
)
this._dom.container = this._dom.canvas
.wrap(this._dom.container)
.before(this._dom.controllersContainer)
.parent()
// let's extend default options
this._options = jQuery.extend({}, _dftOpts, options)
this._supportedTools = ['point', 'polyline', 'polygon', 'circle']
// internal state
this._state = {
drawingTool: null, // actual drawing tool
tools: [] // available tools
}
this._map = null
// when importing data we need bounds to fit them into the map
this._bounds = L.latLngBounds()
// controllers
this._ctrlContainer = null
this._controllers = {
clearMap: null,
clearMapCb: null,
exportMap: null,
exportMapCb: null,
tipsMap: null,
geocoder: null,
geocoderField: null,
geocoderCenterButton: null,
geocoderDrawButton: null,
fullscreen: null
}
// check options!
this._processOptions()
}
/**
* @summary Processes the options object setting properly some class properties
* @ignore
* @return void
*/
_processOptions () {
// init tools
var self = this
this._supportedTools.reverse().forEach((toolName, index) => {
if (self._options.tools.hasOwnProperty(toolName)) {
var handler = null
var ctrl = self._options.tools[toolName].ctrl || null
// set tool
if (ctrl) {
handler = jQuery(ctrl)
if (!handler.length) {
throw new Error(
sprintf(
'The given control handler for the {0} tool is not a DOM element',
toolName
)
)
}
}
// add the tool
self.addTool(
new OpenStreetMapDrawer[capitalize(toolName) + 'Tool'](
self,
handler,
self._options.tools[toolName].options || null
)
)
}
})
}
/**
* @summary Initializes the map and its events
* @ignore
* @return void
*/
_initMap () {
let mapCenter = L.latLng(this._options.center[0], this._options.center[1])
let mapOptions = {}
this._map = L.map(this._dom.canvas[0], { editable: true }).setView(
mapCenter,
this._options.zoom
)
L.tileLayer(
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
{
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 18,
}
).addTo(this._map)
this.geocoder = new OpenStreetMapProvider()
this._map.on('click', evt => {
this._mapClick(evt)
})
}
/**
* @summary Initializes all the map controllers
* @ignore
* @return void
*/
_initControllers () {
if (this._options.clearMapCtrl) {
this._setClearMapController()
}
if (this._options.exportMapCtrl && this._options.exportMapCb) {
this._setExportMapController()
}
if (this._options.geocoderMapField) {
this._setGeocoderMapFieldController()
}
if (this._options.fullscreen) {
this._setFullscreenController()
}
if (this._options.tipsMapCtrl) {
this._setTipsMapController()
}
}
/**
* @summary Initializes the map set tools
* @ignore
* @return void
*/
_initTools () {
for (let k in this._state.tools) {
if (this._supportedTools.indexOf(k) !== -1) {
this._state.tools[k].activate()
}
}
}
/**
* @summary Sets the clear map controller depending on the options.clearMapCtrl value
* @ignore
* @return void
*/
_setClearMapController () {
if (this._options.clearMapCtrl === 'default') {
this._controllers.clearMap = jQuery('<div />', {
class: 'osmdrawer-ctrl-clear-map'
})
.attr('title', 'clear map')
.appendTo(this._dom.actionsCtrlsContainer)
} else if (this._options.clearMapCtrl) {
this._controllers.clearMap = jQuery(this._options.clearMapCtrl)
if (!this._controllers.clearMap) {
throw new Error('The given clear map controller is not a DOM element')
}
}
this._controllers.clearMapCb = this.clearMap.bind(this)
this._controllers.clearMap.on('click', this._controllers.clearMapCb)
}
/**
* @summary Removes the clear map event and the controller if the default one
* @ignore
* @return void
*/
_removeClearMapController () {
this._controllers.clearMap.off('click', null, this._controllers.clearMapCb)
if (this._options.clearMapCtrl === 'default') {
this._controllers.clearMap.remove()
}
}
/**
* @summary Sets the export map controller depending on the options.exportMapCtrl value
* @ignore
* @return void
*/
_setExportMapController () {
if (this._options.exportMapCtrl === 'default') {
this._controllers.exportMap = jQuery('<div />', {
class: 'osmdrawer-ctrl-export-map'
})
.attr('title', 'export map')
.appendTo(this._dom.actionsCtrlsContainer)
} else if (this._options.exportMapCtrl) {
this._controllers.exportMap = jQuery(this._options.exportMapCtrl)
if (!this._controllers.exportMap.length) {
throw new Error('The given export map controller is not a DOM element')
}
}
this._controllers.exportMapCb = function () {
this._options.exportMapCb(this.exportMap())
}.bind(this)
this._controllers.exportMap.on('click', this._controllers.exportMapCb)
}
/**
* @summary Removes the export map event and the controller if the default one
* @ignore
* @return void
*/
_removeExportMapController () {
this._controllers.exportMap.off(
'click',
null,
this._controllers.exportMapCb
)
if (this._options.exportMapCtrl === 'default') {
this._controllers.exportMap.remove()
}
}
/**
* @summary Sets the help tips map controller depending on the options.tips_map_ctrl value
* @ignore
* @return void
*/
_setTipsMapController () {
if (this._options.tipsMapCtrl === 'default') {
this._controllers.tipsMap = jQuery('<div />', {
class: 'osmdrawer-tips-map'
}).appendTo(this._dom.controllersContainer)
} else if (this._options.tipsMapCtrl) {
this._controllers.tipsMap = jQuery(this._options.tipsMapCtrl)
if (!this._controllers.tipsMap.length) {
throw new Error('The given tips map controller is not a DOM element')
}
}
if (this._controllers.tipsMap.length) {
this.updateTips(this._initMapTips())
}
}
/**
* @summary Removes the tips map controller if the default one
* @ignore
* @return void
*/
_removeTipsMapController () {
if (this._options.tipsMapCtrl === 'default') {
this._controllers.tipsMap.remove()
}
}
/**
* @summary Sets the geocoder input text field and its controllers
* @ignore
* @return void
*/
_setGeocoderMapFieldController () {
this._controllers.geocoderField = jQuery('<input />', {
class: 'osmdrawer-geocoder-field',
type: 'text',
placeholder: 'insert an address'
}).on('focus', () => {
this.updateTips(
'Write an address in the field, then center the map in the calculated point, ' +
'or use it to draw the selected shape (acts as a click on the map).'
)
})
this._controllers.geocoderCenterButton = jQuery('<div />', {
class: 'osmdrawer-ctrl-geocoder-center-btn',
title: 'center in geolocalized point'
})
this._controllers.geocoderDrawButton = jQuery('<div />', {
class: 'osmdrawer-ctrl-geocoder-draw-btn',
title: 'draw the geolocalized point'
})
this._controllers.geocoderCenterButton.on(
'click',
this.geocoderCenter.bind(this)
)
this._controllers.geocoderDrawButton.on(
'click',
this.geocoderDraw.bind(this)
)
this._dom.geocoderCtrlsContainer.append(
this._controllers.geocoderField,
this._controllers.geocoderCenterButton,
this._controllers.geocoderDrawButton
)
}
/**
* @summary Removes the geocoder input text field and its controllers
* @ignore
* @return void
*/
_removeGeocoderMapField () {
this._controllers.geocoderCenterButton.off()
this._controllers.geocoderDrawButton.off()
this._controllers.geocoderField.remove()
this._controllers.geocoderCenterButton.remove()
this._controllers.geocoderDrawButton.remove()
}
/**
* @summary Sets the fullscreen controller
* @ignore
* @return void
*/
_setFullscreenController () {
this._controllers.fullscreen = jQuery('<div />', {
class: 'osmdrawer-ctrl-fullscreen',
title: 'fullscreen'
})
.appendTo(this._dom.screenCtrlsContainer)
.on('click', () => {
this._dom.container.toggleClass('osmdrawer-fullscreen')
this._controllers.fullscreen.attr(
'title',
this._controllers.fullscreen.attr('title') === 'fullscreen'
? 'exit fullscreen'
: 'fullscreen'
)
this._map._onResize()
})
}
/**
* @summary Removes the fullscreen controller
* @ignore
* @return void
*/
_removeFullscreenController () {
this._controllers.fullscreen.off()
this._controllers.fullscreen.remove()
}
/**
* @summary Returns the init text shown in the tips controller
* @ignore
* @return {String} text The initial tip text
*/
_initMapTips () {
return 'Displays help tips about drawing tools.'
}
/**
* @summary Handles the click event over the map, calling the active tool handler
* @ignore
* @param {Object} point an object with a key latlng storing the LatLng point
* @return void
*/
_mapClick (point) {
if (this._state.drawingTool === null) {
return false
}
this._state.drawingTool.clickHandler(point)
}
// PUBLIC METHODS (to be intended as public ;)
/**
* @summary Adds a drawing tool
* @param {osmdrawer.Tool} tool The tool object
* @return {Map} map object
*/
addTool (tool) {
if (!(tool instanceof Tool)) {
throw new Error('The given tool object is not of the proper type')
}
let toolName = tool.getToolName()
if (this._supportedTools.indexOf(toolName) === -1) {
throw new Error(sprintf('The tool {0} is not supported', toolName))
}
this._state.tools[toolName] = tool
return this
}
/**
* @summary Gets a tool object giving its name
* @param {String} toolName One of the supported tools name
* @return {osmdrawer.Tool | null} The tool object if set or null
*/
getTool (toolName) {
if (this._supportedTools.indexOf(toolName) === -1) {
throw new Error(sprintf('The {0} tool is not supported', toolName))
}
return this._state.tools[toolName]
}
/**
* @summary Removes a drawing tool
* @param {String} toolName The name of the tool to be removed
* @param {osmdrawer.Tool} tool The tool object
* @return {Map} map object
*/
removeTool (toolName) {
if (toolName && this._supportedTools.indexOf(toolName) === -1) {
throw new Error(sprintf('The {0} tool is not supported', toolName))
}
if (this._state.tools[toolName]) {
this._state.tools[toolName].deactivate(true)
delete this._state.tools[toolName]
}
return this
}
/**
* @summary Gets the active drawing tool
* @return {osmdrawer.Tool} The drawing tool
*/
getDrawingTool () {
return this._state.drawingTool
}
/**
* @summary Sets the active drawing tool name
* @param {osmdrawer.Tool|null} tool The actual drawing tool, null to have no active tool
* @return {Map} map object
*/
setDrawingTool (tool) {
if (
tool !== null &&
!this._state.tools.hasOwnProperty(tool.getToolName())
) {
throw new Error("Can't set the drawing tool since it's not active")
}
Object.keys(this._state.tools).forEach(k =>
this._state.tools[k].setUnselected()
)
this._state.drawingTool = tool
if (tool) {
tool.setSelected()
}
return this
}
/**
* @summary Renders the widget
* @return {Map} map object
*/
render () {
// map initialization
this._initMap()
// add controllers
this._initControllers()
// init tools
this._initTools()
return this
}
/**
* @summary Adds a controller in the default controllers container
* @param {Object} ctrl The jQuery controller element to be added
* @return void
*/
addDefaultCtrl (ctrl) {
if (!ctrl.length) {
throw new Error('The given controller is not an element')
}
ctrl.prependTo(this._dom.toolsCtrlsContainer)
}
/**
* @summary Clears the map
* @return {Map} map object
*/
clearMap () {
for (let k in this._state.tools) {
if (this._supportedTools.indexOf(k) !== -1) {
this._state.tools[k].clear()
}
}
console.info('osmdrawer: map cleared')
return this
}
/**
* @summary Updates the text displayed in the tips controller
* @param {String} html The tip text
* @return void
*/
updateTips (html) {
if (this._controllers.tipsMap.length) {
this._controllers.tipsMap.html(html)
}
}
/**
* @summary Returns the leaflet map instance
* @description The leaflet map class instance allows to customize direclty some map properties using
* the Map public interface
* @return {Map} The leaflet map instance
*/
gmap () {
return this._map
}
/**
* @summary Sets the center of the map
* @param {Array} center The [lat, lng] coordinates array
* @return {Map} map object
*/
setCenter (center) {
this._options.center = center
if (this._map) {
this._map.setView({ lat: center[0], lng: center[1] })
}
return this
}
/**
* @summary Sets the zoom of the map
* @param {Number} zoom The zoom level
* @return {Map} map object
*/
setZoom (zoom) {
this._options.zoom = zoom
if (this._map) {
this._map.setZoom(zoom)
}
return this
}
/**
* @summary Sets the clear map controller
* @param {String|Element} ctrl
* The clear map controller.
* If 'default' the built-in controller is used, if <code>null</code> the clear map
* functionality is removed. If selctor or jQuery element the clear map functionality
* is attached to the element.
* @return {Map} map object
*/
setClearMapCtrl (ctrl) {
if (ctrl !== this._options.clearMapCtrl) {
this._removeClearMapController()
}
this._options.clearMapCtrl = ctrl
this._setClearMapController()
return this
}
/**
* @summary Sets the export map controller
* @param {String|Element} ctrl
* The clear map controller.
* If 'default' the built-in controller is used, if <code>null</code> the export map
* functionality is removed. If selctor or jQuery element the export map functionality
* is attached to the element.
* @return {Map} map object
*/
setExportMapCtrl (ctrl) {
if (ctrl !== this._options.exportMapCtrl) {
this._removeExportMapController()
}
this._options.exportMapCtrl = ctrl
this._setExportMapController()
return this
}
/**
* @summary Sets the geocoder map field option
* @param {Boolean} activate Whether or not to activate the geocoder functionality
* @return {Map} map object
*/
setGeocoderMapField (activate) {
this._options.geocoderMapField = activate
if (!activate) {
this._removeGeocoderMapField()
} else {
this._setGeocoderMapFieldController()
}
return this
}
/**
* @summary Sets the tips map controller
* @param {String|Element} ctrl
* The help tips map controller (shows tips about drawing tools).
* If 'default' the built-in controller is used, if <code>null</code> the tips box is not shown,
* if selector or jQuery element the functionality is attached to the element
* @return {Map} map object
*/
setTipsMapCtrl (ctrl) {
if (ctrl !== this._options.tipsMapCtrl) {
this._removeTipsMapController()
}
this._options.tipsMapCtrl = ctrl
this._setTipsMapController()
return this
}
/**
* @summary Sets the fullscreen option
* @param {Boolean} activate Whether or not to activate the fullscreen functionality
* @return {Map} map object
*/
setFullscreen (activate) {
this._options.fullscreen = activate
if (!activate) {
this._removeFullscreenController()
} else {
this._setFullscreenController()
}
return this
}
/**
* @summary Exports the map drawed shapes as data points
* @return {Object} data The drawed data
* @example
* // exported data
* {
* 'point': [
* {lat: 45, lng: 12},
* {lat: 43, lng: 16}
* ],
* 'polyline': [
* [
* {lat: 45, lng: 12},
* {lat: 42, lng: 12},
* {lat: 42.6, lng: 11}
* ],
* [
* {lat: 36.7, lng: 11.2},
* {lat: 39, lng: 12}
* ],
* ],
* 'circle': [
* {lat: 45, lng: 12, radius: 10000},
* {lat: 44, lng: 11, radius: 230000}
* ]
* }
*/
exportMap () {
let data = {}
for (var k in this._state.tools) {
if (this._supportedTools.indexOf(k) !== -1) {
data[k] = this._state.tools[k].exportData()
}
}
console.info('osmdrawer: exporting data')
return data
}
/**
* @summary Imports data
* @description Data must be in the same format as the exported ones, see {@link Map#exportMap}
* @param {Object} data The data object
* @return {Map} map object
*/
importMap (data) {
this._supportedTools.forEach(toolName => {
if (typeof data[toolName] !== 'undefined') {
if (this.getTool(toolName) === null) {
let Cls = capitalize(toolName) + 'Tool'
let ntool = new Cls(this, null)
this.addTool(ntool)
ntool.activate()
}
this.getTool(toolName).importData(data[toolName])
this.getTool(toolName).extendBounds(this._bounds)
}
})
this._map.fitBounds(this._bounds)
return this
}
/**
* @summary Sets the map center converting the geocoder field input address in a LatLng point
* @return {Map} map object
*/
async geocoderCenter () {
const results = await this.geocoder.search({
query: this._controllers.geocoderField.val()
})
if (results.length) {
this._map.setView({ lat: results[0].y, lng: results[0].x })
} else {
console.log('osmdrawer: geocoder response: ' + results)
alert('Cannot retrieve address coordinates')
}
return this
}
/**
* @summary Fires a map click in a LatLng point converted from the geocoder field input address
* @return {Map} map object
*/
async geocoderDraw () {
const results = await this.geocoder.search({
query: this._controllers.geocoderField.val()
})
if (results.length) {
if (this._state.drawingTool === null) {
alert('select a drawing tool')
}
this._mapClick({
latlng: { lat: results[0].y, lng: results[0].x }
})
} else {
console.log('osmdrawer: geocoder response: ' + results)
alert('Cannot retrieve address coordinates')
}
return this
}
}
export default Map