{empowering business}
Clustering / grouping markers in Google Maps
Recently, I had a client ask me to upgrade an embedded Google map for their locations page. The map allows you to view their locations by category: Manufacturing, Corporate, Engineering, or Technical.
The problem was that some of the location markers were too close together. Two of their plants in Ohio are just down the road from each other, so trying to show those two location on the same map that shows facilities in Hawaii and Connecticut was nearly impossible. The two markers in Ohio are directly on top of each other.
The solution comes from a Google Maps plugin called MarkerClusterer.
Prior to creating the "marker clusters", the map looked like this:

That one marker in Ohio is actually two locations, and because of the default zoom, the Hawaii plant isn't even showing here.
Let's start from the beginning. First, import our Google Maps and MarkerClusterer source code:
<!-- Import Google Maps V3 directly from Google -->
<script src="http://maps.google.com/maps/api/js?sensor=false"></script>
<script type="text/javascript">
// MarkerClusterer compiled
(function(){var d=null;function e(a){return function(b){this[a]=b}}function h(a){return function(){return this[a]}}var j;
function k(a,b,c){this.extend(k,google.maps.OverlayView);this.c=a;this.a=[];this.f=[];this.ca=[53,56,66,78,90];this.j=[];this.A=!1;c=c||{};this.g=c.gridSize||60;this.l=c.minimumClusterSize||2;this.J=c.maxZoom||d;this.j=c.styles||[];this.X=c.imagePath||this.Q;this.W=c.imageExtension||this.P;this.O=!0;if(c.zoomOnClick!=void 0)this.O=c.zoomOnClick;this.r=!1;if(c.averageCenter!=void 0)this.r=c.averageCenter;l(this);this.setMap(a);this.K=this.c.getZoom();var f=this;google.maps.event.addListener(this.c,
"zoom_changed",function(){var a=f.c.getZoom();if(f.K!=a)f.K=a,f.m()});google.maps.event.addListener(this.c,"idle",function(){f.i()});b&&b.length&&this.C(b,!1)}j=k.prototype;j.Q="http://google-maps-utility-library-v3.googlecode.com/svn/trunk/markerclusterer/images/m";j.P="png";j.extend=function(a,b){return function(a){for(var b in a.prototype)this.prototype[b]=a.prototype[b];return this}.apply(a,[b])};j.onAdd=function(){if(!this.A)this.A=!0,n(this)};j.draw=function(){};
function l(a){if(!a.j.length)for(var b=0,c;c=a.ca[b];b++)a.j.push({url:a.X+(b+1)+"."+a.W,height:c,width:c})}j.S=function(){for(var a=this.o(),b=new google.maps.LatLngBounds,c=0,f;f=a[c];c++)b.extend(f.getPosition());this.c.fitBounds(b)};j.z=h("j");j.o=h("a");j.V=function(){return this.a.length};j.ba=e("J");j.I=h("J");j.G=function(a,b){for(var c=0,f=a.length,g=f;g!==0;)g=parseInt(g/10,10),c++;c=Math.min(c,b);return{text:f,index:c}};j.$=e("G");j.H=h("G");
j.C=function(a,b){for(var c=0,f;f=a[c];c++)q(this,f);b||this.i()};function q(a,b){b.s=!1;b.draggable&&google.maps.event.addListener(b,"dragend",function(){b.s=!1;a.L()});a.a.push(b)}j.q=function(a,b){q(this,a);b||this.i()};function r(a,b){var c=-1;if(a.a.indexOf)c=a.a.indexOf(b);else for(var f=0,g;g=a.a[f];f++)if(g==b){c=f;break}if(c==-1)return!1;b.setMap(d);a.a.splice(c,1);return!0}j.Y=function(a,b){var c=r(this,a);return!b&&c?(this.m(),this.i(),!0):!1};
j.Z=function(a,b){for(var c=!1,f=0,g;g=a[f];f++)g=r(this,g),c=c||g;if(!b&&c)return this.m(),this.i(),!0};j.U=function(){return this.f.length};j.getMap=h("c");j.setMap=e("c");j.w=h("g");j.aa=e("g");
j.v=function(a){var b=this.getProjection(),c=new google.maps.LatLng(a.getNorthEast().lat(),a.getNorthEast().lng()),f=new google.maps.LatLng(a.getSouthWest().lat(),a.getSouthWest().lng()),c=b.fromLatLngToDivPixel(c);c.x+=this.g;c.y-=this.g;f=b.fromLatLngToDivPixel(f);f.x-=this.g;f.y+=this.g;c=b.fromDivPixelToLatLng(c);b=b.fromDivPixelToLatLng(f);a.extend(c);a.extend(b);return a};j.R=function(){this.m(!0);this.a=[]};
j.m=function(a){for(var b=0,c;c=this.f[b];b++)c.remove();for(b=0;c=this.a[b];b++)c.s=!1,a&&c.setMap(d);this.f=[]};j.L=function(){var a=this.f.slice();this.f.length=0;this.m();this.i();window.setTimeout(function(){for(var b=0,c;c=a[b];b++)c.remove()},0)};j.i=function(){n(this)};
function n(a){if(a.A)for(var b=a.v(new google.maps.LatLngBounds(a.c.getBounds().getSouthWest(),a.c.getBounds().getNorthEast())),c=0,f;f=a.a[c];c++)if(!f.s&&b.contains(f.getPosition())){for(var g=a,u=4E4,o=d,v=0,m=void 0;m=g.f[v];v++){var i=m.getCenter();if(i){var p=f.getPosition();if(!i||!p)i=0;else var w=(p.lat()-i.lat())*Math.PI/180,x=(p.lng()-i.lng())*Math.PI/180,i=Math.sin(w/2)*Math.sin(w/2)+Math.cos(i.lat()*Math.PI/180)*Math.cos(p.lat()*Math.PI/180)*Math.sin(x/2)*Math.sin(x/2),i=6371*2*Math.atan2(Math.sqrt(i),
Math.sqrt(1-i));i<u&&(u=i,o=m)}}o&&o.F.contains(f.getPosition())?o.q(f):(m=new s(g),m.q(f),g.f.push(m))}}function s(a){this.k=a;this.c=a.getMap();this.g=a.w();this.l=a.l;this.r=a.r;this.d=d;this.a=[];this.F=d;this.n=new t(this,a.z(),a.w())}j=s.prototype;
j.q=function(a){var b;a:if(this.a.indexOf)b=this.a.indexOf(a)!=-1;else{b=0;for(var c;c=this.a[b];b++)if(c==a){b=!0;break a}b=!1}if(b)return!1;if(this.d){if(this.r)c=this.a.length+1,b=(this.d.lat()*(c-1)+a.getPosition().lat())/c,c=(this.d.lng()*(c-1)+a.getPosition().lng())/c,this.d=new google.maps.LatLng(b,c),y(this)}else this.d=a.getPosition(),y(this);a.s=!0;this.a.push(a);b=this.a.length;b<this.l&&a.getMap()!=this.c&&a.setMap(this.c);if(b==this.l)for(c=0;c<b;c++)this.a[c].setMap(d);b>=this.l&&a.setMap(d);
a=this.c.getZoom();if((b=this.k.I())&&a>b)for(a=0;b=this.a[a];a++)b.setMap(this.c);else if(this.a.length<this.l)z(this.n);else{b=this.k.H()(this.a,this.k.z().length);this.n.setCenter(this.d);a=this.n;a.B=b;a.ga=b.text;a.ea=b.index;if(a.b)a.b.innerHTML=b.text;b=Math.max(0,a.B.index-1);b=Math.min(a.j.length-1,b);b=a.j[b];a.da=b.url;a.h=b.height;a.p=b.width;a.M=b.textColor;a.e=b.anchor;a.N=b.textSize;a.D=b.backgroundPosition;this.n.show()}return!0};
j.getBounds=function(){for(var a=new google.maps.LatLngBounds(this.d,this.d),b=this.o(),c=0,f;f=b[c];c++)a.extend(f.getPosition());return a};j.remove=function(){this.n.remove();this.a.length=0;delete this.a};j.T=function(){return this.a.length};j.o=h("a");j.getCenter=h("d");function y(a){a.F=a.k.v(new google.maps.LatLngBounds(a.d,a.d))}j.getMap=h("c");
function t(a,b,c){a.k.extend(t,google.maps.OverlayView);this.j=b;this.fa=c||0;this.u=a;this.d=d;this.c=a.getMap();this.B=this.b=d;this.t=!1;this.setMap(this.c)}j=t.prototype;
j.onAdd=function(){this.b=document.createElement("DIV");if(this.t)this.b.style.cssText=A(this,B(this,this.d)),this.b.innerHTML=this.B.text;this.getPanes().overlayMouseTarget.appendChild(this.b);var a=this;google.maps.event.addDomListener(this.b,"click",function(){var b=a.u.k;google.maps.event.trigger(b,"clusterclick",a.u);b.O&&a.c.fitBounds(a.u.getBounds())})};function B(a,b){var c=a.getProjection().fromLatLngToDivPixel(b);c.x-=parseInt(a.p/2,10);c.y-=parseInt(a.h/2,10);return c}
j.draw=function(){if(this.t){var a=B(this,this.d);this.b.style.top=a.y+"px";this.b.style.left=a.x+"px"}};function z(a){if(a.b)a.b.style.display="none";a.t=!1}j.show=function(){if(this.b)this.b.style.cssText=A(this,B(this,this.d)),this.b.style.display="";this.t=!0};j.remove=function(){this.setMap(d)};j.onRemove=function(){if(this.b&&this.b.parentNode)z(this),this.b.parentNode.removeChild(this.b),this.b=d};j.setCenter=e("d");
function A(a,b){var c=[];c.push("background-image:url("+a.da+");");c.push("background-position:"+(a.D?a.D:"0 0")+";");typeof a.e==="object"?(typeof a.e[0]==="number"&&a.e[0]>0&&a.e[0]<a.h?c.push("height:"+(a.h-a.e[0])+"px; padding-top:"+a.e[0]+"px;"):c.push("height:"+a.h+"px; line-height:"+a.h+"px;"),typeof a.e[1]==="number"&&a.e[1]>0&&a.e[1]<a.p?c.push("width:"+(a.p-a.e[1])+"px; padding-left:"+a.e[1]+"px;"):c.push("width:"+a.p+"px; text-align:center;")):c.push("height:"+a.h+"px; line-height:"+a.h+
"px; width:"+a.p+"px; text-align:center;");c.push("cursor:pointer; top:"+b.y+"px; left:"+b.x+"px; color:"+(a.M?a.M:"black")+"; position:absolute; font-size:"+(a.N?a.N:11)+"px; font-family:Arial,sans-serif; font-weight:bold");return c.join("")}window.MarkerClusterer=k;k.prototype.addMarker=k.prototype.q;k.prototype.addMarkers=k.prototype.C;k.prototype.clearMarkers=k.prototype.R;k.prototype.fitMapToMarkers=k.prototype.S;k.prototype.getCalculator=k.prototype.H;k.prototype.getGridSize=k.prototype.w;
k.prototype.getExtendedBounds=k.prototype.v;k.prototype.getMap=k.prototype.getMap;k.prototype.getMarkers=k.prototype.o;k.prototype.getMaxZoom=k.prototype.I;k.prototype.getStyles=k.prototype.z;k.prototype.getTotalClusters=k.prototype.U;k.prototype.getTotalMarkers=k.prototype.V;k.prototype.redraw=k.prototype.i;k.prototype.removeMarker=k.prototype.Y;k.prototype.removeMarkers=k.prototype.Z;k.prototype.resetViewport=k.prototype.m;k.prototype.repaint=k.prototype.L;k.prototype.setCalculator=k.prototype.$;
k.prototype.setGridSize=k.prototype.aa;k.prototype.setMaxZoom=k.prototype.ba;k.prototype.onAdd=k.prototype.onAdd;k.prototype.draw=k.prototype.draw;s.prototype.getCenter=s.prototype.getCenter;s.prototype.getSize=s.prototype.T;s.prototype.getMarkers=s.prototype.o;t.prototype.onAdd=t.prototype.onAdd;t.prototype.draw=t.prototype.draw;t.prototype.onRemove=t.prototype.onRemove;
})();
</script>
Next, in JavaScript, set up our default configuration settings and location marker array (with category, latitude, longitude, and HTML for infoWindow):
<script type="text/javascript">
// configure options
var map;
var initLatLng = new google.maps.LatLng(37, -110);
var initZoom = 3;
var locations = new Array();
var markers = new Array();
var markerCluster = null;
// markerClusterer options.
// gridSize determines how close markers must be on-screen before
// they are turned into a cluster
// maxZoom is the map zoom level beyond which clusters all
// turn into individual markers
var mcOptions = {gridSize: 20, maxZoom: 15};
// create blank infoWindow.
var infoWindow = new google.maps.InfoWindow();
// All locations in an array, with category, latitude,
// longitude, and description for infoWindow.
locations[1] = new Array();
locations[1][0] = 'Manufacturing';
locations[1][1] = 39.250288;
locations[1][2] = -76.45945;
locations[1][3] =
"<strong>BALTIMORE, MD</strong><br />" +
"4601 North Point Boulevard<br />" +
"Baltimore, MD 21219<br />" +
"Phone: (410) 477-4000<br />" +
"Fax: (410) 477-1550";
locations[2] = new Array();
locations[2][0] = 'Manufacturing';
locations[2][1] = 28.378;
locations[2][2] = -82.187;
locations[2][3] =
"<strong>DADE CITY, FL</strong><br />" +
"38020 Pulp Drive<br />" +
"Dade City, FL 33523<br />" +
"Phone: (352) 518-4400<br />" +
"Fax: (352) 518-4450";
locations[3] = new Array();
locations[3][0] = 'Manufacturing';
locations[3][1] = 32.863951;
locations[3][2] = -96.878975;
locations[3][3] =
"<strong>DALLAS, TX</strong><br />" +
"10340 Denton Drive <br />" +
"Dallas, TX 75220<br />" +
"Phone: (214) 350-1716<br />" +
"Fax: (214) 350-7252";
locations[4] = new Array();
locations[4][0] = 'Manufacturing';
locations[4][1] = 33.968554;
locations[4][2] = -117.46175;
locations[4][3] =
"<strong>RIVERSIDE, CA</strong><br />" +
"6510 General Drive<br />" +
"Riverside, CA 92509<br />" +
"Phone: (951) 360-3500<br />" +
"Fax: (951) 360-3131";
// Omitting the other locations, for the sake of brevity.
Next, the functions that do the heavy lifting:
function initialize() {
// set map options
var myOptions = {
zoom: initZoom,
center: initLatLng,
streetViewControl: false,
mapTypeControl: true,
mapTypeControlOptions: {
style: google.maps.MapTypeControlStyle.DROPDOWN_MENU
},
zoomControl: true,
zoomControlOptions: {
style: google.maps.ZoomControlStyle.SMALL
},
mapTypeId: google.maps.MapTypeId.ROADMAP,
}
// create map, attached to div#map_canvas
map = new google.maps.Map(document.getElementById("map_canvas"), myOptions);
// iterate through all locations, setting markers and assigning listener for infoWindow
for (i = 1; i < locations.length; i++) {
if (typeof(locations[i]) == 'object') {
var point = new google.maps.LatLng(locations[i][1], locations[i][2]);
markers[i] = new google.maps.Marker({
position: point,
});
markers[i].setMap(map);
}
} // for
google.maps.event.addListener(markers[1], 'click', function() {infoWindow.setContent(locations[1][3]);infoWindow.open(map, markers[1]);});
google.maps.event.addListener(markers[2], 'click', function() {infoWindow.setContent(locations[2][3]);infoWindow.open(map, markers[2]);});
google.maps.event.addListener(markers[3], 'click', function() {infoWindow.setContent(locations[3][3]);infoWindow.open(map, markers[3]);});
google.maps.event.addListener(markers[4], 'click', function() {infoWindow.setContent(locations[4][3]);infoWindow.open(map, markers[4]);});
google.maps.event.addListener(markers[5], 'click', function() {infoWindow.setContent(locations[5][3]);infoWindow.open(map, markers[5]);});
google.maps.event.addListener(markers[6], 'click', function() {infoWindow.setContent(locations[6][3]);infoWindow.open(map, markers[6]);});
google.maps.event.addListener(markers[7], 'click', function() {infoWindow.setContent(locations[7][3]);infoWindow.open(map, markers[7]);});
google.maps.event.addListener(markers[8], 'click', function() {infoWindow.setContent(locations[8][3]);infoWindow.open(map, markers[8]);});
google.maps.event.addListener(markers[9], 'click', function() {infoWindow.setContent(locations[9][3]);infoWindow.open(map, markers[9]);});
google.maps.event.addListener(markers[10], 'click', function() {infoWindow.setContent(locations[10][3]);infoWindow.open(map, markers[10]);});
google.maps.event.addListener(markers[11], 'click', function() {infoWindow.setContent(locations[11][3]);infoWindow.open(map, markers[11]);});
google.maps.event.addListener(markers[12], 'click', function() {infoWindow.setContent(locations[12][3]);infoWindow.open(map, markers[12]);});
google.maps.event.addListener(markers[13], 'click', function() {infoWindow.setContent(locations[13][3]);infoWindow.open(map, markers[13]);});
google.maps.event.addListener(markers[14], 'click', function() {infoWindow.setContent(locations[14][3]);infoWindow.open(map, markers[14]);});
google.maps.event.addListener(markers[15], 'click', function() {infoWindow.setContent(locations[15][3]);infoWindow.open(map, markers[15]);});
google.maps.event.addListener(markers[16], 'click', function() {infoWindow.setContent(locations[16][3]);infoWindow.open(map, markers[16]);});
google.maps.event.addListener(markers[17], 'click', function() {infoWindow.setContent(locations[17][3]);infoWindow.open(map, markers[17]);});
google.maps.event.addListener(markers[18], 'click', function() {infoWindow.setContent(locations[18][3]);infoWindow.open(map, markers[18]);});
google.maps.event.addListener(markers[19], 'click', function() {infoWindow.setContent(locations[19][3]);infoWindow.open(map, markers[19]);});
google.maps.event.addListener(markers[20], 'click', function() {infoWindow.setContent(locations[20][3]);infoWindow.open(map, markers[20]);});
google.maps.event.addListener(markers[21], 'click', function() {infoWindow.setContent(locations[21][3]);infoWindow.open(map, markers[21]);});
google.maps.event.addListener(markers[22], 'click', function() {infoWindow.setContent(locations[22][3]);infoWindow.open(map, markers[22]);});
// check to see which category is selected
var location_selector = document.getElementsByName('loc_sel');
for (var i=0; i < location_selector.length; i++) {
if (location_selector[i].checked) {
var location_type = location_selector[i].value;
}
}
// show markers based on category selected
show_markers(location_type);
} // function initialize() {
function show_markers (location_type) {
// create temp array for cluster
var temp_markers = new Array();
// if the markerClusterer object doesn't exist, create it with empty temp_markers
if (markerCluster == null) {
markerCluster = new MarkerClusterer(map, temp_markers, mcOptions);
}
// clear all markers
markerCluster.clearMarkers();
// iterate through all locations, setting only those in the selected category
for (i = 1; i < locations.length; i++) {
if (typeof(locations[i]) == 'object') {
if (locations[i][0] == location_type) {
markers[i].setVisible(true);
// add marker to temp array, for clustering
temp_markers.push(markers[i]);
} else {
markers[i].setVisible(false);
}
}
} // for
// add all current markers to cluster
markerCluster.addMarkers(temp_markers);
// re-center map, in case it has changed
map.setCenter(initLatLng);
// reset map zoom to initial value
map.setZoom(initZoom);
} // function show_markers
</script>
And finally, the HTML for the category selectors and the map itself, followed by the JavaScript call to initialize the map:
<ul>
<li style="display: inline-block; padding: 3px;"><label><input checked="checked" id="test" name="loc_sel" onclick="show_markers(this.value);" value="Manufacturing" type="radio">Manufacturing</label></li>
<li style="display: inline-block; padding: 3px;"><label><input name="loc_sel" onclick="show_markers(this.value);" value="Corporate" type="radio">Corporate Office</label></li>
<li style="display: inline-block; padding: 3px;"><label><input name="loc_sel" onclick="show_markers(this.value);" value="Engineering" type="radio">Engineering Services</label></li>
<li style="display: inline-block; padding: 3px;"><label><input name="loc_sel" onclick="show_markers(this.value);" value="Technical" type="radio">Technical Services</label></li>
</ul>
<div id="map_canvas" style="width:640px; height: 480px; text-align:left; color:#333; font-size:12px;"> </div>
<script type="text/javascript">
initialize();
</script>
Rather than breaking apart each line of code, I prefer to show it as intact as possible, with comments inline where necessary. I hope this helps you out.
The one problem I ran into was that I initially tried to send the global array markers to the markerClusterer. However, it didn't work. The only thing I could figure out was that it didn't work because I assigned the array elements with integer keys, rather than just pushing them into the array (as the documentation shows). After a couple hours scratching my head, with no clusters in sight, I tried creating the array temp_markers by pushing the individual markers into it. This worked. (BTW, I apologize for my sloppy vari_able namingConventions. I really need to standardize when writing JavaScript.)
The final result looks like this:

Note that, based on the options we set (mcOptions), MarkerClusterer automatically created clusters for all the markers closer than the lower limit we set via gridSize.
Here is the live version of the map.
The only thing I find a bit finicky about the clusters is that it's very easy to click on the Ohio cluster when you intend to click on the east coast locations. I'll work with the options a bit more to get that dialed in.



Comments
Thanks for posting this! I
Thanks for posting this! I was tinkering with adding clusters to a dynamic map search, and I had the same problem with the markers array. I just pushed the markers to a separate array, and it now works great! Now to change the icons from those odd nuclear-reactor stock symbols.
Clistering
I have one error like this when i am using this code.
TypeError: a is undefined
You'll need to step through
You'll need to step through the JavaScript via Firebug and find out what parameter is undefined.
marker count incorrect..
Hi,
I've created something similar, loading points via xml and ajax,
But I've got a problem with the marker totals reflected in the cluster.
At low zoom levels I'll get numbers like 7k and 12k occasionally - and we've only got 4500 or so points in our db. Have you experienced this?
Wow, no, I've never
Wow, no, I've never experienced that. My experience with clustering is limited to this one map, and it has very few markers. So far, the cluster counts are correct.