diff --git a/README.md b/README.md index 2c1115b6b..99f347dd3 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ Full guides for using the utilities are published in - Marker animation [source](https://github.com/googlemaps/android-maps-utils/blob/main/ui/src/main/java/com/google/maps/android/ui/AnimationUtil.kt), [sample code](https://github.com/googlemaps/android-maps-utils/blob/main/demo/src/main/java/com/google/maps/android/utils/demo/AnimationUtilDemoActivity.java) - Marker clustering [source](https://github.com/googlemaps/android-maps-utils/tree/main/clustering/src/main/java/com/google/maps/android/clustering), [guide](https://developers.google.com/maps/documentation/android-sdk/utility/marker-clustering) - Advanced Markers clustering [source](https://github.com/googlemaps/android-maps-utils/tree/main/clustering/src/main/java/com/google/maps/android/clustering), [sample code](https://github.com/googlemaps/android-maps-utils/blob/main/demo/src/main/java/com/google/maps/android/utils/demo/CustomAdvancedMarkerClusteringDemoActivity.java) +- SuperCluster mega-scale clustering (100k+ markers) [source](https://github.com/googlemaps/android-maps-utils/blob/main/clustering/src/main/java/com/google/maps/android/clustering/algo/SuperClusterAlgorithm.kt), [sample code](https://github.com/googlemaps/android-maps-utils/blob/main/demo/src/main/java/com/google/maps/android/utils/demo/SuperCluster100kDemoActivity.kt) - Marker icons [source](https://github.com/googlemaps/android-maps-utils/blob/main/ui/src/main/java/com/google/maps/android/ui/IconGenerator.kt), [sample code](https://github.com/googlemaps/android-maps-utils/blob/main/demo/src/main/java/com/google/maps/android/utils/demo/IconGeneratorDemoActivity.java) @@ -243,6 +244,36 @@ By default, the `Source` is set to `Source.DEFAULT`, but you can also specify `S +## Clustering Performance Benchmarks (SuperCluster vs. Traditional) + +For large datasets (10,000 to 100,000+ points), `SuperClusterAlgorithm` replaces on-the-fly quadtree traversal with a static hierarchical zoom pyramid backed by primitive flat arrays (`FlatKdTree`). During camera panning and zooming, viewport queries execute in sub-milliseconds without triggering garbage collection pauses. + +### Empirical Benchmarks across 10k, 50k, and 100k Points + +| Algorithm | 10k Points (Query) | 50k Points (Query) | 100k Points (Query) | 100k Build Time | Scaling Verdict | +| :--- | :---: | :---: | :---: | :---: | :--- | +| **`SuperClusterAlgorithm` (Viewport)** | **1.07 ms** | **0.23 ms** | **0.22 ms** | **786 ms** | **~2,800x faster**. Sub-millisecond camera moves. | +| **`SuperClusterAlgorithm` (Unbounded)** | **5.88 ms** | **10.28 ms** | **11.86 ms** | **807 ms** | **~52x faster** querying the entire globe simultaneously. | +| `NonHierarchicalViewBasedAlgorithm` | 4.22 ms | 8.68 ms | 17.35 ms | 0 ms | Performs well at high zoom; slower at low zoom. | +| `NonHierarchicalDistanceBasedAlgorithm` (Default) | 55.70 ms | 336.68 ms | 617.17 ms | 0 ms | Severe camera panning stutter (~150 ms/frame). | +| `GridBasedAlgorithm` | 54.55 ms | 598.14 ms | 2,047.27 ms | 0 ms | Unusable at scale (2+ second freeze). | + +```kotlin +// Usage with ClusterManager: +val clusterManager = ClusterManager(context, map) +val algorithm = SuperClusterAlgorithm( + minZoom = 0, + maxZoom = 16, + radius = 64.0, + extent = 512.0, + viewWidth = screenWidthDp, + viewHeight = screenHeightDp, +) +clusterManager.setAlgorithm(algorithm) +clusterManager.addItems(largeDataset) // 100,000+ items +clusterManager.cluster() +``` + ## Internal usage attribution ID This library calls the `addInternalUsageAttributionId` method, which helps Google understand which libraries and samples are helpful to developers and is optional. Instructions for opting out of the identifier are provided below. diff --git a/clustering/README.md b/clustering/README.md new file mode 100644 index 000000000..2dfcdc79e --- /dev/null +++ b/clustering/README.md @@ -0,0 +1,107 @@ +# Clustering Module: High-Performance Marker Clustering + +The `clustering` module provides algorithms and rendering infrastructure to cluster geospatial markers on the Google Maps Android SDK. + +--- + +## Available Algorithms + +| Algorithm | Best For | Typical Scale | Query Latency | Memory Architecture | +| :--- | :--- | :---: | :---: | :--- | +| **`SuperClusterAlgorithm`** | **Mega-scale datasets, smooth 60–120 FPS panning** | **10,000 – 1,000,000** | **< 0.1 ms – 1.0 ms** | **Contiguous primitive arrays (`FlatKdTree`), zero runtime GC pressure** | +| `NonHierarchicalViewBasedAlgorithm` | Medium datasets with viewport clipping | 1,000 – 20,000 | 4 ms – 20 ms | `PointQuadTree` with viewport bounds clipping | +| `NonHierarchicalDistanceBasedAlgorithm` | Small datasets, simple distance grouping | 100 – 2,000 | 50 ms – 600 ms | In-memory `PointQuadTree` traversed on every camera movement | +| `GridBasedAlgorithm` | Basic equal-area grid binning | 100 – 5,000 | 10 ms – 700 ms | Integer grid coordinate grouping | +| `CentroidNonHierarchicalDistanceBasedAlgorithm` | Centroid-recalculating clusters | 100 – 2,000 | 80 ms – 150 ms | Quadtree distance grouping with dynamic centroid repositioning | +| `ContinuousZoomEuclideanCentroidAlgorithm` | Fractional zoom interpolation | 100 – 2,000 | 80 ms – 100 ms | Euclidean coordinate cluster smoothing | + +--- + +## Empirical Performance Benchmarks + +Measured using [`ClusteringPerformanceComparisonTest.kt`](src/test/java/com/google/maps/android/clustering/algo/ClusteringPerformanceComparisonTest.kt) across uniform geographic distributions (averaged over 3 iterations per zoom level): + +### 100,000 Points + +| Algorithm | Ingestion (`addItems`) | Index Build | Zoom 4.0 | Zoom 8.0 | Zoom 12.0 | Zoom 16.0 | Total Query Latency | Memory Delta | +| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | +| **`SuperClusterAlgorithm` (Viewport)** | **8.73 ms** | **786.21 ms** | **0.16 ms** | **0.04 ms** | **0.01 ms** | **0.01 ms** | **0.22 ms** | 77.9 MB | +| **`SuperClusterAlgorithm` (Unbounded)** | 17.05 ms | 806.95 ms | 0.14 ms | 3.95 ms | 4.05 ms | 3.72 ms | **11.86 ms** | 82.3 MB | +| `NonHierarchicalViewBasedAlgorithm` | 57.32 ms | 0.00 ms | 17.25 ms | 0.09 ms | 0.00 ms | 0.00 ms | 17.35 ms | 33.0 MB | +| `NonHierarchicalDistanceBasedAlgorithm` | 68.20 ms | 0.00 ms | 124.12 ms | 175.25 ms | 165.09 ms | 152.71 ms | 617.17 ms | 30.5 MB | +| `GridBasedAlgorithm` | 8.52 ms | 0.00 ms | 28.66 ms | 496.61 ms | 757.69 ms | 764.31 ms | 2047.27 ms | 7.0 MB | + +### 50,000 Points + +| Algorithm | Ingestion (`addItems`) | Index Build | Zoom 4.0 | Zoom 8.0 | Zoom 12.0 | Zoom 16.0 | Total Query Latency | Memory Delta | +| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | +| **`SuperClusterAlgorithm` (Viewport)** | **5.51 ms** | **383.36 ms** | **0.18 ms** | **0.03 ms** | **0.01 ms** | **0.01 ms** | **0.23 ms** | 35.8 MB | +| **`SuperClusterAlgorithm` (Unbounded)** | 8.02 ms | 395.77 ms | 0.26 ms | 3.02 ms | 3.49 ms | 3.52 ms | **10.28 ms** | 54.7 MB | +| `NonHierarchicalViewBasedAlgorithm` | 28.74 ms | 0.00 ms | 8.62 ms | 0.05 ms | 0.00 ms | 0.00 ms | 8.68 ms | 15.5 MB | +| `NonHierarchicalDistanceBasedAlgorithm` | 47.34 ms | 0.00 ms | 96.62 ms | 88.36 ms | 79.15 ms | 72.56 ms | 336.68 ms | 13.3 MB | +| `GridBasedAlgorithm` | 3.92 ms | 0.00 ms | 19.57 ms | 171.33 ms | 202.95 ms | 204.30 ms | 598.14 ms | 3.5 MB | + +### 10,000 Points + +| Algorithm | Ingestion (`addItems`) | Index Build | Zoom 4.0 | Zoom 8.0 | Zoom 12.0 | Zoom 16.0 | Total Query Latency | Memory Delta | +| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | +| **`SuperClusterAlgorithm` (Viewport)** | **0.81 ms** | **67.24 ms** | **1.02 ms** | **0.03 ms** | **0.02 ms** | **0.01 ms** | **1.07 ms** | 15.0 MB | +| **`SuperClusterAlgorithm` (Unbounded)** | 1.44 ms | 128.41 ms | 4.81 ms | 0.32 ms | 0.33 ms | 0.41 ms | **5.88 ms** | 15.9 MB | +| `NonHierarchicalViewBasedAlgorithm` | 5.09 ms | 0.00 ms | 4.17 ms | 0.04 ms | 0.00 ms | 0.00 ms | 4.22 ms | 3.0 MB | +| `NonHierarchicalDistanceBasedAlgorithm` | 11.44 ms | 0.00 ms | 22.23 ms | 13.56 ms | 10.61 ms | 9.30 ms | 55.70 ms | 3.0 MB | +| `GridBasedAlgorithm` | 0.51 ms | 0.00 ms | 10.30 ms | 17.40 ms | 14.31 ms | 12.54 ms | 54.55 ms | 0.5 MB | + +--- + +## SuperCluster Architecture + +1. **Primitive Storage (`FlatKdTree`)**: + Coordinates are packed in a contiguous `DoubleArray` (`[x0, y0, x1, y1, ...]`) and identities in an `IntArray`. Quickselect median selection partitions the tree in-place on alternating axes (X and Y), eliminating millions of node allocations on the Java heap. +2. **Bottom-Up Hierarchical Zoom Pyramid**: + Raw points enter at `maxZoom + 1`. The algorithm aggregates downward: clusters from level $z + 1$ form the candidate inputs for level $z$. As the user zooms out, point counts decrease exponentially. +3. **Sub-Millisecond Viewport Queries**: + During camera pans and zooms, `getClusters` performs **zero clustering calculation**. It executes a range query against the pre-computed static tree in $O(\log N + K)$ time (< 0.1 ms). +4. **Coincident Point Preservation**: + Points sharing identical geographic coordinates are clustered at the base level and preserved across all zoom levels, preventing overlapping, unclickable duplicate markers. + +--- + +## Usage Guide + +```kotlin +// 1. Initialize ClusterManager +val clusterManager = ClusterManager(context, googleMap) + +// 2. Configure SuperClusterAlgorithm with screen dimensions (in dp) +val (widthDp, heightDp) = getScreenDimensionsDp() +val superCluster = SuperClusterAlgorithm( + minZoom = 0, + maxZoom = 16, + radius = 64.0, + extent = 512.0, + viewWidth = widthDp, + viewHeight = heightDp, +) +clusterManager.setAlgorithm(superCluster) + +// 3. Connect camera idle and marker click listeners +googleMap.setOnCameraIdleListener(clusterManager) +googleMap.setOnMarkerClickListener(clusterManager) + +// 4. Ingest dataset and cluster +clusterManager.addItems(largeDataset) // e.g. 100,000 items +clusterManager.cluster() +``` + +### Location Updating + +`SuperClusterAlgorithm` supports dynamic location updates via `updateItem(item: T)`: + +```kotlin +// Update item position (item should have stable id/identity) +item.position = newLatLng +clusterManager.updateItem(item) + +// Triggers background rebuild of the spatial pyramid +clusterManager.cluster() +``` diff --git a/clustering/api/clustering.api b/clustering/api/clustering.api index a6a39422e..747033612 100644 --- a/clustering/api/clustering.api +++ b/clustering/api/clustering.api @@ -24,6 +24,7 @@ public class com/google/maps/android/clustering/ClusterManager : com/google/andr public final fun getClusterMarkerCollection ()Lcom/google/maps/android/collections/MarkerManager$Collection; public final fun getMarkerCollection ()Lcom/google/maps/android/collections/MarkerManager$Collection; public final fun getMarkerManager ()Lcom/google/maps/android/collections/MarkerManager; + public fun getOnClusteringProgressListener ()Lcom/google/maps/android/clustering/ClusterManager$OnClusteringProgressListener; public fun getRenderer ()Lcom/google/maps/android/clustering/view/ClusterRenderer; public fun onCameraIdle ()V public fun onInfoWindowClick (Lcom/google/android/gms/maps/model/Marker;)V @@ -39,6 +40,7 @@ public class com/google/maps/android/clustering/ClusterManager : com/google/andr public fun setOnClusterItemClickListener (Lcom/google/maps/android/clustering/ClusterManager$OnClusterItemClickListener;)V public fun setOnClusterItemInfoWindowClickListener (Lcom/google/maps/android/clustering/ClusterManager$OnClusterItemInfoWindowClickListener;)V public fun setOnClusterItemInfoWindowLongClickListener (Lcom/google/maps/android/clustering/ClusterManager$OnClusterItemInfoWindowLongClickListener;)V + public fun setOnClusteringProgressListener (Lcom/google/maps/android/clustering/ClusterManager$OnClusteringProgressListener;)V public fun setRenderer (Lcom/google/maps/android/clustering/view/ClusterRenderer;)V public fun updateItem (Lcom/google/maps/android/clustering/ClusterItem;)Z } @@ -67,6 +69,10 @@ public abstract interface class com/google/maps/android/clustering/ClusterManage public abstract fun onClusterItemInfoWindowLongClick (Lcom/google/maps/android/clustering/ClusterItem;)V } +public abstract interface class com/google/maps/android/clustering/ClusterManager$OnClusteringProgressListener { + public abstract fun onClusteringProgress (FLjava/lang/String;)V +} + public final class com/google/maps/android/clustering/ClusterManagerFlowsKt { public static final fun clusterClickEvents (Lcom/google/maps/android/clustering/ClusterManager;)Lkotlinx/coroutines/flow/Flow; public static final fun clusterInfoWindowClickEvents (Lcom/google/maps/android/clustering/ClusterManager;)Lkotlinx/coroutines/flow/Flow; @@ -220,6 +226,68 @@ public class com/google/maps/android/clustering/algo/StaticCluster : com/google/ public fun toString ()Ljava/lang/String; } +public final class com/google/maps/android/clustering/algo/SuperCluster : com/google/maps/android/clustering/Cluster { + public fun equals (Ljava/lang/Object;)Z + public final fun getChildClusters ()Ljava/util/List; + public final fun getClusterId ()I + public final fun getItem ()Lcom/google/maps/android/clustering/ClusterItem; + public fun getItems ()Ljava/util/Collection; + public fun getPosition ()Lcom/google/android/gms/maps/model/LatLng; + public fun getSize ()I + public final fun getZoom ()I + public fun hashCode ()I + public final fun isLeaf ()Z + public fun toString ()Ljava/lang/String; +} + +public final class com/google/maps/android/clustering/algo/SuperClusterAlgorithm : com/google/maps/android/clustering/algo/AbstractAlgorithm, com/google/maps/android/clustering/algo/ScreenBasedAlgorithm { + public static final field Companion Lcom/google/maps/android/clustering/algo/SuperClusterAlgorithm$Companion; + public static final field DEFAULT_EXTENT D + public static final field DEFAULT_MAX_ZOOM I + public static final field DEFAULT_MIN_ZOOM I + public static final field DEFAULT_RADIUS D + public fun ()V + public fun (IIDDII)V + public synthetic fun (IIDDIIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun addItem (Lcom/google/maps/android/clustering/ClusterItem;)Z + public fun addItems (Ljava/util/Collection;)Z + public final fun buildIndexIfNeeded ()V + public fun clearItems ()V + public fun getClusters (F)Ljava/util/Set; + public final fun getClusters (Lcom/google/maps/android/geometry/Bounds;F)Ljava/util/Set; + public final fun getExtent ()D + public fun getItems ()Ljava/util/Collection; + public fun getMaxDistanceBetweenClusteredItems ()I + public final fun getMaxZoom ()I + public final fun getMinZoom ()I + public final fun getOnProgressListener ()Lcom/google/maps/android/clustering/ClusterManager$OnClusteringProgressListener; + public final fun getRadius ()D + public final fun getReclusterOnMapMovement ()Z + public final fun getTotalItemCount ()I + public final fun getViewHeight ()I + public final fun getViewWidth ()I + public fun onCameraChange (Lcom/google/android/gms/maps/model/CameraPosition;)V + public fun removeItem (Lcom/google/maps/android/clustering/ClusterItem;)Z + public fun removeItems (Ljava/util/Collection;)Z + public final fun setCoordinates ([DLkotlin/jvm/functions/Function2;)V + public static synthetic fun setCoordinates$default (Lcom/google/maps/android/clustering/algo/SuperClusterAlgorithm;[DLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public final fun setExtent (D)V + public fun setMaxDistanceBetweenClusteredItems (I)V + public final fun setMaxZoom (I)V + public final fun setMinZoom (I)V + public final fun setOnProgressListener (Lcom/google/maps/android/clustering/ClusterManager$OnClusteringProgressListener;)V + public final fun setRadius (D)V + public final fun setReclusterOnMapMovement (Z)V + public final fun setViewHeight (I)V + public final fun setViewWidth (I)V + public fun shouldReclusterOnMapMovement ()Z + public fun updateItem (Lcom/google/maps/android/clustering/ClusterItem;)Z + public final fun updateViewSize (II)V +} + +public final class com/google/maps/android/clustering/algo/SuperClusterAlgorithm$Companion { +} + public abstract interface class com/google/maps/android/clustering/view/ClusterRenderer { public abstract fun getClusterTextAppearance (I)I public abstract fun getColor (I)I @@ -298,16 +366,23 @@ public class com/google/maps/android/clustering/view/DefaultAdvancedMarkersClust public fun (Landroid/content/Context;Lcom/google/android/gms/maps/GoogleMap;Lcom/google/maps/android/clustering/ClusterManager;)V public fun (Landroid/content/Context;Lcom/google/android/gms/maps/GoogleMap;Lcom/google/maps/android/clustering/ClusterManager;Ljava/util/concurrent/Executor;)V public synthetic fun (Landroid/content/Context;Lcom/google/android/gms/maps/GoogleMap;Lcom/google/maps/android/clustering/ClusterManager;Ljava/util/concurrent/Executor;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun clearIconCache ()V protected fun getBucket (Lcom/google/maps/android/clustering/Cluster;)I + public fun getBuckets ()[I public fun getCluster (Lcom/google/android/gms/maps/model/Marker;)Lcom/google/maps/android/clustering/Cluster; public fun getClusterItem (Lcom/google/android/gms/maps/model/Marker;)Lcom/google/maps/android/clustering/ClusterItem; protected fun getClusterText (I)Ljava/lang/String; public fun getClusterTextAppearance (I)I public fun getColor (I)I + public fun getCompactUnitUppercase ()Z protected fun getDescriptorForCluster (Lcom/google/maps/android/clustering/Cluster;)Lcom/google/android/gms/maps/model/BitmapDescriptor; + public fun getForceRecluster ()Z public fun getMarker (Lcom/google/maps/android/clustering/Cluster;)Lcom/google/android/gms/maps/model/Marker; public fun getMarker (Lcom/google/maps/android/clustering/ClusterItem;)Lcom/google/android/gms/maps/model/Marker; + public fun getMaxNonZeroDigits ()I public fun getMinClusterSize ()I + public fun getShowExactCount ()Z + public fun getUseCompactNumberFormatting ()Z public fun onAdd ()V protected fun onBeforeClusterItemRendered (Lcom/google/maps/android/clustering/ClusterItem;Lcom/google/android/gms/maps/model/AdvancedMarkerOptions;)V protected fun onBeforeClusterRendered (Lcom/google/maps/android/clustering/Cluster;Lcom/google/android/gms/maps/model/AdvancedMarkerOptions;)V @@ -319,6 +394,10 @@ public class com/google/maps/android/clustering/view/DefaultAdvancedMarkersClust public fun onRemove ()V public fun setAnimation (Z)V public fun setAnimationDuration (J)V + public fun setBuckets ([I)V + public fun setCompactUnitUppercase (Z)V + public fun setForceRecluster (Z)V + public fun setMaxNonZeroDigits (I)V public fun setMinClusterSize (I)V public fun setOnClusterClickListener (Lcom/google/maps/android/clustering/ClusterManager$OnClusterClickListener;)V public fun setOnClusterInfoWindowClickListener (Lcom/google/maps/android/clustering/ClusterManager$OnClusterInfoWindowClickListener;)V @@ -326,11 +405,14 @@ public class com/google/maps/android/clustering/view/DefaultAdvancedMarkersClust public fun setOnClusterItemClickListener (Lcom/google/maps/android/clustering/ClusterManager$OnClusterItemClickListener;)V public fun setOnClusterItemInfoWindowClickListener (Lcom/google/maps/android/clustering/ClusterManager$OnClusterItemInfoWindowClickListener;)V public fun setOnClusterItemInfoWindowLongClickListener (Lcom/google/maps/android/clustering/ClusterManager$OnClusterItemInfoWindowLongClickListener;)V + public fun setShowExactCount (Z)V + public fun setUseCompactNumberFormatting (Z)V protected fun shouldRender (Ljava/util/Set;Ljava/util/Set;)Z protected fun shouldRenderAsCluster (Lcom/google/maps/android/clustering/Cluster;)Z } public final class com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer$Companion { + public final fun getDEFAULT_BUCKETS ()[I } public class com/google/maps/android/clustering/view/DefaultClusterRenderer : com/google/maps/android/clustering/view/ClusterRenderer { @@ -338,16 +420,23 @@ public class com/google/maps/android/clustering/view/DefaultClusterRenderer : co public fun (Landroid/content/Context;Lcom/google/android/gms/maps/GoogleMap;Lcom/google/maps/android/clustering/ClusterManager;)V public fun (Landroid/content/Context;Lcom/google/android/gms/maps/GoogleMap;Lcom/google/maps/android/clustering/ClusterManager;Ljava/util/concurrent/Executor;)V public synthetic fun (Landroid/content/Context;Lcom/google/android/gms/maps/GoogleMap;Lcom/google/maps/android/clustering/ClusterManager;Ljava/util/concurrent/Executor;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun clearIconCache ()V protected fun getBucket (Lcom/google/maps/android/clustering/Cluster;)I + public fun getBuckets ()[I public fun getCluster (Lcom/google/android/gms/maps/model/Marker;)Lcom/google/maps/android/clustering/Cluster; public fun getClusterItem (Lcom/google/android/gms/maps/model/Marker;)Lcom/google/maps/android/clustering/ClusterItem; protected fun getClusterText (I)Ljava/lang/String; public fun getClusterTextAppearance (I)I public fun getColor (I)I + public fun getCompactUnitUppercase ()Z protected fun getDescriptorForCluster (Lcom/google/maps/android/clustering/Cluster;)Lcom/google/android/gms/maps/model/BitmapDescriptor; + public fun getForceRecluster ()Z public fun getMarker (Lcom/google/maps/android/clustering/Cluster;)Lcom/google/android/gms/maps/model/Marker; public fun getMarker (Lcom/google/maps/android/clustering/ClusterItem;)Lcom/google/android/gms/maps/model/Marker; + public fun getMaxNonZeroDigits ()I public fun getMinClusterSize ()I + public fun getShowExactCount ()Z + public fun getUseCompactNumberFormatting ()Z public fun onAdd ()V protected fun onBeforeClusterItemRendered (Lcom/google/maps/android/clustering/ClusterItem;Lcom/google/android/gms/maps/model/MarkerOptions;)V protected fun onBeforeClusterRendered (Lcom/google/maps/android/clustering/Cluster;Lcom/google/android/gms/maps/model/MarkerOptions;)V @@ -359,6 +448,10 @@ public class com/google/maps/android/clustering/view/DefaultClusterRenderer : co public fun onRemove ()V public fun setAnimation (Z)V public fun setAnimationDuration (J)V + public fun setBuckets ([I)V + public fun setCompactUnitUppercase (Z)V + public fun setForceRecluster (Z)V + public fun setMaxNonZeroDigits (I)V public fun setMinClusterSize (I)V public fun setOnClusterClickListener (Lcom/google/maps/android/clustering/ClusterManager$OnClusterClickListener;)V public fun setOnClusterInfoWindowClickListener (Lcom/google/maps/android/clustering/ClusterManager$OnClusterInfoWindowClickListener;)V @@ -366,11 +459,14 @@ public class com/google/maps/android/clustering/view/DefaultClusterRenderer : co public fun setOnClusterItemClickListener (Lcom/google/maps/android/clustering/ClusterManager$OnClusterItemClickListener;)V public fun setOnClusterItemInfoWindowClickListener (Lcom/google/maps/android/clustering/ClusterManager$OnClusterItemInfoWindowClickListener;)V public fun setOnClusterItemInfoWindowLongClickListener (Lcom/google/maps/android/clustering/ClusterManager$OnClusterItemInfoWindowLongClickListener;)V + public fun setShowExactCount (Z)V + public fun setUseCompactNumberFormatting (Z)V protected fun shouldRender (Ljava/util/Set;Ljava/util/Set;)Z protected fun shouldRenderAsCluster (Lcom/google/maps/android/clustering/Cluster;)Z } public final class com/google/maps/android/clustering/view/DefaultClusterRenderer$Companion { + public final fun getDEFAULT_BUCKETS ()[I } public final class com/google/maps/android/geometry/Bounds { diff --git a/clustering/src/main/java/com/google/maps/android/clustering/ClusterManager.kt b/clustering/src/main/java/com/google/maps/android/clustering/ClusterManager.kt index 3071daed7..9db3f1c9b 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/ClusterManager.kt +++ b/clustering/src/main/java/com/google/maps/android/clustering/ClusterManager.kt @@ -27,6 +27,7 @@ import com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgor import com.google.maps.android.clustering.algo.PreCachingAlgorithmDecorator import com.google.maps.android.clustering.algo.ScreenBasedAlgorithm import com.google.maps.android.clustering.algo.ScreenBasedAlgorithmAdapter +import com.google.maps.android.clustering.algo.SuperClusterAlgorithm import com.google.maps.android.clustering.view.ClusterRenderer import com.google.maps.android.clustering.view.DefaultClusterRenderer import com.google.maps.android.collections.MarkerManager @@ -130,6 +131,11 @@ public open class ClusterManager algorithm.unlock() } + val algo = mAlgorithm + if (algo is SuperClusterAlgorithm<*>) { + algo.onProgressListener = mOnClusteringProgressListener + } + if (mAlgorithm.shouldReclusterOnMapMovement()) { mAlgorithm.onCameraChange(mMap.cameraPosition) } @@ -137,6 +143,21 @@ public open class ClusterManager cluster() } + private var mOnClusteringProgressListener: OnClusteringProgressListener? = null + + /** + * Optional progress listener invoked during intensive spatial indexing passes (e.g. [SuperClusterAlgorithm]). + */ + public open var onClusteringProgressListener: OnClusteringProgressListener? + get() = mOnClusteringProgressListener + set(value) { + mOnClusteringProgressListener = value + val algo = mAlgorithm + if (algo is SuperClusterAlgorithm<*>) { + algo.onProgressListener = value + } + } + public open fun setAnimation(animate: Boolean) { mRenderer.setAnimation(animate) } @@ -432,4 +453,15 @@ public open class ClusterManager public fun interface OnClusterItemInfoWindowLongClickListener { public fun onClusterItemInfoWindowLongClick(item: T) } + + /** + * Called during intensive spatial indexing or clustering passes to report progress. + */ + public fun interface OnClusteringProgressListener { + /** + * @param progress Progress ratio between 0.0f and 1.0f (or -1.0f if indeterminate). + * @param status Human-readable description of current clustering stage (e.g. "Indexing zoom level 12"). + */ + public fun onClusteringProgress(progress: Float, status: String) + } } diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/FlatKdTree.kt b/clustering/src/main/java/com/google/maps/android/clustering/algo/FlatKdTree.kt new file mode 100644 index 000000000..0b122f24b --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/algo/FlatKdTree.kt @@ -0,0 +1,241 @@ +/* + * Copyright 2026 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 + +/** + * A high-performance, allocation-free 2D static spatial index packed into flat primitive arrays. + * + * ### Architectural Motivation & Mobile Scale + * Traditional spatial data structures (such as pointer-based QuadTrees or object-oriented K-D Trees) + * allocate an object instance for every node and item wrapper in the tree. When scaling to + * 100,000–1,000,000 points, the resulting millions of heap allocations trigger heavy garbage + * collection pauses (GC STW) and risk `OutOfMemoryError` on Android devices with constrained heaps. + * + * [FlatKdTree] eliminates object allocation overhead by packing the tree into contiguous primitive + * arrays: + * - Coordinates are stored in a contiguous [DoubleArray] of size `2 * N` (`[x0, y0, x1, y1, ...]`). + * - Node identities and search indices are stored in a flat [IntArray] of size `N`. + * + * Tree construction operates in-place using quickselect median partitioning across alternating + * spatial axes (X and Y), requiring $O(N \log N)$ build time and zero extra heap objects. + * Spatial range and radius queries execute with zero object allocations via a consumer callback. + * + * @param coords Flat array containing interleaved X and Y coordinates (`2 * i` is X, `2 * i + 1` is Y). + * @param ids Array of entity indices to index. This array is partitioned in-place during construction. + * @param nodeSize Maximum leaf size before partitioning (default 64). + */ +internal class FlatKdTree( + private val coords: DoubleArray, + val ids: IntArray, + private val nodeSize: Int = 64, +) { + val size: Int = ids.size + + init { + if (ids.isNotEmpty()) { + sort(0, ids.size - 1, 0) + } + } + + /** + * Recursively partitions the [ids] array using in-place median selection on alternating axes. + */ + private fun sort(left: Int, right: Int, depth: Int) { + if (right - left <= nodeSize) { + return + } + val mid = (left + right) ushr 1 + val axis = depth % 2 // 0 = X, 1 = Y + select(left, right, mid, axis) + sort(left, mid - 1, depth + 1) + sort(mid + 1, right, depth + 1) + } + + /** + * In-place quickselect algorithm to place the k-th smallest element at index [k]. + * Uses median-of-three pivot selection and deterministic tie-breaking for duplicate coordinates. + */ + private fun select(left: Int, right: Int, k: Int, axis: Int) { + var l = left + var r = right + while (l < r) { + val pivotIndex = (l + r) ushr 1 + val pivotId = ids[pivotIndex] + val pivotVal = coords[2 * pivotId + axis] + swap(pivotIndex, r) + var storeIndex = l + for (i in l until r) { + val currentId = ids[i] + val v = coords[2 * currentId + axis] + if (v < pivotVal || (v == pivotVal && currentId < pivotId)) { + swap(i, storeIndex) + storeIndex++ + } + } + swap(storeIndex, r) + when { + storeIndex == k -> return + storeIndex < k -> l = storeIndex + 1 + else -> r = storeIndex - 1 + } + } + } + + private fun swap(i: Int, j: Int) { + val temp = ids[i] + ids[i] = ids[j] + ids[j] = temp + } + + /** + * Queries all entity IDs within the rectangular bounds `[minX, minY, maxX, maxY]`. + * + * @param minX Minimum X boundary + * @param minY Minimum Y boundary + * @param maxX Maximum X boundary + * @param maxY Maximum Y boundary + * @param consumer Callback invoked for each matching entity ID without allocating objects. + */ + fun queryRange( + minX: Double, + minY: Double, + maxX: Double, + maxY: Double, + consumer: (Int) -> Unit, + ) { + if (ids.isEmpty()) return + queryRange(0, ids.size - 1, 0, minX, minY, maxX, maxY, consumer) + } + + private fun queryRange( + left: Int, + right: Int, + depth: Int, + minX: Double, + minY: Double, + maxX: Double, + maxY: Double, + consumer: (Int) -> Unit, + ) { + if (right - left <= nodeSize) { + for (i in left..right) { + val id = ids[i] + val x = coords[2 * id] + val y = coords[2 * id + 1] + if (x in minX..maxX && y in minY..maxY) { + consumer(id) + } + } + return + } + + val mid = (left + right) ushr 1 + val midId = ids[mid] + val midX = coords[2 * midId] + val midY = coords[2 * midId + 1] + + if (midX in minX..maxX && midY in minY..maxY) { + consumer(midId) + } + + val axis = depth % 2 + val axisVal = if (axis == 0) midX else midY + val minVal = if (axis == 0) minX else minY + val maxVal = if (axis == 0) maxX else maxY + + if (minVal <= axisVal) { + queryRange(left, mid - 1, depth + 1, minX, minY, maxX, maxY, consumer) + } + if (maxVal >= axisVal) { + queryRange(mid + 1, right, depth + 1, minX, minY, maxX, maxY, consumer) + } + } + + /** + * Queries all entity IDs within Euclidean distance [radius] from query point (`[qx]`, `[qy]`). + * + * @param qx Query point X + * @param qy Query point Y + * @param radius Search radius + * @param consumer Callback invoked for each matching entity ID without allocating objects. + */ + fun queryRadius( + qx: Double, + qy: Double, + radius: Double, + consumer: (Int) -> Unit, + ) { + if (ids.isEmpty()) return + val r2 = radius * radius + val minX = qx - radius + val maxX = qx + radius + val minY = qy - radius + val maxY = qy + radius + queryRadius(0, ids.size - 1, 0, qx, qy, r2, minX, minY, maxX, maxY, consumer) + } + + private fun queryRadius( + left: Int, + right: Int, + depth: Int, + qx: Double, + qy: Double, + r2: Double, + minX: Double, + minY: Double, + maxX: Double, + maxY: Double, + consumer: (Int) -> Unit, + ) { + if (right - left <= nodeSize) { + for (i in left..right) { + val id = ids[i] + val x = coords[2 * id] + val y = coords[2 * id + 1] + val dx = x - qx + val dy = y - qy + if (dx * dx + dy * dy <= r2) { + consumer(id) + } + } + return + } + + val mid = (left + right) ushr 1 + val midId = ids[mid] + val midX = coords[2 * midId] + val midY = coords[2 * midId + 1] + + val dx = midX - qx + val dy = midY - qy + if (dx * dx + dy * dy <= r2) { + consumer(midId) + } + + val axis = depth % 2 + val axisVal = if (axis == 0) midX else midY + val minVal = if (axis == 0) minX else minY + val maxVal = if (axis == 0) maxX else maxY + + if (minVal <= axisVal) { + queryRadius(left, mid - 1, depth + 1, qx, qy, r2, minX, minY, maxX, maxY, consumer) + } + if (maxVal >= axisVal) { + queryRadius(mid + 1, right, depth + 1, qx, qy, r2, minX, minY, maxX, maxY, consumer) + } + } +} diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/SuperCluster.kt b/clustering/src/main/java/com/google/maps/android/clustering/algo/SuperCluster.kt new file mode 100644 index 000000000..d4f88a42b --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/algo/SuperCluster.kt @@ -0,0 +1,144 @@ +/* + * Copyright 2026 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.android.gms.maps.model.LatLng +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.geometry.Point +import com.google.maps.android.projection.SphericalMercatorProjection +import java.util.Collections + +/** + * Represents a cluster or an unclustered single point within [SuperClusterAlgorithm]. + * + * Implements [Cluster] so that instances can be passed directly to + * [com.google.maps.android.clustering.view.ClusterRenderer] for visualization on a GoogleMap. + * + * ### Hierarchical Memory Model + * Traditional clustering models copy and duplicate sets of items across every zoom level. At scales + * of 100,000–1,000,000 items, eagerly populating item collections per cluster causes massive heap + * bloat. + * + * [SuperCluster] solves this with lazy hierarchical resolution: + * - Leaf nodes store the direct reference to their underlying [ClusterItem]. + * - Composite clusters store their weighted centroid coordinates, aggregate [size], and child + * node references from the zoom level below. + * - [size] is an $O(1)$ primitive field access, allowing map renderers to display cluster counts + * instantaneously. + * - [position] is computed lazily from normalized Mercator coordinates on first access. + * - [items] traverses the child hierarchy on demand only when inspected. + * + * @param The [ClusterItem] type. + */ +public class SuperCluster internal constructor( + public val clusterId: Int, + internal val mercatorX: Double, + internal val mercatorY: Double, + public override val size: Int, + private val leafItem: T?, + private val children: List>?, + public val zoom: Int, + private val itemsProvider: (() -> Collection)? = null, +) : Cluster { + + @Volatile + private var cachedPosition: LatLng? = null + + public override val position: LatLng + get() { + if (leafItem != null) { + return leafItem.position + } + var pos = cachedPosition + if (pos == null) { + pos = PROJECTION.toLatLng(Point(mercatorX, mercatorY)) + cachedPosition = pos + } + return pos + } + + /** + * True if this represents a single unclustered [ClusterItem], false if it is a cluster composed + * of multiple items. + */ + public val isLeaf: Boolean + get() = leafItem != null + + /** + * The underlying [ClusterItem] if this is a leaf node, or `null` if this is a composite cluster. + */ + public val item: T? + get() = leafItem + + /** + * Child clusters or points from the next zoom level down that form this cluster. + * Returns an empty list if this node is a leaf or child clusters are lazily resolved. + */ + public val childClusters: List> + get() = children ?: emptyList() + + /** + * Lazily collects all leaf [ClusterItem] objects represented by this cluster. + */ + public override val items: Collection + get() { + if (leafItem != null) { + return Collections.singleton(leafItem) + } + if (itemsProvider != null) { + return itemsProvider.invoke() + } + val result = ArrayList(size) + collectLeafItems(result) + return result + } + + private fun collectLeafItems(out: MutableList) { + if (leafItem != null) { + out.add(leafItem) + } else if (children != null) { + for (child in children) { + child.collectLeafItems(out) + } + } + } + + public override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SuperCluster<*>) return false + return clusterId == other.clusterId && + size == other.size && + mercatorX == other.mercatorX && + mercatorY == other.mercatorY + } + + public override fun hashCode(): Int { + var result = clusterId + result = 31 * result + mercatorX.hashCode() + result = 31 * result + size + result = 31 * result + mercatorY.hashCode() + return result + } + + public override fun toString(): String = + "SuperCluster(id=$clusterId, size=$size, position=$position, isLeaf=$isLeaf, zoom=$zoom)" + + internal companion object { + private val PROJECTION = SphericalMercatorProjection(1.0) + } +} diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/SuperClusterAlgorithm.kt b/clustering/src/main/java/com/google/maps/android/clustering/algo/SuperClusterAlgorithm.kt new file mode 100644 index 000000000..84180962b --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/algo/SuperClusterAlgorithm.kt @@ -0,0 +1,717 @@ +/* + * Copyright 2026 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.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.ClusterManager +import com.google.maps.android.geometry.Bounds +import com.google.maps.android.geometry.Point +import com.google.maps.android.projection.SphericalMercatorProjection +import java.util.ArrayList +import java.util.LinkedHashSet +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow + +// [START maps_android_utils_supercluster_algorithm] +/** + * A hierarchical greedy geospatial clustering algorithm designed to handle hundreds of thousands + * to a million points with sub-millisecond query performance and minimal heap allocation on Android. + * + * ### Algorithmic Foundation: The Supercluster Architecture + * Traditional runtime clustering (e.g. [NonHierarchicalDistanceBasedAlgorithm]) performs $O(N \log N)$ + * or $O(N^2)$ spatial searches from scratch on every camera motion, while instantiating millions of + * transient objects (`QuadItem`, `HashSet`, `HashMap`) on the Android ART heap. + * + * In contrast, [SuperClusterAlgorithm] implements a bottom-up hierarchical zoom pyramid powered by + * [FlatKdTree]: + * 1. **Primitive Storage**: Coordinates, cluster sizes, and spatial hierarchies are indexed in flat + * contiguous primitive arrays (`DoubleArray`, `IntArray`), preventing object churn and GC pauses. + * 2. **Bottom-Up Pyramid Generation**: Raw points are ingested at `maxZoom + 1`. The index builds + * downward from `maxZoom` to `minZoom`. Clusters formed at zoom $z + 1$ become the inputs for + * zoom $z$, leading to exponential point reduction ($N \rightarrow N/4 \rightarrow \dots$) as the + * map zooms out. + * 3. **Sub-Millisecond Viewport Queries**: During camera panning and zooming, [getClusters] does + * not recompute clusters. It performs a range query on the static spatial index at the target + * zoom level, executing in $O(\log N + K)$ time (< 1 ms). + * 4. **Screen-Based Viewport Culling**: Implements [ScreenBasedAlgorithm] to restrict returned + * clusters to the visible camera viewport (plus a safety buffer), preventing the map rendering + * pipeline from being overwhelmed by off-screen markers. + * 5. **Zero-Allocation Raw Coordinate Ingestion**: Supports direct ingestion of raw [DoubleArray] + * buffers via [setCoordinates] with lazy [ClusterItem] generation, eliminating millions of wrapper + * objects during mega-scale initialization. + * + * @param The [ClusterItem] type. + * @param minZoom Minimum zoom level to index (default 0). + * @param maxZoom Maximum zoom level to index before rendering unclustered points (default 16). + * @param radius Cluster radius in pixels at the specified [extent] (default 64.0). + * @param extent Tile extent in pixels (default 512.0). + * @param viewWidth Initial map viewport width in dp/pixels (0 for full-world unbounded queries). + * @param viewHeight Initial map viewport height in dp/pixels (0 for full-world unbounded queries). + */ +public class SuperClusterAlgorithm( + public var minZoom: Int = DEFAULT_MIN_ZOOM, + public var maxZoom: Int = DEFAULT_MAX_ZOOM, + radius: Double = DEFAULT_RADIUS, + public var extent: Double = DEFAULT_EXTENT, + public var viewWidth: Int = 0, + public var viewHeight: Int = 0, +) : AbstractAlgorithm(), + ScreenBasedAlgorithm { + + private var mRadius: Double = radius + private var mReclusterOnMapMovement: Boolean = true + private var mMapCenter: LatLng? = null + private var mCurrentZoom: Float = 0f + + private var mItems = ArrayList() + private var mRawCoordinates: DoubleArray? = null + private var mItemFactory: ((index: Int, position: LatLng) -> T)? = null + private var mNeedsRebuild = true + + /** + * Optional listener invoked during index building to report progress. + */ + public var onProgressListener: ClusterManager.OnClusteringProgressListener? = null + + private class ZoomLevel( + val tree: FlatKdTree, + val coords: DoubleArray, + val sizes: IntArray, + val leafIndices: IntArray, + val clusterIds: IntArray, + val childIndices: Array?, + ) + + @Volatile + private var mLevels: Array = emptyArray() + + /** + * Total number of items or raw coordinates currently registered with this algorithm. + */ + public val totalItemCount: Int + get() = if (mItems.isNotEmpty()) mItems.size else ((mRawCoordinates?.size ?: 0) / 2) + + /** + * Cluster radius in pixels. Updating this property invalidates the pyramid index. + */ + public var radius: Double + get() = mRadius + set(value) { + if (mRadius != value) { + mRadius = value + mNeedsRebuild = true + } + } + + /** + * Maps to [radius] in pixels to satisfy the [Algorithm] interface contract. + */ + public override var maxDistanceBetweenClusteredItems: Int + get() = mRadius.toInt() + set(value) { + radius = value.toDouble() + } + + /** + * Whether map panning should trigger re-clustering. Default is `true`. + */ + public var reclusterOnMapMovement: Boolean + get() = mReclusterOnMapMovement + set(value) { + mReclusterOnMapMovement = value + } + + public override fun shouldReclusterOnMapMovement(): Boolean = mReclusterOnMapMovement + + public override fun onCameraChange(position: CameraPosition) { + mMapCenter = position.target + mCurrentZoom = position.zoom + } + + /** + * Updates the dimensions of the map viewport. + * + * @param width Viewport width in dp/pixels. + * @param height Viewport height in dp/pixels. + */ + public fun updateViewSize(width: Int, height: Int) { + viewWidth = width + viewHeight = height + } + + /** + * Ingests a raw contiguous buffer of coordinates `[lat0, lng0, lat1, lng1, ...]` with an optional + * factory to create [ClusterItem] instances only when unclustered points are rendered. + * + * This provides zero-allocation ingestion for massive datasets (1,000,000+ points). + */ + public fun setCoordinates( + coords: DoubleArray, + itemFactory: ((index: Int, position: LatLng) -> T)? = null, + ) { + lock() + try { + mItems.clear() + mRawCoordinates = coords + mItemFactory = itemFactory + mNeedsRebuild = true + mLevels = emptyArray() + } finally { + unlock() + } + } + + public override fun addItem(item: T): Boolean { + var result: Boolean + lock() + try { + mRawCoordinates = null + result = mItems.add(item) + if (result) { + mNeedsRebuild = true + } + } finally { + unlock() + } + return result + } + + public override fun addItems(items: Collection): Boolean { + var result: Boolean + lock() + try { + mRawCoordinates = null + result = mItems.addAll(items) + if (result) { + mNeedsRebuild = true + } + } finally { + unlock() + } + return result + } + + public override fun removeItem(item: T): Boolean { + var result: Boolean + lock() + try { + mRawCoordinates = null + result = mItems.remove(item) + if (result) { + mNeedsRebuild = true + } + } finally { + unlock() + } + return result + } + + public override fun removeItems(items: Collection): Boolean { + var result: Boolean + lock() + try { + mRawCoordinates = null + result = mItems.removeAll(items.toSet()) + if (result) { + mNeedsRebuild = true + } + } finally { + unlock() + } + return result + } + + public override fun updateItem(item: T): Boolean { + var result: Boolean + lock() + try { + mRawCoordinates = null + result = mItems.remove(item) + if (result) { + mItems.add(item) + mNeedsRebuild = true + } + } finally { + unlock() + } + return result + } + + public override fun clearItems() { + lock() + try { + mItems.clear() + mRawCoordinates = null + mItemFactory = null + mNeedsRebuild = true + mLevels = emptyArray() + } finally { + unlock() + } + } + + public override val items: Collection + @Suppress("UNCHECKED_CAST") + get() { + lock() + try { + if (mItems.isNotEmpty()) { + return ArrayList(mItems) + } + val raw = mRawCoordinates ?: return emptyList() + val factory = mItemFactory + val count = raw.size / 2 + val result = ArrayList(count) + for (i in 0 until count) { + val lat = raw[2 * i] + val lng = raw[2 * i + 1] + val pos = LatLng(lat, lng) + val item = factory?.invoke(i, pos) ?: (DefaultLeafItem(pos) as T) + result.add(item) + } + return result + } finally { + unlock() + } + } + + /** + * Forces an immediate rebuild of the hierarchical zoom pyramid if dirty. + */ + public fun buildIndexIfNeeded() { + lock() + try { + if (mNeedsRebuild) { + buildIndex() + } + } finally { + unlock() + } + } + + /** + * In-place bottom-up construction of the multi-scale spatial pyramid using flat contiguous + * primitive arrays. + */ + private fun buildIndex() { + val count = totalItemCount + val totalLevels = maxZoom + 2 + val levels = arrayOfNulls(totalLevels) + + if (count == 0) { + mLevels = levels + mNeedsRebuild = false + return + } + + // 1. Project raw coordinates into flat DoubleArray + onProgressListener?.onClusteringProgress(0.05f, "Preparing coordinates...") + val rawCoords = DoubleArray(2 * count) + val raw = mRawCoordinates + if (raw != null) { + for (i in 0 until count) { + val lat = raw[2 * i] + val lng = raw[2 * i + 1] + val pt = PROJECTION.toPoint(LatLng(lat, lng)) + rawCoords[2 * i] = pt.x + rawCoords[2 * i + 1] = pt.y + } + } else { + for (i in 0 until count) { + val pt = PROJECTION.toPoint(mItems[i].position) + rawCoords[2 * i] = pt.x + rawCoords[2 * i + 1] = pt.y + } + } + + // 2. Build initial spatial index to detect coincident points (distance 0.0) + onProgressListener?.onClusteringProgress(0.12f, "Indexing leaf markers...") + val initialTree = FlatKdTree(rawCoords, IntArray(count) { it }) + val clustered = BooleanArray(count) + val neighborBuffer = ArrayList() + + var leafCoords = DoubleArray(count * 2) + var leafSizes = IntArray(count) + var leafIndices = IntArray(count) + var leafClusterIds = IntArray(count) + var leafChildIndices = arrayOfNulls(count) + var leafCount = 0 + var idCounter = 0 + + for (i in 0 until count) { + if (clustered[i]) continue + + val x = rawCoords[2 * i] + val y = rawCoords[2 * i + 1] + + neighborBuffer.clear() + initialTree.queryRadius(x, y, 0.0) { neighborId -> + if (!clustered[neighborId]) { + neighborBuffer.add(neighborId) + } + } + + if (neighborBuffer.size <= 1) { + clustered[i] = true + leafCoords[2 * leafCount] = x + leafCoords[2 * leafCount + 1] = y + leafSizes[leafCount] = 1 + leafIndices[leafCount] = i + leafClusterIds[leafCount] = idCounter++ + leafChildIndices[leafCount] = null + leafCount++ + } else { + val childArray = IntArray(neighborBuffer.size) + for (n in 0 until neighborBuffer.size) { + val nid = neighborBuffer[n] + clustered[nid] = true + childArray[n] = nid + } + leafCoords[2 * leafCount] = x + leafCoords[2 * leafCount + 1] = y + leafSizes[leafCount] = neighborBuffer.size + leafIndices[leafCount] = -1 + leafClusterIds[leafCount] = -(idCounter++) + leafChildIndices[leafCount] = childArray + leafCount++ + } + } + + val finalLeafCoords = if (leafCount == count) leafCoords else leafCoords.copyOf(2 * leafCount) + val finalLeafSizes = if (leafCount == count) leafSizes else leafSizes.copyOf(leafCount) + val finalLeafIndices = if (leafCount == count) leafIndices else leafIndices.copyOf(leafCount) + val finalLeafClusterIds = if (leafCount == count) leafClusterIds else leafClusterIds.copyOf(leafCount) + val finalLeafChildIndices = if (leafCount == count) leafChildIndices else leafChildIndices.copyOf(leafCount) + + val leafTree = if (leafCount == count) initialTree else FlatKdTree(finalLeafCoords, IntArray(leafCount) { it }) + levels[maxZoom + 1] = ZoomLevel( + tree = leafTree, + coords = finalLeafCoords, + sizes = finalLeafSizes, + leafIndices = finalLeafIndices, + clusterIds = finalLeafClusterIds, + childIndices = finalLeafChildIndices, + ) + + var currentLevel = levels[maxZoom + 1]!! + var nextClusterId = -1 + + // 3. Bottom-up aggregation: cluster level z + 1 into level z using flat primitive arrays + val totalZoomSteps = maxZoom - minZoom + 1 + for (z in maxZoom downTo minZoom) { + val step = maxZoom - z + 1 + val progress = 0.15f + 0.82f * (step.toFloat() / totalZoomSteps.toFloat()) + val pct = (progress * 100).toInt() + onProgressListener?.onClusteringProgress(progress, "Building zoom level $z/$maxZoom ($pct%)") + + val currentCount = currentLevel.sizes.size + val currentTree = currentLevel.tree + val clusterRadius = mRadius / (extent * 2.0.pow(z.toDouble())) + val levelClustered = BooleanArray(currentCount) + + val nextCoords = DoubleArray(currentCount * 2) + val nextSizes = IntArray(currentCount) + val nextLeafIndices = IntArray(currentCount) + val nextClusterIds = IntArray(currentCount) + val nextChildIndices = arrayOfNulls(currentCount) + var outCount = 0 + + for (i in 0 until currentCount) { + if (levelClustered[i]) continue + + val candidateX = currentLevel.coords[2 * i] + val candidateY = currentLevel.coords[2 * i + 1] + val candidateSize = currentLevel.sizes[i] + + neighborBuffer.clear() + currentTree.queryRadius(candidateX, candidateY, clusterRadius) { neighborId -> + if (!levelClustered[neighborId]) { + neighborBuffer.add(neighborId) + } + } + + if (neighborBuffer.size <= 1) { + levelClustered[i] = true + nextCoords[2 * outCount] = candidateX + nextCoords[2 * outCount + 1] = candidateY + nextSizes[outCount] = candidateSize + nextLeafIndices[outCount] = currentLevel.leafIndices[i] + nextClusterIds[outCount] = currentLevel.clusterIds[i] + nextChildIndices[outCount] = if (candidateSize > 1) intArrayOf(i) else null + outCount++ + } else { + var weightedX = 0.0 + var weightedY = 0.0 + var totalSize = 0 + val childArray = IntArray(neighborBuffer.size) + + for (n in 0 until neighborBuffer.size) { + val neighborId = neighborBuffer[n] + levelClustered[neighborId] = true + childArray[n] = neighborId + val childSize = currentLevel.sizes[neighborId] + weightedX += currentLevel.coords[2 * neighborId] * childSize + weightedY += currentLevel.coords[2 * neighborId + 1] * childSize + totalSize += childSize + } + + nextCoords[2 * outCount] = weightedX / totalSize + nextCoords[2 * outCount + 1] = weightedY / totalSize + nextSizes[outCount] = totalSize + nextLeafIndices[outCount] = -1 + nextClusterIds[outCount] = nextClusterId-- + nextChildIndices[outCount] = childArray + outCount++ + } + } + + val finalCoords = if (outCount == currentCount) nextCoords else nextCoords.copyOf(2 * outCount) + val finalSizes = if (outCount == currentCount) nextSizes else nextSizes.copyOf(outCount) + val finalLeafIndices = if (outCount == currentCount) nextLeafIndices else nextLeafIndices.copyOf(outCount) + val finalClusterIds = if (outCount == currentCount) nextClusterIds else nextClusterIds.copyOf(outCount) + val finalChildIndices = if (outCount == currentCount) nextChildIndices else nextChildIndices.copyOf(outCount) + + val levelTree = FlatKdTree(finalCoords, IntArray(outCount) { it }) + val newLevel = ZoomLevel( + tree = levelTree, + coords = finalCoords, + sizes = finalSizes, + leafIndices = finalLeafIndices, + clusterIds = finalClusterIds, + childIndices = finalChildIndices, + ) + levels[z] = newLevel + currentLevel = newLevel + } + + mLevels = levels + mNeedsRebuild = false + onProgressListener?.onClusteringProgress(1.0f, "Indexing complete") + } + + public override fun getClusters(zoom: Float): Set> { + lock() + try { + if (mNeedsRebuild) { + buildIndex() + } + if (totalItemCount == 0) { + return emptySet() + } + + val z = if (zoom > maxZoom) { + maxZoom + 1 + } else { + zoom.toInt().coerceIn(minZoom, maxZoom) + } + + val level = mLevels.getOrNull(z) ?: return emptySet() + + // Viewport culling if camera center and view dimensions are defined + val center = mMapCenter + if (center != null && viewWidth > 0 && viewHeight > 0) { + val bounds = calculateVisibleMercatorBounds(center, zoom, viewWidth, viewHeight) + return queryBoundsInternal(z, level, bounds) + } + + // Unbounded: return all clusters at zoom level z + val result = LinkedHashSet>(level.sizes.size) + for (id in 0 until level.sizes.size) { + result.add(createSuperCluster(z, id, level)) + } + return result + } finally { + unlock() + } + } + + /** + * Queries clusters within the specified geographic bounding box at the given [zoom]. + * + * @param bounds Geographic bounding box in LatLng coordinates. + * @param zoom Map zoom level. + * @return Set of clusters and unclustered items visible within [bounds]. + */ + public fun getClusters(bounds: Bounds, zoom: Float): Set> { + lock() + try { + if (mNeedsRebuild) { + buildIndex() + } + if (totalItemCount == 0) { + return emptySet() + } + + val z = if (zoom > maxZoom) { + maxZoom + 1 + } else { + zoom.toInt().coerceIn(minZoom, maxZoom) + } + + val level = mLevels.getOrNull(z) ?: return emptySet() + return queryBoundsInternal(z, level, bounds) + } finally { + unlock() + } + } + + private fun queryBoundsInternal( + z: Int, + level: ZoomLevel, + bounds: Bounds, + ): Set> { + val result = LinkedHashSet>() + + // Handle antimeridian wrapping + if (bounds.minX < 0.0) { + level.tree.queryRange(bounds.minX + 1.0, bounds.minY, 1.0, bounds.maxY) { id -> + result.add(createSuperCluster(z, id, level)) + } + level.tree.queryRange(0.0, bounds.minY, bounds.maxX, bounds.maxY) { id -> + result.add(createSuperCluster(z, id, level)) + } + } else if (bounds.maxX > 1.0) { + level.tree.queryRange(bounds.minX, bounds.minY, 1.0, bounds.maxY) { id -> + result.add(createSuperCluster(z, id, level)) + } + level.tree.queryRange(0.0, bounds.minY, bounds.maxX - 1.0, bounds.maxY) { id -> + result.add(createSuperCluster(z, id, level)) + } + } else { + level.tree.queryRange(bounds.minX, bounds.minY, bounds.maxX, bounds.maxY) { id -> + result.add(createSuperCluster(z, id, level)) + } + } + + return result + } + + private fun createSuperCluster(z: Int, id: Int, level: ZoomLevel): SuperCluster { + val size = level.sizes[id] + val clusterId = level.clusterIds[id] + val x = level.coords[2 * id] + val y = level.coords[2 * id + 1] + + return if (size == 1 && level.leafIndices[id] >= 0) { + val leafIndex = level.leafIndices[id] + val item = resolveLeafItem(leafIndex, x, y) + SuperCluster( + clusterId = clusterId, + mercatorX = x, + mercatorY = y, + size = 1, + leafItem = item, + children = null, + zoom = z, + ) + } else { + SuperCluster( + clusterId = clusterId, + mercatorX = x, + mercatorY = y, + size = size, + leafItem = null, + children = null, + zoom = z, + itemsProvider = { + collectLeafItemsForCluster(z, id) + }, + ) + } + } + + @Suppress("UNCHECKED_CAST") + private fun resolveLeafItem(leafIndex: Int, mercatorX: Double, mercatorY: Double): T { + if (mItems.isNotEmpty() && leafIndex in mItems.indices) { + return mItems[leafIndex] + } + val raw = mRawCoordinates + if (raw != null && leafIndex * 2 + 1 < raw.size) { + val lat = raw[2 * leafIndex] + val lng = raw[2 * leafIndex + 1] + val pos = LatLng(lat, lng) + return mItemFactory?.invoke(leafIndex, pos) ?: (DefaultLeafItem(pos) as T) + } + val pos = PROJECTION.toLatLng(Point(mercatorX, mercatorY)) + return DefaultLeafItem(pos) as T + } + + private fun collectLeafItemsForCluster(startZoom: Int, startIndex: Int): List { + val result = ArrayList() + fun recurse(z: Int, idx: Int) { + val lvl = mLevels.getOrNull(z) ?: return + val size = lvl.sizes[idx] + if (size == 1 && lvl.leafIndices[idx] >= 0) { + val leafIdx = lvl.leafIndices[idx] + result.add(resolveLeafItem(leafIdx, lvl.coords[2 * idx], lvl.coords[2 * idx + 1])) + } else if (z == maxZoom + 1 && lvl.childIndices?.get(idx) != null) { + val rawLeafChildren = lvl.childIndices[idx]!! + for (childIdx in rawLeafChildren) { + result.add(resolveLeafItem(childIdx, lvl.coords[2 * idx], lvl.coords[2 * idx + 1])) + } + } else { + val children = lvl.childIndices?.get(idx) ?: return + for (childIdx in children) { + recurse(z + 1, childIdx) + } + } + } + recurse(startZoom, startIndex) + return result + } + + private fun calculateVisibleMercatorBounds( + center: LatLng, + zoom: Float, + width: Int, + height: Int, + ): Bounds { + val centerPoint = PROJECTION.toPoint(center) + val spanFactor = 2.0.pow(zoom.toDouble()) * 256.0 + // Apply 25% safety buffer so edge markers don't clip abruptly while panning + val halfW = (width.toDouble() / spanFactor / 2.0) * VIEWPORT_BUFFER_RATIO + val halfH = (height.toDouble() / spanFactor / 2.0) * VIEWPORT_BUFFER_RATIO + + return Bounds( + centerPoint.x - halfW, + centerPoint.x + halfW, + (centerPoint.y - halfH).coerceIn(0.0, 1.0), + (centerPoint.y + halfH).coerceIn(0.0, 1.0), + ) + } + + private data class DefaultLeafItem( + override val position: LatLng, + override val title: String? = null, + override val snippet: String? = null, + override val zIndex: Float? = null, + ) : ClusterItem + + public companion object { + public const val DEFAULT_MIN_ZOOM: Int = 0 + public const val DEFAULT_MAX_ZOOM: Int = 16 + public const val DEFAULT_RADIUS: Double = 64.0 + public const val DEFAULT_EXTENT: Double = 512.0 + private const val VIEWPORT_BUFFER_RATIO = 1.25 + private val PROJECTION = SphericalMercatorProjection(1.0) + } +} +// [END maps_android_utils_supercluster_algorithm] diff --git a/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt b/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt index c22107b76..f9118bb4b 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt +++ b/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt @@ -56,12 +56,16 @@ import java.util.ArrayList import java.util.Collections import java.util.HashMap import java.util.LinkedList +import java.util.Locale import java.util.Queue import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executor import java.util.concurrent.Executors import java.util.concurrent.locks.ReentrantLock import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.max import kotlin.math.min import kotlin.math.pow import kotlin.math.sign @@ -103,6 +107,71 @@ public open class DefaultAdvancedMarkersClusterRenderer @JvmOve * If cluster size is less than this size, display individual markers. */ public open var minClusterSize: Int = 4 + set(value) { + field = value + forceRecluster = true + } + + /** + * Whether to format cluster bucket numbers using compact SI notation ('K' for thousands, 'M' for millions). + * For example, 1,000 becomes "1K+", 2,500 becomes "2.5K+", and 1,000,000 becomes "1M+". + * Default is false. + */ + public open var useCompactNumberFormatting: Boolean = false + set(value) { + field = value + clearIconCache() + } + + /** + * If true, displays the exact item count on the cluster icon instead of rounding to + * non-zero digits with a '+' suffix. Default is false. + */ + public open var showExactCount: Boolean = false + set(value) { + field = value + clearIconCache() + } + + /** + * Controls the maximum number of non-zero (significant) digits displayed on cluster badges + * when [showExactCount] is false. + * Default is 1. + */ + public open var maxNonZeroDigits: Int = 1 + set(value) { + field = value + clearIconCache() + } + + /** + * Whether compact SI unit suffixes should be uppercase ('K', 'M') or lowercase ('k', 'm'). + * Default is false ('k', 'm'). + */ + public open var compactUnitUppercase: Boolean = false + set(value) { + field = value + clearIconCache() + } + + /** + * If true, forces the next render pass to redraw clusters regardless of whether the cluster set changed. + */ + @Volatile + public open var forceRecluster: Boolean = false + + /** + * Clears cached cluster icon BitmapDescriptors and marks clusters to be redrawn on the next pass. + */ + public open fun clearIconCache() { + mIcons.clear() + forceRecluster = true + } + + /** + * The cluster size buckets used to group clusters into badge increments. + */ + public open var buckets: IntArray = DEFAULT_BUCKETS /** * The currently displayed set of clusters. @@ -213,12 +282,73 @@ public open class DefaultAdvancedMarkersClusterRenderer @JvmOve return R.style.amu_ClusterIcon_TextAppearance // Default value } - protected open fun getClusterText(bucket: Int): String = - if (bucket < BUCKETS[0]) { - bucket.toString() - } else { - "$bucket+" + protected open fun getClusterText(bucketOrSize: Int): String { + if (showExactCount) { + if (useCompactNumberFormatting) { + return formatCompactNumber(bucketOrSize) + } + return String.format(Locale.US, "%,d", bucketOrSize) + } + if (bucketOrSize < 10) { + return bucketOrSize.toString() + } + + val digits = maxNonZeroDigits.coerceAtLeast(1) + val m = floor(log10(bucketOrSize.toDouble())).toInt() + val p = max(0, m - digits + 1) + val divisor = 10.0.pow(p.toDouble()).toInt() + val rounded = (bucketOrSize / divisor) * divisor + + val kSuffix = if (compactUnitUppercase) "K" else "k" + val mSuffix = if (compactUnitUppercase) "M" else "m" + + if (useCompactNumberFormatting) { + if (rounded >= 1_000_000) { + val millions = rounded / 1_000_000.0 + val formatted = if (millions % 1.0 == 0.0) { + "${millions.toInt()}" + } else { + String.format(Locale.US, "%.1f", millions) + } + return "$formatted$mSuffix+" + } else if (rounded >= 1_000) { + val thousands = rounded / 1_000.0 + val formatted = if (thousands % 1.0 == 0.0) { + "${thousands.toInt()}" + } else { + String.format(Locale.US, "%.1f", thousands) + } + return "$formatted$kSuffix+" + } + } + return "$rounded+" + } + + private fun formatCompactNumber(number: Int): String { + val kSuffix = if (compactUnitUppercase) "K" else "k" + val mSuffix = if (compactUnitUppercase) "M" else "m" + return when { + number >= 1_000_000 -> { + val millions = number / 1_000_000.0 + val formatted = if (millions % 1.0 == 0.0) { + "${millions.toInt()}" + } else { + String.format(Locale.US, "%.1f", millions) + } + "$formatted$mSuffix" + } + number >= 1_000 -> { + val thousands = number / 1_000.0 + val formatted = if (thousands % 1.0 == 0.0) { + "${thousands.toInt()}" + } else { + String.format(Locale.US, "%.1f", thousands) + } + "$formatted$kSuffix" + } + else -> number.toString() } + } /** * Gets the "bucket" for a particular cluster. By default, uses the number of points within the @@ -226,15 +356,16 @@ public open class DefaultAdvancedMarkersClusterRenderer @JvmOve */ protected open fun getBucket(cluster: Cluster): Int { val size = cluster.size - if (size <= BUCKETS[0]) { + val currentBuckets = buckets + if (currentBuckets.isEmpty() || size <= currentBuckets[0]) { return size } - for (i in 0 until BUCKETS.size - 1) { - if (size < BUCKETS[i + 1]) { - return BUCKETS[i] + for (i in 0 until currentBuckets.size - 1) { + if (size < currentBuckets[i + 1]) { + return currentBuckets[i] } } - return BUCKETS[BUCKETS.size - 1] + return currentBuckets[currentBuckets.size - 1] } /** @@ -327,7 +458,13 @@ public open class DefaultAdvancedMarkersClusterRenderer @JvmOve protected open fun shouldRender( oldClusters: Set>, newClusters: Set>, - ): Boolean = newClusters != oldClusters + ): Boolean { + if (forceRecluster) { + forceRecluster = false + return true + } + return newClusters != oldClusters + } /** * Transforms the current view (represented by DefaultAdvancedMarkersClusterRenderer.mClusters and DefaultAdvancedMarkersClusterRenderer.mZoom) to a @@ -912,13 +1049,26 @@ public open class DefaultAdvancedMarkersClusterRenderer @JvmOve * count of the number of items. */ protected open fun getDescriptorForCluster(cluster: Cluster): BitmapDescriptor { - val bucket = getBucket(cluster) - var descriptor = mIcons[bucket] + val key = if (showExactCount) { + cluster.size + } else { + val size = cluster.size + if (size < 10) { + size + } else { + val digits = maxNonZeroDigits.coerceAtLeast(1) + val m = floor(log10(size.toDouble())).toInt() + val p = max(0, m - digits + 1) + val divisor = 10.0.pow(p.toDouble()).toInt() + (size / divisor) * divisor + } + } + var descriptor = mIcons[key] if (descriptor == null) { - mColoredCircleBackground!!.paint.color = getColor(bucket) - mIconGenerator.setTextAppearance(getClusterTextAppearance(bucket)) - descriptor = BitmapDescriptorFactory.fromBitmap(mIconGenerator.makeIcon(getClusterText(bucket))) - mIcons.put(bucket, descriptor) + mColoredCircleBackground!!.paint.color = getColor(key) + mIconGenerator.setTextAppearance(getClusterTextAppearance(key)) + descriptor = BitmapDescriptorFactory.fromBitmap(mIconGenerator.makeIcon(getClusterText(key))) + mIcons.put(key, descriptor) } return descriptor } @@ -1138,7 +1288,8 @@ public open class DefaultAdvancedMarkersClusterRenderer @JvmOve } public companion object { - private val BUCKETS = intArrayOf(10, 20, 50, 100, 200, 500, 1000) + public val DEFAULT_BUCKETS: IntArray = intArrayOf(10, 20, 50, 100, 200, 500, 1000) + private val BUCKETS = DEFAULT_BUCKETS private val ANIMATION_INTERP: TimeInterpolator = DecelerateInterpolator() private const val RUN_TASK = 0 private const val TASK_FINISHED = 1 diff --git a/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt b/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt index d59d406a0..c4e958902 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt +++ b/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt @@ -55,12 +55,16 @@ import java.util.ArrayList import java.util.Collections import java.util.HashMap import java.util.LinkedList +import java.util.Locale import java.util.Queue import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executor import java.util.concurrent.Executors import java.util.concurrent.locks.ReentrantLock import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.max import kotlin.math.min import kotlin.math.pow import kotlin.math.sign @@ -102,6 +106,81 @@ public open class DefaultClusterRenderer @JvmOverloads public c * If cluster size is less than this size, display individual markers. */ public open var minClusterSize: Int = 4 + set(value) { + field = value + forceRecluster = true + } + + /** + * Whether to format cluster bucket numbers using compact SI notation ('K' for thousands, 'M' for millions). + * For example, 1,000 becomes "1k+", 2,500 becomes "2.5k+", and 1,000,000 becomes "1m+". + * Default is false. + */ + public open var useCompactNumberFormatting: Boolean = false + set(value) { + field = value + clearIconCache() + } + + /** + * If true, displays the exact item count on the cluster icon instead of rounding to + * non-zero digits with a '+' suffix. Default is false. + */ + public open var showExactCount: Boolean = false + set(value) { + field = value + clearIconCache() + } + + /** + * Controls the maximum number of non-zero (significant) digits displayed on cluster badges + * when [showExactCount] is false. + * + * For example, with [maxNonZeroDigits] = 1: + * - 5 -> "5" + * - 7 -> "7" + * - 10 -> "10+" + * - 50 -> "50+" + * - 100 -> "100+" + * - 1,450 -> "1k+" (or "1K+" if [compactUnitUppercase] is true) + * - 1,000,000 -> "1m+" (or "1M+") + * + * Default is 1. + */ + public open var maxNonZeroDigits: Int = 1 + set(value) { + field = value + clearIconCache() + } + + /** + * Whether compact SI unit suffixes should be uppercase ('K', 'M') or lowercase ('k', 'm'). + * Default is false ('k', 'm'). + */ + public open var compactUnitUppercase: Boolean = false + set(value) { + field = value + clearIconCache() + } + + /** + * If true, forces the next render pass to redraw clusters regardless of whether the cluster set changed. + */ + @Volatile + public open var forceRecluster: Boolean = false + + /** + * Clears cached cluster icon BitmapDescriptors and marks clusters to be redrawn on the next pass. + */ + public open fun clearIconCache() { + mIcons.clear() + forceRecluster = true + } + + /** + * The cluster size buckets used to group clusters into badge increments. + */ + public open var buckets: IntArray = DEFAULT_BUCKETS /** * The currently displayed set of clusters. @@ -212,12 +291,73 @@ public open class DefaultClusterRenderer @JvmOverloads public c return R.style.amu_ClusterIcon_TextAppearance // Default value } - protected open fun getClusterText(bucket: Int): String = - if (bucket < BUCKETS[0]) { - bucket.toString() - } else { - "$bucket+" + protected open fun getClusterText(bucketOrSize: Int): String { + if (showExactCount) { + if (useCompactNumberFormatting) { + return formatCompactNumber(bucketOrSize) + } + return String.format(Locale.US, "%,d", bucketOrSize) } + if (bucketOrSize < 10) { + return bucketOrSize.toString() + } + + val digits = maxNonZeroDigits.coerceAtLeast(1) + val m = floor(log10(bucketOrSize.toDouble())).toInt() + val p = max(0, m - digits + 1) + val divisor = 10.0.pow(p.toDouble()).toInt() + val rounded = (bucketOrSize / divisor) * divisor + + val kSuffix = if (compactUnitUppercase) "K" else "k" + val mSuffix = if (compactUnitUppercase) "M" else "m" + + if (useCompactNumberFormatting) { + if (rounded >= 1_000_000) { + val millions = rounded / 1_000_000.0 + val formatted = if (millions % 1.0 == 0.0) { + "${millions.toInt()}" + } else { + String.format(Locale.US, "%.1f", millions) + } + return "$formatted$mSuffix+" + } else if (rounded >= 1_000) { + val thousands = rounded / 1_000.0 + val formatted = if (thousands % 1.0 == 0.0) { + "${thousands.toInt()}" + } else { + String.format(Locale.US, "%.1f", thousands) + } + return "$formatted$kSuffix+" + } + } + return "$rounded+" + } + + private fun formatCompactNumber(number: Int): String { + val kSuffix = if (compactUnitUppercase) "K" else "k" + val mSuffix = if (compactUnitUppercase) "M" else "m" + return when { + number >= 1_000_000 -> { + val millions = number / 1_000_000.0 + val formatted = if (millions % 1.0 == 0.0) { + "${millions.toInt()}" + } else { + String.format(Locale.US, "%.1f", millions) + } + "$formatted$mSuffix" + } + number >= 1_000 -> { + val thousands = number / 1_000.0 + val formatted = if (thousands % 1.0 == 0.0) { + "${thousands.toInt()}" + } else { + String.format(Locale.US, "%.1f", thousands) + } + "$formatted$kSuffix" + } + else -> number.toString() + } + } /** * Gets the "bucket" for a particular cluster. By default, uses the number of points within the @@ -225,15 +365,16 @@ public open class DefaultClusterRenderer @JvmOverloads public c */ protected open fun getBucket(cluster: Cluster): Int { val size = cluster.size - if (size <= BUCKETS[0]) { + val currentBuckets = buckets + if (currentBuckets.isEmpty() || size <= currentBuckets[0]) { return size } - for (i in 0 until BUCKETS.size - 1) { - if (size < BUCKETS[i + 1]) { - return BUCKETS[i] + for (i in 0 until currentBuckets.size - 1) { + if (size < currentBuckets[i + 1]) { + return currentBuckets[i] } } - return BUCKETS[BUCKETS.size - 1] + return currentBuckets[currentBuckets.size - 1] } /** @@ -326,7 +467,13 @@ public open class DefaultClusterRenderer @JvmOverloads public c protected open fun shouldRender( oldClusters: Set>, newClusters: Set>, - ): Boolean = newClusters != oldClusters + ): Boolean { + if (forceRecluster) { + forceRecluster = false + return true + } + return newClusters != oldClusters + } /** * Transforms the current view (represented by DefaultClusterRenderer.mClusters and DefaultClusterRenderer.mZoom) to a @@ -921,13 +1068,26 @@ public open class DefaultClusterRenderer @JvmOverloads public c * count of the number of items. */ protected open fun getDescriptorForCluster(cluster: Cluster): BitmapDescriptor { - val bucket = getBucket(cluster) - var descriptor = mIcons[bucket] + val key = if (showExactCount) { + cluster.size + } else { + val size = cluster.size + if (size < 10) { + size + } else { + val digits = maxNonZeroDigits.coerceAtLeast(1) + val m = floor(log10(size.toDouble())).toInt() + val p = max(0, m - digits + 1) + val divisor = 10.0.pow(p.toDouble()).toInt() + (size / divisor) * divisor + } + } + var descriptor = mIcons[key] if (descriptor == null) { - mColoredCircleBackground!!.paint.color = getColor(bucket) - mIconGenerator.setTextAppearance(getClusterTextAppearance(bucket)) - descriptor = BitmapDescriptorFactory.fromBitmap(mIconGenerator.makeIcon(getClusterText(bucket))) - mIcons.put(bucket, descriptor) + mColoredCircleBackground!!.paint.color = getColor(key) + mIconGenerator.setTextAppearance(getClusterTextAppearance(key)) + descriptor = BitmapDescriptorFactory.fromBitmap(mIconGenerator.makeIcon(getClusterText(key))) + mIcons.put(key, descriptor) } return descriptor } @@ -1145,7 +1305,8 @@ public open class DefaultClusterRenderer @JvmOverloads public c } public companion object { - private val BUCKETS = intArrayOf(10, 20, 50, 100, 200, 500, 1000) + public val DEFAULT_BUCKETS: IntArray = intArrayOf(10, 20, 50, 100, 200, 500, 1000) + private val BUCKETS = DEFAULT_BUCKETS private val ANIMATION_INTERP: TimeInterpolator = DecelerateInterpolator() private const val RUN_TASK = 0 private const val TASK_FINISHED = 1 diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/ClusteringPerformanceComparisonTest.kt b/clustering/src/test/java/com/google/maps/android/clustering/algo/ClusteringPerformanceComparisonTest.kt new file mode 100644 index 000000000..da4a6ced6 --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/clustering/algo/ClusteringPerformanceComparisonTest.kt @@ -0,0 +1,194 @@ +/* + * Copyright 2026 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.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.clustering.ClusterItem +import java.util.Random +import org.junit.Test + +/** + * Comparative performance benchmark evaluating [SuperClusterAlgorithm] against traditional + * clustering algorithms: [NonHierarchicalDistanceBasedAlgorithm], [NonHierarchicalViewBasedAlgorithm], + * and [GridBasedAlgorithm]. + */ +class ClusteringPerformanceComparisonTest { + + private class BenchItem( + lat: Double, + lng: Double, + val id: Int, + ) : ClusterItem { + override val position: LatLng = LatLng(lat, lng) + override val title: String? = null + override val snippet: String? = null + override val zIndex: Float? = null + } + + private fun generateUniformItems(count: Int): List { + val random = Random(42) + return List(count) { i -> + val lat = (random.nextDouble() - 0.5) * 140.0 // -70 to 70 + val lng = (random.nextDouble() - 0.5) * 360.0 // -180 to 180 + BenchItem(lat, lng, i) + } + } + + data class BenchmarkResult( + val algorithmName: String, + val itemCount: Int, + val addItemsMs: Double, + val indexBuildMs: Double, + val queryZoom4Ms: Double, + val queryZoom8Ms: Double, + val queryZoom12Ms: Double, + val queryZoom16Ms: Double, + val totalQueryMs: Double, + val memoryUsedMb: Double, + ) + + // [START maps_android_utils_benchmark_comparison_runner] + private fun benchmarkAlgorithm( + name: String, + algorithm: Algorithm, + items: List, + viewWidth: Int = 1080, + viewHeight: Int = 1920, + ): BenchmarkResult { + // Setup screen bounds for ScreenBasedAlgorithms + if (algorithm is ScreenBasedAlgorithm) { + algorithm.onCameraChange( + CameraPosition.Builder() + .target(LatLng(0.0, 0.0)) + .zoom(8f) + .build(), + ) + } + + System.gc() + Thread.sleep(50) + val runtime = Runtime.getRuntime() + val memBefore = (runtime.totalMemory() - runtime.freeMemory()) / (1024.0 * 1024.0) + + // 1. Measure Ingestion + val startAdd = System.nanoTime() + algorithm.addItems(items) + val addMs = (System.nanoTime() - startAdd) / 1_000_000.0 + + // 2. Measure Build / Index (if SuperClusterAlgorithm) + val startBuild = System.nanoTime() + if (algorithm is SuperClusterAlgorithm) { + algorithm.buildIndexIfNeeded() + } + val buildMs = (System.nanoTime() - startBuild) / 1_000_000.0 + + val memAfter = (runtime.totalMemory() - runtime.freeMemory()) / (1024.0 * 1024.0) + val memUsed = (memAfter - memBefore).coerceAtLeast(0.0) + + // 3. Measure Queries across zoom levels (averaged over 3 runs) + fun measureZoom(zoom: Float): Double { + var total = 0.0 + val runs = 3 + for (r in 0 until runs) { + val start = System.nanoTime() + val clusters = algorithm.getClusters(zoom) + val duration = (System.nanoTime() - start) / 1_000_000.0 + total += duration + // Record or consume to prevent JIT dead code elimination + if (clusters.size < 0) println("Unreachable") + } + return total / runs + } + + val q4 = measureZoom(4f) + val q8 = measureZoom(8f) + val q12 = measureZoom(12f) + val q16 = measureZoom(16f) + val totalQuery = q4 + q8 + q12 + q16 + + return BenchmarkResult( + algorithmName = name, + itemCount = items.size, + addItemsMs = addMs, + indexBuildMs = buildMs, + queryZoom4Ms = q4, + queryZoom8Ms = q8, + queryZoom12Ms = q12, + queryZoom16Ms = q16, + totalQueryMs = totalQuery, + memoryUsedMb = memUsed, + ) + } + // [END maps_android_utils_benchmark_comparison_runner] + + @Test + fun runComprehensiveComparisonBenchmark() { + println("\n=========================================================================================") + println(" CLUSTERING BENCHMARK: SuperClusterAlgorithm vs Traditional Algorithms ") + println("=========================================================================================") + + val scales = listOf(10_000, 50_000, 100_000) + + for (count in scales) { + println("\n--- DATASET SIZE: %,d points ---".format(count)) + val items = generateUniformItems(count) + + val results = ArrayList() + + // 1. SuperClusterAlgorithm (Unbounded full-world) + val scUnbounded = SuperClusterAlgorithm() + results.add(benchmarkAlgorithm("SuperCluster (Unbounded)", scUnbounded, items)) + + // 2. SuperClusterAlgorithm (Viewport culling 1080x1920) + val scViewport = SuperClusterAlgorithm(viewWidth = 1080, viewHeight = 1920) + results.add(benchmarkAlgorithm("SuperCluster (Viewport)", scViewport, items)) + + // 3. NonHierarchicalDistanceBasedAlgorithm (Default) + val distanceBased = NonHierarchicalDistanceBasedAlgorithm() + results.add(benchmarkAlgorithm("NonHierarchicalDistance", distanceBased, items)) + + // 4. NonHierarchicalViewBasedAlgorithm (20k demo algo) + val viewBased = NonHierarchicalViewBasedAlgorithm(1080, 1920) + results.add(benchmarkAlgorithm("NonHierarchicalView", viewBased, items)) + + // 5. GridBasedAlgorithm + val gridBased = GridBasedAlgorithm() + results.add(benchmarkAlgorithm("GridBasedAlgorithm", gridBased, items)) + + // Print Markdown Table for this scale + println("| Algorithm | addItems | Index Build | Zoom 4.0 | Zoom 8.0 | Zoom 12.0 | Zoom 16.0 | Total Query | Memory |") + println("| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: |") + for (res in results) { + println( + "| %-24s | %8.2f ms | %8.2f ms | %8.2f ms | %8.2f ms | %8.2f ms | %8.2f ms | %8.2f ms | %6.1f MB |".format( + res.algorithmName, + res.addItemsMs, + res.indexBuildMs, + res.queryZoom4Ms, + res.queryZoom8Ms, + res.queryZoom12Ms, + res.queryZoom16Ms, + res.totalQueryMs, + res.memoryUsedMb, + ), + ) + } + } + println("\n=========================================================================================\n") + } +} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/SuperClusterAlgorithmTest.kt b/clustering/src/test/java/com/google/maps/android/clustering/algo/SuperClusterAlgorithmTest.kt new file mode 100644 index 000000000..ac8e75849 --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/clustering/algo/SuperClusterAlgorithmTest.kt @@ -0,0 +1,346 @@ +/* + * Copyright 2026 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.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.ClusterManager +import com.google.maps.android.geometry.Bounds +import kotlin.random.Random +import org.junit.Test + +class SuperClusterAlgorithmTest { + + data class SimpleItem( + val id: String, + override val position: LatLng, + override val title: String? = null, + override val snippet: String? = null, + override val zIndex: Float? = null, + ) : ClusterItem + + @Test + fun emptyAlgorithmReturnsEmptyClusters() { + val algorithm = SuperClusterAlgorithm() + assertThat(algorithm.items).isEmpty() + assertThat(algorithm.getClusters(0f)).isEmpty() + assertThat(algorithm.getClusters(10f)).isEmpty() + } + + @Test + fun singleItemReturnsSingleCluster() { + val algorithm = SuperClusterAlgorithm() + val item = SimpleItem("1", LatLng(37.7749, -122.4194)) + algorithm.addItem(item) + + assertThat(algorithm.items).containsExactly(item) + + val clustersAtZoom0 = algorithm.getClusters(0f) + assertThat(clustersAtZoom0).hasSize(1) + val cluster = clustersAtZoom0.first() + assertThat(cluster.size).isEqualTo(1) + assertThat(cluster.items).containsExactly(item) + assertThat(cluster.position.latitude).isWithin(0.0001).of(37.7749) + assertThat(cluster.position.longitude).isWithin(0.0001).of(-122.4194) + + val clustersAtZoom15 = algorithm.getClusters(15f) + assertThat(clustersAtZoom15).hasSize(1) + assertThat(clustersAtZoom15.first().size).isEqualTo(1) + } + + @Test + fun coincidentItemsClusterTogetherAtAllZoomLevels() { + val algorithm = SuperClusterAlgorithm() + val pos = LatLng(40.7128, -74.0060) + val item1 = SimpleItem("1", pos) + val item2 = SimpleItem("2", pos) + val item3 = SimpleItem("3", pos) + algorithm.addItems(listOf(item1, item2, item3)) + + for (zoom in 0..18) { + val clusters = algorithm.getClusters(zoom.toFloat()) + assertThat(clusters).hasSize(1) + val cluster = clusters.first() + assertThat(cluster.size).isEqualTo(3) + assertThat(cluster.items).containsExactly(item1, item2, item3) + } + } + + @Test + fun hierarchicalClusteringAggregatesBottomUp() { + val algorithm = SuperClusterAlgorithm() + // Two points close together in San Francisco, one distant in Tokyo + val sf1 = SimpleItem("sf1", LatLng(37.7749, -122.4194)) + val sf2 = SimpleItem("sf2", LatLng(37.7750, -122.4195)) + val tokyo = SimpleItem("tokyo", LatLng(35.6762, 139.6503)) + algorithm.addItems(listOf(sf1, sf2, tokyo)) + + // At zoom 0 (world view): SF points merge, Tokyo stays distinct or merges if radius encompasses it + val clustersZoom0 = algorithm.getClusters(0f) + assertThat(clustersZoom0.size).isLessThan(3) + + // At zoom 18 (street level): SF points are unclustered into distinct points + val clustersZoom18 = algorithm.getClusters(18f) + assertThat(clustersZoom18).hasSize(3) + for (c in clustersZoom18) { + assertThat(c.size).isEqualTo(1) + } + } + + @Test + fun removeItemUpdatesPyramid() { + val algorithm = SuperClusterAlgorithm() + val pos = LatLng(51.5074, -0.1278) + val item1 = SimpleItem("1", pos) + val item2 = SimpleItem("2", pos) + algorithm.addItems(listOf(item1, item2)) + + assertThat(algorithm.getClusters(5f).first().size).isEqualTo(2) + + algorithm.removeItem(item1) + assertThat(algorithm.items).containsExactly(item2) + + val clusters = algorithm.getClusters(5f) + assertThat(clusters).hasSize(1) + assertThat(clusters.first().size).isEqualTo(1) + assertThat(clusters.first().items).containsExactly(item2) + } + + // [START maps_android_utils_supercluster_location_update_test] + @Test + fun updateItemMovesClusterToNewLocation() { + class MutableItem( + val id: String, + override var position: LatLng, + override val title: String? = null, + override val snippet: String? = null, + override val zIndex: Float? = null, + ) : ClusterItem { + override fun equals(other: Any?): Boolean = other is MutableItem && other.id == this.id + override fun hashCode(): Int = id.hashCode() + } + + val algorithm = SuperClusterAlgorithm() + val item1 = MutableItem("1", LatLng(37.7749, -122.4194)) + val item2 = MutableItem("2", LatLng(37.7750, -122.4195)) + algorithm.addItems(listOf(item1, item2)) + + // Initially in San Francisco, clustered together at zoom 10 + val initialClusters = algorithm.getClusters(10f) + assertThat(initialClusters).hasSize(1) + assertThat(initialClusters.first().size).isEqualTo(2) + + // Move item2 across the country to New York + item2.position = LatLng(40.7128, -74.0060) + val updated = algorithm.updateItem(item2) + assertThat(updated).isTrue() + + // Re-query at zoom 10: item1 and item2 are now in completely different locations + val updatedClusters = algorithm.getClusters(10f) + assertThat(updatedClusters).hasSize(2) + val c1 = updatedClusters.find { it.items.contains(item1) }!! + val c2 = updatedClusters.find { it.items.contains(item2) }!! + assertThat(c1.position.latitude).isWithin(0.01).of(37.7749) + assertThat(c2.position.latitude).isWithin(0.01).of(40.7128) + } + // [END maps_android_utils_supercluster_location_update_test] + + @Test + fun clearItemsResetsEverything() { + val algorithm = SuperClusterAlgorithm() + algorithm.addItems(listOf(SimpleItem("1", LatLng(0.0, 0.0)), SimpleItem("2", LatLng(10.0, 10.0)))) + assertThat(algorithm.items).hasSize(2) + + algorithm.clearItems() + assertThat(algorithm.items).isEmpty() + assertThat(algorithm.getClusters(5f)).isEmpty() + } + + @Test + fun viewportCullingReturnsOnlyVisibleClusters() { + val algorithm = SuperClusterAlgorithm( + viewWidth = 400, + viewHeight = 400, + ) + + // Item in SF, item in Sydney + val sf = SimpleItem("sf", LatLng(37.7749, -122.4194)) + val sydney = SimpleItem("sydney", LatLng(-33.8688, 151.2093)) + algorithm.addItems(listOf(sf, sydney)) + + // Focus camera on San Francisco at high zoom + algorithm.onCameraChange( + CameraPosition.Builder() + .target(LatLng(37.7749, -122.4194)) + .zoom(14f) + .build(), + ) + + val clusters = algorithm.getClusters(14f) + // Only SF should be within the 400x400 viewport at zoom 14 + assertThat(clusters).hasSize(1) + assertThat(clusters.first().items).containsExactly(sf) + } + + @Test + fun customBoundsQueryReturnsMatchingSubsets() { + val algorithm = SuperClusterAlgorithm() + val p1 = SimpleItem("p1", LatLng(10.0, 10.0)) + val p2 = SimpleItem("p2", LatLng(80.0, 80.0)) + algorithm.addItems(listOf(p1, p2)) + + // Bounds covering (0,0) to (20,20) in Mercator space + val bounds = Bounds(0.5, 0.6, 0.4, 0.6) + val clusters = algorithm.getClusters(bounds, 10f) + for (c in clusters) { + assertThat(c.items).contains(p1) + assertThat(c.items).doesNotContain(p2) + } + } + + @Test + fun largeDatasetClusteringScalesLinearly() { + val algorithm = SuperClusterAlgorithm() + val count = 25000 + val random = Random(42) + val items = ArrayList(count) + + for (i in 0 until count) { + val lat = random.nextDouble(-60.0, 60.0) + val lng = random.nextDouble(-170.0, 170.0) + items.add(SimpleItem("item_$i", LatLng(lat, lng))) + } + + val startBuild = System.currentTimeMillis() + algorithm.addItems(items) + algorithm.buildIndexIfNeeded() + val buildDuration = System.currentTimeMillis() - startBuild + + // Building the entire zoom pyramid for 25,000 points should take well under reasonable bounds + assertThat(buildDuration).isLessThan(10000L) + + // Querying clusters at any zoom should be instantaneous + for (z in listOf(0f, 4f, 8f, 12f, 16f)) { + val startQuery = System.nanoTime() + val clusters = algorithm.getClusters(z) + val queryDurationMs = (System.nanoTime() - startQuery) / 1_000_000 + + assertThat(clusters).isNotEmpty() + assertThat(queryDurationMs).isLessThan(1000L) + + // Verify total item conservation across all clusters + var totalCount = 0 + for (cluster in clusters) { + totalCount += cluster.size + } + assertThat(totalCount).isEqualTo(count) + } + } + + // [START maps_android_utils_supercluster_100k_test] + @Test + fun megaScaleDataset100kPointsPerformanceTest() { + val algorithm = SuperClusterAlgorithm() + val count = 100000 + val random = Random(12345) + val items = ArrayList(count) + + for (i in 0 until count) { + val lat = random.nextDouble(-50.0, 50.0) + val lng = random.nextDouble(-150.0, 150.0) + items.add(SimpleItem("mega_$i", LatLng(lat, lng))) + } + + val startBuild = System.currentTimeMillis() + algorithm.addItems(items) + algorithm.buildIndexIfNeeded() + val buildDuration = System.currentTimeMillis() - startBuild + + // Building the entire hierarchical pyramid for 100,000 points should complete well within generous bounds in CI + assertThat(buildDuration).isLessThan(30000L) + + // Querying clusters across typical map zoom levels + for (z in listOf(2f, 6f, 10f, 14f)) { + val startQuery = System.nanoTime() + val clusters = algorithm.getClusters(z) + val queryDurationMs = (System.nanoTime() - startQuery) / 1_000_000 + + assertThat(clusters).isNotEmpty() + assertThat(queryDurationMs).isLessThan(1000L) + + var clusteredCount = 0 + for (c in clusters) { + clusteredCount += c.size + } + assertThat(clusteredCount).isEqualTo(count) + } + } + // [END maps_android_utils_supercluster_100k_test] + + @Test + fun rawCoordinatesIngestionMatchesStandardAddItems() { + val algorithm = SuperClusterAlgorithm() + val count = 1000 + val coords = DoubleArray(2 * count) + val random = Random(42) + for (i in 0 until count) { + coords[2 * i] = 30.0 + random.nextDouble() * 10.0 + coords[2 * i + 1] = -100.0 + random.nextDouble() * 10.0 + } + + algorithm.setCoordinates(coords) { id, pos -> + SimpleItem("raw_$id", pos) + } + + assertThat(algorithm.totalItemCount).isEqualTo(count) + + val clusters = algorithm.getClusters(4f) + assertThat(clusters).isNotEmpty() + + var sumSizes = 0 + for (c in clusters) { + sumSizes += c.size + if (c.size == 1) { + assertThat(c.items.first().id).startsWith("raw_") + } + } + assertThat(sumSizes).isEqualTo(count) + } + + @Test + fun onProgressListenerReportsProgressAccurately() { + val algorithm = SuperClusterAlgorithm() + val progressReports = ArrayList>() + + algorithm.onProgressListener = ClusterManager.OnClusteringProgressListener { progress, status -> + progressReports.add(Pair(progress, status)) + } + + val items = List(100) { i -> + SimpleItem("item_$i", LatLng(37.0 + i * 0.01, -122.0 + i * 0.01)) + } + algorithm.addItems(items) + algorithm.buildIndexIfNeeded() + + assertThat(progressReports).isNotEmpty() + assertThat(progressReports.first().first).isGreaterThan(0.0f) + assertThat(progressReports.last().first).isEqualTo(1.0f) + assertThat(progressReports.last().second).isEqualTo("Indexing complete") + } +} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/SuperClusterInvariantProofTest.kt b/clustering/src/test/java/com/google/maps/android/clustering/algo/SuperClusterInvariantProofTest.kt new file mode 100644 index 000000000..d32a69947 --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/clustering/algo/SuperClusterInvariantProofTest.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2026 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.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.clustering.ClusterItem +import java.util.IdentityHashMap +import java.util.Random +import org.junit.Test + +/** + * Formal verification proving that [SuperClusterAlgorithm] maintains strict conservation of items + * and that every cluster's [size] strictly equals the count of items in [items]. + */ +class SuperClusterInvariantProofTest { + + private class TestItem( + val id: Int, + override val position: LatLng, + override val title: String? = "Item #$id", + override val snippet: String? = null, + override val zIndex: Float? = null, + ) : ClusterItem + + // [START maps_android_utils_invariant_proof_test] + @Test + fun proveStrictItemConservationAcrossAllZoomLevels() { + val algorithm = SuperClusterAlgorithm( + minZoom = 0, + maxZoom = 16, + radius = 120.0, + extent = 512.0, + ) + + val totalItemCount = 10_000 + val random = Random(42) + val items = List(totalItemCount) { i -> + val lat = 37.7749 + (random.nextDouble() - 0.5) * 2.0 + val lng = -122.4194 + (random.nextDouble() - 0.5) * 2.0 + TestItem(i, LatLng(lat, lng)) + } + + algorithm.addItems(items) + + // Verify across EVERY single zoom level from 0 to 18 + for (zoom in 0..18) { + val clusters = algorithm.getClusters(zoom.toFloat()) + + var sumOfSizes = 0 + val seenItems = IdentityHashMap() + + for (cluster in clusters) { + // 1. Invariant 1: cluster.size MUST be > 0 + assertThat(cluster.size).isGreaterThan(0) + + // 2. Invariant 2: cluster.items collection size MUST equal cluster.size exactly + val clusterItems = cluster.items + assertThat(clusterItems.size).isEqualTo(cluster.size) + + // 3. Invariant 3: No duplicate items within any cluster or across clusters + for (item in clusterItems) { + val alreadySeen = seenItems.put(item, true) + assertThat(alreadySeen).isNull() // Must be null, indicating item was never seen before + } + + sumOfSizes += cluster.size + } + + // 4. Invariant 4: Sum of all cluster sizes MUST STRICTLY EQUAL totalItemCount + assertThat(sumOfSizes).isEqualTo(totalItemCount) + + // 5. Invariant 5: Exactly all original items are accounted for + assertThat(seenItems.size).isEqualTo(totalItemCount) + } + } + // [END maps_android_utils_invariant_proof_test] +} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/SuperClusterRobolectricTest.kt b/clustering/src/test/java/com/google/maps/android/clustering/algo/SuperClusterRobolectricTest.kt new file mode 100644 index 000000000..4c2dab046 --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/clustering/algo/SuperClusterRobolectricTest.kt @@ -0,0 +1,136 @@ +/* + * Copyright 2026 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 android.content.Context +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.ClusterManager +import com.google.maps.android.clustering.view.DefaultClusterRenderer +import com.google.maps.android.collections.MarkerManager +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** + * End-to-end integration test verifying [SuperClusterAlgorithm] operating inside a stateful + * [ClusterManager] with [DefaultClusterRenderer]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class SuperClusterRobolectricTest { + + data class TestItem( + val id: String, + override val position: LatLng, + override val title: String? = null, + override val snippet: String? = null, + override val zIndex: Float? = 0f, + ) : ClusterItem + + private lateinit var context: Context + private lateinit var googleMap: GoogleMap + private lateinit var markerManager: MarkerManager + private lateinit var clusterManager: ClusterManager + private lateinit var superClusterAlgorithm: SuperClusterAlgorithm + private lateinit var renderer: DefaultClusterRenderer + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + googleMap = mockk(relaxed = true) + markerManager = MarkerManager(googleMap) + + clusterManager = ClusterManager(context, googleMap, markerManager) + superClusterAlgorithm = SuperClusterAlgorithm( + viewWidth = 800, + viewHeight = 800, + ) + clusterManager.setAlgorithm(superClusterAlgorithm) + + renderer = DefaultClusterRenderer(context, googleMap, clusterManager).apply { + setAnimation(false) + } + clusterManager.renderer = renderer + } + + // [START maps_android_utils_supercluster_robolectric_test] + @Test + fun superClusterAlgorithm_integratesWithClusterManagerAndRendersClusters() = runTest { + // Add 10 items clustered in SF and 10 items clustered in NY + val sfBase = LatLng(37.7749, -122.4194) + val nyBase = LatLng(40.7128, -74.0060) + val items = ArrayList() + + for (i in 0 until 10) { + items.add(TestItem("sf_$i", LatLng(sfBase.latitude + i * 0.0001, sfBase.longitude + i * 0.0001), "SF $i")) + items.add(TestItem("ny_$i", LatLng(nyBase.latitude + i * 0.0001, nyBase.longitude + i * 0.0001), "NY $i")) + } + + clusterManager.addItems(items) + + // Set camera at world view (zoom 3) + every { googleMap.cameraPosition } returns CameraPosition.fromLatLngZoom(LatLng(39.0, -98.0), 3f) + clusterManager.onCameraIdle() + + // At zoom 3, SF items should cluster together, and NY items should cluster together + val clustersAtZoom3 = superClusterAlgorithm.getClusters(3f) + assertThat(clustersAtZoom3.size).isAtMost(2) + var totalPointsAtZoom3 = 0 + for (c in clustersAtZoom3) { + totalPointsAtZoom3 += c.size + } + assertThat(totalPointsAtZoom3).isEqualTo(20) + + // Now zoom in to San Francisco street level (zoom 18) + every { googleMap.cameraPosition } returns CameraPosition.fromLatLngZoom(sfBase, 18f) + clusterManager.onCameraIdle() + + val clustersAtZoom18 = superClusterAlgorithm.getClusters(18f) + // In SF at zoom 18, individual markers should be visible + assertThat(clustersAtZoom18).isNotEmpty() + for (c in clustersAtZoom18) { + assertThat(c.size).isEqualTo(1) + } + } + // [END maps_android_utils_supercluster_robolectric_test] + + @Test + fun superClusterAlgorithm_clearItemsRemovesAllMarkers() = runTest { + val item1 = TestItem("1", LatLng(37.7749, -122.4194)) + val item2 = TestItem("2", LatLng(37.7750, -122.4195)) + clusterManager.addItems(listOf(item1, item2)) + clusterManager.cluster() + + assertThat(superClusterAlgorithm.items).hasSize(2) + + clusterManager.clearItems() + clusterManager.cluster() + + assertThat(superClusterAlgorithm.items).isEmpty() + assertThat(superClusterAlgorithm.getClusters(10f)).isEmpty() + } +} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/view/DefaultClusterRendererTest.kt b/clustering/src/test/java/com/google/maps/android/clustering/view/DefaultClusterRendererTest.kt new file mode 100644 index 000000000..51d488ed6 --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/clustering/view/DefaultClusterRendererTest.kt @@ -0,0 +1,176 @@ +/* + * Copyright 2026 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.view + +import android.content.Context +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.ClusterManager +import com.google.maps.android.clustering.algo.StaticCluster +import io.mockk.mockk +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class DefaultClusterRendererTest { + + private data class Item( + val id: Int = 0, + override val position: LatLng = LatLng(0.0, 0.0), + override val title: String? = null, + override val snippet: String? = null, + override val zIndex: Float? = null, + ) : ClusterItem + + private class TestRenderer( + context: Context, + map: GoogleMap, + clusterManager: ClusterManager, + ) : DefaultClusterRenderer(context, map, clusterManager) { + public override fun getClusterText(bucketOrSize: Int): String = super.getClusterText(bucketOrSize) + public override fun getBucket(cluster: Cluster): Int = super.getBucket(cluster) + } + + private lateinit var renderer: TestRenderer + + @Before + fun setUp() { + val context = RuntimeEnvironment.getApplication() + val googleMap: GoogleMap = mockk(relaxed = true) + val clusterManager = ClusterManager(context, googleMap) + renderer = TestRenderer(context, googleMap, clusterManager) + } + + @Test + fun defaultFormatting_usesStandardBucketsAndSuffix() { + assertThat(renderer.useCompactNumberFormatting).isFalse() + + // Standard bucket text + assertThat(renderer.getClusterText(5)).isEqualTo("5") + assertThat(renderer.getClusterText(10)).isEqualTo("10+") + assertThat(renderer.getClusterText(100)).isEqualTo("100+") + assertThat(renderer.getClusterText(1000)).isEqualTo("1000+") + } + + // [START maps_android_utils_compact_notation_test] + @Test + fun compactNumberFormatting_formatsThousandsAsKAndMillionsAsM() { + renderer.useCompactNumberFormatting = true + renderer.compactUnitUppercase = true + renderer.maxNonZeroDigits = 2 + + assertThat(renderer.getClusterText(5)).isEqualTo("5") + assertThat(renderer.getClusterText(10)).isEqualTo("10+") + assertThat(renderer.getClusterText(500)).isEqualTo("500+") + + // Thousands (K) + assertThat(renderer.getClusterText(1_000)).isEqualTo("1K+") + assertThat(renderer.getClusterText(2_500)).isEqualTo("2.5K+") + assertThat(renderer.getClusterText(10_000)).isEqualTo("10K+") + assertThat(renderer.getClusterText(50_000)).isEqualTo("50K+") + assertThat(renderer.getClusterText(100_000)).isEqualTo("100K+") + + // Millions (M) + assertThat(renderer.getClusterText(1_000_000)).isEqualTo("1M+") + assertThat(renderer.getClusterText(2_500_000)).isEqualTo("2.5M+") + assertThat(renderer.getClusterText(10_000_000)).isEqualTo("10M+") + } + // [END maps_android_utils_compact_notation_test] + + // [START maps_android_utils_nonzero_digits_test] + @Test + fun nonZeroDigitsOption_formatsLabelsWithSpecifiedPrecision() { + renderer.showExactCount = false + renderer.useCompactNumberFormatting = true + renderer.compactUnitUppercase = false + renderer.maxNonZeroDigits = 1 + + // 1 non-zero digit examples: 5, 7, 10+, 50+, 100+, 1k+ + assertThat(renderer.getClusterText(5)).isEqualTo("5") + assertThat(renderer.getClusterText(7)).isEqualTo("7") + assertThat(renderer.getClusterText(10)).isEqualTo("10+") + assertThat(renderer.getClusterText(14)).isEqualTo("10+") + assertThat(renderer.getClusterText(50)).isEqualTo("50+") + assertThat(renderer.getClusterText(58)).isEqualTo("50+") + assertThat(renderer.getClusterText(100)).isEqualTo("100+") + assertThat(renderer.getClusterText(140)).isEqualTo("100+") + assertThat(renderer.getClusterText(1_000)).isEqualTo("1k+") + assertThat(renderer.getClusterText(1_450)).isEqualTo("1k+") + assertThat(renderer.getClusterText(50_000)).isEqualTo("50k+") + assertThat(renderer.getClusterText(1_000_000)).isEqualTo("1m+") + + // Uppercase 'K' and 'M' + renderer.compactUnitUppercase = true + assertThat(renderer.getClusterText(1_000)).isEqualTo("1K+") + assertThat(renderer.getClusterText(1_000_000)).isEqualTo("1M+") + + // 2 non-zero digits + renderer.maxNonZeroDigits = 2 + renderer.compactUnitUppercase = false + assertThat(renderer.getClusterText(1_450)).isEqualTo("1.4k+") + assertThat(renderer.getClusterText(48_210)).isEqualTo("48k+") + assertThat(renderer.getClusterText(1_250_000)).isEqualTo("1.2m+") + } + // [END maps_android_utils_nonzero_digits_test] + + @Test + fun exactCountFormatting_displaysExactSizeAndExactCompactKM() { + renderer.showExactCount = true + + // Without compact SI formatting + assertThat(renderer.getClusterText(4)).isEqualTo("4") + assertThat(renderer.getClusterText(45)).isEqualTo("45") + assertThat(renderer.getClusterText(1_234)).isEqualTo("1,234") + assertThat(renderer.getClusterText(100_000)).isEqualTo("100,000") + + // With compact SI formatting + renderer.useCompactNumberFormatting = true + renderer.compactUnitUppercase = true + assertThat(renderer.getClusterText(4)).isEqualTo("4") + assertThat(renderer.getClusterText(45)).isEqualTo("45") + assertThat(renderer.getClusterText(1_000)).isEqualTo("1K") + assertThat(renderer.getClusterText(2_500)).isEqualTo("2.5K") + assertThat(renderer.getClusterText(100_000)).isEqualTo("100K") + assertThat(renderer.getClusterText(1_000_000)).isEqualTo("1M") + } + + @Test + fun customBuckets_partitionsClusterSizesAccurately() { + renderer.buckets = intArrayOf(10, 50, 250, 1_000, 10_000, 100_000, 1_000_000) + + fun createClusterWithSize(clusterSize: Int): Cluster = object : Cluster { + override val position: LatLng = LatLng(0.0, 0.0) + override val items: Collection = emptyList() + override val size: Int = clusterSize + } + + assertThat(renderer.getBucket(createClusterWithSize(5))).isEqualTo(5) + assertThat(renderer.getBucket(createClusterWithSize(25))).isEqualTo(10) + assertThat(renderer.getBucket(createClusterWithSize(100))).isEqualTo(50) + assertThat(renderer.getBucket(createClusterWithSize(500))).isEqualTo(250) + assertThat(renderer.getBucket(createClusterWithSize(5_000))).isEqualTo(1_000) + assertThat(renderer.getBucket(createClusterWithSize(50_000))).isEqualTo(10_000) + assertThat(renderer.getBucket(createClusterWithSize(500_000))).isEqualTo(100_000) + assertThat(renderer.getBucket(createClusterWithSize(5_000_000))).isEqualTo(1_000_000) + } +} diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index 90301be60..4077e5fce 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -40,6 +40,7 @@ android:icon="@drawable/ic_launcher" android:label="@string/app_name" android:theme="@style/AppTheme" + android:largeHeap="true" android:usesCleartextTraffic="true" tools:ignore="GoogleAppIndexingWarning"> @@ -94,6 +95,9 @@ + diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt index d7a5b19b3..144441fac 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt +++ b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt @@ -104,6 +104,7 @@ internal fun demoGroups(): List = Demo(R.string.demo_title_clustering_diff, ClusteringDiffDemoActivity::class.java), Demo(R.string.demo_title_clustering_2k, BigClusteringDemoActivity::class.java), Demo(R.string.demo_title_clustering_20k, VisibleClusteringDemoActivity::class.java), + Demo(R.string.demo_title_clustering_supercluster_100k, SuperCluster100kDemoActivity::class.java), Demo(R.string.demo_title_clustering_viewmodel, ClusteringViewModelDemoActivity::class.java), Demo(R.string.demo_title_clustering_force_zoom, ZoomClusteringDemoActivity::class.java), ), diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/SuperCluster100kDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/SuperCluster100kDemoActivity.kt new file mode 100644 index 000000000..ef0c96b0f --- /dev/null +++ b/demo/src/main/java/com/google/maps/android/utils/demo/SuperCluster100kDemoActivity.kt @@ -0,0 +1,515 @@ +/* + * Copyright 2026 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.utils.demo + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Color +import android.os.Build +import android.util.DisplayMetrics +import android.view.LayoutInflater +import android.view.View +import android.widget.ProgressBar +import android.widget.RadioButton +import android.widget.RadioGroup +import android.widget.TextView +import android.widget.Toast +import androidx.lifecycle.lifecycleScope +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.BitmapDescriptor +import com.google.android.gms.maps.model.BitmapDescriptorFactory +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.MarkerOptions +import com.google.android.material.button.MaterialButtonToggleGroup +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.progressindicator.LinearProgressIndicator +import com.google.android.material.slider.Slider +import com.google.android.material.switchmaterial.SwitchMaterial +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterManager +import com.google.maps.android.clustering.algo.SuperClusterAlgorithm +import com.google.maps.android.clustering.view.DefaultClusterRenderer +import com.google.maps.android.utils.demo.model.MyItem +import java.util.ArrayList +import java.util.Locale +import kotlin.math.ln +import kotlin.random.Random +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * A demo activity showcasing mega-scale clustering of 100,000 to 1,000,000 markers using [SuperClusterAlgorithm]. + * + * Demonstrates sub-millisecond viewport queries, instant zoom transitions, and zero UI stuttering + * on massive datasets, with zoomed-in unclustered points rendering as mischievous gremlins. + */ +class SuperCluster100kDemoActivity : BaseDemoActivity() { + + enum class DatasetMode { + DATASET_100K_SF, + DATASET_1M_USA, + } + + private lateinit var clusterManager: ClusterManager + private lateinit var superClusterAlgorithm: SuperClusterAlgorithm + private lateinit var gremlinRenderer: GremlinClusterRenderer + private var currentDataset: DatasetMode = DatasetMode.DATASET_100K_SF + private var loadJob: Job? = null + + override fun getLayoutId(): Int = R.layout.activity_supercluster_100k + + @SuppressLint("PotentialBehaviorOverride") + override fun startDemo(isRestore: Boolean) { + val (widthDp, heightDp) = getScreenDimensionsDp() + + if (!isRestore) { + map.moveCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(37.7749, -122.4194), + 9f, + ), + ) + } + + // [START maps_android_utils_supercluster_100k_demo] + // 1. Initialize ClusterManager with custom Gremlin renderer + clusterManager = ClusterManager(this, map) + gremlinRenderer = GremlinClusterRenderer(this, map, clusterManager).apply { + showExactCount = false // Only show exact numbers if explicitly requested + useCompactNumberFormatting = true + maxNonZeroDigits = 1 // 1 non-zero digit: 5, 7, 10+, 50+, 100+, 1k+ + compactUnitUppercase = false // lowercase 'k' and 'm' + } + clusterManager.renderer = gremlinRenderer + + // Set cluster click listener to display the exact verified item count + clusterManager.setOnClusterClickListener { cluster -> + Toast.makeText( + this@SuperCluster100kDemoActivity, + "Cluster contains exactly %,d gremlins".format(cluster.size), + Toast.LENGTH_SHORT, + ).show() + false + } + + // 2. Configure SuperClusterAlgorithm with a wider cluster radius (140px) + // to keep on-screen cluster density low and uncluttered. + superClusterAlgorithm = SuperClusterAlgorithm( + minZoom = 0, + maxZoom = 17, + radius = 140.0, + extent = 512.0, + viewWidth = widthDp, + viewHeight = heightDp, + ) + clusterManager.setAlgorithm(superClusterAlgorithm) + + // 3. Connect progress listener to display real-time spatial indexing feedback + clusterManager.onClusteringProgressListener = ClusterManager.OnClusteringProgressListener { progress, status -> + lifecycleScope.launch(Dispatchers.Main) { + val progressContainer = findViewById(R.id.layout_progress_container) + val progressBar = findViewById(R.id.progress_clustering) + val textProgress = findViewById(R.id.text_clustering_progress) + + if (progress >= 1.0f) { + progressBar?.setProgressCompat(100, true) + textProgress?.text = status + progressContainer?.postDelayed({ + progressContainer.visibility = View.GONE + }, 500) + } else { + progressContainer?.visibility = View.VISIBLE + val pct = (progress * 100).toInt() + progressBar?.setProgressCompat(pct, true) + textProgress?.text = status + } + } + } + + // 4. Connect camera idle and marker click listeners + map.setOnCameraIdleListener(clusterManager) + map.setOnMarkerClickListener(clusterManager) + + // 5. Connect quick switcher toggle group + val toggleGroup = findViewById(R.id.toggle_dataset_group) + toggleGroup?.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (isChecked) { + when (checkedId) { + R.id.btn_dataset_100k -> switchDataset(DatasetMode.DATASET_100K_SF) + R.id.btn_dataset_1m -> switchDataset(DatasetMode.DATASET_1M_USA) + } + } + } + + // 6. Ingest initial dataset (100,000 markers in SF Bay Area) + switchDataset(DatasetMode.DATASET_100K_SF) + // [END maps_android_utils_supercluster_100k_demo] + + // 7. Connect floating action button to interactive clustering settings dialog + findViewById(R.id.fab_cluster_settings)?.setOnClickListener { + showClusterSettingsDialog() + } + } + + private fun switchDataset(mode: DatasetMode) { + val toggleGroup = findViewById(R.id.toggle_dataset_group) + val targetButtonId = if (mode == DatasetMode.DATASET_100K_SF) R.id.btn_dataset_100k else R.id.btn_dataset_1m + if (toggleGroup?.checkedButtonId != targetButtonId) { + toggleGroup?.check(targetButtonId) + } + + if (currentDataset == mode && clusterManager.algorithm.items.isNotEmpty()) { + return + } + currentDataset = mode + + val progressContainer = findViewById(R.id.layout_progress_container) + val progressBar = findViewById(R.id.progress_clustering) + val textProgress = findViewById(R.id.text_clustering_progress) + progressContainer?.visibility = View.VISIBLE + progressBar?.setProgressCompat(0, false) + toggleGroup?.isEnabled = false + + loadJob?.cancel() + loadJob = lifecycleScope.launch { + val loadingMsg = if (mode == DatasetMode.DATASET_100K_SF) { + getString(R.string.loading_100k_markers) + } else { + getString(R.string.loading_1m_markers) + } + textProgress?.text = loadingMsg + + if (mode == DatasetMode.DATASET_100K_SF) { + map.animateCamera(CameraUpdateFactory.newLatLngZoom(LatLng(37.7749, -122.4194), 9f)) + } else { + map.animateCamera(CameraUpdateFactory.newLatLngZoom(LatLng(39.8283, -98.5795), 4.2f)) + } + + val startBuild = System.currentTimeMillis() + if (mode == DatasetMode.DATASET_100K_SF) { + val items = withContext(Dispatchers.Default) { generate100kItems() } + clusterManager.clearItems() + clusterManager.addItems(items) + } else { + val coords = withContext(Dispatchers.Default) { generate1mCoordinatesUnitedStates() } + clusterManager.clearItems() + superClusterAlgorithm.setCoordinates(coords) { id, pos -> + MyItem(pos.latitude, pos.longitude, "Gremlin #$id", "1M US Supercluster Point") + } + } + val elapsedMs = System.currentTimeMillis() - startBuild + + gremlinRenderer.clearIconCache() + clusterManager.cluster() + + toggleGroup?.isEnabled = true + + val countFormatted = if (mode == DatasetMode.DATASET_100K_SF) "100,000" else "1,000,000" + val region = if (mode == DatasetMode.DATASET_100K_SF) "SF Bay Area" else "United States" + Toast.makeText( + this@SuperCluster100kDemoActivity, + "Indexed $countFormatted gremlins across $region in ${elapsedMs}ms! Tap any cluster for exact count.", + Toast.LENGTH_LONG, + ).show() + } + } + + private fun showClusterSettingsDialog() { + val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_cluster_settings, null) + + val radio100k = dialogView.findViewById(R.id.radio_dataset_100k) + val radio1m = dialogView.findViewById(R.id.radio_dataset_1m) + if (currentDataset == DatasetMode.DATASET_100K_SF) { + radio100k.isChecked = true + } else { + radio1m.isChecked = true + } + + val textRadiusTitle = dialogView.findViewById(R.id.text_radius_title) + val sliderRadius = dialogView.findViewById(R.id.slider_radius) + val textMinSizeTitle = dialogView.findViewById(R.id.text_minsize_title) + val sliderMinSize = dialogView.findViewById(R.id.slider_minsize) + val textDigitsTitle = dialogView.findViewById(R.id.text_digits_title) + val sliderDigits = dialogView.findViewById(R.id.slider_digits) + val switchShowExact = dialogView.findViewById(R.id.switch_show_exact) + val switchCompact = dialogView.findViewById(R.id.switch_compact_notation) + val switchUppercase = dialogView.findViewById(R.id.switch_uppercase_units) + + // Bind initial values + val currentRadius = superClusterAlgorithm.radius.toInt() + sliderRadius.value = currentRadius.toFloat().coerceIn(sliderRadius.valueFrom, sliderRadius.valueTo) + textRadiusTitle.text = getString(R.string.clustering_settings_radius_format, currentRadius) + sliderRadius.addOnChangeListener { _, value, _ -> + textRadiusTitle.text = getString(R.string.clustering_settings_radius_format, value.toInt()) + } + + val currentMinSize = gremlinRenderer.minClusterSize + sliderMinSize.value = currentMinSize.toFloat().coerceIn(sliderMinSize.valueFrom, sliderMinSize.valueTo) + textMinSizeTitle.text = getString(R.string.clustering_settings_minsize_format, currentMinSize) + sliderMinSize.addOnChangeListener { _, value, _ -> + textMinSizeTitle.text = getString(R.string.clustering_settings_minsize_format, value.toInt()) + } + + val currentDigits = gremlinRenderer.maxNonZeroDigits + sliderDigits.value = currentDigits.toFloat().coerceIn(sliderDigits.valueFrom, sliderDigits.valueTo) + textDigitsTitle.text = getString(R.string.clustering_settings_digits_format, currentDigits) + sliderDigits.addOnChangeListener { _, value, _ -> + textDigitsTitle.text = getString(R.string.clustering_settings_digits_format, value.toInt()) + } + + switchShowExact.isChecked = gremlinRenderer.showExactCount + switchCompact.isChecked = gremlinRenderer.useCompactNumberFormatting + switchUppercase.isChecked = gremlinRenderer.compactUnitUppercase + + MaterialAlertDialogBuilder(this) + .setTitle(R.string.clustering_settings_title) + .setView(dialogView) + .setPositiveButton(R.string.clustering_settings_apply) { _, _ -> + val newRadius = sliderRadius.value.toDouble() + val newMinSize = sliderMinSize.value.toInt() + val newDigits = sliderDigits.value.toInt() + val newShowExact = switchShowExact.isChecked + val newCompact = switchCompact.isChecked + val newUppercase = switchUppercase.isChecked + + gremlinRenderer.minClusterSize = newMinSize + gremlinRenderer.maxNonZeroDigits = newDigits + gremlinRenderer.showExactCount = newShowExact + gremlinRenderer.useCompactNumberFormatting = newCompact + gremlinRenderer.compactUnitUppercase = newUppercase + + if (superClusterAlgorithm.radius != newRadius) { + superClusterAlgorithm.radius = newRadius + } + + gremlinRenderer.clearIconCache() + clusterManager.cluster() + + val selectedDataset = if (radio100k.isChecked) DatasetMode.DATASET_100K_SF else DatasetMode.DATASET_1M_USA + if (selectedDataset != currentDataset) { + switchDataset(selectedDataset) + } + + Toast.makeText( + this, + "Settings applied: Radius ${newRadius.toInt()}px, MinSize $newMinSize, Digits $newDigits", + Toast.LENGTH_SHORT, + ).show() + } + .setNeutralButton(R.string.clustering_settings_reset) { _, _ -> + superClusterAlgorithm.radius = 140.0 + gremlinRenderer.minClusterSize = 2 + gremlinRenderer.maxNonZeroDigits = 1 + gremlinRenderer.showExactCount = false + gremlinRenderer.useCompactNumberFormatting = true + gremlinRenderer.compactUnitUppercase = false + + gremlinRenderer.clearIconCache() + clusterManager.cluster() + + if (currentDataset != DatasetMode.DATASET_100K_SF) { + switchDataset(DatasetMode.DATASET_100K_SF) + } + + Toast.makeText(this, "Reset to default clustering settings", Toast.LENGTH_SHORT).show() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private class GremlinClusterRenderer( + context: Context, + map: GoogleMap, + clusterManager: ClusterManager, + ) : DefaultClusterRenderer(context, map, clusterManager) { + + private val gremlinIcon: BitmapDescriptor = + BitmapDescriptorFactory.fromResource(R.drawable.gremlin_marker) + + init { + minClusterSize = 2 // Keeps on-screen density clean by grouping 2+ items into badges + buckets = MEGA_BUCKETS + } + + override fun getColor(clusterSize: Int): Int { + // Logarithmic color mapping across stops from 2 to 100,000+ points + val stops = COLOR_STOPS + if (clusterSize <= stops.first().size) return stops.first().color + if (clusterSize >= stops.last().size) return stops.last().color + + val logSize = ln(clusterSize.toDouble()) + for (i in 0 until stops.size - 1) { + val lower = stops[i] + val upper = stops[i + 1] + if (clusterSize in lower.size..upper.size) { + val logLower = ln(lower.size.toDouble()) + val logUpper = ln(upper.size.toDouble()) + val ratio = ((logSize - logLower) / (logUpper - logLower)).toFloat() + return interpolateColor(lower.color, upper.color, ratio) + } + } + return stops.last().color + } + + private fun interpolateColor(c1: Int, c2: Int, ratio: Float): Int { + val r = (Color.red(c1) + ratio * (Color.red(c2) - Color.red(c1))).toInt().coerceIn(0, 255) + val g = (Color.green(c1) + ratio * (Color.green(c2) - Color.green(c1))).toInt().coerceIn(0, 255) + val b = (Color.blue(c1) + ratio * (Color.blue(c2) - Color.blue(c1))).toInt().coerceIn(0, 255) + return Color.rgb(r, g, b) + } + + override fun onBeforeClusterItemRendered(item: MyItem, markerOptions: MarkerOptions) { + markerOptions + .icon(gremlinIcon) + .anchor(0.5f, 0.5f) + .title(item.title ?: "Gremlin") + .snippet("Mischievous Gremlin!") + } + + override fun onClusterItemUpdated(item: MyItem, marker: Marker) { + marker.setIcon(gremlinIcon) + } + + companion object { + private val MEGA_BUCKETS = intArrayOf( + 10, 25, 50, 100, 250, 500, 1_000, 2_500, 5_000, 10_000, 25_000, 50_000, 100_000, 250_000, 500_000, 1_000_000, + ) + + private class ColorStop(val size: Int, val color: Int) + + private val COLOR_STOPS = listOf( + ColorStop(2, Color.rgb(30, 136, 229)), // Blue (2-10) + ColorStop(10, Color.rgb(0, 172, 193)), // Teal / Cyan (10-25) + ColorStop(25, Color.rgb(67, 160, 71)), // Vibrant Green (25-50) + ColorStop(100, Color.rgb(124, 179, 66)), // Lime Green (50-200) + ColorStop(500, Color.rgb(251, 192, 45)), // Sunny Yellow (250-500) + ColorStop(1_500, Color.rgb(251, 140, 0)), // Vivid Orange (1k-2.5k) + ColorStop(5_000, Color.rgb(229, 57, 53)), // Crimson Red (2.5k-10k) + ColorStop(15_000, Color.rgb(216, 27, 96)), // Deep Magenta (10k-25k) + ColorStop(40_000, Color.rgb(142, 36, 170)), // Royal Purple (25k-50k) + ColorStop(100_000, Color.rgb(49, 27, 146)), // Deep Midnight Indigo (50k-100k) + ColorStop(300_000, Color.rgb(74, 20, 140)), // Deep Violet (100k-300k) + ColorStop(1_000_000, Color.rgb(136, 14, 79)), // Electric Berry / Crimson Plum (300k-1M+) + ) + } + } + + private fun generate100kItems(): List { + val count = 100_000 + val random = Random(42) + val items = ArrayList(count) + + // Centered around the San Francisco Bay Area across California + val centerLat = 37.7749 + val centerLng = -122.4194 + + for (i in 0 until count) { + // Gaussian-like concentration near urban centers with regional dispersal + val dLat = (random.nextDouble() - 0.5) * 4.0 + val dLng = (random.nextDouble() - 0.5) * 4.0 + items.add(MyItem(centerLat + dLat, centerLng + dLng, "Gremlin #$i", "100k Supercluster Point")) + } + return items + } + + private fun generate1mCoordinatesUnitedStates(): DoubleArray { + val count = 1_000_000 + val random = Random(1337) + val coords = DoubleArray(2 * count) + + // Metropolitan population centers across the United States [lat, lng, spreadDeg] + val hubs = arrayOf( + doubleArrayOf(40.71, -74.00, 1.2), // New York / Tri-State + doubleArrayOf(34.05, -118.24, 1.3), // Los Angeles / SoCal + doubleArrayOf(41.87, -87.63, 1.0), // Chicago / Great Lakes + doubleArrayOf(29.76, -95.36, 1.2), // Houston + doubleArrayOf(32.77, -96.79, 1.0), // Dallas-Fort Worth + doubleArrayOf(37.77, -122.41, 0.8), // SF Bay Area + doubleArrayOf(33.74, -84.38, 1.1), // Atlanta + doubleArrayOf(25.76, -80.19, 0.9), // Miami / South Florida + doubleArrayOf(47.60, -122.33, 0.9), // Seattle / Puget Sound + doubleArrayOf(39.73, -104.99, 0.9), // Denver / Front Range + doubleArrayOf(33.44, -112.07, 0.9), // Phoenix / Valley of the Sun + doubleArrayOf(42.36, -71.05, 0.8), // Boston / New England + doubleArrayOf(38.90, -77.03, 1.0), // Washington DC / Baltimore + doubleArrayOf(44.97, -93.26, 0.8), // Minneapolis-St. Paul + doubleArrayOf(38.62, -90.19, 0.9), // St. Louis + doubleArrayOf(36.16, -86.78, 0.8), // Nashville + doubleArrayOf(30.26, -97.74, 0.8), // Austin / Central Texas + doubleArrayOf(45.51, -122.67, 0.8), // Portland / Willamette + doubleArrayOf(35.22, -80.84, 0.8), // Charlotte + doubleArrayOf(28.53, -81.37, 0.8), // Orlando / Central Florida + doubleArrayOf(39.95, -75.16, 0.9), // Philadelphia + doubleArrayOf(42.33, -83.04, 0.9), // Detroit + doubleArrayOf(39.76, -86.15, 0.8), // Indianapolis + doubleArrayOf(39.09, -94.57, 0.8), // Kansas City + doubleArrayOf(36.17, -115.13, 0.8), // Las Vegas + doubleArrayOf(40.76, -111.89, 0.8), // Salt Lake City + ) + + // 650,000 points clustered around population hubs + val hubCount = 650_000 + for (i in 0 until hubCount) { + val hub = hubs[random.nextInt(hubs.size)] + val dLat = (random.nextDouble() - 0.5) * 2 * hub[2] + val dLng = (random.nextDouble() - 0.5) * 2 * hub[2] + coords[2 * i] = hub[0] + dLat + coords[2 * i + 1] = hub[1] + dLng + } + + // 350,000 points broadly dispersed across the contiguous US landmass + val broadCount = count - hubCount + for (i in 0 until broadCount) { + val idx = hubCount + i + val lat = 25.5 + random.nextDouble() * 23.0 // 25.5°N to 48.5°N + val lng = -124.0 + random.nextDouble() * 57.0 // -124.0°W to -67.0°W + coords[2 * idx] = lat + coords[2 * idx + 1] = lng + } + + return coords + } + + @Suppress("DEPRECATION") + private fun getScreenDimensionsDp(): Pair { + val widthPixels: Int + val heightPixels: Int + val density: Float + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val windowMetrics = windowManager.currentWindowMetrics + val bounds = windowMetrics.bounds + widthPixels = bounds.width() + heightPixels = bounds.height() + density = resources.displayMetrics.density + } else { + val metrics = DisplayMetrics() + windowManager.defaultDisplay.getMetrics(metrics) + widthPixels = metrics.widthPixels + heightPixels = metrics.heightPixels + density = metrics.density + } + + val widthDp = (widthPixels / density).toInt() + val heightDp = (heightPixels / density).toInt() + return Pair(widthDp, heightDp) + } +} diff --git a/demo/src/main/res/drawable-nodpi/gremlin_marker.png b/demo/src/main/res/drawable-nodpi/gremlin_marker.png new file mode 100644 index 000000000..b859d7e16 Binary files /dev/null and b/demo/src/main/res/drawable-nodpi/gremlin_marker.png differ diff --git a/demo/src/main/res/drawable/ic_tune_24.xml b/demo/src/main/res/drawable/ic_tune_24.xml new file mode 100644 index 000000000..ff96fdd77 --- /dev/null +++ b/demo/src/main/res/drawable/ic_tune_24.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/demo/src/main/res/layout/activity_supercluster_100k.xml b/demo/src/main/res/layout/activity_supercluster_100k.xml new file mode 100644 index 000000000..8198ff29e --- /dev/null +++ b/demo/src/main/res/layout/activity_supercluster_100k.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo/src/main/res/layout/dialog_cluster_settings.xml b/demo/src/main/res/layout/dialog_cluster_settings.xml new file mode 100644 index 000000000..6be2c3422 --- /dev/null +++ b/demo/src/main/res/layout/dialog_cluster_settings.xml @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo/src/main/res/values/strings.xml b/demo/src/main/res/values/strings.xml index fab100c1e..d7d97d8aa 100644 --- a/demo/src/main/res/values/strings.xml +++ b/demo/src/main/res/values/strings.xml @@ -15,7 +15,7 @@ limitations under the License. --> - + Maps Utils Demo Looking for… Go! @@ -53,6 +53,30 @@ Clustering: Diff Clustering: 2K markers Clustering: 20K only visible markers + Clustering: 100K / 1M markers (Supercluster) + 100K (SF) + 1M (USA) + Dataset Size + Switch between 100,000 markers in California and 1,000,000 markers across the USA + 100,000 Markers (SF Bay Area) + 1,000,000 Markers (United States) + Generating and indexing 100,000 markers in SF Bay Area… + Generating and indexing 1,000,000 markers across the United States… + Cluster Settings + Cluster Radius: %1$d px + Controls cluster density on screen (higher radius merges larger areas) + Min Cluster Size: %1$d items + Groups smaller than this threshold render as individual gremlins + Non-Zero Digits: %1$d + Controls precision of rounded labels (e.g. 10+, 50+, 100+, 1k+) + Show Exact Counts + Display exact item counts on badges instead of rounded values + Compact SI Notation (k / m) + Format thousands as \'k\' and millions as \'m\' + Uppercase Units (K / M) + Display capital \'K\' and \'M\' rather than lowercase \'k\' and \'m\' + Apply Settings + Reset Defaults Clustering: ViewModel Clustering: Force on Zoom