D3 V4 Properly placing a bubble in the US Map

2020-04-30 01:24发布

问题:

I am creating a US Map and I have a series of ACTUAL coordinates of some places in US. I would like to put a point or bubble on the right spot in the map. How do I scale/translate these?

This is what I get:

With what I have tried:

function USAPlot(divid, data) {

    var margin = { top: 20, right: 20, bottom: 30, left: 50 },
        width = 1040 - margin.left - margin.right,
        height = 700 - margin.top - margin.bottom;

    // formatting the data
    data.forEach(function (d) {
        d.loc = d.location;
        d.count = d.count;
        d.lat = d.latitude;
        d.lon = d.longitude;
    });

    var svg = d3.select(divid)
        .append("svg")
        .attr("width", width + margin.left + margin.right)
        .attr("height", height + margin.top + margin.bottom)
        ;
    var path = d3.geoPath();
    var projection = d3.geoMercator()       
        .scale(200)  
        .translate([margin.left + width / 2, margin.top + height / 2])

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

        svg.append("g")
            .attr("class", "states")
            .attr("fill-opacity", 0.4)
            .selectAll("path")
            .data(topojson.feature(us, us.objects.states).features)
            .enter().append("path")
            .attr("d", path);

        svg.append("path")
            .attr("class", "state-borders")
            .attr("d", path(topojson.mesh(us, us.objects.states, function (a, b) { return a !== b; })));
    });


    svg.selectAll("myCircles")
        .data(data)
        .enter()
        .append("circle")
        .attr("cx", function (d) { return projection([d.lon, d.lat])[0]; })
        .attr("cy", function (d) { return projection([d.lon, d.lat])[1]; })
        .attr("r", 14)  //first testing with fixed radius and then will scale acccording to count
        .style("fill", "69b3a2")
        .attr("stroke", "#69b3a2")
        .attr("stroke-width", 3)
        .attr("fill-opacity", 1);

}

I have no idea whether these bubbles are dropping at the actual place - which I am definitely looking for.

回答1:

As far as a testing method to see if features are alinging properly, try placing easy to identify landmarks, I use Seatle and Miami below - they're on opposite sides of the area of interest, and it should be easy to tell if they are in the wrong place (in the water or inland).

I'm not sure where they are supposed to fall as I do not have the coordinates but I can tell you they aren't where they are supposed to be.

The reason I can know this is because you are using two different projections for your data.

Mercator Projection

You define one of the projections and use it to position the dots:

var projection = d3.geoMercator()       
    .scale(200)  
    .translate([margin.left + width / 2, margin.top + height / 2])

This is a Mercator projection centred at [0°,0°] (by default). Here is the world projected with that projection (with the margin and same sized SVG):

D3 GᴇᴏMᴇʀᴄᴀᴛᴏʀ Wɪᴛʜ Cᴇɴᴛᴇʀ [0,0] ᴀɴᴅ Sᴄᴀʟᴇ 200

You are projecting coordinates for the circles based on this projection.

For reproducability, here's a snippet - you should view in full screen:

  var margin = { top: 20, right: 20, bottom: 30, left: 50 },
  width = 1040 - margin.left - margin.right,
  height = 700 - margin.top - margin.bottom;
		
d3.json("https://cdn.jsdelivr.net/npm/world-atlas@2/land-50m.json").then(function(json) {
		
  var svg = d3.select("body")
    .append("svg")
    .attr("width", width + margin.left + margin.right)
    .attr("height", height + margin.top + margin.bottom)		
	
  var projection = d3.geoMercator()       
    .scale(200)  
    .translate([margin.left + width / 2, margin.top + height / 2])

  var path = d3.geoPath().projection(projection);
		
  svg.append("g")
     .attr("class", "states")
     .attr("fill-opacity", 0.4)
     .selectAll("path")
     .data(topojson.feature(json, json.objects.land).features)
     .enter().append("path")
     .attr("d", path);
	})
		
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/topojson/3.0.2/topojson.js"></script>

A mystery projection

The second projection is not obvious. If you look at the snippet used to create the above image, you'll notice that it assigns the projection to the path:

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

This is so the path converts each geographic coordinate (a spherical latitude/longitude pair) to the correct coordinate on the screen (a Cartesian pixel x,y value): a coordinate of [-17°,85°] will be converted to something like [100px,50px].

In your question you simply use:

var path = d3.geoPath();

You don't assign a projection to the path - so d3.geoPath() simply plots every vertice/point in the geojson/topojson as though the coordinate contains pixel coordinates: a coordinate of [100px,50px] in the geojson/topojson is plotted on the SVG at x=100, y=50.

Despite not using a projection, your the US states plot as expected. Why? Because the geojson/topojson was already projected. Since it was preprojected, we don't need to use a projection when we plot it with D3.

Pre-projected geometry can be useful as it requires less calculations to draw, resulting in faster rendering speeds, but comes at a cost of less flexibility (see here).

If we overlay your pre-projected geometry with the geometry you project with d3.geoProjection, we get:

Naturally, you can see there is no point that is the same between the two. Consequently, you are not projecting points so that they properly overlay the pre-projected geometries.

Cᴏᴍᴘᴀʀɪsᴏɴ ʙᴇᴛᴡᴇᴇɴ ᴛʜᴇ ᴛᴡᴏ ᴘʀᴏᴊᴇᴄᴛɪᴏɴs

Snippet to reproduce:

var margin = { top: 20, right: 20, bottom: 30, left: 50 },
        width = 1040 - margin.left - margin.right,
        height = 700 - margin.top - margin.bottom;
		
   var svg = d3.select("body")
		  .append("svg")
          .attr("width", width + margin.left + margin.right)
          .attr("height", height + margin.top + margin.bottom)	

	d3.json("https://cdn.jsdelivr.net/npm/world-atlas@2/land-50m.json").then(function(json) {
		
        var projection = d3.geoMercator()       
          .scale(200)  
          .translate([margin.left + width / 2, margin.top + height / 2])

		var path = d3.geoPath().projection(projection);
		
		svg.append("g")
            .attr("fill-opacity", 0.4)
            .selectAll("path")
            .data(topojson.feature(json, json.objects.land).features)
            .enter().append("path")
            .attr("d", path);
	
	})
	
	d3.json("https://d3js.org/us-10m.v1.json").then(function(us) {

		var path = d3.geoPath();

        svg.append("g")
            .attr("fill-opacity", 0.4)
            .selectAll("path")
            .data(topojson.feature(us, us.objects.states).features)
            .enter().append("path")
            .attr("d", path);
		
	})
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/topojson/3.0.2/topojson.js"></script>

Unsatisfactory Solution

Without metadata explaining what projection and coordinate system a geojson/topojson uses, we generally cannot duplicate that projection to overlay other features.

In this case, however, if we look carefully at the plotted US states, we can see that an Albers projection was used to pre-project the state outlines.

Sometimes, we can guess the projection parameters. As I'm fairly familiar with this file (), I can tell you it uses the following parameters:

d3.geoAlbersUsa()
  .scale(d3.geoAlbersUsa().scale()*6/5)
  .translate([480,300]);

Here's an example showing Miami and Seattle overlain:

  var width = 960,height = 600;
		
var svg = d3.select("body")
	.append("svg")
  .attr("width",width)
  .attr("height",height);

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

  var path = d3.geoPath();
  var projection = d3.geoAlbersUsa()
    .scale(d3.geoAlbersUsa().scale()*6/5)
    .translate([width/2,height/2]);
    
  svg.append("g")
    .attr("fill-opacity", 0.4)
    .selectAll("path")
    .data(topojson.feature(us, us.objects.states).features)
    .enter().append("path")
    .attr("d", path);
    
    var places = [
      [-122.3367534,47.5996582],
      [-80.1942949,25.7645783]
    ]
    
    svg.selectAll(null)
      .data(places)
      .enter()
      .append("circle")
      .attr("r", 3)
      .attr("transform", function(d) {
        return "translate("+projection(d)+")";
      })
		
	})
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/topojson/3.0.2/topojson.js"></script>

But, this has a downside of being very obtuse in adopting for other screen sizes, translations, centers, scales, etc. Pre-projected geometry also generates a lot of confusion when combined with unprojected geometry. For example, this question shows a common frustration on sizing and centring pre-projected geometry properly.

Better Solution

A better solution is to use one projection for everything. Either pre-project everything first (which is a bit more complex), or project everything on the fly (it really doesn't take that long for a browser). This is just clearer and easier when modifying the visualization or the geographic data.

To project everything the same way, you'll need to make sure all your data is unprojected, that is to say it uses lat/long pairs for its coordinates / coordinate space. As your US json is pre-projected, we'll need to find another, perhaps:

  • https://bl.ocks.org/mbostock/raw/4090846/us.json

And we simply run everything through the projection:

Snippet won't load the resource, but here's a bl.ock, with the code shown below:

var width =960,height = 600;

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

d3.json("us.json").then(function(us) {

  var projection = d3.geoAlbersUsa()
    .scale(150)
    .translate([width/2,height/2]);
   var path = d3.geoPath().projection(projection);

  svg.append("g")
    .attr("fill-opacity", 0.4)
    .selectAll("path")
    .data(topojson.feature(us, us.objects.states).features)
    .enter().append("path")
    .attr("d", path);

    var places = [
      [-122.3367534,47.5996582],
      [-80.1942949,25.7645783]
    ]

    svg.selectAll(null)
      .data(places)
      .enter()
      .append("circle")
      .attr("r", 3)
      .attr("transform", function(d) {
        return "translate("+projection(d)+")";
      })

})