Array with drawn values

There are plenty of libraries for visualizing data such as chart.js and highcharts.
I'd like to have a version where I can see the contents of an array as an xy-plot that is also drawable i.e. I want to change the values of the array by drawing with my mouse. I'll use a canvas that corresponds to values in an array.
I also decided to experiment with getting the generated javascript as small as possible using the rules and conventions described in ClojureScript UIs in 500 Bytes by Chris McCormick (see also cljs-ultralight).
(ns canvas-array.main
(:require [ultralight.core :as u]))
To make this simple let's start with default width and height of 300 and 150:
<canvas id="canvasv1" width="300" height="150">
</canvas>
Let's continue with drawing the contents of an array. We'll assume the contents of the array form a continuous path where the array index corresponds to an x-coordinate and the value corresponds to a y-coordinate.
(defn draw-array! [canvas arr]
(let [ctx (.getContext canvas "2d")
width (.-clientWidth canvas)
height (.-clientHeight canvas)
length (.-length arr)]
(.clearRect ctx 0 0 width height)
(.beginPath ctx)
(set! (.-lineWidth ctx) "1")
(set! (.-fillStyle ctx) "black")
(.moveTo ctx 0 (aget arr 0))
(dotimes [n (dec length)]
(let [x (inc n)]
(.lineTo ctx x (aget arr x))))
(.stroke ctx)))
Let's try it out with an array of a length of 300 (corresponding to the canvas width) and we'll fill it up with some values so we don't have to worry about nonexistent values breaking the logic above:
(defn setup-canvas-1 [el arr]
(.fill arr 100 0)
(aset arr 0 90)
(aset arr 299 110)
(draw-array! el arr))
;; defined as :init-fn in shadow-cljs.edn
(defn main! []
(let [c1 (u/$ "#canvasv1")
arr1 (js/Array 300)]
(setup-canvas-1 c1 arr1)))
And the end result (with some additional styling for the canvas) looks like this:
No interactivity so far, so let's make this drawable. Let's see what we can do with simple mouse events. We're using another canvas with an id of "canvasv2":
(defn setup-mouse-events-1 [state canvas arr]
(.addEventListener
canvas "mousedown"
(fn [e]
(let [x (.-offsetX e)
y (.-offsetY e)]
;; keep track whether we should keep drawing on mousemove event:
(aset state "drawing?" true)
(aset arr x y)
(draw-array canvas arr)))
(.addEventListener
js/window "mouseup"
(fn [e]
(aset state "drawing?" false)))
(.addEventListener
canvas "mousemove"
(fn [e]
(when (aget state "drawing?")
(let [x (.-offsetX e)
y (.-offsetY e)]
(aset arr x y)
(draw-array canvas arr))))))
(defn setup-canvas-2 [canvas arr]
(.fill arr 100 0)
(aset arr 0 110)
(aset arr 299 90)
(draw-array canvas arr)
(setup-mouse-events-1 #js {} canvas arr))
The resulting canvas (try drawing on it with your mouse):
Since mousemove event isn't triggered on each neighboring pixel, the resulting values inside the array will be non-continuous and looks something like this:

We'll need to improve the mousemove event handler. Instead of setting the individual xy-values from the event, we should rather draw a line from previous xy-coordinate to the next one. Luckily we already have a state variable we can use:
(defn line-to-array [arr x1 y1 x2 y2]
(if (coercive-= x1 x2)
(aset arr x1 y2)
(let [ydelta (- y2 y1)
xdelta (- x2 x1)
min-x (min x1 x2)
max-x (max x1 x2)
x-range (inc (- max-x min-x))
coeff (/ ydelta xdelta)
yval (fn [x]
(+ y1 (* coeff (- x x1))))]
(dotimes [i x-range]
(let [k (+ i min-x)]
(aset arr k (yval k)))))))
(defn setup-mouse-events-2 [^js state canvas arr]
(.addEventListener
canvas "mousedown"
(fn [e]
(let [x (.-offsetX e)
y (.-offsetY e)]
(aset state "drawing?" true)
(aset state "prev-x" x)
(aset state "prev-y" y)
(aset arr x y)
(draw-array canvas arr))))
(.addEventListener
js/window "mouseup"
(fn [_]
(aset state "drawing?" false)))
(.addEventListener
canvas "mousemove"
(fn [e]
(when (aget state "drawing?")
(let [x (.-offsetX e)
y (.-offsetY e)
prev-x (aget state "prev-x")
prev-y (aget state "prev-y")]
(aset state "prev-x" x)
(aset state "prev-y" y)
(line-to-array arr prev-x prev-y x y)
(draw-array canvas arr))))))
(defn setup-canvas-3[canvas arr]
(.fill arr 100 0)
(aset arr 0 110)
(aset arr 299 90)
(draw-array canvas arr)
(setup-mouse-events-2 #js {} canvas arr))
The improved version:
Now this seems more reasonable. We can still notice some unwanted behavior; while holding down the mouse button, move the cursor out of the canvas and then move it back - we get a line drawn from the coordinates we exited the canvas to the coordinates we re-entered the canvas.
We can fix this by resetting the prev-x and prev-y on mouseout, then draw the line only if those values are defined:
(.addEventListener
canvas "mouseout"
(fn [_]
(aset state "prev-x" nil)
(aset state "prev-y" nil)))
;; and update the earlier mousemove handler from earlier
(line-to-array arr prev-x prev-y x y)
;; to
(when (and prev-x prev-y)
(line-to-array arr prev-x prev-y x y))
The result:
Since mouse events might be triggered multiple times per frame, we don't have to redraw the canvas on each mousemove. Let's create another function that uses requestAnimationFrame to ensure draw-array is called only once per frame.
(defn draw-on-next-frame [state k canvas arr]
(let [draw? (aget state k)]
(when-not draw?
(aset state k true)
(js/requestAnimationFrame
(fn [_]
(aset state k nil)
(draw-array canvas arr))))))
The function takes a state variable (a js-hashmap) and a key where to keep track whether we're redrawing the canvas on the next frame:
;;replace (draw-canvas canvas arr) with:
(draw-on-next-frame state "canvasv4-draw" canvas arr)
The visible functionality won't differ from the previous example, so no need to replicate it here.
That's it for now. What happens when there are non-numerical values inside the array? What if the canvas width differs from the array length? Let's leave these considerations for later.
FYI: the resulting js-file is smaller than 4 kB.