A few months ago, I released the v1 of topheman/d3-react-experiments. At that time, my goal was to experiment some of the existing d3 libraries that you could directly use as React components. I managed to setup a few examples with Victory and d3act.
For the v2, I took the approach of a pure d3 user that may or may not know React but would want to keep full control over the creation of his chart, using the d3 API – mixed with React lifecycle hooks.
D3 / StaticMultiLineChart
That way, I created the StaticMultiLineChart component which embeds plain d3 code (I nearly made a full copy/paste of this bl.ocks.org example) and it works just out of the box with React, without needing to know much about React lifecycle hooks.
As you can see, we only need a componentWillUpdate()
lifecycle hook, to cleanup the svg node, so that drawLineChart()
will work properly on each update (since DOMNodes are not reused and it appends some on each update, we would end up with multiple charts).
This is a very naive approach, but it shows how, with little knowing about React lifcecyle hooks and JSX, you can still do d3 … Transitions may not be possible to implement that way (see TransitionMultiLineChart).
import React from 'react';
import ColorHash from 'color-hash';
import { scaleLinear } from 'd3-scale';
import { line } from 'd3-shape';
import { select } from 'd3-selection';
import { axisBottom, axisLeft } from 'd3-axis';
const colorHash = new ColorHash();
export default class StaticMultiLineChart extends React.Component {
static propTypes = {
margin: React.PropTypes.object,
width: React.PropTypes.number,
height: React.PropTypes.number,
data: React.PropTypes.object.isRequired,
minX: React.PropTypes.number,
maxX: React.PropTypes.number,
minY: React.PropTypes.number,
maxY: React.PropTypes.number
}
static defaultProps = {
margin: {
top: 20,
right: 20,
bottom: 30,
left: 50
},
width: 700,
height: 400
}
constructor() {
super();
}
/**
* This example is a reuse of some plain code from an example on https://bl.ocks.org/d3noob/4db972df5d7efc7d611255d1cc6f3c4f
* Since the render method contains .append() invocations, I remove any child of the root node at each render
*
* See the other examples for smarter approaches
*/
componentWillUpdate() {
// each update, flush the nodes of the chart - this isn't the best way - see the other example for better practice
while (this.rootNode.firstChild) {
this.rootNode.removeChild(this.rootNode.firstChild);
}
}
drawLineChart() {
// ... the d3 code manipulating the DOM Node this.rootNode goes here
}
render() {
// only start drawing (accessing the DOM) after the first render, once we get hold on the ref of the node
if (this.rootNode) {
this.drawLineChart();
}
else {
// setTimeout necessary for the very first draw, to ensure drawing using a DOMNode and prevent the following error:
// "Uncaught TypeError: Cannot read property 'ownerDocument' of null"
setTimeout(() => this.drawLineChart(), 0);
}
return (
<svg ref={(node) => this.rootNode = node}></svg>
);
}
}
D3 / TransitionMultiLineChart
To apply d3 transitions, you won’t be able to flush all the DOMNodes on each updates, you’ll need to keep a reference to each of them inside the component to let d3 mutate them (this isn’t specific to React).
We will use componentDidMount()
hook to call our init()
method which will create the nodes for the axis and line groups and store them on the component instance. Thanks to componentDidMount()
behavior, it will only be called once and ensure we have access to the DOM created by React.
All our d3 code updating the DOM is in update()
– split in updateSize()
and updateData()
. This update()
method is called in componentDidUpdate()
, which fires when props or state have changed (such as data, width, height …).
Since componentDidUpdate()
is not called at first render, we call some setState()
in componentDidMount()
to ensure that our chart updates after the very first render.
To avoid some unnecessary DOMNodes attributes changing, we use componentWillReceiveProps()
hook to check whether if it’s worth it to call updateSize()
inside update()
on the next tick by tagging this.shouldUpdateSize
.
In fact, the difference between this code and plain d3 is only a little management of the lifecycle hooks of React to ensure you have access to you DOMNode or know when your data changed …
import React from 'react';
import ColorHash from 'color-hash';
import { scaleLinear } from 'd3-scale';
import { line } from 'd3-shape';
import { select } from 'd3-selection';
import { axisLeft, axisBottom } from 'd3-axis';
import 'd3-transition';
const colorHash = new ColorHash();
export default class TransitionMultiLineChart extends React.Component {
static propTypes = {
margin: React.PropTypes.object,
width: React.PropTypes.number,
height: React.PropTypes.number,
data: React.PropTypes.object.isRequired,
minX: React.PropTypes.number,
maxX: React.PropTypes.number,
minY: React.PropTypes.number,
maxY: React.PropTypes.number
}
static defaultProps = {
margin: {
top: 20,
right: 20,
bottom: 30,
left: 50
},
width: 700,
height: 400
}
constructor() {
super();
this.shouldUpdateSize = true;
// minimal state to manage React lifecycle
this.state = {
initialized: false
};
}
/**
* From React doc https://facebook.github.io/react/docs/component-specs.html#mounting-componentdidmount :
*
* "Invoked once, only on the client (not on the server), immediately after the initial rendering occurs.
* At this point in the lifecycle, you can access any refs to your children (e.g., to access the underlying DOM representation).
* The componentDidMount() method of child components is invoked before that of parent components."
*
* this.init is called here because:
* - we need the ref to the svg node
* - it won't we called again
*/
componentDidMount() {
console.log('componentDidMount');
this.init();
// the code bellow is to trigger componentDidUpdate (which is not called at first render)
setTimeout(() => {
this.setState({
initialized: true
});
});
}
/**
* From React doc : https://facebook.github.io/react/docs/component-specs.html#updating-componentwillreceiveprops
*
* "Invoked when a component is receiving new props. This method is not called for the initial render.
* Use this as an opportunity to react to a prop transition before render() is called
* by updating the state using this.setState().
* The old props can be accessed via this.props. Calling this.setState() within this function will not trigger an additional render."
*
* I use this hook to check whether or not this.updateSize should be called on the next update
* Doing the same thing about this.updateData would involve deep checking the whole data passed.
*/
componentWillReceiveProps({ margin, width, height, minX, maxX, maxY }) {
console.log('componentWillReceiveProps');
if (margin !== this.props.margin || width !== this.props.width || height !== this.props.height ||
minX !== this.props.minX || maxX !== this.props.maxX || maxY !== this.props.maxY) {
console.log('change size');
this.shouldUpdateSize = true;
}
}
/**
* From React doc https://facebook.github.io/react/docs/component-specs.html#updating-componentdidupdate :
*
* "Invoked immediately after the component's updates are flushed to the DOM.
* This method is not called for the initial render.
* Use this as an opportunity to operate on the DOM when the component has been updated."
*
* this.update is called here because:
* - it's not called for initial render - componentDidMount ensures to have our svg element init
* - it's called after each update of the component - we get the new props
*/
componentDidUpdate() {
console.log('componentDidUpdate');
this.update();
}
extractSize() {
const { margin, width: widthIncludingMargins, height: heightIncludingMargins } = this.props;
const width = widthIncludingMargins - margin.left - margin.right;
const height = heightIncludingMargins - margin.top - margin.bottom;
return {
width,
height,
margin
};
}
/**
* Create svg nodes in order to reuse them
*/
init() {
console.log('init');
this.lineGroup = this.rootNode.append('g');
this.axisLeftGroup = this.lineGroup.append('g');
this.axisBottomGroup = this.lineGroup.append('g');
}
updateSize() {
console.log('updateSize');
const { width, height, margin } = this.extractSize();
const { minX, maxX, maxY } = this.props;
// resize/re-align root nodes
this.rootNode
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom);
this.lineGroup
.attr('transform',
'translate(' + margin.left + ',' + margin.top + ')');
// set domain for axis
const xScale = scaleLinear().range([0, width]);
const yScale = scaleLinear().range([height, 0]);
// Scale the range of the data
xScale.domain([minX, maxX]);
yScale.domain([0, maxY]);
// Update the X Axis
this.axisBottomGroup.transition()
.attr('transform', 'translate(0,' + height + ')')
.call(axisBottom(xScale).ticks(width > 500 ? Math.floor(width / 80) : 4)); // prevent from having too much ticks on small screens
// Update the Y Axis
this.axisLeftGroup.transition()
.call(axisLeft(yScale));
// this.line is not called directy since it's used as a callback and is re-assigned. It is wrapped inside this.lineReference
this.line = line() // .interpolate("monotone")
.x(d => xScale(d.x))
.y(d => yScale(d.y));
}
updateData() {
console.log('updateData');
const { data } = this.props;
const drawLine = this.line;
// prepare data to [ [{x, y, color}, {x, y, color}], [{x, y, color}, {x, y, color}] ... ]
const processedData = [];
Object.keys(data).forEach(countryName => {
processedData.push(data[countryName].map((infos) => ({ color: colorHash.hex(countryName), ...infos})));
});
// generate line paths
const lines = this.lineGroup.selectAll('.line').data(processedData);
// [Update] transition from previous paths to new paths
this.lineGroup.selectAll('.line')
.transition()
.style('stroke', d => d[0] ? d[0].color : null)
.attr('d', drawLine);
// [Enter] any new data
lines.enter()
.append('path')
.attr('class', 'line')
.style('stroke-width', '2px')
.style('fill', 'none')
.style('stroke', d => d[0] ? d[0].color : null)
.attr('d', drawLine);
// [Exit]
lines.exit()
.remove();
}
update() {
console.log('update');
// only call this.updateSize() if some props involving size have changed (check is done on componentWillReceiveProps)
if (this.shouldUpdateSize === true) {
this.updateSize();
this.shouldUpdateSize = false;
}
this.updateData();
}
render() {
console.log('render');
return (
<svg ref={(node) => this.rootNode = select(node)}></svg>
);
}
}
Conclusion
I just wanted to expose how I managed to make d3 work with React and how, even if you’re a pure d3 user, you should still be able to take advantage of the whole possibilities of d3.
Though, with its component approach and its JSX declaration syntax, React has a great potential for reusable chart components (that’s where it becomes very interesting).
The next step on topheman/d3-react-experiments will be to try to make reusable charts based on JSX (leave the computation part to d3 and make the rendering in React, without letting d3 mutate the DOM).
This may seem like rebuilding the wheel, there are already libraries that do that kind of thing – I’ve even tested some of them on that project – but I can’t seem to find any clear winner …
Are you making data visualisations on React ? What libraries are you using ? Do you use home-made components ?…
Tophe
;
https://rumbleinc.github.io/rumble-js-charts