Web-dev & Me: a non-story
As much as I enjoy programming, and also spending (arguably too much) time on the web, I've never really got into web development. I think part of the reason is that I associate it with JavaScript, which I just can't force myself to like, and all its associated tooling, which is monstrously complex.
So, except for tiny bits of js (typically some jQuery thing, and a couple of Rails apps done for some subjective test campaigns for web QoE a few years back) here and there, I've mostly stayed away from web applications.
A few weeks ago, however, I needed to prototype some audio analysis stuff for work, and being that I work with WebRTC, the analysis needed to be done in the browser. So I finally had a compelling reason to properly use some Clojurescript, and learn a bit about Reagent and re-frame, which I'd been meaning to look into for a while.
Long story short, I found the re-frame approach conceptually very nice, and quite simple to pick up. The prototype for work, which had started as a couple of simple reagent components was a suitable case study for a few hours over the weekend, and all was well... Except now I'd been bitten by the web bug.
Browsing around on YouTube, I came across this cool talk by Douglas Hamilton, showcasing his threeagent project, which provides a reagent-like API wrapper for three.js. So, we can use the same approach for the "web page" parts, and the 3D ones, which is super neat.
Since I'm not a gifted 3D designer, I chose to do a toy project that's almost, but not quite 2d: a clock.
Voilà, the unnecessarily-3d clock! It's built in parts: there's the face, the hands, the little knob in the middle, and the ticks on the face. Each of those components is based on a few three.js primitives provided by threeagent. So the ticks are built like this:
(defn ticks [props-in radius length pos-z]
(let [props (merge {:color "black"
:opacity 0.8
:transparent true} props-in)
positions (mapv (fn[i]
(let [angle (subs/hand-angle (* 5 i))
pos-x (* radius (js/Math.cos angle))
pos-y (* radius (js/Math.sin angle))]
[:box {:position [pos-x pos-y pos-z]
:rotation [0 0 (+ (/ js/Math.PI 2) angle)]
:width 0.2
:depth 0.1
:height length
:material props}]))
(range 12))]
`[:object
~@positions]))
The hours and minutes hands are handled by this function:
(defn hand [material-props unit-key length]
(let [props (merge {:color "black"
:opacity 0.8
:transparent true} material-props)
units (re-frame/subscribe [unit-key])
angle (subs/hand-angle @units)
[pos-x pos-y] (subs/hand-offsets length angle)]
[:object {:position [pos-x pos-y]
:rotation [0 0 angle]}
[:box {:position [0 0 -10.5]
:width 0.4
:height length
:depth 0.1
:material props}]]))
We can manage the state of the 3D bits in the same way (re-frame's app-db
) as
we manage the state of the app itself. This is pretty cool! The full source is
up on GitHub, if you're curious.
Some re-frame bits (the 30,000ft. view)
Re-frame brings order into the app, allowing us to cleanly separate the state
(which is centralized in the app-db
), the views (which are along the lines of
the code above), and events, which make things happen. Events are triggered (in
this toy example, there's only one event type, and it's triggered by a timer),
and are handled by pure functions (event-handlers), usually changing the
state of the application. If side-effects are needed (e.g., either as a result
of the event, or as in input to the event handler), they are handled separately,
via effects and coeffects, respectively. This sounds a bit overly
complicated, but keeping the event handlers pure does make it easier to reason
about and test the application. In order to update the UI, the views
subscribe to bits of the app state (or values derived from them, via
so-called reactive subscriptions). When those are updated by an event
handler, the views are re-rendered as needed.
So basically, the different concerns in the app are neatly separated, and there is a clear way in which information flows: components can trigger events, which affect the state of the application (and potentially also trigger further events), and that state is propagated, via subscriptions, to the components, which can then update themselves.
If you haven't looked into re-frame before, you can have a go at its docs, there's a lot of info to get started there.
Interactive development
A nice thing about Clojurescript is that it has good tooling for live coding. Besides the REPL, you can get live hot reloading of your app, while maintaining application state. For this, I used Thomas Heller's shadow-cljs, which handles this neatly (as well as the rest of the build process, and provides easy integration with the node.js ecosystem).
This is particularly handy when developing the views, as you can simply save the file and the app will be reloaded automatically, so your changes are immediately visible.
It's nice to have the same type of workflow as when working on Clojure, it greatly helps boost your productivity.