Drawing Rays in Elm (Beginners)

In my last post, I showed how you can generate a simple mouse-chasing effect with Elm. That was an accidental side-effect for my true purpose: To demonstrate how to draw triangles in Elm. This post will continue along that journey, demonstrating how to generate rays from a fixed point out to the current mouse location using the standard Elm Architecture.

I won’t be introducing any new concepts here; I’ll simply be layering on a little bit more complexity on top of what we’ve learned in the previous posts.

Objective

Our objective with this demo is pretty simple: Just to click the mouse to start a ray at a fixed point, which will draw a line out to the current mouse position (and click again to clear the view).

Click here to see the demo in action and don’t forget to start clicking around!

This sounds simpler than the previous post’s example of drawing lines that chase the mouse, but the code is actually a bit more complex – although it sets us up nicely for the next post in this series.

Once again, I’ll include prolific inline comments, so that the code can (hopefully) speak for itself. If you’re too impatient to read the whole post, the code comments may be enough to understand all of the code.

Set Up and Run

(First, make sure you have elm installed.)

I’ve published the code in this post to a github repo.

You can run the example as-is with the following commands (I’ve included the command output for reference):

> git clone https://github.com/stormont/elm-mouse-rays.git
Cloning into 'elm-mouse-rays'...
remote: Counting objects: 13, done.
remote: Compressing objects: 100% (9/9), done.
remote: Total 13 (delta 2), reused 13 (delta 2), pack-reused 0
Unpacking objects: 100% (13/13), done.
Checking connectivity... done.

> cd elm-mouse-rays

> git checkout 2015.10.30
Note: checking out '2015.10.30'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b new_branch_name

HEAD is now at 215a3dc... Updated README with simple directions.

> elm-make src/Main.elm --output=demo.html
Some new packages are needed. Here is the upgrade plan.

  Install:
    elm-lang/core 2.1.0

Do you approve of this plan? (y/n) y
Downloading elm-lang/core
Packages configured successfully!
Success! Compiled 32 modules.
Successfully generated demo.html

Setting Up our Imports

We’ll be importing a few modules from the core Elm libraries:

import Color exposing (..)
import Graphics.Collage exposing (..)
import Graphics.Element exposing (..)
import Mouse
import Window

We’ll also set a simple configuration for the maximum length of points to draw along our path. This is limited to two for this example, because we want to draw rays, but you can play with this number for additional effects.

maxPathLength = 2  -- Define our path to be limited to two points.

Defining our Model

Like in the last post, we’ll use a few type aliases to more clearly represent the purposes of our types. But again, the definitions on both sides of the assignment are synonymous, so we can use either definition.

type alias MouseX = Int
type alias MouseY = Int
type alias Point = (MouseX, MouseY)            -- Mouse position (x,y).

For our Model this time around, we’re going to create a State Machine:

type Model = NoPath                            -- No current path.
           | ActivePath   (List Point, Point)  -- An active path, plus an actively moving position.

You can think of this as an enumeration of values, but with the ability to carry state across different definitions:

  • NoPath – This is our base definition, which indicates that we haven’t stated a “path” yet.
  • ActivePath (List Point, Point) – This definition indicates that we currently have a path in progress, including previously fixed points along with an active point indicating the current mouse position.

Updating our Model

Our update function is a bit more complicated this time around. We’ll have a different helper function for each state in our state machine.

To start things off, we’ll define a simple helper function to extract the current path from the state machine:

getModelPoints : Model -> List Point
getModelPoints model =
  case model of
    NoPath             -> []  -- If no path is active, return an empty list.
    ActivePath (ps, _) -> ps  -- Otherwise, retrieve the defined points.

If we currently have no working path, we simply return an empty list of Points using <string[]. In the other case, we return the previously defined path.

Lastly, we come to the real meat-and-potatoes that is our update function!

update : (Point,Point) -> Model -> Model
update (p,movePoint) model =
  let
    isClick = p == movePoint             -- Check if this is a mouse click (defined by our mouse
                                         --   position being equal to our last click).
    ps = getModelPoints model            -- Get the active path points.
    len = List.length ps                 -- Get the length of the path.
    path = if isClick                    -- If this is a mouse click...
            then p :: ps                 --   ... extend the path, otherwise...
            else ps                      --   ... use the existing path.
  in
    if len < maxPathLength               -- Check if the path is currently incomplete.
      then ActivePath (path, movePoint)  -- If incomplete, update the current path.
      else NoPath                        -- Otherwise, reset the path.

Here, we’re passing in an argument that could use some explanation. (p,movePoint) is a tuple that contains our last mouse click position, along with the current mouse location. We’ll see later on how these are determined.

With isClick, we determine if those two points are equivalent; this tells us that the current Signal was an active mouse click. We then use our getModelPoints function to retrieve the currently defined path. And we use the isClick variable to determine whether we should extend the path or keep the existing definition, which we store in the path variable.

Finally, in our if expression at the bottom, we determine what the next state of the Model should be. If the path is still incomplete, we keep it defined as an ActivePath. Otherwise, we simply reset our path state with NoPath.

Viewing our Model

Starting off our view part of the architecture, you’ll recognize our lineStyle function from last time:

lineStyle : LineStyle
lineStyle =
  { defaultLine          -- Extend the "default" definition.
      | width <- 10.0    -- Line width.
      , color <- blue    -- Assign the color.
      , cap   <- Round   -- The shape of the end points.
      , join  <- Smooth  -- The shape of the joints.
  }

Moving right along to our drawLineSegments function, you’ll see that it is also the same as we had defined in the last post:

drawLineSegments : (Int,Int) -> List Point -> Form
drawLineSegments (w,h) points =
  List.map (\(x,y) -> (toFloat x, toFloat -y)) points  -- Convert the mouse points to
                                                       --   "model" coordinates.
    |> path                                            -- Build a path from the points.
    |> traced lineStyle                                -- Trace the line with defined form.
    |> move (-(toFloat w) / 2, (toFloat h) / 2)        -- Move drawing from middle to upper
                                                       --   left ("screen" coordinates).

Lastly, our view function is a bit different than last time around, but it’s not too complex. We just hook up the various pieces we’ve already defined:

view : (Int,Int) -> Model -> Element
view (w,h) model =
  case model of
    NoPath             ->                               -- If no path is currently defined...
      collage w h []                                    --   ... build an empty canvas.
    ActivePath (ps, p) ->                               -- If an actively moving path is defined...
      collage w h [ drawLineSegments (w,h) (p :: ps) ]  --   ... draw the line segments, with the
                                                        --   active motion.

In the case of the NoPath state, we render nothing to our canvas, while in the ActivePath state, we call drawLineSegments to pass our defined path, extending the active path with the current mouse position as we do so.

Hooking up our Signals

Okay! So, we’ve defined our Model, told the architecture how to update and view it. Now, all we have to do is hook up our Signals!

The first thing we need to do is to define a Signal to get the mouse position each time a click occurs:

sampleOnClick : Signal Point
sampleOnClick =
  Signal.sampleOn   -- Each time a...
    Mouse.clicks    --   ... mouse click occurs, return...
    Mouse.position  --   ... the mouse position.

Signal.sampleOn retrieves the value of the second Signal each time the first Signal is triggered.

For this demo, we want to know both the position of the last click, plus the current mouse position. To do this, we need to merge these two Signals:

mergeMouse : Signal (Point, Point)
mergeMouse =
  Signal.map2       -- Each time either event happens...
    (,)             --   ... collect both...
    sampleOnClick   --   ... the last mouse click...
    Mouse.position  --   ... and the current mouse position.

We use Signal.map2 to generate a tuple that contains both the value last retrieved from the sampleOnClick Signal that we just defined, along with the Signal for the current mouse position.

Why is this necessary? Well, recall that Elm will only send updates to any Signals. So, under most circumstances, either only the first part of the tuple or the second part will update, but not both.

Next, we come along to our mousePositions function. This has changed a little bit from the last post; I’ve highlighted the changed lines:

mousePositions : Signal Model
mousePositions =
  Signal.foldp      -- Fold each signal into an accumulated model...
    update          --   ... through the update function.
    NoPath          -- Start with an empty path.
    mergeMouse      -- Updates given by mouse position changes.

This time, we’re setting our initial state to NoPath, while we’re consuming the Signals from our new mergeMouse function, rather than directly consuming Mouse.position.

For our final piece of code, we kick everything off with the exact same main function that we used in the last post:

main : Signal Element
main =
  Signal.map2          -- Map two signals together...
    view               --   ... through the view function.
    Window.dimensions  -- Use updates to the window dimensions as the first signal.
    mousePositions     -- Use updates to mouse positions as the second signal.

And, we’re done!

Conclusion

This was a pretty simple example, but it introduced a little bit more complexity with our handling of Signals. It also sets up our Model using the Elm Architecture to layer further complexity beyond what we’ve done here.

One comment

  1. Pingback: Drawing Triangles in Elm (Beginner) | voyage in tech