diff --git a/library/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.java b/library/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.java new file mode 100644 index 000000000..c49dcb634 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025 Google LLC + * + * 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.maps.android.clustering.ClusterItem; +import com.google.maps.android.geometry.Bounds; +import com.google.maps.android.quadtree.PointQuadTree; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +/** + * A variant of {@link CentroidNonHierarchicalDistanceBasedAlgorithm} that uses + * continuous zoom scaling and Euclidean distance for clustering. + * + *

This class overrides {@link #getClusteringItems(PointQuadTree, float)} to compute + * clusters with a zoom-dependent radius, while keeping the centroid-based cluster positions.

+ * + * @param the type of cluster item + */ +public class ContinuousZoomEuclideanCentroidAlgorithm + extends CentroidNonHierarchicalDistanceBasedAlgorithm { + + @Override + protected Collection> getClusteringItems(PointQuadTree> quadTree, float zoom) { + // Continuous zoom — no casting to int + final double zoomSpecificSpan = getMaxDistanceBetweenClusteredItems() / Math.pow(2, zoom) / 256; + + final Set> visitedCandidates = new HashSet<>(); + final Collection> result = new ArrayList<>(); + synchronized (mQuadTree) { + for (QuadItem candidate : mItems) { + if (visitedCandidates.contains(candidate)) continue; + + Bounds searchBounds = createBoundsFromSpan(candidate.getPoint(), zoomSpecificSpan); + Collection> clusterItems = new ArrayList<>(); + for (QuadItem clusterItem : mQuadTree.search(searchBounds)) { + double distance = distanceSquared(clusterItem.getPoint(), candidate.getPoint()); + double radiusSquared = Math.pow(zoomSpecificSpan / 2, 2); + if (distance < radiusSquared) { + clusterItems.add(clusterItem); + } + } + + visitedCandidates.addAll(clusterItems); + result.add(candidate); + } + } + return result; + } + +} diff --git a/library/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java b/library/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java index 894bb111f..f9bff856d 100644 --- a/library/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java +++ b/library/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java @@ -24,6 +24,7 @@ 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; @@ -53,12 +54,12 @@ public class NonHierarchicalDistanceBasedAlgorithm extend /** * Any modifications should be synchronized on mQuadTree. */ - private final Collection> mItems = new LinkedHashSet<>(); + protected final Collection> mItems = new LinkedHashSet<>(); /** * Any modifications should be synchronized on mQuadTree. */ - private final PointQuadTree> mQuadTree = new PointQuadTree<>(0, 1, 0, 1); + protected final PointQuadTree> mQuadTree = new PointQuadTree<>(0, 1, 0, 1); private static final SphericalMercatorProjection PROJECTION = new SphericalMercatorProjection(1); @@ -246,11 +247,25 @@ public int getMaxDistanceBetweenClusteredItems() { return mMaxDistance; } - private double distanceSquared(Point a, Point b) { + /** + * Calculates the squared Euclidean distance between two points. + * + * @param a the first point + * @param b the second point + * @return the squared Euclidean distance between {@code a} and {@code b} + */ + protected 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) { + /** + * Creates a square bounding box centered at a point with the specified span. + * + * @param p the center point + * @param span the total width/height of the bounding box + * @return the {@link Bounds} object representing the search area + */ + protected 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; @@ -260,7 +275,7 @@ private Bounds createBoundsFromSpan(Point p, double span) { } protected static class QuadItem implements PointQuadTree.Item, Cluster { - private final T mClusterItem; + protected final T mClusterItem; private final Point mPoint; private final LatLng mPosition; private Set singletonSet; diff --git a/library/src/test/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.java b/library/src/test/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.java new file mode 100644 index 000000000..02a998e96 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.java @@ -0,0 +1,117 @@ +/* + * Copyright 2025 Google LLC + * + * 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 androidx.annotation.NonNull; + +import com.google.android.gms.maps.model.LatLng; +import com.google.maps.android.clustering.Cluster; +import com.google.maps.android.clustering.ClusterItem; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class ContinuousZoomEuclideanCentroidAlgorithmTest { + + static class TestClusterItem implements ClusterItem { + private final LatLng position; + + TestClusterItem(double lat, double lng) { + this.position = new LatLng(lat, lng); + } + + @NonNull + @Override + public LatLng getPosition() { + return position; + } + + @Override + public String getTitle() { + return null; + } + + @Override + public String getSnippet() { + return null; + } + + @Override + public Float getZIndex() { + return 0f; + } + } + + @Test + public void testContinuousZoomMergesClosePairAtLowZoomAndSeparatesAtHighZoom() { + ContinuousZoomEuclideanCentroidAlgorithm algo = + new ContinuousZoomEuclideanCentroidAlgorithm<>(); + + Collection items = Arrays.asList( + new TestClusterItem(10.0, 10.0), + new TestClusterItem(10.0001, 10.0001), // very close to the first + new TestClusterItem(20.0, 20.0) // far away + ); + + algo.addItems(items); + + // At a high zoom, the close pair should be separate (small radius) + Set> highZoom = algo.getClusters(20.0f); + assertEquals(3, highZoom.size()); + + // At a lower zoom, the close pair should merge (larger radius) + Set> lowZoom = algo.getClusters(5.0f); + assertTrue(lowZoom.size() < 3); + + // Specifically, we expect one cluster of size 2 and one singleton + boolean hasClusterOfTwo = lowZoom.stream().anyMatch(c -> c.getItems().size() == 2); + boolean hasClusterOfOne = lowZoom.stream().anyMatch(c -> c.getItems().size() == 1); + assertTrue(hasClusterOfTwo); + assertTrue(hasClusterOfOne); + } + + @Test + public void testClusterPositionsAreCentroids() { + ContinuousZoomEuclideanCentroidAlgorithm algo = + new ContinuousZoomEuclideanCentroidAlgorithm<>(); + + Collection items = Arrays.asList( + new TestClusterItem(0.0, 0.0), + new TestClusterItem(0.0, 2.0), + new TestClusterItem(2.0, 0.0) + ); + + algo.addItems(items); + + Set> clusters = algo.getClusters(1.0f); + + // Expect all items clustered into one + assertEquals(1, clusters.size()); + + Cluster cluster = clusters.iterator().next(); + + // The centroid should be approximately (0.6667, 0.6667) + LatLng centroid = cluster.getPosition(); + assertEquals(0.6667, centroid.latitude, 0.0001); + assertEquals(0.6667, centroid.longitude, 0.0001); + } +}