diff --git a/umap/static/umap/js/modules/data/features.js b/umap/static/umap/js/modules/data/features.js new file mode 100644 index 00000000..7b2e25b5 --- /dev/null +++ b/umap/static/umap/js/modules/data/features.js @@ -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('') + 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 + } +} diff --git a/umap/static/umap/js/modules/data/layer.js b/umap/static/umap/js/modules/data/layer.js index f0b80a20..65689299 100644 --- a/umap/static/umap/js/modules/data/layer.js +++ b/umap/static/umap/js/modules/data/layer.js @@ -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]) - } - return this // Why returning "this" ? + makeFeatures(geojson = {}, sync = true) { + if (geojson.type === 'Feature' || geojson.coordinates) { + geojson = [geojson] } - - 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) - break - } - if (feature) { - feature.setLatLng(latlng) - return feature - } - return this._pointToLayer(geojson, latlng, id) - + // FIXME: deal with MultiPoint + feature = new Point(this, geojson, id) + break 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', }) ) } - } - - _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) + if (feature) { + this.addFeature(feature) + if (sync) feature.onCommit() + return feature + } } 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' } diff --git a/umap/static/umap/js/modules/formatter.js b/umap/static/umap/js/modules/formatter.js index 64ac5e2b..a4d864f8 100644 --- a/umap/static/umap/js/modules/formatter.js +++ b/umap/static/umap/js/modules/formatter.js @@ -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 diff --git a/umap/static/umap/js/modules/global.js b/umap/static/umap/js/modules/global.js index f9950548..ca9fe1c1 100644 --- a/umap/static/umap/js/modules/global.js +++ b/umap/static/umap/js/modules/global.js @@ -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, diff --git a/umap/static/umap/js/modules/rendering/layers/cluster.js b/umap/static/umap/js/modules/rendering/layers/cluster.js index 14d6f900..d0623bcf 100644 --- a/umap/static/umap/js/modules/rendering/layers/cluster.js +++ b/umap/static/umap/js/modules/rendering/layers/cluster.js @@ -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 diff --git a/umap/static/umap/js/modules/rendering/layers/heat.js b/umap/static/umap/js/modules/rendering/layers/heat.js index 71813319..a8d7dca0 100644 --- a/umap/static/umap/js/modules/rendering/layers/heat.js +++ b/umap/static/umap/js/modules/rendering/layers/heat.js @@ -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) } diff --git a/umap/static/umap/js/modules/rendering/layers/relative.js b/umap/static/umap/js/modules/rendering/layers/relative.js index 26d1b2ad..010d76a1 100644 --- a/umap/static/umap/js/modules/rendering/layers/relative.js +++ b/umap/static/umap/js/modules/rendering/layers/relative.js @@ -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 diff --git a/umap/static/umap/js/modules/rendering/ui.js b/umap/static/umap/js/modules/rendering/ui.js new file mode 100644 index 00000000..86316195 --- /dev/null +++ b/umap/static/umap/js/modules/rendering/ui.js @@ -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) + }, +}) diff --git a/umap/static/umap/js/modules/sync/updaters.js b/umap/static/umap/js/modules/sync/updaters.js index 4bd7ae51..ca72ba0e 100644 --- a/umap/static/umap/js/modules/sync/updaters.js +++ b/umap/static/umap/js/modules/sync/updaters.js @@ -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) diff --git a/umap/static/umap/js/modules/tableeditor.js b/umap/static/umap/js/modules/tableeditor.js index 958176a8..630360f1 100644 --- a/umap/static/umap/js/modules/tableeditor.js +++ b/umap/static/umap/js/modules/tableeditor.js @@ -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) => `${feature.properties[prop] || ''}` ) html += `${tds.join('')}` - } + }) 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) diff --git a/umap/static/umap/js/modules/utils.js b/umap/static/umap/js/modules/utils.js index b117cf1a..b43128df 100644 --- a/umap/static/umap/js/modules/utils.js +++ b/umap/static/umap/js/modules/utils.js @@ -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) { diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index 1bbe8c87..8b5ea4e9 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -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) { diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js deleted file mode 100644 index 740a3d0e..00000000 --- a/umap/static/umap/js/umap.features.js +++ /dev/null @@ -1,1292 +0,0 @@ -U.FeatureMixin = { - staticOptions: { mainColor: 'color' }, - - getPreviewColor: function () { - return this.getDynamicOption(this.staticOptions.mainColor) - }, - - getSyncMetadata: function () { - return { - subject: 'feature', - metadata: { - id: this.id, - layerId: this.datalayer?.umap_id || null, - featureType: this.getClassName(), - }, - } - }, - - onCommit: function () { - // When the layer is a remote layer, we don't want to sync the creation of the - // points via the websocket, as the other peers will get them themselves. - if (this.datalayer?.isRemoteLayer()) return - - // 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()) - }, - - getGeometry: function () { - return this.toGeoJSON().geometry - }, - - initialize: function (map, latlng, options, id) { - this.map = map - this.sync = map.sync_engine.proxy(this) - this._marked_for_deletion = false - - if (typeof options === 'undefined') { - options = {} - } - // DataLayer the marker belongs to - this.datalayer = options.datalayer || null - this.properties = { _umap_options: {} } - - if (options.geojson) { - this.populate(options.geojson) - } - - if (id) { - this.id = id - } else { - let geojson_id - if (options.geojson) { - geojson_id = options.geojson.id - } - - // Each feature needs an unique identifier - if (U.Utils.checkId(geojson_id)) { - this.id = geojson_id - } else { - this.id = U.Utils.generateId() - } - } - let isDirty = false - try { - Object.defineProperty(this, 'isDirty', { - get: () => isDirty, - set: (status) => { - if (!isDirty && status) { - this.fire('isdirty') - } - isDirty = status - if (this.datalayer) { - this.datalayer.isDirty = status - } - }, - }) - } catch (e) { - // Certainly IE8, which has a limited version of defineProperty - } - this.preInit() - this.addInteractions() - this.parentClass.prototype.initialize.call(this, latlng, options) - }, - - preInit: () => {}, - - isReadOnly: function () { - return this.datalayer?.isDataReadOnly() - }, - - getSlug: function () { - return this.properties[this.map.getOption('slugKey') || 'name'] || '' - }, - - getPermalink: function () { - const slug = this.getSlug() - if (slug) - return `${U.Utils.getBaseUrl()}?${U.Utils.buildQueryString({ feature: slug })}${ - window.location.hash - }` - }, - - view: function (e) { - const outlink = this.getOption('outlink') - const target = this.getOption('outlinkTarget') - if (outlink) { - switch (target) { - case 'self': - window.location = outlink - break - case 'parent': - window.top.location = outlink - break - default: - window.open(this.properties._umap_options.outlink) - } - return - } - // TODO deal with an event instead? - if (this.map.slideshow) { - this.map.slideshow.current = this - } - this.map.currentFeature = this - this.attachPopup() - this.openPopup(e?.latlng || this.getCenter()) - }, - - render: function (fields) { - const impactData = fields.some((field) => { - return field.startsWith('properties.') - }) - if (impactData) { - if (this.map.currentFeature === this) { - this.view() - } - } - this._redraw() - }, - - openPopup: function () { - this.parentClass.prototype.openPopup.apply(this, arguments) - }, - - edit: function (e) { - if (!this.map.editEnabled || this.isReadOnly()) return - const container = L.DomUtil.create('div', 'umap-feature-container') - L.DomUtil.createTitle( - container, - L._('Feature properties'), - `icon-${this.getClassName()}` - ) - - let builder = new U.FormBuilder( - this, - [['datalayer', { handler: 'DataLayerSwitcher' }]], - { - callback: function () { - this.edit(e) - }, // removeLayer step will close the edit panel, let's reopen it - } - ) - container.appendChild(builder.build()) - - const properties = [] - let property - for (let i = 0; i < this.datalayer._propertiesIndex.length; i++) { - property = this.datalayer._propertiesIndex[i] - if (L.Util.indexOf(['name', 'description'], property) !== -1) { - continue - } - properties.push([`properties.${property}`, { label: property }]) - } - // We always want name and description for now (properties management to come) - properties.unshift('properties.description') - properties.unshift('properties.name') - builder = new U.FormBuilder(this, properties, { - id: 'umap-feature-properties', - }) - container.appendChild(builder.build()) - this.appendEditFieldsets(container) - const advancedActions = L.DomUtil.createFieldset(container, L._('Advanced actions')) - this.getAdvancedEditActions(advancedActions) - const onLoad = this.map.editPanel.open({ content: container }) - onLoad.then(() => { - builder.helpers['properties.name'].input.focus() - }) - this.map.editedFeature = this - if (!this.isOnScreen()) this.zoomTo(e) - }, - - getAdvancedEditActions: function (container) { - L.DomUtil.createButton('button umap-delete', container, L._('Delete'), (e) => { - this.confirmDelete().then(() => this.map.editPanel.close()) - }) - }, - - appendEditFieldsets: function (container) { - const optionsFields = this.getShapeOptions() - let builder = new U.FormBuilder(this, optionsFields, { - id: 'umap-feature-shape-properties', - }) - const shapeProperties = L.DomUtil.createFieldset(container, L._('Shape properties')) - shapeProperties.appendChild(builder.build()) - - const advancedOptions = this.getAdvancedOptions() - builder = new U.FormBuilder(this, advancedOptions, { - id: 'umap-feature-advanced-properties', - }) - const advancedProperties = L.DomUtil.createFieldset( - container, - L._('Advanced properties') - ) - advancedProperties.appendChild(builder.build()) - - const interactionOptions = this.getInteractionOptions() - builder = new U.FormBuilder(this, interactionOptions) - const popupFieldset = L.DomUtil.createFieldset( - container, - L._('Interaction options') - ) - popupFieldset.appendChild(builder.build()) - }, - - getInteractionOptions: () => [ - '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: function (fallback) { - if (fallback === undefined) fallback = this.datalayer.options.name - const key = this.getOption('labelKey') || 'name' - // Variables mode. - if (U.Utils.hasVar(key)) - return U.Utils.greedyTemplate(key, this.extendedProperties()) - // Simple mode. - return this.properties[key] || this.properties.title || fallback - }, - - hasPopupFooter: function () { - if (L.Browser.ielt9) return false - if (this.datalayer.isRemoteLayer() && this.datalayer.options.remoteData.dynamic) - return false - return this.map.getOption('displayPopupFooter') - }, - - getPopupClass: function () { - const old = this.getOption('popupTemplate') // Retrocompat. - return U.Popup[this.getOption('popupShape') || old] || U.Popup - }, - - attachPopup: function () { - const Class = this.getPopupClass() - this.bindPopup(new Class(this)) - }, - - confirmDelete: async function () { - const confirmed = await this.map.dialog.confirm( - L._('Are you sure you want to delete the feature?') - ) - if (confirmed) { - this.del() - return true - } - return false - }, - - del: function (sync) { - this.isDirty = true - this.map.closePopup() - if (this.datalayer) { - this.datalayer.removeLayer(this, sync) - } - }, - - connectToDataLayer: function (datalayer) { - this.datalayer = datalayer - this.options.renderer = this.datalayer.renderer - }, - - disconnectFromDataLayer: function (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: function (feature) { - this.properties = Object.fromEntries( - Object.entries(feature.properties || {}).map(this.cleanProperty) - ) - this.properties._umap_options = L.extend( - {}, - this.properties._storage_options, - this.properties._umap_options - ) - // Retrocompat - if (this.properties._umap_options.clickable === false) { - this.properties._umap_options.interactive = false - delete this.properties._umap_options.clickable - } - }, - - changeDataLayer: function (datalayer) { - if (this.datalayer) { - this.datalayer.isDirty = true - this.datalayer.removeLayer(this) - } - - datalayer.addLayer(this) - this.sync.upsert(this.toGeoJSON()) - datalayer.isDirty = true - this._redraw() - }, - - getOption: function (option, fallback) { - let value = fallback - if (typeof this.staticOptions[option] !== 'undefined') { - value = this.staticOptions[option] - } else if (U.Utils.usableOption(this.properties._umap_options, option)) { - value = this.properties._umap_options[option] - } else if (this.datalayer) { - value = this.datalayer.getOption(option, this) - } else { - value = this.map.getOption(option) - } - return value - }, - - getDynamicOption: function (option, fallback) { - let value = this.getOption(option, fallback) - // There is a variable inside. - if (U.Utils.hasVar(value)) { - value = U.Utils.greedyTemplate(value, this.properties, true) - if (U.Utils.hasVar(value)) value = this.map.getDefaultOption(option) - } - return value - }, - - zoomTo: function ({ easing, latlng, callback } = {}) { - if (easing === undefined) easing = this.map.getOption('easing') - if (callback) this.map.once('moveend', callback.call(this)) - if (easing) { - this.map.flyTo(this.getCenter(), this.getBestZoom()) - } else { - latlng = latlng || this.getCenter() - this.map.setView(latlng, this.getBestZoom() || this.map.getZoom()) - } - }, - - getBestZoom: function () { - return this.getOption('zoomTo') - }, - - getNext: function () { - return this.datalayer.getNextFeature(this) - }, - - getPrevious: function () { - return this.datalayer.getPreviousFeature(this) - }, - - cloneProperties: function () { - const properties = L.extend({}, this.properties) - properties._umap_options = L.extend({}, properties._umap_options) - if (Object.keys && Object.keys(properties._umap_options).length === 0) { - delete properties._umap_options // It can make a difference on big data sets - } - return properties - }, - - deleteProperty: function (property) { - delete this.properties[property] - this.makeDirty() - }, - - renameProperty: function (from, to) { - this.properties[to] = this.properties[from] - this.deleteProperty(from) - }, - - toGeoJSON: function () { - const geojson = this.parentClass.prototype.toGeoJSON.call(this) - geojson.properties = this.cloneProperties() - geojson.id = this.id - delete geojson.properties._storage_options - return geojson - }, - - addInteractions: function () { - this.on('contextmenu editable:vertex:contextmenu', this._showContextMenu, this) - this.on('click', this._onClick) - }, - - _onClick: function (e) { - if (this.map.measureTools?.enabled()) return - this._popupHandlersAdded = true // Prevent leaflet from managing event - if (!this.map.editEnabled) { - this.view(e) - } else if (!this.isReadOnly()) { - if (e.originalEvent.shiftKey) { - if (e.originalEvent.ctrlKey || e.originalEvent.metaKey) { - this.datalayer.edit(e) - } else { - if (this._toggleEditing) this._toggleEditing(e) - else this.edit(e) - } - } else { - new L.Toolbar.Popup(e.latlng, { - className: 'leaflet-inplace-toolbar', - anchor: this.getPopupToolbarAnchor(), - actions: this.getInplaceToolbarActions(e), - }).addTo(this.map, this, e.latlng) - } - } - L.DomEvent.stop(e) - }, - - getPopupToolbarAnchor: () => [0, 0], - - getInplaceToolbarActions: (e) => [U.ToggleEditAction, U.DeleteFeatureAction], - - _showContextMenu: function (e) { - L.DomEvent.stop(e) - const pt = this.map.mouseEventToContainerPoint(e.originalEvent) - e.relatedTarget = this - this.map.contextmenu.showAt(pt, e) - }, - - makeDirty: function () { - this.isDirty = true - }, - - getMap: function () { - return this.map - }, - - getContextMenuItems: function (e) { - const permalink = this.getPermalink() - let items = [] - if (permalink) - items.push({ - text: L._('Permalink'), - callback: () => { - window.open(permalink) - }, - }) - if (this.map.editEnabled && !this.isReadOnly()) { - items = items.concat(this.getContextMenuEditItems(e)) - } - return items - }, - - getContextMenuEditItems: function () { - let items = ['-'] - if (this.map.editedFeature !== this) { - items.push({ - text: `${L._('Edit this feature')} (⇧+Click)`, - callback: this.edit, - context: this, - iconCls: 'umap-edit', - }) - } - items = items.concat( - { - text: this.map.help.displayLabel('EDIT_FEATURE_LAYER'), - callback: this.datalayer.edit, - context: this.datalayer, - iconCls: 'umap-edit', - }, - { - text: L._('Delete this feature'), - callback: this.confirmDelete, - context: this, - iconCls: 'umap-delete', - }, - { - text: L._('Clone this feature'), - callback: this.clone, - context: this, - } - ) - return items - }, - - onRemove: function (map) { - this.parentClass.prototype.onRemove.call(this, map) - if (this.map.editedFeature === this) { - this._marked_for_deletion = true - this.endEdit() - this.map.editPanel.close() - } - }, - - resetTooltip: function () { - if (!this.hasGeom()) return - const displayName = this.getDisplayName(null) - let showLabel = this.getOption('showLabel') - const oldLabelHover = this.getOption('labelHover') - - const options = { - direction: this.getOption('labelDirection'), - interactive: this.getOption('labelInteractive'), - } - - if (oldLabelHover && showLabel) showLabel = null // Retrocompat. - options.permanent = showLabel === true - this.unbindTooltip() - if ((showLabel === true || showLabel === null) && displayName) - this.bindTooltip(U.Utils.escapeHTML(displayName), options) - }, - - isFiltered: function () { - const filterKeys = this.datalayer.getFilterKeys() - const filter = this.map.browser.options.filter - if (filter && !this.matchFilter(filter, filterKeys)) return true - if (!this.matchFacets()) return true - return false - }, - - matchFilter: function (filter, keys) { - filter = filter.toLowerCase() - if (U.Utils.hasVar(keys)) { - return this.getDisplayName().toLowerCase().indexOf(filter) !== -1 - } - keys = keys.split(',') - for (let i = 0, value; i < keys.length; i++) { - value = `${this.properties[keys[i]] || ''}` - if (value.toLowerCase().indexOf(filter) !== -1) return true - } - return false - }, - - matchFacets: function () { - const selected = this.map.facets.selected - for (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 || L._('') - if (choices?.length && !choices.includes(value)) return false - break - } - } - return true - }, - - onVertexRawClick: function (e) { - new L.Toolbar.Popup(e.latlng, { - className: 'leaflet-inplace-toolbar', - actions: this.getVertexActions(e), - }).addTo(this.map, this, e.latlng, e.vertex) - }, - - getVertexActions: () => [U.DeleteVertexAction], - - isMulti: () => false, - - clone: function () { - const geoJSON = this.toGeoJSON() - delete geoJSON.id - delete geoJSON.properties.id - const layer = this.datalayer.geojsonToFeatures(geoJSON) - layer.isDirty = true - layer.edit() - return layer - }, - - extendedProperties: function () { - // Include context properties - properties = this.map.getGeoContext() - const locale = L.getLocale() - if (locale) properties.locale = locale - if (L.lang) properties.lang = L.lang - properties.rank = this.getRank() + 1 - properties.layer = this.datalayer.getName() - if (this._map && this.hasGeom()) { - center = this.getCenter() - properties.lat = center.lat - properties.lon = center.lng - properties.lng = center.lng - properties.alt = this._latlng?.alt - if (typeof this.getMeasure !== 'undefined') { - properties.measure = this.getMeasure() - } - } - return L.extend(properties, this.properties) - }, - - getRank: function () { - return this.datalayer._index.indexOf(L.stamp(this)) - }, -} - -U.Marker = L.Marker.extend({ - parentClass: L.Marker, - includes: [U.FeatureMixin], - - preInit: function () { - this.setIcon(this.getIcon()) - }, - - highlight: function () { - L.DomUtil.addClass(this.options.icon.elements.main, 'umap-icon-active') - }, - - resetHighlight: function () { - L.DomUtil.removeClass(this.options.icon.elements.main, 'umap-icon-active') - }, - - addInteractions: function () { - U.FeatureMixin.addInteractions.call(this) - this.on( - 'dragend', - function (e) { - this.isDirty = true - this.edit(e) - this.sync.update('geometry', this.getGeometry()) - }, - this - ) - this.on('editable:drawing:commit', this.onCommit) - if (!this.isReadOnly()) this.on('mouseover', this._enableDragging) - this.on('mouseout', this._onMouseOut) - this._popupHandlersAdded = true // prevent Leaflet from binding event on bindPopup - this.on('popupopen', this.highlight) - this.on('popupclose', this.resetHighlight) - }, - - hasGeom: function () { - return !!this._latlng - }, - - _onMouseOut: function () { - if (this.dragging?._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() - } - }, - - _redraw: function () { - if (this.datalayer?.isVisible()) { - this._initIcon() - this.update() - } - }, - - _initIcon: function () { - this.options.icon = this.getIcon() - L.Marker.prototype._initIcon.call(this) - // Allow to run code when icon is actually part of the DOM - this.options.icon.onAdd() - this.resetTooltip() - }, - - _getTooltipAnchor: function () { - const anchor = this.options.icon.options.tooltipAnchor.clone() - const direction = this.getOption('labelDirection') - if (direction === 'left') { - anchor.x *= -1 - } else if (direction === 'bottom') { - anchor.x = 0 - anchor.y = 0 - } else if (direction === 'top') { - anchor.x = 0 - } - return anchor - }, - - disconnectFromDataLayer: function (datalayer) { - this.options.icon.datalayer = null - U.FeatureMixin.disconnectFromDataLayer.call(this, datalayer) - }, - - _getIconUrl: function (name) { - if (typeof name === 'undefined') name = 'icon' - return this.getOption(`${name}Url`) - }, - - getIconClass: function () { - return this.getOption('iconClass') - }, - - getIcon: function () { - const Class = U.Icon[this.getIconClass()] || U.Icon.Default - return new Class(this.map, { feature: this }) - }, - - getCenter: function () { - return this._latlng - }, - - getClassName: () => 'marker', - - getShapeOptions: () => [ - 'properties._umap_options.color', - 'properties._umap_options.iconClass', - 'properties._umap_options.iconUrl', - 'properties._umap_options.iconOpacity', - ], - - getAdvancedOptions: () => ['properties._umap_options.zoomTo'], - - appendEditFieldsets: function (container) { - U.FeatureMixin.appendEditFieldsets.call(this, container) - const coordinatesOptions = [ - ['_latlng.lat', { handler: 'FloatInput', label: L._('Latitude') }], - ['_latlng.lng', { handler: 'FloatInput', label: L._('Longitude') }], - ] - const builder = new U.FormBuilder(this, coordinatesOptions, { - callback: function () { - if (!this._latlng.isValid()) { - U.Alert.error(L._('Invalid latitude or longitude')) - builder.restoreField('_latlng.lat') - builder.restoreField('_latlng.lng') - } - this.zoomTo({ easing: false }) - }, - callbackContext: this, - }) - const fieldset = L.DomUtil.createFieldset(container, L._('Coordinates')) - fieldset.appendChild(builder.build()) - }, - - zoomTo: function (e) { - if (this.datalayer.isClustered() && !this._icon) { - // callback is mandatory for zoomToShowLayer - this.datalayer.layer.zoomToShowLayer(this, e.callback || (() => {})) - } else { - U.FeatureMixin.zoomTo.call(this, e) - } - }, - - isOnScreen: function (bounds) { - bounds = bounds || this.map.getBounds() - return bounds.contains(this._latlng) - }, - - getPopupToolbarAnchor: function () { - return this.options.icon.options.popupAnchor - }, -}) - -U.PathMixin = { - hasGeom: function () { - return !this.isEmpty() - }, - - connectToDataLayer: function (datalayer) { - U.FeatureMixin.connectToDataLayer.call(this, datalayer) - // We keep markers on their own layer on top of the paths. - this.options.pane = this.datalayer.pane - }, - - edit: function (e) { - if (this.map.editEnabled) { - if (!this.editEnabled()) this.enableEdit() - U.FeatureMixin.edit.call(this, e) - } - }, - - _toggleEditing: function (e) { - if (this.map.editEnabled) { - if (this.editEnabled()) { - this.endEdit() - this.map.editPanel.close() - } else { - this.edit(e) - } - } - // FIXME: disable when disabling global edit - L.DomEvent.stop(e) - }, - - styleOptions: [ - 'smoothFactor', - 'color', - 'opacity', - 'stroke', - 'weight', - 'fill', - 'fillColor', - 'fillOpacity', - 'dashArray', - 'interactive', - ], - - getShapeOptions: () => [ - 'properties._umap_options.color', - 'properties._umap_options.opacity', - 'properties._umap_options.weight', - ], - - getAdvancedOptions: () => [ - 'properties._umap_options.smoothFactor', - 'properties._umap_options.dashArray', - 'properties._umap_options.zoomTo', - ], - - setStyle: function (options) { - options = options || {} - let option - for (const idx in this.styleOptions) { - option = this.styleOptions[idx] - options[option] = this.getDynamicOption(option) - } - if (options.interactive) this.options.pointerEvents = 'visiblePainted' - else this.options.pointerEvents = 'stroke' - this.parentClass.prototype.setStyle.call(this, options) - }, - - _redraw: function () { - if (this.datalayer?.isVisible()) { - this.setStyle() - this.resetTooltip() - } - }, - - onAdd: function (map) { - this._container = null - this.setStyle() - // Show tooltip again when Leaflet.label allow static label on path. - // cf https://github.com/Leaflet/Leaflet/pull/3952 - // this.map.on('showmeasure', this.showMeasureTooltip, this); - // this.map.on('hidemeasure', this.removeTooltip, this); - this.parentClass.prototype.onAdd.call(this, map) - if (this.editing?.enabled()) this.editing.addHooks() - this.resetTooltip() - }, - - onRemove: function (map) { - // this.map.off('showmeasure', this.showMeasureTooltip, this); - // this.map.off('hidemeasure', this.removeTooltip, this); - if (this.editing?.enabled()) this.editing.removeHooks() - U.FeatureMixin.onRemove.call(this, map) - }, - - getBestZoom: function () { - return this.getOption('zoomTo') || this.map.getBoundsZoom(this.getBounds(), true) - }, - - endEdit: function () { - this.disableEdit() - U.FeatureMixin.endEdit.call(this) - }, - - highlightPath: function () { - this.parentClass.prototype.setStyle.call(this, { - fillOpacity: Math.sqrt(this.getDynamicOption('fillOpacity', 1.0)), - opacity: 1.0, - weight: 1.3 * this.getDynamicOption('weight'), - }) - }, - - _onMouseOver: function () { - if (this.map.measureTools?.enabled()) { - this.map.tooltip.open({ content: this.getMeasure(), anchor: this }) - } else if (this.map.editEnabled && !this.map.editedFeature) { - this.map.tooltip.open({ content: L._('Click to edit'), anchor: this }) - } - }, - - addInteractions: function () { - U.FeatureMixin.addInteractions.call(this) - this.on('editable:disable', this.onCommit) - this.on('mouseover', this._onMouseOver) - this.on('edit', this.makeDirty) - this.on('drag editable:drag', this._onDrag) - this.on('popupopen', this.highlightPath) - this.on('popupclose', this._redraw) - }, - - _onDrag: function () { - if (this._tooltip) this._tooltip.setLatLng(this.getCenter()) - }, - - transferShape: function (at, to) { - const shape = this.enableEdit().deleteShapeAt(at) - this.disableEdit() - if (!shape) return - to.enableEdit().appendShape(shape) - if (!this._latlngs.length || !this._latlngs[0].length) this.del() - }, - - isolateShape: function (at) { - if (!this.isMulti()) return - const shape = this.enableEdit().deleteShapeAt(at) - this.disableEdit() - if (!shape) return - const properties = this.cloneProperties() - const other = new (this instanceof U.Polyline ? U.Polyline : U.Polygon)( - this.map, - shape, - { - geojson: { properties }, - } - ) - this.datalayer.addLayer(other) - other.edit() - return other - }, - - getContextMenuItems: function (e) { - let items = U.FeatureMixin.getContextMenuItems.call(this, e) - items.push({ - text: L._('Display measure'), - callback: function () { - U.Alert.info(this.getMeasure()) - }, - context: this, - }) - if (this.map.editEnabled && !this.isReadOnly() && this.isMulti()) { - items = items.concat(this.getContextMenuMultiItems(e)) - } - return items - }, - - getContextMenuMultiItems: function (e) { - const items = [ - '-', - { - text: L._('Remove shape from the multi'), - callback: function () { - this.enableEdit().deleteShapeAt(e.latlng) - }, - context: this, - }, - ] - const shape = this.shapeAt(e.latlng) - if (this._latlngs.indexOf(shape) > 0) { - items.push({ - text: L._('Make main shape'), - callback: function () { - this.enableEdit().deleteShape(shape) - this.editor.prependShape(shape) - }, - context: this, - }) - } - return items - }, - - getContextMenuEditItems: function (e) { - const items = U.FeatureMixin.getContextMenuEditItems.call(this, e) - if ( - this.map.editedFeature && - this.isSameClass(this.map.editedFeature) && - this.map.editedFeature !== this - ) { - items.push({ - text: L._('Transfer shape to edited feature'), - callback: function () { - this.transferShape(e.latlng, this.map.editedFeature) - }, - context: this, - }) - } - if (this.isMulti()) { - items.push({ - text: L._('Extract shape to separate feature'), - callback: function () { - this.isolateShape(e.latlng, this.map.editedFeature) - }, - context: this, - }) - } - return items - }, - - getInplaceToolbarActions: function (e) { - const items = U.FeatureMixin.getInplaceToolbarActions.call(this, e) - if (this.isMulti()) { - items.push(U.DeleteShapeAction) - items.push(U.ExtractShapeFromMultiAction) - } - return items - }, - - isOnScreen: function (bounds) { - bounds = bounds || this.map.getBounds() - return bounds.overlaps(this.getBounds()) - }, - - zoomTo: function (e) { - // Use bounds instead of centroid for paths. - e = e || {} - const easing = e.easing !== undefined ? e.easing : this.map.getOption('easing') - if (easing) { - this.map.flyToBounds(this.getBounds(), this.getBestZoom()) - } else { - this.map.fitBounds(this.getBounds(), this.getBestZoom() || this.map.getZoom()) - } - if (e.callback) e.callback.call(this) - }, -} - -U.Polyline = L.Polyline.extend({ - parentClass: L.Polyline, - includes: [U.FeatureMixin, U.PathMixin], - - staticOptions: { - stroke: true, - fill: false, - mainColor: 'color', - }, - - isSameClass: (other) => other instanceof U.Polyline, - - getClassName: () => 'polyline', - - getMeasure: function (shape) { - const length = L.GeoUtil.lineLength(this.map, shape || this._defaultShape()) - return L.GeoUtil.readableDistance(length, this.map.measureTools.getMeasureUnit()) - }, - - getContextMenuEditItems: function (e) { - const items = U.PathMixin.getContextMenuEditItems.call(this, e) - const vertexClicked = e.vertex - let index - if (!this.isMulti()) { - items.push({ - text: L._('Transform to polygon'), - callback: this.toPolygon, - context: this, - }) - } - if (vertexClicked) { - index = e.vertex.getIndex() - if (index !== 0 && index !== e.vertex.getLastIndex()) { - items.push({ - text: L._('Split line'), - callback: e.vertex.split, - context: e.vertex, - }) - } else if (index === 0 || index === e.vertex.getLastIndex()) { - items.push({ - text: this.map.help.displayLabel('CONTINUE_LINE'), - callback: e.vertex.continue, - context: e.vertex.continue, - }) - } - } - return items - }, - - getContextMenuMultiItems: function (e) { - const items = U.PathMixin.getContextMenuMultiItems.call(this, e) - items.push({ - text: L._('Merge lines'), - callback: this.mergeShapes, - context: this, - }) - return items - }, - - toPolygon: function () { - const geojson = this.toGeoJSON() - geojson.geometry.type = 'Polygon' - geojson.geometry.coordinates = [ - U.Utils.flattenCoordinates(geojson.geometry.coordinates), - ] - - delete geojson.id // delete the copied id, a new one will be generated. - - const polygon = this.datalayer.geojsonToFeatures(geojson) - polygon.edit() - this.del() - }, - - getAdvancedEditActions: function (container) { - U.FeatureMixin.getAdvancedEditActions.call(this, container) - L.DomUtil.createButton( - 'button umap-to-polygon', - container, - L._('Transform to polygon'), - this.toPolygon, - this - ) - }, - - _mergeShapes: function (from, to) { - const toLeft = to[0] - const toRight = to[to.length - 1] - const fromLeft = from[0] - const fromRight = from[from.length - 1] - const l2ldistance = toLeft.distanceTo(fromLeft) - const l2rdistance = toLeft.distanceTo(fromRight) - const r2ldistance = toRight.distanceTo(fromLeft) - const r2rdistance = toRight.distanceTo(fromRight) - let toMerge - if (l2rdistance < Math.min(l2ldistance, r2ldistance, r2rdistance)) { - toMerge = [from, to] - } else if (r2ldistance < Math.min(l2ldistance, l2rdistance, r2rdistance)) { - toMerge = [to, from] - } else if (r2rdistance < Math.min(l2ldistance, l2rdistance, r2ldistance)) { - from.reverse() - toMerge = [to, from] - } else { - from.reverse() - toMerge = [from, to] - } - const a = toMerge[0] - 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: function () { - if (!this.isMulti()) return - const latlngs = this.getLatLngs() - if (!latlngs.length) return - while (latlngs.length > 1) { - latlngs.splice(0, 2, this._mergeShapes(latlngs[1], latlngs[0])) - } - this.setLatLngs(latlngs[0]) - if (!this.editEnabled()) this.edit() - this.editor.reset() - this.isDirty = true - }, - - isMulti: function () { - return !L.LineUtil.isFlat(this._latlngs) && this._latlngs.length > 1 - }, - - getVertexActions: function (e) { - const actions = U.FeatureMixin.getVertexActions.call(this, e) - const index = e.vertex.getIndex() - if (index === 0 || index === e.vertex.getLastIndex()) - actions.push(U.ContinueLineAction) - else actions.push(U.SplitLineAction) - return actions - }, -}) - -U.Polygon = L.Polygon.extend({ - parentClass: L.Polygon, - includes: [U.FeatureMixin, U.PathMixin], - staticOptions: { - mainColor: 'fillColor', - }, - - isSameClass: (other) => other instanceof U.Polygon, - - getClassName: () => 'polygon', - - getShapeOptions: () => { - const options = U.PathMixin.getShapeOptions() - options.push( - 'properties._umap_options.stroke', - 'properties._umap_options.fill', - 'properties._umap_options.fillColor', - 'properties._umap_options.fillOpacity' - ) - return options - }, - - getPreviewColor: function () { - // 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 !== U.SCHEMA.color.default) return color - return this.getDynamicOption('color') - }, - - getInteractionOptions: () => { - const options = U.FeatureMixin.getInteractionOptions() - options.push('properties._umap_options.interactive') - return options - }, - - getMeasure: function (shape) { - const area = L.GeoUtil.geodesicArea(shape || this._defaultShape()) - return L.GeoUtil.readableArea(area, this.map.measureTools.getMeasureUnit()) - }, - - getContextMenuEditItems: function (e) { - const items = U.PathMixin.getContextMenuEditItems.call(this, e) - const shape = this.shapeAt(e.latlng) - // No multi and no holes. - if (shape && !this.isMulti() && (L.LineUtil.isFlat(shape) || shape.length === 1)) { - items.push({ - text: L._('Transform to lines'), - callback: this.toPolyline, - context: this, - }) - } - items.push({ - text: L._('Start a hole here'), - callback: this.startHole, - context: this, - }) - return items - }, - - startHole: function (e) { - this.enableEdit().newHole(e.latlng) - }, - - toPolyline: function () { - const geojson = this.toGeoJSON() - delete geojson.id - delete geojson.properties.id - geojson.geometry.type = 'LineString' - geojson.geometry.coordinates = U.Utils.flattenCoordinates( - geojson.geometry.coordinates - ) - const polyline = this.datalayer.geojsonToFeatures(geojson) - polyline.edit() - this.del() - }, - - getAdvancedEditActions: function (container) { - U.FeatureMixin.getAdvancedEditActions.call(this, container) - const toPolyline = L.DomUtil.createButton( - 'button umap-to-polyline', - container, - L._('Transform to lines'), - this.toPolyline, - this - ) - }, - - isMulti: function () { - // Change me when Leaflet#3279 is merged. - return ( - !L.LineUtil.isFlat(this._latlngs) && - !L.LineUtil.isFlat(this._latlngs[0]) && - this._latlngs.length > 1 - ) - }, - - getInplaceToolbarActions: function (e) { - const items = U.PathMixin.getInplaceToolbarActions.call(this, e) - items.push(U.CreateHoleAction) - return items - }, -}) diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index b7190984..4288a4c8 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -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() && diff --git a/umap/static/umap/js/umap.icon.js b/umap/static/umap/js/umap.icon.js index b8de3668..33c0651e 100644 --- a/umap/static/umap/js/umap.icon.js +++ b/umap/static/umap/js/umap.icon.js @@ -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 = ' ' 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 diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index 36af26b5..8c402fbd 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -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() diff --git a/umap/static/umap/unittests/utils.js b/umap/static/umap/unittests/utils.js index b48c56e7..958ccfa1 100644 --- a/umap/static/umap/unittests/utils.js +++ b/umap/static/umap/unittests/utils.js @@ -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')) diff --git a/umap/templates/umap/js.html b/umap/templates/umap/js.html index 187d3314..36693778 100644 --- a/umap/templates/umap/js.html +++ b/umap/templates/umap/js.html @@ -46,7 +46,6 @@ - diff --git a/umap/tests/integration/conftest.py b/umap/tests/integration/conftest.py index 504ff5f3..079f6b78 100644 --- a/umap/tests/integration/conftest.py +++ b/umap/tests/integration/conftest.py @@ -27,10 +27,24 @@ def mock_osm_tiles(page): @pytest.fixture -def page(context): - page = context.new_page() - page.on("console", lambda msg: print(msg.text) if msg.type != "warning" else None) - return page +def new_page(context): + def make_page(prefix="console"): + page = context.new_page() + 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 diff --git a/umap/tests/integration/test_draw_polygon.py b/umap/tests/integration/test_draw_polygon.py index bd76c843..f3443e8e 100644 --- a/umap/tests/integration/test_draw_polygon.py +++ b/umap/tests/integration/test_draw_polygon.py @@ -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): diff --git a/umap/tests/integration/test_draw_polyline.py b/umap/tests/integration/test_draw_polyline.py index fadb3d52..6759cf93 100644 --- a/umap/tests/integration/test_draw_polyline.py +++ b/umap/tests/integration/test_draw_polyline.py @@ -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": [ diff --git a/umap/tests/integration/test_websocket_sync.py b/umap/tests/integration/test_websocket_sync.py index a038f784..dfa86c3a 100644 --- a/umap/tests/integration/test_websocket_sync.py +++ b/umap/tests/integration/test_websocket_sync.py @@ -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")