wip: refactor DataLayer.geojsonToFeatures and fix sync tests

This commit is contained in:
Yohan Boniface 2024-07-26 11:18:07 +02:00
parent 081323dc8a
commit a022619625
8 changed files with 92 additions and 143 deletions

View file

@ -75,6 +75,15 @@ class Feature {
return this.ui.getBounds() return this.ui.getBounds()
} }
get geometry() {
return this._geometry
}
set geometry(value) {
this._geometry = value
this.geometryChanged()
}
getClassName() { getClassName() {
return this.staticOptions.className return this.staticOptions.className
} }
@ -112,10 +121,6 @@ class Feature {
this.sync.upsert(this.toGeoJSON()) this.sync.upsert(this.toGeoJSON())
} }
getGeometry() {
return this.toGeoJSON().geometry
}
isReadOnly() { isReadOnly() {
return this.datalayer?.isDataReadOnly() return this.datalayer?.isDataReadOnly()
} }
@ -333,7 +338,7 @@ class Feature {
} }
populate(geojson) { populate(geojson) {
this.geometry = geojson.geometry this._geometry = geojson.geometry
this.properties = Object.fromEntries( this.properties = Object.fromEntries(
Object.entries(geojson.properties || {}).map(this.cleanProperty) Object.entries(geojson.properties || {}).map(this.cleanProperty)
) )
@ -494,13 +499,13 @@ class Feature {
} }
clone() { clone() {
const geoJSON = this.toGeoJSON() const geojson = this.toGeoJSON()
delete geoJSON.id delete geojson.id
delete geoJSON.properties.id delete geojson.properties.id
const layer = this.datalayer.geojsonToFeatures(geoJSON) const feature = this.datalayer.makeFeature(geojson)
layer.isDirty = true feature.isDirty = true
layer.edit() feature.edit()
return layer return feature
} }
extendedProperties() { extendedProperties() {
@ -552,6 +557,10 @@ export class Point extends Feature {
this.geometry.coordinates = GeoJSON.latLngToCoords(latlng) this.geometry.coordinates = GeoJSON.latLngToCoords(latlng)
} }
geometryChanged() {
this.ui.setLatLng(this.coordinates)
}
makeUI() { makeUI() {
return new LeafletMarker(this) return new LeafletMarker(this)
} }
@ -628,6 +637,10 @@ class Path extends Feature {
this.geometry.type = type 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.
@ -814,7 +827,7 @@ export class LineString extends Path {
delete geojson.id // delete the copied id, a new one will be generated. delete geojson.id // delete the copied id, a new one will be generated.
const polygon = this.datalayer.geojsonToFeatures(geojson) const polygon = this.datalayer.makeFeature(geojson)
polygon.edit() polygon.edit()
this.del() this.del()
} }
@ -957,7 +970,7 @@ export class Polygon extends Path {
geojson.geometry.coordinates = Utils.flattenCoordinates( geojson.geometry.coordinates = Utils.flattenCoordinates(
geojson.geometry.coordinates geojson.geometry.coordinates
) )
const polyline = this.datalayer.geojsonToFeatures(geojson) const polyline = this.datalayer.makeFeature(geojson)
polyline.edit() polyline.edit()
this.del() this.del()
} }

View file

@ -375,7 +375,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.layer.removeLayer(feature.ui)
delete this.map.features_index[feature.getSlug()] delete this.map.features_index[feature.getSlug()]
feature.disconnectFromDataLayer(this) feature.disconnectFromDataLayer(this)
@ -414,135 +414,56 @@ export class DataLayer {
try { try {
// Do not fail if remote data is somehow invalid, // Do not fail if remote data is somehow invalid,
// otherwise the layer becomes uneditable. // otherwise the layer becomes uneditable.
this.geojsonToFeatures(geojson, sync) this.makeFeatures(geojson, sync)
} catch (err) { } catch (err) {
console.log('Error with DataLayer', this.umap_id) console.log('Error with DataLayer', this.umap_id)
console.error(err) console.error(err)
} }
} }
// The choice of the name is not ours, because it is required by Leaflet. makeFeatures(geojson = {}, sync = true) {
// It is misleading, as the returned objects are uMap objects, and not if (geojson.type === 'Feature' || geojson.coordinates) {
// GeoJSON features. geojson = [geojson]
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" ?
} }
const collection = Array.isArray(geojson)
const geometry = geojson.type === 'Feature' ? geojson.geometry : geojson ? geojson
: geojson.features || geojson.geometries
const feature = this.geoJSONToLeaflet({ geometry, geojson }) Utils.sortFeatures(collection, this.map.getOption('sortKey'), L.lang)
if (feature) { for (const feature of collection) {
this.addFeature(feature) this.makeFeature(feature, sync)
if (sync) feature.onCommit()
return feature
} }
} }
/** makeFeature(geojson = {}, sync = true, id = null) {
* Create or update Leaflet features from GeoJSON geometries. const geometry = geojson.geometry || geojson
* let feature
* 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 }
switch (geometry.type) { switch (geometry.type) {
case 'Point': case 'Point':
try { // FIXME: deal with MutliPoint
latlng = GeoJSON.coordsToLatLng(coords) feature = new Point(this, geojson, id)
} catch (e) { break
console.error('Invalid latlng object from', coords)
break
}
if (feature) {
feature.coordinates = latlng
return feature
}
return new Point(this, geojson)
case 'MultiLineString': case 'MultiLineString':
case 'LineString': case 'LineString':
latlngs = GeoJSON.coordsToLatLngs( feature = new LineString(this, geojson, id)
coords, break
geometry.type === 'LineString' ? 0 : 1
)
if (!latlngs.length) break
if (feature) {
feature.coordinates = latlngs
return feature
}
return new LineString(this, geojson)
case 'MultiPolygon': case 'MultiPolygon':
case 'Polygon': case 'Polygon':
latlngs = GeoJSON.coordsToLatLngs(coords, geometry.type === 'Polygon' ? 1 : 2) feature = new Polygon(this, geojson, id)
if (feature) { break
feature.coordinates = latlngs
return feature
}
return new Polygon(this, geojson)
case 'GeometryCollection':
return this.geojsonToFeatures(geometry.geometries)
default: default:
console.log(geojson)
Alert.error( Alert.error(
translate('Skipping unknown geometry.type: {type}', { translate('Skipping unknown geometry.type: {type}', {
type: geometry.type || 'undefined', type: geometry.type || 'undefined',
}) })
) )
} }
} if (feature) {
this.addFeature(feature)
_pointToLayer(geojson, latlng, id) { if (sync) feature.onCommit()
return new U.Marker(this.map, latlng, { geojson: geojson, datalayer: this }, id) return feature
} }
_lineToLayer(geojson, latlngs, id) {
return new U.Polyline(
this.map,
latlngs,
{
geojson: geojson,
datalayer: this,
color: null,
},
id
)
}
_polygonToLayer(geojson, latlngs, id) {
// Ensure no empty hole
// for (let i = latlngs.length - 1; i > 0; i--) {
// if (!latlngs.slice()[i].length) latlngs.splice(i, 1);
// }
return new U.Polygon(this.map, latlngs, { geojson: geojson, datalayer: this }, id)
} }
async importRaw(raw, format) { async importRaw(raw, format) {
@ -554,7 +475,7 @@ export class DataLayer {
} }
importFromFiles(files, type) { importFromFiles(files, type) {
for (let i = 0, f; (f = files[i]); i++) { for (const f of files) {
this.importFromFile(f, type) this.importFromFile(f, type)
} }
} }

View file

@ -126,6 +126,7 @@ export const LeafletMarker = Marker.extend({
geometryChanged: function() { geometryChanged: function() {
this.feature.coordinates = this._latlng this.feature.coordinates = this._latlng
this.feature.sync.update('geometry', this.feature.geometry)
}, },
addInteractions() { addInteractions() {
@ -133,7 +134,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.feature.sync.update('geometry', this.feature.getGeometry()) this.geometryChanged()
}) })
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)
@ -251,7 +252,6 @@ const PathMixin = {
}, },
_onDrag: function () { _onDrag: function () {
this.geometryChanged()
if (this._tooltip) this._tooltip.setLatLng(this.getCenter()) if (this._tooltip) this._tooltip.setLatLng(this.getCenter())
}, },

View file

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

View file

@ -1022,7 +1022,7 @@ U.Search = L.PhotonSearch.extend({
L.DomEvent.on(edit, 'mousedown', (e) => { L.DomEvent.on(edit, 'mousedown', (e) => {
L.DomEvent.stop(e) L.DomEvent.stop(e)
const datalayer = this.map.defaultEditDataLayer() const datalayer = this.map.defaultEditDataLayer()
const layer = datalayer.geojsonToFeatures(feature) const layer = datalayer.makeFeature(feature)
layer.isDirty = true layer.isDirty = true
layer.edit() layer.edit()
}) })

View file

@ -27,10 +27,24 @@ def mock_osm_tiles(page):
@pytest.fixture @pytest.fixture
def page(context): def new_page(context):
page = context.new_page() def make_page(prefix="console"):
page.on("console", lambda msg: print(msg.text) if msg.type != "warning" else None) page = context.new_page()
return 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 @pytest.fixture

View file

@ -243,7 +243,9 @@ def test_can_transfer_shape_from_multi(live_server, page, tilelayer, settings):
data = save_and_get_json(page) data = save_and_get_json(page)
assert data["features"][0]["geometry"] == { assert data["features"][0]["geometry"] == {
"coordinates": [ "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": "LineString", "type": "LineString",
} }

View file

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