From 723b46a5c0bfc71952e5c56fc4d00a32f3f16281 Mon Sep 17 00:00:00 2001 From: Programmeister Date: Mon, 10 Mar 2025 12:32:59 +0300 Subject: [PATCH 1/3] Fix: #1424 - Added zIndex to markerOptions in onBeforeClusterRendered and onBeforeClusterItemRendered --- .../clustering/view/DefaultClusterRenderer.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/library/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.java b/library/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.java index 08ab0e081..0f0d6ba19 100644 --- a/library/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.java +++ b/library/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.java @@ -836,6 +836,9 @@ protected void onBeforeClusterItemRendered(@NonNull T item, @NonNull MarkerOptio } else if (item.getSnippet() != null) { markerOptions.title(item.getSnippet()); } + if (item.getZIndex() != null) { + markerOptions.zIndex(item.getZIndex()); + } } /** @@ -906,6 +909,13 @@ protected void onClusterItemUpdated(@NonNull T item, @NonNull Marker marker) { protected void onBeforeClusterRendered(@NonNull Cluster cluster, @NonNull MarkerOptions markerOptions) { // TODO: consider adding anchor(.5, .5) (Individual markers will overlap more often) markerOptions.icon(getDescriptorForCluster(cluster)); + ArrayList items = new ArrayList<>(cluster.getItems()); + if (!items.isEmpty()) { + Float zIndex = items.get(0).getZIndex(); + if (zIndex != null) { + markerOptions.zIndex(zIndex); + } + } } /** From ae1fbbbc56b0e27291b4183c412d03b218eac07d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez-Ma=C3=B1as?= Date: Wed, 20 Aug 2025 00:15:09 +0200 Subject: [PATCH 2/3] feat: added ContinuousZoomEuclideanCentroidAlgorithm (#1559) * feat: added ContinuousZoomEuclideanAlgorithm * feat: added ContinuousZoomEuclideanAlgorithm * feat: added ContinuousZoomEuclideanAlgorithm * feat: replaced methods in NonHierarchicalDistanceBasedAlgorithm.java * feat: refactored to create ContinuousZoomEuclideanCentroidAlgorithm.java --- ...tinuousZoomEuclideanCentroidAlgorithm.java | 68 ++++++++++ ...NonHierarchicalDistanceBasedAlgorithm.java | 25 +++- ...ousZoomEuclideanCentroidAlgorithmTest.java | 117 ++++++++++++++++++ 3 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 library/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.java create mode 100644 library/src/test/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.java 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); + } +} From 80a28009f568e6ab2d69705580e70336ab833a06 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 19 Aug 2025 22:20:42 +0000 Subject: [PATCH 3/3] chore(release): 3.15.0 [skip ci] # [3.15.0](https://github.com/googlemaps/android-maps-utils/compare/v3.14.0...v3.15.0) (2025-08-19) ### Features * added ContinuousZoomEuclideanCentroidAlgorithm ([#1559](https://github.com/googlemaps/android-maps-utils/issues/1559)) ([b781ff8](https://github.com/googlemaps/android-maps-utils/commit/b781ff8149ba1193e8294f393efd99804b641e1b)) --- README.md | 2 +- build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a3ea68db2..5a8558de7 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ dependencies { // Utilities for Maps SDK for Android (requires Google Play Services) // You do not need to add a separate dependency for the Maps SDK for Android // since this library builds in the compatible version of the Maps SDK. - implementation 'com.google.maps.android:android-maps-utils:3.14.0' + implementation 'com.google.maps.android:android-maps-utils:3.15.0' // Optionally add the Kotlin Extensions (KTX) for full Kotlin language support // See latest version at https://github.com/googlemaps/android-maps-ktx diff --git a/build.gradle.kts b/build.gradle.kts index ab894b2dc..5f055b4e1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,5 +37,5 @@ tasks.register("clean") { allprojects { group = "com.google.maps.android" - version = "3.14.0" + version = "3.15.0" } \ No newline at end of file