Draw an Interactive Elevation Chart with D3 & React, Part 2

Part 2: Data visualization is too fun. My team made an app that consumes a user’s trip data and uses it to draw cool stuff. Here’s how we used React, D3 and Google Maps to do it.

Draw an Interactive Elevation Chart with D3 & React, Part 2

tags: reactd3javascriptgoogle mapsdata-visualization

This is part 2 of a 3-part series about using Google Maps and D3 to create an elevation graph that responds dynamically with user interaction. Here are all 3 posts:

Drawing the Chart

In this part (part 2), we’ll be building an interactive, dynamic area chart that plots distance on the x-axis and elevation on the y-axis.

Catch up on part 1 here!

First let’s remind ourselves what we’re building:

We’ve got 5 things to do, plus 1 bonus step to spruce up your chart’s appearance with gridlines that span across the x- and y-scales at each tick on the chart.

5 Steps + 1 Bonus

  1. Declare our constants, helper functions, and configure D3 so it’s ready for action.

  2. Map out our "ElevationGraph" component, including:

    • – what lives on state & what comes on props?
    • – decide which lifecycle methods we’ll need
    • – name and pseudocode our custom methods
  3. Draw the chart using D3’s API! This is the fun part, and the meat and potatoes of our task today

  4. Render that bad boy! We’ll talk styles here too, especially regarding svg styling (wtf is viewPort vs. viewBox, etc.)

  5. Write a callback function that draws a DOM element on the map depending on where the user is hovering

  6. [Bonus] Make it pretty

1. Declarations & Config

Helper Functions

First we need some helper functions. This first batch is pure JavaScript, and do not require the D3 library yet (although fromLatLngToPoint does expect a Google Map instance as a parameter):

1export const numOfSamples = 100
2export const metersToMiles = m => m * 0.000621371
3export const metersToFeet = m => m * 3.28084
4
5export const arraysEqual = (arr1, arr2) => {
6 if (arr1.length !== arr2.length) return false
7 for (let i = arr1.length; i--; ) {
8 if (arr1[i] !== arr2[i]) return false
9 }
10 return true
11}
12
13export const fromLatLngToPoint = (latLng, map) => {
14 const topRight = map
15 .getProjection()
16 .fromLatLngToPoint(map.getBounds().getNorthEast())
17 const bottomLeft = map
18 .getProjection()
19 .fromLatLngToPoint(map.getBounds().getSouthWest())
20 const scale = Math.pow(2, map.getZoom())
21 const worldPoint = map.getProjection().fromLatLngToPoint(latLng)
22 const point = new window.google.maps.Point(
23 (worldPoint.x - bottomLeft.x) * scale,
24 (worldPoint.y - topRight.y) * scale
25 )
26 return point
27}

We export these because ideally we’re putting them in a separate utils.js file, to be imported as needed in our ElevationGraph component.

The first, numOfSamples, is a constant; we will be using the number of samples in a few places, so it’s easier to change later because we declare it in a single place. We choose 100 samples along our pathline because it makes the math easy. For example, if our area chart is 600 pixels wide, then we need to divide it up into 6px sections and create a hover event over each to dynamically render a point or “blip” on the map to show the user exactly where on the path she would encounter the elevation and grade our Infobox shows her.

The next two are functions we will use to do unit conversion. The Google Maps Elevation and Distance APIs return us meters, but since we do things backwards in the States, we need to convert those to miles and feet so our user doesn’t have to do conversions herself.

arraysEqual returns true if 2 arrays contain the same elements and false if they don’t.

We will come back to fromLatLngToPoint toward the end of the article — in a nutshell, it takes a LatLng object and a Google Map instance and does a lookup to see where a particular coordinate exists on the user’s screen, so we know where to draw the blip.

D3 Config

It’s finally time to start working with D3! First let’s make sure we installed and imported it correctly:

1import * as d3 from "d3"
2
3const D3Version = () => (
4 <p id="d3-version">
5 D3 Version: <strong>{d3.version}</strong>
6 </p>
7)
8
9<D3Version />

That renders:


D3 Version: 5.9.1

Cool! Let’s start configuring:

1// 1. DECLARATIONS / CONFIG
2const margin = { top: 0, right: 0, bottom: 15, left: 50 }
3const width = 750 - margin.left - margin.right
4const height = 155 - margin.top - margin.bottom
5const xAxisTicks = 8
6const yAxisTicks = 6

This is the conventional way of creating margins around your chart in D3.

Next we need to create our xScale and yScale. We will use a linear scale for both.

For continuous quantitative data, you typically want a linear scale.

- D3 Docs

We need to give our linear scale a domain and a range:

1const xScale = d3
2 .scaleLinear()
3 .domain(d3.extent(data, d => d.x))
4 .range([0, width])
5
6const yScale = d3
7 .scaleLinear()
8 .domain([d3.min(data, co => co.y), d3.max(data, co => co.y)])
9 .range([height, 0])

d3.scaleLinear is a function that creates a scale based on the domain and range you pass it.

  1. domain takes an array of 2 values that represent the maximum and minimum values in our data set.

To avoid having to hardcode this, we can use d3.min and d3.max, passing in our entire data set and a function that tells D3 how to resolve our data objects to the values we care about.

In this case, because the x-axis only cares about data.x and our y-axis only cares about data.y, mapping out the domain is really simple:

1d3.scaleLinear().domain([d3.min(data, co => co.y), d3.max(data, co => co.y)])
  1. range takes an array of 2 values that represent the boundaries of our svg chart as they will be drawn on the screen.

Cool! Let’s draw the chart. We won’t be plotting the data yet, but it should render at the size we expect based on the data we pass in.

Drawing the SVG

How do we actually draw the chart?

We're going to use D3 the select a DOM node, append an svg element, give it some attributes, and append elements to it (path and g elements, specifically).

1const svg = d3
2 .select("#elevationChart")
3 .append("svg")
4 .attr("width", 750)
5 .attr("height", 155)
6 .attr("viewBox", "0 0 " + width + " " + 160)

D3 is nice to read. Even without knowing the library, we can follow what is going on. D3 selectors and DOM mutations are so much easier to work with than vanilla JavaScript!

Aside: It’s almost too easy with D3! We should be mindful of this, and only be mutating our chart, and delegate any other stateful effects to React to be handled more responsibly.

Let’s call drawChart when the component has mounted, and voilà!

Look, we did a thing! We’re like, artists or something.

Here’s what the code looks like right now. This is just everything we’ve done thus far, put together:

1import React from "react"
2import * as d3 from "d3"
3import { data } from "./data" // This would be an API call IRL!
4const margin = { top: 0, right: 0, bottom: 15, left: 50 }
5const width = 700 - margin.left - margin.right
6const height = 155 - margin.top - margin.bottom
7
8export default class Chart extends React.Component {
9 constructor(props) {
10 super(props)
11 this.state = {
12 data: null
13 }
14 }
15
16 componentDidMount() {
17 this.setState({ data }, () => this.drawChart())
18 }
19
20 drawChart = () => {
21 const { data } = this.state
22 const xScale = d3
23 .scaleLinear()
24 .domain(d3.extent(data, d => d.x))
25 .range([0, width])
26 const yScale = d3
27 .scaleLinear()
28 .domain([d3.min(data, co => co.y), d3.max(data, co => co.y)])
29 .range([height, 0])
30 const svg = d3
31 .select("#elevationChart")
32 .append("svg")
33 .attr("width", 700)
34 .attr("height", 155)
35 .attr("viewBox", "0 0 " + width + " " + 160)
36 .attr("preserveAspectRatio", "xMinYMid")
37 .append("g")
38 .attr("transform", `translate(${margin.left}, 2.5)`)
39
40 svg
41 .append("g")
42 .attr("transform", `translate(0, ${height})`)
43 .call(d3.axisBottom(xScale))
44
45 svg.append("g").call(d3.axisLeft(yScale))
46 }
47
48 render() {
49 return (
50 <div style={{
51 display: "flex",
52 justify-content: "center",
53 box-shadow: "rgba(0, 0, 0, 0.1) 0px 0px 0.625rem 0px"
54 }}>
55 {this.state.data.length > 0 && <div id="elevationChart" />}
56 </div>
57 )
58 }
59}

This is all pretty simple and stateful. We draw a chart by mutating the DOM and pass it our data. But how do we know what our x- and y-domains are without labels?

Before we get into labelling the axes, let’s draw something first!!

What if we gave our chart some data? Let’s pass it 3 points:

1const data = [
2 { x: 5, y: 15 },
3 { x: 15, y: 20 },
4 { x: 35, y: 5 }
5]
6<Chart data={data} />

Remember, the domains of our xScale and yScale are created by functions that are calculating the bounds of our chart.

There’s nothing magical about x and y here, it’s just where I told the D3 functions to look:

1xScale.domain(d3.extent(data, d => d.x))
2
3yScale.domain([d3.min(data, co => co.y), d3.max(data, co => co.y)])

Before our chart will render, we need to tell D3 1.) what kind of visualization we want to draw, and 2.) where

In this case, we decided to draw an area chart — basically a line chart with shading underneath.

Here’s all we have to do!

  1. Configure our visualization & pass D3 our scales, so it knows where on the screen to plot each point:
  2. Append a path element to our SVG with a d attribute containing our data, along with how we want things to render:
1// 1.)
2const area = d3
3 .area()
4 .x(d => xScale(d.x))
5 .y0(yScale(yScale.domain()[0]))
6 .y1(d => yScale(d.y))
7
8// 2.)
9svg
10 .append("path")
11 .attr("d", area(data))
12 .attr("class", "chartLine")
13 .style("stroke", "#787979")
14 .style("stroke-opacity", 0.2)
15 .style("stroke-width", 1)
16 .style("fill", "#787979")
17 .style("fill-opacity", 0.2)

Input:

1{ x: 5, y: 15 },
2{ x: 15, y: 20 },
3{ x: 35, y: 5 }

Let’s try it out:

Whoaaa! Who said we wanted labels?

D3 automatically created ticks for our axes. We’ll adjust these later, but for now we get those for free.

Let’s feed it more data. Can we do something to soften those points a bit?

We can use D3’s curve method across our y points and pass it a function name that none of us will ever remember, curveCatmullRom:

While we’re there, let’s customize our ticks and add a grid for better visualization:

1d3.area()
2 .x(d => /* ... */)
3 .y0(yScale( /* ... */)
4 .y1(d => /* ... */)
5 .curve(d3.curveCatmullRom.alpha(0.005))
6
7// Let’s write functions that handle spacing our ticks out:
8const xAxisTicks = 8
9const yAxisTicks = 6
10const makeXGridlines = xScale => d3.axisBottom(xScale).ticks(xAxisTicks)
11const makeYGridlines = yScale => d3.axisLeft(yScale).ticks(yAxisTicks)
12
13d3.axisBottom(xScale)
14 // ...
15 .ticks(yAxisTicks)
16 .tickSize(0)
17 .tickPadding(9)
18
19d3.axisLeft(yScale)
20 // ...
21 .tickSize(0)
22 .tickPadding(8)
23 .ticks(yAxisTicks)

Cool! Let’s see how it looks.

Looking pretty good. What about those gridlines?

If we’re feeling clever, we can just make another set of ticks that scale along the same x- and y-scales, only these will stretch the entire height/width of the svg.

1// Make X grid:
2svg
3 .append("g")
4 .attr("class", "chartGrid")
5 .attr("transform", `translate(0, ${height})`)
6 .call(
7 makeXGridlines(xScale)
8 // STRETCH IT PARALLEL TO Y:
9 .tickSize(-height)
10 // NO FORMAT, THESE JUST BE LINES:
11 .tickFormat("")
12 )
13
14// Make Y grid:
15svg
16 .append("g")
17 .attr("class", "chartGrid")
18 .call(
19 makeYGridlines(yScale)
20 // STRETCH IT PARALLEL TO X:
21 .tickSize(-width)
22 // NO FORMAT, THESE JUST BE LINES:
23 .tickFormat("")
24 )

You might be noticing a pattern: When we want to add a new feature or visualization,

We start by 1.) appending to our SVG element (or “selecting” an existing one), then 2.) configure the appended/selected element by chaining the methods calls we need, 3.) occasionally using .call when we need to hook into a different context or functionality.

Let’s see how our gridlines turned out:

See? This stuff isn’t so bad. Think of all the stuff we can draw. What if we could consume an API to paint a picture of how little crypto is worth today, compared to its market cap 2 years ago?

To do that, we would simply use a D3 time-scale instead of a linear-scale. That’s outside the scope of this tutorial, but it isn’t hard to do once you understand scaleLinear.

Creating User Interaction

This is where our app becomes dynamic.

Although the data for this blog is hardcoded, you could use the Google Maps Markers API to allow users to drag and drop pins on a map (see Part I for more info on maps and markers).

D3 gives you some callback functions that give you control over when and how your visualizations should rerender. Let’s see how that works.

1. Create an Infobox & Crossbar on Hover

When a user hovers over our chart, they should be able to see specific data for that particular intersection.

Changes along the x-axis will draw a vertical line (which I’m calling a crossBar) at that point, and next to it an infobox should read back the specific x- and y- values at the nearest point.

We’re going to use D3 to create them; note that their display is initialized to none because the user has not yet hovered:

1const crossBar = svg
2 .append("g")
3 .attr("class", "crossBar")
4 .style("display", "none")
5
6crossBar
7 .append("line")
8 .attr("x1", 0)
9 .attr("x2", 0)
10 .attr("y1", height)
11 .attr("y2", 0)
12
13crossBar
14 .append("text")
15 .attr("x", 10)
16 .attr("y", 17.5)
17 .attr("class", "crossBarText")
18
19const infoBox = svg
20 .append("g")
21 .attr("class", "infoBox")
22 .style("display", "none")
23
24infoBox
25 .append("rect")
26 .attr("x", 0)
27 .attr("y", 10)
28 .style("height", 45)
29 .style("width", 125)
30
31const infoBoxElevation = infoBox
32 .append("text")
33 .attr("x", 8)
34 .attr("y", 30)
35 .attr("class", "infoBoxElevation")
36
37infoBoxElevation
38 .append("tspan")
39 .attr("class", "infoBoxElevationTitle")
40 .text("Elev: ")
41
42infoBoxElevation.append("tspan").attr("class", "infoBoxElevationValue")

These are both just SVG elements that we’re creating, so we have access to all the same methods and utilities that we used for our chart.

Next we need to create a rect element that will act as an overlay for our entire chart. The overlay will have a higher z-index than the chart so that it takes precedence when receiving mouseover events.

1svg
2 .append("rect")
3 .attr("class", "chartOverlay")
4 .attr("width", width)
5 .attr("height", height)
6 .on("mouseover", function() {
7 crossBar.style("display", null)
8 infoBox.style("display", null)
9 })
10 .on("mouseout", function() {
11 crossBar.style("display", "none")
12 infoBox.style("display", "none")
13 })
14 .on("mousemove", mousemove)
15
16function mousemove() {
17 return null
18}

Did you see that? I snuck in the event handlers. A dozen lines of code is all it took!

D3 makes it easy to manage events with its on method, which might remind you of jQuery.

We create our overlay, giving it the same width and height as our chart, along with a className so we give it absolute positioning and a z-index higher than the chart.

Then we define 3 eventhandlers: one when a user mouses over our overlay, one when a user mouses out, and one that listens for changes in the user’s screenX and screenY coordinates.

If you’re familiar with eventhandlers then you probably already see what this code is doing. Since we stored references to our crossBar and infoBox earlier, we can mutate their display to appear or disapear when the user enters or exits the chart with her mouse.

We will implement mousemove next, as it requires a little extra work. For now it’s just a noop to satisfy the compiler.

Let’s fire things up and see if our eventhandlers do what we want:

This is getting fun. Now the user can hover over the area chart and get a localized display of the elevation and distance at that point in space.

There were a couple other things I had to implement to get this to work, namely the mousemove function.

After we get through this part, the last thing to do is wire this chart up to a map so they can start working together.

D3 Event Callbacks & Bisection

Bisection. It just sounds hard. To be honest I didn’t fully understand why this was the right solution until after I saw it in action (and even then it still took some time to sink in).

Let’s eat the frog and learn about bisecting data.

1. Bisecting

1const bisect = d3.bisector(function(d) {
2 return d.x
3}).left

[WIP]

2. D3 Callbacks

Phew, now that that’s over, callbacks are going to be easy. Let’s dive right in.

Check out what the mouseover function is doing.

Note that it needs to be a function declaration or a function expression so we keep this context around — an arrow function won’t work unless you keep a reference to this around (remember, these aren’t React classes, so in my opinion apply would be my preference over bind).

1// NEEDS TO BE A FUNCTION EXPRESSION FOR ACCESS TO `THIS`!
2function mousemove() {
3 const x0 = xScale.invert(d3.mouse(this)[0])
4 const i = bisect(data, x0, 1)
5 const d0 = data[i - 1]
6 const d1 = data[i]
7 const d = !d1 ? d0 : x0 - d0.x > d1.x - x0 ? d1 : d0
8 crossBar.attr("transform", `translate(${xScale(d.x)}, 0)`)
9 crossBar.select("text").text(d3.format(".1f")(metersToMiles(d.x)) + " mi")
10 infoBox.attr("transform", `translate(${xScale(d.x) + 10}, 12.5)`)
11 infoBox
12 .select(".infoBoxElevationValue")
13 .text(d3.format(",.0f")(metersToFeet(d.y)) + " ft")
14 infoBox.select(".infoBoxGradeValue").text(d3.format(".1%")(d.grade))
15 return null
16}

That’s it. There’s a lot going on in there, and we’ll unpack it, but that’s all the set up for our elevation chart.

We already got our maps set up last time, but if you aren’t familiar with the Google Maps API I recommend reading this first, as it goes over the exact requirements that we need to finish up this project.

Almost done. We’ll put the finishing touches on our feature in Part 3.

Until next time!

Note: This is part 2 of a 3-part series about using Google Maps and D3 to create an elevation graph that responds dynamically with user interaction. Here are all 3 posts: