Skip to content

Commit

Permalink
feat(sync): Sync features over websockets
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
almet committed Apr 19, 2024
1 parent 6c66d9a commit d58a144
Show file tree
Hide file tree
Showing 3 changed files with 96 additions and 33 deletions.
29 changes: 17 additions & 12 deletions umap/static/umap/js/umap.features.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})
},
Expand All @@ -39,25 +40,29 @@ 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 = {}
}
// 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
Expand Down
78 changes: 62 additions & 16 deletions umap/static/umap/js/umap.layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -1006,8 +1006,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)
Expand All @@ -1018,10 +1016,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':
Expand All @@ -1031,8 +1061,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':
Expand All @@ -1041,14 +1074,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)

Expand All @@ -1060,14 +1099,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) {
Expand Down Expand Up @@ -1532,6 +1567,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]
Expand Down
22 changes: 17 additions & 5 deletions umap/ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
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

Expand All @@ -32,15 +32,24 @@ 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):
kind: str = "operation"
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(
Expand All @@ -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)
Expand Down

0 comments on commit d58a144

Please sign in to comment.