Building a Map-Based Data Visualization with React and D3
This post is about this visualization.
About two years ago, I was playing around with D3 and made this map animation. This weekend, I converted it into a React component and made several updates and optimizations.
This post is not designed to give an intro to React or D3! If you are already familiar with one or both, it may give some inspiration.
I chose to use a dataset that plots documented meteor strikes around the world. There are a few problems (for example, the meteors tend to overlap and obscure the countries), but the purpose is to demonstrate understanding of D3.js concepts more than to educate the populace about meteors.
Design
I mostly wanted to do a simple map with a straightforward "dots at coordinates" presentation, with colors mapped on a logarithmic scale. (The largest meteor strike on record was 23000000 tons, while the smallest was 0.15 tons.) Undefined masses are handled with the color black, and text "mass unknown." If I were to make this a real-world application, I would try to implement a better visual differentiation among the strikes. Perhaps by adding a zoom so that a user can get to a view where the dots aren't overlapping. Or a country-level zoom, at least. I'm not totally sold on the color scheme, either. I plan to explore this more later, but I'm not really a data scientist and right now am more focused on building the component well than in following best practices for presenting data.
Each country and meteor strike has an explanatory tooltip. The countries display their name, and the meteors show name, mass and the date of the strike.
Originally I made the countries change color on hover. However, this implies that the country is clickable/there is some interaction to be had. So I removed that visual effect and left the tooltips.
I also didn't do a ton of cleaning on this dataset. There are about 12 meteors that don't have long/lat data, and I simply filtered those out. There is also at least one meteor with "undefined" mass. In a real-world application I would probably have to have some way of indicating these discrepancies. But again... not a data scientist, and the purpose of this project was not to practice data science.
For information about the map animations themselves, check out my original post on the vanilla JS version of the map. For the dots animation, I added a simple delay based on the index of the dot.
Building the Map
My preferred way of using D3 in React is to use the UseRef hook to persist an SVG container across renderings, in conjunction with UseEffect to update things as needed.
Note: I wasn't able to get D3's built-in d3.json(path)
function to work. I'm not exactly sure why, but I didn't spent that much time troubleshooting because a plain fetch
worked fine, so I just converted my calls to use that instead.
To make this component, I added a land group <g>
component to the svg. I then used fetch
to get the geojson and appended each feature to the map.
To make the tooltips, I added a div to the main element on the page using d3.select('main')
. The element has an opacity of 0, and is located offscreen. The mouseover event for each element sets the tooltip div to appear near the mouse, and display the appropriate data. (There's some CSS involved.)
Adding animation handlers
After that I took my animations (again, check out my original post for a quick writeup of that, or see the repo) and plopped them into Onclick handlers. That's... pretty straightforward. I was more or less able to keep exactly the same code, just updating the setTimeout functions to React syntax and adjusting the arguments a bit, and tweaked the easing function a little bit to add an overshoot to the move animation.
Making Responsive
This one was a bit tricky to puzzle out in my head. I ended up setting the ViewBox value to
and then setting it in the useEffect hook
const cWidth = d3.select('.map-container').style("width").split("px")[0]
const cHeight = d3.select('.map-container').style("height").split("px")[0]
console.log(cWidth, cHeight)
setVBoxDimensions(${cWidth*0.1} ${cHeight*0.1} ${cWidth} ${cHeight}
)
### Further Improvements
* Separate out the data rendering into its own component to further separate the responsibilities. For example, if I were to rebuild the visualization I did for the State Department, I would want the ability to write a completely different data rendering function without having to open the map component at all. The map probably won't change much between datasets, and would be a good standalone component, but datasets themselves will be hard to standardize for many reasons (like differences in attribute names).
* Keep track of the timeouts so that I can cancel them if the user clicks the other button mid-animation. Currently, it is possible to cause strange behavior by rapidly clicking the buttons. I did add handlers to cancel the stroke-offset attribute becuase otherwise you could end up with a map made of tiny dots, and that was too much of a breaking behavior for me.
* Add more customization options to the map - right now I have hardcoded the projection and config options instead of taking them as props.
* Explore adding a stagger to the draw effect so all the paths aren't starting exactly at once. This might look weird, but I'm not totally happy with the way the lines start appearing right at the beginning.
* The tooltip is at a different spot depending on how far down the page you are; this is because of the ClientY property. Explore fixing this.
* Making the viewbox passable as props to accomodate different heights, OR doing more math to calculate the exact positioning automatically based on the parent container.
All of these would be good improvements, but this was a Saturday morning breakfast project to make sure I remembered how to use React and D3 together.