Canvas Graphics
Chapter Goals
This chapter will be an extended example focussing on the canvas
package, which provides a way to generate 2D graphics from PureScript using the HTML5 Canvas API.
Project Setup
This module's project introduces the following new dependencies:
canvas
, which gives types to methods from the HTML5 Canvas APIrefs
, which provides a side-effect for using global mutable references
The source code for the chapter is broken up into a set of modules, each of which defines a main
method. Different sections of this chapter are implemented in different files, and the Main
module can be changed by modifying the Spago build command to run the appropriate file's main
method at each point.
The HTML file html/index.html
contains a single canvas
element which will be used in each example, and a script
element to load the compiled PureScript code. To test the code for each section, open the HTML file in your browser. Because most exercises target the browser, this chapter has no unit tests.
Simple Shapes
The Example/Rectangle.purs
file contains a simple introductory example, which draws a single blue rectangle at the center of the canvas. The module imports the Effect
type from the Effect
module, and also the Graphics.Canvas
module, which contains actions in the Effect
monad for working with the Canvas API.
The main
action starts, like in the other modules, by using the getCanvasElementById
action to get a reference to the canvas object and the getContext2D
action to access the 2D rendering context for the canvas:
The void
function takes a functor and replaces its value with Unit
. In the example, it is used to make main
conform with its signature.
main :: Effect Unit
main = void $ unsafePartial do
Just canvas <- getCanvasElementById "canvas"
ctx <- getContext2D canvas
Note: the call to unsafePartial
here is necessary since the pattern match on the result of getCanvasElementById
is partial, matching only the Just
constructor. For our purposes, this is fine, but in production code, we would probably want to match the Nothing
constructor and provide an appropriate error message.
The types of these actions can be found using PSCi or by looking at the documentation:
getCanvasElementById :: String -> Effect (Maybe CanvasElement)
getContext2D :: CanvasElement -> Effect Context2D
CanvasElement
and Context2D
are types defined in the Graphics.Canvas
module. The same module also defines the Canvas
effect, which is used by all of the actions in the module.
The graphics context ctx
manages the state of the canvas and provides methods to render primitive shapes, set styles and colors, and apply transformations.
We continue by setting the fill style to solid blue using the setFillStyle
action. The longer hex notation of #0000FF
may also be used for blue, but shorthand notation is easier for simple colors:
setFillStyle ctx "#00F"
Note that the setFillStyle
action takes the graphics context as an argument. This is a common pattern in the Graphics.Canvas
module.
Finally, we use the fillPath
action to fill the rectangle. fillPath
has the following type:
fillPath :: forall a. Context2D -> Effect a -> Effect a
fillPath
takes a graphics context and another action that builds the path to render. To build a path, we can use the rect
action. rect
takes a graphics context and a record that provides the position and size of the rectangle:
fillPath ctx $ rect ctx
{ x: 250.0
, y: 250.0
, width: 100.0
, height: 100.0
}
Build the rectangle example, providing Example.Rectangle
as the name of the main module:
$ spago bundle-app --main Example.Rectangle --to dist/Main.js
Now, open the html/index.html
file and verify that this code renders a blue rectangle in the center of the canvas.
Putting Row Polymorphism to Work
There are other ways to render paths. The arc
function renders an arc segment, and the moveTo
, lineTo
, and closePath
functions can render piecewise-linear paths.
The Shapes.purs
file renders three shapes: a rectangle, an arc segment, and a triangle.
We have seen that the rect
function takes a record as its argument. In fact, the properties of the rectangle are defined in a type synonym:
type Rectangle =
{ x :: Number
, y :: Number
, width :: Number
, height :: Number
}
The x
and y
properties represent the location of the top-left corner, while the width
and height
properties represent the lengths of the rectangle, respectively.
To render an arc segment, we can use the arc
function, passing a record with the following type:
type Arc =
{ x :: Number
, y :: Number
, radius :: Number
, start :: Number
, end :: Number
}
Here, the x
and y
properties represent the center point, radius
is the radius, start
and end
represent the endpoints of the arc in radians.
For example, this code fills an arc segment centered at (300, 300)
with radius 50
. The arc completes 2/3rds of a rotation. Note that the unit circle is flipped vertically since the y-axis increases towards the bottom of the canvas:
fillPath ctx $ arc ctx
{ x : 300.0
, y : 300.0
, radius : 50.0
, start : 0.0
, end : Math.tau * 2.0 / 3.0
}
Notice that both the Rectangle
and Arc
record types contain x
and y
properties of type Number
. In both cases, this pair represents a point. This means we can write row-polymorphic functions acting on either type of record.
For example, the Shapes
module defines a translate
function that translates a shape by modifying its x
and y
properties:
translate
:: forall r
. Number
-> Number
-> { x :: Number, y :: Number | r }
-> { x :: Number, y :: Number | r }
translate dx dy shape = shape
{ x = shape.x + dx
, y = shape.y + dy
}
Notice the row-polymorphic type. It says that translate
accepts any record with x
and y
properties and any other properties, and returns the same type of record. The x
and y
fields are updated, but the rest of the fields remain unchanged.
This is an example of record update syntax. The expression shape { ... }
creates a new record based on the shape
record, with the fields inside the braces updated to the specified values. Note that the expressions inside the braces are separated from their labels by equals symbols, not colons like in record literals.
The translate
function can be used with both the Rectangle
and Arc
records, as seen in the Shapes
example.
The third type of path rendered in the Shapes
example is a piecewise-linear path. Here is the corresponding code:
setFillStyle ctx "#F00"
fillPath ctx $ do
moveTo ctx 300.0 260.0
lineTo ctx 260.0 340.0
lineTo ctx 340.0 340.0
closePath ctx
There are three functions in use here:
moveTo
moves the current location of the path to the specified coordinates,lineTo
renders a line segment between the current location and the specified coordinates, and updates the current location,closePath
completes the path by rendering a line segment joining the current location to the start position.
The result of this code snippet is to fill an isosceles triangle.
Build the example by specifying Example.Shapes
as the main module:
$ spago bundle-app --main Example.Shapes --to dist/Main.js
and open html/index.html
again to see the result. You should see the three different types of shapes rendered to the canvas.
Exercises
-
(Easy) Experiment with the
strokePath
andsetStrokeStyle
functions in each example so far. -
(Easy) The
fillPath
andstrokePath
functions can render complex paths with a common style using a do notation block inside the function argument. Try changing theRectangle
example to render two rectangles side-by-side using the same call tofillPath
. Try rendering a sector of a circle by using a combination of a piecewise-linear path and an arc segment. -
(Medium) Given the following record type:
type Point = { x :: Number, y :: Number }
which represents a 2D point, write a function
renderPath
which strokes a closed path constructed from a number of points:renderPath :: Context2D -> Array Point -> Effect Unit
Given a function
f :: Number -> Point
which takes a
Number
between0
and1
as its argument and returns aPoint
, write an action that plotsf
by using yourrenderPath
function. Your action should approximate the path by samplingf
at a finite set of points.Experiment by rendering different paths by varying the function
f
.
Drawing Random Circles
The Example/Random.purs
file contains an example that uses the Effect
monad to interleave two types of side-effect: random number generation and canvas manipulation. The example renders one hundred randomly generated circles onto the canvas.
The main
action obtains a reference to the graphics context as before and then sets the stroke and fill styles:
setFillStyle ctx "#F00"
setStrokeStyle ctx "#000"
Next, the code uses the for_
function to loop over the integers between 0
and 100
:
for_ (1 .. 100) \_ -> do
On each iteration, the do notation block starts by generating three random numbers distributed between 0
and 1
. These numbers represent the x
and y
coordinates and the radius of a circle:
x <- random
y <- random
r <- random
Next, for each circle, the code creates an Arc
based on these parameters and finally fills and strokes the arc with the current styles:
let path = arc ctx
{ x : x * 600.0
, y : y * 600.0
, radius : r * 50.0
, start : 0.0
, end : Number.tau
, useCounterClockwise: false
}
fillPath ctx path
strokePath ctx path
Build this example by specifying the Example.Random
module as the main module:
$ spago bundle-app --main Example.Random --to dist/Main.js
and view the result by opening html/index.html
.
Transformations
There is more to the canvas than just rendering simple shapes. Every canvas maintains a transformation that is used to transform shapes before rendering. Shapes can be translated, rotated, scaled, and skewed.
The canvas
library supports these transformations using the following functions:
translate :: Context2D
-> TranslateTransform
-> Effect Context2D
rotate :: Context2D
-> Number
-> Effect Context2D
scale :: Context2D
-> ScaleTransform
-> Effect Context2D
transform :: Context2D
-> Transform
-> Effect Context2D
The translate
action performs a translation whose components are specified by the properties of the TranslateTransform
record.
The rotate
action rotates around the origin through some number of radians specified by the first argument.
The scale
action performs a scaling, with the origin as the center. The ScaleTransform
record specifies the scale factors along the x
and y
axes.
Finally, transform
is the most general action of the four here. It performs an affine transformation specified by a matrix.
Any shapes rendered after these actions have been invoked will automatically have the appropriate transformation applied.
In fact, the effect of each of these functions is to post-multiply the transformation with the context's current transformation. The result is that if multiple transformations applied after one another, then their effects are actually applied in reverse:
transformations ctx = do
translate ctx { translateX: 10.0, translateY: 10.0 }
scale ctx { scaleX: 2.0, scaleY: 2.0 }
rotate ctx (Math.tau / 4.0)
renderScene
The effect of this sequence of actions is that the scene is rotated, then scaled, and finally translated.
Preserving the Context
A common use case is to render some subset of the scene using a transformation and then reset the transformation.
The Canvas API provides the save
and restore
methods, which manipulate a stack of states associated with the canvas. canvas
wraps this functionality into the following functions:
save
:: Context2D
-> Effect Context2D
restore
:: Context2D
-> Effect Context2D
The save
action pushes the current state of the context (including the current transformation and any styles) onto the stack, and the restore
action pops the top state from the stack and restores it.
This allows us to save the current state, apply some styles and transformations, render some primitives, and finally restore the original transformation and state. For example, the following function performs some canvas action but applies a rotation before doing so and restores the transformation afterwards:
rotated ctx render = do
save ctx
rotate (Math.tau / 3.0) ctx
render
restore ctx
In the interest of abstracting over common use cases using higher-order functions, the canvas
library provides the withContext
function, which performs some canvas action while preserving the original context state:
withContext
:: Context2D
-> Effect a
-> Effect a
We could rewrite the rotated
function above using withContext
as follows:
rotated ctx render =
withContext ctx do
rotate (Math.tau / 3.0) ctx
render
Global Mutable State
In this section, we'll use the refs
package to demonstrate another effect in the Effect
monad.
The Effect.Ref
module provides a type constructor for global mutable references and an associated effect:
> import Effect.Ref
> :kind Ref
Type -> Type
A value of type Ref a
is a mutable reference cell containing a value of type a
, used to track global mutation. As such, it should be used sparingly.
The Example/Refs.purs
file contains an example that uses a Ref
to track mouse clicks on the canvas
element.
The code starts by creating a new reference containing the value 0
by using the new
action:
clickCount <- Ref.new 0
Inside the click event handler, the modify
action is used to update the click count, and the updated value is returned.
count <- Ref.modify (\count -> count + 1) clickCount
In the render
function, the click count is used to determine the transformation applied to a rectangle:
withContext ctx do
let scaleX = Number.sin (toNumber count * Number.tau / 8.0) + 1.5
let scaleY = Number.sin (toNumber count * Number.tau / 12.0) + 1.5
translate ctx { translateX: 300.0, translateY: 300.0 }
rotate ctx (toNumber count * Number.tau / 36.0)
scale ctx { scaleX: scaleX, scaleY: scaleY }
translate ctx { translateX: -100.0, translateY: -100.0 }
fillPath ctx $ rect ctx
{ x: 0.0
, y: 0.0
, width: 200.0
, height: 200.0
}
This action uses withContext
to preserve the original transformation and then applies the following sequence of transformations (remember that transformations are applied bottom-to-top):
- The rectangle is translated through
(-100, -100)
, so its center lies at the origin. - The rectangle is scaled around the origin.
- The rectangle is rotated through some multiple of
10
degrees around the origin. - The rectangle is translated through
(300, 300)
, so its center lies at the center of the canvas.
Build the example:
$ spago bundle-app --main Example.Refs --to dist/Main.js
and open the html/index.html
file. If you click the canvas repeatedly, you should see a green rectangle rotating around the center of the canvas.
Exercises
- (Easy) Write a higher-order function that simultaneously strokes and fills a path. Rewrite the
Random.purs
example using your function. - (Medium) Use
Random
andDom
to create an application that renders a circle with random position, color, and radius to the canvas when the mouse is clicked. - (Medium) Write a function that transforms the scene by rotating it around a point with specified coordinates. Hint: use a translation to first translate the scene to the origin.
L-Systems
In this final example, we will use the canvas
package to write a function for rendering L-systems (or Lindenmayer systems).
An L-system is defined by an alphabet, an initial sequence of letters from the alphabet, and a set of production rules. Each production rule takes a letter of the alphabet and returns a sequence of replacement letters. This process is iterated some number of times, starting with the initial sequence of letters.
If each letter of the alphabet is associated with some instruction to perform on the canvas, the L-system can be rendered by following the instructions in order.
For example, suppose the alphabet consists of the letters L
(turn left), R
(turn right), and F
(move forward). We might define the following production rules:
L -> L
R -> R
F -> FLFRRFLF
If we start with the initial sequence "FRRFRRFRR" and iterate, we obtain the following sequence:
FRRFRRFRR
FLFRRFLFRRFLFRRFLFRRFLFRRFLFRR
FLFRRFLFLFLFRRFLFRRFLFRRFLFLFLFRRFLFRRFLFRRFLF...
and so on. Plotting a piecewise-linear path corresponding to this set of instructions approximates the Koch curve. Increasing the number of iterations increases the resolution of the curve.
Let's translate this into the language of types and functions.
We can represent our alphabet of letters with the following ADT:
data Letter = L | R | F
This data type defines one data constructor for each letter in our alphabet.
How can we represent the initial sequence of letters? Well, that's just an array of letters from our alphabet, which we will call a Sentence
:
type Sentence = Array Letter
initial :: Sentence
initial = [F, R, R, F, R, R, F, R, R]
Our production rules can be represented as a function from Letter
to Sentence
as follows:
productions :: Letter -> Sentence
productions L = [L]
productions R = [R]
productions F = [F, L, F, R, R, F, L, F]
This is just copied straight from the specification above.
Now we can implement a function lsystem
that will take a specification in this form and render it to the canvas. What type should lsystem
have? Well, it needs to take values like initial
and productions
as arguments, as well as a function that can render a letter of the alphabet to the canvas.
Here is a first approximation to the type of lsystem
:
Sentence
-> (Letter -> Sentence)
-> (Letter -> Effect Unit)
-> Int
-> Effect Unit
The first two argument types correspond to the values initial
and productions
.
The third argument represents a function that takes a letter of the alphabet and interprets it by performing some actions on the canvas. In our example, this would mean turning left in the case of the letter L
, turning right in the case of the letter R
, and moving forward in the case of a letter F
.
The final argument is a number representing the number of iterations of the production rules we would like to perform.
The first observation is that the lsystem
function should work for only one type of Letter
, but for any type, so we should generalize our type accordingly. Let's replace Letter
and Sentence
with a
and Array a
for some quantified type variable a
:
forall a. Array a
-> (a -> Array a)
-> (a -> Effect Unit)
-> Int
-> Effect Unit
The second observation is that, to implement instructions like "turn left" and "turn right", we will need to maintain some state, namely the direction in which the path is moving at any time. We need to modify our function to pass the state through the computation. Again, the lsystem
function should work for any type of state, so we will represent it using the type variable s
.
We need to add the type s
in three places:
forall a s. Array a
-> (a -> Array a)
-> (s -> a -> Effect s)
-> Int
-> s
-> Effect s
Firstly, the type s
was added as the type of an additional argument to lsystem
. This argument will represent the initial state of the L-system.
The type s
also appears as an argument to, and as the return type of the interpretation function (the third argument to lsystem
). The interpretation function will now receive the current state of the L-system as an argument, and will return a new, updated state as its return value.
In the case of our example, we can define use following type to represent the state:
type State =
{ x :: Number
, y :: Number
, theta :: Number
}
The properties x
and y
represent the current position of the path, and the theta
property represents the current direction of the path, specified as the angle between the path direction and the horizontal axis, in radians.
The initial state of the system might be specified as follows:
initialState :: State
initialState = { x: 120.0, y: 200.0, theta: 0.0 }
Now let's try to implement the lsystem
function. We will find that its definition is remarkably simple.
It seems reasonable that lsystem
should recurse on its fourth argument (of type Int
). On each step of the recursion, the current sentence will change, having been updated by using the production rules. With that in mind, let's begin by introducing names for the function arguments, and delegating to a helper function:
lsystem :: forall a s
. Array a
-> (a -> Array a)
-> (s -> a -> Effect s)
-> Int
-> s
-> Effect s
lsystem init prod interpret n state = go init n
where
The go
function works by recursion on its second argument. There are two cases: when n
is zero and n
is non-zero.
In the first case, the recursion is complete, and we need to interpret the current sentence according to the interpretation function. We have a sentence of type Array a
, a state of type s
, and a function of type s -> a -> Effect s
. This sounds like a job for the foldM
function which we defined earlier, and which is available from the control
package:
go s 0 = foldM interpret state s
What about in the non-zero case? In that case, we can simply apply the production rules to each letter of the current sentence, concatenate the results, and repeat by calling go
recursively:
go s i = go (concatMap prod s) (i - 1)
That's it! Note how using higher-order functions like foldM
and concatMap
allowed us to communicate our ideas concisely.
However, we're not quite done. The type we have given is actually still too specific. Note that we don't use any canvas operations anywhere in our implementation. Nor do we make use of the structure of the Effect
monad at all. In fact, our function works for any monad m
!
Here is the more general type of lsystem
, as specified in the accompanying source code for this chapter:
lsystem :: forall a m s
. Monad m
=> Array a
-> (a -> Array a)
-> (s -> a -> m s)
-> Int
-> s
-> m s
We can understand this type as saying that our interpretation function is free to have any side-effects at all, captured by the monad m
. It might render to the canvas, print information to the console, or support failure or multiple return values. The reader is encouraged to try writing L-systems that use these various types of side-effect.
This function is a good example of the power of separating data from implementation. The advantage of this approach is that we can interpret our data in multiple ways. We might even factor lsystem
into two smaller functions: the first would build the sentence using repeated application of concatMap
, and the second would interpret the sentence using foldM
. This is also left as an exercise for the reader.
Let's complete our example by implementing its interpretation function. The type of lsystem
tells us that its type signature must be s -> a -> m s
for some types a
and s
and a type constructor m
. We know that we want a
to be Letter
and s
to be State
, and for the monad m
we can choose Effect
. This gives us the following type:
interpret :: State -> Letter -> Effect State
To implement this function, we need to handle the three data constructors of the Letter
type. To interpret the letters L
(move left) and R
(move right), we simply have to update the state to change the angle theta
appropriately:
interpret state L = pure $ state { theta = state.theta - Number.tau / 6.0 }
interpret state R = pure $ state { theta = state.theta + Number.tau / 6.0 }
To interpret the letter F
(move forward), we can calculate the new position of the path, render a line segment, and update the state as follows:
interpret state F = do
let x = state.x + Number.cos state.theta * 1.5
y = state.y + Number.sin state.theta * 1.5
moveTo ctx state.x state.y
lineTo ctx x y
pure { x, y, theta: state.theta }
Note that in the source code for this chapter, the interpret
function is defined using a let
binding inside the main
function, so that the name ctx
is in scope. It would also be possible to move the context into the State
type, but this would be inappropriate because it is not a changing part of the state of the system.
To render this L-system, we can simply use the strokePath
action:
strokePath ctx $ lsystem initial productions interpret 5 initialState
Compile the L-system example using
$ spago bundle-app --main Example.LSystem --to dist/Main.js
and open html/index.html
. You should see the Koch curve rendered to the canvas.
Exercises
-
(Easy) Modify the L-system example above to use
fillPath
instead ofstrokePath
. Hint: you will need to include a call toclosePath
, and move the call tomoveTo
outside of theinterpret
function. -
(Easy) Try changing the various numerical constants in the code to understand their effect on the rendered system.
-
(Medium) Break the
lsystem
function into two smaller functions. The first should build the final sentence using repeated application ofconcatMap
, and the second should usefoldM
to interpret the result. -
(Medium) Add a drop shadow to the filled shape using the
setShadowOffsetX
,setShadowOffsetY
,setShadowBlur
, andsetShadowColor
actions. Hint: use PSCi to find the types of these functions. -
(Medium) The angle of the corners is currently a constant \( \tau / 6 \). Instead, it can be moved into the
Letter
data type, which allows it to be changed by the production rules:type Angle = Number data Letter = L Angle | R Angle | F
How can this new information be used in the production rules to create interesting shapes?
-
(Difficult) An L-system is given by an alphabet with four letters:
L
(turn left through 60 degrees),R
(turn right through 60 degrees),F
(move forward), andM
(also move forward).The initial sentence of the system is the single letter
M
.The production rules are specified as follows:
L -> L R -> R F -> FLMLFRMRFRMRFLMLF M -> MRFRMLFLMLFLMRFRM
Render this L-system. Note: you will need to decrease the number of iterations of the production rules since the size of the final sentence grows exponentially with the number of iterations.
Now, notice the symmetry between
L
andM
in the production rules. The two "move forward" instructions can be differentiated using aBoolean
value using the following alphabet type:data Letter = L | R | F Boolean
Implement this L-system again using this representation of the alphabet.
-
(Difficult) Use a different monad
m
in the interpretation function. You might try usingEffect.Console
to write the L-system onto the console, or usingEffect.Random
to apply random "mutations" to the state type.
Conclusion
In this chapter, we learned how to use the HTML5 Canvas API from PureScript by using the canvas
library. We also saw a practical demonstration of many techniques we have learned already: maps and folds, records and row polymorphism, and the Effect
monad for handling side-effects.
The examples also demonstrated the power of higher-order functions and separating data from implementation. It would be possible to extend these ideas to completely separate the representation of a scene from its rendering function, using an algebraic data type, for example:
data Scene
= Rect Rectangle
| Arc Arc
| PiecewiseLinear (Array Point)
| Transformed Transform Scene
| Clipped Rectangle Scene
| ...
This approach is taken in the drawing
package, and it brings the flexibility of manipulating the scene as data in various ways before rendering.
For examples of games rendered to the canvas, see the "Behavior" and "Signal" recipes in the cookbook.