MadcoreTom

Observable WebComponent

I've read about Web Components before, but never tried to use them. I mostly followed the MDN article (linked above) and the examples github repo that is also linked from that page.

For this website, I figured it might be a nice way to inlcude interactive demos. In this post I'll also explore the Intersection Observer API so that demos with animations can be set up to only animate when visible.

I'll start off by showing the component, and then explaining how I wrote it.

The basics

At its simplest level, a webcomponent is a class that extends HTMLElement and a call to define that element. For example

class MyComponent extends HTMLElement {
    constructor() {
        super();
    }
}

customElements.define("my-component", MyComponent);

in your HTML code, you'd create one of these like

<my-component></my-component>

There are other options, and there are ways to pass in attributes (you can read the MDN article for more), but here's where we're starting.

Adding a canvas

Most of my projects involve a canvas of some sort, and an animation loop.

Extending upon the previous example, we can implement the connectedCallback method to add sub elements to our element. You can use this.innerHTML="<canvas></canvas>" but I'm doing it this way since I want to have a reference to the canvas element anyway.

const CANVAS_WIDTH = 400;
const CANVAS_HEIGHT = 400;

class MyComponent extends HTMLElement {

    private ctx: CanvasRenderingContext2D; // this is added here too

    constructor() {
        super();
    }

    connectedCallback() {
        // Populate this element
        const canvas = document.createElement("canvas") as HTMLCanvasElement;
        canvas.width = CANVAS_WIDTH;
        canvas.height = CANVAS_HEIGHT;
        this.ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
        this.appendChild(canvas);
    }
}

Now we have a canvas, let's draw something

Drawing on the canvas

Create a new method draw and add a private property frames

private frames = 0;

private draw(time: number) {
    this.ctx.font = "bold 18px sans-serif";
    this.ctx.fillStyle = `hsl(${this.frames / 10},100%,50%)`;
    this.ctx.fillRect(0, 0, 400, 400);
    this.ctx.fillStyle = `hsl(${(this.frames / 10) + 180},100%,25%)`;
    this.ctx.fillText("Frames: " + (this.frames++), 10, 20);
    this.ctx.fillText("Time: " + Math.floor(time / 1000), 10, 40);

    window.requestAnimationFrame(time => this.draw(time));
}

At the end of connectedCallback add the following to get the render loop started

window.requestAnimationFrame(time => this.draw(time));

the draw method will get called at whatever rate the browser feels like. Mobile devices (particularly on low power) may render at a low framerate like 15 or 30fps, and a typical PC may render at 60fps. Don't count on these numbers, and they won't be 100% stable either. It's best practise to use the time parameter that is populated. It's an arbitrary time (its not Date.now()), but if you just want to compare this frame time with the last frame time, you can do so to get the frame times.

Drawing only when you can see the canvas

We'll use the Intersection Observer API. In the MDN documentation they list the reasons you may want to use it

Intersection information is needed for many reasons, such as:

  • Lazy-loading of images or other content as a page is scrolled.
  • Implementing "infinite scrolling" websites, where more and more content is loaded and rendered as you scroll, so that the user doesn't have to flip through pages.
  • Reporting of visibility of advertisements in order to calculate ad revenues.
  • Deciding whether or not to perform tasks or animation processes based on whether or not the user will see the result.

Within the connectedCallback method, I added the following to create the observer, and hook it up to the canvas within my webcomponent.

// Create an observer
let options: IntersectionObserverInit = {
    rootMargin: "0px",
    threshold: 0,
};
var observer = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => {
        if (entry.intersectionRatio > 0 && this.animRequestId == null) {
            this.requestAnim();
        } else if (entry.intersectionRatio <= 0) {
            this.stopAnim();
        }
    });
}, options);

observer.observe(canvas);

I've replaced the method to request an animation frame, and added a way to stop it, as well as tracking the animation request id as animRequestId

private animRequestId: number | null = null;

private requestAnim() {
    this.animRequestId = window.requestAnimationFrame(time => this.draw(time));
}

private stopAnim() {
    if (this.animRequestId != null) {
        window.cancelAnimationFrame(this.animRequestId);
        this.animRequestId = null;
    }
}

All the code, all together

const CANVAS_WIDTH = 400;
const CANVAS_HEIGHT = 400;

class MyComponent extends HTMLElement {
    private ctx: CanvasRenderingContext2D;
    private animRequestId: number | null = null;
    private frames = 0;
    constructor() {
        super();
    }
    connectedCallback() {
        // Populate this element
        const canvas = document.createElement("canvas") as HTMLCanvasElement;
        canvas.width = CANVAS_WIDTH;
        canvas.height = CANVAS_HEIGHT;
        this.ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
        this.appendChild(canvas);

        // Create an observer
        let options: IntersectionObserverInit = {
            rootMargin: "0px",
            threshold: 0,
        };
        var observer = new IntersectionObserver((entries, observer) => {
            entries.forEach(entry => {
                if (entry.intersectionRatio > 0 && this.animRequestId == null) {
                    this.requestAnim();
                } else if (entry.intersectionRatio <= 0) {
                    this.stopAnim();
                }
            });
        }, options);

        observer.observe(canvas);

        this.requestAnim();
    }

    private requestAnim() {
        this.animRequestId = window.requestAnimationFrame(time => this.draw(time));
    }

    private stopAnim() {
        if (this.animRequestId != null) {
            window.cancelAnimationFrame(this.animRequestId);
            this.animRequestId = null;
        }
    }

    private draw(time: number) {
        this.ctx.font = "bold 18px sans-serif";
        this.ctx.fillStyle = `hsl(${this.frames / 10},100%,50%)`;
        this.ctx.fillRect(0, 0, 400, 400);
        this.ctx.fillStyle = `hsl(${(this.frames / 10) + 180},100%,25%)`;
        this.ctx.fillText("Frames: " + (this.frames++), 10, 20);
        this.ctx.fillText("Time: " + Math.floor(time / 1000), 10, 40);

        this.requestAnim();
    }
}

customElements.define("my-component", MyComponent);

One last demo

Here it is again. You'll notice that the frame number will be different, since this has been at the bottom of the page, and not visible