umap/umap/static/umap/js/umap.features.js
Yohan Boniface a7a854dd74 wip: move default options to schema
This commit also introduce a new settings UMAP_SCHEMA, that could
be used to easily override schema default, like the default color, the
default path weigth and so on. I'm not documenting yet, because I'm
not yet totally sure we want this.
2024-03-04 17:43:40 +01:00

1228 lines
34 KiB
JavaScript

U.FeatureMixin = {
staticOptions: { mainColor: 'color' },
initialize: function (map, latlng, options) {
this.map = map
if (typeof options === 'undefined') {
options = {}
}
// DataLayer the marker belongs to
this.datalayer = options.datalayer || null
this.properties = { _umap_options: {} }
let geojson_id
if (options.geojson) {
this.populate(options.geojson)
geojson_id = options.geojson.id
}
// Each feature needs an unique identifier
if (U.Utils.checkId(geojson_id)) {
this.id = geojson_id
} else {
this.id = U.Utils.generateId()
}
let isDirty = false
const self = this
try {
Object.defineProperty(this, 'isDirty', {
get: function () {
return isDirty
},
set: function (status) {
if (!isDirty && status) {
self.fire('isdirty')
}
isDirty = status
if (self.datalayer) {
self.datalayer.isDirty = status
}
},
})
} catch (e) {
// Certainly IE8, which has a limited version of defineProperty
}
this.preInit()
this.addInteractions()
this.parentClass.prototype.initialize.call(this, latlng, options)
},
preInit: function () {},
isReadOnly: function () {
return this.datalayer && this.datalayer.isDataReadOnly()
},
getSlug: function () {
return this.properties[this.map.getOption('slugKey') || 'name'] || ''
},
getPermalink: function () {
const slug = this.getSlug()
if (slug)
return `${L.Util.getBaseUrl()}?${L.Util.buildQueryString({ feature: slug })}${
window.location.hash
}`
},
view: function (e) {
if (this.map.editEnabled) return
const outlink = this.getOption('outlink'),
target = this.getOption('outlinkTarget')
if (outlink) {
switch (target) {
case 'self':
window.location = outlink
break
case 'parent':
window.top.location = outlink
break
default:
const win = window.open(this.properties._umap_options.outlink)
}
return
}
// TODO deal with an event instead?
if (this.map.slideshow) this.map.slideshow.current = this
this.map.currentFeature = this
this.attachPopup()
this.openPopup((e && e.latlng) || this.getCenter())
},
openPopup: function () {
if (this.map.editEnabled) return
this.parentClass.prototype.openPopup.apply(this, arguments)
},
edit: function (e) {
if (!this.map.editEnabled || this.isReadOnly()) return
const container = L.DomUtil.create('div', 'umap-feature-container')
L.DomUtil.add(
'h3',
`umap-feature-properties ${this.getClassName()}`,
container,
L._('Feature properties')
)
let builder = new U.FormBuilder(this, ['datalayer'], {
callback: function () {
this.edit(e)
}, // removeLayer step will close the edit panel, let's reopen it
})
container.appendChild(builder.build())
const properties = []
let property
for (let i = 0; i < this.datalayer._propertiesIndex.length; i++) {
property = this.datalayer._propertiesIndex[i]
if (L.Util.indexOf(['name', 'description'], property) !== -1) {
continue
}
properties.push([`properties.${property}`, { label: property }])
}
// We always want name and description for now (properties management to come)
properties.unshift('properties.description')
properties.unshift('properties.name')
builder = new U.FormBuilder(this, properties, {
id: 'umap-feature-properties',
callback: this._redraw, // In case we have dynamic options…
})
container.appendChild(builder.build())
this.map.ui.once('panel:ready', () => {
builder.helpers['properties.name'].input.focus()
})
this.appendEditFieldsets(container)
const advancedActions = L.DomUtil.createFieldset(container, L._('Advanced actions'))
this.getAdvancedEditActions(advancedActions)
this.map.ui.openPanel({ data: { html: container }, className: 'dark' })
this.map.editedFeature = this
if (!this.isOnScreen()) this.zoomTo(e)
},
getAdvancedEditActions: function (container) {
const deleteButton = L.DomUtil.createButton(
'button umap-delete',
container,
L._('Delete'),
function (e) {
L.DomEvent.stop(e)
if (this.confirmDelete()) this.map.ui.closePanel()
},
this
)
},
appendEditFieldsets: function (container) {
const optionsFields = this.getShapeOptions()
let builder = new U.FormBuilder(this, optionsFields, {
id: 'umap-feature-shape-properties',
callback: this._redraw,
})
const shapeProperties = L.DomUtil.createFieldset(container, L._('Shape properties'))
shapeProperties.appendChild(builder.build())
const advancedOptions = this.getAdvancedOptions()
builder = new U.FormBuilder(this, advancedOptions, {
id: 'umap-feature-advanced-properties',
callback: this._redraw,
})
const advancedProperties = L.DomUtil.createFieldset(
container,
L._('Advanced properties')
)
advancedProperties.appendChild(builder.build())
const interactionOptions = this.getInteractionOptions()
builder = new U.FormBuilder(this, interactionOptions, {
callback: this._redraw,
})
const popupFieldset = L.DomUtil.createFieldset(
container,
L._('Interaction options')
)
popupFieldset.appendChild(builder.build())
},
getInteractionOptions: function () {
return [
'properties._umap_options.popupShape',
'properties._umap_options.popupTemplate',
'properties._umap_options.showLabel',
'properties._umap_options.labelDirection',
'properties._umap_options.labelInteractive',
'properties._umap_options.outlink',
'properties._umap_options.outlinkTarget',
]
},
endEdit: function () {},
getDisplayName: function (fallback) {
if (fallback === undefined) fallback = this.datalayer.options.name
const key = this.getOption('labelKey') || 'name'
// Variables mode.
if (L.Util.hasVar(key)) return L.Util.greedyTemplate(key, this.extendedProperties())
// Simple mode.
return this.properties[key] || this.properties.title || fallback
},
hasPopupFooter: function () {
if (L.Browser.ielt9) return false
if (this.datalayer.isRemoteLayer() && this.datalayer.options.remoteData.dynamic)
return false
return this.map.getOption('displayPopupFooter')
},
getPopupClass: function () {
const old = this.getOption('popupTemplate') // Retrocompat.
return U.Popup[this.getOption('popupShape') || old] || U.Popup
},
attachPopup: function () {
const Class = this.getPopupClass()
this.bindPopup(new Class(this))
},
confirmDelete: function () {
if (confirm(L._('Are you sure you want to delete the feature?'))) {
this.del()
return true
}
return false
},
del: function () {
this.isDirty = true
this.map.closePopup()
if (this.datalayer) {
this.datalayer.removeLayer(this)
this.disconnectFromDataLayer(this.datalayer)
}
},
connectToDataLayer: function (datalayer) {
this.datalayer = datalayer
this.options.renderer = this.datalayer.renderer
},
disconnectFromDataLayer: function (datalayer) {
if (this.datalayer === datalayer) {
this.datalayer = null
}
},
cleanProperty: function ([key, value]) {
// dot in key will break the dot based property access
// while editing the feature
key = key.replace('.', '_')
return [key, value]
},
populate: function (feature) {
this.properties = Object.fromEntries(
Object.entries(feature.properties || {}).map(this.cleanProperty)
)
this.properties._umap_options = L.extend(
{},
this.properties._storage_options,
this.properties._umap_options
)
// Retrocompat
if (this.properties._umap_options.clickable === false) {
this.properties._umap_options.interactive = false
delete this.properties._umap_options.clickable
}
},
changeDataLayer: function (datalayer) {
if (this.datalayer) {
this.datalayer.isDirty = true
this.datalayer.removeLayer(this)
}
datalayer.addLayer(this)
datalayer.isDirty = true
this._redraw()
},
getOption: function (option, fallback) {
let value = fallback
if (typeof this.staticOptions[option] !== 'undefined') {
value = this.staticOptions[option]
} else if (L.Util.usableOption(this.properties._umap_options, option)) {
value = this.properties._umap_options[option]
} else if (this.datalayer) {
value = this.datalayer.getOption(option, this)
} else {
value = this.map.getOption(option)
}
return value
},
getDynamicOption: function (option, fallback) {
let value = this.getOption(option, fallback)
// There is a variable inside.
if (L.Util.hasVar(value)) {
value = L.Util.greedyTemplate(value, this.properties, true)
if (L.Util.hasVar(value)) value = this.map.getDefaultOption(option)
}
return value
},
zoomTo: function (e) {
e = e || {}
const easing = e.easing !== undefined ? e.easing : this.map.getOption('easing')
if (easing) {
this.map.flyTo(this.getCenter(), this.getBestZoom())
} else {
const latlng = e.latlng || this.getCenter()
this.map.setView(latlng, this.getBestZoom() || this.map.getZoom())
}
if (e.callback) e.callback.call(this)
},
getBestZoom: function () {
return this.getOption('zoomTo')
},
getNext: function () {
return this.datalayer.getNextFeature(this)
},
getPrevious: function () {
return this.datalayer.getPreviousFeature(this)
},
cloneProperties: function () {
const properties = L.extend({}, this.properties)
properties._umap_options = L.extend({}, properties._umap_options)
if (Object.keys && Object.keys(properties._umap_options).length === 0) {
delete properties._umap_options // It can make a difference on big data sets
}
return properties
},
deleteProperty: function (property) {
delete this.properties[property]
this.makeDirty()
},
renameProperty: function (from, to) {
this.properties[to] = this.properties[from]
this.deleteProperty(from)
},
toGeoJSON: function () {
const geojson = this.parentClass.prototype.toGeoJSON.call(this)
geojson.properties = this.cloneProperties()
geojson.id = this.id
delete geojson.properties._storage_options
return geojson
},
addInteractions: function () {
this.on('contextmenu editable:vertex:contextmenu', this._showContextMenu, this)
this.on('click', this._onClick)
},
_onClick: function (e) {
if (this.map.measureTools && this.map.measureTools.enabled()) return
this._popupHandlersAdded = true // Prevent leaflet from managing event
if (!this.map.editEnabled) {
this.view(e)
} else if (!this.isReadOnly()) {
if (e.originalEvent.shiftKey) {
if (e.originalEvent.ctrlKey || e.originalEvent.metaKey) {
this.datalayer.edit(e)
} else {
if (this._toggleEditing) this._toggleEditing(e)
else this.edit(e)
}
} else {
new L.Toolbar.Popup(e.latlng, {
className: 'leaflet-inplace-toolbar',
anchor: this.getPopupToolbarAnchor(),
actions: this.getInplaceToolbarActions(e),
}).addTo(this.map, this, e.latlng)
}
}
L.DomEvent.stop(e)
},
getPopupToolbarAnchor: function () {
return [0, 0]
},
getInplaceToolbarActions: function (e) {
return [U.ToggleEditAction, U.DeleteFeatureAction]
},
_showContextMenu: function (e) {
L.DomEvent.stop(e)
const pt = this.map.mouseEventToContainerPoint(e.originalEvent)
e.relatedTarget = this
this.map.contextmenu.showAt(pt, e)
},
makeDirty: function () {
this.isDirty = true
},
getMap: function () {
return this.map
},
getContextMenuItems: function (e) {
const permalink = this.getPermalink()
let items = []
if (permalink)
items.push({
text: L._('Permalink'),
callback: function () {
window.open(permalink)
},
})
if (this.map.editEnabled && !this.isReadOnly()) {
items = items.concat(this.getContextMenuEditItems(e))
}
return items
},
getContextMenuEditItems: function () {
let items = ['-']
if (this.map.editedFeature !== this) {
items.push({
text: L._('Edit this feature') + ' (⇧+Click)',
callback: this.edit,
context: this,
iconCls: 'umap-edit',
})
}
items = items.concat(
{
text: L._("Edit feature's layer") + ' (Ctrl+⇧+Click)',
callback: this.datalayer.edit,
context: this.datalayer,
iconCls: 'umap-edit',
},
{
text: L._('Delete this feature'),
callback: this.confirmDelete,
context: this,
iconCls: 'umap-delete',
},
{
text: L._('Clone this feature'),
callback: this.clone,
context: this,
}
)
return items
},
onRemove: function (map) {
this.parentClass.prototype.onRemove.call(this, map)
if (this.map.editedFeature === this) {
this.endEdit()
this.map.ui.closePanel()
}
},
resetTooltip: function () {
if (!this.hasGeom()) return
const displayName = this.getDisplayName(null)
let showLabel = this.getOption('showLabel')
const oldLabelHover = this.getOption('labelHover')
const options = {
direction: this.getOption('labelDirection'),
interactive: this.getOption('labelInteractive'),
}
if (oldLabelHover && showLabel) showLabel = null // Retrocompat.
options.permanent = showLabel === true
this.unbindTooltip()
if ((showLabel === true || showLabel === null) && displayName)
this.bindTooltip(L.Util.escapeHTML(displayName), options)
},
matchFilter: function (filter, keys) {
filter = filter.toLowerCase()
for (let i = 0, value; i < keys.length; i++) {
value = (this.properties[keys[i]] || '') + ''
if (value.toLowerCase().indexOf(filter) !== -1) return true
}
return false
},
matchFacets: function () {
const facets = this.map.facets
for (const [property, expected] of Object.entries(facets)) {
if (expected.length) {
let value = this.properties[property]
if (!value || !expected.includes(value)) return false
}
}
return true
},
onVertexRawClick: function (e) {
new L.Toolbar.Popup(e.latlng, {
className: 'leaflet-inplace-toolbar',
actions: this.getVertexActions(e),
}).addTo(this.map, this, e.latlng, e.vertex)
},
getVertexActions: function () {
return [U.DeleteVertexAction]
},
isMulti: function () {
return false
},
clone: function () {
const layer = this.datalayer.geojsonToFeatures(this.toGeoJSON())
layer.isDirty = true
layer.edit()
return layer
},
extendedProperties: function () {
// Include context properties
properties = this.map.getGeoContext()
if (L.locale) properties.locale = L.locale
if (L.lang) properties.lang = L.lang
properties.rank = this.getRank() + 1
if (this.hasGeom()) {
center = this.getCenter()
properties.lat = center.lat
properties.lon = center.lng
properties.lng = center.lng
if (typeof this.getMeasure !== 'undefined') {
properties.measure = this.getMeasure()
}
}
return L.extend(properties, this.properties)
},
getRank: function () {
return this.datalayer._index.indexOf(L.stamp(this))
},
}
U.Marker = L.Marker.extend({
parentClass: L.Marker,
includes: [U.FeatureMixin],
preInit: function () {
this.setIcon(this.getIcon())
},
highlight: function () {
L.DomUtil.addClass(this.options.icon.elements.main, 'umap-icon-active')
},
resetHighlight: function () {
L.DomUtil.removeClass(this.options.icon.elements.main, 'umap-icon-active')
},
addInteractions: function () {
U.FeatureMixin.addInteractions.call(this)
this.on(
'dragend',
function (e) {
this.isDirty = true
this.edit(e)
},
this
)
if (!this.isReadOnly()) this.on('mouseover', this._enableDragging)
this.on('mouseout', this._onMouseOut)
this._popupHandlersAdded = true // prevent Leaflet from binding event on bindPopup
this.on('popupopen', this.highlight)
this.on('popupclose', this.resetHighlight)
},
hasGeom: function () {
return !!this._latlng
},
_onMouseOut: function () {
if (
this.dragging &&
this.dragging._draggable &&
!this.dragging._draggable._moving
) {
// Do not disable if the mouse went out while dragging
this._disableDragging()
}
},
_enableDragging: function () {
// TODO: start dragging after 1 second on mouse down
if (this.map.editEnabled) {
if (!this.editEnabled()) this.enableEdit()
// Enabling dragging on the marker override the Draggable._OnDown
// event, which, as it stopPropagation, refrain the call of
// _onDown with map-pane element, which is responsible to
// set the _moved to false, and thus to enable the click.
// We should find a cleaner way to handle this.
this.map.dragging._draggable._moved = false
}
},
_disableDragging: function () {
if (this.map.editEnabled) {
if (this.editor && this.editor.drawing) return // when creating a new marker, the mouse can trigger the mouseover/mouseout event
// do not listen to them
this.disableEdit()
}
},
_redraw: function () {
if (this.datalayer && this.datalayer.isVisible()) {
this._initIcon()
this.update()
}
},
_initIcon: function () {
this.options.icon = this.getIcon()
L.Marker.prototype._initIcon.call(this)
// Allow to run code when icon is actually part of the DOM
this.options.icon.onAdd()
this.resetTooltip()
},
_getTooltipAnchor: function () {
const anchor = this.options.icon.options.tooltipAnchor.clone(),
direction = this.getOption('labelDirection')
if (direction === 'left') {
anchor.x *= -1
} else if (direction === 'bottom') {
anchor.x = 0
anchor.y = 0
} else if (direction === 'top') {
anchor.x = 0
}
return anchor
},
disconnectFromDataLayer: function (datalayer) {
this.options.icon.datalayer = null
U.FeatureMixin.disconnectFromDataLayer.call(this, datalayer)
},
_getIconUrl: function (name) {
if (typeof name === 'undefined') name = 'icon'
return this.getOption(`${name}Url`)
},
getIconClass: function () {
return this.getOption('iconClass')
},
getIcon: function () {
const Class = U.Icon[this.getIconClass()] || U.Icon.Default
return new Class(this.map, { feature: this })
},
getCenter: function () {
return this._latlng
},
getClassName: function () {
return 'marker'
},
getShapeOptions: function () {
return [
'properties._umap_options.color',
'properties._umap_options.iconClass',
'properties._umap_options.iconUrl',
'properties._umap_options.iconOpacity',
]
},
getAdvancedOptions: function () {
return ['properties._umap_options.zoomTo']
},
appendEditFieldsets: function (container) {
U.FeatureMixin.appendEditFieldsets.call(this, container)
const coordinatesOptions = [
['_latlng.lat', { handler: 'FloatInput', label: L._('Latitude') }],
['_latlng.lng', { handler: 'FloatInput', label: L._('Longitude') }],
]
const builder = new U.FormBuilder(this, coordinatesOptions, {
callback: function () {
if (!this._latlng.isValid()) {
this.map.ui.alert({
content: L._('Invalid latitude or longitude'),
level: 'error',
})
builder.resetField('_latlng.lat')
builder.resetField('_latlng.lng')
}
this._redraw()
this.zoomTo({ easing: false })
},
callbackContext: this,
})
const fieldset = L.DomUtil.createFieldset(container, L._('Coordinates'))
fieldset.appendChild(builder.build())
},
zoomTo: function (e) {
if (this.datalayer.isClustered() && !this._icon) {
// callback is mandatory for zoomToShowLayer
this.datalayer.layer.zoomToShowLayer(this, e.callback || (() => {}))
} else {
U.FeatureMixin.zoomTo.call(this, e)
}
},
isOnScreen: function (bounds) {
bounds = bounds || this.map.getBounds()
return bounds.contains(this._latlng)
},
getPopupToolbarAnchor: function () {
return this.options.icon.options.popupAnchor
},
})
U.PathMixin = {
hasGeom: function () {
return !this.isEmpty()
},
connectToDataLayer: function (datalayer) {
U.FeatureMixin.connectToDataLayer.call(this, datalayer)
// We keep markers on their own layer on top of the paths.
this.options.pane = this.datalayer.pane
},
edit: function (e) {
if (this.map.editEnabled) {
if (!this.editEnabled()) this.enableEdit()
U.FeatureMixin.edit.call(this, e)
}
},
_toggleEditing: function (e) {
if (this.map.editEnabled) {
if (this.editEnabled()) {
this.endEdit()
this.map.ui.closePanel()
} else {
this.edit(e)
}
}
// FIXME: disable when disabling global edit
L.DomEvent.stop(e)
},
styleOptions: [
'smoothFactor',
'color',
'opacity',
'stroke',
'weight',
'fill',
'fillColor',
'fillOpacity',
'dashArray',
'interactive',
],
getShapeOptions: function () {
return [
'properties._umap_options.color',
'properties._umap_options.opacity',
'properties._umap_options.weight',
]
},
getAdvancedOptions: function () {
return [
'properties._umap_options.smoothFactor',
'properties._umap_options.dashArray',
'properties._umap_options.zoomTo',
]
},
setStyle: function (options) {
options = options || {}
let option
for (const idx in this.styleOptions) {
option = this.styleOptions[idx]
options[option] = this.getDynamicOption(option)
}
if (options.interactive) this.options.pointerEvents = 'visiblePainted'
else this.options.pointerEvents = 'stroke'
this.parentClass.prototype.setStyle.call(this, options)
},
_redraw: function () {
if (this.datalayer && this.datalayer.isVisible()) {
this.setStyle()
this.resetTooltip()
}
},
onAdd: function (map) {
this._container = null
this.setStyle()
// Show tooltip again when Leaflet.label allow static label on path.
// cf https://github.com/Leaflet/Leaflet/pull/3952
// this.map.on('showmeasure', this.showMeasureTooltip, this);
// this.map.on('hidemeasure', this.removeTooltip, this);
this.parentClass.prototype.onAdd.call(this, map)
if (this.editing && this.editing.enabled()) this.editing.addHooks()
this.resetTooltip()
},
onRemove: function (map) {
// this.map.off('showmeasure', this.showMeasureTooltip, this);
// this.map.off('hidemeasure', this.removeTooltip, this);
if (this.editing && this.editing.enabled()) this.editing.removeHooks()
U.FeatureMixin.onRemove.call(this, map)
},
getBestZoom: function () {
return this.getOption('zoomTo') || this.map.getBoundsZoom(this.getBounds(), true)
},
endEdit: function () {
this.disableEdit()
U.FeatureMixin.endEdit.call(this)
},
highlightPath: function () {
this.parentClass.prototype.setStyle.call(this, {
fillOpacity: Math.sqrt(this.getDynamicOption('fillOpacity', 1.0)),
opacity: 1.0,
weight: 1.3 * this.getDynamicOption('weight'),
})
},
_onMouseOver: function () {
if (this.map.measureTools && this.map.measureTools.enabled()) {
this.map.ui.tooltip({ content: this.getMeasure(), anchor: this })
} else if (this.map.editEnabled && !this.map.editedFeature) {
this.map.ui.tooltip({ content: L._('Click to edit'), anchor: this })
}
},
addInteractions: function () {
U.FeatureMixin.addInteractions.call(this)
this.on('mouseover', this._onMouseOver)
this.on('edit', this.makeDirty)
this.on('drag editable:drag', this._onDrag)
this.on('popupopen', this.highlightPath)
this.on('popupclose', this._redraw)
},
_onDrag: function () {
if (this._tooltip) this._tooltip.setLatLng(this.getCenter())
},
transferShape: function (at, to) {
const shape = this.enableEdit().deleteShapeAt(at)
this.disableEdit()
if (!shape) return
to.enableEdit().appendShape(shape)
if (!this._latlngs.length || !this._latlngs[0].length) this.del()
},
isolateShape: function (at) {
if (!this.isMulti()) return
const shape = this.enableEdit().deleteShapeAt(at)
this.disableEdit()
if (!shape) return
const properties = this.cloneProperties()
const other = new (this instanceof U.Polyline ? U.Polyline : U.Polygon)(
this.map,
shape,
{ geojson: { properties: properties } }
)
this.datalayer.addLayer(other)
other.edit()
return other
},
getContextMenuItems: function (e) {
let items = U.FeatureMixin.getContextMenuItems.call(this, e)
items.push({
text: L._('Display measure'),
callback: function () {
this.map.ui.alert({ content: this.getMeasure(), level: 'info' })
},
context: this,
})
if (this.map.editEnabled && !this.isReadOnly() && this.isMulti()) {
items = items.concat(this.getContextMenuMultiItems(e))
}
return items
},
getContextMenuMultiItems: function (e) {
const items = [
'-',
{
text: L._('Remove shape from the multi'),
callback: function () {
this.enableEdit().deleteShapeAt(e.latlng)
},
context: this,
},
]
const shape = this.shapeAt(e.latlng)
if (this._latlngs.indexOf(shape) > 0) {
items.push({
text: L._('Make main shape'),
callback: function () {
this.enableEdit().deleteShape(shape)
this.editor.prependShape(shape)
},
context: this,
})
}
return items
},
getContextMenuEditItems: function (e) {
const items = U.FeatureMixin.getContextMenuEditItems.call(this, e)
if (
this.map.editedFeature &&
this.isSameClass(this.map.editedFeature) &&
this.map.editedFeature !== this
) {
items.push({
text: L._('Transfer shape to edited feature'),
callback: function () {
this.transferShape(e.latlng, this.map.editedFeature)
},
context: this,
})
}
if (this.isMulti()) {
items.push({
text: L._('Extract shape to separate feature'),
callback: function () {
this.isolateShape(e.latlng, this.map.editedFeature)
},
context: this,
})
}
return items
},
getInplaceToolbarActions: function (e) {
const items = U.FeatureMixin.getInplaceToolbarActions.call(this, e)
if (this.isMulti()) {
items.push(U.DeleteShapeAction)
items.push(U.ExtractShapeFromMultiAction)
}
return items
},
isOnScreen: function (bounds) {
bounds = bounds || this.map.getBounds()
return bounds.overlaps(this.getBounds())
},
zoomTo: function (e) {
// Use bounds instead of centroid for paths.
e = e || {}
const easing = e.easing !== undefined ? e.easing : this.map.getOption('easing')
if (easing) {
this.map.flyToBounds(this.getBounds(), this.getBestZoom())
} else {
this.map.fitBounds(this.getBounds(), this.getBestZoom() || this.map.getZoom())
}
if (e.callback) e.callback.call(this)
},
}
U.Polyline = L.Polyline.extend({
parentClass: L.Polyline,
includes: [U.FeatureMixin, U.PathMixin],
staticOptions: {
stroke: true,
fill: false,
mainColor: 'color',
},
isSameClass: function (other) {
return other instanceof U.Polyline
},
getClassName: function () {
return 'polyline'
},
getMeasure: function (shape) {
const length = L.GeoUtil.lineLength(this.map, shape || this._defaultShape())
return L.GeoUtil.readableDistance(length, this.map.measureTools.getMeasureUnit())
},
getContextMenuEditItems: function (e) {
const items = U.PathMixin.getContextMenuEditItems.call(this, e)
const vertexClicked = e.vertex
let index
if (!this.isMulti()) {
items.push({
text: L._('Transform to polygon'),
callback: this.toPolygon,
context: this,
})
}
if (vertexClicked) {
index = e.vertex.getIndex()
if (index !== 0 && index !== e.vertex.getLastIndex()) {
items.push({
text: L._('Split line'),
callback: e.vertex.split,
context: e.vertex,
})
} else if (index === 0 || index === e.vertex.getLastIndex()) {
items.push({
text: L._('Continue line (Ctrl+Click)'),
callback: e.vertex.continue,
context: e.vertex.continue,
})
}
}
return items
},
getContextMenuMultiItems: function (e) {
const items = U.PathMixin.getContextMenuMultiItems.call(this, e)
items.push({
text: L._('Merge lines'),
callback: this.mergeShapes,
context: this,
})
return items
},
toPolygon: function () {
const geojson = this.toGeoJSON()
geojson.geometry.type = 'Polygon'
geojson.geometry.coordinates = [
L.Util.flattenCoordinates(geojson.geometry.coordinates),
]
const polygon = this.datalayer.geojsonToFeatures(geojson)
polygon.edit()
this.del()
},
getAdvancedEditActions: function (container) {
U.FeatureMixin.getAdvancedEditActions.call(this, container)
const toPolygon = L.DomUtil.createButton(
'button umap-to-polygon',
container,
L._('Transform to polygon'),
this.toPolygon,
this
)
},
_mergeShapes: function (from, to) {
const toLeft = to[0]
const toRight = to[to.length - 1]
const fromLeft = from[0]
const fromRight = from[from.length - 1]
const l2ldistance = toLeft.distanceTo(fromLeft)
const l2rdistance = toLeft.distanceTo(fromRight)
const r2ldistance = toRight.distanceTo(fromLeft)
const r2rdistance = toRight.distanceTo(fromRight)
let toMerge
if (l2rdistance < Math.min(l2ldistance, r2ldistance, r2rdistance)) {
toMerge = [from, to]
} else if (r2ldistance < Math.min(l2ldistance, l2rdistance, r2rdistance)) {
toMerge = [to, from]
} else if (r2rdistance < Math.min(l2ldistance, l2rdistance, r2ldistance)) {
from.reverse()
toMerge = [to, from]
} else {
from.reverse()
toMerge = [from, to]
}
const a = toMerge[0],
b = toMerge[1],
p1 = this.map.latLngToContainerPoint(a[a.length - 1]),
p2 = this.map.latLngToContainerPoint(b[0]),
tolerance = 5 // px on screen
if (Math.abs(p1.x - p2.x) <= tolerance && Math.abs(p1.y - p2.y) <= tolerance) {
a.pop()
}
return a.concat(b)
},
mergeShapes: function () {
if (!this.isMulti()) return
const latlngs = this.getLatLngs()
if (!latlngs.length) return
while (latlngs.length > 1) {
latlngs.splice(0, 2, this._mergeShapes(latlngs[1], latlngs[0]))
}
this.setLatLngs(latlngs[0])
if (!this.editEnabled()) this.edit()
this.editor.reset()
this.isDirty = true
},
isMulti: function () {
return !L.LineUtil.isFlat(this._latlngs) && this._latlngs.length > 1
},
getVertexActions: function (e) {
const actions = U.FeatureMixin.getVertexActions.call(this, e),
index = e.vertex.getIndex()
if (index === 0 || index === e.vertex.getLastIndex())
actions.push(U.ContinueLineAction)
else actions.push(U.SplitLineAction)
return actions
},
})
U.Polygon = L.Polygon.extend({
parentClass: L.Polygon,
includes: [U.FeatureMixin, U.PathMixin],
staticOptions: {
mainColor: 'fillColor',
},
isSameClass: function (other) {
return other instanceof U.Polygon
},
getClassName: function () {
return 'polygon'
},
getShapeOptions: function () {
const options = U.PathMixin.getShapeOptions()
options.push(
'properties._umap_options.stroke',
'properties._umap_options.fill',
'properties._umap_options.fillColor',
'properties._umap_options.fillOpacity'
)
return options
},
getInteractionOptions: function () {
const options = U.FeatureMixin.getInteractionOptions()
options.push('properties._umap_options.interactive')
return options
},
getMeasure: function (shape) {
const area = L.GeoUtil.geodesicArea(shape || this._defaultShape())
return L.GeoUtil.readableArea(area, this.map.measureTools.getMeasureUnit())
},
getContextMenuEditItems: function (e) {
const items = U.PathMixin.getContextMenuEditItems.call(this, e),
shape = this.shapeAt(e.latlng)
// No multi and no holes.
if (shape && !this.isMulti() && (L.LineUtil.isFlat(shape) || shape.length === 1)) {
items.push({
text: L._('Transform to lines'),
callback: this.toPolyline,
context: this,
})
}
items.push({
text: L._('Start a hole here'),
callback: this.startHole,
context: this,
})
return items
},
startHole: function (e) {
this.enableEdit().newHole(e.latlng)
},
toPolyline: function () {
const geojson = this.toGeoJSON()
geojson.geometry.type = 'LineString'
geojson.geometry.coordinates = L.Util.flattenCoordinates(
geojson.geometry.coordinates
)
const polyline = this.datalayer.geojsonToFeatures(geojson)
polyline.edit()
this.del()
},
getAdvancedEditActions: function (container) {
U.FeatureMixin.getAdvancedEditActions.call(this, container)
const toPolyline = L.DomUtil.createButton(
'button umap-to-polyline',
container,
L._('Transform to lines'),
this.toPolyline,
this
)
},
isMulti: function () {
// Change me when Leaflet#3279 is merged.
return (
!L.LineUtil.isFlat(this._latlngs) &&
!L.LineUtil.isFlat(this._latlngs[0]) &&
this._latlngs.length > 1
)
},
getInplaceToolbarActions: function (e) {
const items = U.PathMixin.getInplaceToolbarActions.call(this, e)
items.push(U.CreateHoleAction)
return items
},
})