A pattern for responsive canvas illustrations
Making interactive visualisations can be hard to get right, and one particular aspect that can be difficult on the web platform is making visualisations responsive, meaning that they behave well on screens of all sizes, and respond sensibly when undergoing size changes (most commonly a window resize or device rotation). In this post I’ll share a pattern I’ve been using for achieving responsive drawings in a simple way. The result is an illustration which is dynamically drawn using Javascript, whose size is set via CSS, and which gracefully handles resizes and even entering a kind of “fullscreen mode”. Try resizing your browser window or rotating your device, and watch the result:
Having an illustration (whether it be SVG, canvas, or WebGL) whose dimensions are purely determined by CSS is fantastic from a design usability point of view, since it decouples the image drawing logic from the page design. (This is in contrast to many introductory tutorials to canvas and WebGL, which fix dimensions up-front in Javascript).
The main gist of the pattern is that a <canvas>
element will always check its page dimensions before drawing, and update its internal dimensions appropriately (accounting for a high-DPI correction), with a ResizeObserver triggering re-draws on every resize. Of course the devil is in the details, especially if you want to be able to overlay more data on top of the canvas using absolute positioning and so on. Read on for all the details, or skip to the end if you just want the code.
This is part 1 of a series on things I learned while creating LieVis.
Step 1: An illustration
The first step is having something to draw, and determining what you want the end result to look like (keeping in mind you won’t have strict control over the dimensions of the resulting image). I’ve chosen to draw:
- A dark background,
- Two red sine waves extending for the whole width of the image, and
- A blue Lissajous curve, fitting inside a square whose side length is the smaller of width and height.
You can find the basic drawing code in responsive-canvas.ts
, and any extra code embedded in the source of this HTML file. Our first attempt is to draw a <canvas>
with a fixed width and height.
<canvas id="canvas-step1" width="300" height="200"></canvas>
<script type="module">
import {plotImage} from './responsive-canvas.js'
let canvas = document.getElementById('canvas-step1')
let ctx = canvas.getContext('2d')
plotImage(ctx, canvas.width, canvas.height)
</script>
which results in something behaving a lot like a raw <img>
tag:
This is a good starting point, but it has two deficiencies: the size is fixed up-front, and the image is not corrected for high-DPI displays (if you happen to be on a high-DPI display, you’ll notice immediately that the image is not sharp). Attempting to change the size via CSS goes poorly, for example manually setting {height: 150px; width: 300px}
on the canvas gives a warped image:
Step 2: Dynamically resizing
The warped image above shows that there are two different coordinate systems at play. Each <canvas>
element is backed by a rectangular array of pixels, whose dimensions are (canvas.width, canvas.height)
: this is the buffer that is drawn to using ctx.lineTo(x, y)
and so on. On the other hand, the dimensions that the <canvas>
element take up on the screen are determined by whatever CSS rules are in play, and need not bear any relationship to the internal canvas dimensions. Image distortion will happen when these get out-of-sync, just like with an <img>
element.
To fix this, we will resize the internal <canvas>
buffer before each re-draw by assigning to the canvas.width
and canvas.height
attributes. The dimensions the <canvas>
element is currently shown at on the page can be read in Javascript using (canvas.clientWidth, canvas.clientHeight)
1, so our drawing function becomes:
function redraw() {
// Make internal buffer dimensions match CSS dimensions.
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
let ctx = canvas.getContext('2d')
plotImage(ctx, canvas.width, canvas.height)
}
This will work when the element is first drawn to the page, but it may become distorted after this due to page resizes, device rotations, etc. The now-widely-supported ResizeObserver API allows us to trigger a redraw any time the canvas element is resized, with a simple line2 of code:
new ResizeObserver(() => redraw()).observe(canvas)
This time we will wrap the <canvas>
element in a <figure>
element. The <canvas>
element will be set to width and height 100%
, while the <figure>
element will have a responsive height and width from CSS. For the sake of terseness I’ll write the styles inline:
<figure>
<canvas id="liss-step2" style="width: 100%; height: 100%;"></canvas>
</figure>
<script type="module">
import {plotImage} from './responsive-canvas.js'
let canvas = document.getElementById('liss-step2')
function redraw() {
// Make internal buffer dimensions match CSS dimensions.
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
let ctx = canvas.getContext('2d')
plotImage(ctx, canvas.width, canvas.height)
}
new ResizeObserver(() => redraw()).observe(canvas)
</script>
The result is below. I am also animating the width
of the <figure>
element using the Web Animations API, to demonstrate the responsiveness of the image. The width animation knows nothing about the canvas, but the canvas is re-sizing itself as necessary. You can if you like.
Something quite pleasant about the ResizeObserver API is that a resize event will be fired as soon as an element is added using .observe()
3, so we didn’t even need an initial call to redraw()
to put the image on the page.
Step 3: Correcting for DPI
If you’re using a high-DPI display, you’ll notice that the previous few drawings have not been sharp. There is a simple fix for this (a fix which leaves our drawing code entirely untouched!), but to understand what is going on we need a little background.
High-DPI displays use more pixels on the same physical screen area to display sharper images. This has the potential to cause a nasty surprise for developers though: for example a developer might expect a 38-pixel-high font to display at approximately 1 cm physically, but on a high-DPI display with double density, that 38 pixels might only display at 0.5 cm physically, causing everything on the screen to become tiny. Various platforms (Android, iOS, Windows, etc) have various solutions to this, but in the end they all start working with two different sets of coordinates: abstract pixels and device pixels. Abstract pixels (sometimes called device-independent pixels) should show up as the-same-size-ish on all devices, and so most developers should target abstract pixels.
On the web platform, the px
unit in CSS denotes an abstract pixel length, and the device pixel ratio or DPR gives the conversion factor between the CSS px
length and the actual number of underlying device pixels. (Careful: DPR could be fractional rather than a whole number). The DPR can be read from window.devicePixelRatio
. So far we have been setting the canvas width and height to agree with the clientWidth
and clientHeight
properties, but these properties use units of CSS pixels, rather than device pixels. We need to multiply these by the DPR to get the desired dimensions of the canvas in device pixels. This helper function does the calculations:
function canvasDims(canvas) {
let dpr = window.devicePixelRatio // DPR: How many device pixels per CSS pixel (can be fractional)
let cssWidth = canvas.clientWidth // CSS dimensions of canvas
let cssHeight = canvas.clientHeight
let pxWidth = Math.round(dpr * cssWidth) // Dimensions we should set the backing buffer to.
let pxHeight = Math.round(dpr * cssHeight)
return {dpr, cssWidth, cssHeight, pxWidth, pxHeight}
}
At the start of the redraw function, we’ll change its dimensions to be in device pixels rather than CSS pixels:
function redraw() {
// Make internal buffer dimensions match DPI-corrected CSS dimensions.
let {cssWidth, cssHeight, pxWidth, pxHeight, dpr} = canvasDims(canvas)
canvas.width = pxWidth
canvas.height = pxHeight
Now a hitch: our plotImage(context, width, height)
function is expecting to be given width
and height
in CSS dimensions. If we were to give it device dimensions, everything (text, line widths, etc) would end up being too small on high-DPI displays. A neat trick to fix this is to use the transformation matrix on the drawing context to scale everything up by dpr
before drawing:
let ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
plotImage(ctx, cssWidth, cssHeight)
}
Now we can use our completely unmodified drawing code, but with a scaled context, to get the right image. This is a good philosophy to follow in general: work mostly in CSS coordinates, treating DPR as an output-only issue4. Especially if you’re doing interactive visualisations, all event listeners on the page are going to return pointer coordinates in the CSS coordinate system, and all absolutely positioned elements are going to be in CSS coordinates, so it is best to work there as much as possible.
The full DPI-corrected code is:
<figure>
<canvas id="liss-step3" style="width: 100%; height: 100%;"></canvas>
</figure>
<script type="module">
import {canvasDims, plotImage} from './responsive-canvas.js'
let canvas = document.getElementById('liss-step3')
function redraw() {
// Make internal buffer dimensions match DPI-corrected CSS dimensions.
let {cssWidth, cssHeight, pxWidth, pxHeight, dpr} = canvasDims(canvas)
canvas.width = pxWidth
canvas.height = pxHeight
let ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
plotImage(ctx, cssWidth, cssHeight)
}
new ResizeObserver(() => redraw()).observe(canvas)
</script>
and our lovely crisp image:
If you’re not on a high-DPI display, you probably haven’t seen a difference between any of these images, but try instead zooming the page into 150% or so. Page zooms usually change the DPR, so you should see the earlier images become blown-up and fuzzy, while the later images stay crisp.
Final result (and fullscreen)
As a last touch, I often want my illustrations (any overlays included) to be able to “pop out” and cover the whole screen. This requires one more wrapping element between the <figure>
and the <canvas>
, so that the HTML looks like:
<figure> <!-- Figure whose width is set by HTML/CSS -->
<div> <!-- The wrapping element -->
<canvas> <!-- The picture -->
<button> <!-- Controls -->
This seems to be a redundant level of wrapping, but both elements play a role:
- The
<div>
will be positioned (it will be either{position: relative}
or{position: fixed}
) in order to provide an anchor point for the absolutely-positioned canvas, controls, and any other overlays. When on the page it will have width and height set to100%
, and when popped to fullscreen it will have width and height equal to the viewport. - The
<figure>
element has a constrained size, and will maintain the illustration’s position on the page when it is popped out. This stops the page moving around underneath the fullscreen overlay, so that when the illustration is popped back in, the user is still at their same position.
We’ll finish up with a full code example. Firstly the CSS utility classes we’ll be using:
figure { max-width: 100%; height: 250px; }
.relative-full { position: relative; width: 100%; height: 100%; }
.fixed-fullscreen { position: fixed; left: 0; top: 0; width: 100vw; height: 100vh; z-index: 1; }
.inset { position: absolute; left: 5px; top: 5px; }
And a couple more comments:
- The wrapping
<div>
will be.relative-full
when it is on the page, and change to.fixed-fullscreen
when in fullscreen mode. In this mode it has az-index
of 1, to make sure nothing from the page below “pokes out”. - When in fullscreen mode, we also need to add
{overflow: hidden}
to the<body>
element, which hides the scroll bar. We’ll do this directly using Javascript. - One last gotcha: for this fullscreen to work well on mobile, you need to have configured a viewport meta tag, i.e. put something like this in the
<head>
of the document:
<meta name="viewport" content="width=device-width, initial-scale=1">
Without further ado, the code:
<figure>
<div id="div-fullscreen" class="relative-full">
<canvas id="liss-fullscreen" class="relative-full"></canvas>
<button id="button-fullscreen" type="button" class="inset">Fullscreen toggle</button>
</div>
</figure>
<script type="module">
import {canvasDims, plotImage} from './responsive-canvas.js'
let canvas = document.getElementById('liss-fullscreen')
function redraw() {
// Make internal buffer dimensions match DPI-corrected CSS dimensions.
let {cssWidth, cssHeight, pxWidth, pxHeight, dpr} = canvasDims(canvas)
canvas.width = pxWidth
canvas.height = pxHeight
let ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
plotImage(ctx, cssWidth, cssHeight)
}
new ResizeObserver(() => redraw()).observe(canvas)
let div = document.getElementById('div-fullscreen')
let button = document.getElementById('button-fullscreen')
let isFullscreen = false
button.addEventListener('click', function () {
if (!isFullscreen) {
isFullscreen = true
div.classList.remove('relative-full')
div.classList.add('fixed-fullscreen')
document.body.style.overflow = 'hidden'
} else {
isFullscreen = false
div.classList.remove('fixed-fullscreen')
div.classList.add('relative-full')
document.body.style.overflow = ''
}
})
</script>
And final result (the same as the illustration at the beginning):
This has proven for me to be a solid base to work off when doing 2D illustrations in canvas and WebGL (SVG is similar, but no DPR correction needs to be applied), allowing a complex illustration with many overlays to be resized and fullscreened correctly.
Further notes
I did first start trying to implement fullscreen by using the Fullscreen API on the wrapping <div>
element: everything “just works”, but unfortunately the fullscreen API is not supported on all devices, notably iOS devices. I also find the API itself a little odd: you can call div.requestFullScreen()
which is great, but then to listen for whether full-screen control has been taken away, you need to register a listener on document
rather than on div
.
-
clientWidth
andclientHeight
exclude borders, margins, and vertical scrollbars, but includes padding. I recommend you don’t include any padding on your<canvas>
elements! ↩ -
When building components for a long-lived page, make sure to keep a reference to the
ResizeObserver
and callobserver.disconnect()
when the component is destroyed. ↩ -
The relevant text from the spec is: Observation will fire when observation starts if Element is being rendered, and Element’s size is not 0,0. ↩
-
For those interested in doing 2D drawings in WebGL, the same principle applies: work mostly in CSS coordinates, and treat the transformation to clip space, as well as the DPR scaling, as an output-only issue. ↩