d3 world map with country click and zoom almost working not quite
Asked Answered
I

1

3

I am working on a world map that features a click to zoom feature. When clicking a country the map zooms in but the country is not always centered -- the same happens when you click out and repeat, it never seems to deliver the same result.

Note: If you disable the transition function, the zoom and centering does work, only when rotation is added it displays incorrectly.

What is wrong with my code?

I created a plunker for convenience http://plnkr.co/edit/tgIHG76bM3cbBLktjTX0?p=preview

<!DOCTYPE html>
<meta charset="utf-8">
<style>

.background {
  fill: none;
  pointer-events: all;
  stroke:grey;
}

.feature, {
  fill: #ccc;
  cursor: pointer;
}

.feature.active {
  fill: orange;
}

.mesh,.land {
  fill: black;
  stroke: #ddd;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.water {
  fill: #00248F;
}
</style>
<body>
<script src="//d3js.org/d3.v3.min.js"></script>
<script src="//d3js.org/topojson.v1.min.js"></script>
<script src="//d3js.org/queue.v1.min.js"></script>
<script>

var width = 960,
    height = 600,
    active = d3.select(null);

var projection = d3.geo.orthographic()
    .scale(250)
    .translate([width / 2, height / 2])
    .clipAngle(90);

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

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

svg.append("rect")
    .attr("class", "background")
    .attr("width", width)
    .attr("height", height)
    .on("click", reset);

var g = svg.append("g")
    .style("stroke-width", "1.5px");

var countries;
var countryIDs;

 queue()
  .defer(d3.json, "js/world-110m.json")
  .defer(d3.tsv, "js/world-110m-country-names.tsv")
  .await(ready)

function ready(error, world, countryData) {
  if (error) throw error;

  countries = topojson.feature(world, world.objects.countries).features;
  countryIDs = countryData;

    //Adding water
    g.append("path")
      .datum({type: "Sphere"})
      .attr("class", "water")
      .attr("d", path);

    var world = g.selectAll("path.land")
    .data(countries)
    .enter().append("path")
    .attr("class", "land")
    .attr("d", path)
    .on("click", clicked)

};

function clicked(d) {
  if (active.node() === this) return reset();
  active.classed("active", false);
  active = d3.select(this).classed("active", true);

  var bounds = path.bounds(d),
      dx = bounds[1][0] - bounds[0][0],
      dy = bounds[1][1] - bounds[0][1],
      x = (bounds[0][0] + bounds[1][0]) / 2,
      y = (bounds[0][1] + bounds[1][1]) / 2,
      scale = 0.5 / Math.max(dx / width, dy / height),
      translate = [width / 2 - scale * x, height / 2 - scale * y];

  g.transition()
      .duration(750)
      .style("stroke-width", 1.5 / scale + "px")
      .attr("transform", "translate(" + translate + ")scale(" + scale + ")");

  var countryCode;

  for (i=0;i<countryIDs.length;i++) {
    if(countryIDs[i].id==d.id) {
      countryCode = countryIDs[i];
    }
  }


  var rotate = projection.rotate();
  var focusedCountry = country(countries, countryCode);
  var p = d3.geo.centroid(focusedCountry);


  (function transition() {
    d3.transition()
    .duration(2500)
    .tween("rotate", function() {
      var r = d3.interpolate(projection.rotate(), [-p[0], -p[1]]);

      return function(t) {
        projection.rotate(r(t));
        g.selectAll("path").attr("d", path)
        //.classed("focused", function(d, i) { return d.id == focusedCountry.id ? focused = d : false; });
      };
    })
    })();

    function country(cnt, sel) {
      for(var i = 0, l = cnt.length; i < l; i++) {
        console.log(sel.id)
        if(cnt[i].id == sel.id) {
          return cnt[i];
        }
      }
    };
}

function reset() {
  active.classed("active", false);
  active = d3.select(null);

  g.transition()
      .duration(750)
      .style("stroke-width", "1.5px")
      .attr("transform", "");
}

</script>
Iveson answered 9/8, 2017 at 15:53 Comment(0)
L
4

This is a difficult question - I was surprised to see that there are not good examples of this (and the issue may have been raised previously without resolution). Based on the problem and what you are trying to achieve, I think you are overly complicating your transitions (and the tween functionality can be made clearer, perhaps). Instead of using both a transform on the g and a modification of the projection, you can achieve this with just a modification of the projection.

Current Approach

Currently you pan and zoom the g, this pans and zooms the g to the intended destination. After the click, the g is positioned so that the feature is in the middle and then scaled to showcase the feature. Consequently, the g is no longer centered in the svg (as it has been scaled and translated), in other words, the globe is moved and stretched so that the feature is centered. No paths are altered.

At this point, you rotate the projection, which recalculates the paths based on the new rotation. This moves the selected features to the center of the g, which is no longer centered within the svg - as the feature was already centered within the svg, any movement will decenter it. For example, if you remove the code that rescales and translates the g, you'll notice your feature is centered on click.

Potential solution

You appear to be after two transformations:

  1. rotation
  2. scale

Panning(/translating) is not something you probably want to do here, as this moves the globe when you simply want to rotate it.

Rotation can only be done with a d3 projection and scale can be done with either manipulation to the g or within the d3 projection. Therefore, it is probably simpler to just use a d3 projection to handle your map transformations.

Also, an issue with the current approach is that by using path.bounds to get a bbox, to derive both scale and translate, you are calculating values which may change as the projection is updated (the type of projection will vary the variance too). For example, if only a portion of a feature is rendered (because it is partly over the horizon), the bounding box will be different than it should, this will cause problems in scaling and translating. To overcome this limitation in my proposed solution, rotate the globe first, calculate the bounds, and scale to that factor. You can calculate the scale without actually updating the rotation of the paths on the globe, just update path and transition the drawn paths later.

Solution Implementation

I've modified your code slightly, and I think it is cleaner ultimately, to implement the code:

I store the current rotation and scale (so we can transition from this to the new values) here:

 // Store the current rotation and scale:
  var currentRotate = projection.rotate();
  var currentScale = projection.scale();

Using your variable p to get the feature centroid we are zooming to, I figure out the bounding box of the feature with the applied rotation (but I don't actually rotate the map yet). With the bbox, I get the scale needed to zoom to the selected feature:

  projection.rotate([-p[0], -p[1]]);
  path.projection(projection);

  // calculate the scale and translate required:
  var b = path.bounds(d);
  var nextScale = currentScale * 1 / Math.max((b[1][0] - b[0][0]) / (width/2), (b[1][1] - b[0][1]) / (height/2));
  var nextRotate = projection.rotate(); // as projection has already been updated.

For more information on the calculation of the parameters here, see this answer.

Then I tween between the current scale and rotation and the target (next) scale and rotation:

  // Update the map:
  d3.selectAll("path")
   .transition()
   .attrTween("d", function(d) {
      var r = d3.interpolate(currentRotate, nextRotate);
      var s = d3.interpolate(currentScale, nextScale);
        return function(t) {
          projection
            .rotate(r(t))
            .scale(s(t));
          path.projection(projection);
          return path(d);
        }
   })
   .duration(1000);

Now we are transitioning both properties simultaneously:

Plunker

Not only that, since we are redrawing the paths only, we don't need to modify the stroke to account for scaling the g.

Other refinements

You can get the centroid of the country/feature with just this:

  // Clicked on feature:
  var p = d3.geo.centroid(d);

Updated Plunker or Bl.ock

You can also toy with the easing - rather than just using a linear interpolation - such as in this plunker or bl.ock. This might help with keeping features in view during the transition.

Alternative Implementation

If you really want to keep the zoom as a manipulation of the g, rather than the projection, then you can achieve this, but the zoom has to be after the rotation - as the feature will then be centered in the g which will be centered in the svg. See this plunker. You could calculate the bbox prior to the rotation, but then the zoom will temporarily move the globe off center if making both transitions simultaneously (rotation and scale).

Why do I need to use tweening functions to rotate and scale?

Because portions of the paths are hidden, the actual paths can gain or loose points, completely appear or disappear. The transition to its final state might not represent the transition as one rotates beyond the horizon of the globe (in fact it surely won't), a plain transition of paths like this can cause artifacts, see this plunker for a visual demonstration using a modification of your code. To address this, we use the tween method .attrTween.

Since the .attrTween method is setting the transition from one path to another, we need to scale at the same time. We cannot use:

path.transition()
  .attrTween("d", function()...) // set rotation
  .attr("d", path) // set scale

Scaling SVG vs Scaling Projection

Many cylindrical projections can be panned and zoomed by manipulating the paths/svg directly, without updating the projection. As this doesn't recalculate the paths with a geoPath, it should be less demanding.

This is not a luxury afforded by the orthographic or conical projections, depending on the circumstances involved. Since you are recalculating the paths anyways when updating the rotation, an update of the scale likely won't lead to extra delay - the geographic path generator needs to re-calculate and re-draw the paths considering both scale and rotation anyways.

Luxuriance answered 10/8, 2017 at 4:8 Comment(2)
Nice answer. I looked at this for a while this morning and couldn't find a good solution.Ec
Thanks, I saw this earlier as well. It stayed in the back of my mind all day - turned out a bit more difficult for me than I anticipated at first, but that made it all the more interesting of challenge. Always nice to see another northerner too.Luxuriance

© 2022 - 2024 — McMap. All rights reserved.