Monday, 3 March 2014

Leaflet.js map with d3.js objects that scale with the map

The following post is a portion of the D3 Tips and Tricks book which is free to download. To use this post in context, consider it with the others in the blog or just download the the book as a pdf / epub or mobi .
----------------------------------------------------------

Leaflet map with d3.js objects that scale with the map

The first example we’ll look at will project a leaflet.js map on the screen with a d3.js object (in this case a simple rectangle) onto the map.
The rectangle will be bound to a set of geographic coordinates so that as the map is panned and zoomed the rectangle will shrink and grow. For example the following diagram shows a rectangle (made with d3.js ) superimposed over a leaflet.js map;
Rectangular d3 area on leaflet map
If we then zoom in…
Zoomed rectangular d3 area on leaflet map
…the rectangle zooms in as well.
This may not sound terribly exciting and if you’re familiar with Leaflet you will know that it is possible to draw polygons onto a map using only leaflet’s built in functions. However, the real strength of this application of vector data comes when making the d3.js content interactive which is more difficult with leaflet.js.
For an excellent example of this please visit Mike Bostock’s tutorial where he demonstrates superimposing a map of the United States separated by state (which react individually to the mouse being hovered over them). My following explanation is a humble derivation of his code.
Speaking of code, here is a full listing of the code that we will be using;
<!DOCTYPE html>
<html>
<head>
    <title>Leaflet and D3 Map</title>
    <meta charset="utf-8" />
    <link 
        rel="stylesheet" 
        href="http://cdn.leafletjs.com/leaflet-0.7/leaflet.css"
    />
    
</head>
<body>

    <div id="map" style="width: 600px; height: 400px"></div>

 <script src="http://d3js.org/d3.v3.min.js"></script>

    <script
        src="http://cdn.leafletjs.com/leaflet-0.7/leaflet.js">
    </script>

    <script>
 
        var map = L.map('map').setView([-41.2858, 174.7868], 13);
        mapLink = 
            '<a href="http://openstreetmap.org">OpenStreetMap</a>';
        L.tileLayer(
            'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
            attribution: '&copy; ' + mapLink + ' Contributors',
            maxZoom: 18,
            }).addTo(map);

  // Add an SVG element to Leaflet’s overlay pane
  var svg = d3.select(map.getPanes().overlayPane).append("svg"),
   g = svg.append("g").attr("class", "leaflet-zoom-hide");
   
  d3.json("rectangle.json", function(geoShape) {
  
  //  create a d3.geo.path to convert GeoJSON to SVG
  var transform = d3.geo.transform({point: projectPoint}),
            path = d3.geo.path().projection(transform);
 
  // create path elements for each of the features
  d3_features = g.selectAll("path")
   .data(geoShape.features)
   .enter().append("path");

  map.on("viewreset", reset);

  reset();

  // fit the SVG element to leaflet's map layer
  function reset() {
        
   bounds = path.bounds(geoShape);

   var topLeft = bounds[0],
    bottomRight = bounds[1];

   svg .attr("width", bottomRight[0] - topLeft[0])
    .attr("height", bottomRight[1] - topLeft[1])
    .style("left", topLeft[0] + "px")
    .style("top", topLeft[1] + "px");

   g .attr("transform", "translate(" + -topLeft[0] + "," 
                                     + -topLeft[1] + ")");

   // initialize the path data 
   d3_features.attr("d", path)
    .style("fill-opacity", 0.7)
    .attr('fill','blue');
  } 

  // Use Leaflet to implement a D3 geometric transformation.
  function projectPoint(x, y) {
   var point = map.latLngToLayerPoint(new L.LatLng(y, x));
   this.stream.point(point.x, point.y);
  }

 })
        
    </script>
</body>
</html>
There is also an associated json data file (called rectangle.json) that has the following contents;
{
"type": "FeatureCollection",
"features": [ { 
 "type": "Feature", 
 "geometry": { 
  "type": "Polygon", 
  "coordinates": [ [ 
  [ 174.78, -41.29 ], 
  [ 174.79, -41.29 ], 
  [ 174.79, -41.28 ], 
  [ 174.78, -41.28 ], 
  [ 174.78, -41.29 ] 
  ] ] 
  }
 } 
]
}
The full code and a live example are available online at bl.ocks.org or GitHub. They are also available as the files ‘leaflet-d3-combined.html’ and ‘rectangle.json’ as a separate download with D3 Tips and Tricks. A a copy of all the files that appear in the book can be downloaded (in a zip file) when you download the book from Leanpub
While I will explain the code below, please be aware that I will gloss over some of the simpler sections that are covered in other sections of either books and will instead focus on the portions that are important to understand the combination of d3 and leaflet.
Our code begins by setting up the html document in a fairly standard way.
<!DOCTYPE html>
<html>
<head>
    <title>Leaflet and D3 Map</title>
    <meta charset="utf-8" />
    <link 
        rel="stylesheet" 
        href="http://cdn.leafletjs.com/leaflet-0.7/leaflet.css"
    />
    
</head>
<body>

    <div id="map" style="width: 600px; height: 400px"></div>

 <script src="http://d3js.org/d3.v3.min.js"></script>

    <script
        src="http://cdn.leafletjs.com/leaflet-0.7/leaflet.js">
    </script>
Here we’re getting some css styling and loading our leaflet.js / d3.js libraries. The only configuration item is where we set up the size of the map (in the <style> section and as part of the map div).
Then we break into the JavaScript code. The first thing we do is to project our Leaflet map;
var map = L.map('map').setView([-41.2858, 174.7868], 13);
mapLink = 
 '<a href="http://openstreetmap.org">OpenStreetMap</a>';
L.tileLayer(
 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
 attribution: '&copy; ' + mapLink + ' Contributors',
 maxZoom: 18,
 }).addTo(map);
This is exactly the same as we have done in any of the simple map explanations in Leaflet Tips and Tricks and in this case we are using the OpenStreetMap tiles.
Then we start on the d3.js part of the code.
The first part of that involves making sure that Leaflet and D3 are synchronised in the view that they’re projecting. This synchronisation needs to occur in zooming and panning so we add an SVG element to Leaflet’soverlayPlane
var svg = d3.select(map.getPanes().overlayPane).append("svg"),
 g = svg.append("g").attr("class", "leaflet-zoom-hide");
Then we add a g element that ensures that the SVG element and the Leaflet layer have the same common point of reference. Otherwise when they zoomed and panned it could be offset. The leaflet-zoom-hide affects the presentation of the map when zooming. Without it the underlying map zooms to a new size, but the d3.js elements remain as they are until the zoom effect has taken place and then they adjust. It still works fine, but it ‘looks’ wrong.
Then we load our data file with the line…
d3.json("rectangle.json", function(geoShape) {
This is pretty standard fare for d3.js but it’s worth being mindful that while the type of data file is .json this is a GeoJSON file and they have particular features (literally) that allow them to do their magic. There is a good explanation of how they are structured at geojson.org for those who are unfamiliar with the differences.
Using our data we need to ensure that it is correctly transformed from our latitude/longitude coordinates as supplied to coordinates on the screen. We do this by implementing d3’s geographic transformation features (d3.geo).
var transform = d3.geo.transform({point: projectPoint}),
 path = d3.geo.path().projection(transform);
Here the path that we want to create in SVG is generated from the points that are supplied from the data file which are converted by the function projectPoint This function (which is placed at the end of the file) takes our latitude and longitudes and transforms them to screen (layer) coordinates.
function projectPoint(x, y) {
 var point = map.latLngToLayerPoint(new L.LatLng(y, x));
 this.stream.point(point.x, point.y);
}
With the transformations now all taken care of we can generate our path in the traditional d3.js way and append it to our g group.
d3_features = g.selectAll("path")
 .data(geoShape.features)
 .enter().append("path");
The last ‘main’ part of our JavaScript makes sure that when our view of what we’re looking at changes (we zoom or pan) that our d3 elements change as well;
 map.on("viewreset", reset);

 reset();
Obviously when our view changes we call the function reset. It’s the job of the reset function to ensure that whatever the leaflet layer does, the SVG (d3.js) layer follows;
function reset() {

 bounds = path.bounds(geoShape);

 var topLeft = bounds[0],
  bottomRight = bounds[1];

 svg .attr("width", bottomRight[0] - topLeft[0])
  .attr("height", bottomRight[1] - topLeft[1])
  .style("left", topLeft[0] + "px")
  .style("top", topLeft[1] + "px");

 g .attr("transform", "translate(" + -topLeft[0] + "," 
           + -topLeft[1] + ")");

 // initialize the path data 
 d3_features.attr("d", path)
  .style("fill-opacity", 0.7)
  .attr('fill','blue');
} 
It does this by establishing the topLeft and bottomRightcorners of the desired area and then it applies thewidthheighttop and bottom attributes to the svg element and translates the g element to the right spot. Last, but not least it redraws the path.
The end result being a fine combination of leaflet.js map and d3.js element;
Rectangular d3 area on leaflet map

The description above (and heaps of other stuff) is in the D3 Tips and Tricks book that can be downloaded for free (or donate if you really want to :-)).

2 comments:

  1. This is really helpful! Thanks so much for writing.

    ReplyDelete
  2. Thanks so much for this info. It saved me a ton of time!!!

    ReplyDelete