On Github tennety / codemash2015
{:github "tennety" :twitter "tennety"}
{:github "jxa" :twitter "xandrews"}
Thank you for coming to this talk, we value your time and are excited that you chose to share it with us. I'm Chandu, I do app development at Neo, mostly using Rails and JavaScript. This is John Andrews.
Hi I'm John Andrews. I used to work with Chandu at Neo. Currently I'm an engineer at LendingHome, an online mortgage marketplace. Most of my work is done in Ruby and Javascript, however I've been creating things with Clojure for several years.
Before we jump in, we'd like to get a feel for how familiar you are with some of the things we're going to talk about. How many of you have heard of:
Well, John and I made a thing that uses all of these things and more. We had a lot of fun making it, and would like to talk about our experiences, and hopefully give you an overview of the technologies and techniques we used.
The thing we made is called Birdwave, and it's primarily a data visualization. The idea of Birdwave came to me partly because of an interest and a hobby that I have.
Yellow Warbler
Chestnut-sided Wabler
/* JavaScript */ function greet(who, event) { return "Greetings " + who + "! Welcome to " + event "!"; } greet("Good People", "Codemash");
;; Clojure (defn greet [who event] (str "Greetings " who "! Welcome to " event "!")) (greet "Good People" "Codemash")
;; Clojure (def fields [:sighting/guid ;; "GLOBAL UNIQUE IDENTIFIER" :taxon/order ;; "TAXONOMIC ORDER" nil ;; "CATEGORY" :taxon/common-name ;; "COMMON NAME" :taxon/scientific-name ;; "SCIENTIFIC NAME" :taxon/subspecies-common-name ;; "SUBSPECIES COMMON NAME" :taxon/subspecies-scientific-name ;; "SUBSPECIES SCIENTIFIC NAME" :sighting/count ;; "OBSERVATION COUNT" ;; x indicates uncounted ;; ... ])
;; Clojure (defn sighting-seq "Return a lazy sequence of lines from filename, transformed into sighting maps" [filename skip-rows nth-row] (->> (io/reader filename) (line-seq) (drop (or skip-rows 1)) (take-nth nth-row) (map sighting)))
;; Clojure (q '[:find (sum ?count) (count ?e) :where [?t :taxon/order "2881"] [?e :sighting/taxon ?t] [?e :sighting/state "Ohio"] [?e :sighting/county "Sandusky"] [?e :sighting/count ?count]] (db conn)) ;;=> [[540 108]]
John: We had v1 of the API complete with endpoints for retrieving sighting data for a given species. The data set was so big that we were just using a partial import for development. Importing the whole set meant that query became less and less performant. It was time for me to go deep with my understanding of Datomic.
Meanwhile, Chandu had some success spiking out a dynamic map so he decided to start working on the client-side. Let's take a look at his solution.
We'll quickly cover the parts relating to mapping. d3 understands 2 kinds of mapping data: GeoJSON and TopoJSON.
GeoJSON is a subset of JSON that encodes geographic data. Using shapefiles generated from GIS mapping software, it represents geometries as connected arcs. d3 can take these arcs and plot SVG paths in the browser, complete with projections.
TopoJSON is a form of GeoJSON specific to topologies, meaning that the arcs are aware of "inside" and "outside", and can eliminate redundant arcs between adjacent geometries (such as state borders). TopoJSON payloads can be upto 80% smaller than GeoJSON for the same amount of information. We used TopoJSON which contained each county's name and state name bound to the geometry to uniquely identify each county.
By normalizing the API to use the same key for each county as the SVG, we were able to associate the county-wise sighting data with the map, and as it changed month-to-month, d3 would transition the elements' colors accordingly.
Immediate wins:
ClojureScript seemed like an easy choice, given that we had a few immediate wins:
As we began using more of it, its value became more apparent. Initially, though, it took a little getting used to.
When you ask most people what ClojureScript is, this is the reaction you seem to get. In the world of JavaScript dev, it's not very clear where ClojureScript fits in. It's not a framework, or syntactic sugar on top of JavaScript. So what is it? It's a program that compiles Clojure into JavaScript.
These benefits have been spoken about in detail in other talks, and we have resources at the end which list some of them. It's really interesting work, and has come a long way since the initial thought experiment.
So, the next step was to get d3 working with ClojureScript.
// JavaScript var activeState = function() { return d3.select(".active"); }
;; ClojureScript (defn active-state [] (.select js/d3 ".active"))
// JavaScript var target = function() { return d3.event.target; }
;; ClojureScript (defn target [] (.-target (.-event js/d3))) ;; OR (defn target [] (.. js/d3 -event -target)
// JavaScript var months = d3.time.scale .domain([new Date(2012,10,15), new Date(2013,10,15)]) .range([0, 900])
;; ClojureScript (def months ( -> (js/d3.time.scale) (.domain (array (js/Date. 2012 10 15) (js/Date. 2013 10 15))) (.range (array 0 900))))
Chandu:
The fact that d3 could do DOM manipulation and event-handling let us build a prototype UI rather quickly by using d3 for both the mapping as well as the other interactive parts of the app, such as the bird and month selectors.
I felt like we were done. We had a working version, lots of material for write-ups, some good insights. For me, ClojureScript was still just a means to get d3 working, nothing more. But John had other ideas!
John:
John: What Chandu did with D3 and the interactive maps is amazing and D3 is a super powerful library for creating visualizations but when it came time to create the controls for our application we were frustrated by the same thing we're always frustrated by namely the scattering around of state and the growing complexity of dealing with events and coordination of state.
We wondered what a functional approach to UIs would look like. [Next Slide]
;; ClojureScript (ul {:className "species-list"} (li {:className "species"} (a {:href "#/taxon/1"} "Abert's Towhee")) (li {:className "species"} (a {:href "#/taxon/2"} "Acadian Flycatcher")) (li {:className "species"} (a {:href "#/taxon/3"} "Acorn Woodpecker")) ... )
;; ClojureScript (defn species-li [species] (li {:className "species"} (a {:href (path species)} (:common-name species)))) (map species-li all-species)
In imagination land the most straightforward way to render this list is to iterate over our data, building up our list as we go. Then when the list is built we replace old dom with our new representation. And our job is done.
Note that this code doesn't mutate the DOM directly. It returns a datastructure that we can then ask the browser to paint for us. This makes testing our view code easy.
;; ClojureScript (map species-li (filter (match-string "black-thr") species))
User types in some filter text. On each keystroke we match against the list.
;; ClojureScript (defn filter-list-items [filter-text species] (ul {:className "species-list"} (map species-li (filter (match-string filter-text) species))))
Om is a library which uses react and extends it with some powerful concepts. Because it uses clojurescripts persistent data structures it is able to achieve better out-of-the box performance than React itself. Let's see an example.
;; ClojureScript (defn species-list [model owner] (reify om/IRenderState (render-state [this state] (apply dom/ul #js {:className "species-list"} (om/build-all species-item (:filtered-list state) {:state (select-keys state [:highlighted :selected :select-ch])})))))
;; ClojureScript (defn species-item [model owner] (reify om/IRenderState (render-state [_ {:keys [highlighted selected select-ch]}] (dom/li #js {:className (str "taxon" (if (= model highlighted) " highlighted") (if (= model selected) " active"))} (dom/a #js {:href (taxon-path (:taxon/order model)) :onClick (fn [e] (.preventDefault e) (put! select-ch model))} (display-name model))))))
Chandu: It took me a little while to warm up to Om and React. In the beginning, it seemed like needless complexity as compared to what I was used to with say something like Angular, which would just bind to whatever state you already had.
But as I began to understand that I didn't need to care about the component's view state, only the data it needed, I began to see the simplicity of the approach. I wanted to try building a component, and the perfect opportunity presented itself.
One of the feature requests for Birdwave was to show a photo of the selected bird where possible. I needed a service that could take the bird name, search Flickr for images with a CC license and return the first one. What I didn't need was a giant Flickr library, just something simple that could build (as it turned out) 2 API requests.
;; ClojureScript (ns bird-wave.flickr (:require [cemerick.url :refer (url)])) (def api-base-url (url "https://api.flickr.com/services/rest/")) ;;#cemerick.url.URL{:protocol "https", :username nil, :password nil, :host "api.flickr.com", :port -1, :path "/services/rest", :query nil, :anchor nil} (str (assoc api-base-url :query {:api_key "my_super_flickr_key"})) ;; https://api.flickr.com/services/rest/?api_key=my_super_flickr_key
Here's a simple example of what ClojureScript allowed me to do: Query string can contain: api_key, nojsoncallback, license, search The url library allows me to take a string and create a URL instance I can associate a map of key values to this instance When I call str on it, it creates a query string with the map * It made creating the long Flickr API strings a breeze
;; ClojureScript (js/d3.json search-url (fn [data] (om/update! model :photo (first-photo data)))) ;; { "photos": { "page": 1, "pages": "223", "perpage": 1, "total": "223", ;; "photo": [ ;; { "id": "4769690133", "owner": "31064702@N05", "secret": "818406d0cd", "server": "4123", "farm": 5, "title": "Eastern Kingbird", "ispublic": 1, "isfriend": 0, "isfamily": 0, "ownername": "Dawn Huczek", "url_q": "https:\/\/farm5.staticflickr.com\/4123\/4769690133_818406d0cd_q.jpg", "height_q": "150", "width_q": "150" } ;; ] }, "stat": "ok" } (js/d3.json url (fn [data] (om/update! model :attribution (attribution data)))) ;; { "photo": { "id": "4769690133", "secret": "818406d0cd", "server": "4123", "farm": 5, "dateuploaded": "1278473496", "isfavorite": 0, "license": 4, "safety_level": 0, "rotation": 0, "originalsecret": "d7072dbb9a", "originalformat": "jpg", ;; "owner": { "nsid": "31064702@N05", "username": "Dawn Huczek", "realname": "", "location": "USA", "iconserver": "2915", "iconfarm": 3, "path_alias": "" }, ;; ... ;; "urls": { ;; "url": [ ;; { "type": "photopage", "_content": "https:\/\/www.flickr.com\/photos\/31064702@N05\/4769690133\/" } ;; ] }, "media": "photo" }, "stat": "ok" }
When a user selects a bird, we make the first request. search-url is the Flickr url we need to hit. The json we get back is shown under. The first-photo function extracts the nested photo info.
When a user clicks the "view attribution" link, we make the second request, and fetch the json under that. The attribution function pulls out the username and link to the Flickr photo page.
;; ClojureScript (dom/div #js {:id "selection-image" :className (if (seq model) "loaded" "no-photo")} (dom/img #js {:className "photo" :src (try-with-default model :url_q "/images/loading.png")}) (dom/div #js {:className "attribution"} (dom/h3 #js {:className "title"} (try-with-default model :title "No photo available")) (dom/div #js {:className "by"} … (if (seq (:attribution model)) (dom/a #js {:className "detail fetched" :href (get-in model [:attribution :url]) :target "_blank"} (get-in model [:attribution :by])) (dom/a #js {:className "detail" :href "#" :onClick #(fetch-attribution % model)} "view attribution")))))
;; ClojureScript (defn watch-screen-size [model] (let [size-handler (fn [size] #(swap! model assoc :screen-size size))] (-> js/enquire (.register "screen and (min-width: 0px) and (max-width: 520px)" (size-handler "xs")) (.register "screen and (min-width: 521px) and (max-width: 768px)" (size-handler "sm")) (.register "screen and (min-width: 769px) and (max-width: 1024px)" (size-handler "md")) (.register "screen and (min-width: 1025px)" (size-handler "lg")))))
;; ClojureScript ;; display the slider on large screens, and the select widget on small (if (contains? #{"lg" "md"} (:screen-size model)) (om/build date-slider model {:state {:time-period-ch time-period-ch}}) (om/build date-select model {:state {:time-period-ch time-period-ch}}))