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() {