diff --git a/frontend/src/uxbox/main/data/workspace.cljs b/frontend/src/uxbox/main/data/workspace.cljs index fa4b87005..80dab0176 100644 --- a/frontend/src/uxbox/main/data/workspace.cljs +++ b/frontend/src/uxbox/main/data/workspace.cljs @@ -494,9 +494,10 @@ name (generate-unique-name names (:name obj)) renamed-obj (assoc obj :id id :name name) moved-obj (geom/move renamed-obj delta) + frames (cp/select-frames objects) frame-id (if frame-id frame-id - (dwc/calculate-frame-overlap objects moved-obj)) + (dwc/calculate-frame-overlap frames moved-obj)) parent-id (or parent-id frame-id) diff --git a/frontend/src/uxbox/main/ui/workspace/drawarea.cljs b/frontend/src/uxbox/main/ui/workspace/drawarea.cljs index ab6402424..d5d088019 100644 --- a/frontend/src/uxbox/main/ui/workspace/drawarea.cljs +++ b/frontend/src/uxbox/main/ui/workspace/drawarea.cljs @@ -167,7 +167,7 @@ (rx/concat (->> mouse (rx/take 1) - (rx/map (fn [pt] #(initialize-drawing % pt frame-id)))) + (rx/map (fn [pt] #(initialize-drawing % pt (or frame-id uuid/zero))))) (->> mouse (rx/with-latest vector ms/mouse-position-ctrl) (rx/map (fn [[pt ctrl?]] #(update-drawing % initial snap-data pt ctrl?))) diff --git a/frontend/src/uxbox/util/geom/snap.cljs b/frontend/src/uxbox/util/geom/snap.cljs index 2a2a0152c..90ce9afcb 100644 --- a/frontend/src/uxbox/util/geom/snap.cljs +++ b/frontend/src/uxbox/util/geom/snap.cljs @@ -14,9 +14,10 @@ [uxbox.util.math :as mth] [uxbox.common.uuid :refer [zero]] [uxbox.util.geom.shapes :as gsh] - [uxbox.util.geom.point :as gpt])) + [uxbox.util.geom.point :as gpt] + [uxbox.util.range-tree :as rt])) -(def ^:private snap-accuracy 20) +(def ^:private snap-accuracy 10) (defn mapm "Map over the values of a map" @@ -68,19 +69,20 @@ (let [modified-path (gsh/transform-apply-modifiers shape) shape-center (gsh/center modified-path)] (case (:type shape) - :frame (frame-snap-points shape) + :frame (-> modified-path gsh/shape->rect-shape frame-snap-points) (:path :curve) (into #{shape-center} (-> modified-path gsh/shape->rect-shape :segments)) (into #{shape-center} (-> modified-path :segments))))) (defn create-coord-data [shapes coord] - (let [process-shape - (fn [coord] - (fn [shape] - (let [points (shape-snap-points shape)] - (map #(vector % (:id shape)) points))))] + (let [process-shape (fn [coord] + (fn [shape] + (let [points (shape-snap-points shape)] + (map #(vector % (:id shape)) points)))) + into-tree (fn [tree [point _ :as data]] + (rt/insert tree (coord point) data))] (->> shapes (mapcat (process-shape coord)) - (group-by (comp coord first))))) + (reduce into-tree (rt/make-tree))))) (defn initialize-snap-data "Initialize the snap information with the current workspace information" @@ -98,20 +100,13 @@ :y (create-coord-data shapes :y)}) frame-shapes))) -(defn range-query - "Queries the snap-data within a range of values" - [snap-data from-value to-value] - (filter (fn [[value _]] (and (>= value from-value) - (<= value to-value))) - snap-data)) - (defn remove-from-snap-points [snap-points ids-to-remove] (->> snap-points (map (fn [[value data]] [value (remove (comp ids-to-remove second) data)])) (filter (fn [[_ data]] (not (empty? data)))))) (defn search-snap-point - "Search snap for a single point" + "Search snap for a single point in the `coord` given" [point coord snap-data filter-shapes] (let [coord-value (get point coord) @@ -119,7 +114,7 @@ ;; This gives a list of [value [[point1 uuid1] [point2 uuid2] ...] we need to remove ;; the shapes in filter shapes candidates (-> snap-data - (range-query (- coord-value snap-accuracy) (+ coord-value snap-accuracy)) + (rt/range-query (- coord-value snap-accuracy) (+ coord-value snap-accuracy)) (remove-from-snap-points filter-shapes)) ;; Now return with the distance and the from-to pair that we'll return if this is the chosen @@ -134,7 +129,7 @@ (let [snap-points (mapcat #(search-snap-point % coord snap-data filter-shapes) points) result (->> snap-points (apply min-key first) second)] - (or result [0 0]))) + result)) (defn snap-frame-id [shapes] (let [frames (into #{} (map :frame-id shapes))] @@ -162,8 +157,8 @@ [snap-from-x snap-to-x] (search-snap shapes-points :x (get-in snap-data [frame-id :x]) remove-shapes) [snap-from-y snap-to-y] (search-snap shapes-points :y (get-in snap-data [frame-id :y]) remove-shapes) - snapv (gpt/to-vec (gpt/point snap-from-x snap-from-y) - (gpt/point snap-to-x snap-to-y))] + snapv (gpt/to-vec (gpt/point (or snap-from-x 0) (or snap-from-y 0)) + (gpt/point (or snap-to-x 0) (or snap-to-y 0)))] (gpt/add trans-vec snapv))) @@ -187,8 +182,8 @@ ;; Search for values within 1 pixel snap-matches (-> (get-in snap-data [frame-id coord]) - (range-query (- value 1) (+ value 1)) - (remove-from-snap-points filter-shapes)) + (rt/range-query (- value 1) (+ value 1)) + (remove-from-snap-points filter-shapes)) snap-points (mapcat (fn [[v data]] (map (fn [[point _]] point) data)) snap-matches)] snap-points)) @@ -196,5 +191,5 @@ (defn is-snapping? [snap-data frame-id shape-id point coord] (let [value (coord point) ;; Search for values within 1 pixel - snap-points (range-query (get-in snap-data [frame-id coord]) (- value 1.0) (+ value 1.0))] + snap-points (rt/range-query (get-in snap-data [frame-id coord]) (- value 1.0) (+ value 1.0))] (some (fn [[point other-shape-id]] (not (= shape-id other-shape-id))) snap-points))) diff --git a/frontend/src/uxbox/util/range_tree.js b/frontend/src/uxbox/util/range_tree.js new file mode 100644 index 000000000..bc16a6a8f --- /dev/null +++ b/frontend/src/uxbox/util/range_tree.js @@ -0,0 +1,371 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * This Source Code Form is "Incompatible With Secondary Licenses", as + * defined by the Mozilla Public License, v. 2.0. + * + * Copyright (c) 2020 UXBOX Labs SL + */ + +/* + * Balanced Binary Search Tree based on the red-black BST + * described at "Algorithms" by Robert Sedwick & Kevin Wayne + */ +"use strict"; + +goog.provide("uxbox.util.range_tree"); +goog.require("cljs.core") + +goog.scope(function() { + const eq = cljs.core._EQ_; + const vec = cljs.core.vec; + const nil = cljs.core.nil; + + const Color = { + RED: 1, + BLACK: 2 + } + + class Node { + constructor(value, data) { + this.value = value; + this.data = [ data ]; + this.left = null; + this.right = null; + this.color = Color.BLACK; + } + } + + // Will store a map from key to list of data + // value => [ data ] + // The values can be queried in range and the data stored will be retrived whole + // but can be removed/updated individually using clojurescript equality + class RangeTree { + constructor() { + this.root = null; + } + + insert(value, data) { + this.root = recInsert(this.root, value, data); + this.root.color = Color.BLACK; + return this; + } + + remove(value, data) { + if (!this.root) { + return this; + } + + this.root = recRemoveData(this.root, value, data); + + const newData = recGet(this.root, value); + + if (newData && newData.length === 0) { + if (!isRed(this.root.left) && !isRed(this.root.right)) { + this.root.color = Color.RED; + } + + this.root = recRemoveNode(this.root, value); + + if (this.root) { + this.root.color = Color.BLACK; + } + } + + return this; + } + + update (value, oldData, newData) { + this.root = recUpdate(this.root, value, oldData, newData); + return this; + } + + get(value) { + return recGet(this.root, value); + } + + rangeQuery (fromValue, toValue) { + return recRangeQuery(this.root, fromValue, toValue, []); + } + + height() { + return recHeight(this.root); + } + + isEmpty() { + return this.root === null; + } + + toString() { + const result = []; + recToString(this.root, result); + return result.join(", "); + } + } + + // Tree implementation functions + + function isRed(branch) { + return branch !== null && branch.color === Color.RED; + } + + // Insert recursively in the tree + function recInsert (branch, value, data) { + if (branch === null) { + const ret = new Node(value, data); + ret.color = Color.RED; + return ret; + } else if (branch.value === value) { + // Find node we'll add to the end of the list + branch.data.push(data); + } else if (branch.value > value) { + // Target value is less than the current value we go left + branch.left = recInsert(branch.left, value, data); + } else if (branch.value < value) { + branch.right = recInsert(branch.right, value, data); + } + + if (isRed(branch.right) && !isRed(branch.left)) { + branch = rotateLeft(branch); + } + if (isRed(branch.left) && isRed(branch.left.left)) { + branch = rotateRight(branch); + } + if (isRed(branch.left) && isRed(branch.right)) { + flipColors(branch); + } + return branch; + } + + // Search for the min node + function searchMin(branch) { + if (branch.left === null) { + return branch; + } else { + return searchMin(branch.left); + } + } + + // Remove the lefmost node of the current branch + function recRemoveMin(branch) { + if (branch.left === null) { + return null; + } + + if (!isRed(branch.left) && !isRed(branch.left.left)) { + branch = moveRedLeft(branch); + } + branch.left = recRemoveMin(branch.left); + return balance(branch); + } + + // Remove the data element for the value given + // this will not remove the node, we have to remove the empty node afterwards + function recRemoveData(branch, value, data) { + if (branch === null) { + // Not found + return branch; + } else if (branch.value === value) { + // Node found, we remove the data + branch.data = branch.data.filter ((it) => !eq(it, data)); + return branch; + } else if (branch.value > value) { + branch.left = recRemoveData (branch.left, value, data); + return branch; + } else if (branch.value < value) { + branch.right = recRemoveData(branch.right, value, data); + return branch; + } + } + + function recRemoveNode(branch, value) { + if (value < branch.value) { + if (!isRed(branch.left) && !isRed(branch.left.left)) { + branch = moveRedLeft(branch); + } + branch.left = recRemoveNode(branch.left, value); + } else { + if (isRed(branch.left)) { + branch = rotateRight(branch); + } + if (value === branch.value && branch.right === null) { + return null; + } + if (!isRed(branch.right) && !isRed(branch.right.left)) { + branch = moveRedRight(branch); + } + + if (value === branch.value) { + const x = searchMin(branch.right); + branch.value = x.value; + branch.data = x.data; + branch.right = recRemoveMin(branch.right); + } else { + branch.right = recRemoveNode(branch.right, value); + } + } + return balance(branch); + } + + // Retrieve all the data related to value + function recGet(branch, value) { + if (branch === null) { + return null; + } else if (branch.value === value) { + return branch.data; + } else if (branch.value > value) { + return recGet(branch.left, value); + } else if (branch.value < value) { + return recGet(branch.right, value); + } + } + + function recUpdate(branch, value, oldData, newData) { + if (branch === null) { + return branch; + } else if (branch.value === value) { + branch.data = branch.data.map((it) => (eq(it, oldData)) ? newData : it); + return branch; + } else if (branch.value > value) { + return recUpdate(branch.left, value, oldData, newData); + } else if (branch.value < value) { + return recUpdate(branch.right, value, oldData, newData); + } + } + + function recRangeQuery(branch, fromValue, toValue, result) { + if (branch === null) { + return result; + } + if (fromValue < branch.value) { + recRangeQuery(branch.left, fromValue, toValue, result); + } + if (fromValue <= branch.value && toValue >= branch.value) { + // Array.prototype.push.apply(result, branch.data); + result.push(vec([branch.value, vec(branch.data)])) + } + if (toValue > branch.value) { + recRangeQuery(branch.right, fromValue, toValue, result); + } + return result; + } + + function rotateLeft(branch) { + const x = branch.right; + branch.right = x.left; + x.left = branch; + x.color = x.left.color; + x.left.color = Color.RED; + return x; + } + + function rotateRight(branch) { + const x = branch.left; + branch.left = x.right; + x.right = branch; + x.color = x.right.color; + x.right.color = Color.RED; + return x; + } + + function balance(branch) { + if (isRed(branch.right)) { + branch = rotateLeft(branch); + } + if (isRed(branch.left) && isRed(branch.left.left)) { + branch = rotateRight(branch); + } + if (isRed(branch.left) && isRed(branch.right)) { + flipColors(branch); + } + return branch; + } + + function moveRedLeft(branch) { + flipColors(branch); + if (isRed(branch.right.left)) { + branch.right = rotateRight(branch.right); + branch = rotateLeft(branch); + flipColors(branch); + } + return branch; + } + + function moveRedRight(branch) { + flipColors(branch); + if (branch.left && isRed(branch.left.left)) { + branch = rotateRight(branch); + flipColors(branch); + } + return branch; + } + + function flip(color) { + return color === Color.RED ? Color.BLACK : Color.RED; + } + + function flipColors(branch) { + branch.color = flip(branch.color); + if (branch.left) { + branch.left.color = flip(branch.left.color); + } + if (branch.right) { + branch.right.color = flip(branch.right.color); + } + } + + function recHeight(branch) { + let curHeight = 0; + if (branch !== null) { + curHeight = Math.max(recHeight(branch.left), recHeight(branch.right)) + } + return 1 + curHeight; + } + + // This will return the string representation. We don't care about internal structure + // only the data + function recToString(branch, result) { + if (branch === null) { + return; + } + + recToString(branch.left, result); + result.push(`${branch.value}: [${branch.data.join(", ")}]`) + recToString(branch.right, result); + } + + // This function prints the tree structure, not the data + function printTree(tree) { + if (!tree) { + return ""; + } + const val = tree.color[0] + "(" + tree.value + ")"; + return "[" + printTree(tree.left) + " " + val + " " + printTree(tree.right) + "]"; + } + + // External API to CLJS + const self = uxbox.util.range_tree; + self.make_tree = () => new RangeTree(); + self.insert = (tree, value, data) => tree.insert(value, data); + self.remove = (tree, value, data) => tree.remove(value, data); + self.update = (tree, value, oldData, newData) => tree.update(value, oldData, newData); + self.get = (tree, value) => { + const result = tree.get(value); + if (!result) { + return nil; + } + return vec(result); + }; + self.range_query = (tree, from_value, to_value) => { + if (!tree) { + return vec(); + } + return vec(tree.rangeQuery(from_value, to_value)) + }; + self.empty_QMARK_ = (tree) => tree.isEmpty(); + self.height = (tree) => tree.height(); + self.print = (tree) => printTree(tree.root); +}); + diff --git a/frontend/tests/uxbox/test_util_range_tree.cljs b/frontend/tests/uxbox/test_util_range_tree.cljs new file mode 100644 index 000000000..ba0c82ac4 --- /dev/null +++ b/frontend/tests/uxbox/test_util_range_tree.cljs @@ -0,0 +1,197 @@ +(ns uxbox.test-util-range-tree + (:require [cljs.test :as t :include-macros true] + [cljs.pprint :refer [pprint]] + [uxbox.util.geom.point :as gpt] + [uxbox.util.range-tree :as rt])) + +(defn check-max-height [tree num-nodes]) +(defn check-sorted [tree]) + +(defn create-random-tree [num-nodes]) + +(t/deftest test-insert-and-retrive-data + (t/testing "Retrieve on empty tree" + (let [tree (rt/make-tree)] + (t/is (= (rt/get tree 100) nil)))) + + (t/testing "First insert/retrieval" + (let [tree (-> (rt/make-tree) + (rt/insert 100 :a))] + (t/is (= (rt/get tree 100) [:a])) + (t/is (= (rt/get tree 200) nil)))) + + (t/testing "Insert best case scenario" + (let [tree (-> (rt/make-tree) + (rt/insert 100 :a) + (rt/insert 50 :b) + (rt/insert 200 :c))] + (t/is (= (rt/get tree 100) [:a])) + (t/is (= (rt/get tree 50) [:b])) + (t/is (= (rt/get tree 200) [:c])))) + + (t/testing "Insert duplicate entry" + (let [tree (-> (rt/make-tree) + (rt/insert 100 :a) + (rt/insert 50 :b) + (rt/insert 200 :c) + (rt/insert 50 :d) + (rt/insert 200 :e))] + (t/is (= (rt/get tree 100) [:a])) + (t/is (= (rt/get tree 50) [:b :d])) + (t/is (= (rt/get tree 200) [:c :e]))))) + +(t/deftest test-remove-elements + (t/testing "Insert and delete data but not the node" + (let [tree (-> (rt/make-tree) + (rt/insert 100 :a) + (rt/insert 100 :b) + (rt/insert 100 :c) + (rt/remove 100 :b))] + (t/is (= (rt/get tree 100) [:a :c])))) + + (t/testing "Try to delete data not in the node is noop" + (let [tree (-> (rt/make-tree) + (rt/insert 100 :a) + (rt/insert 100 :b) + (rt/insert 100 :c) + (rt/remove 100 :xx))] + (t/is (= (rt/get tree 100) [:a :b :c])))) + + (t/testing "Delete data and node" + (let [tree (-> (rt/make-tree) + (rt/insert 100 :a) + (rt/insert 200 :b) + (rt/insert 300 :c) + (rt/remove 200 :b))] + (t/is (= (rt/get tree 200) nil)))) + + (t/testing "Delete root node the new tree should be correct" + (let [tree (-> (rt/make-tree) + (rt/insert 100 :a) + (rt/insert 50 :b) + (rt/insert 150 :c) + (rt/insert 25 :d) + (rt/insert 75 :e) + (rt/insert 125 :f) + (rt/insert 175 :g) + (rt/remove 100 :a))] + + (t/is (= (rt/get tree 100) nil)) + (t/is (= (rt/get tree 50) [:b])) + (t/is (= (rt/get tree 150) [:c])) + (t/is (= (rt/get tree 25) [:d])) + (t/is (= (rt/get tree 75) [:e])) + (t/is (= (rt/get tree 125) [:f])) + (t/is (= (rt/get tree 175) [:g])))) + + (t/testing "Adds a bunch of nodes and then delete. The tree should be empty" + ;; Try an increase range + (let [size 10000 + tree (rt/make-tree) + tree (reduce #(rt/insert %1 %2 :x) tree (range 0 (dec size))) + tree (reduce #(rt/remove %1 %2 :x) tree (range 0 (dec size)))] + (t/is (rt/empty? tree))) + + ;; Try a decreasing range + (let [size 10000 + tree (rt/make-tree) + tree (reduce #(rt/insert %1 %2 :x) tree (range (dec size) -1 -1)) + tree (reduce #(rt/remove %1 %2 :x) tree (range (dec size) -1 -1))] + (t/is (rt/empty? tree))))) + +(t/deftest test-update-elements + (t/testing "Updates an element" + (let [tree (-> (rt/make-tree) + (rt/insert 100 :a) + (rt/insert 50 :b) + (rt/insert 150 :c) + (rt/insert 50 :d) + (rt/insert 50 :e) + (rt/update 50 :d :xx))] + (t/is (= (rt/get tree 50) [:b :xx :e])))) + + (t/testing "Try to update non-existing element" + (let [tree (-> (rt/make-tree) + (rt/insert 100 :a) + (rt/insert 50 :b) + (rt/insert 150 :c) + (rt/insert 50 :d) + (rt/insert 50 :e) + (rt/update 50 :zz :xx))] + (t/is (= (rt/get tree 50) [:b :d :e]))))) + +(t/deftest test-range-query + (t/testing "Creates a tree and test different range queries" + (let [tree (-> (rt/make-tree) + (rt/insert 0 :a) + (rt/insert 25 :b) + (rt/insert 50 :c) + (rt/insert 75 :d) + (rt/insert 100 :e) + (rt/insert 100 :f) + (rt/insert 125 :g) + (rt/insert 150 :h) + (rt/insert 175 :i) + (rt/insert 200 :j) + (rt/insert 200 :k))] + (t/is (= (rt/range-query tree 0 200) + [[0 [:a]] + [25 [:b]] + [50 [:c]] + [75 [:d]] + [100 [:e :f]] + [125 [:g]] + [150 [:h]] + [175 [:i]] + [200 [:j :k]]])) + (t/is (= (rt/range-query tree 0 100) + [[0 [:a]] + [25 [:b]] + [50 [:c]] + [75 [:d]] + [100 [:e :f]]])) + (t/is (= (rt/range-query tree 100 200) + [[100 [:e :f]] + [125 [:g]] + [150 [:h]] + [175 [:i]] + [200 [:j :k]]])) + (t/is (= (rt/range-query tree 10 60) + [[25 [:b]] + [50 [:c]]])) + (t/is (= (rt/range-query tree 199.5 200.5) + [[200 [:j :k]]])))) + + (t/testing "Empty range query" + (let [tree (-> (rt/make-tree) + (rt/insert 100 :a) + (rt/insert 50 :b) + (rt/insert 150 :c) + (rt/insert 25 :d) + (rt/insert 75 :e) + (rt/insert 125 :f) + (rt/insert 175 :g))] + (t/is (= (rt/range-query tree -100 0) [])) + (t/is (= (rt/range-query tree 200 300) [])) + (t/is (= (rt/range-query tree 200 0) [])))) + + (t/testing "Range query over null should return empty" + (t/is (= (rt/range-query nil 0 100) [])))) + +(t/deftest test-balanced-tree + (t/testing "Creates a worst-case BST and probes for a balanced height" + (let [size 1024 + tree (reduce #(rt/insert %1 %2 :x) (rt/make-tree) (range 0 (dec size))) + height (rt/height tree)] + (t/is (= height (inc (js/Math.log2 size))))))) + +(t/deftest test-to-string + (t/testing "Creates a tree and prints it" + (let [tree (-> (rt/make-tree) + (rt/insert 50 :a) + (rt/insert 25 :b) + (rt/insert 25 :c) + (rt/insert 100 :d) + (rt/insert 75 :e)) + result (str tree)] + (t/is (= result "25: [:b, :c], 50: [:a], 75: [:e], 100: [:d]")))))