Skip to content

Commit ae1fbbb

Browse files
kikosodkhawk
authored andcommitted
feat: added ContinuousZoomEuclideanCentroidAlgorithm (googlemaps#1559)
* feat: added ContinuousZoomEuclideanAlgorithm * feat: added ContinuousZoomEuclideanAlgorithm * feat: added ContinuousZoomEuclideanAlgorithm * feat: replaced methods in NonHierarchicalDistanceBasedAlgorithm.java * feat: refactored to create ContinuousZoomEuclideanCentroidAlgorithm.java
1 parent 723b46a commit ae1fbbb

File tree

3 files changed

+205
-5
lines changed

3 files changed

+205
-5
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.maps.android.clustering.algo;
18+
19+
import com.google.maps.android.clustering.ClusterItem;
20+
import com.google.maps.android.geometry.Bounds;
21+
import com.google.maps.android.quadtree.PointQuadTree;
22+
23+
import java.util.ArrayList;
24+
import java.util.Collection;
25+
import java.util.HashSet;
26+
import java.util.Set;
27+
28+
/**
29+
* A variant of {@link CentroidNonHierarchicalDistanceBasedAlgorithm} that uses
30+
* continuous zoom scaling and Euclidean distance for clustering.
31+
*
32+
* <p>This class overrides {@link #getClusteringItems(PointQuadTree, float)} to compute
33+
* clusters with a zoom-dependent radius, while keeping the centroid-based cluster positions.</p>
34+
*
35+
* @param <T> the type of cluster item
36+
*/
37+
public class ContinuousZoomEuclideanCentroidAlgorithm<T extends ClusterItem>
38+
extends CentroidNonHierarchicalDistanceBasedAlgorithm<T> {
39+
40+
@Override
41+
protected Collection<QuadItem<T>> getClusteringItems(PointQuadTree<QuadItem<T>> quadTree, float zoom) {
42+
// Continuous zoom — no casting to int
43+
final double zoomSpecificSpan = getMaxDistanceBetweenClusteredItems() / Math.pow(2, zoom) / 256;
44+
45+
final Set<QuadItem<T>> visitedCandidates = new HashSet<>();
46+
final Collection<QuadItem<T>> result = new ArrayList<>();
47+
synchronized (mQuadTree) {
48+
for (QuadItem<T> candidate : mItems) {
49+
if (visitedCandidates.contains(candidate)) continue;
50+
51+
Bounds searchBounds = createBoundsFromSpan(candidate.getPoint(), zoomSpecificSpan);
52+
Collection<QuadItem<T>> clusterItems = new ArrayList<>();
53+
for (QuadItem<T> clusterItem : mQuadTree.search(searchBounds)) {
54+
double distance = distanceSquared(clusterItem.getPoint(), candidate.getPoint());
55+
double radiusSquared = Math.pow(zoomSpecificSpan / 2, 2);
56+
if (distance < radiusSquared) {
57+
clusterItems.add(clusterItem);
58+
}
59+
}
60+
61+
visitedCandidates.addAll(clusterItems);
62+
result.add(candidate);
63+
}
64+
}
65+
return result;
66+
}
67+
68+
}

library/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import com.google.maps.android.projection.SphericalMercatorProjection;
2525
import com.google.maps.android.quadtree.PointQuadTree;
2626

27+
import java.util.ArrayList;
2728
import java.util.Collection;
2829
import java.util.Collections;
2930
import java.util.HashMap;
@@ -53,12 +54,12 @@ public class NonHierarchicalDistanceBasedAlgorithm<T extends ClusterItem> extend
5354
/**
5455
* Any modifications should be synchronized on mQuadTree.
5556
*/
56-
private final Collection<QuadItem<T>> mItems = new LinkedHashSet<>();
57+
protected final Collection<QuadItem<T>> mItems = new LinkedHashSet<>();
5758

5859
/**
5960
* Any modifications should be synchronized on mQuadTree.
6061
*/
61-
private final PointQuadTree<QuadItem<T>> mQuadTree = new PointQuadTree<>(0, 1, 0, 1);
62+
protected final PointQuadTree<QuadItem<T>> mQuadTree = new PointQuadTree<>(0, 1, 0, 1);
6263

6364
private static final SphericalMercatorProjection PROJECTION = new SphericalMercatorProjection(1);
6465

@@ -246,11 +247,25 @@ public int getMaxDistanceBetweenClusteredItems() {
246247
return mMaxDistance;
247248
}
248249

249-
private double distanceSquared(Point a, Point b) {
250+
/**
251+
* Calculates the squared Euclidean distance between two points.
252+
*
253+
* @param a the first point
254+
* @param b the second point
255+
* @return the squared Euclidean distance between {@code a} and {@code b}
256+
*/
257+
protected double distanceSquared(Point a, Point b) {
250258
return (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y);
251259
}
252260

253-
private Bounds createBoundsFromSpan(Point p, double span) {
261+
/**
262+
* Creates a square bounding box centered at a point with the specified span.
263+
*
264+
* @param p the center point
265+
* @param span the total width/height of the bounding box
266+
* @return the {@link Bounds} object representing the search area
267+
*/
268+
protected Bounds createBoundsFromSpan(Point p, double span) {
254269
// TODO: Use a span that takes into account the visual size of the marker, not just its
255270
// LatLng.
256271
double halfSpan = span / 2;
@@ -260,7 +275,7 @@ private Bounds createBoundsFromSpan(Point p, double span) {
260275
}
261276

262277
protected static class QuadItem<T extends ClusterItem> implements PointQuadTree.Item, Cluster<T> {
263-
private final T mClusterItem;
278+
protected final T mClusterItem;
264279
private final Point mPoint;
265280
private final LatLng mPosition;
266281
private Set<T> singletonSet;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing,
11+
* software distributed under the License is distributed on an "AS IS"
12+
* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.google.maps.android.clustering.algo;
17+
18+
import androidx.annotation.NonNull;
19+
20+
import com.google.android.gms.maps.model.LatLng;
21+
import com.google.maps.android.clustering.Cluster;
22+
import com.google.maps.android.clustering.ClusterItem;
23+
24+
import org.junit.Test;
25+
26+
import java.util.Arrays;
27+
import java.util.Collection;
28+
import java.util.Set;
29+
30+
import static org.junit.Assert.assertEquals;
31+
import static org.junit.Assert.assertTrue;
32+
33+
public class ContinuousZoomEuclideanCentroidAlgorithmTest {
34+
35+
static class TestClusterItem implements ClusterItem {
36+
private final LatLng position;
37+
38+
TestClusterItem(double lat, double lng) {
39+
this.position = new LatLng(lat, lng);
40+
}
41+
42+
@NonNull
43+
@Override
44+
public LatLng getPosition() {
45+
return position;
46+
}
47+
48+
@Override
49+
public String getTitle() {
50+
return null;
51+
}
52+
53+
@Override
54+
public String getSnippet() {
55+
return null;
56+
}
57+
58+
@Override
59+
public Float getZIndex() {
60+
return 0f;
61+
}
62+
}
63+
64+
@Test
65+
public void testContinuousZoomMergesClosePairAtLowZoomAndSeparatesAtHighZoom() {
66+
ContinuousZoomEuclideanCentroidAlgorithm<TestClusterItem> algo =
67+
new ContinuousZoomEuclideanCentroidAlgorithm<>();
68+
69+
Collection<TestClusterItem> items = Arrays.asList(
70+
new TestClusterItem(10.0, 10.0),
71+
new TestClusterItem(10.0001, 10.0001), // very close to the first
72+
new TestClusterItem(20.0, 20.0) // far away
73+
);
74+
75+
algo.addItems(items);
76+
77+
// At a high zoom, the close pair should be separate (small radius)
78+
Set<? extends Cluster<TestClusterItem>> highZoom = algo.getClusters(20.0f);
79+
assertEquals(3, highZoom.size());
80+
81+
// At a lower zoom, the close pair should merge (larger radius)
82+
Set<? extends Cluster<TestClusterItem>> lowZoom = algo.getClusters(5.0f);
83+
assertTrue(lowZoom.size() < 3);
84+
85+
// Specifically, we expect one cluster of size 2 and one singleton
86+
boolean hasClusterOfTwo = lowZoom.stream().anyMatch(c -> c.getItems().size() == 2);
87+
boolean hasClusterOfOne = lowZoom.stream().anyMatch(c -> c.getItems().size() == 1);
88+
assertTrue(hasClusterOfTwo);
89+
assertTrue(hasClusterOfOne);
90+
}
91+
92+
@Test
93+
public void testClusterPositionsAreCentroids() {
94+
ContinuousZoomEuclideanCentroidAlgorithm<TestClusterItem> algo =
95+
new ContinuousZoomEuclideanCentroidAlgorithm<>();
96+
97+
Collection<TestClusterItem> items = Arrays.asList(
98+
new TestClusterItem(0.0, 0.0),
99+
new TestClusterItem(0.0, 2.0),
100+
new TestClusterItem(2.0, 0.0)
101+
);
102+
103+
algo.addItems(items);
104+
105+
Set<? extends Cluster<TestClusterItem>> clusters = algo.getClusters(1.0f);
106+
107+
// Expect all items clustered into one
108+
assertEquals(1, clusters.size());
109+
110+
Cluster<TestClusterItem> cluster = clusters.iterator().next();
111+
112+
// The centroid should be approximately (0.6667, 0.6667)
113+
LatLng centroid = cluster.getPosition();
114+
assertEquals(0.6667, centroid.latitude, 0.0001);
115+
assertEquals(0.6667, centroid.longitude, 0.0001);
116+
}
117+
}

0 commit comments

Comments
 (0)