diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapMarkerClustering.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapMarkerClustering.kt new file mode 100644 index 0000000..d108fcd --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapMarkerClustering.kt @@ -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, + 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, + projection: Projection?, + clusterDistancePx: Float, + clusterEnabled: Boolean + ): List { + 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(items.size) + val cellMap = mutableMapOf, MutableList>() + + 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>() + 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): Pair { + 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]++ + } + } + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt index 679d6ea..9ed9264 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt @@ -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, 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, + 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): 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(R.id.cluster_background) + val text = iconView.findViewById(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() { diff --git a/app/src/main/res/layout/view_map_marker_cluster.xml b/app/src/main/res/layout/view_map_marker_cluster.xml new file mode 100644 index 0000000..adb7576 --- /dev/null +++ b/app/src/main/res/layout/view_map_marker_cluster.xml @@ -0,0 +1,34 @@ + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fc749d5..e524e3e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -291,6 +291,8 @@ Open device %1$s is not wired yet Online Offline + %1$d devices + %1$d+ My location logic will be added later Previous %1$s Next %1$s