GAMR1520: Markup languages and scripting

javascript logo

Lab 5.3: Drawing on the HTML canvas element

Part of Week 5: Introducing Javascript

General setup

For all lab exercises you should create a folder for the lab somewhere sensible.

Assuming you have a GAMR1520-labs folder, you should create a GAMR1520-labs/week_5 folder for this week and a GAMR1520-labs/week_5/lab_5.3 folder inside that.

JavaScript setup

Though it is possible to contain everything within one file, a JavaScript project will usually contain a collection of multiple files.

GAMR1520-labs
└─ week_5
    └─ lab_5.3
        ├─ experiment1
        │    ├─ index.html
        │    └─ scripts.js
        └─ experiment2
             ├─ index.html
             └─ scripts.js

For simple projects, there will always be an index.html and the javascript file can always be something like scripts.js, though you can choose your own names. Using the same template for multiple examples is convenient. Try to name your folders better than this, the folder name should reflect their content. For example, blank_template, edit_elements or simple_drawing.

Resources

If you want to find out more information about any aspect of web development, the best resource is the Mozilla Developer Network web documents, in particular the JavaScript documentation will be invaluable for this module.

General approach

As you encounter new concepts, try to create examples for yourself that prove you understand what is going on. Try to break stuff, its a good way to learn. But always save a working version.

Modifying the example code is a good start, but try to write your own programmes from scratch, based on the example code. They might start very simple, but over the weeks you can develop them into more complex programmes.

Think of a programme you would like to write (don't be too ambitious). Break the problem down into small pieces and spend some time each session trying to solve a small problem.

Using the HTML canvas to draw is a great way to build simple, game-like systems.

In this exercise we will introduce the main drawing API and leave you to play with drawing your own scenes.

Some examples

The first step is to create an HTML document.

Copy this one into a new folder as index.html. It contains five HTML <canvas> elements. Also note the style rules which help to identify the canvas elements when they are blank.

HTML canvas elements are transparent by default.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTML canvas example 1</title>
    <style>
        body {
            display: grid; 
            gap: 1em;
            place-items: center;
        }
        canvas {border: 1px solid black;}
    </style>
</head>
<body>
    <canvas id="example1" width="300" height="100"></canvas>
    <canvas id="example2" width="300" height="100"></canvas>
    <canvas id="example3" width="300" height="100"></canvas>
    <canvas id="example4" width="300" height="100"></canvas>
    <canvas id="example5" width="300" height="100"></canvas>
    <script src="scripts.js"></script>
</body>
</html>

We have also included a <script> element pointing at scripts.js. This is a file you need to create, we will add code to draw on each of the canvases in turn.

The scripts.js file will become very long with five examples. Keep it organised.

To start us off, add this code into scripts.js.

const ctx1 = example1.getContext("2d");

This code is accessing the first canvas (example1) element and calling the getContext method to get access to a “2d” canvas rendering context for the canvas. This ctx object will we of type CanvasRenderingContext2D and gives us methods we can use to draw on the first canvas.

Notice that for each example below we are getting a new context object which points to a different canvas element. Each CanvasRenderingContext2D object can only draw to its own canvas.

Example 1: fillRect

When drawing on canvases there are two main things we can do. We can fill and we can stroke. These are usually achieved with the CanvasRenderingContext2D.fill() and CanvasRenderingContext2D.stroke() methods. However, there are some methods which do the filling for us. One example is CanvasRenderingContext2D.fillRect(). This will fill a rectangle with the specified location and dimensions.

Here’s how we can do it.

const ctx1 = example1.getContext("2d");
ctx1.fillRect(100, 0, 100, 100);

Refresh the page and you should see a black square inside the first canvas element.

We pass four parameters into the method. The first two parameters are the coordinates of the top-left corner of the rectangle. We passed 100 and 0. That is, the rectangle begins 100px in from the left edge of the canvas and 0px down from the top edge. The second pair of parameters are the width and height of the rectangle. We passed 100 and 100, so we are actually filling a 100px square.

Let’s add a few more squares.

const ctx1 = example1.getContext("2d");
ctx1.fillRect(100, 0, 100, 100);
ctx1.fillRect(30, 30, 40, 40);
ctx1.fillRect(230, 30, 40, 40);

Notice the coordinates and size are different in each call to fillRect().

The default fill colour is black. If we want to change it, we can set CanvasRenderingContext2D.fillStyle to a string containing any valid CSS color value such as "#f00", "#ffdd00", "red", "rgb(10, 10, 10)" etc.

Let’s draw a final square on top of the original one.

const ctx1 = example1.getContext("2d");
ctx1.fillRect(100, 0, 100, 100);
ctx1.fillRect(30, 30, 40, 40);
ctx1.fillRect(230, 30, 40, 40);
ctx1.fillStyle = "white";
ctx1.fillRect(130, 30, 40, 40);

The final canvas should look like this.

Example 2: moveTo and lineTo

Line drawings require a bit more work. First, we need to create a path (or several sub-paths). We can do that using CanvasRenderingContext2D.moveTo() and CanvasRenderingContext2D.lineTo().

You should be checking what the canvas looks like for each step and code examples below. It is critical that you understand each line of code.

Add the following to the end of your script.js file.

const ctx2 = example2.getContext("2d");
ctx2.moveTo(10, 10);
ctx2.lineTo(290, 90);
ctx2.stroke();

First we get a CanvasRenderingContext2D object, ctx2 for the second canvas. Then we call moveTo() to start a new sub-path at the provided coordinates (10px in from the top-left corner). Then we call lineTo() to add a line to the specified location (10px in from the bottom-right corner). Finally, we call stroke() to draw a line which follows the path we defined.

If we didn’t call stroke(), nothing would be drawn.

We can extend the path with more calls to lineTo.

const ctx2 = example2.getContext("2d");
ctx2.moveTo(10, 10);
ctx2.lineTo(290, 90);
ctx2.lineTo(290, 10);
ctx2.lineTo(10, 90);
ctx2.lineTo(10, 10);
ctx2.stroke();

Rather than drawing a line back to the first point, we could have used CanvasRenderingContext2D.closePath() to do exactly the same thing.

If we want to add another sub-path, we can add a call to CanvasRenderingContext2D.moveTo().

const ctx2 = example2.getContext("2d");
ctx2.moveTo(10, 10);
ctx2.lineTo(290, 90);
ctx2.lineTo(290, 10);
ctx2.lineTo(10, 90);
ctx2.lineTo(10, 10);

ctx2.strokeStyle = "red";

ctx2.moveTo(20, 20);
ctx2.lineTo(125, 50);
ctx2.lineTo(20, 80);
ctx2.closePath();

ctx2.stroke();

We can think of this as lifting and moving the pen. Whilst lineTo() draws a line.

Notice that we have also changed the strokeStyle but the change has been applied to the entire path. This is an important thing to understand. Using the moveTo and lineTo methods we are building up the coordinates of a path (made of two sub-paths in this case). It is only when we call stroke or fill that we are actually affecting the pixels of the canvas. The current settings of the strokeStyle and/or fillStyle will be used.

We can clear the current path and start a new one by calling CanvasRenderingContext2D.beginPath(). This is what we need to do if we want to use multiple colours or styles (lineWidth for example).

const ctx2 = example2.getContext("2d");
ctx2.moveTo(10, 10);
ctx2.lineTo(290, 90);
ctx2.lineTo(290, 10);
ctx2.lineTo(10, 90);
ctx2.lineTo(10, 10);
ctx2.strokeStyle = "red";
ctx2.stroke();

ctx2.beginPath();
ctx2.moveTo(20, 20);
ctx2.lineTo(125, 50);
ctx2.lineTo(20, 80);
ctx2.closePath();
ctx2.strokeStyle = "blue";
ctx2.fillStyle = "red";
ctx2.lineWidth = 5;
ctx2.fill();
ctx2.stroke();

Notice how the changes to the context can be made any time before the calls to stroke() or fill().

Try removing the call to beginPath(). You should see that the original path is extended and the later calls to fill() and stroke() will apply to the entire path.

This is important to understand when drawing this way.

Example 3: Curves and circles

Drawing curves is a lot like drawing straight lines but we use control points to define the curvature.

quadraticCurveTo

The CanvasRenderingContext2D.quadraticCurveTo() method takes four parameters. The first pair of parameters are the coordinates for a control point that determines the curvature. The second pair of parameters are the coordinates for the destination.

Curves don’t necessarily pass through their control points, in fact they rarely do.

const ctx3 = example3.getContext("2d");
ctx3.strokeStyle = "red";
ctx3.lineWidth = 3;
ctx3.moveTo(10, 50);
ctx3.quadraticCurveTo(150, 90, 290, 50);
ctx3.stroke();

The line curves towards the control point, but doesn’t reach it.

We want to visualise the control point. One way is to draw a circle using CanvasRenderingContext2D.arc(). The arc() method takes five arguments. The first pair of arguments is the coordinates of the center of the arc. The third argument is the radius of the circle. The fourth and fifth arguments are the starting and ending angle, in radians.

For a circle, these are always 0 and 2 * Math.PI. If you want a semi-circle or a pac-man shape, you would use different angles.

const ctx3 = example3.getContext("2d");
ctx3.strokeStyle = "red";
ctx3.lineWidth = 3;
// The curve
ctx3.moveTo(10, 50);
ctx3.quadraticCurveTo(150, 90, 290, 50);
ctx3.stroke();
// start and end points
ctx3.fillStyle = "red";
ctx3.lineWidth = 3;
ctx3.beginPath();
ctx3.arc(10, 50, 5, 0, 2 * Math.PI);
ctx3.arc(290, 50, 5, 0, 2 * Math.PI);
ctx3.fill();
// control points
ctx3.fillStyle = "blue";
ctx3.strokeStyle = "blue";
ctx3.lineWidth = 1;
ctx3.beginPath();
ctx3.arc(150, 90, 5, 0, 2 * Math.PI);
ctx3.fill();
ctx3.beginPath();
ctx3.moveTo(10, 50);
ctx3.lineTo(150, 90);
ctx3.lineTo(290, 50);
ctx3.stroke();

The blue dot is the control point. So we can see how the quadratic curve pulls the line away from a straight path.

Now, we can refactor the code into a function that will allow us to place the control point wherever we want.

const ctx3 = example3.getContext("2d");
function drawMyCurve(ctlX, ctlY) {
    ctx3.strokeStyle = "red";
    ctx3.lineWidth = 3;
    ctx3.beginPath();
    ctx3.moveTo(10, 50);
    ctx3.quadraticCurveTo(ctlX, ctlY, 290, 50);
    ctx3.stroke();
    // start and end
    ctx3.fillStyle = "red";
    ctx3.lineWidth = 3;
    ctx3.beginPath();
    ctx3.arc(10, 50, 5, 0, 2 * Math.PI);
    ctx3.arc(290, 50, 5, 0, 2 * Math.PI);
    ctx3.fill();
    // control points
    ctx3.fillStyle = "blue";
    ctx3.strokeStyle = "blue";
    ctx3.lineWidth = 1;
    ctx3.beginPath();
    ctx3.arc(ctlX, ctlY, 5, 0, 2 * Math.PI);
    ctx3.fill();
    ctx3.beginPath();
    ctx3.moveTo(10, 50);
    ctx3.lineTo(ctlX, ctlY);
    ctx3.lineTo(290, 50);
    ctx3.stroke();
}
drawMyCurve(150, 50);

The above change is very minor. All we have done is placed the code inside a function and swapped out the hard-coded literal values for the control point coordinates with the arguments ctlX and ctlY.

Try tweaking the final line to use a different control point.

But we can do better than that. If we add a simple event listener, we can move the control point using the offsetX and offsetY properties of the mousemove event!

Add the following to the end of the code.

example3.addEventListener('mousemove', ev => {
    ctx3.clearRect(0, 0, 300, 100);
    drawMyCurve(ev.offsetX, ev.offsetY);
})

This event listener will fire whenever the mouse moves over the canvas. The event object contains information about the coordinates. So we have added two lines, first we clear the canvas using CanvasRenderingContext2D.clearRect() and then we redraw our curve with the new control point.

Try moving your mouse over the canvas below.

BezierCurveTo

We can do something very similar with CanvasRenderingContext2D.bezierCurveTo(). Except this time the function requires two control points. We will only move one of them.

const ctx3 = example3.getContext("2d");
function drawBezierCurve(ctlX1, ctlY1, ctlX2, ctlY2) {
    ctx3.strokeStyle = "red";
    ctx3.lineWidth = 3;
    ctx3.beginPath();
    ctx3.moveTo(10, 50);
    ctx3.bezierCurveTo(ctlX1, ctlY1, ctlX2, ctlY2, 290, 50);
    ctx3.stroke();
    // start and end
    ctx3.fillStyle = "red";
    ctx3.lineWidth = 3;
    ctx3.beginPath();
    ctx3.arc(10, 50, 5, 0, 2 * Math.PI);
    ctx3.arc(290, 50, 5, 0, 2 * Math.PI);
    ctx3.fill();
    // control points
    ctx3.fillStyle = "blue";
    ctx3.strokeStyle = "blue";
    ctx3.lineWidth = 1;
    ctx3.beginPath();
    ctx3.moveTo(10, 50);
    ctx3.lineTo(ctlX1, ctlY1);
    ctx3.stroke();
    ctx3.beginPath();
    ctx3.moveTo(290, 50);
    ctx3.lineTo(ctlX2, ctlY2);
    ctx3.stroke();
    ctx3.beginPath();
    ctx3.arc(ctlX1, ctlY1, 5, 0, 2 * Math.PI);
    ctx3.arc(ctlX2, ctlY2, 5, 0, 2 * Math.PI);
    ctx3.fill();
}
example3.addEventListener('mousemove', ev => {
    ctx3.clearRect(0, 0, 300, 100);
    drawBezierCurve(ev.offsetX, ev.offsetY, 200, 70);
})
drawBezierCurve(150, 50, 200, 70);

The resulting interface is not quite what we want.

We need to implement an interface that allows us to click and drag the control points. To do this we will need variables to hold the control point coordinates, which we will initialise with some arbitrary coordinates.

Place these variables at the top of the example, or at the top of the file if you prefer.

let cp1 = [100, 70];
let cp2 = [200, 70];

Alternatively, these could be objects with x and y properties. This would require slightly different code.

We also need to calculate the distance between the mouse and the to control points. This will allow us to detect whether the user has clicked near the blue circles. If they have, we want to enable movement.

For this we will define a simple function.

function distance(p1, p2) {
    return ((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2) ** 0.5;
}

For convenience, we also need variables to store the distances, which we will calculate when the mouse moves.

let dist1;
let dist2;

Finally, we also need a variable which will record which (if any) of the control points is selected.

When we move the mouse over the canvas, we want to also move the selected point. Unless neither are selected, in which case we won’t move anything.

let selected = 0;

With this in place, we can update our mousemove event listener as follows:

example3.addEventListener('mousemove', ev => {
    dist1 = distance(cp1, [ev.offsetX, ev.offsetY]);
    dist2 = distance(cp2, [ev.offsetX, ev.offsetY]);
    if(selected == 1) {
        cp1 = [ev.offsetX, ev.offsetY]
    } else if(selected == 2) {
        cp2 = [ev.offsetX, ev.offsetY]
    }
    ctx3.clearRect(0, 0, 300, 100);
    drawBezierCurve(cp1[0], cp1[1], cp2[0], cp2[1]);
})

We have added a distance calculation, so we always know how far the mouse it from each point.

Useful for potentially highlighting a point if it is under the mouse.

We have also added a conditional statement that will update the control point coordinates only if the selected variable is not zero.

The final change we made was to pass the control point variables into our drawBezierCurve function. This is critical as it makes the control points dynamic.

At this point, nothing much has changed. Because the selected variable is still always 0, the control points cannot be moved.

Though if you are following carefully, you should be able to update the value of cp1 in the console and then, moving the mouse over the canvas will apply the change.

The final step is to implement mouse clicking with mousedown and mouseup events which fire when the mouse button is pressed and released. Clicking a control point will trigger it to be updatable when the mouse button is down. Releasing the mouse button will trigger the point to stop moving with the mouse.

The mouseup event sets selected to zero, so nothing moves once the mouse button is released.

example3.addEventListener('mouseup', ev => { selected = 0; })

The mousedown event checks the distance variables and sets selected accordingly.

example3.addEventListener('mousedown', ev => {
    selected = 0;
    if(dist1 < 20) {
        selected = 1
    } else if(dist2 < 20) {
        selected = 2
    }
})

Now, try clicking and dragging the blue control points.

Example 4: Images and text

Adding text into your canvas is pretty easy using the fillText and strokeText methods.

Both methods take a string for the text plus an x and y coordinate which determine where to place the text on the canvas.

const ctx4 = example4.getContext("2d");
for (let i = 20; i <= 80; i += 10) {
    ctx4.fillText("Hello world", i, i);
}

The code above generates this.

You can set the font property of the context much like the strokeStyle and fillStyle properties. The textAlign and textBaseline properties can be changed to determine how the text is placed relative to the provided coordinates.

Adding images requires an image to be loaded into the page. This can be done programmatically or by simply adding an image into the page.

We will use this spritesheet as an example.

spritesheet

You can download it here.

Save the file into the same folder as your index.html file and add the image into the page, above the canvas elements.

<img id="spritesheet" src="spritesheet.svg" alt="spritesheet">

Notice we have given the image an id attribute.

Your index.html file should now look like this.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTML canvas example 1</title>
    <style>
        body {
            display: grid; 
            gap: 1em;
            place-items: center;
        }
        canvas {border: 1px solid black;}
    </style>
</head>
<body>
    <img id="spritesheet" src="spritesheet.svg" alt="spritesheet">
    <canvas id="example1" width="300" height="100"></canvas>
    <canvas id="example2" width="300" height="100"></canvas>
    <canvas id="example3" width="300" height="100"></canvas>
    <canvas id="example4" width="300" height="100"></canvas>
    <canvas id="example5" width="300" height="100"></canvas>
    <script src="scripts.js"></script>
</body>
</html>

Now we can use JavaScript to get the image.

A spritesheet variable will be automatically declared for us to refer to the image element because we set the id attribute of the image.

Replace the fillText code with this.

const ctx4 = example4.getContext("2d");
ctx4.drawImage(spritesheet, 0, 0, 300, 100);

The drawImage method takes the provided image and renders it on the canvas in a rectangle which is specified with four parameters just like fillRect. The first two parameters determine the coordinates of the top-left corner of the image. The last two parameters determine the width and height of the rendered image.

We have squeezed the entire image so it covers the canvas.

Warning: this may not work reliably because the script can run before the image has been loaded. This can be solved by running any code which needs spritesheet only once the image has loaded.

const ctx4 = example4.getContext("2d");
spritesheet.addEventListener("load", ev => {
  ctx4.drawImage(spritesheet, 0, 0, 300, 100);
});

As it happens, we know that the original image is 256px wide and 384px high. It contains 16 sprites, each of which is 64px wide and 96px high.

If we want to display just one of the sprite images, we can do this by specifying an additional four parameters before the canvas coordinates. Similarly, these define a rectangle within the original image.

For example, to show the first sprite, we need to select a rectangle starting in the top-left corner of our image which is 64px wide and 96px high.

const ctx4 = example4.getContext("2d");
spritesheet.addEventListener("load", ev => {
    ctx4.drawImage(spritesheet, 0, 0, 64, 96, 0, 0, 300, 100);
});

To avoid stretching the image, we need to specify (with the last four parameters) a region with the same aspect ration as our sprite. We can set the width and height to 64 and 96.

const ctx4 = example4.getContext("2d");
spritesheet.addEventListener("load", ev => {
    ctx4.drawImage(spritesheet, 0, 0, 64, 96, 0, 0, 64, 96);
});

If we want to display a different sprite, it’s complicated working out the correct numbers, so we can define a function for this.

const ctx4 = example4.getContext("2d");

function drawSprite(col, row, x, y) {
    const w = spritesheet.naturalWidth / 4;
    const h = spritesheet.naturalHeight / 4;
    const sx = w * col;
    const sy = h * row;
    ctx4.drawImage(spritesheet, sx, sy, w, h, x, y, w, h);
}

spritesheet.addEventListener("load", ev => {
    drawSprite(1, 2, 118, 0);
});

The function takes four arguments. The first two arguments determine which sprite is selected from the spritesheet. The second two arguments determine where the sprite will be placed on the canvas.

We first calculate the width and height of a sprite by dividing the image width and height by the total number of rows and columns in the spritesheet. The values 4 and 4 are specific to this spritesheet image and would need to be changed for a different image.

We then calculate the location in the spritesheet of our chosen sprite. Since we have selected the sprite in row 1, column 2, we multiply the width by 1 and the height by 2.

The top-left sprite is on row 0, column 0.

We then simply call drawImage with these calculated values and pass through the requested location on the canvas (x and y). We have located our image in the middle of the canvas.

It’s now easy to draw any sprite from the spritesheet.

spritesheet.addEventListener("load", ev => {
    drawSprite(0, 0, 22, 0);
    drawSprite(0, 1, 86, 0);
    drawSprite(0, 2, 150, 0);
    drawSprite(0, 3, 212, 0);
});

As a final example, we can capture the W, A, S and D key events and display a given sprite accordingly.

We will do this with a switch statement to set the row differently depending on which key was pressed (referencing ev.key).

let col = 0;
let row;
window.addEventListener("keydown", ev => {
    switch(ev.key) {
        case "s":
            row = 0
            break;
        case "w":
            row = 1
            break;                
        case "a":
            row = 2
            break;
        case "d":
            row = 3
            break;
    }
    drawSprite(col, row, 118, 0);
    col = (col + 1) % 4;
});

Pressing the W, A, S and D keys should now update the sprite.

Try it here, it should affect the canvas above.

Example 5: Translate, scale and rotate

When we draw to the canvas, we have seen that the coordinate system has its origin at the top-left corner of the canvas.

We can see this if we draw a circle at coordinate (0, 0).

const ctx5 = example5.getContext("2d");
ctx5.arc(0, 0, 30, 0, 2 * Math.PI);
ctx5.fill();

The has been given a radius of 30px, but it’s center is directly over the top left corner of the canvas and so most of the circle is not visible.

translate

We can move the origin by calling CanvasRenderingContext2D.translate() with pixel values for the x and y direction. For example, if we want the whole of our circle (30px radius) to be within the canvas, then we can shift the origin to position (30, 30) before we draw our arc.

const ctx5 = example5.getContext("2d");
ctx5.translate(30, 30);
ctx5.arc(0, 0, 30, 0, 2 * Math.PI);
ctx5.fill();

rotate

Using rotate is similar, if we want to draw a square rotated by 45 degrees, this is pretty easy. We just need to think in radians. So 45 degrees is an eighth of a full rotation, which is Math.PI / 4.

const ctx5 = example5.getContext("2d");
ctx5.translate(150, 50);
ctx5.rotate(Math.PI / 4);
ctx5.fillRect(-15, -15, 30, 30);

In the above example, we first move the origin to the center of the canvas and then we rotate (around the origin) by 45 degrees (Math.PI / 4 in radians). Once the canvas has been rotated, we draw a 30px by 30px square at position (-15, -15) which is the top-left corner of the square.

Its important to understand that transformations are a property of the canvas context, they are equivalent to moving the paper under the pen and are additive, i.e. they apply on top of each other.

save and restore

We can use the CanvasRenderingContext2D.save() and CanvasRenderingContext2D.restore() methods to store the current status of the canvas context (including strokeStyle, font etc., as well as all transformations) and reload an old, saved setting.

const ctx5 = example5.getContext("2d");
ctx5.translate(50, 50);
ctx5.fillStyle = "hsla(0, 50%, 50%, 0.4)";
ctx5.strokeStyle = "hsla(0, 50%, 30%, 0.5)";
Array(21).fill().forEach((item, index) => {
    ctx5.save();
    ctx5.beginPath();
    ctx5.translate(10 * index, 0);
    ctx5.rotate(Math.PI / 20 * index);
    ctx5.rect(-25, -25, 50, 50);
    ctx5.fill();
    ctx5.stroke();
    ctx5.restore();
});

In this code we are looping over an array with 21 elements. For each iteration we draw a square but each square is translated and rotated a little differently. We are able to avoid these transformations adding together by including calls to save() and restore(). The save() call saves the state of the context (which is includes one translation). Then we translate and rotate the context and we add the square to the path. Finally, we restore the context back to the saved state.

Try a few experiments:

You will see that the transformations combine in different ways which can be very confusing. Make sure you understand what’s going on.

requestAnimationFrame

Finally, we can explore animating a simple rotating square. We will define an angle variable which will be passed into rotate.

const ctx5 = example5.getContext("2d");

let angle = 0;

Then we will define a function to call which will draw each frame. This function should clear the entire canvas before drawing.

function frame() {
    angle += 0.03;
    ctx5.clearRect(0, 0, 300, 100);
    ctx5.save();
    ctx5.beginPath();
    ctx5.translate(150, 50);
    ctx5.rotate(angle);
    ctx5.rect(-15, -15, 30, 30);
    ctx5.stroke();
    ctx5.restore();
    requestAnimationFrame(frame)
}

The way we animate is we call the built-in window.requestAnimationFrame function. This tells the browser we want it to call the provided function the next time it redraws the page. Using this approach is essential when animating on a canvas because it ensures we run our frame function exactly once each frame.

The usual pattern is to call requestAnimationFrame at the end of our frame function to indicate we are now ready for another frame.

If our function takes longer than a frame to complete then the browser will happily refresh without calling the function again.

function frame() {
    angle += 0.03;
    ctx5.clearRect(0, 0, 300, 100);
    ctx5.save();
    ctx5.beginPath();
    ctx5.translate(150, 50);
    ctx5.rotate(angle);
    ctx5.rect(-15, -15, 30, 30);
    ctx5.stroke();
    ctx5.restore();
    requestAnimationFrame(frame)
}
requestAnimationFrame(frame)

Notice that we call requestAnimationFrame once at the end of the script to begin the process.

Elaborate on all the examples

At this point you should be experimenting and being creative.

Create a series of five examples of your own, based on the examples in this exercise.

In each case:

  • Extract the example into a separate HTML file with its own script file.
  • Change the canvas dimensions
  • Begin with the example code
  • Modify the code to do something a bit different

Become familiar with the capabilities of the CanvasRenderingContext2D API.

Now take an idea and run with it. Create your JavaScript assignment submission.