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:
@@ -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]++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
34
app/src/main/res/layout/view_map_marker_cluster.xml
Normal file
34
app/src/main/res/layout/view_map_marker_cluster.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user