5 Commits 9fb9a2601a ... a2c4169b48

Author SHA1 Message Date
  Brandon Wong a2c4169b48 split up evaluation code 4 weeks ago
  Brandon Wong 9c4e2598c0 splitting up evaluation code 4 weeks ago
  Brandon Wong bcf0ef1cb0 split up views code 4 weeks ago
  Brandon Wong 46abed816b applied code formatting 4 weeks ago
  Brandon Wong d35ce2e688 updating project 4 weeks ago

+ 2 - 0
frontend/.gitignore

@@ -14,4 +14,6 @@
 # shadow-cljs cache, port files
 /.shadow-cljs/
 
+**/.cache
+
 *.swp

File diff suppressed because it is too large
+ 396 - 296
frontend/package-lock.json


+ 3 - 5
frontend/src/cljs/microtables_frontend/core.cljs

@@ -1,12 +1,10 @@
 (ns microtables-frontend.core
   (:require
-   [reagent.core :as reagent]
-   [re-frame.core :as re-frame]
+   [microtables-frontend.config :as config]
    [microtables-frontend.events :as events]
    [microtables-frontend.views :as views]
-   [microtables-frontend.config :as config]))
-
-
+   [re-frame.core :as re-frame]
+   [reagent.core :as reagent]))
 
 (defn dev-setup []
   (when config/debug?

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

@@ -1,8 +1,7 @@
 (ns microtables-frontend.db)
 
 (def default-db
-  {
-   :controls nil
+  {:controls nil
    ;TODO: add "start" and "end" corners as selection
    :position {:cursor nil
               :selection nil #_{:start {:col "A" :row 5}

+ 98 - 0
frontend/src/cljs/microtables_frontend/evaluation.cljs

@@ -0,0 +1,98 @@
+(ns microtables-frontend.evaluation
+  (:require
+   [microtables-frontend.evaluation.impl :as impl]
+   [microtables-frontend.utils.data :as data-utils]))
+
+; 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
+
+; 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))))
+;(defn row-map? [m] (and (map? m) (every? #(and (integer? %) (pos? %)) (keys m))))
+;(defn get-all-cells [data] (filter #(not (or (col-map? %) (row-map? %))) (tree-seq #(and (map? %) (or (col-map? %) (row-map? %))) vals data)))
+
+(defn create-all-back-references
+  "Assuming all references have been added, insert all back references."
+  [data]
+  (loop [data data
+         formulas (data-utils/walk-get-refs data #(data-utils/formula? (:value %3)))]
+    (if (empty? formulas)
+      data
+      (let [origin (first formulas)
+            refs (get-in data [(:col origin) (:row origin) :refs])
+            updated-one (impl/notify-references data origin refs)]
+        (recur updated-one (rest formulas))))))
+
+(defn reset-references
+  "If there has been a change to which cells are referenced by this cell, then change the necessary back-references to this cell."
+  [data c r]
+  (let [datum (get-in data [c r])
+        parsed (data-utils/add-references datum)]
+    (if (= (:refs datum) (:refs parsed))
+      data
+      (-> data
+          (assoc-in [c r] parsed)
+          (impl/denotify-references {:col c :row r} (:refs datum))
+          (impl/notify-references {:col c :row r} (:refs parsed))))))
+
+;TODO: deal with lowercase cell references
+
+; 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)
+; - evaluate (using forward references if necessary)
+; - add all back-references to the queue
+; - recurse
+; - TODO: consider initialization case
+; - TODO: consider multiple cells modified simultaneously
+(defn evaluate-from-cell
+  "Evaluate the final value of a cell, and recursively re-evaluate all the cells that reference it."
+  [data c r]
+  (let [cycles? (impl/find-cycle data c r)
+        new-data (if cycles?
+                   (-> data                                           ; if there are cycles, mark :cycle-error and remove :dirty (rathan than evaluate) - still need to recurse up the tree to mark dependents with :cycle-error
+                       (update-in [c r] dissoc :dirty)
+                       (assoc-in [c r :display] :cycle-error))
+                   (impl/gather-variables-and-evaluate-cell data c r))]    ; if there are no cycles, evaluate the cell
+    (loop [data new-data
+           queue (get-in new-data [c r :inbound])]
+      (if (empty? queue)
+        data                                                          ; if the queue is empty, we're done
+        (let [current (first queue)
+              cc (:col current)
+              cr (:row current)
+              dirty? (get-in data [cc cr :dirty])
+              re-evaluated-data (if dirty?
+                                  (impl/gather-variables-and-evaluate-cell data cc cr)
+                                  data)
+              sufficient? (not= (get-in re-evaluated-data [cc cr :display]) :insufficient-data)
+              new-queue (if dirty?
+                          (if sufficient?
+                            (concat (rest queue) (get-in re-evaluated-data [cc cr :inbound]))   ; if all is well, then add the back-references onto the queue
+                            (concat (rest queue) (list current)))                               ; if the current cell's dependencies are not satisfied, re-add to the end of the queue
+                          (rest queue))]                                                        ; if the current cell is not marked as dirty, then it has already been processed
+          (recur re-evaluated-data new-queue))))))
+
+;TODO: does this need a cycle check?
+(defn evaluate-all
+  "Evaluates all cells marked as \"dirty\".
+  Generally reserved for the initialization."
+  ([data]
+   (evaluate-all data (data-utils/walk-get-refs data #(:dirty %3))))
+  ([data queue]
+   (if (empty? queue)
+     data
+     (let [cur (first queue)
+           cc (:col cur)
+           cr (:row cur)
+           dirty? (get-in data [cc cr :dirty])]
+       (if dirty?
+         (let [evaluated (evaluate-from-cell data (:col cur) (:row cur))
+               result (get-in evaluated [cc cr :display])]
+           (if (= result :insufficient-data)
+             (recur data (concat (rest queue) (list cur)))
+             (recur evaluated (rest queue))))
+         (recur data (rest queue)))))))
+
+

+ 107 - 0
frontend/src/cljs/microtables_frontend/evaluation/impl.cljs

@@ -0,0 +1,107 @@
+(ns microtables-frontend.evaluation.impl
+  (:require
+   ["mathjs" :as mathjs]
+   [clojure.set :refer [intersection]]
+   [clojure.string :as string]
+   [microtables-frontend.utils.data :as data-utils]))
+
+; 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- remove-valueless-range-elements
+  "Remove nil values specifically from ranges (to solve issues with some functions like average)."
+  [variables var-list]
+  (let [l (string/split (string/replace (first var-list) #"[()]" "") #",")
+        has-values (filter #(not (nil? (variables %))) l)]
+    (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 (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}))
+
+;TODO: memoize dynamically? probably not worth memoizing directly, and could take up too much memory over time
+;      https://stackoverflow.com/a/13123571/8172807
+(defn find-cycle
+  "Accepts the data and a datum, and peforms a depth-first search to find reference cycles, following back-references."
+  ([data c r] (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)
+         inbound (:inbound datum)
+         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)))))
+
+(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]
+             (let [range-replaced (data-utils/replace-ranges-in-expression expression)
+                   {ready-expression :expression
+                    ready-variables :variables} (preprocess-expression range-replaced variables)]
+               (try
+                 (.evaluate mathjs ready-expression (clj->js ready-variables))
+                 (catch js/Error e
+                   (println "mathjs evaluation error" (.-message e) e)
+                   :calc-error))))))
+
+(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."
+  [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)
+        value (:value datum)
+        formula (data-utils/formula? value)
+        resolved-refs (map #(merge % (get-in data [(:col %) (:row %)])) refs)
+        evaluated-refs (map #(if (data-utils/formula? (:value %)) (:display %) (:value %)) resolved-refs)
+        invalid-refs (some nil? resolved-refs)
+        dirty-refs (some :dirty resolved-refs)
+        error-refs (some #(= (:display %) :error) resolved-refs)
+        ;unevaluated-refs (some nil? evaluated-refs)
+        cycle-refs (some #(= (:display %) :cycle-error) resolved-refs)
+        disqualified? (or invalid-refs dirty-refs error-refs)]
+    (cond
+      (not formula) (assoc-in data [c r] datum)     ; if it's not a formula, then return as is (with the dirty flag removed)
+      cycle-refs (-> data                           ; if one of its references has a reference cycle, then this one is "poisoned" as well
+                     (assoc-in [c r] datum)
+                     (assoc-in [c r :display] :cycle-error))
+      ;unevaluated-refs (assoc-in data [c r :display] :insufficient-data) ; do not un-mark as "dirty", since it has not been evaluated yet
+      disqualified? (-> data                        ; some other error is present
+                        (assoc-in [c r] datum)
+                        (assoc-in [c r :display] :error))
+      (empty? refs) (-> data
+                        (assoc-in [c r] datum)
+                        (assoc-in [c r :display] (evaluate-expression (subs value 1) {})))
+      :else (let [variables (zipmap (map #(str (:col %) (:row %)) refs) evaluated-refs)
+                  evaluated-value (evaluate-expression (subs value 1) variables)
+                  new-datum (assoc datum :display evaluated-value)]
+              (assoc-in data [c r] new-datum)))))

+ 47 - 48
frontend/src/cljs/microtables_frontend/events.cljs

@@ -1,72 +1,71 @@
 (ns microtables-frontend.events
   (:require
-   [re-frame.core :as re-frame]
    [microtables-frontend.db :as db]
-   [microtables-frontend.utils :as utils]))
-
-
-
+   [microtables-frontend.evaluation :as eval]
+   [microtables-frontend.utils.coordinates :as coords]
+   [microtables-frontend.utils.data :as data-utils]
+   [re-frame.core :as re-frame]))
 
 (re-frame/reg-event-db
  ::initialize-db
  (fn [_ _]
    (println "initializing db")
    (-> db/default-db
-       (update-in [:table-data] #(utils/walk-modify-data % (fn [c r datum] (if (= (first (:value datum)) "=") (assoc datum :dirty true) datum))))
-       (update-in [:table-data] utils/create-all-references)
-       (update-in [:table-data] utils/create-all-back-references)
-       (update-in [:table-data] utils/evaluate-all))))
-
+       (update-in [:table-data] #(data-utils/walk-modify-data
+                                  %
+                                  (fn [_c _r datum]
+                                    (if (= (first (:value datum)) "=")
+                                      (assoc datum :dirty true)
+                                      datum))))
+       (update-in [:table-data] data-utils/create-all-references)
+       (update-in [:table-data] eval/create-all-back-references)
+       (update-in [:table-data] eval/evaluate-all))))
 
 (re-frame/reg-event-db
-  ::movement-enter-cell
-  (fn [db [_ c r]]
-    (println "::movement-enter-cell" c r)
-    (assoc-in db [:position :cursor] {:col c :row r})))
-
-
+ ::movement-enter-cell
+ (fn [db [_ c r]]
+   (println "::movement-enter-cell" c r)
+   (assoc-in db [:position :cursor] {:col c :row r})))
 
 (re-frame/reg-event-db
-  ::movement-leave-cell
-  (fn [db [_ c r]]
-    (println "::movement-leave-cell" c r)
-    (-> db
-        (assoc-in [:position :cursor] nil)
-        (assoc-in [:position :selection] nil)
-        (update-in [:table-data] #(utils/reset-references % c r))
-        (update-in [:table-data] #(utils/evaluate-from-cell % c r)))))
-
+ ::movement-leave-cell
+ (fn [db [_ c r]]
+   (println "::movement-leave-cell" c r)
+   (-> db
+       (assoc-in [:position :cursor] nil)
+       (assoc-in [:position :selection] nil)
+       (update-in [:table-data] #(eval/reset-references % c r))
+       (update-in [:table-data] #(eval/evaluate-from-cell % c r)))))
 
 (re-frame/reg-event-db
-  ::edit-cell-value
-  (fn [db [_ c r value]]
-    (println "::edit-cell-value" c r value)
-    (update-in db [:table-data] #(utils/change-datum-value % c r value))))
+ ::edit-cell-value
+ (fn [db [_ c r value]]
+   (println "::edit-cell-value" c r value)
+   (update-in db [:table-data] #(data-utils/change-datum-value % c r value))))
 
 ; handle pressing enter (move to the next cell down)
 ; tab is taken care of natively, and is good enough
 (re-frame/reg-event-fx
-  ::press-enter-in-cell
-  (fn [{:keys [db]} [_ c r]]
-    (let [max-row? (= (utils/highest-row (:table-data db)) r)
-          max-col? (= (utils/highest-col (:table-data db)) c)
-          new-col (if max-row?
-                    (if max-col?
-                      "A"
-                      (utils/next-letter c))
-                    c)
-          new-row (if max-row?
-                    1
-                    (inc r))]
-      (println "::press-enter-in-cell" c r)
-      {:focus-on-cell [new-col new-row]})))
+ ::press-enter-in-cell
+ (fn [{:keys [db]} [_ c r]]
+   (let [max-row? (= (coords/highest-row (:table-data db)) r)
+         max-col? (= (coords/highest-col (:table-data db)) c)
+         new-col (if max-row?
+                   (if max-col?
+                     "A"
+                     (coords/next-letter c))
+                   c)
+         new-row (if max-row?
+                   1
+                   (inc r))]
+     (println "::press-enter-in-cell" c r)
+     {:focus-on-cell [new-col new-row]})))
 
 (re-frame/reg-fx
-  :focus-on-cell
-  (fn [[c r]]
-    (println "fx for :press-enter" c r)
-    (.focus (.getElementById js/document (str c r)))))
-
+ :focus-on-cell
+ (fn [[c r]]
+   (println "fx for :press-enter" c r)
+   (.focus (.getElementById js/document (str c r)))))
 
 (re-frame/reg-event-db
  ::set-controls-state

+ 17 - 17
frontend/src/cljs/microtables_frontend/subs.cljs

@@ -1,13 +1,13 @@
 (ns microtables-frontend.subs
   (:require
-   [re-frame.core :as re-frame]
-   [microtables-frontend.utils :as utils]))
+   [microtables-frontend.utils.coordinates :as coords]
+   [re-frame.core :as re-frame]))
 
 (re-frame/reg-sub
  ::controls-state
  (fn [db]
-  (println "reporting state of controls")
-  (:controls db)))
+   (println "reporting state of controls")
+   (:controls db)))
 
 (defn highlight-cells
   "Modify all cells in data (set :view to :highlighted) that are within the given range."
@@ -16,7 +16,7 @@
         r1 (get-in selection [:start :row])
         c2 (get-in selection [:end :col])
         r2 (get-in selection [:end :row])
-        cells (utils/range->list c1 r1 c2 r2)]
+        cells (coords/range->list c1 r1 c2 r2)]
     (loop [new-data data
            remaining cells]
       (if (empty? remaining)
@@ -27,17 +27,17 @@
 
 ;TODO: insert other display mode data? ("value": formula (cursor), "display" (default): evaluated, "highlighted": in a selection (just a class?))
 (re-frame/reg-sub
-  ::table-data
-  (fn [db]
-    (println "returning table data")
-    (let [data (:table-data db)
-          cursor (get-in db [:position :cursor])
-          selection (get-in db [:position :selection])
-          highlighted (if selection
-                        (highlight-cells data selection)
-                        data)]
-      (if cursor
-        (assoc-in highlighted [(:col cursor) (:row cursor) :view] :value)
-        highlighted))))
+ ::table-data
+ (fn [db]
+   (println "returning table data")
+   (let [data (:table-data db)
+         cursor (get-in db [:position :cursor])
+         selection (get-in db [:position :selection])
+         highlighted (if selection
+                       (highlight-cells data selection)
+                       data)]
+     (if cursor
+       (assoc-in highlighted [(:col cursor) (:row cursor) :view] :value)
+       highlighted))))
 
 

+ 0 - 362
frontend/src/cljs/microtables_frontend/utils.cljs

@@ -1,362 +0,0 @@
-(ns microtables-frontend.utils
-  (:require
-   ["mathjs" :as mathjs]))
-
-; 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))))))
-
-(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
-      (empty? s) [65]
-      (= 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"))
-
-
-(defn order-two-cols
-  "Accepts two column names (letters) and returns them in order."
-  [col1 col2]
-  (cond
-    (> (.-length col1) (.-length col2)) [col2 col1]
-    (> (.-length col2) (.-length col1)) [col1 col2]
-    (= (max col1 col2) col1) [col2 col1]
-    :else [col1 col2]))
-
-(defn range->list
-  "Converts two cells (accepting four coordinates) into a list of all the cells in the range between them (inclusively)."
-  [col1 row1 col2 row2]
-  (let [[start-col end-col] (order-two-cols col1 col2)
-        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})))
-
-; 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
-(defn parse-range
-  "Converts a range in \"A1:B2\" notation to a list of col/row cells: {:col \"A\" :row 1}, etc."
-  [range-string]
-  (let [col1 (second (re-find #"\(\s*([A-Z]+)" range-string))
-        col2 (second (re-find #":\s*([A-Z]+)" range-string))
-        row1 (.parseInt js/window (second (re-find #"([0-9]+)\s*:" range-string)))
-        row2 (.parseInt js/window (second (re-find #"([0-9]+)\s*\)" range-string)))]
-    (range->list col1 row1 col2 row2)))
-
-(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) ")")))))
-
-(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))))
-
-(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."
-  [value]
-  (if (= (first value) "=")
-    (subs value 1)
-    nil))
-
-(def parse-variables (memoize (fn [expression]
-                                (as-> (js->clj (.parse mathjs (replace-ranges-in-expression expression))) $
-                                  (.filter $ #(true? (.-isSymbolNode %)))
-                                  (map #(.-name %) $)
-                                  (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))]
-                          {:row r :col c}))))
-
-; 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 refs as necessary"
-  [datum]
-  (let [formula (formula? (:value datum))]
-    (if formula
-      (let [vars (parse-variables formula)
-            refs (map str->rc vars)]
-        (-> datum
-            (assoc :refs refs)
-            (dissoc :error)))
-      (-> datum
-          (dissoc :refs)
-          (dissoc :display)
-          (dissoc :error)))))
-
-; 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))
-
-
-(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))
-
-; 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))))
-;(defn row-map? [m] (and (map? m) (every? #(and (integer? %) (pos? %)) (keys m))))
-;(defn get-all-cells [data] (filter #(not (or (col-map? %) (row-map? %))) (tree-seq #(and (map? %) (or (col-map? %) (row-map? %))) vals data)))
-
-(defn create-all-back-references
-  "Assuming all references have been added, insert all back references."
-  [data]
-  (loop [data data
-         formulas (walk-get-refs data #(formula? (: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))))))
-
-(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."
-  ([data c r]
-   (set-dirty-flags data (list {:col c :row r})))
-  ([data queue]
-   (if (empty? queue)
-     data
-     (let [cur (first queue)
-           c (:col cur)
-           r (:row cur)
-           datum (get-in data [c r])]
-       (if (true? (:dirty datum))
-         (recur data (rest queue))
-         (let [new-data (assoc-in data [c r :dirty] true)
-               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]
-  (let [datum (get-in data [c r])
-        updated (assoc datum :value value)]
-    (-> data
-        (assoc-in [c r :value] value)
-        (set-dirty-flags c r))))
-
-(defn reset-references
-  "If there has been a change to which cells are referenced by this cell, then change the necessary back-references to this cell."
-  [data c r]
-  (let [datum (get-in data [c r])
-        parsed (add-references datum)]
-    (if (= (:refs datum) (:refs parsed))
-      data
-      (-> data
-          (assoc-in [c r] parsed)
-          (denotify-references {:col c :row r} (:refs datum))
-          (notify-references {:col c :row r} (:refs parsed))))))
-
-(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) #"[()]" "") #",")
-        has-values (filter #(not (nil? (variables %))) l)]
-    (str "(" (clojure.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))
-        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."
-  (memoize (fn [expression variables]
-             (let [range-replaced (replace-ranges-in-expression expression)
-                   {ready-expression :expression ready-variables :variables} (preprocess-expression range-replaced variables)]
-               (try
-                 (.evaluate mathjs ready-expression (clj->js ready-variables))
-                 (catch js/Error e
-                   (println "mathjs evaluation error" (.-message e) e)
-                   :calc-error))))))
-
-;TODO: deal with lowercase cell references
-
-;TODO: memoize dynamically? probably not worth memoizing directly, and could take up too much memory over time
-;      https://stackoverflow.com/a/13123571/8172807
-(defn find-cycle
-  "Accepts the data and a datum, and peforms a depth-first search to find reference cycles, following back-references."
-  ([data c r] (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)
-         inbound (:inbound datum)
-         found-repeat (not (empty? (clojure.set/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."
-  [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)
-        value (:value datum)
-        formula (formula? value)
-        resolved-refs (map #(merge % (get-in data [(:col %) (:row %)])) refs)
-        evaluated-refs (map #(if (formula? (:value %)) (:display %) (:value %)) resolved-refs)
-        invalid-refs (some nil? resolved-refs)
-        dirty-refs (some :dirty resolved-refs)
-        error-refs (some #(= (:display %) :error) resolved-refs)
-        ;unevaluated-refs (some nil? evaluated-refs)
-        cycle-refs (some #(= (:display %) :cycle-error) resolved-refs)
-        disqualified? (or invalid-refs dirty-refs error-refs)]
-    (cond
-      (not formula) (assoc-in data [c r] datum)     ; if it's not a formula, then return as is (with the dirty flag removed)
-      cycle-refs (-> data                           ; if one of its references has a reference cycle, then this one is "poisoned" as well
-                     (assoc-in [c r] datum)
-                     (assoc-in [c r :display] :cycle-error))
-      ;unevaluated-refs (assoc-in data [c r :display] :insufficient-data) ; do not un-mark as "dirty", since it has not been evaluated yet
-      disqualified? (-> data                        ; some other error is present
-                        (assoc-in [c r] datum)
-                        (assoc-in [c r :display] :error))
-      (empty? refs) (-> data
-                        (assoc-in [c r] datum)
-                        (assoc-in [c r :display] (evaluate-expression (subs value 1) {})))
-      :else (let [variables (zipmap (map #(str (:col %) (:row %)) refs) evaluated-refs)
-                  evaluated-value (evaluate-expression (subs value 1) variables)
-                  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)
-; - evaluate (using forward references if necessary)
-; - add all back-references to the queue
-; - recurse
-; - TODO: consider initialization case
-; - TODO: consider multiple cells modified simultaneously
-(defn evaluate-from-cell
-  "Evaluate the final value of a cell, and recursively re-evaluate all the cells that reference it."
-  [data c r]
-  (let [cycles? (find-cycle data c r)
-        new-data (if cycles?
-                   (-> data                                           ; if there are cycles, mark :cycle-error and remove :dirty (rathan than evaluate) - still need to recurse up the tree to mark dependents with :cycle-error
-                       (update-in [c r] dissoc :dirty)
-                       (assoc-in [c r :display] :cycle-error))
-                   (gather-variables-and-evaluate-cell data c r))]    ; if there are no cycles, evaluate the cell
-    (loop [data new-data
-           queue (get-in new-data [c r :inbound])]
-      (if (empty? queue)
-        data                                                          ; if the queue is empty, we're done
-        (let [current (first queue)
-              cc (:col current)
-              cr (:row current)
-              dirty? (get-in data [cc cr :dirty])
-              re-evaluated-data (if dirty?
-                                  (gather-variables-and-evaluate-cell data cc cr)
-                                  data)
-              sufficient? (not= (get-in re-evaluated-data [cc cr :display]) :insufficient-data)
-              new-queue (if dirty?
-                          (if sufficient?
-                            (concat (rest queue) (get-in re-evaluated-data [cc cr :inbound]))   ; if all is well, then add the back-references onto the queue
-                            (concat (rest queue) (list current)))                               ; if the current cell's dependencies are not satisfied, re-add to the end of the queue
-                          (rest queue))]                                                        ; if the current cell is not marked as dirty, then it has already been processed
-          (recur re-evaluated-data new-queue))))))
-
-;TODO: does this need a cycle check?
-(defn evaluate-all
-  "Evaluates all cells marked as \"dirty\". Generally reserved for the initialization."
-  ([data]
-   (evaluate-all data (walk-get-refs data #(:dirty %3))))
-  ([data queue]
-   (if (empty? queue)
-     data
-     (let [cur (first queue)
-           cc (:col cur)
-           cr (:row cur)
-           dirty? (get-in data [cc cr :dirty])]
-       (if dirty?
-         (let [evaluated (evaluate-from-cell data (:col cur) (:row cur))
-               result (get-in evaluated [cc cr :display])]
-           (if (= result :insufficient-data)
-             (recur data (concat (rest queue) (list cur)))
-             (recur evaluated (rest queue))))
-         (recur data (rest queue)))))))
-
-

+ 69 - 0
frontend/src/cljs/microtables_frontend/utils/coordinates.cljs

@@ -0,0 +1,69 @@
+(ns microtables-frontend.utils.coordinates)
+
+(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
+  (->> 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
+  (->> data
+       vals
+       (map keys)
+       flatten
+       (apply max)))
+
+(defn- increment-letter-code [s]
+  (let [l (last s)]
+    (cond
+      (empty? s) [65]
+      (= 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]
+  (->> 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."
+  [col1 col2]
+  (cond
+    (> (.-length col1) (.-length col2)) [col2 col1]
+    (> (.-length col2) (.-length col1)) [col1 col2]
+    (= (max col1 col2) col1) [col2 col1]
+    :else [col1 col2]))
+
+(defn range->list
+  "Converts two cells (accepting four coordinates) into a list of all the cells in the range between them (inclusively)."
+  [col1 row1 col2 row2]
+  (let [[start-col end-col] (order-two-cols col1 col2)
+        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})))
+
+; 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
+(defn parse-range
+  "Converts a range in \"A1:B2\" notation to a list of col/row cells: {:col \"A\" :row 1}, etc."
+  [range-string]
+  (let [col1 (second (re-find #"\(\s*([A-Z]+)" range-string))
+        col2 (second (re-find #":\s*([A-Z]+)" range-string))
+        row1 (.parseInt js/window (second (re-find #"([0-9]+)\s*:" range-string)))
+        row2 (.parseInt js/window (second (re-find #"([0-9]+)\s*\)" range-string)))]
+    (range->list col1 row1 col2 row2)))

+ 120 - 0
frontend/src/cljs/microtables_frontend/utils/data.cljs

@@ -0,0 +1,120 @@
+(ns microtables-frontend.utils.data
+  (:require
+   ["mathjs" :as mathjs]
+   [clojure.string :as string]
+   [microtables-frontend.utils.coordinates :as coords]))
+
+(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."
+  [value]
+  (if (= (first value) "=")
+    (subs value 1)
+    nil))
+
+(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 (coords/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]
+             (string/replace expression #"\(\s*[A-Z]+[0-9]+\s*:\s*[A-Z]+[0-9]+\s*\)" range->commalist))))
+
+(def parse-variables (memoize (fn [expression]
+                                (as-> (js->clj (.parse mathjs (replace-ranges-in-expression expression))) $
+                                  (.filter $ #(true? (.-isSymbolNode %)))
+                                  (map #(.-name %) $)
+                                  (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))]
+                          {:row r :col c}))))
+
+; 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 refs as necessary"
+  [datum]
+  (let [formula (formula? (:value datum))]
+    (if formula
+      (let [vars (parse-variables formula)
+            refs (map str->rc vars)]
+        (-> datum
+            (assoc :refs refs)
+            (dissoc :error)))
+      (-> datum
+          (dissoc :refs)
+          (dissoc :display)
+          (dissoc :error)))))
+
+(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))
+
+(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]
+               (->> column
+                    (filter (fn [[r datum]] (pred c r datum)))
+                    (map (fn [[r _]] {:col c :row r}))
+                    (concat l)))
+             '()
+             data))
+
+(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."
+  ([data c r]
+   (set-dirty-flags data (list {:col c :row r})))
+  ([data queue]
+   (if (empty? queue)
+     data
+     (let [cur (first queue)
+           c (:col cur)
+           r (:row cur)
+           datum (get-in data [c r])]
+       (if (true? (:dirty datum))
+         (recur data (rest queue))
+         (let [new-data (assoc-in data [c r :dirty] true)
+               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]
+  (let [datum (get-in data [c r])
+        updated (assoc datum :value value)]
+    (-> data
+        (assoc-in [c r :value] value)
+        (set-dirty-flags c r))))

+ 3 - 106
frontend/src/cljs/microtables_frontend/views.cljs

@@ -1,112 +1,9 @@
 (ns microtables-frontend.views
   (:require
-   [re-frame.core :as re-frame]
    [microtables-frontend.subs :as subs]
-   [microtables-frontend.events :as events]
-   [microtables-frontend.utils :as utils]))
-
-
-;; TABLE COMPONENTS
-
-(defn cell [c r data]
-  (let [datum (get-in data [c r])]
-    ^{:key (str c r)} [:td
-                       [:input {:id (str c r)
-                                :class (if (= (:view datum) :highlighted) "highlighted" "")
-                                :value (if (= (get datum :view nil) :value)
-                                         (get datum :value "")
-                                         (get datum :error (get datum :display (get datum :value ""))));TODO: add "highlight" display mode (possibly just a css class)
-                                :on-change #(re-frame/dispatch [::events/edit-cell-value c r (.. % -target -value)])
-                                :on-focus #(re-frame/dispatch [::events/movement-enter-cell c r])
-                                :on-blur #(re-frame/dispatch [::events/movement-leave-cell c r])
-                                :on-keyPress #(when (= (.. % -which) 13)
-                                                (re-frame/dispatch [::events/press-enter-in-cell c r]))}]]))
-
-(defn row [r cols data]
-  ^{:key (str "row-" r)} [:tr
-                          (cons
-                           ^{:key (str "row-head-" r)} [:th (str r)]
-                           (map #(cell % r data) cols))])
-
-(defn header-row [cols]
-  ^{:key "header"} [:tr
-                    (cons
-                      ^{:key "corner"} [:th]
-                      (map (fn [c] ^{:key (str "col-head-" c)} [:th c]) cols))])
-
-(defn sheet [data]
-  [:table
-   {:id "main-table"}
-   [:tbody
-    ;TODO: figure out appropriate starting values for maxrow and maxcol (maybe keep them intentionally small)
-    ;TODO: figure out movement (maybe allow scroll overflow)
-    (let [maxrow 50;(utils/highest-row data)
-          maxcol "Z";(utils/highest-col data)
-          cols (take-while (partial not= (utils/next-letter maxcol)) utils/col-letters)]
-      (cons
-        (header-row cols)
-        (map #(row % cols data) (range 1 (inc maxrow)))))]])
-
-
-(defn extend-range
-  []
-  [:div {:class "control-group"}
-   [:div {:class "control-label"}
-    "Extend Range"]
-   [:button "-cols"]
-   [:input]
-   [:button]])
-
-(defn controls []
-  [:div
-   ;[extend-range]
-   ;extend range (number field, then shrink/left/up and expand/right/down buttons on either side)
-   ;fill range (single tap, single column/row fill? number field and button?)
-   ;move range (designate new "top-left corner" cell for range, figure out overwriting destination cells)
-   ;empty range (delete contents, re-evaluate)
-   ;delete range (figure out how to move cells over)
-   ;copy range (figure out clipboard)
-   [:div
-    {:class "control-group"}
-    #_[:a ;TODO: consider making the "About" info an overlay rather than a link
-       {:href "/about.html"
-        :target "_blank"}
-       "About"]
-    "This is a demo version only, and still in development. Nothing gets saved for the moment."
-    [:br]
-    "Try adding values (plain numbers) or formulae (ex: \"=B2 + sum(A1:A6)\") into the cells."
-    [:br]
-    "Created by "
-    [:a
-     {:href "https://betweentwocommits.com/about"
-      :target "_blank"}
-     "Brandon Wong"]]])
-
-(defn control-panel [state]
-  [:div
-   {:id "controls"}
-   [:div
-    ;TODO: link left controls with position of the whole table
-    {:id "controls-left"
-     :class (if (= state :left) "open" "")}
-    [controls]]
-   [:div
-    {:id "controls-bottom"
-     :class (if (= state :bottom) "open" "")}
-    [controls]]
-   ;←↑→↓
-   [:div
-    {:id "left-controls-button"
-     :on-click #(re-frame/dispatch [::events/set-controls-state (if (= state :left) nil :left)])}
-    (if (= state :left) "←" "→")]
-   [:div
-    {:id "bottom-controls-button"
-     :on-click #(re-frame/dispatch [::events/set-controls-state (if (= state :bottom) nil :bottom)])}
-    (if (= state :bottom) "↓" "↑")]
-   [:div
-    {:id "main-logo"
-     :title "Microtables"}
-    [:img {:src "logo.svg"}]]])
+   [microtables-frontend.views.control-panel :refer [control-panel]]
+   [microtables-frontend.views.sheet :refer [sheet]]
+   [re-frame.core :as re-frame]))
 
 (defn main-panel []
   (let [data (re-frame/subscribe [::subs/table-data])

+ 64 - 0
frontend/src/cljs/microtables_frontend/views/control_panel.cljs

@@ -0,0 +1,64 @@
+(ns microtables-frontend.views.control-panel
+  (:require
+   [microtables-frontend.events :as events]
+   [re-frame.core :as re-frame]))
+
+(defn- extend-range
+  []
+  [:div {:class "control-group"}
+   [:div {:class "control-label"}
+    "Extend Range"]
+   [:button "-cols"]
+   [:input]
+   [:button]])
+
+(defn- controls []
+  [:div
+   ;[extend-range]
+   ;extend range (number field, then shrink/left/up and expand/right/down buttons on either side)
+   ;fill range (single tap, single column/row fill? number field and button?)
+   ;move range (designate new "top-left corner" cell for range, figure out overwriting destination cells)
+   ;empty range (delete contents, re-evaluate)
+   ;delete range (figure out how to move cells over)
+   ;copy range (figure out clipboard)
+   [:div
+    {:class "control-group"}
+    #_[:a ;TODO: consider making the "About" info an overlay rather than a link
+       {:href "/about.html"
+        :target "_blank"}
+       "About"]
+    "This is a demo version only, and still in development. Nothing gets saved for the moment."
+    [:br]
+    "Try adding values (plain numbers) or formulae (ex: \"=B2 + sum(A1:A6)\") into the cells."
+    [:br]
+    "Created by "
+    [:a
+     {:href "https://betweentwocommits.com/about"
+      :target "_blank"}
+     "Brandon Wong"]]])
+
+(defn control-panel [state]
+  [:div
+   {:id "controls"}
+   [:div
+    ;TODO: link left controls with position of the whole table
+    {:id "controls-left"
+     :class (if (= state :left) "open" "")}
+    [controls]]
+   [:div
+    {:id "controls-bottom"
+     :class (if (= state :bottom) "open" "")}
+    [controls]]
+   ;←↑→↓
+   [:div
+    {:id "left-controls-button"
+     :on-click #(re-frame/dispatch [::events/set-controls-state (if (= state :left) nil :left)])}
+    (if (= state :left) "←" "→")]
+   [:div
+    {:id "bottom-controls-button"
+     :on-click #(re-frame/dispatch [::events/set-controls-state (if (= state :bottom) nil :bottom)])}
+    (if (= state :bottom) "↓" "↑")]
+   [:div
+    {:id "main-logo"
+     :title "Microtables"}
+    [:img {:src "logo.svg"}]]])

+ 44 - 0
frontend/src/cljs/microtables_frontend/views/sheet.cljs

@@ -0,0 +1,44 @@
+(ns microtables-frontend.views.sheet
+  (:require
+   [microtables-frontend.events :as events]
+   [microtables-frontend.utils.coordinates :as coords]
+   [re-frame.core :as re-frame]))
+
+(defn cell [c r data]
+  (let [datum (get-in data [c r])]
+    ^{:key (str c r)} [:td
+                       [:input {:id (str c r)
+                                :class (if (= (:view datum) :highlighted) "highlighted" "")
+                                :value (if (= (get datum :view nil) :value)
+                                         (get datum :value "")
+                                         (get datum :error (get datum :display (get datum :value ""))));TODO: add "highlight" display mode (possibly just a css class)
+                                :on-change #(re-frame/dispatch [::events/edit-cell-value c r (.. % -target -value)])
+                                :on-focus #(re-frame/dispatch [::events/movement-enter-cell c r])
+                                :on-blur #(re-frame/dispatch [::events/movement-leave-cell c r])
+                                :on-keyPress #(when (= (.. % -which) 13)
+                                                (re-frame/dispatch [::events/press-enter-in-cell c r]))}]]))
+
+(defn row [r cols data]
+  ^{:key (str "row-" r)} [:tr
+                          (cons
+                           ^{:key (str "row-head-" r)} [:th (str r)]
+                           (map #(cell % r data) cols))])
+
+(defn header-row [cols]
+  ^{:key "header"} [:tr
+                    (cons
+                     ^{:key "corner"} [:th]
+                     (map (fn [c] ^{:key (str "col-head-" c)} [:th c]) cols))])
+
+(defn sheet [data]
+  [:table
+   {:id "main-table"}
+   [:tbody
+    ;TODO: figure out appropriate starting values for maxrow and maxcol (maybe keep them intentionally small)
+    ;TODO: figure out movement (maybe allow scroll overflow)
+    (let [maxrow 50;(coords/highest-row data)
+          maxcol "Z";(coords/highest-col data)
+          cols (take-while (partial not= (coords/next-letter maxcol)) coords/col-letters)]
+      (cons
+       (header-row cols)
+       (map #(row % cols data) (range 1 (inc maxrow)))))]])