Skip to content

Instantly share code, notes, and snippets.

@patricksurry
Forked from mbostock/.block
Last active October 12, 2017 15:28
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save patricksurry/6621971 to your computer and use it in GitHub Desktop.
Save patricksurry/6621971 to your computer and use it in GitHub Desktop.
Rolling pan and zoom with Mercator projection

Example illustrating zoom and pan with a "rolling" Mercator projection. Drag left-right to rotate projection cylinder, and up-down to translate, clamped by max absolute latitude. Ensures projection always fits properly in viewbox.

<!DOCTYPE html>
<meta charset="utf-8">
<style>
svg {
background-color: lavender;
border: 1px solid black;
}
path {
fill: oldlace;
stroke: #666;
stroke-width: .5px;
}
</style>
<body>
<script src="http://d3js.org/d3.v3.min.js"></script>
<script src="http://d3js.org/topojson.v1.min.js"></script>
<script>
var width = 600,
height = 400,
rotate = 60, // so that [-60, 0] becomes initial center of projection
maxlat = 83; // clip northern and southern poles (infinite in mercator)
var projection = d3.geo.mercator()
.rotate([rotate,0])
.scale(1) // we'll scale up to match viewport shortly.
.translate([width/2, height/2]);
// find the top left and bottom right of current projection
function mercatorBounds(projection, maxlat) {
var yaw = projection.rotate()[0],
xymax = projection([-yaw+180-1e-6,-maxlat]),
xymin = projection([-yaw-180+1e-6, maxlat]);
return [xymin,xymax];
}
// set up the scale extent and initial scale for the projection
var b = mercatorBounds(projection, maxlat),
s = width/(b[1][0]-b[0][0]),
scaleExtent = [s, 10*s];
projection
.scale(scaleExtent[0]);
var zoom = d3.behavior.zoom()
.scaleExtent(scaleExtent)
.scale(projection.scale())
.translate([0,0]) // not linked directly to projection
.on("zoom", redraw);
var path = d3.geo.path()
.projection(projection);
var svg = d3.selectAll('body')
.append('svg')
.attr('width',width)
.attr('height',height)
.call(zoom);
d3.json("world-50m.json", function ready(error, world) {
svg.selectAll('path')
.data(topojson.feature(world, world.objects.countries).features)
.enter().append('path')
redraw(); // update path data
});
// track last translation and scale event we processed
var tlast = [0,0],
slast = null;
function redraw() {
if (d3.event) {
var scale = d3.event.scale,
t = d3.event.translate;
// if scaling changes, ignore translation (otherwise touch zooms are weird)
if (scale != slast) {
projection.scale(scale);
} else {
var dx = t[0]-tlast[0],
dy = t[1]-tlast[1],
yaw = projection.rotate()[0],
tp = projection.translate();
// use x translation to rotate based on current scale
projection.rotate([yaw+360.*dx/width*scaleExtent[0]/scale, 0, 0]);
// use y translation to translate projection, clamped by min/max
var b = mercatorBounds(projection, maxlat);
if (b[0][1] + dy > 0) dy = -b[0][1];
else if (b[1][1] + dy < height) dy = height-b[1][1];
projection.translate([tp[0],tp[1]+dy]);
}
// save last values. resetting zoom.translate() and scale() would
// seem equivalent but doesn't seem to work reliably?
slast = scale;
tlast = t;
}
svg.selectAll('path') // re-project path data
.attr('d', path);
}
</script>
Display the source blob
Display the rendered blob
Raw
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
@AlfredMan
Copy link

Thanks for your code. Very clear and self explanatory. I do run into problem when I try to make custom zoom extent to a bounding box containing some data at different lng/lat. Problem is: when I calculate the bounding box, sometimes the map will calculate the bounding box from the outer left margin warping around the the outer right margin. Wonder is there any simple algorithm to create bounding box always warping the inside margin.

My code:

// datas is an array contains lng, lat information
let boundLngLat=[datas[0].lng,datas[0].lat],[datas[0].lng,datas[0].lat];
for (let data of datas){
boundLngLat[0][0]=Math.min(data.lng,boundLngLat[0][0]);
boundLngLat[0][1]=Math.max(data.lat,boundLngLat[0][1]);
boundLngLat[1][0]=Math.max(data.lng,boundLngLat[1][0]);
boundLngLat[1][1]=Math.min(data.lat,boundLngLat[1][1]);
}
let boundXY=[this.projection(boundLngLat[0]),this.projection(boundLngLat[1]) ];

then I use try to zoom extent to this boundXY by:
adding this in redraw function:
redraw(bound){
if (bound){
let boundCenter=[(boundXY[0][0]+boundXY[1][0])/2 , (boundXY[0][1]+boundXY[1][1])/2];

  let dx=this.width/2-boundCenter[0];
  let dy=this.height/2-boundCenter[1];

  let yaw = this.projection.rotate()[0],
  tp = this.projection.translate();
  this.projection.rotate([yaw+360.*dx/this.width*this.scaleExtent[0]/scale, 0, 0]);
  let b = this.mercatorBounds(this.projection, this.maxlat);
  if (b[0][1] + dy > 0) dy = -b[0][1];
  else if (b[1][1] + dy < this.height) dy = this.height-b[1][1];
  this.projection.translate([tp[0],tp[1]+dy]);     

  this.g.selectAll('path')       // re-project path data
  .attr('d', this.path);



  let boundWidth=(boundXY[1][0]-boundXY[0][0]);
  let boundHeight=(boundXY[1][1]-boundXY[0][1]);

  scale=1/Math.max(boundWidth / this.width, boundHeight / this.height);
  scale*=this.projection.scale();

  if (scale<this.scaleExtent[0]){scale=this.scaleExtent[0]}
  if (scale>this.scaleExtent[1]){scale=this.scaleExtent[1]}

  this.projection.scale(scale);
  this.g.selectAll('path')       // re-project path data
  .attr('d', this.path);


  //These 2 lines here doesnt work with case when user do mouse zoom/pan after this custom zoom caused d3.event.zoom and d3.event.translate seems out of sync from this.slast and this.tlast registered here.
  this.slast=scale;
  this.tlast = this.projection.translate();

}else if (d3.event){
... run original code for zoom/pan

}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment