feat(sync): sync features and map properties

Synced objects now expose different methods, such as:
- `getSyncEngine` which returns the location of the sync object.
- `getMetadata` which returns the associated metadata with the object.

Hooks have been added when features are created or changed, so the
changes can be synced with other peers.
This commit is contained in:
Alexis Métaireau 2024-04-19 17:15:12 +02:00
parent f255c3c8a5
commit 698c926997
6 changed files with 67 additions and 7 deletions

View file

@ -53,7 +53,7 @@ export class MessagesDispatcher {
dispatch({ kind, payload }) { dispatch({ kind, payload }) {
console.log(kind, payload) console.log(kind, payload)
if (kind == 'sync-protocol') { if (kind == 'operation') {
let updater = this.getUpdater(payload.subject, payload.metadata) let updater = this.getUpdater(payload.subject, payload.metadata)
updater.applyMessage(payload) updater.applyMessage(payload)
} }
@ -74,7 +74,7 @@ export class MessagesSender {
} }
send(message) { send(message) {
this._transport.send('sync-protocol', message) this._transport.send('operation', message)
} }
upsert(subject, metadata, value) { upsert(subject, metadata, value) {

View file

@ -1263,6 +1263,7 @@ U.Editable = L.Editable.extend({
this.on('editable:drawing:commit', function (e) { this.on('editable:drawing:commit', function (e) {
e.layer.isDirty = true e.layer.isDirty = true
if (this.map.editedFeature !== e.layer) e.layer.edit(e) if (this.map.editedFeature !== e.layer) e.layer.edit(e)
e.layer.onCommit()
}) })
this.on('editable:editing', (e) => { this.on('editable:editing', (e) => {
const layer = e.layer const layer = e.layer

View file

@ -1,6 +1,44 @@
U.FeatureMixin = { U.FeatureMixin = {
staticOptions: { mainColor: 'color' }, staticOptions: { mainColor: 'color' },
getSyncMetadata: function () {
return {
subject: 'feature',
metadata: {
id: this.id,
layerId: this.datalayer?.id || null,
featureType: this.getClassName(),
},
}
},
getSyncEngine: function () {
// FIXME use a get property / defineProperty
return this.map.sync
},
onCommit: function () {
this.map.sync.upsert(this.getSyncSubject(), this.getSyncMetadata(), {
geometry: this.getGeometry(),
})
},
getGeometry: function () {
return this.toGeoJSON().geometry
},
syncUpdatedProperties: function (properties) {
if ('latlng'.includes(properties)) {
const { subject, metadata } = this.getSyncMetadata()
this.map.sync.update(subject, metadata, 'geometry', this.getGeometry())
}
},
syncDelete: function () {
let { subject, metadata } = this.getSyncMetadata()
this.map.sync.delete(subject, metadata)
},
initialize: function (map, latlng, options) { initialize: function (map, latlng, options) {
this.map = map this.map = map
if (typeof options === 'undefined') { if (typeof options === 'undefined') {
@ -241,12 +279,15 @@ U.FeatureMixin = {
return false return false
}, },
del: function () { del: function (fromSync) {
this.isDirty = true this.isDirty = true
this.map.closePopup() this.map.closePopup()
if (this.datalayer) { if (this.datalayer) {
this.datalayer.removeLayer(this) this.datalayer.removeLayer(this)
this.disconnectFromDataLayer(this.datalayer) this.disconnectFromDataLayer(this.datalayer)
// Do not relay the event if we received it.
if (!fromSync) this.syncDelete()
} }
}, },
@ -602,6 +643,7 @@ U.Marker = L.Marker.extend({
function (e) { function (e) {
this.isDirty = true this.isDirty = true
this.edit(e) this.edit(e)
this.syncUpdatedProperties(['latlng'])
}, },
this this
) )

View file

@ -1186,6 +1186,9 @@ U.FormBuilder = L.FormBuilder.extend({
L.FormBuilder.prototype.setter.call(this, field, value) L.FormBuilder.prototype.setter.call(this, field, value)
this.obj.isDirty = true this.obj.isDirty = true
if ('render' in this.obj) this.obj.render([field], this) if ('render' in this.obj) this.obj.render([field], this)
const syncEngine = this.obj.getSyncEngine()
const { subject, metadata } = this.obj.getSyncMetadata()
syncEngine.update(subject, metadata, field, value)
}, },
finish: function () { finish: function () {

View file

@ -249,10 +249,10 @@ U.Map = L.Map.extend({
this.initContextMenu() this.initContextMenu()
this.on('click contextmenu.show', this.closeInplaceToolbar) this.on('click contextmenu.show', this.closeInplaceToolbar)
Promise.resolve(this.getSyncEngine()) Promise.resolve(this.initSyncEngine())
}, },
getSyncEngine: async function () { initSyncEngine: async function () {
// Get the authentication token from the server // Get the authentication token from the server
// And pass it to the sync engine. // And pass it to the sync engine.
const [response, _, error] = await this.server.get('/map/2/ws-token/') const [response, _, error] = await this.server.get('/map/2/ws-token/')
@ -261,6 +261,16 @@ U.Map = L.Map.extend({
} }
}, },
getSyncMetadata: function () {
return {
subject: 'map',
}
},
getSyncEngine: function () {
return this.sync
},
render: function (fields) { render: function (fields) {
let impacts = U.Utils.getImpactsFromSchema(fields) let impacts = U.Utils.getImpactsFromSchema(fields)

View file

@ -2,7 +2,7 @@
import asyncio import asyncio
from collections import defaultdict from collections import defaultdict
from typing import Literal from typing import Literal, Optional
import django import django
import websockets import websockets
@ -32,11 +32,15 @@ class JoinMessage(BaseModel):
token: str token: str
# FIXME better define the different messages
# to ensure only relying valid ones.
class OperationMessage(BaseModel): class OperationMessage(BaseModel):
kind: str = "operation" kind: str = "operation"
verb: str = Literal["upsert", "update", "delete"]
subject: str = Literal["map", "layer", "feature"] subject: str = Literal["map", "layer", "feature"]
metadata: dict metadata: dict
data: dict key: str
value: Optional[str]
async def join_and_listen( async def join_and_listen(