diff --git a/clustering/api/clustering.api b/clustering/api/clustering.api index 747033612..d7a7f259c 100644 --- a/clustering/api/clustering.api +++ b/clustering/api/clustering.api @@ -1,3 +1,19 @@ +public final class com/google/maps/android/clustering/AreaCluster : com/google/maps/android/clustering/Cluster { + public fun (Lcom/google/android/gms/maps/model/LatLng;ILcom/google/maps/android/clustering/ClusterItem;)V + public synthetic fun (Lcom/google/android/gms/maps/model/LatLng;ILcom/google/maps/android/clustering/ClusterItem;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public fun getItems ()Ljava/util/Collection; + public final fun getLeafItem ()Lcom/google/maps/android/clustering/ClusterItem; + public fun getPosition ()Lcom/google/android/gms/maps/model/LatLng; + public fun getSize ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/google/maps/android/clustering/AreaClusterProvider { + public abstract fun getClustersForArea (Lcom/google/android/gms/maps/model/LatLngBounds;F)Ljava/util/Collection; +} + public abstract interface class com/google/maps/android/clustering/Cluster { public abstract fun getItems ()Ljava/util/Collection; public abstract fun getPosition ()Lcom/google/android/gms/maps/model/LatLng; @@ -34,6 +50,7 @@ public class com/google/maps/android/clustering/ClusterManager : com/google/andr public fun setAlgorithm (Lcom/google/maps/android/clustering/algo/Algorithm;)V public fun setAlgorithm (Lcom/google/maps/android/clustering/algo/ScreenBasedAlgorithm;)V public fun setAnimation (Z)V + public fun setAreaClusterProvider (Lcom/google/maps/android/clustering/AreaClusterProvider;)V public fun setOnClusterClickListener (Lcom/google/maps/android/clustering/ClusterManager$OnClusterClickListener;)V public fun setOnClusterInfoWindowClickListener (Lcom/google/maps/android/clustering/ClusterManager$OnClusterInfoWindowClickListener;)V public fun setOnClusterInfoWindowLongClickListener (Lcom/google/maps/android/clustering/ClusterManager$OnClusterInfoWindowLongClickListener;)V @@ -103,6 +120,27 @@ public abstract interface class com/google/maps/android/clustering/algo/Algorith public abstract fun updateItem (Lcom/google/maps/android/clustering/ClusterItem;)Z } +public final class com/google/maps/android/clustering/algo/AreaClusterProviderAlgorithm : com/google/maps/android/clustering/algo/AbstractAlgorithm, com/google/maps/android/clustering/algo/ScreenBasedAlgorithm { + public fun ()V + public fun (Lcom/google/maps/android/clustering/AreaClusterProvider;)V + public synthetic fun (Lcom/google/maps/android/clustering/AreaClusterProvider;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun addItem (Lcom/google/maps/android/clustering/ClusterItem;)Z + public fun addItems (Ljava/util/Collection;)Z + public fun clearItems ()V + public fun getClusters (F)Ljava/util/Set; + public fun getItems ()Ljava/util/Collection; + public fun getMaxDistanceBetweenClusteredItems ()I + public final fun getProvider ()Lcom/google/maps/android/clustering/AreaClusterProvider; + 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 fun setMaxDistanceBetweenClusteredItems (I)V + public final fun setProvider (Lcom/google/maps/android/clustering/AreaClusterProvider;)V + public final fun setVisibleBounds (Lcom/google/android/gms/maps/model/LatLngBounds;)V + public fun shouldReclusterOnMapMovement ()Z + public fun updateItem (Lcom/google/maps/android/clustering/ClusterItem;)Z +} + public class com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm : com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm { public fun ()V protected final fun computeCentroid (Ljava/util/Collection;)Lcom/google/android/gms/maps/model/LatLng; diff --git a/clustering/src/main/java/com/google/maps/android/clustering/AreaCluster.kt b/clustering/src/main/java/com/google/maps/android/clustering/AreaCluster.kt new file mode 100644 index 000000000..d87362c90 --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/AreaCluster.kt @@ -0,0 +1,39 @@ +package com.google.maps.android.clustering + +import com.google.android.gms.maps.model.LatLng +import java.util.Collections + +/** + * A lightweight [Cluster] implementation representing either an aggregated cluster of items + * or a single unclustered leaf item. + * + * @param T The type of [ClusterItem] represented by this cluster. + * @property position The geographic position (centroid) of the cluster, or position of the individual item. + * @property size The total number of items represented by this cluster. + * @property leafItem The underlying [ClusterItem] if this is a single item ([size] == 1), or `null` if composite. + */ +public class AreaCluster( + public override val position: LatLng, + public override val size: Int, + public val leafItem: T? = null, +) : Cluster { + public override val items: Collection + get() = if (leafItem != null) Collections.singleton(leafItem) else emptyList() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AreaCluster<*>) return false + return size == other.size && position == other.position && leafItem == other.leafItem + } + + override fun hashCode(): Int { + var result = position.hashCode() + result = 31 * result + size + result = 31 * result + (leafItem?.hashCode() ?: 0) + return result + } + + override fun toString(): String = + if (leafItem != null) "AreaCluster(position=$position, leafItem=$leafItem)" + else "AreaCluster(position=$position, size=$size)" +} diff --git a/clustering/src/main/java/com/google/maps/android/clustering/AreaClusterProvider.kt b/clustering/src/main/java/com/google/maps/android/clustering/AreaClusterProvider.kt new file mode 100644 index 000000000..b7c33354b --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/AreaClusterProvider.kt @@ -0,0 +1,23 @@ +package com.google.maps.android.clustering + +import com.google.android.gms.maps.model.LatLngBounds + +/** + * Functional interface for supplying clusters and individual markers on-demand based on the + * visible geographic bounding box and camera zoom level. + * + * This allows massive datasets (e.g. millions of points) to be queried efficiently from spatial + * databases, memory-mapped files, or precomputed pyramids without requiring all items to reside + * in heap memory as [ClusterItem] instances. + */ +public fun interface AreaClusterProvider { + /** + * Returns a collection of [Cluster] instances for the requested geographic area and zoom level. + * + * @param bounds Geographic area currently visible (typically includes viewport margin). + * @param zoom Current map zoom level. + * @return A collection of [Cluster] items to display. Items with [Cluster.getSize] > 1 are rendered + * as cluster badges, while items with [Cluster.getSize] == 1 are rendered as individual markers. + */ + public fun getClustersForArea(bounds: LatLngBounds, zoom: Float): Collection> +} 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 9db3f1c9b..21b46ee4b 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.AreaClusterProviderAlgorithm import com.google.maps.android.clustering.algo.SuperClusterAlgorithm import com.google.maps.android.clustering.view.ClusterRenderer import com.google.maps.android.clustering.view.DefaultClusterRenderer @@ -288,6 +289,27 @@ public open class ClusterManager } } + /** + * Sets an [AreaClusterProvider] to dynamically query clusters and individual markers based on the + * visible map viewport and zoom level. + * + * @param provider The provider callback, or `null` to clear. + */ + public open fun setAreaClusterProvider(provider: AreaClusterProvider?) { + if (provider != null) { + val algo = AreaClusterProviderAlgorithm(provider) + val currentBounds = try { + mMap.projection.visibleRegion.latLngBounds + } catch (e: Exception) { + null + } + if (currentBounds != null) { + algo.setVisibleBounds(currentBounds) + } + algorithm = algo + } + } + /** * Force a re-cluster on the map. You should call this after adding, removing, updating, * or clearing item(s). @@ -297,6 +319,15 @@ public open class ClusterManager try { // Attempt to cancel the in-flight request. mClusterTask?.cancel() + val currentBounds = try { + mMap.projection.visibleRegion.latLngBounds + } catch (e: Exception) { + null + } + if (currentBounds != null && mAlgorithm is AreaClusterProviderAlgorithm<*>) { + @Suppress("UNCHECKED_CAST") + (mAlgorithm as AreaClusterProviderAlgorithm).setVisibleBounds(currentBounds) + } mClusterTask = scope.launch { val param = mMap.cameraPosition.zoom diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/AreaClusterProviderAlgorithm.kt b/clustering/src/main/java/com/google/maps/android/clustering/algo/AreaClusterProviderAlgorithm.kt new file mode 100644 index 000000000..dd878949b --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/algo/AreaClusterProviderAlgorithm.kt @@ -0,0 +1,100 @@ +package com.google.maps.android.clustering.algo + +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLngBounds +import com.google.maps.android.clustering.AreaClusterProvider +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem + +/** + * An [Algorithm] and [ScreenBasedAlgorithm] implementation that queries an [AreaClusterProvider] + * on-demand when the map camera moves or changes zoom level. + * + * This algorithm allows massive spatial datasets (millions of points) to be rendered with dynamic + * clustering without loading all points into JVM heap memory. + * + * @param T The type of [ClusterItem] to display. + * @property provider The provider callback supplying clusters and individual markers. + */ +public class AreaClusterProviderAlgorithm( + public var provider: AreaClusterProvider? = null, +) : AbstractAlgorithm(), ScreenBasedAlgorithm { + + @Volatile + private var mCurrentBounds: LatLngBounds? = null + + @Volatile + private var mCurrentZoom: Float = 0f + + private var mMaxDistance: Int = 35 + + /** + * Updates the current visible geographic bounding box of the map camera. + * + * @param bounds The current visible viewport bounds (optionally padded). + */ + public fun setVisibleBounds(bounds: LatLngBounds) { + lock() + try { + mCurrentBounds = bounds + } finally { + unlock() + } + } + + public override fun onCameraChange(position: CameraPosition) { + lock() + try { + mCurrentZoom = position.zoom + } finally { + unlock() + } + } + + public override fun shouldReclusterOnMapMovement(): Boolean = true + + public override var maxDistanceBetweenClusteredItems: Int + get() = mMaxDistance + set(value) { + mMaxDistance = value + } + + public override fun getClusters(zoom: Float): Set> { + val p: AreaClusterProvider + val bounds: LatLngBounds? + lock() + try { + p = provider ?: return emptySet() + bounds = mCurrentBounds + } finally { + unlock() + } + + if (bounds == null) { + return emptySet() + } + + val result = p.getClustersForArea(bounds, zoom) + return if (result is Set<*>) { + @Suppress("UNCHECKED_CAST") + result as Set> + } else { + result.toSet() + } + } + + public override fun addItem(item: T): Boolean = false + + public override fun addItems(items: Collection): Boolean = false + + public override fun clearItems() {} + + public override fun removeItem(item: T): Boolean = false + + public override fun removeItems(items: Collection): Boolean = false + + public override fun updateItem(item: T): Boolean = false + + public override val items: Collection + get() = emptyList() +} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/AreaClusterProviderAlgorithmTest.kt b/clustering/src/test/java/com/google/maps/android/clustering/algo/AreaClusterProviderAlgorithmTest.kt new file mode 100644 index 000000000..1ed2a5980 --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/clustering/algo/AreaClusterProviderAlgorithmTest.kt @@ -0,0 +1,82 @@ +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.android.gms.maps.model.LatLngBounds +import com.google.maps.android.clustering.AreaCluster +import com.google.maps.android.clustering.AreaClusterProvider +import com.google.maps.android.clustering.ClusterItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AreaClusterProviderAlgorithmTest { + + private class TestItem( + override val position: LatLng, + override val title: String? = null, + override val snippet: String? = null, + override val zIndex: Float? = null, + ) : ClusterItem + + @Test + fun testAreaClusterLeafItem() { + val pos = LatLng(32.5, -117.0) + val item = TestItem(pos, "Building 1") + val cluster = AreaCluster(pos, 1, item) + + assertTrue(cluster.items.contains(item)) + assertEquals(1, cluster.size) + assertEquals(pos, cluster.position) + assertEquals(item, cluster.leafItem) + } + + @Test + fun testAreaClusterComposite() { + val pos = LatLng(32.5, -117.0) + val cluster = AreaCluster(pos, 42, null) + + assertTrue(cluster.items.isEmpty()) + assertEquals(42, cluster.size) + assertEquals(pos, cluster.position) + } + + @Test + fun testAlgorithmDelegation() { + var requestedBounds: LatLngBounds? = null + var requestedZoom = -1f + + val provider = AreaClusterProvider { bounds, zoom -> + requestedBounds = bounds + requestedZoom = zoom + + listOf( + AreaCluster(LatLng(32.0, -116.0), 100), + AreaCluster(LatLng(32.5, -116.5), 1, TestItem(LatLng(32.5, -116.5))), + ) + } + + val algo = AreaClusterProviderAlgorithm(provider) + assertTrue(algo.shouldReclusterOnMapMovement()) + + // Initial query before bounds are set returns empty + assertTrue(algo.getClusters(10f).isEmpty()) + + // Set bounds and query + val bounds = LatLngBounds(LatLng(31.0, -118.0), LatLng(33.0, -115.0)) + algo.setVisibleBounds(bounds) + algo.onCameraChange(CameraPosition(LatLng(32.0, -116.5), 12f, 0f, 0f)) + + val clusters = algo.getClusters(12f) + assertEquals(2, clusters.size) + assertEquals(bounds, requestedBounds) + assertEquals(12f, requestedZoom, 0.001f) + } + + @Test + fun testNullProviderReturnsEmpty() { + val algo = AreaClusterProviderAlgorithm() + algo.setVisibleBounds(LatLngBounds(LatLng(0.0, 0.0), LatLng(1.0, 1.0))) + assertTrue(algo.getClusters(5f).isEmpty()) + } +} diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index d09950675..bb3db1629 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -64,6 +64,10 @@ if (!secretsFile.exists()) { } android { + androidResources { + noCompress += listOf("scbin") + } + lint { sarifOutput = layout.buildDirectory.file("reports/lint-results.sarif").get().asFile } diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index 4077e5fce..7684e9d14 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -98,6 +98,9 @@ + diff --git a/demo/src/main/assets/buildings_pyramid.scbin b/demo/src/main/assets/buildings_pyramid.scbin new file mode 100644 index 000000000..ad39f8d93 Binary files /dev/null and b/demo/src/main/assets/buildings_pyramid.scbin differ diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/BinarySpatialPyramidStore.kt b/demo/src/main/java/com/google/maps/android/utils/demo/BinarySpatialPyramidStore.kt new file mode 100644 index 000000000..f001d2b65 --- /dev/null +++ b/demo/src/main/java/com/google/maps/android/utils/demo/BinarySpatialPyramidStore.kt @@ -0,0 +1,187 @@ +package com.google.maps.android.utils.demo + +import android.content.Context +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds +import com.google.maps.android.clustering.AreaCluster +import com.google.maps.android.clustering.AreaClusterProvider +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.utils.demo.model.BuildingClusterItem +import java.io.FileInputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.channels.FileChannel + +/** + * Memory-mapped spatial pyramid reader for the 2.72M building coordinate dataset. + * + * Implements [AreaClusterProvider] to serve clusters and individual markers on-demand based on the + * visible map camera bounds and zoom level. + * + * Enforces zoom thresholds and viewport density limits so that: + * 1. Clusters ALWAYS represent 2 or more items (never a cluster of "1"). + * 2. Individual leaf markers (candy houses) only appear at street level (zoom >= minZoomForLeafMarkers). + * 3. Isolated single buildings are omitted at regional zoom levels where single houses are sub-pixel noise. + */ +public class BinarySpatialPyramidStore( + context: Context, + assetName: String = "buildings_pyramid.scbin", +) : AreaClusterProvider { + + private val buffer: ByteBuffer + public val totalPoints: Int + public val minZoom: Int + public val maxClusterZoom: Int + public val minLat: Float + public val maxLat: Float + public val minLng: Float + public val maxLng: Float + + private val zoomCounts: IntArray + private val zoomOffsets: LongArray + private val rawCount: Int + private val rawOffset: Long + + /** Minimum zoom level where individual building markers are permitted to render. */ + public var minZoomForLeafMarkers: Float = 17.0f + + /** Maximum individual leaf markers allowed in any single viewport query to protect frame rates. */ + public var maxMarkersPerViewport: Int = 80 + + init { + val mappedBuffer = try { + val afd = context.assets.openFd(assetName) + val channel = FileInputStream(afd.fileDescriptor).channel + channel.map(FileChannel.MapMode.READ_ONLY, afd.startOffset, afd.length) + } catch (e: Exception) { + val bytes = context.assets.open(assetName).use { it.readBytes() } + ByteBuffer.wrap(bytes) + } + mappedBuffer.order(ByteOrder.LITTLE_ENDIAN) + buffer = mappedBuffer + + // Validate Magic (8 bytes: "SCBIN2\0\0") + val magic0 = buffer.getInt(0) + val magic1 = buffer.getInt(4) + check(magic0 == 0x49424353 && magic1 == 0x0000324E) { + "Invalid SCBIN2 magic header: %08X %08X".format(magic0, magic1) + } + + totalPoints = buffer.getInt(8) + minZoom = buffer.getInt(12) + maxClusterZoom = buffer.getInt(16) + minLat = buffer.getFloat(20) + maxLat = buffer.getFloat(24) + minLng = buffer.getFloat(28) + maxLng = buffer.getFloat(32) + + val levelsCount = maxClusterZoom + 1 + zoomCounts = IntArray(levelsCount) + zoomOffsets = LongArray(levelsCount) + + var tablePos = 40 + for (z in 0 until levelsCount) { + zoomCounts[z] = buffer.getInt(tablePos) + zoomOffsets[z] = buffer.getLong(tablePos + 4) + tablePos += 16 + } + + rawCount = buffer.getInt(tablePos) + rawOffset = buffer.getLong(tablePos + 4) + } + + override fun getClustersForArea( + bounds: LatLngBounds, + zoom: Float, + ): Collection> { + val z = zoom.toInt() + val latMargin = (bounds.northeast.latitude - bounds.southwest.latitude) * 0.15 + val lngMargin = (bounds.northeast.longitude - bounds.southwest.longitude) * 0.15 + + val minQueryLat = (bounds.southwest.latitude - latMargin).toFloat() + val maxQueryLat = (bounds.northeast.latitude + latMargin).toFloat() + val minQueryLng = (bounds.southwest.longitude - lngMargin).toFloat() + val maxQueryLng = (bounds.northeast.longitude + lngMargin).toFloat() + + val results = ArrayList>() + var leafMarkerCount = 0 + + if (z <= maxClusterZoom) { + val targetZoom = z.coerceIn(minZoom, maxClusterZoom) + val count = zoomCounts[targetZoom] + val offset = zoomOffsets[targetZoom] + if (count == 0) return results + + // Binary search for minQueryLat + val startIndex = binarySearchLat(offset, count, 12, minQueryLat) + var idx = startIndex + while (idx < count) { + val entryOffset = (offset + idx * 12).toInt() + val lat = buffer.getFloat(entryOffset) + if (lat > maxQueryLat) break + + val lng = buffer.getFloat(entryOffset + 4) + if (lng in minQueryLng..maxQueryLng) { + val clusterSize = buffer.getInt(entryOffset + 8) + val pos = LatLng(lat.toDouble(), lng.toDouble()) + + if (clusterSize >= 2) { + // Genuine cluster of 2 or more items: always a cluster badge + results.add(AreaCluster(pos, clusterSize, null)) + } else if (zoom >= minZoomForLeafMarkers && leafMarkerCount < maxMarkersPerViewport) { + // Single item at street level: individual candy house marker + val item = BuildingClusterItem(lat.toDouble(), lng.toDouble(), idx) + results.add(AreaCluster(pos, 1, item)) + leafMarkerCount++ + } + // If clusterSize == 1 and zoom < minZoomForLeafMarkers: + // Omit from low zoom views. A single building is not a cluster and is sub-pixel noise. + } + idx++ + } + } else { + // Zoom > maxClusterZoom: High detail street level view + if (rawCount == 0) return results + val startIndex = binarySearchLat(rawOffset, rawCount, 8, minQueryLat) + var idx = startIndex + while (idx < rawCount) { + val entryOffset = (rawOffset + idx * 8).toInt() + val lat = buffer.getFloat(entryOffset) + if (lat > maxQueryLat) break + + val lng = buffer.getFloat(entryOffset + 4) + if (lng in minQueryLng..maxQueryLng) { + val pos = LatLng(lat.toDouble(), lng.toDouble()) + if (leafMarkerCount < maxMarkersPerViewport) { + val item = BuildingClusterItem(lat.toDouble(), lng.toDouble(), idx) + results.add(AreaCluster(pos, 1, item)) + leafMarkerCount++ + } + } + idx++ + } + } + + return results + } + + private fun binarySearchLat(baseOffset: Long, count: Int, entrySize: Int, targetLat: Float): Int { + var low = 0 + var high = count - 1 + var result = count + + while (low <= high) { + val mid = (low + high) ushr 1 + val midOffset = (baseOffset + mid * entrySize).toInt() + val midLat = buffer.getFloat(midOffset) + + if (midLat >= targetLat) { + result = mid + high = mid - 1 + } else { + low = mid + 1 + } + } + return result + } +} diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/BuildingMegaClusterDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/BuildingMegaClusterDemoActivity.kt new file mode 100644 index 000000000..b33760a68 --- /dev/null +++ b/demo/src/main/java/com/google/maps/android/utils/demo/BuildingMegaClusterDemoActivity.kt @@ -0,0 +1,229 @@ +/* + * 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.view.LayoutInflater +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.MarkerOptions +import com.google.android.material.button.MaterialButtonToggleGroup +import com.google.android.material.dialog.MaterialAlertDialogBuilder +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.view.DefaultClusterRenderer +import com.google.maps.android.utils.demo.model.BuildingClusterItem +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +// [START maps_android_utils_building_megacluster] +/** + * Demonstrates mega-scale marker clustering across a 2,719,379 building dataset using an + * on-demand [com.google.maps.android.clustering.AreaClusterProvider] and memory-mapped spatial pyramid. + * + * Key capabilities showcased: + * 1. Area-based callback clustering: markers and clusters are dynamically resolved on-demand + * for the visible map camera bounds and zoom level. + * 2. Zero JVM heap exhaustion: millions of points are stored out-of-heap in memory-mapped assets. + * 3. Gremlin leaf markers: zoomed-in individual buildings render using custom Gremlin marker icons. + * 4. Configurable cluster badges: compact formatting (e.g. 2.7M, 116K) with customizable unit case. + */ +class BuildingMegaClusterDemoActivity : BaseDemoActivity() { + + private lateinit var clusterManager: ClusterManager + private lateinit var spatialStore: BinarySpatialPyramidStore + private lateinit var renderer: BuildingClusterRenderer + + private var textStats: TextView? = null + + override fun getLayoutId(): Int = R.layout.activity_building_megacluster + + @SuppressLint("PotentialBehaviorOverride") + override fun startDemo(isRestore: Boolean) { + val map = map ?: return + + textStats = findViewById(R.id.text_stats) + + // Initial camera center over the San Diego / Baja California / Mexicali corridor + map.moveCamera(CameraUpdateFactory.newLatLngZoom(LatLng(32.55, -116.2), 9f)) + + lifecycleScope.launch(Dispatchers.Default) { + val store = BinarySpatialPyramidStore(this@BuildingMegaClusterDemoActivity) + withContext(Dispatchers.Main) { + spatialStore = store + setupClusterManager(map, store) + } + } + + findViewById(R.id.fab_settings)?.setOnClickListener { + showClusterSettingsDialog() + } + + val toggleMapTypeGroup = findViewById(R.id.toggle_map_type_group) + toggleMapTypeGroup?.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (!isChecked) return@addOnButtonCheckedListener + when (checkedId) { + R.id.btn_map_type_normal -> map.mapType = GoogleMap.MAP_TYPE_NORMAL + R.id.btn_map_type_satellite -> map.mapType = GoogleMap.MAP_TYPE_SATELLITE + R.id.btn_map_type_hybrid -> map.mapType = GoogleMap.MAP_TYPE_HYBRID + } + } + } + + private fun setupClusterManager(map: GoogleMap, store: BinarySpatialPyramidStore) { + clusterManager = ClusterManager(this, map) + renderer = BuildingClusterRenderer(this, map, clusterManager) + + // Configure cluster renderer with compact number formatting + renderer.useCompactNumberFormatting = true + renderer.compactUnitUppercase = true + renderer.maxNonZeroDigits = 3 + renderer.setAnimation(true) + clusterManager.renderer = renderer + + // Connect the on-demand AreaClusterProvider callback + clusterManager.setAreaClusterProvider(store) + + map.setOnCameraIdleListener { + clusterManager.onCameraIdle() + updateStatsDisplay(map.cameraPosition.zoom) + } + + map.setOnMarkerClickListener(clusterManager) + map.setOnInfoWindowClickListener(clusterManager) + + clusterManager.setOnClusterClickListener { cluster -> + val targetZoom = (map.cameraPosition.zoom + 2f).coerceAtMost(20f) + map.animateCamera(CameraUpdateFactory.newLatLngZoom(cluster.position, targetZoom)) + true + } + + clusterManager.setOnClusterItemClickListener { item -> + Toast.makeText(this, "Building at ${item.snippet}", Toast.LENGTH_SHORT).show() + false + } + + clusterManager.cluster() + updateStatsDisplay(map.cameraPosition.zoom) + } + + private fun updateStatsDisplay(zoom: Float) { + val mode = if (zoom >= spatialStore.minZoomForLeafMarkers) "Candy Houses & Badges" else "Clusters Only" + textStats?.text = String.format(Locale.US, "Zoom: %.1f | %s", zoom, mode) + } + + /** + * Custom renderer that styles individual building markers as Hansel & Gretel candy houses and + * styles composite clusters with dynamic count badges. + */ + private class BuildingClusterRenderer( + context: Context, + map: GoogleMap, + clusterManager: ClusterManager, + ) : DefaultClusterRenderer(context, map, clusterManager) { + + private val mCandyHouseIcon: BitmapDescriptor = + BitmapDescriptorFactory.fromResource(R.drawable.candy_house_marker) + + override fun shouldRenderAsCluster(cluster: Cluster): Boolean { + // A cluster badge must ALWAYS represent at least 2 items (never a badge labeled "1") + return cluster.size >= minClusterSize.coerceAtLeast(2) + } + + override fun onBeforeClusterItemRendered( + item: BuildingClusterItem, + markerOptions: MarkerOptions, + ) { + markerOptions.icon(mCandyHouseIcon) + markerOptions.title(item.title) + markerOptions.snippet(item.snippet) + } + + override fun getColor(clusterSize: Int): Int { + return when { + clusterSize >= 1_000_000 -> Color.rgb(180, 0, 0) // Crimson for 1M+ + clusterSize >= 100_000 -> Color.rgb(220, 50, 0) // Deep orange for 100K+ + clusterSize >= 10_000 -> Color.rgb(235, 120, 0) // Amber orange for 10K+ + clusterSize >= 1_000 -> Color.rgb(240, 180, 0) // Golden yellow for 1K+ + clusterSize >= 100 -> Color.rgb(30, 140, 60) // Emerald green for 100+ + else -> Color.rgb(26, 115, 232) // Google Blue for smaller + } + } + } + + private fun showClusterSettingsDialog() { + val view = LayoutInflater.from(this).inflate(R.layout.dialog_cluster_settings, null) + + // Hide dataset selection and radius sections since this demo is dedicated to the 2.72M spatial pyramid + view.findViewById(R.id.section_dataset)?.visibility = android.view.View.GONE + view.findViewById(R.id.section_radius)?.visibility = android.view.View.GONE + + val textMinSizeTitle = view.findViewById(R.id.text_minsize_title) + val sliderMinSize = view.findViewById(R.id.slider_minsize) + val currentMinSize = renderer.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 textDigitsTitle = view.findViewById(R.id.text_digits_title) + val sliderDigits = view.findViewById(R.id.slider_digits) + val currentDigits = renderer.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()) + } + + val switchCompact = view.findViewById(R.id.switch_compact_notation) + val switchUppercase = view.findViewById(R.id.switch_uppercase_units) + val switchShowExact = view.findViewById(R.id.switch_show_exact) + + switchCompact.isChecked = renderer.useCompactNumberFormatting + switchUppercase.isChecked = renderer.compactUnitUppercase + switchShowExact.isChecked = renderer.showExactCount + + MaterialAlertDialogBuilder(this) + .setTitle("Clustering & Badge Settings") + .setView(view) + .setPositiveButton("Apply") { _, _ -> + renderer.useCompactNumberFormatting = switchCompact.isChecked + renderer.compactUnitUppercase = switchUppercase.isChecked + renderer.showExactCount = switchShowExact.isChecked + renderer.maxNonZeroDigits = sliderDigits.value.toInt() + renderer.minClusterSize = sliderMinSize.value.toInt() + renderer.clearIconCache() + clusterManager.cluster() + } + .setNegativeButton("Cancel", null) + .show() + } +} +// [END maps_android_utils_building_megacluster] 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 144441fac..b99fba98f 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 @@ -105,6 +105,7 @@ internal fun demoGroups(): List = 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_building_megacluster, BuildingMegaClusterDemoActivity::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/model/BuildingClusterItem.kt b/demo/src/main/java/com/google/maps/android/utils/demo/model/BuildingClusterItem.kt new file mode 100644 index 000000000..0983afe84 --- /dev/null +++ b/demo/src/main/java/com/google/maps/android/utils/demo/model/BuildingClusterItem.kt @@ -0,0 +1,44 @@ +package com.google.maps.android.utils.demo.model + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.clustering.ClusterItem + +/** + * A lightweight [ClusterItem] representing an individual building point. + * + * @property lat Latitude in degrees. + * @property lng Longitude in degrees. + * @property id Optional identifier of the building. + */ +public class BuildingClusterItem( + public val lat: Double, + public val lng: Double, + public val id: Int = -1, +) : ClusterItem { + private val mPosition: LatLng = LatLng(lat, lng) + + override val position: LatLng + get() = mPosition + + override val title: String + get() = if (id >= 0) "Building #$id" else "Building" + + override val snippet: String + get() = "Lat: %.5f, Lng: %.5f".format(lat, lng) + + override val zIndex: Float? + get() = null + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is BuildingClusterItem) return false + return lat == other.lat && lng == other.lng && id == other.id + } + + override fun hashCode(): Int { + var result = lat.hashCode() + result = 31 * result + lng.hashCode() + result = 31 * result + id + return result + } +} diff --git a/demo/src/main/res/drawable-nodpi/candy_house_marker.png b/demo/src/main/res/drawable-nodpi/candy_house_marker.png new file mode 100644 index 000000000..cd26470b6 Binary files /dev/null and b/demo/src/main/res/drawable-nodpi/candy_house_marker.png differ diff --git a/demo/src/main/res/drawable/ic_layers_24.xml b/demo/src/main/res/drawable/ic_layers_24.xml new file mode 100644 index 000000000..a12006042 --- /dev/null +++ b/demo/src/main/res/drawable/ic_layers_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/demo/src/main/res/layout/activity_building_megacluster.xml b/demo/src/main/res/layout/activity_building_megacluster.xml new file mode 100644 index 000000000..37e7ad5c6 --- /dev/null +++ b/demo/src/main/res/layout/activity_building_megacluster.xml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo/src/main/res/layout/dialog_cluster_settings.xml b/demo/src/main/res/layout/dialog_cluster_settings.xml index 6be2c3422..76283d416 100644 --- a/demo/src/main/res/layout/dialog_cluster_settings.xml +++ b/demo/src/main/res/layout/dialog_cluster_settings.xml @@ -27,76 +27,99 @@ android:padding="20dp"> - - - - - - + android:text="@string/clustering_settings_dataset_title" + android:textAppearance="?attr/textAppearanceSubtitle1" + android:textStyle="bold" /> - - + android:layout_marginTop="2dp" + android:text="@string/clustering_settings_dataset_desc" + android:textAppearance="?attr/textAppearanceCaption" + android:textColor="?android:attr/textColorSecondary" /> - + + + + + + + + + - + android:orientation="vertical"> - + - + + + + + + Clustering: 2K markers Clustering: 20K only visible markers Clustering: 100K / 1M markers (Supercluster) + Clustering: 2.72M Buildings (Area Callback) 100K (SF) 1M (USA) Dataset Size diff --git a/tools/preprocess_buildings.py b/tools/preprocess_buildings.py new file mode 100644 index 000000000..71a9688dc --- /dev/null +++ b/tools/preprocess_buildings.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +Preprocesses a large CSV of latitude/longitude coordinates into a compact, +memory-mappable hierarchical spatial pyramid (.scbin) for Android Google Maps clustering. + +Uses greedy radius clustering with spatial hash acceleration to enforce minimum +inter-cluster distance, preventing cluster badge overlap on screen. +""" + +import argparse +import math +import os +import struct +import sys +import time + +def lng_to_x(lng: float) -> float: + return lng / 360.0 + 0.5 + +def lat_to_y(lat: float) -> float: + sin_lat = math.sin(lat * math.pi / 180.0) + sin_lat = max(-0.9999, min(0.9999, sin_lat)) + y = 0.5 - 0.25 * math.log((1.0 + sin_lat) / (1.0 - sin_lat)) / math.pi + return max(0.0, min(1.0, y)) + +def preprocess(csv_path: str, output_path: str, max_cluster_zoom: int = 16, pixel_radius: float = 96.0): + print(f"Reading CSV from: {csv_path}") + t0 = time.time() + + points = [] + min_lat, max_lat = 90.0, -90.0 + min_lng, max_lng = 180.0, -180.0 + + with open(csv_path, 'r', encoding='utf-8') as f: + header = next(f, None) + for line in f: + line = line.strip() + if not line: + continue + parts = line.split(',') + lat, lng = float(parts[0]), float(parts[1]) + points.append((lat, lng)) + if lat < min_lat: min_lat = lat + if lat > max_lat: max_lat = lat + if lng < min_lng: min_lng = lng + if lng > max_lng: max_lng = lng + + total_points = len(points) + print(f"Loaded {total_points:,} points in {time.time() - t0:.2f}s") + print(f"Bounds: Lat [{min_lat:.6f}, {max_lat:.6f}], Lng [{min_lng:.6f}, {max_lng:.6f}]") + print(f"Cluster pixel radius: {pixel_radius}px") + + # Initial points: (lat, lng, count, mercator_x, mercator_y) + curr = [] + for lat, lng in points: + curr.append((lat, lng, 1, lng_to_x(lng), lat_to_y(lat))) + + t_pyramid = time.time() + levels = {} + + for z in range(max_cluster_zoom, -1, -1): + t_z = time.time() + # Radius in Mercator [0, 1] coordinate space + r = pixel_radius / (256.0 * (2 ** z)) + r2 = r * r + + # Index current points into a spatial hash grid of cell size r + grid = {} + for i, it in enumerate(curr): + gx = int(it[3] / r) + gy = int(it[4] / r) + k = (gx, gy) + if k not in grid: + grid[k] = [] + grid[k].append(i) + + clustered = [False] * len(curr) + next_level = [] + + # Greedy radius clustering: any item within distance r is absorbed and marked + for i in range(len(curr)): + if clustered[i]: + continue + it = curr[i] + gx = int(it[3] / r) + gy = int(it[4] / r) + + sum_lat = it[0] * it[2] + sum_lng = it[1] * it[2] + cnt = it[2] + clustered[i] = True + + # Query 9 adjacent cells + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + cell = grid.get((gx + dx, gy + dy)) + if not cell: + continue + for nid in cell: + if clustered[nid]: + continue + nit = curr[nid] + dist2 = (it[3] - nit[3]) ** 2 + (it[4] - nit[4]) ** 2 + if dist2 <= r2: + clustered[nid] = True + sum_lat += nit[0] * nit[2] + sum_lng += nit[1] * nit[2] + cnt += nit[2] + + c_lat = sum_lat / cnt + c_lng = sum_lng / cnt + next_level.append((c_lat, c_lng, cnt, lng_to_x(c_lng), lat_to_y(c_lat))) + + levels[z] = next_level + print(f"Zoom {z:2d}: {len(next_level):6,d} clusters ({time.time() - t_z:.2f}s)") + curr = next_level + + print(f"Pyramid generated in {time.time() - t_pyramid:.2f}s") + + # Create destination directory if needed + os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) + + print(f"Writing binary pyramid to {output_path}...") + with open(output_path, 'wb') as f: + # Magic: 8 bytes + f.write(b'SCBIN2\x00\x00') + # Header (32 bytes): totalPoints, minZoom, maxClusterZoom, minLat, maxLat, minLng, maxLng, reserved + f.write(struct.pack('