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.