Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Render only visible items on the map #217

Merged
merged 9 commits into from
Apr 17, 2018
1 change: 1 addition & 0 deletions demo/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
<activity android:name=".DistanceDemoActivity"/>
<activity android:name=".ClusteringDemoActivity"/>
<activity android:name=".BigClusteringDemoActivity"/>
<activity android:name=".VisibleClusteringDemoActivity"/>
<activity android:name=".CustomMarkerClusteringDemoActivity"/>
<activity android:name=".TileProviderAndProjectionDemo"/>
<activity android:name=".HeatmapsDemoActivity"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ protected void onCreate(Bundle savedInstanceState) {
addDemo("Clustering", ClusteringDemoActivity.class);
addDemo("Clustering: Custom Look", CustomMarkerClusteringDemoActivity.class);
addDemo("Clustering: 2K markers", BigClusteringDemoActivity.class);
addDemo("Clustering: 20k only visible markers", VisibleClusteringDemoActivity.class);
addDemo("PolyUtil.decode", PolyDecodeDemoActivity.class);
addDemo("IconGenerator", IconGeneratorDemoActivity.class);
addDemo("SphericalUtil.computeDistanceBetween", DistanceDemoActivity.class);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.google.maps.android.utils.demo;

import android.util.DisplayMetrics;
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.clustering.algo.VisibleNonHierarchicalDistanceBasedAlgorithm;
import com.google.maps.android.utils.demo.model.MyItem;

import org.json.JSONException;

import java.io.InputStream;
import java.util.List;

public class VisibleClusteringDemoActivity extends BaseDemoActivity {
private ClusterManager<MyItem> mClusterManager;

@Override
protected void startDemo() {
DisplayMetrics metrics = new DisplayMetrics();
getWindowManager().getDefaultDisplay().getMetrics(metrics);

getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 10));

mClusterManager = new ClusterManager<MyItem>(this, getMap());
mClusterManager.setClusterOnlyVisibleArea(true);
mClusterManager.setAlgorithm(new VisibleNonHierarchicalDistanceBasedAlgorithm<MyItem>(metrics.widthPixels, metrics.heightPixels));

getMap().setOnCameraChangeListener(mClusterManager);

try {
readItems();
} 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.radar_search);
List<MyItem> items = new MyItemReader().read(inputStream);
for (int i = 0; i < 100; i++) {
double offset = i / 60d;
for (MyItem item : items) {
LatLng position = item.getPosition();
double lat = position.latitude + offset;
double lng = position.longitude + offset;
MyItem offsetItem = new MyItem(lat, lng);
mClusterManager.addItem(offsetItem);
}
}
}
}
29 changes: 22 additions & 7 deletions library/src/com/google/maps/android/clustering/ClusterManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public class ClusterManager<T extends ClusterItem> implements GoogleMap.OnCamera
private OnClusterInfoWindowClickListener<T> mOnClusterInfoWindowClickListener;
private OnClusterItemInfoWindowClickListener<T> mOnClusterItemInfoWindowClickListener;
private OnClusterClickListener<T> mOnClusterClickListener;
private boolean mShowOnlyVisibleArea;

public ClusterManager(Context context, GoogleMap map) {
this(context, map, new MarkerManager(map));
Expand Down Expand Up @@ -92,13 +93,23 @@ public void setAlgorithm(Algorithm<T> algorithm) {
if (mAlgorithm != null) {
algorithm.addItems(mAlgorithm.getItems());
}
mAlgorithm = new PreCachingAlgorithmDecorator<T>(algorithm);

mAlgorithm = algorithm;
} finally {
mAlgorithmLock.writeLock().unlock();
}

if (mAlgorithm instanceof GoogleMap.OnCameraChangeListener) {
((GoogleMap.OnCameraChangeListener) mAlgorithm).onCameraChange(mMap.getCameraPosition());
}

cluster();
}

public void setClusterOnlyVisibleArea(boolean onlyVisibleArea) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that a user should have to provide an algorithm as well as specifying this property. Can you refactor such that this property is defined on the Algorithm implementation? e.g. add a method to Algorithm like boolean shouldReclusterOnMapMovement()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about back compatibility?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we could use Java 8 we could add a default method to the interface, but alas that's not an option.

How about extending Algorithm (e.g. ViewBasedAlgorithm) and use that to determine instead. You may even be able to move the onCameraChangeListener code in there, too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about this variant too, but in this case I will have to use instanceof to check, if this algorythm is ViewBasedAlgorithm, but if this algorithm will be decorated(like you do with PreCachingAlgorithmDecorator), it will not work at all. That's why I removed this decoration by default

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh, good point. My main concern is that the behaviour of using setClusterOnlyVisibleArea on anything other than the new Algorithm you've introduced is undefined.

The only other option available is to break the Algorithm interface by adding the method suggested above. I've thought about this a bit and I think that it's the right thing to do but I (or whoever releases the next version to Maven) will need to make sure we bump the version number appropriately so it's clear what's happening.

What do you think about that? Reasonable? Or too drastic?

mShowOnlyVisibleArea = onlyVisibleArea;
}

public void clearItems() {
mAlgorithmLock.writeLock().lock();
try {
Expand Down Expand Up @@ -166,14 +177,18 @@ public void onCameraChange(CameraPosition cameraPosition) {
((GoogleMap.OnCameraChangeListener) mRenderer).onCameraChange(cameraPosition);
}

// Don't re-compute clusters if the map has just been panned/tilted/rotated.
CameraPosition position = mMap.getCameraPosition();
if (mPreviousCameraPosition != null && mPreviousCameraPosition.zoom == position.zoom) {
return;
if (mAlgorithm instanceof GoogleMap.OnCameraChangeListener) {
((GoogleMap.OnCameraChangeListener) mAlgorithm).onCameraChange(cameraPosition);
}
mPreviousCameraPosition = mMap.getCameraPosition();

cluster();
// 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
cluster();
} else if (mPreviousCameraPosition == null || mPreviousCameraPosition.zoom != cameraPosition.zoom) {
mPreviousCameraPosition = mMap.getCameraPosition();
cluster();
}
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
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.
* <p/>
* High level algorithm:<br>
* 1. Iterate over items in the order they were added (candidate clusters).<br>
* 2. Create a cluster with the center of the item. <br>
* 3. Add all items that are within a certain distance to the cluster. <br>
* 4. Move any items out of an existing cluster if they are closer to another cluster. <br>
* 5. Remove those items from the list of candidate clusters.
* <p/>
* Clusters have the center of the first element (not the centroid of the items within it).
*/
public class VisibleNonHierarchicalDistanceBasedAlgorithm<T extends ClusterItem>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a lot of code duplicated here from NonHierarchicalDistanceBasedAlgorithm that you could save if you extended it. Can you do that? Feel free to loosen up the member privacy as needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I didn't wanted to change initial algorithm.
Ok, I will refactor NonHierarchicalDistanceBasedAlgorithm for inheritance.

implements Algorithm<T>, GoogleMap.OnCameraChangeListener {

public static final int MAX_DISTANCE_AT_ZOOM = 100; // essentially 100 dp.

/**
* Any modifications should be synchronized on mQuadTree.
*/
private final Collection<QuadItem<T>> mItems = new ArrayList<QuadItem<T>>();

/**
* Any modifications should be synchronized on mQuadTree.
*/
private final PointQuadTree<QuadItem<T>> mQuadTree = new PointQuadTree<QuadItem<T>>(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<T> quadItem = new QuadItem<T>(item);
synchronized (mQuadTree) {
mItems.add(quadItem);
mQuadTree.add(quadItem);
}
}

@Override
public void addItems(Collection<T> 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<? extends Cluster<T>> getClusters(double zoom) {
final int discreteZoom = (int) zoom;

final double zoomSpecificSpan = MAX_DISTANCE_AT_ZOOM / Math.pow(2, discreteZoom) / 256;

final Set<QuadItem<T>> visitedCandidates = new HashSet<QuadItem<T>>();
final Set<Cluster<T>> results = new HashSet<Cluster<T>>();
final Map<QuadItem<T>, Double> distanceToCluster = new HashMap<QuadItem<T>, Double>();
final Map<QuadItem<T>, StaticCluster<T>> itemToCluster = new HashMap<QuadItem<T>, StaticCluster<T>>();

synchronized (mQuadTree) {

Bounds visibleBounds = getVisibleBounds(discreteZoom);

Collection<QuadItem<T>> items = mQuadTree.search(visibleBounds);

for (QuadItem<T> candidate : items) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this block is straight from the NonHierarchicalDistanceBasedAlgorithm, it'd be great to pull it out into a separate method, on the parent class, when you subclass it.

if (visitedCandidates.contains(candidate)) {
// Candidate is already part of another cluster.
continue;
}

Bounds searchBounds = createBoundsFromSpan(candidate.getPoint(), zoomSpecificSpan);
Collection<QuadItem<T>> 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<T> cluster = new StaticCluster<T>(candidate.mClusterItem.getPosition());
results.add(cluster);

for (QuadItem<T> 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<T> getItems() {
final List<T> items = new ArrayList<T>();
synchronized (mQuadTree) {
for (QuadItem<T> 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<T extends ClusterItem> implements PointQuadTree.Item, Cluster<T> {
private final T mClusterItem;
private final Point mPoint;
private final LatLng mPosition;
private Set<T> 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<T> getItems() {
return singletonSet;
}

@Override
public int getSize() {
return 1;
}
}
}