Building an Interactive Sparkline Graph with D3

D3 is a great JavaScript library for building data visualisations using SVG elements. Today we’re going to walk through how to use it to build a simple line graph with an interactive, 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.

Screenshot of the D3 NPM page with sparkline graph shown on the right

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.0

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])
Illustration showing the chart’s axis increasing from bottom to top, whereas the viewbox y co-ordinates increase from top to bottom

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!

Purple line with light purple fill

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.0

Resources

  • D3 documentation
  • Fullstack D3 and Data Visualization course by Amelia Wattenberger
  • Amelia’s blog
  • Learn D3 tutorials from Observable