diff --git a/umap/static/umap/js/modules/global.js b/umap/static/umap/js/modules/global.js index 813fe651..d158a40b 100644 --- a/umap/static/umap/js/modules/global.js +++ b/umap/static/umap/js/modules/global.js @@ -13,31 +13,34 @@ import { AjaxAutocomplete, AjaxAutocompleteMultiple } from './autocomplete.js' import Orderable from './orderable.js' import Importer from './importer.js' import Help from './help.js' +import { SyncEngine } from './sync/engine.js' // Import modules and export them to the global scope. // For the not yet module-compatible JS out there. +// By alphabetic order window.U = { - URLs, - Request, - ServerRequest, - RequestError, - HTTPError, - NOKError, - Browser, - Facets, - Panel, Alert, - Dialog, - Tooltip, - EditPanel, - FullPanel, - Utils, - SCHEMA, - Importer, - Orderable, - Caption, AjaxAutocomplete, AjaxAutocompleteMultiple, + Browser, + Caption, + Dialog, + EditPanel, + Facets, + FullPanel, Help, + HTTPError, + Importer, + NOKError, + Orderable, + Panel, + Request, + RequestError, + SCHEMA, + ServerRequest, + SyncEngine, + Tooltip, + URLs, + Utils, } diff --git a/umap/static/umap/js/modules/sync/engine.js b/umap/static/umap/js/modules/sync/engine.js new file mode 100644 index 00000000..7c77ef27 --- /dev/null +++ b/umap/static/umap/js/modules/sync/engine.js @@ -0,0 +1,91 @@ +import { WebSocketTransport } from './websocket.js' +import { + MapUpdater, + MarkerUpdater, + PolygonUpdater, + PolylineUpdater, + DatalayerUpdater, +} from './updaters.js' + +export class SyncEngine { + constructor(map, webSocketURI, authToken) { + this.map = map + this.receiver = new MessagesDispatcher(this.map) + this.transport = new WebSocketTransport(webSocketURI, authToken, this.receiver) + this.sender = new MessagesSender(this.transport) + + this.upsert = this.sender.upsert.bind(this.sender) + this.update = this.sender.update.bind(this.sender) + this.delete = this.sender.delete.bind(this.sender) + } +} + +export class MessagesDispatcher { + constructor(map) { + this.map = map + this.updaters = { + map: new MapUpdater(this.map), + marker: new MarkerUpdater(this.map), + polyline: new PolylineUpdater(this.map), + polygon: new PolygonUpdater(this.map), + datalayer: new DatalayerUpdater(this.map), + } + } + + getUpdater(subject, metadata) { + switch (subject) { + case 'feature': + const featureTypeExists = Object.keys(this.updaters).includes( + metadata.featureType + ) + if (featureTypeExists) { + const updater = this.updaters[metadata.featureType] + console.log(`found updater ${metadata.featureType}, ${updater}`) + return updater + } + case 'map': + case 'datalayer': + return this.updaters[subject] + default: + throw new Error(`Unknown updater ${subject}, ${metadata}`) + } + } + + dispatch({ kind, payload }) { + console.log(kind, payload) + if (kind == 'sync-protocol') { + let updater = this.getUpdater(payload.subject, payload.metadata) + updater.applyMessage(payload) + } + } +} + +/** + * Sends the message to the other party (using the specified transport): + * + * - `subject` is the type of object this is referering to (map, feature, layer) + * - `metadata` contains information about the object we're refering to (id, layerId for instance) + * - `key` and + * - `value` are the keys and values that are being modified. + */ +export class MessagesSender { + constructor(transport) { + this._transport = transport + } + + send(message) { + this._transport.send('sync-protocol', message) + } + + upsert(subject, metadata, value) { + this.send({ verb: 'upsert', subject, metadata, value }) + } + + update(subject, metadata, key, value) { + this.send({ verb: 'update', subject, metadata, key, value }) + } + + delete(subject, metadata, key) { + this.send({ verb: 'delete', subject, metadata, key }) + } +} diff --git a/umap/static/umap/js/modules/sync/updaters.js b/umap/static/umap/js/modules/sync/updaters.js new file mode 100644 index 00000000..62a13f6a --- /dev/null +++ b/umap/static/umap/js/modules/sync/updaters.js @@ -0,0 +1,131 @@ +/** + * This file contains the updaters: classes that are able to convert messages + * received from another party (or the server) to changes on the map. + */ + +class BaseUpdater { + constructor(map) { + this.map = map + } + + updateObjectValue(obj, key, value) { + // XXX refactor so it's cleaner + let path = key.split('.') + let what + for (var i = 0, l = path.length; i < l; i++) { + what = path[i] + if (what === path[l - 1]) { + if (typeof value === 'undefined') { + delete obj[what] + } else { + obj[what] = value + } + } else { + obj = obj[what] + } + } + } + + getLayerFromID(layerId) { + if (layerId) return this.map.getDataLayerByUmapId(layerId) + return this.map.defaultEditDataLayer() + } + + applyMessage(message) { + let { verb } = message + return this[verb](message) + } +} + +export class MapUpdater extends BaseUpdater { + update({ key, value }) { + console.log(key, value) + this.updateObjectValue(this.map, key, value) + this.map.renderProperties([key.replace('options.', '')]) + } +} + +export class DatalayerUpdater extends BaseUpdater { + update({ key, metadata, value }) { + const datalayer = this.getLayerFromID(metadata.id) + console.log(datalayer, key, value) + this.updateObjectValue(datalayer, key, value) + const property = key.replace('options.', '') + datalayer.renderProperties([property]) + } +} + +/** + * This is an abstract base class + * And needs to be subclassed to be used. + * + * The child classes need to expose: + * - `featureClass`: the name of the class to create the feature + * - `featureArgument`: an object with the properties to pass to the class when bulding it. + **/ +class FeatureUpdater extends BaseUpdater { + getFeatureFromMetadata({ id, layerId }) { + const datalayer = this.getLayerFromID(layerId) + return datalayer.getFeatureById(id) + } + + // XXX Not sure about the naming. It's returning latlng OR latlngS + getGeometry({ type, coordinates }) { + if (type == 'Point') { + return L.GeoJSON.coordsToLatLng(coordinates) + } + return L.GeoJSON.coordsToLatLngs(coordinates) + } + + upsert({ metadata, value }) { + let { id, layerId } = metadata + const datalayer = this.getLayerFromID(layerId) + let feature = this.getFeatureFromMetadata(metadata, value) + feature = datalayer.geometryToFeature({ geometry: value.geometry, id, feature }) + feature.addTo(datalayer) + } + + update({ key, metadata, value }) { + let feature = this.getFeatureFromMetadata(metadata) + + switch (key) { + case 'geometry': + const datalayer = this.getLayerFromID(metadata.layerId) + datalayer.geometryToFeature({ geometry: value, id: metadata.id, feature }) + default: + this.updateObjectValue(feature, key, value) + } + + feature.datalayer.indexProperties(feature) + feature.renderProperties([key]) + } + + delete({ metadata }) { + // XXX Distinguish between properties getting deleted + // and the wole feature getting deleted + let feature = this.getFeatureFromMetadata(metadata) + if (feature) feature.del() + } +} + +class PathUpdater extends FeatureUpdater {} + +class MarkerUpdater extends FeatureUpdater { + featureType = 'marker' + featureClass = U.Marker + featureArgument = 'latlng' +} + +class PolygonUpdater extends PathUpdater { + featureType = 'polygon' + featureClass = U.Polygon + featureArgument = 'latlngs' +} + +class PolylineUpdater extends PathUpdater { + featureType = 'polyline' + featureClass = U.Polyline + featureArgument = 'latlngs' +} + +export { MarkerUpdater, PolygonUpdater, PolylineUpdater } diff --git a/umap/static/umap/js/modules/sync/websocket.js b/umap/static/umap/js/modules/sync/websocket.js new file mode 100644 index 00000000..fa103119 --- /dev/null +++ b/umap/static/umap/js/modules/sync/websocket.js @@ -0,0 +1,22 @@ +export class WebSocketTransport { + constructor(webSocketURI, authToken, messagesReceiver) { + this.websocket = new WebSocket(webSocketURI) + this.websocket.onopen = () => { + this.send('join', { token: authToken }) + } + this.websocket.addEventListener('message', this.onMessage.bind(this)) + this.receiver = messagesReceiver + } + + onMessage(wsMessage) { + // XXX validate incoming data. + this.receiver.dispatch(JSON.parse(wsMessage.data)) + } + + send(kind, payload) { + const message = { ...payload } + message.kind = kind + let encoded = JSON.stringify(message) + this.websocket.send(encoded) + } +} diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index c9ea9d0c..d8d6a839 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -248,6 +248,17 @@ U.Map = L.Map.extend({ this.backup() this.initContextMenu() this.on('click contextmenu.show', this.closeInplaceToolbar) + + Promise.resolve(this.getSyncEngine()) + }, + + getSyncEngine: async function () { + // Get the authentication token from the server + // And pass it to the sync engine. + const [response, _, error] = await this.server.get('/map/2/ws-token/') + if (!error) { + this.sync = new U.SyncEngine(this, 'ws://localhost:8001', response.token) + } }, render: function (fields) {