javascriptd3.jssvggeojsontopojson

Scaling d3 v4 map to fit SVG (or at all)


I am trying to make this map of the us scale smaller. Either to my SVG, or even manually.

This is my code in its simplest from:

function initializeMapDifferent(){
    var svg = d3.select("#map").append("svg")
        .attr("width", 1000)
        .attr("height", 500);



    d3.json("https://d3js.org/us-10m.v1.json", function (error, us){

        svg.append("g")
            .attr("class", "states")
            .selectAll("path")
            .data(topojson.feature(us, us.objects.states).features)
            .enter().append("path")
            .attr("fill", "gray")
            .attr("d", d3.geoPath());
    });
}

I have tried something like:

  var path = d3.geoPath()
  .projection(d3.geoConicConformal()
      .parallels([33, 45])
      .rotate([96, -39])
      .fitSize([width, height], conus));

but every time I add anything to my path variable I get NAN errors from the internal parts of D3. Thanks for any help!


Solution

  • Why the data doesn't project properly

    The key issue is that your data is already projected. D3 geoProjections use data that is unprojected, or in lat long pairs. Data in the WGS84 datum. Essentially a d3 geoProjection takes spherical coordinates and translates them into planar cartesian x,y coordinates.

    Your data does not conform to this - it is already planar. You can see most evidently because Alaska is not where it should be (unless someone changed the lat long pairs of Alaska, which is unlikely). Other signs and symptoms of already projected data may be a feature that covers the entire planet, and NaN errors.

    That this is a composite projection makes it hard to unproject, but you can display already projected data in d3.js.

    "Projecting" already projected data

    Null Projection:

    Most simply, you can define your projection as null:

    var path = d3.geoPath(null);
    

    This will take the x,y data from the geojson geometries and display it as x,y data. However, if your x,y coordinates exceed the width and height of your svg, the map will not be contained within your svg (as you found in your example with .attr("d", d3.geoPath());).

    The particular file in this question is pre-projected to fit a 960x600 map, so this is ideal for a null projection - it was designed with the dimensions in mind. Its units are pixels and all coordinates fall within the desired dimensions. However, most projected geometries use coordinate systems with units such as meters, so that the bounding box of the feature's coordinates may be millions of units across. In these cases the null projection won't work - it'll convert a map unit value to a pixel value with no scaling.

    With d3, A null projection is commonly used with geojson/topojson that is preprojected to fit a specified viewport using a d3 projection. See command line cartography for an example (the example uses unprojected source files - the same issues that arise from using a d3 projection on projected data apply in both browser and command line). The primary advantage of preprojecting a file for use with a null projection is performance.

    geoIdentity

    If all you need is to scale and center the features, you can use a geoIdentity. This is implements a geoTransform but with standard projection methods such as scale, translate, and most importantly - fitSize/fitExtent. So, we can set the projection to a geoIdentity:

    var projection = d3.geoIdentity();
    

    This currently does the same as the null projection used above, it takes x,y data from the geojson geometries and displays it as x,y data with no transform - treating each coordinate in the geojson as a pixel coordinate. But, we can apply fitSize to this (or fitExtent) which will automatically scale and translate the data into the specified bounding box:

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

    or

    var projection = d3.geoIdentity()
      .fitExtent([[left,top],[right,bottom]], geojsonObject);
    

    Keep in mind, most projected data uses geographic conventions, y=0 is at the bottom, with y values increasing as one moves north. In svg/canvas coordinate space, y=0 is at the top, with y values increasing as one moves down. So, we will often need to flip the y axis:

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

    This particular dataset: https://d3js.org/us-10m.v1.json was projected with a d3 projection, so its y axis has already been flipped as d3 projections project to a svg or canvas coordinate space.

    geoIdentity Demo

    var width = 600;
    var height = 300;
    
    var svg = d3.select("body").append("svg")
     .attr("width", width)
     .attr("height", height);
    
    
    
    d3.json("https://d3js.org/us-10m.v1.json", function (error, us){
      var featureCollection = topojson.feature(us, us.objects.states);
      
      var projection = d3.geoIdentity()
      .fitExtent([[50,50],[600-50,300-50]], featureCollection)
    
      var path = d3.geoPath().projection(projection)
      
      svg.append("g")
        .attr("class", "states")
        .selectAll("path")
        .data(featureCollection.features)
        .enter().append("path")
        .attr("fill", "gray")
        .attr("d", path);
      
    });
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.6.0/d3.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/topojson/2.2.0/topojson.js"></script>

    geoTransform

    If you want a little more control over how that data is displayed you can use a geoTransform.

    From Mike Bostock:

    But what if your geometry is already planar? That is, what if you just want to take projected geometry, but still translate or scale it to fit the viewport?

    You can implement a custom geometry transform to gain complete control over the projection process.

    To use a geoTransform is relatively straightforward assuming that you do not want to change the type of projection. For example, if you want to scale the data you could implement a short function for scaling with geoTransform:

    function scale (scaleFactor) {
        return d3.geoTransform({
            point: function(x, y) {
                this.stream.point(x * scaleFactor, y  * scaleFactor);
            }
        });
    }
    
    var path = d3.geoPath().projection(scale(0.2));
    

    Though, this will scale everything into the top left corner as you zoom out. To keep things centered, you could add some code to center the projection:

    function scale (scaleFactor,width,height) {
        return d3.geoTransform({
            point: function(x, y) {
                this.stream.point( (x - width/2) * scaleFactor + width/2 , (y - height/2) * scaleFactor + height/2);
            }
        });
        }
    
    var path = d3.geoPath().projection(scale(0.2,width,height))
    

    geoTransform Demo:

    Here is an example using your file and a geoTransform:

    var width = 600;
    var height = 300;
    
    var svg = d3.select("body").append("svg")
     .attr("width", width)
     .attr("height", height);
    
    
    function scale (scaleFactor,width,height) {
      return d3.geoTransform({
        point: function(x, y) {
          this.stream.point( (x - width/2) * scaleFactor + width/2 , (y - height/2) * scaleFactor + height/2);
        }
      });
    }
      
    d3.json("https://d3js.org/us-10m.v1.json", function (error, us){
      var path = d3.geoPath().projection(scale(0.2,width,height))
     
      svg.append("g")
        .attr("class", "states")
        .selectAll("path")
        .data(topojson.feature(us, us.objects.states).features)
        .enter().append("path")
        .attr("fill", "gray")
        .attr("d", path);
      
    });
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.6.0/d3.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/topojson/2.2.0/topojson.js"></script>

    Unproject the data

    This method is useful under certain circumstances. But it requires you to know the projection that was used to create your data. Using QGIS/ArcGIS or even mapshaper you can change the data's projection so that it is "projected" as WGS84 (aka EPSG 4326). Once converted you have unprojected data.

    In Mapshaper this is pretty easy with shapefiles, drag in the .dbf, .shp, and .prj files of a shapefile into the window. Open the console in mapshaper and type proj wgs84.

    If you don't know the projection used to create the data, you can't unproject it - you don't know what transformation was applied and with what parameters.

    Once unprojected, you can use regular d3 projections as normal as you have coordinates in the correct coordinate space: longitude latitude pairs.

    Unprojecting is useful if you also have unprojected data and want to mix both in the same map. Alternatively you could project the unprojected data so that both use the same coordinate system. Combining unmatched coordinate systems in a map with d3 is not easy and d3 is likely not the correct vehicle for this. If you really want to replicate a specific projection with d3 to match features that are already projected with unprojected features, then this question may be useful.

    How can you tell if your data is projected already?

    You could do check to see that the geometry of your features respect the limits of latitude and longitude. For example, if you were to log:

    d3.json("https://d3js.org/us-10m.v1.json", function (error, us){
       console.log(topojson.feature(us, us.objects.states).features);
    });
    

    You will quickly see that values are in excess of +/- 90 degrees N/S and +/- 180 degrees E/W. Unlikely to be lat long pairs.

    Alternatively, you could import your data to an online service such as mapshaper.org and compare against another topojson/geojson that you know is unprojected (or 'projected' using WGS84).

    If dealing with geojson, you may be lucky enough to see a property that defines the projection, such as: "name": "urn:ogc:def:crs:OGC:1.3:CRS84" (CRS stands for coordinate reference system) or an EPSG number: EPSG:4326 (EPSG stands for European Petroleum Survey Group).

    Also, if your data projects with a null projection but not a standard projection (scaled/zoomed out to ensure you aren't looking in the wrong area), you might be dealing with projected data. Likewise if your viewport is entirely covered by one feature (and you aren't zoomed in). NaN coordinates are also a potential indicator. However, these last indicators of projected data can also mean other problems.

    Lastly, the data source may also indicate data is already projected either in meta data or how it is used: Looking at this block, we can see that no projection was used when the geoPath is defined.