Google Maps v3 - can I ensure smooth panning every time?
The threshold of the smooth panning does not depend on the distance between the current center and the new target. It depends on whether the change will require a full page scroll (horizontally and vertically) or not:
Quoting from the API Reference:
panTo(latLng:LatLng)
Changes the center of the map to the given LatLng. If the change is less than both the width and height of the map, the transition will be smoothly animated.
Therefore, as long as you are zoomed out such that your viewport is 20 miles in height and width, you should be guaranteed smooth panning for distances under 20 miles.
We developed a workaround to smoothly animate the panTo
in all cases.
Basically in cases that the native panTo
will not animate smoothly, we zoom out
, panTo
and zoom in
to the destination location.
To use the code below, call smoothlyAnimatePanTo
passing the map
instance as first parameter and the destination latLng
as second parameter.
There is a jsfiddle to demonstrate this solution in action here. Just edit the script
tag to put your own google maps javascript api key.
Any comments and contributions will be welcome.
/**
* Handy functions to project lat/lng to pixel
* Extracted from: https://developers.google.com/maps/documentation/javascript/examples/map-coordinates
**/
function project(latLng) {
var TILE_SIZE = 256
var siny = Math.sin(latLng.lat() * Math.PI / 180)
// Truncating to 0.9999 effectively limits latitude to 89.189. This is
// about a third of a tile past the edge of the world tile.
siny = Math.min(Math.max(siny, -0.9999), 0.9999)
return new google.maps.Point(
TILE_SIZE * (0.5 + latLng.lng() / 360),
TILE_SIZE * (0.5 - Math.log((1 + siny) / (1 - siny)) / (4 * Math.PI)))
}
/**
* Handy functions to project lat/lng to pixel
* Extracted from: https://developers.google.com/maps/documentation/javascript/examples/map-coordinates
**/
function getPixel(latLng, zoom) {
var scale = 1 << zoom
var worldCoordinate = project(latLng)
return new google.maps.Point(
Math.floor(worldCoordinate.x * scale),
Math.floor(worldCoordinate.y * scale))
}
/**
* Given a map, return the map dimension (width and height)
* in pixels.
**/
function getMapDimenInPixels(map) {
var zoom = map.getZoom()
var bounds = map.getBounds()
var southWestPixel = getPixel(bounds.getSouthWest(), zoom)
var northEastPixel = getPixel(bounds.getNorthEast(), zoom)
return {
width: Math.abs(southWestPixel.x - northEastPixel.x),
height: Math.abs(southWestPixel.y - northEastPixel.y)
}
}
/**
* Given a map and a destLatLng returns true if calling
* map.panTo(destLatLng) will be smoothly animated or false
* otherwise.
*
* optionalZoomLevel can be optionally be provided and if so
* returns true if map.panTo(destLatLng) would be smoothly animated
* at optionalZoomLevel.
**/
function willAnimatePanTo(map, destLatLng, optionalZoomLevel) {
var dimen = getMapDimenInPixels(map)
var mapCenter = map.getCenter()
optionalZoomLevel = !!optionalZoomLevel ? optionalZoomLevel : map.getZoom()
var destPixel = getPixel(destLatLng, optionalZoomLevel)
var mapPixel = getPixel(mapCenter, optionalZoomLevel)
var diffX = Math.abs(destPixel.x - mapPixel.x)
var diffY = Math.abs(destPixel.y - mapPixel.y)
return diffX < dimen.width && diffY < dimen.height
}
/**
* Returns the optimal zoom value when animating
* the zoom out.
*
* The maximum change will be currentZoom - 3.
* Changing the zoom with a difference greater than
* 3 levels will cause the map to "jump" and not
* smoothly animate.
*
* Unfortunately the magical number "3" was empirically
* determined as we could not find any official docs
* about it.
**/
function getOptimalZoomOut(map, latLng, currentZoom) {
if(willAnimatePanTo(map, latLng, currentZoom - 1)) {
return currentZoom - 1
} else if(willAnimatePanTo(map, latLng, currentZoom - 2)) {
return currentZoom - 2
} else {
return currentZoom - 3
}
}
/**
* Given a map and a destLatLng, smoothly animates the map center to
* destLatLng by zooming out until distance (in pixels) between map center
* and destLatLng are less than map width and height, then panTo to destLatLng
* and finally animate to restore the initial zoom.
*
* optionalAnimationEndCallback can be optionally be provided and if so
* it will be called when the animation ends
**/
function smoothlyAnimatePanToWorkarround(map, destLatLng, optionalAnimationEndCallback) {
var initialZoom = map.getZoom(), listener
function zoomIn() {
if(map.getZoom() < initialZoom) {
map.setZoom(Math.min(map.getZoom() + 3, initialZoom))
} else {
google.maps.event.removeListener(listener)
//here you should (re?)enable only the ui controls that make sense to your app
map.setOptions({draggable: true, zoomControl: true, scrollwheel: true, disableDoubleClickZoom: false})
if(!!optionalAnimationEndCallback) {
optionalAnimationEndCallback()
}
}
}
function zoomOut() {
if(willAnimatePanTo(map, destLatLng)) {
google.maps.event.removeListener(listener)
listener = google.maps.event.addListener(map, 'idle', zoomIn)
map.panTo(destLatLng)
} else {
map.setZoom(getOptimalZoomOut(map, destLatLng, map.getZoom()))
}
}
//here you should disable all the ui controls that your app uses
map.setOptions({draggable: false, zoomControl: false, scrollwheel: false, disableDoubleClickZoom: true})
map.setZoom(getOptimalZoomOut(map, destLatLng, initialZoom))
listener = google.maps.event.addListener(map, 'idle', zoomOut)
}
function smoothlyAnimatePanTo(map, destLatLng) {
if(willAnimatePanTo(map, destLatLng)) {
map.panTo(destLatLng)
} else {
smoothlyAnimatePanToWorkarround(map, destLatLng)
}
}
Here's a solution that pans smoothly and also allows for other click requests to be queue'd up while a previous pan is already in progress:
var panPath = []; // An array of points the current panning action will use
var panQueue = []; // An array of subsequent panTo actions to take
var STEPS = 50; // The number of steps that each panTo action will undergo
function panTo(newLat, newLng) {
if (panPath.length > 0) {
// We are already panning...queue this up for next move
panQueue.push([newLat, newLng]);
} else {
// Lets compute the points we'll use
panPath.push("LAZY SYNCRONIZED LOCK"); // make length non-zero - 'release' this before calling setTimeout
var curLat = map.getCenter().lat();
var curLng = map.getCenter().lng();
var dLat = (newLat - curLat)/STEPS;
var dLng = (newLng - curLng)/STEPS;
for (var i=0; i < STEPS; i++) {
panPath.push([curLat + dLat * i, curLng + dLng * i]);
}
panPath.push([newLat, newLng]);
panPath.shift(); // LAZY SYNCRONIZED LOCK
setTimeout(doPan, 20);
}
}
function doPan() {
var next = panPath.shift();
if (next != null) {
// Continue our current pan action
map.panTo( new google.maps.LatLng(next[0], next[1]));
setTimeout(doPan, 20 );
} else {
// We are finished with this pan - check if there are any queue'd up locations to pan to
var queued = panQueue.shift();
if (queued != null) {
panTo(queued[0], queued[1]);
}
}
}