Drawing with the Canvas (revisited)
Scaling and rotating
If you want to draw things at a different scale or rotation, you don't change the drawing command. Rather, you change the canvas' transformation matrix. Unlike typical transformation you do in Photoshop and the like, we're not actually rotating the image itself - we're effectively changing the way in which subsequent drawing operations will happen.
There are three helper methods to manipulate the matrix: rotate, scale and translate.
The use of the matrix can significantly help with some operations. Rather than doing a lot of tricky math to figure out coordinates when you want to arrange things on a circular path, for example, you can simply use rotate
and let the canvas do the heavy lifting.
Rotation
Use ctx.rotate(radians)
to rotate the matrix.
var canvas = document.getElementById('canvas');
var ctx = canvas.getContext('2d');
// Conversion helper
let degreesToRadians = (d) => d * Math.PI / 180;
// Rotate takes radian angles. Rotate by 5 degrees
ctx.rotate(degreesToRadians(5));
// Draw a blue rectangle
ctx.fillStyle = 'blue';
ctx.fillRect(10,10,50,50);
By default the rotation happens from 0,0 - i.e, the top-left of the canvas. It's easy it your drawing code to seem to break when using larger rotations because you're effectively drawing off the bounds of the canvas.
To fix this, use translate
first to set the center of the rotation.
Translate
Translation, via ctx.translate(x,y)
basically changes the origin of the canvas. After a translation is applied, the meaning of coordinates changes:
// Translate x by 50 and y by 10
ctx.translate(50, 10);
// Draw a rectangle at 0,0
ctx.fillStyle = 'blue';
ctx.fillRect(0,0,50,50);
Note that the rectangle is appearing at x:50 and y:10, even though we've used the coordinates 0,0 when filling it. That's translation at work.
Scale
You can scale a drawing operation with ctx.scale(x, y)
, where x and y is a percentage (1 = 100%, no scaling, 0.5 = half size, 2.0 = double sized, and so on)
The below example uses fillRect
to draw a centered 100x100 px square, but because of the scaling, it appears squished and off-centered:
// Scale x by 150%, y by 50%
ctx.scale(1.5, 0.5);
// Draw a rectangle at 0,0
ctx.fillStyle = 'blue';
let size = 100;
ctx.fillRect(
ctx.canvas.width/2-size/2,ctx.canvas.height/2-size/2,
size,size
);
Resetting
After changing the matrix, all subsequent drawing operations will be affected. Calling rotate
, scale
or translate
again in an 'opposite' direction doesn't work the way you expect since the effect is compounded.
To be able to use different transformations as you go, use save
to save the current state of the matrix, and restore
to flip back to it:
ctx.save(); // Save the state
// Tweak the transformation matrix by using .rotate, .scale and/or .translate
ctx.restore(); // Continue now the way it was before
// Keep on drawin'
If this pattern doesn't work in your situation, you can reset the matrix by calling setTransform
directly with default values:
ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset matrix
Here's an example of applying two different sets of transforms:
ctx.save(); // Save current state
// Scale X, draw text
ctx.scale(5, 1);
ctx.fillText("Hello", 10, 50);
ctx.restore(); // Flip back to saved state
// Scale Y and rotate, draw text
ctx.scale(1, 5);
ctx.rotate(Math.PI/9);
ctx.fillText("again", 100, -20);
Read more
Reference:
Animation
Drawing a single graphic is fine enough, but it gets much more interesting when your graphics are dynamic and reactive! For that we want to animate things. Conceptually, animation is really simple - you simply draw over and over again.
We'll set that up by making a new starter skeleton:
// Assumes onDocumentReady is called elsewhere
function onDocumentReady() {
// Set up event listeners
window.addEventListener('resize', onResize);
onResize(); // Manually trigger first time
// Request 'draw' be called when the browser has a moment
window.requestAnimationFrame(draw);
}
function draw() {
let ctx = document.getElementById('canvas').getContext('2d');
// Your drawing code here!
// After drawing, ask the browser to call 'draw' again
// when it's about to repaint:
window.requestAnimationFrame(draw);
}
function onResize() {
var canvas = document.getElementById('canvas');
// Size canvas to match actual pixels
canvas.width = document.body.offsetWidth;
canvas.height = document.body.offsetHeight;
}
Each time you draw, rather than drawing the exact same thing, you draw according to some parameters that are changing.
In the below example, we get a strobing rainbow effect:
let hue = 0;
function draw() {
let ctx = document.getElementById('canvas').getContext('2d');
hue++;
ctx.fillStyle = 'hsl(' + (hue%360) +',100%,50%)';
ctx.fillRect(0,0,ctx.canvas.width, ctx.canvas.height);
// After drawing, ask the browser to call 'draw' again
// when it's about to repaint:
window.requestAnimationFrame(draw);
}
hue
is defined outside the function, and each time we draw, it's incremented. This will quickly get the number far beyond the range of 0-360, so what we do is use the modulo (or [remainder](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Arithmetic_Operators#Remainder())) operator %
to make this number wrap between 0-360. Using modulo for animation is a pretty common pattern.
You might want to be more specific about when your counter resets, or what it resets to. Another pattern is to increment, and then check the value, possibly resetting it if necessary.
In this example we cycle back and forth between 90 and 180:
let hue = 90;
let stepBy = 1;
function draw() {
let ctx = document.getElementById('canvas').getContext('2d');
hue = hue + stepBy;
if (hue >= 180) {
// start decrementing instead
stepBy = -1;
} else if (hue <= 90 && stepBy == -1) {
// start incrementing again
stepBy = 1.0;
}
ctx.fillStyle = 'hsl(' + hue +',100%,50%)';
ctx.fillRect(0,0,ctx.canvas.width, ctx.canvas.height);
// After drawing, ask the browser to call 'draw' again
// when it's about to repaint:
window.requestAnimationFrame(draw);
}
Frame drawing
So far redrawing has been pretty simple since we're painting the whole canvas. Let's draw according to something dynamic: the pointer position.
If the following is added to the onDocumentReady
, we can easily keep track of the current pointer coordinates:
document.getElementById('canvas').addEventListener('pointermove', e=>{
window.pointerX = e.clientX;
window.pointerY = e.clientY;
});
Now, draw a rectangle according to the last known coordinates:
let ctx = document.getElementById('canvas').getContext('2d');
// ctx.clearRect(0,0,ctx.canvas.width,ctx.canvas.height);
let size = 50;
// Fill a rectangle centred on the pointer
ctx.fillRect(window.pointerX-(size/2),window.pointerY-(size/2),size,size);
window.requestAnimationFrame(draw);
As we keep redrawing a rectangle, it visually smudges across the screen leaving a trace. It's not necessary to keep track of where the pointer has been before - it's simply the canvas accumulating "paint" which provides this effect.
To try not leaving a trail behind, you can clear the frame before drawing on it, for example with fillRect
or clearRect
:
ctx.clearRect(0,0,ctx.canvas.width,ctx.canvas.height);
Incremental drawing
There's some other things to try with this basic drawing set up. What if the drawing should be translucent, so the more you go over the same area, the darker it appears.
One way to do this is, before drawing, set the fill style to something with opacity:
ctx.fillStyle = 'hsla(0,100%,50%,0.1)';
ctx.fillRect(window.pointerX-(size/2),window.pointerY-(size/2),size,size);
Another option is to set globalAlpha
, the opacity of any drawing operation which follows.
ctx.globalAlpha = 0.1;
ctx.fillRect(window.pointerX-(size/2),window.pointerY-(size/2),size,size);
Another technique is to paint will full opacity as we were doing originally, but fade out the entire canvas a little bit each time we draw. This makes the contents of the canvas fade out over time. There's a few ways of doing this, but a simple option for solid backgrounds is just to fillRect
with a translucent colour:
// Fade out the entire canvas
ctx.fillStyle = 'rgba(255,255,255,0.2)'; // Tweak alpha value to change how fast things fade
ctx.fillRect(0,0,ctx.canvas.width,ctx.canvas.height);
// ..and then draw at pointer as usual
ctx.fillStyle = 'black';
ctx.fillRect(window.pointerX-(size/2),window.pointerY-(size/2),size,size);
Read more: globalAlpha
Read more
Blending mode
Like Photoshop's blending mode feature, you can also change how drawing operations combine with pixels which are already on the canvas with the globalCompositeOperation property.
Read more
- Compositing (MDN)
Read more
Example: Rainbow comet
This example shows a colour-shifting comet that orbits a point. We're also using two functions described on the ./math. A translation is used so drawing happens in reference to the center of the canvas.
let angle = 0;
function drawCircular() {
let ctx = document.getElementById('canvas').getContext('2d');
// Fade out the entire canvas
ctx.fillStyle = 'rgba(255,255,255,0.2)';
ctx.fillRect(0,0,ctx.canvas.width,ctx.canvas.height);
ctx.save();
// Set center to be 0,0
ctx.translate(ctx.canvas.width/2, ctx.canvas.height/2);
let radius = ctx.canvas.width/3;
// Get a point orbiting circle
let pointCoord = polarToCartesian(degreesToRadians(angle%360), radius, 0, 0);
// Change hue according to angle
ctx.fillStyle = 'hsl(' + (angle%360) +',100%,50%)';
// Fill a little circle
ctx.beginPath();
ctx.arc(pointCoord.x,pointCoord.y,5, 0, 2*Math.PI);
ctx.fill();
ctx.closePath();
// Increment angle
angle++;
ctx.restore();
window.requestAnimationFrame(drawCircular);
}