Skip to content

Commit c39ba07

Browse files
fix: stale QuadItem removal after ClusterItem position updates (#1730)
* Fix stale QuadItem removal after ClusterItem position updates * test(clustering): add regression tests and demo for stale QuadItem removal (#1730) - Expand QuadItemTest with comprehensive unit tests for single removal, bulk removal, clearing, and coordinate-boundary updates after a mutable ClusterItem changes position. - Add fallback to QuadItem(item) in NonHierarchicalDistanceBasedAlgorithm.kt removeItem/removeItems for subclass robustness if mItemMap is bypassed. - Enhance ClusteringDiffDemoActivity to mutate ClusterItem coordinates in-place and include background filler markers to trigger PointQuadTree quadrant splitting, demonstrating the fix on-device. --------- Co-authored-by: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
1 parent c2a165d commit c39ba07

4 files changed

Lines changed: 131 additions & 5 deletions

File tree

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ open class NonHierarchicalDistanceBasedAlgorithm<T : ClusterItem> : AbstractAlgo
4747
*/
4848
@JvmField
4949
protected val mItems: MutableCollection<QuadItem<T>> = LinkedHashSet()
50+
protected val mItemMap = HashMap<T, QuadItem<T>>()
5051

5152
/**
5253
* Any modifications should be synchronized on mQuadTree.
@@ -61,6 +62,7 @@ open class NonHierarchicalDistanceBasedAlgorithm<T : ClusterItem> : AbstractAlgo
6162
synchronized(mQuadTree) {
6263
val result = mItems.add(quadItem)
6364
if (result) {
65+
mItemMap[item] = quadItem
6466
mQuadTree.add(quadItem)
6567
}
6668
return result
@@ -81,15 +83,16 @@ open class NonHierarchicalDistanceBasedAlgorithm<T : ClusterItem> : AbstractAlgo
8183
override fun clearItems() {
8284
synchronized(mQuadTree) {
8385
mItems.clear()
86+
mItemMap.clear()
8487
mQuadTree.clear()
8588
}
8689
}
8790

8891
override fun removeItem(item: T): Boolean {
8992
// QuadItem delegates hashcode() and equals() to its item so,
9093
// removing any QuadItem to that item will remove the item
91-
val quadItem = QuadItem(item)
9294
synchronized(mQuadTree) {
95+
val quadItem = mItemMap.remove(item) ?: QuadItem(item)
9396
val result = mItems.remove(quadItem)
9497
if (result) {
9598
mQuadTree.remove(quadItem)
@@ -104,7 +107,7 @@ open class NonHierarchicalDistanceBasedAlgorithm<T : ClusterItem> : AbstractAlgo
104107
for (item in items) {
105108
// QuadItem delegates hashcode() and equals() to its item so,
106109
// removing any QuadItem to that item will remove the item
107-
val quadItem = QuadItem(item)
110+
val quadItem = mItemMap.remove(item) ?: QuadItem(item)
108111
val individualResult = mItems.remove(quadItem)
109112
if (individualResult) {
110113
mQuadTree.remove(quadItem)

‎clustering/src/test/java/com/google/maps/android/clustering/QuadItemTest.java‎

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,117 @@ public void testInsertionOrder() {
9797
}
9898
}
9999

100+
@Test
101+
public void testUpdateItemAfterPositionChange() {
102+
NonHierarchicalDistanceBasedAlgorithm<TestingItem> algo =
103+
new NonHierarchicalDistanceBasedAlgorithm<>();
104+
TestingItem item = new TestingItem("title1", 0.0, 0.0);
105+
algo.addItem(item);
106+
assertEquals(1, algo.getItems().size());
107+
108+
// Update the position of the mutable item
109+
item.setPosition(10.0, 10.0);
110+
111+
// Call updateItem
112+
assertTrue("updateItem should return true after position change", algo.updateItem(item));
113+
assertEquals(1, algo.getItems().size());
114+
115+
// Verify that the old QuadItem at (0, 0) was removed from the tree
116+
// and only the new position (10, 10) is indexed
117+
java.util.Set<? extends Cluster<TestingItem>> clusters = algo.getClusters(4.0f);
118+
assertEquals(1, clusters.size());
119+
Cluster<TestingItem> cluster = clusters.iterator().next();
120+
assertEquals(10.0, cluster.getPosition().latitude, 0.001);
121+
assertEquals(10.0, cluster.getPosition().longitude, 0.001);
122+
}
123+
124+
@Test
125+
public void testRemoveItemAfterPositionChange() {
126+
NonHierarchicalDistanceBasedAlgorithm<TestingItem> algo =
127+
new NonHierarchicalDistanceBasedAlgorithm<>();
128+
TestingItem item = new TestingItem("title1", 0.0, 0.0);
129+
algo.addItem(item);
130+
assertEquals(1, algo.getItems().size());
131+
132+
// Update the position of the mutable item
133+
item.setPosition(10.0, 10.0);
134+
135+
// Removing the item should succeed and remove it from the tree
136+
assertTrue("removeItem should return true after position change", algo.removeItem(item));
137+
assertEquals(0, algo.getItems().size());
138+
assertEquals(0, algo.getClusters(4.0f).size());
139+
}
140+
141+
@Test
142+
public void testUpdateItemPreventsStaleQuadTreeEntries() {
143+
TestAlgorithm<TestingItem> algo = new TestAlgorithm<>();
144+
145+
// Add 60 filler items to force PointQuadTree to split (MAX_ELEMENTS = 50)
146+
for (int i = 0; i < 60; i++) {
147+
algo.addItem(new TestingItem("filler" + i, 10.0 + i * 0.001, 10.0 + i * 0.001));
148+
}
149+
150+
// Add item1 in top-left quadrant
151+
TestingItem item1 = new TestingItem("item1", 1.0, 1.0);
152+
algo.addItem(item1);
153+
154+
assertEquals("QuadTree should contain item1 at (1, 1)", 1, algo.getQuadTreeItemCount(1.0, 1.0, 0.001));
155+
156+
// Move item1 far across quadrant boundary to (50.0, 50.0) and update
157+
item1.setPosition(50.0, 50.0);
158+
algo.updateItem(item1);
159+
160+
// Without fix, old QuadItem remains at (1.0, 1.0) in mQuadTree because remove traversed the new coordinates
161+
assertEquals("QuadTree should NOT contain stale entry at (1, 1) after update", 0, algo.getQuadTreeItemCount(1.0, 1.0, 0.001));
162+
assertEquals("QuadTree should contain item1 at (50, 50)", 1, algo.getQuadTreeItemCount(50.0, 50.0, 0.001));
163+
}
164+
165+
@Test
166+
public void testRemoveItemsAfterPositionChange() {
167+
NonHierarchicalDistanceBasedAlgorithm<TestingItem> algo =
168+
new NonHierarchicalDistanceBasedAlgorithm<>();
169+
TestingItem item1 = new TestingItem("title1", 0.0, 0.0);
170+
TestingItem item2 = new TestingItem("title2", 1.0, 1.0);
171+
algo.addItems(java.util.Arrays.asList(item1, item2));
172+
assertEquals(2, algo.getItems().size());
173+
174+
// Update the position of both items
175+
item1.setPosition(10.0, 10.0);
176+
item2.setPosition(20.0, 20.0);
177+
178+
assertTrue("removeItems should return true after position change",
179+
algo.removeItems(java.util.Arrays.asList(item1, item2)));
180+
assertEquals(0, algo.getItems().size());
181+
assertEquals(0, algo.getClusters(4.0f).size());
182+
}
183+
184+
@Test
185+
public void testClearItemsAfterPositionChange() {
186+
NonHierarchicalDistanceBasedAlgorithm<TestingItem> algo =
187+
new NonHierarchicalDistanceBasedAlgorithm<>();
188+
TestingItem item1 = new TestingItem("title1", 0.0, 0.0);
189+
algo.addItem(item1);
190+
item1.setPosition(10.0, 10.0);
191+
192+
algo.clearItems();
193+
assertEquals(0, algo.getItems().size());
194+
assertEquals(0, algo.getClusters(4.0f).size());
195+
}
196+
197+
private static class TestAlgorithm<T extends ClusterItem> extends NonHierarchicalDistanceBasedAlgorithm<T> {
198+
private static final com.google.maps.android.projection.SphericalMercatorProjection PROJ =
199+
new com.google.maps.android.projection.SphericalMercatorProjection(1.0);
200+
201+
public int getQuadTreeItemCount(double lat, double lng, double span) {
202+
com.google.maps.android.geometry.Point p = PROJ.toPoint(new LatLng(lat, lng));
203+
com.google.maps.android.geometry.Bounds bounds = new com.google.maps.android.geometry.Bounds(
204+
p.x - span, p.x + span, p.y - span, p.y + span);
205+
return mQuadTree.search(bounds).size();
206+
}
207+
}
208+
100209
private static class TestingItem implements ClusterItem {
101-
private final LatLng mPosition;
210+
private LatLng mPosition;
102211
private String mTitle;
103212

104213
TestingItem(String title, double lat, double lng) {
@@ -110,6 +219,9 @@ private static class TestingItem implements ClusterItem {
110219
mTitle = "";
111220
mPosition = new LatLng(lat, lng);
112221
}
222+
public void setPosition(double lat, double lng) {
223+
mPosition = new LatLng(lat, lng);
224+
}
113225

114226
@NonNull
115227
@Override

‎demo/src/main/java/com/google/maps/android/utils/demo/ClusteringDiffDemoActivity.java‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,13 @@ protected void startDemo(boolean isRestore) {
161161
}
162162

163163
private void addItems() {
164+
// Add 60 filler background markers across Greater London to force PointQuadTree to split into quadrants (MAX_ELEMENTS = 50)
165+
for (int i = 0; i < 60; i++) {
166+
double lat = 51.3 + (i % 10) * 0.05;
167+
double lng = -0.4 + (i / 10) * 0.08;
168+
mClusterManager.addItem(new Person(new LatLng(lat, lng), "Citizen " + i, R.drawable.john));
169+
}
170+
164171
// Marker in Enfield
165172
mClusterManager.addItem(new Person(City.ENFIELD.latLng, "John", R.drawable.john));
166173

@@ -181,7 +188,7 @@ private void rotateLocation() {
181188
Log.d("ClusterTest", "Item rotated to: " + newLocation.toString() + ", City: " + cityName);
182189

183190
if (itemToUpdate != null) {
184-
itemToUpdate = new Person(newLocation, "Teach", R.drawable.teacher);
191+
itemToUpdate.setPosition(newLocation);
185192
mClusterManager.updateItem(itemToUpdate); // Update the marker
186193
mClusterManager.cluster();
187194
}

‎demo/src/main/java/com/google/maps/android/utils/demo/model/Person.java‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,18 @@
2424
public class Person implements ClusterItem {
2525
public final String name;
2626
public final int profilePhoto;
27-
private final LatLng mPosition;
27+
private LatLng mPosition;
2828

2929
public Person(LatLng position, String name, int pictureResource) {
3030
this.name = name;
3131
profilePhoto = pictureResource;
3232
mPosition = position;
3333
}
3434

35+
public void setPosition(LatLng position) {
36+
mPosition = position;
37+
}
38+
3539
@NonNull
3640
@Override
3741
public LatLng getPosition() {

0 commit comments

Comments
 (0)