diff --git a/demo/src/com/google/maps/android/utils/demo/VisibleClusteringDemoActivity.java b/demo/src/com/google/maps/android/utils/demo/VisibleClusteringDemoActivity.java index 8d66235c0..e9606e55c 100644 --- a/demo/src/com/google/maps/android/utils/demo/VisibleClusteringDemoActivity.java +++ b/demo/src/com/google/maps/android/utils/demo/VisibleClusteringDemoActivity.java @@ -1,3 +1,19 @@ +/* + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.google.maps.android.utils.demo; import android.util.DisplayMetrics; @@ -6,7 +22,7 @@ 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.clustering.algo.VisibleNonHierarchicalDistanceBasedAlgorithm; +import com.google.maps.android.clustering.algo.NonHierarchicalViewBasedAlgorithm; import com.google.maps.android.utils.demo.model.MyItem; import org.json.JSONException; @@ -25,8 +41,8 @@ protected void startDemo() { getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 10)); mClusterManager = new ClusterManager(this, getMap()); - mClusterManager.setClusterOnlyVisibleArea(true); - mClusterManager.setAlgorithm(new VisibleNonHierarchicalDistanceBasedAlgorithm(metrics.widthPixels, metrics.heightPixels)); + mClusterManager.setAlgorithm(new NonHierarchicalViewBasedAlgorithm( + metrics.widthPixels, metrics.heightPixels)); getMap().setOnCameraChangeListener(mClusterManager); diff --git a/library/src/com/google/maps/android/clustering/ClusterManager.java b/library/src/com/google/maps/android/clustering/ClusterManager.java index 6096639a7..6d946471b 100644 --- a/library/src/com/google/maps/android/clustering/ClusterManager.java +++ b/library/src/com/google/maps/android/clustering/ClusterManager.java @@ -27,6 +27,8 @@ 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.algo.ScreenBasedAlgorithm; +import com.google.maps.android.clustering.algo.ScreenBasedAlgorithmAdapter; import com.google.maps.android.clustering.view.ClusterRenderer; import com.google.maps.android.clustering.view.DefaultClusterRenderer; @@ -41,12 +43,14 @@ * ClusterManager should be added to the map as an:
  • {@link com.google.android.gms.maps.GoogleMap.OnCameraChangeListener}
  • *
  • {@link com.google.android.gms.maps.GoogleMap.OnMarkerClickListener}
*/ -public class ClusterManager implements GoogleMap.OnCameraChangeListener, GoogleMap.OnMarkerClickListener, GoogleMap.OnInfoWindowClickListener { +public class ClusterManager implements GoogleMap.OnCameraChangeListener, + GoogleMap.OnMarkerClickListener, GoogleMap.OnInfoWindowClickListener { + private final MarkerManager mMarkerManager; private final MarkerManager.Collection mMarkers; private final MarkerManager.Collection mClusterMarkers; - private Algorithm mAlgorithm; + private ScreenBasedAlgorithm mAlgorithm; private final ReadWriteLock mAlgorithmLock = new ReentrantReadWriteLock(); private ClusterRenderer mRenderer; @@ -59,7 +63,6 @@ public class ClusterManager implements GoogleMap.OnCamera private OnClusterInfoWindowClickListener mOnClusterInfoWindowClickListener; private OnClusterItemInfoWindowClickListener mOnClusterItemInfoWindowClickListener; private OnClusterClickListener mOnClusterClickListener; - private boolean mShowOnlyVisibleArea; public ClusterManager(Context context, GoogleMap map) { this(context, map, new MarkerManager(map)); @@ -71,7 +74,9 @@ public ClusterManager(Context context, GoogleMap map, MarkerManager markerManage mClusterMarkers = markerManager.newCollection(); mMarkers = markerManager.newCollection(); mRenderer = new DefaultClusterRenderer(context, map, this); - mAlgorithm = new PreCachingAlgorithmDecorator(new NonHierarchicalDistanceBasedAlgorithm()); + mAlgorithm = new ScreenBasedAlgorithmAdapter(new PreCachingAlgorithmDecorator( + new NonHierarchicalDistanceBasedAlgorithm())); + mClusterTask = new ClusterTask(); mRenderer.onAdd(); } @@ -104,6 +109,10 @@ public void setRenderer(ClusterRenderer view) { } public void setAlgorithm(Algorithm algorithm) { + setAlgorithm(new ScreenBasedAlgorithmAdapter(algorithm)); + } + + public void setAlgorithm(ScreenBasedAlgorithm algorithm) { mAlgorithmLock.writeLock().lock(); try { if (mAlgorithm != null) { @@ -115,17 +124,13 @@ public void setAlgorithm(Algorithm algorithm) { mAlgorithmLock.writeLock().unlock(); } - if (mAlgorithm instanceof GoogleMap.OnCameraChangeListener) { - ((GoogleMap.OnCameraChangeListener) mAlgorithm).onCameraChange(mMap.getCameraPosition()); + if (mAlgorithm.shouldReclusterOnMapMovement()) { + mAlgorithm.onCameraChange(mMap.getCameraPosition()); } cluster(); } - public void setClusterOnlyVisibleArea(boolean onlyVisibleArea) { - mShowOnlyVisibleArea = onlyVisibleArea; - } - public void clearItems() { mAlgorithmLock.writeLock().lock(); try { @@ -193,14 +198,13 @@ public void onCameraChange(CameraPosition cameraPosition) { ((GoogleMap.OnCameraChangeListener) mRenderer).onCameraChange(cameraPosition); } - if (mAlgorithm instanceof GoogleMap.OnCameraChangeListener) { - ((GoogleMap.OnCameraChangeListener) mAlgorithm).onCameraChange(cameraPosition); - } + mAlgorithm.onCameraChange(cameraPosition); - // Don't re-compute clusters if the map has just been panned/tilted/rotated. - if (mShowOnlyVisibleArea) { - // algorithm will decide if it is need to recompute clusters + // delegate clustering to the algorithm + if (mAlgorithm.shouldReclusterOnMapMovement()) { cluster(); + + // Don't re-compute clusters if the map has just been panned/tilted/rotated. } else if (mPreviousCameraPosition == null || mPreviousCameraPosition.zoom != cameraPosition.zoom) { mPreviousCameraPosition = mMap.getCameraPosition(); cluster(); diff --git a/library/src/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java b/library/src/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java index b7a243667..b4b32341b 100644 --- a/library/src/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java +++ b/library/src/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java @@ -108,7 +108,7 @@ public Set> getClusters(double zoom) { final Map, StaticCluster> itemToCluster = new HashMap, StaticCluster>(); synchronized (mQuadTree) { - for (QuadItem candidate : mItems) { + for (QuadItem candidate : getClusteringItems(mQuadTree, discreteZoom)) { if (visitedCandidates.contains(candidate)) { // Candidate is already part of another cluster. continue; @@ -148,6 +148,10 @@ public Set> getClusters(double zoom) { return results; } + protected Collection> getClusteringItems(PointQuadTree> quadTree, int discreteZoom) { + return mItems; + } + @Override public Collection getItems() { final List items = new ArrayList(); @@ -172,7 +176,7 @@ private Bounds createBoundsFromSpan(Point p, double span) { p.y - halfSpan, p.y + halfSpan); } - private static class QuadItem implements PointQuadTree.Item, Cluster { + static class QuadItem implements PointQuadTree.Item, Cluster { private final T mClusterItem; private final Point mPoint; private final LatLng mPosition; diff --git a/library/src/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.java b/library/src/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.java new file mode 100644 index 000000000..ecacf1df2 --- /dev/null +++ b/library/src/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.java @@ -0,0 +1,89 @@ +/* + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.clustering.algo; + +import com.google.android.gms.maps.model.CameraPosition; +import com.google.android.gms.maps.model.LatLng; +import com.google.maps.android.clustering.ClusterItem; +import com.google.maps.android.geometry.Bounds; +import com.google.maps.android.projection.Point; +import com.google.maps.android.projection.SphericalMercatorProjection; +import com.google.maps.android.quadtree.PointQuadTree; + +import java.util.Collection; + +/** + * This algorithm works the same way as {@link NonHierarchicalDistanceBasedAlgorithm} but works, only in + * visible area. It requires to be reclustered on camera movement because clustering is done only for visible area. + * @param + */ +public class NonHierarchicalViewBasedAlgorithm + extends NonHierarchicalDistanceBasedAlgorithm implements ScreenBasedAlgorithm { + + private static final SphericalMercatorProjection PROJECTION = new SphericalMercatorProjection(1); + + private int mViewWidth; + private int mViewHeight; + + private LatLng mMapCenter; + + public NonHierarchicalViewBasedAlgorithm(int screenWidth, int screenHeight) { + mViewWidth = screenWidth; + mViewHeight = screenHeight; + } + + @Override + public void onCameraChange(CameraPosition cameraPosition) { + mMapCenter = cameraPosition.target; + } + + @Override + protected Collection> getClusteringItems(PointQuadTree> quadTree, int discreteZoom) { + return quadTree.search(getVisibleBounds(discreteZoom)); + } + + @Override + public boolean shouldReclusterOnMapMovement() { + return true; + } + + /** + * Update view width and height in case map size was changed. + * You need to recluster all the clusters, to update view state after view size changes. + * @param width map width + * @param height map height + */ + public void updateViewSize(int width, int height) { + mViewWidth = width; + mViewHeight = height; + } + + private Bounds getVisibleBounds(int zoom) { + if (mMapCenter == null) { + return new Bounds(0, 0, 0, 0); + } + + Point p = PROJECTION.toPoint(mMapCenter); + + final double halfWidthSpan = mViewWidth / Math.pow(2, zoom) / 256 / 2; + final double halfHeightSpan = mViewHeight / Math.pow(2, zoom) / 256 / 2; + + return new Bounds( + p.x - halfWidthSpan, p.x + halfWidthSpan, + p.y - halfHeightSpan, p.y + halfHeightSpan); + } +} diff --git a/library/src/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.java b/library/src/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.java new file mode 100644 index 000000000..c08028509 --- /dev/null +++ b/library/src/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.java @@ -0,0 +1,31 @@ +/* + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.clustering.algo; + +import com.google.android.gms.maps.GoogleMap; +import com.google.maps.android.clustering.ClusterItem; + +/** + * + * This algorithm uses map position for clustering, and should be reclustered on map movement + * @param + */ + +public interface ScreenBasedAlgorithm extends Algorithm, GoogleMap.OnCameraChangeListener { + + boolean shouldReclusterOnMapMovement(); +} diff --git a/library/src/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.java b/library/src/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.java new file mode 100644 index 000000000..49e97cc27 --- /dev/null +++ b/library/src/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.java @@ -0,0 +1,73 @@ +/* + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.clustering.algo; + +import com.google.android.gms.maps.model.CameraPosition; +import com.google.maps.android.clustering.Cluster; +import com.google.maps.android.clustering.ClusterItem; + +import java.util.Collection; +import java.util.Set; + +public class ScreenBasedAlgorithmAdapter implements ScreenBasedAlgorithm { + + private Algorithm mAlgorithm; + + public ScreenBasedAlgorithmAdapter(Algorithm algorithm) { + mAlgorithm = algorithm; + } + + @Override + public boolean shouldReclusterOnMapMovement() { + return false; + } + + @Override + public void addItem(T item) { + mAlgorithm.addItem(item); + } + + @Override + public void addItems(Collection items) { + mAlgorithm.addItems(items); + } + + @Override + public void clearItems() { + mAlgorithm.clearItems(); + } + + @Override + public void removeItem(T item) { + mAlgorithm.removeItem(item); + } + + @Override + public Set> getClusters(double zoom) { + return mAlgorithm.getClusters(zoom); + } + + @Override + public Collection getItems() { + return mAlgorithm.getItems(); + } + + @Override + public void onCameraChange(CameraPosition cameraPosition) { + // stub + } +} diff --git a/library/src/com/google/maps/android/clustering/algo/VisibleNonHierarchicalDistanceBasedAlgorithm.java b/library/src/com/google/maps/android/clustering/algo/VisibleNonHierarchicalDistanceBasedAlgorithm.java deleted file mode 100644 index c8f65d573..000000000 --- a/library/src/com/google/maps/android/clustering/algo/VisibleNonHierarchicalDistanceBasedAlgorithm.java +++ /dev/null @@ -1,224 +0,0 @@ -package com.google.maps.android.clustering.algo; - -import com.google.android.gms.maps.GoogleMap; -import com.google.android.gms.maps.model.CameraPosition; -import com.google.android.gms.maps.model.LatLng; -import com.google.maps.android.clustering.Cluster; -import com.google.maps.android.clustering.ClusterItem; -import com.google.maps.android.geometry.Bounds; -import com.google.maps.android.geometry.Point; -import com.google.maps.android.projection.SphericalMercatorProjection; -import com.google.maps.android.quadtree.PointQuadTree; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -/** - * A simple clustering algorithm with O(nlog n) performance. Resulting clusters are not - * hierarchical. This algorithm will compute clusters only in visible area. - *

- * High level algorithm:
- * 1. Iterate over items in the order they were added (candidate clusters).
- * 2. Create a cluster with the center of the item.
- * 3. Add all items that are within a certain distance to the cluster.
- * 4. Move any items out of an existing cluster if they are closer to another cluster.
- * 5. Remove those items from the list of candidate clusters. - *

- * Clusters have the center of the first element (not the centroid of the items within it). - */ -public class VisibleNonHierarchicalDistanceBasedAlgorithm - implements Algorithm, GoogleMap.OnCameraChangeListener { - - public static final int MAX_DISTANCE_AT_ZOOM = 100; // essentially 100 dp. - - /** - * Any modifications should be synchronized on mQuadTree. - */ - private final Collection> mItems = new ArrayList>(); - - /** - * Any modifications should be synchronized on mQuadTree. - */ - private final PointQuadTree> mQuadTree = new PointQuadTree>(0, 1, 0, 1); - - private static final SphericalMercatorProjection PROJECTION = new SphericalMercatorProjection(1); - private final int mScreenWidth; - private final int mScreenHeight; - private LatLng mMapCenter; - - public VisibleNonHierarchicalDistanceBasedAlgorithm(int screenWidth, int screenHeight) { - mScreenWidth = screenWidth; - mScreenHeight = screenHeight; - } - - @Override - public void addItem(T item) { - final QuadItem quadItem = new QuadItem(item); - synchronized (mQuadTree) { - mItems.add(quadItem); - mQuadTree.add(quadItem); - } - } - - @Override - public void addItems(Collection items) { - for (T item : items) { - addItem(item); - } - } - - @Override - public void clearItems() { - synchronized (mQuadTree) { - mItems.clear(); - mQuadTree.clear(); - } - } - - @Override - public void removeItem(T item) { - // TODO: delegate QuadItem#hashCode and QuadItem#equals to its item. - throw new UnsupportedOperationException("VisibleNonHierarchicalDistanceBasedAlgorithm.remove not implemented"); - } - - @Override - public Set> getClusters(double zoom) { - final int discreteZoom = (int) zoom; - - final double zoomSpecificSpan = MAX_DISTANCE_AT_ZOOM / Math.pow(2, discreteZoom) / 256; - - final Set> visitedCandidates = new HashSet>(); - final Set> results = new HashSet>(); - final Map, Double> distanceToCluster = new HashMap, Double>(); - final Map, StaticCluster> itemToCluster = new HashMap, StaticCluster>(); - - synchronized (mQuadTree) { - - Bounds visibleBounds = getVisibleBounds(discreteZoom); - - Collection> items = mQuadTree.search(visibleBounds); - - for (QuadItem candidate : items) { - if (visitedCandidates.contains(candidate)) { - // Candidate is already part of another cluster. - continue; - } - - Bounds searchBounds = createBoundsFromSpan(candidate.getPoint(), zoomSpecificSpan); - Collection> clusterItems; - clusterItems = mQuadTree.search(searchBounds); - if (clusterItems.size() == 1) { - // Only the current marker is in range. Just add the single item to the results. - results.add(candidate); - visitedCandidates.add(candidate); - distanceToCluster.put(candidate, 0d); - continue; - } - StaticCluster cluster = new StaticCluster(candidate.mClusterItem.getPosition()); - results.add(cluster); - - for (QuadItem clusterItem : clusterItems) { - Double existingDistance = distanceToCluster.get(clusterItem); - double distance = distanceSquared(clusterItem.getPoint(), candidate.getPoint()); - if (existingDistance != null) { - // Item already belongs to another cluster. Check if it's closer to this cluster. - if (existingDistance < distance) { - continue; - } - // Move item to the closer cluster. - itemToCluster.get(clusterItem).remove(clusterItem.mClusterItem); - } - distanceToCluster.put(clusterItem, distance); - cluster.add(clusterItem.mClusterItem); - itemToCluster.put(clusterItem, cluster); - } - visitedCandidates.addAll(clusterItems); - } - } - return results; - } - - @Override - public Collection getItems() { - final List items = new ArrayList(); - synchronized (mQuadTree) { - for (QuadItem quadItem : mItems) { - items.add(quadItem.mClusterItem); - } - } - return items; - } - - private double distanceSquared(Point a, Point b) { - return (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y); - } - - private Bounds createBoundsFromSpan(Point p, double span) { - // TODO: Use a span that takes into account the visual size of the marker, not just its - // LatLng. - double halfSpan = span / 2; - return new Bounds( - p.x - halfSpan, p.x + halfSpan, - p.y - halfSpan, p.y + halfSpan); - } - - private Bounds getVisibleBounds(int zoom) { - if (mMapCenter == null) { - return new Bounds(0, 0, 0, 0); - } - - Point p = PROJECTION.toPoint(mMapCenter); - - final double halfWidthSpan = mScreenWidth / Math.pow(2, zoom) / 256 / 2; - final double halfHeightSpan = mScreenHeight / Math.pow(2, zoom) / 256 / 2; - - return new Bounds( - p.x - halfWidthSpan, p.x + halfWidthSpan, - p.y - halfHeightSpan, p.y + halfHeightSpan); - } - - @Override - public void onCameraChange(CameraPosition cameraPosition) { - mMapCenter = cameraPosition.target; - } - - private static class QuadItem implements PointQuadTree.Item, Cluster { - private final T mClusterItem; - private final Point mPoint; - private final LatLng mPosition; - private Set singletonSet; - - private QuadItem(T item) { - mClusterItem = item; - mPosition = item.getPosition(); - mPoint = PROJECTION.toPoint(mPosition); - singletonSet = Collections.singleton(mClusterItem); - } - - @Override - public Point getPoint() { - return mPoint; - } - - @Override - public LatLng getPosition() { - return mPosition; - } - - @Override - public Set getItems() { - return singletonSet; - } - - @Override - public int getSize() { - return 1; - } - } -}