Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions clustering/api/clustering.api
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
public final class com/google/maps/android/clustering/AreaCluster : com/google/maps/android/clustering/Cluster {
public fun <init> (Lcom/google/android/gms/maps/model/LatLng;ILcom/google/maps/android/clustering/ClusterItem;)V
public synthetic fun <init> (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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <init> ()V
public fun <init> (Lcom/google/maps/android/clustering/AreaClusterProvider;)V
public synthetic fun <init> (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 <init> ()V
protected final fun computeCentroid (Ljava/util/Collection;)Lcom/google/android/gms/maps/model/LatLng;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T : ClusterItem>(
public override val position: LatLng,
public override val size: Int,
public val leafItem: T? = null,
) : Cluster<T> {
public override val items: Collection<T>
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)"
}
Original file line number Diff line number Diff line change
@@ -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<T : ClusterItem> {
/**
* 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<Cluster<T>>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -288,6 +289,27 @@ public open class ClusterManager<T : ClusterItem>
}
}

/**
* 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<T>?) {
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).
Expand All @@ -297,6 +319,15 @@ public open class ClusterManager<T : ClusterItem>
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<T>).setVisibleBounds(currentBounds)
}
mClusterTask =
scope.launch {
val param = mMap.cameraPosition.zoom
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T : ClusterItem>(
public var provider: AreaClusterProvider<T>? = null,
) : AbstractAlgorithm<T>(), ScreenBasedAlgorithm<T> {

@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<Cluster<T>> {
val p: AreaClusterProvider<T>
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<Cluster<T>>
} else {
result.toSet()
}
}

public override fun addItem(item: T): Boolean = false

public override fun addItems(items: Collection<T>): Boolean = false

public override fun clearItems() {}

public override fun removeItem(item: T): Boolean = false

public override fun removeItems(items: Collection<T>): Boolean = false

public override fun updateItem(item: T): Boolean = false

public override val items: Collection<T>
get() = emptyList()
}
Original file line number Diff line number Diff line change
@@ -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<TestItem>(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<TestItem> { 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<TestItem>()
algo.setVisibleBounds(LatLngBounds(LatLng(0.0, 0.0), LatLng(1.0, 1.0)))
assertTrue(algo.getClusters(5f).isEmpty())
}
}
4 changes: 4 additions & 0 deletions demo/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ if (!secretsFile.exists()) {
}

android {
androidResources {
noCompress += listOf("scbin")
}

lint {
sarifOutput = layout.buildDirectory.file("reports/lint-results.sarif").get().asFile
}
Expand Down
3 changes: 3 additions & 0 deletions demo/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@
<activity
android:name=".SuperCluster100kDemoActivity"
android:exported="true" />
<activity
android:name=".BuildingMegaClusterDemoActivity"
android:exported="true" />
<activity
android:name=".CustomMarkerClusteringDemoActivity"
android:exported="true" />
Expand Down
Binary file added demo/src/main/assets/buildings_pyramid.scbin
Binary file not shown.
Loading
Loading