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 () {
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 () {

View file

@ -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]

View file

@ -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)