D3.js Geo fun

WARNING: Heavy CPU Usage ahead.

I recently decided to create some mapping visualisations. Mostly because mapping is required by many visualisations, and creating such visualisation is a skill I lacked.

I initially looked at Kartograph which has a python back-end to generate maps, and a CoffeeScript library for client side visualisation. This sounded great because it involved two of my favourite languages. However, to generate the maps it required more GIS knowledge than I was willing to invest.

I then started to look at D3.js new features in version 3. I had used D3.js in the past on projects like 100 companies, so I understood the philosophy of the API.

Examples

Here is an example I edited from here.

...
var width = 480, height = 250;

var projection = d3.geo.equirectangular()
    .scale(75)
    .translate([width/2,height/2])
    .rotate([-180,0]);

var path = d3.geo.path()
    .projection(projection);

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

svg.selectAll(".graticule")
    .data([topojson.object(worldtopo, worldtopo.objects.land)])
    .enter()
    .append("path")
    .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. 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

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.

setInterval(function(){
currentScale = (currentScale + 1) % 350
 projection.scale(currentScale);
  svg.selectAll(".land")
      .attr("d", path);
},100);
Rotation

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:

setInterval(function(){
currentRotation += 1
 projection.rotate([currentRotation,0]);
  svg.selectAll(".land")
      .attr("d", path);
},100);
Translate

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

setInterval(function(){
currentX = (currentX + 1) % width
 projection.translate([currentX,height/2]);
  svg.selectAll(".land")
      .attr("d", path);
},100);

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.

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]] }

svg.selectAll(".geojson").data([cook])
.enter()
.append("path")
.attr("class","geojson")
..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.

Future work

Using the Raphaël I have started to write an animation library. This library enables the calculation of subpaths, so that GEOJson features can be split up and animated. This work is only preliminary and I would like any suggestions on the direction it should go.

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. comments powered by Disqus