Elm logo
elm
examples

elm-visualization

/

examples

/

Sunburst

Edit on Ellie

Sunburst

We can use Hierarchy to visualize data that is not naturally in a tree like format. In this example the data is a list of sequences of page visits users have made on a website and looks like this:

account-account-account-account-account-account,22781
account-account-account-account-account-end,3311
account-account-account-account-account-home,906
...

We turn this into a tree-like data structure by making the parent of each item its prefix (so the parent of account-account-account-account-account-home is account-account-account-account-account), then aggregating the counts up the tree.

To make this visualization performant and sensible, the sequences are limited in the dataset to be six or less and the long tail of pages is aggregated into an other category. We distinguish truncated and complete sequences by adding the end token to a complete sequence (the end can be understood as the user leaving the website).

For serious deployment, a server could provide additional data combined with zooming in on subsets interactively.

Based on work by Kerry Roden (under Apache 2 License).

module Sunburst exposing (main)


import Browser
import Color exposing (Color)
import Csv.Decode as Csv
import Curve
import Example
import Hierarchy
import Html exposing (Html)
import Http
import List.Extra
import Path exposing (Path)
import Scale exposing (OrdinalScale)
import Scale.Color
import Set
import Shape
import Svg.Lazy
import Tree exposing (Tree)
import TypedSvg exposing (g, rect, svg, text_)
import TypedSvg.Attributes exposing (dy, fill, stroke, textAnchor, transform, viewBox)
import TypedSvg.Attributes.InPx exposing (height, rx, strokeWidth, width, x, y)
import TypedSvg.Core exposing (Svg, text)
import TypedSvg.Events
import TypedSvg.Types exposing (AnchorAlignment(..), Opacity(..), Paint(..), Transform(..), em)



-- Constants


w : Float
w =
    990


h : Float
h =
    504


radius : Float
radius =
    min w h / 2


spacing : Float
spacing =
    50


breadCrumbHeight : Float
breadCrumbHeight =
    40


breadCrumbWidth : Float
breadCrumbWidth =
    140


arrowProtrusion : Float
arrowProtrusion =
    10



-- Types


type alias Datum =
    { sequence : List String, category : String, visits : Int }


type alias Data =
    Tree Datum


type alias LayedOutDatum =
    { x : Float, y : Float, width : Float, height : Float, value : Float, node : Datum }


type alias LoadedModel =
    { layout : List LayedOutDatum
    , hovered : Maybe { sequence : List String, percentage : Float }
    , total : Float
    }


type Model
    = Loading
    | Error Http.Error
    | Loaded LoadedModel


type Msg
    = ReceivedData (Result Http.Error Data)
    | Hover (Maybe { sequence : List String, percentage : Float })



-- Data loading and processing


init : () -> ( Model, Cmd Msg )
init () =
    ( Loading
    , Http.get
        { url = "data/visit-sequences.csv"
        , expect = expectCsv ReceivedData
        }
    )


expectCsv : (Result Http.Error Data -> msg) -> Http.Expect msg
expectCsv tagger =
    Http.expectString
        (Result.andThen
            (Csv.decodeCsv Csv.NoFieldNames decoder
                >> Result.mapError (Csv.errorToString >> Http.BadBody)
            )
            >> Result.andThen
                (Tree.stratifyWithPath
                    { path = \item -> List.Extra.inits item.sequence
                    , createMissingNode = \path -> { sequence = List.Extra.last path |> Maybe.withDefault [], visits = 0 }
                    }
                    >> Result.mapError (always (Http.BadBody "Tree creation failed"))
                )
            >> Result.map
                (Tree.sumUp identity
                    (\node children ->
                        { node | visits = List.sum (List.map .visits children) }
                    )
                    >> Tree.map
                        (\d ->
                            { sequence = d.sequence
                            , visits = d.visits
                            , category = List.Extra.last d.sequence |> Maybe.withDefault "end"
                            }
                        )
                    >> Tree.sortWith (\_ a b -> compare (Tree.label b).visits (Tree.label a).visits)
                )
            >> tagger
        )


decoder : Csv.Decoder { sequence : List String, visits : Int }
decoder =
    Csv.into
        (\sequence count ->
            { sequence = sequence, visits = count }
        )
        |> Csv.pipeline (Csv.map (String.split "-") (Csv.column 0 Csv.string))
        |> Csv.pipeline (Csv.column 1 Csv.int)


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case ( msg, model ) of
        ( ReceivedData (Ok rawData), _ ) ->
            ( Loaded
                { layout =
                    rawData
                        |> Hierarchy.partition [ Hierarchy.size (2 * pi) (radius * radius) ] (.visits >> toFloat)
                        |> Tree.toList
                        |> List.tail
                        |> Maybe.withDefault []
                        |> List.filter (\d -> d.width > 0.001)
                , total = toFloat (Tree.label rawData).visits
                , hovered = Nothing
                }
            , Cmd.none
            )

        ( ReceivedData (Err e), _ ) ->
            ( Error e, Cmd.none )

        ( Hover hover, Loaded mod ) ->
            ( Loaded { mod | hovered = hover }, Cmd.none )

        _ ->
            ( model, Cmd.none )



-- Visualization


colorScale : OrdinalScale String Color
colorScale =
    Scale.ordinal (Color.rgb 0.5 0.5 0.5 :: Scale.Color.tableau10) [ "end", "home", "product", "search", "account", "other" ]


view : Model -> Html Msg
view model =
    case model of
        Loaded data ->
            svg [ viewBox 0 0 w h ]
                [ sunburst data
                , breadcrumbs data
                ]

        Loading ->
            Example.loading []

        Error e ->
            Example.error Nothing e


arrow : Path
arrow =
    [ Curve.linearClosed [ ( 0, 0 ), ( breadCrumbWidth, 0 ), ( breadCrumbWidth, breadCrumbHeight ), ( breadCrumbWidth / 2, breadCrumbHeight + arrowProtrusion ), ( 0, breadCrumbHeight ) ] ]


breadcrumbs : LoadedModel -> Svg msg
breadcrumbs model =
    case model.hovered of
        Just { sequence, percentage } ->
            sequence
                |> List.indexedMap
                    (\i el ->
                        g [ transform [ Translate (radius * 2 + spacing) (toFloat i * breadCrumbHeight) ] ]
                            [ if el == "end" then
                                rect
                                    [ width breadCrumbWidth
                                    , height (breadCrumbHeight + arrowProtrusion)
                                    , fill (Paint (Scale.convert colorScale el |> Maybe.withDefault Color.black))
                                    , rx 3
                                    , stroke (Paint Color.white)
                                    , strokeWidth 3
                                    ]
                                    []

                              else
                                Path.element arrow
                                    [ fill (Paint (Scale.convert colorScale el |> Maybe.withDefault Color.black))
                                    , stroke (Paint Color.white)
                                    , strokeWidth 3
                                    ]
                            , text_
                                [ x (breadCrumbWidth / 2)
                                , y breadCrumbHeight
                                , textAnchor AnchorMiddle
                                , fill (Paint Color.white)
                                , dy (em -0.3)
                                , TypedSvg.Attributes.InPx.fontSize 14
                                , TypedSvg.Attributes.fontFamily [ "sans-serif" ]
                                ]
                                [ text el ]
                            ]
                    )
                |> List.append
                    [ text_
                        [ x (radius * 2 + spacing + breadCrumbWidth / 2)
                        , y (toFloat (List.length sequence + 1) * breadCrumbHeight)
                        , textAnchor AnchorMiddle
                        , dy (em -0.6)
                        , TypedSvg.Attributes.InPx.fontSize 14
                        , TypedSvg.Attributes.fontFamily [ "sans-serif" ]
                        ]
                        [ text (format percentage) ]
                    ]
                |> List.reverse
                |> g []

        Nothing ->
            text ""


sunburst : LoadedModel -> Svg Msg
sunburst model =
    let
        hovered =
            case model.hovered of
                Just { sequence } ->
                    List.Extra.inits sequence
                        |> Set.fromList

                Nothing ->
                    Set.empty

        opacity seq =
            TypedSvg.Attributes.fillOpacity
                (Opacity
                    (if
                        case model.hovered of
                            Just _ ->
                                Set.member seq hovered

                            Nothing ->
                                True
                     then
                        0.8

                     else
                        0.3
                    )
                )
    in
    g [ transform [ Translate radius radius ] ]
        [ g []
            (model.layout
                |> List.map
                    (\item ->
                        Path.element (arc item)
                            [ opacity item.node.sequence
                            , fill (Paint (Scale.convert colorScale item.node.category |> Maybe.withDefault Color.black))
                            ]
                    )
            )
        , Svg.Lazy.lazy2 mouseInteractionArcs model.layout model.total
        , case model.hovered of
            Just { percentage, sequence } ->
                g [ textAnchor AnchorMiddle, TypedSvg.Attributes.fontFamily [ "sans-serif" ], fill (Paint (Color.rgb 0.5 0.5 0.5)) ]
                    [ text_ [ TypedSvg.Attributes.InPx.fontSize 28, y -8 ] [ text (format percentage) ]
                    , text_ [ TypedSvg.Attributes.InPx.fontSize 10, y 10 ]
                        [ text
                            (if List.Extra.last sequence == Just "end" then
                                "of visits complete this sequence"

                             else
                                "of visits begin with this sequence"
                            )
                        ]
                    ]

            Nothing ->
                text ""
        ]


mouseInteractionArcs : List LayedOutDatum -> Float -> Svg Msg
mouseInteractionArcs segments total =
    g [ TypedSvg.Attributes.pointerEvents "all", TypedSvg.Events.onMouseLeave (Hover Nothing) ]
        (segments
            |> List.map
                (\item ->
                    Path.element (mouseArc item)
                        [ fill PaintNone
                        , TypedSvg.Events.onMouseEnter
                            (Hover
                                (Just
                                    { sequence = item.node.sequence
                                    , percentage = 100 * item.value / total
                                    }
                                )
                            )
                        ]
                )
        )


arc : LayedOutDatum -> Path
arc s =
    Shape.arc
        { innerRadius = sqrt s.y
        , outerRadius = sqrt (s.y + s.height) - 1
        , cornerRadius = 0
        , startAngle = s.x
        , endAngle = s.x + s.width
        , padAngle = 1 / radius
        , padRadius = radius
        }


mouseArc : LayedOutDatum -> Path
mouseArc s =
    Shape.arc
        { innerRadius = sqrt s.y
        , outerRadius = radius
        , cornerRadius = 0
        , startAngle = s.x
        , endAngle = s.x + s.width
        , padAngle = 0
        , padRadius = 0
        }


format : Float -> String
format f =
    String.left 5 (String.fromFloat f) ++ "%"


main : Program () Model Msg
main =
    Browser.element
        { init = init
        , view = view
        , update = update
        , subscriptions = always Sub.none
        }