DEV Community

loading...

Graphing with Web Components 3: Canvas

ndesmic
I like to make fun web things from scratch. Ideally build-less, framework-less, infrastructure-less and free from the annoyances of my day job.
・10 min read

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);
Enter fullscreen mode Exit fullscreen mode

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
         }));

Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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:

download (6)

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();
Enter fullscreen mode Exit fullscreen mode

download

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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And the drawing loop gets simpler:

for(const point of points){
    createShape(this.context, point.shape, [point.x, point.y], point.size, point.color);
}
Enter fullscreen mode Exit fullscreen mode

And we get nice points instead of rectangles:

download (1)

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();
}
Enter fullscreen mode Exit fullscreen mode

download (2)

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

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.

Before:
Screenshot 2021-04-25 085915
After:
Screenshot 2021-04-25 085823

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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 => {  ... }
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

In attachEvents we need to add another event:

worker.addEventListener("message", e => this.render(e.data));
Enter fullscreen mode Exit fullscreen mode

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 });
    }
}
Enter fullscreen mode Exit fullscreen mode

And let's try it out:

download (3)

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);
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    });
}

Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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

Discussion (0)