Center a map in d3 given a geoJSON object
Asked Answered
T

11

146

Currently in d3 if you have a geoJSON object that you are going to draw you have to scale it and translate it in order to get it to the size that one wants and translate it in order to center it. This is a very tedious task of trial and error, and I was wondering if anyone knew a better way to obtain these values?

So for instance if I have this code

var path, vis, xy;
xy = d3.geo.mercator().scale(8500).translate([0, -1200]);

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

vis = d3.select("#vis").append("svg:svg").attr("width", 960).attr("height", 600);

d3.json("../../data/ireland2.geojson", function(json) {
  return vis.append("svg:g")
    .attr("class", "tracts")
    .selectAll("path")
    .data(json.features).enter()
    .append("svg:path")
    .attr("d", path)
    .attr("fill", "#85C3C0")
    .attr("stroke", "#222");
});

How the hell do I obtain .scale(8500) and .translate([0, -1200]) without going little by little?

Tsuda answered 24/1, 2013 at 1:11 Comment(1)
Se also D3.geo : Frame a map given a geojson object?Prudential
C
139

The following seems to do approximately what you want. The scaling seems to be ok. When applying it to my map there is a small offset. This small offset is probably caused because I use the translate command to center the map, while I should probably use the center command.

  1. Create a projection and d3.geo.path
  2. Calculate the bounds of the current projection
  3. Use these bounds to calculate the scale and translation
  4. Recreate the projection

In code:

  var width  = 300;
  var height = 400;

  var vis = d3.select("#vis").append("svg")
      .attr("width", width).attr("height", height)

  d3.json("nld.json", function(json) {
      // create a first guess for the projection
      var center = d3.geo.centroid(json)
      var scale  = 150;
      var offset = [width/2, height/2];
      var projection = d3.geo.mercator().scale(scale).center(center)
          .translate(offset);

      // create the path
      var path = d3.geo.path().projection(projection);

      // using the path determine the bounds of the current map and use 
      // these to determine better values for the scale and translation
      var bounds  = path.bounds(json);
      var hscale  = scale*width  / (bounds[1][0] - bounds[0][0]);
      var vscale  = scale*height / (bounds[1][1] - bounds[0][1]);
      var scale   = (hscale < vscale) ? hscale : vscale;
      var offset  = [width - (bounds[0][0] + bounds[1][0])/2,
                        height - (bounds[0][1] + bounds[1][1])/2];

      // new projection
      projection = d3.geo.mercator().center(center)
        .scale(scale).translate(offset);
      path = path.projection(projection);

      // add a rectangle to see the bound of the svg
      vis.append("rect").attr('width', width).attr('height', height)
        .style('stroke', 'black').style('fill', 'none');

      vis.selectAll("path").data(json.features).enter().append("path")
        .attr("d", path)
        .style("fill", "red")
        .style("stroke-width", "1")
        .style("stroke", "black")
    });
Coquet answered 1/2, 2013 at 21:2 Comment(5)
Hey Jan van der Laan I never thanked you for this response. This is a really good response to by the way if I could split out the bounty I would. Thank for it!Tsuda
If I apply this I get bounds = infinity. Any idea on how this can be solved?Tote
@SimkeNys This might the be the same problem as mentioned here #23953866 Try the solution mentioned there.Coquet
Hi Jan, thank you for your code. I tried your example with some GeoJson data but it didn't worked. Can you tell me what I'm doing wrong? :) I uploaded the GeoJson data: onedrive.live.com/…Electroplate
In D3 v4 projection fitting is a built-in method: projection.fitSize([width, height], geojson) (API docs) - see @dnltsk 's answer below.Girand
S
181

My answer is close to Jan van der Laan’s, but you can simplify things slightly because you don’t need to compute the geographic centroid; you only need the bounding box. And, by using an unscaled, untranslated unit projection, you can simplify the math.

The important part of the code is this:

// Create a unit projection.
var projection = d3.geo.albers()
    .scale(1)
    .translate([0, 0]);

// Create a path generator.
var path = d3.geo.path()
    .projection(projection);

// Compute the bounds of a feature of interest, then derive scale & translate.
var b = path.bounds(state),
    s = .95 / Math.max((b[1][0] - b[0][0]) / width, (b[1][1] - b[0][1]) / height),
    t = [(width - s * (b[1][0] + b[0][0])) / 2, (height - s * (b[1][1] + b[0][1])) / 2];

// Update the projection to use computed scale & translate.
projection
    .scale(s)
    .translate(t);

After comping the feature’s bounding box in the unit projection, you can compute the appropriate scale by comparing the aspect ratio of the bounding box (b[1][0] - b[0][0] and b[1][1] - b[0][1]) to the aspect ratio of the canvas (width and height). In this case, I’ve also scaled the bounding box to 95% of the canvas, rather than 100%, so there’s a little extra room on the edges for strokes and surrounding features or padding.

Then you can compute the translate using the center of the bounding box ((b[1][0] + b[0][0]) / 2 and (b[1][1] + b[0][1]) / 2) and the center of the canvas (width / 2 and height / 2). Note that since the bounding box is in the unit projection’s coordinates, it must be multiplied by the scale (s).

For example, bl.ocks.org/4707858:

project to bounding box

There’s a related question where which is how to zoom to a specific feature in a collection without adjusting the projection, i.e., combining the projection with a geometric transform to zoom in and out. That uses the same principles as above, but the math is slightly different because the geometric transform (the SVG "transform" attribute) is combined with the geographic projection.

For example, bl.ocks.org/4699541:

zoom to bounding box

Sixtieth answered 4/2, 2013 at 17:11 Comment(13)
I want to point out that there are a few errors in the above code, specifically in the indices of the bounds. It should look like: s = (0.95 / Math.max((b[1][0] - b[0][0]) / width, (b[1][1] - b[0][0]) / height)) * 500, t = [(width - s * (b[1][0] + b[0][0])) / 2, (height - s * (b[1][1] + b[0][1])) / 2];Receptor
@Receptor - Looks like the * 500 is extraneous here... also, b[1][1] - b[0][0] should be b[1][1] - b[0][1] in the scale calculation.Defrost
Seems like there is a bug in the markdown parser that messes with the b[x][y]. The array of arrays notation [x][y] matches the syntax for links/images, probably Mikes answer was wright but the parser messed with it when a link/image was added.Khamsin
Weird, seems like a bug with Stack Overflow. I’ve fixed those errors, but I can’t guarantee they won’t reappear…Sixtieth
Bug report: meta.stackexchange.com/questions/184140/…Khamsin
This helped a lot. One note: You're calculating the width and height of the bounding box in the unit projection as north minus south and east minus west, respectively. This assumes you're in the US's quadrant of the globe. To make this work for any shape anywhere in the world, you need to take the absolute value, otherwise the height or width or both can be negative which throws off the max() calculation.Antihalation
So: b.s = b[0][1]; b.n = b[1][1]; b.w = b[0][0]; b.e = b[1][0]; b.height = Math.abs(b.n - b.s); b.width = Math.abs(b.e - b.w); s = .9 / Math.max(b.width / width, (b.height / height));Antihalation
It is because of a community like this that D3 is such a joy to work with. Awesome!Chimp
@Sixtieth The comment by Herb Caudill is the correct answer. I tested your code on the bl.ocks.org example and if lets say your bounds are the UK map and you set the width to a lot smaller than the height then the height still works out correctly but the aspect ratio doesn't get taken into consideration. This is what I mean : i.imgur.com/UYEGSyY.png However, with Herb's algorithm : i.imgur.com/XMhP4Sf.pngTumescent
Best support ever.Afflictive
This is the superior answer, as it allows you to use a pre-rotated and/or centered projection by only affecting translate() and scale().Girand
@Sixtieth Is it possible, to get the calculated data from d3 without having it to draw? I would just need the complete translated, scaled and calculated coordinates (the one, which will be drawn using svg path element).Capitate
What is the "state"?Anacardiaceous
C
139

The following seems to do approximately what you want. The scaling seems to be ok. When applying it to my map there is a small offset. This small offset is probably caused because I use the translate command to center the map, while I should probably use the center command.

  1. Create a projection and d3.geo.path
  2. Calculate the bounds of the current projection
  3. Use these bounds to calculate the scale and translation
  4. Recreate the projection

In code:

  var width  = 300;
  var height = 400;

  var vis = d3.select("#vis").append("svg")
      .attr("width", width).attr("height", height)

  d3.json("nld.json", function(json) {
      // create a first guess for the projection
      var center = d3.geo.centroid(json)
      var scale  = 150;
      var offset = [width/2, height/2];
      var projection = d3.geo.mercator().scale(scale).center(center)
          .translate(offset);

      // create the path
      var path = d3.geo.path().projection(projection);

      // using the path determine the bounds of the current map and use 
      // these to determine better values for the scale and translation
      var bounds  = path.bounds(json);
      var hscale  = scale*width  / (bounds[1][0] - bounds[0][0]);
      var vscale  = scale*height / (bounds[1][1] - bounds[0][1]);
      var scale   = (hscale < vscale) ? hscale : vscale;
      var offset  = [width - (bounds[0][0] + bounds[1][0])/2,
                        height - (bounds[0][1] + bounds[1][1])/2];

      // new projection
      projection = d3.geo.mercator().center(center)
        .scale(scale).translate(offset);
      path = path.projection(projection);

      // add a rectangle to see the bound of the svg
      vis.append("rect").attr('width', width).attr('height', height)
        .style('stroke', 'black').style('fill', 'none');

      vis.selectAll("path").data(json.features).enter().append("path")
        .attr("d", path)
        .style("fill", "red")
        .style("stroke-width", "1")
        .style("stroke", "black")
    });
Coquet answered 1/2, 2013 at 21:2 Comment(5)
Hey Jan van der Laan I never thanked you for this response. This is a really good response to by the way if I could split out the bounty I would. Thank for it!Tsuda
If I apply this I get bounds = infinity. Any idea on how this can be solved?Tote
@SimkeNys This might the be the same problem as mentioned here #23953866 Try the solution mentioned there.Coquet
Hi Jan, thank you for your code. I tried your example with some GeoJson data but it didn't worked. Can you tell me what I'm doing wrong? :) I uploaded the GeoJson data: onedrive.live.com/…Electroplate
In D3 v4 projection fitting is a built-in method: projection.fitSize([width, height], geojson) (API docs) - see @dnltsk 's answer below.Girand
C
75

With d3 v4 or v5 its getting way easier!

var projection = d3.geoMercator().fitSize([width, height], geojson);
var path = d3.geoPath().projection(projection);

and finally

g.selectAll('path')
  .data(geojson.features)
  .enter()
  .append('path')
  .attr('d', path)
  .style("fill", "red")
  .style("stroke-width", "1")
  .style("stroke", "black");

Enjoy, Cheers

Comely answered 2/12, 2016 at 20:2 Comment(9)
I hope this answer gets voted up more. Been working with d3v4 for a while and just discovered this method.Abell
Where does g come from? Is that the svg container?Wymore
Tschallacka g should be <g></g> tagSola
Shame this is so far down and after 2 quality answers. It's easy to miss this and it's obviously way simpler than the other answers.Gaiter
Thank you. Works in v5 too!Uella
Actually it doesnt work for v5. I had an alternate v4.5 version which the code as linking to. :/Uella
Definitely the best way to do it for now.Vapor
I am back here for the third time! many thanks! If I could upvote your answer one more time.Ryder
Perfect! Glad I scrolled down a bit, this is the easiest answerOccultism
K
55

I'm new to d3 - will try to explain how I understand it but I'm not sure I got everything right.

The secret is knowing that some methods will operate on the cartographic space (latitude,longitude) and others on the cartesian space (x,y on the screen). The cartographic space (our planet) is (almost) spherical, the cartesian space (screen) is flat - in order to map one over the other you need an algorithm, which is called projection. This space is too short to deep into the fascinating subject of projections and how they distort geographic features in order to turn spherical into plane; some are designed to conserve angles, others conserve distances and so on - there is always a compromise (Mike Bostock has a huge collection of examples).

enter image description here

In d3, the projection object has a center property/setter, given in map units:

projection.center([location])

If center is specified, sets the projection’s center to the specified location, a two-element array of longitude and latitude in degrees and returns the projection. If center is not specified, returns the current center which defaults to ⟨0°,0°⟩.

There is also the translation, given in pixels - where the projection center stands relative to the canvas:

projection.translate([point])

If point is specified, sets the projection’s translation offset to the specified two-element array [x, y] and returns the projection. If point is not specified, returns the current translation offset which defaults to [480, 250]. The translation offset determines the pixel coordinates of the projection’s center. The default translation offset places ⟨0°,0°⟩ at the center of a 960×500 area.

When I want to center a feature in the canvas, I like to set the projection center to the center of the feature bounding box - this works for me when using mercator (WGS 84, used in google maps) for my country (Brazil), never tested using other projections and hemispheres. You may have to make adjustments for other situations, but if you nail these basic principles you will be fine.

For example, given a projection and path:

var projection = d3.geo.mercator()
    .scale(1);

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

The bounds method from path returns the bounding box in pixels. Use it to find the correct scale, comparing the size in pixels with the size in map units (0.95 gives you a 5% margin over the best fit for width or height). Basic geometry here, calculating the rectangle width/height given diagonally opposed corners:

var b = path.bounds(feature),
    s = 0.9 / Math.max(
                   (b[1][0] - b[0][0]) / width, 
                   (b[1][1] - b[0][1]) / height
               );
projection.scale(s); 

enter image description here

Use the d3.geo.bounds method to find the bounding box in map units:

b = d3.geo.bounds(feature);

Set the center of the projection to the center of the bounding box:

projection.center([(b[1][0]+b[0][0])/2, (b[1][1]+b[0][1])/2]);

Use the translate method to move the center of the map to the center of the canvas:

projection.translate([width/2, height/2]);

By now you should have the feature in the center of the map zoomed with a 5% margin.

Khamsin answered 12/6, 2013 at 13:54 Comment(4)
Is there a bl.ocks somewhere ?Prudential
Sorry, no bl.ocks or gist, what are you trying to do? Is it something like a click-to-zoom? Publish it and I can take a look at your code.Khamsin
Bostock's answer and images provides links to bl.ocks.org examples which let me to copy engineer a whole code. Job done. +1 and thanks for your great illustrations!Prudential
What do I do when b = d3.geo.bounds(feature) returns [Infinity, Infinity], [-Infinity, -Infinity] ? Why does it return "infinity"?Disquietude
T
4

There is a center() method you can use that accepts a lat/lon pair.

From what I understand, translate() is only used for literally moving the pixels of the map. I am not sure how to determine what scale is.

Telemark answered 29/1, 2013 at 21:31 Comment(1)
If you are using TopoJSON and want to center the whole map, you can run topojson with --bbox to include a bbox attribute in the JSON object. The lat/lon coordinates for the center should be [(b[0]+b[2])/2, (b[1]+b[3])/2] (where b is the bbox value).Khamsin
T
3

In addition to Center a map in d3 given a geoJSON object, note that you may prefer fitExtent() over fitSize() if you want to specify a padding around the bounds of your object. fitSize() automatically sets this padding to 0.

Teens answered 1/4, 2018 at 12:7 Comment(0)
H
2

I was looking around on the Internet for a fuss-free way to center my map, and got inspired by Jan van der Laan and mbostock's answer. Here's an easier way using jQuery if you are using a container for the svg. I created a border of 95% for padding/borders etc.

var width = $("#container").width() * 0.95,
    height = $("#container").width() * 0.95 / 1.9 //using height() doesn't work since there's nothing inside

var projection = d3.geo.mercator().translate([width / 2, height / 2]).scale(width);
var path = d3.geo.path().projection(projection);

var svg = d3.select("#container").append("svg").attr("width", width).attr("height", height);

If you looking for exact scaling, this answer won't work for you. But if like me, you wish to display a map that centralizes in a container, this should be enough. I was trying to display the mercator map and found that this method was useful in centralizing my map, and I could easily cut off the Antarctic portion since I didn't need it.

Hypozeuxis answered 10/6, 2013 at 8:24 Comment(0)
L
1

To pan/zoom the map you should look at overlaying the SVG on Leaflet. That will be a lot easier than transforming the SVG. See this example http://bost.ocks.org/mike/leaflet/ and then How to change the map center in leaflet

Leprechaun answered 31/1, 2013 at 22:25 Comment(2)
If adding another dependence is of concern, PAN and ZOOM can be done easily in pure d3, see #17094114Khamsin
This answer doesn't really deal with d3. You can pan/zoom the map in d3 also, leaflet is not necessary. (Just realised this an old post, was just browsing the answers)Tumescent
O
0

With mbostocks' answer, and Herb Caudill's comment, I started running into issues with Alaska since I was using a mercator projection. I should note that for my own purposes, I am trying to project and center US States. I found that I had to marry the two answers with Jan van der Laan answer with following exception for polygons that overlap hemispheres (polygons that end up with a absolute value for East - West that is greater than 1):

  1. set up a simple projection in mercator:

    projection = d3.geo.mercator().scale(1).translate([0,0]);

  2. create the path:

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

3.set up my bounds:

var bounds = path.bounds(topoJson),
  dx = Math.abs(bounds[1][0] - bounds[0][0]),
  dy = Math.abs(bounds[1][1] - bounds[0][1]),
  x = (bounds[1][0] + bounds[0][0]),
  y = (bounds[1][1] + bounds[0][1]);

4.Add exception for Alaska and states that overlap the hemispheres:

if(dx > 1){
var center = d3.geo.centroid(topojson.feature(json, json.objects[topoObj]));
scale = height / dy * 0.85;
console.log(scale);
projection = projection
    .scale(scale)
    .center(center)
    .translate([ width/2, height/2]);
}else{
scale = 0.85 / Math.max( dx / width, dy / height );
offset = [ (width - scale * x)/2 , (height - scale * y)/2];

// new projection
projection = projection                     
    .scale(scale)
    .translate(offset);
}

I hope this helps.

Odious answered 3/11, 2015 at 15:36 Comment(0)
O
0

For people who want to adjust verticaly et horizontaly, here is the solution :

  var width  = 300;
  var height = 400;

  var vis = d3.select("#vis").append("svg")
      .attr("width", width).attr("height", height)

  d3.json("nld.json", function(json) {
      // create a first guess for the projection
      var center = d3.geo.centroid(json)
      var scale  = 150;
      var offset = [width/2, height/2];
      var projection = d3.geo.mercator().scale(scale).center(center)
          .translate(offset);

      // create the path
      var path = d3.geo.path().projection(projection);

      // using the path determine the bounds of the current map and use 
      // these to determine better values for the scale and translation
      var bounds  = path.bounds(json);
      var hscale  = scale*width  / (bounds[1][0] - bounds[0][0]);
      var vscale  = scale*height / (bounds[1][1] - bounds[0][1]);
      var scale   = (hscale < vscale) ? hscale : vscale;
      var offset  = [width - (bounds[0][0] + bounds[1][0])/2,
                        height - (bounds[0][1] + bounds[1][1])/2];

      // new projection
      projection = d3.geo.mercator().center(center)
        .scale(scale).translate(offset);
      path = path.projection(projection);

      // adjust projection
      var bounds  = path.bounds(json);
      offset[0] = offset[0] + (width - bounds[1][0] - bounds[0][0]) / 2;
      offset[1] = offset[1] + (height - bounds[1][1] - bounds[0][1]) / 2;

      projection = d3.geo.mercator().center(center)
        .scale(scale).translate(offset);
      path = path.projection(projection);

      // add a rectangle to see the bound of the svg
      vis.append("rect").attr('width', width).attr('height', height)
        .style('stroke', 'black').style('fill', 'none');

      vis.selectAll("path").data(json.features).enter().append("path")
        .attr("d", path)
        .style("fill", "red")
        .style("stroke-width", "1")
        .style("stroke", "black")
    });
Occiput answered 13/2, 2016 at 12:1 Comment(0)
F
0

How I centered a Topojson, where I needed to pull out the feature:

      var projection = d3.geo.albersUsa();

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


      var tracts = topojson.feature(mapdata, mapdata.objects.tx_counties);

      projection
          .scale(1)
          .translate([0, 0]);

      var b = path.bounds(tracts),
          s = .95 / Math.max((b[1][0] - b[0][0]) / width, (b[1][1] - b[0][1]) / height),
          t = [(width - s * (b[1][0] + b[0][0])) / 2, (height - s * (b[1][1] + b[0][1])) / 2];

      projection
          .scale(s)
          .translate(t);

        svg.append("path")
            .datum(topojson.feature(mapdata, mapdata.objects.tx_counties))
            .attr("d", path)
Filicide answered 31/3, 2016 at 21:18 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.