Drawing Triangles in Elm (Beginner)

Hey, let’s draw some triangles! If you’ve been following my last two posts on mouse chasing and drawing rays using Elm, this post will be a simple extension. If you haven’t already read those posts, I encourage you to start from the beginning, because we’ll be making iterative updates to practice getting our hands dirty with Elm.

Once again, this is targeted at newcomers to Elm, so we won’t be covering any advanced topics. If you want to see a quick demo, follow this link and start clicking around!

With the completion of the code from the last post, we’ve already done most of the heavy lifting. We’re going to make some small tweaks throughout, plus some slightly heavier updates to the update part of our architecture, and voila! We’ll extend our rays into triangles.

I’ll list the full source in segments as we go, but much of the code hasn’t changed. As we go along, I’ll highlight those changes that we’ll be making.

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-triangles.git
Cloning into 'elm-mouse-triangles'...
remote: Counting objects: 10, done.
remote: Compressing objects: 100% (8/8), done.
remote: Total 10 (delta 0), reused 10 (delta 0), pack-reused 0
Unpacking objects: 100% (10/10), done.
Checking connectivity... done.

> cd elm-mouse-triangles

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

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 9e74d5e... Initial revision

> 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

From here, you can open up the generated demo.html to run locally.

Setting Up our Imports

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

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

We’ll also be defining the same global path length variable:

maxPathLength = 2  -- Define our path to be limited to two points (zero-indexed).

In this section, we haven’t changed anything here from the last time. However, the significance of the maxPathLength has changed somewhat. When we finish the rest of the code, come back to this and try modifying this number. This single setting will let us change the kind of free-form polygons we can draw!

Defining our Model

We’ll also use a few type aliases to more clearly represent the purposes of our types, as in the last post. But in this case, we’re going to make a few tweaks. I’ll highlight these, then explain the intent in more detail:

-- MODEL

type alias MouseX = Int
type alias MouseY = Int
type alias Point = (MouseX, MouseY)                     -- Mouse position (x,y).
type alias WasClick = Bool                              -- Was the last Signal a click?
type Model = NoPath       WasClick                      -- No current path.
           | ActivePath   WasClick (List Point, Point)  -- An active path, plus an actively moving position.
           | FinishedPath WasClick (List Point)         -- A completed (inactive) path.

Here, we’ve added a new WasClick alias, which is a simple boolean type, to signify whether the last state was a mouse click. We’ll also update our existing Model definition to include this type within our state machine.

Lastly, we add a new state to our state machine, to define a “finished” path. This is intended to act nearly the same as the ActivePath definition, except that when we’re in this state, we will stop drawing a ray to follow the mouse, to free up our mouse movement from affecting the model.

From here, I’m going to mix up the order of how we’ve done this previously defined the rest of the architecture. The segments are the same, but the signals and view definitions have very few updates to make, so let’s just get them out of the way!

Hooking up our Signals

First, our Signals. We only have one tiny update to make, to initialize our state machine. This is to reflect the modification that we made to our NoPath model state.

-- SIGNALS

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


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.


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


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.

We’re just defining the previous mouse click state as “not being clicked”, because we’re starting fresh.

Viewing our Model

Our view needs just a little bit more configuration to get working with our new state machine. Let’s take a look:

-- VIEW

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.
  }


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).


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.
    FinishedPath _ ps      ->                           -- If a completed path is defined...
      collage w h [ drawLineSegments (w,h) ps ]         --   ... draw the line segments, with no
                                                        --   active motion.

We’ve only made a couple of modifications here. Our NoPath and ActivePath states now need to take an additional argument in the pattern match, to represent the previous mouse click state. For these cases, we don’t actually care what the state was, so we use the underscore pattern match, to indicate that it’s a placeholder.

The only real update we’re making is to define a new pattern match for our FinishedPath state. But, this one is still pretty easy: It’s just like our ActivePath state, except we’re ignoring the active mouse position. This makes the handling just the same, but actually just a bit simpler.

Updating our Model

The update part of our architecture is more complex. Let’s walk through it one piece at a time.

To start things off, we’re going to remove our getModelPoints function from the last post. We won’t use this anymore.

We’ll also change the update function to handle less logic on its own; it now acts basically as a pass-through to separate functions that each know how to handle the different states, passing the new Signal information passed into our update function. Only our case expression is new here (and we’ve removed some unnecessary expression definitions in the let clause):

-- UPDATE

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).
  in
    case model of             -- Just pass through the various arguments to update.
      NoPath       _                 -> updateEmptyPath    isClick          p
      ActivePath   wasClick (ps, mp) -> updateActivePath   isClick wasClick ps (p,movePoint)
      FinishedPath wasClick ps       -> updateFinishedPath isClick wasClick ps model

Let’s take a look at these various new update functions one at a time.

The updateEmptyPath function looks like this:

updateEmptyPath : Bool -> Point -> Model
updateEmptyPath isClick p =
 if isClick                        -- If a mouse click has occurred...
    then ActivePath True ([p], p)  --   ... define a new path, otherwise...
    else NoPath False              --   ... it was just a mouse movement, so we can
                                   --   ignore it.

Here, the prerequisite assumption for this function is that we’re currently in the NoPath state. We check if the new Signal indicates that a mouse click is occurring. If it is, we return a new ActivePath state, using the current mouse location as the initial starting point in the path (as well as defining it as the current mouse location to store in the state). If it is not, we simply return a NoPath state, specifying that the mouse wasn’t clicked (which is what we just compared to assure).

Next, let’s take a look at our new updateActivePath function:

updateActivePath : Bool -> Bool -> List Point -> (Point,Point) -> Model
updateActivePath isClick wasClick ps (p,movePoint) =
  if not isClick || wasClick                          -- Were either of the previous Signals a click?
    then ActivePath False (ps, movePoint)             -- If so, just return the model.
    else
      let
        path = p :: ps                                -- Prepend the new mouse Signal.
      in
        if List.length path <= maxPathLength          -- If we're not at the bounds of the path...
          then ActivePath   True (path, p)            --   ... Update the current path, otherwise...
          else FinishedPath True (completePath path)  --   ... Complete the path.

We start off my checking if either the last or current Signal indicates that a mouse click occurred. If not, we know that the current polygon path doesn’t need updating; we just return an ActivePath with the same path points as we had previously, but we update the active mouse position in order to update the ray drawn to the current position. We have to check for both the isClick and wasClick signals, because a mouse click in Elm can coincide with a new mouse movement, which can cause multiple Signal updates that otherwise look like the same event.

If a click has occurred, we first prepend the new mouse position to the existing points in the path. We’ll use this new path variable to extend our drawing state, but first we need to check the List.length of the new path:

  • If we haven’t passed the path bound set by our maxPathLength variable, we know that we’re still in an ActivePath state. We define this with the new path and the awareness of an active click state, set to True, along with the active mouse position, p.
  • If we’re at the limitation of our maxPathLength, we know that we’ve now completed the path. We return a FinishedPath state, likewise indicating that the active click state is set to True, and we define the full extent of the path by passing our path variable to the function completePath, which we’ll look at next.
completePath : List Point -> List Point
completePath points =
  case (List.head <| List.reverse points) of  -- Get the very first point and...
    Nothing -> points                         --   ... if none exists, we have nothing to update, otherwise...
    Just h  -> h :: points                    --   ... add the point to the existing points.

Our completePath function looks a little funny, but all we’re doing is this: We’re trying to get the very first point of the list. Remember, we’ve been prepending our path points so far, so we need to call List.reverse to reverse the order. Then, we apply the reversed list outcome, using <|, to the List.head function to take the first element off of the reversed list.

List.head is a function that verifies type-safety by telling us if the list had Nothing to extract. If that’s the case, we just return the original list of points that were passed in to the function. For the purposes of this tutorial, we know we’ll never call this function without a valid list, but Elm requires this result checking. This is actually a feature of the language; it enforces at compile time that we can never hit an exception by attempting to extract elements from an empty list!

For the expected resulting case, we pattern match with Just h, which tells us that we “just” found a single point, which we’ll name h for “head”. We prepend the h point to the rest of our points; this is what will form the completed triangle! (Or polygon, if you’ve fiddled with the maxPathLength variable.)

We’ve now covered how to update the NoPath and ActivePath states in our Model; now, let’s take a look at how to update the FinishedPath state with the updateFinishedPath function:

updateFinishedPath : Bool -> Bool -> List Point -> Model -> Model
updateFinishedPath isClick wasClick points model =
  if not isClick                              -- If no mouse click just occurred...
    then model                                --   ... it's just a mouse movement;
                                              --   return the existing model.
    else case wasClick of                     -- If the last path Signal was a click...
          True  -> FinishedPath False points  --   ... set up the path to be restarted, otherwise...
          False -> NoPath       False         --   ... restart at an empty path.

First, we check if the active Signal indicates that the mouse is currently not clicked. If the mouse isn’t clicked, we simply return our existing model, because all we’ve done is move the mouse (or have resized the window).

If we have detected a mouse click, we check if the previous Signal was also a mouse click (remember, this is how we defined the FinishedPath state in our updateActivePath function). If this is the case, we “reset” the captured mouse click state by returning the same FinishedPath state, but with the WasClick parameter set to False. If we don’t do this by simply returning the result of the False pattern of our case expression, the double-firing of mouse click and mouse movement will cause the path to quickly complete, then clear itself by being immediately set to NoPath. Setting up the expression this way requires a second mouse click to cause the path to be “reset”.

And that’s it!

Conclusion

Our various update functions were a bit more complicated this time around, but we simply decomposed our Model state into smaller, more digestible chunks. We don’t need to care about the other states so much this way, we just need to know how to update or advance from our current state.

If you run compile and run the demo from here, you’ll see that you can now draw triangles. You can also take the code above and paste it into try Elm to tinker with code a bit more quickly.