Hack 58. Find the Right Zoom Level
You've got some points to show on a map. How do you pick the right zoom level to show them all at once?
The Google Maps API is powerful but, as tends to be the case for such toolkits, has some omissions and inconveniences. For example, in many real-world mapping problems, it's common to have a set of points or lines that we wish to display on a map. Typically, we choose the outer four corners, or extents, of the map to match the bounding box of the data, which is given by the minimum and maximum x and y coordinates in our data set. By selecting the map extents to match the bounding box of the data, we can guarantee that everything we want to show should be visible in the map display.
The bounding box of a data set is usually very easy to calculate. One way to do so is to simply iterate through the data set, looking for the largest and smallest coordinates in both dimensions. The problem is that the Google Maps API doesn't give us any way to set the map extents based on a bounding box. Instead, the API exports a centerAndZoom( ) method that accepts a center point of the map, expressed as a latitude and longitude pair, and a zoom level. Google documents the zoom level as an integer, and the examples in their documentation all use 4 as the zoom level, but no definition of the zoom levels themselves is given. Experimentation shows that they correspond with the ticks on the zoom level control.
Our question is then very simple: given a bounding box, how does one determine an appropriate center and zoom level, so that our data is guaranteed to appear on the screen, regardless of screen geometry?
6.9.1. The Brute Force Method
One approach is to look at the current map geometry and see if the screen will hold the bounding box and keep zooming in or out as needed. The zoom levels are approximately powers of two, so it's practical to use a sort of brute-force method, and just multiply or divide the current level by two, and see if it still fits. The following JavaScript does just that:
function computeZoom(minX, minY, maxX, maxY) { var zl; var bounds = map.getBoundsLatLng(); var cz = map.getZoomLevel(); var mapWidth = Math.abs(bounds.maxX - bounds.minX); var mapHeight = Math.abs(bounds.maxY - bounds.minY); var myWidth = Math.abs(maxX - minX); var myHeight = Math.abs(maxY minY); var changeZoom = myWidth > mapWidth ? 1 : -1; // Just rip through until we find the lowest that will hold our span. // For certain geometries (around hemisphere boundaries), this won't work for(zl=map.getZoomLeve( );zl> 2&&zl<17; zl += changeZoom) { width = width * Math.pow(2, changeZoom); height = height * Math.pow(2, changeZoom); if ((width < lon) || (height < lat)) return zl - 1; } return zl; }
This makes setting that center point and zoom level very simple:
centerLat = (minLat + maxLat) / 2; centerLon = (minLon + maxLon) / 2; var zl = computeZoom(minLon, minLat, maxLon, maxLat); map.centerAndZoom(new GPoint(centerLon, centerLat), zl);
6.9.2. The Analytic Method
However, we can simplify this approach even further. Empirically, we note that at zoom level 17, the resolution of the map is 1.46025 degrees of longitude per pixel. Similarly, we can observe that this resolution halves for each subsequent zoom level, and that this seems to be invariant for the x axis of the map. For the y axis of the map, this property applies to lower zoom levels of the map, but not the higher zoom levels, because instead of wrapping the map, blank space is shown above and below the poles.
Nevertheless, as a first approximation, we can calculate what the resolution of our map ought to be, given the display geometry of the map in pixels and a bounding box, to get a map that covers the whole box. We can exploit this relationship by taking the logarithm of the ratio of 1.46025 to our desired resolution to the base 2, and then subtracting this value from 17. The following JavaScript performs this operation:
function calculateZoom2 (minX, maxX, minY, maxY) { var mapElement = document.getElementById("map"); var degLonPerPixel = Math.abs(maxX minX) / parseInt(mapElement.style. width); var degLatPerPixel = Math.abs(maxYminY) / parseInt(mapElement.style. height); var resolution = Math.max( degLonPerPixel, degLatPerPixel ); return Math.ceil( 17 - Math.log(1.46025 / resolution) / Math.log(2) ); }
Since the JavaScript Math.log( ) returns the logarithm to the base e, we use the mathematical relationship loga(x) = logb(x) / logb(a) in the code example above to obtain the logarithm to the base 2. Similarly, we use Math.ceil() to find the smallest integer greater than or equal to the result, to ensure that the resulting map is at least as large as our bounding box. As for the difference in resolutions between the x and y axes for the higher zoom level, we observe empirically that the maximum resolution for the y axis at any zoom level is smaller than the resolution of the x axis at zoom level 15. At zoom level 15, the entire world is still shown north to south, so this won't be a problem either.
The main advantage of this method is that, unlike the brute force method, it can be applied on the server side as well as the client side. The corresponding bit of Perl code from a CGI script might look like this:
my $zoom = int( 17 log(1.46025 / $resolution) / log(2) ) + 1;
This approach requires a little bit less code than the brute-force method, and it feels a lot more elegant. The disadvantage, of course, is that it relies on hardcoded constants that could break if Google decides to change the semantics of its zoom levels without warning, though this seems unlikely at the moment.
6.9.3. The Undocumented API Method
Of course, Google probably needs its own method to determine the right zoom level right? As it happens, the 1.0 version of Google Maps API does provide such a method, albeit an undocumented one. The following code makes use of this undocumented method:
var center = new GPoint( (minX + maxX) / 2, (minY + maxY) / 2 ); var span = new GSize( maxX - minX, minX minY ); var zoom = map.spec.getLowestZoomLevel(center, span, map.viewSize); map.centerAndZoom(center, zoom);
This method may be the simplest of all, but it relies on two presently undocumented features of the Google Maps API: the map.spec.getLowestZoomLevel( ) method and the map.viewSize property. Using them runs an even higher risk than the other two methods that your code will break at some point, should Google alter its API. However, we won't be at all surprised if Google decides to document this method, or methods like it in future releases. Read more about this issue on the Google Groups thread at http://xrl.us/getLowestZoomLevel.
One final thought: all these methods try to find the optimal match between the supplied bounding box and a corresponding zoom level. If your bounding box area should happen to match a zoom level exactly, this might not be what you want, because the outliers of your data set will be displayed at the very edge of the map. For this reason, it might be smart to expand the width and height of your bounding box by, say, 10%, to ensure that not only is everything displayed on the map, but everything is displayed well within the map.
Robert Lipe