Here I am assuming you have little knowledge of D3 and how it works. If you just want to see how the sausage is made here is the finished code: https://codesandbox.io/s/react-spring-and-d3-micex
If you've googled, "how to make charts with JS" you've almost certainly come across D3. It's one of the most popular projects on GitHub, and the de facto framework for creating visualizations on the web. It's also got a reputation for being very hard to learn. That reputation isn't completely unwarranted because a lot of people go into it thinking it's a data visualization library. Which, I think is a bad way of thinking about D3. The way I think about data visualization libraries is you usually have a component that takes in some data and other parameters and then it generates a chart. D3 isn't like that, it's more low level. It rather gives you a collection of modules that help you create visualizations. This is why I think it works well with React. We can pick and choose the modules that we need to use to create our charts.
So let's get started, and see how both of them can work well together. We'll look at some good approaches and bad ones for mixing both technologies.
Let's make a basic scatter plot
First, get a basic react project started, you can use create-react-app
or CodeSandbox, or anything that will get you started quickly. Once you have a basic environment setup create a Scatter.js component and some random data.
import React from "react";
function RandomData() {
const data = [...Array(100)].map((e, i) => {
return {
x: Math.random() * 40,
y: Math.random() * 40,
temparature: Math.random() * 500
};
});
return data;
}
function Scatter() {
const data = RandomData()
return (
<div></div>
);
}
export default Scatter;
Usually, you'll be getting data from an API or a state management system, but for this example, we'll keep things super simple.
Next, we'll add an svg
and a g
element. We're also going to give the svg
a width and height. The default for svg
's is 300 by 150 and we'll want our chart to be bigger than that. We'll also want to add some padding so we're going to create margins, very similar to css and subtract it from our width and height.
function Scatter() {
const data = RandomData(),
w = 600,
h = 600,
margin = {
top: 40,
bottom: 40,
left: 40,
right: 40
};
const width = w - margin.right - margin.left,
height = h - margin.top - margin.bottom;
return (
<div>
<svg width={w} height={h}>
<g transform={`translate(${margin.left},${margin.top})`}>
</g>
</svg>
</div>
);
}
export default Scatter;
This is where approaches get a bit different. Some people, here will create a ref
, then use D3 select to select the g
or svg
element and use D3 to render the data to the dom.
import React, { useRef, useEffect } from "react";
import "./styles.css";
import { select, scaleLinear, extent } from "d3";
function RandomData() {
const data = [...Array(100)].map((e, i) => {
return {
x: Math.random() * 40,
y: Math.random() * 40,
temparature: Math.random() * 500
};
});
return data;
}
export default function App() {
const data = RandomData();
const ref = useRef(null);
const w = 600,
h = 600,
margin = {
top: 40,
bottom: 40,
left: 40,
right: 40
};
const width = w - margin.right - margin.left,
height = h - margin.top - margin.bottom;
useEffect(() => {
const g = select(ref.current);
const xScale = scaleLinear()
.domain(extent(data, d => d.x))
.range([0, width]);
const yScale = scaleLinear()
.domain(extent(data, d => d.y))
.range([height, 0]);
g.selectAll(".circles")
.data(data)
.enter()
.append("circle")
.attr("r", 3)
.attr("cx", d => xScale(d.x))
.attr("cy", d => yScale(d.y))
.attr("fill", "black")
}, [data, height, width]);
return (
<div className="App">
<svg width={w} height={h}>
<g ref={ref} transform={`translate(${margin.left},${margin.top})`} />
</svg>
</div>
);
}
I dislike this approach. The code is less declarative and readable, especially for your colleagues that don't know D3. It's also less reusable because you can't break the different parts into components. And it's not as performant, because you don't get to take advantage of React and the virtual dom.
What we are going to do is use React to render the elements to the dom, and use D3 to do the math. This way we'll get the best of both worlds.
With that in mind, we'll create our scales.
Don't forget to npm
or yarn
install d3-scale
and d3-array
.
import React from "react";
import { scaleLinear } from "d3-scale";
import {extent} from "d3-array"
function RandomData() {
const data = [...Array(100)].map((e, i) => {
return {
x: Math.random() * 40,
y: Math.random() * 40,
temparature: Math.random() * 500
};
});
return data;
}
function Scatter() {
const data = RandomData(),
w = 600,
h = 600,
margin = {
top: 40,
bottom: 40,
left: 40,
right: 40
};
const width = w - margin.right - margin.left,
height = h - margin.top - margin.bottom;
const xScale = scaleLinear()
.domain(extent(data, d => d.x))
.range([0, width]);
const yScale = scaleLinear()
.domain(extent(data, d => d.y))
.range([height, 0]);
return (
<div>
<svg width={w} height={h}>
<g transform={`translate(${margin.left},${margin.top})`}>
</g>
</svg>
</div>
);
}
export default Scatter;
Here we've created our scaling functions. In the next step, we will use these functions to map our data to pixels. The domain
takes an array with two or more elements. The domain is the input, the data you want to scale. The range is the output. The given dimensions of how it will appear on the screen.
extent
is a function that returns an array with the min and max of your data.
Also, notice how we are using our padded width
and height
. If we didn't, if we used just used our w
and h
variables then some circles would be at the edge of the svg
and cut off.
Now let's render some circles with React and use our newly created scales to accurately translate our data (x,y coordinates) to pixel measurements.
import React from "react";
import { scaleLinear } from "d3-scale";
import {extent} from "d3-array"
function RandomData() {
const data = [...Array(100)].map((e, i) => {
return {
x: Math.random() * 40,
y: Math.random() * 40,
temparature: Math.random() * 500
};
});
return data;
}
function Scatter() {
const data = RandomData(),
w = 600,
h = 600,
margin = {
top: 40,
bottom: 40,
left: 40,
right: 40
};
const width = w - margin.right - margin.left,
height = h - margin.top - margin.bottom;
const xScale = scaleLinear()
.domain(extent(data, d => d.x))
.range([0, width]);
const yScale = scaleLinear()
.domain(extent(data, d => d.y))
.range([height, 0]);
const circles = data.map((d, i) => (
<circle
key={i}
r={5}
cx={xScale(d.x)}
cy={yScale(d.y)}
style={{ fill: "lightblue"}}
/>
));
return (
<div>
<svg width={w} height={h}>
<g transform={`translate(${margin.left},${margin.top})`}>
{circles}
</g>
</svg>
</div>
);
}
export default Scatter;
Here we are mapping over our data and rendering a bunch of svg circles. As you can see we've successfully translated our raw data into pixels. If you are still confused by what scales are doing, try removing them and see what happens.
Let's add some axes!!
Create a new component and let's call it AxisLeft.js. Here we are going to create our y-axis.
To do this we are going to use the scale.ticks()
method which will generate an array based on our scale domain. If there is not a specified number for ticks, it defaults to 10.
Then we are going to loop over it to create our axis.
import React from "react";
function AxisLeft({ yScale, width }) {
const textPadding = -20
const axis = yScale.ticks(5).map((d, i) => (
<g key={i} className="y-tick">
<line
style={{ stroke: "#e4e5eb" }}
y1={yScale(d)}
y2={yScale(d)}
x1={0}
x2={width}
/>
<text
style={{ fontSize: 12 }}
x={textPadding}
dy=".32em"
y={yScale(d)}
>
{d}
</text>
</g>
));
return <>{axis}</>;
}
export default AxisLeft;
For our text we want it to be properly centered, which is handled by the dy
attribute, with our gridlines and have the appropriate padding, which is why we have a negative value for the x
attribute.
The line
svg element is used to create the gridlines.
As a challenge, try and create the x-axis with what we learned here. Create a new component, and call it AxisBottom
. I'll create some space below so that you don't see the answer, just scroll once you think you got it...
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
Got it? good!
If not it's okay, we're still learning.
Here is the result:
import React from "react";
function AxisBottom({ xScale, height }) {
const textPadding = 10;
const axis = xScale.ticks(10).map((d, i) => (
<g className="x-tick" key={i}>
<line
style={{ stroke: "#e4e5eb" }}
y1={0}
y2={height}
x1={xScale(d)}
x2={xScale(d)}
/>
<text
style={{ textAnchor: "middle", fontSize: 12 }}
dy=".71em"
x={xScale(d)}
y={height + textPadding}
>
{d}
</text>
</g>
));
return <>{axis}</>;
}
export default AxisBottom;
As you can see it's pretty similar to our y-axis. In a future post, we'll get into making these more reusable.
Now import and add your new shiny axes components to your scatter component, add a little title for the fans, and voila a scatter plot!
return (
<div>
<h1>React + D3</h1>
<svg width={w} height={h}>
<g transform={`translate(${margin.left},${margin.top})`}>
<AxisLeft yScale={yScale} width={width} />
<AxisBottom xScale={xScale} height={height} />
{circles}
</g>
</svg>
</div>
);
Your output should look something like this:
Here we have the best of both worlds. Our code is declarative and easy to read. It takes advantage of react's rendering power, and componentization to make our code more reusable.
Feel free to also play around with the design and make it look better! Play with the axes and scales, color, title, etc...
Bonus
You might also be thinking, what about interactivity. Again you can create a ref and use D3 to do the animation, but React also does have useful and performant animation libraries that we can use. I don't do a ton of animations personally but when I do, I usually use react-spring
. For a little bonus, I've created a code sandbox with the same example, except with some react-spring sprinkled in: https://codesandbox.io/s/react-spring-and-d3-micex.
Top comments (1)
"What we are going to do is use React to render the elements to the dom, and use D3 to do the math. This way we'll get the best of both worlds."
This. By appending everything using d3 only, we leave the understanding of line, text, circle HTML elements beside. Really liked your approach. Kudos