Skip to content
Merged
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
57 changes: 39 additions & 18 deletions androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ import org.meshtastic.feature.map.layers.MapLayerItem
import org.meshtastic.feature.map.layers.opacityOf
import org.meshtastic.feature.map.layers.toPickedMapFile
import org.meshtastic.feature.map.mergeStationaryRuns
import org.meshtastic.feature.map.stackCoincident
import org.meshtastic.feature.map.terrain.GeoBounds
import org.meshtastic.feature.map.terrain.MapterhornEndpoints
import org.meshtastic.feature.map.tiles.mapAttributionText
Expand All @@ -243,6 +244,7 @@ import java.io.ByteArrayInputStream
import java.io.InputStream
import kotlin.coroutines.resume
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.roundToInt
import android.graphics.Color as AndroidColor
Expand Down Expand Up @@ -318,6 +320,21 @@ private const val BOX_AUTHORING_MIN_CORNER_DELTA = 1e-4
/** Above the raster basemap at -1, below every marker and shape the mesh draws at 0 and up. */
private const val OVERLAY_Z_INDEX = -0.5f

// A tapped cluster opens to at least this span, about street level, matching the Apple map's floor.
private const val CLUSTER_FIT_MIN_SPAN_METERS = 140.0
private val CLUSTER_FIT_PADDING = 64.dp
private const val METERS_PER_DEGREE = 111_320.0

/** These bounds grown, about their center, to at least [meters] across. */
private fun LatLngBounds.atLeastSpan(meters: Double): LatLngBounds {
val halfLatitude = meters / 2 / METERS_PER_DEGREE
val halfLongitude = halfLatitude / cos(Math.toRadians(center.latitude)).coerceAtLeast(MIN_COS_LATITUDE)
return including(LatLng(center.latitude + halfLatitude, center.longitude + halfLongitude))
.including(LatLng(center.latitude - halfLatitude, center.longitude - halfLongitude))
}

private const val MIN_COS_LATITUDE = 0.01

/** Below the offline vector layer's own water/roads/boundaries (-0.75f) so those still draw on top of the shading. */
private const val TERRAIN_HILLSHADE_Z_INDEX = -0.9f

Expand Down Expand Up @@ -1270,14 +1287,16 @@ fun MapView(
showClusterItemsDialog?.let { items ->
ClusterMembersDialog(
members =
items.map {
ClusterMemberEntry(
nodeNum = it.node.num,
title = it.nodeTitle,
subtitle = it.nodeSnippet,
node = it.node,
)
},
items
.flatMap { it.stackMembers.ifEmpty { listOf(it) } }
.map {
ClusterMemberEntry(
nodeNum = it.node.num,
title = it.nodeTitle,
subtitle = it.nodeSnippet,
node = it.node,
)
},
onMemberClick = { nodeNum ->
navigateToNodeDetails(nodeNum)
showClusterItemsDialog = null
Expand Down Expand Up @@ -1324,16 +1343,20 @@ internal fun rememberNodeClusterItems(
val unknownText = stringResource(Res.string.unknown)
val nowText = stringResource(Res.string.now)
return remember(nodes, myNodeNum, relativeTimeBucket, unknownText, nowText) {
nodes.map { node ->
fun itemFor(node: Node): NodeClusterItem {
val latLng = LatLng((node.position.latitude_i ?: 0) * DEG_D, (node.position.longitude_i ?: 0) * DEG_D)
NodeClusterItem(
return NodeClusterItem(
node = node,
nodePosition = latLng,
nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time, unknownText, nowText)}",
nodeSnippet = node.effectiveLongName,
myNodeNum = myNodeNum,
)
}
stackCoincident(nodes, myNodeNum).map { stack ->
val members = if (stack.count > 1) stack.members.map(::itemFor) else emptyList()
itemFor(stack.top).copy(stackMembers = members)
}
}
}

Expand All @@ -1359,10 +1382,12 @@ private fun MainMapContent(
coroutineScope: CoroutineScope,
onShowClusterItemsDialog: (List<NodeClusterItem>?) -> Unit,
) {
val clusterFitPaddingPx = with(LocalDensity.current) { CLUSTER_FIT_PADDING.roundToPx() }
NodeClusterMarkers(
nodeClusterItems = nodeClusterItems,
mapFilterState = mapFilterState,
navigateToNodeDetails = navigateToNodeDetails,
onStackClick = { onShowClusterItemsDialog(it) },
onClusterClick = { cluster ->
val items = cluster.items.toList()
val allSameLocation = items.size > 1 && items.all { it.position == items.first().position }
Expand All @@ -1371,15 +1396,11 @@ private fun MainMapContent(
} else {
val bounds = LatLngBounds.builder()
cluster.items.forEach { bounds.include(it.position) }
// Fit the members, as the Apple map does, rather than step one zoom level and leave a tight cluster
// needing several taps to break.
val fitted = bounds.build().atLeastSpan(CLUSTER_FIT_MIN_SPAN_METERS)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major · Functional Correctness · Quick win

🔎 Supported by static analysis

Script executed:

set -o pipefail
printf '%s\n' '--- diff ---'
git diff --unified=80 bf7e1155a69585b4190d774b3e93eba6d374ea8c d56154bf1079cadb79e44a38308f0d75202a73d1 -- androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
printf '%s\n' '--- relevant symbols ---'
rg -n -F -- 'CLUSTER_FIT_MIN_SPAN_METERS' androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
rg -n -F -e 'ClusterManager' -e 'ClusterItem' -e 'clusterClick' -e 'onClusterClick' -e 'atLeastSpan' -e 'allSameLocation' -e 'setOnCameraIdleListener' androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
printf '%s\n' '--- dependency/config references ---'
rg -n -F -e 'maps-compose-utils' -e 'maps-utils' -e 'cluster' gradle* androidApp build-logic gradle 2>/dev/null || true

Repository: meshtastic/Meshtastic-Android

Length of output: 38972


Script executed:

set -o pipefail
printf '%s\n' '--- NodeClusterMarkers.kt ---'
nl -ba androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt
printf '%s\n' '--- NodeClusterItem.kt ---'
nl -ba androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt
printf '%s\n' '--- relevant dependency declarations ---'
nl -ba gradle/libs.versions.toml | sed -n '60,80p;210,245p'
nl -ba androidApp/build.gradle.kts | sed -n '260,295p'
printf '%s\n' '--- clustering tests and related helpers ---'
rg -n -F -e 'NonHierarchicalDistanceBasedAlgorithm' -e 'NonHierarchicalViewBasedAlgorithm' -e 'NodeClusterMarkers' -e 'CLUSTER_FIT_MIN_SPAN_METERS' -e '140' androidApp/src/testGoogle androidApp/src/google feature 2>/dev/null || true

Repository: meshtastic/Meshtastic-Android

Length of output: 42967


Web query:

official GitHub source com.google.maps.android android-maps-utils 6.1.0 NonHierarchicalDistanceBasedAlgorithm max distance clustering

Result:

<source_evidence>
<source kind="search">
<title>NonHierarchicalDistanceBasedAlgorithm (library API)</title>
<location>https://googlemaps.github.io/android-maps-utils/javadoc/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.html</location>
<excerpt>citeturn0search0 [wordlim: 200] Published: 6.7 years ago; Crawled: 3 weeks ago;     *       * com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm&lt;T&gt; ... `protected java.util.Collection&lt;NonHierarchicalDistanceBasedAlgorithm.QuadItem&lt;T&gt;&gt;`  | `getClusteringItems(PointQuadTree&lt;NonHierarchicalDistanceBasedAlgorithm.QuadItem&lt;T&gt;&gt; quadTree, float zoom)` ... `int`  | `getMaxDistanceBetweenClusteredItems()`

com.google.maps.android.clustering.algo

## Class NonHierarchicalDistanceBasedAlgorithm&lt;T extends ClusterItem&gt;

  * java.lang.Object
  *     * com.google.maps.android.clustering.algo.AbstractAlgorithm&lt;T&gt;
    *       * com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm&lt;T&gt;

  * All Implemented Interfaces:
    Algorithm&lt;T&gt;

Direct Known Subclasses:
    NonHierarchicalViewBasedAlgorithm

* * *
    
    public class NonHierarchicalDistanceBasedAlgorithm&lt;T extends ClusterItem&gt;
    extends AbstractAlgorithm&lt;T&gt;

A simple clustering algorithm with O(nlog n) performance. Resulting clusters are not hierarchical.

High level algorithm:
1. Iterate over items in the order they were added (candidate clusters).
2. Create a cluster with the center of the item.
3. Add all items that are within a certain distance to the cluster.
4. Move any items out of an existing cluster if they are closer to another cluster.
5. Remove those items from the list of candidate clusters.

Clusters have the center of the first element (not the centroid of the items within it).

  *     * ### Nested Class Summary

Nested Classes 
Modifier and Type  | Class and Description
--- | ---
`protected static class `  | `NonHierarchicalDistanceBasedAlgorithm.QuadItem&lt;T extends ClusterItem&gt;` 
    * ### Constructor Summary

Constructors 
Constructor and Description
---
`NonHierarchicalDistanceBasedAlgorithm()` 
    * ### Method Summary

All Methods Instance Methods Concrete Methods 
Modifier and Type  | Method and Description
--- | ---
`boolean`  | `addItem(T item)` Adds an item to the algorithm
`boolean`  | `addItems(java.util.Collection&lt;T&gt; items)` Adds a collection of items to the algorithm
`void`  | `clearItems()` 
`protected java.util.Collection&lt;NonHierarchicalDistanceBasedAlgorithm.QuadItem&lt;T&gt;&gt;`  | `getClusteringItems(PointQuadTree&lt;NonHierarchicalDistanceBasedAlgorithm.QuadItem&lt;T&gt;&gt; quadTree, float zoom)` 
`java.util.Set&lt;? extends Cluster&lt;T&gt;&gt;`  | `getClusters(float zoom)` 
`java.util.Collection&lt;T&gt;`  | `getItems()` 
`int`  | `getMaxDistanceBetweenClusteredItems()` 
`boolean`  | `removeItem(T item)` Removes an item from the algorithm
`boolean`  | `removeItems(java.util.Collection&lt;T&gt; items)` Removes a collection of items from the algorithm
`void`  | `setMaxDistanceBetweenClusteredItems(int maxDistance)` 
`boolean`  | `updateItem(T item)` Updates the provided item in the algorithm

  *     * ### Constructor Detail

      * #### NonHierarchicalDistanceBasedAlgorithm
            
            public NonHierarchicalDistanceBasedAlgorithm()

    * ### Method Detail

      * #### addItem
            
            public boolean addItem(T item)

Adds an item to the algorithm

Parameters:
    `item` - the item to be added
Returns:
    true if the algorithm contents changed as a result of the call
      * #### addItems
            
            public boolean addItems(java.util.Collection&lt;T&gt; items)

Adds a collection of items to the algorithm

Parameters:
    `items` - the items to be added
Returns:
    true if the algorithm contents changed as a result of the call
      * #### clearItems
            
            public void clearItems()

      * #### removeItem
            
            public boolean removeItem(T item)

Removes an item from the algorithm

Parameters:
    `item` - the item to be removed
Returns:
    true if this algorithm contained the specified element (or equivalently, if this al…[truncated]</excerpt>
</source>
<source kind="search">
<title>GitHub - googlemaps/android-maps-utils: Maps SDK for Android Utility Library · GitHub</title>
<location>https://github.com/googlemaps/android-maps-utils</location>
<excerpt>citeturn0search5 [wordlim: 200] Crawled: 3 weeks ago; Required for demos using Advanced Markers or Cloud-based Map Styling. ...   1. `secrets.properties`: Add a line to your `secrets.properties` file with your Map ID: ...             android:name=&quot;com.google.maps.android.utils.attribution.AttributionIdInitializer&quot; ... Accordingly, while the library is in version 0.x, backwards-incompatible changes may be introduced at any time.If you find a bug, or have a feature request, please file an issue on GitHub.
You can learn more about map IDs in the official documentation. You can set the Map ID in one of the following ways:

  1. `secrets.properties`: Add a line to your `secrets.properties` file with your Map ID:
    
    MAP_ID=YOUR_MAP_ID

  2. `strings.xml`: Alternatively, you can set the Map ID in the `demo/src/main/res/values/strings.xml` file:
    
    &lt;string name=&quot;map_id&quot;&gt;YOUR_MAP_ID&lt;/string&gt;

  3. XML Layout Files: You can also hardcode the Map ID directly in the XML layout files where a map is defined, by setting the `map:mapId` attribute:
    
    &lt;androidx.fragment.app.FragmentContainerView
        map:mapId=&quot;YOUR_MAP_ID&quot; 
        /&gt;

## Documentation

See the documentation for a full list of classes and their methods.

Full guides for using the utilities are published in Google Maps Platform documentation.

## Usage

Marker utilities

### Marker utilities

  * Marker animation source, sample code
  * Marker clustering source, guide
  * Advanced Markers clustering source, sample code
  * Marker icons source, sample code
Data visualization utilities

### Data visualization utilities

  * Display heat maps source, guide
  * Import GeoJSON source, guide
  * Import KML source, guide
Polyline and spherical geometry utilities

### Additional utilities

  * Polyline encoding and decoding source, encoding sample, decoding sample
  * Spherical geometry source, compute distance sample
Street View metadata utility

### Street View metadata utility

The StreetViewUtil class provides functionality to check whether a location is supported in StreetView. You can avoid errors when adding a Street View panorama to an Android app by calling this metadata utility and only adding a Street View panorama if the response is `OK`.
    
    StreetViewUtils.fetchStreetViewData(LatLng(8.1425918, 11.5386121), BuildConfig.MAPS_API_KEY,Source.DEFAULT)

`fetchStreetViewData` will return `NOT_FOUND`, `OK`, `ZERO_RESULTS` or `REQUEST_DENIED`, depending on the response.

By default, the `Source` is set to `Source.DEFAULT`, but you can also specify `Source.OUTDOOR` to request outdoor Street View panoramas.

## 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.

If you wish to disable this, you can do so by removing the initializer in your `AndroidManifest.xml` using the `tools:node=&quot;remove&quot;` attribute:
    
    &lt;provider
        android:name=&quot;androidx.startup.InitializationProvider&quot;
        android:authorities=&quot;${applicationId}.androidx-startup&quot;
        android:exported=&quot;false&quot;
        tools:node=&quot;merge&quot;&gt;
        &lt;meta-data
            android:name=&quot;com.google.maps.android.utils.attribution.AttributionIdInitializer&quot;
            tools:node=&quot;remove&quot; /&gt;
    &lt;/provider&gt;

## Contributing

Contributions are welcome and encouraged! If you&#39;d like to contribute, send us a pull request and refer to our code of conduct and contributing guide.

### Using AI

This repository provides an official Gemini Skill and an `llm-integration-prompt.md` to help AI agents navigate the codebase and provide assistance. Refer to the contributing guide for more details on AI usage.

## Terms of Service

This library uses Goog…[truncated]</excerpt>
</source>
<source kind="search">
<title>NonHierarchicalDistanceBasedAlgorithm (library API)</title>
<location>https://googlemaps.github.io/android-maps-utils/javadoc/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.html</location>
<excerpt>citeturn2search0 [wordlim: 200] Published: 6.7 years ago; Crawled: 3 weeks ago; `protected java.util.Collection&lt;NonHierarchicalDistanceBasedAlgorithm.QuadItem&lt;T&gt;&gt;`  | `getClusteringItems(PointQuadTree&lt;NonHierarchicalDistanceBasedAlgorithm.QuadItem&lt;T&gt;&gt; quadTree, float zoom)` ... `void`  | `setMaxDistanceBetweenClusteredItems(int maxDistance)`

com.google.maps.android.clustering.algo

## Class NonHierarchicalDistanceBasedAlgorithm&lt;T extends ClusterItem&gt;

  * java.lang.Object
  *     * com.google.maps.android.clustering.algo.AbstractAlgorithm&lt;T&gt;
    *       * com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm&lt;T&gt;

  * All Implemented Interfaces:
    Algorithm&lt;T&gt;

Direct Known Subclasses:
    NonHierarchicalViewBasedAlgorithm

* * *
    
    public class NonHierarchicalDistanceBasedAlgorithm&lt;T extends ClusterItem&gt;
    extends AbstractAlgorithm&lt;T&gt;

A simple clustering algorithm with O(nlog n) performance. Resulting clusters are not hierarchical.

High level algorithm:
1. Iterate over items in the order they were added (candidate clusters).
2. Create a cluster with the center of the item.
3. Add all items that are within a certain distance to the cluster.
4. Move any items out of an existing cluster if they are closer to another cluster.
5. Remove those items from the list of candidate clusters.

Clusters have the center of the first element (not the centroid of the items within it).

  *     * ### Nested Class Summary

Nested Classes 
Modifier and Type  | Class and Description
--- | ---
`protected static class `  | `NonHierarchicalDistanceBasedAlgorithm.QuadItem&lt;T extends ClusterItem&gt;` 
    * ### Constructor Summary

Constructors 
Constructor and Description
---
`NonHierarchicalDistanceBasedAlgorithm()` 
    * ### Method Summary

All Methods Instance Methods Concrete Methods 
Modifier and Type  | Method and Description
--- | ---
`boolean`  | `addItem(T item)` Adds an item to the algorithm
`boolean`  | `addItems(java.util.Collection&lt;T&gt; items)` Adds a collection of items to the algorithm
`void`  | `clearItems()` 
`protected java.util.Collection&lt;NonHierarchicalDistanceBasedAlgorithm.QuadItem&lt;T&gt;&gt;`  | `getClusteringItems(PointQuadTree&lt;NonHierarchicalDistanceBasedAlgorithm.QuadItem&lt;T&gt;&gt; quadTree, float zoom)` 
`java.util.Set&lt;? extends Cluster&lt;T&gt;&gt;`  | `getClusters(float zoom)` 
`java.util.Collection&lt;T&gt;`  | `getItems()` 
`int`  | `getMaxDistanceBetweenClusteredItems()` 
`boolean`  | `removeItem(T item)` Removes an item from the algorithm
`boolean`  | `removeItems(java.util.Collection&lt;T&gt; items)` Removes a collection of items from the algorithm
`void`  | `setMaxDistanceBetweenClusteredItems(int maxDistance)` 
`boolean`  | `updateItem(T item)` Updates the provided item in the algorithm

  *     * ### Constructor Detail

      * #### NonHierarchicalDistanceBasedAlgorithm
            
            public NonHierarchicalDistanceBasedAlgorithm()

    * ### Method Detail

      * #### addItem
            
            public boolean addItem(T item)

Adds an item to the algorithm

Parameters:
    `item` - the item to be added
Returns:
    true if the algorithm contents changed as a result of the call
      * #### addItems
            
            public boolean addItems(java.util.Collection&lt;T&gt; items)

Adds a collection of items to the algorithm

Parameters:
    `items` - the items to be added
Returns:
    true if the algorithm contents changed as a result of the call
      * #### clearItems
            
            public void clearItems()

      * #### removeItem
            
            public boolean removeItem(T item)

Removes an item from the algorithm

Parameters:
    `item` - the item to be removed
Returns:
    true if this algorithm contained the specified element (or equivalently, if this algorithm changed as a result of the call).
      * #### removeItems
            
         …[truncated]</excerpt>
</source>
<source kind="search">
<title>GitHub - googlemaps/android-maps-utils: Maps SDK for Android Utility Library · GitHub</title>
<location>https://github.com/googlemaps/android-maps-utils</location>
<excerpt>citeturn2search6 [wordlim: 200] Crawled: 3 weeks ago;   1. `secrets.properties`: Add a line to your `secrets.properties` file with your Map ID: ...     &lt;string name=&quot;map_id&quot;&gt;YOUR_MAP_ID&lt;/string&gt; ...             android:name=&quot;com.google.maps.android.utils.attribution.AttributionIdInitializer&quot; ... If you find a bug, or have a feature request, please file an issue on GitHub.
  * Marker clustering source, guide
  * Advanced Markers clustering source, sample code
  * Marker icons source, sample code
Data visualization utilities

### Data visualization utilities

  * Display heat maps source, guide
  * Import GeoJSON source, guide
  * Import KML source, guide
Polyline and spherical geometry utilities

### Additional utilities

  * Polyline encoding and decoding source, encoding sample, decoding sample
  * Spherical geometry source, compute distance sample
Street View metadata utility

### Street View metadata utility

The StreetViewUtil class provides functionality to check whether a location is supported in StreetView. You can avoid errors when adding a Street View panorama to an Android app by calling this metadata utility and only adding a Street View panorama if the response is `OK`.
    
    StreetViewUtils.fetchStreetViewData(LatLng(8.1425918, 11.5386121), BuildConfig.MAPS_API_KEY,Source.DEFAULT)

`fetchStreetViewData` will return `NOT_FOUND`, `OK`, `ZERO_RESULTS` or `REQUEST_DENIED`, depending on the response.

By default, the `Source` is set to `Source.DEFAULT`, but you can also specify `Source.OUTDOOR` to request outdoor Street View panoramas.

Refer to the contributing guide for more details on AI usage.

## Terms of Service

This library uses Google Maps Platform services. Use of Google Maps Platform services through this library is subject to the Google Maps Platform Terms of Service.

If your billing address is in the European Economic Area, effective on 8 July 2025, the Google Maps Platform EEA Terms of Service will apply to your use of the Services. Functionality varies by region. Learn more.

This library is not a Google Maps Platform Core Service. Therefore, the Google Maps Platform Terms of Service (e.g. Technical Support Services, Service Level Agreements, and Deprecation Policy) do not apply to the code in this library.

## Support

This library is offered via an open source license. It is not governed by the Google Maps Platform Support Technical Support Services Guidelines, the SLA, or the Deprecation Policy. However, any Google Maps Platform services used by the library remain subject to the Google Maps Platform Terms of Service.

This library adheres to semantic versioning to indicate when backwards-incompatible changes are introduced. Accordingly, while the library is in version 0.x, backwards-incompatible changes may be introduced at any time.

If you find a bug, or have a feature request, please file an issue on GitHub. If you would like to get answers to technical questions from other Google Maps Platform developers, ask through one of our developer community channels. If you&#39;d like to contribute, please check the contributing guide.

You can also discuss this library on our Discord server.

</excerpt>
</source>
<source kind="search">
<title>com.google.maps.android.clustering.algo (library API)</title>
<location>https://googlemaps.github.io/android-maps-utils/javadoc/com/google/maps/android/clustering/algo/package-summary.html</location>
<excerpt>citeturn0search1 [wordlim: 200] Published: 6.7 years ago; Crawled: 3 weeks ago; # Package com.google.maps.android.clustering.algo ... NonHierarchicalDistanceBasedAlgorithm&lt;T extends ClusterItem&gt;  | A simple clustering algorithm with O(nlog n) performance.

# Package com.google.maps.android.clustering.algo

  * Interface Summary 
Interface  | Description
--- | ---
Algorithm&lt;T extends ClusterItem&gt;  | Logic for computing clusters
ScreenBasedAlgorithm&lt;T extends ClusterItem&gt;  | This algorithm uses map position for clustering, and should be reclustered on map movement
  * Class Summary 
Class  | Description
--- | ---
AbstractAlgorithm&lt;T extends ClusterItem&gt;  | Base Algorithm class that implements lock/unlock functionality.
GridBasedAlgorithm&lt;T extends ClusterItem&gt;  | Groups markers into a grid.
NonHierarchicalDistanceBasedAlgorithm&lt;T extends ClusterItem&gt;  | A simple clustering algorithm with O(nlog n) performance.
NonHierarchicalDistanceBasedAlgorithm.QuadItem&lt;T extends ClusterItem&gt;  |  
NonHierarchicalViewBasedAlgorithm&lt;T extends ClusterItem&gt;  | This algorithm works the same way as `NonHierarchicalDistanceBasedAlgorithm` but works, only in visible area.
PreCachingAlgorithmDecorator&lt;T extends ClusterItem&gt;  | Optimistically fetch clusters for adjacent zoom levels, caching them as necessary.
ScreenBasedAlgorithmAdapter&lt;T extends ClusterItem&gt;  |  
StaticCluster&lt;T extends ClusterItem&gt;  | A cluster whose center is determined upon creation.</excerpt>
</source>
<omitted_sources count="37">Additional source records were omitted from this bounded result.</omitted_sources>
</source_evidence>

Citations:

- 1: https://googlemaps.github.io/android-maps-utils/javadoc/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.html?utm_source=openai
- 2: https://github.com/googlemaps/android-maps-utils?utm_source=openai

Web query:

official GitHub source com.google.maps.android maps-compose 9.0.1 Clustering default algorithm

Result:

<source_evidence>
<source kind="find_in_page">
<title>android-maps-compose/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt at v9.0.1 · googlemaps/android-maps-compose · GitHub</title>
<location>https://github.com/googlemaps/android-maps-compose/blob/v9.0.1/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt</location>
<excerpt>citeturn3view2 [wordlim: 200] Crawled: today; Content type: text/html; Source: find({&quot;ref_id&quot;:&quot;turn2view0&quot;,&quot;pattern&quot;:&quot;Algorithm&quot;}); Total lines: 2457
L1586: * @param clusterItemContentRotation the rotation of the non-clustered item in degrees clockwise about the marker&#39;s anchor point
L1587: 
L1588: * @param clusterRenderer an optional ClusterRenderer that can be used to specify the algorithm used by the rendering.
L1589: 
L1590: */
L1591: 
L1592: @Composable
L1593: 
L1594: @GoogleMapComposable
L1595: 
L1596: @MapsComposeExperimentalApi
L1597: 
L1598: @Deprecated(
L1599: 
L1600: message = &quot;If clusterRenderer is specified, clusterContent and clusterItemContent are not used; use a function that takes ClusterManager as an argument instead.&quot;,
L1987: * @param items all items to show
L1988: 
L1989: * @param clusterManager a [ClusterManager] that can be used to specify the algorithm used by the rendering.
L1990: 
L1991: */
L1992: 
L1993: @Composable
L1994: 
L1995: @GoogleMapComposable
L1996: 
L1997: @MapsComposeExperimentalApi
L1998: 
L1999: public fun &lt;T : ClusterItem&gt; Clustering(
L2000: 
L2001: items: Collection&lt;T&gt;,
L2002: 
L2003: clusterManager: ClusterManager&lt;T&gt;,
L2004: 
L2005: clusterItemDecoration: @Composable @GoogleMapComposable (T) -&gt; Unit = {},
L2006: 
L2007: ) {
L2008: 
L2009: Clustering(
L2010: 
L2011: items = items,
L2012: 
L2013: clusterManager = clusterManager,
L2014: 
L2015: clusterItemDecoration = clusterItemDecoration,
</excerpt>
</source>
<source kind="open_page">
<title>android-maps-compose/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt at v9.0.1 · googlemaps/android-maps-compose · GitHub</title>
<location>https://github.com/googlemaps/android-maps-compose/blob/v9.0.1/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt</location>
<excerpt>citeturn2view0 [wordlim: 200] Crawled: today; Content type: text/html; Source: open({&quot;ref_id&quot;:&quot;https://github.com/googlemaps/android-maps-compose/blob/v9.0.1/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt&quot;,&quot;lineno&quot;:null}); Total lines: 2457
L0: 【0†Skip to content】
L1: ## Navigation Menu
L2: 
L3: 【1†Sign in】
L4: 
L5: Appearance settings
L6: 
L7:   * Platform
L8: 
L9:     * AI CODE CREATION
L10:       * 【2†GitHub CopilotWrite better code with AI】
L11:       * 【3†GitHub Copilot appDirect agents from issue to merge】
L12:       * 【4†MCP RegistryIntegrate external tools】
L13: 
L14:     * DEVELOPER WORKFLOWS
L15:       * 【5†ActionsAutomate any workflow】
L16:       * 【6†CodespacesInstant dev environments】
L17:       * 【7†IssuesPlan and track work】
L18:       * 【8†Code ReviewManage code changes】
L19:       * 【9†Code QualityEnforce quality at merge】
L20:     * APPLICATION SECURITY
L21:       * 【10†GitHub Advanced SecurityFind and fix vulnerabilities】
L22:       * 【11†Code securitySecure your code as you build】
L23:       * 【12†Secret protectionStop leaks before they start】
L24: 
L25:     * EXPLORE
L26:       * 【13†Why GitHub】
L27:       * 【14†Documentation†docs.github.com】
L28:       * 【15†Blog†github.blog】
L29:       * 【16†Changelog†github.blog】
L30:       * 【17†Marketplace】
L31: 
L32: 【18†View all features】
L33: 
L34:   * Solutions
L35:     * BY COMPANY SIZE
L36:       * 【19†Enterprises】
L37:       * 【20†Small and medium teams】
L38:       * 【21†Startups】
L39:       * 【22†Nonprofits】
L40: 
L41:     * BY USE CASE
L42:       * 【23†App Modernization】
L43:       * 【24†DevSecOps】
L44:       * 【25†DevOps】
L45:       * 【26†CI/CD】
L46:       * 【27†View all use cases】
L47: 
L48:     * BY INDUSTRY
L49:       * 【28†Healthcare】
L50:       * 【29†Financial services】
L51:       * 【30†Manufacturing】
L52:       * 【31†Government】
L53:       * 【32†See all industries】
L54: 
L55: 【33†View all solutions】
L56: 
L57:   * Resources
L58:     * EXPLORE BY TOPIC
L59:       * 【34†AI】
L60:       * 【35†Software Development】
L61:       * 【36†DevOps】
L62:       * 【37†Security】
L63:       * 【38†View all topics】
L64: 
L65:     * EXPLORE BY TYPE
L66:       * 【39†Customer stories】
L67:       * 【40†Events &amp; webinars】
L68:       * 【41†Ebooks &amp; reports】
L69:       * 【42†Business insights】
L70:       * 【43†GitHub Skills†skills.github.com】
L71:     * SUPPORT &amp; SERVICES
L72:       * 【14†Documentation†docs.github.com】
L73:       * 【44†Customer support†support.github.com】
L74:       * 【45†Community forum】
L75:       * 【46†Trust center】
L76:       * 【47†Partners】
L77: 
L78: 【48†View all resources】
L79: 
L80:   * Open Source
L81: 
L82:     * COMMUNITY
L83:       * 【49†GitHub SponsorsFund open source developers】
L84:     * PROGRAMS
L85:       * 【50†Security Lab†securitylab.github.com】
L86:       * 【51†Maintainer Community†maintainers.github.com】
L87:       * 【52†GitHub Stars†stars.github.com】
L88:       * 【53†Archive Program†archiveprogram.github.com】
L89: 
L90:     * REPOSITORIES
L91:       * 【54†Topics】
L92:       * 【55†Trending】
L93:       * 【56†Collections】
L94: 
L95:   * Enterprise
L96: 
L97:     * ENTERPRISE SOLUTIONS
L98:       * 【19†Enterprise platformAI-powered developer platform】
L99:     * AVAILABLE ADD-ONS
L100:       * 【10†GitHub Advanced SecurityEnterprise-grade security features】
L101:       * 【57†Copilot for BusinessEnterprise-grade AI features】
L102:       * 【58†Premium SupportEnterprise-grade 24/7 support】
L103: 
L104:   * 【59†Pricing】
L105: 
L106: Search`/`
L107: 
L108: 【1†Sign in】
L109: 
L110: 【60†Sign up】
L111: 
L112: Appearance settings
L113: You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session. You switched accounts on another tab or window. Reload to refresh your session. Dismiss alert
L114: 
L115:   1. 【61†googlemaps】
L116:   2. 【62†android-maps-compose】
L117: ## Repos…[truncated]</excerpt>
</source>
<source kind="search">
<title>GitHub - googlemaps/android-maps-compose: Jetpack Compose composables for the Maps SDK for Android · GitHub</title>
<location>https://github.com/googlemaps/android-maps-compose</location>
<excerpt>citeturn0search0 [wordlim: 200] Crawled: yesterday;         implementation &#39;com.google.maps.android:maps-compose:9.0.1&#39; // {x-release-please-version}        // Optionally, you can include the Compose utils library for Clustering, ... This library also provides optional composable widgets in the `maps-compose-widgets` library that you can use alongside the `GoogleMap` composable. ... If you find a bug, or have a feature request, please file an issue on GitHub.

# Maps Compose 🗺

## Description

This repository contains Jetpack Compose components for the Maps SDK for Android.

## Requirements

  * Android API level 21+
  * Kotlin-enabled project
  * Jetpack Compose-enabled project (see releases for the required version of Jetpack Compose)
  * Sign up with Google Maps Platform
  * A Google Maps Platform project with the Maps SDK for Android enabled

  * An API key associated with the project above ... follow the API key instructions if you&#39;re new to the process

## Installation

You no longer need to specify the Maps SDK for Android or its Utility Library as separate dependencies, since `maps-compose` and `maps-compose-utils` pull in the appropriate versions of these respectively.
    
    dependencies {
        implementation &#39;com.google.maps.android:maps-compose:9.0.1&#39; // {x-release-please-version}
        // Optionally, you can include the Compose utils library for Clustering,
        // Street View metadata checks, etc.
        implementation &#39;com.google.maps.android:maps-compose-utils:9.0.1&#39; // {x-release-please-version}
        // Optionally, you can include the widgets library for ScaleBar, etc.
        implementation &#39;com.google.maps.android:maps-compose-widgets:9.0.1&#39; // {x-release-please-version}
    }

## Sample App

This repository includes a sample app.

To run the demo app, ensure you&#39;ve met the requirements above then:

  1. Open the `secrets.properties` file in your top-level directory, and then add the following code. Replace YOUR_API_KEY with your API key. Store your key in this file because secrets.properties is excluded from being checked into a version control system. If the `secrets.properties` file does not exist, create it in the same folder as the `local.default.properties` file.
    
    `MAPS_API_KEY=YOUR_API_KEY
    `

  2. Build and run

## Documentation

See the documentation for a full list of classes and their methods.

## Maps Compose Utility Library

This library provides optional utilities in the `maps-compose-utils` library from the Maps SDK for Android Utility Library.

### Clustering

The marker clustering utility helps you manage multiple markers at different zoom levels. When a user views the map at a high zoom level, the individual markers show on the map. When the user zooms out, the markers gather together into clusters, to make viewing the map easier.

The MarkerClusteringActivity demonstrates usage.
    
    Clustering(
        items = items,
        // Optional: Handle clicks on clusters, cluster items, and cluster item info windows
        onClusterClick = null,
        onClusterItemClick = null,
        onClusterItemInfoWindowClick = null,
        // Optional: Custom rendering for clusters
        clusterContent = null,
        // Optional: Custom rendering for non-clustered items
        clusterItemContent = null,
    )

### Street View metadata utility

The `fetchStreetViewData` method provides functionality to check whether a location is supported in StreetView. </excerpt>
</source>
<source kind="search">
<title>android-maps-compose/CHANGELOG.md at main · googlemaps/android-maps-compose · GitHub</title>
<location>https://github.com/googlemaps/android-maps-compose/blob/main/CHANGELOG.md</location>
<excerpt>citeturn0search1 [wordlim: 200] Crawled: yesterday;   * migrate from android-maps-ktx to android-maps-utils (v9.0.0) (#1002) ...   * maps-compose: default mapColorScheme to FOLLOW_SYSTEM in GoogleMap (#986) (5f1d438) ...   * avoid zero-size crash in rememberComposeBitmapDescriptor when used inside Clustering (#963) (11a2430)

# Changelog

## 9.0.1 (2026-10-08)

### Bug Fixes

  * maps-compose: do not apply mapColorScheme to Lite mode maps (#1029) (114aaa5)

## 9.0.0 (2026-10-01)

### ⚠ BREAKING CHANGES

  * migrate from android-maps-ktx to android-maps-utils (v9.0.0) (#1002)

### Features

  * ci: publish an instrumented coverage badge (#1009) (b0d6420)
  * migrate from android-maps-ktx to android-maps-utils (v9.0.0) (#1002) (0c49e8d)

## 8.6.0 (2026-09-03)

### Features

  * expose map zoom limits (#949) (e0d4572)
  * maps-compose: default mapColorScheme to FOLLOW_SYSTEM in GoogleMap (#986) (5f1d438)

### Bug Fixes

  * guard against null in StreetView panorama change listeners (#970) (6147f75)
  * maps-compose: apply mapColorScheme to GoogleMapOptions during MapView creation (#976) (7dec2b7)
  * pin ViewTreeLifecycleOwner/SavedStateRegistryOwner on MapView to prevent info window crash (#972) (4024515)

## 8.5.0 (2026-08-17)

### Features

  * expose info window customization for advanced markers (#965) (4c65f8c)

### Bug Fixes

  * avoid re-parenting crash for MarkerInfoWindowContent/MarkerInfoWindowComposable (#953) (12f35a4)
  * avoid zero-size crash in rememberComposeBitmapDescriptor when used inside Clustering (#963) (11a2430)
  * rename InvalidatingComposeView getRotation param to avoid View shadowing (#952) (1d971cd)
</excerpt>
</source>
<source kind="search">
<title>Clustering</title>
<location>https://googlemaps.github.io/android-maps-compose/maps-compose-utils/com.google.maps.android.compose.clustering/-clustering.html</location>
<excerpt>citeturn0search2 [wordlim: 200] Crawled: last month; @MapsComposeExperimentalApifun &lt;T : ClusterItem&gt; Clustering(items: Collection&lt;T&gt;, onClusterClick: (Cluster&lt;T&gt;) -&gt; Boolean = { false }, onClusterItemClick: (T) -&gt; Boolean = { false }, onClusterItemInfoWindowClick: (T) -&gt; Unit = { }, onClusterItemInfoWindowLongClick: (T) -&gt; Unit = { }, clusterContent: @Composable (Cluster&lt;T&gt;) -&gt; Unit? ... = null, clusterContentAnchor: Offset = Offset(0.5f, 1.0f), clusterItemContentAnchor: Offset = Offset(0.5f, 1.0f), clusterContentZIndex: Float = 0.0f, clusterItemContentZIndex: Float = 0.0f, clusterContentRotation: Float = 0.0f, clusterItemContentRotation: Float = 0.0f, clusterItemDecoration: @Composable (T) -&gt; Unit = {})Groups many items on a map based on zoom level.
</excerpt>
</source>
<omitted_sources count="33">Additional source records were omitted from this bounded result.</omitted_sources>
</source_evidence>

Citations:

- 1: https://github.com/googlemaps/android-maps-compose/blob/v9.0.1/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt

Web query:

site:github.com/googlemaps/android-maps-utils/blob v6.1.0 NonHierarchicalDistanceBasedAlgorithm DEFAULT_MAX_DISTANCE_BETWEEN_CLUSTERED_ITEMS 100

Result:

<source_evidence>
<source kind="search">
<title>NonHierarchicalDistanceBasedAlgorithm.java example</title>
<location>https://www.javatips.net/api/android-maps-utils-master/library/src/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java</location>
<excerpt>citeturn2search0 [wordlim: 200] Crawled: 4 months ago;     import java.util.Map; ...     import com.google.android.gms.maps.model.LatLng; ...     public class NonHierarchicalDistanceBasedAlgorithm&lt;T extends ClusterItem&gt; implements Algorithm&lt;T&gt; {        public static final int MAX_DISTANCE_AT_ZOOM = 100; // essentially 100 dp.
    import com.google.maps.android.clustering.Cluster;
    import com.google.maps.android.clustering.ClusterItem;
    import com.google.maps.android.geometry.Bounds;
    import com.google.maps.android.geometry.Point;
    import com.google.maps.android.projection.SphericalMercatorProjection;
    import com.google.maps.android.quadtree.PointQuadTree;
    
    /**
     * A simple clustering algorithm with O(nlog n) performance. Resulting clusters are not
     * hierarchical.
     * &lt;p/&gt;
     * High level algorithm:&lt;br&gt;
     * 1. Iterate over items in the order they were added (candidate clusters).&lt;br&gt;
     * 2. Create a cluster with the center of the item. &lt;br&gt;
     * 3. Add all items that are within a certain distance to the cluster. &lt;br&gt;
     * 4. Move any items out of an existing cluster if they are closer to another cluster. &lt;br&gt;
     * 5. Remove those items from the list of candidate clusters.
     * &lt;p/&gt;
     * Clusters have the center of the first element (not the centroid of the items within it).
     */
    public class NonHierarchicalDistanceBasedAlgorithm&lt;T extends ClusterItem&gt; implements Algorithm&lt;T&gt; {
        public static final int MAX_DISTANCE_AT_ZOOM = 100; // essentially 100 dp.
    
        /**
         * Any modifications should be synchronized on mQuadTree.
         */
        private final Collection&lt;QuadItem&lt;T&gt;&gt; mItems = new ArrayList&lt;QuadItem&lt;T&gt;&gt;();
    
        /**
         * Any modifications should be synchronized on mQuadTree.
         */
        private final PointQuadTree&lt;QuadItem&lt;T&gt;&gt; mQuadTree = new PointQuadTree&lt;QuadItem&lt;T&gt;&gt;(0, 1, 0, 1);
    
        private static final SphericalMercatorProjection PROJECTION = new SphericalMercatorProjection(1);
    
        @Override
        public void addItem(T item) {
            final QuadItem&lt;T&gt; quadItem = new QuadItem&lt;T&gt;(item);
            synchronized (mQuadTree) {
                mItems.add(quadItem);
                mQuadTree.add(quadItem);
            }
        }
    
        @Override
        public void addItems(Collection&lt;T&gt; items) {
            for (T item : items) {
                addItem(item);
            }
        }
    
        @Override
        public void clearItems() {
            synchronized (mQuadTree) {
                mItems.clear();
                mQuadTree.clear();
            }
        }
    
        @Override
        public void removeItem(T item) {
            // QuadItem delegates hashcode() and equals() to its item so,
            //   removing any QuadItem to that item will remove the item
            final QuadItem&lt;T&gt; quadItem = new QuadItem&lt;T&gt;(item);
            synchronized (mQuadTree) {
                mItems.remove(quadItem);
                mQuadTree.remove(quadItem);
            }
        }
    
        @Override
        public Set&lt;? extends Cluster&lt;T&gt;&gt; getClusters(double zoom) {
            final int discreteZoom = (int) zoom;
    
            final double zoomSpecificSpan = MAX_DISTANCE_AT_ZOOM / Math.pow(2, discreteZoom) / 256;
    
            final Set&lt;QuadItem&lt;T&gt;&gt; visitedCandidates = new HashSet&lt;QuadItem&lt;T&gt;&gt;();
            final Set&lt;Cluster&lt;T&gt;&gt; results = new HashSet&lt;Cluster&lt;T&gt;&gt;();
            final Map&lt;QuadItem&lt;T&gt;, Double&gt; distanceToCluster = new HashMap&lt;QuadItem&lt;T&gt;, Double&gt;();
            final Map&lt;QuadItem&lt;T&gt;, StaticCluster&lt;T&gt;&gt; itemToCluster = new HashMap&lt;QuadItem&lt;T&gt;, StaticCluster&lt;T&gt;&gt;();
    
            synchronized…[truncated]</excerpt>
</source>
<source kind="search">
<title>android-maps-utils/CHANGELOG.md at main · googlemaps/android-maps-utils · GitHub</title>
<location>https://github.com/googlemaps/android-maps-utils/blob/main/CHANGELOG.md</location>
<excerpt>citeturn0search0 [wordlim: 200] Crawled: last month;   * restore isLayerOnMap() as abstract member of Layer (#1748) (ddc283c), closes #1746## 5.1.0 (2026-08-05) ...   * stale QuadItem removal after ClusterItem position updates (#1730) (c39ba07) ...   * rewrite android maps utils (#1658) (eeb0156)

143 lines (68 loc) · 9.11 KB

Raw

Copy raw file

Download raw file

Outline

Edit and raw actions

# Changelog

## 5.2.0 (2026-08-28)

### Features

  * clustering: make ClusterManager and its public API open with JvmSuppressWildcards (#1759) (bbb66c4)
  * collections,clustering: complete Kotlin migration for core modules and apply optimizations (#1766) (ca97025)

### Bug Fixes

  * ci: stop failing external PRs on missing coverage token (#1765) (09030dc)
  * data: prevent SSRF from untrusted KML/GeoJSON icon and GroundOverlay href (#1758) (9078c33)

## 5.1.1 (2026-08-11)

### Bug Fixes

  * restore isLayerOnMap() as abstract member of Layer (#1748) (ddc283c), closes #1746

## 5.1.0 (2026-08-05)

### Features

  * data: add support for parsing GroundOverlay with LatLonQuad bounds (#1739) (c2a165d)

### Bug Fixes

  * apply polygon styling to GeoJSON MultiPolygon features (#1727) (67d7252)
  * bound untrusted GeoJSON input to prevent parser resource exhaustion (#1733) (1e41aa4)
  * remove MultiGeometry features from the map in removeFeature (#1725) (4f796ca)
  * render legacy bridge point markers visible (#1726) (fe802a4)
  * stale QuadItem removal after ClusterItem position updates (#1730) (c39ba07)
  * visual-testing: add UiScrollable chip scroll support for robust chip selection (#1735) (6104d93)

## 5.0.0 (2026-07-15)

### ⚠ BREAKING CHANGES

  * rewrite android maps utils (#1658)

### Features

  * rewrite android maps utils (#1658) (eeb0156)

### Bug Fixes

  * prevent StackOverflowError from deeply nested GeometryCollection in GeoJsonParser (#1699) (f513807)

## 4.5.2 (2026-07-07)

### Bug Fixes

  * prevent StackOverflowError when parsing deeply nested KML containers and multi-geometries (#1710) (1463cc5)

## 4.5.1 (2026-06-17)

### Bug Fixes

  * enable filterTouchesWhenObscured on WebView layouts (#1705) (3e59470)

## 4.5.0 (2026-06-16)

### Features

  * add manual RC release workflow (#1695) (688fc83)

### Bug Fixes

  * change model name from gemini-2.5-flash to gemini-3.5-flash (#1696) (838a2bf)
  * enable filterTouchesWhenObscured on WebView (#1703) (0674d75)

## 4.4.1 (2026-05-26)

### Bug Fixes

  * address lint, compiler, and Error Prone warnings (#1692) (ce809c7)

## 4.4.0 (2026-05-20)

### Features

  * add navigation flavor to demo for SDK 7.x reproduction (#1680) (12ac29a)

## 4.3.0 (2026-04-24)

### Features

  * add constructor parameter for custom Executor in cluster renderers (#1682) (b803dc0)

## 4.2.0 (2026-04-22)

### Features

  * KML URL Sanitizer API (#1678) (43855e9)

### Bug Fixes

  * disable XML external entities in KML parser (#1673) (4516be3)
  * KMZ zip bomb mitigation by adding entry and size limits (#1677) (bae3455)
  * prevent NPE in LatLngBounds.contains by adding null checks for positions (#1675) (6b5a2d3)
  * restrict KML image downloads to http/https schemes (#1674) (898ae8f)

## 4.1.1 (2026-03-11)

### Bug Fixes

  * trigger release for PR #1661 (#1662) (8a96267)

## 4.1.0 (2026-02-20)

### Features

  * Transit Layer Demo &amp; Quality Improvements (#1653) (cb074f9)

## 4.0.0 (2026-01-23)

### ⚠ BREAKING CHANGES

  * update to maps 20.0.0 (#1644)

### Features

  * update to maps 20.0.0 (#1644) (60adcde)</excerpt>
</source>
<source kind="search">
<title>GitHub - googlemaps/android-maps-utils: Maps SDK for Android Utility Library · GitHub</title>
<location>https://github.com/googlemaps/android-maps-utils</location>
<excerpt>citeturn0search1 [wordlim: 200] Crawled: 3 weeks ago; Required for demos using Advanced Markers or Cloud-based Map Styling. ...     StreetViewUtils.fetchStreetViewData(LatLng(8.1425918, 11.5386121), BuildConfig.MAPS_API_KEY,Source.DEFAULT) ... Accordingly, while the library is in version 0.x, backwards-incompatible changes may be introduced at any time.If you find a bug, or have a feature request, please file an issue on GitHub. ... android geojson google-maps heat-maps kml maps marker-clustering utility-library
Refer to the contributing guide for more details on AI usage.

## Terms of Service

This library uses Google Maps Platform services. Use of Google Maps Platform services through this library is subject to the Google Maps Platform Terms of Service.

If your billing address is in the European Economic Area, effective on 8 July 2025, the Google Maps Platform EEA Terms of Service will apply to your use of the Services. Functionality varies by region. Learn more.

This library is not a Google Maps Platform Core Service. Therefore, the Google Maps Platform Terms of Service (e.g. Technical Support Services, Service Level Agreements, and Deprecation Policy) do not apply to the code in this library.

## Support

This library is offered via an open source license. It is not governed by the Google Maps Platform Support Technical Support Services Guidelines, the SLA, or the Deprecation Policy. However, any Google Maps Platform services used by the library remain subject to the Google Maps Platform Terms of Service.

This library adheres to semantic versioning to indicate when backwards-incompatible changes are introduced. Accordingly, while the library is in version 0.x, backwards-incompatible changes may be introduced at any time.

If you find a bug, or have a feature request, please file an issue on GitHub. If you would like to get answers to technical questions from other Google Maps Platform developers, ask through one of our developer community channels. If you&#39;d like to contribute, please check the contributing guide.

You can also discuss this library on our Discord server.

</excerpt>
</source>
<source kind="search">
<title>android-maps-utils/README.md at main · googlemaps/android-maps-utils · GitHub</title>
<location>https://github.com/googlemaps/android-maps-utils/blob/main/README.md</location>
<excerpt>citeturn0search2 [wordlim: 200] Crawled: last month;   * Heatmaps — display a large number of points as a heat map ...         implementation(&quot;com.google.maps.android:android-maps-utils-clustering:5.2.0&quot;) // x-release-please-version

Edit and raw actions

# Maps SDK for Android Utility Library

## Description

This open-source library contains utilities that are useful for a wide range of applications using the Google Maps SDK for Android.

As of version 5.0.0, the library has been rewritten in Kotlin and split into focused submodules so you can depend only on what you need. If you are upgrading from 4.x, see the migration guide.

  * Marker animation - animates a marker from one position to another
  * Marker clustering — handles the display of a large number of points
  * Marker icons — display text on your Markers
  * Heatmaps — display a large number of points as a heat map
  * Import KML — displays KML data on the map
  * Import GeoJSON — displays and styles GeoJSON data on the map
  * Polyline encoding and decoding — compact encoding for paths, interoperability with Maps API web services
  * Spherical geometry — for example: computeDistance, computeHeading, computeArea
  * Street View metadata — checks if a Street View panorama exists at a given location

You can also find Kotlin extensions for this library in Maps Android KTX.

## Requirements

  * Android API level 23+
  * Sign up with Google Maps Platform
  * A Google Maps Platform project with the Maps SDK for Android enabled

  * An API key associated with the project above ... follow the API key instructions if you&#39;re new to the process

## Installation
    
    dependencies {
        // Utilities for Maps SDK for Android (requires Google Play Services)
        // You do not need to add a separate dependency for the Maps SDK for Android
        // since this library builds in the compatible version of the Maps SDK.
        // The aggregator artifact transitively pulls in all submodules below.
        implementation(&quot;com.google.maps.android:android-maps-utils:5.2.0&quot;) // x-release-please-version
    }

Alternatively, depend only on the submodules your app needs:
    
    dependencies {
        // Base utilities: PolyUtil, SphericalUtil, collection managers, Street View metadata
        implementation(&quot;com.google.maps.android:android-maps-utils-core:5.2.0&quot;) // x-release-please-version
        // Marker clustering
        implementation(&quot;com.google.maps.android:android-maps-utils-clustering:5.2.0&quot;) // x-release-please-version
        // KML and GeoJSON import
        implementation(&quot;com.google.maps.android:android-maps-utils-data:5.2.0&quot;) // x-release-please-version
        // Heatmaps
        implementation(&quot;com.google.maps.android:android-maps-utils-heatmaps:5.2.0&quot;) // x-release-please-version
        // Marker icons and animation
        implementation(&quot;com.google.maps.android:android-maps-utils-ui:5.2.0&quot;) // x-release-please-version
    }

### Compatibility with Google Navigation SDK for Android

If your project integrates the Google Navigation SDK for Android, you must exclude the transitive `play-services-maps` dependency to prevent duplicate class conflicts:
    
    configurations.all {
        if (name.contains(&quot;navigation&quot;, ignoreCase = true)) {
            exclude(group = &quot;com.google.android.gms&quot;, module = &quot;play-services-maps&quot;)
        }
    }

</excerpt>
</source>
<source kind="search">
<title>Releases · googlemaps/android-maps-ktx · GitHub</title>
<location>https://github.com/googlemaps/android-maps-ktx/releases</location>
<excerpt>citeturn0search3 [wordlim: 200] Crawled: 2 months ago; # Releases: googlemaps/android-maps-ktx ...   * v6.1.0 ... This commit was created on GitHub.com and signed with GitHub’s verified signature.
</excerpt>
</source>
<omitted_sources count="20">Additional source records were omitted from this bounded result.</omitted_sources>
</source_evidence>

Citations:

- 1: https://www.javatips.net/api/android-maps-utils-master/library/src/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java?utm_source=openai

Web query:

github googlemaps android-maps-utils NonHierarchicalDistanceBasedAlgorithm "maxDistanceBetweenClusteredItems" source

Result:

<source_evidence>
<source kind="open_page">
<title>NonHierarchicalDistanceBasedAlgorithm (library API)</title>
<location>https://googlemaps.github.io/android-maps-utils/javadoc/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.html</location>
<excerpt>citeturn1view1 [wordlim: 200] Crawled: last month; Content type: text/html; Source: open({&quot;ref_id&quot;:&quot;turn0search0&quot;,&quot;lineno&quot;:null}); Total lines: 194
L0: JavaScript is disabled on your browser.
L1: 
L2: 【0†Skip navigation links】
L3: 
L4:   * 【1†Overview】
L5:   * 【2†Package】
L6:   * Class
L7:   * 【3†Tree】
L8:   * 【4†Deprecated】
L9:   * 【5†Index】
L10:   * 【6†Help】
L11: 
L12:   * 【7†Prev Class】
L13:   * 【8†Next Class】
L14: 
L15:   * 【9†Frames】
L16:   * 【10†No Frames】
L17: 
L18:   * 【11†All Classes】
L19: 
L20:   * Summary:
L21:   * 【12†Nested】 |
L22:   * Field |
L23:   * 【13†Constr】 |
L24:   * 【14†Method】
L25: 
L26:   * Detail:
L27:   * Field |
L28:   * 【15†Constr】 |
L29:   * 【16†Method】
L30: 
L31: com.google.maps.android.clustering.algo
L32: ## Class NonHierarchicalDistanceBasedAlgorithm&lt;T extends 【17†ClusterItem】&gt;
L33: 
L34:   * java.lang.Object
L35:   *     * 【18†com.google.maps.android.clustering.algo.AbstractAlgorithm】&lt;T&gt;
L36:     *       * com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm&lt;T&gt;
L37: 
L38:   * All Implemented Interfaces:
L39:     【19†Algorithm】&lt;T&gt;
L40: 
L41: Direct Known Subclasses:
L42:     【20†NonHierarchicalViewBasedAlgorithm】
L43: * * *
L44: 
L45:     public class NonHierarchicalDistanceBasedAlgorithm&lt;T extends 【17†ClusterItem】&gt;
L46:     extends 【18†AbstractAlgorithm】&lt;T&gt;
L47: 
L48: A simple clustering algorithm with O(nlog n) performance. Resulting clusters are not hierarchical.
L49: High level algorithm:
L50: 1. Iterate over items in the order they were added (candidate clusters).
L51: 2. Create a cluster with the center of the item.
L52: 3. Add all items that are within a certain distance to the cluster.
L53: 4. Move any items out of an existing cluster if they are closer to another cluster.
L54: 5. Remove those items from the list of candidate clusters.
L55: 
L56: Clusters have the center of the first element (not the centroid of the items within it).
L57: 
L58:   *     * ### Nested Class Summary
L59: Nested Classes
L60: Modifier and Type  | Class and Description
L61: --- | ---
L62: `protected static class `  | `【8†NonHierarchicalDistanceBasedAlgorithm.QuadItem】&lt;【8†T】 extends 【17†ClusterItem】&gt;`
L63:     * ### Constructor Summary
L64: 
L65: Constructors
L66: Constructor and Description
L67: ---
L68: `【21†NonHierarchicalDistanceBasedAlgorithm】()`
L69:     * ### Method Summary
L70: All Methods Instance Methods Concrete Methods
L71: Modifier and Type  | Method and Description
L72: --- | ---
L73: `boolean`  | `【22†addItem】(【10†T】 item)` Adds an item to the algorithm
L74: `boolean`  | `【23†addItems】(java.util.Collection&lt;【10†T】&gt; items)` Adds a collection of items to the algorithm
L75: `void`  | `【24†clearItems】()`
L76: `protected java.util.Collection&lt;【8†NonHierarchicalDistanceBasedAlgorithm.QuadItem】&lt;【10†T】&gt;&gt;`  | `【25†getClusteringItems】(【26†PointQuadTree】&lt;【8†NonHierarchicalDistanceBasedAlgorithm.QuadItem】&lt;【10†T】&gt;&gt; quadTree, float zoom)`
L77: `java.util.Set&lt;? extends 【27†Cluster】&lt;【10†T】&gt;&gt;`  | `【28†getClusters】(float zoom)`
L78: `java.util.Collection&lt;【10†T】&gt;`  | `【29†getItems】()`
L79: `int`  | `【30†getMaxDistanceBetweenClusteredItems】()`
L80: `boolean`  | `【31†removeItem】(【10†T】 item)` Removes an item from the algorithm
L81: `boolean`  | `【32†removeItems】(java.util.Collection&lt;【10†T】&gt; items)` Removes a collection of items from the algorithm
L82: `void`  | `【33†setMaxDistanceBetweenClusteredItems】(int maxDistance)`
L83: `boolean`  | `【34†updateItem】(【10†T】 item)` Updates the provided item in the algorithm
L84:       * ### Methods inherited from class com.google.maps.android.clustering.algo.【18†AbstractAlgorithm】
L85: `【35†lock】, 【36†unlock】`
L86:       * ### Methods inherited from class java.lang.Object
L87: 
L88: `clone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait`
L89: 
L90:   *     * ### Constructor Detail
L9…[truncated]</excerpt>
</source>
<source kind="search">
<title>NonHierarchicalDistanceBasedAlgorithm (library API)</title>
<location>https://googlemaps.github.io/android-maps-utils/javadoc/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.html</location>
<excerpt>citeturn0search0 [wordlim: 200] Published: 6.7 years ago; Crawled: 3 weeks ago;     *       * com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm&lt;T&gt; ... `protected java.util.Collection&lt;NonHierarchicalDistanceBasedAlgorithm.QuadItem&lt;T&gt;&gt;`  | `getClusteringItems(PointQuadTree&lt;NonHierarchicalDistanceBasedAlgorithm.QuadItem&lt;T&gt;&gt; quadTree, float zoom)` ... `int`  | `getMaxDistanceBetweenClusteredItems()`

com.google.maps.android.clustering.algo

## Class NonHierarchicalDistanceBasedAlgorithm&lt;T extends ClusterItem&gt;

  * java.lang.Object
  *     * com.google.maps.android.clustering.algo.AbstractAlgorithm&lt;T&gt;
    *       * com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm&lt;T&gt;

  * All Implemented Interfaces:
    Algorithm&lt;T&gt;

Direct Known Subclasses:
    NonHierarchicalViewBasedAlgorithm

* * *
    
    public class NonHierarchicalDistanceBasedAlgorithm&lt;T extends ClusterItem&gt;
    extends AbstractAlgorithm&lt;T&gt;

A simple clustering algorithm with O(nlog n) performance. Resulting clusters are not hierarchical.

High level algorithm:
1. Iterate over items in the order they were added (candidate clusters).
2. Create a cluster with the center of the item.
3. Add all items that are within a certain distance to the cluster.
4. Move any items out of an existing cluster if they are closer to another cluster.
5. Remove those items from the list of candidate clusters.
`java.util.Set&lt;? extends Cluster&lt;T&gt;&gt;`  | `getClusters(float zoom)` 
`java.util.Collection&lt;T&gt;`  | `getItems()` 
`int`  | `getMaxDistanceBetweenClusteredItems()` 
`boolean`  | `removeItem(T item)` Removes an item from the algorithm
`boolean`  | `removeItems(java.util.Collection&lt;T&gt; items)` Removes a collection of items from the algorithm
`void`  | `setMaxDistanceBetweenClusteredItems(int maxDistance)` 
`boolean`  | `updateItem(T item)` Updates the provided item in the algorithm

  *     * ### Constructor Detail

      * #### NonHierarchicalDistanceBasedAlgorithm
            
            public NonHierarchicalDistanceBasedAlgorithm()

    * ### Method Detail

      * #### addItem
            
            public boolean addItem(T item)

Adds an item to the algorithm

Parameters:
    `item` - the item to be added
Returns:
    true if the algorithm contents changed as a result of the call

Updates the provided item in the algorithm

Parameters:
    `item` - the item to be updated
Returns:
    true if the item existed in the algorithm and was updated, or false if the item did not exist in the algorithm and the algorithm contents remain unchanged.
      * #### getClusters
            
            public java.util.Set&lt;? extends Cluster&lt;T&gt;&gt; getClusters(float zoom)

      * #### getClusteringItems
            
            protected java.util.Collection&lt;NonHierarchicalDistanceBasedAlgorithm.QuadItem&lt;T&gt;&gt; getClusteringItems(PointQuadTree&lt;NonHierarchicalDistanceBasedAlgorithm.QuadItem&lt;T&gt;&gt; quadTree,
                                                                                                                 float zoom)

      * #### getItems
            
            public java.util.Collection&lt;T&gt; getItems()

      * #### setMaxDistanceBetweenClusteredItems
            
            public void setMaxDistanceBetweenClusteredItems(int maxDistance)

      * #### getMaxDistanceBetweenClusteredItems
            
            public int getMaxDistanceBetweenClusteredItems()</excerpt>
</source>
<source kind="search">
<title>Algorithm (library API)</title>
<location>https://googlemaps.github.io/android-maps-utils/javadoc/com/google/maps/android/clustering/algo/Algorithm.html</location>
<excerpt>citeturn0search1 [wordlim: 200] Published: 6.7 years ago; Crawled: 3 weeks ago; com.google.maps.android.clustering.algo## Interface Algorithm&lt;T extends ClusterItem&gt; ...     AbstractAlgorithm, GridBasedAlgorithm, NonHierarchicalDistanceBasedAlgorithm, NonHierarchicalViewBasedAlgorithm, PreCachingAlgorithmDecorator, ScreenBasedAlgorithmAdapter ... `int`  | `getMaxDistanceBetweenClusteredItems()`
`int`  | `getMaxDistanceBetweenClusteredItems()` 
`void`  | `lock()` 
`boolean`  | `removeItem(T item)` Removes an item from the algorithm
`boolean`  | `removeItems(java.util.Collection&lt;T&gt; items)` Removes a collection of items from the algorithm
`void`  | `setMaxDistanceBetweenClusteredItems(int maxDistance)` 
`void`  | `unlock()` 
`boolean`  | `updateItem(T item)` Updates the provided item in the algorithm

  *     * ### Method Detail

      * #### addItem
            
            boolean addItem(T item)

Adds an item to the algorithm

Parameters:
    `item` - the item to be added
Returns:
    true if the algorithm contents changed as a result of the call
      * #### addItems
            
            boolean addItems(java.util.Collection&lt;T&gt; items)

Adds a collection of items to the algorithm

Parameters:
    `items` - the items to be added
Returns:
    true if the algorithm contents changed as a result of the call
</excerpt>
</source>
<source kind="search">
<title>NonHierarchicalViewBasedAlgorithm</title>
<location>https://googlemaps.github.io/android-maps-utils/android-maps-utils%20/com.google.maps.android.clustering.algo/-non-hierarchical-view-based-algorithm/index.html</location>
<excerpt>citeturn0search2 [wordlim: 200] Crawled: 3 months ago; open class NonHierarchicalViewBasedAlgorithm&lt;T : ClusterItem? ... It requires shouldReclusterOnMapMovement to be true in order to re-render clustering when camera movement changes the visible area. ... open fun getMaxDistanceBetweenClusteredItems(): Int
</excerpt>
</source>
<source kind="search">
<title>GitHub - googlemaps/android-maps-utils: Maps SDK for Android Utility Library · GitHub</title>
<location>https://github.com/googlemaps/android-maps-utils</location>
<excerpt>citeturn0search3 [wordlim: 200] Crawled: 3 weeks ago; Required for demos using Advanced Markers or Cloud-based Map Styling. ...   * Spherical geometry source, compute distance sample ...             android:name=&quot;com.google.maps.android.utils.attribution.AttributionIdInitializer&quot; ... If you find a bug, or have a feature request, please file an issue on GitHub. ... android geojson google-maps heat-maps kml maps marker-clustering utility-library
</excerpt>
</source>
<omitted_sources count="15">Additional source records were omitted from this bounded result.</omitted_sources>
</source_evidence>

Citations:

- 1: https://googlemaps.github.io/android-maps-utils/javadoc/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.html

Script executed:

set -o pipefail
base="${HOME}/.gradle/caches/modules-2/files-2.1/com.google.maps.android"
printf '%s\n' '--- cached maps-utils artifacts ---'
find "$base" -maxdepth 4 -type f \( -name '*android-maps-utils*6.1.0*.jar' -o -name '*android-maps-utils*6.1.0*.aar' -o -name '*android-maps-utils*6.1.0*.pom' -o -name '*android-maps-utils*6.1.0*.module' \) -print 2>/dev/null
printf '%s\n' '--- cached clustering source/class candidates ---'
find "$base" -type f \( -name '*sources.jar' -o -name '*.jar' -o -name '*.aar' \) -print 2>/dev/null | grep -E 'android-maps-utils|maps-compose' | head -80

Repository: meshtastic/Meshtastic-Android

Length of output: 253


Script executed:

set -o pipefail
printf '%s\n' '--- stackCoincident definition and callers ---'
rg -n -F -e 'fun stackCoincident' -e 'stackCoincident(' feature androidApp
printf '%s\n' '--- implementation ---'
file=$(rg -l -F 'fun stackCoincident' feature androidApp | head -1)
if [ -n "$file" ]; then
  nl -ba "$file"
fi
printf '%s\n' '--- stack tests ---'
rg -n -F -e 'stackCoincident' -e '5 m' -e '5m' feature/*/src/*Test androidApp/src 2>/dev/null || true

Repository: meshtastic/Meshtastic-Android

Length of output: 44003


Ensure a cluster tap can split distinct stack tops.

A cluster containing at least 10 distinct stack tops can remain clustered after the 140 m minimum fit. The distance-based algorithm groups items by a zoom-dependent screen distance, and a 140 m geographic floor does not guarantee that a 6 m separation exceeds that distance. A second tap then recomputes the same bounds because allSameLocation is false, so the cluster does not open its member list. Fit closer, or open the member list when a cluster remains unchanged after fitting.

coroutineScope.launch {
cameraPositionState.animate(
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder()
.target(bounds.build().center)
.zoom(cameraPositionState.position.zoom + 1)
.build(),
),
)
cameraPositionState.animate(CameraUpdateFactory.newLatLngBounds(fitted, clusterFitPaddingPx))
}
Logger.d { "Cluster clicked! $cluster" }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,18 @@
*/
package org.meshtastic.app.map.component

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import com.google.maps.android.clustering.Cluster
import com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm
import com.google.maps.android.clustering.algo.NonHierarchicalViewBasedAlgorithm
Expand All @@ -30,9 +39,37 @@ import com.google.maps.android.compose.clustering.Clustering
import com.google.maps.android.compose.clustering.ClusteringMarkerProperties
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.feature.map.stackBadgeLabel

private const val MIN_CLUSTER_SIZE = 10

// Matches the MapLibre map's precision fill, so both maps shade a degraded position alike.
private const val PRECISION_FILL_ALPHA = 0.15f

// BadgedBox draws the badge past its anchor's top end corner, and a marker is rasterized at its measured size, so the
// marker is padded by this much above and to the sides to keep the badge unclipped. Never below: the marker anchors on
// its bottom edge, and bottom padding would lift every chip off its point.
private val STACK_BADGE_INSET = 12.dp

/** A node's chip, with a Material 3 count badge when it stands for several nodes at one spot. */
@Composable
private fun StackedNodeChip(item: NodeClusterItem, modifier: Modifier = Modifier) {
Box(modifier.padding(start = STACK_BADGE_INSET, top = STACK_BADGE_INSET, end = STACK_BADGE_INSET)) {
BadgedBox(
badge = {
if (item.stackMembers.size > 1) {
// Secondary rather than the default error color: a count of nodes is not an alert.
Badge(containerColor = MaterialTheme.colorScheme.secondary) {
Text(stackBadgeLabel(item.stackMembers.size))
}
}
},
) {
PulsingNodeChip(node = item.node)
}
}
}

/**
* Threshold for switching to [NonHierarchicalViewBasedAlgorithm], matching that class's own KDoc describing itself as
* being for "large numbers of items (>1000 markers)".
Expand All @@ -42,10 +79,9 @@ private const val VIEW_BASED_ALGORITHM_NODE_THRESHOLD = 1000
/**
* Renders node markers with clustering via the library's [Clustering] composable.
*
* Each unclustered node is composed as a [PulsingNodeChip] (`clusterItemContent`), with its z-index forwarded through
* [ClusteringMarkerProperties]; cluster bubbles keep the library's default rendering. Native info windows
* (title/snippet from [NodeClusterItem]) and click interactions are handled by the library renderer, and precision
* circles are drawn for the currently-unclustered items via `clusterItemDecoration`.
* Each unclustered item is composed as a [PulsingNodeChip] (`clusterItemContent`), badged with a count when it stands
* for several nodes at one spot, with its z-index forwarded through [ClusteringMarkerProperties]; cluster bubbles keep
* the library's default rendering. Precision circles are drawn for every item, clustered or not.
*
* Requires maps-compose >= 8.4.0: earlier versions composed cluster items into a detached `ComposeView` that carried no
* lifecycle/saved-state owners, crashing under the Navigation 3 hierarchy (fixed upstream in
Expand All @@ -65,31 +101,39 @@ fun NodeClusterMarkers(
mapFilterState: BaseMapViewModel.MapFilterState,
navigateToNodeDetails: (Int) -> Unit,
onClusterClick: (Cluster<NodeClusterItem>) -> Boolean,
onStackClick: (List<NodeClusterItem>) -> Unit,
) {
val configuration = LocalConfiguration.current
// One circle per stack, drawn whether or not the stack sits inside a cluster bubble, as the Apple and MapLibre maps
// do: the circle says where the nodes could be, which a count bubble does not.
if (mapFilterState.showPrecisionCircle) {
nodeClusterItems.forEach { item ->
val precisionMeters = item.getPrecisionMeters()
if (precisionMeters != null && precisionMeters > 0) {
key(item.node.num) {
Circle(
center = item.position,
radius = precisionMeters,
fillColor = Color(item.node.colors.second).copy(alpha = PRECISION_FILL_ALPHA),
strokeColor = Color(item.node.colors.second),
strokeWidth = 2f,
zIndex = 0f,
)
}
}
}
}
Clustering(
items = nodeClusterItems,
onClusterClick = onClusterClick,
onClusterItemInfoWindowClick = { item -> navigateToNodeDetails(item.node.num) },
// A tap goes where the MapLibre map's does: a stack lists its nodes, a single node opens its details.
onClusterItemClick = { item ->
if (item.stackMembers.size > 1) onStackClick(item.stackMembers) else navigateToNodeDetails(item.node.num)
true
},
clusterItemContent = { item ->
ClusteringMarkerProperties(zIndex = item.zIndex)
PulsingNodeChip(node = item.node)
},
clusterItemDecoration = { item ->
if (mapFilterState.showPrecisionCircle) {
item.getPrecisionMeters()?.let { precisionMeters ->
if (precisionMeters > 0) {
Circle(
center = item.position,
radius = precisionMeters,
fillColor = Color(item.node.colors.second).copy(alpha = 0.2f),
strokeColor = Color(item.node.colors.second),
strokeWidth = 2f,
zIndex = 0f,
)
}
}
}
StackedNodeChip(item)
},
onClusterManager = { clusterManager ->
// The library renderer extends DefaultClusterRenderer; raise its clustering threshold once.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ data class NodeClusterItem(
val nodeTitle: String,
val nodeSnippet: String,
val myNodeNum: Int? = null,
/** Every node at this spot, this one included, when more than one is; empty otherwise. */
val stackMembers: List<NodeClusterItem> = emptyList(),
) : ClusterItem {
override val position: LatLng
get() = nodePosition
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,25 @@ class MapNodeClusterItemsTest {
assertNotSame(changedNodeItems, latestItems)
}

@Test
fun `nodes at one spot become one item that carries every member`() = runComposeUiTest {
val spot = testNodes(count = 1).single().position
val nodes = testNodes(count = 4).mapIndexed { i, node -> if (i < 3) node.copy(position = spot) else node }
var latestItems: List<NodeClusterItem> = emptyList()

setContent {
val items = rememberNodeClusterItems(nodes = nodes, myNodeNum = 2, relativeTimeBucket = FIXED_TIME_BUCKET)
SideEffect { latestItems = items }
}
waitForIdle()

assertEquals(2, latestItems.size)
val stack = latestItems.first()
assertEquals(2, stack.node.num)
assertEquals(listOf(1, 2, 3), stack.stackMembers.map { it.node.num })
assertTrue(latestItems.last().stackMembers.isEmpty())
}

@Test
fun `relative time bucket advances at the next minute boundary`() = runTest {
val startSeconds = FIXED_TIME_BUCKET * 60 + 30
Expand Down Expand Up @@ -144,8 +163,9 @@ class MapNodeClusterItemsTest {
position =
Position.Builder()
.also { wb ->
wb.latitude_i = 210_000_000 + index
wb.longitude_i = -1_570_000_000 + index
// About 11 m apart, so no two fall within the 5 m that stacks nodes.
wb.latitude_i = 210_000_000 + index * 1_000
wb.longitude_i = -1_570_000_000 + index * 1_000
wb.time = 1_700_000_000
}
.build(),
Expand Down
6 changes: 4 additions & 2 deletions docs/en/user/map-and-waypoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Map & Waypoints
parent: User Guide
nav_order: 6
last_updated: 2026-10-21
last_updated: 2026-10-24
description: View node positions on the map, create and share waypoints, manage map layers and Site Planner, and control position sharing and privacy.
aliases:
- map
Expand All @@ -28,7 +28,9 @@ The map displays:

### Node Markers

Each node that reports a position is shown as a **node chip** marker displaying the node's short name. The chip is colored by the node's own identity color (a stable color derived from its node number) — the same chip used in the node list, so a node looks the same everywhere. Marker color does **not** encode online/offline status. When a node's position updates live, its marker briefly pulses. Nearby markers are clustered as you zoom out. Tapping a cluster zooms in on it, and a cluster whose nodes all share one spot lists them instead.
Each node that reports a position is shown as a **node chip** marker displaying the node's short name. The chip is colored by the node's own identity color (a stable color derived from its node number) — the same chip used in the node list, so a node looks the same everywhere. Marker color does **not** encode online/offline status. When a node's position updates live, its marker briefly pulses. Tapping a chip opens that node's details.

Nodes within 5 m of each other, which is common when they share a reduced position precision or sit at one site, are drawn as one chip with a count on its corner, showing a favorite or your own node when one is there. Tapping it lists every node at that spot, favorites first. Nearby markers are clustered as you zoom out. Tapping a cluster zooms in to fit its nodes, and a cluster whose nodes all share one spot lists them instead. With **Show Precision Circles** on, each spot's circle stays visible inside a cluster too.

### Map Controls

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,55 +39,16 @@ private fun MapBounds.toBoundingBox(): BoundingBox = BoundingBox(
)

/**
* The nodes clustering will leave standing on their own at [zoom].
* [nodes] ordered by how alone each one is: fewest neighbours within [radiusPx] first.
*
* Both the chips and the precision circles are only wanted for these. Clustering decides the split inside the source at
* render time, and there is no API that reports it back, so this applies the same rule supercluster does: a point joins
* a cluster when at least [minPoints] points fall within [radiusPx] of each other in tile pixels. Its own indexes are
* built per whole zoom level, which is why the caller should pass a floored zoom — it also keeps this off the recompose
* path for the fractional zoom of a pinch.
* This is the order to rasterize chips in. Clustering decides inside the source which nodes stand alone and there is no
* API that reports it back, but a node the source draws on its own has, by definition, few neighbours, so the ones that
* need a chip most sort to the front and the ones just past the cluster threshold come next rather than last. That
* makes the image budget run out on nodes buried mid-cluster, which are never drawn anyway.
*
* Distances are measured in Web Mercator world pixels, the space clustering itself works in, rather than in degrees — a
* degree of longitude is a very different distance at the top of the map than at the equator.
*/
fun unclusteredNodes(nodes: List<Node>, zoom: Int, radiusPx: Int, minPoints: Int, maxClusterZoom: Int): List<Node> {
// Past its ceiling the source stops clustering entirely, so everything stands alone.
if (zoom > maxClusterZoom || nodes.size < minPoints) return nodes

val worldSize = TILE_SIZE * 2.0.pow(zoom)
val pixels = nodes.map { it.worldPixel(worldSize) }
val cells = mutableMapOf<Pair<Int, Int>, MutableList<Int>>()
pixels.forEachIndexed { index, pixel -> cells.getOrPut(pixel.cell(radiusPx)) { mutableListOf() }.add(index) }

// Supercluster assigns greedily rather than counting neighbours: it walks the points, and the first one to claim a
// neighbourhood takes it. Counting instead called a node clustered whenever enough others were nearby, even where
// those others had already been taken by someone else and the node would in fact be drawn on its own — which left
// it with a bare dot and no chip.
val taken = BooleanArray(nodes.size)
val alone = mutableListOf<Node>()
pixels.indices.forEach { index ->
if (taken[index]) return@forEach
val neighbours = neighboursWithin(index, pixels, cells, radiusPx, taken)
if (neighbours.size >= minPoints) {
neighbours.forEach { taken[it] = true }
} else {
// Marked taken too: supercluster excludes every visited point from later neighbourhoods, clustered
// or not — a node already standing alone must not pad a later cluster up to the minimum.
taken[index] = true
alone += nodes[index]
}
}
return alone
}

/**
* [nodes] ordered by how alone each one is: fewest neighbours within [radiusPx] first.
*
* This is the order to rasterize chips in. A node the source draws on its own has, by definition, few neighbours, so
* the ones that need a chip most sort to the front — and the ones just past the cluster threshold, where
* [unclusteredNodes] is most likely to have guessed wrong, come next rather than last. That makes the image budget run
* out on nodes buried mid-cluster, which are never drawn anyway.
*/
fun nodesByIsolation(nodes: List<Node>, zoom: Int, radiusPx: Int): List<Node> {
val worldSize = TILE_SIZE * 2.0.pow(zoom)
val pixels = nodes.map { it.worldPixel(worldSize) }
Expand Down
Loading