|
@@ -1,25 +1,33 @@
|
|
|
(ns microtables-frontend.utils
|
|
|
(:require
|
|
|
- ["mathjs" :as mathjs]))
|
|
|
+ ["mathjs" :as mathjs]
|
|
|
+ [clojure.set :refer [intersection]]
|
|
|
+ [clojure.string :as string]))
|
|
|
|
|
|
; to add an npm package to shadow-cljs:
|
|
|
; https://clojureverse.org/t/guide-on-how-to-use-import-npm-modules-packages-in-clojurescript/2298
|
|
|
; https://shadow-cljs.github.io/docs/UsersGuide.html#npm
|
|
|
|
|
|
-
|
|
|
(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))))))
|
|
|
+ (->> data
|
|
|
+ keys
|
|
|
+ (group-by #(.-length %))
|
|
|
+ (apply max-key key)
|
|
|
+ val
|
|
|
+ (apply max)))
|
|
|
|
|
|
(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)))))
|
|
|
-
|
|
|
-
|
|
|
+ (->> data
|
|
|
+ vals
|
|
|
+ (map keys)
|
|
|
+ flatten
|
|
|
+ (apply max)))
|
|
|
|
|
|
(defn increment-letter-code [s]
|
|
|
(let [l (last s)]
|
|
@@ -28,13 +36,15 @@
|
|
|
(= l 90) (conj (increment-letter-code (subvec s 0 (dec (count s)))) 65)
|
|
|
:else (conj (subvec s 0 (dec (count s))) (inc l)))))
|
|
|
|
|
|
-
|
|
|
(defn next-letter [lc]
|
|
|
- (apply str (map char (increment-letter-code (mapv #(.charCodeAt % 0) lc)))))
|
|
|
-
|
|
|
-
|
|
|
-(def col-letters (iterate next-letter "A"))
|
|
|
+ (->> lc
|
|
|
+ (mapv #(.charCodeAt % 0))
|
|
|
+ increment-letter-code
|
|
|
+ (map char)
|
|
|
+ (apply str)))
|
|
|
|
|
|
+(def col-letters
|
|
|
+ (iterate next-letter "A"))
|
|
|
|
|
|
(defn order-two-cols
|
|
|
"Accepts two column names (letters) and returns them in order."
|
|
@@ -52,8 +62,8 @@
|
|
|
start-row (min row1 row2)
|
|
|
end-row (max row1 row2)]
|
|
|
(for [col (take-while #(not= (next-letter end-col) %) (iterate next-letter start-col))
|
|
|
- row (range start-row (inc end-row))]
|
|
|
- {:col col :row row})))
|
|
|
+ row (range start-row (inc end-row))]
|
|
|
+ {:col col :row row})))
|
|
|
|
|
|
; the order goes top to bottom, then left to right - that makes the most sense to me
|
|
|
; I don't know why a different order would be important, or even in what situation order is important at all
|
|
@@ -69,17 +79,19 @@
|
|
|
(def range->commalist
|
|
|
"Converts a range in \"A1:B2\" notation to a comma-separated list of cells: \"A1,A2,B1,B2\"."
|
|
|
(memoize (fn [range-string]
|
|
|
- (let [cell-list (parse-range range-string)
|
|
|
- strings (map #(str (:col %) (:row %)) cell-list)]
|
|
|
- (str "(" (clojure.string/join "," strings) ")")))))
|
|
|
+ (let [cell-list (parse-range range-string)
|
|
|
+ strings (map #(str (:col %) (:row %)) cell-list)]
|
|
|
+ (str "(" (string/join "," strings) ")")))))
|
|
|
|
|
|
(def replace-ranges-in-expression
|
|
|
"Receives an expression string, and replaces all ranges in colon notation (\"A1:B2\") into a comma-separated list of cells (\"A1,A2,B1,B2\")."
|
|
|
(memoize (fn [expression]
|
|
|
- (clojure.string/replace expression #"\(\s*[A-Z]+[0-9]+\s*:\s*[A-Z]+[0-9]+\s*\)" range->commalist))))
|
|
|
+ (string/replace expression #"\(\s*[A-Z]+[0-9]+\s*:\s*[A-Z]+[0-9]+\s*\)" range->commalist))))
|
|
|
|
|
|
(defn formula?
|
|
|
- "Determines if a value is a fomula. If it is, it returns it (without the leading equals sign. If not, it returns nil."
|
|
|
+ "Determines if a value is a fomula.
|
|
|
+ If it is, it returns it (without the leading equals sign).
|
|
|
+ If not, it returns nil."
|
|
|
[value]
|
|
|
(if (= (first value) "=")
|
|
|
(subs value 1)
|
|
@@ -92,7 +104,6 @@
|
|
|
(map #(.toUpperCase %) $)
|
|
|
(filter #(re-matches #"[A-Z]+[0-9]+" %) $)))))
|
|
|
|
|
|
-
|
|
|
(def str->rc (memoize (fn [s]
|
|
|
(let [c (re-find #"^[A-Z]+" s)
|
|
|
r (.parseInt js/window (re-find #"[0-9]+$" s))]
|
|
@@ -124,6 +135,7 @@
|
|
|
(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]
|
|
@@ -132,38 +144,45 @@
|
|
|
(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."
|
|
|
+ "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))
|
|
|
+ (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))
|
|
|
-
|
|
|
+ (fn [columns c curr-column]
|
|
|
+ (assoc columns c (reduce-kv
|
|
|
+ (fn [rows r datum]
|
|
|
+ (assoc rows r (f c r datum)))
|
|
|
+ {}
|
|
|
+ curr-column)))
|
|
|
+ {}
|
|
|
+ data))
|
|
|
|
|
|
(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))
|
|
|
+ (reduce-kv (fn [l c column]
|
|
|
+ (->> column
|
|
|
+ (filter (fn [[r datum]] (pred c r datum)))
|
|
|
+ (map (fn [[r _]] {:col c :row r}))
|
|
|
+ (concat l)))
|
|
|
+ '()
|
|
|
+ data))
|
|
|
|
|
|
; proposed alternative (the beginning of one) to walk-get-refs
|
|
|
;(defn col-map? [m] (and (map? m) (every? #(and (string? %) (re-matches #"[A-Z]+" %)) (keys m))))
|
|
@@ -183,7 +202,8 @@
|
|
|
(recur updated-one (rest formulas))))))
|
|
|
|
|
|
(defn set-dirty-flags
|
|
|
- "Sets the target cell to \"dirty\" and recursively repeat with its back-references all the way up. Returns the new data set."
|
|
|
+ "Sets the target cell to \"dirty\" and recursively repeat with its back-references all the way up.
|
|
|
+ Returns the new data set."
|
|
|
([data c r]
|
|
|
(set-dirty-flags data (list {:col c :row r})))
|
|
|
([data queue]
|
|
@@ -199,7 +219,6 @@
|
|
|
new-queue (concat (rest queue) (:inbound datum))]
|
|
|
(recur new-data new-queue)))))))
|
|
|
|
|
|
-
|
|
|
(defn change-datum-value
|
|
|
"Modify the value of a datum in the table, and update all applicable references"
|
|
|
[data c r value]
|
|
@@ -224,28 +243,29 @@
|
|
|
(defn remove-valueless-range-elements
|
|
|
"Remove nil values specifically from ranges (to solve issues with some functions like average)."
|
|
|
[variables var-list]
|
|
|
- (let [l (clojure.string/split (clojure.string/replace (first var-list) #"[()]" "") #",")
|
|
|
+ (let [l (string/split (string/replace (first var-list) #"[()]" "") #",")
|
|
|
has-values (filter #(not (nil? (variables %))) l)]
|
|
|
- (str "(" (clojure.string/join "," has-values) ")")))
|
|
|
-
|
|
|
-
|
|
|
+ (str "(" (string/join "," has-values) ")")))
|
|
|
|
|
|
(defn preprocess-expression
|
|
|
"Handle range cases, rename certain functions (to work with math.js), prepare expression and variables for processing."
|
|
|
[expression variables]
|
|
|
- (let [renamed-expression (clojure.string/replace expression #"\baverage\(" "mean(")
|
|
|
- new-expression (clojure.string/replace renamed-expression #"\(([A-Z]+[0-9]+,)*[A-Z]+[0-9]+\)" (partial remove-valueless-range-elements variables))
|
|
|
+ (let [renamed-expression (string/replace expression #"\baverage\(" "mean(")
|
|
|
+ new-expression (string/replace renamed-expression
|
|
|
+ #"\(([A-Z]+[0-9]+,)*[A-Z]+[0-9]+\)"
|
|
|
+ (partial remove-valueless-range-elements variables))
|
|
|
new-variables (reduce-kv #(assoc %1 %2 (if (nil? %3) "0" %3)) {} variables)]
|
|
|
(println "PREPROCESS" {:expression new-expression :variables new-variables})
|
|
|
{:expression new-expression
|
|
|
:variables new-variables}))
|
|
|
|
|
|
-
|
|
|
(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."
|
|
|
+ "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]
|
|
|
(let [range-replaced (replace-ranges-in-expression expression)
|
|
|
- {ready-expression :expression ready-variables :variables} (preprocess-expression range-replaced variables)]
|
|
|
+ {ready-expression :expression
|
|
|
+ ready-variables :variables} (preprocess-expression range-replaced variables)]
|
|
|
(try
|
|
|
(.evaluate mathjs ready-expression (clj->js ready-variables))
|
|
|
(catch js/Error e
|
|
@@ -264,14 +284,15 @@
|
|
|
current {:col c :row r}
|
|
|
this-and-above (conj ancest current)
|
|
|
inbound (:inbound datum)
|
|
|
- found-repeat (not (empty? (clojure.set/intersection this-and-above (set inbound))))]
|
|
|
+ found-repeat (not (empty? (intersection this-and-above (set inbound))))]
|
|
|
(if found-repeat
|
|
|
:cycle-error
|
|
|
(some #(find-cycle data (:col %) (:row %) this-and-above) inbound)))))
|
|
|
|
|
|
-
|
|
|
(defn gather-variables-and-evaluate-cell
|
|
|
- "Assumes that all the cell's immediate references have been resolved. Collects the final values from them, then evaluates the current cell's expression. Returns the new data map."
|
|
|
+ "Assumes that all the cell's immediate references have been resolved.
|
|
|
+ Collects the final values from them, then evaluates the current cell's expression.
|
|
|
+ Returns the new data map."
|
|
|
[data c r]
|
|
|
(let [datum (dissoc (dissoc (get-in data [c r]) :dirty) :display) ; get rid of the dirty flag right away (it must be included with the returned data to have effect)
|
|
|
refs (:refs datum)
|
|
@@ -302,7 +323,6 @@
|
|
|
new-datum (assoc datum :display evaluated-value)]
|
|
|
(assoc-in data [c r] new-datum)))))
|
|
|
|
|
|
-
|
|
|
; THE NEW EVALUATE FUNCTION
|
|
|
; - 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)
|
|
|
; - 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)
|
|
@@ -341,7 +361,8 @@
|
|
|
|
|
|
;TODO: does this need a cycle check?
|
|
|
(defn evaluate-all
|
|
|
- "Evaluates all cells marked as \"dirty\". Generally reserved for the initialization."
|
|
|
+ "Evaluates all cells marked as \"dirty\".
|
|
|
+ Generally reserved for the initialization."
|
|
|
([data]
|
|
|
(evaluate-all data (walk-get-refs data #(:dirty %3))))
|
|
|
([data queue]
|