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
37 changes: 30 additions & 7 deletions app/src/main/java/org/groundplatform/android/ui/IconFactory.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import android.graphics.PorterDuff
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.os.Build
import android.util.LruCache
import androidx.appcompat.content.res.AppCompatResources
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.BitmapDescriptorFactory
Expand All @@ -36,6 +37,24 @@ import org.groundplatform.android.ui.util.obtainTextPaintFromStyle
/** Responsible for building dynamically generated icon bitmaps. */
@Singleton
class IconFactory @Inject constructor(@ApplicationContext private val context: Context) {

private val markerIcons =
// Bitmap cache keyed by job color and scale.
object : LruCache<Pair<Int, Float>, BitmapDescriptor>(DEFAULT_ICON_CACHE_SIZE) {
override fun create(key: Pair<Int, Float>): BitmapDescriptor =
BitmapDescriptorFactory.fromBitmap(getMarkerBitmap(key.first, key.second))
}

// Bitmap cache keyed by cluster label. Resized to fit the visible clusters once the map is ready.
private val clusterIcons =
object : LruCache<String, BitmapDescriptor>(DEFAULT_ICON_CACHE_SIZE) {
override fun create(key: String): BitmapDescriptor = createClusterIcon(key)
}

fun setClusterIconCacheSize(maxVisibleClusters: Int) {
clusterIcons.resize(maxVisibleClusters.coerceAtLeast(1))
}

/** Create a scaled bitmap based on the dimensions of a given [Drawable]. */
private fun createBitmap(drawable: Drawable, scale: Float = 1f): Bitmap {
val width = (drawable.intrinsicWidth * scale).toInt()
Expand Down Expand Up @@ -67,14 +86,13 @@ class IconFactory @Inject constructor(@ApplicationContext private val context: C
return bitmap
}

/** Returns a [BitmapDescriptor] for representing an individual marker on the map. */
fun getMarkerIcon(color: Int, scale: Float): BitmapDescriptor {
val bitmap = getMarkerBitmap(color, scale)
return BitmapDescriptorFactory.fromBitmap(bitmap)
}
/** Returns a cached [BitmapDescriptor] representing an individual marker on the map. */
fun getMarkerIcon(color: Int, scale: Float): BitmapDescriptor = markerIcons.get(color to scale)

/** Returns a [BitmapDescriptor] for representing a marker cluster on the map. */
fun getClusterIcon(text: String): BitmapDescriptor {
/** Returns a cached [BitmapDescriptor] representing a marker cluster on the map. */
fun getClusterIcon(text: String): BitmapDescriptor = clusterIcons.get(text)

private fun createClusterIcon(text: String): BitmapDescriptor {
val fill = AppCompatResources.getDrawable(context, R.drawable.cluster_marker)
val bitmap = createBitmap(fill!!)
val canvas = Canvas(bitmap)
Expand All @@ -95,4 +113,9 @@ class IconFactory @Inject constructor(@ApplicationContext private val context: C

return BitmapDescriptorFactory.fromBitmap(bitmap)
}

companion object {
/** Default capacity of the icon caches, sufficient to cover most combinations. */
private const val DEFAULT_ICON_CACHE_SIZE = 16
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class FeatureClusterRenderer(
map: GoogleMap,
clusterManager: ClusterManager<FeatureClusterItem>,
private var zoom: Float,
private val iconFactory: IconFactory,
) : DefaultClusterRenderer<FeatureClusterItem>(context, map, clusterManager) {
/**
* Called when the cluster balloon is shown so that implementations can unhide related map items.
Expand All @@ -52,8 +53,6 @@ class FeatureClusterRenderer(
*/
lateinit var onClusterItemRendered: (Feature.Tag) -> Unit

private val markerIconFactory: IconFactory = IconFactory(context)

private var oldZoom = zoom

fun setZoom(newZoom: Float) {
Expand All @@ -77,7 +76,7 @@ class FeatureClusterRenderer(
private fun createClusterIcon(cluster: Cluster<FeatureClusterItem>): BitmapDescriptor {
val itemsWithFlag = cluster.items.count { it.feature.flag }
val totalItems = cluster.items.size
return markerIconFactory.getClusterIcon("$itemsWithFlag/$totalItems")
return iconFactory.getClusterIcon("$itemsWithFlag/$totalItems")
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import org.groundplatform.android.di.coroutines.MainScope
import org.groundplatform.android.ui.IconFactory
import org.groundplatform.android.ui.map.Feature
import timber.log.Timber

Expand All @@ -45,6 +46,7 @@ constructor(
private val pointRenderer: PointRenderer,
private val polygonRenderer: PolygonRenderer,
private val lineStringRenderer: LineStringRenderer,
private val iconFactory: IconFactory,
) {
private val features = mutableSetOf<Feature>()
private val featuresByTag = mutableMapOf<Feature.Tag, Feature>()
Expand Down Expand Up @@ -72,15 +74,17 @@ constructor(
mapsItemManager = MapsItemManager(map, pointRenderer, polygonRenderer, lineStringRenderer)
clusterManager = FeatureClusterManager(context, map, createMarkerManager(map))
// Render only visible features; off-screen clusterable features are omitted
clusterManager.setAlgorithm(
val algorithm =
with(context.resources.displayMetrics) {
NonHierarchicalViewBasedAlgorithm(
NonHierarchicalViewBasedAlgorithm<FeatureClusterItem>(
(widthPixels / density).toInt(),
(heightPixels / density).toInt(),
)
}
)
clusterRenderer = FeatureClusterRenderer(context, map, clusterManager, map.cameraPosition.zoom)
clusterManager.setAlgorithm(algorithm)
iconFactory.setClusterIconCacheSize(maxVisibleClusters(algorithm))
clusterRenderer =
FeatureClusterRenderer(context, map, clusterManager, map.cameraPosition.zoom, iconFactory)
clusterRenderer.onClusterItemRendered = { showClusterableItem(it) }
clusterRenderer.onClusterRendered = { hideClusterableItem(it) }
clusterManager.renderer = clusterRenderer
Expand Down Expand Up @@ -182,4 +186,13 @@ constructor(
fun onCameraIdle() {
clusterManager.onCameraIdle()
}

@VisibleForTesting
fun maxVisibleClusters(algorithm: NonHierarchicalViewBasedAlgorithm<*>): Int =
with(context.resources.displayMetrics) {
val spacingDp = algorithm.maxDistanceBetweenClusteredItems
val columns = widthPixels / density / spacingDp
val rows = heightPixels / density / spacingDp
(columns * rows).toInt()
}
}
53 changes: 53 additions & 0 deletions app/src/test/java/org/groundplatform/android/ui/IconFactoryTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,21 @@ import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import androidx.appcompat.content.res.AppCompatResources.getDrawable
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.BitmapDescriptorFactory
import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidTest
import javax.inject.Inject
import org.groundplatform.android.BaseHiltTest
import org.groundplatform.android.R
import org.junit.After
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.MockedStatic
import org.mockito.Mockito.mockStatic
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.robolectric.RobolectricTestRunner

@HiltAndroidTest
Expand All @@ -39,6 +46,17 @@ class IconFactoryTest : BaseHiltTest() {
private val unscaledWidth by lazy { testMarker!!.intrinsicWidth }
private val unscaledHeight by lazy { testMarker!!.intrinsicHeight }

private val bitmapDescriptorFactory: MockedStatic<BitmapDescriptorFactory> =
mockStatic(BitmapDescriptorFactory::class.java).apply {
`when`<BitmapDescriptor> { BitmapDescriptorFactory.fromBitmap(any()) }
.thenAnswer { mock<BitmapDescriptor>() }
}

@After
fun closeStaticMock() {
bitmapDescriptorFactory.close()
}

@Test
fun `getMarkerBitmap() stretches marker`() {
val bitmap = iconFactory.getMarkerBitmap(Color.BLUE, 2.0f)
Expand All @@ -53,6 +71,41 @@ class IconFactoryTest : BaseHiltTest() {
assertBitmapScale(bitmap, 0.5f)
}

@Test
fun `getMarkerIcon returns the same instance for the same color and scale`() {
val first = iconFactory.getMarkerIcon(Color.BLUE, 2.0f)

assertThat(iconFactory.getMarkerIcon(Color.BLUE, 2.0f)).isSameInstanceAs(first)
}

@Test
fun `getMarkerIcon builds a distinct icon per color and per scale`() {
val blue = iconFactory.getMarkerIcon(Color.BLUE, 2.0f)
val red = iconFactory.getMarkerIcon(Color.RED, 2.0f)
val blueSelected = iconFactory.getMarkerIcon(Color.BLUE, 3.0f)

assertThat(red).isNotSameInstanceAs(blue)
assertThat(blueSelected).isNotSameInstanceAs(blue)
}

@Test
fun `getClusterIcon returns the same instance for the same label`() {
iconFactory.setClusterIconCacheSize(2)
val first = iconFactory.getClusterIcon("3/10")

assertThat(iconFactory.getClusterIcon("4/10")).isNotSameInstanceAs(first)
assertThat(iconFactory.getClusterIcon("3/10")).isSameInstanceAs(first)
}

@Test
fun `getClusterIcon evicts the least recently used label once the cache is full`() {
iconFactory.setClusterIconCacheSize(1)
val first = iconFactory.getClusterIcon("3/10")
iconFactory.getClusterIcon("4/10")

assertThat(iconFactory.getClusterIcon("3/10")).isNotSameInstanceAs(first)
}

private fun assertBitmapScale(bitmap: Bitmap, scale: Float) {
val expectedWidth = (unscaledWidth * scale).toInt()
val expectedHeight = (unscaledHeight * scale).toInt()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import com.google.maps.android.clustering.Cluster
import com.google.maps.android.clustering.ClusterManager
import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST_CLUSTER_ITEM
import org.groundplatform.android.common.Constants.CLUSTERING_ZOOM_THRESHOLD
import org.groundplatform.android.ui.IconFactory
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
Expand Down Expand Up @@ -51,7 +52,8 @@ class FeatureClusterRendererTest {
fun setUp() {
context = ApplicationProvider.getApplicationContext()
com.google.android.gms.maps.MapsInitializer.initialize(context)
featureClusterRenderer = FeatureClusterRenderer(context, map, clusterManager, 10f)
featureClusterRenderer =
FeatureClusterRenderer(context, map, clusterManager, 10f, IconFactory(context))
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ 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.IconFactory
import org.groundplatform.android.ui.map.Feature
import org.groundplatform.domain.model.geometry.Coordinates
import org.groundplatform.domain.model.geometry.LinearRing
Expand All @@ -38,6 +39,7 @@ import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

@RunWith(RobolectricTestRunner::class)
class FeatureManagerTest {
Expand Down Expand Up @@ -67,6 +69,7 @@ class FeatureManagerTest {
pointRenderer,
polygonRenderer,
lineStringRenderer,
IconFactory(ApplicationProvider.getApplicationContext()),
)
featureManager.onMapReady(map)
}
Expand Down Expand Up @@ -144,6 +147,26 @@ class FeatureManagerTest {
verify(mapsPolygon).remove()
}

@Test
@Config(qualifiers = "w360dp-h800dp-xxhdpi")
fun `counts the clusters that fit on a phone screen`() {
// 360dp / 100dp = 3.6 columns, 800dp / 100dp = 8 rows, 3.6 * 8 = 28.8 cells.
assertThat(featureManager.maxVisibleClusters(clusterAlgorithm)).isEqualTo(28)
}

@Test
@Config(qualifiers = "w360dp-h800dp-mdpi")
fun `counts the clusters that fit on screen independently of density`() {
assertThat(featureManager.maxVisibleClusters(clusterAlgorithm)).isEqualTo(28)
}

@Test
@Config(qualifiers = "w1280dp-h800dp-xhdpi")
fun `counts the clusters that fit on a tablet screen`() {
// 1280dp / 100dp = 12.8 columns, 800dp / 100dp = 8 rows, 12.8 * 8 = 102.4 cells.
assertThat(featureManager.maxVisibleClusters(clusterAlgorithm)).isEqualTo(102)
}

private fun clusterableFeature(id: String) =
Feature(
tag = Feature.Tag(id, Feature.Type.LOCATION_OF_INTEREST),
Expand Down
Loading