Basic Animations

All of our renderings so far have been "static" in the sense that the state, or model, values have been independent of time (though they have been dependent on other time-varying values, such as mouse position and clicks).

We will now work through an example where we will keep track of time in order to animate what we render. Our goal will be to draw circles around each mouse click that fade out gradually over time.

Clicks with Timestamps

We'll start by defining the State in our application to be a list of Clicks, where each value records the Time and position of the click.

type alias Click = (Time, (Int,Int))
type alias State = List Click

The definitions of the initial State as well as the function to update it upon each Click are both trivial.

initState = []
upstate   = (::)

To help during the development process, let's render the most recent Click to see how the State of our application changes.

view (w,h) clicks =
  let str =
    E.centered <| T.fromString <|
      case clicks of
        []   -> "No clicks yet"
        p::_ -> toString p
  in
    C.collage w h [C.toForm str]

We now need to use primitive signals to "drive" our application. In particular, we need to track time and mouse clicks. One way to define time is to sum the time deltas returned by Time.fps.

time : Signal Time
time = Signal.foldp (+) 0 (Time.fps 10)

Next, we can generate timestamps for clicks by sampling both time and Mouse.position whenever Mouse.clicks is updated.

clicks : Signal Click
clicks =
  let onClick = Signal.sampleOn Mouse.clicks in
  Signal.map2 (,) (onClick time) (onClick Mouse.position)

Because we sample both time and Mouse.position whenever the same signal (Mouse.clicks) changes, the values from each signal are "in sync."

We can now base our State updates on the clicks signal in order to render the latest one to the screen.

state : Signal State
state = Signal.foldp upstate initState clicks

main = Signal.map2 view Window.dimensions state

This pattern of attaching timestamps to time-varying values lends itself to the following generalization:

timestamp : Signal a -> Signal (Time, a)
timestamp sig = Signal.map2 (,) (Signal.sampleOn sig time) sig

clicks : Signal Click
clicks = timestamp (Signal.sampleOn Mouse.clicks Mouse.position)

Indeed, the Time library provides a timestamp function, albeit with slightly different behavior: the timestamps attached to values record system time (via Time.every) rather than application time (computed via Time.fps).

Drawing All Dots

We'll start by drawing circles for all clicks, irrespective of their timestamps. There are several factors to consider:

  • mouse coordinates (produced by Mouse.position) are specified relative to the top-left corner of the window,
  • collage (which we will use to draw the entire window) is centered in the middle of the window, and
  • move (supplied with positive arguments) translates Shapes in a collage up and to the right.

Taking these into account, we draw dots as follows:

view (w,h) clicks =
  let
    str     = E.centered <| T.fromString <|
                case clicks of
                  []   -> "No clicks yet"
                  p::_ -> toString p
    circ    = C.filled Color.darkBlue (C.circle 20)
    (fw,fh) = (toFloat w, toFloat h)
    (dx,dy) = (-fw/2, fh/2)
    dots    = List.map
                (\(_,(x,y)) -> circ |> C.move (toFloat x + dx, toFloat (-y) + dy))
                clicks
  in
    C.collage w h (dots ++ [C.toForm str])

The source code for this milestone can be found in FadingDots0.elm, and here is a demo in action.

Drawing Recent Dots

In order to display only the most recent dots, we need to track the current time in State — there's nowhere else to put it!

type alias State = (Time, List Click)

initState = (0, [])

The upstating function stores the timestamp t of a new Click as the current time (that is, the most recently updated current time).

upstate (t,xy) (_,clicks) = (t, (t,xy)::clicks)

Now that we have the now time in our State, we can draw dots for only those clicks that occurred within the last, say, two seconds. The tfade binding allows us to twiddle this parameter if desired.

view (w,h) (now,clicks) =
  ...
    tfade   = 2 * Time.second
    dots    =
      clicks |> List.concatMap (\(t,(x,y)) ->
        if (now-t) > tfade
        then []
        else [circ |> C.move (toFloat x + dx, toFloat (-y) + dy)]
      )
  ...

The source code for this milestone can be found in FadingDots1.elm, and here is a demo in action.

This is good progress, but dots can linger for more than two seconds; old dots are "discarded" only when the next click occurs, which can be arbitrarily far in the future. So, what can we do?

Merging Signals

The State of our application is defined (in main) to react only to changes in clicks, but we also want to react to changes in time so that we can promptly "discard" old clicks. The following function combines two signals into one:

Signal.merge : Signal a -> Signal a -> Signal a

We cannot merge the signals time and clicks straight away, because they produce different types of values. Not to worry, however, because we can define a new datatype to describe values produced by either signal. And our upstating function will then handle each kind of value separately.

type Update = NewTime Time | NewClick Click

upstate u (_,clicks) = case u of
  NewTime t       -> (t, clicks)
  NewClick (t,xy) -> (t, (t,xy)::clicks)

Notice how both NewTime and NewClick values carry the new time t to track in the updated State.

While we're modifying upstate, we might as well optimize our State representation so that it contains only those points that will be rendered. The pruneOld function below takes advantage of the invariant that the clicks in State are ordered from youngest to oldest. Therefore, it stops traversing clicks as soon as the first "stale" one is found.

pruneOld now clicks = case clicks of
  [] -> []
  (t,xy) :: clicks' ->
    if (now-t) > tfade
      then []
      else (t,xy) :: pruneOld now clicks'

upstate u (_,clicks) = case u of
  NewTime t       -> (t, pruneOld t clicks)
  NewClick (t,xy) -> (t, (t,xy) :: pruneOld t clicks)

We now update our main to merge the two signals, where their values are appropriately wrapped with the NewTime and NewClick data constructors.

state = Signal.foldp upstate initState
          (Signal.merge (Signal.map NewTime time)
                        (Signal.map NewClick clicks))

Animation: Fade

We have reached the home stretch. Dots will now disappear expediently, but let's have them fade out gradually and, at the same time, grow in size to achieve a nice diffusion effect.

For each mouse click, we define pct to be the percentage of time that has passed between when the dot first appears (its timestamp t) and when it completely disappears (t + tfade). We use this coefficient to compute the transparency of the circle as well its increased radius.

setAlpha : Float -> Color.Color -> Color.Color
setAlpha a c =
  let rgb = Color.toRgb c in
  Color.rgba rgb.red rgb.green rgb.blue a

view (w,h) (now,clicks) =
  let
    (fw,fh) = (toFloat w, toFloat h)
    (dx,dy) = (-fw/2, fh/2)
    color a = setAlpha a Color.darkBlue
    rad pct = 20 + 100 * pct
    circ pct = C.filled (color (1-pct)) (C.circle (rad pct))
    dots =
      clicks |> List.map (\(t,(x,y)) ->
        let pct = (now-t) / tfade in
        circ pct |> C.move (toFloat x + dx, toFloat (-y) + dy))
  in
    C.collage w h dots

Notice that view no longer needs to filter old dots, because upstate has already taken care of it. A much more pleasing factoring of work!

Now that we're drawing larger circles, the delay between frames becomes more noticeable. So, let's crank it up.

time = Signal.foldp (+) 0 (Time.fps 40)

The final version is contained in FadingDots2.elm. Check it out in action!

Different Ways of Tracking Time

Notice that there are two different ways to keep track of how long an Elm application has been running: by summing the time deltas produced by Time.fps (as we did in this example), and by comparing the current value of Time.every to its intial value. These two approaches do not always yield the same result! To investigate, try out the Lag.elm example by Stuart Kurtz.