A while back we added a basic chart to our dashboard using elm-chart. Since then, the elm-visualization package has become extremely capable, and I'd like to switch to using that. One of the great things it has going for it is the fact that's based on D3, so the general data structures and functions have been vetted for years. We'll also fetch data from our API so that we're capable of eventually providing dynamic data to our chart. Let's get started.

Project

I'm starting out with the time-tracker project, tagged with before_episode_028.4. I've added an API endpoint that provides some data for us to use, though our endpoint is presently returning static data. The nice part here is that all we need to do once this is working is update our backend to produce data based on the database in order to chart data from our actual data model.

Setup and Basic Example

First, we'll want to add the elm-visualization package to our project:

elm-package install -y gampleman/elm-visualization

Next, we'll open up the Charts view and replace the static chart with an example from the new package. I'll just paste this in and we can talk through it:

module View.Charts exposing (..)

import Visualization.Scale as Scale exposing (ContinuousScale, ContinuousTimeScale)
import Visualization.Axis as Axis
import Visualization.List as List
import Visualization.Shape as Shape
import Svg exposing (..)
import Svg.Attributes exposing (..)
import Date exposing (Date)
import String


padding : Float
padding =
    30


-- Our data will be a list of dates and floats.  The x axis is our dates and the
-- y axis is the value we're graphing for that date.
type alias LineChartData =
    List ( Date, Float )


-- Our `activity` function accepts a 2-tuple representing the width and height
-- of the graph, as well as the data that we wish to graph.  It produces an SVG.
activity : ( Float, Float ) -> LineChartData -> Svg msg
activity ( w, h ) lineChartData =
    let
        -- Our x scale will be a time scale, continuous from the beginning date
        -- to the end date.  We'll ultimately want to grab these from the API's
        -- data once we're not just returning the static data from the API.
        -- The two arguments are the domain and the range for this axis.
        -- As a quick primer, the domain is the interval that data values take
        -- on this axis, and the range is the interval that is being reflected
        -- on the screen, which the data is mapped to.
        xScale : ContinuousTimeScale
        xScale =
            Scale.time ( Date.fromTime 1448928000000, Date.fromTime 1456790400000 ) ( 0, w - 2 * padding )

        -- Our y scale is just a continuous scale, mapping our input values to
        -- the values that we will reflect in the height of our lines.
        yScale : ContinuousScale
        yScale =
            Scale.linear ( 0, 5 ) ( h - 2 * padding, 0 )

        -- Here we're defining the default options for our Axis.  You can customize
        -- the following properties of an axis:
        --
        -- orientation, ticks, tickFormat, tickCount, tickSize, and tickPadding
        opts : Axis.Options a
        opts =
            Axis.defaultOptions

        -- This function produces our x axis using a function from
        -- elm-visualization's `Axis` module.  We orient it to the bottom, and add a
        -- tick for each value in our input data set
        xAxis : Svg msg
        xAxis =
            Axis.axis { opts | orientation = Axis.Bottom, tickCount = List.length lineChartData } xScale

        -- Our y axis is placed on the left and has explicitly 5 ticks.
        yAxis : Svg msg
        yAxis =
            Axis.axis { opts | orientation = Axis.Left, tickCount = 5 } yScale

        -- Given a date and a float, this function produces two pairs
        -- representing a top line and a bottom line for an area graph.
        areaGenerator : ( Date, Float ) -> Maybe ( ( Float, Float ), ( Float, Float ) )
        areaGenerator ( x, y ) =
            Just ( ( Scale.convert xScale x, fst (Scale.rangeExtent yScale) ), ( Scale.convert xScale x, Scale.convert yScale y ) )

        -- Given a date and a float, this function produces a single pair
        -- representing a coordinate for those values using our scales.
        lineGenerator : ( Date, Float ) -> Maybe ( Float, Float )
        lineGenerator ( x, y ) =
            Just ( Scale.convert xScale x, Scale.convert yScale y )

        -- This produces the SVG string representing the filled area portion of
        -- our graph.
        area : String
        area =
            List.map areaGenerator lineChartData
                |> Shape.area Shape.monotoneInXCurve

        -- This produces the SVG string representing the line for our graph.
        line : String
        line =
            List.map lineGenerator lineChartData
                |> Shape.line Shape.monotoneInXCurve
    in
        -- Given the above, we can produce an SVG image that has this data on it
        -- fairly easily.

        -- We create an SVG using our specified width and height.
        svg [ width (toString w ++ "px"), height (toString h ++ "px") ]
            -- We place our X axis in a group by itself
            [ g [ transform ("translate(" ++ toString (padding - 1) ++ ", " ++ toString (h - padding) ++ ")") ]
                [ xAxis ]
            -- We place our Y axis in a group by itself
            , g [ transform ("translate(" ++ toString (padding - 1) ++ ", " ++ toString padding ++ ")") ]
                [ yAxis ]
            -- We produce a group containing the area and the line that we
            -- calculated above.
            , g [ transform ("translate(" ++ toString padding ++ ", " ++ toString padding ++ ")"), class "series" ]
                [ Svg.path [ d area, stroke "none", strokeWidth "3px", fill "rgba(255, 0, 0, 0.54)" ] []
                , Svg.path [ d line, stroke "red", strokeWidth "3px", fill "none" ] []
                ]
            ]

So this was just a mild modification of one of the examples provided by the library, but I tried to step through it and explain it all. We still need to pass the data into it when we use it, so we'll open up the Home view and pass in some static data initially:

module View.Home exposing (view)
-- ...
import Date exposing (Date)
-- ...
viewActivitySummary : Model -> Html a
viewActivitySummary model =
    let
        -- Here we're producing the static data for our graph.
        staticData =
            [ ( Date.fromTime 1448928000000, 1 )
            , ( Date.fromTime 1451606400000, 1 )
            , ( Date.fromTime 1454284800000, 2 )
            , ( Date.fromTime 1456790400000, 3 )
            ]
    in
        [ Card.text []
            -- And pass it into the function we just created
            [ View.Charts.activity ( 800, 200 ) staticData
            ]
        ]
            |> viewGridCard

With that, we can check it out in the browser and see how it looks.

Fetching Data from the Backend

Now, we'd like to fetch the data for this from our backend API and store it in our model. Let's just begin by moving this data into a field on our model. We'll update the Home view to fetch from a new model field we'll call chartData:

module View.Home exposing (view)
-- ...
viewActivitySummary : Model -> Html a
viewActivitySummary model =
    [ Card.text []
        [ View.Charts.activity ( 800, 200 ) model.chartData
        ]
    ]
        |> viewGridCard

And we'll add this field and move that static data into the initialModel:

module Model exposing (Model, UsersModel, ProjectsModel, OrganizationsModel, initialModel)
-- ...
import Date exposing (Date)
-- ...
type alias Model =
    { -- ...
    , chartData : List ( Date, Float )
    }
-- ...
initialModel : Maybe Route.Location -> Model
initialModel location =
    { -- ...
    , chartData =
        [ ( Date.fromTime 1448928000000, 1 )
        , ( Date.fromTime 1451606400000, 1 )
        , ( Date.fromTime 1454284800000, 2 )
        , ( Date.fromTime 1456790400000, 3 )
        ]
    }

OK, with that we should be able to refresh and have everything continue working. Next, we'll add a function to our API to fetch data in this shape from the backend:

module API
    exposing
        ( -- ...
        , fetchChartData
        )
-- ...
import Date exposing (Date)
-- ...
fetchChartData : Model -> (List ( Date, Float ) -> Msg) -> Cmd Msg
fetchChartData model msg =
    get model "/charts" Decoders.chartDataDecoder (always NoOp) msg

Now we need to add a chartDataDecoder to our Decoders module and expose it:

module Decoders
    exposing
        ( -- ...
        , chartDataDecoder
        )
-- ...
import Date exposing (Date)
-- ...
chartDataDecoder : JD.Decoder (List ( Date, Float ))
chartDataDecoder =
    JD.list <|
        JD.tuple2 (,)
            (JD.map Date.fromTime JD.float)
            JD.float

Next, we'll need to add a Msg that can be used to forward data we receive and decode from our API into our update function:

module Msg exposing (Msg(..), UserMsg(..), ProjectMsg(..), OrganizationMsg(..), LoginMsg(..))
-- ...
import Date exposing (Date)
-- ...
type Msg
    = -- ...
    | GotChartData (List ( Date, Float ))
module Update exposing (update)
-- ...
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        -- ...
        GotChartData chartData ->
            { model | chartData = chartData } ! []

With that, we've wired up the means of fetching and storing the chart data, but we've never kicked off the request to the backend. We can add this to the cmdsForModelRoute function in the Util module:

module Util exposing (cmdsForModelRoute, MaterialTableHeader, onEnter)
-- ...
cmdsForModelRoute : Model -> List (Cmd Msg)
cmdsForModelRoute model =
    case model.route of
        -- ...
        Just Home ->
            [ API.fetchChartData model GotChartData ]

Alright, now if we visit the homepage, we should see our chart start out with our initial data and then update when the server sends us new data.

Summary

It works! In today's episode, we included elm-visualization in our application and used it to show an area chart for a data set fetched from our backend. We aren't actually fetching particularly interesting data from the backend in this case, but if we did it would dutifully show up here. I hope you've enjoyed it. See you soon!

Resources

Josh Adams

I've been building web-based software for businesses for over 18 years. In the last four years I realized that functional programming was in fact amazing, and have been pretty eager since then to help people build software better.

  1. Comments for Better Charts with elm-visualization

You must login to comment

You May Also Like