Browse Source

WIP: attempting to refactor the table data (and all associated functions) to a map structure

Brandon Wong 3 years ago
parent
commit
74e21b7631

+ 15 - 1
frontend/src/cljs/microtables_frontend/db.cljs

@@ -20,5 +20,19 @@
                 {:row 7 :col "B" :value "=C5 + D6"}
                 {:row 8 :col "B" :value "=B7 * 2"}
                 {:row 7 :col "C" :value "=D1"}
-                {:row 12 :col "B" :value "=C12"}]})
+                {:row 12 :col "B" :value "=C12"}]
+   :alt-table-data {"A" {1 {:value "59"}
+                         12 {:value "2405"}}
+                    "B" {4 {:value "7893"}
+                         5 {:value "7863"}
+                         7 {:value "=C5 + D6"}
+                         8 {:value "=B7 * 2"}
+                         12 {:value "=C12"}}
+                    "C" {7 {:value "=D1"}
+                         5 {:value "269"}}
+                    "D" {6 {:value "4065"}
+                         10 {:value "8272"}
+                         11 {:value "2495"}}
+                    "F" {2 {:value "8650"}
+                         7 {:value "5316"}}}})
 

+ 13 - 5
frontend/src/cljs/microtables_frontend/events.cljs

@@ -13,7 +13,9 @@
    (println "initializing db")
    (-> db/default-db
        (update-in [:table-data] (partial map utils/add-parsed-variables))
-       (update-in [:table-data] utils/re-evaluate))))
+       (update-in [:table-data] utils/re-evaluate)
+       (update-in [:alt-table-data] utils/create-all-references)
+       (update-in [:alt-table-data] utils/create-all-back-references))))
 
 
 (re-frame/reg-event-db
@@ -31,12 +33,14 @@
 (re-frame/reg-event-db
   ::movement-leave-cell
   (fn [db [_ c r]]
-    (let [datum (utils/get-datum (:table-data db) c r)]
+    (let [datum (utils/get-datum (:table-data db) c r)
+          alt-datum (get-in (:alt-table-data db) [c r])]
       (println "::movement-leave-cell" c r (if (:dirty datum) "- dirty" ""))
       (-> db
           (assoc-in [:position :cursor] nil)
           (assoc-in [:position :selection] nil)
           (update-in [:table-data] (partial utils/add-parsed-variables-to-specific-datum c r))
+          (update-in [:alt-table-data c r] utils/add-references)
           (re-evaluate-if-dirty (:dirty datum))))))
 
 
@@ -44,7 +48,11 @@
   ::edit-cell-value
   (fn [db [_ c r existing-datum value]]
     (println "::edit-cell-value" c r value)
-    (if (nil? existing-datum)
-      (assoc db :table-data (conj (:table-data db) {:row r :col c :value value :dirty true}))
-      (assoc db :table-data (map #(if (and (= r (:row %)) (= c (:col %))) (assoc (assoc % :dirty true) :value value) %) (:table-data db))))))
+    (if (nil? existing-datum);TODO: when removing list-based data, remove this conditional, and all references to "existing-datum" (not needed in map-based data)
+      (-> db
+          (assoc :table-data (conj (:table-data db) {:row r :col c :value value :dirty true}))
+          (update-in [:alt-table-data] #(utils/change-datum-value % c r value)))
+      (-> db
+          (assoc :table-data (map #(if (and (= r (:row %)) (= c (:col %))) (assoc (assoc % :dirty true) :value value) %) (:table-data db)))
+          (update-in [:alt-table-data] #(utils/change-datum-value % c r value))))))
 

+ 10 - 0
frontend/src/cljs/microtables_frontend/subs.cljs

@@ -18,4 +18,14 @@
                 %) data)
         data))))
 
+(re-frame/reg-sub
+  ::alt-table-data
+  (fn [db]
+    (println "returning alternative table data")
+    (let [data (:alt-table-data db)
+          cursor (get-in db [:position :cursor])]
+      (if cursor
+        (assoc-in data [(:col cursor) (:row cursor) :view] :value)
+        data))))
+
 

+ 163 - 6
frontend/src/cljs/microtables_frontend/utils.cljs

@@ -8,6 +8,21 @@
 
 (defn highest [dir data] (apply max (map dir data)))
 
+
+(defn highest-col
+  "Return the highest column (letter) for which there is a non-empty cell"
+  [data]
+  ; choose the "max" (alphabetical order) value among the longest keys
+  (apply max (val (apply max-key key (group-by #(.-length %) (keys data))))))
+
+(defn highest-row
+  "Return the highest row (number) for which there is a non-empty cell"
+  [data]
+  ; get all the row keys from all the column objects (and flatten), then pick the max
+  (apply max (flatten (map keys (vals data)))))
+
+
+
 (defn increment-letter-code [s]
   (let [l (last s)]
     (cond
@@ -44,6 +59,16 @@
       (-> datum (assoc :vars vars) (assoc :refs refs) (dissoc :error)))
     (-> datum (dissoc :vars) (dissoc :refs) (dissoc :display) (dissoc :error))))
 
+; leave in the :inbound references, since they probably have not have changed
+(defn add-references
+  "Parses the expression in the value of a datum, and adds vars and refs as necessary"
+  [datum]
+  (if (= (first (:value datum)) "=")
+    (let [vars (parse-variables (subs (:value datum) 1))
+          refs (map str->rc vars)]
+      (-> datum (assoc :vars vars) (assoc :refs refs) (dissoc :error)))
+    (-> datum (dissoc :vars) (dissoc :refs) (dissoc :display) (dissoc :error))))
+
 (defn add-parsed-variables-to-specific-datum
   "Parse variables from the value of a datum and add in :vars and :refs (for swap! data-atom).
   If the value does not contain a fomula, remove any :vars and :refs that may have been there."
@@ -51,13 +76,104 @@
                      (add-parsed-variables %)
                      %) data))
 
+; the references in the data are a set of disconnected, doubly-linked trees
+;TODO: rather than denotify all, then re-notify all, maybe use a diff? maybe on small scales it's not worth it?
+(defn denotify-references
+  "Remove references in all cells formerly referenced by this cell"
+  [data origin refs]
+  (if (empty? refs)
+    data
+    (let [target (first refs)
+          de-notified (update-in data [(:col target) (:row target) :inbound] (partial filter #(not= % origin)))]
+      (recur de-notified origin (rest refs)))))
+(defn notify-references
+  "Update references in all cells referenced by this cell"
+  [data origin refs]
+  (if (empty? refs)
+    data
+    (let [target (first refs)
+          notified (update-in data [(:col target) (:row target) :inbound] conj origin)]
+      (recur notified origin (rest refs)))))
+(defn create-all-references
+  "Starting from a clean slate, add in all references. This wipes any references that may have been present."
+  [data]
+  (reduce-kv
+    (fn [columns c curr-column]
+      (assoc columns c (reduce-kv
+                         (fn [rows r datum]
+                           (assoc rows r (add-references (dissoc (dissoc datum :refs) :inbound))))
+                         {}
+                         curr-column)))
+    {}
+    data))
+
+;TODO: re-write create-all-references to use walk-modify-data instead
+(defn walk-modify-data
+  "Walks through the data map and updates each datum by applying f (a function accepting col, row, datum)."
+  [data f]
+  (reduce-kv
+    (fn [columns c curr-column]
+      (assoc columns c (reduce-kv
+                         (fn [rows r datum]
+                           (assoc rows r (f c r datum)))
+                         {}
+                         curr-column)))
+    {}
+    data))
+
+;(create-all-back-references (create-all-references {"A" {1 {:value "=B2"}} "B" {2 {:value "=B3"} 3 {:value "=A1"}}}))
+;(= (walk-modify-data (:alt-table-data microtables-frontend.db/default-db) #(-> %3 (dissoc :refs) (dissoc :inbound) (add-references))) (walk-modify-data (:alt-table-data microtables-frontend.db/default-db) #(add-references (dissoc (dissoc %3 :refs) :inbound))) (create-all-references (:alt-table-data microtables-frontend.db/default-db)))
+
+(defn walk-get-refs
+  "Walks through the data map and returns a list of :col/:row maps for each cell which satisfies the predicate (a function accepting col, row, datum)."
+  [data pred]
+  (reduce-kv (fn [l c column] (concat l (map (fn [[r _]] {:col c :row r}) (filter (fn [[r datum]] (pred c r datum)) column)))) '() data))
 
-(def evaluate-expression (memoize (fn [expression variables]
-                                    (try
-                                      (.evaluate mathjs expression (clj->js variables))
-                                      (catch js/Error e
-                                        (println "mathjs evaluation error" (.-message e) e)
-                                        :calc-error)))))
+
+(defn create-all-back-references
+  "Assuming all references have been added, insert all back references."
+  [data]
+  (loop [data data
+         formulas (walk-get-refs data #(= (first (:value %3)) "="))]
+    (if (empty? formulas)
+      data
+      (let [origin (first formulas)
+            refs (get-in data [(:col origin) (:row origin) :refs])
+            updated-one (notify-references data origin refs)]
+        (recur updated-one (rest formulas))))))
+
+;TODO: change to recursive queue-based tree search
+(defn set-dirty-flag
+  "Determines if a datum needs to be marked as \"dirty\", based on value and inbound references. Returns datum with :dirty flag present or absent."
+  [datum]
+  (let [formula? (= (first (:value datum)) "=")
+        back-refs (:inbound datum)
+        back-refs? (not (empty? back-refs))]
+    (if (or formula? back-refs?)
+      (assoc datum :dirty true)
+      (dissoc datum :dirty))))
+
+(defn change-datum-value
+  "Modify the value of a datum in the table, and update all applicable references"
+  [data c r value]
+  (let [datum (get-in data [c r])
+        updated (assoc datum :value value)
+        parsed (add-references updated)]
+    (-> data
+        (assoc-in [c r :value] value)
+        (update-in [c r] set-dirty-flag)
+        (denotify-references {:col c :row r} (:refs datum))
+        (notify-references {:col c :row r} (:refs parsed)))))
+
+
+(def evaluate-expression
+  "Convert (via mathjs) an expression string to a final answer (also a string).  A map of variables must also be provided. If there is an error, it will return :calc-error."
+  (memoize (fn [expression variables]
+             (try
+               (.evaluate mathjs expression (clj->js variables))
+               (catch js/Error e
+                 (println "mathjs evaluation error" (.-message e) e)
+                 :calc-error)))))
 
 ;TODO: deal with lowercase cell references
 (defn find-cell [data c r]
@@ -83,6 +199,47 @@
        (some (fn [cell]
                (find-cycle data (find-ref data cell) this-and-above)) refs)))))
 
+(defn alt-find-cycle
+  "Accepts the data and a datum, and peforms a depth-first search to find reference cycles."
+  ([data c r] (alt-find-cycle data c r #{}))
+  ([data c r ancest]
+   (let [datum (get-in data [c r])
+         current {:col c :row r}
+         this-and-above (conj ancest current)
+         refs (:refs datum)
+         found-repeat (not (empty? (clojure.set/intersection this-and-above (set refs))))]
+     (if found-repeat
+       :cycle-error
+       (some #(alt-find-cycle data (:col %) (:row %) this-and-above) refs)))))
+
+
+
+; THE NEW EVALUATE FUNCTION
+;TODO TODO TODO TODO TODO TODO TODO TODO TODO TODO
+; - check for cycles in the back references, starting from the target cell (if any, use another function to mark it and its back references with :cycle-error and remove :dirty)
+;   - TODO: modify alt-find-cycle to use back-references instead of forward references
+; - if any of the forward references are dirty, mark the cell (and recurse up) with an error (and set a TODO to think about this further)
+; - evaluate (using forward references if necessary)
+; - add all back-references to the queue
+; - recurse
+; - TODO: consider initialization case
+; - TODO: consider multiple cells modified simultaneously
+; - TODO: comment the code well
+#_(defn evaluate-from-cell
+    "Evaluate the final value of a cell, and recursively re-evaluate all the cells that reference it."
+    [data c r])
+
+
+(defn alt-re-evaluate
+  "Evaluate the values of cells that contain formulae, following reference chains if applicable."
+  [data]
+  (let [non-empty-cells (flatten (map (fn [[c v]] (map (fn [[r _]] {:col c :row r}) v)) data))
+        {has-formula true original-values false} (group-by #(= (first (get-in data [(:col %) (:row %) :value])) "=") non-empty-cells)
+        found-cycles (map #(let [found (alt-find-cycle data (:col %) (:row %))]
+                             (if found (assoc % :error found) %)) has-formula)]
+    non-empty-cells))
+
+
 (defn find-val [data c r]
   (let [l (find-cell data c r)
         v (get l :display (get l :value))

+ 6 - 6
frontend/src/cljs/microtables_frontend/views.cljs

@@ -9,7 +9,7 @@
 ;; TABLE COMPONENTS
 
 (defn cell [c r data]
-  (let [datum (utils/get-datum data c r)]
+  (let [datum (get-in data [c r])]
     ^{:key (str c r)} [:td
                        [:input {:id (str c r)
                                 :value (if (= (get datum :view nil) :value)
@@ -23,8 +23,8 @@
 (defn row [r cols data]
   ^{:key (str "row-" r)} [:tr
                           (cons
-                            ^{:key (str "row-head-" r)} [:th (str r)]
-                            (map #(cell % r data) cols))])
+                           ^{:key (str "row-head-" r)} [:th (str r)]
+                           (map #(cell % r data) cols))])
 
 (defn header-row [cols]
   ^{:key "header"} [:tr
@@ -34,8 +34,8 @@
 
 (defn sheet [data]
   [:table [:tbody
-           (let [maxrow (utils/highest :row data)
-                 cols (take-while (partial not= (utils/next-letter (utils/highest :col data))) utils/col-letters)]
+           (let [maxrow (utils/highest-row data)
+                 cols (take-while (partial not= (utils/next-letter (utils/highest-col data))) utils/col-letters)]
              (cons
                (header-row cols)
                (map #(row % cols data) (range 1 (inc maxrow)))))]])
@@ -46,7 +46,7 @@
 
 
 (defn main-panel []
-  (let [data (re-frame/subscribe [::subs/table-data])]
+  (let [data (re-frame/subscribe [::subs/alt-table-data])]
     [:div
      [:h1 "Microtables"]
      [sheet @data]]))