Plotting Functions with React and d3.js
April 18, 2020
As the basis of many upcoming visualizations, I will use libraries of the huge d3 ecosystem. For many applications, for example to animate vibrating strings, it want to plot functions:
The code:
<Plot functions={[(x) => Math.sin(x), (x) => Math.cos(x)]} range={{ x: [-Math.PI, Math.PI], y: [-1, 1] }} />
Combining d3 and react
As d3 has its own API for handling the DOM, we could theoretically do the job without React. But as I want to be able to use components in MDX files directly, React needs to be at least a wrapper for d3.
With d3-selection
So now let's just use React as a wrapper and let d3 handle the DOM inside the svg element, by using its jquery-like API d3-selection.
By using most of the code from here and pasting it into the ref callback, we can operate directly on the svg element:
import React from 'react';
import { scaleLinear } from 'd3-scale';
import { select } from 'd3-selection';
import { axisBottom, axisLeft } from 'd3-axis';
export default function FunctionPlot({ f, width, margin, height, range }) {
margin = margin || 40;
width = width || 400;
height = height || 300;
range = range || { x: [-1, 1], y: [-1, 1] };
return (
<svg
ref={(el) => {
const svg = select(el)
.attr('width', width + 20)
.attr('height', height + 20),
x = scaleLinear()
.domain(range.x)
.range([margin, width - margin]),
y = scaleLinear()
.domain(range.y)
.range([height - margin, margin]),
g = svg.append('g'),
line = [];
for (let i = margin + 1e-6; i < width - margin; i += 1) {
const X = x.invert(i),
Y = f(X),
j = y(Y);
line.push([i, j]);
}
g.append('path')
.attr('d', 'M' + line.join('L'))
.style('stroke', 'steelblue')
.style('fill', 'none');
g.append('g').attr('transform', `translate(${margin},0)`).call(axisLeft(y));
g.append('g')
.attr('transform', `translate(0,${y(0)})`)
.call(axisBottom(x));
}}
></svg>
);
}
and using it:
<FunctionPlot f={(x) => Math.sin(x)} range={{ x: [-4, 4], y: [-1, 1] }} />
Other d3 APIs used:
- d3-scale for a convenient translation of the function value range to the pixels dimensions on screen
- d3-axis for easy-to-use x and y axis
With JSX
As React is already a pretty good lib to handle the DOM efficiently, we can (mostly) spare d3-selection and render the SVG elements in JSX:
export function FunctionPlot({ f, width, margin, height, range }) {
margin = margin || 40;
width = width || 400;
height = height || 300;
range = range || { x: [-1, 1], y: [-1, 1] };
const line = [],
x = scaleLinear()
.domain(range.x)
.range([margin, width - margin]),
y = scaleLinear()
.domain(range.y)
.range([height - margin, margin]);
for (let i = margin + 1e-6; i < width - margin; i += 1) {
const X = x.invert(i),
Y = f(X),
j = y(Y);
line.push([i, j]);
}
return (
<svg width={width + 20} height={height + 20}>
<g>
<path d={`M${line.join('L')}`} stroke="steelblue" fill="none" />
<g transform={`translate(${margin},0)`} ref={(g) => select(g).call(axisLeft(y))} />
<g transform={`translate(0,${y(0)})`} ref={(g) => select(g).call(axisBottom(x))} />
</g>
</svg>
);
}
Now the .select / .append calls have been refactored to JSX. I like this code much more as it looks flatter and lets me manipulate the svg elements directly as JSX without using a wide API surface of chaining methods.
The output is exactly the same:
You may have noticed that the axes are still rendered using ref callback + d3-selection. This is because the axes methods are generators that output a subtree of DOM nodes. If we wanted to avoid using d3-selection completely, we could implement our own axis component using scale.ticks.
But for my use case, the above approach is perfectly fine.
I am really excited by d3 and the way it is organized in small modules, letting me pick only the parts I need! It will be a big part of upcoming visualizations on this blog.