Caleb Brown

Caleb Brown

Full-stack Engineer
< Blog

Google Maps Marker Cluster Not Working

Comments

I recently had to map a large number of points on a map for a large arts foundation to display their grants for the last 45 years. I looked into a few mapping and clustering options and since PostgreSQL was not an option, we couldn't use PostGIS due to a technical requirement by their hosting provider. With that in mind, I found the Marker Cluster Plus which seems to do exactly what we need, map a large dataset while keeping it legible for the user.

As part of the requirements for the project, we needed to be able to show the map at a specific zoom level with a specific latitude and longitude. Our initial approach was to store the lat/long in a hidden form field and use that value to zoom the map and set the center. When a user changes the map, we listen to the 'zoom_changed' event of the map and set those values in our form and update the URL and points on the map.

Easy peasy right?

Unfortunately, the moment you start listening to the zoom_changed event, the default behavior of the marker clusterer zoom stops working. Apparently, you can only listen to that event once on a map, so the moment you start listening to it, the marker cluster can't zoom in properly when you've clicked on a cluster.

To get around this, I listened to the bounds_changed event instead to get the zoom level and lat/long at that point. This will leave the marker clusters working properly and you can set the values in the form. Here's what our map.js ended up looking like.

// A helper function for debouncing our bounds_changed event listener
var debounce = function(func, wait, immediate) {
  var timeout = {};
  return function() {
    var context = this,
        args = arguments;

    var later = function() {
      timeout = {};
      if (!immediate) func.apply(context, args);
    };

    var callNow = immediate && !timeout;
    clearInterval(timeout);
    timeout = setTimeout(later, wait);
    if (callNow) func.apply(context, args);
  };
};var GRANT_MAP = (function($, undefined) {
  'use strict';

  var app = {},
      $el,
      $blocker = null,

      $s = $('#dir-s'),
      $w = $('#dir-w'),
      $n = $('#dir-n'),
      $e = $('#dir-e'),
      $z = $('#map-zoom'),

      $lat = $('#lat'),
      $lon = $('#lon'),
      $boundsCheck = $('#bounds-limit'),

      boundDebounce,
      map,
      markerClusterer = null,
      markers = [],

      // Constants
      BOUNCE_DELAY = 500,
      STATIC_DIR = '/static/images/map/',
      IMAGE_URL = STATIC_DIR + 'crc1.png',
      MIN_ZOOM = 2,
      MAX_ZOOM = 10,
      MAP_TYPE = 'custom_map',
      MAP_STYLES = [{
        'featureType': 'water',
        'stylers': [{ 'color': '#93cbe0' }]
      }, {
        'featureType': 'landscape.natural.landcover',
        'stylers': [{ 'color': '#fcfcf1' }]},
      {}],
      CLUSTER_STYLES = [{
        url: STATIC_DIR + 'crc1.png',
        width: 50,
        height: 50,
        textColor: '#ffffff',
        textSize: 18
      }, {
        url: STATIC_DIR + 'crc2.png',
        width: 60,
        height: 60,
        textColor: '#ffffff',
        textSize: 20
      }, {
        url: STATIC_DIR + 'crc3.png',
        width: 70,
        height: 70,
        textColor: '#ffffff',
        textSize: 20
      }, {
        url: STATIC_DIR + 'crc4.png',
        width: 85,
        height: 85,
        textColor: '#ffffff',
        textSize: 20
      }, {
        url: STATIC_DIR + 'crc5.png',
        width: 115,
        height: 115,
        textColor: '#ffffff',
        textSize: 20
      }];

  google.maps.event.addDomListener(window, 'load', init);

  // Public methods called by other JS objects
  app.clear = function() {
    // console.log('clear');
    if (markerClusterer) {
      markerClusterer.removeMarkers(markers, true);
      markerClusterer.clearMarkers();
      markers = [];
    }
  };

  app.hideBlocker = function() {
    if ($blocker === null) { return; }
    $blocker.remove();
    $blocker = null;
  };

  app.refreshMap = function(r) {

    var markerImage = new google.maps.MarkerImage(IMAGE_URL, new google.maps.Size(50, 50));
    for (var i = 0, len = r.length; i < len; i++) {

      markers.push(new google.maps.Marker({
          position: new google.maps.LatLng(r[i].lat, r[i].lon),
          icon: markerImage
        })
      );
    }

    markerClusterer = new MarkerClusterer(map, markers, {
      minZoom: MIN_ZOOM,
      maxZoom: MAX_ZOOM,
      gridSize: 80,
      styles: CLUSTER_STYLES
    });

  };

  // Show the loading screen
  app.showBlocker = function() {
    app.hideBlocker();

    $blocker = $('
'); $('#grant-map-wrap').prepend($blocker); }; // Set the zoom and bounds on the map from a set of params app.zoomAndBound = function(params) { var limit = '', z = '', n = '', s = '', e = '', w = '', lat = '', lon = ''; // An ugly switch statement for now. Should clean up $.each(params, function(idx, el){ switch (el.key) { case 'n': n = el.value; break; case 's': s = el.value; break; case 'e': e = el.value; break; case 'w': w = el.value; break; case 'z': z = el.value; break; case 'limit': limit = el.value; break; case 'lat': lat = el.value; break; case 'lon': lon = el.value; break; } }); // Stop listening to the map events unbind(); // Store our map bounds $n.val(n); $s.val(s); $e.val(e); $w.val(w); // Store the lat/long $lat.val(lat); $lon.val(lon); // Store the zoom $z.val(z === '' ? 2 : z); // If we have a limit in our params, check the limit if (limit !== '') { $boundsCheck.prop('checked', 'checked'); } else { $boundsCheck.removeAttr('checked'); } map.setZoom(parseInt(z, 10)); if (lat !== '' && lon !== '') { map.setCenter(new google.maps.LatLng($lat.val(), $lon.val())); } // Bind events bind(); }; function bind() { unbind(); // Listen the bounds check click event $boundsCheck.on('click', onBoundsCheck); google.maps.event.addListener(map, "bounds_changed", boundDebounce); // If we're checked, also listen the map if (!$boundsCheck.is(':checked')) { ensureCoordsEmpty(); } } function unbind() { // If we're checked, also listen the map $boundsCheck.off('click', onBoundsCheck); google.maps.event.clearListeners(map, "bounds_changed", boundDebounce); } function init() { boundDebounce = debounce(onBoundsChange, BOUNCE_DELAY); // Set up a styled map var styledMap = new google.maps.StyledMapType(MAP_STYLES), options = { center: new google.maps.LatLng($lat.val(), $lon.val()), minZoom: MIN_ZOOM, maxZoom: MAX_ZOOM, mapTypeControlOptions: { mapTypeIds: [google.maps.MapTypeId.ROADMAP, MAP_TYPE] }, mapTypeControl: false, mapTypeId: MAP_TYPE, panControl: true, panControlOptions: { position: google.maps.ControlPosition.RIGHT_TOP }, streetViewControl: false, zoom: parseInt($z.val(), 10), zoomControl: true, zoomControlOptions: { style: google.maps.ZoomControlStyle.LARGE, position: google.maps.ControlPosition.RIGHT_TOP } }; // Prepare the map map = new google.maps.Map(document.getElementById('grant-map'), options); map.mapTypes.set(MAP_TYPE, styledMap); // Bind our events bind(); } function updateCenter() { var center = map.getCenter(); $lat.val(center.lat()); $lon.val(center.lng()); } function onBoundsChange(e) { updateCenter(); // Update our zoom value for posting $z.val(map.getZoom()); if ($boundsCheck.is(':checked')) { handleMapChanges(); } else { GRANT_FORM.updatePath(); } } function onBoundsCheck() { // If we're checked if ($boundsCheck.is(':checked')) { // Populate the inputs if they check the box. addCoordsToForm(); } else { // Otherwise, empty the values to prevent surprises. ensureCoordsEmpty(); } // Submit our form to update the map. // This is another object, not included in this demo GRANT_FORM.submit(); } function handleMapChanges(){ addCoordsToForm(); // submit the form. GRANT_FORM.submit(); } function ensureCoordsEmpty(){ // Empty all the form's bounding inputs. $n.val(''); $e.val(''); $w.val(''); $s.val(''); } function addCoordsToForm(){ var coords = map.getBounds(), ne = coords.getNorthEast(), sw = coords.getSouthWest(), w = sw.lng(), n = ne.lat(), e = ne.lng(), s = sw.lat(); // set dem vals $n.val(n); $e.val(e); $s.val(s); $w.val(w); } return app; } (jQuery));

TLDR;

Don't listen to the zoom_changed event on a google map when using the Marker Clusterer plugin and instead use the bounds_changed event.