Skip to content

Commit 4a65d60

Browse files
committed
feat: added ContinuousZoomEuclideanAlgorithm
1 parent 014c64e commit 4a65d60

File tree

2 files changed

+246
-0
lines changed

2 files changed

+246
-0
lines changed
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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.Cluster;
20+
import com.google.maps.android.clustering.ClusterItem;
21+
import com.google.maps.android.geometry.Bounds;
22+
import com.google.maps.android.geometry.Point;
23+
24+
import java.util.ArrayList;
25+
import java.util.Collection;
26+
import java.util.HashMap;
27+
import java.util.HashSet;
28+
import java.util.Map;
29+
import java.util.Set;
30+
31+
/**
32+
* A variant of {@link NonHierarchicalDistanceBasedAlgorithm} that supports:
33+
* <ul>
34+
* <li><strong>Continuous zoom-based clustering</strong> — the clustering radius
35+
* changes smoothly with the zoom level, rather than stepping at integer zoom levels.</li>
36+
* <li><strong>Euclidean distance metric</strong> — items are clustered based on their
37+
* true Euclidean distance in projected map coordinates, instead of relying solely on
38+
* rectangular bounds overlap.</li>
39+
* </ul>
40+
*
41+
* <p>This algorithm overrides {@link #getClusters(float)} to calculate clusters using a
42+
* zoom-dependent span and a circular radius check, improving visual stability during zoom
43+
* animations and producing clusters that are spatially more uniform.</p>
44+
*
45+
* @param <T> the type of cluster item
46+
*/
47+
public class ContinuousZoomEuclideanAlgorithm<T extends ClusterItem>
48+
extends NonHierarchicalDistanceBasedAlgorithm<T> {
49+
50+
/**
51+
* Returns clusters for the given zoom level using continuous zoom scaling and
52+
* a Euclidean distance threshold.
53+
*
54+
* <p>The algorithm works as follows:</p>
55+
* <ol>
56+
* <li>Computes a {@code zoomSpecificSpan} in projected coordinates based on the
57+
* current zoom level and the configured maximum clustering distance.</li>
58+
* <li>Iterates over unvisited items in the quadtree.</li>
59+
* <li>For each candidate item, searches nearby items within a bounding box
60+
* derived from {@code zoomSpecificSpan}.</li>
61+
* <li>Filters those items by actual Euclidean distance to ensure they fall
62+
* within a circular clustering radius.</li>
63+
* <li>Creates a {@link StaticCluster} if more than one item is within range,
64+
* otherwise treats the item as its own singleton cluster.</li>
65+
* </ol>
66+
*
67+
* @param zoom the current map zoom level (fractional values supported)
68+
* @return a set of clusters computed with continuous zoom and Euclidean distance
69+
*/
70+
@Override
71+
public Set<? extends Cluster<T>> getClusters(float zoom) {
72+
final double zoomSpecificSpan = getMaxDistanceBetweenClusteredItems()
73+
/ Math.pow(2, zoom) / 256;
74+
75+
final Set<QuadItem<T>> visitedCandidates = new HashSet<>();
76+
final Set<Cluster<T>> results = new HashSet<>();
77+
final Map<QuadItem<T>, Double> distanceToCluster = new HashMap<>();
78+
final Map<QuadItem<T>, StaticCluster<T>> itemToCluster = new HashMap<>();
79+
80+
synchronized (mQuadTree) {
81+
for (QuadItem<T> candidate : getClusteringItems(mQuadTree, zoom)) {
82+
if (visitedCandidates.contains(candidate)) {
83+
continue;
84+
}
85+
86+
Bounds searchBounds = createBoundsFromSpan(candidate.getPoint(), zoomSpecificSpan);
87+
Collection<QuadItem<T>> clusterItems = new ArrayList<>();
88+
for (QuadItem<T> clusterItem : mQuadTree.search(searchBounds)) {
89+
double distance = distanceSquared(clusterItem.getPoint(), candidate.getPoint());
90+
double radiusSquared = Math.pow(zoomSpecificSpan / 2, 2);
91+
if (distance < radiusSquared) {
92+
clusterItems.add(clusterItem);
93+
}
94+
}
95+
96+
if (clusterItems.size() == 1) {
97+
results.add(candidate);
98+
visitedCandidates.add(candidate);
99+
distanceToCluster.put(candidate, 0d);
100+
continue;
101+
}
102+
103+
StaticCluster<T> cluster = new StaticCluster<>(candidate.getPosition());
104+
results.add(cluster);
105+
106+
for (QuadItem<T> clusterItem : clusterItems) {
107+
Double existingDistance = distanceToCluster.get(clusterItem);
108+
double distance = distanceSquared(clusterItem.getPoint(), candidate.getPoint());
109+
if (existingDistance != null && existingDistance < distance) {
110+
continue;
111+
}
112+
if (existingDistance != null) {
113+
itemToCluster.get(clusterItem).remove(clusterItem.mClusterItem);
114+
}
115+
distanceToCluster.put(clusterItem, distance);
116+
cluster.add(clusterItem.mClusterItem);
117+
itemToCluster.put(clusterItem, cluster);
118+
}
119+
120+
visitedCandidates.addAll(clusterItems);
121+
}
122+
}
123+
return results;
124+
}
125+
126+
/**
127+
* Calculates the squared Euclidean distance between two points.
128+
*
129+
* @param a the first point
130+
* @param b the second point
131+
* @return the squared Euclidean distance between {@code a} and {@code b}
132+
*/
133+
private double distanceSquared(Point a, Point b) {
134+
return (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y);
135+
}
136+
137+
/**
138+
* Creates a square bounding box centered at a point with the specified span.
139+
*
140+
* @param p the center point
141+
* @param span the total width/height of the bounding box
142+
* @return the {@link Bounds} object representing the search area
143+
*/
144+
private Bounds createBoundsFromSpan(Point p, double span) {
145+
double halfSpan = span / 2;
146+
return new Bounds(
147+
p.x - halfSpan, p.x + halfSpan,
148+
p.y - halfSpan, p.y + halfSpan
149+
);
150+
}
151+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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+
17+
package com.google.maps.android.clustering.algo;
18+
19+
import androidx.annotation.NonNull;
20+
21+
import com.google.android.gms.maps.model.LatLng;
22+
import com.google.maps.android.clustering.Cluster;
23+
import com.google.maps.android.clustering.ClusterItem;
24+
25+
import org.junit.Test;
26+
27+
import java.util.Arrays;
28+
import java.util.Collection;
29+
import java.util.Set;
30+
31+
import static org.junit.Assert.assertEquals;
32+
import static org.junit.Assert.assertTrue;
33+
34+
public class ContinuousZoomEuclideanAlgorithmTest {
35+
36+
static class TestClusterItem implements ClusterItem {
37+
private final LatLng position;
38+
39+
TestClusterItem(double lat, double lng) {
40+
this.position = new LatLng(lat, lng);
41+
}
42+
43+
@NonNull
44+
@Override
45+
public LatLng getPosition() {
46+
return position;
47+
}
48+
49+
@Override
50+
public String getTitle() {
51+
return null;
52+
}
53+
54+
@Override
55+
public String getSnippet() {
56+
return null;
57+
}
58+
59+
@Override
60+
public Float getZIndex() {
61+
return 0f;
62+
}
63+
}
64+
65+
@Test
66+
public void testContinuousZoomMergesClosePairAtLowZoomAndSeparatesAtHighZoom() {
67+
ContinuousZoomEuclideanAlgorithm<TestClusterItem> algo =
68+
new ContinuousZoomEuclideanAlgorithm<>();
69+
70+
// Optional: customize threshold if your defaults differ
71+
// algo.setMaxDistanceBetweenClusteredItems(100);
72+
73+
Collection<TestClusterItem> items = Arrays.asList(
74+
new TestClusterItem(10.0, 10.0),
75+
new TestClusterItem(10.0001, 10.0001), // very close to the first
76+
new TestClusterItem(20.0, 20.0) // far away
77+
);
78+
79+
algo.addItems(items);
80+
81+
// At a high zoom, the close pair should be separate (small radius)
82+
Set<? extends Cluster<TestClusterItem>> highZoom = algo.getClusters(20.0f);
83+
assertEquals(3, highZoom.size());
84+
85+
// At a lower zoom, the close pair should merge (larger radius)
86+
Set<? extends Cluster<TestClusterItem>> lowZoom = algo.getClusters(5.0f);
87+
assertTrue(lowZoom.size() < 3);
88+
89+
// And specifically, we expect one cluster of size 2 and one singleton
90+
boolean hasClusterOfTwo = lowZoom.stream().anyMatch(c -> c.getItems().size() == 2);
91+
boolean hasClusterOfOne = lowZoom.stream().anyMatch(c -> c.getItems().size() == 1);
92+
assertTrue(hasClusterOfTwo);
93+
assertTrue(hasClusterOfOne);
94+
}
95+
}

0 commit comments

Comments
 (0)