How I made Chart.js sane by wrapping it in a web component

21 Jan

I have this tiny web-based project which isn’t fun, doesn’t go well and I still keep working on once every 6 months or so (perhaps this is its main problem). One of its features is related to presenting loads of data, so a chart would be great to have. At first I looked at libraries for charts like Chart.js and Highcharts, and I decided on Chart.js because of its license. Unfortunately it has this unintuitive API that requires to pass a canvas to it instead of directly declare a line chart.

That’s why I decided to make it myself. Not a good idea, but that’s how I roll. Six to eight months of trying to make it work, I had a horrible spaghetti code that yielded this:

The not so beautiful mess that my chart is.

Then I compared my code to the Chart.js GitHub repository, and I decided it isn’t worth it to start from scratch. They’re pretty good at coding! So I gave it a try another time.

Salvaging useless code

I decided I’m going to replace the old chart I made directly with the new one. This way it was easier to debug and test. This allowed me to reuse the web component setup I worked so hard to incorporate into my failed project. At least there’s some time recovered from that fiasco.

How to make a web component

Even though I salvaged it from a previous development, I feel it’s right to explain how to make a web component. This way it’s pretty clear what you need to make all of this work.

Following MDN’s tutorials, I created a class that extends HTMLElement and in its constructor I did this:

class LineChart extends HTMLElement{
  constructor() {
    super();
    this.attachShadow({mode:'open'});
    this.shadowRootDiv = document.createElement('div');
    this.canvas = document.createElement('canvas');
    this.shadowRootDiv.append(this.canvas);
    this.props = {};

    this.shadowRoot.appendChild(this.shadowRootDiv);
  }
}

It’s very important to call the super() constructor to initialize the web component.

Then we need to attach the shadow DOM and afterwards we have all the necessary elements of the component created and structured. This includes the canvas we’ll be using for Chart.js:

    this.attachShadow({mode:'open'});
    this.shadowRootDiv = document.createElement('div');
    this.canvas = document.createElement('canvas');
    this.shadowRootDiv.append(this.canvas);

Finally, of course, we need to append the component elements to the shadowRoot:

this.shadowRoot.appendChild(this.shadowRootDiv);

Interacting with the canvas in the web component

What my attempt to create a chart has thought me is that if you create a web component, you can’t use it’s elements directly from its constructor. It’s simply too early and they’re not attached to the DOM yet.

This means you need to wait for the canvas to be loaded before you can access it. Therefore I didn’t create a new Chart object directly in the controller, but put it into a separate function:

render() {
  const data = JSON.parse(this.getAttribute('data'));
  const context = this.canvas.getContext('2d');
  const chart = new Chart(context, data);
}

You can also see that I extract the data for the chart from the web component’s attributes. It needs to be parsed before it is passed, since it is in the form of string.

This function is still not executed though. I needed to find an appropriate way to call it, when all the elements of the web component are loaded.

Somewhere in the MDN docs I found this lifecycle function that the web components have. It’s called connectedCallback and it is executed right after the shadowDOM is connected to the page DOM. After this I can happily edit a canvas and see the results on the screen. So I used it like this:

  connectedCallback() {
    this.render();
  }

Importing of Chart.js modules and dependencies

Of course, even if you assemble the code snippets above, the code wouldn’t work. After all, we haven’t imported the Chart object yet. But that’s not too hard to do:

import Chart from 'chart.js/auto'

This would import all the different modules that Chart.js uses.

But why is there an entire heading in this blog post just for one obvious line of code?

Well, the Docs say you can also import parts of the modules instead. This seems a good practice in a larger project, where you don’t want huge chunks of useless code to be delivered to your user despite you want to just use a Line Chart.

To import only the Line Chart relevant modules you need to import Chart first, then the modules, and then register them like this:

import { Chart, LineController, LineElement, PointElement, CategoryScale, LinearScale } from 'chart.js'

Chart.register(LineController, LineElement, PointElement, CategoryScale, LinearScale)

After this, it should be possible to draw a chart on your canvas.

One thing I haven’t tried yet is what happens when two web components import the chart like this and then they are used on the same page. I hope that the web component works as a module and there is no problem doing this, but I need to test it.

Passing the data to the Chart.js object

At this point I have a setup that would work only if there were data to show.

To pass the data to the chart I’d put it in the <line-chart> tag’s data attribute:

<line-chart id='line-chart-id' data="{type: 'line', ...}" className="chart"></line-chart>

This way our web component can read it and pass it to Chart.js.

You might notice that the data attribute is a string. This is totally ok, but it’s much easier for me to write down the configuration in a json instead. So I prefer having an event handler which sets the data dynamically. This also allows me to load the data asynchronously later (like from a server or something).

Set the data to the web component dynamically

To do this instead of having a data attribute in the definition of the tag, we can set up an event handler, which loads the data and sets it to the line chart:

document.addEventListener('DOMContentLoaded', async (event) => {
  const lineChart = document.getElementById('line-chart-id');
  lineChart.setAttribute('data', await fetchData());
});

You can see that the data is loaded by calling the asynchronous fetchData() and awaiting its result. There we can write down the json which is representing our data:

function fetchData() {
  return JSON.stringify({
    type: 'line',
    data: {
      labels: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ],
      datasets: [
        {
          label: 'dataset 1',
          data: [{ x: 0, y1:2, y2:1 },{ x: 1,y1:2}, { x: 1.4,y1:6 }, { x:2,y1:3 }, { x:3,y1:6 }, {x:5, y2: 2}, {x:6, y2:1}, {x:8, y2:6}, {x:9,y2:6}, {x:10,y2:0}],
          parsing: {
            yAxisKey: 'y1'
          }
        },
        {
          label: 'dataset 2',
          data: [{ x: 0, y1:2, y2:1 },{ x: 1,y1:2}, { x: 1.4,y1:6 }, { x:2,y1:3 }, { x:3,y1:6 }, {x:5, y2: 2}, {x:6, y2:1}, {x:8, y2:6}, {x:9,y2:6}, {x:10,y2:0}],
          parsing: {
            yAxisKey: 'y2'
          }
        },
      ],
    }
  });
}

What’s important here is that the config of the chart is written in JSON, but before it is returned it is turned into a string. This way we can set it to the attribute directly.

The rest you can see in Chart.js’s own documentation.

Unfortunately, the result looks like this:

No chart can be seen here, although the Chart.js web component is loaded

Why is the chart not loading?

Because we’re setting the data dynamically now, our web component is too fast in creating the Chart object. This means it passes empty data object to it.

To fix this we need to render a bit later. For example when the data attribute changes. This is why I’m adding the following methods to the web component:

  static get observedAttributes() {
    return ['data'];
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    this.render()
  }

This alone unfortunately does not change the page. What it changes is in the console:

Uncaught Error: Canvas is already in use. Chart with ID '0' must be destroyed before the canvas with ID '' can be reused.

How to make Chart.js redraw?

Well, the problem is now clear – we can’t paint over a chart, so what’s the solution? We need to destroy the chart first. The canvas is reusable, but the Chart object from Chart.js insists on being destroyed before the canvas can be repainted.

Therefore I added this code to the attributeChangedCallback:

    if (this.chart) {
      this.chart.destroy();
      this.chart = null;
    }

I decided also it is a good idea to force the garbage collector to remove the old object by setting the reference to null. Oh, and also, notice that the chart is now a property of the web component object. This means our render()function needs to populate it. Now it looks like this:

render() {
  const data = JSON.parse(this.getAttribute('data'));
  const context = this.canvas.getContext('2d');
  this.chart = new Chart(context, data);
}

The chart is alive

And finally we have a working chart:

The same data I had in my home-grown chart is now looking much prettier in Chart.js

While it is not easy to see the gray lines and there are breaks in the lines of one of the data series, the Chart.js version is much prettier than my own.

It would take another few hours to brush up the rough edges, but it definitely sped up my project with a few months if not years. And this makes me really happy. I’ve learned, that creating a charting library isn’t a two hours work, but rather a years-long moonlighting project if you have a team of one.

I’ve also learned, that painting things on the browser isn’t impossible. And if you choose your tools correctly you can make some really beautiful projects.

If this is your kind of fun, then go for it! I definitely don’t regret it.

Cheers,


If you would like to see how I made the chart as big as the page, you can read this article: Making Canvas greedy using CSS flexbox

Or if you feel like seeing an entire Log-in form created, you can start here: Entering the 21-st century: Learning React