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.
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
Declare our constants, helper functions, and configure D3 so it’s ready for action.
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
Draw the chart using D3’s API! This is the fun part, and the meat and potatoes of our task today
Render that bad boy! We’ll talk styles here too, especially regarding svg styling (wtf is viewPort vs. viewBox, etc.)
Write a callback function that draws a DOM element on the map depending on where the user is hovering
[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 = 1002export const metersToMiles = m => m * 0.0006213713export const metersToFeet = m => m * 3.2808445export const arraysEqual = (arr1, arr2) => {6 if (arr1.length !== arr2.length) return false7 for (let i = arr1.length; i--; ) {8 if (arr1[i] !== arr2[i]) return false9 }10 return true11}1213export const fromLatLngToPoint = (latLng, map) => {14 const topRight = map15 .getProjection()16 .fromLatLngToPoint(map.getBounds().getNorthEast())17 const bottomLeft = map18 .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) * scale25 )26 return point27}
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"23const D3Version = () => (4 <p id="d3-version">5 D3 Version: <strong>{d3.version}</strong>6 </p>7)89<D3Version />
That renders:
D3 Version: 5.9.1
Cool! Let’s start configuring:
1// 1. DECLARATIONS / CONFIG2const margin = { top: 0, right: 0, bottom: 15, left: 50 }3const width = 750 - margin.left - margin.right4const height = 155 - margin.top - margin.bottom5const xAxisTicks = 86const 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.
We need to give our linear scale a domain and a range:
1const xScale = d32 .scaleLinear()3 .domain(d3.extent(data, d => d.x))4 .range([0, width])56const yScale = d37 .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.
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)])
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 = d32 .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.right6const height = 155 - margin.top - margin.bottom78export default class Chart extends React.Component {9 constructor(props) {10 super(props)11 this.state = {12 data: null13 }14 }1516 componentDidMount() {17 this.setState({ data }, () => this.drawChart())18 }1920 drawChart = () => {21 const { data } = this.state22 const xScale = d323 .scaleLinear()24 .domain(d3.extent(data, d => d.x))25 .range([0, width])26 const yScale = d327 .scaleLinear()28 .domain([d3.min(data, co => co.y), d3.max(data, co => co.y)])29 .range([height, 0])30 const svg = d331 .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)`)3940 svg41 .append("g")42 .attr("transform", `translate(0, ${height})`)43 .call(d3.axisBottom(xScale))4445 svg.append("g").call(d3.axisLeft(yScale))46 }4748 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))23yScale.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!
- Configure our visualization & pass D3 our scales, so it knows where on the screen to plot each point:
- 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 = d33 .area()4 .x(d => xScale(d.x))5 .y0(yScale(yScale.domain()[0]))6 .y1(d => yScale(d.y))78// 2.)9svg10 .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))67// Let’s write functions that handle spacing our ticks out:8const xAxisTicks = 89const yAxisTicks = 610const makeXGridlines = xScale => d3.axisBottom(xScale).ticks(xAxisTicks)11const makeYGridlines = yScale => d3.axisLeft(yScale).ticks(yAxisTicks)1213d3.axisBottom(xScale)14 // ...15 .ticks(yAxisTicks)16 .tickSize(0)17 .tickPadding(9)1819d3.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:2svg3 .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 )1314// Make Y grid:15svg16 .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 = svg2 .append("g")3 .attr("class", "crossBar")4 .style("display", "none")56crossBar7 .append("line")8 .attr("x1", 0)9 .attr("x2", 0)10 .attr("y1", height)11 .attr("y2", 0)1213crossBar14 .append("text")15 .attr("x", 10)16 .attr("y", 17.5)17 .attr("class", "crossBarText")1819const infoBox = svg20 .append("g")21 .attr("class", "infoBox")22 .style("display", "none")2324infoBox25 .append("rect")26 .attr("x", 0)27 .attr("y", 10)28 .style("height", 45)29 .style("width", 125)3031const infoBoxElevation = infoBox32 .append("text")33 .attr("x", 8)34 .attr("y", 30)35 .attr("class", "infoBoxElevation")3637infoBoxElevation38 .append("tspan")39 .attr("class", "infoBoxElevationTitle")40 .text("Elev: ")4142infoBoxElevation.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.
1svg2 .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)1516function mousemove() {17 return null18}
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.x3}).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 : d08 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 infoBox12 .select(".infoBoxElevationValue")13 .text(d3.format(",.0f")(metersToFeet(d.y)) + " ft")14 infoBox.select(".infoBoxGradeValue").text(d3.format(".1%")(d.grade))15 return null16}
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: