Merge pull request #2019 from umap-project/features-to-modules

wip: first step in moving features to modules (work in progress)
This commit is contained in:
Yohan Boniface 2024-07-29 16:35:42 +02:00 committed by GitHub
commit daa6e37073
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1850 additions and 1525 deletions

View file

@ -0,0 +1,997 @@
import {
DomUtil,
DomEvent,
stamp,
GeoJSON,
LineUtil,
} from '../../../vendors/leaflet/leaflet-src.esm.js'
import * as Utils from '../utils.js'
import { SCHEMA } from '../schema.js'
import { translate } from '../i18n.js'
import { uMapAlert as Alert } from '../../components/alerts/alert.js'
import { LeafletMarker, LeafletPolyline, LeafletPolygon } from '../rendering/ui.js'
class Feature {
constructor(datalayer, geojson = {}, id = null) {
this.sync = datalayer.map.sync_engine.proxy(this)
this._marked_for_deletion = false
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._ui = this.makeUI()
return this._ui
}
get map() {
return this.datalayer?.map
}
get center() {
return this.ui.getCenter()
}
get bounds() {
return this.ui.getBounds()
}
get geometry() {
return this._geometry
}
set geometry(value) {
this._geometry = value
this.geometryChanged()
}
getClassName() {
return this.staticOptions.className
}
getPreviewColor() {
return this.getDynamicOption(this.staticOptions.mainColor)
}
getSyncMetadata() {
return {
subject: 'feature',
metadata: {
id: this.id,
layerId: this.datalayer?.umap_id || null,
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.
if (this.datalayer?.isRemoteLayer()) return
// The "endEdit" event is triggered at the end of an edition,
// and will trigger the sync.
// In the case of a deletion (or a change of layer), we don't want this
// event triggered to cause a sync event, as it would reintroduce
// deleted features.
// The `._marked_for_deletion` private property is here to track this status.
if (this._marked_for_deletion === true) {
this._marked_for_deletion = false
return
}
this.sync.upsert(this.toGeoJSON())
}
isReadOnly() {
return this.datalayer?.isDataReadOnly()
}
getSlug() {
return this.properties[this.map.getOption('slugKey') || 'name'] || ''
}
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.map.slideshow) {
this.map.slideshow.current = this
}
this.map.currentFeature = this
this.attachPopup()
this.ui.openPopup(latlng || this.center)
}
render(fields) {
const impactData = fields.some((field) => {
return field.startsWith('properties.')
})
if (impactData) {
if (this.map.currentFeature === this) {
this.view()
}
}
this.redraw()
}
edit(event) {
if (!this.map.editEnabled || this.isReadOnly()) return
const container = DomUtil.create('div', 'umap-feature-container')
DomUtil.createTitle(
container,
translate('Feature properties'),
`icon-${this.getClassName()}`
)
let builder = new U.FormBuilder(
this,
[['datalayer', { handler: 'DataLayerSwitcher' }]],
{
callback() {
this.edit(event)
}, // removeLayer step will close the edit panel, let's reopen it
}
)
container.appendChild(builder.build())
const properties = []
for (const property of this.datalayer._propertiesIndex) {
if (['name', 'description'].includes(property)) {
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 = DomUtil.createFieldset(
container,
translate('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(event)
}
getAdvancedEditActions(container) {
DomUtil.createButton('button umap-delete', container, translate('Delete'), () => {
this.confirmDelete().then(() => this.map.editPanel.close())
})
}
appendEditFieldsets(container) {
const optionsFields = this.getShapeOptions()
let builder = new U.FormBuilder(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 U.FormBuilder(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 U.FormBuilder(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(fallback) {
if (fallback === undefined) fallback = this.datalayer.getName()
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() {
if (this.datalayer.isRemoteLayer() && this.datalayer.options.remoteData.dynamic) {
return false
}
return this.map.getOption('displayPopupFooter')
}
getPopupClass() {
const old = this.getOption('popupTemplate') // Retrocompat.
return U.Popup[this.getOption('popupShape') || old] || U.Popup
}
attachPopup() {
const Class = this.getPopupClass()
this.ui.bindPopup(new Class(this))
}
async confirmDelete() {
const confirmed = await this.map.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.map.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 (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(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({ 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.center, this.getBestZoom())
} else {
latlng = latlng || this.center
this.map.setView(latlng, this.getBestZoom() || this.map.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,
})
}
getInplaceToolbarActions() {
return [U.ToggleEditAction, U.DeleteFeatureAction]
}
getMap() {
return this.map
}
isFiltered() {
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(filter, keys) {
filter = filter.toLowerCase()
if (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() {
const selected = this.map.facets.selected
for (const [name, { type, min, max, choices }] of Object.entries(selected)) {
let value = this.properties[name]
const parser = this.map.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
}
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.map.getGeoContext()
const locale = L.getLocale()
if (locale) properties.locale = locale
if (L.lang) properties.lang = L.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.getMeasure !== 'undefined') {
properties.measure = this.getMeasure()
}
}
return L.extend(properties, this.properties)
}
getRank() {
return this.datalayer._index.indexOf(L.stamp(this))
}
redraw() {
if (this.datalayer?.isVisible()) {
this.ui._redraw()
}
}
}
export class Point extends Feature {
constructor(datalayer, geojson, id) {
super(datalayer, geojson, id)
this.staticOptions = {
mainColor: 'color',
className: 'marker',
}
}
get coordinates() {
return GeoJSON.coordsToLatLng(this.geometry.coordinates)
}
set coordinates(latlng) {
this.geometry.coordinates = GeoJSON.latLngToCoords(latlng)
}
geometryChanged() {
this.ui.setLatLng(this.coordinates)
}
makeUI() {
return new LeafletMarker(this)
}
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 U.FormBuilder(this, coordinatesOptions, {
callback: () => {
if (!this.ui._latlng.isValid()) {
Alert.error(translate('Invalid latitude or longitude'))
builder.restoreField('ui._latlng.lat')
builder.restoreField('ui._latlng.lng')
}
this.zoomTo({ easing: false })
},
})
const fieldset = DomUtil.createFieldset(container, translate('Coordinates'))
fieldset.appendChild(builder.build())
}
zoomTo(event) {
if (this.datalayer.isClustered() && !this._icon) {
// callback is mandatory for zoomToShowLayer
this.datalayer.layer.zoomToShowLayer(this, event.callback || (() => {}))
} else {
super.zoomTo(event)
}
}
isOnScreen(bounds) {
bounds = bounds || this.map.getBounds()
return bounds.contains(this.coordinates)
}
}
class Path extends Feature {
hasGeom() {
return !this.isEmpty()
}
get coordinates() {
return this._toLatlngs(this.geometry)
}
set coordinates(latlngs) {
const { coordinates, type } = this._toGeometry(latlngs)
this.geometry.coordinates = coordinates
this.geometry.type = type
}
geometryChanged() {
this.ui.setLatLngs(this.coordinates)
}
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.map.editEnabled) {
if (!this.ui.editEnabled()) this.ui.enableEdit()
super.edit(event)
}
}
_toggleEditing(event) {
if (this.map.editEnabled) {
if (this.ui.editEnabled()) {
this.endEdit()
this.map.editPanel.close()
} else {
this.edit(event)
}
}
// FIXME: disable when disabling global edit
L.DomEvent.stop(event)
}
getStyleOptions() {
return [
'smoothFactor',
'color',
'opacity',
'stroke',
'weight',
'fill',
'fillColor',
'fillOpacity',
'dashArray',
'interactive',
]
}
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',
]
}
getStyle() {
const options = {}
for (const option of this.getStyleOptions()) {
options[option] = this.getDynamicOption(option)
}
if (options.interactive) options.pointerEvents = 'visiblePainted'
else options.pointerEvents = 'stroke'
return options
}
getBestZoom() {
return this.getOption('zoomTo') || this.map.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.ui.geometryChanged()
this.ui.disableEdit()
if (!shape) return
to.ui.enableEdit().appendShape(shape)
to.ui.geometryChanged()
if (this.isEmpty()) this.del()
}
isolateShape(latlngs) {
const properties = this.cloneProperties()
const type = this instanceof LineString ? 'LineString' : 'Polygon'
const geometry = this._toGeometry(latlngs)
const other = this.datalayer.makeFeature({ type, geometry, properties })
other.edit()
return other
}
getInplaceToolbarActions(event) {
const items = super.getInplaceToolbarActions(event)
if (this.isMulti()) {
items.push(U.DeleteShapeAction)
items.push(U.ExtractShapeFromMultiAction)
}
return items
}
isOnScreen(bounds) {
bounds = bounds || this.map.getBounds()
return bounds.overlaps(this.bounds)
}
zoomTo({ easing, callback }) {
// Use bounds instead of centroid for paths.
easing = easing || this.map.getOption('easing')
if (easing) {
this.map.flyToBounds(this.bounds, this.getBestZoom())
} else {
this.map.fitBounds(this.bounds, this.getBestZoom() || this.map.getZoom())
}
if (callback) callback.call(this)
}
}
export class LineString extends Path {
constructor(datalayer, geojson, id) {
super(datalayer, geojson, id)
this.staticOptions = {
stroke: true,
fill: false,
mainColor: 'color',
className: 'polyline',
}
}
_toLatlngs(geometry) {
return GeoJSON.coordsToLatLngs(
geometry.coordinates,
geometry.type === 'LineString' ? 0 : 1
)
}
_toGeometry(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 }
}
isEmpty() {
return !this.coordinates.length
}
makeUI() {
return new LeafletPolyline(this)
}
isSameClass(other) {
return other instanceof LineString
}
getMeasure(shape) {
const length = L.GeoUtil.lineLength(this.map, shape || this.ui._defaultShape())
return L.GeoUtil.readableDistance(length, this.map.measureTools.getMeasureUnit())
}
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.map.latLngToContainerPoint(a[a.length - 1])
const p2 = this.map.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.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() {
return !LineUtil.isFlat(this.coordinates) && this.coordinates.length > 1
}
}
export class Polygon extends Path {
constructor(datalayer, geojson, id) {
super(datalayer, geojson, id)
this.staticOptions = {
mainColor: 'fillColor',
className: 'polygon',
}
}
_toLatlngs(geometry) {
return GeoJSON.coordsToLatLngs(
geometry.coordinates,
geometry.type === 'Polygon' ? 1 : 2
)
}
_toGeometry(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
}
makeUI() {
return new LeafletPolygon(this)
}
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
}
getMeasure(shape) {
const area = L.GeoUtil.geodesicArea(shape || this.ui._defaultShape())
return L.GeoUtil.readableArea(area, this.map.measureTools.getMeasureUnit())
}
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()
}
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
)
}
getInplaceToolbarActions(event) {
const items = super.getInplaceToolbarActions(event)
items.push(U.CreateHoleAction)
return items
}
}

View file

@ -1,4 +1,3 @@
// Uses U.Marker, U.Polygon, U.Polyline, U.TableEditor not yet ES modules
// Uses U.FormBuilder not available as ESM
// FIXME: this module should not depend on Leaflet
@ -19,6 +18,7 @@ import {
} from '../../components/alerts/alert.js'
import { translate } from '../i18n.js'
import { DataLayerPermissions } from '../permissions.js'
import { Point, LineString, Polygon } from './features.js'
export const LAYER_TYPES = [DefaultLayer, Cluster, Heat, Choropleth, Categorized]
@ -32,7 +32,7 @@ export class DataLayer {
this.map = map
this.sync = map.sync_engine.proxy(this)
this._index = Array()
this._layers = {}
this._features = {}
this._geojson = null
this._propertiesIndex = []
this._loaded = false // Are layer metadata loaded
@ -41,6 +41,7 @@ export class DataLayer {
this.parentPane = this.map.getPane('overlayPane')
this.pane = this.map.createPane(`datalayer${stamp(this)}`, this.parentPane)
this.pane.dataset.id = stamp(this)
// FIXME: should be on layer
this.renderer = L.svg({ pane: this.pane })
this.defaultOptions = {
displayOnLoad: true,
@ -188,23 +189,14 @@ export class DataLayer {
if (visible) this.map.removeLayer(this.layer)
const Class = LAYER_MAP[this.options.type] || DefaultLayer
this.layer = new Class(this)
this.eachLayer(this.showFeature)
this.eachFeature(this.showFeature)
if (visible) this.show()
this.propagateRemote()
}
eachLayer(method, context) {
for (const i in this._layers) {
method.call(context || this, this._layers[i])
}
return this
}
eachFeature(method, context) {
if (this.isBrowsable()) {
for (let i = 0; i < this._index.length; i++) {
method.call(context || this, this._layers[this._index[i]])
}
for (const idx of this._index) {
method.call(context || this, this._features[idx])
}
return this
}
@ -254,7 +246,7 @@ export class DataLayer {
clear() {
this.layer.clearLayers()
this._layers = {}
this._features = {}
this._index = Array()
if (this._geojson) {
this.backupData()
@ -268,13 +260,9 @@ export class DataLayer {
}
reindex() {
const features = []
this.eachFeature((feature) => features.push(feature))
const features = Object.values(this._features)
Utils.sortFeatures(features, this.map.getOption('sortKey'), L.lang)
this._index = []
for (let i = 0; i < features.length; i++) {
this._index.push(stamp(features[i]))
}
this._index = features.map((feature) => stamp(feature))
}
showAtZoom() {
@ -371,28 +359,28 @@ export class DataLayer {
showFeature(feature) {
if (feature.isFiltered()) return
this.layer.addLayer(feature)
this.layer.addLayer(feature.ui)
}
addLayer(feature) {
addFeature(feature) {
const id = stamp(feature)
feature.connectToDataLayer(this)
this._index.push(id)
this._layers[id] = feature
this._features[id] = feature
this.indexProperties(feature)
this.map.features_index[feature.getSlug()] = feature
this.showFeature(feature)
if (this.hasDataLoaded()) this.dataChanged()
}
removeLayer(feature, sync) {
removeFeature(feature, sync) {
const id = stamp(feature)
if (sync !== false) feature.sync.delete()
this.layer.removeLayer(feature)
this.layer.removeLayer(feature.ui)
delete this.map.features_index[feature.getSlug()]
feature.disconnectFromDataLayer(this)
this._index.splice(this._index.indexOf(id), 1)
delete this._layers[id]
delete this.map.features_index[feature.getSlug()]
delete this._features[id]
if (this.hasDataLoaded() && this.isVisible()) this.dataChanged()
}
@ -416,7 +404,7 @@ export class DataLayer {
}
sortedValues(property) {
return Object.values(this._layers)
return Object.values(this._features)
.map((feature) => feature.properties[property])
.filter((val, idx, arr) => arr.indexOf(val) === idx)
.sort(Utils.naturalSort)
@ -426,135 +414,57 @@ export class DataLayer {
try {
// Do not fail if remote data is somehow invalid,
// otherwise the layer becomes uneditable.
this.geojsonToFeatures(geojson, sync)
this.makeFeatures(geojson, sync)
} catch (err) {
console.log('Error with DataLayer', this.umap_id)
console.error(err)
}
}
// The choice of the name is not ours, because it is required by Leaflet.
// It is misleading, as the returned objects are uMap objects, and not
// GeoJSON features.
geojsonToFeatures(geojson, sync) {
if (!geojson) return
const features = Array.isArray(geojson) ? geojson : geojson.features
let i
let len
if (features) {
Utils.sortFeatures(features, this.map.getOption('sortKey'), L.lang)
for (i = 0, len = features.length; i < len; i++) {
this.geojsonToFeatures(features[i])
makeFeatures(geojson = {}, sync = true) {
if (geojson.type === 'Feature' || geojson.coordinates) {
geojson = [geojson]
}
return this // Why returning "this" ?
}
const geometry = geojson.type === 'Feature' ? geojson.geometry : geojson
const feature = this.geoJSONToLeaflet({ geometry, geojson })
if (feature) {
this.addLayer(feature)
if (sync) feature.onCommit()
return feature
const collection = Array.isArray(geojson)
? geojson
: geojson.features || geojson.geometries
Utils.sortFeatures(collection, this.map.getOption('sortKey'), L.lang)
for (const feature of collection) {
this.makeFeature(feature, sync)
}
}
/**
* Create or update Leaflet features from GeoJSON geometries.
*
* If no `feature` is provided, a new feature will be created.
* If `feature` is provided, it will be updated with the passed geometry.
*
* GeoJSON and Leaflet use incompatible formats to encode coordinates.
* This method takes care of the convertion.
*
* @param geometry GeoJSON geometry field
* @param geojson Enclosing GeoJSON. If none is provided, a new one will
* be created
* @param id Id of the feature
* @param feature Leaflet feature that should be updated with the new geometry
* @returns Leaflet feature.
*/
geoJSONToLeaflet({ geometry, geojson = null, id = null, feature = null } = {}) {
if (!geometry) return // null geometry is valid geojson.
const coords = geometry.coordinates
let latlng
let latlngs
// Create a default geojson if none is provided
if (geojson === undefined) geojson = { type: 'Feature', geometry: geometry }
makeFeature(geojson = {}, sync = true, id = null) {
// Both Feature and Geometry are valid geojson objects.
const geometry = geojson.geometry || geojson
let feature
switch (geometry.type) {
case 'Point':
try {
latlng = GeoJSON.coordsToLatLng(coords)
} catch (e) {
console.error('Invalid latlng object from', coords)
// FIXME: deal with MultiPoint
feature = new Point(this, geojson, id)
break
}
if (feature) {
feature.setLatLng(latlng)
return feature
}
return this._pointToLayer(geojson, latlng, id)
case 'MultiLineString':
case 'LineString':
latlngs = GeoJSON.coordsToLatLngs(
coords,
geometry.type === 'LineString' ? 0 : 1
)
if (!latlngs.length) break
if (feature) {
feature.setLatLngs(latlngs)
return feature
}
return this._lineToLayer(geojson, latlngs, id)
feature = new LineString(this, geojson, id)
break
case 'MultiPolygon':
case 'Polygon':
latlngs = GeoJSON.coordsToLatLngs(coords, geometry.type === 'Polygon' ? 1 : 2)
if (feature) {
feature.setLatLngs(latlngs)
return feature
}
return this._polygonToLayer(geojson, latlngs, id)
case 'GeometryCollection':
return this.geojsonToFeatures(geometry.geometries)
feature = new Polygon(this, geojson, id)
break
default:
console.log(geojson)
Alert.error(
translate('Skipping unknown geometry.type: {type}', {
type: geometry.type || 'undefined',
})
)
}
if (feature) {
this.addFeature(feature)
if (sync) feature.onCommit()
return feature
}
_pointToLayer(geojson, latlng, id) {
return new U.Marker(this.map, latlng, { geojson: geojson, datalayer: this }, id)
}
_lineToLayer(geojson, latlngs, id) {
return new U.Polyline(
this.map,
latlngs,
{
geojson: geojson,
datalayer: this,
color: null,
},
id
)
}
_polygonToLayer(geojson, latlngs, id) {
// Ensure no empty hole
// for (let i = latlngs.length - 1; i > 0; i--) {
// if (!latlngs.slice()[i].length) latlngs.splice(i, 1);
// }
return new U.Polygon(this.map, latlngs, { geojson: geojson, datalayer: this }, id)
}
async importRaw(raw, format) {
@ -566,7 +476,7 @@ export class DataLayer {
}
importFromFiles(files, type) {
for (let i = 0, f; (f = files[i]); i++) {
for (const f of files) {
this.importFromFile(f, type)
}
}
@ -910,9 +820,9 @@ export class DataLayer {
getOption(option, feature) {
if (this.layer?.getOption) {
const value = this.layer.getOption(option, feature)
if (typeof value !== 'undefined') return value
if (value !== undefined) return value
}
if (typeof this.getOwnOption(option) !== 'undefined') {
if (this.getOwnOption(option) !== undefined) {
return this.getOwnOption(option)
}
if (this.layer?.defaults?.[option]) {
@ -966,7 +876,7 @@ export class DataLayer {
featuresToGeoJSON() {
const features = []
this.eachLayer((layer) => features.push(layer.toGeoJSON()))
this.eachFeature((feature) => features.push(feature.toGeoJSON()))
return features
}
@ -1031,19 +941,21 @@ export class DataLayer {
getFeatureByIndex(index) {
if (index === -1) index = this._index.length - 1
const id = this._index[index]
return this._layers[id]
return this._features[id]
}
// TODO Add an index
// For now, iterate on all the features.
getFeatureById(id) {
return Object.values(this._layers).find((feature) => feature.id === id)
return Object.values(this._features).find((feature) => feature.id === id)
}
getNextFeature(feature) {
const id = this._index.indexOf(stamp(feature))
const nextId = this._index[id + 1]
return nextId ? this._layers[nextId] : this.getNextBrowsable().getFeatureByIndex(0)
return nextId
? this._features[nextId]
: this.getNextBrowsable().getFeatureByIndex(0)
}
getPreviousFeature(feature) {
@ -1053,7 +965,7 @@ export class DataLayer {
const id = this._index.indexOf(stamp(feature))
const previousId = this._index[id - 1]
return previousId
? this._layers[previousId]
? this._features[previousId]
: this.getPreviousBrowsable().getFeatureByIndex(-1)
}
@ -1187,7 +1099,7 @@ export class DataLayer {
// By default, it will we use the "name" property, which is also the one used as label in the features list.
// When map owner has configured another label or sort key, we try to be smart and search in the same keys.
if (this.map.options.filterKey) return this.map.options.filterKey
if (this.options.labelKey) return this.options.labelKey
if (this.getOption('labelKey')) return this.getOption('labelKey')
if (this.map.options.sortKey) return this.map.options.sortKey
return 'name'
}

View file

@ -22,7 +22,7 @@ export const EXPORT_FORMATS = {
const table = []
map.eachFeature((feature) => {
const row = feature.toGeoJSON().properties
const center = feature.getCenter()
const center = feature.center
delete row._umap_options
row.Latitude = center.lat
row.Longitude = center.lng

View file

@ -28,6 +28,8 @@ import URLs from './urls.js'
import * as Utils from './utils.js'
import { DataLayer, LAYER_TYPES } from './data/layer.js'
import { DataLayerPermissions, MapPermissions } from './permissions.js'
import { Point, LineString, Polygon } from './data/features.js'
import { LeafletMarker, LeafletPolyline, LeafletPolygon } from './rendering/ui.js'
// Import modules and export them to the global scope.
// For the not yet module-compatible JS out there.
@ -52,10 +54,16 @@ window.U = {
HTTPError,
Importer,
LAYER_TYPES,
LeafletMarker,
LeafletPolygon,
LeafletPolyline,
LineString,
MapPermissions,
NOKError,
Orderable,
Panel,
Point,
Polygon,
Request,
RequestError,
Rules,

View file

@ -4,6 +4,7 @@
import { translate } from '../../i18n.js'
import { LayerMixin } from './base.js'
import * as Utils from '../../utils.js'
import { Evented } from '../../../../vendors/leaflet/leaflet-src.esm.js'
const MarkerCluster = L.MarkerCluster.extend({
// Custom class so we can call computeTextColor
@ -49,7 +50,6 @@ export const Cluster = L.MarkerClusterGroup.extend({
return L.MarkerClusterGroup.prototype.onAdd.call(this, map)
},
onRemove: function (map) {
// In some situation, the onRemove is called before the layer is really
// added to the map: basically when combining a defaultView=data + max/minZoom

View file

@ -1,5 +1,11 @@
// Uses global L.HeatLayer, not exposed as ESM
import { Marker, LatLng, latLngBounds, Bounds, point } from '../../../../vendors/leaflet/leaflet-src.esm.js'
import {
Marker,
LatLng,
latLngBounds,
Bounds,
point,
} from '../../../../vendors/leaflet/leaflet-src.esm.js'
import { LayerMixin } from './base.js'
import * as Utils from '../../utils.js'
import { translate } from '../../i18n.js'
@ -27,7 +33,7 @@ export const Heat = L.HeatLayer.extend({
let alt
if (this.datalayer.options.heat?.intensityProperty) {
alt = Number.parseFloat(
layer.properties[this.datalayer.options.heat.intensityProperty || 0]
layer.feature.properties[this.datalayer.options.heat.intensityProperty || 0]
)
latlng = new LatLng(latlng.lat, latlng.lng, alt)
}

View file

@ -56,8 +56,8 @@ const RelativeColorLayerMixin = {
getValues: function () {
const values = []
this.datalayer.eachLayer((layer) => {
const value = this._getValue(layer)
this.datalayer.eachFeature((feature) => {
const value = this._getValue(feature)
if (value !== undefined) values.push(value)
})
return values

View file

@ -0,0 +1,484 @@
// Goes here all code related to Leaflet, DOM and user interactions.
import {
Marker,
Polyline,
Polygon,
DomUtil,
LineUtil,
} from '../../../vendors/leaflet/leaflet-src.esm.js'
import { translate } from '../i18n.js'
import { uMapAlert as Alert } from '../../components/alerts/alert.js'
import * as Utils from '../utils.js'
const FeatureMixin = {
initialize: function (feature) {
this.feature = feature
this.parentClass.prototype.initialize.call(this, this.feature.coordinates)
},
onAdd: function (map) {
this.addInteractions()
return this.parentClass.prototype.onAdd.call(this, map)
},
onRemove: function (map) {
this.parentClass.prototype.onRemove.call(this, map)
if (map.editedFeature === this.feature) {
this.feature._marked_for_deletion = true
this.feature.endEdit()
map.editPanel.close()
}
},
addInteractions: function () {
this.on('contextmenu editable:vertex:contextmenu', this._showContextMenu)
this.on('click', this.onClick)
},
onClick: function (event) {
if (this._map.measureTools?.enabled()) return
this._popupHandlersAdded = true // Prevent leaflet from managing event
if (!this._map.editEnabled) {
this.feature.view(event)
} else if (!this.feature.isReadOnly()) {
if (event.originalEvent.shiftKey) {
if (event.originalEvent.ctrlKey || event.originalEvent.metaKey) {
this.feature.datalayer.edit(event)
} else {
if (this.feature._toggleEditing) this.feature._toggleEditing(event)
else this.feature.edit(event)
}
} else if (!this._map.editTools?.drawing()) {
new L.Toolbar.Popup(event.latlng, {
className: 'leaflet-inplace-toolbar',
anchor: this.getPopupToolbarAnchor(),
actions: this.feature.getInplaceToolbarActions(event),
}).addTo(this._map, this.feature, event.latlng)
}
}
L.DomEvent.stop(event)
},
resetTooltip: function () {
if (!this.feature.hasGeom()) return
const displayName = this.feature.getDisplayName(null)
let showLabel = this.feature.getOption('showLabel')
const oldLabelHover = this.feature.getOption('labelHover')
const options = {
direction: this.feature.getOption('labelDirection'),
interactive: this.feature.getOption('labelInteractive'),
}
if (oldLabelHover && showLabel) showLabel = null // Retrocompat.
options.permanent = showLabel === true
this.unbindTooltip()
if ((showLabel === true || showLabel === null) && displayName) {
this.bindTooltip(Utils.escapeHTML(displayName), options)
}
},
_showContextMenu: function (event) {
L.DomEvent.stop(event)
const pt = this._map.mouseEventToContainerPoint(event.originalEvent)
event.relatedTarget = this
this._map.contextmenu.showAt(pt, event)
},
getContextMenuItems: function (event) {
const permalink = this.feature.getPermalink()
let items = []
if (permalink)
items.push({
text: translate('Permalink'),
callback: () => {
window.open(permalink)
},
})
if (this._map.editEnabled && !this.feature.isReadOnly()) {
items = items.concat(this.getContextMenuEditItems(event))
}
return items
},
getContextMenuEditItems: function () {
let items = ['-']
if (this._map.editedFeature !== this) {
items.push({
text: `${translate('Edit this feature')} (⇧+Click)`,
callback: this.feature.edit,
context: this.feature,
iconCls: 'umap-edit',
})
}
items = items.concat(
{
text: this._map.help.displayLabel('EDIT_FEATURE_LAYER'),
callback: this.feature.datalayer.edit,
context: this.feature.datalayer,
iconCls: 'umap-edit',
},
{
text: translate('Delete this feature'),
callback: this.feature.confirmDelete,
context: this.feature,
iconCls: 'umap-delete',
},
{
text: translate('Clone this feature'),
callback: this.feature.clone,
context: this.feature,
}
)
return items
},
onCommit: function () {
this.geometryChanged(false)
this.feature.onCommit()
},
getPopupToolbarAnchor: () => [0, 0],
}
export const LeafletMarker = Marker.extend({
parentClass: Marker,
includes: [FeatureMixin],
initialize: function (feature) {
FeatureMixin.initialize.call(this, feature)
this.setIcon(this.getIcon())
},
geometryChanged: function (sync = true) {
this.feature.coordinates = this._latlng
if (sync) {
this.feature.sync.update('geometry', this.feature.geometry)
}
},
addInteractions() {
FeatureMixin.addInteractions.call(this)
this.on('dragend', (event) => {
this.isDirty = true
this.feature.edit(event)
this.geometryChanged()
})
this.on('editable:drawing:commit', this.onCommit)
if (!this.feature.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)
},
_onMouseOut: function () {
if (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?.drawing) return // when creating a new marker, the mouse can trigger the mouseover/mouseout event
// do not listen to them
this.disableEdit()
}
},
_initIcon: function () {
this.options.icon = this.getIcon()
Marker.prototype._initIcon.call(this)
// Allow to run code when icon is actually part of the DOM
this.options.icon.onAdd()
this.resetTooltip()
},
getIconClass: function () {
return this.feature.getOption('iconClass')
},
getIcon: function () {
const Class = U.Icon[this.getIconClass()] || U.Icon.Default
return new Class({ feature: this.feature })
},
_getTooltipAnchor: function () {
const anchor = this.options.icon.options.tooltipAnchor.clone()
const direction = this.feature.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
},
_redraw: function () {
// May no be on the map when in a cluster.
if (this._map) {
this._initIcon()
this.update()
}
},
getCenter: function () {
return this._latlng
},
highlight: function () {
DomUtil.addClass(this.options.icon.elements.main, 'umap-icon-active')
},
resetHighlight: function () {
DomUtil.removeClass(this.options.icon.elements.main, 'umap-icon-active')
},
getPopupToolbarAnchor: function () {
return this.options.icon.options.popupAnchor
},
})
const PathMixin = {
_onMouseOver: function () {
if (this._map.measureTools?.enabled()) {
this._map.tooltip.open({ content: this.feature.getMeasure(), anchor: this })
} else if (this._map.editEnabled && !this._map.editedFeature) {
this._map.tooltip.open({ content: translate('Click to edit'), anchor: this })
}
},
geometryChanged: function () {
this.feature.coordinates = this._latlngs
},
addInteractions: function () {
FeatureMixin.addInteractions.call(this)
this.on('editable:disable', this.onCommit)
this.on('mouseover', this._onMouseOver)
this.on('drag editable:drag', this._onDrag)
this.on('popupopen', this.highlightPath)
this.on('popupclose', this._redraw)
},
highlightPath: function () {
this.parentClass.prototype.setStyle.call(this, {
fillOpacity: Math.sqrt(this.feature.getDynamicOption('fillOpacity', 1.0)),
opacity: 1.0,
weight: 1.3 * this.feature.getDynamicOption('weight'),
})
},
_onDrag: function () {
if (this._tooltip) this._tooltip.setLatLng(this.getCenter())
},
onAdd: function (map) {
this._container = null
this.setStyle()
FeatureMixin.onAdd.call(this, map)
if (this.editing?.enabled()) this.editing.addHooks()
this.resetTooltip()
this._path.dataset.feature = this.feature.id
},
onRemove: function (map) {
if (this.editing?.enabled()) this.editing.removeHooks()
FeatureMixin.onRemove.call(this, map)
},
setStyle: function (options = {}) {
for (const option of this.feature.getStyleOptions()) {
options[option] = this.feature.getDynamicOption(option)
}
options.pointerEvents = options.interactive ? 'visiblePainted' : 'stroke'
this.parentClass.prototype.setStyle.call(this, options)
},
_redraw: function () {
this.setStyle()
this.resetTooltip()
},
getVertexActions: () => [U.DeleteVertexAction],
onVertexRawClick: function (event) {
new L.Toolbar.Popup(event.latlng, {
className: 'leaflet-inplace-toolbar',
actions: this.getVertexActions(event),
}).addTo(this._map, this, event.latlng, event.vertex)
},
getContextMenuItems: function (event) {
let items = FeatureMixin.getContextMenuItems.call(this, event)
items.push({
text: translate('Display measure'),
callback: () => Alert.info(this.feature.getMeasure()),
})
if (this._map.editEnabled && !this.feature.isReadOnly() && this.feature.isMulti()) {
items = items.concat(this.getContextMenuMultiItems(event))
}
return items
},
getContextMenuMultiItems: function (event) {
const items = [
'-',
{
text: translate('Remove shape from the multi'),
callback: () => {
this.enableEdit().deleteShapeAt(event.latlng)
},
},
]
const shape = this.shapeAt(event.latlng)
if (this._latlngs.indexOf(shape) > 0) {
items.push({
text: translate('Make main shape'),
callback: () => {
this.enableEdit().deleteShape(shape)
this.editor.prependShape(shape)
},
})
}
return items
},
getContextMenuEditItems: function (event) {
const items = FeatureMixin.getContextMenuEditItems.call(this, event)
if (
this._map?.editedFeature !== this &&
this.feature.isSameClass(this._map.editedFeature)
) {
items.push({
text: translate('Transfer shape to edited feature'),
callback: () => {
this.feature.transferShape(event.latlng, this._map.editedFeature)
},
})
}
if (this.feature.isMulti()) {
items.push({
text: translate('Extract shape to separate feature'),
callback: () => {
this.isolateShape(event.latlng)
},
})
}
return items
},
isolateShape: function(atLatLng) {
if (!this.feature.isMulti()) return
const shape = this.enableEdit().deleteShapeAt(atLatLng)
this.geometryChanged()
this.disableEdit()
if (!shape) return
return this.feature.isolateShape(shape)
}
}
export const LeafletPolyline = Polyline.extend({
parentClass: Polyline,
includes: [FeatureMixin, PathMixin],
getVertexActions: function (event) {
const actions = PathMixin.getVertexActions.call(this, event)
const index = event.vertex.getIndex()
if (index === 0 || index === event.vertex.getLastIndex()) {
actions.push(U.ContinueLineAction)
} else {
actions.push(U.SplitLineAction)
}
return actions
},
getContextMenuEditItems: function (event) {
const items = PathMixin.getContextMenuEditItems.call(this, event)
const vertexClicked = event.vertex
let index
if (!this.feature.isMulti()) {
items.push({
text: translate('Transform to polygon'),
callback: this.feature.toPolygon,
context: this.feature,
})
}
if (vertexClicked) {
index = event.vertex.getIndex()
if (index !== 0 && index !== event.vertex.getLastIndex()) {
items.push({
text: translate('Split line'),
callback: event.vertex.split,
context: event.vertex,
})
} else if (index === 0 || index === event.vertex.getLastIndex()) {
items.push({
text: this._map.help.displayLabel('CONTINUE_LINE'),
callback: event.vertex.continue,
context: event.vertex.continue,
})
}
}
return items
},
getContextMenuMultiItems: function (event) {
const items = PathMixin.getContextMenuMultiItems.call(this, event)
items.push({
text: translate('Merge lines'),
callback: this.feature.mergeShapes,
context: this.feature,
})
return items
},
})
export const LeafletPolygon = Polygon.extend({
parentClass: Polygon,
includes: [FeatureMixin, PathMixin],
getContextMenuEditItems: function (event) {
const items = PathMixin.getContextMenuEditItems.call(this, event)
const shape = this.shapeAt(event.latlng)
// No multi and no holes.
if (
shape &&
!this.feature.isMulti() &&
(LineUtil.isFlat(shape) || shape.length === 1)
) {
items.push({
text: translate('Transform to lines'),
callback: this.feature.toLineString,
context: this.feature,
})
}
items.push({
text: translate('Start a hole here'),
callback: this.startHole,
context: this,
})
return items
},
startHole: function (event) {
this.enableEdit().newHole(event.latlng)
},
})

View file

@ -71,15 +71,13 @@ export class FeatureUpdater extends BaseUpdater {
upsert({ metadata, value }) {
const { id, layerId } = metadata
const datalayer = this.getDataLayerFromID(layerId)
let feature = this.getFeatureFromMetadata(metadata, value)
const feature = this.getFeatureFromMetadata(metadata, value)
feature = datalayer.geoJSONToLeaflet({
geometry: value.geometry,
geojson: value,
id,
feature,
})
datalayer.addLayer(feature)
if (feature) {
feature.geometry = value.geometry
} else {
datalayer.makeFeature(value)
}
}
// Update a property of an object
@ -90,7 +88,8 @@ export class FeatureUpdater extends BaseUpdater {
}
if (key === 'geometry') {
const datalayer = this.getDataLayerFromID(metadata.layerId)
datalayer.geoJSONToLeaflet({ geometry: value, id: metadata.id, feature })
const feature = this.getFeatureFromMetadata(metadata, value)
feature.geometry = value
} else {
this.updateObjectValue(feature, key, value)
feature.datalayer.indexProperties(feature)

View file

@ -89,15 +89,15 @@ export default class TableEditor extends WithTemplate {
const bounds = this.map.getBounds()
const inBbox = this.map.browser.options.inBbox
let html = ''
for (const feature of Object.values(this.datalayer._layers)) {
if (feature.isFiltered()) continue
if (inBbox && !feature.isOnScreen(bounds)) continue
this.datalayer.eachFeature((feature) => {
if (feature.isFiltered()) return
if (inBbox && !feature.isOnScreen(bounds)) return
const tds = this.properties.map(
(prop) =>
`<td tabindex="0" data-property="${prop}">${feature.properties[prop] || ''}</td>`
)
html += `<tr data-feature="${feature.id}"><th><input type="checkbox" /></th>${tds.join('')}</tr>`
}
})
this.elements.body.innerHTML = html
}
@ -125,7 +125,7 @@ export default class TableEditor extends WithTemplate {
.prompt(translate('Please enter the new name of this property'))
.then(({ prompt }) => {
if (!prompt || !this.validateName(prompt)) return
this.datalayer.eachLayer((feature) => {
this.datalayer.eachFeature((feature) => {
feature.renameProperty(property, prompt)
})
this.datalayer.deindexProperty(property)
@ -140,7 +140,7 @@ export default class TableEditor extends WithTemplate {
translate('Are you sure you want to delete this property on all the features?')
)
.then(() => {
this.datalayer.eachLayer((feature) => {
this.datalayer.eachFeature((feature) => {
feature.deleteProperty(property)
})
this.datalayer.deindexProperty(property)

View file

@ -302,6 +302,10 @@ export function flattenCoordinates(coords) {
return coords
}
export function polygonMustBeFlattened(coords) {
return coords.length === 1 && typeof coords?.[0]?.[0]?.[0] !== 'number'
}
export function buildQueryString(params) {
const query_string = []
for (const key in params) {

View file

@ -142,7 +142,7 @@ U.AddPolylineShapeAction = U.BaseAction.extend({
},
addHooks: function () {
this.map.editedFeature.editor.newShape()
this.map.editedFeature.ui.editor.newShape()
},
})
@ -182,8 +182,8 @@ U.CreateHoleAction = U.BaseFeatureAction.extend({
},
},
onClick: function (e) {
this.feature.startHole(e)
onClick: function (event) {
this.feature.ui.startHole(event)
},
})
@ -195,11 +195,11 @@ U.ToggleEditAction = U.BaseFeatureAction.extend({
},
},
onClick: function (e) {
onClick: function (event) {
if (this.feature._toggleEditing) {
this.feature._toggleEditing(e) // Path
this.feature._toggleEditing(event) // Path
} else {
this.feature.edit(e) // Marker
this.feature.edit(event) // Marker
}
},
})
@ -244,7 +244,7 @@ U.ExtractShapeFromMultiAction = U.BaseFeatureAction.extend({
},
onClick: function (e) {
this.feature.isolateShape(e.latlng)
this.feature.ui.isolateShape(e.latlng)
},
})
@ -310,7 +310,7 @@ U.DrawToolbar = L.Toolbar.Control.extend({
}
if (this.map.options.enablePolylineDraw) {
this.options.actions.push(U.DrawPolylineAction)
if (this.map.editedFeature && this.map.editedFeature instanceof U.Polyline) {
if (this.map.editedFeature && this.map.editedFeature instanceof U.LineString) {
this.options.actions.push(U.AddPolylineShapeAction)
}
}
@ -1022,7 +1022,7 @@ U.Search = L.PhotonSearch.extend({
L.DomEvent.on(edit, 'mousedown', (e) => {
L.DomEvent.stop(e)
const datalayer = this.map.defaultEditDataLayer()
const layer = datalayer.geojsonToFeatures(feature)
const layer = datalayer.makeFeature(feature)
layer.isDirty = true
layer.edit()
})
@ -1145,56 +1145,79 @@ U.Editable = L.Editable.extend({
initialize: function (map, options) {
L.Editable.prototype.initialize.call(this, map, options)
this.on('editable:drawing:click editable:drawing:move', this.drawingTooltip)
this.on('editable:drawing:end', (e) => {
this.on('editable:drawing:end', (event) => {
this.map.tooltip.close()
// Leaflet.Editable will delete the drawn shape if invalid
// (eg. line has only one drawn point)
// So let's check if the layer has no more shape
if (!e.layer.hasGeom()) e.layer.del()
else e.layer.edit()
})
// Layer for items added by users
this.on('editable:drawing:cancel', (e) => {
if (e.layer instanceof U.Marker) e.layer.del()
})
this.on('editable:drawing:commit', function (e) {
e.layer.isDirty = true
if (this.map.editedFeature !== e.layer) e.layer.edit(e)
})
this.on('editable:editing', (e) => {
const layer = e.layer
layer.isDirty = true
if (layer._tooltip && layer.isTooltipOpen()) {
layer._tooltip.setLatLng(layer.getCenter())
layer._tooltip.update()
if (!event.layer.feature.hasGeom()) {
event.layer.feature.del()
} else {
event.layer.feature.edit()
}
})
this.on('editable:vertex:ctrlclick', (e) => {
const index = e.vertex.getIndex()
if (index === 0 || (index === e.vertex.getLastIndex() && e.vertex.continue))
e.vertex.continue()
// Layer for items added by users
this.on('editable:drawing:cancel', (event) => {
if (event.layer instanceof U.LeafletMarker) event.layer.feature.del()
})
this.on('editable:vertex:altclick', (e) => {
if (e.vertex.editor.vertexCanBeDeleted(e.vertex)) e.vertex.delete()
this.on('editable:drawing:commit', function (event) {
event.layer.feature.isDirty = true
if (this.map.editedFeature !== event.layer) event.layer.feature.edit(event)
})
this.on('editable:editing', (event) => {
const layer = event.layer
layer.feature.isDirty = true
if (layer instanceof L.Marker) {
layer.feature.coordinates = layer._latlng
} else {
layer.feature.coordinates = layer._latlngs
}
// if (layer._tooltip && layer.isTooltipOpen()) {
// layer._tooltip.setLatLng(layer.getCenter())
// layer._tooltip.update()
// }
})
this.on('editable:vertex:ctrlclick', (event) => {
const index = event.vertex.getIndex()
if (
index === 0 ||
(index === event.vertex.getLastIndex() && event.vertex.continue)
)
event.vertex.continue()
})
this.on('editable:vertex:altclick', (event) => {
if (event.vertex.editor.vertexCanBeDeleted(event.vertex)) event.vertex.delete()
})
this.on('editable:vertex:rawclick', this.onVertexRawClick)
},
createPolyline: function (latlngs) {
return new U.Polyline(this.map, latlngs, this._getDefaultProperties())
const datalayer = this.map.defaultEditDataLayer()
const point = new U.LineString(datalayer, {
geometry: { type: 'LineString', coordinates: [] },
})
return point.ui
},
createPolygon: function (latlngs) {
return new U.Polygon(this.map, latlngs, this._getDefaultProperties())
const datalayer = this.map.defaultEditDataLayer()
const point = new U.Polygon(datalayer, {
geometry: { type: 'Polygon', coordinates: [] },
})
return point.ui
},
createMarker: function (latlng) {
return new U.Marker(this.map, latlng, this._getDefaultProperties())
const datalayer = this.map.defaultEditDataLayer()
const point = new U.Point(datalayer, {
geometry: { type: 'Point', coordinates: [latlng.lng, latlng.lat] },
})
return point.ui
},
_getDefaultProperties: function () {
const result = {}
if (this.map.options.featuresHaveOwner && this.map.options.hasOwnProperty('user')) {
if (this.map.options.featuresHaveOwner?.user) {
result.geojson = { properties: { owner: this.map.options.user.id } }
}
return result
@ -1203,7 +1226,7 @@ U.Editable = L.Editable.extend({
connectCreatedToMap: function (layer) {
// Overrided from Leaflet.Editable
const datalayer = this.map.defaultEditDataLayer()
datalayer.addLayer(layer)
datalayer.addFeature(layer.feature)
layer.isDirty = true
return layer
},
@ -1231,7 +1254,7 @@ U.Editable = L.Editable.extend({
} else {
const tmpLatLngs = e.layer.editor._drawnLatLngs.slice()
tmpLatLngs.push(e.latlng)
measure = e.layer.getMeasure(tmpLatLngs)
measure = e.layer.feature.getMeasure(tmpLatLngs)
if (e.layer.editor._drawnLatLngs.length < e.layer.editor.MIN_VERTEX) {
// when drawing second point
@ -1243,7 +1266,7 @@ U.Editable = L.Editable.extend({
}
} else {
// when moving an existing point
measure = e.layer.getMeasure()
measure = e.layer.feature.getMeasure()
}
if (measure) {
if (e.layer instanceof L.Polygon) {

File diff suppressed because it is too large Load diff

View file

@ -360,7 +360,6 @@ L.FormBuilder.DataLayerSwitcher = L.FormBuilder.Select.extend({
getOptions: function () {
const options = []
this.builder.map.eachDataLayerReverse((datalayer) => {
console.log(datalayer.isLoaded(), datalayer.isDataReadOnly(), datalayer.isBrowsable())
if (
datalayer.isLoaded() &&
!datalayer.isDataReadOnly() &&

View file

@ -2,11 +2,10 @@ U.Icon = L.DivIcon.extend({
statics: {
RECENT: [],
},
initialize: function (map, options) {
this.map = map
initialize: function (options) {
const default_options = {
iconSize: null, // Made in css
iconUrl: this.map.getDefaultOption('iconUrl'),
iconUrl: U.SCHEMA.iconUrl.default,
feature: null,
}
options = L.Util.extend({}, default_options, options)
@ -40,13 +39,13 @@ U.Icon = L.DivIcon.extend({
let color
if (this.feature) color = this.feature.getDynamicOption('color')
else if (this.options.color) color = this.options.color
else color = this.map.getDefaultOption('color')
else color = U.SCHEMA.color.default
return color
},
_getOpacity: function () {
if (this.feature) return this.feature.getOption('iconOpacity')
return this.map.getDefaultOption('iconOpacity')
return U.SCHEMA.iconOpacity.default
},
formatUrl: (url, feature) =>
@ -63,9 +62,9 @@ U.Icon.Default = U.Icon.extend({
className: 'umap-div-icon',
},
initialize: function (map, options) {
initialize: function (options) {
options = L.Util.extend({}, this.default_options, options)
U.Icon.prototype.initialize.call(this, map, options)
U.Icon.prototype.initialize.call(this, options)
},
_setIconStyles: function (img, name) {
@ -92,6 +91,7 @@ U.Icon.Default = U.Icon.extend({
'icon_container',
this.elements.main
)
this.elements.main.dataset.feature = this.feature?.id
this.elements.arrow = L.DomUtil.create('div', 'icon_arrow', this.elements.main)
const src = this._getIconUrl('icon')
if (src) {
@ -103,14 +103,14 @@ U.Icon.Default = U.Icon.extend({
})
U.Icon.Circle = U.Icon.extend({
initialize: function (map, options) {
initialize: function (options) {
const default_options = {
popupAnchor: new L.Point(0, -6),
tooltipAnchor: new L.Point(6, 0),
className: 'umap-circle-icon',
}
options = L.Util.extend({}, default_options, options)
U.Icon.prototype.initialize.call(this, map, options)
U.Icon.prototype.initialize.call(this, options)
},
_setIconStyles: function (img, name) {
@ -124,6 +124,7 @@ U.Icon.Circle = U.Icon.extend({
this.elements.main = L.DomUtil.create('div')
this.elements.main.innerHTML = '&nbsp;'
this._setIconStyles(this.elements.main, 'icon')
this.elements.main.dataset.feature = this.feature?.id
return this.elements.main
},
})
@ -153,6 +154,7 @@ U.Icon.Ball = U.Icon.Default.extend({
'icon_container',
this.elements.main
)
this.elements.main.dataset.feature = this.feature?.id
this.elements.arrow = L.DomUtil.create('div', 'icon_arrow', this.elements.main)
this._setIconStyles(this.elements.main, 'icon')
return this.elements.main

View file

@ -193,7 +193,7 @@ U.Map = L.Map.extend({
window.onbeforeunload = () => (this.editEnabled && this.isDirty) || null
this.backup()
this.initContextMenu()
this.on('click contextmenu.show', this.closeInplaceToolbar)
this.on('click', this.closeInplaceToolbar)
},
initSyncEngine: async function () {
@ -863,7 +863,7 @@ U.Map = L.Map.extend({
},
eachFeature: function (callback, context) {
this.eachDataLayer((datalayer) => {
this.eachBrowsableDataLayer((datalayer) => {
if (datalayer.isVisible()) datalayer.eachFeature(callback, context)
})
},
@ -1555,6 +1555,7 @@ U.Map = L.Map.extend({
this.editPanel.close()
this.fullPanel.close()
this.sync.stop()
this.closeInplaceToolbar()
},
hasEditMode: function () {
@ -1832,6 +1833,14 @@ U.Map = L.Map.extend({
return url
},
getFeatureById: function (id) {
let feature
for (const datalayer of Object.values(this.datalayers)) {
feature = datalayer.getFeatureById(id)
if (feature) return feature
}
},
closeInplaceToolbar: function () {
const toolbar = this._toolbars[L.Toolbar.Popup._toolbar_class_id]
if (toolbar) toolbar.remove()

View file

@ -479,6 +479,113 @@ describe('Utils', () => {
})
})
describe('#polygonMustBeFlattened', () => {
it('should return false for simple polygon', () => {
const coords = [
[
[100.0, 0.0],
[101.0, 0.0],
[101.0, 1.0],
[100.0, 1.0],
[100.0, 0.0],
],
]
assert.notOk(Utils.polygonMustBeFlattened(coords))
})
it('should return false for simple polygon with hole', () => {
const coords = [
[
[100.0, 0.0],
[101.0, 0.0],
[101.0, 1.0],
[100.0, 1.0],
[100.0, 0.0],
],
[
[100.8, 0.8],
[100.8, 0.2],
[100.2, 0.2],
[100.2, 0.8],
[100.8, 0.8],
],
]
assert.notOk(Utils.polygonMustBeFlattened(coords))
})
it('should return false for multipolygon', () => {
const coords = [
[
[
[102.0, 2.0],
[103.0, 2.0],
[103.0, 3.0],
[102.0, 3.0],
[102.0, 2.0],
],
],
[
[
[100.0, 0.0],
[101.0, 0.0],
[101.0, 1.0],
[100.0, 1.0],
[100.0, 0.0],
],
[
[100.2, 0.2],
[100.2, 0.8],
[100.8, 0.8],
[100.8, 0.2],
[100.2, 0.2],
],
],
]
assert.notOk(Utils.polygonMustBeFlattened(coords))
})
it('should return true for false multi polygon', () => {
const coords = [
[
[
[100.0, 0.0],
[101.0, 0.0],
[101.0, 1.0],
[100.0, 1.0],
[100.0, 0.0],
],
],
]
assert.ok(Utils.polygonMustBeFlattened(coords))
})
it('should return true for false multi polygon with hole', () => {
const coords = [
[
[
[100.0, 0.0],
[101.0, 0.0],
[101.0, 1.0],
[100.0, 1.0],
[100.0, 0.0],
],
[
[100.8, 0.8],
[100.8, 0.2],
[100.2, 0.2],
[100.2, 0.8],
[100.8, 0.8],
],
],
]
assert.ok(Utils.polygonMustBeFlattened(coords))
})
it('should return false for empty coords', () => {
assert.notOk(Utils.polygonMustBeFlattened([]))
})
})
describe('#usableOption()', () => {
it('should consider false', () => {
assert.ok(Utils.usableOption({ key: false }, 'key'))

View file

@ -46,7 +46,6 @@
<script src="{% static 'umap/js/umap.popup.js' %}" defer></script>
<script src="{% static 'umap/js/umap.forms.js' %}" defer></script>
<script src="{% static 'umap/js/umap.icon.js' %}" defer></script>
<script src="{% static 'umap/js/umap.features.js' %}" defer></script>
<script src="{% static 'umap/js/umap.controls.js' %}" defer></script>
<script src="{% static 'umap/js/umap.js' %}" defer></script>
<script src="{% static 'umap/js/components/fragment.js' %}" defer></script>

View file

@ -27,11 +27,25 @@ def mock_osm_tiles(page):
@pytest.fixture
def page(context):
def new_page(context):
def make_page(prefix="console"):
page = context.new_page()
page.on("console", lambda msg: print(msg.text) if msg.type != "warning" else None)
page.on(
"console",
lambda msg: print(f"{prefix}: {msg.text}")
if msg.type != "warning"
else None,
)
page.on("pageerror", lambda exc: print(f"{prefix} uncaught exception: {exc}"))
return page
yield make_page
@pytest.fixture
def page(new_page):
return new_page()
@pytest.fixture
def login(context, settings, live_server):

View file

@ -243,7 +243,8 @@ def test_can_transfer_shape_from_simple_polygon(live_server, page, tilelayer):
expect(polygons).to_have_count(1)
def test_can_extract_shape(live_server, page, tilelayer):
def test_can_extract_shape(live_server, page, tilelayer, settings):
settings.UMAP_ALLOW_ANONYMOUS = True
page.goto(f"{live_server.url}/en/map/new/")
polygons = page.locator(".leaflet-overlay-pane path")
expect(polygons).to_have_count(0)
@ -269,6 +270,58 @@ def test_can_extract_shape(live_server, page, tilelayer):
polygons.first.click(position={"x": 20, "y": 20}, button="right")
extract_button.click()
expect(polygons).to_have_count(2)
data = save_and_get_json(page)
assert len(data["features"]) == 2
assert data["features"][0]["geometry"]["type"] == "Polygon"
assert data["features"][1]["geometry"]["type"] == "Polygon"
assert data["features"][0]["geometry"]["coordinates"] == [
[
[
-6.569824,
53.159947,
],
[
-6.569824,
52.49616,
],
[
-7.668457,
52.49616,
],
[
-7.668457,
53.159947,
],
[
-6.569824,
53.159947,
],
],
]
assert data["features"][1]["geometry"]["coordinates"] == [
[
[
-8.76709,
54.457267,
],
[
-8.76709,
53.813626,
],
[
-9.865723,
53.813626,
],
[
-9.865723,
54.457267,
],
[
-8.76709,
54.457267,
],
],
]
def test_cannot_transfer_shape_to_line(live_server, page, tilelayer):

View file

@ -187,7 +187,7 @@ def test_can_transfer_shape_from_simple_polyline(live_server, page, tilelayer):
map.click(position={"x": 100, "y": 200})
expect(lines).to_have_count(1)
# Draw another polygon
# Draw another line
page.get_by_title("Draw a polyline").click()
map.click(position={"x": 250, "y": 250})
map.click(position={"x": 200, "y": 250})
@ -196,7 +196,7 @@ def test_can_transfer_shape_from_simple_polyline(live_server, page, tilelayer):
map.click(position={"x": 200, "y": 200})
expect(lines).to_have_count(2)
# Now that polygon 2 is selected, right click on first one
# Now that line 2 is selected, right click on first one
# and transfer shape
lines.first.click(position={"x": 10, "y": 1}, button="right")
page.get_by_role("link", name="Transfer shape to edited feature").click()
@ -235,18 +235,19 @@ def test_can_transfer_shape_from_multi(live_server, page, tilelayer, settings):
map.click(position={"x": 300, "y": 300})
expect(lines).to_have_count(2)
# Now that polygon 2 is selected, right click on first one
# Now that line 2 is selected, right click on first one
# and transfer shape
lines.first.click(position={"x": 10, "y": 1}, button="right")
page.get_by_role("link", name="Transfer shape to edited feature").click()
expect(lines).to_have_count(2)
data = save_and_get_json(page)
# FIXME this should be a LineString, not MultiLineString
assert data["features"][0]["geometry"] == {
"coordinates": [
[[-6.569824, 52.49616], [-7.668457, 52.49616], [-7.668457, 53.159947]]
[-6.569824, 52.49616],
[-7.668457, 52.49616],
[-7.668457, 53.159947],
],
"type": "MultiLineString",
"type": "LineString",
}
assert data["features"][1]["geometry"] == {
"coordinates": [

View file

@ -12,7 +12,7 @@ DATALAYER_UPDATE = re.compile(r".*/datalayer/update/.*")
@pytest.mark.xdist_group(name="websockets")
def test_websocket_connection_can_sync_markers(
context, live_server, websocket_server, tilelayer
new_page, live_server, websocket_server, tilelayer
):
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
map.settings["properties"]["syncEnabled"] = True
@ -20,9 +20,9 @@ def test_websocket_connection_can_sync_markers(
DataLayerFactory(map=map, data={})
# Create two tabs
peerA = context.new_page()
peerA = new_page("Page A")
peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
peerB = context.new_page()
peerB = new_page("Page B")
peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
a_marker_pane = peerA.locator(".leaflet-marker-pane > div")
@ -60,12 +60,12 @@ def test_websocket_connection_can_sync_markers(
expect(b_marker_pane).to_have_count(2)
# Drag a marker on peer B and check that it moved on peer A
a_first_marker.bounding_box() == b_first_marker.bounding_box()
assert a_first_marker.bounding_box() == b_first_marker.bounding_box()
b_old_bbox = b_first_marker.bounding_box()
b_first_marker.drag_to(b_map_el, target_position={"x": 250, "y": 250})
assert b_old_bbox is not b_first_marker.bounding_box()
a_first_marker.bounding_box() == b_first_marker.bounding_box()
assert a_first_marker.bounding_box() == b_first_marker.bounding_box()
# Delete a marker from peer A and check it's been deleted on peer B
a_first_marker.click(button="right")