D3 - Part 2 Intro

This is part 2 of our introduction to d3. In the first part, we looked at bar charts based on divs. In that post, we also looked at the join operator (hardcoded version) briefly. In this post, we will see how we can make a bar chart out of svg elements like rects.

But first, let's see the non hard coded version of the join operator in action.

Example-1: Join operator to emphasize new data

Example-1: Data

(defonce example-1-data
  (r/atom
   {:elements-to-display 3
    :country-data
    (let
     [headers (first sd/csvdata)
      csvdata (rest sd/csvdata)]
      (->> csvdata
           (map (partial zipmap (map keyword headers)))
           (map (fn [d] (update-in d [:pop] cljs.reader/read-string)))
           (map (fn [d] (update-in d [:cluster] cljs.reader/read-string)))
           (map (fn [d] (update-in d [:life_expect] cljs.reader/read-string)))
           (map (fn [d] (update-in d [:fertility] cljs.reader/read-string)))
           vec))}))

:country-data represents various countries key statistics over the years and looks like this

({:year "1955", :country "Afghanistan", :cluster 0, :pop 8891209, :life_expect 30.332, :fertility 7.7} 
{:year "1960", :country "Af ghanistan", :cluster 0, :pop 9829450, :life_expect 31.997, :fertility 7.7} 
{:year "1965", :country "Afghanistan", :cluster 0, :pop 10997885, :life_expect 34.02, :fertility 7.7} ...)

Example-1: Layout

Like with examples from part 1, for our UI we integrate d3 components with reagent hiccups to get user affordances.

(defn example-1 []
  [:div
   [d3-component example-1-data "#enter-update-exit"]
   [:br]
   [:div.buttons
    [:button.button.is-dark
     {:on-click
      #(swap! example-1-data update-in  [:elements-to-display] inc)} "inc"]
    [:button.button.is-dark
     {:on-click
      #(swap! example-1-data update-in  [:elements-to-display] dec)} "dec"]]])

Example-1: Component

(defn d3-component [example-data root-selection]
  (let [div-id (keyword (str "ol" root-selection))]
    (r/create-class
     {:reagent-render       (fn []
                              (.log js/console "in render" (:elements-to-display @example-data))
                              [:div.container
                               [div-id]])
      :component-did-mount  (fn []
                              (d3-logic))
      :component-did-update (fn []
                              (d3-logic))})))

Example-1: Logic

(defn d3-logic []
  (let
   [ol (-> (d3/select "#enter-update-exit"))
    {:keys [country-data elements-to-display]} @example-1-data
    display-data (take elements-to-display country-data)]
    (.log js/console "in update" elements-to-display)
    (-> ol
        (.selectAll "li")
        (.data (clj->js display-data))
        (.join
         (fn [enter] (-> enter
                         (.append "li")
                         (.style "color" "green")))
         (fn [update] (-> update
                          (.style "color" "red")))
               ;(fn [exit] (-> exit
               ;  (.remove)
               ;    )
               ;)
)
        (.text (fn [d] (str (.-country d) "<->" (int-comma (.-pop d))))))))


So far we had seen d3 charts made out of divs. Here we see it made out of ol and li elements.

We can now see that the new data enters in green. This is because we have specified 2 functions as arguments to join. These functions describe how we want the enter and update segments (technically enter and update are d3 selects too) look and behave. This is the basis of emphasizing data as it enters, updates or exits the chart frame. The function to specify exit is commented out. If so, the default specification is to remove the segments as they exit.


Example-2: Vertical orient the bar graph through svg

divs are simple and work great for horizontal bar charts. However, they have a limitation when it comes to orienting the bar chart vertically. In this example, we will see how we may construct a vertically oriented bar chart with elements made from svg and rects

Example-2: Data and layout

(defonce example-2-data
  (r/atom
   {:bar-data
    [{:key 1, :value 420}
     {:key 2, :value 48}
     {:key 3, :value 66}
     {:key 4, :value 68}
     {:key 5, :value 62}]}))


(defn example-2 []
  [:div
   [d3-simple-example example-2-data "#d3-example-2"]
   [:br]
   [:div.buttons
    [:button.button.is-dark
     {:on-click
      #(swap! example-2-data
              (fn [d]
                (update-in d [:bar-data]
                           (fn [s]
                             (map
                              (fn [d] (assoc-in d [:value]
                                                (inc (rand-int 100)))) s)))))} "rand-int++"]
    [:button.button.is-dark
     {:on-click
      #(swap! example-2-data
              (fn [d]
                (update-in d [:bar-data]
                           (fn [s]
                             (map
                              (fn [d] (update-in d [:value]
                                                 inc)) s)))))} "inc"]
    [:button.button.is-dark
     {:on-click
      #(swap! example-2-data
              (fn [d]
                (update-in d [:bar-data]
                           (fn [s]
                             (map
                              (fn [d] (update-in d [:value]
                                                 dec)) s)))))} "dec"]]])

Example-2: Component

(defn d3-simple-example [example-data root-selection]
  (let [div-id (keyword (str "svg" root-selection))]
    (r/create-class
     {:reagent-render
      (fn [] (let [v (:bar-data @example-data)] [div-id]))
      :component-did-mount (fn [] (d3-vertical-bars example-data root-selection))
      :component-did-update (fn [] (d3-vertical-bars example-data root-selection))})))

Example-2: Logic

(defn d3-vertical-bars [example-data root-selection]
  (let [outer-svg (-> (d3/select root-selection))
        {:keys [bar-data]} @example-data
        display-data bar-data
        height 400
        width (-
               (-> js/document
                   (.getElementById "example-2")
                   (.-clientWidth))
               50)
        x-scale (-> d3
                    .scaleBand
                    (.domain (clj->js (map :key display-data)))
                    (.range #js [0 width]))

        y-scale (-> d3
                    .scaleLinear
                    (.domain
                     #js [0 (apply max (map :value display-data))])
                    (.range #js [height 0]))
        color-scale (-> d3
                        (.scaleOrdinal (-> d3 (.-schemeTableau10)))
                        (.domain (clj->js (map :key display-data))))]
    (.log js/console "in vertical logic")
    (-> outer-svg
        (.attr "width" width)
        (.attr "height" height))
    (-> outer-svg
        (.selectAll "rect")
        (.data (clj->js display-data))
        (.join "rect")
        (.transition)
        (.duration 2000)
        ;.attr('x', d => xScale(d.key))
        (.attr "x" (fn [d] (x-scale (.-key d))))
        ;.attr('y', d => yScale(d.value))
        (.attr "y" (fn [d] (y-scale (.-value d))))
        ;.attr('width', xScale.bandwidth())
        (.attr "width" (-> x-scale (.bandwidth)))
        ;.attr('height', d => height - yScale(d.value))
        (.attr "height" (fn [d] (- height (y-scale (.-value d)))))
        ;.style('fill', d => colorScale(d.key))
        (.style "fill" (fn [d] (color-scale (.-key d))))
        ;.style('stroke', 'white')
        (.style "stroke" "white"))

    (-> outer-svg
        (.selectAll "text")
        (.data (clj->js display-data))
        (.join "text")

        ;.attr('x', d => xScale(d.key))
        (.attr "x" (fn [d] (x-scale (.-key d))))
        ;.attr('y', d => yScale(d.value))
        (.attr "y" (fn [d] (y-scale (.-value d))))
        (.attr "dx" 30)
        (.attr "dy" "1em")
        (.attr "fill" "white")
        (.style "font-size" "small")
        (.style "text-anchor" "middle")
        (.text (fn [d] (.-value d))))))


We can see in the above example, how we manipulate x-scale and y-scale to change the orientation of the graph to vertical. This would not have been possible had we continued to build the bar chart just using divs. Note there are 2 data joins in the above code. 1 for rect and 1 for text. We will see if we can collaapse these to 1 join in the following example.


Example-3: svg grouping

When we draw things like flow charts, we often group multiple elements to make 1 composite compound. Manipulating these groups of elements is made simpler once we have grouping. Transforms like scaling, translating, rotating etc can be done to the whole group as a single unit. The same is true in svg programming. The g element makes it easy to specify transforms and data, by applying them to the group as a whole.

Example-3: Data and layout

(defonce example-3-data
  (r/atom
   {:bar-data
    [{:key 1, :value 420}
     {:key 2, :value 48}
     {:key 3, :value 66}
     {:key 4, :value 68}
     {:key 5, :value 62}]}))

(defn example-3 []
  [:div
   [d3-g-example example-3-data "#d3-example-3"]
   [:br]
   [:div.buttons
    [:button.button.is-dark
     {:on-click
      #(swap! example-3-data
              (fn [d]
                (update-in d [:bar-data]
                           (fn [s]
                             (map
                              (fn [d] (assoc-in d [:value]
                                                (inc (rand-int 100)))) s)))))} "rand-int++"]
    [:button.button.is-dark
     {:on-click
      #(swap! example-3-data
              (fn [d]
                (update-in d [:bar-data]
                           (fn [s]
                             (map
                              (fn [d] (update-in d [:value]
                                                 inc)) s)))))} "inc"]
    [:button.button.is-dark
     {:on-click
      #(swap! example-3-data
              (fn [d]
                (update-in d [:bar-data]
                           (fn [s]
                             (map
                              (fn [d] (update-in d [:value]
                                                 dec)) s)))))} "dec"]]])

Example-3: Component

(defn d3-g-example [example-data root-selection]
  (let [div-id (keyword (str "svg" root-selection))]
    (r/create-class
     {:reagent-render
      (fn [] (let [v (:bar-data @example-data)] [div-id]))
      :component-did-mount (fn [] (d3-vertical-bars-g example-data root-selection))
      :component-did-update (fn [] (d3-vertical-bars-g example-data root-selection))})))

Example-3: Logic

(defn d3-vertical-bars-g [example-data root-selection]
  (let [outer-svg (-> (d3/select root-selection))
        {:keys [bar-data]} @example-data
        display-data bar-data
        height 400
        width (-
               (-> js/document
                   (.getElementById "example-2")
                   (.-clientWidth))
               50)
        y-scale (-> d3
                    .scaleBand
                    (.domain (clj->js (map :key display-data)))
                    (.range #js [height 0]))

        x-scale (-> d3
                    .scaleLinear
                    (.domain
                     #js [0 (apply max (map :value display-data))])
                    (.range #js [0 width]))
        color-scale (-> d3
                        (.scaleOrdinal (-> d3 (.-schemeTableau10)))
                        (.domain (clj->js (map :key display-data))))
        g (-> outer-svg
              (.selectAll "g")
              (.data (clj->js display-data))
              (.join "g")
              ;these 2 wont work here
             ;(.transition)
              ;(.duration 2000)
              (.attr "transform" (fn [d] (str "translate(0," (y-scale (.-key d)) ")"))))
        old-rects (-> outer-svg (.selectAll "rect"))
        old-texts (-> outer-svg (.selectAll "text"))]
    (.log js/console "in vertical logic")
    ;clear existing stuff
    (-> old-rects .remove)
    (-> old-texts .remove)
    ;clear existing stuff
    (-> outer-svg
        (.attr "width" width)
        (.attr "height" height))

    (-> g
     ;Notice, we no longer need to bind data to <rect>, or specify y positions.
        (.append "rect")
        (.attr "width" (fn [d] (x-scale (.-value d))))
        (.attr "height" (-> y-scale (.bandwidth)))
        (.style "fill" (fn [d] (color-scale (.-key d))))
        (.style "stroke" "white"))

    (-> g
        (.append "text")
        (.attr "x" (fn [d] (x-scale (.-value d))))
        (.attr "dx" -20)
        (.attr "dy" "1.2em")
        (.attr "fill" "white")
        (.style "font-size" "small")
        (.style "text-anchor" "middle")
        (.text (fn [d] (.-value d))))))


Instead of having 2 join operators for rect and text, here we have only 1 join operator. But this time it is applied to the group. Each group has the rect and text associated with that data point as members. This makes it convenient to think about multiple svg elements representing 1 data point naturally.

When we group like in the example above, instead of individually specifying x and y coordinates for elements, we use the translate transform to place the groups first. Once the groups are placed, we append the elements inside the respective groups. The elements will automatically be appended at the 0, 0 of each group which has its own sand boxed coordinate system.

The only disadvantage with groups like the one shown in the example above, is that we lose the ability to specify transitions. Also, we have to ensure we remove existing data i.e old-rects and old-texts, before we start manipulating new data.


Example 4: svg grouping with axis

Axes are important elements of charts. d3 has good support for these. Lets see how we can add 2 axes to our charts.

Example-4: Data and layout

(defonce example-4-data
  (r/atom
   {:bar-data
    [{:key 1, :value 420}
     {:key 2, :value 48}
     {:key 3, :value 66}
     {:key 4, :value 68}
     {:key 5, :value 62}]}))

(defn example-4 []
  [:div
   [d3-g-axis-example example-4-data "#d3-example-4"]
   [:br]
   [:div.buttons
    [:button.button.is-dark
     {:on-click
      #(swap! example-4-data
              (fn [d]
                (update-in d [:bar-data]
                           (fn [s]
                             (map
                              (fn [d] (assoc-in d [:value]
                                                (inc (rand-int 100)))) s)))))} "rand-int++"]
    [:button.button.is-dark
     {:on-click
      #(swap! example-4-data
              (fn [d]
                (update-in d [:bar-data]
                           (fn [s]
                             (map
                              (fn [d] (update-in d [:value]
                                                 inc)) s)))))} "inc"]
    [:button.button.is-dark
     {:on-click
      #(swap! example-4-data
              (fn [d]
                (update-in d [:bar-data]
                           (fn [s]
                             (map
                              (fn [d] (update-in d [:value]
                                                 dec)) s)))))} "dec"]]])

Example-4: Component

(defn d3-g-axis-example [example-data root-selection]
  (let [div-id (keyword (str "svg" root-selection))]
    (r/create-class
     {:reagent-render
      (fn [] (let [v (:bar-data @example-data)] [div-id]))
      :component-did-mount (fn [] (d3-vertical-bars-g-axis example-data root-selection))
      :component-did-update (fn [] (d3-vertical-bars-g-axis example-data root-selection))})))

Example-4: Logic

(defn d3-vertical-bars-g-axis [example-data root-selection]
  (let [outer-svg (-> (d3/select root-selection))
        {:keys [bar-data]} @example-data
        display-data bar-data
        height 400
        width (-
               (-> js/document
                   (.getElementById "example-2")
                   (.-clientWidth))
               50)
        y-scale (-> d3
                    .scaleBand
                    (.domain (clj->js (map :key display-data)))
                    (.range #js [height 0]))

        x-scale (-> d3
                    .scaleLinear
                    (.domain
                     #js [0 (apply max (map :value display-data))])
                    (.range #js [0 width]))
        color-scale (-> d3
                        (.scaleOrdinal (-> d3 (.-schemeTableau10)))
                        (.domain (clj->js (map :key display-data))))
        old-rects (-> outer-svg (.selectAll "rect") .remove)
        old-texts (-> outer-svg (.selectAll "text") .remove)
        margin {:top 10 :right 10 :bottom 20 :left 20}
        x-margin (-> x-scale .copy (.range #js [(:left margin) (- width (:right margin))]))
        y-margin (-> y-scale .copy (.range #js [(- height (:bottom margin)) (:top margin)]))
        g (-> outer-svg
              (.selectAll "g")
              (.data (clj->js display-data))
              (.join "g")
              (.attr "transform" (fn [d] (str "translate(" (:left margin) "," (y-margin (.-key d)) ")"))))]
    (.log js/console "in vertical logic")
    ;clear existing stuff
    ;(-> old-rects .remove)
    ;(-> old-texts .remove)
    ;clear existing stuff
    (-> outer-svg
        (.attr "width" width)
        (.attr "height" height)
        ;A dotted border helps us see the added margin whitespace
        (.style "border" "1px dotted #999")) (-> g
                                                 (.append "rect")
                                                 (.attr "width" (fn [d] (- (x-margin (.-value d)) (x-margin 0))))
                                                 (.attr "height" (-> y-margin (.bandwidth)))
                                                 (.style "fill" (fn [d] (color-scale (.-key d))))
                                                 (.style "stroke" "white"))

    (-> g
        (.append "text")
        (.attr "x" (fn [d] (- (x-scale (.-value d)) (x-margin 0))))
        (.attr "dx" -20)
        (.attr "dy" "1.2em")
        (.attr "fill" "white")
        (.style "font-size" "small")
        (.style "text-anchor" "middle")
        (.text (fn [d] (.-value d))))

    (-> outer-svg
        (.append "g")
        (.attr "transform" (str "translate(0," (- height (:bottom margin)) ")"))
        (.call (-> d3 (.axisBottom x-margin))))

    (-> outer-svg
        (.append "g")
        (.attr "transform" (str "translate(" (:left margin) ",0)"))
        (.call (-> d3 (.axisLeft y-margin))));axis
))


The first thing we do in this example, is to make space available for margins and then add the axis in the space made available.

x-margin and y-margin are basically slight modifications of x-scale and y-scale respectively, to account for margin offsets.

We can now use these modified scales in our group data join.

Finally we call the d3 objects axisBottom and axisLeft to auto render the axis with ticks periods. The reason it is able to do so is because they are passed the same modified scales, we used to position the groups. This also demonstrates how re-usable and convenient the scale objects are as they can help link various chart elements.


This concludes our Part 2 and our introduction to d3.