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

Part 3: 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 3

tags: reactd3javascriptgoogle mapsdata-visualization

Image credit above goes to furryhead, see the original creative here.

This is part 3 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:

In this section, we’ll finish building this. Try hovering over the graph to see how our users can interact with this component!

Loading...

Alright. Let’s get started.

Creating User Interaction

In this part (part 3), we’ll be connecting our Map and Graph components, updating them both dynamically based on user input.

Here is where we left off in Part 1:

Loading...

Actions:

On the map above, go ahead and click:

  1. Show Markers –>
  2. Draw Path –>
  3. Elevation Samples

and then check your console to see what the response from the Google API looks like.

(In case you’re on mobile, here’s a sample of the elevations that the Google Maps API returns to us:)

1{elevation: 35.52785110473633, location: _.P, resolution: 19.08790397644043}
2{elevation: 35.73416519165039, location: _.P, resolution: 19.08790397644043}
3// 96 more...
4{elevation: 80.05186462402344, location: _.P, resolution: 19.08790397644043}

We can ignore the resolution field for now, and we’ll discuss what the _.P value means soon.

Remember: We asked for 100 samples along our pathline, so we’re working with 100 data points, not just the 6 markers that display in our map!

And here is where we left off in Part 2:

Cool! So what’s next?

Next we’re going to connect our map and our elevation chart so when a user hovers over the chart, a “blip” appears dynamically on the map to show the user where on the map the user will experience that particular elevation.

Let’s jump right in! ✨

Getting Our Distances

So we have our elevations, but how do we get distances? We need 100 of them to populate the x-axis so our every elevation on the y-axis maps to a corresponding value.

For our purposes, each distances will represent how far that elevation sample is from the starting point, which is our first map marker.

We will also need to keep a reference to the map around so we can ask it where a particular longitude and latitude is on the map (that way we know where on the screen to draw our “blip”).

Our component’s signature should look like this when we’re done:

1<Chart
2 elevations={this.state.elevations}
3 markers={this.state.markers}
4 mapName={this.props.mapRef}
5/>

Where map is a reference to our Google Maps instance, markers are the Google Map Markers that together comprise our polyline, and elevations is an array of 100 objects with the following shape:

1{
2 elevation: float!
3 location: LagLng
4 resolution: float!
5}

So how are we going to get those distances?

First we have to talk about what this _.P thing is.

Getting an Elevation’s Coordinates

The _.P value that you see at the location key is an instance of the Google Maps LatLng class (see the docs on LatLng class).

We can use this object to get an elevation’s corresponding latitude and longitude coordinates.

That the Elevations API returns a LatLng instance is the key to making all of this work.

If we want to get the coordinates of our first object in our Chart component, we do:

1class Chart extends React.Component {
2 render() {
3 return (
4 <div>
5 <p>1st Elevation Latitude: {this.props.data[0].location.lat()}</p>
6 <p>1st Elevation Longitude: {this.props.data[0].location.lng()}</p>
7 </div>
8 )
9 }
10}

Among other things, the LatLng class comes with 2 methods that simply return the latitude or longitude value they’re holding.

Test it out yourself!

If you want to test it out yourself, I’ve added the map above to window at this address:

window.__map__Baldwin_St___Dunedin__NZ

If you open the console, you can run any method you’d like. Try running:

1window.__map__Baldwin_St___Dunedin__NZ.center.lat() // -45.849184470199674
2window.__map__Baldwin_St___Dunedin__NZ.center.lng() // 170.5342357575057

Those are the coordinates for the center of our map! GoogleMap.center is an instance of the LatLng class.

Okay, so we can get coordinates. But we’re creating an area chart (basically a line graph with shading under the line), which means our x-axis needs to represent a scale (do you remember what kind of scale?).

Our xScale is distance from the polyline’s point of origin, along the polyline. This is important because we’re assuming that the polyline represents a user’s trip, and so a particular point on our graph tells us how far along the trip the user can expect to encounter a particular elevation.

So our scales are:

  1. x = distance from origin along polyline
  2. y = elevation at point

Getting Our Distances

At first my team struggled with this.

Sure, the Google Maps API does allow you to pass an “array” of coordinate pairs and returns an array of distance results, but we’d only be getting the beeline distance, which is different than what we’re after.

To get around this, we decided to calculate the distance between each marker to come up with the total distance along our pathline, then divide that number by 100 (the arbitrary number of samples we decided to take, saved as a constant).

Although this isn’t 100% perfect, the getElevationAlongPath function Google Maps provides returns as many samples as you ask for along the polyline that you pass it.

It’s not perfect because Google Maps approximates, rather than calculates, the elevation based on the surrounding topography (the resolution tells us how accurate the return value is).

But it’s pretty damn close (as you can see in the final product). So we considered our users and decided that and we’re comfortable with the tradeoff:

The tradeoff: Less app complexity + fewer API calls in exchange for occasional, slight inaccuracies.

That means we can divide our total distance by the number of elevation samples to create a unit, and then multiply that unit by the elevation’s index (plus 1) to get an elevation’s cumulative distance from the point of origin.

Here’s the code:

1export const numOfSamples = 100
2const startDistance = 0
3const endDistance = distances.reduce((acc, curr) => acc + curr, startDistance)
4const sampleUnit = endDistance / numOfSamples
5
6const makeData = data =>
7 data.reduce((acc, curr, i) => {
8 const dist = sampleUnit * (i + 1)
9 return acc.concat({ x: dist, y: curr.elevation, ...curr })
10 }, [])

Calling makeData and passing in the array of elevations that we received on props, we get an array of objects with this shape:

1{
2 x: float! // cululative distance from origin along path
3 y: float! // elevation in meters at point
4 location: LagLng!
5 resolution: float!
6}

Bingo. We have our distances.

Get Screen Coordinates

But there’s still one big problem we haven’t solved ye yett:

How are we supposed to draw our “blip” on the map?

At first we tried using the Marker API to create a marker from a given location, only to destroy it and redraw a new one when the user’s mouse moves.

That ended up creating a very choppy user experience, not to mention the event handler was lossy — the Markers API was not called, so sometimes a marker would render and sometimes it would not be deleted.

However it wasn’t hard to implement, so we tried it out. Each elevation comes with a set coordinates, so all we do is new google.maps.Marker(...), passing in the coordinate set.

Here a silly video of how our first iteration turned out — you can tell we were getting a little loopy from lack of sleep:

So yeah. Markers were out.

If not markers, how?

As far as we could ascertain, the Markers API was the only way to render an element at a particular point on the map. Sure, technically the API affords us other SVG options, but they all suffer from the same choppy UX.

We poured over the Google Maps documentation, looking for an escape hatch. Some way to “map” a set of coordinates that exist on the map canvas to its corresponding set of screen-x and screen-y coordinates in the browser window.

We were feeling pretty stuck until we stumbled across the Point class, which represents a point on a two-dimensional plane.

From there we found the fromLatLngToPoint class, and Googling it led us to this short, wonderful blog post by Krasimir Tsonev.

The magic function:

1const fromLatLngToPoint = (latLng, map) => {
2 const topRight = map
3 .getProjection()
4 .fromLatLngToPoint(map.getBounds().getNorthEast())
5 const bottomLeft = map
6 .getProjection()
7 .fromLatLngToPoint(map.getBounds().getSouthWest())
8 const scale = Math.pow(2, map.getZoom())
9 const worldPoint = map
10 .getProjection()
11 .fromLatLngToPoint(new window.google.maps.LatLng(latLng))
12 const point = new window.google.maps.Point(
13 (worldPoint.x - bottomLeft.x) * scale,
14 (worldPoint.y - topRight.y) * scale
15 )
16 return point
17}

The Math.pow(2, map.getZoom()) on line 8 was exactly what we needed to make the conversion.

Because we pass the map as an argument each time, even if the user drags or zooms the map mouse moves, we always get the fresh and current screen x- and y-coordinates at a given position.

Almost done. Now that we know where to draw the blip, let’s draw the damn thing.

Drawing the “Blip”

We’ll draw the blip inside our mousemove function:

1function mousemove() {
2 const x0 = xScale.invert(d3.mouse(this)[0])
3 const i = bisect(data, x0, 1)
4 const d0 = data[i - 1]
5 const d1 = data[i]
6 const d = !d1 ? d0 : x0 - d0.x > d1.x - x0 ? d1 : d0
7 crossBar.attr("transform", `translate(${xScale(d.x)}, 0)`)
8 crossBar.select("text").text(d3.format(".1f")(metersToMiles(d.x)) + "mi")
9 infoBox.attr("transform", `translate(${xScale(d.x) + 10}, 12.5)`)
10 infoBox
11 .select(".infoBoxElevationValue")
12 .text(d3.format(",.0f")(metersToFeet(d.y)) + "ft")
13
14 /*** NEW STUFF ***/
15 const { x: px, y: py } = fromLatLngToPoint(d.location, this.state.map)
16 blip.style("transform", `translate3d(${px}px, ${py}px, 0px)`)
17
18 return null
19}

And finally, we need to toggle the blip’s display in our mouseover and mouseleave events. Just 2 lines of code does the trick:

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

And we’re done it! That’s a wrap 💯

Let’s take one last look at what we made together:

Loading...

Check out the source code for the ElevationChart component here.

These components were part of our capstone project at Lambda School. If you have any questions about the project or my experience there, feel free to reach out to me at ahrjarrett@gmail.com.

Until next time.