Graphing with Web Components 3: Canvas
ndesmic
Posted on May 11, 2021
So far we've written the same line graph/scatterplot using SVG and WebGL. This time we'll look at canvas. While I think canvas is much more straight-forward especially once you understand how the point scaling works, there are some interesting features that I find are often underused like scaling and rendering off the main thread. Let's take a look.
Boilerplate
Pretty much the same, though point-wise it'll work more similar to the SVG variant since we're not as constrained as we were with WebGL.
function windowValue(v, vmin, vmax, flipped = false) {
v = flipped ? -v : v;
return (v - vmin) / (vmax - vmin);
}
function hyphenCaseToCamelCase(text) {
return text.replace(/-([a-z])/g, g => g[1].toUpperCase());
}
class WcGraphCanvas extends HTMLElement {
#points = [];
#width = 320;
#height = 240;
#xmax = 100;
#xmin = -100;
#ymax = 100;
#ymin = -100;
#func;
#step = 1;
#thickness = 1;
#continuous = false;
#defaultShape = "circle";
#defaultSize = 2;
#defaultColor = "#F00"
static observedAttributes = ["points", "func", "step", "width", "height", "xmin", "xmax", "ymin", "ymax", "default-shape", "default-size", "default-color", "continuous", "thickness"];
constructor() {
super();
this.bind(this);
}
bind(element) {
element.attachEvents.bind(element);
}
attachEvents() {
}
connectedCallback() {
this.attachShadow({ mode: "open" });
this.canvas = document.createElement("canvas");
this.shadowRoot.appendChild(this.canvas);
this.canvas.height = this.#height;
this.canvas.width = this.#width;
this.context = this.canvas.getContext("2d");
this.render();
this.attachEvents();
}
render(){
}
attributeChangedCallback(name, oldValue, newValue) {
this[hyphenCaseToCamelCase(name)] = newValue;
}
set points(value) {
if (typeof (value) === "string") {
value = JSON.parse(value);
}
value = value.map(p => ({
x: p[0],
y: p[1],
color: p[2] ?? this.#defaultColor,
size: p[3] ?? this.#defaultSize,
shape: p[4] ?? this.#defaultShape
}));
this.#points = value;
this.render();
}
get points() {
return this.#points;
}
set width(value) {
this.#width = parseFloat(value);
}
get width() {
return this.#width;
}
set height(value) {
this.#height = parseFloat(value);
}
get height() {
return this.#height;
}
set xmax(value) {
this.#xmax = parseFloat(value);
}
get xmax() {
return this.#xmax;
}
set xmin(value) {
this.#xmin = parseFloat(value);
}
get xmin() {
return this.#xmin;
}
set ymax(value) {
this.#ymax = parseFloat(value);
}
get ymax() {
return this.#ymax;
}
set ymin(value) {
this.#ymin = parseFloat(value);
}
get ymin() {
return this.#ymin;
}
set func(value) {
this.#func = new Function(["x"], value);
this.render();
}
set step(value) {
this.#step = parseFloat(value);
}
set defaultSize(value) {
this.#defaultSize = parseFloat(value);
}
set defaultShape(value) {
this.#defaultShape = value;
}
set defaultColor(value) {
this.#defaultColor = value;
}
set continuous(value) {
this.#continuous = value !== undefined;
}
set thickness(value) {
this.#thickness = parseFloat(value);
}
}
customElements.define("wc-graph-canvas", WcGraphCanvas);
Drawing
We've done this a few time so let's just jump straight into it.
let points;
if(this.#func){
points = [];
for (let x = this.#xmin; x < this.#xmax; x += this.#step) {
const y = this.#func(x);
points.push({ x, y, color: this.#defaultColor, size: this.#defaultSize, shape: this.#defaultShape});
}
} else {
points = this.#points;
}
points = points.map(p => ({
x: windowValue(p.x, this.#xmin, this.#xmax) * this.#width,
y: windowValue(p.y, this.#ymin, this.#ymax, true) * this.#height,
color: p.color,
size: p.size,
shape: p.shape
}));
We can start by mapping out the points, scaling them and if it's a function creating them.
Then let's do a rough draw:
for(const point of points){
this.context.fillStyle = point.color;
this.context.fillRect(point.x, point.y, point.size * 2, point.size * 2);
}
Here size needs to be doubled because it's a radius and fillRect
takes the length of a rectangle.
And we're up and running:
Let's add the not-so-useful guides:
this.context.clearRect(0,0,this.#width,this.#height);
this.context.strokeStyle = "#000";
this.context.moveTo(this.#width / 2, 0);
this.context.lineTo(this.#width / 2, this.#height);
this.context.moveTo(0, this.height / 2);
this.context.lineTo(this.#width, this.#height / 2);
this.context.stroke();
Shapes
Shapes are about the same as SVG only we need to pass in the context to draw on:
function createShape(context, shape, [x, y], size, color) {
const halfSize = size / 2;
switch (shape) {
case "circle": {
context.fillStyle = color;
context.beginPath();
context.ellipse(x, y, size, size, 0, 0, Math.PI * 2);
context.closePath();
context.fill();
break;
}
case "square": {
context.fillStyle = color;
context.fillRect(x - halfSize, y - halfSize, size * 2, size * 2);
break;
}
}
}
And the drawing loop gets simpler:
for(const point of points){
createShape(this.context, point.shape, [point.x, point.y], point.size, point.color);
}
And we get nice points instead of rectangles:
Continuous lines
Now we can finally add continuous lines.
if(this.#continuous){
this.context.strokeStyle = this.#defaultColor;
this.context.lineWidth = this.#thickness;
this.context.beginPath();
this.context.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
this.context.lineTo(points[i].x, points[i].y);
}
this.context.stroke();
}
As we can see canvas is probably the easiest to implement overall but we won't get scaling for free.
Scaling
Thankfully this isn't too hard. The underlying concept is that we need to scale the canvas by the devicePixelRatio
but maintain the same CSS size. So first we set up the canvas:
connectedCallback() {
this.attachShadow({ mode: "open" });
this.canvas = document.createElement("canvas");
this.shadowRoot.appendChild(this.canvas);
const dpr = window.devicePixelRatio;
this.canvas.height = this.#height * dpr;
this.canvas.width = this.#width * dpr;
this.canvas.style.height = this.#height + "px";
this.canvas.style.width = this.#width + "px";
this.context = this.canvas.getContext("2d");
this.context.scale(dpr, dpr);
this.render();
this.attachEvents();
}
Here we need to check the devicePixelRatio
and then adjust our height and width based on it. On a normal screen the devicePixelRatio
is 1 at normal zoom so we'll be drawing exactly what we were but for high dpi devices and zoom we'll be drawing bigger. We also need scale up all drawing with context.scale()
which prevents us from having to change all the drawing code to have extra multiplications by the devicePixelRatio
. Last we need to fix the canvas size by scaling it back down the size it's supposed to be. This means we need to set the CSS to the actual size.
But there's one more scenario. Users might try to zoom in and we want to accommodate this by maintain scale so that we match the behavior of the SVG version. We also get to use that attachEvents
method that I left in the boilerplate but thus far has be unused:
attachEvents() {
window.addEventListener("resize", () => {
const dpr = window.devicePixelRatio;
if(lastDpr !== dpr){
this.canvas.height = this.#height * dpr;
this.canvas.width = this.#width * dpr;
this.context.scale(dpr, dpr);
lastDpr = dpr;
this.render();
}
});
}
There's no native zoom event. Instead the browser will produce "resize" events when you zoom. However, the devicePixelRatio
does change so if we capture it on startup and then compare we can tell if we need to make adjustments. lastDpr
is just a module global set to window.devicePixelRatio
. With this the user can zoom in and we'll still get a crisp image exactly like we would with SVG.
As a checkpoint here's the whole render single-threaded function:
render(){
if(!this.context) return;
let points;
if (this.#func) {
points = [];
for (let x = this.#xmin; x < this.#xmax; x += this.#step) {
const y = this.#func(x);
points.push({ x, y, color: this.#defaultColor, size: this.#defaultSize, shape: this.#defaultShape });
}
} else {
points = this.#points;
}
points = points.map(p => ({
x: windowValue(p.x, this.#xmin, this.#xmax) * this.#width,
y: windowValue(p.y, this.#ymin, this.#ymax, true) * this.#height,
color: p.color,
size: p.size,
shape: p.shape
}));
this.context.clearRect(0,0,this.#width,this.#height);
this.context.strokeStyle = "#000";
this.context.moveTo(this.#width / 2, 0);
this.context.lineTo(this.#width / 2, this.#height);
this.context.moveTo(0, this.#height / 2);
this.context.lineTo(this.#width, this.#height / 2);
this.context.stroke();
if(this.#continuous){
this.context.strokeStyle = this.#defaultColor;
this.context.lineWidth = this.#thickness;
this.context.beginPath();
this.context.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
this.context.lineTo(points[i].x, points[i].y);
}
this.context.stroke();
}
for(const point of points){
createShape(this.context, point.shape, [point.x, point.y], point.size, point.color);
}
}
Off the main thread
One of the main advantages of canvas versus SVG is that we can better control the CPU usage. Specifically this means that we can take the rendering to a separate thread whereas SVG is DOM and thus has to be done on the main thread.
Unfortunately, as of the time of writing we need to write the worker in another file. This is especially tedious to deal with when using a bundler but we're going buildless so we don't have to care! There is a proposal for module blocks though. Maybe in the future this will be even easier.
I've created wc-graph-canvas-worker.js
that will house the worker part of the code. Since this is a relatively simple worker everything can be wrapped in a single event listener (though in practice I usually setup a switch statement to have different message types):
globalThis.onmessage = e => { ... }
The code is nearly identical we just need to update references to this
with their local equivalents but for brevity I won't show it as it should be fairly obvious and easy to debug. We can also move windowValue
and createShape
in the worker since they are only used there. Finally we replace the canvas with and OffscreenCanvas
which has pretty much the same API.
let {
points,
xmax,
ymax,
xmin,
ymin,
step,
func,
width,
height,
defaultColor,
defaultSize,
defaultShape,
continuous,
thickness,
devicePixelRatio
} = e.data;
const canvas = new OffscreenCanvas(width * devicePixelRatio, height * devicePixelRatio);
const context = canvas.getContext("2d");
context.scale(devicePixelRatio, devicePixelRatio);
I'm using let here because it was just easier to update the copy-pasted code, but you can make it immutable if you want.
At the bottom of the worker we need to post the information back to the main thread:
const image = canvas.transferToImageBitmap();
globalThis.postMessage(image, [image]);
This first line converts the canvas to an image so we can transfer the result. The second line posts the canvas but the second parameter is special. It's called the "transferables list." It basically says that the post target takes ownership of the objects referenced. If you don't provide this then it'll make a new copy which is not what we want with a big image. Since it is unsafe to have two threads holding on to references to the same thing if you try to do anything with the canvas after it's been posted you'll get errors.
Back in the main thread I'm going to split the render into 2 parts. willRender
and render
where willRender
submits the render job and render
will display the finished work.
willRender(){
if(!this.context) return;
worker.postMessage({
points: this.#points,
xmax: this.#xmax,
ymax: this.#ymax,
xmin: this.#xmin,
ymin: this.#ymin,
step: this.#step,
func: this.#func,
width: this.#width,
height: this.#height,
defaultColor: this.#defaultColor,
defaultSize: this.#defaultSize,
defaultShape: this.#defaultShape,
continuous: this.#continuous,
thickness: this.#thickness,
devicePixelRatio
});
}
render(image){
this.context.drawImage(image, 0, 0);
}
In attachEvents
we need to add another event:
worker.addEventListener("message", e => this.render(e.data));
Anywhere we used render
should be updated to use willRender
.
One issue we run into is that this.#func
can't be sent to the worker because functions aren't serializable. Instead we need to change this to a string and on the worker side parse it into a function.
//wc-graph-canvas-worker.js
if (func) {
func = new Function(["x"], func);
points = [];
for (let x = xmin; x < xmax; x += step) {
const y = func(x);
points.push({ x, y, color: defaultColor, size: defaultSize, shape: defaultShape });
}
}
And let's try it out:
Hmmm that's wierd, what happend?
There's two problems here. The first is that since we're sharing a worker between instances of the component each one will get updated with each other's data! The second is that while we cleared the offscreen canvas (which was unnecessary since we throw it away after render), we need to clear the main canvas. Number 2 is easy:
render(image){
this.context.clearRect(this.#width, this.#height);
this.context.drawImage(image, 0, 0);
}
One requires modifying the events so we can keep track of them. Again this is where having a library sitting on top of the worker can help with dispatch of events and link them up together. I'm still not ready for that so let's do a simple fix, adding a recipient to the passed in messages and then filtering them on the way out.
//wc-graph-canvas.js
let id = 0; //module global
connectedCallback() {
this.#id = id++;
//...
}
willRender(){
if(!this.context) return;
worker.postMessage({
//...
recipientId
});
}
attachEvents() {
worker.addEventListener("message", e => {
if(e.data.recipientId === this.#id){
this.render(e.data.image)
}
});
}
We create a top-level variable called id and increment it for every instance so each one has a unique id. Then we pass that in with the rest of the data and when the worker posts back it should include the id so we can filter which messages are for us.
//wc-graph-canvas-worker.js
globalThis.onmessage = e => {
let {
//...
recipientId
} = e.data;
//...
globalThis.postMessage({ image, recipientId }, [image]);
}
There's one last problem which has to do with the scaling. When we resize we shouldn't set the canvas size immediately. This is because it can get out of sync if you quickly scale up because the render is asynchronous and the window might zoom during render. So instead we should set the size once the image comes back from render:
window.addEventListener("resize", () => {
const dpr = window.devicePixelRatio;
if(lastDpr !== dpr){
lastDpr = dpr;
this.willRender();
}
});
render(image){
this.canvas.height = this.#height * window.devicePixelRatio;
this.canvas.width = this.#width * window.devicePixelRatio;
this.context.clearRect(0,0,this.#width * window.devicePixelRatio, this.#height * window.devicePixelRatio);
this.context.drawImage(image, 0, 0);
}
Even with this fix it can still be possible for this to happen, I'm not sure if it's because the event doesn't always trigger or what but even better might be to debounce the resize event so we're not drawing frames that don't matter. I'll leave that as an exercise to the reader.
And with that it should work again and we're doing it all on another thread where we aren't going to block rendering of other things.
So hopefully this gives some ideas on how it implement graphing with canvas. I find it very easy to do but it gets a little more complicated moving it off the main thread (and you really should do this). By doing so we can gain a lot of render performance over SVG and if we're clever about it.
Unfortunately codepen isn't good about dealing with workers but if you want the full code you can find it here:
https://github.com/ndesmic/wc-lib/blob/master/graph/wc-graph-canvas.js
https://github.com/ndesmic/wc-lib/blob/master/graph/wc-graph-canvas-worker.js
Posted on May 11, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.