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.
This commit is contained in:
Alexis Métaireau 2024-04-19 20:53:32 +02:00
parent c9abb15dd1
commit 66105127cb
3 changed files with 97 additions and 33 deletions

View file

@ -18,7 +18,8 @@ U.FeatureMixin = {
}, },
onCommit: function () { onCommit: function () {
this.map.sync.upsert(this.getSyncSubject(), this.getSyncMetadata(), { const { subject, metadata } = this.getSyncMetadata()
this.map.sync.upsert(subject, metadata, {
geometry: this.getGeometry(), geometry: this.getGeometry(),
}) })
}, },
@ -39,7 +40,7 @@ U.FeatureMixin = {
this.map.sync.delete(subject, metadata) this.map.sync.delete(subject, metadata)
}, },
initialize: function (map, latlng, options) { initialize: function (map, latlng, options, id) {
this.map = map this.map = map
if (typeof options === 'undefined') { if (typeof options === 'undefined') {
options = {} options = {}
@ -47,6 +48,9 @@ U.FeatureMixin = {
// DataLayer the marker belongs to // DataLayer the marker belongs to
this.datalayer = options.datalayer || null this.datalayer = options.datalayer || null
this.properties = { _umap_options: {} } this.properties = { _umap_options: {} }
if (id) {
this.id = id
} else {
let geojson_id let geojson_id
if (options.geojson) { if (options.geojson) {
this.populate(options.geojson) this.populate(options.geojson)
@ -59,6 +63,7 @@ U.FeatureMixin = {
} else { } else {
this.id = U.Utils.generateId() this.id = U.Utils.generateId()
} }
}
let isDirty = false let isDirty = false
const self = this const self = this
try { try {
@ -136,6 +141,7 @@ U.FeatureMixin = {
this.view() this.view()
} }
} }
this._redraw()
}, },
openPopup: function () { openPopup: function () {

View file

@ -1010,8 +1010,6 @@ U.DataLayer = L.Evented.extend({
const features = geojson instanceof Array ? geojson : geojson.features const features = geojson instanceof Array ? geojson : geojson.features
let i let i
let len let len
let latlng
let latlngs
if (features) { if (features) {
U.Utils.sortFeatures(features, this.map.getOption('sortKey'), L.lang) 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 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. if (!geometry) return // null geometry is valid geojson.
const coords = geometry.coordinates const coords = geometry.coordinates
let layer let latlng, latlngs
let tmp
// Create a default geojson if none is provided
geojson ??= { type: 'Feature', geometry: geometry }
switch (geometry.type) { switch (geometry.type) {
case 'Point': case 'Point':
@ -1035,8 +1065,11 @@ U.DataLayer = L.Evented.extend({
console.error('Invalid latlng object from', coords) console.error('Invalid latlng object from', coords)
break break
} }
layer = this._pointToLayer(geojson, latlng) if (feature) {
break feature.setLatLng(latlng)
return feature
}
return this._pointToLayer(geojson, latlng, id)
case 'MultiLineString': case 'MultiLineString':
case 'LineString': case 'LineString':
@ -1045,14 +1078,20 @@ U.DataLayer = L.Evented.extend({
geometry.type === 'LineString' ? 0 : 1 geometry.type === 'LineString' ? 0 : 1
) )
if (!latlngs.length) break if (!latlngs.length) break
layer = this._lineToLayer(geojson, latlngs) if (feature) {
break feature.setLatLngs(latlngs)
return feature
}
return this._lineToLayer(geojson, latlngs, id)
case 'MultiPolygon': case 'MultiPolygon':
case 'Polygon': case 'Polygon':
latlngs = L.GeoJSON.coordsToLatLngs(coords, geometry.type === 'Polygon' ? 1 : 2) latlngs = L.GeoJSON.coordsToLatLngs(coords, geometry.type === 'Polygon' ? 1 : 2)
layer = this._polygonToLayer(geojson, latlngs) if (feature) {
break feature.setLatLngs(latlngs)
return feature
}
return this._polygonToLayer(geojson, latlngs, id)
case 'GeometryCollection': case 'GeometryCollection':
return this.geojsonToFeatures(geometry.geometries) return this.geojsonToFeatures(geometry.geometries)
@ -1064,14 +1103,10 @@ U.DataLayer = L.Evented.extend({
level: 'error', level: 'error',
}) })
} }
if (layer) {
this.addLayer(layer)
return layer
}
}, },
_pointToLayer: function (geojson, latlng) { _pointToLayer: function (geojson, latlng, id) {
return new U.Marker(this.map, latlng, { geojson: geojson, datalayer: this }) return new U.Marker(this.map, latlng, { geojson: geojson, datalayer: this }, id)
}, },
_lineToLayer: function (geojson, latlngs) { _lineToLayer: function (geojson, latlngs) {
@ -1544,6 +1579,17 @@ U.DataLayer = L.Evented.extend({
return this._layers[id] 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) { getNextFeature: function (feature) {
const id = this._index.indexOf(L.stamp(feature)) const id = this._index.indexOf(L.stamp(feature))
const nextId = this._index[id + 1] const nextId = this._index[id + 1]

View file

@ -8,7 +8,7 @@ import django
import websockets import websockets
from django.conf import settings from django.conf import settings
from django.core.signing import TimestampSigner from django.core.signing import TimestampSigner
from pydantic import BaseModel from pydantic import BaseModel, ValidationError
from websockets import WebSocketClientProtocol from websockets import WebSocketClientProtocol
from websockets.server import serve from websockets.server import serve
@ -32,6 +32,15 @@ class JoinMessage(BaseModel):
token: str token: str
class Geometry(BaseModel):
type: Literal["Point",]
coordinates: list
class GeometryValue(BaseModel):
geometry: Geometry
# FIXME better define the different messages # FIXME better define the different messages
# to ensure only relying valid ones. # to ensure only relying valid ones.
class OperationMessage(BaseModel): class OperationMessage(BaseModel):
@ -39,8 +48,8 @@ class OperationMessage(BaseModel):
verb: str = Literal["upsert", "update", "delete"] verb: str = Literal["upsert", "update", "delete"]
subject: str = Literal["map", "layer", "feature"] subject: str = Literal["map", "layer", "feature"]
metadata: Optional[dict] = None metadata: Optional[dict] = None
key: str key: Optional[str] = None
value: Optional[str] value: Optional[str | bool | int | GeometryValue]
async def join_and_listen( async def join_and_listen(
@ -58,9 +67,12 @@ async def join_and_listen(
# recompute the peers-list at the time of message-sending. # recompute the peers-list at the time of message-sending.
# as doing so beforehand would miss new connections # as doing so beforehand would miss new connections
peers = CONNECTIONS[map_id] - {websocket} peers = CONNECTIONS[map_id] - {websocket}
# Only relay valid "operation" messages # Only relay valid "operation" messages
try:
OperationMessage.model_validate_json(raw_message) OperationMessage.model_validate_json(raw_message)
except ValidationError as e:
print(raw_message, e)
websockets.broadcast(peers, raw_message) websockets.broadcast(peers, raw_message)
finally: finally:
CONNECTIONS[map_id].remove(websocket) CONNECTIONS[map_id].remove(websocket)