Creating Maps using D3.js in React.
If you’ve been working on UI development for a while, chances are you’ve had to deal with charts and maps at some point.
Now, there are plenty of fantastic open-source libraries for creating charts, but when it comes to interactive maps, you might find yourself leaning towards react-simple-maps. Sure, there are other map options like Google Maps, Leaflet, and MapBox, but they often come with subscription fees that increase as your user base grows.
But here’s the thing: most of the time, you don’t really need a live, constantly updating map. What you do need is a way to visualize data on a geographical map, like showing population density or product sales. Going for big guns like Google Maps, Leaflet, or MapBox in these cases can be overkill and drain your budget.
So, what’s the friendly advice here?🙄 Well, consider giving D3.js a shot! With D3.js, you can create impressive, highly customized maps in no time. It’s not just about saving money; it’s also about saving time and reducing your reliance on third-party libraries. Plus, it’s pretty fun to work with!
In this article, I’ve chosen to use a USA map as our example for the sake of simplicity. The reason is that there’s a wide array of geoJSON files and resources readily available for the USA, and I’m familiar with working on these maps.
However, if you’re interested in achieving similar results for other countries or continents, it’s just as feasible. All you’ll need is a detailed GeoJSON file for the specific geographic area you’re interested in and a slight adjustment to the D3 projection. I’ll explain how to do this in the later sections of the article.
The same is true for React. The example I created is more than 95% pure JavaScript, so you don’t have to use React. You can use any JavaScript library or framework you want or none at all!!. That’s the beauty of D3.
Before we do that, let’s explore a little bit about D3 and the methods that we’re going to use.
Exploring D3:
“D3” is short for “Data-Driven Documents,” and it’s a library used to create dynamic and interactive data visualizations in web browsers. It does this by utilizing Scalable Vector Graphics (SVG).
Mapping concepts
The 3 concepts that are key to understanding map creation using D3 are:
- GeoJSON (a JSON-based format for specifying geographic data)
- Projections (functions that convert from latitude/longitude coordinates to x & y coordinates)
- Geographic path generators (functions that convert GeoJSON shapes into SVG or Canvas paths)
GeoJSON
GeoJSON is a standard for representing geographic data using the JSON format and the full specification is at geojson.org.
Here’s a typical GeoJSON object:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"name": "Africa"
},
"geometry": {
"type": "Polygon",
"coordinates": [[[-6, 36], [33, 30], ... , [-6, 36]]]
}
},
{
"type": "Feature",
"properties": {
"name": "Australia"
},
"geometry": {
"type": "Polygon",
"coordinates": [[[143, -11], [153, -28], ... , [143, -11]]]
}
}
]
}In the above object there’s a FeatureCollection containing an array of 2 features:
- Africa
- Australia
Each feature consists of geometry (simple polygons in the case of the countries) and properties.
Properties can contain any information about the feature such as name, id, and other data such as population, GDP, etc.
D3 takes care of most of the detail when rendering GeoJSON so you only need a basic understanding of GeoJSON to get started with D3 mapping.
Projections
A projection function takes a longitude and latitude coordinate (in the form of an array [lon, lat]) and transforms it into an x and y coordinate:
function projection( lonLat ) {
let x = ... // some formula here to calculate x
let y = ... // some formula here to calculate y
return [x, y];
}
projection( [-3.0026, 16.7666] )
// returns [474.7594743879618, 220.7367625635119]Projection mathematics can get quite complex but fortunately, D3 provides a large number of projection functions.
For example, you can create an equirectangular projection function using:
let projection = d3.geoAlbers();
projection( [-3.0026, 16.7666] )
// returns [474.7594743879618, 220.7367625635119]There are numerous ways to convert, or ‘project,’ a point from the surface of a sphere (like the Earth) onto a flat surface (such as a screen), and many articles, including this one, have discussed the pros and cons of various projection methods.
In simple terms, there is no perfect projection because each one inevitably distorts aspects like shape, area, distance, or direction to some degree. When choosing a projection, it’s a matter of deciding which property you’re willing to accept distortion in while preserving others, or you can opt for a projection that tries to strike a balance. For example, if accurately representing the sizes of countries is crucial, you might select a projection that focuses on preserving the area, even if it means some compromise in shape, distance, and direction accuracy.
D3 provides several core projection options that should meet the needs of most scenarios:
geoAzimuthalEqualAreageoAzimuthalEquidistantgeoGnomonicgeoOrthographicgeoStereographicgeoAlbersgeoConicConformalgeoConicEqualAreageoConicEquidistantgeoEquirectangulargeoMercatorgeoTransverseMercator
When it comes down to it, there are really only two commonly used projections for creating the maps we’re accustomed to: Albers and Mercator. Albers projection is better for preserving the area, while Mercator projection is better for preserving shape.
In this article, I’ll be using the AlbersUsa projection to build the map, but in most cases, it’s usually better to go with the Mercator projection.
Geographic path generators
A geographic path generator is a function that transforms GeoJSON into an SVG path string (or into canvas element calls).
geoGenerator(geoJson);
// e.g. returns a SVG path string "M464.01,154.09L491.15,154.88 ... L448.03,183.13Z"You create the generator using d3.geoPath() and must configure its projection type.
let projection = d3.geoAlbers();
let geoGenerator = d3.geoPath()
.projection(projection);You can use the generator to create either SVG or canvas maps. SVG maps are easier to implement, especially for user interaction, as they allow you to add event handlers and hover states. Canvas maps require more work, but they are typically faster to render and more memory efficient.
Now that we have a basic understanding of D3, let’s try rendering some SVG using D3. To render an SVG map, we need to do the following:
- Join a GeoJSON features array to SVG path elements.
- Update each path element’s d attribute using the geographic path generator.
For example:
let geoJson = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"name": "Africa"
},
"geometry": {
"type": "Polygon",
"coordinates": [[[-6, 36], [33, 30], ... , [-6, 36]]]
}
},
{
"type": "Feature",
"properties": {
"name": "Australia"
},
"geometry": {
"type": "Polygon",
"coordinates": [[[143, -11], [153, -28], ... , [143, -11]]]
}
},
{
"type": "Feature",
"properties": {
"name": "Timbuktu"
},
"geometry": {
"type": "Point",
"coordinates": [-3.0026, 16.7666]
}
}
]
}
let projection = d3.geoEquirectangular();
let geoGenerator = d3.geoPath()
.projection(projection);
// Join the FeatureCollection's features array to path elements
let u = d3.select('#content g.map')
.selectAll('path')
.data(geojson.features)
.join('path')
.attr('d', geoGenerator);The above code will render something like this,
The small circle you see in Africa is a representation of the city of Timbuktu. I added it to the map so that you can see how GeoJSON can be used to represent different types of geographical features.
I have also attached a screenshot of the “inspect window” to show you how D3 uses the SVG path element to create the shape of the country or region.
Now that we’ve covered everything necessary, let's dive into creating a USA map that’ll zoom in to show counties when you click on its states.
In this example, I’ll use a random color generator to color both states and counties. But you can choose colors or color ranges from any dataset you have.
D3 also provides scaleLinear method if you want to use a color range, all you have to do is pass the min and max values of your data set.
const colorScale = scaleLinear()
.domain([
//Min value of your data set,
//Max value of your data set,
])
.range(["#fee9c8", "#e34a33"]);
//Now, you can use this colorScale method like this
//fill={currentData ? colorScale(currentData.value) : DEFAULT_COLOR}I have used the GeoJSON file from this website, make sure to download the 500k version of the GeoJSON files, as they have more coordinates than the other two versions.
Here is the code that I have written to create the USA map. I have added detailed inline comments to explain what each line of code does.
//App.jsx
import './App.css';
import 'react-toastify/dist/ReactToastify.css';
import React, { useEffect } from "react";
import * as d3 from 'd3'
import { ToastContainer, toast } from 'react-toastify';
// Geo json files
import countyData from "./data/counties.json";
import stateData from "./data/states.json";
const mapRatio = 0.5
const margin = {
top: 10,
bottom: 10,
left: 10,
right: 10
}
const colorScale = ["#B9EDDD", "#87CBB9", "#569DAA", "#577D86"];
function App() {
// A random color generator
const colorGenerator = () => {
return colorScale[Math.floor(Math.random() * 4)]
}
useEffect(() => {
let width = parseInt(d3.select('.viz').style('width'))
let height = width * mapRatio
let active = d3.select(null);
width = width - margin.left - margin.right
const svg = d3.select('.viz').append('svg')
.attr('class', 'center-container')
.attr('height', height + margin.top + margin.bottom)
.attr('width', width + margin.left + margin.right);
svg.append('rect')
.attr('class', 'background center-container')
.attr('height', height + margin.top + margin.bottom)
.attr('width', width + margin.left + margin.right)
// Creating projection, it's best to use 'geoAlbersUsa' projection if you're rendering USA map and for other maps use 'geoMercator'.
const projection = d3.geoAlbersUsa()
.translate([width / 2, height / 2])
.scale(width);
// Creating path generator fromt the projecttion created above.
const pathGenerator = d3.geoPath()
.projection(projection);
// Creating the container
const g = svg.append("g")
.attr('class', 'center-container center-items us-state')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
// Creating counties layer
g.append("g")
.attr("id", "counties")
.selectAll("path")
.data(countyData.features)
.enter()
.append("path")
.attr("d", pathGenerator)
.attr("key", feature => {
return feature.properties.STATE + feature.properties.COUNTY;
})
.attr("class", "county-boundary")
.attr("fill", (feature) => {
// I could directly call colorGenerator instead of calling it in a arrow function, I've added it in that way so that you'd know we can send values from geo json into every step of the map creation.
return colorGenerator()
})
.on("click", resetZoom);
// Creating state layer on top of counties layer.
g.append("g")
.attr("id", "states")
.selectAll("path")
.data(stateData.features)
.enter()
.append("path")
.attr("key", feature => {
return feature.properties.NAME
})
.attr("d", pathGenerator)
.attr("class", "state")
// Here's an example of what I was saying in my previous comment.
.attr("fill", colorGenerator)
.on("click", handleZoom)
function handleZoom(stateFeature) {
// Set the state backgroud to 'none' so that the counties can be displayed.
active.classed("active", false);
active = d3.select(this).classed("active", true);
toast.info(`Selected state is ${stateFeature.properties.NAME}`, {
position: "top-right",
autoClose: 5000,
hideProgressBar: true,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "light",
});
// Call to zoom in.
zoomIn(stateFeature)
}
function zoomIn(currentState) {
// Get bounding box values for the selected county.
let bounds = pathGenerator.bounds(currentState);
// Zoom In calculations
let dx = bounds[1][0] - bounds[0][0];
let dy = bounds[1][1] - bounds[0][1];
let x = (bounds[0][0] + bounds[1][0]) / 2;
let y = (bounds[0][1] + bounds[1][1]) / 2;
let scale = .9 / Math.max(dx / width, dy / height);
let translate = [width / 2 - scale * x, height / 2 - scale * y];
// Updaing the css using D3
g.transition()
.duration(750)
.style("stroke-width", 1.5 / scale + "px")
.attr("transform", "translate(" + translate + ")scale(" + scale + ")");
}
function resetZoom() {
// Remove the active class so that state color will be restored and conuties will be hidden again.
active.classed("active", false);
active = d3.select(null);
// Resetting the css using D3
g.transition()
.delay(100)
.duration(750)
.style("stroke-width", "1.5px")
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
}
}, []);
return (
<div>
<div class="viz">
</div>
<ToastContainer />
</div>
);
}
export default App;Here’s the CSS file that goes with the above code, to make it a little bit more prettier.
/** App.css **/
.background {
fill: none;
pointer-events: all;
}
#states {
fill: none;
stroke: #fff;
stroke-linejoin: round;
stroke-width: 1.5px;
}
#states .active {
display: none;
}
.county-boundary {
stroke: #fff;
stroke-width: 0.5px;
}
.county-boundary:hover,
.state:hover {
fill: #002b5b;
}Now, Let’s look at the results.
See how easy it is to create a fully functioning and interactive map in a few hours! However, if you don’t have a few hours to spare, I’m planning to create an NPM library that will allow you to get this interactive map directly into your code base without any hassle. (I know, the irony of this post!😂) Stay tuned for more updates.
Happy coding!!👨🏻💻🍻