Creating a Choropleth Map with Clojurescript

World Statistics App.

Introduction

Technology Overview

Backend — Creating the Data

(defmethod ig/init-key :backend/data [_ {:keys [data-files topojson-file]}]
(init/get-data data-files topojson-file))
(defn get-data [data-files topojson-file]
(log/debug "ENTER get-data")
(let [topojson-country-codes (read-topojson-country-codes topojson-file)
ids (into {} (map (juxt :acr :id) topojson-country-codes))
data-points (get-data-points data-files ids)
{:keys [countries series years]} (get-metadata data-points)]
{:raw-points data-points
:points (remove-non-country-values data-points non-country-codes)
:countries countries
:series series
:years years
:country-codes (country-codes-to-names countries)
:series-codes (series-codes-to-names series)
:non-country-codes non-country-codes
:country-ids ids
:topojson-country-codes topojson-country-codes
}))
(count (:raw-points (user/data)))
=> 153152
(count (:points (user/data)))
=> 129824
(first (transduce (ws-filter/filter-by :country_code :FIN) conj (:points (ws-world/get-world-data (user/env) :SH.MED.BEDS.ZS))))
=>
{:country_name "Finland",
:country_code :FIN,
:series-name "Hospital beds (per 1,000 people)",
:series-code :SH.MED.BEDS.ZS,
:year 2002,
:value 7.4,
:country-id 246}

Backend — Using Clojure REPL to Experiment with the Data

(set! *print-length* 100)
=> 100 ; So that we don't accidentally list 150.000 data points in repl...
;; Require some namespaces.
(require '[worldstat.backend.data.world :as ws-world])
=> nil
(require '[worldstat.common.data.filter :as ws-filter])
=> nil
(require '[kixi.stats.core :as kixi])
=> nil
;; Check how many points there are...
(count (:raw-points (user/data)))
=> 153152
(count (:points (user/data)))
=> 129824
;; What keys do we have in our data?
(keys (user/data))
=>
(:country-codes
:raw-points
:series
:non-country-codes
:country-ids
:points
:series-codes
:countries
:topojson-country-codes
:years)
;; Let's sort countries...
(sort-by :country_name (:countries (user/data)))
=>
({:country_name "Afghanistan", :country_code :AFG, :country-id 4}
{:country_name "Albania", :country_code :ALB, :country-id 8}
{:country_name "Algeria", :country_code :DZA, :country-id 12}
{:country_name "American Samoa", :country_code :ASM, :country-id 16}
...)
;; Get hospital beds, filter data points belonging to Finland, and get first data point.
(first (transduce (ws-filter/filter-by :country_code :FIN) conj (:points (ws-world/get-world-data (user/env) :SH.MED.BEDS.ZS))))
=>
{:country_name "Finland",
:country_code :FIN,
:series-name "Hospital beds (per 1,000 people)",
:series-code :SH.MED.BEDS.ZS,
:year 2002,
:value 7.4,
:country-id 246}
;; Let's check some basic statistics, mean and standard deviation for certain data.
(transduce (map :value) kixi/mean (:points (ws-world/get-world-data (user/env) :SH.MED.BEDS.ZS)))
=> 3.668379451410944
(transduce (map :value) kixi/standard-deviation (:points (ws-world/get-world-data (user/env) :SH.MED.BEDS.ZS)))
=> 2.601440577329428

Backend — API

["/data" {:swagger {:tags ["data"]}}
["/metric/:metric" {:get {:summary "metric"
:parameters {:path [:map [:metric string?]]}
:responses {200 {:description "data"}}
:handler (fn [req]
(let [metric (get-in req [:parameters :path :metric])]
(-> (world/get-world-data env (keyword metric))
r/ok)))}}]
["/countries" {:get {:summary "countries"
:responses {200 {:description "data"}}
:handler (fn [req]
(-> (world/get-countries env)
r/ok))}}]
["/years" {:get {:summary "years"
:responses {200 {:description "data"}}
:handler (fn [req]
(-> (world/get-years env)
r/ok))}}]
["/metric-names" {:get {:summary "metric-names"
:responses {200 {:description "data"}}
:handler (fn [req]
(-> (world/get-metric-names env)
r/ok))}}]]]])
API Swagger documentation.

Frontend — Reagent and Re-frame

Clojurescript frontend tools.

CSS — Bulma

[:div.row
[:div.columns
[:div.column.is-9
[oz/vega-lite (ws-data/world-schema points selected-year selected-metric-name min max) (ws-util/vega-debug)]]
[:div.column.auto
(if country-stats
[:div.rows
[:div.row
[:p.is-size-3 selected-country-name]]
[:div.row
[:p.is-size-5 "Min: " (ws-util/one-decimal (:min country-stats))]
[:p.is-size-5 "Max: " (ws-util/one-decimal (:max country-stats))]
[:p.is-size-5 "Mean: " (ws-util/one-decimal (:mean country-stats))]
[:p.is-size-5 "Std.dev: " (ws-util/one-decimal (:standard-deviation country-stats))]]
[:div.row
[oz/vega-lite (ws-data/country-year-diagram points selected-metric-name selected-country-code min max) (ws-util/vega-debug)]]]
[:p.is-size-4 "Click a country!"])]]]
[:div.row
[:p "(Missing data for country is shown with color gray)"]]
[:div.row
[:p "Read more about this app in my blog "
[:a {:href "https://www.karimarttila.fi/clojure/2021/01/19/world-statistics-exercise.html"} "www.karimarttila.fi"]]]
(defn slider [id initial step min max type]
(let [slider-value (r/atom initial)]
[:div.slider-content
[:input.slider.is-fullwidth {:id id :step step :min min :max max :value @slider-value :type type
:on-change (fn [event]
(.preventDefault event)
(let [new-value (.-value (.getElementById js/document id))]
(reset! slider-value new-value)
(re-frame/dispatch [::ws-state/select-year (js/parseInt new-value)])))}]]))

Frontend — Creating the Map

(defn world-schema [points year metric-name min max]
{:schema "https://vega.github.io/schema/vega/v5.json"
:autosize "none"
:width 900
:height 500
:scales [{:name "color",
:type "quantize",
...
:marks [{:type "shape"
:from {:data "graticule"}
:encode {:update {:strokeWidth {:value 1}
...
{:type "shape"
:from {:data "world"}
:encode {:enter {:tooltip {:signal "datum.value === null ? {'country': datum.country_name, 'value': 'Missing data'} : {'country': datum.country_name, 'value': format(datum.value, '.2f')}"}
:href {:field "url" :type "nominal"}}
;; If missing data show as grey.
:update {:fill [{:test "datum.value === null" :value "gray"}
{:scale "color" "field" "value"}]}
:hover {:fill {:value "red"}}}
:transform [{:type "geoshape"
...
:data [{:name "countries"
:values (transduce (ws-filter/filter-by-year year) conj points)}
{:name "world"
:url "data/world-110m.json"
:format {:type "topojson" :feature "countries"}
:transform [{:type "lookup"
:from "countries"
:key "country-id"
:fields ["id"]
:values ["value" "country_name" "country_code"]}
{:type "formula"
:from "countries"
:expr "'#/country/' + datum.country_code" :as "url"}]}
{:name "graticule"
:transform [{:type "graticule"}]}]})
{selected-metric-code :code selected-metric-name :name} @(re-frame/subscribe [::ws-state/current-metric])
selected-year @(re-frame/subscribe [::ws-state/current-year])
{selected-country-code :code selected-country-name :name} @(re-frame/subscribe [::ws-state/current-country])
{:keys [points min max mean standard-deviation]} @(re-frame/subscribe [::ws-state/world-data selected-metric-code])
metric-names @(re-frame/subscribe [::ws-state/metric-names])
_ (if-not points (re-frame/dispatch [::ws-state/get-world-data selected-metric-code]))
country-stats (if selected-country-code (ws-data/get-stats points selected-country-code))]
...
[:div.column.is-9
[oz/vega-lite (ws-data/world-schema points selected-year selected-metric-name min max) (ws-util/vega-debug)]]

Using Vega Editor

Going to Vega Editor.
Vega Editor.

Frontend — Navigation etc.

(def routes-dev
["/"
[""
{:name ::ws-state/home
:view home-page
:link-text "Home"
:controllers
[{:start (fn [& params] (ws-log/clog (str "Entering home page, params: " params)))
:stop (fn [& params] (ws-log/clog (str "Leaving home page, params: " params)))}]}]
["country/:country-code"
{:name ::ws-state/country
:parameters {:path {:country-code string?}}
:view home-page
:link-text "country"
:controllers
[{:start (fn [& params] (ws-log/clog (str "Entering country, params: " params)))
:stop (fn [& params] (ws-log/clog (str "Leaving country, params: " params)))}]}]])
(re-frame/reg-event-db
::navigated
(fn [db [_ new-match]]
(let [old-match (:current-route db)
new-path (:path new-match)
country-code (get-in new-match [:parameters :path :country-code])
country-name (get-country-name country-code)
controllers (rfc/apply-controllers (:controllers old-match) new-match)]
(ws-log/clog (str "::state/navigated, new-path: " new-path))
(ws-log/clog (str "::state/navigated, country-code: " country-code))
(cond-> (assoc db :current-route (assoc new-match :controllers controllers))
country-code (assoc :current-country {:code country-code :name country-name})))))

Docker Container and Heroku Deployment

Conclusions

I’m a Software architect and developer. Currently implementing systems on AWS / GCP / Azure / Docker / Kubernetes using Java, Python, Go and Clojure.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store