Google Maps v3 - can I ensure smooth panning every time?

My map has several hundred markers within a city. Usually no more than a 20 mile radius. I've read through the documentation and haven't found a way to set the init to automatically pan between every marker, no matter the distance. The default behavior is to pan if close, jump if far. I understand why they would do this since the map doesn't load the whole world at the selected zoom level and it could screw up if the distance was too great. However, I think it could handle 20 mile radius with minimal complaints.

If anyone has any ideas, I'd love to hear them. Thanks

Answers


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.


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]);
    }
  }
}

See this other SO answer about using javascript's setInterval function to create a periodic function that calls panBy on your map: Can Google Maps be set to a slow constant pan? Like a globe revolution?

This can be used to pan the map by x pixels on each call to panBy, allowing you to slow down the panBy rate (since you are only telling gmaps to panTo a short distance).


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(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(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(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)
    }
}

As Daniel has mentioned, the built-in panTo() function will not work for you if the two points are too far apart. You can manually animate it yourself if that's the case though: for each zoom level, figure out the distance covered by say 100 pixels. Now, when you have to pan to a point, you can use this information to figure out if the panTo() funciton will animate or jump. If the distance moved is so big that it will not animate, you should do the animation manually - compute some intermediate waypoints between your current map center and your destination, and pan to them in sequence.


@tato.rodrigo

I don't have enough reputation to post as an answer so am posting as a reply to Tato here as his plugin works well for me and is exactly what I needed but has a bug (I use it as a dependency so the map variable is passed through the function)

You need to pass map to function getOptimalZoomOut(latLng, currentZoom) {}

as you use the map variable inside that function.

like this: function getOptimalZoomOut(latLng, currentZoom, map) {}

and later: map.setZoom(getOptimalZoomOut(destLatLng, initialZoom)); pass it in: map.setZoom(getOptimalZoomOut(destLatLng, initialZoom, map)); and maybe another stray one.


Need Your Help

MySQL's lower_case_table_names won't change

mysql case-sensitive

I have a problem with changing lower_case_table_names variable value in MySQL 5.6 ...

RouterLink with multiple params in Angular

angular router angular-routing angular-router angular-routerlink

I want to create a link to the route with multiple parameters and bind them in tempalte. Until now, I've been doing this by executing the function on (click) event, but I was wondering if it's poss...