Build a data visualization using Angular and Chart.js
A basic understanding of Angular and Node is required. You will need to have Node and npm installed on your machine.
Data visualization is viewed by many disciplines as a modern equivalent of visual communication. It involves the creation and study of the visual representation of data. - Wikipedia
An important advantage of data visualization is how it enables users to more effectively see connections as they are occurring between operating conditions and business performance. Adding realtime functionality using Pusher improves this experience as data changes are witnessed in realtime.
Using Angular you can extend the template language with your own components and use a wide array of existing components.
To follow this tutorial a basic understanding of Angular and Node.js is required. Please ensure that you have Node and npm installed before you begin.
If you have no prior knowledge of Angular, kindly follow the tutorial here. Come back and finish the tutorial when you’re done.
We’ll be using these tools to build our application:
We’ll be creating an application that will present data about the age demographics of a given population. Using our application, users will complete a poll and then see the data from the polls in realtime.
Here’s a screenshot of the final product:
Initializing application and installing dependencies
To get started, we will use the CLI (command line interface) provided by the Angular team to initialize our project.
First, install the CLI by running npm install -g @angular/cli
. NPM is a package manager used for installing packages. It will be available on your PC if you have Node installed.
To create a new Angular project using the CLI, open a terminal and run:
ng new angular-data-visualization --style=scss
This command is used to initialize a new Angular project; the project will make use of SCSS for styling.
Next run the following command in the root folder of the project to install dependencies.
// install depencies required to build the server
npm install express body-parser dotenv pusher
// front-end dependencies
npm install chart.js ng2-charts pusher-js
Start the Angular development server by running ng serve
in a terminal in the root folder of your project.
Building our server
We’ll build our server using Express. Express is a fast, unopinionated, minimalist web framework for Node.js.
Create a file called server.js
in the root of the project and update it with the code snippet below
// server.js
require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const Pusher = require('pusher');
const app = express();
const port = process.env.PORT || 4000;
const pusher = new Pusher({
appId: process.env.PUSHER_APP_ID,
key: process.env.PUSHER_KEY,
secret: process.env.PUSHER_SECRET,
cluster: 'eu',
});
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: false}));
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header(
'Access-Control-Allow-Headers',
'Origin, X-Requested-With, Content-Type, Accept'
);
next();
});
app.listen(port, () => {
console.log(`Server started on port ${port}`);
});
The calls to our endpoint will be coming in from a different origin. Therefore, we need to make sure we include the CORS headers (Access-Control-Allow-Origin
). If you are unfamiliar with the concept of CORS headers, you can find more information here.
This is a standard Node application configuration, nothing specific to our app.
Create a Pusher account and a new Pusher Channels app if you haven’t done so yet and get your appId
, key
and secret
. Create a file in the root folder of the project and name it .env
. Copy the the following snippet into the .env
file and ensure to replace the placeholder values with your Pusher credentials.
// .env
// Replace the placeholder values with your actual pusher credentials
PUSHER_APP_ID=PUSHER_APP_ID
PUSHER_KEY=PUSHER_KEY
PUSHER_SECRET=PUSHER_SECRET
We’ll make use of the dotenv
library to load the variables contained in the .env
file into the Node environment. The dotenv
library should be initalized as early as possible in the application.
Send votes
To let users send requests to the server, we’ll create a route to handle incoming requests. Update your server.js
file with the code below.
// server.js
require('dotenv').config();
...
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
...
});
app.post('/vote', (req, res) => {
const {body} = req;
const data = {
...body,
// set the selected property of the body to true
selected: true,
};
// trigger a new-entry event on the vote-channel
pusher.trigger('vote-channel', 'new-entry', data);
res.json(data);
});
...
- We added a POST route(
/vote
) to handle incoming requests. - Using object destructuring, we got the body of the request.
- The trigger is achieved using the
trigger
method which takes the trigger identifier(vote-channel
), an event name (new-entry
), and a payload. - The payload being sent contains the body of the request sent in. The
selected
property of the payload is set totrue
.
Home view
The home view of the project will house both the polling area and the area where the data is visualized. Both views will appear side by side.
Open the app.component.html
file and replace it with the content below.
// app.component.html
<div class="main">
<div class="section census">
<h4>Age Demographics</h4>
<p>Select your age range</p>
<div class="option-list">
<button class="option" *ngFor="let option of options; index as i" (click)="takeVote(i)" [ngClass]="{selected: option.selected}"
[disabled]="selectedOption && selectedOption !== option.value">
<i class="fas fa-check" *ngIf="option.selected"></i>
{{option.label}}
</button>
</div>
<p class="error">{{message}}</p>
</div>
<div class="section data">
<!-- Include the chart component here -->
</div>
</div>
- In the code snippet above, we looped through
options
to create a view based on the player’s information. - The
takeVote
method will make use of theHttpClient
to send the user’s selection as a request to the server. - We append a
selected
class if theoption
’s selected property is true. - Options are disabled if the current
selectedOption
isn’t equal to the option’svalue
.
Variables used will be defined in the component’s Typescript file.
Styling
// app.component.scss
$buttonShadow: #ca6eca + 120;
.main {
display: flex;
padding-top: 5%;
.section {
padding: 20px;
}
.section.census {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.data{
flex: 2;
}
}
p.error{
font-size: 13px;
color: red + 100;
margin: 5px 0;
}
h4 {
margin-bottom: 8px;
font-size: 13px;
text-transform: uppercase;
color: #ca6eca;
font-weight: bold;
letter-spacing: 0.4px;
}
p {
margin: 5px 0;
}
div.option-list {
padding-left: 0;
width: 200px;
display: flex;
flex-direction: column;
button.option {
padding: 15px;
box-shadow: 0 2px 4px 0 $buttonShadow;
margin: 7px 0;
background: #ca6eca;
color: white;
border-radius: 5px;
cursor: pointer;
font-size: 12px;
text-transform: uppercase;
font-weight: bold;
.fas {
font-size: 1.2em;
margin-right: 10px;
}
}
button[disabled] {
background: #ca6eca;
box-shadow: none;
cursor: default;
}
button.selected {
background: #ca6eca + 50;
}
button:focus {
background: #ca6eca + 50;
outline: none;
}
}
These styles are meant to add a bit of life to our application. It also helps distinguish between states during application use.
Also, we’ll be using the FontAwesome icon set for our project and the Roboto font. Include a link to their respective stylesheets in the index.html
file.
// index.html
<head>
...
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.12/css/all.css" integrity="sha384-G0fIWCsCzJIMAVNQPfjH08cyYaUtMwjJwqiRKxxE/rx96Uroj1BtIQ6MLJuheaO9" crossorigin="anonymous">
...
</head>
...
Then we’ll select Roboto as our default font family. Open the styles.scss
file and update it with the following content:
// styles.scss
body{
font-family: 'Roboto', sans-serif;
}
App component
In the html snippet, we made reference to some variables that weren’t yet defined, we’ll create the variables here with the logic behind our application.
// app.component.ts
import {Component, OnInit} from '@angular/core';
import {HttpClient} from '@angular/common/http';
export interface Option {
label: string;
selected: boolean;
value: string;
}
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
constructor(private http: HttpClient) {
}
selectedOption = null;
message: string = '';
options: Option[] = [
{
label: '14 - 25',
selected: false,
value: '14-25'
},
{
label: '25 - 35',
selected: false,
value: '25-35'
},
{
label: '35 - 45',
selected: false,
value: '35-45'
},
{
label: '45 - 60',
selected: false,
value: '45-60'
},
{
label: '60 and above',
selected: false,
value: '60+'
}
];
censusData = {
'14-25': 0,
'25-35': 0,
'35-45': 0,
'45-60': 0,
'60+': 0
};
takeVote(index) {
const selectedOption = this.options[index];
if (!selectedOption.selected) {
this.http
.post('http://localhost:4000/vote', selectedOption)
.subscribe((res: Option) => {
const options = this.options.map(
(option, i) => (index === i ? { ...res } : { ...option })
);
this.options = [...options];
this.selectedOption = res.value;
});
}
else{
this.message = "You've already placed a vote";
}
}
onNewEntry(data) {
this.censusData[data.value] += 1;
}
ngOnInit() {
}
}
-
takeVote
: this method makes use of the native HttpClient service to make requests to our server. A request is sent, only if the user hasn’t made a previous selection. When a response is returned, it loops through the availableoptions
and replaces the selected option with the data returned from the request. TheselectedOption
property is then set to the value of the response. -
censusData
: this will be used when we create the charts view. -
onNewEntry
: this method will be used to update thecensusData
whenever there’s a new Pusher event. We’ll use this method after we’ve created the chart component.
To make use of the HttpClient service, we’ll need to import the HttpClientModule
into the app.module.ts
file. Update your app module file as follows:
// app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import {HttpClientModule} from '@angular/common/http';
....
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, HttpClientModule],
providers: [],
bootstrap: [AppComponent],
})
....
Let’s check how our application looks at this point. Make sure the server(node server
) and Angular’s dev server(ng serve
) are both running.
Introducing Pusher
So far we have an application that allows users be a part of the polling process but data updates aren’t happening in realtime. Let’s create a service that will make it easier to include Pusher in our components.
To make the pusher library available in our project, add the library as a third party script to be loaded by Angular CLI. All CLI config is stored in .angular-cli.json
file. Modify the scripts
property to include the link to pusher.min.js
.
// .angular-cli.json
...
"scripts": [
"../node_modules/pusher-js/dist/web/pusher.min.js",
]
...
After updating this file, you’ll need to restart the angular server so that the CLI compiles the new script files added.
Now we’ll create a Pusher service to be used application wide. The Angular CLI can aid in the service creation. Open a terminal in your project’s root folder and run the following command.
ng generate service pusher
This command simply tells the CLI to generate a service named pusher
. Now open the pusher.service.ts
file and update it with the code below.
// pusher.service.ts
import { Injectable } from '@angular/core';
declare const Pusher: any;
@Injectable()
export class PusherService {
constructor() {
const pusher = new Pusher('PUSHER_KEY', {
cluster: 'eu',
});
this.channel = pusher.subscribe('vote-channel');
}
channel;
public init() {
return this.channel;
}
}
- First, we initialize Pusher in the constructor.
- The
init
method returns the Pusher property we created.
Note: ensure you replace the
PUSHER_KEY
string with your actual Pusher key.
To make the service available application wide, import it into the app.module.ts
file.
// app.module.ts
import {PusherService} from './pusher.service'
...
@NgModule({
....
providers: [PusherService],
....
})
Charts component
To visualize the data in our application, we’ll be making use of Chart.js to create charts and present the data in a graphical format. Let’s create the charts component using the CLI by running the following command:
ng generate component charts
Open the charts.component.html
file and update it with the contents below:
// charts.component.html
<div>
<canvas baseChart [chartType]="chartType" [labels]="chartLabels" [data]="chartData">
</canvas>
</div>
<div class="stats">
<div class="stat">
<div class="header">
<p>Total Pop.</p>
</div>
<div class="body"><h4>{{totalPopulation}}</h4></div>
</div>
<div class="stat">
<div class="header">
<p>Pop. under 35</p>
</div>
<div class="body">
<h4>{{under35 || 0}}</h4>
<span class="percent">%</span>
</div>
</div>
<div class="stat">
<div class="header">
<p>Pop. between 35 and 60</p>
</div>
<div class="body">
<h4>{{between35and60 || 0}}</h4>
<span class="percent">%</span>
</div>
</div>
<div class="stat">
<div class="header">
<p>Pop. above 60</p>
</div>
<div class="body">
<h4>{{above60 || 0}}</h4>
<span class="percent">%</span>
</div>
</div>
</div>
Style up the component by adding the following styles to the charts.component.scss
file:
// charts.component.scss
.stats {
display: flex;
margin-top: 30px;
.stat {
flex: 1;
box-shadow: 0 2px 4px 0 rgba(0,0,0,0.10);
margin: 0 15px;
background: rgba(0, 0, 0, 0.05);
.header {
background: white;
padding: 10px 5px;
p {
font-size: 12px;
margin: 0;
font-weight: bold;
text-transform: uppercase;
letter-spacing: -.4px;
text-align: center;
opacity: .7;
}
}
.body {
display: flex;
justify-content: center;
align-items: center;
padding: 15px;
h4 {
text-align: center;
font-size: 30px;
font-weight: 300;
opacity: 0.8;
}
.percent{
font-size: 32px;
}
}
}
}
Add the following content to the charts.component.ts
file:
// charts.component.ts
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
import {PusherService} from '../pusher.service';
import {Option} from '../app.component';
@Component({
selector: 'app-charts',
templateUrl: './charts.component.html',
styleUrls: ['./charts.component.scss']
})
export class ChartsComponent implements OnInit {
constructor(private pusher: PusherService) {
}
@Input() censusData = {};
@Output() newEntry: EventEmitter<Option> = new EventEmitter();
chartType = 'pie';
chartData = [];
chartLabels = [];
totalPopulation = 0;
under35 = 0;
between35and60 = 0;
above60 = 0;
computeData() {
this.chartData = Object.values(this.censusData);
this.chartLabels = Object.keys(this.censusData);
this.totalPopulation = this.getTotalPopulation();
this.under35 = Math.round(this.getPopulationUnder35());
this.between35and60 = Math.round(this.getPopulationBetween35and60());
this.above60 = Math.round(this.getPopulationAbove60());
}
getTotalPopulation() {
const values: number[] = Object.values(this.censusData);
return values.reduce((defaultValue, val) => defaultValue + val, 0);
}
getPopulationUnder35() {
const total = this.getTotalPopulation();
const populationUnder35 = Object.keys(this.censusData).reduce(
(initVal, val) => {
if (val === '14-25' || val === '25-35') {
return initVal + this.censusData[val];
}
return initVal;
},
0
);
return populationUnder35 / total * 100;
}
getPopulationBetween35and60() {
const total = this.getTotalPopulation();
const populationBetween35and60 = Object.keys(this.censusData).reduce(
(initVal, val) => {
if (val === '35-45' || val === '45-60') {
return initVal + this.censusData[val];
}
return initVal;
},
0
);
return populationBetween35and60 / total * 100;
}
getPopulationAbove60() {
const total = this.getTotalPopulation();
const above60 = Object.keys(this.censusData).reduce((initVal, val) => {
if (val === '60+') {
return initVal + this.censusData[val];
}
return initVal;
}, 0);
return above60 / total * 100;
}
ngOnInit() {
this.computeData();
const channel = this.pusher.init();
channel.bind('new-entry', (data: Option) => {
this.newEntry.emit(data);
this.computeData();
});
}
}
We’ve defined a few methods for computing the data to be used in the view:
-
getPopulation
: this method maps the values to an array and gets the total amount of people that took part in the polls. -
getPopulationUnder35
: this method checks for the percentage of the people under the age of 35 taking part in the polls. -
getPopulationBetween35and60
: in this method, we loop through the data and check for the percentage of users between the age of 35 and 60. -
getPopulationAbove60
: using this method we get the percentage of people over the age of 60. -
Finally, in the
computeData
method, we run the different methods to get the required data for the view. -
In the
ngOnInit
lifecycle, we call thecomputeData
method. Also, we listen for thenew-entry
event on the Pusher channel.
To make use of the ng2-charts
package, we’ll have to import the ChartsModule
into our module file. Also, we’ll have to load the chart.js
into our application by including it into the .angular-cli.json
file.
Update the app.module.ts
file like so:
// app.module.ts
...
import { ChartsComponent } from './charts/charts.component';
import {ChartsModule} from 'ng2-charts';
@NgModule({
declarations: [
...
],
imports: [
BrowserModule,
HttpClientModule,
ChartsModule
],
...
})
...
Include the chart.js
file in the .angular-cli.json
file. Update the CLI file like so:
// .angular-cli.json
...
"scripts": [
"../node_modules/pusher-js/dist/web/pusher.min.js",
"../node_modules/chart.js/dist/Chart.min.js"
],
...
Update the app.component.html
file to include the Charts component.
// app.component.html
<div class="main">
<div class="section census"
...
</div>
<div class="section data">
// include the charts component here
<app-charts [censusData]="censusData" (newEntry)="onNewEntry($event)">
</app-charts>
</div
At this point, your application should have realtime updates when votes are placed. Ensure that the server is running alongside the Angular development server. If not, run node server
and ng serve
in two separate terminals. Both terminals should be opened in the root folder of your project.
To test the realtime functionality of the application, open two browsers side-by-side and engage the application. Data updates should be in realtime.
Conclusion
Using Pusher, we’ve built out an application using the pub/sub pattern to recieve realtime updates. With the help of Chart.js, our data was well presented using charts. You can check out the repo containing the demo on GitHub.
13 May 2018
by Christian Nwamba