Drawing Maps with D3.js and Other Geographical Fun

🚀 Coinbase is looking for DevOps and Software Engineers 🚀

WARNING: Heavy CPU Usage ahead

I recently decided to create some mapping visualisations. Mostly because using a map is an awesome way to present many data sets, and creating such visualisation is a skill I lacked.

So I looked around and found that D3.js has geographical features.
I had used D3.js in the past on projects like 100 companies, so I understood how to use it and could apply that knowledge to make visualisations with maps.

In this post I will go over a few examples of how to use D3's geographical API to create visualisation with maps.

Note: I also wrote a post about creating and editing maps for use by D3 here


Here is an example I edited from here.

View on bl.ocks

var width = 480, height = 250;

var projection = d3.geo.equirectangular()  

var path = d3.geo.path()  

var svg = d3.select("#js-map-nz-center").append("svg")  
    .attr("width", width)
    .attr("height", height);

    .data([topojson.object(worldtopo, worldtopo.objects.land)])
    .attr("class", "land")
    .attr("d", path);

The basic steps are:

  1. Create a projection function.
  2. Create a path function.
  3. Using a GEOJson object as the data, draw the map using the path function.

In my opinion understanding these three things (along with d3.js in general) is all you need to understand this library.

Projection function

The projection function takes a location [longitude, latitude] and returns a Cartesian coordinates [x,y] (in pixels).
The pros and cons of many projections are well explained by xkcd.

The other functions that were used are:

  1. scale is the linear scale to scale the map.
  2. rotation rotates the entire map.
  3. translate, moves the returned points.

Scale is the function that determines the scale transformation from a location (latitude and longitude) to point (x,y).

Here is an example that demonstrates its purpose.

View on bl.ocks

 currentScale = (currentScale + 1) % 350;
       .attr("d", path);

Rotate takes [longitude, latitude, roll] and moves the projection (roll is defaulted to 0 if none is given). To centre the map on a specific location then negative values are necessary, i.e. [- longitude, - latitude].

Example of rotation:

View on bl.ocks

 currentRotation += 1;
       .attr("d", path);

Translation moves each point that is drawn. This function makes no assumptions about the projection, and thus takes a point as argument.

View on bl.ocks

 currentX = (currentX + 1) % width;
       .attr("d", path);

Path function

The path function translates GEOJson features into svg path data.

GEOJson features

GEOJson is a JSON format for encoding geographic data structures (features). The worldtopo object (in the code above) is a compressed set of GEOJson objects. The compression is handled by the topojson library.

A GEOJson feature looks like

{type: "Point", coordinates: [-180,0]}

Some GEOJson features are:

  1. Point, a single point [longitude, latitude]
  2. MultiPoint, a list of points
  3. LineString, a list of points (they are meant to be connected)
  4. MultiLineString, a list of LineStrings
  5. Polygon, a list of LineStrings (they will be closed)
  6. MultiPolygon, a list of Polygons

All features can be handled by the path function.

An example I have created is an approximation of James Cook's first voyage.

View on bl.ocks

cook = {"type": "LineString", "coordinates": [[-4.1397, 50.3706], [-43.2436, -22.9083] ,  
[-67.2717, -55.9797] , [-149.4500, -17.6667], [172.1936, -41.4395] ,[151.1667, -34] ,
[147.70, -18.3] ,[106.7, -6], [18.4719, -34.3], [-5,-15], [-25.6, 37.7],[-4.1397, 50.3706]] }

..attr("d", path);

I can easily see the possibilities for such a format to be used in many projects.

[longitude, latitude] Gotcha

To find the longitude and latitude of any place in the world you can use Google.
The problem with this method is that the returned results are backwards to mathematical and programming convention. The first measurement is latitude then longitude, which is the y co-ordinate before the x.

Also, instead of using negative values they may use South, or West. For example, 30.1S, 20.2W will translate to [-20.2,-30.1].

These difficulties came about because of my lack of experience with geographic co-ordinate systems.

Auto Scaling Projection to a GEOJson feature

When using this library I found few utility functions available. One utility I would have found useful would be an auto scaling function for rendering maps of an appropriate scale for a particular GEOJson feature.

There is a bounding function d3.geo.bounds, however there is a gotcha with this function. On a sphere (the earth) given any two points returned from the bounding function, TWO squares can be calculated. The smaller square and that squares inverse. For example, if a person travelled the length of New Zealand, their bounding box would be the same as a person who travelled around the world from the top left point of NZ to the bottom right point. I found this out when plotting Cooks voyage above.

Another function provided is finding the centre of a feature. By finding the centre, and measuring the distance from one of the corners of the bounding box, the real box can be found and the scale calculated.

Once again another annoying gotcha. The distance between two points is not the same as on a plane. I found this algorithm (which assumes the earth is a sphere) that calculates the distance between points.

  calcDist: (p1,p2) ->
    #Haversine formula
    dLatRad = Math.abs(p1[1] - p2[1]) * Math.PI/180;
    dLonRad = Math.abs(p1[0] - p2[0]) * Math.PI/180;
    # Calculate origin in Radians
    lat1Rad = p1[1] * Math.PI/180;
    lon1Rad = p1[0] * Math.PI/180;
    # Calculate new point in Radians
    lat2Rad = p2[1] * Math.PI/180;
    lon2Rad = p2[0] * Math.PI/180;

    # Earth's Radius
    eR = 6371;
    d1 = Math.sin(dLatRad/2) * Math.sin(dLatRad/2) +
       Math.sin(dLonRad/2) * Math.sin(dLonRad/2) * Math.cos(lat1Rad) * Math.cos(lat2Rad);
    d2 = 2 * Math.atan2(Math.sqrt(d1), Math.sqrt(1-d1));
    return(eR * d2);

One final gotcha is that a point on a map can be zoomed infinity as it covers 0 area. Therefore it is important to ensure that you define limits on the zoom.

The final code:

    [x,y] = d3.geo.bounds(feature)[0]
    [xc,yc] = d3.geo.centroid(feature)
    distToCenterOfBbox = @calcDist([x, y],[xc,yc])

    minScale = 79
    maxScale = 300    
    scaleCalc = d3.scale.linear().range([maxScale,minScale]).domain([0,5000]).clamp(true)
    s = scaleCalc(distToCenterOfBbox)
    projection = d3.geo.equirectangular().scale(s)

This was hastily written, and is therefore not perfect code (e.g. the scaling function needs to take into account Pythagoras).

Over all impression

After using the geo functionality provided in d3.js I was able to get a mapping visualisation up and running. There was a significant amount of learning on my part to understand the co-ordinate system and GEOJson. However, once these hurdles were overcome I was able to quickly and easily create the visualisations that I wanted.

Learn More

Learn more from :
Data Visualization with D3.js Cookbook


Interactive Data Visualization for the Web

Future work

You may have noticed the efficiency is horrible in these examples. To increase efficiency a dynamic simplification algorithm is needed (like the one implemented here) with auto-scaling. With such an algorithm the precision of the projections can be based on the size and scale. An algorithm to simply paths, may be less expensive that rendering unnecessary path data.

comments powered by Disqus