Welcome friends! I’ve finally gotten around to my next Google Maps installment, this time dealing with offsetting map behavior.
This was a problem that faced us for a time here at Reonomy, when we were working with overlaying property information cards over a map, and needing the map interactions to be aware of that overlay.
In other words, we wanted all map interactions to be confined to a particular space, so that markers or other map info didn’t disappear behind the elements floating over it.
We’ll be focusing on two major interactions of a map: zooming and setting bounds, which we’ll need to create completely custom functions for.
There are no magical offset parameters you can pass to the native Google Maps setZoom
or setBounds
functions (yet!). But that’s good, we like a challenge.
Here’s the fiddle we’re working towards.
The Problem
If our map looks like this:


There are a couple ways to solve this, and more specifically how you construct that highlighted area. If you have multiple elements on the sides, top, what have you, crowding the map, you’ll likely want to do some robust highlight area calculating.
But since we know we always will have just one element overlaid, the concept of “offset” will suffice. Although, to be precise, its really more of added “inner padding” than it is an offset. But I digress.
A core piece of functionality we’ll need is to be able to offset a lat/lng point by a certain pixel amount. With the aid of a bunch of Google Map helpers, we can create a nice generic function:
const offsetLatLng = (latlng, offsetX, offsetY) => {
offsetX = offsetX || 0;
offsetY = offsetY || 0;
const scale = Math.pow(2, gmap.getZoom());
const point = gmap.getProjection().fromLatLngToPoint(latlng);
const pixelOffset = new google.maps.Point((offsetX/scale), (offsetY/scale));
const newPoint = new google.maps.Point(
point.x - pixelOffset.x,
point.y + pixelOffset.y
);
return gmap.getProjection().fromPointToLatLng(newPoint);
};
1. Offsetting Zoom
Offsetting the zoom is super simple. All you need to know is the width and/or height of your overlay element and you’re good to go. Google Maps takes care of the other calculations.
const zoomWithOffset = shouldZoom => {
const currentzoom = gmap.getZoom();
const newzoom = shouldZoom? currentzoom + 1 : currentzoom - 1;
const offset = {
x: shouldZoom? -mapOffset.x/4 : mapOffset.x/2,
y: shouldZoom? -mapOffset.y/4 : mapOffset.y/2
};
const newCenter = offsetLatLng(gmap.getCenter(), offset.x, offset.y);
if(shouldZoom){
gmap.setZoom(newzoom);
gmap.setCenter(newCenter);
} else {
gmap.setCenter(newCenter);
gmap.setZoom(newzoom);
}
};
With just a reference to our offsetLatLng
, we’re done. We take in a boolean (true = zoom in, false = zoom out), and offset our center according to direction we’re zooming. The center of the map is really all we care about when zooming. When zooming out, its going to be offset by half the total offset amount.
When zooming in, it will be offset by the negative of half of that, or -1/4. I’ll spare you the geometry lesson, but this is all thanks to the fact that Google uses the convention of increasing or decreasing zoom distance by a factor of 2.
Offsetting setBounds
Here’s where things get tricky. When setting the map bounds based on an array of locations, there is a lot to do to calculate this:
- Get the bounds of the entire array, i.e., draw four points that would exactly encompass all the points.
- Get the dimensions of the concerned area of the map.
- Get the dimensions of the array bounds, and calculate at what zoom level those bounds would fit on the map.
- Set the map to that zoom level.
- Offset the center (using our generic function, hah, score!).
First things first, we’ll need to write our own getBounds
that gets the bounds of our locations:
const getBounds = locations => {
let northeastLat;
let northeastLong;
let southwestLat;
let southwestLong;
locations.forEach(function(location){
if(!northeastLat) {
northeastLat = southwestLat = location.lat;
southwestLong = northeastLong = location.lng;
return;
}
if(location.lat > northeastLat) northeastLat = location.lat;
else if (location.lat < southwestLat) southwestLat = location.lat;
if(location.lng < northeastLong) northeastLong = location.lng;
else if(location.lng > southwestLong) southwestLong = location.lng;
});
const northeast = new google.maps.LatLng(northeastLat, northeastLong);
const southwest = new google.maps.LatLng(southwestLat, southwestLong);
const bounds = new google.maps.LatLngBounds();
bounds.extend(northeast);
bounds.extend(southwest);
return bounds;
};
Pretty straight forward. Let’s assume we have the offset-adjusted map dimensions for now. The next brain teaser we have is trying to calculate what maximum zoom level these bounds would be visible. Not an easy one, so here goes:
const getBoundsZoomLevel = (bounds, dimensions) => {
const latRadian = lat => {
let sin = Math.sin(lat * Math.PI / 180);
let radX2 = Math.log((1 + sin) / (1 - sin)) / 2;
return Math.max(Math.min(radX2, Math.PI), -Math.PI) / 2;
};
const zoom = (mapPx, worldPx, fraction) => {
return Math.floor(Math.log(mapPx / worldPx / fraction) / Math.LN2);
};
const ne = bounds.getNorthEast();
const sw = bounds.getSouthWest();
const latFraction = (latRadian(ne.lat()) - latRadian(sw.lat())) / Math.PI;
const lngDiff = ne.lng() - sw.lng();
const lngFraction = ((lngDiff < 0) ? (lngDiff + 360) : lngDiff) / 360;
const latZoom = zoom(dimensions.height, TILE_SIZE.height, latFraction);
const lngZoom = zoom(dimensions.width, TILE_SIZE.width, lngFraction);
return Math.min(latZoom, lngZoom, ZOOM_MAX);
};
Thanks to john-s for this very helpful fiddle in helping us sort this out.
Here’s further discussion on the topic for those interested. Note that TILE_SIZE
s are 256, and ZOOM_MAX
is 21, as defined by Google.
Here’s our final setMapBounds
function using offsets:
const setMapBounds = locations => {
updateMapDimensions();
const bounds = getBounds(locations);
const dimensions = {
width: mapDimensions.width - mapOffset.x - BUFFER * 2,
height: mapDimensions.height - mapOffset.y - BUFFER * 2
};
const zoomLevel = getBoundsZoomLevel(bounds, dimensions);
gmap.setZoom(zoomLevel);
setOffsetCenter(bounds.getCenter());
};
One unfortunate thing here is that we need to calculate offsets twice – once when calculating the map dimensions, and again when setting the center after zooming.
If I had my wits about me, and some more free time, I’d go ahead and refactor a lot of these methods to be more functional. Not exactly the simplest task with such a stateful thing as a map, but one day, perhaps.
Enjoy the fiddle!