From 93faf84d32338fd9ca990f046af6112817255323 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 4 Aug 2026 23:26:06 +0200 Subject: [PATCH 1/2] set clustered features to the map only while they are drawn --- .../ui/map/gms/features/FeatureManager.kt | 29 ++++-- .../ui/map/gms/features/MapsItemManager.kt | 3 + .../ui/map/gms/features/FeatureManagerTest.kt | 95 +++++++++++++++++++ .../map/gms/features/MapsItemManagerTest.kt | 26 +++++ 4 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureManagerTest.kt diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt index 4397e979b9..3a1e3a957e 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt @@ -70,8 +70,8 @@ constructor( mapsItemManager = MapsItemManager(map, pointRenderer, polygonRenderer, lineStringRenderer) clusterManager = FeatureClusterManager(context, map, createMarkerManager(map)) clusterRenderer = FeatureClusterRenderer(context, map, clusterManager, map.cameraPosition.zoom) - clusterRenderer.onClusterItemRendered = { mapsItemManager.setVisible(it, true) } - clusterRenderer.onClusterRendered = { mapsItemManager.setVisible(it, false) } + clusterRenderer.onClusterItemRendered = { showClusterableItem(it) } + clusterRenderer.onClusterRendered = { hideClusterableItem(it) } clusterManager.renderer = clusterRenderer this.map = map } @@ -124,17 +124,32 @@ constructor( mapsItemManager.getIntersectingPolygonTags(latLng).mapNotNull { featuresByTag[it] }.toSet() /** - * Adds a feature to the map, cluster, and to this class' internal index. Clusterable features are - * initialized as hidden so that the clusterer can determine whether they should be shown based on - * zoom level. + * Adds a feature to the cluster and to this class' internal index. Clusterable feature map items + * are created only when drawn individually to reduce heap pressure. */ private fun add(feature: Feature) = with(feature) { features.add(this) featuresByTag[tag] = this - if (clusterable) clusterManager.addFeature(this) - mapsItemManager.put(this, visible = !clusterable) + if (clusterable) { + clusterManager.addFeature(this) + } else { + mapsItemManager.put(this, visible = true) + } + } + + /** Draws a clustered feature individually, creating its map item if it doesn't have one yet. */ + private fun showClusterableItem(tag: Feature.Tag) { + if (mapsItemManager.contains(tag)) { + mapsItemManager.setVisible(tag, true) + } else { + featuresByTag[tag]?.let { mapsItemManager.put(it, visible = true) } } + } + + private fun hideClusterableItem(tag: Feature.Tag) { + mapsItemManager.remove(tag) + } private fun remove(feature: Feature) = with(feature) { diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/MapsItemManager.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/MapsItemManager.kt index c0687139e8..b143dd866c 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/MapsItemManager.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/MapsItemManager.kt @@ -61,6 +61,9 @@ class MapsItemManager( } } + /** Returns whether map items are currently allocated for the specified feature's tag. */ + fun contains(tag: Feature.Tag): Boolean = itemsByTag.containsKey(tag) + /** Removes map items associated with the specified feature's tag. */ fun remove(tag: Feature.Tag) = itemsByTag.remove(tag)?.forEach { diff --git a/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureManagerTest.kt b/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureManagerTest.kt new file mode 100644 index 0000000000..bc21700395 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureManagerTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.map.gms.features + +import androidx.test.core.app.ApplicationProvider +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Polygon as MapsPolygon +import kotlinx.coroutines.test.TestScope +import org.groundplatform.android.ui.map.Feature +import org.groundplatform.domain.model.geometry.Coordinates +import org.groundplatform.domain.model.geometry.LinearRing +import org.groundplatform.domain.model.geometry.Polygon +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FeatureManagerTest { + private val map: GoogleMap = mock() + private val pointRenderer: PointRenderer = mock() + private val polygonRenderer: PolygonRenderer = mock() + private val lineStringRenderer: LineStringRenderer = mock() + + private lateinit var featureManager: FeatureManager + + @Before + fun setUp() { + whenever(map.cameraPosition).thenReturn(CameraPosition(LatLng(0.0, 0.0), 10f, 0f, 0f)) + whenever(polygonRenderer.add(any(), any(), any(), any(), any(), any(), anyOrNull())) + .thenReturn(mock()) + featureManager = + FeatureManager( + ApplicationProvider.getApplicationContext(), + TestScope(), + pointRenderer, + polygonRenderer, + lineStringRenderer, + ) + featureManager.onMapReady(map) + } + + @Test + fun `does not render clusterable features to the map right when they are added`() { + featureManager.setFeatures(listOf(clusterableFeature("a"), clusterableFeature("b"))) + + verify(polygonRenderer, never()).add(any(), any(), any(), any(), any(), any(), anyOrNull()) + } + + @Test + fun `renders non-clusterable features to the map as soon as they are added`() { + featureManager.setFeatures(listOf(clusterableFeature("a").copy(clusterable = false))) + + verify(polygonRenderer).add(any(), any(), any(), any(), any(), any(), anyOrNull()) + } + + private fun clusterableFeature(id: String) = + Feature( + tag = Feature.Tag(id, Feature.Type.LOCATION_OF_INTEREST), + geometry = + Polygon( + LinearRing( + listOf( + Coordinates(0.0, 0.0), + Coordinates(0.0, 1.0), + Coordinates(1.0, 1.0), + Coordinates(0.0, 0.0), + ) + ) + ), + style = Feature.Style(0), + clusterable = true, + ) +} diff --git a/app/src/test/java/org/groundplatform/android/ui/map/gms/features/MapsItemManagerTest.kt b/app/src/test/java/org/groundplatform/android/ui/map/gms/features/MapsItemManagerTest.kt index a28e6355e5..b79c12f52a 100644 --- a/app/src/test/java/org/groundplatform/android/ui/map/gms/features/MapsItemManagerTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/map/gms/features/MapsItemManagerTest.kt @@ -82,6 +82,32 @@ class MapsItemManagerTest { assertThat(mapsItemManager.update(TEST_POINT_FEATURE)).isFalse() } + @Test + fun `contains() reports whether a feature currently holds map items`() { + whenever(pointRenderer.add(any(), any(), any(), any(), any(), any(), anyOrNull())) + .thenReturn(mock()) + + assertThat(mapsItemManager.contains(TEST_POINT_FEATURE.tag)).isFalse() + + mapsItemManager.put(TEST_POINT_FEATURE, visible = true) + assertThat(mapsItemManager.contains(TEST_POINT_FEATURE.tag)).isTrue() + + mapsItemManager.remove(TEST_POINT_FEATURE.tag) + assertThat(mapsItemManager.contains(TEST_POINT_FEATURE.tag)).isFalse() + } + + @Test + fun `remove() releases the underlying map item so it stops consuming memory`() { + val marker = mock() + whenever(pointRenderer.add(any(), any(), any(), any(), any(), any(), anyOrNull())) + .thenReturn(marker) + mapsItemManager.put(TEST_POINT_FEATURE, visible = true) + + mapsItemManager.remove(TEST_POINT_FEATURE.tag) + + verify(marker).remove() + } + private companion object { val TEST_LINE_STRING_FEATURE = Feature( From 16aeee9dd372eb8129bdeba7ccd3f6846e370e06 Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 5 Aug 2026 18:55:35 +0200 Subject: [PATCH 2/2] set NonHierarchicalViewBasedAlgorithm to optimize rendering so that only features in the viewport are drawn --- .../ui/map/gms/features/FeatureManager.kt | 15 +++- .../ui/map/gms/features/FeatureManagerTest.kt | 86 ++++++++++++++++++- 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt index 3a1e3a957e..52e8f4ec07 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt @@ -17,9 +17,11 @@ package org.groundplatform.android.ui.map.gms.features import android.content.Context +import androidx.annotation.VisibleForTesting import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.Marker +import com.google.maps.android.clustering.algo.NonHierarchicalViewBasedAlgorithm import com.google.maps.android.collections.MarkerManager import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -49,8 +51,8 @@ constructor( private lateinit var map: GoogleMap private lateinit var mapsItemManager: MapsItemManager - private lateinit var clusterManager: FeatureClusterManager - private lateinit var clusterRenderer: FeatureClusterRenderer + @VisibleForTesting internal lateinit var clusterManager: FeatureClusterManager + @VisibleForTesting internal lateinit var clusterRenderer: FeatureClusterRenderer private val _markerClicks: MutableSharedFlow = MutableSharedFlow() val markerClicks = _markerClicks.asSharedFlow() @@ -69,6 +71,15 @@ constructor( featuresByTag.clear() mapsItemManager = MapsItemManager(map, pointRenderer, polygonRenderer, lineStringRenderer) clusterManager = FeatureClusterManager(context, map, createMarkerManager(map)) + // Render only visible features; off-screen clusterable features are omitted + clusterManager.setAlgorithm( + with(context.resources.displayMetrics) { + NonHierarchicalViewBasedAlgorithm( + (widthPixels / density).toInt(), + (heightPixels / density).toInt(), + ) + } + ) clusterRenderer = FeatureClusterRenderer(context, map, clusterManager, map.cameraPosition.zoom) clusterRenderer.onClusterItemRendered = { showClusterableItem(it) } clusterRenderer.onClusterRendered = { hideClusterableItem(it) } diff --git a/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureManagerTest.kt b/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureManagerTest.kt index bc21700395..be4f8a116d 100644 --- a/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureManagerTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/map/gms/features/FeatureManagerTest.kt @@ -20,6 +20,8 @@ import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.Polygon as MapsPolygon +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.clustering.algo.NonHierarchicalViewBasedAlgorithm import kotlinx.coroutines.test.TestScope import org.groundplatform.android.ui.map.Feature import org.groundplatform.domain.model.geometry.Coordinates @@ -30,6 +32,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -42,14 +45,21 @@ class FeatureManagerTest { private val pointRenderer: PointRenderer = mock() private val polygonRenderer: PolygonRenderer = mock() private val lineStringRenderer: LineStringRenderer = mock() + private val mapsPolygon: MapsPolygon = mock() private lateinit var featureManager: FeatureManager + @Suppress("UNCHECKED_CAST") + private val clusterAlgorithm + get() = + featureManager.clusterManager.algorithm + as NonHierarchicalViewBasedAlgorithm + @Before fun setUp() { whenever(map.cameraPosition).thenReturn(CameraPosition(LatLng(0.0, 0.0), 10f, 0f, 0f)) whenever(polygonRenderer.add(any(), any(), any(), any(), any(), any(), anyOrNull())) - .thenReturn(mock()) + .thenReturn(mapsPolygon) featureManager = FeatureManager( ApplicationProvider.getApplicationContext(), @@ -75,6 +85,65 @@ class FeatureManagerTest { verify(polygonRenderer).add(any(), any(), any(), any(), any(), any(), anyOrNull()) } + @Test + fun `only features within the visible area reach the renderer`() { + featureManager.setFeatures( + listOf( + clusterableFeature("near"), + clusterableFeature("far").copy(geometry = FAR_FROM_THE_DEFAULT_POSITION), + ) + ) + + val reachingRenderer = clusterAlgorithm.getClusters(ZOOM).flatMap { it.items } + + assertThat(reachingRenderer.map { it.feature.tag.id }).containsExactly("near") + } + + @Test + fun `features reach the renderer as they come into view`() { + featureManager.setFeatures( + listOf( + clusterableFeature("near"), + clusterableFeature("far").copy(geometry = FAR_FROM_THE_DEFAULT_POSITION), + ) + ) + + clusterAlgorithm.onCameraChange(CameraPosition(LatLng(60.0, 60.0), ZOOM, 0f, 0f)) + val reachingRenderer = clusterAlgorithm.getClusters(ZOOM).flatMap { it.items } + + assertThat(reachingRenderer.map { it.feature.tag.id }).containsExactly("far") + } + + @Test + fun `draws a feature individually when the renderer reports it as unclustered`() { + val feature = clusterableFeature("a") + featureManager.setFeatures(listOf(feature)) + + featureManager.clusterRenderer.onClusterItemRendered(feature.tag) + + verify(polygonRenderer) + .add( + map = any(), + tag = eq(feature.tag), + geometry = any(), + style = any(), + selected = any(), + visible = any(), + tooltipText = anyOrNull(), + ) + } + + @Test + fun `releases an individually drawn feature's map item when it becomes clustered`() { + val feature = clusterableFeature("a") + featureManager.setFeatures(listOf(feature)) + featureManager.clusterRenderer.onClusterItemRendered(feature.tag) + + featureManager.clusterRenderer.onClusterRendered(feature.tag) + + verify(mapsPolygon).remove() + } + private fun clusterableFeature(id: String) = Feature( tag = Feature.Tag(id, Feature.Type.LOCATION_OF_INTEREST), @@ -92,4 +161,19 @@ class FeatureManagerTest { style = Feature.Style(0), clusterable = true, ) + + private companion object { + const val ZOOM = 5f + val FAR_FROM_THE_DEFAULT_POSITION = + Polygon( + LinearRing( + listOf( + Coordinates(60.0, 60.0), + Coordinates(60.0, 61.0), + Coordinates(61.0, 61.0), + Coordinates(60.0, 60.0), + ) + ) + ) + } }