From 66105127cba5c6566bbaffabbb76fdd1f9d244f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Fri, 19 Apr 2024 20:53:32 +0200 Subject: [PATCH] feat(sync): Sync features over websockets Added a new `geometryToFeature` method in `umap.layer.js` which can update a given geometry if needed. A new `id` property can also be passed to the features on creation, to make it possible to have the same features `id` on different peers. --- umap/static/umap/js/umap.features.js | 30 ++++++----- umap/static/umap/js/umap.layer.js | 78 ++++++++++++++++++++++------ umap/ws.py | 22 ++++++-- 3 files changed, 97 insertions(+), 33 deletions(-) diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index 7c53cd94..1a42d724 100644 --- a/umap/static/umap/js/umap.features.js +++ b/umap/static/umap/js/umap.features.js @@ -18,7 +18,8 @@ U.FeatureMixin = { }, onCommit: function () { - this.map.sync.upsert(this.getSyncSubject(), this.getSyncMetadata(), { + const { subject, metadata } = this.getSyncMetadata() + this.map.sync.upsert(subject, metadata, { geometry: this.getGeometry(), }) }, @@ -39,7 +40,7 @@ U.FeatureMixin = { this.map.sync.delete(subject, metadata) }, - initialize: function (map, latlng, options) { + initialize: function (map, latlng, options, id) { this.map = map if (typeof options === 'undefined') { options = {} @@ -47,17 +48,21 @@ U.FeatureMixin = { // DataLayer the marker belongs to this.datalayer = options.datalayer || null this.properties = { _umap_options: {} } - let geojson_id - if (options.geojson) { - this.populate(options.geojson) - geojson_id = options.geojson.id - } - - // Each feature needs an unique identifier - if (U.Utils.checkId(geojson_id)) { - this.id = geojson_id + if (id) { + this.id = id } else { - this.id = U.Utils.generateId() + let geojson_id + if (options.geojson) { + this.populate(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 const self = this @@ -136,6 +141,7 @@ U.FeatureMixin = { this.view() } } + this._redraw() }, openPopup: function () { diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 798b098c..d3c6d671 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -1010,8 +1010,6 @@ U.DataLayer = L.Evented.extend({ const features = geojson instanceof Array ? geojson : geojson.features let i let len - let latlng - let latlngs if (features) { U.Utils.sortFeatures(features, this.map.getOption('sortKey'), L.lang) @@ -1022,10 +1020,42 @@ U.DataLayer = L.Evented.extend({ } const geometry = geojson.type === 'Feature' ? geojson.geometry : geojson + + let feature = this.geometryToFeature({ geometry, geojson }) + if (feature) { + this.addLayer(feature) + return feature + } + }, + + /** + * 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. + */ + geometryToFeature: function ({ + geometry, + geojson = null, + id = null, + feature = null, + } = {}) { if (!geometry) return // null geometry is valid geojson. const coords = geometry.coordinates - let layer - let tmp + let latlng, latlngs + + // Create a default geojson if none is provided + geojson ??= { type: 'Feature', geometry: geometry } switch (geometry.type) { case 'Point': @@ -1035,8 +1065,11 @@ U.DataLayer = L.Evented.extend({ console.error('Invalid latlng object from', coords) break } - layer = this._pointToLayer(geojson, latlng) - break + if (feature) { + feature.setLatLng(latlng) + return feature + } + return this._pointToLayer(geojson, latlng, id) case 'MultiLineString': case 'LineString': @@ -1045,14 +1078,20 @@ U.DataLayer = L.Evented.extend({ geometry.type === 'LineString' ? 0 : 1 ) if (!latlngs.length) break - layer = this._lineToLayer(geojson, latlngs) - break + if (feature) { + feature.setLatLngs(latlngs) + return feature + } + return this._lineToLayer(geojson, latlngs, id) case 'MultiPolygon': case 'Polygon': latlngs = L.GeoJSON.coordsToLatLngs(coords, geometry.type === 'Polygon' ? 1 : 2) - layer = this._polygonToLayer(geojson, latlngs) - break + if (feature) { + feature.setLatLngs(latlngs) + return feature + } + return this._polygonToLayer(geojson, latlngs, id) case 'GeometryCollection': return this.geojsonToFeatures(geometry.geometries) @@ -1064,14 +1103,10 @@ U.DataLayer = L.Evented.extend({ level: 'error', }) } - if (layer) { - this.addLayer(layer) - return layer - } }, - _pointToLayer: function (geojson, latlng) { - return new U.Marker(this.map, latlng, { geojson: geojson, datalayer: this }) + _pointToLayer: function (geojson, latlng, id) { + return new U.Marker(this.map, latlng, { geojson: geojson, datalayer: this }, id) }, _lineToLayer: function (geojson, latlngs) { @@ -1544,6 +1579,17 @@ U.DataLayer = L.Evented.extend({ return this._layers[id] }, + // TODO Add an index + // For now, iterate on all the features. + getFeatureById: function (id) { + for (const i in this._layers) { + let feature = this._layers[i] + if (feature.id == id) { + return feature + } + } + }, + getNextFeature: function (feature) { const id = this._index.indexOf(L.stamp(feature)) const nextId = this._index[id + 1] diff --git a/umap/ws.py b/umap/ws.py index 41601f4c..a6a51cfd 100644 --- a/umap/ws.py +++ b/umap/ws.py @@ -8,7 +8,7 @@ import django import websockets from django.conf import settings from django.core.signing import TimestampSigner -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from websockets import WebSocketClientProtocol from websockets.server import serve @@ -32,6 +32,15 @@ class JoinMessage(BaseModel): token: str +class Geometry(BaseModel): + type: Literal["Point",] + coordinates: list + + +class GeometryValue(BaseModel): + geometry: Geometry + + # FIXME better define the different messages # to ensure only relying valid ones. class OperationMessage(BaseModel): @@ -39,8 +48,8 @@ class OperationMessage(BaseModel): verb: str = Literal["upsert", "update", "delete"] subject: str = Literal["map", "layer", "feature"] metadata: Optional[dict] = None - key: str - value: Optional[str] + key: Optional[str] = None + value: Optional[str | bool | int | GeometryValue] async def join_and_listen( @@ -58,9 +67,12 @@ async def join_and_listen( # recompute the peers-list at the time of message-sending. # as doing so beforehand would miss new connections peers = CONNECTIONS[map_id] - {websocket} - # Only relay valid "operation" messages - OperationMessage.model_validate_json(raw_message) + try: + OperationMessage.model_validate_json(raw_message) + except ValidationError as e: + print(raw_message, e) + websockets.broadcast(peers, raw_message) finally: CONNECTIONS[map_id].remove(websocket)