128
votes

Bit stuck on this one. I am retrieving a list of geo coords via JSON and popping them onto a google map. All is working well except in the instance when I have two or more markers on the exact same spot. The API only displays 1 marker - the top one. This is fair enough I suppose but would like to find a way to display them all somehow.

I've searched google and found a few solutions but they mostly seem to be for V2 of the API or just not that great. Ideally I'd like a solution where you click some sort of group marker and that then shows the markers clustered around the spot they are all in.

Anybody had this problem or similar and would care to share a solution?

19

19 Answers

121
votes

Take a look at OverlappingMarkerSpiderfier.
There's a demo page, but they don't show markers which are exactly on the same spot, only some which are very close together.

But a real life example with markers on the exact same spot can be seen on http://www.ejw.de/ejw-vor-ort/ (scroll down for the map and click on a few markers to see the spider-effect).

That seems to be the perfect solution for your problem.

37
votes

Offsetting the markers isn't a real solution if they're located in the same building. What you might want to do is modify the markerclusterer.js like so:

  1. Add a prototype click method in the MarkerClusterer class, like so - we will override this later in the map initialize() function:

    MarkerClusterer.prototype.onClick = function() { 
        return true; 
    };
    
  2. In the ClusterIcon class, add the following code AFTER the clusterclick trigger:

    // Trigger the clusterclick event.
    google.maps.event.trigger(markerClusterer, 'clusterclick', this.cluster_);
    
    var zoom = this.map_.getZoom();
    var maxZoom = markerClusterer.getMaxZoom();
    // if we have reached the maxZoom and there is more than 1 marker in this cluster
    // use our onClick method to popup a list of options
    if (zoom >= maxZoom && this.cluster_.markers_.length > 1) {
       return markerClusterer.onClickZoom(this);
    }
    
  3. Then, in your initialize() function where you initialize the map and declare your MarkerClusterer object:

    markerCluster = new MarkerClusterer(map, markers);
    // onClickZoom OVERRIDE
    markerCluster.onClickZoom = function() { return multiChoice(markerCluster); }
    

    Where multiChoice() is YOUR (yet to be written) function to popup an InfoWindow with a list of options to select from. Note that the markerClusterer object is passed to your function, because you will need this to determine how many markers there are in that cluster. For example:

    function multiChoice(mc) {
         var cluster = mc.clusters_;
         // if more than 1 point shares the same lat/long
         // the size of the cluster array will be 1 AND
         // the number of markers in the cluster will be > 1
         // REMEMBER: maxZoom was already reached and we can't zoom in anymore
         if (cluster.length == 1 && cluster[0].markers_.length > 1)
         {
              var markers = cluster[0].markers_;
              for (var i=0; i < markers.length; i++)
              {
                  // you'll probably want to generate your list of options here...
              }
    
              return false;
         }
    
         return true;
    }
    
18
votes

I used this alongside jQuery and it does the job:

var map;
var markers = [];
var infoWindow;

function initialize() {
    var center = new google.maps.LatLng(-29.6833300, 152.9333300);

    var mapOptions = {
        zoom: 5,
        center: center,
        panControl: false,
        zoomControl: false,
        mapTypeControl: false,
        scaleControl: false,
        streetViewControl: false,
        overviewMapControl: false,
        mapTypeId: google.maps.MapTypeId.ROADMAP
      }


    map = new google.maps.Map(document.getElementById('map-canvas'), mapOptions);

    $.getJSON('jsonbackend.php', function(data) {
        infoWindow = new google.maps.InfoWindow();

        $.each(data, function(key, val) {
            if(val['LATITUDE']!='' && val['LONGITUDE']!='')
            {                
                // Set the coordonates of the new point
                var latLng = new google.maps.LatLng(val['LATITUDE'],val['LONGITUDE']);

                //Check Markers array for duplicate position and offset a little
                if(markers.length != 0) {
                    for (i=0; i < markers.length; i++) {
                        var existingMarker = markers[i];
                        var pos = existingMarker.getPosition();
                        if (latLng.equals(pos)) {
                            var a = 360.0 / markers.length;
                            var newLat = pos.lat() + -.00004 * Math.cos((+a*i) / 180 * Math.PI);  //x
                            var newLng = pos.lng() + -.00004 * Math.sin((+a*i) / 180 * Math.PI);  //Y
                            var latLng = new google.maps.LatLng(newLat,newLng);
                        }
                    }
                }

                // Initialize the new marker
                var marker = new google.maps.Marker({map: map, position: latLng, title: val['TITLE']});

                // The HTML that is shown in the window of each item (when the icon it's clicked)
                var html = "<div id='iwcontent'><h3>"+val['TITLE']+"</h3>"+
                "<strong>Address: </strong>"+val['ADDRESS']+", "+val['SUBURB']+", "+val['STATE']+", "+val['POSTCODE']+"<br>"+
                "</div>";

                // Binds the infoWindow to the point
                bindInfoWindow(marker, map, infoWindow, html);

                // Add the marker to the array
                markers.push(marker);
            }
        });

        // Make a cluster with the markers from the array
        var markerCluster = new MarkerClusterer(map, markers, { zoomOnClick: true, maxZoom: 15, gridSize: 20 });
    });
}

function markerOpen(markerid) {
    map.setZoom(22);
    map.panTo(markers[markerid].getPosition());
    google.maps.event.trigger(markers[markerid],'click');
    switchView('map');
}

google.maps.event.addDomListener(window, 'load', initialize);
14
votes

Expanding on Chaoley's answer, I implemented a function that, given a list of locations (objects with lng and lat properties) whose coordinates are exactly the same, moves them away from their original location a little bit (modifying objects in place). They then form a nice circle around the center point.

I found that, for my latitude (52deg North), 0.0003 degrees of circle radius work best, and that you have to make up for the difference between latitude and longitude degrees when converted to kilometres. You can find approximate conversions for your latitude here.

var correctLocList = function (loclist) {
    var lng_radius = 0.0003,         // degrees of longitude separation
        lat_to_lng = 111.23 / 71.7,  // lat to long proportion in Warsaw
        angle = 0.5,                 // starting angle, in radians
        loclen = loclist.length,
        step = 2 * Math.PI / loclen,
        i,
        loc,
        lat_radius = lng_radius / lat_to_lng;
    for (i = 0; i < loclen; ++i) {
        loc = loclist[i];
        loc.lng = loc.lng + (Math.cos(angle) * lng_radius);
        loc.lat = loc.lat + (Math.sin(angle) * lat_radius);
        angle += step;
    }
};
11
votes

@Ignatius most excellent answer, updated to work with v2.0.7 of MarkerClustererPlus.

  1. Add a prototype click method in the MarkerClusterer class, like so - we will override this later in the map initialize() function:

    // BEGIN MODIFICATION (around line 715)
    MarkerClusterer.prototype.onClick = function() { 
        return true; 
    };
    // END MODIFICATION
    
  2. In the ClusterIcon class, add the following code AFTER the click/clusterclick trigger:

    // EXISTING CODE (around line 143)
    google.maps.event.trigger(mc, "click", cClusterIcon.cluster_);
    google.maps.event.trigger(mc, "clusterclick", cClusterIcon.cluster_); // deprecated name
    
    // BEGIN MODIFICATION
    var zoom = mc.getMap().getZoom();
    // Trying to pull this dynamically made the more zoomed in clusters not render
    // when then kind of made this useless. -NNC @ BNB
    // var maxZoom = mc.getMaxZoom();
    var maxZoom = 15;
    // if we have reached the maxZoom and there is more than 1 marker in this cluster
    // use our onClick method to popup a list of options
    if (zoom >= maxZoom && cClusterIcon.cluster_.markers_.length > 1) {
        return mc.onClick(cClusterIcon);
    }
    // END MODIFICATION
    
  3. Then, in your initialize() function where you initialize the map and declare your MarkerClusterer object:

    markerCluster = new MarkerClusterer(map, markers);
    // onClick OVERRIDE
    markerCluster.onClick = function(clickedClusterIcon) { 
      return multiChoice(clickedClusterIcon.cluster_); 
    }
    

    Where multiChoice() is YOUR (yet to be written) function to popup an InfoWindow with a list of options to select from. Note that the markerClusterer object is passed to your function, because you will need this to determine how many markers there are in that cluster. For example:

    function multiChoice(clickedCluster) {
      if (clickedCluster.getMarkers().length > 1)
      {
        // var markers = clickedCluster.getMarkers();
        // do something creative!
        return false;
      }
      return true;
    };
    
6
votes

The answers above are more elegant, but I found a quick and dirty way that actually works really really incredibly well. You can see it in action at www.buildinglit.com

All I did was add a random offset to the latitude and longditude to my genxml.php page so it returns slightly different results each time with offset each time the map is created with markers. This sounds like a hack, but in reality you only need the markers to move a slight nudge in a random direction for them to be clickable on the map if they are overlapping. It actually works really well, I would say better than the spider method because who wants to deal with that complexity and have them spring everywhere. You just want to be able to select the marker. Nudging it randomly works perfect.

Here is an example of the while statement iteration node creation in my php_genxml.php

while ($row = @mysql_fetch_assoc($result)){ $offset = rand(0,1000)/10000000;
$offset2 = rand(0, 1000)/10000000;
$node = $dom->createElement("marker");
$newnode = $parnode->appendChild($node);
$newnode->setAttribute("name", $row['name']);
$newnode->setAttribute("address", $row['address']);
$newnode->setAttribute("lat", $row['lat'] + $offset);
$newnode->setAttribute("lng", $row['lng'] + $offset2);
$newnode->setAttribute("distance", $row['distance']);
$newnode->setAttribute("type", $row['type']);
$newnode->setAttribute("date", $row['date']);
$newnode->setAttribute("service", $row['service']);
$newnode->setAttribute("cost", $row['cost']);
$newnode->setAttribute("company", $company);

Notice under lat and long there is the +offset. from the 2 variables above. I had to divide random by 0,1000 by 10000000 in order to get a decimal that was randomly small enough to just barely move the markers around. Feel free to tinker with that variable to get one that is more precise for your needs.

5
votes

This is more of a stopgap 'quick and dirty' solution similar to the one Matthew Fox suggests, this time using JavaScript.

In JavaScript you can just offset the lat and long of all of your locations by adding a small random offset to both e.g.

myLocation[i].Latitude+ = (Math.random() / 25000)

(I found that dividing by 25000 gives enough separation but doesn't move the marker significantly from the exact location e.g. a specific address)

This makes a reasonably good job of offsetting them from one another, but only after you've zoomed in closely. When zoomed out, it still won't be clear that there are multiple options for the location.

3
votes

For situations where there are multiple services in the same building you could offset the markers just a little, (say by .001 degree), in a radius from the actual point. This should also produce a nice visual effect.

2
votes

Check out Marker Clusterer for V3 - this library clusters nearby points into a group marker. The map zooms in when the clusters are clicked. I'd imagine when zoomed right in you'd still have the same problem with markers on the same spot though.

1
votes

Updated to work with MarkerClustererPlus.

  google.maps.event.trigger(mc, "click", cClusterIcon.cluster_);
  google.maps.event.trigger(mc, "clusterclick", cClusterIcon.cluster_); // deprecated name

  // BEGIN MODIFICATION
  var zoom = mc.getMap().getZoom();
  // Trying to pull this dynamically made the more zoomed in clusters not render
  // when then kind of made this useless. -NNC @ BNB
  // var maxZoom = mc.getMaxZoom();
  var maxZoom = 15;
  // if we have reached the maxZoom and there is more than 1 marker in this cluster
  // use our onClick method to popup a list of options
  if (zoom >= maxZoom && cClusterIcon.cluster_.markers_.length > 1) {
    var markers = cClusterIcon.cluster_.markers_;
    var a = 360.0 / markers.length;
    for (var i=0; i < markers.length; i++)
    {
        var pos = markers[i].getPosition();
        var newLat = pos.lat() + -.00004 * Math.cos((+a*i) / 180 * Math.PI);  // x
        var newLng = pos.lng() + -.00004 * Math.sin((+a*i) / 180 * Math.PI);  // Y
        var finalLatLng = new google.maps.LatLng(newLat,newLng);
        markers[i].setPosition(finalLatLng);
        markers[i].setMap(cClusterIcon.cluster_.map_);
    }
    cClusterIcon.hide();
    return ;
  }
  // END MODIFICATION
1
votes

I like simple solutions so here's mine. Instead of modifying the lib, which would make it harder to mantain. you can simply watch the event like this

google.maps.event.addListener(mc, "clusterclick", onClusterClick);

then you can manage it on

function onClusterClick(cluster){
    var ms = cluster.getMarkers();

i, ie, used bootstrap to show a panel with a list. which i find much more confortable and usable than spiderfying on "crowded" places. (if you are using a clusterer chances are you will end up with collisions once you spiderfy). you can check the zoom there too.

btw. i just found leaflet and it seems to work much better, the cluster AND spiderfy works very fluidly http://leaflet.github.io/Leaflet.markercluster/example/marker-clustering-realworld.10000.html and it's open-source.

0
votes

Check this: https://github.com/plank/MarkerClusterer

This is the MarkerCluster modified to have a infoWindow in a cluster marker, when you have several markers in the same position.

You can see how it works here: http://culturedays.ca/en/2013-activities

0
votes

Expanding on the answers given above, just ensure you set maxZoom option when initializing the map object.

0
votes

Giving offset will make the markers faraway when the user zoom in to max. So i found a way to achieve that. this may not be a proper way but it worked very well.

// This code is in swift
for loop markers
{
//create marker
let mapMarker = GMSMarker()
mapMarker.groundAnchor = CGPosition(0.5, 0.5)
mapMarker.position = //set the CLLocation
//instead of setting marker.icon set the iconView
let image:UIIMage = UIIMage:init(named:"filename")
let imageView:UIImageView = UIImageView.init(frame:rect(0,0, ((image.width/2 * markerIndex) + image.width), image.height))
imageView.contentMode = .Right
imageView.image = image
mapMarker.iconView = imageView
mapMarker.map = mapView
}

set the zIndex of the marker so that you will see the marker icon which you want to see on top, otherwise it will animate the markers like auto swapping. when the user tap the marker handle the zIndex to bring the marker on top using zIndex Swap.

0
votes

How to get away with it.. [Swift]

    var clusterArray = [String]()
    var pinOffSet : Double = 0
    var pinLat = yourLat
    var pinLong = yourLong
    var location = pinLat + pinLong

A new marker is about to be created? check clusterArray and manipulate it's offset

 if(!clusterArray.contains(location)){
        clusterArray.append(location)
    } else {

        pinOffSet += 1
        let offWithIt = 0.00025 // reasonable offset with zoomLvl(14-16)
        switch pinOffSet {
        case 1 : pinLong = pinLong + offWithIt ; pinLat = pinLat + offWithIt
        case 2 : pinLong = pinLong + offWithIt ; pinLat = pinLat - offWithIt
        case 3 : pinLong = pinLong - offWithIt ; pinLat = pinLat - offWithIt
        case 4 : pinLong = pinLong - offWithIt ; pinLat = pinLat + offWithIt
        default : print(1)
        }


    }

result

enter image description here

0
votes

Adding to Matthew Fox's sneaky genius answer, I have added a small random offset to each lat and lng when setting the marker object. For example:

new LatLng(getLat()+getMarkerOffset(), getLng()+getMarkerOffset()),

private static double getMarkerOffset(){
    //add tiny random offset to keep markers from dropping on top of themselves
    double offset =Math.random()/4000;
    boolean isEven = ((int)(offset *400000)) %2 ==0;
    if (isEven) return  offset;
    else        return -offset;
}
0
votes

Adding to above answers but offering an alternative quick solution in php and wordpress. For this example I am storing the location field via ACF and looping through the posts to grab that data.

I found that storing the lat / lng in an array and check the value of that array to see if the loop matches, we can then update the value within that array with the amount we want to shift our pips by.

//This is the value to shift the pips by. I found this worked best with markerClusterer
$add_to_latlng = 0.00003;

while ($query->have_posts()) {
    $query->the_post();
    $meta = get_post_meta(get_the_ID(), "postcode", true); //uses an acf field to store location
    $lat = $meta["lat"];
    $lng = $meta["lng"];

    if(in_array($meta["lat"],$lat_checker)){ //check if this meta matches
        
        //if matches then update the array to a new value (current value + shift value)
        // This is using the lng value for a horizontal line of pips, use lat for vertical, or both for a diagonal
        if(isset($latlng_storer[$meta["lng"]])){
            $latlng_storer[$meta["lng"]] = $latlng_storer[$meta["lng"]] + $add_to_latlng;
            $lng = $latlng_storer[$meta["lng"]];
        } else {
            $latlng_storer[$meta["lng"]] = $meta["lng"];
            $lng = $latlng_storer[$meta["lng"]];
        }

    } else {
        $lat_checker[] = $meta["lat"]; //just for simple checking of data
        $latlng_storer[$meta["lat"]] = floatval($meta["lat"]);
        $latlng_storer[$meta["lng"]] =  floatval($meta["lng"]);
    }

    $entry[] = [
        "lat" => $lat,
        "lng" => $lng,
    //...Add all the other post data here and use this array for the pips
    ];
} // end query

Once I've grabbed these locations I json encode the $entry variable and use that within my JS.

let locations = <?=json_encode($entry)?>;

I know this is a rather specific situation but I hope this helps someone along the line!

0
votes

I used markerclustererplus, and for me this works:

//Code
google.maps.event.addListener(cMarkerClusterer, "clusterclick", function (c) {
            var markers = c.getMarkers();
            
            //Check Markers array for duplicate position and offset a little
            if (markers .length > 1) {
                //Check if all markers are in the same position (with 4 significant digits)
                if (markers .every((val, index, arr) => (val.getPosition().lat().toFixed(4) == arr[0].getPosition().lat().toFixed(4)) && (val.getPosition().lng().toFixed(4) == arr[0].getPosition().lng().toFixed(4)))) { /
                    //Don't modify first element
                    for (i = 1; i < markers.length; i++) {
                        var existingMarker = markers[i];
                        var pos = existingMarker.getPosition();                        
                        var quot = 360.0 / markers.length;
                        var newLat = pos.lat() + -.00008 * Math.cos(+quot * i); //+ -.00008 * Math.cos((+quot * i) / 180 * Math.PI);  //x                        
                        var newLng = pos.lng() + -.00008 * Math.sin(+quot * i);  //+ -.0008 * Math.sin((+quot * i) / 180 * Math.PI);  //Y
                        existingMarker.setPosition(new google.maps.LatLng(newLat, newLng));                        
                    }
                    let cZoom = map.getZoom();
                    map.setZoom(cZoom-1);
                    map.setZoom(cZoom+1);
                } 
            }            
        });
-2
votes

Extending answers above, when you got joined strings, not added/subtracted position (e.g. "37.12340-0.00069"), convert your original lat/longitude to floats, e.g. using parseFloat(), then add or subtract corrections.