Why you should use D3

Yes, Paul, you don’t need a library to visualize data. But here are two reasons why you might want to use a library like D3 (or Vega).

Visualization is harder than you think. It’s easy to draw shapes, but there are a surprising number of subtleties in visualization, such as how to draw nice ticks or a smooth curve between data points. A library can provide good defaults and let you explore the design space more quickly.

D3 is smaller (and easier) than you think. D3 isn’t a monolithic framework; it’s a suite of small modules (thirty-one and counting) for data analysis and visualization. These modules work well together, but you should pick and choose the parts you need. D3’s weirdest concept is selections and if you’re using a DOM framework like React (and don’t need transitions), you probably don’t need selections!

To get a sense of what D3 provides, let’s compare it to vanilla JavaScript.

You could use Math.max to compute a maximum.

let MAX_X = Math.max(...data.map(d => d.x));
let MAX_Y = Math.max(...data.map(d => d.y));

Putting aside that this code will overflow the call stack if the data array is big enough (since each value is passed as an argument), using max from d3-array will save you a few keystrokes.

let MAX_X = max(data, d => d.x);
let MAX_Y = max(data, d => d.y);

More usefully, D3’s max will ignore NaN or undefined values, which is handy if you have missing data. And you can use D3’s extent to compute the minimum and maximum in one go.

let [MIN_X, MAX_X] = extent(data, d => d.x);
let [MIN_Y, MAX_Y] = extent(data, d => d.y);

You could write your linear scales by hand.

let x = val => val / MAX_X * WIDTH;
let y = val => HEIGHT - val / MAX_Y * HEIGHT;

And here’s the equivalent with d3-scale.

let x = scaleLinear().domain([0, MAX_X]).range([0, WIDTH]);
let y = scaleLinear().domain([0, MAX_Y]).range([HEIGHT, 0]);

A scale abstraction not only makes your code more readable, it makes it easier to alter the scale definition, say to nice the domain or to adopt a square-root transform. (And wait until we get to axes…)

You could write your own ticks implementation.

let x_ticks = getTicks(TICK_COUNT, MAX_X);
let y_ticks = getTicks(TICK_COUNT, MAX_Y).reverse();
function getTicks (count, max) {
return [...Array(count).keys()].map(d => {
return max / (count - 1) * parseInt(d);
});
}

But if you use scale.ticks from d3-scale (or ticks from d3-array, if the 14.4K dependency of d3-scale is too big), you won’t need to.

let x_ticks = x.ticks(TICK_COUNT);
let y_ticks = y.ticks(TICK_COUNT);

And better still, you’ll get human-readable ticks at multiples of 2, 5 or 10, rather than multiples of 17.5 or some other awkward interval.

[0, 17.5, 35, 52.5, 70] // awkward ticks
[0, 10, 20, 30, 40, 50, 60, 70] // nice ticks

You could generate SVG paths by hand.

let d = `
M${x(data[0].x)} ${y(data[0].y)}
${data.slice(1).map(d => {
return `L${x(d.x)} ${y(d.y)}`;
}).join(' ')}
`;

Why not use line from d3-shape?

let d = line()
.x(d => x(d.x))
.y(d => y(d.y))
(data);

By using D3, your code will be shorter and you’ll be able to do more. For example, line.curve lets you choose a variety of curve implementations to interpolate between data points, say if you want a Catmull–Rom spline or a cubic spline that preserves monotonicity. (You don’t want to write that one by hand.) And line.defined lets you easily show gaps for missing or invalid data.

And what about those pesky axes? Well, you could… but I’ll spare you the vanilla JavaScript—don’t use CSS flexbox to place ticks, that’s for sure! Relying on CSS layout to draw axes will almost certainly distort your data.

Instead, use d3-axis. Using refs, it’s easy to drop in an axis in your DOM framework of choice. Here’s the complete code using Preact + HTM:

class LineChart extends Preact.Component {
render({data}) {
const margin = {top: 10, right: 20, bottom: 20, left: 30};
const width = 500;
const height = 300;
    const x = d3.scaleLinear()
.domain([0, d3.max(data, d => d.x)])
.range([margin.left, width - margin.right]);
    const y = d3.scaleLinear()
.domain([0, d3.max(data, d => d.y)])
.range([height - margin.bottom, margin.top]);
    return htm`
<svg width=${width} height=${height}>
<path
fill="none"
stroke="#33C7FF"
stroke-width="2"
d=${d3.line().x(d => x(d.x)).y(d => y(d.y))(data)} />
<g
transform="translate(${margin.left},0)"
ref=${g => d3.select(g).call(d3.axisLeft(y))} />
<g
transform="translate(0,${height - margin.bottom})"
ref=${g => d3.select(g).call(d3.axisBottom(x))} />
</svg>
`;
}
}

Here’s a live, editable version on Observable:

https://beta.observablehq.com/@mbostock/d3-preact-htm