Drawing Fractals with PureScript

Recently, for no good reason, I added an easter egg to my personal website. I love fractals and decided to add a visualization of the dragon curve. Here were the requirements:

I decided to use SVG to draw the lines of the fractal. Initially, I built a solution in Elm. However, I was missing the flexibility I’d experienced with my previous back end work in PureScript and wanted to explore using it on the front end. This post walks through the process of rebuilding the solution in PureScript.

Modeling the Fractal

First, we need to model the fractal itself. Here are the relevant types:

data Dir
  = Up
  | Down
  | Left
  | Right

derive instance eqDir :: Eq Dir

derive instance genericDir :: Generic Dir _

instance showDir :: Show Dir where
  show :: Dir -> String
  show = genericShow

type Model
  = { dirs :: Array Dir
    , iteration :: Int

The Dir type is a sum type representing the directions of lines in the fractal. A stroke can move up, down, left or right from the current position. Dir derives Eq and implements Show using generic programming. Both instances were required for test assertions.

Once we have Dir, our Model is quite simple. It’s a record type with two fields: dirs and iterations. The dirs field is a Array of Dirs that we’ll need to draw and the iterations field tracks the current iteration number.

Given this model, we next define a function update that, given a Model, returns the next iteration of the Model.

update :: Model -> Model
update model =
  if model.iteration > 10 then
    { dirs: unfold model.dirs
    , iteration: model.iteration + 1

Our update function first checks if the iteraction field is greater than 10. If so, it returns a new model. Here’s the newModel function:

newModel :: Model
newModel = { dirs: [], iteration: 0 }

In the case where the iteration is less than or equal to 10, we increase the interation count by 1 and unfold the next iteration of directions.

The choice of the name unfold is intentional as you can think of each successive iteration of the dragon curve as “unfolding” a piece of paper. Here’s the code for unfold:

unfold :: Array Dir -> Array Dir
unfold = case _ of
  [] -> [ Down ]
  dirs -> dirs <> (rotate <$> reverse dirs)

rotate :: Dir -> Dir
rotate = case _ of
  Right -> Up
  Up -> Left
  Left -> Down
  Down -> Right

If we’re given an empty array of directions, we add a single Down stroke. In the case where have a non-empty array, we take the existing set of directions, reverse them, rotate each direction counter-clockwise 90 degrees and append this to the existing directions.

Now that we have a way to model our fractal and update it, all that’s left is to draw it on the page.

Drawing the Fractal

PureScript has several options for building UIs on the front end, the most popular being Halogen. Halogen didn’t feel appropriate to me for this project given its complexity and the small problem I was trying to solve.

At the same time, I didn’t want to simply use PureScript React Basic because I felt that too much of the react abstraction leaks through to the PureScript code.

After some research and prototyping, I ended up choosing Concur. Concur is exciting for several reasons:

Concur has two core concepts: Widgets and Signals.

A Widget is a UI component that can produce a value (or no value if it loops forever). Rather than defining new mechanisms for extracting a value from a Widget or sending values down to it, Concur uses standard purely functional idioms. To send a value to a Widget, you simple pass it as an argument to a function that produces a Widget. To receive a value that a Widget produces, you bind to it. Yep, that’s right, a Widget is a Monad.

A Signal is a simplified version of the concept from Functional Reactive Programming (FRP). You can think of it as a widget that loops forever, but can be composed with other Signals via bind and a few “looping” helpers.

I decided to implement my view using Signals. Here’s the top-level signal function:

signal :: Signal HTML Model
signal =
  loopS newModel \model -> do
    model' <- timer model
    svgLines model'

I’m using the loopS helper, which loops a provided Signal back onto itself, creating a never-ending loop. loopS is provided an initial value, our newModel, and a function that is called any time that value is updated. The body of our function consists of two Signals: timer and svgLines. The timer signal is responsible for updating our Model every 10 seconds and the svgLines Signal is responsible for rendering our lines and handling user clicks.

Note that the output of the timer signal is fed into the svgLines signal via bind. Any time an “upstream” Signal emits a new value, all “downstream” Signals will re-render. And because we’re using loopS, an update in svgLines “loops back around” to cause the timer signal to re-render.

Let’s look at timer first:

timer :: Model -> Signal HTML Model
timer init =
  loopW init \model -> do
    liftAff $ delay $ Milliseconds tickTime
    pure $ update model

Here, we use the loopW helper to that lets us create a Signal from a Widget by looping it over and over. Widgets are MonadAffs, so we can use Aff functions inside of them. Our timer simply sleeps for some amount of time specified by tickTime and then emits an updated model using the update function from above.

Next, let’s look at svgLines:

svgLines :: Model -> Signal HTML Model
svgLines init =
  loopW init \model -> do
      [ unit <$ onClick
      , width "500"
      , height "500"
      , viewBox "0 0 500 500"
      (renderLines model)
    pure $ update model

Again, we use loopW to create a Signal from a looped Widget. Here, we create an svg area, add an onClick handler to it and render our lines. If the onClick fires, we throw away the value and emit an updated model.

Next, let’s look at how we actually render our lines. Perhaps there’s a more elegant way to implement this logic, but it gets the job done.

type Coord
  = Int /\ Int

renderLines :: forall a. Model -> Array (Widget HTML a)
renderLines = snd <<< foldl renderLine (startCoord /\ []) <<< _.dirs

renderLine ::
  forall a.
  Coord /\ Array (Widget HTML a) ->
  Dir ->
  Coord /\ Array (Widget HTML a)
renderLine (coord /\ lines) dir =
    newCoord = move coord dir

    newLine = makeLine coord newCoord
    newCoord /\ newLine : lines

move :: Coord -> Dir -> Coord
move (x /\ y) = case _ of
  Up -> x /\ (y - stepSize)
  Down -> x /\ (y + stepSize)
  Left -> (x - stepSize) /\ y
  Right -> (x + stepSize) /\ y

makeLine :: forall a. Coord -> Coord -> Widget HTML a
makeLine (xa /\ ya) (xb /\ yb) =
    [ x1 xa
    , x2 xb
    , y1 ya
    , y2 yb
    , strokeWidth 2
    , stroke "#000000"

We introduce a new type, Coord, that is a tuple of two Ints to represent positions. The renderLines function folds over our Model’s dirs accumulating a tuple of the current coordinate and the list of lines to render. Finally, we extract the lines from the resulting accumulator and throw away the coordinate. The renderLine function is the workhouse of our fold.

renderLine receives the current state of our accumulator and the next direction to move. It then updates the current coordinate and appends a new line to the array of lines. The move and makeLine helper functions handle updating the coordinate and rendering the current line.

The final helper functions we need are the x1, x2, y1 and y2 properties as they’re not provided by the react bindings.

x1 :: forall a. Int -> ReactProps a
x1 = unsafeMkProp "x1" <<< show

x2 :: forall a. Int -> ReactProps a
x2 = unsafeMkProp "x2" <<< show

y1 :: forall a. Int -> ReactProps a
y1 = unsafeMkProp "y1" <<< show

y2 :: forall a. Int -> ReactProps a
y2 = unsafeMkProp "y2" <<< show

Finally, we mount our top-level signal function onto the DOM.

main :: Effect Unit
main = runWidgetInDom "main" $ dyn signal

The dyn function turns a Signal into a never-ending Widget that can be attached to the DOM.

Parting Thoughts

I very much enjoyed the exercise of building a small front end project in PureScript. I left feeling excited about the potential of the Concur framework and the PureScript ecosystem as a whole.