commit 0500aae9d3b531ab6b2cda729aed16e9360e2b95
parent 33be3922c48620822fd7b3fe66a8a7d65ff274cc
Author: Jacob R. Edwards <jacob@jacobedwards.org>
Date: Tue, 13 Aug 2024 12:51:35 -0700
Add backend layer to floorplan editor
This interface will manage communication with the server and try
and stay out of the way.
It keeps a set of diffs and will update the server as necessary.
The editor was reworked to simply push changes to the backend, then
pull them back again to make changes to the document, eliminating
any difference between data fetched from the server and data pushed
from the editor.
Currently it doesn't communicate with the server, but it's built
to have that functionality added in the future so it shouldn't be
too difficult.
Diffstat:
3 files changed, 407 insertions(+), 21 deletions(-)
diff --git a/files/floorplans/floorplan/backend.js b/files/floorplans/floorplan/backend.js
@@ -0,0 +1,272 @@
+export class FloorplanBackend {
+ constructor() {
+ // Cache for server (both from and to)
+ this.cache = {
+ // { pointId: { x: Number, y: Number } }
+ points: {},
+
+ /*
+ * { pointMapId: { type: mapType*, from: pointId, to: pointId } }
+ *
+ * [*] The only map types I think are needed are wall and door
+ * at the moment.
+ */
+ pointmaps: {}
+
+ // There will be here more later, such as furnature
+ }
+
+ /*
+ * Considered making a diff tree, decided against
+ * since I don't know how I would display it to the
+ * user usefully. Can still be done in the future.
+ *
+ * Array of diff sets (meant to be one user action).
+ * Oldest last, same with diffs: inside
+ * [
+ * // Array of diffs
+ * {
+ * // In a map for future metadata
+ * // Ordered array of differences, in JSON patch format
+ * diff: [
+ * { op: XXX, path: YYY, value: ZZZ, time: Date.now() }
+ * // e.g.:
+ * { op: "add", path: "points/399", value: { x: 302, y: 422 }: time: Date.now() }
+ * ]
+ * }
+ * ]
+ */
+
+ /*
+ * I considered making a diff tree, but decided against it
+ * because I won't know how much value there would be
+ * in it, especially considering the difficulty in providing
+ * users access to it.
+ * Nonetheless it can be added later if I like.
+ *
+ * [
+ * // Array of diffs, a set for each user action
+ * {
+ * [dirty: true]
+ * diff: <JSON Patch>
+ * }
+ * [...]
+ * }
+ */
+ this.diffs = []
+
+ // The cache's state in relation to the diffs
+ this.diff = null
+ }
+
+ // Start writing new differences to a new diff set
+ newDiff() {
+ if (this.diffs.length > 0 && this.diffs[this.diff].diff.length === 0) {
+ console.warn("Current diff empty, not creating new one")
+ return this.diff
+ }
+
+ for (let i = this.diffs.length - 1; i > this.diff; --i) {
+ delete this.diffs[i]
+ }
+ this.diff = this.diffs.push({
+ diff: []
+ }) - 1
+ console.debug("newDiff", this.diff)
+ return this.diff
+ }
+
+ // Add to current diff
+ addToDiff(op, path, value, dirty) {
+ if (!op || !path) {
+ throw new Error("Requires op and path")
+ }
+ if (op === "add") {
+ if (!value) {
+ throw new Error("Add requires a value")
+ }
+ } else if (op !== "remove") {
+ throw new Error("Only add and remove operations supported")
+ }
+
+ if (!this.diff) {
+ this.newDiff()
+ }
+ let diff = {
+ op: op,
+ path: path,
+ value: value,
+ time: Date.now()
+ }
+ if (dirty) {
+ diff.dirty = true
+ }
+ this.diffs[this.diff].diff.push(diff)
+ console.debug("Backend.addToDiff", diff)
+ }
+
+ // Apply's diffs in order to get to the state at the beginning of the given diff id
+ // reconstructTo(diff) {}
+
+ // Updates since the given time
+ updatesSince(time) {
+ return this.updatesBetween(time)
+ }
+
+ // Inclusive updates between time1 and time2, returned in the order required
+ // to get from time1 to time2
+ updatesBetween(time1, time2) {
+ let updates = []
+ let reverse = true
+
+ 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) {
+ reverse = !reverse
+ let t = time1
+ time1 = time2
+ time2 = t
+ }
+
+ 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) {
+ updates.push(diff)
+ } else if (diff.time > time2) {
+ return reverse ? updates.reverse() : updates
+ }
+ }
+ }
+
+ return reverse ? updates.reverse() : updates
+ }
+
+ /*
+ * Add some type of data within the cache.
+ * If key is not given, a random one will be generated.
+ * If clean is not given, it is marked dirty
+ * (thus data from the server, with a known key, can be marked clean)
+ */
+ addData(type, value, key, options) {
+ if (!options) {
+ options = { diff: true, clean: false }
+ }
+
+ if (!key) {
+ /*
+ * We'll have to generate a temporary id for it here
+ * since we can't wait for the server to respond with
+ * the ID it decides. It will need to be updated once
+ * we do get the server response.
+ */
+ key = uniqueKey(this.cache[type])
+ }
+ console.debug("Backend.addData", type, key, value)
+ 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)
+ }
+ return key
+ }
+
+ removeData(type, key, options) {
+ if (!options) {
+ options = { diff: true, clean: false }
+ }
+
+ 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)
+ }
+ delete this.cache[type][key]
+ }
+
+ addPoint(point, options) {
+ if (typeof point.x !== "number" || typeof point.y !== "number") {
+ console.error("Backend.addPoint", point)
+ throw new Error("Point must have x and y be numbers")
+ }
+ // I suppose point could have other keys, that's okay though
+ return this.addData("points", point, options)
+ }
+
+ removePoint(id, options) {
+ return this.removeData("points", id, options)
+ }
+
+ // Returns map id
+ mapPoints(type, a, b, options) {
+ if (type != "wall") {
+ throw new Error("Only walls allowed in pointmap so far")
+ }
+ if (!this.cache.points[a] || !this.cache.points[b]) {
+ throw new Error("Pointmap must reference existing points")
+ }
+ return this.addData("pointmaps", {
+ type: type,
+ a: a,
+ b: b
+ }, options)
+ }
+
+ unmapPoints(id, options) {
+ return removeData("pointmaps", id, options)
+ }
+
+ reqId(type, id) {
+ let obj = this.byId(type, id)
+ if (!obj) {
+ throw new Error(id + " for " + type + "doesn't exist")
+ }
+ return obj
+ }
+
+ byId(type, id) {
+ if (!this.cache[type]) {
+ throw new Error(type + ": Invalid type")
+ }
+ return this.cache[type][id]
+ }
+
+ // Push updates to the server
+ //push() {}
+
+ /*
+ * Pull updates from the server.
+ * (Set AddData diff option to false, and call newDiff()
+ * once at the end.)
+ */
+ //pull() {}
+}
+
+function diffPath(type, id) {
+ return type + "/" + id
+}
+
+function uniqueKey(obj, prefix) {
+ let key
+ do {
+ key = (prefix ? prefix : "") + 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
+ return key
+}
diff --git a/files/floorplans/floorplan/editor.js b/files/floorplans/floorplan/editor.js
@@ -1,4 +1,5 @@
import { default as SVG } from "/lib/github.com/svgdotjs/svg.js/svg.js"
+import { FloorplanBackend as Backend } from "./backend.js"
SVG.extend(SVG.Element, {
select: function() {
@@ -19,9 +20,18 @@ SVG.extend(SVG.Element, {
if (results.length == 1)
return results[0]
return undefined
+ },
+
+ findExactlyOne: function(selector) {
+ let r = this.findOneMax(selector)
+ if (!r) {
+ throw new Error("Didn't find " + selector)
+ }
+ return r
}
})
+// May not be needed anymore
SVG.extend(SVG.Circle, {
// Maybe this already exists?
pos: function() {
@@ -36,6 +46,8 @@ export class FloorplanEditor {
this.mode
this.modes = {}
this.mode_states = {}
+ this.backend = new Backend()
+ this.updated = null // last time updated from backend
let floorplan = this.draw.group().attr({ id: "floorplan" })
floorplan.group().attr({ id: "walls" }) // lines
@@ -114,16 +126,18 @@ export class FloorplanEditor {
return this
}
+ // Should be called after each user "action"
+ finishAction() {
+ this.backend.newDiff()
+ }
+
addPoint(point) {
let already = this.pointAt(point)
if (already) {
return already.select()
}
- return this.draw.findOne("#points")
- .circle(4)
- .addClass("point")
- .move(point.x, point.y)
- .select()
+ this.backend.addPoint(point)
+ this.updateDisplay()
}
pointAt(point) {
@@ -137,11 +151,15 @@ export class FloorplanEditor {
return pointInside
}
- addWall() {
+ mapPoints(type) {
+ let pointId = function(id) { return id.split("_")[1] }
let points = this.selectedPoints()
- return this.draw.find("#walls")
- .line(points.b.x, points.b.y, points.a.x, points.a.y)
- .stroke("black")
+
+ this.backend.mapPoints(type,
+ pointId(points.a.attr("id")),
+ pointId(points.b.attr("id"))
+ )
+ this.updateDisplay()
}
selectedPoints() {
@@ -152,11 +170,108 @@ export class FloorplanEditor {
}
selectedPoint() {
- return this.draw.findOneMax("#points > .selected").pos()
+ return this.draw.findOneMax("#points > .selected")
}
lastSelectedPoint() {
- return this.draw.findOneMax("#points > .last_selected").pos()
+ return this.draw.findOneMax("#points > .last_selected")
+ }
+
+ updateDisplay() {
+ let diffs = this.backend.updatesSince(this.updated + 1)
+ if (diffs.length === 0) {
+ return
+ }
+ this.updated = diffs[0].time
+ this.applyDiff(diffs)
+ }
+
+ applyDiff(diff, reverse) {
+ if (!reverse) {
+ for (let op in diff) {
+ this.applyOp(diff[op], reverse)
+ }
+ } else {
+ for (let op = diff.length - 1; i >= 0; --i) {
+ this.applyOp(diff[op], reverse)
+ }
+ }
+ }
+
+ applyOp(diff, reverse) {
+ console.debug("Editor.applyOp", diff)
+ let editor = this
+
+ const reverseOps = {
+ add: "remove",
+ remove: "add"
+ }
+ const ops = {
+ add: {
+ points: function(name, value) {
+ let cur = editor.draw.findOneMax(byId(name))
+ // Update pointmaps
+ if (cur) {
+ cur.cx(value.x).cy(value.y)
+ .select()
+ } else {
+ editor.draw.findOne("#points")
+ .circle(4)
+ .cx(value.x).cy(value.y)
+ .attr({ id: name })
+ .addClass("point")
+ .select()
+ .on("click", function(event) {
+ if (event.shiftKey) {
+ this.select()
+ event.preventDefault()
+ }
+ })
+
+ }
+ },
+ pointmaps: function(name, value) {
+ if (value.type !== "wall") {
+ throw new Error("Only walls currently supported")
+ }
+ let a = editor.backend.reqId("points", value.a)
+ let b = editor.backend.reqId("points", value.b)
+ let wall = editor.draw.findOneMax(name)
+ if (wall) {
+ 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")
+ }
+ }
+ },
+ remove: {
+ points: function(name) {
+ // Remove pointmaps
+ editor.draw.findExactlyOne(byId(name)).remove()
+ },
+ pointmaps: function(name) {
+ editor.draw.findExactlyOne(byId(name)).remove()
+ }
+ }
+ }
+
+ if (!ops[diff.op]) {
+ 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 op = reverse ? reverseOps[diff.op] : diff.op
+
+ if (!ops[op][type]) {
+ throw new Error("Unhandled patch")
+ }
+ ops[op][type](type + "_" + id, diff.value)
}
}
@@ -177,3 +292,7 @@ function add_mode_handlers(target, mode_handlers) {
}
}
}
+
+function byId(id) {
+ return "#" + id
+}
diff --git a/files/floorplans/floorplan/main.js b/files/floorplans/floorplan/main.js
@@ -90,7 +90,7 @@ let modes = {
}
// click
-function addWallHandler(click, state) {
+function addWallHandler(click, editor) {
if (click.type !== "click") {
throw new Error("Expected click event")
}
@@ -98,16 +98,11 @@ function addWallHandler(click, state) {
return
}
- state.addPoint(state.draw.point(click.clientX, click.clientY))
- .on("click", function(event) {
- if (event.shiftKey) {
- this.select()
- event.preventDefault()
- }
- })
- if (state.draw.findOne("#points").children().length >= 2) {
- state.addWall()
+ editor.addPoint(editor.draw.point(click.clientX, click.clientY))
+ if (editor.draw.findOne("#points").children().length >= 2) {
+ editor.mapPoints("wall")
}
+ editor.finishAction()
click.preventDefault()
}