Elm logo
elm
examples

elm-visualization

/

examples

/

Box Plot

Edit on Ellie

Box Plot

This module shows how to build a box-and-whisker plot.

The trick is first computing all the statistical pieces that a box plot requires, then we use scales to transform these into coordinates and finally draw the various SVG pieces.

module BoxPlot exposing (main)


import Axis
import Color
import List.Extra
import LowLevel.Command exposing (lineTo, moveTo)
import Path
import Scale exposing (BandScale, ContinuousScale, defaultBandConfig)
import Statistics exposing (quantile)
import SubPath
import TypedSvg exposing (circle, defs, g, line, linearGradient, rect, stop, svg)
import TypedSvg.Attributes exposing (class, fill, id, offset, opacity, stopColor, stroke, transform, viewBox)
import TypedSvg.Attributes.InPx exposing (cx, cy, height, r, strokeWidth, width, x, x1, x2, y, y1, y2)
import TypedSvg.Core exposing (Svg)
import TypedSvg.Types exposing (AnchorAlignment(..), Length(..), Opacity(..), Paint(..), Transform(..))


w : Float
w =
    900


h : Float
h =
    450


padding : Float
padding =
    30


xScale : List (List Float) -> BandScale Int
xScale model =
    List.range 0 (List.length model - 1)
        |> Scale.band { defaultBandConfig | paddingInner = 0.1, paddingOuter = 0.2 } ( 0, w - 2 * padding )


yScale : ContinuousScale Float
yScale =
    Scale.linear ( h - 2 * padding, 0 ) ( -12, 12 )


type alias BoxStats =
    { firstQuartile : Float, median : Float, thirdQuartile : Float, max : Float, min : Float, outliers : List Float }


computeStatistics : List Float -> BoxStats
computeStatistics yList =
    let
        sortedYList =
            List.sort yList

        reverseSortedYList =
            List.reverse sortedYList

        -- Gather stats
        firstQuartile =
            Statistics.quantile 0.25 sortedYList
                |> Maybe.withDefault 0

        thirdQuartile =
            Maybe.withDefault 0 <| quantile 0.75 sortedYList

        interQuartileRange =
            thirdQuartile - firstQuartile

        whiskerTopMax =
            thirdQuartile + 1.5 * interQuartileRange

        whiskerBottomMin =
            firstQuartile - (1.5 * interQuartileRange)
    in
    { firstQuartile = firstQuartile
    , median = Statistics.quantile 0.5 sortedYList |> Maybe.withDefault 0
    , thirdQuartile = thirdQuartile
    , max = computeWhiskerMax (<=) (Maybe.withDefault 0 (List.head reverseSortedYList)) whiskerTopMax reverseSortedYList
    , min = computeWhiskerMax (>=) (Maybe.withDefault 0 (List.head sortedYList)) whiskerBottomMin sortedYList
    , outliers =
        List.Extra.takeWhile (\y -> y < whiskerBottomMin) sortedYList
            ++ List.Extra.takeWhile (\y -> y > whiskerTopMax) reverseSortedYList
    }


{-| The whiskers should be either 1.5 the interquantile range or the highest datum, whichever is lowest.
-}
computeWhiskerMax : (number -> number -> Bool) -> number -> number -> List number -> number
computeWhiskerMax cmp dataMax whiskerMax sortedData =
    if cmp whiskerMax dataMax then
        Maybe.withDefault 0 <| List.head <| List.Extra.dropWhile (cmp whiskerMax) sortedData

    else
        dataMax


xAxis : List (List Float) -> Svg msg
xAxis model =
    Axis.bottom [] (Scale.toRenderable (\c -> c + 1 |> String.fromInt) (xScale model))


yAxis : Svg msg
yAxis =
    Axis.left [ Axis.tickCount 9 ] yScale


whisker : { max : Float, min : Float, width : Float, center : Float } -> Svg msg
whisker { max, min, width, center } =
    Path.element
        [ SubPath.with (moveTo ( center, min )) [ lineTo [ ( center, max ) ] ]
        , SubPath.with (moveTo ( center - width / 2, max )) [ lineTo [ ( center + width / 2, max ) ] ]
        ]
        [ strokeWidth 1
        , stroke <| Paint <| Color.black
        , opacity <| Opacity 0.7
        ]


column : BandScale Int -> BoxStats -> Int -> Svg msg
column scale stats index =
    let
        -- Prepare for viz
        seriesMedianY =
            stats.median
                |> Scale.convert yScale

        firstQuartileY =
            Scale.convert yScale stats.firstQuartile

        thirdQuartileY =
            Scale.convert yScale stats.thirdQuartile

        leftSide =
            Scale.convert scale index

        boxWidth =
            Scale.bandwidth scale

        center =
            leftSide + boxWidth / 2
    in
    g [ class [ "column" ] ]
        ([ whisker
            { max = Scale.convert yScale stats.max
            , min = thirdQuartileY
            , width = boxWidth / 3
            , center = center
            }
         , whisker
            { max = Scale.convert yScale stats.min
            , min = firstQuartileY
            , width = boxWidth / 3
            , center = center
            }
         , rect
            [ x leftSide
            , y thirdQuartileY
            , width boxWidth
            , height (firstQuartileY - thirdQuartileY)
            , fill <| Reference "linearGradient"
            , opacity <| Opacity 0.9
            ]
            []
         , line
            -- median line in middle of rectangle
            [ x1 leftSide
            , y1 seriesMedianY
            , x2 <| leftSide + boxWidth
            , y2 seriesMedianY
            , strokeWidth 1
            , stroke <| Paint <| Color.black
            , opacity <| Opacity 0.6
            ]
            []
         ]
            ++ List.map (Scale.convert yScale >> outlierCircle center) stats.outliers
        )


outlierCircle : Float -> Float -> Svg msg
outlierCircle x y =
    circle
        [ cx x
        , cy y
        , r 3
        , fill <| Paint <| Color.rgb255 180 20 20
        , stroke <| PaintNone
        , opacity <| Opacity 0.7
        ]
        []


yGridLine : Int -> Float -> Svg msg
yGridLine index tick =
    line
        [ x1 0
        , x2 (w - 2 * padding)
        , y1 (Scale.convert yScale tick)
        , y2 (Scale.convert yScale tick)
        , stroke <| Paint Color.black
        , strokeWidth (toFloat (modBy 2 index) * 0.25 + 0.25)
        , opacity <| Opacity 0.3
        ]
        []


gradient : Svg msg
gradient =
    linearGradient
        [ id "linearGradient"
        , TypedSvg.Attributes.x1 <| Percent 0.0
        , TypedSvg.Attributes.y1 <| Percent 0.0
        , TypedSvg.Attributes.x2 <| Percent 0.0
        , TypedSvg.Attributes.y2 <| Percent 100.0
        ]
        [ stop [ offset "0%", stopColor "#e52d27" ] []
        , stop [ offset "100%", stopColor "#b31217" ] []
        ]


view : List (List Float) -> Svg msg
view model =
    svg [ viewBox 0 0 w h ]
        [ defs [] [ gradient ]
        , g [ transform [ Translate padding (padding + 0.5) ] ] <| List.indexedMap yGridLine <| Scale.ticks yScale 9
        , g [ transform [ Translate (padding - 1) (h - padding) ] ]
            [ xAxis model ]
        , g [ transform [ Translate (padding - 1) padding ] ]
            [ yAxis ]
        , g [ transform [ Translate padding padding ], class [ "series" ] ] <|
            List.indexedMap (\index datum -> column (xScale model) (computeStatistics datum) index) model
        ]


main : Svg msg
main =
    view dataSeries


dataSeries : List (List Float)
dataSeries =
    [ [ 2.0, 5.9, -3.6, 1.0, 5.4, -1.6, -9.2, -8.6, 5.0, 3.1, 9.7, -3.1, 7.0, 1.2, -4.1, -2.1, 3.6, 1.2, 1.5, -3.8, 1.0, 3.9, 8.3, -1.1, 1.7, -9.3, -7.6, -1.3, 2.5, 7.3, 9.0, 1.1, -1.3, -7.6, 1.3, 4.2, -4.5, 1.0, 7.7, -1.6 ]
    , [ 2.1, 2.7, 2.4, 2.3, 2.5, 1.5, 1.9, 2.1, 1.9, 2.0, 1.8, 1.1, 1.9, 2.4, 1.2, 2.5, 1.5, 2.3, 1.9, 3.0, 2.1, 1.8, 2.5, 1.1, 2.6, 2.2, 1.8, 2.2, 2.3, 2.2, 1.8, 2.3, 1.7, 1.8, 2.1, 2.1, 2.2, 1.4, 2.7, 1.7 ]
    , [ 4.9, 3.4, 3.1, 2.1, 2.7, -6.1, 3.5, 2.4, -1.2, -3.4, 3.1, 4.0, 4.8, 2.2, 9.4, 4.8, 9.5, 3.3, 1.7, -1.6, 1.9, 1.7, 2.5, 3.0, 2.4, 4.5, 1.2, 4.3, 1.2, 4.6, -1.4, -6.9, 2.0, 1.6, 3.2, 4.2, 8.5, 1.0, 3.2, 3.3 ]
    , [ 2.2, 7.8, -1.4, -1.5, -1.2, 9.5, 3.4, -1.6, 1.1, 1.4, 6.2, -8.8, -7.0, 3.6, -6.7, -4.9, -1.1, 6.7, -1.0, 1.4, -1.0, -5.7, -1.0, 7.6, -7.5, 1.2, -8.6, -7.3, -1.6, -7.2, -9.5, -5.3, 2.4, -5.5, -6.5, -1.5, 3.6, 3.1, -1.3, 5.7 ]
    , [ 9.1, 4.8, -1.1, 6.6, 6.5, 6.5, 1.2, 5.0, 9.9, 1.7, 7.3, 1.5, 1.4, 3.8, 9.4, 7.4, 8.4, 1.4, 1.4, 1.2, 1.0, 1.3, 2.0, 1.1, 1.4, 1.6, 1.4, 1.1, 3.7, 9.5, 6.7, 1.1, 7.5, 8.3, 6.8, 6.2, 2.3, 1.0, 9.3, 1.1 ]
    ]