Creating an interactive map with D3.js, TopoJSON and JavaScript.

Creating an interactive map with D3.js, TopoJSON and JavaScript.

As in my previous front-end related posts I was focussing primarily on architectural frameworks, this time I'd like to write a bit about some experience that I've gained while working with data visualisation and geography specific libraries and how they play together with some JavaScript in order to render a simple interactive flight connection map in the browser. Note that the code described in this post is highly experimental. To begin with, below are the links to a map demo site and its code repo:

Flight connections map demo
Code repository

Looking at src folder in the project's repository, You will notice europe.json and index.html files. First one contains a map of Europe in TopoJSON format, which is an extension to GeoJSON - an open standard designed for representing geographical objects. Both of these formats, as their names suggest are based on JSON, with the main difference between them being the fact that TopoJSON objects are built from collections of shared line segments called arcs resulting in smaller files.

D3.js which is a data visualisation library used in this exercise, supports rendering GeoJSON encoded maps only, so I needed to perform the necessary conversion. Luckily, the TopoJSON-client project comes in with the handy API method feature which allowed me to do that on line 244 in file index.html. Below is the code snippet with an operation of converting map data and drawing it with D3.js API:

        var width = 800;
        var height = 800;
        var center = [4, 68.6];
        var scale = 700;
        projection = d3.geoMercator().scale(scale).translate([width / 2, 0]).center(center);
        path = d3.geoPath().projection(projection);
        svg = d3.select('#map').append('svg')
          .attr('height', height)
          .attr('width', width)
          .style('background', '#C1E1EC');
        countries = svg.append("g");
        d3.json('europe.json', function(data) {
          countries.selectAll('.country')
          .data(topojson.feature(data, data.objects.europe).features)
          .enter()
          .append('path')
          .attr('class', 'country')
          .attr('d', path)  
          return;
        });

Another key operation shown here is the usage of Mercator projection. Its responsibility is to transform spherical polygonal geometry - as it is embedded in GeoJSON, to a planar polygonal geometry, where the world is projected to a square clipped to approximately ±85° latitude.

I could also use any other projection offered by D3-geo package here to render my map differently. More about D3.js geographic projections is here. Anyways, let's get back to my Mercator projection. Transformed projection is referenced as a variable, so it can be fed to D3's path generator which is responsible for drawing SVG objects based on GeoJSON data provided.

The code block described above is the part of drawMap method implementation. I must admit that this name could be a bit confusing to some readers, as it's responsibility is not only to draw paths representing Europe's map but also a circular shape representing origin airport.

        var source = svg.selectAll('circleOrigin');
          source
          .data([originGeo]).enter()
          .append('circle')
          .attr('cx', function (d) { return projection(d)[0]; })
          .attr('cy', function (d) { return projection(d)[1]; })
          .attr('r', '3px')
          .style('opacity', 1)
          .attr('fill', 'green')
          .attr('class', 'circleOrigin')
          source
          .data([originGeo]).enter()
          .append('circle')
          .attr('cx', function (d) { return projection(d)[0]; })
          .attr('cy', function (d) { return projection(d)[1]; })
          .attr('r', '10px')
          .style('opacity', 0.05)
          .attr('fill', 'black')
          .attr('class', 'circleOrigin')
          .on('mouseover', function (d) {
            tooltip.html('<span style="color:white">' + originName + '</span>')
              .attr('class', 'tooltipOrigin')
              .style('left', projection(d)[0] + 12 + 'px')
              .style('top', projection(d)[1] - 20 + 'px')
              .transition()
              .duration(700)
              .style('opacity', 1)
          })
          .on('mouseout', function (d) {
            tooltip.transition()
              .duration(700)
              .style('opacity', 0)
          });

The code above draws a green dot representing an origin airport, based on its geographic coordinates declared on line 50:

var originGeo = [16.8286, 52.4200];  

Note that I needed to transform POZ Lawica Airport's coordinates to circle's cx and cy attributes which are basically dimensions in pixels and I used Mercator projection mentioned before in order to achieve that. Another thing to note here is the fact that dots get drawn twice. There is a simple reason behind it. Bigger dot serves the purpose of a mouse hit area, as I wanted to capture mouse over events on a bigger than 3 pixels radius in order to show tooltip with the airport code.

At this point, the basic map was ready, and I could start implementing the actual animations of connections based on destinations array (in the form of an array containing destination airport's Longitude and Latitude) declared on line 52 of index.html.

var destinations = [  
 {'coord': [20.9679, 52.1672], 'name': 'WAW'},
 {'coord': [23.9569, 49.8134], 'name': 'LWO'},
 {'coord': [30.4433, 50.4120], 'name': 'IEV'},
 {'coord': [13.3724, 55.5355], 'name': 'MMX'},
 {'coord': [12.6508, 55.6180], 'name': 'CPH'},
 {'coord': [16.9154, 58.7890], 'name': 'NYO'},
 {'coord': [10.2569, 59.1824], 'name': 'TRF'},
 {'coord': [9.1526, 55.7408], 'name': 'BLL'},
 {'coord': [8.5622, 50.0379], 'name': 'FRA'},
 {'coord': [11.7750, 48.3537], 'name': 'MUC'},
 {'coord': [5.3921, 51.4584], 'name': 'EIN'},
 {'coord': [2.1115, 49.4545], 'name': 'BVA'},
 {'coord': [-2.7135, 51.3836], 'name': 'BRS'},
 {'coord': [0.3717, 51.8763], 'name': 'LTN'},
 {'coord': [0.2389, 51.8860], 'name': 'STN'},
 {'coord': [-1.743507, 52.4524], 'name': 'BHX'},
 {'coord': [-2.8544, 53.3375], 'name': 'LPL'},
 {'coord': [-3.3615, 55.9508], 'name': 'EDI'},
 {'coord': [-1.010464, 53.480662], 'name': 'DSA'},
 {'coord': [-6.2499, 53.4264], 'name': 'DUB'},
 {'coord': [-0.560056, 38.285483], 'name': 'ALC'}, 
 {'coord': [0.065603, 40.207479], 'name': 'CDT'},
 {'coord': [-3.56795, 40.4839361], 'name': 'MAD'},
 {'coord': [2.071062, 41.288288], 'name': 'BCN'},
 {'coord': [2.766066, 41.898201], 'name': 'GRO'},
 {'coord': [14.483279, 35.854114], 'name': 'MLA'},     
 {'coord': [23.9484, 37.9356467], 'name': 'ATH'},   
 {'coord': [19.914486, 39.607645], 'name': 'CFU'},
 {'coord': [34.9362, 29.9511], 'name': 'VDA'},
 {'coord': [34.8854, 32.0055], 'name': 'TLV'}
];

Let's look at the the beginning of a function drawConnection(index) on line 110. Before starting the arc tween, I needed to perform a few computations based on the destination index passed to it. Code snippet below shows the usage of already known Mercator projection object in order to transform geographic coordinates to cartesian coordinates (X,Y) of origin and destination points. Those transformations are needed to construct connection data array used directly on a D3.js path generator employed to draw the connection arc on the SVG container.

var destination = this.destinations[index];  
var originPos = projection(this.originGeo);  
var destinationPos = projection(destination.coord);  
var connection = [ originPos, destinationPos ];  
var destinationName = destination.name;  
var originGeo = this.originGeo;  
var destinationGeo = destination.coord;  
var distance = calculateDistance(originGeo[1], originGeo[0], destinationGeo[1], destinationGeo[0]);  
var duration = calculateDuration(distance);  

Since the connection drawing is animated, I also needed to calculate the distance between origin and destination points with the following function on the line 99:

function calculateDistance(lat1, lon1, lat2, lon2) {  
 var p = 0.017453292519943295;
 var c = Math.cos;
 var a = 0.5 - c((lat2 - lat1) * p)/2 + c(lat1 * p) * c(lat2 * p) * (1 - c((lon2 - lon1) * p))/2;
 return 12742 * Math.asin(Math.sqrt(a));
}

The result of this computation is then used as an input to calculate duration of the animation as a function of distance between points and speed. The next step is the actual drawing with D3's path generator:

        var arc = svg
          .append('path')
          .datum(connection)
          .attr('class', 'arc' + index)
          .attr('d', function(coordinates) {
              var d = {
                  origin: { x: coordinates[0][0], y: coordinates[0][1]},
                  destination: { x: coordinates[1][0], y: coordinates[1][1]}
              };
              var s = false;
              if (d.destination.x > d.origin.x) {
                  s = true;
              }
              return getArc(d, s);
          }) 
          .style('stroke', 'steelblue')
          .style('stroke-width', 2)
          .style('fill', 'none')
          .transition()
          .duration(duration)
          .attrTween('stroke-dasharray', function() {
              var len = this.getTotalLength();
              return function(t) {
                return (d3.interpolate('0,' + len, len + ',0'))(t)
              };
          })

The connection arc has to be curved accordingly to a direction of flight, so the getArc method needs to take an extra s attribute which value equals true if destination point's X coordinate is greater than origin's X.

function getArc(d, s) {  
 var dx = d.destination.x - d.origin.x;
 var dy = d.destination.y - d.origin.y;
 var dr = Math.sqrt(dx * dx + dy * dy);
 var spath = s == false ? ' 0 0,0 ' : ' 0 0,1 ';
 return 'M' + d.origin.x + ',' + d.origin.y + 'A' + dr + ',' + dr + spath + d.destination.x + ',' + d.destination.y;
}

Last piece of this code is the animation end handler on line 147. This block is responsible for animating three dots representing destination airport: blue one, black one with opacity set to 0.05 and the third one being the blue with animated radius, which is also serving as a mouse hit area for the tooltip.

Although this was my first experience with D3.js's geographic capabilities, I am pretty sure I will be coming back to this again, so if You are interested in this too - bookmark this blog.

Comments

comments powered by Disqus