umap-project 2.6.3__py3-none-any.whl → 2.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of umap-project might be problematic. Click here for more details.
- umap/__init__.py +1 -1
- umap/admin.py +64 -1
- umap/asgi.py +15 -0
- umap/context_processors.py +1 -0
- umap/locale/cs_CZ/LC_MESSAGES/django.mo +0 -0
- umap/locale/cs_CZ/LC_MESSAGES/django.po +96 -92
- umap/locale/de/LC_MESSAGES/django.mo +0 -0
- umap/locale/de/LC_MESSAGES/django.po +19 -18
- umap/locale/en/LC_MESSAGES/django.po +47 -43
- umap/locale/es/LC_MESSAGES/django.mo +0 -0
- umap/locale/es/LC_MESSAGES/django.po +134 -128
- umap/locale/fr/LC_MESSAGES/django.mo +0 -0
- umap/locale/fr/LC_MESSAGES/django.po +51 -47
- umap/locale/pt/LC_MESSAGES/django.mo +0 -0
- umap/locale/pt/LC_MESSAGES/django.po +64 -60
- umap/management/commands/clean_tilelayer.py +152 -0
- umap/management/commands/purge_purgatory.py +28 -0
- umap/models.py +27 -2
- umap/settings/base.py +3 -1
- umap/static/umap/base.css +4 -4
- umap/static/umap/css/contextmenu.css +6 -1
- umap/static/umap/css/icon.css +7 -2
- umap/static/umap/css/importers.css +4 -0
- umap/static/umap/img/16-white.svg +9 -2
- umap/static/umap/img/16.svg +1 -181
- umap/static/umap/img/24-white.svg +1 -0
- umap/static/umap/img/24.svg +1 -0
- umap/static/umap/img/importers/cadastrefr.svg +23 -0
- umap/static/umap/img/source/16-white.svg +10 -3
- umap/static/umap/img/source/16.svg +753 -197
- umap/static/umap/img/source/24-white.svg +3 -2
- umap/static/umap/img/source/24.svg +3 -2
- umap/static/umap/js/modules/autocomplete.js +7 -3
- umap/static/umap/js/modules/browser.js +54 -1
- umap/static/umap/js/modules/caption.js +16 -5
- umap/static/umap/js/modules/data/features.js +176 -2
- umap/static/umap/js/modules/data/layer.js +57 -40
- umap/static/umap/js/modules/formatter.js +3 -2
- umap/static/umap/js/modules/global.js +2 -0
- umap/static/umap/js/modules/importer.js +3 -0
- umap/static/umap/js/modules/importers/cadastrefr.js +62 -0
- umap/static/umap/js/modules/importers/communesfr.js +15 -3
- umap/static/umap/js/modules/permissions.js +123 -93
- umap/static/umap/js/modules/rendering/layers/classified.js +2 -0
- umap/static/umap/js/modules/rendering/ui.js +60 -213
- umap/static/umap/js/modules/share.js +1 -3
- umap/static/umap/js/modules/slideshow.js +1 -1
- umap/static/umap/js/modules/sync/engine.js +371 -14
- umap/static/umap/js/modules/sync/hlc.js +106 -0
- umap/static/umap/js/modules/sync/updaters.js +18 -6
- umap/static/umap/js/modules/sync/websocket.js +1 -1
- umap/static/umap/js/modules/tableeditor.js +1 -1
- umap/static/umap/js/modules/ui/base.js +2 -2
- umap/static/umap/js/modules/ui/contextmenu.js +51 -18
- umap/static/umap/js/modules/urls.js +5 -1
- umap/static/umap/js/modules/utils.js +28 -4
- umap/static/umap/js/umap.controls.js +73 -52
- umap/static/umap/js/umap.core.js +3 -3
- umap/static/umap/js/umap.forms.js +3 -1
- umap/static/umap/js/umap.js +115 -124
- umap/static/umap/locale/br.js +13 -4
- umap/static/umap/locale/br.json +13 -4
- umap/static/umap/locale/ca.js +28 -15
- umap/static/umap/locale/ca.json +28 -15
- umap/static/umap/locale/cs_CZ.js +87 -78
- umap/static/umap/locale/cs_CZ.json +87 -78
- umap/static/umap/locale/de.js +17 -8
- umap/static/umap/locale/de.json +17 -8
- umap/static/umap/locale/en.js +13 -2
- umap/static/umap/locale/en.json +13 -2
- umap/static/umap/locale/es.js +330 -319
- umap/static/umap/locale/es.json +330 -319
- umap/static/umap/locale/eu.js +10 -3
- umap/static/umap/locale/eu.json +10 -3
- umap/static/umap/locale/fa_IR.js +11 -4
- umap/static/umap/locale/fa_IR.json +11 -4
- umap/static/umap/locale/fr.js +15 -4
- umap/static/umap/locale/fr.json +15 -4
- umap/static/umap/locale/hu.js +10 -3
- umap/static/umap/locale/hu.json +10 -3
- umap/static/umap/locale/pt.js +17 -8
- umap/static/umap/locale/pt.json +17 -8
- umap/static/umap/locale/pt_PT.js +13 -4
- umap/static/umap/locale/pt_PT.json +13 -4
- umap/static/umap/locale/zh_TW.js +13 -4
- umap/static/umap/locale/zh_TW.json +13 -4
- umap/static/umap/map.css +44 -29
- umap/static/umap/unittests/hlc.js +165 -0
- umap/static/umap/unittests/sync.js +321 -15
- umap/static/umap/unittests/utils.js +47 -0
- umap/static/umap/vars.css +2 -1
- umap/static/umap/vendors/colorbrewer/colorbrewer.js +309 -317
- umap/static/umap/vendors/dompurify/purify.es.js +15 -16
- umap/static/umap/vendors/dompurify/purify.es.mjs.map +1 -1
- umap/static/umap/vendors/georsstogeojson/GeoRSSToGeoJSON.js +111 -80
- umap/static/umap/vendors/locatecontrol/L.Control.Locate.min.js +2 -2
- umap/static/umap/vendors/locatecontrol/L.Control.Locate.min.js.map +1 -1
- umap/static/umap/vendors/simple-statistics/simple-statistics.min.js +1 -1
- umap/static/umap/vendors/simple-statistics/simple-statistics.min.js.map +1 -1
- umap/templates/umap/css.html +0 -2
- umap/templates/umap/dashboard_menu.html +4 -2
- umap/templates/umap/js.html +0 -5
- umap/templates/umap/map_detail.html +2 -2
- umap/tests/fixtures/test_upload_data.csv +2 -2
- umap/tests/integration/test_anonymous_owned_map.py +1 -0
- umap/tests/integration/test_basics.py +1 -1
- umap/tests/integration/test_browser.py +69 -7
- umap/tests/integration/test_caption.py +3 -3
- umap/tests/integration/test_circles_layer.py +12 -0
- umap/tests/integration/test_datalayer.py +2 -1
- umap/tests/integration/test_draw_polygon.py +17 -9
- umap/tests/integration/test_draw_polyline.py +12 -8
- umap/tests/integration/test_edit_datalayer.py +5 -8
- umap/tests/integration/test_edit_map.py +2 -2
- umap/tests/integration/test_edit_marker.py +1 -1
- umap/tests/integration/test_facets_browser.py +3 -3
- umap/tests/integration/test_import.py +1 -0
- umap/tests/integration/test_map.py +1 -0
- umap/tests/integration/test_owned_map.py +1 -1
- umap/tests/integration/test_view_marker.py +63 -0
- umap/tests/integration/test_view_polygon.py +12 -12
- umap/tests/integration/test_websocket_sync.py +65 -3
- umap/tests/test_clean_tilelayer.py +83 -0
- umap/tests/test_datalayer.py +24 -0
- umap/tests/test_map_views.py +20 -0
- umap/tests/test_purge_purgatory.py +25 -0
- umap/tests/test_websocket_server.py +22 -0
- umap/urls.py +5 -1
- umap/views.py +6 -3
- umap/websocket_server.py +130 -27
- {umap_project-2.6.3.dist-info → umap_project-2.7.0.dist-info}/METADATA +18 -14
- {umap_project-2.6.3.dist-info → umap_project-2.7.0.dist-info}/RECORD +135 -127
- umap/static/umap/vendors/contextmenu/leaflet.contextmenu.min.css +0 -1
- umap/static/umap/vendors/contextmenu/leaflet.contextmenu.min.js +0 -7
- {umap_project-2.6.3.dist-info → umap_project-2.7.0.dist-info}/WHEEL +0 -0
- {umap_project-2.6.3.dist-info → umap_project-2.7.0.dist-info}/entry_points.txt +0 -0
- {umap_project-2.6.3.dist-info → umap_project-2.7.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,46 @@
|
|
|
1
|
+
import * as Utils from '../utils.js'
|
|
2
|
+
import { HybridLogicalClock } from './hlc.js'
|
|
1
3
|
import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js'
|
|
2
4
|
import { WebSocketTransport } from './websocket.js'
|
|
3
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The syncEngine exposes an API to sync messages between peers over the network.
|
|
8
|
+
*
|
|
9
|
+
* It's taking care of initializing the `transport` layer (sending and receiving
|
|
10
|
+
* messages over websocket), the `operations` list (to store them locally),
|
|
11
|
+
* and the `updaters` to apply messages to the map.
|
|
12
|
+
*
|
|
13
|
+
* You can use the `update`, `upsert` and `delete` methods.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
*
|
|
17
|
+
* ```
|
|
18
|
+
* const sync = new SyncEngine(map)
|
|
19
|
+
*
|
|
20
|
+
* // Get the authentication token from the umap server
|
|
21
|
+
* sync.authenticate(tokenURI, webSocketURI, server)
|
|
22
|
+
*
|
|
23
|
+
* // Alternatively, start the engine manually with
|
|
24
|
+
* sync.start(webSocketURI, authToken)
|
|
25
|
+
*
|
|
26
|
+
* // Then use the `upsert`, `update` and `delete` methods.
|
|
27
|
+
* let {metadata, subject} = object.getSyncMetadata()
|
|
28
|
+
* sync.upsert(subject, metadata, "value")
|
|
29
|
+
* sync.update(subject, metadata, "key", "value")
|
|
30
|
+
* sync.delete(subject, metadata, "key")
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* A `proxy()` method is also exposed, making it easier to use without having
|
|
34
|
+
* to specify `subject` and `metadata` fields on each call:
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```
|
|
38
|
+
* // Or using the `proxy()` method:
|
|
39
|
+
* let syncProxy = sync.proxy(object)
|
|
40
|
+
* syncProxy.upsert("value")
|
|
41
|
+
* syncProxy.update("key", "value")
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
4
44
|
export class SyncEngine {
|
|
5
45
|
constructor(map) {
|
|
6
46
|
this.updaters = {
|
|
@@ -9,6 +49,7 @@ export class SyncEngine {
|
|
|
9
49
|
datalayer: new DataLayerUpdater(map),
|
|
10
50
|
}
|
|
11
51
|
this.transport = undefined
|
|
52
|
+
this._operations = new Operations()
|
|
12
53
|
}
|
|
13
54
|
|
|
14
55
|
async authenticate(tokenURI, webSocketURI, server) {
|
|
@@ -27,6 +68,27 @@ export class SyncEngine {
|
|
|
27
68
|
this.transport = undefined
|
|
28
69
|
}
|
|
29
70
|
|
|
71
|
+
upsert(subject, metadata, value) {
|
|
72
|
+
this._send({ verb: 'upsert', subject, metadata, value })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
update(subject, metadata, key, value) {
|
|
76
|
+
this._send({ verb: 'update', subject, metadata, key, value })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
delete(subject, metadata, key) {
|
|
80
|
+
this._send({ verb: 'delete', subject, metadata, key })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_send(inputMessage) {
|
|
84
|
+
const message = this._operations.addLocal(inputMessage)
|
|
85
|
+
|
|
86
|
+
if (this.offline) return
|
|
87
|
+
if (this.transport) {
|
|
88
|
+
this.transport.send('OperationMessage', message)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
30
92
|
_getUpdater(subject, metadata) {
|
|
31
93
|
if (Object.keys(this.updaters).includes(subject)) {
|
|
32
94
|
return this.updaters[subject]
|
|
@@ -34,32 +96,170 @@ export class SyncEngine {
|
|
|
34
96
|
throw new Error(`Unknown updater ${subject}, ${metadata}`)
|
|
35
97
|
}
|
|
36
98
|
|
|
37
|
-
|
|
99
|
+
_applyOperation(operation) {
|
|
100
|
+
const updater = this._getUpdater(operation.subject, operation.metadata)
|
|
101
|
+
updater.applyMessage(operation)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
getNumberOfConnectedPeers() {
|
|
105
|
+
if (this.peers) return this.peers.length
|
|
106
|
+
return 0
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* This is called by the transport layer on new messages,
|
|
111
|
+
* and dispatches the different "on*" methods.
|
|
112
|
+
*/
|
|
38
113
|
receive({ kind, ...payload }) {
|
|
39
|
-
if (kind === '
|
|
40
|
-
|
|
41
|
-
|
|
114
|
+
if (kind === 'OperationMessage') {
|
|
115
|
+
this.onOperationMessage(payload)
|
|
116
|
+
} else if (kind === 'JoinResponse') {
|
|
117
|
+
this.onJoinResponse(payload)
|
|
118
|
+
} else if (kind === 'ListPeersResponse') {
|
|
119
|
+
this.onListPeersResponse(payload)
|
|
120
|
+
} else if (kind === 'PeerMessage') {
|
|
121
|
+
debug('received peermessage', payload)
|
|
122
|
+
if (payload.message.verb === 'ListOperationsRequest') {
|
|
123
|
+
this.onListOperationsRequest(payload)
|
|
124
|
+
} else if (payload.message.verb === 'ListOperationsResponse') {
|
|
125
|
+
this.onListOperationsResponse(payload)
|
|
126
|
+
}
|
|
42
127
|
} else {
|
|
43
|
-
throw new Error(`
|
|
128
|
+
throw new Error(`Received unknown message from the websocket server: ${kind}`)
|
|
44
129
|
}
|
|
45
130
|
}
|
|
46
131
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Received when an operation has been performed by another peer.
|
|
134
|
+
*
|
|
135
|
+
* Stores the passed operation locally and apply it.
|
|
136
|
+
*
|
|
137
|
+
* @param {Object} payload
|
|
138
|
+
*/
|
|
139
|
+
onOperationMessage(payload) {
|
|
140
|
+
this._operations.storeRemoteOperations([payload])
|
|
141
|
+
this._applyOperation(payload)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Received when the server acknowledges the `join` for this peer.
|
|
146
|
+
*
|
|
147
|
+
* @param {Object} payload
|
|
148
|
+
* @param {string} payload.uuid The server-assigned uuid for this peer
|
|
149
|
+
* @param {string[]} payload.peers The list of peers uuids
|
|
150
|
+
*/
|
|
151
|
+
onJoinResponse({ uuid, peers }) {
|
|
152
|
+
debug('received join response', { uuid, peers })
|
|
153
|
+
this.uuid = uuid
|
|
154
|
+
this.onListPeersResponse({ peers })
|
|
155
|
+
|
|
156
|
+
// Get one peer at random
|
|
157
|
+
const randomPeer = this._getRandomPeer()
|
|
158
|
+
|
|
159
|
+
if (randomPeer) {
|
|
160
|
+
// Retrieve the operations which happened before join.
|
|
161
|
+
this.sendToPeer(randomPeer, 'ListOperationsRequest', {
|
|
162
|
+
lastKnownHLC: this._operations.getLastKnownHLC(),
|
|
163
|
+
})
|
|
50
164
|
}
|
|
51
165
|
}
|
|
52
166
|
|
|
53
|
-
|
|
54
|
-
|
|
167
|
+
/**
|
|
168
|
+
* Received when the list of peers has changed.
|
|
169
|
+
*
|
|
170
|
+
* @param {Object} payload
|
|
171
|
+
* @param {string[]} payload.peers The list of peers uuids
|
|
172
|
+
*/
|
|
173
|
+
onListPeersResponse({ peers }) {
|
|
174
|
+
debug('received peerinfo', { peers })
|
|
175
|
+
this.peers = peers
|
|
176
|
+
this.updaters.map.update({ key: 'numberOfConnectedPeers' })
|
|
55
177
|
}
|
|
56
178
|
|
|
57
|
-
|
|
58
|
-
|
|
179
|
+
/**
|
|
180
|
+
* Received when another peer asks for the list of operations.
|
|
181
|
+
*
|
|
182
|
+
* @param {Object} payload
|
|
183
|
+
* @param {string} payload.sender the uuid of the requesting peer
|
|
184
|
+
* @param {string} payload.latestKnownHLC the latest known HLC of the requesting peer
|
|
185
|
+
*/
|
|
186
|
+
onListOperationsRequest({ sender, lastKnownHLC }) {
|
|
187
|
+
this.sendToPeer(sender, 'ListOperationsResponse', {
|
|
188
|
+
operations: this._operations.getOperationsSince(lastKnownHLC),
|
|
189
|
+
})
|
|
59
190
|
}
|
|
60
191
|
|
|
61
|
-
|
|
62
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Received when another peer sends the list of operations.
|
|
194
|
+
*
|
|
195
|
+
* When receiving this message, operations are filtered and applied
|
|
196
|
+
*
|
|
197
|
+
* @param {*} operations The list of (encoded operations)
|
|
198
|
+
*/
|
|
199
|
+
onListOperationsResponse({ sender, message }) {
|
|
200
|
+
debug(`received operations from peer ${sender}`, message.operations)
|
|
201
|
+
|
|
202
|
+
if (message.operations.length === 0) return
|
|
203
|
+
|
|
204
|
+
// Get the list of stored operations before this message.
|
|
205
|
+
const remoteOperations = Operations.sort(message.operations)
|
|
206
|
+
this._operations.storeRemoteOperations(remoteOperations)
|
|
207
|
+
|
|
208
|
+
// Sort the local operations only once, see below.
|
|
209
|
+
for (const remote of remoteOperations) {
|
|
210
|
+
if (this._operations.shouldBypassOperation(remote)) {
|
|
211
|
+
debug(
|
|
212
|
+
'Skipping the following operation, because a newer one has been found locally',
|
|
213
|
+
remote
|
|
214
|
+
)
|
|
215
|
+
} else {
|
|
216
|
+
this._applyOperation(remote)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// TODO: compact the changes here?
|
|
221
|
+
// e.g. we might want to :
|
|
222
|
+
// - group cases of multiple updates
|
|
223
|
+
// - not apply changes where we have a more recent version (but store them nevertheless)
|
|
224
|
+
|
|
225
|
+
// 1. Get the list of fields that changed (in the incoming operations)
|
|
226
|
+
// 2. For each field, get the last version
|
|
227
|
+
// 3. Check if we should apply the changes.
|
|
228
|
+
|
|
229
|
+
// For each operation
|
|
230
|
+
// Get the updated key hlc
|
|
231
|
+
// If key.local_hlc > key.remote_hlc: drop
|
|
232
|
+
// Else: apply
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Send a message to another peer (via the transport layer)
|
|
237
|
+
*
|
|
238
|
+
* @param {*} recipient
|
|
239
|
+
* @param {*} verb
|
|
240
|
+
* @param {*} payload
|
|
241
|
+
*/
|
|
242
|
+
sendToPeer(recipient, verb, payload) {
|
|
243
|
+
payload.verb = verb
|
|
244
|
+
this.transport.send('PeerMessage', {
|
|
245
|
+
sender: this.uuid,
|
|
246
|
+
recipient: recipient,
|
|
247
|
+
message: payload,
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Selects a peer ID at random within the known ones.
|
|
253
|
+
*
|
|
254
|
+
* @returns {string|bool} the selected peer uuid, or False if none was found.
|
|
255
|
+
*/
|
|
256
|
+
_getRandomPeer() {
|
|
257
|
+
const otherPeers = this.peers.filter((p) => p !== this.uuid)
|
|
258
|
+
if (otherPeers.length > 0) {
|
|
259
|
+
const random = Math.floor(Math.random() * otherPeers.length)
|
|
260
|
+
return otherPeers[random]
|
|
261
|
+
}
|
|
262
|
+
return false
|
|
63
263
|
}
|
|
64
264
|
|
|
65
265
|
/**
|
|
@@ -91,3 +291,160 @@ export class SyncEngine {
|
|
|
91
291
|
return new Proxy(this, handler)
|
|
92
292
|
}
|
|
93
293
|
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Registry of local and remote operations, keeping a constant ordering.
|
|
297
|
+
*/
|
|
298
|
+
export class Operations {
|
|
299
|
+
constructor() {
|
|
300
|
+
this._hlc = new HybridLogicalClock()
|
|
301
|
+
this._operations = new Array()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Tick the clock and add store the passed message in the operations list.
|
|
306
|
+
*
|
|
307
|
+
* @param {*} inputMessage
|
|
308
|
+
* @returns {*} clock-aware message
|
|
309
|
+
*/
|
|
310
|
+
addLocal(inputMessage) {
|
|
311
|
+
const message = { ...inputMessage, hlc: this._hlc.tick() }
|
|
312
|
+
this._operations.push(message)
|
|
313
|
+
return message
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Returns the current list of operations ordered by their HLC.
|
|
318
|
+
*
|
|
319
|
+
* This DOES NOT modify the list in place, but instead return a new copy.
|
|
320
|
+
*
|
|
321
|
+
* @returns {Array}
|
|
322
|
+
*/
|
|
323
|
+
sorted() {
|
|
324
|
+
return Operations.sort(this._operations)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Static method to order the given list of operations by their HCL.
|
|
329
|
+
*
|
|
330
|
+
* @param {Object[]} operations
|
|
331
|
+
* @returns an ordered copy
|
|
332
|
+
*/
|
|
333
|
+
static sort(operations) {
|
|
334
|
+
const copy = [...operations]
|
|
335
|
+
copy.sort((a, b) => (a.hlc < b.hlc ? -1 : 1))
|
|
336
|
+
return copy
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Store a list of remote operations locally
|
|
341
|
+
*
|
|
342
|
+
* Note that operations are not applied as part of this method.
|
|
343
|
+
*
|
|
344
|
+
* - Updates the list of operations with the remote ones.
|
|
345
|
+
* - Updates the clock to reflect these changes.
|
|
346
|
+
*
|
|
347
|
+
* @param {Array} remoteOperations
|
|
348
|
+
*/
|
|
349
|
+
storeRemoteOperations(remoteOperations) {
|
|
350
|
+
// get the highest date from the passed operations
|
|
351
|
+
const greatestHLC = remoteOperations
|
|
352
|
+
.map((op) => op.hlc)
|
|
353
|
+
.reduce((max, current) => (current > max ? current : max))
|
|
354
|
+
|
|
355
|
+
// Bump the current HLC.
|
|
356
|
+
this._hlc.receive(greatestHLC)
|
|
357
|
+
this._operations.push(...remoteOperations)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Get operations that happened since a specific clock tick.
|
|
362
|
+
*/
|
|
363
|
+
getOperationsSince(hlc) {
|
|
364
|
+
if (!hlc) return this._operations
|
|
365
|
+
// first get the position of the clock that was sent
|
|
366
|
+
const start = this._operations.findIndex((op) => op.hlc === hlc)
|
|
367
|
+
this._operations.slice(start)
|
|
368
|
+
return this._operations.filter((op) => op.hlc > hlc)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Returns the last known HLC value.
|
|
373
|
+
*/
|
|
374
|
+
getLastKnownHLC() {
|
|
375
|
+
return this._operations.at(-1)?.hlc
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Checks if a given operation should be bypassed.
|
|
380
|
+
*
|
|
381
|
+
* Note that this doesn't only check the clock, but also if the operation share
|
|
382
|
+
* on the same context (subject + metadata).
|
|
383
|
+
*
|
|
384
|
+
* @param {Object} remote the remote operation to compare to
|
|
385
|
+
* @returns bool
|
|
386
|
+
*/
|
|
387
|
+
shouldBypassOperation(remote) {
|
|
388
|
+
const sortedLocalOperations = this.sorted()
|
|
389
|
+
// No operations are stored, no need to check
|
|
390
|
+
if (sortedLocalOperations.length <= 0) {
|
|
391
|
+
debug('No operations are stored, no need to check')
|
|
392
|
+
return false
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Latest local operation is older than the remote one
|
|
396
|
+
const latest = sortedLocalOperations.at(-1)
|
|
397
|
+
if (latest.hlc < remote.hlc) {
|
|
398
|
+
debug('Latest local operation is older than the remote one')
|
|
399
|
+
return false
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Skip operations enabling the sync engine:
|
|
403
|
+
// If we receive something, we are already connected.
|
|
404
|
+
if (
|
|
405
|
+
remote.hasOwnProperty('key') &&
|
|
406
|
+
remote.key === 'options.syncEnabled' &&
|
|
407
|
+
remote.value === true
|
|
408
|
+
) {
|
|
409
|
+
return true
|
|
410
|
+
}
|
|
411
|
+
for (const local of sortedLocalOperations) {
|
|
412
|
+
if (
|
|
413
|
+
local.hlc > remote.hlc &&
|
|
414
|
+
Operations.haveSameContext(local, remote) &&
|
|
415
|
+
// For now (and until we fix the conflict between updates and upsert)
|
|
416
|
+
// upsert always have priority over other operations
|
|
417
|
+
remote.verb !== 'upsert'
|
|
418
|
+
) {
|
|
419
|
+
debug('this is newer:', local)
|
|
420
|
+
return true
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return false
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Compares two operations to see if they share the same context.
|
|
428
|
+
*
|
|
429
|
+
* @param {Object} local
|
|
430
|
+
* @param {Object} remote
|
|
431
|
+
* @return {bool} true if the two operations share the same context.
|
|
432
|
+
*/
|
|
433
|
+
static haveSameContext(local, remote) {
|
|
434
|
+
const shouldCheckKey =
|
|
435
|
+
local.hasOwnProperty('key') &&
|
|
436
|
+
remote.hasOwnProperty('key') &&
|
|
437
|
+
typeof local.key !== 'undefined' &&
|
|
438
|
+
typeof remote.key !== 'undefined'
|
|
439
|
+
|
|
440
|
+
return (
|
|
441
|
+
Utils.deepEqual(local.subject, remote.subject) &&
|
|
442
|
+
Utils.deepEqual(local.metadata, remote.metadata) &&
|
|
443
|
+
(!shouldCheckKey || (shouldCheckKey && local.key == remote.key))
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function debug(...args) {
|
|
449
|
+
console.debug('SYNC ⇆', ...args)
|
|
450
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import * as Utils from '../utils.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This is an implementation of a Hybrid Logical Clock (HLC).
|
|
5
|
+
*
|
|
6
|
+
* There are three parts in the clock:
|
|
7
|
+
*
|
|
8
|
+
* - walltime: the relative clock of each of the peers
|
|
9
|
+
* - NN: a local counter that gets incremented in case of ties.
|
|
10
|
+
* - id: to identify the peer
|
|
11
|
+
*
|
|
12
|
+
* HLCs are used to order operations consistently in distributed systems.
|
|
13
|
+
*/
|
|
14
|
+
export class HybridLogicalClock {
|
|
15
|
+
constructor(walltime = Date.now(), nn = 0, id = Utils.generateId()) {
|
|
16
|
+
this._current = { walltime, nn, id }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Return a serialized version of the current clock
|
|
21
|
+
*/
|
|
22
|
+
serialize(clock = this._current) {
|
|
23
|
+
const { walltime, nn, id } = clock
|
|
24
|
+
return `${walltime}:${nn}:${id}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse a serialized time and return a JS object.
|
|
29
|
+
* @param string raw
|
|
30
|
+
* @returns object
|
|
31
|
+
*/
|
|
32
|
+
parse(raw) {
|
|
33
|
+
const tokens = raw.split(':')
|
|
34
|
+
|
|
35
|
+
if (tokens.length !== 3) {
|
|
36
|
+
throw new SyntaxError(`Unable to parse ${raw}`)
|
|
37
|
+
}
|
|
38
|
+
const [walltime, rawNN, id] = tokens
|
|
39
|
+
|
|
40
|
+
let nn = Number.parseInt(rawNN)
|
|
41
|
+
if (Number.isNaN(nn)) {
|
|
42
|
+
nn = 0
|
|
43
|
+
}
|
|
44
|
+
return { walltime, nn, id }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Increment the current clock by one tick.
|
|
49
|
+
*
|
|
50
|
+
* - If the current time is greater than the last known tip, increment it.
|
|
51
|
+
* - Otherwise, increment the `nn` counter by 1.
|
|
52
|
+
*
|
|
53
|
+
* This allows each tick to be different from each other.
|
|
54
|
+
*
|
|
55
|
+
* @returns a serialized clock
|
|
56
|
+
*/
|
|
57
|
+
tick() {
|
|
58
|
+
// Copy the current value of the hlc to avoid concurrency issues
|
|
59
|
+
const current = { ...this._current }
|
|
60
|
+
const now = Date.now()
|
|
61
|
+
|
|
62
|
+
let nextValue
|
|
63
|
+
|
|
64
|
+
if (now > current.walltime) {
|
|
65
|
+
nextValue = { ...current, walltime: now, nn: 0 }
|
|
66
|
+
} else {
|
|
67
|
+
nextValue = { ...current, nn: current.nn + 1 }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this._current = nextValue
|
|
71
|
+
return this.serialize(this._current)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Receive a remote clock info, and update the local clock.
|
|
76
|
+
*
|
|
77
|
+
* - If the current wall time is greater than both local and remote wall time, use the local one.
|
|
78
|
+
* - If the current wall time is the same, increment max (local, remote) `nn` counter by 1.
|
|
79
|
+
* - If remote time is greater, keep the remote time and increment `nn`
|
|
80
|
+
* - Otherwise, keep local values and increment `nn`
|
|
81
|
+
*
|
|
82
|
+
* This allows to take into account clock drifting, when clocks on different peers are getting
|
|
83
|
+
* out of sync.
|
|
84
|
+
**/
|
|
85
|
+
receive(remoteRaw) {
|
|
86
|
+
const local = { ...this._current }
|
|
87
|
+
const remote = this.parse(remoteRaw)
|
|
88
|
+
const now = Date.now()
|
|
89
|
+
|
|
90
|
+
let nextValue
|
|
91
|
+
|
|
92
|
+
if (now > local.walltime && now > remote.walltime) {
|
|
93
|
+
nextValue = { ...local, walltime: now }
|
|
94
|
+
} else if (local.walltime == remote.walltime) {
|
|
95
|
+
const nn = Math.max(local.nn, remote.nn) + 1
|
|
96
|
+
nextValue = { ...local, nn: nn }
|
|
97
|
+
} else if (remote.walltime > local.walltime) {
|
|
98
|
+
nextValue = { ...remote, id: local.id, nn: remote.nn + 1 }
|
|
99
|
+
} else {
|
|
100
|
+
nextValue = { ...local, nn: local.nn + 1 }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this._current = nextValue
|
|
104
|
+
return this._current
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { fieldInSchema } from '../utils.js'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
*
|
|
3
|
-
* received from
|
|
4
|
+
* Updaters are classes able to convert messages
|
|
5
|
+
* received from other peers (or from the server) to changes on the map.
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
8
|
class BaseUpdater {
|
|
@@ -42,7 +44,10 @@ class BaseUpdater {
|
|
|
42
44
|
|
|
43
45
|
export class MapUpdater extends BaseUpdater {
|
|
44
46
|
update({ key, value }) {
|
|
45
|
-
|
|
47
|
+
if (fieldInSchema(key)) {
|
|
48
|
+
this.updateObjectValue(this.map, key, value)
|
|
49
|
+
}
|
|
50
|
+
|
|
46
51
|
this.map.render([key])
|
|
47
52
|
}
|
|
48
53
|
}
|
|
@@ -56,7 +61,14 @@ export class DataLayerUpdater extends BaseUpdater {
|
|
|
56
61
|
|
|
57
62
|
update({ key, metadata, value }) {
|
|
58
63
|
const datalayer = this.getDataLayerFromID(metadata.id)
|
|
59
|
-
|
|
64
|
+
if (fieldInSchema(key)) {
|
|
65
|
+
this.updateObjectValue(datalayer, key, value)
|
|
66
|
+
} else {
|
|
67
|
+
console.debug(
|
|
68
|
+
'Not applying update for datalayer because key is not in the schema',
|
|
69
|
+
key
|
|
70
|
+
)
|
|
71
|
+
}
|
|
60
72
|
datalayer.render([key])
|
|
61
73
|
}
|
|
62
74
|
}
|
|
@@ -76,7 +88,7 @@ export class FeatureUpdater extends BaseUpdater {
|
|
|
76
88
|
if (feature) {
|
|
77
89
|
feature.geometry = value.geometry
|
|
78
90
|
} else {
|
|
79
|
-
datalayer.makeFeature(value)
|
|
91
|
+
datalayer.makeFeature(value, false)
|
|
80
92
|
}
|
|
81
93
|
}
|
|
82
94
|
|
|
@@ -85,9 +97,9 @@ export class FeatureUpdater extends BaseUpdater {
|
|
|
85
97
|
const feature = this.getFeatureFromMetadata(metadata)
|
|
86
98
|
if (feature === undefined) {
|
|
87
99
|
console.error(`Unable to find feature with id = ${metadata.id}.`)
|
|
100
|
+
return
|
|
88
101
|
}
|
|
89
102
|
if (key === 'geometry') {
|
|
90
|
-
const datalayer = this.getDataLayerFromID(metadata.layerId)
|
|
91
103
|
const feature = this.getFeatureFromMetadata(metadata, value)
|
|
92
104
|
feature.geometry = value
|
|
93
105
|
} else {
|
|
@@ -2,7 +2,7 @@ export class WebSocketTransport {
|
|
|
2
2
|
constructor(webSocketURI, authToken, messagesReceiver) {
|
|
3
3
|
this.websocket = new WebSocket(webSocketURI)
|
|
4
4
|
this.websocket.onopen = () => {
|
|
5
|
-
this.send('
|
|
5
|
+
this.send('JoinRequest', { token: authToken })
|
|
6
6
|
}
|
|
7
7
|
this.websocket.addEventListener('message', this.onMessage.bind(this))
|
|
8
8
|
this.receiver = messagesReceiver
|
|
@@ -72,9 +72,9 @@ export class Positioned {
|
|
|
72
72
|
left = x - this.container.offsetWidth
|
|
73
73
|
}
|
|
74
74
|
if (y < window.innerHeight / 2) {
|
|
75
|
-
top = y
|
|
75
|
+
top = Math.min(y, window.innerHeight - this.container.offsetHeight)
|
|
76
76
|
} else {
|
|
77
|
-
top = y - this.container.offsetHeight
|
|
77
|
+
top = Math.max(0, y - this.container.offsetHeight)
|
|
78
78
|
}
|
|
79
79
|
this.setPosition({ left, top })
|
|
80
80
|
}
|
|
@@ -15,27 +15,60 @@ export default class ContextMenu extends Positioned {
|
|
|
15
15
|
})
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
open(
|
|
18
|
+
open(event, items) {
|
|
19
|
+
const left = event.clientX
|
|
20
|
+
const top = event.clientY
|
|
21
|
+
this.openAt([left, top], items)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
openBelow(element, items) {
|
|
25
|
+
const coords = this.getPosition(element)
|
|
26
|
+
this.openAt([coords.left, coords.bottom], items)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
openAt([left, top], items) {
|
|
19
30
|
this.container.innerHTML = ''
|
|
20
31
|
for (const item of items) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
this.
|
|
32
|
+
if (item === '-') {
|
|
33
|
+
this.container.appendChild(document.createElement('hr'))
|
|
34
|
+
} else if (typeof item.action === 'string') {
|
|
35
|
+
const li = loadTemplate(
|
|
36
|
+
`<li class="${item.className || ''}"><a tabindex="0" href="${item.action}">${item.label}</a></li>`
|
|
37
|
+
)
|
|
38
|
+
this.container.appendChild(li)
|
|
39
|
+
} else {
|
|
40
|
+
const li = loadTemplate(
|
|
41
|
+
`<li class="${item.className || ''}"><button tabindex="0" class="flat">${item.label}</button></li>`
|
|
42
|
+
)
|
|
43
|
+
li.addEventListener('click', () => {
|
|
44
|
+
this.close()
|
|
45
|
+
item.action()
|
|
46
|
+
})
|
|
47
|
+
this.container.appendChild(li)
|
|
37
48
|
}
|
|
38
|
-
}
|
|
49
|
+
}
|
|
50
|
+
// When adding contextmenu below the map container, clicking on any link will send the
|
|
51
|
+
// "focusout" element on link click, preventing to trigger the click action
|
|
52
|
+
const parent = document
|
|
53
|
+
.elementFromPoint(left, top)
|
|
54
|
+
.closest('.leaflet-container').parentNode
|
|
55
|
+
parent.appendChild(this.container)
|
|
56
|
+
if (this.options.fixed) {
|
|
57
|
+
this.setPosition({ left, top })
|
|
58
|
+
} else {
|
|
59
|
+
this.computePosition([left, top])
|
|
60
|
+
}
|
|
61
|
+
this.container.querySelector('li > *').focus()
|
|
62
|
+
this.container.addEventListener(
|
|
63
|
+
'keydown',
|
|
64
|
+
(event) => {
|
|
65
|
+
if (event.key === 'Escape') {
|
|
66
|
+
event.stopPropagation()
|
|
67
|
+
this.close()
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
{ once: true }
|
|
71
|
+
)
|
|
39
72
|
}
|
|
40
73
|
|
|
41
74
|
close() {
|