diff --git a/demo/AndroidManifest.xml b/demo/AndroidManifest.xml
index a22e10dc4..f849c0cbe 100644
--- a/demo/AndroidManifest.xml
+++ b/demo/AndroidManifest.xml
@@ -56,6 +56,7 @@
+
diff --git a/demo/res/raw/markers_same_location.json b/demo/res/raw/markers_same_location.json
new file mode 100644
index 000000000..1b5c33026
--- /dev/null
+++ b/demo/res/raw/markers_same_location.json
@@ -0,0 +1,7 @@
+[
+{ "lat" : 51.503186, "lng" : -0.126446, "title" : "Marker 1", "snippet": "Marker 1"},
+{ "lat" : 51.503186, "lng" : -0.126446, "title" : "Marker 2", "snippet" : "Marker 2"},
+{ "lat" : 51.503186, "lng" : -0.126446, "title" : "Marker 3", "snippet": "Marker 3"},
+{ "lat" : 51.503186, "lng" : -0.126446, "title" : "Marker 4", "snippet": "Marker 4" },
+{ "lat" : 51.503186, "lng" : -0.126446, "title" : "Marker 5", "snippet": "Marker 5"}
+]
\ No newline at end of file
diff --git a/demo/src/com/google/maps/android/utils/demo/ClusteringSameLocationActivity.java b/demo/src/com/google/maps/android/utils/demo/ClusteringSameLocationActivity.java
new file mode 100644
index 000000000..ddeb6dc43
--- /dev/null
+++ b/demo/src/com/google/maps/android/utils/demo/ClusteringSameLocationActivity.java
@@ -0,0 +1,44 @@
+package com.google.maps.android.utils.demo;
+
+import android.util.Log;
+import android.widget.Toast;
+
+import com.google.android.gms.maps.CameraUpdateFactory;
+import com.google.android.gms.maps.model.LatLng;
+import com.google.maps.android.clustering.ClusterManager;
+import com.google.maps.android.utils.demo.model.MyItem;
+
+import org.json.JSONException;
+
+import java.io.InputStream;
+import java.util.List;
+
+public class ClusteringSameLocationActivity extends BaseDemoActivity {
+
+ private ClusterManager mClusterManager;
+
+ @Override
+ protected void startDemo() {
+ getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 10));
+
+ mClusterManager = new ClusterManager<>(this, getMap());
+
+ getMap().setOnMarkerClickListener(mClusterManager);
+ getMap().setOnCameraMoveListener(mClusterManager);
+
+ try {
+ readItems();
+
+ mClusterManager.cluster();
+ } catch (JSONException e) {
+ Toast.makeText(this, "Problem reading list of markers.", Toast.LENGTH_LONG).show();
+ }
+ }
+
+ private void readItems() throws JSONException {
+ InputStream inputStream = getResources().openRawResource(R.raw.markers_same_location);
+ List items = new MyItemReader().read(inputStream);
+
+ mClusterManager.addItems(items);
+ }
+}
diff --git a/demo/src/com/google/maps/android/utils/demo/MainActivity.java b/demo/src/com/google/maps/android/utils/demo/MainActivity.java
index 7f8a7f7fe..2800cb648 100644
--- a/demo/src/com/google/maps/android/utils/demo/MainActivity.java
+++ b/demo/src/com/google/maps/android/utils/demo/MainActivity.java
@@ -38,6 +38,7 @@ protected void onCreate(Bundle savedInstanceState) {
addDemo("Clustering", ClusteringDemoActivity.class);
addDemo("Clustering: Custom Look", CustomMarkerClusteringDemoActivity.class);
addDemo("Clustering: 2K markers", BigClusteringDemoActivity.class);
+ addDemo("Clustering: Markers in same location", ClusteringSameLocationActivity.class);
addDemo("PolyUtil.decode", PolyDecodeDemoActivity.class);
addDemo("PolyUtil.simplify", PolySimplifyDemoActivity.class);
addDemo("IconGenerator", IconGeneratorDemoActivity.class);
diff --git a/demo/src/com/google/maps/android/utils/demo/model/MyItem.java b/demo/src/com/google/maps/android/utils/demo/model/MyItem.java
index 86abad829..43ff20088 100644
--- a/demo/src/com/google/maps/android/utils/demo/model/MyItem.java
+++ b/demo/src/com/google/maps/android/utils/demo/model/MyItem.java
@@ -47,6 +47,15 @@ public LatLng getPosition() {
@Override
public String getSnippet() { return mSnippet; }
+ @Override
+ public ClusterItem copy(double lat, double lng) {
+ MyItem item = new MyItem(lat, lng);
+ item.setSnippet(getSnippet());
+ item.setTitle(getTitle());
+
+ return item;
+ }
+
/**
* Set the title of the marker
* @param title string to be set as title
diff --git a/demo/src/com/google/maps/android/utils/demo/model/Person.java b/demo/src/com/google/maps/android/utils/demo/model/Person.java
index e69cc3494..54d0a458a 100644
--- a/demo/src/com/google/maps/android/utils/demo/model/Person.java
+++ b/demo/src/com/google/maps/android/utils/demo/model/Person.java
@@ -44,4 +44,9 @@ public String getTitle() {
public String getSnippet() {
return null;
}
+
+ @Override
+ public ClusterItem copy(double lat, double lng) {
+ return new Person(new LatLng(lat, lng), name, profilePhoto);
+ }
}
diff --git a/library/src/com/google/maps/android/clustering/ClusterItem.java b/library/src/com/google/maps/android/clustering/ClusterItem.java
index 746482490..21f8c82c9 100644
--- a/library/src/com/google/maps/android/clustering/ClusterItem.java
+++ b/library/src/com/google/maps/android/clustering/ClusterItem.java
@@ -37,4 +37,11 @@ public interface ClusterItem {
* The description of this marker.
*/
String getSnippet();
+
+ /**
+ * Produces a copy of the same object but setting the given location.
+ *
+ * @return The new object copied.
+ */
+ ClusterItem copy(double lat, double lng);
}
\ No newline at end of file
diff --git a/library/src/com/google/maps/android/clustering/ClusterManager.java b/library/src/com/google/maps/android/clustering/ClusterManager.java
index 83b5d25f1..0cfd2a032 100644
--- a/library/src/com/google/maps/android/clustering/ClusterManager.java
+++ b/library/src/com/google/maps/android/clustering/ClusterManager.java
@@ -27,10 +27,14 @@
import com.google.maps.android.clustering.algo.Algorithm;
import com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm;
import com.google.maps.android.clustering.algo.PreCachingAlgorithmDecorator;
+import com.google.maps.android.clustering.view.ClusterItemsDistributor;
import com.google.maps.android.clustering.view.ClusterRenderer;
+import com.google.maps.android.clustering.view.DefaultClusterItemsDistributor;
import com.google.maps.android.clustering.view.DefaultClusterRenderer;
import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
import java.util.Set;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
@@ -43,6 +47,7 @@
*/
public class ClusterManager implements
GoogleMap.OnCameraIdleListener,
+ GoogleMap.OnCameraMoveListener,
GoogleMap.OnMarkerClickListener,
GoogleMap.OnInfoWindowClickListener {
@@ -53,6 +58,7 @@ public class ClusterManager implements
private Algorithm mAlgorithm;
private final ReadWriteLock mAlgorithmLock = new ReentrantReadWriteLock();
private ClusterRenderer mRenderer;
+ private ClusterItemsDistributor mClusterItemsDistributor;
private GoogleMap mMap;
private CameraPosition mPreviousCameraPosition;
@@ -76,7 +82,16 @@ public ClusterManager(Context context, GoogleMap map, MarkerManager markerManage
mRenderer = new DefaultClusterRenderer(context, map, this);
mAlgorithm = new PreCachingAlgorithmDecorator(new NonHierarchicalDistanceBasedAlgorithm());
mClusterTask = new ClusterTask();
+ mClusterItemsDistributor = new DefaultClusterItemsDistributor<>(this);
mRenderer.onAdd();
+
+ setOnClusterClickListener(new ClusterManager.OnClusterClickListener() {
+
+ @Override
+ public boolean onClusterClick(Cluster cluster) {
+ return handleClusterClick(cluster);
+ }
+ });
}
public MarkerManager.Collection getMarkerCollection() {
@@ -123,6 +138,10 @@ public void setAnimation(boolean animate) {
mRenderer.setAnimation(animate);
}
+ public void setClusterItemsDistributor(ClusterItemsDistributor clusterItemsDistributor) {
+ this.mClusterItemsDistributor = clusterItemsDistributor;
+ }
+
public ClusterRenderer getRenderer() {
return mRenderer;
}
@@ -159,6 +178,13 @@ public void addItem(T myItem) {
}
}
+ public void removeItems(List items) {
+
+ for (T item : items) {
+ removeItem(item);
+ }
+ }
+
public void removeItem(T item) {
mAlgorithmLock.writeLock().lock();
try {
@@ -206,6 +232,15 @@ public void onCameraIdle() {
cluster();
}
+ @Override
+ public void onCameraMove() {
+
+ // collect markers to the original position if they were relocated
+ if (mMap.getCameraPosition().zoom < mMap.getMaxZoomLevel()) {
+ mClusterItemsDistributor.collect();
+ }
+ }
+
@Override
public boolean onMarkerClick(Marker marker) {
return getMarkerManager().onMarkerClick(marker);
@@ -216,6 +251,49 @@ public void onInfoWindowClick(Marker marker) {
getMarkerManager().onInfoWindowClick(marker);
}
+ public boolean itemsInSameLocation(Cluster cluster) {
+ Collection items = cluster.getItems();
+
+ if (items.size() < 2) {
+ return false;
+ }
+
+ Iterator iterator = items.iterator();
+ T item = iterator.next();
+
+ double longitude = item.getPosition().longitude;
+ double latitude = item.getPosition().latitude;
+
+ while (iterator.hasNext()) {
+ T t = iterator.next();
+
+ if (Double.compare(longitude, t.getPosition().longitude) != 0 && Double.compare(latitude, t.getPosition().latitude) != 0) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public boolean handleClusterClick(Cluster cluster) {
+ float maxZoomLevel = mMap.getMaxZoomLevel();
+ float currentZoomLevel = mMap.getCameraPosition().zoom;
+
+ // only show markers if users is in the max zoom level
+ if (currentZoomLevel != maxZoomLevel) {
+ return false;
+ }
+
+ if (!itemsInSameLocation(cluster)) {
+ return false;
+ }
+
+ // relocate the markers as defined in the distributor
+ mClusterItemsDistributor.distribute(cluster);
+
+ return true;
+ }
+
/**
* Runs the clustering algorithm in a background thread, then re-paints when results come back.
*/
diff --git a/library/src/com/google/maps/android/clustering/view/ClusterItemsDistributor.java b/library/src/com/google/maps/android/clustering/view/ClusterItemsDistributor.java
new file mode 100644
index 000000000..50a374dd6
--- /dev/null
+++ b/library/src/com/google/maps/android/clustering/view/ClusterItemsDistributor.java
@@ -0,0 +1,20 @@
+package com.google.maps.android.clustering.view;
+
+import com.google.maps.android.clustering.Cluster;
+import com.google.maps.android.clustering.ClusterItem;
+
+/**
+ * It distributes the items in a cluster.
+ */
+public interface ClusterItemsDistributor {
+
+ /**
+ * Proceed with the distribution of the items in a cluster.
+ */
+ void distribute(Cluster cluster);
+
+ /**
+ * Proceed to collect the items back to their previous state.
+ */
+ void collect();
+}
diff --git a/library/src/com/google/maps/android/clustering/view/DefaultClusterItemsDistributor.java b/library/src/com/google/maps/android/clustering/view/DefaultClusterItemsDistributor.java
new file mode 100644
index 000000000..7796e0865
--- /dev/null
+++ b/library/src/com/google/maps/android/clustering/view/DefaultClusterItemsDistributor.java
@@ -0,0 +1,72 @@
+package com.google.maps.android.clustering.view;
+
+import com.google.maps.android.clustering.Cluster;
+import com.google.maps.android.clustering.ClusterItem;
+import com.google.maps.android.clustering.ClusterManager;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The default distributor of items included in a cluster. It distributes the items around the original lat/lng in a given radius.
+ *
+ * @param Cluster item type.
+ */
+public class DefaultClusterItemsDistributor implements ClusterItemsDistributor {
+
+ private static final double DEFAULT_RADIUS = 0.00003;
+
+ private static final String DEFAULT_DELETE_LIST = "itemsDeleted";
+
+ private static final String DEFAULT_ADDED_LIST = "itemsAdded";
+
+ private double mDistributionRadius;
+
+ private ClusterManager mClusterManager;
+
+ private Map> mItemsCache;
+
+ public DefaultClusterItemsDistributor(ClusterManager clusterManager) {
+ this(clusterManager, DEFAULT_RADIUS);
+ }
+
+ public DefaultClusterItemsDistributor(ClusterManager clusterManager, double distributionRadius) {
+ mClusterManager = clusterManager;
+ mDistributionRadius = distributionRadius;
+ mItemsCache = new HashMap<>();
+ mItemsCache.put(DEFAULT_ADDED_LIST, new ArrayList());
+ mItemsCache.put(DEFAULT_DELETE_LIST, new ArrayList());
+ }
+
+ @Override
+ public void distribute(Cluster cluster) {
+ // relocate the markers around the original markers position
+ int counter = 0;
+ float rotateFactor = (360 / cluster.getItems().size());
+
+ for (T item : cluster.getItems()) {
+ double lat = item.getPosition().latitude + (mDistributionRadius * Math.cos(++counter * rotateFactor));
+ double lng = item.getPosition().longitude + (mDistributionRadius * Math.sin(counter * rotateFactor));
+ T copy = (T) item.copy(lat, lng);
+
+ mClusterManager.removeItem(item);
+ mClusterManager.addItem(copy);
+ mClusterManager.cluster();
+
+ mItemsCache.get(DEFAULT_ADDED_LIST).add(copy);
+ mItemsCache.get(DEFAULT_DELETE_LIST).add(item);
+ }
+ }
+
+ public void collect() {
+ // collect the items
+ mClusterManager.removeItems(mItemsCache.get(DEFAULT_ADDED_LIST));
+ mClusterManager.addItems(mItemsCache.get(DEFAULT_DELETE_LIST));
+ mClusterManager.cluster();
+
+ mItemsCache.get(DEFAULT_ADDED_LIST).clear();
+ mItemsCache.get(DEFAULT_DELETE_LIST).clear();
+ }
+}
diff --git a/library/tests/src/com/google/maps/android/clustering/QuadItemTest.java b/library/tests/src/com/google/maps/android/clustering/QuadItemTest.java
index d3096549d..8928db3d6 100644
--- a/library/tests/src/com/google/maps/android/clustering/QuadItemTest.java
+++ b/library/tests/src/com/google/maps/android/clustering/QuadItemTest.java
@@ -44,6 +44,11 @@ public String getTitle() {
public String getSnippet() {
return null;
}
+
+ @Override
+ public TestingItem copy(double lat, double lng) {
+ return new TestingItem(lat, lng);
+ }
}
public void setUp() {