From 35d23110d70f04952768571adaee7cd22a69375d Mon Sep 17 00:00:00 2001 From: beetzung Date: Tue, 24 Mar 2026 00:21:00 +0100 Subject: [PATCH] feat(map): add OSMDroid heatmap with legacy dust tiers Port legacy Google Maps heatmap behavior: PM2.5-style buckets, tier colors, halos under markers. Draw halos in Overlay.draw with live projection so heat tracks during pan (no debounced bitmap). Radioactivity uses single green tier to match marker styling. Made-with: Cursor --- .../airmq/features/map/MapHeatmapOverlay.kt | 114 ++++++++++++++++++ .../db3/airmq/features/map/MapMarkerStyle.kt | 9 ++ .../org/db3/airmq/features/map/MapScreen.kt | 4 + 3 files changed, 127 insertions(+) create mode 100644 app/src/main/kotlin/org/db3/airmq/features/map/MapHeatmapOverlay.kt diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapHeatmapOverlay.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapHeatmapOverlay.kt new file mode 100644 index 0000000..45cdce9 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapHeatmapOverlay.kt @@ -0,0 +1,114 @@ +package org.db3.airmq.features.map + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Point +import android.graphics.RadialGradient +import android.graphics.Shader +import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils +import org.db3.airmq.R +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Overlay + +/** + * OSMDroid overlay that draws heat halos under markers, projected on every frame so pan/zoom + * stays aligned with the map (unlike a cached screen bitmap that only updates after debounce). + * Mirrors legacy Google Maps heatmap: PM2.5-style dust tiers, ~40dp radius, ~0.4 opacity. + */ +private const val HeatmapRadiusDp = 40f +private const val HeatmapOpacity = 0.4f + +private val heatmapTierColorOrder = intArrayOf( + R.color.sensorGreen, + R.color.sensorYellow, + R.color.sensorOrange, + R.color.sensorRed, + R.color.sensorPink, + R.color.sensorPurple +) + +private class HeatmapTierBatch( + val shader: RadialGradient, + val points: List +) + +/** + * One [RadialGradient] per tier (centered at origin); [Matrix] translates it each draw for low GC during pan. + */ +class MapHeatmapOverlay private constructor( + private val radiusPx: Float, + private val batches: List +) : Overlay() { + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val shaderMatrix = Matrix() + private val scratch = Point() + + override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) { + if (shadow) return + val projection = mapView.projection + for (batch in batches) { + paint.shader = batch.shader + for (geo in batch.points) { + projection.toPixels(geo, scratch) + val cx = scratch.x.toFloat() + val cy = scratch.y.toFloat() + shaderMatrix.reset() + shaderMatrix.setTranslate(cx, cy) + batch.shader.setLocalMatrix(shaderMatrix) + canvas.drawCircle(cx, cy, radiusPx, paint) + } + } + paint.shader = null + } + + /** Kept for [removeHeatmapOverlaysRecycle]; no bitmap to recycle anymore. */ + fun recycleBitmap() {} + + companion object { + fun create(mapView: MapView, items: List): MapHeatmapOverlay? { + val byTier = LinkedHashMap>() + for (res in heatmapTierColorOrder) { + byTier[res] = ArrayList() + } + for (item in items) { + val colorRes = MapMarkerStyle.heatmapTierColorRes(item) ?: continue + byTier.getOrPut(colorRes) { ArrayList() } + .add(GeoPoint(item.latitude, item.longitude)) + } + + val density = mapView.context.resources.displayMetrics.density + val radiusPx = HeatmapRadiusDp * density + val centerAlpha = (255 * HeatmapOpacity).toInt().coerceIn(0, 255) + val ctx = mapView.context + + val batches = ArrayList() + for (colorRes in heatmapTierColorOrder) { + val points = byTier[colorRes] ?: continue + if (points.isEmpty()) continue + val rgb = ContextCompat.getColor(ctx, colorRes) + val centerColor = ColorUtils.setAlphaComponent(rgb, centerAlpha) + val shader = RadialGradient( + 0f, + 0f, + radiusPx, + centerColor, + Color.TRANSPARENT, + Shader.TileMode.CLAMP + ) + batches.add(HeatmapTierBatch(shader, points)) + } + if (batches.isEmpty()) return null + return MapHeatmapOverlay(radiusPx, batches) + } + } +} + +internal fun MapView.removeHeatmapOverlaysRecycle() { + overlays.filterIsInstance().forEach { it.recycleBitmap() } + overlays.removeAll { it is MapHeatmapOverlay } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapMarkerStyle.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapMarkerStyle.kt index 6faf33e..731fff1 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/map/MapMarkerStyle.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapMarkerStyle.kt @@ -16,6 +16,15 @@ object MapMarkerStyle { } } + /** + * Color tier for heatmap halos; null when there is no reading (excluded from heatmap). + */ + @ColorRes + fun heatmapTierColorRes(marker: MapMarker): Int? { + if (marker.value == null) return null + return valueColorRes(marker.value, marker.sensorType) + } + @ColorRes fun valueColorRes(value: Double?, sensorType: SensorType): Int { if (value == null) return R.color.colorGrey 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 9ed9264..caf5aed 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 @@ -334,6 +334,7 @@ private fun rebuildAirMqMapOverlays( initialCameraDone: AtomicBoolean ) { if (items.isEmpty()) { + map.removeHeatmapOverlaysRecycle() map.overlays.removeAll { it is Marker } initialCameraDone.set(false) map.invalidate() @@ -363,8 +364,11 @@ private fun rebuildAirMqMapOverlays( } val ctx = map.context + map.removeHeatmapOverlaysRecycle() map.overlays.removeAll { it is Marker } + MapHeatmapOverlay.create(map, items)?.let { map.overlays.add(it) } + val displayItems = MapMarkerClustering.buildDisplayItems( items = items, projection = map.projection,