ReactJS D3 - How to zoom in D3 Geo World Map

Viewed 2079

I am working on a web app that uses ReactJS. I have a world map rendered using D3 in my React component. It has basic functionalities, such as adjusting the geoMercator().fitSize() whenever a country within the world map is clicked (hence 'zooming in'). However, I would like the user to be able to zoom in a custom amount by scrolling the mouse wheel, but I do not know how to do that. When googling online, I came across the d3 zoom function, but examples were all done by adjusting the scales, but I do not know how to apply that to the d3-geo, which does not seem to have scales, but only projections.

Here is my code thus far

function GeoChart() {

    const svgRef = useRef();
    const wrapperRef = useRef();
    const [dimensions, setDimensions] = useState(useResizeObserver(wrapperRef));
    const [selectedCountry, setSelectedCountry] = useState(null);

    useEffect(() => {
        const svg = select(svgRef.current);
        const { width, height } = wrapperRef.current.getBoundingClientRect();

        const minProp = leadCount ? min(leadCount, count => count.count) : null
        const maxProp = leadCount ? max(leadCount, count => count.count) : null
        const colorScale = scaleLinear()
            .domain([minProp, maxProp])
            .range(["#ccc", "red"]);

        // projects geo-coordinates on a 2D plane
        const projection = geoMercator()
            .fitSize([width, height], selectedCountry || data)
            .precision(100);

        // takes geojson data,
        // transforms that into the d attribute of a path element
        const pathGenerator = geoPath().projection(projection);

        // render each country
        svg
            .selectAll(".country")
            .data(data.features)
            .join("path")
            .on("click", feature => {
                console.log(selectedCountry)
                setSelectedCountry(selectedCountry === feature ? null : feature);
            })
            .attr("class", "country")
            .transition()
            .attr("fill", feature => findLeadCount(feature.properties.name))
            .attr("d", feature => pathGenerator(feature))

}, [data, dimensions, selectedCountry]);

return (
        <div ref={wrapperRef} style={{ marginBottom: "2rem" }}>
            <svg id="geo-chart" ref={svgRef}></svg>
        </div>
) }

export default GeoChart;

All help is appreciated, I am new to D3, hence there might be some stuff that I do not know how to do, do help to guide me in the correct direction, thanks!

1 Answers

I'm providing two generic D3 solutions; the adaptation for React should be fairly straightforward. By keeping it generic, the answer and the snippets are much cleaner, though the answer is still long winded...

Coordinates

When dealing with geographic projections and zooming we are dealing with several coordinate systems:

  • Geographic coordinates (3 dimensional coordinates on a globe, measured in degrees)
  • Projected coordinates (Cartesian data, in pixels units, the projection's result)
  • SVG/Canvas coordinates (Translation and scale applied to projected coordinates such as by a svg transform property)

It is possible to use an appropriate translate and scale with the projection function such that no SVG/Canvas transform is required. For example, if a map needs to be zoomed in further, doubling either the D3 projection scale or the SVG/Canvas scale has the same effect. Consequently, we could effectively eliminate consideration of the third coordinate system by ensuring the projection parameters are set such that no SVG/Canvas transform needs to be set.

If we want to avoid using a SVG/Canvas transform we'll be doing a semantic zoom: we'll redraw all the data based on an updated projection. This let's the projection do all the work.

If we want to manipulate the SVG/Canvas transform to implement the zoom we'll be doing a geometric zoom. This requires the projection only for the initial drawing of features and then uses a SVG/Canvas transform to move the already drawn features and size them as needed.

Both have different advantages and disadvantages. I won't speak to them here, but I'll show how both might be achieved.

Semantic Zooming

For your use case, this might be the easier solution to implement.

We can use d3.zoom() to track the current translate and scale:

  let zoom = d3.zoom()
    .on("zoom", function() {
        let t = d3.event.transform;                 // get current zoom state
        projection.scale(t.k).translate([t.x,t.y]); // set scale and translate of projection.
        features.attr("d", path)                    // redraw the features
    })

  svg.call(zoom);

The above will let the user interact with the features (via the projection) by panning or zooming with the mouse. D3.zoom keeps track of the cumulative zoom translate and scale. Instead of using its values set something like an SVG transform, we'll use it to set projection parameters and then redraw all our features with the updated projection.

However, we need to set initial values for the zoom. The zoom's initial scale is 1, which for a d3.geoMercator projection will make a ~6x6 pixel world. We can set an initial zoom state with selection.call(zoom.transform, transform):

 // Set up an initial projection translate and scale.
 svg.call(zoom.transform, d3.zoomIdentity.translate(width/2,height/2).scale(width/Math.PI/2));

The translate represents the center of the viewport. The scale, for a D3 Mercator projection, is generally how many pixels one radian of longitude is spread over. The default center of a Mercator is [0,0], so the above centers the map and scales it to the viewport.

zoom.transform triggers a zoom event, and, importantly, updates the zoom state so that projection and zoom are in alignment.

The trickiest part is using fitSize() - this modifies the projection, but doesn't modify the zoom state. However, fitSize() only modifies a projection's translate and scale, not it's center. So we can simply extract the current scale and translate, and programmatically trigger a zoom event with this data:

 function centerOnFeature(feature) {
    projection.fitSize([width,height],feature);
    var k = projection.scale();
    var t = projection.translate();
    svg.call(zoom.transform, d3.zoomIdentity.translate(...t).scale(k));
  }

Note this does set projection parameters twice: once with fitSize and once in the zoom event function, this is avoidable, but the performance hit should be non-existant, it is the drawing of features that is demanding.

Here are those three code blocks at work (with slight tweaks on the last one):

var width = 480;
var height = 480;

var svg = d3.select("svg");
var projection = d3.geoMercator();
var path = d3.geoPath(projection);

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

  // Draw the world.
  let countries = topojson.feature(world, world.objects.countries).features;
  let features =  svg.selectAll("path")
    .data(countries)
    .enter()
    .append("path")

  // Let the zoom take care of modifying the projection:
  let zoom = d3.zoom()
    .on("zoom", function() {
        let t = d3.event.transform;  
        projection.scale(t.k).translate([t.x,t.y]);
        features.attr("d", path)
    })

  svg.call(zoom);
    
    
   
  // Set up an initial projection translate and scale.
  svg.call(zoom.transform, d3.zoomIdentity.translate(width/2,height/2).scale(width/Math.PI/2));
    
  // Let us click on a country:
  features.on("click", function(d) {
        projection.fitSize([width,height],d);
        var k = projection.scale();
        var t = projection.translate();
        svg.call(zoom.transform, d3.zoomIdentity.translate(...t).scale(k));
      })
  
  // Some buttons to programatically set the translate and scale:  
  d3.select("div")
      .selectAll(null)
      .data([{label:"Angola",id:"024"},{label:"New Zealand",id:"554"}])
      .enter()
      .append("button")
      .text(function(d) { return d.label; })
      .on("click", function(d) {
        projection.fitSize([width,height],getCountrybyID(d.id,countries));
        var k = projection.scale();
        var t = projection.translate();
        svg.call(zoom.transform, d3.zoomIdentity.translate(...t).scale(k));
      })

});

// Helper function:
function getCountrybyID(id,countries) {
  for(var i = 0; i < countries.length; i++) {
    if(id == countries[i].id) return countries[i];
  }
}
path {
  stroke: #ccc;
  stroke-width: 1px;
  fill: #333;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<script src="https://d3js.org/topojson.v2.min.js"></script>
<div>.</div>
<svg width="480" height="480"></svg>

Geometric Zooming

This can cause more confusion as it is easy to mix both semantic and geometric, but should be as easy to implement.

The projection will be used once: to draw the features initially. The use of fitSize() to zoom to certain features will modify the translate and scale of the projection. Since we are using an SVG/Canvas transform to appropriately scale and center the map we cannot use this modified projection to draw new features: it would result in applying the scale and translate twice. A solution in this scenario would be to have a duplicate scale that remains unmolested by fitSize().

Again, let's use d3.zoom() to track the current translate and scale, but this time use it to modify the transform on the SVG:

  let zoom = d3.zoom()
    .on("zoom", function() {
        g.attr("transform", d3.event.transform);    // apply the current zoom to a parent holding our features
    })

   svg.call(zoom);

Again, the above will let the user interact with the features by panning or zooming with the mouse.

We don't need to set an initial zoom state, we just draw the features we want after setting the initial projection parameters:

let projection = d3.geoMercator()
    .translate([width/2,height/2])
    .scale(width/Math.PI/2);

let path = d3.geoPath(projection);

features.attr("d", path);

And, to zoom to a specific country or feature programmatically we can use:

function centerOnFeature(feature) {
    projection.fitSize([width,height],feature);
    let k = projection.scale() / t0.k; // relative to initial scale.
    let x = projection.translate()[0] - t0.x * k; // relative to initial scale.
    let y = projection.translate()[1] - t0.y * k; // relative to initial scale.
    svg.call(zoom.transform, d3.zoomIdentity.translate(x,y).scale(k));
}

Here I'm using t0 to track the initial projection translate and scale. We want to know the relative change in the coordinate values from the initial projection, we then take that relative change and apply it to the zoom with selection.call(zoom.transform,....

Here are those three code blocks at work (with slight tweaks - such as accounting for stroke width on zoom):

var width = 480;
var height = 480;

var t0 = {k:width/2/Math.PI,x:width/2,y:height/2};

var svg = d3.select("svg");
var g = svg.append("g");
var projection = d3.geoMercator().translate([t0.x,t0.y]).scale(t0.k);
var path = d3.geoPath(projection);

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

  // Draw the world.
  let countries = topojson.feature(world, world.objects.countries).features;
  
  let features =  g.selectAll("path")
    .data(countries)
    .enter()
    .append("path")
    .attr("d", path)
    .style("stroke-width",1);

  // Let the zoom take care of modifying the projection:
  let zoom = d3.zoom()
     .on("zoom", function() {
       g.attr("transform", d3.event.transform);    // apply current transform to a parent holding our features
       features.style("stroke-width", 1/d3.event.transform.k); // update stroke width.
    })

  svg.call(zoom);

    
  // Let us click on a country:
  features.on("click", function(d) {
        projection.fitSize([width,height],d);
        let k = projection.scale() / t0.k; // relative to initial scale.
        let x = projection.translate()[0] - t0.x * k; // relative to initial scale.
        let y = projection.translate()[1] - t0.y * k; // relative to initial scale.
        svg.call(zoom.transform, d3.zoomIdentity.translate(x,y).scale(k));
      })

  // Some buttons to programatically set the translate and scale:  
  d3.select("div")
      .selectAll(null)
      .data([{label:"Angola",id:"024"},{label:"New Zealand",id:"554"}])
      .enter()
      .append("button")
      .text(function(d) { return d.label; })
      .on("click", function(d) {
        projection.fitSize([width,height],getCountrybyID(d.id,countries));
        let k = projection.scale() / t0.k; // relative to initial scale.
        let x = projection.translate()[0] - t0.x * k; // relative to initial scale.
        let y = projection.translate()[1] - t0.y * k; // relative to initial scale.
        svg.call(zoom.transform, d3.zoomIdentity.translate(x,y).scale(k));
      })

});

// Helper function:
function getCountrybyID(id,countries) {
  for(var i = 0; i < countries.length; i++) {
    if(id == countries[i].id) return countries[i];
  }
}
path {
  stroke: #ccc;
  fill: #333;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<script src="https://d3js.org/topojson.v2.min.js"></script>
<div>.</div>
<svg width="480" height="480"></svg>

Mixed Approach

Best to avoid this.

Related