Angular is a feature-rich application framework, and d3.js is an effective visualization library. In this post I’ll be walking through how I use the two together
The visual aspects of an Angular application are organized into components, and so d3 code ought to go into component code. The rules I follow when doing this are:
All d3
objects and functions that don’t need to be re-initialized every time the visualization has to update should be created in the constructor. This includes things like d3.scaleLinear()
and d3.forcedLayout()
.
Inject the ElementRef
of the component so you can use it to append and set up the <svg>
element inside the component’s markup.
Set this.svg
(or whatever you want to call the reference to the base d3 <svg>
element) inside of ngOnInit
.
Create a debounced render function which contains your d3 rendering logic. I like to debounce it so that multiple calls to it do not overwork the browser or cause weirdness in animations.
Call the render function created above in ngOnChanges
.
Here is an annotated “pie chart” example of a component written in such a way:
import { Component, OnInit, OnChanges, Input, ElementRef, HostListener } from '@angular/core';
import * as d3 from 'd3';
import * as _ from 'lodash';
// this interface is specific to this example
interface IMetric {
key: string;
color: string;
}
/**
* Declare the component like any other angular component
*/
@Component({
selector: 'pie-chart',
templateUrl: './pie-chart.component.html',
styleUrls: ['./pie-chart.component.scss']
})
export class PieChartComponent implements OnChanges, OnInit {
// These inputs are specific to this pie chart example but you
// could have any inputs related to what you're visualizing
@Input()
metrics: IMetric[];
@Input()
datum: any;
// This will hold the main <g> element inside the single <svg> of this component.
// Still trying to work out the right way to handle types, `any` is fine for now.
private svg: d3.Selection<SVGElement, {}, null, undefined> // TODO: better type signature
// This is a debounced version of the real render function (this._render), to prevent render thrashing
// Also add the hostlistener for window resize events if that is relevant
@HostListener('window:resize')
private render = _.debounce(this._render);
// In this example, we need a d3.arc function. We will
// initialize this in the constructor
private arc: d3.Arc<any, ISliceDatum>;
// All d3 functions and objects that don't need to be re-initialized on every render should be
// created here. In this example, just this.arc falls under that category.
// Also note that this.element is getting injected here. It is the root element for this component.
// We are going to use this in ngOnInit.
constructor(private element: ElementRef) {
// Set up scales and d3 functions here.
this.arc = d3.arc<any, ISliceDatum>()
.cornerRadius(2);
}
// Use this lifecycle hook to initialize the svg element where all
// the visualizations go.
ngOnInit() {
this.svg = d3.select(element.nativeElement)
.append('svg')
.append('g')
.classed('piechart', true);
}
// You will want to call the debounced public render function
// whenever changes are detected, assuming changes of @Input
// values should trigger a re-render. You can of course be more
// granular with what triggers render.
ngOnChanges() {
this.render();
}
// This is where the actual render logic resides. This should look like
// normal d3 usage that you see in most d3 examples.
private _render() {
// create the data to bind
let total = 0;
let sliceData: ISliceDatum[] = this.metrics.map(metric => {
let value = mu.getValue(metric, this.datum);
let offset = total;
total += value;
return { metric, value, offset }
});
let toRadians = Math.PI / total;
// This updates the d3.arc function
this.arc
.startAngle(d => d.offset * toRadians)
.endAngle(d => (d.offset + d.value) * toRadians);
// assign the data
let slices = this.svg
.selectAll('path.slices')
.data(sliceData, (d: ISliceDatum) => d.metric.id)
// EXIT SLICES
slices.exit()
.transition()
.style('opacity', 0)
.remove();
// ENTER SLICES
let newSlices = slices.enter()
.append('path')
// etc..
// MERGE NEW
slices = newSlices.merge(slices);
// UPDATE SLICES
slices
.attr('fill', (d: ISliceDatum) => d.metric.color)
// etc.
}
}
Certainly there are likely other approaches to working with d3 and angular, but so far this has worked for my use-cases. Feedback is welcome and appreciated!