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.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.BitmapDrawable
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater import android.view.LayoutInflater
import android.widget.Toast import android.widget.Toast
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import java.util.concurrent.atomic.AtomicBoolean
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -19,6 +22,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.features.map.MapScreenContract.TimeRange
import org.db3.airmq.ui.theme.AirMQTheme import org.db3.airmq.ui.theme.AirMQTheme
import org.osmdroid.config.Configuration 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.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView import org.osmdroid.views.MapView
@@ -107,6 +115,7 @@ private fun MapScreenContent(
onMarkerClick = { onEvent(Event.MarkerClicked(it)) }, onMarkerClick = { onEvent(Event.MarkerClicked(it)) },
centerOnMarker = centerOnMarker, centerOnMarker = centerOnMarker,
sheetHeightFraction = sheetHeightFraction, sheetHeightFraction = sheetHeightFraction,
clusterEnabled = true,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} else { } 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 @Composable
private fun AirMQMap( private fun AirMQMap(
items: List<MapMarker>, items: List<MapMarker>,
onMarkerClick: (String) -> Unit, onMarkerClick: (String) -> Unit,
centerOnMarker: MapMarker? = null, centerOnMarker: MapMarker? = null,
sheetHeightFraction: Float = 0f, sheetHeightFraction: Float = 0f,
clusterEnabled: Boolean = true,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.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 { val mapView = remember {
Configuration.getInstance().userAgentValue = context.packageName Configuration.getInstance().userAgentValue = context.packageName
MapView(context).apply { 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) { DisposableEffect(lifecycleOwner, mapView) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
when (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 val mapAnimationSpeedMs = 200L
LaunchedEffect(centerOnMarker, sheetHeightFraction) { LaunchedEffect(centerOnMarker, sheetHeightFraction) {
centerOnMarker?.let { marker -> centerOnMarker?.let { marker ->
@@ -247,15 +302,87 @@ private fun AirMQMap(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
factory = { mapView }, factory = { mapView },
update = { map -> update = { map ->
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 } map.overlays.removeAll { it is Marker }
items.forEach { item -> 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 { val marker = Marker(map).apply {
position = GeoPoint(item.latitude, item.longitude) position = GeoPoint(item.latitude, item.longitude)
title = listOfNotNull(item.title, item.city).joinToString(" - ") title = listOfNotNull(item.title, item.city).joinToString(" - ")
subDescription = if (item.isOnline) { subDescription = if (item.isOnline) {
context.getString(R.string.map_status_online) ctx.getString(R.string.map_status_online)
} else { } else {
context.getString(R.string.map_status_offline) ctx.getString(R.string.map_status_offline)
} }
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = createMarkerIcon(map, item) icon = createMarkerIcon(map, item)
@@ -267,19 +394,37 @@ private fun AirMQMap(
map.overlays.add(marker) map.overlays.add(marker)
} }
if (centerOnMarker == null) { is MapMarkerDisplay.Cluster -> {
items.firstOrNull()?.let { first -> 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)
}
}
}
if (!initialCameraDone.get() && centerOnMarker == null && items.isNotEmpty()) {
initialCameraDone.set(true)
map.controller.animateTo( map.controller.animateTo(
GeoPoint(first.latitude, first.longitude), GeoPoint(items.first().latitude, items.first().longitude),
map.zoomLevelDouble, map.zoomLevelDouble,
200L 200L
) )
} }
}
map.invalidate() map.invalidate()
} }
)
}
private fun createMarkerIcon(mapView: MapView, item: MapMarker): BitmapDrawable { private fun createMarkerIcon(mapView: MapView, item: MapMarker): BitmapDrawable {
val context = mapView.context val context = mapView.context
@@ -330,6 +475,30 @@ private fun createMarkerIcon(mapView: MapView, item: MapMarker): BitmapDrawable
return bitmap.toDrawable(context.resources) 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) @Preview(showBackground = true, showSystemUi = true)
@Composable @Composable
private fun PreviewMapScreenDefault() { 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_open_device_not_wired">Open device %1$s is not wired yet</string>
<string name="map_status_online">Online</string> <string name="map_status_online">Online</string>
<string name="map_status_offline">Offline</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_my_location_coming_soon">My location logic will be added later</string>
<string name="map_previous_period">Previous %1$s</string> <string name="map_previous_period">Previous %1$s</string>
<string name="map_next_period">Next %1$s</string> <string name="map_next_period">Next %1$s</string>