From our sponsor: Meco is a distraction-free space for reading and discovering newsletters, separate from the inbox.
D3 is a great JavaScript library for building data visualizations using SVG elements. Today we’re going to walk through how to use it to build a simple line graph with an interactive element, taking inspiration from the NPM website.
What we’re building
Visit any individual package page on the NPM website and you’ll notice a small line graph on the right, showing the total weekly downloads trend over a period, with the total figure to the left.
This type of chart is known as a sparkline. If you hover over the graph on the NPM site, you can scrub through the trend and see the weekly downloads figure for the preceding week, marked by a vertical line and circle. We’re going to build something similar, with a few of our own adaptations. If you’d rather jump straight into the code yourself, you can find the complete demo here.
It’ll come in handy if you have some familiarity with SVG elements. SVGs use their own internal co-ordinate system. For a primer I recommend this timeless article by Sara Soueidan on SVG Coordinate Systems and Transformations.
Data
The first thing we’ll need is some data to work with. I’ve created an API endpoint we can use to fetch some data here. We could use the Fetch API to retrieve it, but instead we’re going to use D3, which conveniently parses the data for us.
HTML
Now let’s add some HTML. We should make sure that our markup makes sense without JS in the first instance. (Let’s assume that our weekly downloads total is a known value, perhaps coming from a CMS.)
We’re adding some data attributes that we’ll need to reference in our JS.
<div class="chart-wrapper" data-wrapper>
<div>
<h3 data-heading>Weekly downloads</h3>
<p data-total>800</p>
</div>
<figure data-chart></figure>
</div>
If we choose to, we could append a static image to the figure
element, to display while our data is loading.
CSS
We’re going to use D3 to draw an SVG chart, so we’ll include some base CSS styles to set a maximum width on the SVG and center the component within the viewport:
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
}
figure {
margin: 0;
}
svg {
width: 100%;
height: auto;
}
.chart-wrapper {
max-width: 600px;
}
D3
To use the D3 library we’ll first need to add it to our project. If you’re using a bundler you can install the NPM package and import it as follows:
import * as d3 from 'd3'
Otherwise, you can download it direct from the D3 website. D3 is quite a large library, and we’re only going to use parts of it for our line graph. Later on we’ll look at how to reduce the size of our bundle by only importing the modules we need.
Now we can fetch the data using D3’s json
method:
d3.json('https://api.npoint.io/6142010a473d754de4e6')
.then(data => {
console.log(data)
})
.catch(error => console.log(error))
We should see the data array logged to the console in our developer tools.
Preparing the data
First let’s create a function for drawing our chart, which we’ll call once we’ve successfully fetched the data:
const draw = (data) => {
console.log(data)
}
d3.json('https://api.npoint.io/6142010a473d754de4e6')
.then(data => {
draw(sortedData)
})
.catch(error => console.log(error))
We should still see our data logged to the console. But before we can draw our chart, we’ll need to sort the data array by date. Currently our data array looks something like this, with the dates as strings:
[
{
date: "2021-12-23T04:32:20Z",
downloads: 445
},
{
date: "2021-07-20T13:41:01Z",
downloads: 210
}
// etc.
]
We’ll need to convert the date strings into JavaScript date objects. Let’s write a function that first of all converts the string to a date object, then sorts the values by date in ascending order, using D3’s ascending
method:
const sortData = (data) => {
/* Convert to date object */
return data.map((d) => {
return {
...d,
date: new Date(d.date)
}
})
/* Sort in ascending order */
.sort((a, b) => d3.ascending(a.date, b.date))
}
We’ll pass the sorted data into our draw
function:
fetch('https://api.npoint.io/897b3f7b5f6a24dcd0cf')
.then(response => response.json())
.then(data => {
const sortedData = sortData(data)
draw(sortedData)
})
.catch(error => console.log(error))
Drawing the chart
Now we’re ready to start creating our data visualization. Let’s first of all define the dimensions of our chart, which we’ll use to draw the SVG at the required size:
const dimensions = {
width: 600,
height: 200
}
In our draw
function, we’re going to use D3’s select
method to select the wrapper element containing our figure
, heading and downloads count:
/* In `draw()` function */
const wrapper = d3.select('[data-wrapper]')
D3 selections are more powerful than using querySelector
, as they allow us to bind data to DOM elements, as well as easily append elements and add or modify attributes. We can then select the figure
element and append a new SVG, using our pre-defined dimensions to set the viewbox:
/* In `draw()` function */
const svg = wrapper
/* Select the `figure` */
.select('[data-chart]')
/* Append the SVG */
.append('svg')
.attr('viewBox', `0 0 ${dimensions.width} ${dimensions.height}`)
If we inspect our page, we should now see an SVG element is present inside the figure
, but it’s not yet visible as we haven’t given it any color. It might be a good idea to add an outline in our CSS, so that we can easily see that the SVG has been created!
svg {
width: 100%;
height: auto;
outline: 1px solid;
}
You might notice a jump in the layout once the SVG is created. We can fix that by adding an aspect ratio to the figure
element. That way it’ll be rendered at the correct height straight away (in browsers that support the aspect-ratio
property).
figure {
margin: 0;
aspect-ratio: 6 / 2;
}
Drawing the line
So far so good, but here’s where things get a little more complex. Don‘t worry, we’ll walk through it step-by-step!
We’re going to draw the trend line on our chart by appending a path
element. But before we can do that, we need to create the scales that will enable us to plot the data within the SVG co-ordinate system. (For more on this, read the tutorial Introduction to D3’s scales by Observable.)
Accessor functions
In Amelia Wattenberger’s book, Fullstack D3 and Data Vizualisation, she recommends creating accessor functions to return the x and y values for any given data point. We’re going to need to refer to those values quite a bit, so let’s do that now.
const xAccessor = (d) => d.date
const yAccessor = (d) => d.downloads
It may seem unnecessary given their simplicity, but if we ever need to make any changes (say, a dataset with a different set of keys) we’ll be grateful to have just one place to update those values!
Scales
Our chart’s x-axis will be time-based — using the date
values from our data, while the y-axis will use a linear scale to plot the number of downloads. We’ll need D3’s scaleTime
and scaleLinear
methods respectively.
When creating our scales we need to set both the domain and the range properties. The domain contains the smallest and largest data values that need to be plotted. The range contains the dimensions onto which we’ll plot the data. D3 does the work behind the scenes to scale the domain to the range and plot the position of each data point accordingly. The concept is illustrated in this demo. Hover over the range area and you’ll see the pointer’s position scaled within the domain area.
See the Pen D3 domain/range by Michelle Barker (@michellebarker) on CodePen.
As our data is already sorted in the correct order, the domain value for the x-axis will be an array containing the date values of our first and last data items:
/* In `draw()` function */
const xDomain = [data[0].date, data[data.length - 1].date]
This is where our accessor functions come in. We could instead use the xAccessor()
function to get the desired values for the x-axis:
/* In `draw()` function */
const xDomain = [xAccessor(data[0]), xAccessor(data[data.length - 1])]
However, there is a simpler way, using D3’s extent
method. We pass in our data array and the accessor function, and it returns the highest and lowest values as an array. It works even if the data is unsorted.
/* In `draw()` function */
const xDomain = d3.extent(data, xAccessor)
The range is simpler still: As our line will need to go all the way across our SVG, from left to right, our range will go from 0 to the SVG viewbox width.
/* In `draw()` function */
const xDomain = d3.extent(data, xAccessor)
const xScale = d3.scaleTime()
.domain(xDomain)
.range([0, dimensions.width])
Our y-axis will be similar, but with a small difference: If we use only the smallest and largest values for the domain, our trend line may appear to fluctuate wildly with even a small difference in the number of downloads. For example, if the number of downloads stayed fairly steady at between 1000 and 1100 per day, our chart would nonetheless display a line that zig-zags right from the bottom to the top of the chart, because a narrow domain is mapped to a (comparatively) wide range. It would be better if we mapped our domain with the lowest value as zero (as it’s impossible to have a negative number of downloads!).
So for the y-axis we’ll set the domain in a slightly different way, using D3’s max
function to return only the highest value. We’ll also use the height instead of width from our dimensions
object for the range, and D3’s scaleLinear
method (which creates a continuous scale) rather than scaleTime
.
You might notice that we’ve flipped the range
values in this case. That’s because the SVG co-ordinate system begins with 0 at the top, and higher values move an SVG element downwards. We need the low values in our domain to be displayed further down the SVG view box than high values — which in fact means mapping them to higher viewbox co-ordinates!
/* In `draw()` function */
const yDomain = [0, d3.max(data, yAccessor)]
const yScale = d3.scaleLinear()
.domain(yDomain)
.range([dimensions.height, 0])
Line generator
Once we have our scales set up, we can use D3’s line()
function to plot the path scaled to fit our SVG viewbox. We’ll create a line generator:
const lineGenerator = d3.line()
.x((d) => xScale(xAccessor(d)))
.y((d) => yScale(yAccessor(d)))
Then we’ll append a path
element to our SVG, and use the line generator for the d
attribute (the attribute that actually defines the shape of the path). We’ll use the datum()
method to bind the data to the path
element. (Read more about data binding in this article.)
/* In `draw()` function */
const line = svg
/* Append `path` */
.append('path')
/* Bind the data */
.datum(data)
/* Pass the generated line to the `d` attribute */
.attr('d', lineGenerator)
/* Set some styles */
.attr('stroke', 'darkviolet')
.attr('stroke-width', 2)
.attr('stroke-linejoin', 'round')
.attr('fill', 'none')
We’re also setting some styles for the fill and stroke of the path. You should now see the plotted path.
Creating the filled area
Now that we have our line, our next step is to create the filled area below the path. We could try setting a fill color on our line:
/* In `draw()` function */
line.attr('fill', 'lavender')
Unfortunately that won’t produce the desired effect!
Luckily, D3 has an area()
function that works similarly to line()
, and is designed exactly for this use case. Instead of a single y parameter, it requires two y values: y0 and y1. This is because it needs to know where to start and end the filled area. In our case, the second y value (y1) will be the height value from our dimensions
object, as the area needs to be filled from the bottom of the chart.
/* In `draw()` function */
const areaGenerator = d3.area()
.x((d) => xScale(xAccessor(d)))
.y1((d) => yScale(yAccessor(d)))
.y0(dimensions.height)
Just like the line before, we’ll append a path
element to the SVG and pass in the area generator for the d
attribute.
/* In `draw()` function */
const area = svg
.append('path')
.datum(data)
.attr('d', areaGenerator)
.attr('fill', 'lavender')
At this point our filled area is partially obscuring the stroke of the primary line (you might notice the stroke appears thinner). We can fix this by changing the order so that we draw the filled area before the line within the draw()
function. (We could also fix it with z-index
in our CSS, but I prefer this way as it doesn’t require any additional code!)
Curved lines
Our line currently looks quite jagged, which is not especially pleasing to the eye. D3 provides us with a number of curve functions to choose from. Let’s add a curve to our line and area generators:
/* In `draw()` function */
/* Area */
const areaGenerator = d3.area()
.x((d) => xScale(xAccessor(d)))
.y1((d) => yScale(yAccessor(d)))
.y0(dimensions.height)
.curve(d3.curveBumpX)
/* Line */
const lineGenerator = d3.line()
.x((d) => xScale(xAccessor(d)))
.y((d) => yScale(yAccessor(d)))
.curve(d3.curveBumpX)
Interaction
The next step is to add an interactive marker, which will move as the user hovers over the chart. We’ll need to add a vertical line, which will move horizontally, and a circle, which will move both horizontally and vertically.
Let’s append those SVG elements. We’ll give them each an opacity of 0, and position them on the far left. We only want them to appear when the user interacts with the chart.
/* In `draw()` function */
const markerLine = svg
.append('line')
.attr('x1', 0)
.attr('x2', 0)
.attr('y1', 0)
.attr('y2', dimensions.height)
.attr('stroke-width', 3)
.attr('stroke', 'darkviolet')
.attr('opacity', 0)
const markerDot = svg
.append('circle')
.attr('cx', 0)
.attr('cy', 0)
.attr('r', 5)
.attr('fill', 'darkviolet')
.attr('opacity', 0)
Now let’s use D3’s on
method to move our markers when the user hovers. We can use the pointer
method which, unlike clientX
/clientY
, will return the SVG co-ordinates of the pointer’s position (when the event target is an SVG), rather than the viewport co-ordinates. We can update the position of the markers with those co-ordinates, and switch the opacity to 1.
/* In `draw()` function */
svg.on('mousemove', (e) => {
const pointerCoords = d3.pointer(e)
const [posX, posY] = pointerCoords
markerLine
.attr('x1', posX)
.attr('x2', posX)
.attr('opacity', 1)
markerDot
.attr('cx', posX)
.attr('cy', posY)
.attr('opacity', 1)
})
Now we should see the line and circle moving with our cursor when we hover on the chart. But something’s clearly wrong: The circle is positioned wherever our cursor happens to be positioned, whereas it should follow the path of the trend line. What we need to do is get the x and y position of the closest data point as the user hovers, and use that to position the marker. That way we also avoid the marker being positioned in between dates on the x-axis.
Bisecting
To get the nearest value to the user’s cursor position, we can use D3’s bisector
method, which finds the position of a given value in an array.
First we need to find the corresponding value for the position of the cursor. Remember the scales we created earlier? We used these to map the position of the data values within the SVG viewbox. But we can also invert them to find the data values from the position. Using the invert
method, we can find the date from the pointer position:
/* In `draw()` function */
svg.on('mousemove', (e) => {
const pointerCoords = d3.pointer(e)
const [posX, posY] = pointerCoords
/* Find date from position */
const date = xScale.invert(posX)
})
Now that we know the exact date at any point when we’re hovering, we can use a bisector to find the nearest data point. Let’s define our custom bisector above:
/* In `draw()` function */
const bisect = d3.bisector(xAccessor)
Remember, this is equivalent to:
const bisect = d3.bisector(d => d.date)
We can use our bisector to find the closest index to the left or right of our position in the data array, or it can return whichever is closest. Let’s go for that third option.
/* In `draw()` function */
const bisect = d3.bisector(xAccessor)
svg.on('mousemove', (e) => {
const pointerCoords = d3.pointer(e)
const [posX, posY] = pointerCoords
/* Find date from position */
const date = xScale.invert(posX)
/* Find the closest data point */
const index = bisect.center(data, date)
const d = data[index]
}
If we console log d
at this point we should see the corresponding data object.
To get the marker position, all that remains is to use our scale functions once again, mapping the data value to the SVG co-ordinates. We can then update our marker positions with those values:
/* In the `mousemove` callback */
const x = xScale(xAccessor(d))
const y = yScale(yAccessor(d))
markerLine
.attr('x1', x)
.attr('x2', x)
.attr('opacity', 1)
markerDot
.attr('cx', x)
.attr('cy', y)
.attr('opacity', 1)
(Read more on D3 bisectors here.)
Updating the text
We also want to update the text showing the date range and the number of weekly downloads as the pointer moves. In our data we only have the current date, so at the top of the file let’s write a function that will find the date one week previously, and format the output. We’ll use D3’s timeFormat
method for the formatting.
To find the date one week previously, we can use D3’s timeDay
helper. This returns a date a given number of days before or after the specified date:
const formatDate = d3.timeFormat('%Y-%m-%d')
const getText = (data, d) => {
/* Current date */
const to = xAccessor(d)
/* Date one week previously */
const from = d3.timeDay.offset(to, -7)
return `${formatDate(from)} to ${formatDate(to)}`
}
Then we’ll call this function to update the text on mouse move:
/* In the `mousemove` callback */
d3.select('[data-heading]').text(getText(data, d))
Updating the total downloads text is a simple one-liner: We select the element, and update the inner text with the corresponding value using our accessor function:
d3.select('[data-total]').text(yAccessor(d))
Resetting
Finally, when the user’s pointer leaves the chart area we should hide the marker and set the text to display the last known value. We’ll add a mouseleave
callback:
/* In `draw()` function */
svg.on('mouseleave', () => {
const lastDatum = data[data.length - 1]
/* Hide the markers */
markerLine.attr('opacity', 0)
markerDot.attr('opacity', 0)
/* Reset the text to show latest value */
d3.select('[data-heading]').text('Weekly downloads')
d3.select('[data-total]').text(yAccessor(lastDatum))
})
Prevent the marker being clipped
If you hover on one of the highest peaks in the line graph, you might notice that the circular marker is being clipped at the top. That’s because we’ve mapped the domain to the full height of our SVG. At the highest point, the center of the circle will be positioned at a y co-ordinate of 0. To fix that, we can add a margin to the top of our chart equivalent to the radius of the marker. Let’s modify our dimensions object:
const dimensions = {
width: 600,
height: 200,
marginTop: 8
}
Then, in our yScale
function, we’ll use a the marginTop
value for our range instead of 0:
const yScale = d3.scaleLinear()
.domain(yDomain)
.range([dimensions.height, dimensions.marginTop]
Now our marker should no longer be clipped.
Color
Now that we have all the functionality in place, let’s turn our attention to customising our chart a little more. I’ve added some styles in the demo to replicate the layout of the NPM chart (although feel free to adapt the layout as you wish!). We’re going to add some bespoke color scheme options, which can be toggled with radio buttons. First we’ll add the radio buttons in our HTML:
<ul class="controls-list">
<li>
<input type="radio" name="color scheme" value="purple" id="c-purple">
<label for="c-purple">Purple</label>
</li>
<li>
<input type="radio" name="color scheme" value="red" id="c-red">
<label for="c-red">Red</label>
</li>
<li>
<input type="radio" name="color scheme" value="blue" id="c-blue">
<label for="c-blue">Blue</label>
</li>
</ul>
We’re going to use CSS custom properties to easily switch between color schemes. First we’ll define some initial colors in our CSS, using custom properties for the fill and stroke colors of our chart, and for the heading color (the “Weekly downloads” title):
:root {
--textHeadingColor: rgb(117, 117, 117);
--fill: hsl(258.1, 100%, 92%);
--stroke: hsl(258.1, 100%, 66.9%);
}
Now, where we’re using named colors in our JS, we’ll swap these out for custom properties. For the marker line and circle, we can additionally include a default value. In some of our color schemes we might want to give these a different color. But if the --marker
custom property isn’t defined it’ll fall back to the stroke color.
const area = svg
.append('path')
.datum(data)
/* ...other attributes */
.attr('fill', 'var(--fill)')
const line = svg
.append('path')
.datum(data)
/* ...other attributes */
.attr('stroke', 'var(--stroke)')
const markerLine = svg
.append('line')
/* ...other attributes */
.attr('stroke', 'var(--marker, var(--stroke))')
const markerDot = svg
.append('circle')
/* ...other attributes */
.attr('fill', 'var(--marker, var(--stroke))')
Now we’ll add a function to toggle the colors when the user clicks a radio button by appending a class to the body
. We could do this with regular JS, but as we’re learning D3 let’s do it the D3 way!
First we’ll select our radio buttons using D3’s selectAll
method:
const inputs = d3.selectAll('input[type="radio"]')
When the user selects an option, we’ll first want to remove any color scheme classes that are already appended, so let’s create an array of color classes to check for. selectAll
returns a D3 selection object rather than the actual DOM nodes. But we can use nodes()
in D3 to select the elements, then map over them to return the input values (which will be the classes to append):
const colors = inputs.nodes().map((input) => {
return input.value
})
Now we can add an event listener to our input wrapper, using D3’s on()
method (using select
to select the element). This will remove any pre-existing color scheme classes, and append the class related to the selected input:
d3.select('.controls-list')
.on('click', (e) => {
const { value, checked } = e.target
if (!value || !checked) return
document.body.classList.remove(...colors)
document.body.classList.add(value)
})
All that remains is to add some CSS for the red and blue color schemes (purple will be the default):
.red {
--stroke: hsl(338 100% 50%);
--fill: hsl(338 100% 83%);
--marker: hsl(277 100% 50%);
--textHeadingColor: hsl(277 5% 9%);
background-color: hsl(338 100% 93%);
color: hsl(277 5% 9%);
}
.blue {
--stroke: hsl(173 82% 46%);
--fill: hsl(173 82% 56% / 0.2);
--marker: hsl(183 100% 99%);
--textHeadingColor: var(--stroke);
background-color: hsl(211 16% 12%);
color: white;
color-scheme: dark;
}
As a nice little extra touch, we can use the new CSS accent-color
property to ensure that our radio buttons adopt the stroke color from the color scheme in supporting browsers too:
.controls-list {
accent-color: var(--stroke);
}
As our blue color scheme has a dark background we can use colour-scheme: dark
to give the checkboxes a matching dark background.
Performance
I mentioned earlier that the D3 library is quite extensive, and we’re only using parts of it. To keep our bundle size as small as possible, we can elect to only import the modules we need. We can modify the import
statements at the top of our file, for example:
import { line, area, curveBumpX } from 'd3-shape'
import { select, selectAll } from 'd3-selection'
import { timeFormat } from 'd3-time-format'
import { extent } from 'd3-array'
The we just need to modify any d3
references in our code:
/* Previously: */
const xDomain = d3.extent(data, xAccessor)
/* Modified: */
const xDomain = extent(data, xAccessor)
See the Pen D3 sparkline chart by Michelle Barker (@michellebarker) on CodePen.
Resources
- D3 documentation
- Fullstack D3 and Data Visualization course by Amelia Wattenberger
- Amelia’s blog
- Learn D3 tutorials from Observable