Skip to content

Commit

Permalink
Adds Brush module (#124)
Browse files Browse the repository at this point in the history
  • Loading branch information
gampleman authored May 10, 2021
1 parent c6e0bfd commit a6c58a8
Show file tree
Hide file tree
Showing 10 changed files with 1,898 additions and 117 deletions.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ However, there are other packages that you will likely need to produce a visuali
- [elm-community/typed-svg](https://package.elm-lang.org/packages/elm-community/typed-svg/latest) for rendering
- [folkertdev/one-true-path-experiment](https://package.elm-lang.org/packages/folkertdev/one-true-path-experiment/latest) for the `Path` type

You can use [this Ellie](https://ellie-app.com/8592jsvBL2ka1) to run the examples, since it has all the dependencies already installed into it.
You can use [this Ellie](https://ellie-app.com/d6JBvDHFhRBa1) to run the examples, since it has all the dependencies already installed into it.

## What's included?

Expand Down Expand Up @@ -64,6 +64,10 @@ Build complex animations using Interpolation.

Compute histograms of data.

### [Brush](http://package.elm-lang.org/packages/gampleman/elm-visualization/latest/Brush)

Interactively select subregions of a dataset.

### [Zoom](http://package.elm-lang.org/packages/gampleman/elm-visualization/latest/Zoom)

Build pan and zoom user interactions.
Expand Down
2 changes: 1 addition & 1 deletion docs.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion elm.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "gampleman/elm-visualization",
"summary": "A data visualization package for Elm",
"license": "MIT",
"version": "2.2.0",
"version": "2.3.0",
"exposed-modules": [
"Scale",
"Scale.Color",
Expand All @@ -14,6 +14,7 @@
"Histogram",
"Interpolation",
"Transition",
"Brush",
"Zoom"
],
"elm-version": "0.19.0 <= v < 0.20.0",
Expand Down
250 changes: 250 additions & 0 deletions examples/DetailChart.elm
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
module DetailChart exposing (..)

{-| Implements the Focus + Context pattern, where the user can zoom in on a place of interest while still seeing the overview.
@category Advanced
-}

import Axis
import Browser
import Browser.Events
import Brush exposing (Brush, OnBrush, OneDimensional)
import Circle3d exposing (at_)
import Color
import DateFormat
import Events
import Json.Decode as D exposing (Decoder)
import LTTB
import Path
import Random
import Scale exposing (ContinuousScale)
import Shape
import Statistics
import Svg.Events exposing (custom)
import Time
import TypedSvg exposing (g, rect, svg)
import TypedSvg.Attributes exposing (cursor, fill, fillOpacity, opacity, pointerEvents, shapeRendering, stroke, transform, viewBox)
import TypedSvg.Attributes.InPx exposing (height, width, x, y)
import TypedSvg.Core exposing (Attribute, Svg)
import TypedSvg.Types exposing (Cursor(..), Opacity(..), Paint(..), ShapeRendering(..), Transform(..))
import Zoom exposing (OnZoom, Zoom)


w : Float
w =
900


h : Float
h =
450


padding : Float
padding =
30


overviewChartHeight : Float
overviewChartHeight =
130


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


subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
[ Zoom.subscriptions model.zoom ZoomMsg
, Brush.subscriptions model.brush BrushMsg
]


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
ZoomMsg zoomMsg ->
let
zoom =
Zoom.update zoomMsg model.zoom

{ scale, translate } =
Zoom.asRecord zoom

bounds =
( (padding - translate.x) / scale
, (w - padding - translate.x) / scale
)
in
( { model
| zoom = zoom
, brush = Brush.setSelection1d Brush.instantly bounds model.brush
}
, Cmd.none
)

BrushMsg brushMsg ->
let
brush =
Brush.update brushMsg model.brush

( brushStart, brushEnd ) =
Brush.selection1d brush
|> Maybe.withDefault ( padding, width - padding )

delta =
brushEnd - brushStart

width =
w - 2 * padding

zoomTransform =
{ scale = width / delta
, translate = { x = padding - brushStart * (width / delta), y = 0 }
}
in
( { model
| brush = brush
, zoom = Zoom.setTransform Zoom.instantly zoomTransform model.zoom
}
, Cmd.none
)


detailChart : ( Time.Posix, Time.Posix ) -> Model -> Svg Msg
detailChart ( min, max ) model =
let
yScale =
model.data
|> List.map Tuple.second
|> List.maximum
|> Maybe.withDefault 0
|> Tuple.pair 0
|> Scale.linear ( h - padding - overviewChartHeight, padding )

actualXScale =
Scale.time Time.utc ( padding, w - padding ) ( min, max )

line =
model.data
|> List.filter
(\( x, _ ) ->
Time.posixToMillis x + 35000 >= Time.posixToMillis min && Time.posixToMillis x - 35000 <= Time.posixToMillis max
)
|> downsample
|> List.map (\( x, y ) -> Just ( Scale.convert actualXScale x, Scale.convert yScale y ))
|> Shape.line Shape.monotoneInXCurve
in
g []
[ Path.element line [ stroke <| Paint Color.blue, fill PaintNone ]
, g [ transform [ Translate padding 0 ] ] [ Axis.left [] yScale ]
, g [ transform [ Translate 0 (h - padding - overviewChartHeight) ] ] [ Axis.bottom [] actualXScale ]
, rect (width w :: height h :: opacity (Opacity 0) :: Zoom.events model.zoom ZoomMsg) []
]


overviewChart : ContinuousScale Time.Posix -> Model -> Svg Msg
overviewChart xScale model =
let
yScale =
model.overviewData
|> List.map Tuple.second
|> List.maximum
|> Maybe.withDefault 0
|> Tuple.pair 0
|> Scale.linear ( h - padding, h - overviewChartHeight )

line =
List.map
(\( x, y ) ->
Just ( Scale.convert xScale x, Scale.convert yScale y )
)
model.overviewData
|> Shape.line Shape.monotoneInXCurve
in
g []
[ Path.element line [ stroke <| Paint Color.blue, fill PaintNone ]
, g [ transform [ Translate padding 0 ] ] [ Axis.left [ Axis.tickCount 4 ] yScale ]
, g [ transform [ Translate 0 (h - padding) ] ] [ Axis.bottom [] xScale ]
, Brush.view [] BrushMsg model.brush
]


view : Model -> Svg Msg
view model =
let
bounds =
Brush.selection1d model.brush |> Maybe.withDefault ( padding, w - padding )

xScale =
model.data
|> List.map Tuple.first
|> Statistics.extentBy Time.posixToMillis
|> Maybe.withDefault ( Time.millisToPosix 0, Time.millisToPosix 0 )
|> Scale.time Time.utc ( padding, w - padding )
in
svg [ viewBox 0 0 w h, width w, height h ]
[ detailChart (Tuple.mapBoth (Scale.invert xScale) (Scale.invert xScale) bounds) model
, overviewChart xScale model
]


type alias Model =
{ data : List ( Time.Posix, Float )
, overviewData : List ( Time.Posix, Float )
, zoom : Zoom
, brush : Brush OneDimensional
}


type Msg
= ZoomMsg OnZoom
| BrushMsg OnBrush


init : () -> ( Model, Cmd Msg )
init () =
let
data =
Random.step timeseriesGenerator (Random.initialSeed 4354554) |> Tuple.first
in
( { data = data
, overviewData = downsample data
, zoom =
Zoom.init { width = w - padding, height = h }
|> Zoom.scaleExtent 1 22
|> Zoom.translateExtent ( ( 0, 0 ), ( w, h - overviewChartHeight ) )
, brush = Brush.initX { top = h - overviewChartHeight, bottom = h - padding, left = padding, right = w - padding }
}
, Cmd.none
)


downsample : List ( Time.Posix, Float ) -> List ( Time.Posix, Float )
downsample data =
LTTB.downsample
{ data = data
, threshold = floor w
, xGetter = Tuple.first >> Time.posixToMillis >> toFloat
, yGetter = Tuple.second
}


timeseriesGenerator : Random.Generator (List ( Time.Posix, Float ))
timeseriesGenerator =
Random.map
(List.indexedMap
(\idx noise ->
( Time.millisToPosix (idx * 30000 + 4534545), abs (sin (toFloat idx / 40) * 100 + noise + (toFloat idx / 60)) )
)
)
(Random.list 10000 (Random.float -40 40))
Loading

0 comments on commit a6c58a8

Please sign in to comment.