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:
- Every 10 seconds, a new iteration of the dragon curve would be drawn.
- If the user clicked anywhere within the drawing area, a new iteration would be drawn and the 10 second clock would be reset.
- After the 10th iteration is drawn, the drawing should be cleared and the process should restart.
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 Dir
s 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
newModel
else
{ 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:
- It is built on react and has good interop with the existing ecosystem.
- It has a small number of building blocks but they can be combined in powerful ways.
- It embraces purely functional programming idioms.
Concur has two core concepts: Widget
s and Signal
s.
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 Signal
s via bind
and a few “looping” helpers.
I decided to implement my view using Signal
s. 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”
Signal
s 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. Widget
s are MonadAff
s, 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
svg
[ 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 =
let
newCoord = move coord dir
newLine = makeLine coord newCoord
in
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) =
line
[ x1 xa
, x2 xb
, y1 ya
, y2 yb
, strokeWidth 2
, stroke "#000000"
]
[]
We introduce a new type, Coord
, that is a tuple of two Int
s to represent
positions. The renderLines
function folds over our Model
’s dirs
accumulating a tuple of the current coordinate and the list of line
s to
render. Finally, we extract the line
s 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.