From d22e4df24a208a318707ea1c90192a9544efe677 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:47:16 -0600 Subject: [PATCH] feat(clustering): add SuperClusterAlgorithm for mega-scale marker clustering and configurable badge formatting - Implement SuperClusterAlgorithm with a bottom-up hierarchical zoom pyramid powered by FlatKdTree flat contiguous arrays, enabling sub-millisecond viewport queries on 100k+ markers with minimal GC allocation. - Optimize SuperClusterAlgorithm internals with zero-allocation flat primitive ZoomLevels, eliminating LinkedHashSet and LinkedHashMap overhead. - Add raw contiguous buffer ingestion (setCoordinates) for zero-allocation initialization of 1,000,000+ points with lazy item instantiation. - Add OnClusteringProgressListener to ClusterManager and SuperClusterAlgorithm for granular progress reporting during intensive spatial indexing passes. - Support location updates, dynamic item addition/removal, and custom cluster radius configuration. - Add configurable non-zero digit precision (maxNonZeroDigits), compact SI unit formatting (k/m and K/M), and exact count toggling (showExactCount) to DefaultClusterRenderer and DefaultAdvancedMarkersClusterRenderer with automatic icon cache invalidation. - Add SuperCluster100kDemoActivity showcasing 100,000 markers in California and 1,000,000 markers across the United States, unclustered gremlin markers, logarithmic color stops, and an interactive cluster settings dialog. - Add quick dataset switcher with opaque card styling, live LinearProgressIndicator progress feedback, and background coroutine loading. - Include comprehensive performance benchmarks and architecture documentation in README files. - Add full unit test coverage validating FlatKdTree, SuperCluster spatial partitioning, location updates, progress reporting, mathematical invariants, and renderer label formatting. --- README.md | 31 + clustering/README.md | 107 +++ clustering/api/clustering.api | 96 +++ .../maps/android/clustering/ClusterManager.kt | 32 + .../android/clustering/algo/FlatKdTree.kt | 241 ++++++ .../android/clustering/algo/SuperCluster.kt | 144 ++++ .../clustering/algo/SuperClusterAlgorithm.kt | 717 ++++++++++++++++++ .../DefaultAdvancedMarkersClusterRenderer.kt | 187 ++++- .../clustering/view/DefaultClusterRenderer.kt | 197 ++++- .../ClusteringPerformanceComparisonTest.kt | 194 +++++ .../algo/SuperClusterAlgorithmTest.kt | 346 +++++++++ .../algo/SuperClusterInvariantProofTest.kt | 92 +++ .../algo/SuperClusterRobolectricTest.kt | 136 ++++ .../view/DefaultClusterRendererTest.kt | 176 +++++ demo/src/main/AndroidManifest.xml | 4 + .../maps/android/utils/demo/MainActivity.kt | 1 + .../demo/SuperCluster100kDemoActivity.kt | 515 +++++++++++++ .../res/drawable-nodpi/gremlin_marker.png | Bin 0 -> 41441 bytes demo/src/main/res/drawable/ic_tune_24.xml | 26 + .../res/layout/activity_supercluster_100k.xml | 118 +++ .../res/layout/dialog_cluster_settings.xml | 215 ++++++ demo/src/main/res/values/strings.xml | 26 +- 22 files changed, 3564 insertions(+), 37 deletions(-) create mode 100644 clustering/README.md create mode 100644 clustering/src/main/java/com/google/maps/android/clustering/algo/FlatKdTree.kt create mode 100644 clustering/src/main/java/com/google/maps/android/clustering/algo/SuperCluster.kt create mode 100644 clustering/src/main/java/com/google/maps/android/clustering/algo/SuperClusterAlgorithm.kt create mode 100644 clustering/src/test/java/com/google/maps/android/clustering/algo/ClusteringPerformanceComparisonTest.kt create mode 100644 clustering/src/test/java/com/google/maps/android/clustering/algo/SuperClusterAlgorithmTest.kt create mode 100644 clustering/src/test/java/com/google/maps/android/clustering/algo/SuperClusterInvariantProofTest.kt create mode 100644 clustering/src/test/java/com/google/maps/android/clustering/algo/SuperClusterRobolectricTest.kt create mode 100644 clustering/src/test/java/com/google/maps/android/clustering/view/DefaultClusterRendererTest.kt create mode 100644 demo/src/main/java/com/google/maps/android/utils/demo/SuperCluster100kDemoActivity.kt create mode 100644 demo/src/main/res/drawable-nodpi/gremlin_marker.png create mode 100644 demo/src/main/res/drawable/ic_tune_24.xml create mode 100644 demo/src/main/res/layout/activity_supercluster_100k.xml create mode 100644 demo/src/main/res/layout/dialog_cluster_settings.xml 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 0000000000000000000000000000000000000000..b859d7e169310f5f4ab000684645fe5d121004d4 GIT binary patch literal 41441 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�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 + + + + 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