umap/umap/static/umap/js/umap.features.js
2024-06-21 13:35:06 +02:00

1308 lines
36 KiB
JavaScript

U.FeatureMixin = {
staticOptions: { mainColor: 'color' },
getSyncMetadata: function () {
return {
subject: 'feature',
metadata: {
id: this.id,
layerId: this.datalayer?.id || null,
featureType: this.getClassName(),
},
}
},
onCommit: function () {
// When the layer is a remote layer, we don't want to sync the creation of the
// points via the websocket, as the other peers will get them themselves.
if (this.datalayer.isRemoteLayer()) return
this.sync.upsert(this.toGeoJSON())
},
getGeometry: function () {
return this.toGeoJSON().geometry
},
syncDelete: function () {
this.sync.delete()
},
initialize: function (map, latlng, options, id) {
this.map = map
this.sync = map.sync_engine.proxy(this)
if (typeof options === 'undefined') {
options = {}
}
// DataLayer the marker belongs to
this.datalayer = options.datalayer || null
this.properties = { _umap_options: {} }
if (options.geojson) {
this.populate(options.geojson)
}
if (id) {
this.id = id
} else {
let geojson_id
if (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 `${U.Utils.getBaseUrl()}?${U.Utils.buildQueryString({ feature: slug })}${
window.location.hash
}`
},
view: function (e) {
const outlink = this.getOption('outlink')
const target = this.getOption('outlinkTarget')
if (outlink) {
switch (target) {
case 'self':
window.location = outlink
break
case 'parent':
window.top.location = outlink
break
default:
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?.latlng || this.getCenter())
},
render: function (fields) {
const impactData = fields.some((field) => {
return field.startsWith('properties.')
})
if (impactData) {
if (this.map.currentFeature === this) {
this.view()
}
}
this._redraw()
},
openPopup: function () {
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.createTitle(
container,
L._('Feature properties'),
`icon-${this.getClassName()}`
)
let builder = new U.FormBuilder(
this,
[['datalayer', { handler: 'DataLayerSwitcher' }]],
{
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',
})
container.appendChild(builder.build())
this.appendEditFieldsets(container)
const advancedActions = L.DomUtil.createFieldset(container, L._('Advanced actions'))
this.getAdvancedEditActions(advancedActions)
const onLoad = this.map.editPanel.open({ content: container })
onLoad.then(() => {
builder.helpers['properties.name'].input.focus()
})
this.map.editedFeature = this
if (!this.isOnScreen()) this.zoomTo(e)
},
getAdvancedEditActions: function (container) {
L.DomUtil.createButton(
'button umap-delete',
container,
L._('Delete'),
function (e) {
L.DomEvent.stop(e)
if (this.confirmDelete()) this.map.editPanel.close()
},
this
)
},
appendEditFieldsets: function (container) {
const optionsFields = this.getShapeOptions()
let builder = new U.FormBuilder(this, optionsFields, {
id: 'umap-feature-shape-properties',
})
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',
})
const advancedProperties = L.DomUtil.createFieldset(
container,
L._('Advanced properties')
)
advancedProperties.appendChild(builder.build())
const interactionOptions = this.getInteractionOptions()
builder = new U.FormBuilder(this, interactionOptions)
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 (U.Utils.hasVar(key))
return U.Utils.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 (sync) {
this.isDirty = true
this.map.closePopup()
if (this.datalayer) {
this.datalayer.removeLayer(this)
this.disconnectFromDataLayer(this.datalayer)
if (sync !== false) this.syncDelete()
}
},
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 (U.Utils.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 (U.Utils.hasVar(value)) {
value = U.Utils.greedyTemplate(value, this.properties, true)
if (U.Utils.hasVar(value)) value = this.map.getDefaultOption(option)
}
return value
},
zoomTo: function ({ easing, latlng, callback } = {}) {
if (easing === undefined) easing = this.map.getOption('easing')
if (callback) this.map.once('moveend', callback.call(this))
if (easing) {
this.map.flyTo(this.getCenter(), this.getBestZoom())
} else {
latlng = latlng || this.getCenter()
this.map.setView(latlng, this.getBestZoom() || this.map.getZoom())
}
},
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: this.map.help.displayLabel('EDIT_FEATURE_LAYER'),
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.editPanel.close()
}
},
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(U.Utils.escapeHTML(displayName), options)
},
isFiltered: function () {
const filterKeys = this.datalayer.getFilterKeys()
const filter = this.map.browser.options.filter
if (filter && !this.matchFilter(filter, filterKeys)) return true
if (!this.matchFacets()) return true
return false
},
matchFilter: function (filter, keys) {
filter = filter.toLowerCase()
if (U.Utils.hasVar(keys)) {
return this.getDisplayName().toLowerCase().indexOf(filter) !== -1
}
keys = keys.split(',')
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 selected = this.map.facets.selected
for (let [name, { type, min, max, choices }] of Object.entries(selected)) {
let value = this.properties[name]
let parser = this.map.facets.getParser(type)
value = parser(value)
switch (type) {
case 'date':
case 'datetime':
case 'number':
if (!isNaN(min) && !isNaN(value) && min > value) return false
if (!isNaN(max) && !isNaN(value) && max < value) return false
break
default:
value = value || L._('<empty value>')
if (choices?.length && !choices.includes(value)) return false
break
}
}
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 geoJSON = this.toGeoJSON()
delete geoJSON.id
delete geoJSON.properties.id
const layer = this.datalayer.geojsonToFeatures(geoJSON)
layer.isDirty = true
layer.edit()
return layer
},
extendedProperties: function () {
// Include context properties
properties = this.map.getGeoContext()
const locale = L.getLocale()
if (locale) properties.locale = locale
if (L.lang) properties.lang = L.lang
properties.rank = this.getRank() + 1
if (this._map && 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.sync.update('geometry', this.getGeometry())
},
this
)
this.on('editable:drawing:commit', this.onCommit)
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()) {
U.Alert.error(L._('Invalid latitude or longitude'))
builder.resetField('_latlng.lat')
builder.resetField('_latlng.lng')
}
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.editPanel.close()
} 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.tooltip.open({ content: this.getMeasure(), anchor: this })
} else if (this.map.editEnabled && !this.map.editedFeature) {
this.map.tooltip.open({ content: L._('Click to edit'), anchor: this })
}
},
addInteractions: function () {
U.FeatureMixin.addInteractions.call(this)
this.on('editable:disable', this.onCommit)
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 },
}
)
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 () {
U.Alert.info(this.getMeasure())
},
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: this.map.help.displayLabel('CONTINUE_LINE'),
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 = [
U.Utils.flattenCoordinates(geojson.geometry.coordinates),
]
delete geojson.id // delete the copied id, a new one will be generated.
const polygon = this.datalayer.geojsonToFeatures(geojson)
polygon.edit()
this.del()
},
getAdvancedEditActions: function (container) {
U.FeatureMixin.getAdvancedEditActions.call(this, container)
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()
delete geojson.id
delete geojson.properties.id
geojson.geometry.type = 'LineString'
geojson.geometry.coordinates = U.Utils.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
},
})