From 7aa07709b3e6a7728029a2416613e11a95597273 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Wed, 24 Jul 2024 23:18:34 +0200 Subject: [PATCH] wip: first step in moving features to modules (work in progress) --- umap/static/umap/js/modules/data/features.js | 1197 +++++++++++++++++ umap/static/umap/js/modules/data/layer.js | 70 +- umap/static/umap/js/modules/global.js | 8 + .../umap/js/modules/rendering/layers/heat.js | 10 +- .../js/modules/rendering/layers/relative.js | 4 +- umap/static/umap/js/modules/rendering/ui.js | 215 +++ umap/static/umap/js/modules/tableeditor.js | 12 +- umap/static/umap/js/umap.controls.js | 92 +- umap/static/umap/js/umap.forms.js | 1 - umap/static/umap/js/umap.icon.js | 20 +- umap/static/umap/js/umap.js | 44 +- umap/templates/umap/js.html | 1 - 12 files changed, 1579 insertions(+), 95 deletions(-) create mode 100644 umap/static/umap/js/modules/data/features.js create mode 100644 umap/static/umap/js/modules/rendering/ui.js 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..61abc277 --- /dev/null +++ b/umap/static/umap/js/modules/data/features.js @@ -0,0 +1,1197 @@ +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.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() + } + + 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()) + } + + getGeometry() { + return this.toGeoJSON().geometry + } + + 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(e) { + 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(e) + }, // 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(e) + } + + getAdvancedEditActions(container) { + DomUtil.createButton('button umap-delete', container, translate('Delete'), (e) => { + 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.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() { + 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 + // this.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 { + type: 'Feature', + geometry: this.geometry, + properties: this.cloneProperties(), + id: this.id, + } + } + + getPopupToolbarAnchor() { + return [0, 0] + } + + getInplaceToolbarActions() { + return [U.ToggleEditAction, U.DeleteFeatureAction] + } + + getMap() { + return this.map + } + + getContextMenuItems(e) { + const permalink = this.getPermalink() + let items = [] + if (permalink) + items.push({ + text: translate('Permalink'), + callback: () => { + window.open(permalink) + }, + }) + if (this.map.editEnabled && !this.isReadOnly()) { + items = items.concat(this.getContextMenuEditItems(e)) + } + return items + } + + getContextMenuEditItems() { + let items = ['-'] + if (this.map.editedFeature !== this) { + items.push({ + text: `${translate('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: translate('Delete this feature'), + callback: this.confirmDelete, + context: this, + iconCls: 'umap-delete', + }, + { + text: translate('Clone this feature'), + callback: this.clone, + context: this, + } + ) + return items + } + + resetTooltip() { + 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(Utils.escapeHTML(displayName), options) + } + } + + 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 (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() { + 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 + } + + getVertexActions() { + return [U.DeleteVertexAction] + } + + isMulti() { + return false + } + + clone() { + 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() { + // 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.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() + } + } + + _showContextMenu(e) { + L.DomEvent.stop(e) + const pt = this.map.mouseEventToContainerPoint(e.originalEvent) + e.relatedTarget = this + this.map.contextmenu.showAt(pt, e) + } +} + +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) + } + + makeUI() { + return new LeafletMarker(this) + } + + highlight() { + DomUtil.addClass(this.options.icon.elements.main, 'umap-icon-active') + } + + resetHighlight() { + DomUtil.removeClass(this.options.icon.elements.main, 'umap-icon-active') + } + + 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) + // const coordinatesOptions = [ + // ['_latlng.lat', { handler: 'FloatInput', label: translate('Latitude') }], + // ['_latlng.lng', { handler: 'FloatInput', label: translate('Longitude') }], + // ] + // const builder = new U.FormBuilder(this, coordinatesOptions, { + // callback() { + // if (!this._latlng.isValid()) { + // Alert.error(translate('Invalid latitude or longitude')) + // builder.restoreField('_latlng.lat') + // builder.restoreField('_latlng.lng') + // } + // this.zoomTo({ easing: false }) + // }, + // callbackContext: this, + // }) + // 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) + } + + // getPopupToolbarAnchor() { + // return this.options.icon.options.popupAnchor + // } +} + +class Path extends Feature { + hasGeom() { + return !this.isEmpty() + } + + connectToDataLayer(datalayer) { + super.connectToDataLayer(datalayer) + // We keep markers on their own layer on top of the paths. + // this.options.pane = this.datalayer.pane + } + + edit(event) { + if (this.map.editEnabled) { + if (!this.ui.editEnabled()) this.ui.enableEdit() + super.edit(event) + } + } + + _toggleEditing(e) { + if (this.map.editEnabled) { + if (this.ui.editEnabled()) { + this.endEdit() + this.map.editPanel.close() + } else { + this.edit(e) + } + } + // FIXME: disable when disabling global edit + L.DomEvent.stop(e) + } + + 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.getBounds(), true) + } + + endEdit() { + this.ui.disableEdit() + super.endEdit() + } + + transferShape(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(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(e) { + let items = super.getContextMenuItems(e) + items.push({ + text: translate('Display measure'), + callback() { + U.Alert.info(this.getMeasure()) + }, + context: this, + }) + if (this.map.editEnabled && !this.isReadOnly() && this.isMulti()) { + items = items.concat(this.getContextMenuMultiItems(e)) + } + return items + } + + getContextMenuMultiItems(e) { + const items = [ + '-', + { + text: translate('Remove shape from the multi'), + callback() { + this.enableEdit().deleteShapeAt(e.latlng) + }, + context: this, + }, + ] + const shape = this.ui.shapeAt(e.latlng) + if (this.ui._latlngs.indexOf(shape) > 0) { + items.push({ + text: translate('Make main shape'), + callback() { + this.enableEdit().deleteShape(shape) + this.editor.prependShape(shape) + }, + context: this, + }) + } + return items + } + + getContextMenuEditItems(e) { + const items = super.getContextMenuEditItems(e) + if ( + this.map.editedFeature && + this.isSameClass(this.map.editedFeature) && + this.map.editedFeature !== this + ) { + items.push({ + text: translate('Transfer shape to edited feature'), + callback() { + this.transferShape(e.latlng, this.map.editedFeature) + }, + context: this, + }) + } + if (this.isMulti()) { + items.push({ + text: translate('Extract shape to separate feature'), + callback() { + this.isolateShape(e.latlng, this.map.editedFeature) + }, + context: this, + }) + } + return items + } + + getInplaceToolbarActions(e) { + const items = super.getInplaceToolbarActions(e) + if (this.isMulti()) { + items.push(U.DeleteShapeAction) + items.push(U.ExtractShapeFromMultiAction) + } + return items + } + + isOnScreen(bounds) { + bounds = bounds || this.map.getBounds() + return bounds.overlaps(this.ui.getBounds()) + } + + zoomTo({ easing, callback }) { + // Use bounds instead of centroid for paths. + easing = 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 (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', + } + } + + get coordinates() { + return GeoJSON.coordsToLatLngs( + this.geometry.coordinates, + this.geometry.type === 'LineString' ? 0 : 1 + ) + } + + set coordinates(latlngs) { + const multi = !LineUtil.isFlat(latlngs) + this.geometry.coordinates = GeoJSON.latLngsToCoords(latlngs, multi ? 1 : 0, false) + this.geometry.type = multi ? 'MultiLineString' : 'LineString' + } + + 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()) + } + + getContextMenuEditItems(e) { + const items = super.getContextMenuEditItems(e) + const vertexClicked = e.vertex + let index + if (!this.isMulti()) { + items.push({ + text: translate('Transform to polygon'), + callback: this.toPolygon, + context: this, + }) + } + if (vertexClicked) { + index = e.vertex.getIndex() + if (index !== 0 && index !== e.vertex.getLastIndex()) { + items.push({ + text: translate('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(e) { + const items = super.getContextMenuMultiItems(e) + items.push({ + text: translate('Merge lines'), + callback: this.mergeShapes, + context: this, + }) + return items + } + + toPolygon() { + 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(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 + } + + getVertexActions(e) { + const actions = super.getVertexActions(e) + const index = e.vertex.getIndex() + if (index === 0 || index === e.vertex.getLastIndex()) + actions.push(U.ContinueLineAction) + else actions.push(U.SplitLineAction) + return actions + } +} + +export class Polygon extends Path { + constructor(datalayer, geojson, id) { + super(datalayer, geojson, id) + this.staticOptions = { + mainColor: 'fillColor', + className: 'polygon', + } + } + + get coordinates() { + return GeoJSON.coordsToLatLngs( + this.geometry.coordinates, + this.geometry.type === 'Polygon' ? 1 : 2 + ) + } + + set coordinates(latlngs) { + const holes = !LineUtil.isFlat(latlngs) + const multi = holes && !LineUtil.isFlat(latlngs[0]) + let coords = GeoJSON.latLngsToCoords(latlngs, multi ? 2 : holes ? 1 : 0, true) + if (!holes) { + coords = [coords] + } + this.geometry.coordinates = coords + this.geometry.type = multi ? 'MultiPolygon' : 'Polygon' + } + + 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()) + } + + getContextMenuEditItems(e) { + const items = super.getContextMenuEditItems(e) + const shape = this.ui.shapeAt(e.latlng) + // No multi and no holes. + if (shape && !this.isMulti() && (LineUtil.isFlat(shape) || shape.length === 1)) { + items.push({ + text: translate('Transform to lines'), + callback: this.toPolyline, + context: this, + }) + } + items.push({ + text: translate('Start a hole here'), + callback: this.startHole, + context: this, + }) + return items + } + + startHole(event) { + this.ui.enableEdit().newHole(event.latlng) + } + + toPolyline() { + 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(container) { + super.getAdvancedEditActions(container) + const toPolyline = DomUtil.createButton( + 'button umap-to-polyline', + container, + translate('Transform to lines'), + this.toPolyline, + this + ) + } + + isMulti() { + // Change me when Leaflet#3279 is merged. + return ( + !LineUtil.isFlat(this.coordinates) && + !LineUtil.isFlat(this.coordinates[0]) && + this.coordinates.length > 1 + ) + } + + getInplaceToolbarActions(e) { + const items = super.getInplaceToolbarActions(e) + 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..b2bd0d85 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 @@ -188,23 +188,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 +245,7 @@ export class DataLayer { clear() { this.layer.clearLayers() - this._layers = {} + this._features = {} this._index = Array() if (this._geojson) { this.backupData() @@ -268,13 +259,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 +358,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) + // if (sync !== false) feature.sync.delete() + 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 +403,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) @@ -454,7 +441,7 @@ export class DataLayer { const feature = this.geoJSONToLeaflet({ geometry, geojson }) if (feature) { - this.addLayer(feature) + this.addFeature(feature) if (sync) feature.onCommit() return feature } @@ -497,7 +484,8 @@ export class DataLayer { feature.setLatLng(latlng) return feature } - return this._pointToLayer(geojson, latlng, id) + return new Point(this, geojson) + // return this._pointToLayer(geojson, latlng, id) case 'MultiLineString': case 'LineString': @@ -510,7 +498,8 @@ export class DataLayer { feature.setLatLngs(latlngs) return feature } - return this._lineToLayer(geojson, latlngs, id) + return new LineString(this, geojson) + // return this._lineToLayer(geojson, latlngs, id) case 'MultiPolygon': case 'Polygon': @@ -519,7 +508,8 @@ export class DataLayer { feature.setLatLngs(latlngs) return feature } - return this._polygonToLayer(geojson, latlngs, id) + return new Polygon(this, geojson) + // return this._polygonToLayer(geojson, latlngs, id) case 'GeometryCollection': return this.geojsonToFeatures(geometry.geometries) @@ -966,7 +956,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 +1021,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 +1045,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) } 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/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..f64ba483 --- /dev/null +++ b/umap/static/umap/js/modules/rendering/ui.js @@ -0,0 +1,215 @@ +import { + Marker, + Polyline, + Polygon, + DomUtil, +} from '../../../vendors/leaflet/leaflet-src.esm.js' +import { translate } from '../i18n.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.feature._showContextMenu, this.feature) + }, + + 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) + }, +} + +export const LeafletMarker = Marker.extend({ + parentClass: Marker, + includes: [FeatureMixin], + + initialize: function (feature) { + FeatureMixin.initialize.call(this, feature) + this.setIcon(this.getIcon()) + }, + + onCommit: function () { + this.feature.coordinates = this._latlng + this.feature.onCommit() + }, + + addInteractions() { + FeatureMixin.addInteractions.call(this) + this.on( + 'dragend', + function (e) { + this.isDirty = true + this.feature.edit(e) + this.feature.sync.update('geometry', this.getGeometry()) + }, + this + ) + 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.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 () { + this._initIcon() + this.update() + }, + + getCenter: function () { + return this._latlng + }, +}) + +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 }) + } + }, + + onCommit: function () { + this.feature.coordinates = this._latlngs + this.feature.onCommit() + }, + + 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 () { + this.feature.coordinates = this._latlngs + 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() + }, +} + +export const LeafletPolyline = Polyline.extend({ + parentClass: Polyline, + includes: [FeatureMixin, PathMixin], +}) + +export const LeafletPolygon = Polygon.extend({ + parentClass: Polygon, + includes: [FeatureMixin, PathMixin], +}) 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/umap.controls.js b/umap/static/umap/js/umap.controls.js index 1bbe8c87..853cce89 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() }, }) @@ -155,6 +155,7 @@ U.AddPolygonShapeAction = U.AddPolylineShapeAction.extend({ U.BaseFeatureAction = L.ToolbarAction.extend({ initialize: function (map, feature, latlng) { + console.log("Toolbar init", latlng) this.map = map this.feature = feature this.latlng = latlng @@ -310,7 +311,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) } } @@ -1145,56 +1146,81 @@ 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() + console.log(event.layer.feature.coordinates, event.layer.feature.hasGeom()) + 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 + console.log('editing') + 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 +1229,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 +1257,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 +1269,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.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..8863205a 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.onClick) }, 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,45 @@ 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 + } + }, + + onClick: function (event) { + const container = event.originalEvent.target.closest('[data-feature]') + if (container) { + const feature = this.getFeatureById(container.dataset.feature) + if (this.measureTools?.enabled()) return + this._popupHandlersAdded = true // Prevent leaflet from managing event + if (!this.editEnabled) { + feature.view(event) + } else if (!feature.isReadOnly()) { + if (event.originalEvent.shiftKey) { + if (event.originalEvent.ctrlKey || event.originalEvent.metaKey) { + feature.datalayer.edit(event) + } else { + if (feature._toggleEditing) feature._toggleEditing(event) + else feature.edit(event) + } + } else { + console.log('should show toolbar') + new L.Toolbar.Popup(event.latlng, { + className: 'leaflet-inplace-toolbar', + anchor: feature.getPopupToolbarAnchor(), + actions: feature.getInplaceToolbarActions(event), + }).addTo(this, feature, event.latlng) + } + } + L.DomEvent.stop(event) + } else { + this.closeInplaceToolbar() + } + }, + closeInplaceToolbar: function () { const toolbar = this._toolbars[L.Toolbar.Popup._toolbar_class_id] if (toolbar) toolbar.remove() 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 @@ -