www.spaceplanner.app

Web client to the spaceplanner API
git clone git://jacobedwards.org/www.spaceplanner.app
Log | Files | Refs

commit 53f6b3bd0a6594f585ff52eca056419de107dcd4
parent f9b5af1e3415f5cd9b605c3f34630184dad34959
Author: Jacob R. Edwards <jacob@jacobedwards.org>
Date:   Fri, 16 Aug 2024 18:22:11 -0700

Push and pull updates to and from the server

Diffstat:
Mfiles/floorplans/floorplan/backend.js | 216++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mfiles/floorplans/floorplan/editor.js | 58+++++++++++++++++++++++++++++++++++++++++-----------------
Mfiles/floorplans/floorplan/main.css | 18++++++++++++++++++
Mfiles/floorplans/floorplan/main.js | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Afiles/icons/arrow-down-outline.svg | 2++
Afiles/icons/arrow-up-outline.svg | 2++
6 files changed, 315 insertions(+), 49 deletions(-)

diff --git a/files/floorplans/floorplan/backend.js b/files/floorplans/floorplan/backend.js @@ -1,5 +1,28 @@ +import * as api from "/lib/api.js" + export class FloorplanBackend { - constructor() { + constructor(floorplan, options) { + if (!options) { + options = {} + } + + if (!floorplan || !floorplan.user || !floorplan.name) { + throw new Error("Requires floorplan") + } + this.floorplan = floorplan + + if (!options.server) { + // This does nothing at the moment + this.server = "https://api.spaceplanner.app" + } else { + this.server = options.server + } + + + if (options.callbacks) { + this.callbacks = options.callbacks + } + // Cache for server (both from and to) this.cache = { // { pointId: { x: Number, y: Number } } @@ -51,7 +74,7 @@ export class FloorplanBackend { * diff: <JSON Patch> * } * [...] - * } + * ] */ this.diffs = [] @@ -59,6 +82,10 @@ export class FloorplanBackend { this.diff = null } + get endpoint() { + return "floorplans/" + this.floorplan.user + "/" + this.floorplan.name + "/data" + } + // Start writing new differences to a new diff set newDiff() { if (this.diffs.length > 0 && this.diffs[this.diff].diff.length === 0) { @@ -73,11 +100,12 @@ export class FloorplanBackend { diff: [] }) - 1 console.debug("newDiff", this.diff) + this.cb("newdiff") return this.diff } // Add to current diff - addToDiff(op, path, value, dirty) { + addToDiff(op, path, value, options) { if (!op || !path) { throw new Error("Requires op and path") } @@ -98,9 +126,12 @@ export class FloorplanBackend { value: value, time: Date.now() } - if (dirty) { + if (!options.clean) { diff.dirty = true } + if (options.new) { + diff.new = true + } this.diffs[this.diff].diff.push(diff) console.debug("Backend.addToDiff", diff) } @@ -117,21 +148,13 @@ export class FloorplanBackend { // to get from time1 to time2 updatesBetween(time1, time2) { let updates = [] - let reverse = true + let reverse = false if (this.diffs.length === 0 || this.diffs[0].length === 0) { return [] } - if (!time1) { - time1 = this.diffs[0].diff[0].time - } - if (!time2) { - // Could use Date.now() I suppose - time2 = this.diffs.at(-1).diff.at(-1).time - } - - if (time1 > time2) { + if (time1 && time2 && time1 > time2) { reverse = !reverse let t = time1 time1 = time2 @@ -141,9 +164,9 @@ export class FloorplanBackend { for (let i in this.diffs) { for (let j in this.diffs[i].diff) { let diff = this.diffs[i].diff[j] - if (diff.time >= time1) { + if (!time1 || diff.time >= time1) { updates.push(diff) - } else if (diff.time > time2) { + } else if (time2 && diff.time > time2) { return reverse ? updates.reverse() : updates } } @@ -160,7 +183,7 @@ export class FloorplanBackend { */ addData(type, value, key, options) { if (!options) { - options = { diff: true, clean: false } + options = {} } if (!key) { @@ -172,27 +195,33 @@ export class FloorplanBackend { */ key = uniqueKey(this.cache[type]) } + console.debug("Backend.addData", type, key, value) + if (!this.cache[type][key]) { + options.new = true + } this.cache[type][key] = value // May want to use replace op if it's appropriate. - if (options.diff) { - this.addToDiff("add", diffPath(type, key), this.cache[type][key], !options.clean) + // Doing this first so it can set new appropriately. + if (!options.nodiff) { + this.addToDiff("add", diffPath(type, key), this.cache[type][key], options) } + return key } removeData(type, key, options) { if (!options) { - options = { diff: true, clean: false } + options = {} } console.debug("Backend.removeData", type, key) if (!this.cache[type][key]) { throw new Error("Expected " + key + " to exist") } - if (options.diff) { - this.addToDiff("remove", diffPath(type, key), null, !options.clean) + if (!options.nodiff) { + this.addToDiff("remove", diffPath(type, key), null, options) } delete this.cache[type][key] } @@ -218,6 +247,8 @@ export class FloorplanBackend { if (!this.cache.points[a] || !this.cache.points[b]) { throw new Error("Pointmap must reference existing points") } + + // NOTE: For now, a and b are numbers. May not always be the case return this.addData("pointmaps", { type: type, a: a, @@ -232,7 +263,7 @@ export class FloorplanBackend { reqId(type, id) { let obj = this.byId(type, id) if (!obj) { - throw new Error(id + " for " + type + "doesn't exist") + throw new Error(id + " for " + type + " doesn't exist") } return obj } @@ -244,29 +275,156 @@ export class FloorplanBackend { return this.cache[type][id] } + dirty() { + let a = [] + for (let i = 0; i <= this.diff; ++i) { + for (let diff in this.diffs[i].diff) { + if (this.diffs[i].diff[diff].dirty) { + a.push(this.diffs[i].diff[diff]) + } + } + } + console.debug("Backend.dirty", a) + return a + } + + cb(name, arg) { + if (this.callbacks[name]) { + this.callbacks[name](arg) + } + } + // Push updates to the server - //push() {} + push() { + // Need a method of making sure we're only sending these once... + let dirty = this.dirty() + let patch = [] + + for (let i in dirty) { + let op + if (dirty[i].op != "add") { + op = dirty[i].op + } else { + if (dirty[i].new) { + op = "new" + } else { + op = "replace" + } + } + patch.push( { op: op, path: dirty[i].path, value: dirty[i].value }) + + let ref = parsePath(dirty[i].path) + if (ref.type === "pointmaps") { + dirty[i].value.a = Number(dirty[i].value.a) + dirty[i].value.b = Number(dirty[i].value.b) + } + } + + console.debug("Backend.push (patch)", patch) + + let backend = this + api.fetch("PATCH", this.endpoint, patch) + .then(function(data) { + updateIds(backend, data) + for (let i in dirty) { + delete dirty[i].dirty + delete dirty[i].new + } + backend.cb("push") + }) + } /* * Pull updates from the server. * (Set AddData diff option to false, and call newDiff() * once at the end.) */ - //pull() {} + pull() { + let backend = this + api.fetch("GET", this.endpoint) + .then(function(data) { + let diff = gendiff("", backend.cache, data) + console.log("Backend.Pull (diff)", diff) + backend.newDiff() + let options = { clean: true } + for (let i in diff) { + let ref = parsePath(diff[i].path) + if (diff[i].op === "remove") { + backend.removeData(ref.type, ref.id, options) + } else { + backend.addData(ref.type, diff[i].value, ref.id, options) + } + } + backend.newDiff() + backend.cb("pull") + }) + } +} + +function gendiff(path, a, b) { + let diffs = [] + + for (let ak in a) { + let p = path + "/" + ak + if (!b[ak]) { + diffs.push({ op: "remove", path: p }) + } else if (typeof a === "object") { + diffs = diffs.concat(gendiff(p, a[ak], b[ak])) + } else if (a[ak] != b[ak]) { + diffs.push({ op: "replace", path: p, value: b[ak] }) + } + } + for (let bk in b) { + if (!a[bk]) { + diffs.push({ op: "add", path: path + "/" + bk, value: b[bk] }) + } + } + + return diffs +} + +function updateIds(backend, newdata) { + for (let type in newdata) { + for (let id in newdata[type]) { + let x = newdata[type][id] + if (x.old_id) { + console.debug("Backend.updateIds", `ID ${x.old_id} > ${id}`) + if (backend.cache[type][id]) { + throw new Error("ERROR: Pull id conflict") + } + backend.cache[type][id] = backend.cache[type][x.old_id] + // Both old and new exist at the moment, hense; + backend.cb("updateId", { type: type, old: x.old_id, new: id }) + delete backend.cache[type][x.old_id] + } + } + } } function diffPath(type, id) { - return type + "/" + id + return "/" + type + "/" + id +} + +export function parsePath(path) { + let a = path.split("/") + if (a.length != 3) { + throw new Error("Invalid path") + } + return newRef(a[1], a[2]) +} + +export function newRef(type, id) { + return { type: type, id: id } } -function uniqueKey(obj, prefix) { +function uniqueKey(obj) { let key do { - key = (prefix ? prefix : "") + Math.random().toString().split(".").join("") + key = Number(Math.random().toString().split(".").join("")) } while (obj[key]) // Wonder if there's an atomic way of testing whether a key is undefined and doing this? // Doesn't matter much for my purposes probably. - obj[key] = true + obj[key] = null return key } diff --git a/files/floorplans/floorplan/editor.js b/files/floorplans/floorplan/editor.js @@ -1,5 +1,5 @@ import { default as SVG } from "/lib/github.com/svgdotjs/svg.js/svg.js" -import { FloorplanBackend as Backend } from "./backend.js" +import * as backend from "./backend.js" SVG.extend(SVG.Element, { select: function() { @@ -41,17 +41,31 @@ SVG.extend(SVG.Circle, { }) export class FloorplanEditor { - constructor(svg) { + constructor(svg, floorplan, options) { + if (!options) { + options = {} + } + this.draw = svg this.mode this.modes = {} this.mode_states = {} - this.backend = new Backend() + + if (!options.backend) { + options.backend = {} + } + if (!options.backend.callbacks) { + options.backend.callbacks = {} + } + + let editor = this + options.backend.callbacks.updateId = function(ids) { editor.updateId(ids) } + this.backend = new backend.FloorplanBackend(floorplan, options.backend) this.updated = null // last time updated from backend - let floorplan = this.draw.group().attr({ id: "floorplan" }) - floorplan.group().attr({ id: "walls" }) // lines - floorplan.group().attr({ id: "points" }) // circles + let data = this.draw.group().attr({ id: "floorplan" }) + data.group().attr({ id: "walls" }) // lines + data.group().attr({ id: "points" }) // circles } addMode(name, mode) { @@ -178,11 +192,11 @@ export class FloorplanEditor { } updateDisplay() { - let diffs = this.backend.updatesSince(this.updated + 1) + let diffs = this.backend.updatesSince(this.updated ? this.updated + 1 : null) if (diffs.length === 0) { return } - this.updated = diffs[0].time + this.updated = diffs.at(-1).time this.applyDiff(diffs) } @@ -241,7 +255,7 @@ export class FloorplanEditor { wall.plot(a.x, a.y, b.x, b.y) } else { wall = editor.draw.findExactlyOne("#walls") - .line(a.x, a.y, b.x, b.y).stroke("black") + .line(a.x, a.y, b.x, b.y).stroke("black").attr({ id: name }) } } }, @@ -260,21 +274,27 @@ export class FloorplanEditor { throw new Error("Unexpected patch operation") } - let path = diff.path.split("/") - if (path.length != 2) { - throw new Error("Expected only two path elements") - } - let type = path[0] - let id = path[1] + let ref = backend.parsePath(diff.path) let op = reverse ? reverseOps[diff.op] : diff.op - if (!ops[op][type]) { + if (!ops[op][ref.type]) { throw new Error("Unhandled patch") } - ops[op][type](type + "_" + id, diff.value) + ops[op][ref.type](refId(ref), diff.value) + } + + updateId(ids) { + let e = this.findRef(backend.newRef(ids.type, ids.old)) + e.attr({ id: refId(backend.newRef(ids.type, ids.new)) }) + console.log("Editor.updateId", `${ids.old} -> ${ids.new}`) + } + + findRef(ref) { + return this.draw.findExactlyOne(byId(refId(ref))) } } + function remove_mode_handlers(target, mode_handlers) { for (let event in mode_handlers) { for (let handler in mode_handlers[event]) { @@ -296,3 +316,7 @@ function add_mode_handlers(target, mode_handlers) { function byId(id) { return "#" + id } + +function refId(ref) { + return ref.type + "_" + ref.id +} diff --git a/files/floorplans/floorplan/main.css b/files/floorplans/floorplan/main.css @@ -20,6 +20,11 @@ header > .toolbar { margin: .25em; } +header > .toolbar > li { + float: left; + padding-right: 1em; +} + svg { flex: 1; border: ridge thin; @@ -50,3 +55,16 @@ svg { .mode_selector.selected { border-color: blue; } + +aside.message { + position: absolute; + box-sizing: border-box; + bottom: 0; + left: 0; + width: 100vw; + background-color: rgba(0, 0, 0, 0.6); + color: white; + border: thin solid darkgrey; + border-radius: 0; + padding: .2em; +} diff --git a/files/floorplans/floorplan/main.js b/files/floorplans/floorplan/main.js @@ -1,8 +1,11 @@ import { default as SVG } from "/lib/github.com/svgdotjs/svg.js/svg.js" import "/lib/github.com/svgdotjs/svg.panzoom.js/svg.panzoom.js" import * as ui from "/lib/ui.js" +import * as etc from "/lib/etc.js" import { FloorplanEditor as Editor } from "./editor.js" +const messageTimeout = 4000 + const buttons = { left: 0, middle: 1, @@ -10,11 +13,15 @@ const buttons = { } function init() { + etc.authorize() + let floorplan = (new URLSearchParams(new URL(document.URL).search)).get("name") if (!floorplan) { document.location.href = "/floorplans" } - document.querySelector("h1").textContent = floorplan + let h1 = document.querySelector("h1") + h1.textContent = floorplan + let suffix = h1.appendChild(document.createTextNode("")) let draw = SVG() .addTo("#floorplan_container") @@ -26,7 +33,33 @@ function init() { zoomFactor: .5 }) - let editor = new Editor(draw) + let editor = new Editor(draw, + { user: localStorage.getItem("username"), name: floorplan }, + { backend: { + callbacks: { + pull: function() { + editor.updateDisplay() + suffix.data = "" + }, + push: function() { + suffix.data = "" + }, + newdiff: function() { + suffix.data = "*" + editor.updateDisplay() + } + } + } + }) + + let push = ui.button("Push", "Push updates", "arrow-up", + { handlers: { click: function() { editor.backend.push(); notify("Pushed floorplan", "pushpull") } } }) + let pull = ui.button("Pull", "Pull updates", "arrow-down", + { handlers: { click: function() { editor.backend.pull(); notify("Pulled floorplan", "pushpull") } } }) + let pushpull = document.createElement("li") + pushpull.appendChild(pull) + pushpull.appendChild(push) + for (let mode in modes) { editor.addMode(mode, modes[mode]) } @@ -35,7 +68,14 @@ function init() { let toolbar = document.querySelector("header") .appendChild(document.createElement("ul")) toolbar.classList.add("toolbar") - toolbar.appendChild(modesSelector(editor, "Modes:")) + + toolbar.append(pushpull) + + let li = document.createElement("li") + li.append(modesSelector(editor, "Modes:")) + toolbar.append(li) + + editor.backend.pull() } function modesSelector(editor, text) { @@ -99,6 +139,7 @@ function addWallHandler(click, editor) { } editor.addPoint(editor.draw.point(click.clientX, click.clientY)) + editor.updateDisplay() if (editor.draw.findOne("#points").children().length >= 2) { editor.mapPoints("wall") } @@ -110,4 +151,25 @@ function preventDefaultHandler(event) { event.preventDefault() } +function notify(message, id) { + console.log("Notify", message) + + let e = document.createElement("aside") + e.id = id + e.classList.add("message") + e.textContent = message + + let old + if (id) { + old = document.getElementById(id) + } + + if (old) { + old.replaceWith(e) + } else { + document.body.prepend(e) + } + setTimeout(function() { e.remove() }, messageTimeout) +} + window.onload = init diff --git a/files/icons/arrow-down-outline.svg b/files/icons/arrow-down-outline.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><polyline points="112 268 256 412 400 268" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:48px"/><line x1="256" y1="392" x2="256" y2="100" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:48px"/></svg> +\ No newline at end of file diff --git a/files/icons/arrow-up-outline.svg b/files/icons/arrow-up-outline.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><polyline points="112 244 256 100 400 244" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:48px"/><line x1="256" y1="120" x2="256" y2="412" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:48px"/></svg> +\ No newline at end of file