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.
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.
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:
<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.
The canvas component creates a canvas element and injects the canvas rendering context into all of its child components via a reactive provider.
<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.
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.
<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:
This is a bar chart drawn on HTML5 canvas with Vue reactivity.
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.
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
This textbox defaults to using Markdown to format your answer.
You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!