D3 is a great library for displaying visual data on a webpage. Getting it integrated with your favourite framework, in my experience at least, takes a bit of time and thought. This post looks at a few of the things I’ve learnt building D3 charts within the EmberJS framework. We’ll start with the basics of installing and then look at a couple of ways to integrate D3 with your Ember components.
At the time of writing Ember is v3.10 and D3 is v5.9 however the patterns should translate well to most other versions.
Installing D3 and Integrating With Ember
Firstly we’re going to want D3:
ember install ember-d3
By default, you’ll have the entire library loaded in at this point and you can start using it like:
import { select } from 'd3-selection';
import { arc } from 'd3-shape';
But to reduce your build size, I’d recommend whitelisting only those parts of the library that you’re using. Add the following to your environment.js file:
module.exports = function(environment) {
let ENV = {
// ...
'ember-d3': {
only: ['d3-shape', 'd3-path', 'd3-selection']
}
};
After changing that array make sure you completely stop and restart Ember. Relying on Ember serve rebuilding didn’t work for me, and the changes were picked up only after a full restart.
One important note with the above import is that some of those imports will themselves depend upon other parts of D3 that you need to add yourself to that array. For example in the above list d3-shape depends on d3-path so you’ll need to add both. The easiest way to achieve this is just to load the ones you directly import and check for errors when the code attempts to import.
In the example shown here, I’d need to add d3-path to the whitelist of D3 sub-libraries to include in the build.
Integrating D3 With Ember
Now we have D3 available to our Ember application, the next step is integrating it with our components.
The way I think of D3 is having two distinct functions, one is to provide utilities to help with display (for example, calculating SVG paths) and the other is DOM selection and data binding. When you’re using a client-side framework such as Ember or React you typically already have all the data binding functionality you need, so you do have the option of using the framework to provide that.
This gives you two approaches to integrating with your components:
Use D3 for data-binding and DOM manipulation
With this approach you pass the HTML element to D3 and let that manage all of the modification of data. This means you need to hook into the component lifecycle and tell D3 to run the updates at that time.
Let Ember do the data binding and DOM manipulation
This approach uses your client-side framework to update the visualisation in a similar manner to how you update data elsewhere in your application using templates.
We’ll look at how these two approaches can be implemented with an example and discuss some of the pros and cons of the two approaches. For these examples I’ve built about the simplest D3 chart I could, which is a success/failure Donut Chart. You can update the count of Passed or Failed by either changing the numbers in the input boxes or by clicking on the coloured arc in the chart itself to increment that number.
See it running here.
Use D3 for Data-Binding and DOM Manipulation
The approach here is to create a class that manages all the D3 code and then add hooks into the component lifecycle telling it when to create and update.
The advantage of this approach is that it’s just standard D3, this means you can take code from online examples or other projects and they’re much easier to drop in. As D3 is managing the DOM transitions will also work as expected. You also end up with something that is quite portable between frameworks as you’re using a plain old Javascript class with a simple interface.
The major disadvantage with this approach is that handling events on the chart itself are tricky as you need a way to communicate back to the component. The pattern I like to use for this is to add actions into your component like you would normally and then pass the D3 class a sendAction function (with “this” bound to the component) that it can call with an action name and arguments. See the Ember send method API docs for details on this.
The component:
import Component from '@ember/component';
import { computed, observer } from '@ember/object';
import DonutChart from 'blog-ember-d3/libs/donut-chart';
export default Component.extend({
componentClass: 'donut-chart',
// Dimensions.
width: 250,
height: computed.alias('width'),
margin: 10,
// Initial values, when these are bound to an input in the template they are
// returned as strings, which is why I prefer to keep them as strings and
// use the computed properties below to convert them into number types.
passedValue: '34',
failedValue: '21',
// Wrap these in computed properties to convert them from strings to ints.
numberPassed: computed('passedValue', function () {
return Number.parseInt(this.passedValue, 10);
}),
numberFailed: computed('failedValue', function () {
return Number.parseInt(this.failedValue, 10);
}),
// After the component has been created in the DOM
didInsertElement () {
this._super(...arguments);
// Grab the DOM element and create the chart.
const container = this.element.querySelector(`.${this.componentClass}`);
// This is what we'll use to send actions from the D3 chart Class back to
// the component actions. It's important to bind it to "this" so that the
// actions have the correct context when the run.
const sendAction = this.send.bind(this);
// Create the chart.
const chart = new DonutChart(container, sendAction, {
height: this.height,
width: this.width,
margin: this.margin
});
this.set('chart', chart);
// Run an initial update on the chart with the data.
chart.update(this.numberPassed, this.numberFailed);
// *** Bonus feature ***
// You can use a pattern like this if you need to update the chart on
// browser window resizing. It will wait for the size to stabilise for
// 500ms so it only runs once the user has stopped dragging the window.
let resizeId;
window.addEventListener('resize', () => {
clearTimeout(resizeId);
resizeId = setTimeout(() => {
chart.update(this.numberPassed, this.numberFailed);
}, 500)
});
},
// Set up an observer here to detect any changes to the data, and tell the
// chart to update. The linter doesn't like the use of an observer although
// it seems like a legitimate use case to me (perhaps using an action on
// the template would be better). One advantage of an observer is it would
// require no change if the "numberPassed" and "numberFailed" properties were
// switched to being passed in rather than modified within the component.
// eslint-disable-next-line ember/no-observers
changeObserver: observer('numberPassed', 'numberFailed', function () {
this.chart.update(this.numberPassed, this.numberFailed);
}),
actions: {
// Actions to run when the chart is clicked.
clickPassed() {
this.set('passedValue', (this.numberPassed + 1).toString());
},
clickFailed() {
this.set('failedValue', (this.numberFailed + 1).toString());
}
}
});
The template:
<h2>Donut Chart - Pure D3</h2>
<span class="failed-label-2">Failed: {{input value=failedValue type="number" min="0"}}</span>
<span class="passed-label-2">Passed: {{input value=passedValue type="number" min="0"}}</span>
<div class={{componentClass}}></div>
The DonutChart D3 class would look like this (stripped down as the D3 specifics aren’t really relevant, see the Github repo for the full code):
import { select, event } from 'd3-selection';
import { arc } from 'd3-shape';
// A class to create and update a D3 donut chart.
class DonutChart {
// Initial set up, pass anything from the component that D3 requires to
// create the chart.
constructor (container, sendAction, options) {
const defaults = {
width: 250,
height: 250,
margin: 10
};
const {height, width, margin} = Object.assign(defaults, options);
this.svgContainer = select(container)
.append('svg')
.attr('height', height)
.attr('width', width);
const transform = `translate(${width / 2}, ${height / 2})`;
this.passedPath = this.svgContainer
.append('path')
.attr('transform', transform)
.attr('class', 'passed-2')
.on('click', () => sendAction('clickPassed')) // Send an action back to the component.
.on('mousedown', () => event.preventDefault());
// … See Github for the full code.
}
// Pass data to update the chart with.
update (numberPassed, numberFailed) {
// Run any update chart code here.
// … See Github for the full code.
}
}
export default DonutChart;
Let Ember Do the Data Binding and DOM Manipulation
So the other way to approach this is to create the SVG (or whatever DOM you’re creating with your chart) yourself in an Ember template, just like any other component. Then you let Ember do all of the DOM manipulation, for example if a value changes you can recalculate the SVG path yourself using the D3 utility tools. See the following example to get the idea.
The component:
import Component from '@ember/component';
import { computed } from '@ember/object';
import { arc } from 'd3-shape';
export default Component.extend({
width: 250,
height: computed.alias('width'),
margin: 10,
// Initial values, when these are bound to an input in the template they are
// returned as strings, which is why I prefer to keep them as strings and
// use the computed properties below to convert them into number types.
passedValue: '10',
failedValue: '2',
// Wrap these in computed properties to convert them from strings to ints.
numberPassed: computed('passedValue', function () {
return Number.parseInt(this.passedValue, 10);
}),
numberFailed: computed('failedValue', function () {
return Number.parseInt(this.failedValue, 10);
}),
totalNumber: computed('numberPassed', 'numberFailed', function () {
return this.numberPassed + this.numberFailed;
}),
outerRadius: computed('width', 'margin', function () {
return (this.width - (this.margin) * 2) / 2;
}),
innerRadius: 70,
transform: computed('width', 'height', function () {
return `translate(${this.width / 2}, ${this.height / 2})`;
}),
// Arc function for the passed tests.
passedArc: computed('numberPassed', 'totalNumber', 'outerRadius', 'innerRadius', function () {
const arcFunction = arc()
.outerRadius(this.outerRadius)
.innerRadius(this.innerRadius)
.startAngle(0)
.endAngle((Math.PI * 2) * (this.numberPassed / this.totalNumber));
return arcFunction();
}),
failedArc: computed('numberPassed', 'totalNumber', 'outerRadius', 'innerRadius', function () {
const arcFunction = arc()
.outerRadius(this.outerRadius)
.innerRadius(this.innerRadius)
.startAngle((Math.PI * 2) * (this.numberPassed / this.totalNumber))
.endAngle(Math.PI * 2);
return arcFunction();
}),
actions: {
clickPassed () {
this.set('passedValue', (this.numberPassed + 1).toString());
},
clickFailed () {
this.set('failedValue', (this.numberFailed + 1).toString());
},
noop () {}
}
});
The template:
{{! template-lint-disable no-invalid-interactive }}
<h2>Donut Chart - Ember Binding</h2>
<span class="failed-label-1">Failed: {{input value=failedValue type="number" min="0"}}</span>
<span class="passed-label-1">Passed: {{input value=passedValue type="number" min="0"}}</span>
<div>
<svg width={{width}} height={{height}}>
<path transform={{transform}} d={{passedArc}} class="passed-1" {{action "clickPassed"}} {{action "noop" on="mouseDown"}} />
<path transform={{transform}} d={{failedArc}} class="failed-1" {{action "clickFailed"}} {{action "noop" on="mouseDown"}} />
<text text-anchor="middle" transform={{transform}}>
<tspan font-size="40" font-weight="600">{{totalNumber}}</tspan>
<tspan font-size="16" x="0" dy="1.5em">Total</tspan>
</text>
</svg>
</div>
You can see that the interactions on the chart here feel much more natural, in fact the whole solution feels more Ember-like and similar to the rest of your application. I also personally prefer writing the SVG into a template rather than having D3 build it. A big downside is you forgo all the coolness of D3’s transitions which is one of its major features, in a lot of cases this will be a show-stopper. You are also coupling your D3 code pretty tightly to Ember which is a problem if you plan to reuse the chart across projects not written with Ember.
Conclusion
Both approaches have a certain niche were they seem to work better. Personally I like having both available for use in project, but in general I’d lean towards the more pure D3 approach as I think transitions are a big feature of D3 and you’d usually want the option of incorporating them. I also like the idea of having a portable Javascript class. However for cases were you’re not interested in transitions and you have lots of user interaction with the chart opting for letting Ember manage the DOM and data binding may be a tidier solution.
Code: https://github.com/ewen/blog-ember-d3
Demo: https://ewen.github.io/blog-ember-d3/
Banner image credit: Andrew Neel