D3 in Depth covers versions 6 and 7 of D3

Home About Newsletter
NEWSLETTER

Get book discounts and receive D3.js related news and tips.

Subscribe

Making maps with D3

D3’s geo module helps you create maps from GeoJSON data. This article explains GeoJSON, projections and path generators. It also shows how to use these three concepts to create maps.

This chapter looks at D3’s approach to rendering geographic information.

As an example, the globe below is drawn using D3. A GeoJSON file is loaded and D3 is used to project the geographic data and draw it on a Canvas element:

D3’s approach differs to so called raster methods such as Leaflet and Google Maps. These pre-render map features as image tiles and these are loaded from a web server and pieced together in the browser to form a map.

Typically D3 requests vector geographic information in the form of GeoJSON and renders this to SVG or Canvas in the browser.

Raster maps often look more like traditional print maps where a lot of detail (e.g. place names, roads, rivers etc.) can be shown without an impact on performance. However, dynamic content such as animation and interaction is more easily implemented using a vector approach. (It’s also quite common to combine the two approaches.)

D3 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 co-ordinates to x & y co-ordinates)
  • 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]]]
      }
    },
    {
      "type": "Feature",
      "properties": {
        "name": "Timbuktu"
      },
      "geometry": {
        "type": "Point",
        "coordinates": [-3.0026, 16.7666]
      }
    }
  ]
}

In the above object there’s a FeatureCollection containing an array of 3 features:

  • Africa
  • Australia
  • the city of Timbuktu

Each feature consists of geometry (simple polygons in the case of the countries and a point for Timbuktu) 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 co-ordinate (in the form of an array [lon, lat]) and transforms it into an x and y co-ordinate:

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 equi-rectangular projection function using:

let projection = d3.geoEquirectangular();

projection( [-3.0026, 16.7666] )
// returns [474.7594743879618, 220.7367625635119]

We’ll look at projections in more detail later.

Geographic path generators

A geographic path generator is a function that takes a GeoJSON object and converts it into an SVG path string. (In fact, it’s just another type of shape generator.)

You can create a generator using the method .geoPath and configure it with a projection function:

let projection = d3.geoEquirectangular();

let geoGenerator = d3.geoPath()
  .projection(projection);

let geoJson = {
  "type": "Feature",
  "properties": {
    "name": "Africa"
  },
  "geometry": {
    "type": "Polygon",
    "coordinates": [[[-6, 36], [33, 30], ... , [-6, 36]]]
  }
}

geoGenerator(geoJson);
// returns "M464.0166237760863,154.09974265651798L491.1506253268278,154.8895088551978 ... L448.03311471280136,183.1346693994119Z"

As usual with shape generators the generated path string is used to set the d attribute on an SVG path element.

Putting it all together

Given some GeoJSON, a projection function and a geographic path generator you can create a basic map:

let geoJson = {
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "properties": {
        "name": "Africa"
      },
      "geometry": {
        "type": "Polygon",
        "coordinates": [[[-6, 36], [33, 30], ... , [-6, 36]]]
      }
    },
    ...
  ]
}

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);

geoJson.features is an array of features. This array is joined to path elements. The d attribute is set using the function geoGenerator. This receives a feature as its first parameter and outputs a path string.

The last line may look like magic but is the equivalent of:

  .attr('d', function(d) {
    return geoGenerator(d);
  });

In this case the parameter d is a GeoJSON feature.

To keep things simple the GeoJSON in the above example uses just a few co-ordinates to define the country boundaries.

The above example shows the essence of creating maps using D3 and I recommend spending time to understand each concept (GeoJSON, projections and geo generators) and how they fit together.

Now that we’ve covered the basics we’ll look at each concept in more detail.

GeoJSON

GeoJSON is a JSON-based structure for specifying geographic data. More often than not it’s converted from shapefile data (a geospatial vector data format widely used in the GIS field) using tools such as mapshaper, ogr2ogr, shp2json or QGIS.

A popular source of world map shapefiles is Natural Earth and if starting out I recommend trying out mapshaper for importing shapefiles and exporting as GeoJSON. It can also filter by properties (e.g. if you wanted to filter countries by continent). For a more in depth look at conversion look at Mike Bostock’s Let’s Make a Map tutorial.

You can create maps without understanding the GeoJSON specification in minute detail because tools such as mapshaper and D3 do such a good job of abstracting away the detail. However, if you did want to understand GeoJSON in greater depth I recommend checking out the official specification.

So far we’ve embedded a GeoJSON object in our example files. In practice the GeoJSON would be in a separate file and loaded using an ajax request. We cover requests in more detail in the requests chapter but for the remainder of this chapter we’ll load a GeoJSON file using:

d3.json('ne_110m_land.json', function(err, json) {
  createMap(json);
})

It’s worth mentioning TopoJSON which is another JSON based standard for describing geographic data and tends to result in significantly smaller file sizes. It requires a bit more work to use, and we don’t cover it in this chapter. However for further information check out the documentation.

Projections

There are numerous (if not infinite) ways of converting (or ‘projecting’) a point on a sphere (e.g. the earth) to a point on a flat surface (e.g. a screen) and people have written countless articles (such as this one) on the pros and cons of different projections.

In short there is no perfect projection as every projection will distort shape, area, distance and/or direction. Choosing a projection is a case of choosing which property you don’t want to be distorted and accepting that there’ll be distortion in the other properties (or choose a projection that strives for a balanced approach). For example, if it’s important that the size of countries are represented accurately then choose a projection that strives to preserve area (probably to the cost of shape, distance and direction).

D3 has a number of core projections that should cover most use cases:

  • geoAzimuthalEqualArea
  • geoAzimuthalEquidistant
  • geoGnomonic
  • geoOrthographic
  • geoStereographic
  • geoAlbers
  • geoConicConformal
  • geoConicEqualArea
  • geoConicEquidistant
  • geoEquirectangular
  • geoMercator
  • geoTransverseMercator

Some projections preserve area (e.g. geoAzimuthalEqualArea & geoConicEqualArea), others distance (e.g. geoAzimuthalEquidistant & geoConicEquidistant) and others relative angles (e.g. geoEquirectangular & geoMercator). For a more in depth discussion of the pros and cons of each projection try resources such as Carlos A. Furuti’s Map Projection Pages.

The grid below shows each core projection on a world map together with a longitude/latitude grid and equal radius circles.

Projection functions

A projection function takes input [longitude, latitude] and outputs a pixel co-ordinate [x, y].

Be careful to note the order of longitude and latitude in the above array!

You’re free to write your own projection functions but much easier is to ask D3 to make one for you. To do this choose a projection method (e.g. d3.geoAzimuthalEqualArea), call it and it’ll return a projection function:

let projection = d3.geoAzimuthalEqualArea();

projection( [-3.0026, 16.7666] );
// returns [473.67353385539417, 213.6120079887163]

The core projections have configuration functions for setting the following parameters:

scaleScale factor of the projection
centerProjection center [longitude, latitude]
translatePixel [x,y] location of the projection center
rotateRotation of the projection [lambda, phi, gamma] (or [yaw, pitch, roll])

The precise meaning of each parameter is dependent on the mathematics behind each projection but broadly speaking:

  • scale specifies the scale factor of the projection. The higher the number the larger the map.
  • center specifies the center of projection (with a [lon, lat] array)
  • translate specifies where the center of projection is located on the screen (with a [x, y] array)
  • rotate specifies the rotation of the projection (with a [λ, φ, γ] array) where the parameters correspond to yaw, pitch and roll, respectively:

For example you can create and configure a projection function such that Timbuktu is centred in a 960x500 map using:

let projection = d3.geoAzimuthalEqualArea()
  .scale(300)
  .center([-3.0026, 16.7666])
  .translate([480, 250]);

To get a feel for how each parameter behaves use the projection explorer below. The (equal radius) circles and grid allow you to assess the projection’s distortion of area and angle.

.invert()

You can convert a pixel co-ordinate [x, y] to a longitude/latitude array using the projection’s .invert() method:

let projection = d3.geoAzimuthalEqualArea();

projection( [-3.0026, 16.7666] )
// returns [473.67353385539417, 213.6120079887163]

projection.invert( [473.67353385539417, 213.6120079887163] )
// returns [-3.0026, 16.766]

Fitting

Given a GeoJSON object, a projection’s .fitExtent() method sets the projection’s scale and translate such that the geometry fits within a given bounding box:

projection.fitExtent([[0, 0], [900, 500]], geojson);

The first argument of .fitExtent is an array containing two coordinates: the top left point ([x, y]) of the bounding box and the size ([width, height]) of the bounding box. The second argument is a GeoJSON object.

In the example below the canvas element has a light grey background and the bounding box into which we’re fitting the geoJSON is shown as a dotted outline. The following code is used to fit the geometry within the bounding box:

projection.fitExtent([[20, 20], [620, 420]], geojson);

If your bounding box’s top left corner is at [0, 0] you can omit the top left coordinate and just supply the width and height:

projection.fitSize([900, 500], geojson);

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 it’s projection type:

let projection = d3.geoEquirectangular();

let geoGenerator = d3.geoPath()
  .projection(projection);

You can now use the generator to help create an SVG or canvas map. The SVG option is a bit easier to implement, especially when it comes to user interaction (because event handlers and hover states can be added).

The canvas approach requires a bit more work but is typically faster to render (and more memory efficient).

Rendering SVG

To render an SVG map you:

  • 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);

geoJson.features is an array of features. This array is joined to path elements. The d attribute is set using the function geoGenerator. This receives a feature as its first parameter and outputs a path string.

Rendering to canvas

To render to a canvas element you pass the canvas DOM element into the generator’s context method:

let context = d3.select('#content canvas')
  .node()
  .getContext('2d');

let geoGenerator = d3.geoPath()
  .projection(projection)
  .context(context);

The .node method returns the first DOM element of a selection.

You then begin a canvas path (using context.beginPath()) and call geoGenerator which will produce the necessary canvas calls:

context.beginPath();
geoGenerator({type: 'FeatureCollection', features: geojson.features})
context.stroke();

Lines and arcs

The geographic path generator is clever enough to distinguish between polygonal (typically for geographic areas) and point (typically for lon/lat locations) features. As can be seen in the above examples it renders polygons as line segments and points as arcs.

You can set the radius of the circles using .pointRadius():

let geoGenerator = d3.geoPath()
  .pointRadius(5)
  .projection(projection);

Path geometry

The geographic path generator can also be used to compute the area (in pixels), centroid, bounding box and path length (in pixels) of a projected GeoJSON feature:

let feature = geojson.features[0];

// Compute the feature's area (in pixels)
geoGenerator.area(feature);
// returns 30324.86518469876

// Compute the feature's centroid (in pixel co-ordinates)
geoGenerator.centroid(feature);
// returns [266.9510120424504, 127.35819206325564]

// Compute the feature's bounds (in pixel co-ordinates)
geoGenerator.bounds(feature);
// returns [[140.6588054321928, 24.336293856408275], [378.02358370342165, 272.17304763960306]]

// Compute the path length (in pixels)
geoGenerator.measure(feature);
// returns 775.7895349902461

This example shows the area and length of a hovered path. It also draws the path’s centroid and bounding box:

Shapes

If you need to add lines and/or circles to a map you can add features to the GeoJSON.

Lines can be added as a LineString feature and will be projected into great-arcs (i.e. the shortest distance across the surface of the globe).

Here’s an example where a line is added between London and New York:

geoGenerator({
  type: 'Feature',
  geometry: {
    type: 'LineString',
    coordinates: [[0.1278, 51.5074], [-74.0059, 40.7128]]
  }
});

Circle features can be generated using d3.geoCircle(). This creates a circle generator which returns a GeoJSON object representing a circle.

Typically the center ([lon, lat]) and the radius (in degrees) are set:

let circleGenerator = d3.geoCircle()
  .center([0.1278, 51.5074])
  .radius(5);

let circle = circleGenerator();
// returns a GeoJSON object representing a circle

geoGenerator(circle);
// returns a path string representing the projected circle

A GeoJSON grid of longitude and latitude lines (known as a graticule) can be generated using d3.graticule(). This creates a graticule generator which returns a GeoJSON object representing the graticules:

let graticuleGenerator = d3.geoGraticule();

let graticules = graticuleGenerator();
// returns a GeoJSON object representing the graticule

geoGenerator(graticules);
// returns a path string representing the projected graticule

(See the official documentation for detailed information on graticule configuration.)

Here’s an example where a line, a circle and graticules are added to a map:

Spherical geometry

There’s a handful of D3 methods that may come in useful from time to time. The first of these .geoArea(), .geoBounds(), .geoCentroid(), .geoDistance() and geoLength() are similar to the path geometry methods described above but operate in spherical space.

Interpolation

The d3.geoInterpolate() method creates a function that accepts input between 0 and 1 and interpolates between two [lon, lat] locations:

let londonLonLat = [0.1278, 51.5074];
let newYorkLonLat = [-74.0059, 40.7128];
let geoInterpolator = d3.geoInterpolate(londonLonLat, newYorkLonLat);

geoInterpolator(0);
// returns [0.1278, 51.5074]

geoInterpolator(0.5);
// returns [-41.182023242967695, 52.41428456719971] (halfway between the two locations)

geoContains

If you’re using a canvas element to render your geometry you don’t have the luxury of being able to add event handlers onto SVG path elements. Instead you can check whether mouse or touch events occur inside the boundary of a feature. You can do this using d3.geoContains which accepts a GeoJSON feature and a [lon, lat] array and returns a boolean:

d3.geoContains(ukFeature, [0.1278, 51.5074]);
// returns true
MY BOOKS

"One of the best D3 books I've read. The contents are very clear, it is easy to follow and the concepts are very solid."

Javier García Fernández

Learn how to make a custom data visualisation using D3.js.

Find out more

Learn the fundamentals of HTML, SVG, CSS and JavaScript for building data visualisations on the web.

Find out more