Add OSMDroid map marker clustering with gentler merge thresholds.

Group nearby markers into count bubbles; cluster tap zooms to member bounds.
Rebuild overlays on a debounced map listener so clusters track pan and zoom.
Add cluster bubble layout, strings, and pixel-distance clustering with a 48dp
base threshold scaled down when zoomed in so clustering stays less aggressive.

Made-with: Cursor
This commit is contained in:
2026-03-24 00:01:16 +01:00
parent c05dd31e95
commit 24c5731c69
4 changed files with 341 additions and 13 deletions

View File

@@ -0,0 +1,123 @@
package org.db3.airmq.features.map
import android.graphics.Point
import org.db3.airmq.features.map.MapScreenContract.SensorType
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.Projection
import kotlin.math.hypot
sealed class MapMarkerDisplay {
data class Single(val marker: MapMarker) : MapMarkerDisplay()
data class Cluster(
val members: List<MapMarker>,
val latitude: Double,
val longitude: Double
) : MapMarkerDisplay()
}
object MapMarkerClustering {
/**
* @param clusterDistancePx screen distance below which markers merge (similar scale to maps-utils defaults).
*/
fun buildDisplayItems(
items: List<MapMarker>,
projection: Projection?,
clusterDistancePx: Float,
clusterEnabled: Boolean
): List<MapMarkerDisplay> {
if (items.isEmpty()) return emptyList()
if (!clusterEnabled || items.size == 1 || projection == null) {
return items.map { MapMarkerDisplay.Single(it) }
}
val threshold = clusterDistancePx.coerceAtLeast(1f)
val cellSize = threshold.toInt().coerceAtLeast(1)
val pixels = ArrayList<Point>(items.size)
val cellMap = mutableMapOf<Pair<Int, Int>, MutableList<Int>>()
for (i in items.indices) {
val p = Point()
projection.toPixels(GeoPoint(items[i].latitude, items[i].longitude), p)
pixels.add(p)
val cx = p.x.floorDiv(cellSize)
val cy = p.y.floorDiv(cellSize)
cellMap.getOrPut(cx to cy) { mutableListOf() }.add(i)
}
val uf = UnionFind(items.size)
for (i in items.indices) {
val pi = pixels[i]
val cx = pi.x.floorDiv(cellSize)
val cy = pi.y.floorDiv(cellSize)
for (dx in -1..1) {
for (dy in -1..1) {
val neighbors = cellMap[cx + dx to cy + dy] ?: continue
for (j in neighbors) {
if (j <= i) continue
val pj = pixels[j]
val dist = hypot(
(pi.x - pj.x).toDouble(),
(pi.y - pj.y).toDouble()
)
if (dist <= threshold) {
uf.union(i, j)
}
}
}
}
}
val groups = mutableMapOf<Int, MutableList<Int>>()
for (i in items.indices) {
val root = uf.find(i)
groups.getOrPut(root) { mutableListOf() }.add(i)
}
return groups.values.map { indices ->
val members = indices.map { items[it] }
if (members.size == 1) {
MapMarkerDisplay.Single(members.first())
} else {
val lat = members.map { it.latitude }.average()
val lon = members.map { it.longitude }.average()
MapMarkerDisplay.Cluster(members, lat, lon)
}
}
}
/** Mode of defined values and last member's sensor type (matches old MarkerClusterRenderer loop). */
fun clusterStyleInputs(members: List<MapMarker>): Pair<Double?, SensorType> {
val lastSensor = members.last().sensorType
val values = members.mapNotNull { it.value }
if (values.isEmpty()) return null to lastSensor
val mode = values.groupingBy { it }.eachCount().maxBy { it.value }.key
return mode to lastSensor
}
private class UnionFind(n: Int) {
private val parent = IntArray(n) { it }
private val rank = IntArray(n)
fun find(i: Int): Int {
var x = i
while (parent[x] != x) {
parent[x] = parent[parent[x]]
x = parent[x]
}
return x
}
fun union(a: Int, b: Int) {
var ra = find(a)
var rb = find(b)
if (ra == rb) return
if (rank[ra] < rank[rb]) {
parent[ra] = rb
} else {
parent[rb] = ra
if (rank[ra] == rank[rb]) rank[ra]++
}
}
}
}

View File

@@ -3,10 +3,13 @@ package org.db3.airmq.features.map
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.widget.Toast
import android.widget.ImageView
import android.widget.TextView
import java.util.concurrent.atomic.AtomicBoolean
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -19,6 +22,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -44,7 +48,11 @@ import org.db3.airmq.features.map.MapScreenContract.State
import org.db3.airmq.features.map.MapScreenContract.TimeRange
import org.db3.airmq.ui.theme.AirMQTheme
import org.osmdroid.config.Configuration
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
@@ -107,6 +115,7 @@ private fun MapScreenContent(
onMarkerClick = { onEvent(Event.MarkerClicked(it)) },
centerOnMarker = centerOnMarker,
sheetHeightFraction = sheetHeightFraction,
clusterEnabled = true,
modifier = Modifier.fillMaxSize()
)
} else {
@@ -183,17 +192,30 @@ private fun MapScreenContent(
}
}
private const val MapClusterDebounceMs = 150L
/** Max on-screen gap (in dp) for two markers to merge; lower = less aggressive clustering. */
private const val MapClusterDistanceDp = 48f
private const val MapClusterZoomPaddingPx = 64
@Composable
private fun AirMQMap(
items: List<MapMarker>,
onMarkerClick: (String) -> Unit,
centerOnMarker: MapMarker? = null,
sheetHeightFraction: Float = 0f,
clusterEnabled: Boolean = true,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val latestItems = rememberUpdatedState(items)
val latestOnMarkerClick = rememberUpdatedState(onMarkerClick)
val latestClusterEnabled = rememberUpdatedState(clusterEnabled)
val latestCenterOnMarker = rememberUpdatedState(centerOnMarker)
val initialCameraDone = remember { AtomicBoolean(false) }
val mapView = remember {
Configuration.getInstance().userAgentValue = context.packageName
MapView(context).apply {
@@ -205,6 +227,20 @@ private fun AirMQMap(
}
}
val handler = remember { Handler(Looper.getMainLooper()) }
val debouncedRebuild = remember(mapView) {
Runnable {
rebuildAirMqMapOverlays(
map = mapView,
items = latestItems.value,
onMarkerClick = latestOnMarkerClick.value,
clusterEnabled = latestClusterEnabled.value,
centerOnMarker = latestCenterOnMarker.value,
initialCameraDone = initialCameraDone
)
}
}
DisposableEffect(lifecycleOwner, mapView) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
@@ -220,6 +256,25 @@ private fun AirMQMap(
}
}
DisposableEffect(mapView, debouncedRebuild) {
val listener = object : MapListener {
override fun onScroll(event: ScrollEvent?): Boolean {
scheduleMapClusterRebuildDebounced(handler, debouncedRebuild)
return false
}
override fun onZoom(event: ZoomEvent?): Boolean {
scheduleMapClusterRebuildDebounced(handler, debouncedRebuild)
return false
}
}
mapView.addMapListener(listener)
onDispose {
mapView.removeMapListener(listener)
handler.removeCallbacks(debouncedRebuild)
}
}
val mapAnimationSpeedMs = 200L
LaunchedEffect(centerOnMarker, sheetHeightFraction) {
centerOnMarker?.let { marker ->
@@ -247,15 +302,87 @@ private fun AirMQMap(
modifier = modifier.fillMaxSize(),
factory = { mapView },
update = { map ->
map.overlays.removeAll { it is Marker }
items.forEach { item ->
map.post {
rebuildAirMqMapOverlays(
map = map,
items = latestItems.value,
onMarkerClick = latestOnMarkerClick.value,
clusterEnabled = latestClusterEnabled.value,
centerOnMarker = latestCenterOnMarker.value,
initialCameraDone = initialCameraDone
)
}
}
)
}
private fun scheduleMapClusterRebuildDebounced(
handler: Handler,
runnable: Runnable,
debounceMs: Long = MapClusterDebounceMs
) {
handler.removeCallbacks(runnable)
handler.postDelayed(runnable, debounceMs)
}
private fun rebuildAirMqMapOverlays(
map: MapView,
items: List<MapMarker>,
onMarkerClick: (String) -> Unit,
clusterEnabled: Boolean,
centerOnMarker: MapMarker?,
initialCameraDone: AtomicBoolean
) {
if (items.isEmpty()) {
map.overlays.removeAll { it is Marker }
initialCameraDone.set(false)
map.invalidate()
return
}
val density = map.context.resources.displayMetrics.density
val baseClusterPx = MapClusterDistanceDp * density
val zoom = map.zoomLevelDouble
// Zoomed in: require markers to overlap more on screen before merging (less aggressive).
val zoomScale = ((18.5 - zoom) / 11.0).coerceIn(0.38, 1.0).toFloat()
val clusterDistancePx = (baseClusterPx * zoomScale).coerceAtLeast(18f * density)
if (map.width <= 0 || map.height <= 0) {
map.post {
if (map.width > 0 && map.height > 0) {
rebuildAirMqMapOverlays(
map,
items,
onMarkerClick,
clusterEnabled,
centerOnMarker,
initialCameraDone
)
}
}
return
}
val ctx = map.context
map.overlays.removeAll { it is Marker }
val displayItems = MapMarkerClustering.buildDisplayItems(
items = items,
projection = map.projection,
clusterDistancePx = clusterDistancePx,
clusterEnabled = clusterEnabled
)
for (entry in displayItems) {
when (entry) {
is MapMarkerDisplay.Single -> {
val item = entry.marker
val marker = Marker(map).apply {
position = GeoPoint(item.latitude, item.longitude)
title = listOfNotNull(item.title, item.city).joinToString(" - ")
subDescription = if (item.isOnline) {
context.getString(R.string.map_status_online)
ctx.getString(R.string.map_status_online)
} else {
context.getString(R.string.map_status_offline)
ctx.getString(R.string.map_status_offline)
}
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = createMarkerIcon(map, item)
@@ -267,18 +394,36 @@ private fun AirMQMap(
map.overlays.add(marker)
}
if (centerOnMarker == null) {
items.firstOrNull()?.let { first ->
map.controller.animateTo(
GeoPoint(first.latitude, first.longitude),
map.zoomLevelDouble,
200L
)
is MapMarkerDisplay.Cluster -> {
val cluster = entry
val marker = Marker(map).apply {
position = GeoPoint(cluster.latitude, cluster.longitude)
title = ctx.getString(R.string.map_cluster_marker_title, cluster.members.size)
subDescription = ""
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = createClusterMarkerIcon(map, cluster.members.size, cluster.members)
setOnMarkerClickListener { _, _ ->
val geoPoints = cluster.members.map { GeoPoint(it.latitude, it.longitude) }
val box = BoundingBox.fromGeoPoints(geoPoints)
map.zoomToBoundingBox(box, true, MapClusterZoomPaddingPx)
true
}
}
map.overlays.add(marker)
}
map.invalidate()
}
)
}
if (!initialCameraDone.get() && centerOnMarker == null && items.isNotEmpty()) {
initialCameraDone.set(true)
map.controller.animateTo(
GeoPoint(items.first().latitude, items.first().longitude),
map.zoomLevelDouble,
200L
)
}
map.invalidate()
}
private fun createMarkerIcon(mapView: MapView, item: MapMarker): BitmapDrawable {
@@ -330,6 +475,30 @@ private fun createMarkerIcon(mapView: MapView, item: MapMarker): BitmapDrawable
return bitmap.toDrawable(context.resources)
}
private fun createClusterMarkerIcon(mapView: MapView, count: Int, members: List<MapMarker>): BitmapDrawable {
val context = mapView.context
val (modeValue, sensorForStyle) = MapMarkerClustering.clusterStyleInputs(members)
val iconView = LayoutInflater.from(context).inflate(R.layout.view_map_marker_cluster, null, false)
val background = iconView.findViewById<android.view.View>(R.id.cluster_background)
val text = iconView.findViewById<TextView>(R.id.cluster_text)
text.text = context.getString(R.string.map_cluster_count_label, count)
val colorRes = if (modeValue == null) {
R.color.colorGrey
} else {
MapMarkerStyle.valueColorRes(modeValue, sensorForStyle)
}
background.backgroundTintList =
android.content.res.ColorStateList.valueOf(ContextCompat.getColor(context, colorRes))
iconView.measure(
android.view.View.MeasureSpec.makeMeasureSpec(0, android.view.View.MeasureSpec.UNSPECIFIED),
android.view.View.MeasureSpec.makeMeasureSpec(0, android.view.View.MeasureSpec.UNSPECIFIED)
)
iconView.layout(0, 0, iconView.measuredWidth, iconView.measuredHeight)
val bitmap = createBitmap(iconView.measuredWidth, iconView.measuredHeight)
iconView.draw(Canvas(bitmap))
return bitmap.toDrawable(context.resources)
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
private fun PreviewMapScreenDefault() {

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:padding="4dp"
tools:background="@color/sensorGreen">
<View
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:background="@drawable/circle_marker" />
<View
android:id="@+id/cluster_background"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_gravity="center"
android:background="@drawable/circle_marker"
tools:backgroundTint="@color/sensorOrange" />
<TextView
android:id="@+id/cluster_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textColor="@color/white"
android:textSize="16sp"
android:textStyle="bold"
tools:text="23+" />
</FrameLayout>

View File

@@ -291,6 +291,8 @@
<string name="map_open_device_not_wired">Open device %1$s is not wired yet</string>
<string name="map_status_online">Online</string>
<string name="map_status_offline">Offline</string>
<string name="map_cluster_marker_title">%1$d devices</string>
<string name="map_cluster_count_label">%1$d+</string>
<string name="map_my_location_coming_soon">My location logic will be added later</string>
<string name="map_previous_period">Previous %1$s</string>
<string name="map_next_period">Next %1$s</string>