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.
We'll start by defining the State
in our application to be a list of Click
s, 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
).
We'll start by drawing circles for all clicks, irrespective of their timestamps. There are several factors to consider:
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, andmove
(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.
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?
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))
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!
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.