I've given a few talks with the title "Data Visualisation in Vue.js": this is the first part of a two-part mini-series which goes a bit more in depth with a specific example.
The code for the entire article can be found on GitHub: callumacrae/covid-visualisations.
If you've been on the internet in the last couple months or so, there's a good chance you'll have seen a visualisation that looked something like this:
#COVIDー19 animated chart, updated May 10th
— StatsAnimated (@StatsAnimated) May 10, 2020
Source: #WHO#COVID_19 #WakeUpIndia #CuidemosAQuienNosCuida#sabadodecuarentena #Reproduktionszahl #COVIDIOTS#CoronavirusOutbreak #LockdownTaughtMe #CoronavirusUSA #CoronavirusLockdown #covid19india #Day58OfLockdown #COVID19outbreak pic.twitter.com/JEI8eGfEm6
I've been working with data visualisation using only Vue.js for a while now, so I wanted to see if I could implement it in Vue without using any other libraries.
We're going to generate the chart using SVG.
SVG is an image format that can be used to generate vector graphics, such as 2D shapes and lines. It's commonly used for icons and simple graphics—SVG images are significantly smaller than their PNG and JPEG counterparts. While it's typically generated by a graphics editors such as Illustrator, Sketch or Figma, they're XML-based which means that it's also possible to write them by hand—or programmatically using code.
Common libraries for working with SVGs include D3, Highcharts, and Chartist, but it's actually possible to use Vue.js to generate and manipulate SVGs directly!
Let's take a brief look at how SVGs work and how you can write them by hand, before we look at how we can produce them using JavaScript. If you're familiar with SVGs, feel free to skip this section.
To write SVG directly into an HTML document, you can use the <svg>
tag, like
so:
<svg
width="400" height="250"
xmlns="http://www.w3.org/2000/svg">
</svg>
When you load your page, you'll see something like this:
As you can see, it's a blank rectangle. I added the border to make it clearer, but usually you wouldn't see that either. This is our blank canvas; our space to draw!
Let's start by adding a rectangle using the <rect>
element:
<svg
width="400" height="250"
xmlns="http://www.w3.org/2000/svg">
<rect
width="200" height="100"
x="50" y="50"
fill="hsl(10, 80%, 70%)"
/>
</svg>
We've passed the element five attributes, telling it how big it should be
(width
and height
), where it should display in the SVG element (x
and
y
—positioned from the top left of the SVG viewport), and what it should look
like (the fill
attribute).
This is the output:
It'll have become clear by now that SVG is a pretty different language to
HTML—while you can write it directly in an HTML document, nearly everything
about it is different other than the fact that they have similar syntaxes. It
has a different set of elements and attributes; while elements in HTML documents
are by default displayed one after the other in the order they appear, SVG
elements generally have to be told where to display, or they'll appear at the
top left, overlapping one another; and the way we style SVG elements are also
different—while with HTML we use CSS with properties like background-color
and
border
to style rectangular elements, in SVG we style shapes using attributes
like fill
, stroke
, and stroke-width
.
Some of these differences are simply because HTML and SVG are two different languages, but some of the differences are what makes SVG so good for vector-based images compared to HTML.
The other thing we'll need for the visualisation is some text. To add text, we
use the appropriately named <text>
element:
<svg
width="400" height="250"
xmlns="http://www.w3.org/2000/svg">
<rect
width="200" height="100"
x="50" y="50"
fill="hsl(10, 80%, 70%)"
/>
<text x="50" y="50">Some text</text>
</svg>
Which outputs:
There are three things to note here:
<text>
element isn't a child of the <rect>
element like it would be if
it were a <div>
and some text in an HTML document. The <rect>
element can
only be used to output the shape, and cannot be used to position anything
inside it. There is an element we can use to do this which we will look at
later.x
and y
. This is configurable with the dominant-baseline
attribute, which again, we will be looking in a bit.In the example above, the rectangle and text elements are direct children of the
SVG element—but what if we have multiple rectangles each with some text
associated with them? We could have a rect
followed by a text
followed by a
rect
, by a text
, etc, but that's not especially nice—especially when we want
to start looping through data in Vue.
SVG has an element for grouping other elements, <g>
. It's kind of similar to
HTML's <div>
element. Here's how we use it:
<svg
width="400" height="250"
xmlns="http://www.w3.org/2000/svg">
<g transform="translate(50, 50)">
<rect
width="200" height="100"
fill="hsl(10, 80%, 70%)"
/>
<text>Some text</text>
</g>
</svg>
This outputs exactly the same as the example above that used x
and y
, but
without the duplication of the positions as the transform
is also being
applied to the elements within the group. Note that the group element doesn't
accept x
and y
attributes: you have to use a transform to translate the
group instead.
Now that we've freed up the x
and y
attributes of the text, let's use it to
position the text in the centre of the rectangle:
<svg
width="400" height="250"
xmlns="http://www.w3.org/2000/svg">
<g transform="translate(50, 50)">
<rect
width="200" height="100"
fill="hsl(10, 80%, 70%)"
/>
<text
x="100" y="50"
dominant-baseline="middle"
text-anchor="middle">
Some text
</text>
</g>
</svg>
Here's the output:
There's a couple things going on here, including two new attributes. First, note that the x and y positions of the text are set to half the width and height of the rectangle. That means that the text will be positioned from the centre of the rectangle. By default, though, that would result in the text appearing in the upper right of the rectangle, like this:
The two attributes added in the last example help us here. dominant-baseline
says where the text should be positioned vertically by specifying which baseline
of the element should be used. text-anchor
does a similar thing, but
horizontally instead of vertically.
Here's a quick demo:
dominant-baseline: text-anchor:
Don't ask me what all the possible values for dominant-baseline do - I have no idea! You can find an explanation on the MDN article: dominant-baseline.
As I mentioned before, you can style SVGs using CSS, but it works in quite a different way to styling HTML using CSS.
The selector logic is (mostly) the same, but the declarations themselves are
different. color: red
won't do anything if you apply it to an SVG element.
Instead, the declarations you write in the CSS are the same as the attributes
you provide directly to the elements.
It's easier understood with an example:
svg text {
x: 100;
y: 50;
dominant-baseline: middle;
text-anchor: middle;
}
That's the equivalent of writing
x="100" y="50" dominant-baseline="middle" text-anchor="middle"
on every text
element inside the SVG element. I tend to use this for styling
stuff—dominant-baseline
and text-anchor
—but keep the positioning attributes
directly on the SVG elements.
So that's a super quick tour of SVG. For a longer read that goes more into depth into all the features of SVGs, I'm a big fan of the SVG tutorial on MDN.
Let's start looking at our COVID-19 visualisation.
The first part of any data visualisation project is often also the most challenging—getting the data!
In this case, it's not too tricky as a lot of the existing media coverage cites John Hopkins University as a source, and a quick search finds this GitHub repository by the Center of System Science and Engineering at John Hopkins.
Now that we've found that, it's a case of working what data in there we want to
visualise—I'm using time_series_covid19_confirmed_global.csv
—and transforming
the data into the exact data we want to visualise. In this case, I want to
visualise the data by country, not by region, so I've got some logic to add the
total up for each country.
You can find my code for getting the data and transforming it in data/get-data.js.
Alternatively, I've committed the generated JSON file too—it's in data/country-cases.json (and is probably a bit out of date).
Now that we have our data, let's look at how we can use Vue to connect up what we've learned about SVGs so far and the data we just fetched, to create a static chart.
Let's start by creating a Vue instance with some mock data in, so that we can make sure the chart works before we add the real dataset (which is considerably larger) in.
const data = {
dates: ['1/22/20', '2/11/20', '3/27/20'],
countryData: {
China: [548, 44386, 81897],
'United Kingdom': [0, 8, 14745],
'United States': [1, 11, 101657]
}
};
new Vue({
data: {
day: 2
}
});
This is a tiny subset of the data: three days worth of data in three countries. As the final dataset isn't that big—it's not like we have to wait two hours for the visualisation to render—it doesn't have to be perfect and we don't have to test all cases before giving it all the data. We just have to make sure that something is displayed.
Note that we're storing the data in an object outside of the Vue instance. This
means that anything we add consuming that data won't be reactive, but that's
okay—that object declaration is eventually going to turn into an import
statement to get the JSON file anyway.
We're aiming to display the number of cases in each country on a given day, so we want to transform the data to something like this:
[
{ "country": "United States", "value": 101657 },
{ "country": "China", "value": 81897 },
{ "country": "United Kingdom", "value": 14745 }
]
Let's add a chartData
computed property that looks at the day
and
countryData
and returns something like that object:
computed: {
chartData() {
return Object.entries(data.countryData)
.map(([country, dataArray]) => {
return {
country,
value: dataArray[this.day]
};
})
.filter(({ value }) => value)
.sort((a, b) => b.value - a.value);
}
}
When we get to animating the chart, Vue's reactivity means that if we modify
this.day
, the data returned by this.chartData
will be updated as well.
For now though, we've got enough to move to the template and start displaying some stuff on screen.
In the template, let's loop through chartData
, outputting a rectangle for each
country:
<svg
width="760" height="170"
xmlns="http://www.w3.org/2000/svg">
<rect
v-for="({ country, value }, i) in chartData"
:width="760 / 101657 * value" height="50"
x="0" :y="i * 60"
fill="hsl(10, 80%, 70%)"
/>
</svg>
This uses a v-for
directive to loop through the data, destructuring each
object to get country
and value
, and also getting i
to set the position
with.
Then, we calculate the width as a percentage of the maximum value (101657) multiplied by the width of the SVG, so that the widest bar is 100% of the width and the other bars are scaled proportional to that.
Finally, we're using i
to set the y position of each rectangle, so that they
display one below each other.
This outputs:
There's a few numbers and calculations that are hardcoded that probably shouldn't be, so let's move them out into the Vue instance:
new Vue({
data: {
day: 2,
chartWidth: 760
},
computed: {
chartData() { /* ... */ },
maxValue() {
return this.chartData.reduce((max, { value }) => Math.max(value, max), 0);
}
},
methods: {
barWidth(value) {
return (this.chartWidth / this.maxValue) * value;
}
}
});
And the new template:
<svg
:width="chartWidth" height="170"
xmlns="http://www.w3.org/2000/svg">
<rect
v-for="({ country, value }, i) in chartData"
:width="barWidth(value)" height="50"
x="0" :y="i * 60"
/>
</svg>
(to avoid repetition, I've also moved the fill out into CSS now)
The next step we'll take is to add some text. Eventually we want to display both the country names and numeric values, but let's just add the country names for now. Remember when we used groups in the first section of this article and I said it would come in handy again? Now is that time!
Let's wrap the <rect>
element in a <g>
tag, move the positioning to the
group (remember, we have to use translate instead of x and y attributes), and
add the new <text>
element:
<svg
:width="chartWidth" height="170"
xmlns="http://www.w3.org/2000/svg">
<g
v-for="({ country, value }, i) in chartData"
:transform="`translate(0, ${i * 60})`">
<rect :width="barWidth(value)" height="50" />
<text
x="10" y="25"
dominant-baseline="middle">
{{ country }}
</text>
</g>
</svg>
Great. I think we're ready to start using the real data now.
To use the real data, we're going to replace the data
declaration with an
import statement, getting the JSON file we created earlier.
import data from './data/country-cases.json';
Now, after changing day
to 63, the chart looks like this:
You'll notice if you inspect the SVG in the DOM that there are many more elements being output to the DOM than are actually displaying on screen—174 to be precise—but that's okay. It'll actually come in handy later when we start animating the chart.
Now that we've got our data displaying on screen, let's add some way of moving through the data. To do that, we'll need some kind of input.
Range inputs are great for moving quickly through data, but can make looking at
a specific day a bit tricky. Text inputs are good for looking at specific days,
but again, are less optimised for moving through data. In this case, we care
more about moving through the data, so I've added a component which uses
vue-slider-component for
a prettier range slider (with a hidden <input type="range">
element as
fallback for screen readers—from a quick test it seems like vue-slider-component
isn't accessible by itself) and a play button to automatically go to the next
day of data after a short interval.
You can find the source for this component in src/DayInput.vue on the repository.
It's a bit difficult to know the scale of the data as it has no numerical information on it, so let's add something to help with that.
We could either add an axis, or we could display a numerical value on each bar, but I think we'll go with displaying a value for each bar for this project—not just for the sake of simplicity, but it's also a bit more precise and won't clutter the chart too much.
We're also going to move the bar slightly to the right so that the country text isn't on top of it. This will involve changing the maths slightly as the bar widths will be slightly narrower.
I hope you're starting to see why SVG is such a powerful tool. Achieving something like the above graph is possible in HTML, but wouldn't be especially fun. And we've barely scraped the surface of what SVG can do!
That's it for part one of this article. In part two, we'll look at how the chart can be animated—make sure you follow me on Twitter to hear when it's published.
Big thanks to Darek Gusto and Bartosz Trzos for proof reading this article!