This is my zoom handler for my map:
const zoom = d3.zoom()
.scaleExtent([1,25])
.translateExtent([[width * -0.5, height * -0.5], [width * 1.5,height*1.5]])
.on('zoom', (ev) => {
svg.selectAll('path').attr('transform', ev.transform);
})
It updates the paths in the svg using the transform params from the event. This works great, but if I use projection(point)
or similar methods to return the x,y coordinates of a point, then they will be incorrect.
I realise I need to update my projection to update the zoom/pan behaviour.
If I record the original map translation before any zooming, const origTrans = projection.translate();
and then apply the x,y transforms then I am able to correctly sync the projection for the top zoom level (ie k=1).
.on("end", (ev)=> {
projection.translate([origTrans[0] + ev.transform.x * ev.transform.k, origTrans[1] + ev.transform.y * ev.transform.k]);
const c = projection([-3.3632, 55]);
svg.append("circle")
.attr("cx", c[0])
.attr("cy", c[1])
.attr("r", 9)
.attr("fill", "red");
});
I'm unclear as how zoom level relates to the projection scale. I can't achieve the same thing
I've tried a few things e.g. - projection.scale(ev.transform.k)
, or projection.scale(projection.scale() * ev.transform.k)
- I'm assuming there's a lot more to it? If it helps I am using geoMercator for the projection.
Rereading your question closer, you may be complicating the problem. The projection's scale and translate can be entirely independent from the SVG's zoom state.
Referencing one from the other creates more problems than it's worth, partly because your number of dynamic coordinate systems increases, partly because you may need to do things like recalculate projected points continuously throughout drag events (depending on approach, which can be laggy).
My understanding of the problem is: your SVG paths rescale but you need to extract, interact, update, or plot specific points and/or non path elements on the SVG to reflect their new location.
Why not use the same approach for the circles/points/other elements as the paths? To do so I'd create a new g
to hold all zoomable elements, paths and otherwise, apply the zoom transform on that, this way the zoom itself takes care of all scaling for you:
let zoomG = svg.append('g');
zoom.on('zoom', (ev) => {
zoomG.attr('transform', ev.transform);
})
Any coordinates of children inside the zoomG will be represented using projected pixel values from the projection. The zoomG is then transformed as a whole according to the zoom.
For example, the below plots some paths and a circle (London) to start. Regardless of zoom state Singapore will be plotted correctly on click anywhere on the map (it'll disappear after a few seconds until clicking again), while the existing features will be panned and zoomed correctly.
var svg = d3.select("svg")
.attr("width", 500)
.attr("height", 500)
var projection = d3.geoMercator()
.scale(500 / 2 /Math.PI )
.translate([250,250])
let zoomG = svg.append('g');
let zoom = d3.zoom()
.on("zoom", (ev)=> zoomG.attr('transform', ev.transform))
svg.call(zoom);
svg.on("click", function(ev) {
let xy = d3.pointer(ev, zoomG.node());
let longlat = projection.invert(xy);
console.log("mouse click at: " + xy + " which represents: " + longlat);
zoomG.append("circle")
.attr("cx", projection([103.820,1.352])[0])
.attr("cy", projection([103.820,1.352])[1])
.attr("r", 4)
.transition()
.attr("r", 0)
.duration(2000)
.remove();
})
d3.json("https://raw.githubusercontent.com/holtzy/D3-graph-gallery/master/DATA/world.geojson").then( function(data){
zoomG.selectAll("path")
.data(data.features)
.enter().append("path")
.attr("fill", "#eee")
.attr("d", d3.geoPath()
.projection(projection)
)
.style("stroke", "#ccc")
zoomG.append("circle")
.attr("cx", projection([0.128,51.507])[0])
.attr("cy", projection([0.128,51.507])[1])
.attr("r", 4)
})
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
<svg></svg>
In the above I've also added a demonstration on how to calculate the geographic position of the mouse.
let xy = d3.pointer(ev, zoomG.node());
let longlat = projection.invert(xy);
console.log("mouse click at: " + xy + " which represents: " + longlat);
We don't need to worry about this when moving from projected coordinates to pixel coordinates as the nesting of the paths/circles/whatever in a parent G with the zoom transform takes care of this for us. But going the reverse direction, we need to consider the zoom transform in where the mouse actually is in projected coordinate space (which is where d3.pointer comes in).