Tutorial

How To Control the HTML5 Canvas with Vue.js

Vue.js

Introduction

Most of the time, you will write Vue.js components that interact with a webpage via the DOM. But Vue’s reactivity system is useful for more than that!

In this article, we’ll create a set of components to render a basic bar chart with Vue, in HTML5 canvas.

Prerequisites

To complete this tutorial, you will need:

This tutorial was verified with Node v16.5.0, npm v7.20.0, and vue v2.6.11.

Step 1 — Setting Up the Project

You can start this project with @vue/cli.

  • npx @vue/cli create vue-canvas-example --default

Then, navigate to the new project directory:

  • cd vue-canvas-example

Then, replace the contents of your App.vue component with the following code:

src/App.vue
<template>
  <div id="app">
    <h2>Bar Chart Example</h2>
    <my-canvas style="width: 100%; height: 600px;">
      <my-box
        v-for="(obj, index) of chartValues"
        :key=index
        :x1="(index / chartValues.length) * 100"
        :x2="(index / chartValues.length) * 100 + 100 / chartValues.length"
        :y1="100"
        :y2="100 - obj.val"
        :color="obj.color"
        :value="obj.val"
      >
      </my-box>
    </my-canvas>
  </div>
</template>

<script>
import MyCanvas from './components/MyCanvas.vue';
import MyBox from './components/MyBox.vue';

export default {
  name: 'app',
  components: {
    MyCanvas,
    MyBox,
  },

  data() {
    return {
      chartValues: [
        { val: 24, color: 'red' },
        { val: 32, color: '#0f0' },
        { val: 66, color: 'rebeccapurple' },
        { val: 1, color: 'green' },
        { val: 28, color: 'blue' },
        { val: 60, color: 'rgba(150, 100, 0, 0.2)' },
      ],
    };
  },

  mounted() {
    let dir = 1;
    let selectedVal = Math.floor(Math.random() * this.chartValues.length);

    setInterval(() => {
      if (Math.random() > 0.995) dir *= -1;
      if (Math.random() > 0.99)
        selectedVal = Math.floor(Math.random() * this.chartValues.length);

      this.chartValues[selectedVal].val = Math.min(
        Math.max(this.chartValues[selectedVal].val + dir * 0.5, 0),
        100
      );
    }, 16);
  },
};
</script>

<style>
html,
body {
  margin: 0;
  padding: 0;
}

#app {
  position: relative;
  height: 100vh;
  width: 100vw;
  padding: 20px;
  box-sizing: border-box;
}
</style>

This is the app template and it uses setInterval and Math.random() to update the chart values every 16 milliseconds.

MyCanvas and MyBox are the two custom components. Values for my-box are percentages of the width of the canvas. Each bar will take up an equal space of the canvas.

Step 2 — Building the Canvas Component

The canvas component creates a canvas element and injects the canvas rendering context into all of its child components via a reactive provider.

src/components/MyCanvas.vue
<template>
  <div class="my-canvas-wrapper">
    <canvas ref="my-canvas"></canvas>
    <slot></slot>
  </div>
</template>

<script>
export default {
  data() {
    return {
      provider: {
        context: null,
      },
    };
  },

  provide() {
    return {
      provider: this.provider,
    };
  },

  mounted() {
    this.provider.context = this.$refs['my-canvas'].getContext('2d');

    this.$refs['my-canvas'].width = this.$refs[
      'my-canvas'
    ].parentElement.clientWidth;
    this.$refs['my-canvas'].height = this.$refs[
      'my-canvas'
    ].parentElement.clientHeight;
  },
};
</script>

By creating the provider in the data property, it becomes reactive, so child components will update when context changes. That will be the CanvasRenderingContext that children will draw to.

provide() allows any child component to inject: ['provider'] and have access to it.

We can’t access the rendering context until the canvas is mounted to the DOM. Once we have it, we provide it to all child components.

Then, resize the canvas to fit its parent’s width. Normally you’d use a more flexible resize system.

Step 3 — Building the Box Component

MyBox.vue is where the magic happens. It’s an abstract component, not a “real” one, so it doesn’t actually render to the DOM.

Note: There is template or styles in this component.

Instead, in the render function, we use normal canvas calls to draw on the injected canvas. As a result, each component still re-renders when their properties change without any extra work.

components/MyBox.vue
<script>
const percentWidthToPix = (percent, ctx) =>
  Math.floor((ctx.canvas.width / 100) * percent);
const percentHeightToPix = (percent, ctx) =>
  Math.floor((ctx.canvas.height / 100) * percent);

export default {
  inject: ['provider'],

  props: {
    x1: {
      type: Number,
      default: 0,
    },
    y1: {
      type: Number,
      default: 0,
    },
    x2: {
      type: Number,
      default: 0,
    },
    y2: {
      type: Number,
      default: 0,
    },
    value: {
      type: Number,
      defualt: 0,
    },
    color: {
      type: String,
      default: '#F00',
    },
  },

  data() {
    return {
      oldBox: {
        x: null,
        y: null,
        w: null,
        h: null,
      },
    };
  },

  computed: {
    calculatedBox() {
      const ctx = this.provider.context;

      const calculated = {
        x: percentWidthToPix(this.x1, ctx),
        y: percentHeightToPix(this.y1, ctx),
        w: percentWidthToPix(this.x2 - this.x1, ctx),
        h: percentHeightToPix(this.y2 - this.y1, ctx),
      };

      // eslint-disable-next-line vue/no-side-effects-in-computed-properties
      this.oldBox = calculated;

      return calculated;
    },
  },

  // eslint-disable-next-line vue/require-render-return
  render() {
    if (!this.provider.context) return;

    const ctx = this.provider.context;
    const oldBox = this.oldBox;
    const newBox = this.calculatedBox;

    ctx.beginPath();
    ctx.clearRect(oldBox.x, oldBox.y, oldBox.w, oldBox.h);
    ctx.clearRect(newBox.x, newBox.y - 42, newBox.w, 100);

    ctx.rect(newBox.x, newBox.y, newBox.w, newBox.h);
    ctx.fillStyle = this.color;
    ctx.fill();

    ctx.fillStyle = '#000';
    ctx.font = '28px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText(
      Math.floor(this.value),
      newBox.x + newBox.w / 2,
      newBox.y - 14
    );
  },
};
</script>

percentWidthToPix and percentHeightToPix are helper functions to convert a percentage of canvas area to pixels.

inject: ['provider'] gets us the provider property from the parent <my-canvas> component.

Since the parent canvas has to mount first, it’s possible that the context may not be injected by the time the render() function runs the first time. Check to see if this.provider.context is defined.

oldBox is used to cache the dimensions of the previous render so that we can clear the area before we re-calculate calculatedBox on the next render.

Note: This does introduce side-effects, but is suitable for the needs of a tutorial. To avoid an ESLint error, we use eslint-disable-next-line.

Save the changes and run your application:

npm run serve

Open the application in the browser:

HTML5 Canvas bar chart rendered with Vue.js.

This is a bar chart drawn on HTML5 canvas with Vue reactivity.

Conclusion

In this article, you created a set of components to render a basic bar chart with Vue, in HTML5 canvas.

This method could be used for any sort of canvas rendering, or even 3D content with WebGL or WebVR! Use your imagination!

Continue your learning with a challenge: try to add individual event handling by passing the dimensions of each box to the injected provider and have the parent canvas decide where to dispatch events.

Creative Commons License