As you can read on the project’s home page, D3.js is a JavaScript library for manipulating documents based on data. D3 helps you bring data to life using HTML, SVG, and CSS. Its emphasis on web standards gives you the full capabilities of modern browsers without tying yourself to a proprietary framework.
Whereas most people will refer to D3.js as a data visualization library, it’s not. D3 is more of a framework comprising different parts — such as jQuery parts (which help us select and manipulate DOM elements), Lodash parts, animation parts, data analysis parts, and data visualization parts.
In this tutorial, we’ll be working with the visualization aspect of D3. The real meat of D3 when visualizing data is:
We want to create an app that lets users search for a repo on GitHub, then get a visual representation of issues opened in the past week that are still open. The end result will look like this:
This tutorial assumes you have a working knowledge of Vue. Previous knowledge of D3.js isn’t required, but if you’d like to get up to speed quickly, you might want to read our D3 by example tutorial.
You’ll also need to have Node installed on your system. You can do this by downloading the binaries for your system from the official website, or using a version manager.
Finally, we’ll be using the following packages to build our app:
I prefer creating new Vue projects using Vue CLI. (If you’re not familiar with Vue CLI, our beginner’s guide in this Vue series gives a full introduction.) Vue CLI provides a nice folder structure for placing different sections of the code, such as styles, components, and so on.
Make sure that the CLI is installed on your machine:
npm install -g @vue/cli
Then create a new project with the following command:
vue create issues-visualization
Note: while creating a new project using Vue CLI, you’ll be prompted to pick a preset. For this particular project, we’ll just stick with the default (Babel + ESLint).
Once our new Vue project has been created, we cd
into the project folder and add the various node modules we’ll need:
npm install lodash d3 axios moment
Even though this is a simple app that doesn’t have many running parts, we’ll still take the components approach instead of dumping all the code inside the App.vue
file. We’re going to have two components, the App
component and a Chart
component that we’re yet to create.
The App
component will handle fetching data from GitHub, then pass this data to the Chart
component as props. The actual drawing of the chart will happen inside the Chart
component. Structuring things this way has the advantage that, if you want to use a library other than axios to fetch the data, it’ll be easier to swap it out. Also, if you want to swap D3 for a different charting library, that’ll be easier too.
We’ll start by building a search interface that lets users enter the name of the repo they want to see visualized.
In src/App.vue
, get rid of everything inside the <template>
tag and replace the content with this:
<template>
<div id="app">
<form action="#" @submit.prevent="getIssues">
<div class="form-group">
<input
type="text"
placeholder="owner/repo Name"
v-model="repository"
class="col-md-2 col-md-offset-5"
>
</div>
</form>
</div>
</template>
Here we have a form which, upon submission, prevents the browser’s default submission action, then calls a getIssues
method that we’re yet to define. We’re also using a v-model
directive to bind the input from the form to a repository
property inside the data model of our Vue instance. Let’s declare that property repository
as an empty string. We’ll also add a startDate
property, which we’ll later use as the first date in our time range:
import moment from "moment";
import axios from "axios";
export default {
name: "app",
data() {
return {
issues: [],
repository: "",
startDate: null
};
},
methods: {
getIssues() {
// code goes in here
}
}
};
Now on to creating the getIssues
method:
getIssues() {
this.startDate = moment()
.subtract(6, "days")
.format("YYYY-MM-DD");
axios
.get(
`https://api.github.com/search/issues?q=repo:${this.repository}+is:issue+is:open+created:>=${this.startDate}`,
{ params: { per_page: 100 } }
)
.then(response => {
const payload = this.getDateRange();
response.data.items.forEach(item => {
const key = moment(item.created_at).format("MMM Do YY");
const obj = payload.filter(o => o.day === key)[0];
obj.issues += 1;
});
this.issues = payload;
console.log(this.issues);
});
}
In the above block of code, we start by setting the startDate
data property to six days ago and formatting it for use with the GitHub API.
We then use axios to make an API request to GitHub to get all issues for a particular repository that were opened in the past week and that are still open. You can refer to GitHub’s search API if you need more examples on how to come up with query string parameters.
When making the HTTP request, we set the results count to 100 per page (the max possible). There are hardly any repositories with over 100 new issues per week, so this should be fine for our purposes. By default, the per_page
value is 30.
If the request completes successfully, we use a custom getDateRange
method to initialize a payload
variable that we will be able to pass to the Chart
component. This payload is an array of objects that will like so:
[
{day: "Dec 7th 18", issues: 0},
{day: "Dec 8th 18", issues: 0},
{day: "Dec 9th 18", issues: 0},
{day: "Dec 10th 18", issues: 0},
{day: "Dec 11th 18", issues: 0},
{day: "Dec 12th 18", issues: 0},
{day: "Dec 13th 18", issues: 0}
]
After that, we iterate over the API’s response. The data we’re interested in is in an items
key on a data
property on the response
object. From this, we take the created_at
key (which is a timestamp) and format it as the day
property in our objects above. From there, we then look up the corresponding date in the payload
array and increment the issues count for that date by one.
Finally, we assign the payload
array to our issues
data property and log the response.
Next, let’s add in the getDateRange
method:
methods: {
getDateRange() {
const startDate = moment().subtract(6, 'days');
const endDate = moment();
const dates = [];
while (startDate.isSameOrBefore(endDate)) {
dates.push({
day: startDate.format('MMM Do YY'),
issues: 0
});
startDate.add(1, 'days');
}
return dates;
},
getIssues() { ... }
}
Before we get to the visualization bit, let’s also log any errors we might encounter when making our request to the console (for debugging purposes):
axios
.get( ...)
.then(response => {
...
})
.catch(error => {
console.error(error);
});
We’ll add some UX for informing the user in the case that something went wrong later.
So far, we have an input field that lets the user enter the organization/repository name they wish to search issues for. Upon form submission, all issues opened in the past one week are logged to the console.
Below is an example of what was logged on the console for the facebook/react
repo:
If you start up the Vue dev server using npm run serve
and enter some different repos, you should see something similar. If you’re stuck for inspiration, check out GitHub’s Trending page.
Next comes the fun bit — visualizing this data.
Earlier on, we mentioned that all the drawing will be handled inside a Chart
component. Let’s create the component:
touch src/components/Chart.vue
D3 works on SVG elements, and for us to draw anything with D3, we need to have an SVG element on the page. In our newly created component (src/components/Chart.vue
), let’s create an SVG tag:
<template>
<div>
<svg></svg>
</div>
</template>
For this particular tutorial, we’ll visualize our data using a bar chart. I picked a bar chart because it represents a low complexity visual element while it teaches the basic application of D3.js itself. The bar chart is also a good intro to the most important D3 concepts, while still having fun!
Before proceeding, let’s update our App
component to include the newly created Chart
component below the form:
<template>
<div id="app">
<form action="#" @submit.prevent="getIssues">
...
</form>
<chart :issues="issues"></chart>
</div>
</template>
Let’s also register it as a component:
import Chart from './components/Chart.vue';
export default {
name: "app",
components: {
Chart
},
...
}
Notice how we’re passing the value of the issues
data property to the Chart
component as a prop:
<chart :issues="issues"></chart>
Let’s now update our Chart
component to make use of that data:
<script>
import * as d3 from "d3";
import _ from "lodash";
export default {
props: ["issues"],
data() {
return {
chart: null
};
},
watch: {
issues(val) {
if (this.chart != null) this.chart.remove();
this.renderChart(val);
}
},
methods: {
renderChart(issues_val) {
// Chart will be drawn here
}
}
};
</script>
In the above code block, we’re importing D3 and Lodash. We then instantiate a chart
data property as null
. We’ll assign a value to this when we start drawing later on.
Since we want to draw the chart every time the value of issues changes, we’ve created a watcher for issues
. Each time this value changes, we’ll destroy the old chart and then draw a new chart.
Drawing will happen inside the renderChart
method. Let’s start fleshing that out:
renderChart(issues_val) {
const margin = 60;
const svg_width = 1000;
const svg_height = 600;
const chart_width = 1000 - 2 * margin;
const chart_height = 600 - 2 * margin;
const svg = d3
.select("svg")
.attr("width", svg_width)
.attr("height", svg_height);
}
Here, we set the height and width of the SVG element we just created. The margin attribute is what we’ll use to give our chart some padding.
D3 comes with DOM selection and manipulation capabilities. Throughout the tutorial, you’ll see lot’s of d3.select
and d3.selectAll
statements. The difference is that select will return the first matching element while selectAll returns all matching elements.
For bar charts, data can either be represented in a vertical or horizontal format. D3 comes with axis methods, which let us define our axes the way we want:
Today, we’ll be creating a vertical bar chart. For vertical bar charts, we’ll only need the axisLeft
and axisBottom
methods:
renderChart(issues_val) {
...
this.chart = svg
.append("g")
.attr("transform", `translate(${margin}, ${margin})`);
const yScale = d3
.scaleLinear()
.range([chart_height, 0])
.domain([0, _.maxBy(issues_val, "issues").issues]);
this.chart
.append("g")
.call(d3.axisLeft(yScale).ticks(_.maxBy(issues_val, "issues").issues));
const xScale = d3
.scaleBand()
.range([0, chart_width])
.domain(issues_val.map(s => s.day))
.padding(0.2);
this.chart
.append("g")
.attr("transform", `translate(0, ${chart_height})`)
.call(d3.axisBottom(xScale));
}
The above block of code draws axes on the SVG element. Let’s go through it step by step:
this.chart = svg.append('g')
.attr('transform', `translate(${margin}, ${margin})`);
We first specify where we want our chart to start within the SVG element. When working with D3, for any element we want to be added to the SVG, we usually call the append
method, then define attributes for this new element.
To add attributes to an element, we usually call the attr
method, which takes in two parameters. The first parameter is the attribute we want to apply to the selected DOM element, and the second parameter is the value we want, or a callback function that returns the desired value. Here we’re moving the start of the chart to the 60, 60
position of the SVG:
const yScale = d3.scaleLinear()
.range([chart_height, 0])
.domain([0, _.maxBy(issues_val, 'issues').issues]);
this.chart.append('g')
.call(d3.axisLeft(yScale)
.ticks(_.maxBy(issues_val, 'issues').issues));
This codeblock draws the y-axis while making use of D3 scales to come up with a y-scale. Scales are functions that will transform our data by either increasing or decreasing its value for better visualization.
The range
function specifies the length that should be divided between the limits of the input data. You may have noticed I used height as the first parameter and not zero when calling range. This is because the SVG coordinate system starts from the top left corner. You’ll get to understand this better when we get to draw the bar heights.
On the other hand, the domain
function denotes minimum and maximum values of the input data. For this particular data set, we want to start from zero to the highest value in our data set. Think of the domain as the input and range as the output.
After defining a y-scale, we make use of this scale to draw the axis by calling the axisLeft
method, which takes in the y-scale
as the argument.
The snippet below draws the x-axis:
const xScale = d3.scaleBand()
.range([0, chart_width])
.domain(issues_val.map((s) => s.day))
.padding(0.2)
this.chart.append('g')
.attr('transform', `translate(0, ${chart_height})`)
.call(d3.axisBottom(xScale));
For the xScale
we use the scaleBand
function, which helps to split the range into bands and compute the coordinates and widths of the bars with additional padding. Assuming 3 is the maximum number of issues raised across all dates, the chart output should look like this:
If you test this in your browser with the code we’ve covered so far, you should see something similar.
Now enough with the axes. Let’s spit some bars!
For our bar chart, the bar width will be fixed and the bar height will vary depending on the dataset size:
renderChart(issues_val) {
...
const barGroups = this.chart
.selectAll("rect")
.data(issues_val)
.enter();
barGroups
.append("rect")
.attr("class", "bar")
.attr("x", g => xScale(g.day))
.attr("y", g => yScale(g.issues))
.attr("height", g => chart_height - yScale(g.issues))
.attr("width", xScale.bandwidth());
}
Let’s address how we added the bars. First, we created a barGroups
element:
const barGroups = this.chart
.selectAll('rect')
.data(issues_val)
.enter()
Calling the selectAll
method on our chart returns an empty selection/array, since we don’t have any rectangles in our chart so far. We then chain the data
method, passing in the dataset we want visualized. This puts the data in a waiting state for further processing.
The next step is to chain the enter
method. The enter
method looks both at the data set we passed into data()
and at the selection we get after calling selectAll()
, and then tries to look for “matches” — more like mapping between our sample data and elements already present in the DOM. In this particular case, no matches were found.
Note: this article is an excellent guide for understanding the <em>select</em>
, <em>enter</em>
and <em>append</em>
sequence when working with D3 to manipulate the DOM.
Since selectAll('rect')
returned an empty array, the enter
method will return a new selection representing the elements in our data set.
Note that, after chaining enter()
, every item in the returned array is acted upon individually. This means that any method chained onto barGroups
will define the behavior of individual items.
barGroups
.append('rect')
.attr('class', 'bar')
.attr('x', (g) => xScale(g.day))
.attr('y', (g) => yScale(g.issues))
.attr('height', (g) => chart_height - yScale(g.issues))
.attr('width', xScale.bandwidth());
The code block above creates a rectangle for each item in our in our data set. We give each of these rectangles a class of bar
.
To set the x and y coordinates for the rectangles, we use the scaling functions we defined earlier. So far, these rectangles are sitting on top of each other, and we need to give our rectangles some height and width.
The width of our bars would be determined by the scaleBand
function. Chaining the bandwidth
function to the xScale
returns a calculated bandwidth from the range and padding provided to the x-scale.
To set the bar height, we subtract the computed y-coordinate of the bar from the height of the SVG to get the correct representation of the value as a column. Remember that, when working with SVGs, x and y coordinates are always calculated starting from the top-left corner.
So far we have a bar chart. But this chart is not really helpful, as it doesn’t tell the user what each axis represents. To give our chart more context, we’ll have to add labels for the axes as well as a chart title.
To add labels, we append text elements to our SVG:
svg
.append('text')
.attr('class', 'label')
.attr('x', -(chart_height / 2) - margin)
.attr('y', margin / 2.4)
.attr('transform', 'rotate(-90)')
.attr('text-anchor', 'middle')
.text('Issues opened')
svg
.append('text')
.attr('class', 'label')
.attr('x', chart_width / 2 + margin)
.attr('y', chart_height + margin * 1.7)
.attr('text-anchor', 'middle')
.text('Days')
svg
.append('text')
.attr('class', 'title')
.attr('x', chart_width / 2 + margin)
.attr('y', 40)
.attr('text-anchor', 'middle')
.text('Issues in the past 1 week')
The text elements can be positioned with x and y coordinates while text alignment is done with the text-anchor
attribute. To add the text itself, we call the text
method on the text element.
We can test things out by serving our app then searching for a repo. Search for any popular repo — such as facebook/react
:
Back in our App
component, we can now get rid of the console statement inside the getIssues
method:
console.log(this.issues)
Even though our chart does an excellent job of visualizing our data, there’s still much to be done in terms of the user experience. In the next section, we’ll look at how to add transitions to D3 charts.
For this particular chart, we want to make it such that, when one hovers over a bar element, its shade changes and the number of issues represented by the bar shows up at the top of the bar.
For this to happen, we have to do some event handling on mouseEnter
and mouseLeave
for the barGroups
.
Edit the barGroups
code block above the three svg
blocks:
barGroups
...
.attr("width", xScale.bandwidth())
.on("mouseenter", function(actual, i) {
d3.select(this)
.transition()
.duration(300)
.attr("opacity", 0.6)
.attr("x", a => xScale(a.day) - 5)
.attr("width", xScale.bandwidth() + 10);
barGroups
.append("text")
.attr("class", "value")
.attr("x", a => xScale(a.day) + xScale.bandwidth() / 2)
.attr("y", a => yScale(a.issues) - 20)
.attr("text-anchor", "middle")
.text((a, idx) => {
return idx !== i ? "" : `${a.issues} issues`;
});
});
We call the transition method to add animations to an element when working with D3.
Each time the cursor hovers over a bar, we reduce the opacity of the bar and increase the width by 10px
. We also add text on top of the bar, stating the number of issues the bar represents. This transition takes a duration of 300 milliseconds.
Since we don’t want to leave this bar in the new state when the mouse leaves, let’s define a mouseleave
event, which removes the selection features we had applied in the mouseenter
event:
barGroups
...
.attr("width", xScale.bandwidth())
.on("mouseenter", function(actual, i) { ... })
.on("mouseleave", function() {
d3.selectAll(".issues").attr("opacity", 1);
d3.select(this)
.transition()
.duration(300)
.attr("opacity", 1)
.attr("x", a => xScale(a.day))
.attr("width", xScale.bandwidth());
svg.selectAll(".value").remove();
});
What we are doing above is setting the opacity of the bar to the original value and also removing the text from the bar.
svg.selectAll('.value').remove();
The code above removes any text on the DOM that has a class of value
.
Here’s the final result:
If you try things out in your browser now, you should see something like the above.
When making the request to GitHub, we anticipate some loading time before getting a response back from GitHub. For UX purposes, we’ll make the user aware we’re still retrieving results from GitHub by flashing a loading alert on the page. In the App
component, add this snippet to the HTML section of your code — right above the Chart
component:
<div class="alert alert-info" v-show="loading">Loading...</div>
<chart :issues="issues"></chart>
For prototyping purposes, we’ll leverage Bootstrap. Vue has an official Bootstrap package that we’re going to install:
npm install bootstrap-vue
Once the package has been installed, we’ll have to import Bootstrap’s CSS classes to our main.js
file to make the alert show up:
[...]
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
We’re almost done, but we need to add a loading
attribute to our data model — whose value will determine whether or not to show the loading alert:
data() {
return {
loading: false,
...
};
},
Every time we make a new request, we’ll set the value of loading
to true
, so the alert shows up, and then we’ll finally set this value to false
for successful requests or in the case the app errored:
getIssues() {
this.loading = true;
...
axios.get( ... )
.then( ... )
.catch( ... )
.finally(() => (this.loading = false));
}
As it stands, we’re just logging any errors we might run into on the console — something the user isn’t aware of. To make the user is aware if something goes wrong when making the request, we’ll flash an error alert on the page. Add this snippet below the loading alert we just created:
<div class="alert alert-danger" v-show="errored">An error occured</div>
<chart :issues="issues"></chartchart>
We also need to update our data model with the errored
attribute, which we initialize with a false
value:
data() {
return {
loading: false,
errored: false,
...
};
},
We’ll set the errored
value to true
every time we run into an error. This should happen inside the catch block:
getIssues() {
this.loading = true;
this.errored = false;
...
axios.get( ... )
.then( ... )
.catch(error => {
console.error(error);
this.errored = true;
})
.finally( ... );
}
Eagle-eyed readers will have noticed that in the screen shot at the top of this guide, the color of the bars was blue. Let’s add that as a final touch to our App
component:
<style>
.bar {
fill: #319bbe;
}
</style>
Congratulations for making it to this point. Even though we have a working bar chart, there’s still more we can do to make the chart more visually appealing. We could, for example, add grid lines, or alternatively visualize this data using a line chart.
What we drew was a simple bar chart, but D3.js has amazing capabilities in terms of what you can do. Any data visualization that you’ve seen anywhere — including powerpoint presentations — can probably be handled by D3. This article is a good read for some of the things D3 is capable of doing. You can also check out D3 on GitHub to see other use cases.
☞ React vs Vue – Choosing the Best JavaScript Library
☞ Set Up a Vue.js Development Environment
☞ 10+ Top Vue.js Tools & Libraries
☞ How to Enforcing Rules in JS with Getters and Setters
☞ Vue.js Pattern for Async Requests: Using Renderless Components
#vue-js #javascript