From 1226d1bf68dfd23dfd445b362780b62bb22776e4 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Sat, 5 Sep 2026 23:11:51 +0100 Subject: [PATCH] fix(maps): give the pins a fingertip-sized tap target A tap lands on what is drawn under it and nothing more, and a 3dp dot at world zoom is not a target. Two invisible circle layers now draw the same pins at 22dp beneath the visible ones with the same handlers: a tap on a dot is answered by the layer on top, a near miss by these. MapLibre Compose 0.14 exposes no rectangle query from the camera, or the miss would be measured instead. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JLPjR85ryVXwT5xc2Myj1G --- .../grain/feature/explore/LocationsScreen.kt | 73 ++++++++++++++----- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/social/grain/feature/explore/LocationsScreen.kt b/app/src/main/java/social/grain/feature/explore/LocationsScreen.kt index 1c006e6..c1a1e94 100644 --- a/app/src/main/java/social/grain/feature/explore/LocationsScreen.kt +++ b/app/src/main/java/social/grain/feature/explore/LocationsScreen.kt @@ -40,6 +40,7 @@ import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.GeoJsonOptions import org.maplibre.compose.sources.rememberGeoJsonSource import org.maplibre.compose.util.ClickResult +import org.maplibre.compose.util.FeaturesClickHandler import org.maplibre.spatialk.geojson.Point import social.grain.ui.components.BrowseTile import social.grain.ui.components.FullScreenMap @@ -198,11 +199,58 @@ private fun LocationsMap( ) val count = feature["point_count"].asNumber() val galleries = feature[PLACE_COUNT].asNumber() + val isCluster = feature.has("point_count") + + val openCluster: FeaturesClickHandler = { features -> + val cluster = features.firstOrNull() + val point = cluster?.geometry as? Point + if (cluster == null || point == null) { + ClickResult.Pass + } else { + val zoom = source.getClusterExpansionZoom(cluster) + scope.launch { + cameraState.animateTo(CameraPosition(target = point.coordinates, zoom = zoom)) + } + ClickResult.Consume + } + } + val openPlace: FeaturesClickHandler = { features -> + val props = features.firstOrNull()?.properties + val cell = props?.get(PLACE_CELL)?.jsonPrimitive?.content + val name = props?.get(PLACE_NAME)?.jsonPrimitive?.content + if (cell == null || name == null) { + ClickResult.Pass + } else { + onOpen(cell, name) + ClickResult.Consume + } + } + + // A tap lands on whatever is drawn under it and nothing more, and a + // 3dp dot is not a target. These two layers are the same pins drawn + // invisibly at finger size, beneath the visible ones: a tap that hits + // a dot is answered by the layer on top, and a near miss by these. + CircleLayer( + id = "place-hit-targets", + source = source, + filter = !isCluster, + opacity = const(0f), + radius = const(HIT_RADIUS), + onClick = openPlace, + ) + CircleLayer( + id = "cluster-hit-targets", + source = source, + filter = isCluster, + opacity = const(0f), + radius = const(HIT_RADIUS), + onClick = openCluster, + ) CircleLayer( id = "place-clusters", source = source, - filter = feature.has("point_count"), + filter = isCluster, color = const(accent), opacity = const(0.85f), radius = interpolate( @@ -214,21 +262,13 @@ private fun LocationsMap( strokeWidth = const(1.5.dp), strokeColor = const(Color.White), strokeOpacity = const(0.85f), - onClick = { features -> - val cluster = features.firstOrNull() ?: return@CircleLayer ClickResult.Pass - val point = cluster.geometry as? Point ?: return@CircleLayer ClickResult.Pass - val zoom = source.getClusterExpansionZoom(cluster) - scope.launch { - cameraState.animateTo(CameraPosition(target = point.coordinates, zoom = zoom)) - } - ClickResult.Consume - }, + onClick = openCluster, ) CircleLayer( id = "place-points", source = source, - filter = !feature.has("point_count"), + filter = !isCluster, color = const(accent), radius = interpolate( type = linear(), @@ -239,17 +279,14 @@ private fun LocationsMap( strokeWidth = const(1.5.dp), strokeColor = const(Color.White), strokeOpacity = const(0.85f), - onClick = { features -> - val props = features.firstOrNull()?.properties ?: return@CircleLayer ClickResult.Pass - val cell = props[PLACE_CELL]?.jsonPrimitive?.content ?: return@CircleLayer ClickResult.Pass - val name = props[PLACE_NAME]?.jsonPrimitive?.content ?: return@CircleLayer ClickResult.Pass - onOpen(cell, name) - ClickResult.Consume - }, + onClick = openPlace, ) } } +/** Half a fingertip: what a pin has to answer a tap from, whatever its size. */ +private val HIT_RADIUS = 22.dp + /** Room between the outermost pins and the edge. */ private val FIT_PADDING = 40.dp -- 2.51.2