Search code examples
javascriptjsond3.jsgeojsonmap-projections

Plotting custom json maps with D3.js


I am creating a map with D3.js. I began by downloading the country (Canada) shapefile here: https://www.arcgis.com/home/item.html?id=dcbcdf86939548af81efbd2d732336db

..and converted it into a geojson here (link to file below): http://mapshaper.org/

So far all I see is a coloured block, without any errors on the console. My question is, how can I tell if my json file or my code is incorrect? Here is my code and on bottom is a link to json file.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>D3: Setting path fills</title>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.6/d3.min.js"></script>
        <!-- <script src="https://d3js.org/topojson.v1.min.js"></script> -->
        <style type="text/css">
        /* styles */       
        </style>
    </head>
    <body>
        <script type="text/javascript">
        var canvas = d3.select("body").append("svg")
        .attr("width", 760)
        .attr("height", 700)
        d3.json("canada.geo.json", function(data) {
        var group = canvas.selectAll("g")
        .data(data.features)
        .enter()
        .append("g")

        var projection = d3.geo.mercator();
        var path = d3.geo.path().projection(projection);
        var areas = group.append("path")
        .attr("d",path)
        .attr("class","area")
        })
        </script>
    </body>
</html>

Link to json file: https://github.com/returnOfTheYeti/CanadaJSON/blob/master/canada.geo.json


Solution

  • A d3 geoProjection uses unprojected coordinates - coordinates on a three dimensional globe. The geoProjection takes those coordinates and projects them onto a two dimensional plane. The units of unprojected coordinates are generally degrees longitude and latitude, and a d3 geoProjection expects this. The problem is that your data is already projected.

    How can I tell if the data is projected?

    There are two quick methods to determine if your data is projected:

    • look at the meta data of the data

    • look at the geographic coordinates themselves

    Look at the Geographic Metadata

    The projection your data uses is defined in the .prj file that forms part of the collection of files that makes up a shapefile:

    PROJCS["Canada_Albers_Equal_Area_Conic",
       GEOGCS["GCS_North_American_1983",
          DATUM["D_North_American_1983",
            SPHEROID["GRS_1980",6378137.0,298.257222101]],
          PRIMEM["Greenwich",0.0],
          UNIT["Degree",0.0174532925199433]],
      PROJECTION["Albers"],
      PARAMETER["False_Easting",0.0],
      PARAMETER["False_Northing",0.0],
      PARAMETER["Central_Meridian",-96.0],
      PARAMETER["Standard_Parallel_1",50.0],
      PARAMETER["Standard_Parallel_2",70.0],
      PARAMETER["Latitude_Of_Origin",40.0],
      UNIT["Meter",1.0]]
    

    Your data is already projected with an Albers projection, and the unit of measurement is the meter. Projecting this data as though it consists of lat/long pairs will not work.

    If you only have a geojson file and no reference shapefile, some geojson files will specify an EPSG number in a projection propery, if this number is something other than 4326 you probably have projected data.

    Look at the Coordinates

    You can tell your data doesn't have unprojected data because the values of each coordinate are outside the bounds of longitude and latitude (+/-180 degrees east/west, +/- 90 degrees north south):

    "coordinates":[[[[899144.944639163,2633537.
    

    Your coordinates translate around the globe several times: this is why your projection results in an svg is filled entirely with features.

    Ok, Now What?

    There are two primary solutions available for you:

    • Convert the projection so that the geojson consists of latitude and longitude points

    • Use d3.geoTransform or d3.geoIdentity to transform your projected data.

    Convert the Projection

    To do this you want to "unproject" your data, or alternatively, project it so that it consists of longitude, latitude points.

    Most GIS software offers the ability to reproject data. It's much easier with a shapefile than a geojson, as shapefiles are much more common in GIS software. GDAL, QGIS, ArcMap offer relatively easy conversion.

    There are also online converters, mapshaper.org is probably the easiest for this, and has added benefits when dealing with d3 - simplification (many shapefiles contain way too much detail for the purposes of web mapping). Drag all the files of the shapefile into the mapshaper window, open the console and type: proj wgs84. Export as geojson (after simplification), and you've got a geojson ready for d3.

    After reprojecting, you may notice that your data is awkward looking. Don't worry, it's unprojected (well, kind of unprojected, it's shown as 2d, but with a very simple projection that assumes Cartesian input data).

    With your unprojected data, you are now ready to project your data in d3.

    Here's an example with your data (d3-v4. data is simplified and reprojected on mapshaper (no affiliation to me))

    Using d3.geoIdentity or d3.geoTransform

    For this I would recommend using d3v4 (I see your code is v3). While geo.transform is available in v3, it is much more cumbersome without the new methods available in v4, namely: d3.geoIdentity and projection.fitSize. I will address the v4 method of using projected data here

    With your data you can define a different sort of projection:

    var projection = d3.geoIdentity();
    

    However, this type of "projection" will give you trouble if you aren't careful. It basically spits out the x,y values it is given. However, geographic projected coordinate spaces typically have [0,0] somewhere in the bottom left, and svg coordinates space has [0,0] in the top left. In svg coordinate space, y values increase as you go down the coordinate plane, in the projected coordinate space of your data, y values increase as you go up. Using an identity will therefore project your data upside down.

    Luckily we can use:

    var projection = d3.geoIdentity()
       .reflectY(true);
    

    One last problem remains: the coordinates in the geojson are not scaled or translated so that the features are properly centered. For this there is the fitSize method:

       var projection = d3.geoIdentity()
           .reflectY(true)
           .fitSize([width,height],geojsonObject)
    

    Here width and height are the width and height of the SVG (or parent container we want to display the feature in), and the geojsonObject is a geojson feature. Note it won't take an array of features, if you have an array of features, place them in a feature collection.

    Here's your data shown taking this approach (I still simplified the geojson).

    You can also use a geoTransform, this is a bit more complex, but allows you to specify your own transform equation. For most situations it is probably overkill, stick with geoIdentity.

    Pros and Cons of Each Option:

    Unprojecting the data:

    Beyond the initial leg work to unproject the data, by unprojecting the data so that it consists of longitude latitude pairs you have to do some extra processing each time you show the data.

    But, you also have a high degree of flexibility in how you show that data by accessing any d3 geoProjection. Using unprojected data also allows you to more easily align different layers: you don't have to worry about rescaling and transforming multiple layers individually.

    Keeping the Projected Data

    By keeping the projection the data comes in, you save on computing time by not having to do spherical math. The downsides are the upsides listed above, it's difficult to match data that doesn't share this projection (which is fine if you export everything using this projection), and your stuck with the representation - a d3.geoTransform doesn't offer much in the way of converting your projection from say a Mercator to an Albers.

    Note that that the fit.size() method I used for option two above is available for all geoProjections (v4).

    In the two examples, I used your code where possible. A couple caveats though, I changed to d3v4 (d3.geo.path -> d3.geoPath, d3.geo.mercator -> d3.geoMercator, for example). I also changed the name of your variable canvas to svg, since it is a selection of an svg and not a canvas. Lastly, in the first example I didn't modify your projection, and a mercator's center defaults to [0,0] (in long/lat), which explains the odd positioning