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.

Files changed (137) hide show
  1. umap/__init__.py +1 -1
  2. umap/admin.py +64 -1
  3. umap/asgi.py +15 -0
  4. umap/context_processors.py +1 -0
  5. umap/locale/cs_CZ/LC_MESSAGES/django.mo +0 -0
  6. umap/locale/cs_CZ/LC_MESSAGES/django.po +96 -92
  7. umap/locale/de/LC_MESSAGES/django.mo +0 -0
  8. umap/locale/de/LC_MESSAGES/django.po +19 -18
  9. umap/locale/en/LC_MESSAGES/django.po +47 -43
  10. umap/locale/es/LC_MESSAGES/django.mo +0 -0
  11. umap/locale/es/LC_MESSAGES/django.po +134 -128
  12. umap/locale/fr/LC_MESSAGES/django.mo +0 -0
  13. umap/locale/fr/LC_MESSAGES/django.po +51 -47
  14. umap/locale/pt/LC_MESSAGES/django.mo +0 -0
  15. umap/locale/pt/LC_MESSAGES/django.po +64 -60
  16. umap/management/commands/clean_tilelayer.py +152 -0
  17. umap/management/commands/purge_purgatory.py +28 -0
  18. umap/models.py +27 -2
  19. umap/settings/base.py +3 -1
  20. umap/static/umap/base.css +4 -4
  21. umap/static/umap/css/contextmenu.css +6 -1
  22. umap/static/umap/css/icon.css +7 -2
  23. umap/static/umap/css/importers.css +4 -0
  24. umap/static/umap/img/16-white.svg +9 -2
  25. umap/static/umap/img/16.svg +1 -181
  26. umap/static/umap/img/24-white.svg +1 -0
  27. umap/static/umap/img/24.svg +1 -0
  28. umap/static/umap/img/importers/cadastrefr.svg +23 -0
  29. umap/static/umap/img/source/16-white.svg +10 -3
  30. umap/static/umap/img/source/16.svg +753 -197
  31. umap/static/umap/img/source/24-white.svg +3 -2
  32. umap/static/umap/img/source/24.svg +3 -2
  33. umap/static/umap/js/modules/autocomplete.js +7 -3
  34. umap/static/umap/js/modules/browser.js +54 -1
  35. umap/static/umap/js/modules/caption.js +16 -5
  36. umap/static/umap/js/modules/data/features.js +176 -2
  37. umap/static/umap/js/modules/data/layer.js +57 -40
  38. umap/static/umap/js/modules/formatter.js +3 -2
  39. umap/static/umap/js/modules/global.js +2 -0
  40. umap/static/umap/js/modules/importer.js +3 -0
  41. umap/static/umap/js/modules/importers/cadastrefr.js +62 -0
  42. umap/static/umap/js/modules/importers/communesfr.js +15 -3
  43. umap/static/umap/js/modules/permissions.js +123 -93
  44. umap/static/umap/js/modules/rendering/layers/classified.js +2 -0
  45. umap/static/umap/js/modules/rendering/ui.js +60 -213
  46. umap/static/umap/js/modules/share.js +1 -3
  47. umap/static/umap/js/modules/slideshow.js +1 -1
  48. umap/static/umap/js/modules/sync/engine.js +371 -14
  49. umap/static/umap/js/modules/sync/hlc.js +106 -0
  50. umap/static/umap/js/modules/sync/updaters.js +18 -6
  51. umap/static/umap/js/modules/sync/websocket.js +1 -1
  52. umap/static/umap/js/modules/tableeditor.js +1 -1
  53. umap/static/umap/js/modules/ui/base.js +2 -2
  54. umap/static/umap/js/modules/ui/contextmenu.js +51 -18
  55. umap/static/umap/js/modules/urls.js +5 -1
  56. umap/static/umap/js/modules/utils.js +28 -4
  57. umap/static/umap/js/umap.controls.js +73 -52
  58. umap/static/umap/js/umap.core.js +3 -3
  59. umap/static/umap/js/umap.forms.js +3 -1
  60. umap/static/umap/js/umap.js +115 -124
  61. umap/static/umap/locale/br.js +13 -4
  62. umap/static/umap/locale/br.json +13 -4
  63. umap/static/umap/locale/ca.js +28 -15
  64. umap/static/umap/locale/ca.json +28 -15
  65. umap/static/umap/locale/cs_CZ.js +87 -78
  66. umap/static/umap/locale/cs_CZ.json +87 -78
  67. umap/static/umap/locale/de.js +17 -8
  68. umap/static/umap/locale/de.json +17 -8
  69. umap/static/umap/locale/en.js +13 -2
  70. umap/static/umap/locale/en.json +13 -2
  71. umap/static/umap/locale/es.js +330 -319
  72. umap/static/umap/locale/es.json +330 -319
  73. umap/static/umap/locale/eu.js +10 -3
  74. umap/static/umap/locale/eu.json +10 -3
  75. umap/static/umap/locale/fa_IR.js +11 -4
  76. umap/static/umap/locale/fa_IR.json +11 -4
  77. umap/static/umap/locale/fr.js +15 -4
  78. umap/static/umap/locale/fr.json +15 -4
  79. umap/static/umap/locale/hu.js +10 -3
  80. umap/static/umap/locale/hu.json +10 -3
  81. umap/static/umap/locale/pt.js +17 -8
  82. umap/static/umap/locale/pt.json +17 -8
  83. umap/static/umap/locale/pt_PT.js +13 -4
  84. umap/static/umap/locale/pt_PT.json +13 -4
  85. umap/static/umap/locale/zh_TW.js +13 -4
  86. umap/static/umap/locale/zh_TW.json +13 -4
  87. umap/static/umap/map.css +44 -29
  88. umap/static/umap/unittests/hlc.js +165 -0
  89. umap/static/umap/unittests/sync.js +321 -15
  90. umap/static/umap/unittests/utils.js +47 -0
  91. umap/static/umap/vars.css +2 -1
  92. umap/static/umap/vendors/colorbrewer/colorbrewer.js +309 -317
  93. umap/static/umap/vendors/dompurify/purify.es.js +15 -16
  94. umap/static/umap/vendors/dompurify/purify.es.mjs.map +1 -1
  95. umap/static/umap/vendors/georsstogeojson/GeoRSSToGeoJSON.js +111 -80
  96. umap/static/umap/vendors/locatecontrol/L.Control.Locate.min.js +2 -2
  97. umap/static/umap/vendors/locatecontrol/L.Control.Locate.min.js.map +1 -1
  98. umap/static/umap/vendors/simple-statistics/simple-statistics.min.js +1 -1
  99. umap/static/umap/vendors/simple-statistics/simple-statistics.min.js.map +1 -1
  100. umap/templates/umap/css.html +0 -2
  101. umap/templates/umap/dashboard_menu.html +4 -2
  102. umap/templates/umap/js.html +0 -5
  103. umap/templates/umap/map_detail.html +2 -2
  104. umap/tests/fixtures/test_upload_data.csv +2 -2
  105. umap/tests/integration/test_anonymous_owned_map.py +1 -0
  106. umap/tests/integration/test_basics.py +1 -1
  107. umap/tests/integration/test_browser.py +69 -7
  108. umap/tests/integration/test_caption.py +3 -3
  109. umap/tests/integration/test_circles_layer.py +12 -0
  110. umap/tests/integration/test_datalayer.py +2 -1
  111. umap/tests/integration/test_draw_polygon.py +17 -9
  112. umap/tests/integration/test_draw_polyline.py +12 -8
  113. umap/tests/integration/test_edit_datalayer.py +5 -8
  114. umap/tests/integration/test_edit_map.py +2 -2
  115. umap/tests/integration/test_edit_marker.py +1 -1
  116. umap/tests/integration/test_facets_browser.py +3 -3
  117. umap/tests/integration/test_import.py +1 -0
  118. umap/tests/integration/test_map.py +1 -0
  119. umap/tests/integration/test_owned_map.py +1 -1
  120. umap/tests/integration/test_view_marker.py +63 -0
  121. umap/tests/integration/test_view_polygon.py +12 -12
  122. umap/tests/integration/test_websocket_sync.py +65 -3
  123. umap/tests/test_clean_tilelayer.py +83 -0
  124. umap/tests/test_datalayer.py +24 -0
  125. umap/tests/test_map_views.py +20 -0
  126. umap/tests/test_purge_purgatory.py +25 -0
  127. umap/tests/test_websocket_server.py +22 -0
  128. umap/urls.py +5 -1
  129. umap/views.py +6 -3
  130. umap/websocket_server.py +130 -27
  131. {umap_project-2.6.3.dist-info → umap_project-2.7.0.dist-info}/METADATA +18 -14
  132. {umap_project-2.6.3.dist-info → umap_project-2.7.0.dist-info}/RECORD +135 -127
  133. umap/static/umap/vendors/contextmenu/leaflet.contextmenu.min.css +0 -1
  134. umap/static/umap/vendors/contextmenu/leaflet.contextmenu.min.js +0 -7
  135. {umap_project-2.6.3.dist-info → umap_project-2.7.0.dist-info}/WHEEL +0 -0
  136. {umap_project-2.6.3.dist-info → umap_project-2.7.0.dist-info}/entry_points.txt +0 -0
  137. {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
- // This method is called by the transport layer on new messages
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 === 'operation') {
40
- const updater = this._getUpdater(payload.subject, payload.metadata)
41
- updater.applyMessage(payload)
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(`Unknown dispatch kind: ${kind}`)
128
+ throw new Error(`Received unknown message from the websocket server: ${kind}`)
44
129
  }
45
130
  }
46
131
 
47
- _send(message) {
48
- if (this.transport) {
49
- this.transport.send('operation', message)
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
- upsert(subject, metadata, value) {
54
- this._send({ verb: 'upsert', subject, metadata, value })
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
- update(subject, metadata, key, value) {
58
- this._send({ verb: 'update', subject, metadata, key, value })
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
- delete(subject, metadata, key) {
62
- this._send({ verb: 'delete', subject, metadata, key })
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
- * This file contains the updaters: classes that are able to convert messages
3
- * received from another party (or the server) to changes on the map.
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
- this.updateObjectValue(this.map, key, value)
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
- this.updateObjectValue(datalayer, key, value)
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('join', { token: authToken })
5
+ this.send('JoinRequest', { token: authToken })
6
6
  }
7
7
  this.websocket.addEventListener('message', this.onMessage.bind(this))
8
8
  this.receiver = messagesReceiver
@@ -64,7 +64,7 @@ export default class TableEditor extends WithTemplate {
64
64
  action: () => this.deleteProperty(property),
65
65
  })
66
66
  }
67
- this.contextmenu.open([event.clientX, event.clientY], actions)
67
+ this.contextmenu.open(event, actions)
68
68
  }
69
69
 
70
70
  renderHeaders() {
@@ -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([x, y], items) {
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
- const li = loadTemplate(
22
- `<li class="${item.className || ''}"><button tabindex="0" class="flat">${item.label}</button></li>`
23
- )
24
- li.addEventListener('click', () => {
25
- this.close()
26
- item.action()
27
- })
28
- this.container.appendChild(li)
29
- }
30
- document.body.appendChild(this.container)
31
- this.computePosition([x, y])
32
- this.container.querySelector('button').focus()
33
- this.container.addEventListener('keydown', (event) => {
34
- if (event.key === 'Escape') {
35
- event.stopPropagation()
36
- this.close()
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() {