Studio
GithubDribbbleTwitter
Richard Zimerman

May 24, 2017

How to create pure react SVG maps with topojson and d3-geo

Since d3 has been split into smaller components in v4, pairing it with react has become more streamlined. Making declarative SVG maps with react, topojson, and d3-geo has never been easier.

Share
banner
A thematic map combining a choropleth with proportional symbols

This article will show you how to create pure react SVG maps with topojson-client and d3-geo. The techniques outlined here will help you make your own reusable SVG mapping components.

What this article covers

  1. The anatomy of an SVG map independent of react
  2. Structure of example app and npm dependencies
  3. Loading topojson and rendering a basic SVG map with react and d3-geo
  4. Adding events
  5. Example data-driven world map with markers

Code

The code for this tutorial is available in the react-svg-maps-tutorial repo on github.

Note: This tutorial covers the creation of a simple SVG map. If you need more functionalities, such as zooming, panning and annotations, I put together a library of reusable components called react-simple-maps.

1. The anatomy of an SVG map

In order to create a map with react and d3-geo let’s first take a look at what the output of such a map could look like:

<svg>
  <g class="wrapper">
    <g class="countries">
      <path d="..." class="country" />
      <path d="..." class="country" />
    </g>
    <g class="markers">
      <circle class="marker" r=10 />
      <circle class="marker" r=10 />
    </g>
  </g>
</svg>

This structure is completely independent from whether you are using react or any other framework/tool to render a map.

Instead of using d3 to manipulate the DOM and manage the above elements, we can use react. In JSX the above structure virtually stays the same (except for class, which becomes className).

<svg>
  <g className="wrapper">
    <g className="countries">
      <path d="..." className="country" />
      <path d="..." className="country" />
    </g>
    <g className="markers">
      <circle className="marker" r=10 />
      <circle className="marker" r=10 />
    </g>
  </g>
</svg>

The only tricky part is generating the d-path for each country. In order to do that you will first need a geo-file (topojson) containing some json describing the country paths. Then you will need a library that can translate such geographical data into coordinates. Topojson-client and d3-geo are ideally suited for these tasks. The former allows you to use topojson files, which are significantly smaller than geojson, and the latter allows you to easily manage geo-projections and translate geo data into a coordinate system. Using d3-geo also allows you to take advantage of the extensive collection of projections created by Mike Bostock.

2. Dependencies and structure

In order to create a map with react you will need a basic react project. For my own example, I used the simplest boilerplate possible using next.js so I don’t have to mess with webpack. See the tutorial github repo for more information.

For the map I will be using d3-geo and topojson-client (assuming you have already installed next, react, and react-dom), so let’s start by installing these libraries:

npm install d3-geo topojson-client --save

The code structure for this example will look like this:

┬ app
├─┬ components
│ └── WorldMap.js
├─┬ pages
│ └── index.js
├─┬ public
│ ├── favicon.ico
│ └── world-110m.json
├── package.json
└── node-modules

3. The WorldMap component

The WorldMap component will render a map based on the world-110m map from the topojson-worldatlas. Since I want to customise the projection and reuse it for markers, I created a separate method that returns an Equal earth projection with some scale and offset customisations. I also take advantage of the SVG viewBox property to prepare the map for variable screen sizes.


// src/components/WorldMap.js

import React, { useState, useEffect } from "react"
import { geoEqualEarth, geoPath } from "d3-geo"
import { feature } from "topojson-client"

const projection = geoEqualEarth()
  .scale(160)
  .translate([ 800 / 2, 450 / 2 ])

const WorldMap = () => {
  const [geographies, setGeographies] = useState([])

  useEffect(() => {
    fetch("/world-110m.json")
      .then(response => {
        if (response.status !== 200) {
          console.log(`There was a problem: ${response.status}`)
          return
        }
        response.json().then(worlddata => {
          setGeographies(feature(worlddata, worlddata.objects.countries).features)
        })
      })
  }, [])

  return (
    <svg width={ 800 } height={ 450 } viewBox="0 0 800 450">
      <g className="countries">
        {
          geographies.map((d,i) => (
            <path
              key={ `path-${ i }` }
              d={ geoPath().projection(projection)(d) }
              className="country"
              fill={ `rgba(38,50,56,${ 1 / geographies.length * i})` }
              stroke="#FFFFFF"
              strokeWidth={ 0.5 }
            />
          ))
        }
      </g>
      <g className="markers">
        <circle
          cx={ this.projection()([8,48])[0] }
          cy={ this.projection()([8,48])[1] }
          r={ 10 }
          fill="#E91E63"
          className="marker"
        />
      </g>
    </svg>
  )
}

export default WorldMap

Start by defining geographies as part of the WorldMap component state (line 13). This will be an empty array in the beginning, and the data will be loaded asynchronously, once the component mounts (see useEffect on line 15). The empty array avoids throwing an error when iterating over worldData before the map is loaded (see line 32).

To fetch world-110m.json I use Chrome’s built-in fetch function, but you can use any AJAX library of your choice (e.g. axios, superagent).

The projection is used to render the d-path of the country paths, as well as the cx and cy property of the marker circles. Since the projection is customised, it makes more sense to store it in a separate variable(see line 8).

In order to not make all countries the same colour, I use the index of the country within the worldData array to set the opacity of the country path fill. If you have data, you can use the same strategy to create a choropleth map.

Note: If you are making choropleth maps, always use an equal area projection. For the world map Equal earth and Mollweide are good options. If you do not use an equal area projection, your map will not show correct relationships between countries or areas in different parts of the world and therefore will not be accurate.

4. Events

Adding events to your SVG paths works the same way as adding events to any other element in JSX. The below code shows how to add an onClick handler to the country paths.

// src/components/WorldMap.js

const WorldMap = () => {
  const [geographies, setGeographies] = useState([])
  
  /*
   * Topojson data fetching logic and state management for setGeographies()
   *
   */

  const handleCountryClick = countryIndex => {
    console.log("Clicked on country: ", geographies[countryIndex])
  }

  return (
    <svg width={ 800 } height={ 450 } viewBox="0 0 800 450">
      <g className="countries">
        {
          geographies.map((d,i) => (
            <path
              key={ `path-${ i }` }
              d={ geoPath().projection(projection)(d) }
              className="country"
              fill={ `rgba(38,50,56,${ 1 / geographies.length * i})` }
              stroke="#FFFFFF"
              strokeWidth={ 0.5 }
              onClick={ () => handleCountryClick(i) }
            />
          ))
        }
      </g>
    </svg>
  )
}

export default WorldMap

The same approach can be used for other events (e.g. mouseEnter, mouseLeave, mouseMove) for countries, but also for markers.

5. Data example: Most populous cities of the world

Let’s try to add some data to the mix and output the world’s most populous cities on the world map. The marker size will be determined by the population size of the city.

// src/components/WorldMap.js

import React, { useState, useEffect } from "react"
import { geoEqualEarth, geoPath } from "d3-geo"
import { feature } from "topojson-client"

const cities = [
  { name: "Tokyo",          coordinates: [139.6917,35.6895],  population: 37843000 },
  { name: "Jakarta",        coordinates: [106.8650,-6.1751],  population: 30539000 },
  { name: "Delhi",          coordinates: [77.1025,28.7041],   population: 24998000 },
  { name: "Manila",         coordinates: [120.9842,14.5995],  population: 24123000 },
  { name: "Seoul",          coordinates: [126.9780,37.5665],  population: 23480000 },
  { name: "Shanghai",       coordinates: [121.4737,31.2304],  population: 23416000 },
  { name: "Karachi",        coordinates: [67.0099,24.8615],   population: 22123000 },
  { name: "Beijing",        coordinates: [116.4074,39.9042],  population: 21009000 },
  { name: "New York",       coordinates: [-74.0059,40.7128],  population: 20630000 },
  { name: "Guangzhou",      coordinates: [113.2644,23.1291],  population: 20597000 },
  { name: "Sao Paulo",      coordinates: [-46.6333,-23.5505], population: 20365000 },
  { name: "Mexico City",    coordinates: [-99.1332,19.4326],  population: 20063000 },
  { name: "Mumbai",         coordinates: [72.8777,19.0760],   population: 17712000 },
  { name: "Osaka",          coordinates: [135.5022,34.6937],  population: 17444000 },
  { name: "Moscow",         coordinates: [37.6173,55.7558],   population: 16170000 },
  { name: "Dhaka",          coordinates: [90.4125,23.8103],   population: 15669000 },
  { name: "Greater Cairo",  coordinates: [31.2357,30.0444],   population: 15600000 },
  { name: "Los Angeles",    coordinates: [-118.2437,34.0522], population: 15058000 },
  { name: "Bangkok",        coordinates: [100.5018,13.7563],  population: 14998000 },
  { name: "Kolkata",        coordinates: [88.3639,22.5726],   population: 14667000 },
  { name: "Buenos Aires",   coordinates: [-58.3816,-34.6037], population: 14122000 },
  { name: "Tehran",         coordinates: [51.3890,35.6892],   population: 13532000 },
  { name: "Istanbul",       coordinates: [28.9784,41.0082],   population: 13287000 },
  { name: "Lagos",          coordinates: [3.3792,6.5244],     population: 13123000 },
  { name: "Shenzhen",       coordinates: [114.0579,22.5431],  population: 12084000 },
  { name: "Rio de Janeiro", coordinates: [-43.1729,-22.9068], population: 11727000 },
  { name: "Kinshasa",       coordinates: [15.2663,-4.4419],   population: 11587000 },
  { name: "Tianjin",        coordinates: [117.3616,39.3434],  population: 10920000 },
  { name: "Paris",          coordinates: [2.3522,48.8566],    population: 10858000 },
  { name: "Lima",           coordinates: [-77.0428,-12.0464], population: 10750000 },
]

const projection = geoEqualEarth()
  .scale(160)
  .translate([ 800 / 2, 450 / 2 ])

const WorldMap = () => {
  const [geographies, setGeographies] = useState([])

  useEffect(() => {
    fetch("/world-110m.json")
      .then(response => {
        if (response.status !== 200) {
          console.log(`There was a problem: ${response.status}`)
          return
        }
        response.json().then(worlddata => {
          setGeographies(feature(worlddata, worlddata.objects.countries).features)
        })
      })
  }, [])

  const handleCountryClick = countryIndex => {
    console.log("Clicked on country: ", geographies[countryIndex])
  }

  const handleMarkerClick = i => {
    console.log("Marker: ", cities[i])
  }

  return (
    <svg width={ 800 } height={ 450 } viewBox="0 0 800 450">
      <g className="countries">
        {
          geographies.map((d,i) => (
            <path
              key={ `path-${ i }` }
              d={ geoPath().projection(projection)(d) }
              className="country"
              fill={ `rgba(38,50,56,${ 1 / geographies.length * i})` }
              stroke="#FFFFFF"
              strokeWidth={ 0.5 }
              onClick={ () => handleCountryClick(i) }
            />
          ))
        }
      </g>
      <g className="markers">
        {
          cities.map((city, i) => (
            <circle
              key={ `marker-${i}` }
              cx={ projection(city.coordinates)[0] }
              cy={ projection(city.coordinates)[1] }
              r={ city.population / 3000000 }
              fill="#E91E63"
              stroke="#FFFFFF"
              className="marker"
              onClick={ () => handleMarkerClick(i) }
            />
          ))
        }
      </g>
    </svg>
  )
}

export default WorldMap

Note: I have also added an onClick event to the markers, outputting the currently clicked city to the console (see lines 61 and 65).

Your map should look like this.

banner
The resulting map displays a choropleth map with proportional symbols

If you want to add tooltips to your map, you can use the technique outlined above for event handling. To render the actual tooltip, you can use a library like react-tooltip.

Next steps

For simple maps, the techniques outlined in this article should be enough to get you started, but once you want to add more complex functionality, such as panning, zooming, etc. it gets a little bit more complicated. Performance is a particularly tricky issue with react SVG maps.

As mentioned before, I have put together a set of reusable components in the react-simple-maps library, which help make more performant SVG maps with react and d3-geo. The library handles the more complex logic related to resizing, panning, and performance optimisation, so that you can focus on making beautiful and engaging visualisations.

Inspiration

If you are interested in reading more about using react and d3 together, check out “Interactive Applications with React and D3” by Elijah Meeks, or “How and why to use d3 with react” by Dan Scanlon.

If you have any questions or thoughts about the code in this article, please let me know. If you found this article helpful, feel free to share and recommend. Happy mapping!

Get our latest articles on design, UX, data visualisation, and code in your inbox.

Have a project in mind? Get in touch and let's make something cool. 

hello@zcreativelabs.comGet in touch

Newsletter

Sign up to get our latest articles on design, data visualization, and code directly in your inbox.

z creative labs GmbH
Sihlquai 131, 8005 Zurich
Switzerland

GithubDribbbleTwitterEmail

© 2020 z creative labs GmbH