mirror of
https://github.com/umap-project/umap.git
synced 2025-05-05 06:01:48 +02:00
1265 lines
33 KiB
JavaScript
1265 lines
33 KiB
JavaScript
import {
|
|
DomEvent,
|
|
DomUtil,
|
|
GeoJSON,
|
|
LineUtil,
|
|
stamp,
|
|
} from '../../../vendors/leaflet/leaflet-src.esm.js'
|
|
import { uMapAlert as Alert } from '../../components/alerts/alert.js'
|
|
import { MutatingForm } from '../form/builder.js'
|
|
import { translate } from '../i18n.js'
|
|
import loadPopup from '../rendering/popup.js'
|
|
import {
|
|
LeafletMarker,
|
|
LeafletPolygon,
|
|
LeafletPolyline,
|
|
MaskPolygon,
|
|
} from '../rendering/ui.js'
|
|
import { SCHEMA } from '../schema.js'
|
|
import * as Utils from '../utils.js'
|
|
|
|
class Feature {
|
|
constructor(umap, datalayer, geojson = {}, id = null) {
|
|
this._umap = umap
|
|
this.sync = umap.syncEngine.proxy(this)
|
|
this._isDirty = false
|
|
this._ui = null
|
|
|
|
// DataLayer the feature belongs to
|
|
this.datalayer = datalayer
|
|
this.properties = { _umap_options: {}, ...(geojson.properties || {}) }
|
|
this.staticOptions = {}
|
|
|
|
if (geojson.coordinates) {
|
|
geojson = { geometry: geojson }
|
|
}
|
|
if (geojson.geometry) {
|
|
this.populate(geojson)
|
|
}
|
|
|
|
if (id) {
|
|
this.id = id
|
|
} else {
|
|
let geojson_id
|
|
if (geojson) {
|
|
geojson_id = geojson.id
|
|
}
|
|
|
|
// Each feature needs an unique identifier
|
|
if (Utils.checkId(geojson_id)) {
|
|
this.id = geojson_id
|
|
} else {
|
|
this.id = Utils.generateId()
|
|
}
|
|
}
|
|
}
|
|
|
|
set isDirty(status) {
|
|
this._isDirty = status
|
|
if (this.datalayer) {
|
|
this.datalayer.isDirty = status
|
|
}
|
|
}
|
|
|
|
get isDirty() {
|
|
return this._isDirty
|
|
}
|
|
|
|
get ui() {
|
|
if (!this._ui) this.makeUI()
|
|
return this._ui
|
|
}
|
|
|
|
get center() {
|
|
return this.ui.getCenter()
|
|
}
|
|
|
|
get bounds() {
|
|
return this.ui.getBounds()
|
|
}
|
|
|
|
get type() {
|
|
return this.geometry.type
|
|
}
|
|
|
|
get coordinates() {
|
|
return this.geometry.coordinates
|
|
}
|
|
|
|
get geometry() {
|
|
return this._geometry
|
|
}
|
|
|
|
set geometry(value) {
|
|
this._geometry_bk = Utils.CopyJSON(this._geometry)
|
|
this._geometry = value
|
|
this.pushGeometry()
|
|
}
|
|
|
|
isOnScreen(bounds) {
|
|
return this.ui?.isOnScreen(bounds)
|
|
}
|
|
|
|
pushGeometry() {
|
|
this._setLatLngs(this.toLatLngs())
|
|
}
|
|
|
|
pullGeometry(sync = true) {
|
|
const oldGeometry = Utils.CopyJSON(this._geometry)
|
|
this.fromLatLngs(this._getLatLngs())
|
|
if (sync) {
|
|
this.sync.update('geometry', this.geometry, oldGeometry)
|
|
}
|
|
}
|
|
|
|
fromLatLngs(latlngs) {
|
|
this._geometry_bk = Utils.CopyJSON(this._geometry)
|
|
this._geometry = this.convertLatLngs(latlngs)
|
|
}
|
|
|
|
makeUI() {
|
|
const klass = this.getUIClass()
|
|
this._ui = new klass(this, this.toLatLngs())
|
|
}
|
|
|
|
getUIClass() {
|
|
return this.getOption('UIClass')
|
|
}
|
|
|
|
getClassName() {
|
|
return this.staticOptions.className
|
|
}
|
|
|
|
getPreviewColor() {
|
|
return this.getDynamicOption(this.staticOptions.mainColor)
|
|
}
|
|
|
|
getSyncMetadata() {
|
|
return {
|
|
subject: 'feature',
|
|
metadata: {
|
|
id: this.id,
|
|
layerId: this.datalayer.id,
|
|
featureType: this.getClassName(),
|
|
},
|
|
}
|
|
}
|
|
|
|
onCommit() {
|
|
// 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.
|
|
const oldGeoJSON = this._just_married ? null : Utils.CopyJSON(this.toGeoJSON())
|
|
this.pullGeometry(false)
|
|
if (this.datalayer?.isRemoteLayer()) return
|
|
if (this._just_married) {
|
|
this.sync.upsert(this.toGeoJSON(), null)
|
|
this._just_married = false
|
|
} else {
|
|
this.sync.update('geometry', this.geometry, this._geometry_bk)
|
|
}
|
|
}
|
|
|
|
isReadOnly() {
|
|
return this.datalayer?.isDataReadOnly()
|
|
}
|
|
|
|
getSlug() {
|
|
return (
|
|
this.properties[this._umap.getProperty('slugKey') || U.DEFAULT_LABEL_KEY] || ''
|
|
)
|
|
}
|
|
|
|
getPermalink() {
|
|
const slug = this.getSlug()
|
|
if (slug)
|
|
return `${Utils.getBaseUrl()}?${Utils.buildQueryString({ feature: slug })}${
|
|
window.location.hash
|
|
}`
|
|
}
|
|
|
|
view({ latlng } = {}) {
|
|
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._umap.slideshow) {
|
|
this._umap.slideshow.current = this
|
|
}
|
|
this._umap.currentFeature = this
|
|
this.attachPopup().then(() => {
|
|
this.ui.openPopup(latlng || this.center)
|
|
})
|
|
}
|
|
|
|
render(fields) {
|
|
const impactData = fields.some((field) => {
|
|
return field.startsWith('properties.')
|
|
})
|
|
if (impactData) {
|
|
if (this._umap.currentFeature === this) {
|
|
this.view()
|
|
}
|
|
this.datalayer.indexProperties(this)
|
|
}
|
|
this.redraw()
|
|
}
|
|
|
|
edit(event) {
|
|
if (!this._umap.editEnabled || this.isReadOnly()) return
|
|
const container = DomUtil.create('div', 'umap-feature-container')
|
|
DomUtil.createTitle(
|
|
container,
|
|
translate('Feature properties'),
|
|
`icon-${this.getClassName()}`
|
|
)
|
|
|
|
let builder = new MutatingForm(this, [
|
|
['datalayer', { handler: 'DataLayerSwitcher' }],
|
|
])
|
|
// removeLayer step will close the edit panel, let's reopen it
|
|
builder.on('set', () => this.edit(event))
|
|
container.appendChild(builder.build())
|
|
|
|
const properties = []
|
|
let labelKeyFound = undefined
|
|
for (const property of this.datalayer.allProperties()) {
|
|
if (!labelKeyFound && U.LABEL_KEYS.includes(property)) {
|
|
labelKeyFound = property
|
|
continue
|
|
}
|
|
if (property === 'description') {
|
|
continue
|
|
}
|
|
properties.push([`properties.${property}`, { label: property }])
|
|
}
|
|
// We always want name and description for now (properties management to come)
|
|
properties.unshift('properties.description')
|
|
if (!labelKeyFound) {
|
|
labelKeyFound = U.DEFAULT_LABEL_KEY
|
|
}
|
|
properties.unshift([`properties.${labelKeyFound}`, { label: labelKeyFound }])
|
|
builder = new MutatingForm(this, properties, {
|
|
id: 'umap-feature-properties',
|
|
})
|
|
container.appendChild(builder.build())
|
|
this.appendEditFieldsets(container)
|
|
const advancedActions = DomUtil.createFieldset(
|
|
container,
|
|
translate('Advanced actions')
|
|
)
|
|
this.getAdvancedEditActions(advancedActions)
|
|
const onLoad = this._umap.editPanel.open({ content: container })
|
|
onLoad.then(() => {
|
|
builder.helpers[`properties.${labelKeyFound}`].input.focus()
|
|
})
|
|
this._umap.editedFeature = this
|
|
if (!this.ui.isOnScreen(this._umap._leafletMap.getBounds())) this.zoomTo(event)
|
|
}
|
|
|
|
toggleEditing() {
|
|
this.edit()
|
|
}
|
|
|
|
getAdvancedEditActions(container) {
|
|
const button = Utils.loadTemplate(`
|
|
<button class="button" type="button">
|
|
<i class="icon icon-24 icon-delete"></i>${translate('Delete')}
|
|
</button>`)
|
|
button.addEventListener('click', () => {
|
|
this.confirmDelete().then(() => this._umap.editPanel.close())
|
|
})
|
|
container.appendChild(button)
|
|
}
|
|
|
|
appendEditFieldsets(container) {
|
|
const optionsFields = this.getShapeOptions()
|
|
let builder = new MutatingForm(this, optionsFields, {
|
|
id: 'umap-feature-shape-properties',
|
|
})
|
|
const shapeProperties = DomUtil.createFieldset(
|
|
container,
|
|
translate('Shape properties')
|
|
)
|
|
shapeProperties.appendChild(builder.build())
|
|
|
|
const advancedOptions = this.getAdvancedOptions()
|
|
builder = new MutatingForm(this, advancedOptions, {
|
|
id: 'umap-feature-advanced-properties',
|
|
})
|
|
const advancedProperties = DomUtil.createFieldset(
|
|
container,
|
|
translate('Advanced properties')
|
|
)
|
|
advancedProperties.appendChild(builder.build())
|
|
|
|
const interactionOptions = this.getInteractionOptions()
|
|
builder = new MutatingForm(this, interactionOptions)
|
|
const popupFieldset = DomUtil.createFieldset(
|
|
container,
|
|
translate('Interaction options')
|
|
)
|
|
popupFieldset.appendChild(builder.build())
|
|
}
|
|
|
|
getInteractionOptions() {
|
|
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() {}
|
|
|
|
getDisplayName() {
|
|
const keys = U.LABEL_KEYS.slice() // Copy.
|
|
const labelKey = this.getOption('labelKey')
|
|
// Variables mode.
|
|
if (labelKey) {
|
|
if (Utils.hasVar(labelKey)) {
|
|
return Utils.greedyTemplate(labelKey, this.extendedProperties()).trim()
|
|
}
|
|
keys.unshift(labelKey)
|
|
}
|
|
for (const key of keys) {
|
|
const value = this.properties[key]
|
|
if (value) return String(value).trim()
|
|
}
|
|
return this.datalayer.getName().trim()
|
|
}
|
|
|
|
hasPopupFooter() {
|
|
if (this.datalayer.isRemoteLayer() && this.datalayer.options.remoteData.dynamic) {
|
|
return false
|
|
}
|
|
return this._umap.getProperty('displayPopupFooter')
|
|
}
|
|
|
|
getPopupClass() {
|
|
const old = this.getOption('popupTemplate') // Retrocompat.
|
|
return loadPopup(this.getOption('popupShape') || old)
|
|
}
|
|
|
|
async attachPopup() {
|
|
const Class = this.getPopupClass()
|
|
const popup = new Class(this)
|
|
this.ui.bindPopup(popup)
|
|
return popup.loadContent()
|
|
}
|
|
|
|
async confirmDelete() {
|
|
const confirmed = await this._umap.dialog.confirm(
|
|
translate('Are you sure you want to delete the feature?')
|
|
)
|
|
if (confirmed) {
|
|
this.del()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
del(sync) {
|
|
this.isDirty = true
|
|
this._umap._leafletMap.closePopup()
|
|
if (this.datalayer) {
|
|
this.datalayer.removeFeature(this, sync)
|
|
}
|
|
}
|
|
|
|
connectToDataLayer(datalayer) {
|
|
this.datalayer = datalayer
|
|
// FIXME should be in layer/ui
|
|
this.ui.options.renderer = this.datalayer.renderer
|
|
}
|
|
|
|
disconnectFromDataLayer(datalayer) {
|
|
if (this.datalayer === datalayer) {
|
|
this.datalayer = null
|
|
}
|
|
}
|
|
|
|
cleanProperty([key, value]) {
|
|
// dot in key will break the dot based property access
|
|
// while editing the feature
|
|
key = key.replace('.', '_')
|
|
return [key, value]
|
|
}
|
|
|
|
populate(geojson) {
|
|
this._geometry = geojson.geometry
|
|
this.properties = Object.fromEntries(
|
|
Object.entries(geojson.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(datalayer) {
|
|
if (this.datalayer) {
|
|
this.datalayer.isDirty = true
|
|
this.datalayer.removeFeature(this)
|
|
}
|
|
|
|
datalayer.addFeature(this)
|
|
this.sync.upsert(this.toGeoJSON())
|
|
datalayer.isDirty = true
|
|
this.redraw()
|
|
}
|
|
|
|
getOption(option, fallback) {
|
|
let value = fallback
|
|
if (typeof this.staticOptions[option] !== 'undefined') {
|
|
value = this.staticOptions[option]
|
|
} else if (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._umap.getProperty(option)
|
|
}
|
|
return value
|
|
}
|
|
|
|
getDynamicOption(key, fallback) {
|
|
let value = this.getOption(key, fallback)
|
|
// There is a variable inside.
|
|
if (Utils.hasVar(value)) {
|
|
value = Utils.greedyTemplate(value, this.properties, true)
|
|
if (Utils.hasVar(value)) value = SCHEMA[key]?.default
|
|
}
|
|
return value
|
|
}
|
|
|
|
zoomTo({ easing, latlng, callback } = {}) {
|
|
if (easing === undefined) easing = this._umap.getProperty('easing')
|
|
if (callback) this._umap._leafletMap.once('moveend', callback.bind(this))
|
|
if (easing) {
|
|
this._umap._leafletMap.flyTo(this.center, this.getBestZoom())
|
|
} else {
|
|
latlng = latlng || this.center
|
|
this._umap._leafletMap.setView(
|
|
latlng,
|
|
this.getBestZoom() || this._umap._leafletMap.getZoom()
|
|
)
|
|
}
|
|
}
|
|
|
|
getBestZoom() {
|
|
return this.getOption('zoomTo')
|
|
}
|
|
|
|
getNext() {
|
|
return this.datalayer.getNextFeature(this)
|
|
}
|
|
|
|
getPrevious() {
|
|
return this.datalayer.getPreviousFeature(this)
|
|
}
|
|
|
|
cloneProperties() {
|
|
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
|
|
}
|
|
// Legacy
|
|
delete properties._storage_options
|
|
return properties
|
|
}
|
|
|
|
deleteProperty(property) {
|
|
delete this.properties[property]
|
|
this.isDirty = true
|
|
}
|
|
|
|
renameProperty(from, to) {
|
|
this.properties[to] = this.properties[from]
|
|
this.deleteProperty(from)
|
|
}
|
|
|
|
toGeoJSON() {
|
|
return Utils.CopyJSON({
|
|
type: 'Feature',
|
|
geometry: this.geometry,
|
|
properties: this.cloneProperties(),
|
|
id: this.id,
|
|
})
|
|
}
|
|
|
|
getInplaceEditMenu() {
|
|
return [
|
|
{
|
|
action: () => this.toggleEditing(),
|
|
title: translate('Toggle edit mode (⇧+Click)'),
|
|
icon: 'icon-edit',
|
|
},
|
|
{
|
|
action: () => this.confirmDelete(),
|
|
title: translate('Delete this feature'),
|
|
icon: 'icon-delete',
|
|
},
|
|
]
|
|
}
|
|
|
|
isFiltered() {
|
|
const filterKeys = this.datalayer.getFilterKeys()
|
|
const filter = this._umap.browser.options.filter
|
|
if (filter && !this.matchFilter(filter, filterKeys)) return true
|
|
if (!this.matchFacets()) return true
|
|
return false
|
|
}
|
|
|
|
matchFilter(filter, keys) {
|
|
filter = filter.toLowerCase()
|
|
// When user hasn't touched settings, when a feature has no name
|
|
// it will use the datalayer's name, so let's make the filtering
|
|
// consistent.
|
|
// Also, if the user has defined a labelKey with vars, let's
|
|
// compute before filtering
|
|
if (Utils.hasVar(keys) || keys === 'displayName') {
|
|
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() {
|
|
const selected = this._umap.facets.selected
|
|
for (const [name, { type, min, max, choices }] of Object.entries(selected)) {
|
|
let value = this.properties[name]
|
|
const parser = this._umap.facets.getParser(type)
|
|
value = parser(value)
|
|
switch (type) {
|
|
case 'date':
|
|
case 'datetime':
|
|
case 'number':
|
|
if (!Number.isNaN(min) && !Number.isNaN(value) && min > value) return false
|
|
if (!Number.isNaN(max) && !Number.isNaN(value) && max < value) return false
|
|
break
|
|
default:
|
|
value = value || translate('<empty value>')
|
|
if (choices?.length && !choices.includes(value)) return false
|
|
break
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
isMulti() {
|
|
return false
|
|
}
|
|
|
|
isEmpty() {
|
|
return !this.coordinates.length
|
|
}
|
|
|
|
clone() {
|
|
const geojson = this.toGeoJSON()
|
|
delete geojson.id
|
|
delete geojson.properties.id
|
|
const feature = this.datalayer.makeFeature(geojson)
|
|
feature.isDirty = true
|
|
feature.edit()
|
|
return feature
|
|
}
|
|
|
|
extendedProperties() {
|
|
// Include context properties
|
|
const properties = this._umap.getGeoContext()
|
|
const locale = L.getLocale()
|
|
if (locale) properties.locale = locale
|
|
if (U.lang) properties.lang = U.lang
|
|
properties.rank = this.getRank() + 1
|
|
properties.layer = this.datalayer.getName()
|
|
if (this.ui._map && this.hasGeom()) {
|
|
const center = this.center
|
|
properties.lat = center.lat
|
|
properties.lon = center.lng
|
|
properties.lng = center.lng
|
|
properties.alt = center?.alt
|
|
if (typeof this.ui.getMeasure !== 'undefined') {
|
|
properties.measure = this.ui.getMeasure()
|
|
}
|
|
}
|
|
return Object.assign(properties, this.properties)
|
|
}
|
|
|
|
getRank() {
|
|
return this.datalayer._index.indexOf(L.stamp(this))
|
|
}
|
|
|
|
redraw() {
|
|
if (this.datalayer?.isVisible()) {
|
|
if (this.getUIClass() !== this.ui.getClass()) {
|
|
this.datalayer.hideFeature(this)
|
|
this.makeUI()
|
|
this.datalayer.showFeature(this)
|
|
} else {
|
|
this.ui._redraw()
|
|
}
|
|
}
|
|
}
|
|
|
|
getContextMenuItems(event) {
|
|
const permalink = this.getPermalink()
|
|
let items = []
|
|
if (permalink) {
|
|
items.push({
|
|
label: translate('Permalink'),
|
|
action: () => {
|
|
window.open(permalink)
|
|
},
|
|
})
|
|
items.push({
|
|
label: translate('Layer permalink'),
|
|
action: () => {
|
|
window.open(this.datalayer.getPermalink())
|
|
},
|
|
})
|
|
}
|
|
items.push({
|
|
label: translate('Copy as GeoJSON'),
|
|
action: () => {
|
|
L.Util.copyToClipboard(JSON.stringify(this.toGeoJSON()))
|
|
this._umap.tooltip.open({ content: L._('✅ Copied!') })
|
|
},
|
|
})
|
|
if (this._umap.editEnabled && !this.isReadOnly()) {
|
|
items = items.concat(this.getContextMenuEditItems(event))
|
|
}
|
|
return items
|
|
}
|
|
|
|
getContextMenuEditItems() {
|
|
let items = ['-']
|
|
if (this._umap.editedFeature !== this) {
|
|
items.push({
|
|
label: `${translate('Edit this feature')} (⇧+Click)`,
|
|
action: () => this.edit(),
|
|
})
|
|
}
|
|
items = items.concat(
|
|
{
|
|
label: this._umap.help.displayLabel('EDIT_FEATURE_LAYER'),
|
|
action: () => this.datalayer.edit(),
|
|
},
|
|
{
|
|
label: translate('Delete this feature'),
|
|
action: () => this.confirmDelete(),
|
|
},
|
|
{
|
|
label: translate('Clone this feature'),
|
|
action: () => this.clone(),
|
|
}
|
|
)
|
|
return items
|
|
}
|
|
|
|
isActive() {
|
|
return this._umap.activeFeature === this
|
|
}
|
|
|
|
activate() {
|
|
this._umap.activeFeature = this
|
|
}
|
|
|
|
deactivate() {
|
|
if (this._umap.activeFeature === this) this._umap.activeFeature = undefined
|
|
}
|
|
}
|
|
|
|
export class Point extends Feature {
|
|
constructor(umap, datalayer, geojson, id) {
|
|
super(umap, datalayer, geojson, id)
|
|
this.staticOptions = {
|
|
mainColor: 'color',
|
|
className: 'marker',
|
|
}
|
|
}
|
|
|
|
_getLatLngs() {
|
|
return this.ui.getLatLng()
|
|
}
|
|
|
|
_setLatLngs(latlng) {
|
|
this.ui.setLatLng(latlng)
|
|
}
|
|
|
|
toLatLngs() {
|
|
return GeoJSON.coordsToLatLng(this.coordinates)
|
|
}
|
|
|
|
convertLatLngs(latlng) {
|
|
return { coordinates: GeoJSON.latLngToCoords(latlng), type: 'Point' }
|
|
}
|
|
|
|
getUIClass() {
|
|
return super.getUIClass() || LeafletMarker
|
|
}
|
|
|
|
hasGeom() {
|
|
return Boolean(this.coordinates)
|
|
}
|
|
|
|
_getIconUrl(name = 'icon') {
|
|
return this.getOption(`${name}Url`)
|
|
}
|
|
|
|
getShapeOptions() {
|
|
return [
|
|
'properties._umap_options.color',
|
|
'properties._umap_options.iconClass',
|
|
'properties._umap_options.iconUrl',
|
|
'properties._umap_options.iconOpacity',
|
|
]
|
|
}
|
|
|
|
getAdvancedOptions() {
|
|
return ['properties._umap_options.zoomTo']
|
|
}
|
|
|
|
appendEditFieldsets(container) {
|
|
super.appendEditFieldsets(container)
|
|
// FIXME edit feature geometry.coordinates instead
|
|
// (by learning FormBuilder to deal with array indexes ?)
|
|
const coordinatesOptions = [
|
|
['ui._latlng.lat', { handler: 'FloatInput', label: translate('Latitude') }],
|
|
['ui._latlng.lng', { handler: 'FloatInput', label: translate('Longitude') }],
|
|
]
|
|
const builder = new MutatingForm(this, coordinatesOptions)
|
|
builder.on('set', () => {
|
|
if (!this.ui._latlng.isValid()) {
|
|
Alert.error(translate('Invalid latitude or longitude'))
|
|
builder.restoreField('ui._latlng.lat')
|
|
builder.restoreField('ui._latlng.lng')
|
|
}
|
|
this.pullGeometry()
|
|
this.zoomTo({ easing: false })
|
|
})
|
|
const fieldset = DomUtil.createFieldset(container, translate('Coordinates'))
|
|
fieldset.appendChild(builder.build())
|
|
}
|
|
|
|
zoomTo(event) {
|
|
if (this.datalayer.isClustered() && !this.ui._icon) {
|
|
// callback is mandatory for zoomToShowLayer
|
|
this.datalayer.layer.zoomToShowLayer(this.ui, event.callback || (() => {}))
|
|
} else {
|
|
super.zoomTo(event)
|
|
}
|
|
}
|
|
}
|
|
|
|
class Path extends Feature {
|
|
hasGeom() {
|
|
return !this.isEmpty()
|
|
}
|
|
|
|
_getLatLngs() {
|
|
return this.ui.getLatLngs()
|
|
}
|
|
|
|
_setLatLngs(latlngs) {
|
|
this.ui.setLatLngs(latlngs)
|
|
}
|
|
|
|
connectToDataLayer(datalayer) {
|
|
super.connectToDataLayer(datalayer)
|
|
// We keep markers on their own layer on top of the paths.
|
|
this.ui.options.pane = this.datalayer.pane
|
|
}
|
|
|
|
edit(event) {
|
|
if (this._umap.editEnabled) {
|
|
super.edit(event)
|
|
if (!this.ui.editEnabled()) this.ui.makeGeometryEditable()
|
|
}
|
|
}
|
|
|
|
toggleEditing() {
|
|
if (this._umap.editEnabled) {
|
|
if (this.ui.editEnabled()) {
|
|
this.endEdit()
|
|
this._umap.editPanel.close()
|
|
} else {
|
|
this.edit()
|
|
}
|
|
}
|
|
}
|
|
|
|
getShapeOptions() {
|
|
return [
|
|
'properties._umap_options.color',
|
|
'properties._umap_options.opacity',
|
|
'properties._umap_options.weight',
|
|
]
|
|
}
|
|
|
|
getAdvancedOptions() {
|
|
return [
|
|
'properties._umap_options.smoothFactor',
|
|
'properties._umap_options.dashArray',
|
|
'properties._umap_options.zoomTo',
|
|
]
|
|
}
|
|
|
|
getBestZoom() {
|
|
return (
|
|
this.getOption('zoomTo') ||
|
|
this._umap._leafletMap.getBoundsZoom(this.bounds, true)
|
|
)
|
|
}
|
|
|
|
endEdit() {
|
|
this.ui.disableEdit()
|
|
super.endEdit()
|
|
}
|
|
|
|
transferShape(at, to) {
|
|
const shape = this.ui.enableEdit().deleteShapeAt(at)
|
|
// FIXME: make Leaflet.Editable send an event instead
|
|
this.pullGeometry()
|
|
this.ui.disableEdit()
|
|
if (!shape) return
|
|
to.ui.enableEdit().appendShape(shape)
|
|
to.pullGeometry()
|
|
if (this.isEmpty()) this.del()
|
|
}
|
|
|
|
isolateShape(latlngs) {
|
|
const properties = this.cloneProperties()
|
|
const type = this instanceof LineString ? 'LineString' : 'Polygon'
|
|
const geometry = this.convertLatLngs(latlngs)
|
|
const other = this.datalayer.makeFeature({ type, geometry, properties })
|
|
other.edit()
|
|
return other
|
|
}
|
|
|
|
getInplaceEditMenu(event) {
|
|
const items = super.getInplaceEditMenu()
|
|
if (this.isMulti()) {
|
|
items.push({
|
|
action: () => this.ui.enableEdit().deleteShapeAt(event.latlng),
|
|
title: translate('Delete this shape'),
|
|
icon: 'icon-delete-shape',
|
|
})
|
|
items.push({
|
|
action: () => this.ui.isolateShape(event.latlng),
|
|
title: translate('Extract shape to separate feature'),
|
|
icon: 'icon-extract-shape',
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
getInplaceEditVertexMenu(event) {
|
|
return [
|
|
{
|
|
action: () => event.vertex.delete(),
|
|
title: translate('Delete this vertex (Alt+Click)'),
|
|
icon: 'icon-delete-vertex',
|
|
},
|
|
]
|
|
}
|
|
|
|
zoomTo({ easing, callback }) {
|
|
// Use bounds instead of centroid for paths.
|
|
easing = easing || this._umap.getProperty('easing')
|
|
if (easing) {
|
|
this._umap._leafletMap.flyToBounds(this.bounds, this.getBestZoom())
|
|
} else {
|
|
this._umap._leafletMap.fitBounds(
|
|
this.bounds,
|
|
this.getBestZoom() || this._umap._leafletMap.getZoom()
|
|
)
|
|
}
|
|
if (callback) callback.call(this)
|
|
}
|
|
|
|
getContextMenuItems(event) {
|
|
const items = super.getContextMenuItems(event)
|
|
items.push({
|
|
label: translate('Display measure'),
|
|
action: () => Alert.info(this.ui.getMeasure()),
|
|
})
|
|
if (this._umap.editEnabled && !this.isReadOnly() && this.isMulti()) {
|
|
items.push(...this.getContextMenuMultiItems(event))
|
|
}
|
|
return items
|
|
}
|
|
|
|
getContextMenuMultiItems(event) {
|
|
const items = [
|
|
'-',
|
|
{
|
|
label: translate('Remove shape from the multi'),
|
|
action: () => {
|
|
this.ui.enableEdit().deleteShapeAt(event.latlng)
|
|
},
|
|
},
|
|
]
|
|
const shape = this.ui.shapeAt(event.latlng)
|
|
if (this.ui._latlngs.indexOf(shape) > 0) {
|
|
items.push({
|
|
label: translate('Make main shape'),
|
|
action: () => {
|
|
this.ui.enableEdit().deleteShape(shape)
|
|
this.ui.editor.prependShape(shape)
|
|
},
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
getContextMenuEditItems(event) {
|
|
const items = super.getContextMenuEditItems(event)
|
|
if (
|
|
this._umap?.editedFeature !== this &&
|
|
this.isSameClass(this._umap.editedFeature)
|
|
) {
|
|
items.push({
|
|
label: translate('Transfer shape to edited feature'),
|
|
action: () => {
|
|
this.transferShape(event.latlng, this._umap.editedFeature)
|
|
},
|
|
})
|
|
}
|
|
if (this.isMulti()) {
|
|
items.push({
|
|
label: translate('Extract shape to separate feature'),
|
|
action: () => {
|
|
this.ui.isolateShape(event.latlng)
|
|
},
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
}
|
|
|
|
export class LineString extends Path {
|
|
constructor(umap, datalayer, geojson, id) {
|
|
super(umap, datalayer, geojson, id)
|
|
this.staticOptions = {
|
|
stroke: true,
|
|
fill: false,
|
|
mainColor: 'color',
|
|
className: 'polyline',
|
|
}
|
|
}
|
|
|
|
toLatLngs(geometry) {
|
|
return GeoJSON.coordsToLatLngs(this.coordinates, this.type === 'LineString' ? 0 : 1)
|
|
}
|
|
|
|
convertLatLngs(latlngs) {
|
|
let multi = !LineUtil.isFlat(latlngs)
|
|
let coordinates = GeoJSON.latLngsToCoords(latlngs, multi ? 1 : 0, false)
|
|
if (coordinates.length === 1 && typeof coordinates[0][0] !== 'number') {
|
|
coordinates = Utils.flattenCoordinates(coordinates)
|
|
multi = false
|
|
}
|
|
const type = multi ? 'MultiLineString' : 'LineString'
|
|
return { coordinates, type }
|
|
}
|
|
|
|
getUIClass() {
|
|
return super.getUIClass() || LeafletPolyline
|
|
}
|
|
|
|
isSameClass(other) {
|
|
return other instanceof LineString
|
|
}
|
|
|
|
toPolygon() {
|
|
const geojson = this.toGeoJSON()
|
|
geojson.geometry.type = 'Polygon'
|
|
geojson.geometry.coordinates = [
|
|
Utils.flattenCoordinates(geojson.geometry.coordinates),
|
|
]
|
|
|
|
delete geojson.id // delete the copied id, a new one will be generated.
|
|
|
|
const polygon = this.datalayer.makeFeature(geojson)
|
|
polygon.edit()
|
|
this.del()
|
|
}
|
|
|
|
getAdvancedEditActions(container) {
|
|
super.getAdvancedEditActions(container)
|
|
DomUtil.createButton(
|
|
'button umap-to-polygon',
|
|
container,
|
|
translate('Transform to polygon'),
|
|
this.toPolygon,
|
|
this
|
|
)
|
|
}
|
|
|
|
_mergeShapes(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]
|
|
const b = toMerge[1]
|
|
const p1 = this._umap._leafletMap.latLngToContainerPoint(a[a.length - 1])
|
|
const p2 = this._umap._leafletMap.latLngToContainerPoint(b[0])
|
|
const 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() {
|
|
if (!this.isMulti()) return
|
|
const latlngs = this.ui.getLatLngs()
|
|
if (!latlngs.length) return
|
|
while (latlngs.length > 1) {
|
|
latlngs.splice(0, 2, this._mergeShapes(latlngs[1], latlngs[0]))
|
|
}
|
|
this.ui.setLatLngs(latlngs[0])
|
|
this.pullGeometry()
|
|
if (!this.ui.editEnabled()) this.edit()
|
|
this.ui.editor.reset()
|
|
this.isDirty = true
|
|
}
|
|
|
|
isMulti() {
|
|
return !LineUtil.isFlat(this.coordinates) && this.coordinates.length > 1
|
|
}
|
|
|
|
getContextMenuEditItems(event) {
|
|
const items = super.getContextMenuEditItems(event)
|
|
const vertexClicked = event.vertex
|
|
if (!this.isMulti()) {
|
|
items.push({
|
|
label: translate('Transform to polygon'),
|
|
action: () => this.toPolygon(),
|
|
})
|
|
}
|
|
if (vertexClicked) {
|
|
const index = event.vertex.getIndex()
|
|
if (index !== 0 && index !== event.vertex.getLastIndex()) {
|
|
items.push({
|
|
label: translate('Split line'),
|
|
action: () => event.vertex.split(),
|
|
})
|
|
} else if (index === 0 || index === event.vertex.getLastIndex()) {
|
|
items.push({
|
|
label: this._umap.help.displayLabel('CONTINUE_LINE'),
|
|
action: () => event.vertex.continue(),
|
|
})
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
|
|
getContextMenuMultiItems(event) {
|
|
const items = super.getContextMenuMultiItems(event)
|
|
items.push({
|
|
label: translate('Merge lines'),
|
|
action: () => this.mergeShapes(),
|
|
})
|
|
return items
|
|
}
|
|
|
|
extendedProperties() {
|
|
const [gain, loss] = this.ui.getElevation()
|
|
return Object.assign({ gain, loss }, super.extendedProperties())
|
|
}
|
|
|
|
getInplaceEditVertexMenu(event) {
|
|
const items = super.getInplaceEditVertexMenu(event)
|
|
const index = event.vertex.getIndex()
|
|
if (index === 0 || index === event.vertex.getLastIndex()) {
|
|
items.push({
|
|
action: () => event.vertex.continue(),
|
|
title: translate('Continue line'),
|
|
icon: 'icon-continue-line',
|
|
})
|
|
} else {
|
|
items.push({
|
|
action: () => event.vertex.split(),
|
|
title: translate('Split line'),
|
|
icon: 'icon-split-line',
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
}
|
|
|
|
export class Polygon extends Path {
|
|
constructor(umap, datalayer, geojson, id) {
|
|
super(umap, datalayer, geojson, id)
|
|
this.staticOptions = {
|
|
mainColor: 'fillColor',
|
|
className: 'polygon',
|
|
}
|
|
}
|
|
|
|
toLatLngs() {
|
|
return GeoJSON.coordsToLatLngs(this.coordinates, this.type === 'Polygon' ? 1 : 2)
|
|
}
|
|
|
|
convertLatLngs(latlngs) {
|
|
const holes = !LineUtil.isFlat(latlngs)
|
|
let multi = holes && !LineUtil.isFlat(latlngs[0])
|
|
let coordinates = GeoJSON.latLngsToCoords(latlngs, multi ? 2 : holes ? 1 : 0, true)
|
|
if (Utils.polygonMustBeFlattened(coordinates)) {
|
|
coordinates = coordinates[0]
|
|
multi = false
|
|
}
|
|
const type = multi ? 'MultiPolygon' : 'Polygon'
|
|
return { coordinates, type }
|
|
}
|
|
|
|
isEmpty() {
|
|
return !this.coordinates.length || !this.coordinates[0].length
|
|
}
|
|
|
|
getUIClass() {
|
|
if (this.getOption('mask')) return MaskPolygon
|
|
return super.getUIClass() || LeafletPolygon
|
|
}
|
|
|
|
isSameClass(other) {
|
|
return other instanceof Polygon
|
|
}
|
|
|
|
getShapeOptions() {
|
|
const options = super.getShapeOptions()
|
|
options.push(
|
|
'properties._umap_options.stroke',
|
|
'properties._umap_options.fill',
|
|
'properties._umap_options.fillColor',
|
|
'properties._umap_options.fillOpacity'
|
|
)
|
|
return options
|
|
}
|
|
|
|
getPreviewColor() {
|
|
// If user set a fillColor, use it, otherwise default to color
|
|
// which is usually the only one set
|
|
const color = this.getDynamicOption(this.staticOptions.mainColor)
|
|
if (color && color !== SCHEMA.color.default) return color
|
|
return this.getDynamicOption('color')
|
|
}
|
|
|
|
getInteractionOptions() {
|
|
const options = super.getInteractionOptions()
|
|
options.push('properties._umap_options.interactive')
|
|
return options
|
|
}
|
|
|
|
toLineString() {
|
|
const geojson = this.toGeoJSON()
|
|
delete geojson.id
|
|
delete geojson.properties.id
|
|
geojson.geometry.type = 'LineString'
|
|
geojson.geometry.coordinates = Utils.flattenCoordinates(
|
|
geojson.geometry.coordinates
|
|
)
|
|
const polyline = this.datalayer.makeFeature(geojson)
|
|
polyline.edit()
|
|
this.del()
|
|
}
|
|
|
|
getAdvancedOptions() {
|
|
const actions = super.getAdvancedOptions()
|
|
actions.push('properties._umap_options.mask')
|
|
return actions
|
|
}
|
|
|
|
getAdvancedEditActions(container) {
|
|
super.getAdvancedEditActions(container)
|
|
const toLineString = DomUtil.createButton(
|
|
'button umap-to-polyline',
|
|
container,
|
|
translate('Transform to lines'),
|
|
this.toLineString,
|
|
this
|
|
)
|
|
}
|
|
|
|
isMulti() {
|
|
// Change me when Leaflet#3279 is merged.
|
|
// FIXME use TurfJS
|
|
return (
|
|
!LineUtil.isFlat(this.coordinates) &&
|
|
!LineUtil.isFlat(this.coordinates[0]) &&
|
|
this.coordinates.length > 1
|
|
)
|
|
}
|
|
|
|
getInplaceEditMenu(event) {
|
|
const items = super.getInplaceEditMenu()
|
|
items.push({
|
|
action: () => this.ui.startHole(event),
|
|
title: translate('Start a hole here'),
|
|
icon: 'icon-hole',
|
|
})
|
|
return items
|
|
}
|
|
|
|
getContextMenuEditItems(event) {
|
|
const items = super.getContextMenuEditItems(event)
|
|
const shape = this.ui.shapeAt(event.latlng)
|
|
// No multi and no holes.
|
|
if (shape && !this.isMulti() && (LineUtil.isFlat(shape) || shape.length === 1)) {
|
|
items.push({
|
|
label: translate('Transform to lines'),
|
|
action: () => this.toLineString(),
|
|
})
|
|
}
|
|
items.push({
|
|
label: translate('Start a hole here'),
|
|
action: () => this.ui.startHole(event),
|
|
})
|
|
return items
|
|
}
|
|
}
|