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 extends Cluster> highZoom = algo.getClusters(20.0f);
+ assertEquals(3, highZoom.size());
+
+ // At a lower zoom, the close pair should merge (larger radius)
+ Set extends Cluster> 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 extends Cluster> 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);
+ }
+}