mirror of
https://github.com/umap-project/umap.git
synced 2025-05-10 16:11:49 +02:00
Compare commits
15 commits
24511d796d
...
d4fb92ec56
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d4fb92ec56 | ||
![]() |
650110fe8a | ||
![]() |
36fdb8190c | ||
![]() |
56f2d3d2f9 | ||
![]() |
80152bf4fb | ||
![]() |
9ddca50d21 | ||
![]() |
1996e315e4 | ||
![]() |
dd7641c92e | ||
![]() |
6caf4c3ed1 | ||
![]() |
471abe1f1b | ||
![]() |
4ea5a28f04 | ||
![]() |
d24f05907c | ||
![]() |
0bc4900b16 | ||
![]() |
0e78d58c03 | ||
![]() |
f93d0b5ca7 |
13 changed files with 302 additions and 122 deletions
|
@ -32,6 +32,10 @@
|
||||||
background-color: var(--color-lightCyan);
|
background-color: var(--color-lightCyan);
|
||||||
color: var(--color-dark);
|
color: var(--color-dark);
|
||||||
}
|
}
|
||||||
|
.dark .off.connected-peers {
|
||||||
|
background-color: var(--color-lightGray);
|
||||||
|
color: var(--color-darkGray);
|
||||||
|
}
|
||||||
|
|
||||||
.leaflet-container .edit-cancel,
|
.leaflet-container .edit-cancel,
|
||||||
.leaflet-container .edit-disable,
|
.leaflet-container .edit-disable,
|
||||||
|
|
|
@ -1,11 +1,39 @@
|
||||||
import { DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js'
|
|
||||||
import { translate } from './i18n.js'
|
import { translate } from './i18n.js'
|
||||||
import * as Utils from './utils.js'
|
import * as Utils from './utils.js'
|
||||||
|
|
||||||
export default class Caption {
|
const TEMPLATE = `
|
||||||
|
<div class="umap-caption">
|
||||||
|
<hgroup>
|
||||||
|
<h3>
|
||||||
|
<i class="icon icon-16 icon-caption icon-block"></i>
|
||||||
|
<span class="map-name" data-ref="name"></span>
|
||||||
|
</h3>
|
||||||
|
<h4 data-ref="author"></h4>
|
||||||
|
</hgroup>
|
||||||
|
<div class="umap-map-description text" data-ref="description"></div>
|
||||||
|
<div class="datalayer-container" data-ref="datalayersContainer"></div>
|
||||||
|
<div class="credits-container">
|
||||||
|
<details>
|
||||||
|
<summary>${translate('Credits')}</summary>
|
||||||
|
<fieldset>
|
||||||
|
<h5>${translate('User content credits')}</h5>
|
||||||
|
<p data-ref="userCredits"></p>
|
||||||
|
<p data-ref="licence">${translate('Map user content has been published under licence')} <a href="#" data-ref="licenceLink"></a></p>
|
||||||
|
<p data-ref="noLicence">${translate('No licence has been set')}</p>
|
||||||
|
<h5>${translate('Map background credits')}</h5>
|
||||||
|
<p><strong data-ref="bgName"></strong> <span data-ref="bgAttribution"></span></p>
|
||||||
|
<p data-ref="poweredBy"></p>
|
||||||
|
</fieldset>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
|
||||||
|
export default class Caption extends Utils.WithTemplate {
|
||||||
constructor(umap, leafletMap) {
|
constructor(umap, leafletMap) {
|
||||||
|
super()
|
||||||
this._umap = umap
|
this._umap = umap
|
||||||
this._leafletMap = leafletMap
|
this._leafletMap = leafletMap
|
||||||
|
this.loadTemplate(TEMPLATE)
|
||||||
}
|
}
|
||||||
|
|
||||||
isOpen() {
|
isOpen() {
|
||||||
|
@ -18,94 +46,67 @@ export default class Caption {
|
||||||
}
|
}
|
||||||
|
|
||||||
open() {
|
open() {
|
||||||
const container = DomUtil.create('div', 'umap-caption')
|
this.elements.name.textContent = this._umap.getDisplayName()
|
||||||
const hgroup = DomUtil.element({ tagName: 'hgroup', parent: container })
|
this.elements.author.innerHTML = ''
|
||||||
DomUtil.createTitle(
|
this._umap.addAuthorLink(this.elements.author)
|
||||||
hgroup,
|
|
||||||
this._umap.getDisplayName(),
|
|
||||||
'icon-caption icon-block',
|
|
||||||
'map-name'
|
|
||||||
)
|
|
||||||
const title = Utils.loadTemplate('<h4></h4>')
|
|
||||||
hgroup.appendChild(title)
|
|
||||||
this._umap.addAuthorLink(title)
|
|
||||||
if (this._umap.properties.description) {
|
if (this._umap.properties.description) {
|
||||||
const description = DomUtil.element({
|
this.elements.description.innerHTML = Utils.toHTML(
|
||||||
tagName: 'div',
|
this._umap.properties.description
|
||||||
className: 'umap-map-description text',
|
)
|
||||||
safeHTML: Utils.toHTML(this._umap.properties.description),
|
} else {
|
||||||
parent: container,
|
this.elements.description.hidden = true
|
||||||
})
|
|
||||||
}
|
}
|
||||||
const datalayerContainer = DomUtil.create('div', 'datalayer-container', container)
|
this.elements.datalayersContainer.innerHTML = ''
|
||||||
this._umap.eachDataLayerReverse((datalayer) =>
|
this._umap.eachDataLayerReverse((datalayer) =>
|
||||||
this.addDataLayer(datalayer, datalayerContainer)
|
this.addDataLayer(datalayer, this.elements.datalayersContainer)
|
||||||
)
|
)
|
||||||
const creditsContainer = DomUtil.create('div', 'credits-container', container)
|
this.addCredits()
|
||||||
this.addCredits(creditsContainer)
|
this._umap.panel.open({ content: this.element }).then(() => {
|
||||||
this._umap.panel.open({ content: container }).then(() => {
|
|
||||||
// Create the legend when the panel is actually on the DOM
|
// Create the legend when the panel is actually on the DOM
|
||||||
this._umap.eachDataLayerReverse((datalayer) => datalayer.renderLegend())
|
this._umap.eachDataLayerReverse((datalayer) => datalayer.renderLegend())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
addDataLayer(datalayer, container) {
|
addDataLayer(datalayer, parent) {
|
||||||
if (!datalayer.options.inCaption) return
|
if (!datalayer.options.inCaption) return
|
||||||
const p = DomUtil.create('p', `caption-item ${datalayer.cssId}`, container)
|
const template = `
|
||||||
const legend = DomUtil.create('span', 'datalayer-legend', p)
|
<p class="caption-item ${datalayer.cssId}">
|
||||||
const headline = DomUtil.create('strong', '', p)
|
<span class="datalayer-legend"></span>
|
||||||
|
<strong data-ref="toolbox"></strong>
|
||||||
|
<span class="text" data-ref="description"></span>
|
||||||
|
</p>`
|
||||||
|
const [element, { toolbox, description }] = Utils.loadTemplateWithRefs(template)
|
||||||
if (datalayer.options.description) {
|
if (datalayer.options.description) {
|
||||||
DomUtil.element({
|
description.innerHTML = Utils.toHTML(datalayer.options.description)
|
||||||
tagName: 'span',
|
} else {
|
||||||
parent: p,
|
description.hidden = true
|
||||||
safeHTML: Utils.toHTML(datalayer.options.description),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
datalayer.renderToolbox(headline)
|
datalayer.renderToolbox(toolbox)
|
||||||
DomUtil.add('span', '', headline, `${datalayer.options.name} `)
|
parent.appendChild(element)
|
||||||
|
// Use textContent for security
|
||||||
|
const name = Utils.loadTemplate('<span></span>')
|
||||||
|
name.textContent = datalayer.options.name
|
||||||
|
toolbox.appendChild(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
addCredits(container) {
|
addCredits() {
|
||||||
const credits = DomUtil.createFieldset(container, translate('Credits'))
|
|
||||||
let title = DomUtil.add('h5', '', credits, translate('User content credits'))
|
|
||||||
if (this._umap.properties.shortCredit || this._umap.properties.longCredit) {
|
if (this._umap.properties.shortCredit || this._umap.properties.longCredit) {
|
||||||
DomUtil.element({
|
this.elements.userCredits.innerHTML = Utils.toHTML(
|
||||||
tagName: 'p',
|
this._umap.properties.longCredit || this._umap.properties.shortCredit
|
||||||
parent: credits,
|
|
||||||
safeHTML: Utils.toHTML(
|
|
||||||
this._umap.properties.longCredit || this._umap.properties.shortCredit
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (this._umap.properties.licence) {
|
|
||||||
const licence = DomUtil.add(
|
|
||||||
'p',
|
|
||||||
'',
|
|
||||||
credits,
|
|
||||||
`${translate('Map user content has been published under licence')} `
|
|
||||||
)
|
|
||||||
DomUtil.createLink(
|
|
||||||
'',
|
|
||||||
licence,
|
|
||||||
this._umap.properties.licence.name,
|
|
||||||
this._umap.properties.licence.url
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
DomUtil.add('p', '', credits, translate('No licence has been set'))
|
this.elements.userCredits.hidden = true
|
||||||
}
|
}
|
||||||
title = DomUtil.create('h5', '', credits)
|
if (this._umap.properties.licence) {
|
||||||
title.textContent = translate('Map background credits')
|
this.elements.licenceLink.href = this._umap.properties.licence.url
|
||||||
const tilelayerCredit = DomUtil.create('p', '', credits)
|
this.elements.licenceLink.textContent = this._umap.properties.licence.name
|
||||||
DomUtil.element({
|
this.elements.noLicence.hidden = true
|
||||||
tagName: 'strong',
|
} else {
|
||||||
parent: tilelayerCredit,
|
this.elements.licence.hidden = true
|
||||||
textContent: `${this._leafletMap.selectedTilelayer.options.name} `,
|
}
|
||||||
})
|
this.elements.bgName.textContent = this._leafletMap.selectedTilelayer.options.name
|
||||||
DomUtil.element({
|
this.elements.bgAttribution.innerHTML =
|
||||||
tagName: 'span',
|
this._leafletMap.selectedTilelayer.getAttribution()
|
||||||
parent: tilelayerCredit,
|
|
||||||
safeHTML: this._leafletMap.selectedTilelayer.getAttribution(),
|
|
||||||
})
|
|
||||||
const urls = {
|
const urls = {
|
||||||
leaflet: 'http://leafletjs.com',
|
leaflet: 'http://leafletjs.com',
|
||||||
django: 'https://www.djangoproject.com',
|
django: 'https://www.djangoproject.com',
|
||||||
|
@ -113,7 +114,7 @@ export default class Caption {
|
||||||
changelog: 'https://docs.umap-project.org/en/master/changelog/',
|
changelog: 'https://docs.umap-project.org/en/master/changelog/',
|
||||||
version: this._umap.properties.umap_version,
|
version: this._umap.properties.umap_version,
|
||||||
}
|
}
|
||||||
const creditHTML = translate(
|
this.elements.poweredBy.innerHTML = translate(
|
||||||
`
|
`
|
||||||
Powered by <a href="{leaflet}">Leaflet</a> and
|
Powered by <a href="{leaflet}">Leaflet</a> and
|
||||||
<a href="{django}">Django</a>,
|
<a href="{django}">Django</a>,
|
||||||
|
@ -122,6 +123,5 @@ export default class Caption {
|
||||||
`,
|
`,
|
||||||
urls
|
urls
|
||||||
)
|
)
|
||||||
DomUtil.element({ tagName: 'p', innerHTML: creditHTML, parent: credits })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ import loadPopup from '../rendering/popup.js'
|
||||||
class Feature {
|
class Feature {
|
||||||
constructor(umap, datalayer, geojson = {}, id = null) {
|
constructor(umap, datalayer, geojson = {}, id = null) {
|
||||||
this._umap = umap
|
this._umap = umap
|
||||||
this.sync = umap.sync_engine.proxy(this)
|
this.sync = umap.syncEngine.proxy(this)
|
||||||
this._marked_for_deletion = false
|
this._marked_for_deletion = false
|
||||||
this._isDirty = false
|
this._isDirty = false
|
||||||
this._ui = null
|
this._ui = null
|
||||||
|
|
|
@ -41,7 +41,7 @@ export class DataLayer extends ServerStored {
|
||||||
constructor(umap, leafletMap, data = {}) {
|
constructor(umap, leafletMap, data = {}) {
|
||||||
super()
|
super()
|
||||||
this._umap = umap
|
this._umap = umap
|
||||||
this.sync = umap.sync_engine.proxy(this)
|
this.sync = umap.syncEngine.proxy(this)
|
||||||
this._index = Array()
|
this._index = Array()
|
||||||
this._features = {}
|
this._features = {}
|
||||||
this._geojson = null
|
this._geojson = null
|
||||||
|
@ -88,7 +88,6 @@ export class DataLayer extends ServerStored {
|
||||||
|
|
||||||
if (!this.createdOnServer) {
|
if (!this.createdOnServer) {
|
||||||
if (this.showAtLoad()) this.show()
|
if (this.showAtLoad()) this.show()
|
||||||
this.isDirty = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only layers that are displayed on load must be hidden/shown
|
// Only layers that are displayed on load must be hidden/shown
|
||||||
|
@ -151,7 +150,6 @@ export class DataLayer extends ServerStored {
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
this.layer.onEdit(field, builder)
|
this.layer.onEdit(field, builder)
|
||||||
}
|
}
|
||||||
this.redraw()
|
|
||||||
this.show()
|
this.show()
|
||||||
break
|
break
|
||||||
case 'remote-data':
|
case 'remote-data':
|
||||||
|
@ -592,7 +590,7 @@ export class DataLayer extends ServerStored {
|
||||||
options.name = translate('Clone of {name}', { name: this.options.name })
|
options.name = translate('Clone of {name}', { name: this.options.name })
|
||||||
delete options.id
|
delete options.id
|
||||||
const geojson = Utils.CopyJSON(this._geojson)
|
const geojson = Utils.CopyJSON(this._geojson)
|
||||||
const datalayer = this._umap.createDataLayer(options)
|
const datalayer = this._umap.createDirtyDataLayer(options)
|
||||||
datalayer.fromGeoJSON(geojson)
|
datalayer.fromGeoJSON(geojson)
|
||||||
return datalayer
|
return datalayer
|
||||||
}
|
}
|
||||||
|
@ -1066,7 +1064,7 @@ export class DataLayer extends ServerStored {
|
||||||
|
|
||||||
setReferenceVersion({ response, sync }) {
|
setReferenceVersion({ response, sync }) {
|
||||||
this._referenceVersion = response.headers.get('X-Datalayer-Version')
|
this._referenceVersion = response.headers.get('X-Datalayer-Version')
|
||||||
this.sync.update('_referenceVersion', this._referenceVersion)
|
if (sync) this.sync.update('_referenceVersion', this._referenceVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
async save() {
|
async save() {
|
||||||
|
|
|
@ -161,7 +161,7 @@ export default class Importer extends Utils.WithTemplate {
|
||||||
get layer() {
|
get layer() {
|
||||||
return (
|
return (
|
||||||
this._umap.datalayers[this.layerId] ||
|
this._umap.datalayers[this.layerId] ||
|
||||||
this._umap.createDataLayer({ name: this.layerName })
|
this._umap.createDirtyDataLayer({ name: this.layerName })
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,12 @@ import { HybridLogicalClock } from './hlc.js'
|
||||||
import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js'
|
import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js'
|
||||||
import { WebSocketTransport } from './websocket.js'
|
import { WebSocketTransport } from './websocket.js'
|
||||||
|
|
||||||
|
// Start reconnecting after 2 seconds, then double the delay each time
|
||||||
|
// maxing out at 32 seconds.
|
||||||
|
const RECONNECT_DELAY = 2000
|
||||||
|
const RECONNECT_DELAY_FACTOR = 2
|
||||||
|
const MAX_RECONNECT_DELAY = 32000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The syncEngine exposes an API to sync messages between peers over the network.
|
* The syncEngine exposes an API to sync messages between peers over the network.
|
||||||
*
|
*
|
||||||
|
@ -42,32 +48,65 @@ import { WebSocketTransport } from './websocket.js'
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class SyncEngine {
|
export class SyncEngine {
|
||||||
constructor(map) {
|
constructor(umap) {
|
||||||
|
this._umap = umap
|
||||||
this.updaters = {
|
this.updaters = {
|
||||||
map: new MapUpdater(map),
|
map: new MapUpdater(umap),
|
||||||
feature: new FeatureUpdater(map),
|
feature: new FeatureUpdater(umap),
|
||||||
datalayer: new DataLayerUpdater(map),
|
datalayer: new DataLayerUpdater(umap),
|
||||||
}
|
}
|
||||||
this.transport = undefined
|
this.transport = undefined
|
||||||
this._operations = new Operations()
|
this._operations = new Operations()
|
||||||
|
|
||||||
|
this._reconnectTimeout = null
|
||||||
|
this._reconnectDelay = RECONNECT_DELAY
|
||||||
|
this.websocketConnected = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async authenticate(tokenURI, webSocketURI, server) {
|
async authenticate() {
|
||||||
const [response, _, error] = await server.get(tokenURI)
|
const websocketTokenURI = this._umap.urls.get('map_websocket_auth_token', {
|
||||||
|
map_id: this._umap.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [response, _, error] = await this._umap.server.get(websocketTokenURI)
|
||||||
if (!error) {
|
if (!error) {
|
||||||
this.start(webSocketURI, response.token)
|
this.start(response.token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
start(webSocketURI, authToken) {
|
start(authToken) {
|
||||||
this.transport = new WebSocketTransport(webSocketURI, authToken, this)
|
this.transport = new WebSocketTransport(
|
||||||
|
this._umap.properties.websocketURI,
|
||||||
|
authToken,
|
||||||
|
this
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
if (this.transport) this.transport.close()
|
if (this.transport) {
|
||||||
|
this.transport.close()
|
||||||
|
}
|
||||||
this.transport = undefined
|
this.transport = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onConnection() {
|
||||||
|
this._reconnectTimeout = null
|
||||||
|
this._reconnectDelay = RECONNECT_DELAY
|
||||||
|
this.websocketConnected = true
|
||||||
|
this.updaters.map.update({ key: 'numberOfConnectedPeers' })
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnect() {
|
||||||
|
this.websocketConnected = false
|
||||||
|
this.updaters.map.update({ key: 'numberOfConnectedPeers' })
|
||||||
|
|
||||||
|
this._reconnectTimeout = setTimeout(() => {
|
||||||
|
if (this._reconnectDelay < MAX_RECONNECT_DELAY) {
|
||||||
|
this._reconnectDelay = this._reconnectDelay * RECONNECT_DELAY_FACTOR
|
||||||
|
}
|
||||||
|
this.authenticate()
|
||||||
|
}, this._reconnectDelay)
|
||||||
|
}
|
||||||
upsert(subject, metadata, value) {
|
upsert(subject, metadata, value) {
|
||||||
this._send({ verb: 'upsert', subject, metadata, value })
|
this._send({ verb: 'upsert', subject, metadata, value })
|
||||||
}
|
}
|
||||||
|
@ -183,9 +222,13 @@ export class SyncEngine {
|
||||||
* @param {string} payload.sender the uuid of the requesting peer
|
* @param {string} payload.sender the uuid of the requesting peer
|
||||||
* @param {string} payload.latestKnownHLC the latest known HLC of the requesting peer
|
* @param {string} payload.latestKnownHLC the latest known HLC of the requesting peer
|
||||||
*/
|
*/
|
||||||
onListOperationsRequest({ sender, lastKnownHLC }) {
|
onListOperationsRequest({ sender, message }) {
|
||||||
|
debug(
|
||||||
|
`received operations request from peer ${sender} (since ${message.lastKnownHLC})`
|
||||||
|
)
|
||||||
|
|
||||||
this.sendToPeer(sender, 'ListOperationsResponse', {
|
this.sendToPeer(sender, 'ListOperationsResponse', {
|
||||||
operations: this._operations.getOperationsSince(lastKnownHLC),
|
operations: this._operations.getOperationsSince(message.lastKnownHLC),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -446,5 +489,5 @@ export class Operations {
|
||||||
}
|
}
|
||||||
|
|
||||||
function debug(...args) {
|
function debug(...args) {
|
||||||
console.debug('SYNC ⇆', ...args)
|
console.debug('SYNC ⇆', ...args.map((x) => JSON.stringify(x)))
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,7 +54,10 @@ export class MapUpdater extends BaseUpdater {
|
||||||
export class DataLayerUpdater extends BaseUpdater {
|
export class DataLayerUpdater extends BaseUpdater {
|
||||||
upsert({ value }) {
|
upsert({ value }) {
|
||||||
// Upsert only happens when a new datalayer is created.
|
// Upsert only happens when a new datalayer is created.
|
||||||
this._umap.createDataLayer(value, false)
|
const datalayer = this._umap.createDataLayer(value, false)
|
||||||
|
// Prevent the layer to get data from the server, as it will get it
|
||||||
|
// from the sync.
|
||||||
|
datalayer._loaded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
update({ key, metadata, value }) {
|
update({ key, metadata, value }) {
|
||||||
|
|
|
@ -1,15 +1,59 @@
|
||||||
|
const PONG_TIMEOUT = 5000
|
||||||
|
const PING_INTERVAL = 30000
|
||||||
|
const FIRST_CONNECTION_TIMEOUT = 2000
|
||||||
|
|
||||||
export class WebSocketTransport {
|
export class WebSocketTransport {
|
||||||
constructor(webSocketURI, authToken, messagesReceiver) {
|
constructor(webSocketURI, authToken, messagesReceiver) {
|
||||||
|
this.receiver = messagesReceiver
|
||||||
|
this.closeRequested = false
|
||||||
|
|
||||||
this.websocket = new WebSocket(webSocketURI)
|
this.websocket = new WebSocket(webSocketURI)
|
||||||
|
|
||||||
this.websocket.onopen = () => {
|
this.websocket.onopen = () => {
|
||||||
this.send('JoinRequest', { token: authToken })
|
this.send('JoinRequest', { token: authToken })
|
||||||
|
this.receiver.onConnection()
|
||||||
}
|
}
|
||||||
this.websocket.addEventListener('message', this.onMessage.bind(this))
|
this.websocket.addEventListener('message', this.onMessage.bind(this))
|
||||||
this.receiver = messagesReceiver
|
this.websocket.onclose = () => {
|
||||||
|
console.log('websocket closed')
|
||||||
|
if (!this.closeRequested) {
|
||||||
|
console.log('Not requested, reconnecting...')
|
||||||
|
this.receiver.reconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ensureOpen = setInterval(() => {
|
||||||
|
if (this.websocket.readyState !== WebSocket.OPEN) {
|
||||||
|
this.websocket.close()
|
||||||
|
clearInterval(this.ensureOpen)
|
||||||
|
}
|
||||||
|
}, FIRST_CONNECTION_TIMEOUT)
|
||||||
|
|
||||||
|
// To ensure the connection is still alive, we send ping and expect pong back.
|
||||||
|
// Websocket provides a `ping` method to keep the connection alive, but it's
|
||||||
|
// unfortunately not possible to access it from the WebSocket object.
|
||||||
|
// See https://making.close.com/posts/reliable-websockets/ for more details.
|
||||||
|
this.pingInterval = setInterval(() => {
|
||||||
|
if (this.websocket.readyState === WebSocket.OPEN) {
|
||||||
|
this.websocket.send('ping')
|
||||||
|
this.pongReceived = false
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.pongReceived) {
|
||||||
|
console.warn('No pong received, reconnecting...')
|
||||||
|
this.websocket.close()
|
||||||
|
clearInterval(this.pingInterval)
|
||||||
|
}
|
||||||
|
}, PONG_TIMEOUT)
|
||||||
|
}
|
||||||
|
}, PING_INTERVAL)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMessage(wsMessage) {
|
onMessage(wsMessage) {
|
||||||
this.receiver.receive(JSON.parse(wsMessage.data))
|
if (wsMessage.data === 'pong') {
|
||||||
|
this.pongReceived = true
|
||||||
|
} else {
|
||||||
|
this.receiver.receive(JSON.parse(wsMessage.data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
send(kind, payload) {
|
send(kind, payload) {
|
||||||
|
@ -20,6 +64,7 @@ export class WebSocketTransport {
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
|
this.closeRequested = true
|
||||||
this.websocket.close()
|
this.websocket.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,8 +61,6 @@ export default class Umap extends ServerStored {
|
||||||
)
|
)
|
||||||
this.searchParams = new URLSearchParams(window.location.search)
|
this.searchParams = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
this.sync_engine = new SyncEngine(this)
|
|
||||||
this.sync = this.sync_engine.proxy(this)
|
|
||||||
// Locale name (pt_PT, en_US…)
|
// Locale name (pt_PT, en_US…)
|
||||||
// To be used for Django localization
|
// To be used for Django localization
|
||||||
if (geojson.properties.locale) setLocale(geojson.properties.locale)
|
if (geojson.properties.locale) setLocale(geojson.properties.locale)
|
||||||
|
@ -124,6 +122,9 @@ export default class Umap extends ServerStored {
|
||||||
this.share = new Share(this)
|
this.share = new Share(this)
|
||||||
this.rules = new Rules(this)
|
this.rules = new Rules(this)
|
||||||
|
|
||||||
|
this.syncEngine = new SyncEngine(this)
|
||||||
|
this.sync = this.syncEngine.proxy(this)
|
||||||
|
|
||||||
if (this.hasEditMode()) {
|
if (this.hasEditMode()) {
|
||||||
this.editPanel = new EditPanel(this, this._leafletMap)
|
this.editPanel = new EditPanel(this, this._leafletMap)
|
||||||
this.fullPanel = new FullPanel(this, this._leafletMap)
|
this.fullPanel = new FullPanel(this, this._leafletMap)
|
||||||
|
@ -323,14 +324,14 @@ export default class Umap extends ServerStored {
|
||||||
dataUrl = decodeURIComponent(dataUrl)
|
dataUrl = decodeURIComponent(dataUrl)
|
||||||
dataUrl = this.renderUrl(dataUrl)
|
dataUrl = this.renderUrl(dataUrl)
|
||||||
dataUrl = this.proxyUrl(dataUrl)
|
dataUrl = this.proxyUrl(dataUrl)
|
||||||
const datalayer = this.createDataLayer()
|
const datalayer = this.createDirtyDataLayer()
|
||||||
await datalayer
|
await datalayer
|
||||||
.importFromUrl(dataUrl, dataFormat)
|
.importFromUrl(dataUrl, dataFormat)
|
||||||
.then(() => datalayer.zoomTo())
|
.then(() => datalayer.zoomTo())
|
||||||
}
|
}
|
||||||
} else if (data) {
|
} else if (data) {
|
||||||
data = decodeURIComponent(data)
|
data = decodeURIComponent(data)
|
||||||
const datalayer = this.createDataLayer()
|
const datalayer = this.createDirtyDataLayer()
|
||||||
await datalayer.importRaw(data, dataFormat).then(() => datalayer.zoomTo())
|
await datalayer.importRaw(data, dataFormat).then(() => datalayer.zoomTo())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -598,8 +599,14 @@ export default class Umap extends ServerStored {
|
||||||
return datalayer
|
return datalayer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createDirtyDataLayer(options) {
|
||||||
|
const datalayer = this.createDataLayer(options, true)
|
||||||
|
datalayer.isDirty = true
|
||||||
|
return datalayer
|
||||||
|
}
|
||||||
|
|
||||||
newDataLayer() {
|
newDataLayer() {
|
||||||
const datalayer = this.createDataLayer({})
|
const datalayer = this.createDirtyDataLayer({})
|
||||||
datalayer.edit()
|
datalayer.edit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1257,18 +1264,13 @@ export default class Umap extends ServerStored {
|
||||||
}
|
}
|
||||||
|
|
||||||
async initSyncEngine() {
|
async initSyncEngine() {
|
||||||
|
// this.properties.websocketEnabled is set by the server admin
|
||||||
if (this.properties.websocketEnabled === false) return
|
if (this.properties.websocketEnabled === false) return
|
||||||
|
// this.properties.syncEnabled is set by the user in the map settings
|
||||||
if (this.properties.syncEnabled !== true) {
|
if (this.properties.syncEnabled !== true) {
|
||||||
this.sync.stop()
|
this.sync.stop()
|
||||||
} else {
|
} else {
|
||||||
const ws_token_uri = this.urls.get('map_websocket_auth_token', {
|
await this.sync.authenticate()
|
||||||
map_id: this.id,
|
|
||||||
})
|
|
||||||
await this.sync.authenticate(
|
|
||||||
ws_token_uri,
|
|
||||||
this.properties.websocketURI,
|
|
||||||
this.server
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1343,7 +1345,17 @@ export default class Umap extends ServerStored {
|
||||||
},
|
},
|
||||||
numberOfConnectedPeers: () => {
|
numberOfConnectedPeers: () => {
|
||||||
Utils.eachElement('.connected-peers span', (el) => {
|
Utils.eachElement('.connected-peers span', (el) => {
|
||||||
el.textContent = this.sync.getNumberOfConnectedPeers()
|
if (this.sync.websocketConnected) {
|
||||||
|
el.textContent = this.sync.getNumberOfConnectedPeers()
|
||||||
|
} else {
|
||||||
|
el.textContent = translate('Disconnected')
|
||||||
|
}
|
||||||
|
el.parentElement.classList.toggle('off', !this.sync.websocketConnected)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
'properties.starred': () => {
|
||||||
|
Utils.eachElement('.map-star', (el) => {
|
||||||
|
el.classList.toggle('starred', this.properties.starred)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -1383,7 +1395,7 @@ export default class Umap extends ServerStored {
|
||||||
fallback.show()
|
fallback.show()
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
return this.createDataLayer()
|
return this.createDirtyDataLayer()
|
||||||
}
|
}
|
||||||
|
|
||||||
findDataLayer(method, context) {
|
findDataLayer(method, context) {
|
||||||
|
@ -1531,7 +1543,7 @@ export default class Umap extends ServerStored {
|
||||||
? translate('Map has been starred')
|
? translate('Map has been starred')
|
||||||
: translate('Map has been unstarred')
|
: translate('Map has been unstarred')
|
||||||
)
|
)
|
||||||
this.render(['starred'])
|
this.render(['properties.starred'])
|
||||||
}
|
}
|
||||||
|
|
||||||
processFileToImport(file, layer, type) {
|
processFileToImport(file, layer, type) {
|
||||||
|
@ -1547,7 +1559,7 @@ export default class Umap extends ServerStored {
|
||||||
if (type === 'umap') {
|
if (type === 'umap') {
|
||||||
this.importUmapFile(file, 'umap')
|
this.importUmapFile(file, 'umap')
|
||||||
} else {
|
} else {
|
||||||
if (!layer) layer = this.createDataLayer({ name: file.name })
|
if (!layer) layer = this.createDirtyDataLayer({ name: file.name })
|
||||||
layer.importFromFile(file, type)
|
layer.importFromFile(file, type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1573,7 +1585,7 @@ export default class Umap extends ServerStored {
|
||||||
delete geojson._storage
|
delete geojson._storage
|
||||||
}
|
}
|
||||||
delete geojson._umap_options?.id // Never trust an id at this stage
|
delete geojson._umap_options?.id // Never trust an id at this stage
|
||||||
const dataLayer = this.createDataLayer(geojson._umap_options)
|
const dataLayer = this.createDirtyDataLayer(geojson._umap_options)
|
||||||
dataLayer.fromUmapGeoJSON(geojson)
|
dataLayer.fromUmapGeoJSON(geojson)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -541,11 +541,7 @@ U.StarControl = L.Control.Button.extend({
|
||||||
options: {
|
options: {
|
||||||
position: 'topleft',
|
position: 'topleft',
|
||||||
title: L._('Star this map'),
|
title: L._('Star this map'),
|
||||||
},
|
className: 'leaflet-control-star map-star umap-control',
|
||||||
|
|
||||||
getClassName: function () {
|
|
||||||
const status = this._umap.properties.starred ? ' starred' : ''
|
|
||||||
return `leaflet-control-star umap-control${status}`
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
|
|
|
@ -8,8 +8,11 @@ import { MapUpdater } from '../js/modules/sync/updaters.js'
|
||||||
import { SyncEngine, Operations } from '../js/modules/sync/engine.js'
|
import { SyncEngine, Operations } from '../js/modules/sync/engine.js'
|
||||||
|
|
||||||
describe('SyncEngine', () => {
|
describe('SyncEngine', () => {
|
||||||
|
const websocketTokenURI = 'http://localhost:8000/api/v1/maps/1/websocket_auth_token/'
|
||||||
|
const websocketURI = 'ws://localhost:8000/ws/maps/1/'
|
||||||
|
|
||||||
it('should initialize methods even before start', () => {
|
it('should initialize methods even before start', () => {
|
||||||
const engine = new SyncEngine({})
|
const engine = new SyncEngine({}, websocketTokenURI, websocketURI)
|
||||||
engine.upsert()
|
engine.upsert()
|
||||||
engine.update()
|
engine.update()
|
||||||
engine.delete()
|
engine.delete()
|
||||||
|
|
|
@ -39,6 +39,9 @@ def test_websocket_connection_can_sync_markers(
|
||||||
a_map_el.click(position={"x": 220, "y": 220})
|
a_map_el.click(position={"x": 220, "y": 220})
|
||||||
expect(a_marker_pane).to_have_count(1)
|
expect(a_marker_pane).to_have_count(1)
|
||||||
expect(b_marker_pane).to_have_count(1)
|
expect(b_marker_pane).to_have_count(1)
|
||||||
|
# Peer B should not be in state dirty
|
||||||
|
expect(peerB.get_by_role("button", name="View")).to_be_visible()
|
||||||
|
expect(peerB.get_by_role("button", name="Cancel edits")).to_be_hidden()
|
||||||
peerA.locator("body").type("Synced name")
|
peerA.locator("body").type("Synced name")
|
||||||
peerA.locator("body").press("Escape")
|
peerA.locator("body").press("Escape")
|
||||||
|
|
||||||
|
@ -415,3 +418,69 @@ def test_should_sync_datalayers(new_page, live_server, websocket_server, tilelay
|
||||||
peerA.get_by_role("button", name="Save").click()
|
peerA.get_by_role("button", name="Save").click()
|
||||||
|
|
||||||
assert DataLayer.objects.count() == 2
|
assert DataLayer.objects.count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xdist_group(name="websockets")
|
||||||
|
def test_create_and_sync_map(
|
||||||
|
new_page, live_server, websocket_server, tilelayer, login, user
|
||||||
|
):
|
||||||
|
# Create a syncable map with peerA
|
||||||
|
peerA = login(user, prefix="Page A")
|
||||||
|
peerA.goto(f"{live_server.url}/en/map/new/")
|
||||||
|
with peerA.expect_response(re.compile("./map/create/.*")):
|
||||||
|
peerA.get_by_role("button", name="Save Draft").click()
|
||||||
|
peerA.get_by_role("link", name="Map advanced properties").click()
|
||||||
|
peerA.get_by_text("Real-time collaboration", exact=True).click()
|
||||||
|
peerA.get_by_text("Enable real-time").click()
|
||||||
|
peerA.get_by_role("link", name="Update permissions and editors").click()
|
||||||
|
peerA.locator('select[name="share_status"]').select_option(str(Map.PUBLIC))
|
||||||
|
with peerA.expect_response(re.compile("./update/settings/.*")):
|
||||||
|
peerA.get_by_role("button", name="Save").click()
|
||||||
|
expect(peerA.get_by_role("button", name="Cancel edits")).to_be_hidden()
|
||||||
|
# Quit edit mode
|
||||||
|
peerA.get_by_role("button", name="View").click()
|
||||||
|
|
||||||
|
# Open map and go to edit mode with peer B
|
||||||
|
peerB = new_page("Page B")
|
||||||
|
peerB.goto(peerA.url)
|
||||||
|
peerB.get_by_role("button", name="Edit").click()
|
||||||
|
|
||||||
|
# Create a marker from peerA
|
||||||
|
markersA = peerA.locator(".leaflet-marker-pane > div")
|
||||||
|
markersB = peerB.locator(".leaflet-marker-pane > div")
|
||||||
|
expect(markersA).to_have_count(0)
|
||||||
|
expect(markersB).to_have_count(0)
|
||||||
|
|
||||||
|
# Add a marker from peer A
|
||||||
|
peerA.get_by_role("button", name="Edit").click()
|
||||||
|
peerA.get_by_title("Draw a marker").click()
|
||||||
|
peerA.locator("#map").click(position={"x": 220, "y": 220})
|
||||||
|
expect(markersA).to_have_count(1)
|
||||||
|
expect(markersB).to_have_count(1)
|
||||||
|
|
||||||
|
# Save and quit edit mode again
|
||||||
|
with peerA.expect_response(re.compile("./datalayer/create/.*")):
|
||||||
|
peerA.get_by_role("button", name="Save").click()
|
||||||
|
peerA.get_by_role("button", name="View").click()
|
||||||
|
expect(markersA).to_have_count(1)
|
||||||
|
expect(markersB).to_have_count(1)
|
||||||
|
peerA.wait_for_timeout(500)
|
||||||
|
expect(markersA).to_have_count(1)
|
||||||
|
expect(markersB).to_have_count(1)
|
||||||
|
|
||||||
|
# Peer B should not be in state dirty
|
||||||
|
expect(peerB.get_by_role("button", name="View")).to_be_visible()
|
||||||
|
expect(peerB.get_by_role("button", name="Cancel edits")).to_be_hidden()
|
||||||
|
|
||||||
|
# Add a marker from peer B
|
||||||
|
peerB.get_by_title("Draw a marker").click()
|
||||||
|
peerB.locator("#map").click(position={"x": 200, "y": 200})
|
||||||
|
expect(markersB).to_have_count(2)
|
||||||
|
expect(markersA).to_have_count(1)
|
||||||
|
with peerB.expect_response(re.compile("./datalayer/update/.*")):
|
||||||
|
peerB.get_by_role("button", name="Save").click()
|
||||||
|
expect(markersB).to_have_count(2)
|
||||||
|
expect(markersA).to_have_count(1)
|
||||||
|
peerA.get_by_role("button", name="Edit").click()
|
||||||
|
expect(markersA).to_have_count(2)
|
||||||
|
expect(markersB).to_have_count(2)
|
||||||
|
|
|
@ -126,6 +126,10 @@ async def join_and_listen(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async for raw_message in websocket:
|
async for raw_message in websocket:
|
||||||
|
if raw_message == "ping":
|
||||||
|
await websocket.send("pong")
|
||||||
|
continue
|
||||||
|
|
||||||
# recompute the peers list at the time of message-sending.
|
# recompute the peers list at the time of message-sending.
|
||||||
# as doing so beforehand would miss new connections
|
# as doing so beforehand would miss new connections
|
||||||
other_peers = connections.get_other_peers(websocket)
|
other_peers = connections.get_other_peers(websocket)
|
||||||
|
@ -192,4 +196,7 @@ def run(host: str, port: int):
|
||||||
logging.debug(f"Waiting for connections on {host}:{port}")
|
logging.debug(f"Waiting for connections on {host}:{port}")
|
||||||
await asyncio.Future() # run forever
|
await asyncio.Future() # run forever
|
||||||
|
|
||||||
asyncio.run(_serve())
|
try:
|
||||||
|
asyncio.run(_serve())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("Closing WebSocket server")
|
||||||
|
|
Loading…
Reference in a new issue