Merge pull request #2041 from umap-project/mask-polygon

feat: allow to display a polygon "negative"
This commit is contained in:
Yohan Boniface 2024-08-13 10:57:37 +02:00 committed by GitHub
commit f08e9cadb5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 195 additions and 77 deletions

View file

@ -9,7 +9,12 @@ import * as Utils from '../utils.js'
import { SCHEMA } from '../schema.js' import { SCHEMA } from '../schema.js'
import { translate } from '../i18n.js' import { translate } from '../i18n.js'
import { uMapAlert as Alert } from '../../components/alerts/alert.js' import { uMapAlert as Alert } from '../../components/alerts/alert.js'
import { LeafletMarker, LeafletPolyline, LeafletPolygon } from '../rendering/ui.js' import {
LeafletMarker,
LeafletPolyline,
LeafletPolygon,
MaskPolygon,
} from '../rendering/ui.js'
import loadPopup from '../rendering/popup.js' import loadPopup from '../rendering/popup.js'
class Feature { class Feature {
@ -60,7 +65,7 @@ class Feature {
} }
get ui() { get ui() {
if (!this._ui) this._ui = this.makeUI() if (!this._ui) this.makeUI()
return this._ui return this._ui
} }
@ -76,13 +81,41 @@ class Feature {
return this.ui.getBounds() return this.ui.getBounds()
} }
get type() {
return this.geometry.type
}
get coordinates() {
return this.geometry.coordinates
}
get geometry() { get geometry() {
return this._geometry return this._geometry
} }
set geometry(value) { set geometry(value) {
this._geometry = value this._geometry = value
this.geometryChanged() this.pushGeometry()
}
pushGeometry() {
this.ui.setLatLngs(this.toLatLngs())
}
pullGeometry(sync = true) {
this.fromLatLngs(this.ui.getLatLngs())
if (sync) {
this.sync.update('geometry', this.geometry)
}
}
fromLatLngs(latlngs) {
this._geometry = this.convertLatLngs(latlngs)
}
makeUI() {
const klass = this.getUIClass()
this._ui = new klass(this, this.toLatLngs())
} }
getClassName() { getClassName() {
@ -536,9 +569,15 @@ class Feature {
redraw() { redraw() {
if (this.datalayer?.isVisible()) { if (this.datalayer?.isVisible()) {
if (this.getUIClass() !== this.ui.getClass()) {
this.datalayer.hideFeature(this)
this.makeUI()
this.datalayer.showFeature(this)
} else {
this.ui._redraw() this.ui._redraw()
} }
} }
}
} }
export class Point extends Feature { export class Point extends Feature {
@ -550,20 +589,16 @@ export class Point extends Feature {
} }
} }
get coordinates() { toLatLngs() {
return GeoJSON.coordsToLatLng(this.geometry.coordinates) return GeoJSON.coordsToLatLng(this.coordinates)
} }
set coordinates(latlng) { convertLatLngs(latlng) {
this.geometry.coordinates = GeoJSON.latLngToCoords(latlng) return { coordinates: GeoJSON.latLngToCoords(latlng), type: 'Point' }
} }
geometryChanged() { getUIClass() {
this.ui.setLatLng(this.coordinates) return LeafletMarker
}
makeUI() {
return new LeafletMarker(this)
} }
hasGeom() { hasGeom() {
@ -620,7 +655,7 @@ export class Point extends Feature {
isOnScreen(bounds) { isOnScreen(bounds) {
bounds = bounds || this.map.getBounds() bounds = bounds || this.map.getBounds()
return bounds.contains(this.coordinates) return bounds.contains(this.toLatLngs())
} }
} }
@ -629,20 +664,6 @@ class Path extends Feature {
return !this.isEmpty() 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) { connectToDataLayer(datalayer) {
super.connectToDataLayer(datalayer) super.connectToDataLayer(datalayer)
// We keep markers on their own layer on top of the paths. // We keep markers on their own layer on top of the paths.
@ -722,18 +743,18 @@ class Path extends Feature {
transferShape(at, to) { transferShape(at, to) {
const shape = this.ui.enableEdit().deleteShapeAt(at) const shape = this.ui.enableEdit().deleteShapeAt(at)
// FIXME: make Leaflet.Editable send an event instead // FIXME: make Leaflet.Editable send an event instead
this.ui.geometryChanged() this.pullGeometry()
this.ui.disableEdit() this.ui.disableEdit()
if (!shape) return if (!shape) return
to.ui.enableEdit().appendShape(shape) to.ui.enableEdit().appendShape(shape)
to.ui.geometryChanged() to.pullGeometry()
if (this.isEmpty()) this.del() if (this.isEmpty()) this.del()
} }
isolateShape(latlngs) { isolateShape(latlngs) {
const properties = this.cloneProperties() const properties = this.cloneProperties()
const type = this instanceof LineString ? 'LineString' : 'Polygon' const type = this instanceof LineString ? 'LineString' : 'Polygon'
const geometry = this._toGeometry(latlngs) const geometry = this.convertLatLngs(latlngs)
const other = this.datalayer.makeFeature({ type, geometry, properties }) const other = this.datalayer.makeFeature({ type, geometry, properties })
other.edit() other.edit()
return other return other
@ -776,14 +797,11 @@ export class LineString extends Path {
} }
} }
_toLatlngs(geometry) { toLatLngs(geometry) {
return GeoJSON.coordsToLatLngs( return GeoJSON.coordsToLatLngs(this.coordinates, this.type === 'LineString' ? 0 : 1)
geometry.coordinates,
geometry.type === 'LineString' ? 0 : 1
)
} }
_toGeometry(latlngs) { convertLatLngs(latlngs) {
let multi = !LineUtil.isFlat(latlngs) let multi = !LineUtil.isFlat(latlngs)
let coordinates = GeoJSON.latLngsToCoords(latlngs, multi ? 1 : 0, false) let coordinates = GeoJSON.latLngsToCoords(latlngs, multi ? 1 : 0, false)
if (coordinates.length === 1 && typeof coordinates[0][0] !== 'number') { if (coordinates.length === 1 && typeof coordinates[0][0] !== 'number') {
@ -798,8 +816,8 @@ export class LineString extends Path {
return !this.coordinates.length return !this.coordinates.length
} }
makeUI() { getUIClass() {
return new LeafletPolyline(this) return LeafletPolyline
} }
isSameClass(other) { isSameClass(other) {
@ -875,7 +893,7 @@ export class LineString extends Path {
while (latlngs.length > 1) { while (latlngs.length > 1) {
latlngs.splice(0, 2, this._mergeShapes(latlngs[1], latlngs[0])) latlngs.splice(0, 2, this._mergeShapes(latlngs[1], latlngs[0]))
} }
this.setLatLngs(latlngs[0]) this.ui.setLatLngs(latlngs[0])
if (!this.editEnabled()) this.edit() if (!this.editEnabled()) this.edit()
this.editor.reset() this.editor.reset()
this.isDirty = true this.isDirty = true
@ -895,14 +913,11 @@ export class Polygon extends Path {
} }
} }
_toLatlngs(geometry) { toLatLngs() {
return GeoJSON.coordsToLatLngs( return GeoJSON.coordsToLatLngs(this.coordinates, this.type === 'Polygon' ? 1 : 2)
geometry.coordinates,
geometry.type === 'Polygon' ? 1 : 2
)
} }
_toGeometry(latlngs) { convertLatLngs(latlngs) {
const holes = !LineUtil.isFlat(latlngs) const holes = !LineUtil.isFlat(latlngs)
let multi = holes && !LineUtil.isFlat(latlngs[0]) let multi = holes && !LineUtil.isFlat(latlngs[0])
let coordinates = GeoJSON.latLngsToCoords(latlngs, multi ? 2 : holes ? 1 : 0, true) let coordinates = GeoJSON.latLngsToCoords(latlngs, multi ? 2 : holes ? 1 : 0, true)
@ -918,8 +933,9 @@ export class Polygon extends Path {
return !this.coordinates.length || !this.coordinates[0].length return !this.coordinates.length || !this.coordinates[0].length
} }
makeUI() { getUIClass() {
return new LeafletPolygon(this) if (this.getOption('mask')) return MaskPolygon
return LeafletPolygon
} }
isSameClass(other) { isSameClass(other) {
@ -969,6 +985,12 @@ export class Polygon extends Path {
this.del() this.del()
} }
getAdvancedOptions() {
const actions = super.getAdvancedOptions()
actions.push('properties._umap_options.mask')
return actions
}
getAdvancedEditActions(container) { getAdvancedEditActions(container) {
super.getAdvancedEditActions(container) super.getAdvancedEditActions(container)
const toLineString = DomUtil.createButton( const toLineString = DomUtil.createButton(

View file

@ -363,6 +363,10 @@ export class DataLayer {
this.layer.addLayer(feature.ui) this.layer.addLayer(feature.ui)
} }
hideFeature(feature) {
this.layer.removeLayer(feature.ui)
}
addFeature(feature) { addFeature(feature) {
const id = stamp(feature) const id = stamp(feature)
feature.connectToDataLayer(this) feature.connectToDataLayer(this)
@ -377,7 +381,7 @@ export class DataLayer {
removeFeature(feature, sync) { removeFeature(feature, sync) {
const id = stamp(feature) const id = stamp(feature)
if (sync !== false) feature.sync.delete() if (sync !== false) feature.sync.delete()
this.layer.removeLayer(feature.ui) this.hideFeature(feature)
delete this.map.features_index[feature.getSlug()] delete this.map.features_index[feature.getSlug()]
feature.disconnectFromDataLayer(this) feature.disconnectFromDataLayer(this)
this._index.splice(this._index.indexOf(id), 1) this._index.splice(this._index.indexOf(id), 1)

View file

@ -5,15 +5,17 @@ import {
Polygon, Polygon,
DomUtil, DomUtil,
LineUtil, LineUtil,
latLng,
LatLngBounds,
} from '../../../vendors/leaflet/leaflet-src.esm.js' } from '../../../vendors/leaflet/leaflet-src.esm.js'
import { translate } from '../i18n.js' import { translate } from '../i18n.js'
import { uMapAlert as Alert } from '../../components/alerts/alert.js' import { uMapAlert as Alert } from '../../components/alerts/alert.js'
import * as Utils from '../utils.js' import * as Utils from '../utils.js'
const FeatureMixin = { const FeatureMixin = {
initialize: function (feature) { initialize: function (feature, latlngs) {
this.feature = feature this.feature = feature
this.parentClass.prototype.initialize.call(this, this.feature.coordinates) this.parentClass.prototype.initialize.call(this, latlngs)
}, },
onAdd: function (map) { onAdd: function (map) {
@ -134,7 +136,7 @@ const FeatureMixin = {
}, },
onCommit: function () { onCommit: function () {
this.geometryChanged(false) this.feature.pullGeometry(false)
this.feature.onCommit() this.feature.onCommit()
}, },
@ -145,16 +147,20 @@ export const LeafletMarker = Marker.extend({
parentClass: Marker, parentClass: Marker,
includes: [FeatureMixin], includes: [FeatureMixin],
initialize: function (feature) { initialize: function (feature, latlng) {
FeatureMixin.initialize.call(this, feature) FeatureMixin.initialize.call(this, feature, latlng)
this.setIcon(this.getIcon()) this.setIcon(this.getIcon())
}, },
geometryChanged: function (sync = true) { getClass: () => LeafletMarker,
this.feature.coordinates = this._latlng
if (sync) { // Make API consistent with path
this.feature.sync.update('geometry', this.feature.geometry) getLatLngs: function () {
} return this.getLatLng()
},
setLatLngs: function (latlng) {
return this.setLatLng(latlng)
}, },
addInteractions() { addInteractions() {
@ -162,7 +168,7 @@ export const LeafletMarker = Marker.extend({
this.on('dragend', (event) => { this.on('dragend', (event) => {
this.isDirty = true this.isDirty = true
this.feature.edit(event) this.feature.edit(event)
this.geometryChanged() this.feature.pullGeometry()
}) })
this.on('editable:drawing:commit', this.onCommit) this.on('editable:drawing:commit', this.onCommit)
if (!this.feature.isReadOnly()) this.on('mouseover', this._enableDragging) if (!this.feature.isReadOnly()) this.on('mouseover', this._enableDragging)
@ -265,10 +271,6 @@ const PathMixin = {
} }
}, },
geometryChanged: function () {
this.feature.coordinates = this._latlngs
},
addInteractions: function () { addInteractions: function () {
FeatureMixin.addInteractions.call(this) FeatureMixin.addInteractions.call(this)
this.on('editable:disable', this.onCommit) this.on('editable:disable', this.onCommit)
@ -385,20 +387,22 @@ const PathMixin = {
return items return items
}, },
isolateShape: function(atLatLng) { isolateShape: function (atLatLng) {
if (!this.feature.isMulti()) return if (!this.feature.isMulti()) return
const shape = this.enableEdit().deleteShapeAt(atLatLng) const shape = this.enableEdit().deleteShapeAt(atLatLng)
this.geometryChanged() this.feature.pullGeometry()
this.disableEdit() this.disableEdit()
if (!shape) return if (!shape) return
return this.feature.isolateShape(shape) return this.feature.isolateShape(shape)
} },
} }
export const LeafletPolyline = Polyline.extend({ export const LeafletPolyline = Polyline.extend({
parentClass: Polyline, parentClass: Polyline,
includes: [FeatureMixin, PathMixin], includes: [FeatureMixin, PathMixin],
getClass: () => LeafletPolyline,
getVertexActions: function (event) { getVertexActions: function (event) {
const actions = PathMixin.getVertexActions.call(this, event) const actions = PathMixin.getVertexActions.call(this, event)
const index = event.vertex.getIndex() const index = event.vertex.getIndex()
@ -455,6 +459,8 @@ export const LeafletPolygon = Polygon.extend({
parentClass: Polygon, parentClass: Polygon,
includes: [FeatureMixin, PathMixin], includes: [FeatureMixin, PathMixin],
getClass: () => LeafletPolygon,
getContextMenuEditItems: function (event) { getContextMenuEditItems: function (event) {
const items = PathMixin.getContextMenuEditItems.call(this, event) const items = PathMixin.getContextMenuEditItems.call(this, event)
const shape = this.shapeAt(event.latlng) const shape = this.shapeAt(event.latlng)
@ -482,3 +488,37 @@ export const LeafletPolygon = Polygon.extend({
this.enableEdit().newHole(event.latlng) this.enableEdit().newHole(event.latlng)
}, },
}) })
const WORLD = [
latLng([90, 180]),
latLng([90, -180]),
latLng([-90, -180]),
latLng([-90, 180]),
]
export const MaskPolygon = LeafletPolygon.extend({
getClass: () => MaskPolygon,
getLatLngs: function () {
// Exclude World coordinates.
return LeafletPolygon.prototype.getLatLngs.call(this).slice(1)
},
_setLatLngs: function (latlngs) {
const newLatLngs = []
newLatLngs.push(WORLD)
if (!this.feature.isMulti()) {
latlngs = [latlngs]
}
for (const ring of latlngs) {
newLatLngs.push(ring)
}
LeafletPolygon.prototype._setLatLngs.call(this, newLatLngs)
this._bounds = new LatLngBounds(latlngs)
},
_defaultShape: function () {
// Do not compute with world coordinates (eg. for centering the popup).
return this._latlngs[1]
},
})

View file

@ -287,6 +287,11 @@ export const SCHEMA = {
nullable: true, nullable: true,
label: translate('Display the measure control'), label: translate('Display the measure control'),
}, },
mask: {
type: Boolean,
impacts: ['data'],
label: translate('Display the polygon inverted'),
},
miniMap: { miniMap: {
type: Boolean, type: Boolean,
impacts: ['ui'], impacts: ['ui'],

View file

@ -1167,15 +1167,7 @@ U.Editable = L.Editable.extend({
this.on('editable:editing', (event) => { this.on('editable:editing', (event) => {
const layer = event.layer const layer = event.layer
layer.feature.isDirty = true layer.feature.isDirty = true
if (layer instanceof L.Marker) { layer.feature.fromLatLngs(layer.getLatLngs())
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) => { this.on('editable:vertex:ctrlclick', (event) => {
const index = event.vertex.getIndex() const index = event.vertex.getIndex()

View file

@ -420,3 +420,58 @@ def test_can_transform_polygon_to_line(live_server, page, tilelayer, settings):
data = save_and_get_json(page) data = save_and_get_json(page)
assert len(data["features"]) == 1 assert len(data["features"]) == 1
assert data["features"][0]["geometry"]["type"] == "LineString" assert data["features"][0]["geometry"]["type"] == "LineString"
def test_can_draw_a_polygon_and_invert_it(live_server, page, tilelayer, settings):
settings.UMAP_ALLOW_ANONYMOUS = True
page.goto(f"{live_server.url}/en/map/new/")
paths = page.locator(".leaflet-overlay-pane path")
expect(paths).to_have_count(0)
page.get_by_title("Draw a polygon").click()
map = page.locator("#map")
map.click(position={"x": 200, "y": 100})
map.click(position={"x": 200, "y": 200})
map.click(position={"x": 100, "y": 200})
map.click(position={"x": 100, "y": 100})
# Click again to finish
map.click(position={"x": 100, "y": 100})
expect(paths).to_have_count(1)
page.get_by_text("Advanced properties").click()
page.get_by_text("Display the polygon inverted").click()
data = save_and_get_json(page)
assert len(data["features"]) == 1
assert data["features"][0]["geometry"]["type"] == "Polygon"
assert data["features"][0]["geometry"]["coordinates"] == [
[
[
-7.668457,
54.457267,
],
[
-7.668457,
53.159947,
],
[
-9.865723,
53.159947,
],
[
-9.865723,
54.457267,
],
[
-7.668457,
54.457267,
],
],
]
page.get_by_role("button", name="View").click()
popup = page.locator(".leaflet-popup")
expect(popup).to_be_hidden()
# Now click on the middle of the polygon, it should not show the popup
map.click(position={"x": 150, "y": 150})
expect(popup).to_be_hidden()
# Click elsewhere on the map, it should now show the popup
map.click(position={"x": 250, "y": 250})
expect(popup).to_be_visible()