This commit is contained in:
Alexis Métaireau 2024-01-29 13:08:17 +01:00
parent d219ed331f
commit 74ea6d800c
16 changed files with 3188 additions and 89 deletions

View file

@ -25,6 +25,13 @@ lint: ## Lint the code and template files
ruff format --check --target-version=py38 . &&\ ruff format --check --target-version=py38 . &&\
vermin --no-tips --violations -t=3.8- . vermin --no-tips --violations -t=3.8- .
yjs:
npx webpack --entry ./localyjs.js && rm -fr umap/static/umap/vendors/yjs && mkdir umap/static/umap/vendors/yjs && mv dist/main.js umap/static/umap/vendors/yjs/yjs.js
yws:
npx webpack --entry ./localyws.js && rm -fr umap/static/umap/vendors/yws && mkdir umap/static/umap/vendors/yws && mv dist/main.js umap/static/umap/vendors/yws/yws.js
serve:
umap runserver
docs: ## Compile the docs docs: ## Compile the docs
mkdocs build mkdocs build

2960
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,8 @@
"optimist": "~0.4.0", "optimist": "~0.4.0",
"prettier": "^2.8.8", "prettier": "^2.8.8",
"sinon": "^15.1.0", "sinon": "^15.1.0",
"uglify-js": "~3.17.4" "uglify-js": "~3.17.4",
"webpack-cli": "^5.1.4"
}, },
"scripts": { "scripts": {
"test": "firefox test/index.html", "test": "firefox test/index.html",
@ -38,6 +39,7 @@
"csv2geojson": "5.1.1", "csv2geojson": "5.1.1",
"dompurify": "^3.0.3", "dompurify": "^3.0.3",
"georsstogeojson": "^0.1.0", "georsstogeojson": "^0.1.0",
"json-joy": "^11.28.0",
"leaflet": "1.9.4", "leaflet": "1.9.4",
"leaflet-contextmenu": "^1.4.0", "leaflet-contextmenu": "^1.4.0",
"leaflet-editable": "^1.2.0", "leaflet-editable": "^1.2.0",
@ -59,6 +61,9 @@
"osmtogeojson": "^3.0.0-beta.3", "osmtogeojson": "^3.0.0-beta.3",
"simple-statistics": "^7.8.3", "simple-statistics": "^7.8.3",
"togpx": "^0.5.4", "togpx": "^0.5.4",
"tokml": "0.4.0" "tokml": "0.4.0",
"webpack": "^5.89.0",
"y-websocket": "^1.5.3",
"yjs": "^13.6.10"
} }
} }

View file

@ -29,5 +29,6 @@ mkdir -p umap/static/umap/vendors/dompurify/ && cp -r node_modules/dompurify/dis
mkdir -p umap/static/umap/vendors/colorbrewer/ && cp node_modules/colorbrewer/index.js umap/static/umap/vendors/colorbrewer/colorbrewer.js mkdir -p umap/static/umap/vendors/colorbrewer/ && cp node_modules/colorbrewer/index.js umap/static/umap/vendors/colorbrewer/colorbrewer.js
mkdir -p umap/static/umap/vendors/simple-statistics/ && cp node_modules/simple-statistics/dist/simple-statistics.min.js umap/static/umap/vendors/simple-statistics/ mkdir -p umap/static/umap/vendors/simple-statistics/ && cp node_modules/simple-statistics/dist/simple-statistics.min.js umap/static/umap/vendors/simple-statistics/
mkdir -p umap/static/umap/vendors/iconlayers/ && cp node_modules/leaflet-iconlayers/dist/* umap/static/umap/vendors/iconlayers/ mkdir -p umap/static/umap/vendors/iconlayers/ && cp node_modules/leaflet-iconlayers/dist/* umap/static/umap/vendors/iconlayers/
mkdir -p umap/static/umap/vendors/y-websocket/ && cp -r node_modules/y-websocket/dist/y-websocket.cjs umap/static/umap/vendors/y-websocket/y-websocket.js
echo 'Done!' echo 'Done!'

View file

@ -1,11 +1,15 @@
import * as L from '../../vendors/leaflet/leaflet-src.esm.js' import * as L from '../../vendors/leaflet/leaflet-src.esm.js'
import * as Y from '../../vendors/yjs/yjs.js' import {MessagesSender} from './sync/messages/sender.js'
import {MessagesReceiver} from "./sync/messages/receiver.js"
import {WebSocketTransport} from './sync/websocket.js'
import URLs from './urls.js' import URLs from './urls.js'
// Import modules and export them to the global scope. // Import modules and export them to the global scope.
// For the not yet module-compatible JS out there. // For the not yet module-compatible JS out there.
// Copy the leaflet module, it's expected by leaflet plugins to be writeable. // Copy the leaflet module, it's expected by leaflet plugins to be writeable.
window.L = { ...L } window.L = { ...L }
window.Y = Y window.umap = { URLs, WebSocketTransport, MessagesReceiver, MessagesSender}
window.umap = { URLs }

View file

@ -0,0 +1,56 @@
export class BaseMessage {
// Possible actions:
// - set-obj-data
// - new-layer
constructor(action, subject, path, value){
this.action = action
this.subject = subject
this.path = path
this.value = value
}
encode(){
return encodeMessage({
action: this.action,
subject: this.subject,
path: this.path,
value: this.value
})
}
decode(encoded){
return decodeMessage(encoded)
}
}
export class SetDataMessage extends BaseMessage{
constructor(subject, path, value){
super("set-data", subject, path, value)
}
}
export class NewLayer extends BaseMessage{
constructor(subject, path, value){
super("new-layer", subject, path, value)
}
}
export function encodeMessage(payload, type="message"){
return JSON.stringify({
type: type,
payload: payload
})
}
export function decodeMessage(encodedData){
console.log("encoded message", encodedData)
// XXX Ensure the data matches what we expect here.
let parsed = JSON.parse(encodedData)
console.log(parsed)
switch(parsed.payload.action){
case 'set-data':
let {subject, path, value} = parsed.payload
return new SetDataMessage(subject, path, value)
}
}

View file

@ -0,0 +1,24 @@
import { MapUpdater } from "../updaters/mapUpdater.js"
import { LayerGroupUpdater } from "../updaters/layerGroupUpdater.js"
// FIXME: Maybe name this MessagesDispatcher ?
export class MessagesReceiver {
constructor(map){
this.updaters = {
map: new MapUpdater(map),
layers: new LayerGroupUpdater(map)
}
}
dispatch(message){
// FIXME if message.subject not in this.updaters: throw
console.log("message", message)
const updater = this.updaters[message.subject]
switch(message.action){
case "set-data":
updater.setData(message)
}
}
}

View file

@ -0,0 +1,14 @@
import { SetDataMessage } from "./messages.js"
export class MessagesSender {
constructor(subject, transport){
this._transport = transport
this._subject = subject
}
set(key, value){
let message = new SetDataMessage(this._subject, key, value)
console.log("message obj", message, message.encode())
this._transport.send(message)
}
}

View file

@ -0,0 +1,10 @@
export class LayerGroupUpdater{
constructor(map){
this.map = map
}
setData(message){
// find the layer we're talking about
// Relay the message.
}
}

View file

@ -0,0 +1,23 @@
// all the updaters have the same interface
// .setData() which should rerender the object.
export class FormBuilderObjectUpdater{
constructor(obj){
this.obj = obj
}
setData(message){
console.log(message)
// FIXME, this is the simple version
// Need to support subobjects, see https://github.com/yohanboniface/Leaflet.FormBuilder/blob/master/Leaflet.FormBuilder.js#L70-L86
this.obj.options[message.path] = message.value
// FIXME, this needs to be rendered only once if possible, accross different updates.
// (not sure how)
this.obj.renderProperties([message.path])
}
}
export class MapUpdater extends FormBuilderObjectUpdater {
}

View file

@ -0,0 +1,28 @@
import { decodeMessage } from "./messages/messages.js";
export class WebSocketTransport {
constructor(messagesReceiver){
this.id = crypto.randomUUID()
this.websocket = new WebSocket("ws://localhost:8001/");
this.websocket.onopen = (msg) => {this.onOpen(msg)}
this.websocket.addEventListener("message", this.onMessage.bind(this));
this.receiver = messagesReceiver
}
onOpen(msg){
var joinMsg = {type: 'join'}
console.log("joining websocket")
this.websocket.send(JSON.stringify(joinMsg))
}
onMessage(encoded){
console.log("received encoded message", encoded)
let message = decodeMessage(encoded.data)
this.receiver.dispatch(message)
}
send(message){
let encoded = message.encode()
this.websocket.send(encoded)
}
}

View file

@ -3,43 +3,39 @@
* *
* The mixed class needs to expose: * The mixed class needs to expose:
* *
* - `dataUpdaters`, an object matching each property with a list of renderers. * - `propertiesRenderers`, an object matching each property with a list of renderers.
* - `getDataObject`, a method returning where the data is stored/retrieved. * - `getDataObject`, a method returning where the data is stored/retrieved.
* - `getCRDT`, a method returning the CRDT used
*/ */
L.U.DataRendererMixin = { L.U.DataRendererMixin = {
populateCRDT: function () {
for (const [key, value] of Object.entries(this.options)) {
this.crdt.set(key, value)
}
},
/** /**
* For each passed property, find the functions to rerender the interface, * Rerender the interface for the properties passed as an argument.
* and call them. *
*
* @param list updatedProperties : properties that have been updated. * @param list updatedProperties : properties that have been updated.
*/ */
renderProperties: function (updatedProperties) { renderProperties: function (updatedProperties) {
console.debug(updatedProperties)
let renderers = new Set() let renderers = new Set()
for (const prop of updatedProperties) { for (const prop of updatedProperties) {
const propRenderers = this.dataUpdaters[prop] const propRenderers = this.propertiesRenderers[prop]
if (propRenderers) { if (propRenderers) {
for (const renderer of propRenderers) renderers.add(renderer) for (const renderer of propRenderers) renderers.add(renderer)
} }
} }
console.debug('renderers', renderers)
for (const renderer of renderers) this[renderer]() for (const renderer of renderers) this[renderer]()
}, },
dataReceived: function () {
// Data has been received over the wire
this.updateInternalData()
this.onPropertiesUpdated(['name', 'color'])
},
} }
L.U.FormBuilderDataRendererMixin = { // L.U.LWWMapDataRendereMixin = L.U.DataRendererMixin {
getDataObject: function () { // updateData: function(){
return this.options // if (this.crdt) this.crdt.set(field, value)
}, // }
} // }
// L.U.FormBuilderDataRendererMixin = L.U.DataRendererMixin.extend({
// getDataObject: function () {
// return this.options
// },
// })

View file

@ -1260,11 +1260,14 @@ L.U.FormBuilder = L.FormBuilder.extend({
L.FormBuilder.prototype.setter.call(this, field, value) L.FormBuilder.prototype.setter.call(this, field, value)
if (this.options.makeDirty !== false) this.obj.isDirty = true if (this.options.makeDirty !== false) this.obj.isDirty = true
// FIXME: for now remove the options prefix // FIXME: for now remove the options prefix if it's present.
field = field.replace('options.', '') field = field.replace('options.', '')
if (this.obj.crdt) this.obj.crdt.set(field, value) console.log("formbuilder object updated", this.obj)
this.obj.onPropertiesUpdated([field]) // FIXME: maybe name this "obj.updateProperty" / "obj.updateData",
// and let it trigger the rendering to expose less surface.
this.obj.broadcast.set(field, value)
this.obj.renderProperties([field])
}, },
finish: function () { finish: function () {

View file

@ -55,6 +55,7 @@ L.Map.mergeOptions({
featuresHaveOwner: false, featuresHaveOwner: false,
}) })
L.U.Map.include(L.U.DataRendererMixin)
L.U.Map.include({ L.U.Map.include({
HIDDABLE_CONTROLS: [ HIDDABLE_CONTROLS: [
'zoom', 'zoom',
@ -171,18 +172,40 @@ L.U.Map.include({
}) })
}, },
broadcastChanges: function (data) { initializeCRDT: function(data){
// Send changes over the wire this.crdt = this.getCRDT()
console.log(data) this.populateCRDT(data)
},
updateInternalData: function () {
this.options.name = 'CRDTS, yeah'
this.options.color = 'Fushia'
}, },
getCRDT: function () { getCRDT: function () {
return this._main_crdt.getMap('map') return this._main_crdt.obj
},
populateCRDT: function (data) {
let object = {}
for (const [key, value] of Object.entries(data)){
// For now ignore embedded types
switch (typeof(value)){
case 'string':
case 'number':
case 'boolean':
object[key] = s.val(value)
default:
}
}
this._main_crdt.api.root({
zoom: val
})
console.log(this._main_crdt.view());
// for (const [key, value] of Object.entries(data)) {
// this.crdt.set(key, value)
// }
},
updateCRDT: function(field, value){
this._main_crdt.obj.set(field, value)
}, },
initialize: function (el, geojson) { initialize: function (el, geojson) {
@ -408,7 +431,12 @@ L.U.Map.include({
this.backup() this.backup()
this.initContextMenu() this.initContextMenu()
this.on('click contextmenu.show', this.closeInplaceToolbar) this.on('click contextmenu.show', this.closeInplaceToolbar)
this._main_crdt = new YJS.Doc()
// Sync with other party
// The dispatcher takes remote messages and route them to the proper handler
let receiver = new umap.MessagesReceiver(this)
let transport = new umap.WebSocketTransport(receiver)
this.broadcast = new umap.MessagesSender("map", transport)
}, },
initControls: function () { initControls: function () {

View file

@ -41,6 +41,7 @@
{% if locale %}<script src="{{ STATIC_URL }}umap/locale/{{ locale }}.js" defer></script>{% endif %} {% if locale %}<script src="{{ STATIC_URL }}umap/locale/{{ locale }}.js" defer></script>{% endif %}
{% compress js %} {% compress js %}
<script src="{{ STATIC_URL }}umap/js/umap.core.js" defer></script> <script src="{{ STATIC_URL }}umap/js/umap.core.js" defer></script>
<script src="{{ STATIC_URL }}umap/js/umap.data.js" defer></script>
<script src="{{ STATIC_URL }}umap/js/umap.autocomplete.js" defer></script> <script src="{{ STATIC_URL }}umap/js/umap.autocomplete.js" defer></script>
<script src="{{ STATIC_URL }}umap/js/umap.popup.js" defer></script> <script src="{{ STATIC_URL }}umap/js/umap.popup.js" defer></script>
<script src="{{ STATIC_URL }}umap/js/umap.xhr.js" defer></script> <script src="{{ STATIC_URL }}umap/js/umap.xhr.js" defer></script>

37
ws.py Normal file
View file

@ -0,0 +1,37 @@
import asyncio
import websockets
from websockets.server import serve
import json
# Just relay all messages to other connected peers for now
CONNECTIONS = set()
async def join_and_listen(websocket):
print(f"Someone joined: {id(websocket)}")
CONNECTIONS.add(websocket)
try:
async for message in websocket:
# recompute the peers-list at the time of message-sending.
# doing so beforehand would miss new connections
peers = CONNECTIONS - {websocket}
print(message)
print(peers)
websockets.broadcast(peers, message)
finally:
CONNECTIONS.remove(websocket)
async def handler(websocket):
message = await websocket.recv()
event = json.loads(message)
# The first event should always be 'join'
assert event["type"] == "join"
await join_and_listen(websocket)
async def main():
async with serve(handler, "localhost", 8001):
await asyncio.Future() # run forever
asyncio.run(main())