How d3 allowed me to make exceptional charts with Angular

Carlos Moura
5 min readFeb 8, 2021

On the distant year of 2016 I started developing dashboards. I used d3 for building the interactive charts, supported by the good old jQuery.

Then, as it happens with all the successful projects, I started getting more and more requests, requests that added increased complexity to the project. Soon my nice code turned into a nice dish of spaghetti. I was losing control of the code and I knew it was time to assess the future of the project.

Eventually I choose Angular and started my projects from scratch. Angular is a fantastic framework, but even today it doesn’t beat the amazing functionalities of d3 regarding chart creation. And so I compromised, balancing the two competing libraries for DOM management: The SVG to D3, everything else to Angular. And it worked! In fact, it worked so well that the once single html page + d3 + jQuery transformed into a full stack ecosystem (laravel API + Angular Framework managing multiple interactive dashboards + d3 to create the charts).

Auto sorting heatmap using a Copeland Method and sub chart tooltip

Is the system in place that is allowing us to deploy a new interactive tool in the fraction of a time that would take if we need to start from scratch.

And everything begins with

import * as d3 from ‘d3’;

From there, you capture the host component at construction time with

constructor(_elementRef: ElementRef) {
this._host = _elementRef.nativeElement;
}

And after catching the svg in the ngOnInit, we are good to go. If you know d3 and you know Angular, then you know how to make charts in Angular.

ngOnInit(): void {
this.svg = d3.select(this._host).select('svg');
}
C3 Graz city performance viz-à-viz its peer cities

Still in doubt about the hybrid relation between D3 and Angular? So here’s a practical example:

Tracking Covid data over time

So here’s our story:

Your boss asked you to add a simple chart to visualize Covid data in the US. He wants a full fledged line chart where an user can explore the data by hovering the chart and highlight certain parts of the information. The chart should use the last daily data available from The COVID Tracking Project api.

He wants to see a first iteration of the chart as soon as possible.

He doesn’t need the full interactive version now. Because he wants to understand if the data is relevant for use with this kind of chart.

Its a sensitive idea to test if the data is a good match for your chart. Data visualization is about telling stories. If the users cannot build their own stories with the visualizations, then the purpose is lost.

Getting the data from the API

We start with a simple service, ApiService, to get the data from the API

Adding the chart component

Then we create a LineChart component, with a data input (from the api) and add it to the AppComponent

Chart inner code

With the chart integrated in the application, its time to start working with d3

Our d3 programming needs to be a reflection of the physical (ie, digital) version of the chart.

So our shopping list is (for the simplified version):

  • The dimensions of the chart
  • The inner dimensions (dimensions — margins)
  • The horizontal axis — with the months represented
  • The vertical axis — the number of covid cases
  • The timeScale — d3.scaleTime
  • The covidNumberScale — d3.scaleLinear
  • The color scale, for colouring the lines — d3.scaleOrdinal(d3.schemeCategory10)
  • A function to convert the time strings from the api into dates — d3.timeParse('%Y%m%d')
  • A line generator to convert the data into paths (the svg lines) — d3.line
  • A selection of parameters to show: ["hospitalized", "death"]
  • A data conversor, to convert the api data into data for the chart
  • A legend

The chart’s lifecycle

The chart has three main stages:

  • Initialization — Where all the fixed (eg: support containers and labels, dimensions) and dynamic (lines, legends, axis, scales…) are added
  • External changes — Where all the dynamic components are updated
  • Internal highlights and filtering (ex, hovering the lines displays a tooltip)

Of course some elements can be fixed in one chart and dynamic in others. For example, if we allow the chart to resize, then the dimensions will be dynamic instead of fixed.

The Initialization stage

We setup:

  • the svg: this.svg = this.host.select('svg')
  • dimensions:
margins = {
top: 20,
right: 80,
bottom: 20,
left: 30
};
const dims = this.svg.node().getBoundingClientRect();this.innerWidth = dims.width - this.margins.left - this.margins.right;this.innerHeight = dims.height - this.margins.top - this.margins.bottom;
  • the axis containers, the data container, the legend Container the title, properly positioned
// horizontal axis container
this.svg.append('g').attr('class', 'horizontalAxisContainer')
.attr('transform', `translate(${this.margins.left}, ${this.margins.top + this.innerHeight})`);

// vertical axis container
this.svg.append('g').attr('class', 'verticalAxisContainer')
.attr('transform', `translate(${this.margins.left}, ${this.margins.top})`);

// data container
this.svg.append('g').attr('class', 'dataContainer')
.attr('transform', `translate(${this.margins.left}, ${this.margins.top})`);

// legend container
this.svg.append('g').attr('class', 'legend')
.attr('transform', `translate(${this.margins.left + this.innerWidth}, ${this.margins.top})`);

// title
this.svg.append('g').attr('transform', `translate(${this.margins.left + 0.5 * this.innerWidth}, 20)`)
.append('text')
.attr('class', 'label label--title')
.style('text-anchor', 'middle')
.text('Covid 19 evolution in the US');

The External Changes phase

We setup all the dynamic elements that are data dependant

  • The transformed data for the lines
// lines data generates the series for the lines
this.linesData = this.variables.map((v) => {
return {
name: v,
data: this.data.map((d) => {
return {
x: this.timeParse(d.date),
y: d[v]
};
})
};
});
  • The scales
// scales
const timeDomain = d3.extent(this.data, d => this.timeParse(d.date));
const maxValue = d3.max(this.linesData, d => d3.max(d.data, elem => elem.y));

this.timeScale = d3.scaleTime()
.domain(timeDomain || [])
.range([0, this.innerWidth]);

this.covidNumbersScale = d3.scaleLinear()
.domain([0, maxValue])
.range([this.innerHeight, 0]);

this.colors = d3.scaleOrdinal(d3.schemeCategory10)
.domain(this.variables);
  • The axis
// axis
const horizontalAxis = d3.axisBottom(this.timeScale).ticks(d3.timeMonth.every(2)).tickSizeOuter(0);

const verticalAxis = d3.axisLeft(this.covidNumbersScale).tickFormat(d3.format('~s'));
this.svg.select('g.horizontalAxisContainer').call(horizontalAxis);

this.svg.select('g.verticalAxisContainer').call(verticalAxis);
  • The lines
// line generator
this.lineGenerator = d3.line()
.x(d => this.timeScale(d.x))
.y(d => this.covidNumbersScale(d.y));
//draw lines

this.svg.select('g.dataContainer')
.selectAll('path.data')
.data(this.linesData, (series) => series.name)
.join(
enter => enter.append('path').attr('class', 'data')
.style('fill', 'none')
.style('stroke', series => this.colors(series.name))
.attr('d', d => this.lineGenerator(d.data)),
update => update
.call(upd => upd.transition()
.attr('d', d => this.lineGenerator(d.data))
),
exit => exit.remove()
);
  • The legend (to be continued)

Conclusion

Adding d3 generated charts to d3 is as simple as working with d3 alone, even simpler considering that we can use all the power of the typescript language.

With some proficiency we can generate a full chart in one day or less, depending on its complexity and features available.

The generated line chart

--

--

Carlos Moura

Statistician, Researcher and developer, likes to code, create visualizations and, why not, design databases